From 7ca6fcd79a62a44b1942350e9e6bd402c0d0a583 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Tue, 12 Sep 2023 00:47:17 -0600 Subject: [PATCH 001/850] Updated README 1. Updated README like @JGreenlee mentioned to successfully build 2. Maintaining current version 3. Will update again after updating JDK in this intel machine --- README.md | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 0c02868db..06d860d0c 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,11 @@ Installing (one time only) --- Run the setup script for the platform you want to build +Make sure you switch to the "label_dashboard_profile_sept_2023" branch +``` +git checkout label_dashboard_profile_sept_2023 +``` + ``` $ bash setup/setup_android_native.sh AND/OR @@ -156,14 +161,6 @@ $ cp www/json/startupConfig.json.sample www/json/startupConfig.json $ cp ..... www/json/connectionConfig.json ``` -### Activation (after install, and in every new shell) - -``` -$ source setup/activate_native.sh -``` - -### Activation (after install, and in every new shell) - If connecting to a development server over http, make sure to turn on http support on android ``` @@ -174,10 +171,15 @@ If connecting to a development server over http, make sure to turn on http suppo ### Run in the emulator +Pick a version and execute the following: + ``` -$ npx cordova emulate ios -AND/OR -$ npx cordova emulate android +$ npm run +``` + +For instance: (build-dev-android) +``` +$ npm run build-dev-android ``` Creating logos From 6822c67466d48e5cc3059aa6003b7930e25e9095 Mon Sep 17 00:00:00 2001 From: niccolopaganini Date: Sat, 23 Sep 2023 13:13:44 -0600 Subject: [PATCH 002/850] multiple changes: 1. structure/ flow 2. added contents section 3. updated certain resources --- README.md | 301 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 185 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index 06d860d0c..a14f99a01 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,60 @@ -e-mission phone app --------------------- +# e-mission phone app -This is the phone component of the e-mission system. +__This is the phone component of the e-mission system.__ -:sparkles: This has now been upgraded to cordova android@9.0.0 and iOS@6.0.1 ([details](https://github.com/e-mission/e-mission-docs/issues/554)). It has also been upgraded to [android API 29](https://github.com/e-mission/e-mission-phone/pull/707/), [cordova-lib@10.0.0 and the most recent node and npm versions](https://github.com/e-mission/e-mission-phone/pull/708)It also now supports CI, so we should not have any build issues in the future. The limitations from the [previous upgrade](https://github.com/e-mission/e-mission-docs/issues/519) have all been resolved. This should be ready to build out of the box, after all the configuration files are changed. +:sparkles: This has now been upgraded to cordova android@12.0.0 and iOS@6.2.0. It has also been upgraded to [android API 33 and the latest iOS versions](https://github.com/e-mission/e-mission-docs/issues/934), [cordova-lib@10.0.0 and the most recent node and npm versions](). It also now supports CI, so we should not have any build issues in the future. __This should be ready to build out of the box.__ -Additional Documentation ---- +## Additional Documentation Additional documentation has been moved to its own repository [e-mission-docs](https://github.com/e-mission/e-mission-docs). Specific e-mission-phone wikis can be found here: https://github.com/e-mission/e-mission-docs/tree/master/docs/e-mission-phone **Issues:** Since this repository is part of a larger project, all issues are tracked [in the central docs repository](https://github.com/e-mission/e-mission-docs/issues). If you have a question, [as suggested by the open source guide](https://opensource.guide/how-to-contribute/#communicating-effectively), please file an issue instead of sending an email. Since issues are public, other contributors can try to answer the question and benefit from the answer. -Updating the UI only +:sparkles: Check 6. Contributing if you're interested in contributing for this project :sparkles: + +## Contents +#### 1. [Creating logos](#1.-Creating-logos) -> Information regarding app Logo +#### 2. [Updating the UI only](#2.-Updating-the-UI-only) -> For UI changes ONLY +#### 3. [Updating the e-mission-* plugins or adding new plugins](#3.-Updating-the-e-mission-\*-plugins-or-adding-new-plugins) -> Work with native code +#### 4. [End to End Testing](#4.-End-to-End-Testing) +#### 5. [Beta-testing debugging](#5.-Beta-testing-debugging) +#### 6. [Contributing](#6.-Contributing) --- + +## 1. Creating logos + +If you are building your own version of the app, you must have your own logo to +avoid app store conficts. Updating the logo is very simple using the [`ionic +cordova resources`](https://ionicframework.com/docs/v3/cli/cordova/resources/) +command. + +**Note**: You may have to install the [`cordova-res` package](https://github.com/ionic-team/cordova-res) for the command to work + +## 2. Updating the UI only [![osx-serve-install](https://github.com/e-mission/e-mission-phone/workflows/osx-serve-install/badge.svg)](https://github.com/e-mission/e-mission-phone/actions?query=workflow%3Aosx-serve-install) If you want to make only UI changes, (as opposed to modifying the existing plugins, adding new plugins, etc), you can use the **new and improved** (as of June 2018) [e-mission dev app](https://github.com/e-mission/e-mission-devapp/) and install the most recent version from [releases](https://github.com/e-mission/e-mission-devapp/releases). ### Installing (one-time) -Run the setup script +1. Run the setup script ``` -$ bash setup/setup_serve.sh +bash setup/setup_serve.sh ``` - **(optional)** Configure by changing the files in `www/json`. Defaults are in `www/json/*.sample` ``` -$ ls www/json/*.sample -$ cp www/json/startupConfig.json.sample www/json/startupConfig.json -$ cp ..... www/json/connectionConfig.json +ls www/json/*.sample +cp www/json/startupConfig.json.sample www/json/startupConfig.json +cp ..... www/json/connectionConfig.json ``` ### Activation (after install, and in every new shell) - +2. Run this to activate ``` -$ source setup/activate_serve.sh +source setup/activate_serve.sh ``` ### Running @@ -46,7 +62,7 @@ $ source setup/activate_serve.sh 1. Start the phonegap deployment server and note the URL(s) that the server is listening to. ``` - $ npm run serve + npm run serve .... [phonegap] listening on 10.0.0.14:3000 [phonegap] listening on 192.168.162.1:3000 @@ -56,10 +72,10 @@ $ source setup/activate_serve.sh .... ``` -1. Change the devapp connection URL to one of these (e.g. 192.168.162.1:3000) and press "Connect" -1. The app will now display the version of e-mission app that is in your local directory - 1. The console logs will be displayed back in the server window (prefaced by `[console]`) - 1. Breakpoints can be added by connecting through the browser +2. Change the devapp connection URL to one of these (e.g. 192.168.162.1:3000) and press "Connect" +3. The app will now display the version of e-mission app that is in your local directory + 4. The console logs will be displayed back in the server window (prefaced by `[console]`) + 5. Breakpoints can be added by connecting through the browser - Safari ([enable develop menu](https://support.apple.com/guide/safari/use-the-safari-develop-menu-sfri20948/mac)): Develop -> Simulator -> index.html - Chrome: chrome://inspect -> Remote target (emulator) @@ -67,37 +83,68 @@ $ source setup/activate_serve.sh **Note1**: You may need to scroll up, past all the warnings about `Content Security Policy has been added` to find the port that the server is listening to. -End to end testing ---- -A lot of the visualizations that we display in the phone client come from the server. In order to do end to end testing, we need to run a local server and connect to it. Instructions for: +## 3. Updating the e-mission-\* plugins or adding new plugins -1. installing a local server, -2. running it, -3. loading it with test data, and -4. running analysis on it +[![osx-build-ios](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml) +[![osx-build-android](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml) -are available in the [e-mission-server README](https://github.com/e-mission/e-mission-server/blob/master/README.md). +__Important__ -In order to make end to end testing easy, if the local server is started on a HTTP (versus HTTPS port), it is in development mode. By default, the phone app connects to the local server (localhost on iOS, [10.0.2.2 on android](https://stackoverflow.com/questions/5806220/how-to-connect-to-my-http-localhost-web-server-from-android-emulator-in-eclips)) with the `prompted-auth` authentication method. To connect to a different server, or to use a different authentication method, you need to create a `www/json/connectionConfig.json` file. More details on configuring authentication [can be found in the docs](https://github.com/e-mission/e-mission-docs/blob/master/docs/install/configuring_authentication.md). +Most of the recent issues encountered have been due to incompatible setup. We +have now: +- locked down the dependencies, +- created setup and teardown scripts to setup self-contained environments with + those dependencies, and +- CI enabled to validate that they continue work. -One advantage of using `skip` authentication in development mode is that any user email can be entered without a password. Developers can use one of the emails that they loaded test data for in step (3) above. So if the test data loaded was with `-u shankari@eecs.berkeley.edu`, then the login email for the phone app would also be `shankari@eecs.berkeley.edu`. +If you have setup failures, please compare the configuration in the **passing CI +builds** with your configuration. That is almost certainly the source of the error. -Updating the e-mission-\* plugins or adding new plugins ---- -[![osx-build-ios](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml) -[![osx-build-android](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml) +### Tested on +__MacOS__ +- Intel chip, MacOS Ventura 13.6 +- Intel chip, MacOS Ventura 13.5.2 +- Intel chip, MacOS Ventura 13.0 +- Intel chip, MacOS Monterey 12.6.7 Pre-requisites --- -- the version of xcode used by the CI +- The version of xcode used by the CI. - to install a particular version, use [xcode-select](https://www.unix.com/man-page/OSX/1/xcode-select/) - or this [supposedly easier to use repo](https://github.com/xcpretty/xcode-install) - **NOTE**: the basic xcode install on Catalina was messed up for me due to a prior installation of command line tools. [These workarounds helped](https://github.com/nodejs/node-gyp/blob/master/macOS_Catalina.md). - git -- Java 11. Tested with [OpenJDK 11 (Temurin) using AdoptOpenJDK](https://adoptopenjdk.net/releases.html). -- android SDK; install manually or use setup script below. Note that you only need to run this once **per computer**. +- Java 17. Tested with [OpenJDK 17 (Temurin) using AdoptOpenJDK](https://adoptium.net). +- if you are not on the most recent version of OSX: `homebrew` + - this allows us to install the current version of cocoapods without + running into ruby incompatibilities - e.g. + https://github.com/CocoaPods/CocoaPods/issues/11763 + +__1. Export statements__ +``` +export ANDROID_SDK_ROOT="/Users//Library/Android/sdk" +``` +``` +export ANDROID_HOME="/Users//Library/Android/sdk" +``` +aka the path where you want the SDK to be installed. + +To setup JAVA_HOME (after installing the latest JDK ), run this command: +``` +/usr/libexec/java_home +``` +Find the location of the Java installation (Default will look something like this:) +``` +/Library/Java/JavaVirtualMachines/... +``` +and then export the package as: +``` +export JAVA_HOME="" +``` + +- android SDK; install manually or use setup script below (**recommended**). Note that you only need to run this once **per computer**. ``` - $ bash setup/prereq_android_sdk_install.sh + bash setup/prereq_android_sdk_install.sh ```
Expected output @@ -120,45 +167,25 @@ Pre-requisites ```
-- if you are not on the most recent version of OSX, `homebrew` - - this allows us to install the current version of cocoapods without - running into ruby incompatibilities - e.g. - https://github.com/CocoaPods/CocoaPods/issues/11763 - -Important ---- -Most of the recent issues encountered have been due to incompatible setup. We -have now: -- locked down the dependencies, -- created setup and teardown scripts to setup self-contained environments with - those dependencies, and -- CI enabled to validate that they continue work. -If you have setup failures, please compare the configuration in the passing CI -builds with your configuration. That is almost certainly the source of the error. -Installing (one time only) ---- -Run the setup script for the platform you want to build +__2. Installing (one time only)__ -Make sure you switch to the "label_dashboard_profile_sept_2023" branch -``` -git checkout label_dashboard_profile_sept_2023 -``` +- Run the setup script for the platform you want to build ``` -$ bash setup/setup_android_native.sh +bash setup/setup_android_native.sh AND/OR -$ bash setup/setup_ios_native.sh +bash setup/setup_ios_native.sh ``` **(optional)** Configure by changing the files in `www/json`. Defaults are in `www/json/*.sample` ``` -$ ls www/json/*.sample -$ cp www/json/startupConfig.json.sample www/json/startupConfig.json -$ cp ..... www/json/connectionConfig.json +ls www/json/*.sample +cp www/json/startupConfig.json.sample www/json/startupConfig.json +cp ..... www/json/connectionConfig.json ``` If connecting to a development server over http, make sure to turn on http support on android @@ -168,44 +195,58 @@ If connecting to a development server over http, make sure to turn on http suppo ``` +__3. Run this in every new shell__ + +- __Activation__ +``` +source setup/activate_native.sh +``` +
Expected Output + +``` +Activating nvm +Using version 19.5.0 +Now using node v19.5.0 (npm v9.3.1) +npm version = 9.3.1 +Adding cocoapods to the path +Verifying /Users//Library/Android/sk or /Users//Library/Android/sdk is set +Activating sdkman, and by default, gradle +Ensuring that we use the most recent version of the command line tools +Configuring the repo for building native code +Copied config.cordovabuild.xml -> config.xml and package.cordovabuild.json -> package.json +``` -### Run in the emulator +
-Pick a version and execute the following: +- __Pick a type of build and execute the following:__ +More "versions" are available in [`package.cordovabuild.json`](https://github.com/e-mission/e-mission-phone/blob/fce117ff859abd995613bd405dbc7d27c703b09b/package.cordovabuild.json) ``` -$ npm run +npm run ``` For instance: (build-dev-android) ``` -$ npm run build-dev-android +npm run build-dev-android ``` -Creating logos ---- -If you are building your own version of the app, you must have your own logo to -avoid app store conficts. Updating the logo is very simple using the [`ionic -cordova resources`](https://ionicframework.com/docs/v3/cli/cordova/resources/) -command. -**Note**: You may have to install the [`cordova-res` package](https://github.com/ionic-team/cordova-res) for the command to work +## 4. End to End Testing +A lot of the visualizations that we display in the phone client come from the server. In order to do end to end testing, we need to run a local server and connect to it. Instructions for: -Troubleshooting ---- -- Make sure to use `npx ionic` and `npx cordova`. This is - because the setup script installs all the modules locally in a self-contained - environment using `npm install` and not `npm install -g` -- Check the CI to see whether there is a known issue -- Run the commands from the script one by one and see which fails - - compare the failed command with the CI logs -- Another workaround is to delete the local environment and recreate it - - javascript errors: `rm -rf node_modules && npm install` - - native code compile errors: `rm -rf plugins && rm -rf platforms && npx cordova prepare` +1. installing a local server, +2. running it, +3. loading it with test data, and +4. running analysis on it -Beta-testing debugging ---- +are available in the [e-mission-server README](https://github.com/e-mission/e-mission-server/blob/master/README.md). + +In order to make end to end testing easy, if the local server is started on a HTTP (versus HTTPS port), it is in development mode. By default, the phone app connects to the local server (localhost on iOS, [10.0.2.2 on android](https://stackoverflow.com/questions/5806220/how-to-connect-to-my-http-localhost-web-server-from-android-emulator-in-eclips)) with the `prompted-auth` authentication method. To connect to a different server, or to use a different authentication method, you need to create a `www/json/connectionConfig.json` file. More details on configuring authentication [can be found in the docs](https://github.com/e-mission/e-mission-docs/blob/master/docs/install/configuring_authentication.md). + +One advantage of using `skip` authentication in development mode is that any user email can be entered without a password. Developers can use one of the emails that they loaded test data for in step (3) above. So if the test data loaded was with `-u shankari@eecs.berkeley.edu`, then the login email for the phone app would also be `shankari@eecs.berkeley.edu`. + +## 5. Beta-testing debugging If users run into problems, they have the ability to email logs to the maintainer. These logs are in the form of an sqlite3 database, so they have to be opened using `sqlite3`. Alternatively, you can export it to a csv with @@ -213,40 +254,68 @@ dates using the `bin/csv_export_add_date.py` script. ``` -$ mv ~/Downloads/loggerDB /tmp/logger. -$ pwd +mv ~/Downloads/loggerDB /tmp/logger. +pwd .../e-mission-phone -$ python bin/csv_export_add_date.py /tmp/loggerDB. -$ less /tmp/loggerDB..withdate.log +python bin/csv_export_add_date.py /tmp/loggerDB. +less /tmp/loggerDB..withdate.log ``` -Contributing ---- - -Add the main repo as upstream - - $ git remote add upstream https://github.com/covid19database/phone-app.git - -Create a new branch (IMPORTANT). Please do not submit pull requests from master - - $ git checkout -b mybranch +## 6. Contributing -Make changes to the branch and commit them - $ git commit - -Push the changes to your local fork - - $ git push origin mybranch - -Generate a pull request from the UI +1. Add the main repo as upstream +``` +2. git remote add upstream +``` +3. Create a new branch (IMPORTANT). Please do not submit pull requests from master +``` +4. git checkout -b +``` +5. Make changes to the branch and commit them +``` +6. git commit +``` + 7. Push the changes to your local fork +``` +8. git push origin +``` +9. Generate a pull request from the UI -Address my review comments +__\*__Address my review comments__\*__ Once I merge the pull request, pull the changes to your fork and delete the branch ``` -$ git checkout master -$ git pull upstream master -$ git push origin master -$ git branch -d mybranch +git checkout master +``` +``` +git pull upstream master +``` +``` +git push origin master +``` +``` +git branch -d ``` + +--- +### Troubleshooting +1. Xcode command line tools +``` +Warning: No developer tools installed. +You should install the Command Line Tools. +``` +``` +xcode-select --install +``` + +2. Creating Logos +- Make sure to use `npx ionic` and `npx cordova`. This is + because the setup script installs all the modules locally in a self-contained + environment using `npm install` and not `npm install -g` +- Check the CI to see whether there is a known issue +- Run the commands from the script one by one and see which fails + - compare the failed command with the CI logs +- Another workaround is to delete the local environment and recreate it + - javascript errors: `rm -rf node_modules && npm install` + - native code compile errors: `rm -rf plugins && rm -rf platforms && npx cordova prepare` From 8ea506d1ce5ad113cb0cc86ec5f5cb425d815d7d Mon Sep 17 00:00:00 2001 From: niccolopaganini Date: Sat, 23 Sep 2023 13:18:38 -0600 Subject: [PATCH 003/850] build successful screenshot --- Build_ss.png | Bin 0 -> 83719 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 Build_ss.png diff --git a/Build_ss.png b/Build_ss.png new file mode 100644 index 0000000000000000000000000000000000000000..18ab48b232d04ae28e72f5677d393045be584659 GIT binary patch literal 83719 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 literal 0 HcmV?d00001 From ed0e871645855418908da33ea3b148183caf2c93 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Sat, 23 Sep 2023 13:23:49 -0600 Subject: [PATCH 004/850] Update README.md - Added hyperlink to the title - Added output screenshot --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a14f99a01..5a25ba0e6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# e-mission phone app +# [e-mission phone app](https://github.com/e-mission/e-mission-phone/tree/master) __This is the phone component of the e-mission system.__ @@ -229,7 +229,8 @@ For instance: (build-dev-android) ``` npm run build-dev-android ``` - +Your output should look something like this: +![Build Successful Message screenshot](Build_ss.png) ## 4. End to End Testing From 54656b8d0992e9946b9cd20d86e843814d3b1f18 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Sat, 23 Sep 2023 13:29:52 -0600 Subject: [PATCH 005/850] Update README.md - Updating markdown anchors --- README.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5a25ba0e6..ed7c03d22 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,16 @@ https://github.com/e-mission/e-mission-docs/tree/master/docs/e-mission-phone **Issues:** Since this repository is part of a larger project, all issues are tracked [in the central docs repository](https://github.com/e-mission/e-mission-docs/issues). If you have a question, [as suggested by the open source guide](https://opensource.guide/how-to-contribute/#communicating-effectively), please file an issue instead of sending an email. Since issues are public, other contributors can try to answer the question and benefit from the answer. -:sparkles: Check 6. Contributing if you're interested in contributing for this project :sparkles: +:sparkles: Check 6. [Contributing](#6-contributing) if you're interested in contributing for this project :sparkles: ## Contents -#### 1. [Creating logos](#1.-Creating-logos) -> Information regarding app Logo -#### 2. [Updating the UI only](#2.-Updating-the-UI-only) -> For UI changes ONLY -#### 3. [Updating the e-mission-* plugins or adding new plugins](#3.-Updating-the-e-mission-\*-plugins-or-adding-new-plugins) -> Work with native code -#### 4. [End to End Testing](#4.-End-to-End-Testing) -#### 5. [Beta-testing debugging](#5.-Beta-testing-debugging) -#### 6. [Contributing](#6.-Contributing) +#### 1. [Creating logos](#1-creating-logos) -> Information regarding app Logo +#### 2. [Updating the UI only](#2-updating-the-ui-only) -> For UI changes ONLY +#### 3. [Updating the e-mission-* plugins or adding new plugins](#3-updating-the-e-mission--plugins-or-adding-new-plugins) -> Work with native code +#### 4. [End to End Testing](#4-end-to-end-testing) +#### 5. [Beta-testing debugging](#5-beta-testing-debugging) +#### 6. [Contributing](#6-contributing) + --- ## 1. Creating logos From 290dcc5038c671abf733fb3288600435c3129973 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Sat, 23 Sep 2023 13:34:56 -0600 Subject: [PATCH 006/850] Update README.md Updating markdown anchors as 4,5, & 6 weren't working --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ed7c03d22..e9eb61229 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ https://github.com/e-mission/e-mission-docs/tree/master/docs/e-mission-phone **Issues:** Since this repository is part of a larger project, all issues are tracked [in the central docs repository](https://github.com/e-mission/e-mission-docs/issues). If you have a question, [as suggested by the open source guide](https://opensource.guide/how-to-contribute/#communicating-effectively), please file an issue instead of sending an email. Since issues are public, other contributors can try to answer the question and benefit from the answer. -:sparkles: Check 6. [Contributing](#6-contributing) if you're interested in contributing for this project :sparkles: +:sparkles: Check [6. Contributing](#6-contributing) if you're interested in contributing for this project :sparkles: ## Contents #### 1. [Creating logos](#1-creating-logos) -> Information regarding app Logo From 809fc5d4fe89e17c9f5d40dced40c41eb46f0a8a Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Sat, 23 Sep 2023 13:46:50 -0600 Subject: [PATCH 007/850] Update README.md - touch up - fixed non-working markdown anchors --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e9eb61229..e267f8e71 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,12 @@ https://github.com/e-mission/e-mission-docs/tree/master/docs/e-mission-phone **Issues:** Since this repository is part of a larger project, all issues are tracked [in the central docs repository](https://github.com/e-mission/e-mission-docs/issues). If you have a question, [as suggested by the open source guide](https://opensource.guide/how-to-contribute/#communicating-effectively), please file an issue instead of sending an email. Since issues are public, other contributors can try to answer the question and benefit from the answer. -:sparkles: Check [6. Contributing](#6-contributing) if you're interested in contributing for this project :sparkles: +:sparkles: Check [Contributing](#6-contributing) if you're interested in contributing for this project :sparkles: ## Contents -#### 1. [Creating logos](#1-creating-logos) -> Information regarding app Logo -#### 2. [Updating the UI only](#2-updating-the-ui-only) -> For UI changes ONLY -#### 3. [Updating the e-mission-* plugins or adding new plugins](#3-updating-the-e-mission--plugins-or-adding-new-plugins) -> Work with native code +#### 1. [Creating logos](#1-creating-logos) +#### 2. [Updating the UI only](#2-updating-the-ui-only) +#### 3. [Updating the e-mission-* plugins or adding new plugins](#3-updating-the-e-mission--plugins-or-adding-new-plugins) #### 4. [End to End Testing](#4-end-to-end-testing) #### 5. [Beta-testing debugging](#5-beta-testing-debugging) #### 6. [Contributing](#6-contributing) From b64ab621b0325c95166950473807d67942dcb9b3 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Mon, 25 Sep 2023 00:29:30 -0600 Subject: [PATCH 008/850] Update README.md Removed manual numbering and updated markdown anchors --- README.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e267f8e71..76d56ea88 100644 --- a/README.md +++ b/README.md @@ -13,16 +13,16 @@ https://github.com/e-mission/e-mission-docs/tree/master/docs/e-mission-phone :sparkles: Check [Contributing](#6-contributing) if you're interested in contributing for this project :sparkles: ## Contents -#### 1. [Creating logos](#1-creating-logos) -#### 2. [Updating the UI only](#2-updating-the-ui-only) -#### 3. [Updating the e-mission-* plugins or adding new plugins](#3-updating-the-e-mission--plugins-or-adding-new-plugins) -#### 4. [End to End Testing](#4-end-to-end-testing) -#### 5. [Beta-testing debugging](#5-beta-testing-debugging) -#### 6. [Contributing](#6-contributing) +#### 1. [Creating logos](#creating-logos) +#### 2. [Updating the UI only](#updating-the-ui-only) +#### 3. [Updating the e-mission-* plugins or adding new plugins](#updating-the-e-mission--plugins-or-adding-new-plugins) +#### 4. [End to End Testing](#end-to-end-testing) +#### 5. [Beta-testing debugging](#beta-testing-debugging) +#### 6. [Contributing](#contributing) --- -## 1. Creating logos +## Creating logos If you are building your own version of the app, you must have your own logo to avoid app store conficts. Updating the logo is very simple using the [`ionic @@ -31,7 +31,7 @@ command. **Note**: You may have to install the [`cordova-res` package](https://github.com/ionic-team/cordova-res) for the command to work -## 2. Updating the UI only +## Updating the UI only [![osx-serve-install](https://github.com/e-mission/e-mission-phone/workflows/osx-serve-install/badge.svg)](https://github.com/e-mission/e-mission-phone/actions?query=workflow%3Aosx-serve-install) If you want to make only UI changes, (as opposed to modifying the existing plugins, adding new plugins, etc), you can use the **new and improved** (as of June 2018) [e-mission dev app](https://github.com/e-mission/e-mission-devapp/) and install the most recent version from [releases](https://github.com/e-mission/e-mission-devapp/releases). @@ -84,7 +84,7 @@ source setup/activate_serve.sh **Note1**: You may need to scroll up, past all the warnings about `Content Security Policy has been added` to find the port that the server is listening to. -## 3. Updating the e-mission-\* plugins or adding new plugins +## Updating the e-mission-\* plugins or adding new plugins [![osx-build-ios](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml) [![osx-build-android](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml) @@ -233,7 +233,7 @@ npm run build-dev-android Your output should look something like this: ![Build Successful Message screenshot](Build_ss.png) -## 4. End to End Testing +## End to End Testing A lot of the visualizations that we display in the phone client come from the server. In order to do end to end testing, we need to run a local server and connect to it. Instructions for: @@ -248,7 +248,7 @@ In order to make end to end testing easy, if the local server is started on a HT One advantage of using `skip` authentication in development mode is that any user email can be entered without a password. Developers can use one of the emails that they loaded test data for in step (3) above. So if the test data loaded was with `-u shankari@eecs.berkeley.edu`, then the login email for the phone app would also be `shankari@eecs.berkeley.edu`. -## 5. Beta-testing debugging +## Beta-testing debugging If users run into problems, they have the ability to email logs to the maintainer. These logs are in the form of an sqlite3 database, so they have to be opened using `sqlite3`. Alternatively, you can export it to a csv with @@ -263,7 +263,7 @@ python bin/csv_export_add_date.py /tmp/loggerDB. less /tmp/loggerDB..withdate.log ``` -## 6. Contributing +## Contributing 1. Add the main repo as upstream From 72922cff1c2fb2b4797106cf5bdcb0ead702700f Mon Sep 17 00:00:00 2001 From: niccolopaganini Date: Mon, 25 Sep 2023 00:42:26 -0600 Subject: [PATCH 009/850] 1. Changed expected output for plugins 2. Removed tested on sub-section 3. Revised formatting for activation sub-section -> for better clarity --- README.md | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 76d56ea88..0b19e492e 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ https://github.com/e-mission/e-mission-docs/tree/master/docs/e-mission-phone **Issues:** Since this repository is part of a larger project, all issues are tracked [in the central docs repository](https://github.com/e-mission/e-mission-docs/issues). If you have a question, [as suggested by the open source guide](https://opensource.guide/how-to-contribute/#communicating-effectively), please file an issue instead of sending an email. Since issues are public, other contributors can try to answer the question and benefit from the answer. -:sparkles: Check [Contributing](#6-contributing) if you're interested in contributing for this project :sparkles: +:sparkles: Check [Contributing](#contributing) if you're interested in contributing for this project :sparkles: ## Contents #### 1. [Creating logos](#creating-logos) @@ -101,13 +101,6 @@ have now: If you have setup failures, please compare the configuration in the **passing CI builds** with your configuration. That is almost certainly the source of the error. -### Tested on -__MacOS__ -- Intel chip, MacOS Ventura 13.6 -- Intel chip, MacOS Ventura 13.5.2 -- Intel chip, MacOS Ventura 13.0 -- Intel chip, MacOS Monterey 12.6.7 - Pre-requisites --- - The version of xcode used by the CI. @@ -196,9 +189,8 @@ If connecting to a development server over http, make sure to turn on http suppo ``` -__3. Run this in every new shell__ +__3. Run this in every new shell for Activation__ -- __Activation__ ``` source setup/activate_native.sh ``` @@ -218,8 +210,9 @@ Copied config.cordovabuild.xml -> config.xml and package.cordovabuild.json -> pa ``` +
-- __Pick a type of build and execute the following:__ + __4. Pick a type of build and execute the following:__ More "versions" are available in [`package.cordovabuild.json`](https://github.com/e-mission/e-mission-phone/blob/fce117ff859abd995613bd405dbc7d27c703b09b/package.cordovabuild.json) ``` @@ -230,8 +223,17 @@ For instance: (build-dev-android) ``` npm run build-dev-android ``` -Your output should look something like this: -![Build Successful Message screenshot](Build_ss.png) +
Your expected output should look something like this + +``` +BUILD SUCCESSFUL in 2m 48s +52 actionable tasks: 52 executed +Built the following apk(s): +/Users//e-mission-phone/platforms/android/app/build/outputs/apk/debug/app-debug.apk +``` + +
+ ## End to End Testing From 73a8259ccb8eaaf63d107b267b51fee991a6ffcd Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 27 Sep 2023 16:03:11 -0400 Subject: [PATCH 010/850] add storage.ts, to replace KVStore storage.ts is replacing storage.js which had the KVStore service inside it. storage.ts will provide a set of functions performing the same duties as KVStorage's functions did. - `get` -> `storageGet` - `set` -> `storageSet` - `remove` -> `storageRemove` - `getDirect` -> `storageGetDirect` - `syncAllWebAndNativeValues` -> `storageSyncLocalAndNative` storage.ts will still use the "BEMUserCache" Cordova plugin in exactly the same way that KVStore did. However, instead of using the `angular-local-storage` package as a wrapper around localStorage, we will use it directly. A couple functions were added ('localStorageSet` and `localStorageGet`) to facilitate using localStorage directly - localStorage requires us to stringify objects before storing, and parse them on retrieval. Other than these substitutions, and being rewritten in modern JS with some typings, the logic is exactly the same as it was in KVStore. --To facilitate this change, storage.js is temporarily renamed to ngStorage.js so that it doesn't conflict with the storage.ts filename. ngStorage.js will be removed soon after it is not used anymore. --- www/index.js | 2 +- www/js/plugin/{storage.js => ngStorage.js} | 0 www/js/plugin/storage.ts | 182 +++++++++++++++++++++ 3 files changed, 183 insertions(+), 1 deletion(-) rename www/js/plugin/{storage.js => ngStorage.js} (100%) create mode 100644 www/js/plugin/storage.ts diff --git a/www/index.js b/www/index.js index 55cb233b5..39304165c 100644 --- a/www/index.js +++ b/www/index.js @@ -31,4 +31,4 @@ import './js/control/uploadService.js'; import './js/metrics-factory.js'; import './js/metrics-mappings.js'; import './js/plugin/logger.ts'; -import './js/plugin/storage.js'; +import './js/plugin/ngStorage.js'; diff --git a/www/js/plugin/storage.js b/www/js/plugin/ngStorage.js similarity index 100% rename from www/js/plugin/storage.js rename to www/js/plugin/ngStorage.js diff --git a/www/js/plugin/storage.ts b/www/js/plugin/storage.ts new file mode 100644 index 000000000..04ca3d539 --- /dev/null +++ b/www/js/plugin/storage.ts @@ -0,0 +1,182 @@ +import { getAngularService } from "../angular-react-helper"; +import { displayErrorMsg, logDebug } from "./logger"; + +const mungeValue = (key, value) => { + let store_val = value; + if (typeof value != "object") { + // Should this be {"value": value} or {key: value}? + store_val = {}; + store_val[key] = value; + } + return store_val; +} + +/* + * If a non-JSON object was munged for storage, unwrap it. + */ +const unmungeValue = (key, retData) => { + if (retData?.[key]) { + // it must have been a simple data type that we munged upfront + return retData[key]; + } else { + // it must have been an object + return retData; + } +} + +const localStorageSet = (key: string, value: {[k: string]: any}) => { + localStorage.setItem(key, JSON.stringify(value)); +} + +const localStorageGet = (key: string) => { + const value = localStorage.getItem(key); + if (value) { + return JSON.parse(value); + } else { + return null; + } +} + +/* We redundantly store data in both local and native storage. This function checks + both for a value. If a value is present in only one, it copies it to the other and returns it. + If a value is present in both, but they are different, it copies the native value to + local storage and returns it. */ +function getUnifiedValue(key) { + let ls_stored_val = localStorageGet(key); + return window['cordova'].plugins.BEMUserCache.getLocalStorage(key, false).then((uc_stored_val) => { + logDebug(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, + ls_stored_val = ${JSON.stringify(ls_stored_val)}.`); + + /* compare stored values by stringified JSON equality, not by == or ===. + for objects, == or === only compares the references, not the contents of the objects */ + if (JSON.stringify(ls_stored_val) == JSON.stringify(uc_stored_val)) { + logDebug("local and native values match, already synced"); + return uc_stored_val; + } else { + // the values are different + if (ls_stored_val == null) { + console.assert(uc_stored_val != null, "uc_stored_val should be non-null"); + logDebug(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, + ls_stored_val = ${JSON.stringify(ls_stored_val)}. + copying native ${key} to local...`); + localStorageSet(key, uc_stored_val); + return uc_stored_val; + } else if (uc_stored_val == null) { + console.assert(ls_stored_val != null); + /* + * Backwards compatibility ONLY. Right after the first + * update to this version, we may have a local value that + * is not a JSON object. In that case, we want to munge it + * before storage. Remove this after a few releases. + */ + ls_stored_val = mungeValue(key, ls_stored_val); + displayErrorMsg(`Local ${key} found, native ${key} missing, writing ${key} to native`); + logDebug(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, + ls_stored_val = ${JSON.stringify(ls_stored_val)}. + copying local ${key} to native...`); + return window['cordova'].plugins.BEMUserCache.putLocalStorage(key, ls_stored_val).then(() => { + // we only return the value after we have finished writing + return ls_stored_val; + }); + } + console.assert(ls_stored_val != null && uc_stored_val != null, + "ls_stored_val =" + JSON.stringify(ls_stored_val) + + "uc_stored_val =" + JSON.stringify(uc_stored_val)); + displayErrorMsg(`Local ${key} found, native ${key} found, but different, + writing ${key} to local`); + logDebug(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, + ls_stored_val = ${JSON.stringify(ls_stored_val)}. + copying native ${key} to local...`); + localStorageSet(key, uc_stored_val); + return uc_stored_val; + } + }); +} + +export function storageSet(key: string, value: any) { + const storeVal = mungeValue(key, value); + /* + * How should we deal with consistency here? Have the threads be + * independent so that there is greater chance that one will succeed, + * or the local only succeed if native succeeds. I think parallel is + * better for greater robustness. + */ + localStorageSet(key, storeVal); + return window['cordova'].plugins.BEMUserCache.putLocalStorage(key, storeVal); +} + +export function storageGet(key: string) { + return getUnifiedValue(key).then((retData) => unmungeValue(key, retData)); +} + +export function storageRemove(key: string) { + localStorage.removeItem(key); + return window['cordova'].plugins.BEMUserCache.removeLocalStorage(key); +} + +export function storageClear({ local, native }: { local?: boolean, native?: boolean }) { + if (local) localStorage.clear(); + if (native) return window['cordova'].plugins.BEMUserCache.clearAll(); + return Promise.resolve(); +} + +export function storageGetDirect(key: string) { + // will run in background, we won't wait for the results + getUnifiedValue(key); + return unmungeValue(key, localStorageGet(key)); +} + +function findMissing(fromKeys, toKeys) { + const foundKeys = []; + const missingKeys = []; + fromKeys.forEach((fk) => { + if (toKeys.includes(fk)) { + foundKeys.push(fk); + } else { + missingKeys.push(fk); + } + }); + return [foundKeys, missingKeys]; +} + +export function storageSyncLocalAndNative() { + const ClientStats = getAngularService('ClientStats'); + console.log("STORAGE_PLUGIN: Called syncAllWebAndNativeValues "); + const syncKeys = window['cordova'].plugins.BEMUserCache.listAllLocalStorageKeys().then((nativeKeys) => { + console.log("STORAGE_PLUGIN: native plugin returned"); + const webKeys = Object.keys(localStorage); + // I thought about iterating through the lists and copying over + // only missing values, etc but `getUnifiedValue` already does + // that, and we don't need to copy it + // so let's just find all the missing values and read them + logDebug("STORAGE_PLUGIN: Comparing web keys " + webKeys + " with " + nativeKeys); + let [foundNative, missingNative] = findMissing(webKeys, nativeKeys); + let [foundWeb, missingWeb] = findMissing(nativeKeys, webKeys); + logDebug("STORAGE_PLUGIN: Found native keys " + foundNative + " missing native keys " + missingNative); + logDebug("STORAGE_PLUGIN: Found web keys " + foundWeb + " missing web keys " + missingWeb); + const allMissing = missingNative.concat(missingWeb); + logDebug("STORAGE_PLUGIN: Syncing all missing keys " + allMissing); + allMissing.forEach(getUnifiedValue); + if (allMissing.length != 0) { + ClientStats.addReading(ClientStats.getStatKeys().MISSING_KEYS, { + "type": "local_storage_mismatch", + "allMissingLength": allMissing.length, + "missingWebLength": missingWeb.length, + "missingNativeLength": missingNative.length, + "foundWebLength": foundWeb.length, + "foundNativeLength": foundNative.length, + "allMissing": allMissing, + }).then(logDebug("Logged missing keys to client stats")); + } + }); + const listAllKeys = window['cordova'].plugins.BEMUserCache.listAllUniqueKeys().then((nativeKeys) => { + logDebug("STORAGE_PLUGIN: For the record, all unique native keys are " + nativeKeys); + if (nativeKeys.length == 0) { + ClientStats.addReading(ClientStats.getStatKeys().MISSING_KEYS, { + "type": "all_native", + }).then(logDebug("Logged all missing native keys to client stats")); + } + }); + + return Promise.all([syncKeys, listAllKeys]); +} From b99e0010c607372dcd9484b7d9015aa0b28088a7 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 27 Sep 2023 18:16:48 -0400 Subject: [PATCH 011/850] use storage.ts everywhere instead of KVStore storage.ts replaces KVStore, so we can replace all uses of KVStore throughout the codebase, substituting the new functions from storage.ts as follows: - `get` -> `storageGet` - `set` -> `storageSet` - `remove` -> `storageRemove` - `getDirect` -> `storageGetDirect` - `syncAllWebAndNativeValues` -> `storageSyncLocalAndNative` --- www/js/config/dynamicConfig.ts | 14 +++++--------- www/js/control/ProfileSettings.jsx | 8 ++++---- www/js/metrics-factory.js | 7 ++++--- www/js/metrics-mappings.js | 5 +++-- www/js/onboarding/SaveQrPage.tsx | 4 ++-- www/js/onboarding/onboardingHelper.ts | 7 +++---- www/js/splash/referral.js | 27 ++++++++++++++------------- www/js/splash/startprefs.js | 9 +++++---- 8 files changed, 40 insertions(+), 41 deletions(-) diff --git a/www/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts index f88255d4c..7994391a4 100644 --- a/www/js/config/dynamicConfig.ts +++ b/www/js/config/dynamicConfig.ts @@ -2,6 +2,7 @@ import i18next from "i18next"; import { displayError, logDebug, logWarn } from "../plugin/logger"; import { getAngularService } from "../angular-react-helper"; import { fetchUrlCached } from "../commHelper"; +import { storageClear, storageGet, storageSet } from "../plugin/storage"; export const CONFIG_PHONE_UI="config/app_ui_config"; export const CONFIG_PHONE_UI_KVSTORE ="CONFIG_PHONE_UI"; @@ -174,7 +175,6 @@ function extractSubgroup(token, config) { * @returns {boolean} boolean representing whether the config was updated or not */ function loadNewConfig(newToken, existingVersion = null) { - const KVStore = getAngularService('KVStore'); const newStudyLabel = extractStudyName(newToken); return readConfigFromServer(newStudyLabel).then((downloadedConfig) => { if (downloadedConfig.version == existingVersion) { @@ -190,7 +190,7 @@ function loadNewConfig(newToken, existingVersion = null) { } const storeConfigPromise = window['cordova'].plugins.BEMUserCache.putRWDocument( CONFIG_PHONE_UI, toSaveConfig); - const storeInKVStorePromise = KVStore.set(CONFIG_PHONE_UI_KVSTORE, toSaveConfig); + const storeInKVStorePromise = storageSet(CONFIG_PHONE_UI_KVSTORE, toSaveConfig); // loaded new config, so it is both ready and changed return Promise.all([storeConfigPromise, storeInKVStorePromise]).then( ([result, kvStoreResult]) => { @@ -220,17 +220,13 @@ export function initByUser(urlComponents) { } export function resetDataAndRefresh() { - const KVStore = getAngularService('KVStore'); - const resetNativePromise = window['cordova'].plugins.BEMUserCache.putRWDocument(CONFIG_PHONE_UI, {}); - const resetKVStorePromise = KVStore.clearAll(); - return Promise.all([resetNativePromise, resetKVStorePromise]) - .then(() => window.location.reload()); + // const resetNativePromise = window['cordova'].plugins.BEMUserCache.putRWDocument(CONFIG_PHONE_UI, {}); + storageClear({ local: true, native: true }).then(() => window.location.reload()); } export function getConfig() { if (storedConfig) return Promise.resolve(storedConfig); - const KVStore = getAngularService('KVStore'); - return KVStore.get(CONFIG_PHONE_UI_KVSTORE).then((config) => { + return storageGet(CONFIG_PHONE_UI_KVSTORE).then((config) => { if (config) { storedConfig = config; return config; diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 8035c9462..566c100c1 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -22,6 +22,7 @@ import ControlCollectionHelper, {getHelperCollectionSettings, getState, isMedium import { resetDataAndRefresh } from "../config/dynamicConfig"; import { AppContext } from "../App"; import { shareQR } from "../components/QrCode"; +import { storageClear } from "../plugin/storage"; //any pure functions can go outside const ProfileSettings = () => { @@ -35,7 +36,6 @@ const ProfileSettings = () => { const CarbonDatasetHelper = getAngularService('CarbonDatasetHelper'); const UploadHelper = getAngularService('UploadHelper'); const EmailHelper = getAngularService('EmailHelper'); - const KVStore = getAngularService('KVStore'); const NotificationScheduler = getAngularService('NotificationScheduler'); const ControlHelper = getAngularService('ControlHelper'); const ClientStats = getAngularService('ClientStats'); @@ -387,15 +387,15 @@ const ProfileSettings = () => { style={settingStyles.dialog(colors.elevation.level3)}> {t('general-settings.clear-data')} - - - diff --git a/www/js/metrics-factory.js b/www/js/metrics-factory.js index ce813fbaa..4fc29fa7a 100644 --- a/www/js/metrics-factory.js +++ b/www/js/metrics-factory.js @@ -3,6 +3,7 @@ import angular from 'angular'; import { getBaseModeByValue } from './diary/diaryHelper' import { labelOptions } from './survey/multilabel/confirmHelper'; +import { storageGet, storageRemove, storageSet } from './plugin/storage'; angular.module('emission.main.metrics.factory', ['emission.main.metrics.mappings', @@ -164,13 +165,13 @@ angular.module('emission.main.metrics.factory', } cc.set = function(info) { - return KVStore.set(USER_DATA_KEY, info); + return storageSet(USER_DATA_KEY, info); }; cc.get = function() { - return KVStore.get(USER_DATA_KEY); + return storageGet(USER_DATA_KEY); }; cc.delete = function() { - return KVStore.remove(USER_DATA_KEY); + return storageRemove(USER_DATA_KEY); }; Number.prototype.between = function (min, max) { return this >= min && this <= max; diff --git a/www/js/metrics-mappings.js b/www/js/metrics-mappings.js index 2b71df739..91db216da 100644 --- a/www/js/metrics-mappings.js +++ b/www/js/metrics-mappings.js @@ -1,6 +1,7 @@ import angular from 'angular'; import { getLabelOptions } from './survey/multilabel/confirmHelper'; import { getConfig } from './config/dynamicConfig'; +import { storageGet, storageSet } from './plugin/storage'; angular.module('emission.main.metrics.mappings', ['emission.plugin.logger', 'emission.plugin.kvstore']) @@ -146,7 +147,7 @@ angular.module('emission.main.metrics.mappings', ['emission.plugin.logger', } this.loadCarbonDatasetLocale = function() { - return KVStore.get(CARBON_DATASET_KEY).then(function(localeCode) { + return storageGet(CARBON_DATASET_KEY).then(function(localeCode) { Logger.log("CarbonDatasetHelper.loadCarbonDatasetLocale() obtained value from storage [" + localeCode + "]"); if (!localeCode) { localeCode = defaultCarbonDatasetCode; @@ -158,7 +159,7 @@ angular.module('emission.main.metrics.mappings', ['emission.plugin.logger', this.saveCurrentCarbonDatasetLocale = function (localeCode) { setCurrentCarbonDatasetLocale(localeCode); - KVStore.set(CARBON_DATASET_KEY, currentCarbonDatasetCode); + storageSet(CARBON_DATASET_KEY, currentCarbonDatasetCode); Logger.log("CarbonDatasetHelper.saveCurrentCarbonDatasetLocale() saved value [" + currentCarbonDatasetCode + "] to storage"); } diff --git a/www/js/onboarding/SaveQrPage.tsx b/www/js/onboarding/SaveQrPage.tsx index 157ff4093..57fe8f679 100644 --- a/www/js/onboarding/SaveQrPage.tsx +++ b/www/js/onboarding/SaveQrPage.tsx @@ -10,6 +10,7 @@ import { useTranslation } from "react-i18next"; import QrCode, { shareQR } from "../components/QrCode"; import { onboardingStyles } from "./OnboardingStack"; import { preloadDemoSurveyResponse } from "./SurveyPage"; +import { storageSet } from "../plugin/storage"; const SaveQrPage = ({ }) => { @@ -33,10 +34,9 @@ const SaveQrPage = ({ }) => { function login(token) { const CommHelper = getAngularService('CommHelper'); - const KVStore = getAngularService('KVStore'); const EXPECTED_METHOD = "prompted-auth"; const dbStorageObject = {"token": token}; - return KVStore.set(EXPECTED_METHOD, dbStorageObject).then((r) => { + return storageSet(EXPECTED_METHOD, dbStorageObject).then((r) => { CommHelper.registerUser((successResult) => { refreshOnboardingState(); }, function(errorResult) { diff --git a/www/js/onboarding/onboardingHelper.ts b/www/js/onboarding/onboardingHelper.ts index 659382079..b1cdff0ed 100644 --- a/www/js/onboarding/onboardingHelper.ts +++ b/www/js/onboarding/onboardingHelper.ts @@ -1,6 +1,7 @@ import { DateTime } from "luxon"; import { getAngularService } from "../angular-react-helper"; import { getConfig } from "../config/dynamicConfig"; +import { storageGet, storageSet } from "../plugin/storage"; export const INTRO_DONE_KEY = 'intro_done'; @@ -39,12 +40,10 @@ async function readConsented() { } async function readIntroDone() { - const KVStore = getAngularService('KVStore'); - return KVStore.get(INTRO_DONE_KEY).then((read_val) => !!read_val) as Promise; + return storageGet(INTRO_DONE_KEY).then((read_val) => !!read_val) as Promise; } export async function markIntroDone() { const currDateTime = DateTime.now().toISO(); - const KVStore = getAngularService('KVStore'); - return KVStore.set(INTRO_DONE_KEY, currDateTime); + return storageSet(INTRO_DONE_KEY, currDateTime); } diff --git a/www/js/splash/referral.js b/www/js/splash/referral.js index 9e4707200..4c0d2823b 100644 --- a/www/js/splash/referral.js +++ b/www/js/splash/referral.js @@ -1,4 +1,5 @@ import angular from 'angular'; +import { storageGetDirect, storageRemove, storageSet } from '../plugin/storage'; angular.module('emission.splash.referral', ['emission.plugin.kvstore']) @@ -11,32 +12,32 @@ angular.module('emission.splash.referral', ['emission.plugin.kvstore']) var REFERRED_USER_ID = 'referred_user_id'; referralHandler.getReferralNavigation = function() { - const toReturn = KVStore.getDirect(REFERRAL_NAVIGATION_KEY); - KVStore.remove(REFERRAL_NAVIGATION_KEY); + const toReturn = storageGetDirect(REFERRAL_NAVIGATION_KEY); + storageRemove(REFERRAL_NAVIGATION_KEY); return toReturn; } referralHandler.setupGroupReferral = function(kvList) { - KVStore.set(REFERRED_KEY, true); - KVStore.set(REFERRED_GROUP_ID, kvList['groupid']); - KVStore.set(REFERRED_USER_ID, kvList['userid']); - KVStore.set(REFERRAL_NAVIGATION_KEY, 'goals'); + storageSet(REFERRED_KEY, true); + storageSet(REFERRED_GROUP_ID, kvList['groupid']); + storageSet(REFERRED_USER_ID, kvList['userid']); + storageSet(REFERRAL_NAVIGATION_KEY, 'goals'); }; referralHandler.clearGroupReferral = function(kvList) { - KVStore.remove(REFERRED_KEY); - KVStore.remove(REFERRED_GROUP_ID); - KVStore.remove(REFERRED_USER_ID); - KVStore.remove(REFERRAL_NAVIGATION_KEY); + storageRemove(REFERRED_KEY); + storageRemove(REFERRED_GROUP_ID); + storageRemove(REFERRED_USER_ID); + storageRemove(REFERRAL_NAVIGATION_KEY); }; referralHandler.getReferralParams = function(kvList) { - return [KVStore.getDirect(REFERRED_GROUP_ID), - KVStore.getDirect(REFERRED_USER_ID)]; + return [storageGetDirect(REFERRED_GROUP_ID), + storageGetDirect(REFERRED_USER_ID)]; } referralHandler.hasPendingRegistration = function() { - return KVStore.getDirect(REFERRED_KEY) + return storageGetDirect(REFERRED_KEY) }; return referralHandler; diff --git a/www/js/splash/startprefs.js b/www/js/splash/startprefs.js index e535d179a..bec593131 100644 --- a/www/js/splash/startprefs.js +++ b/www/js/splash/startprefs.js @@ -1,5 +1,6 @@ import angular from 'angular'; import { getConfig } from '../config/dynamicConfig'; +import { storageGet, storageSet } from '../plugin/storage'; angular.module('emission.splash.startprefs', ['emission.plugin.logger', 'emission.splash.referral', @@ -31,7 +32,7 @@ angular.module('emission.splash.startprefs', ['emission.plugin.logger', // mark in native storage return startprefs.readConsentState().then(writeConsentToNative).then(function(response) { // mark in local storage - KVStore.set(DATA_COLLECTION_CONSENTED_PROTOCOL, + storageSet(DATA_COLLECTION_CONSENTED_PROTOCOL, $rootScope.req_consent); // mark in local variable as well $rootScope.curr_consented = angular.copy($rootScope.req_consent); @@ -41,13 +42,13 @@ angular.module('emission.splash.startprefs', ['emission.plugin.logger', startprefs.markIntroDone = function() { var currTime = moment().format(); - KVStore.set(INTRO_DONE_KEY, currTime); + storageSet(INTRO_DONE_KEY, currTime); $rootScope.$emit(startprefs.INTRO_DONE_EVENT, currTime); } // returns boolean startprefs.readIntroDone = function() { - return KVStore.get(INTRO_DONE_KEY).then(function(read_val) { + return storageGet(INTRO_DONE_KEY).then(function(read_val) { logger.log("in readIntroDone, read_val = "+JSON.stringify(read_val)); $rootScope.intro_done = read_val; }); @@ -84,7 +85,7 @@ angular.module('emission.splash.startprefs', ['emission.plugin.logger', .then(function(startupConfigResult) { $rootScope.req_consent = startupConfigResult.data.emSensorDataCollectionProtocol; logger.log("required consent version = " + JSON.stringify($rootScope.req_consent)); - return KVStore.get(DATA_COLLECTION_CONSENTED_PROTOCOL); + return storageGet(DATA_COLLECTION_CONSENTED_PROTOCOL); }).then(function(kv_store_consent) { $rootScope.curr_consented = kv_store_consent; console.assert(angular.isDefined($rootScope.req_consent), "in readConsentState $rootScope.req_consent", JSON.stringify($rootScope.req_consent)); From 6ffbf1886218898dba60e7294e51158084152f1a Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 27 Sep 2023 18:21:39 -0400 Subject: [PATCH 012/850] remove KVStore / ngStorage.js Rewritten into storage.ts, this Angular service is not needed anymore. We can remove the file, remove it from index.js, and remove it as a dependency from all of the Angular modules that previously used it. --- www/index.js | 1 - www/js/metrics-factory.js | 5 +- www/js/metrics-mappings.js | 7 +- www/js/plugin/ngStorage.js | 221 ------------------------------------ www/js/services.js | 3 +- www/js/splash/referral.js | 4 +- www/js/splash/startprefs.js | 5 +- 7 files changed, 10 insertions(+), 236 deletions(-) delete mode 100644 www/js/plugin/ngStorage.js diff --git a/www/index.js b/www/index.js index 39304165c..a858ef784 100644 --- a/www/index.js +++ b/www/index.js @@ -31,4 +31,3 @@ import './js/control/uploadService.js'; import './js/metrics-factory.js'; import './js/metrics-mappings.js'; import './js/plugin/logger.ts'; -import './js/plugin/ngStorage.js'; diff --git a/www/js/metrics-factory.js b/www/js/metrics-factory.js index 4fc29fa7a..b5ead9c82 100644 --- a/www/js/metrics-factory.js +++ b/www/js/metrics-factory.js @@ -6,8 +6,7 @@ import { labelOptions } from './survey/multilabel/confirmHelper'; import { storageGet, storageRemove, storageSet } from './plugin/storage'; angular.module('emission.main.metrics.factory', - ['emission.main.metrics.mappings', - 'emission.plugin.kvstore']) + ['emission.main.metrics.mappings']) .factory('FootprintHelper', function(CarbonDatasetHelper, CustomDatasetHelper) { var fh = {}; @@ -145,7 +144,7 @@ angular.module('emission.main.metrics.factory', return fh; }) -.factory('CalorieCal', function(KVStore, METDatasetHelper, CustomDatasetHelper) { +.factory('CalorieCal', function(METDatasetHelper, CustomDatasetHelper) { var cc = {}; var highestMET = 0; diff --git a/www/js/metrics-mappings.js b/www/js/metrics-mappings.js index 91db216da..60068711d 100644 --- a/www/js/metrics-mappings.js +++ b/www/js/metrics-mappings.js @@ -3,10 +3,9 @@ import { getLabelOptions } from './survey/multilabel/confirmHelper'; import { getConfig } from './config/dynamicConfig'; import { storageGet, storageSet } from './plugin/storage'; -angular.module('emission.main.metrics.mappings', ['emission.plugin.logger', - 'emission.plugin.kvstore']) +angular.module('emission.main.metrics.mappings', ['emission.plugin.logger']) -.service('CarbonDatasetHelper', function(KVStore) { +.service('CarbonDatasetHelper', function() { var CARBON_DATASET_KEY = 'carbon_dataset_locale'; // Values are in Kg/PKm (kilograms per passenger-kilometer) @@ -182,7 +181,7 @@ angular.module('emission.main.metrics.mappings', ['emission.plugin.logger', return carbonDatasets[currentCarbonDatasetCode].footprintData; }; }) -.service('METDatasetHelper', function(KVStore) { +.service('METDatasetHelper', function() { var standardMETs = { "WALKING": { "VERY_SLOW": { diff --git a/www/js/plugin/ngStorage.js b/www/js/plugin/ngStorage.js deleted file mode 100644 index a14b1db83..000000000 --- a/www/js/plugin/ngStorage.js +++ /dev/null @@ -1,221 +0,0 @@ -import angular from 'angular'; - -angular.module('emission.plugin.kvstore', ['emission.plugin.logger', - 'LocalStorageModule', - 'emission.stats.clientstats']) - -.factory('KVStore', function($window, Logger, localStorageService, $ionicPopup, - $ionicPlatform, ClientStats) { - var logger = Logger; - var kvstoreJs = {} - /* - * Sets in both localstorage and native storage - * If the message is not a JSON object, wrap it in an object with the key - * "value" before storing it. - */ - var getNativePlugin = function() { - return $window.cordova.plugins.BEMUserCache; - } - - /* - * Munge plain, non-JSON objects to JSON objects before storage - */ - - var mungeValue = function(key, value) { - var store_val = value; - if (typeof value != "object") { - // Should this be {"value": value} or {key: value}? - store_val = {}; - store_val[key] = value; - } - return store_val; - } - - - kvstoreJs.set = function(key, value) { - // add checks for data type - var store_val = mungeValue(key, value); - /* - * How should we deal with consistency here? Have the threads be - * independent so that there is greater chance that one will succeed, - * or the local only succeed if native succeeds. I think parallel is - * better for greater robustness. - */ - localStorageService.set(key, store_val); - return getNativePlugin().putLocalStorage(key, store_val); - } - - var getUnifiedValue = function(key) { - var ls_stored_val = localStorageService.get(key, undefined); - return getNativePlugin().getLocalStorage(key, false).then(function(uc_stored_val) { - logger.log("for key "+key+" uc_stored_val = "+JSON.stringify(uc_stored_val)+" ls_stored_val = "+JSON.stringify(ls_stored_val)); - if (angular.equals(ls_stored_val, uc_stored_val)) { - logger.log("local and native values match, already synced"); - return uc_stored_val; - } else { - // the values are different - if (ls_stored_val == null) { - console.assert(uc_stored_val != null, "uc_stored_val should be non-null"); - logger.log("for key "+key+"uc_stored_val = "+JSON.stringify(uc_stored_val)+ - " ls_stored_val = "+JSON.stringify(ls_stored_val)+ - " copying native "+key+" to local..."); - localStorageService.set(key, uc_stored_val); - return uc_stored_val; - } else if (uc_stored_val == null) { - console.assert(ls_stored_val != null); - /* - * Backwards compatibility ONLY. Right after the first - * update to this version, we may have a local value that - * is not a JSON object. In that case, we want to munge it - * before storage. Remove this after a few releases. - */ - ls_stored_val = mungeValue(key, ls_stored_val); - $ionicPopup.alert({template: "Local "+key+" found, native " - +key+" missing, writing "+key+" to native"}) - logger.log("for key "+key+"uc_stored_val = "+JSON.stringify(uc_stored_val)+ - " ls_stored_val = "+JSON.stringify(ls_stored_val)+ - " copying local "+key+" to native..."); - return getNativePlugin().putLocalStorage(key, ls_stored_val).then(function() { - // we only return the value after we have finished writing - return ls_stored_val; - }); - } - console.assert(ls_stored_val != null && uc_stored_val != null, - "ls_stored_val ="+JSON.stringify(ls_stored_val)+ - "uc_stored_val ="+JSON.stringify(uc_stored_val)); - $ionicPopup.alert({template: "Local "+key+" found, native " - +key+" found, but different, writing "+key+" to local"}) - logger.log("for key "+key+"uc_stored_val = "+JSON.stringify(uc_stored_val)+ - " ls_stored_val = "+JSON.stringify(ls_stored_val)+ - " copying native "+key+" to local..."); - localStorageService.set(key, uc_stored_val); - return uc_stored_val; - } - }); - } - - /* - * If a non-JSON object was munged for storage, unwrap it. - */ - var unmungeValue = function(key, retData) { - if((retData != null) && (angular.isDefined(retData[key]))) { - // it must have been a simple data type that we munged upfront - return retData[key]; - } else { - // it must have been an object - return retData; - } - } - - kvstoreJs.get = function(key) { - return getUnifiedValue(key).then(function(retData) { - return unmungeValue(key, retData); - }); - } - - /* - * TODO: Temporary fix for data that: - - we want to return inline instead of in a promise - - is not catastrophic if it is cleared out (e.g. walkthrough code), OR - - is used primarily for session storage so will not be cleared out - (e.g. referral code) - We can replace this with promises in a future PR if needed - - The code does copy the native value to local storage in the background, - so even if this is stripped out, it will work on retry. - */ - kvstoreJs.getDirect = function(key) { - // will run in background, we won't wait for the results - getUnifiedValue(key); - return unmungeValue(key, localStorageService.get(key)); - } - - kvstoreJs.remove = function(key) { - localStorageService.remove(key); - return getNativePlugin().removeLocalStorage(key); - } - - kvstoreJs.clearAll = function() { - localStorageService.clearAll(); - return getNativePlugin().clearAll(); - } - - /* - * Unfortunately, there is weird deletion of native - * https://github.com/e-mission/e-mission-docs/issues/930 - * So we cannot remove this if/until we switch to react native - */ - kvstoreJs.clearOnlyLocal = function() { - return localStorageService.clearAll(); - } - - kvstoreJs.clearOnlyNative = function() { - return getNativePlugin().clearAll(); - } - - let findMissing = function(fromKeys, toKeys) { - const foundKeys = []; - const missingKeys = []; - fromKeys.forEach((fk) => { - if (toKeys.includes(fk)) { - foundKeys.push(fk); - } else { - missingKeys.push(fk); - } - }); - return [foundKeys, missingKeys]; - } - - let syncAllWebAndNativeValues = function() { - console.log("STORAGE_PLUGIN: Called syncAllWebAndNativeValues "); - const syncKeys = getNativePlugin().listAllLocalStorageKeys().then((nativeKeys) => { - console.log("STORAGE_PLUGIN: native plugin returned"); - const webKeys = localStorageService.keys(); - // I thought about iterating through the lists and copying over - // only missing values, etc but `getUnifiedValue` already does - // that, and we don't need to copy it - // so let's just find all the missing values and read them - logger.log("STORAGE_PLUGIN: Comparing web keys "+webKeys+" with "+nativeKeys); - let [foundNative, missingNative] = findMissing(webKeys, nativeKeys); - let [foundWeb, missingWeb] = findMissing(nativeKeys, webKeys); - logger.log("STORAGE_PLUGIN: Found native keys "+foundNative+" missing native keys "+missingNative); - logger.log("STORAGE_PLUGIN: Found web keys "+foundWeb+" missing web keys "+missingWeb); - const allMissing = missingNative.concat(missingWeb); - logger.log("STORAGE_PLUGIN: Syncing all missing keys "+allMissing); - allMissing.forEach(getUnifiedValue); - if (allMissing.length != 0) { - ClientStats.addReading(ClientStats.getStatKeys().MISSING_KEYS, { - "type": "local_storage_mismatch", - "allMissingLength": allMissing.length, - "missingWebLength": missingWeb.length, - "missingNativeLength": missingNative.length, - "foundWebLength": foundWeb.length, - "foundNativeLength": foundNative.length, - "allMissing": allMissing, - }).then(Logger.log("Logged missing keys to client stats")); - } - }); - const listAllKeys = getNativePlugin().listAllUniqueKeys().then((nativeKeys) => { - logger.log("STORAGE_PLUGIN: For the record, all unique native keys are "+nativeKeys); - if (nativeKeys.length == 0) { - ClientStats.addReading(ClientStats.getStatKeys().MISSING_KEYS, { - "type": "all_native", - }).then(Logger.log("Logged all missing native keys to client stats")); - } - }); - - return Promise.all([syncKeys, listAllKeys]); - } - - $ionicPlatform.ready().then(function() { - Logger.log("STORAGE_PLUGIN: app launched, checking storage sync"); - syncAllWebAndNativeValues(); - }); - - $ionicPlatform.on("resume", function() { - Logger.log("STORAGE_PLUGIN: app has resumed, checking storage sync"); - syncAllWebAndNativeValues(); - }); - - return kvstoreJs; -}); diff --git a/www/js/services.js b/www/js/services.js index 9a63b364d..1955cd4bb 100644 --- a/www/js/services.js +++ b/www/js/services.js @@ -2,8 +2,7 @@ import angular from 'angular'; -angular.module('emission.services', ['emission.plugin.logger', - 'emission.plugin.kvstore']) +angular.module('emission.services', ['emission.plugin.logger']) .service('CommHelper', function($rootScope) { var getConnectURL = function(successCallback, errorCallback) { diff --git a/www/js/splash/referral.js b/www/js/splash/referral.js index 4c0d2823b..849de847a 100644 --- a/www/js/splash/referral.js +++ b/www/js/splash/referral.js @@ -1,9 +1,9 @@ import angular from 'angular'; import { storageGetDirect, storageRemove, storageSet } from '../plugin/storage'; -angular.module('emission.splash.referral', ['emission.plugin.kvstore']) +angular.module('emission.splash.referral', []) -.factory('ReferralHandler', function($window, KVStore) { +.factory('ReferralHandler', function($window) { var referralHandler = {}; var REFERRAL_NAVIGATION_KEY = 'referral_navigation'; diff --git a/www/js/splash/startprefs.js b/www/js/splash/startprefs.js index bec593131..92a07e624 100644 --- a/www/js/splash/startprefs.js +++ b/www/js/splash/startprefs.js @@ -3,11 +3,10 @@ import { getConfig } from '../config/dynamicConfig'; import { storageGet, storageSet } from '../plugin/storage'; angular.module('emission.splash.startprefs', ['emission.plugin.logger', - 'emission.splash.referral', - 'emission.plugin.kvstore']) + 'emission.splash.referral']) .factory('StartPrefs', function($window, $state, $interval, $rootScope, $ionicPlatform, - $ionicPopup, KVStore, $http, Logger, ReferralHandler) { + $ionicPopup, $http, Logger, ReferralHandler) { var logger = Logger; var startprefs = {}; // Boolean: represents that the "intro" - the one page summary From cdabde43a743f482325bb021d8cce284831aeebc Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 27 Sep 2023 20:54:36 -0400 Subject: [PATCH 013/850] use JSDOM as jest env so localStorage works Followed the advice of https://stackoverflow.com/a/74309873 so that we can have a mocked out localStorage to use in tests --- jest.config.json | 1 + package.serve.json | 1 + www/__tests__/storage.test.ts | 4 ++++ 3 files changed, 6 insertions(+) create mode 100644 www/__tests__/storage.test.ts diff --git a/jest.config.json b/jest.config.json index 78dc839b4..77b53782c 100644 --- a/jest.config.json +++ b/jest.config.json @@ -1,4 +1,5 @@ { + "testEnvironment": "jsdom", "testPathIgnorePatterns": [ "/node_modules/", "/platforms/", diff --git a/package.serve.json b/package.serve.json index 1a2ef6cb0..57470bc2d 100644 --- a/package.serve.json +++ b/package.serve.json @@ -35,6 +35,7 @@ "expose-loader": "^4.1.0", "file-loader": "^6.2.0", "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "phonegap": "9.0.0+cordova.9.0.0", "process": "^0.11.10", "sass": "^1.62.1", diff --git a/www/__tests__/storage.test.ts b/www/__tests__/storage.test.ts new file mode 100644 index 000000000..841bd7ee3 --- /dev/null +++ b/www/__tests__/storage.test.ts @@ -0,0 +1,4 @@ +it('stores a value in localstorage and then retrieves it', () => { + localStorage.setItem("testKey", "testValue"); + expect(localStorage.getItem("testKey")).toBe("testValue"); +}); From fd4f9d54a66d1e05d89f15fdedce4e44e7c36ffb Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 28 Sep 2023 00:51:06 -0400 Subject: [PATCH 014/850] add tests for storage.ts, plus the needed mocks To test storage.ts we need to be able to mock the usercache cordova plugin, as well as fill in the missing window.Logger. I created mocks for both of these which are kept in the __mocks__ folder and simply called at the start of storage.test.ts. Because storage.ts calls getAngularService, angular-react-helper.tsx was included in the process. That file imported PaperProvider, which did not work with jest. Because we are not angularizing components anymore, we do not actually need much of the code in angular-react-helper anyway. Let's just take it out so we can safely implicate getAngularService. As for the storage tests themselves, they check whether the storage functions are able to store and retrieve data, and crucially they ensure that local and native storage can compensate for each other if one of them gets cleared. --- jest.config.json | 3 ++ www/__mocks__/cordovaMocks.ts | 53 +++++++++++++++++++++++ www/__mocks__/globalMocks.ts | 3 ++ www/__tests__/storage.test.ts | 76 +++++++++++++++++++++++++++++++-- www/js/angular-react-helper.tsx | 55 ------------------------ 5 files changed, 132 insertions(+), 58 deletions(-) create mode 100644 www/__mocks__/cordovaMocks.ts create mode 100644 www/__mocks__/globalMocks.ts diff --git a/jest.config.json b/jest.config.json index 77b53782c..71bc5f5ca 100644 --- a/jest.config.json +++ b/jest.config.json @@ -10,6 +10,9 @@ "transform": { "^.+\\.(ts|tsx|js|jsx)$": "ts-jest" }, + "transformIgnorePatterns": [ + "/node_modules/(?!(@react-native|react-native|react-native-vector-icons))" + ], "moduleNameMapper": { "^react-native$": "react-native-web" } diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts new file mode 100644 index 000000000..5f723a5d7 --- /dev/null +++ b/www/__mocks__/cordovaMocks.ts @@ -0,0 +1,53 @@ +export const mockBEMUserCache = () => { + const _cache = {}; + const mockBEMUserCache = { + getLocalStorage: (key: string, isSecure: boolean) => { + return new Promise((rs, rj) => + setTimeout(() => { + rs(_cache[key]); + }, 100) + ); + }, + putLocalStorage: (key: string, value: any) => { + return new Promise((rs, rj) => + setTimeout(() => { + _cache[key] = value; + rs(); + }, 100) + ); + }, + removeLocalStorage: (key: string) => { + return new Promise((rs, rj) => + setTimeout(() => { + delete _cache[key]; + rs(); + }, 100) + ); + }, + clearAll: () => { + return new Promise((rs, rj) => + setTimeout(() => { + for (let p in _cache) delete _cache[p]; + rs(); + }, 100) + ); + }, + listAllLocalStorageKeys: () => { + return new Promise((rs, rj) => + setTimeout(() => { + rs(Object.keys(_cache)); + }, 100) + ); + }, + listAllUniqueKeys: () => { + return new Promise((rs, rj) => + setTimeout(() => { + rs(Object.keys(_cache)); + }, 100) + ); + } + } + window['cordova'] ||= {}; + window['cordova'].plugins ||= {}; + window['cordova'].plugins.BEMUserCache = mockBEMUserCache; +} diff --git a/www/__mocks__/globalMocks.ts b/www/__mocks__/globalMocks.ts new file mode 100644 index 000000000..3d9b71507 --- /dev/null +++ b/www/__mocks__/globalMocks.ts @@ -0,0 +1,3 @@ +export const mockLogger = () => { + window['Logger'] = { log: console.log }; +} diff --git a/www/__tests__/storage.test.ts b/www/__tests__/storage.test.ts index 841bd7ee3..6fea4f8b9 100644 --- a/www/__tests__/storage.test.ts +++ b/www/__tests__/storage.test.ts @@ -1,4 +1,74 @@ -it('stores a value in localstorage and then retrieves it', () => { - localStorage.setItem("testKey", "testValue"); - expect(localStorage.getItem("testKey")).toBe("testValue"); +import { mockBEMUserCache } from "../__mocks__/cordovaMocks"; +import { mockLogger } from "../__mocks__/globalMocks"; +import { storageClear, storageGet, storageRemove, storageSet } from "../js/plugin/storage"; + +// mocks used - storage.ts uses BEMUserCache and logging. +// localStorage is already mocked for us by Jest :) +mockLogger(); +mockBEMUserCache(); + +it('stores a value and retrieves it back', async () => { + await storageSet('test1', 'test value 1'); + const retVal = await storageGet('test1'); + expect(retVal).toEqual('test value 1'); +}); + +it('stores a value, removes it, and checks that it is gone', async () => { + await storageSet('test2', 'test value 2'); + await storageRemove('test2'); + const retVal = await storageGet('test2'); + expect(retVal).toBeUndefined(); +}); + +it('can store objects too', async () => { + const obj = { a: 1, b: 2 }; + await storageSet('test6', obj); + const retVal = await storageGet('test6'); + expect(retVal).toEqual(obj); +}); + +it('can also store complex nested objects with arrays', async () => { + const obj = { a: 1, b: { c: [1, 2, 3] } }; + await storageSet('test7', obj); + const retVal = await storageGet('test7'); + expect(retVal).toEqual(obj); +}); + +it('preserves values if local gets cleared', async () => { + await storageSet('test3', 'test value 3'); + await storageClear({ local: true }); + const retVal = await storageGet('test3'); + expect(retVal).toEqual('test value 3'); +}); + +it('preserves values if native gets cleared', async () => { + await storageSet('test4', 'test value 4'); + await storageClear({ native: true }); + const retVal = await storageGet('test4'); + expect(retVal).toEqual('test value 4'); +}); + +it('does not preserve values if both local and native are cleared', async () => { + await storageSet('test5', 'test value 5'); + await storageClear({ local: true, native: true }); + const retVal = await storageGet('test5'); + expect(retVal).toBeUndefined(); +}); + +it('preserves values if local gets cleared, then retrieved, then native gets cleared', async () => { + await storageSet('test8', 'test value 8'); + await storageClear({ local: true }); + await storageGet('test8'); + await storageClear({ native: true }); + const retVal = await storageGet('test8'); + expect(retVal).toEqual('test value 8'); +}); + +it('preserves values if native gets cleared, then retrieved, then local gets cleared', async () => { + await storageSet('test9', 'test value 9'); + await storageClear({ native: true }); + await storageGet('test9'); + await storageClear({ local: true }); + const retVal = await storageGet('test9'); + expect(retVal).toEqual('test value 9'); }); diff --git a/www/js/angular-react-helper.tsx b/www/js/angular-react-helper.tsx index 984e529ff..4813ae2e9 100644 --- a/www/js/angular-react-helper.tsx +++ b/www/js/angular-react-helper.tsx @@ -3,61 +3,6 @@ // Modified to use React 18 and wrap elements with the React Native Paper Provider import angular from 'angular'; -import { createRoot } from 'react-dom/client'; -import React from 'react'; -import { Provider as PaperProvider, MD3LightTheme as DefaultTheme, MD3Colors } from 'react-native-paper'; -import { getTheme } from './appTheme'; - -function toBindings(propTypes) { - const bindings = {}; - Object.keys(propTypes).forEach(key => bindings[key] = '<'); - return bindings; -} - -function toProps(propTypes, controller) { - const props = {}; - Object.keys(propTypes).forEach(key => props[key] = controller[key]); - return props; -} - -export function angularize(component, name, modulePath) { - component.module = modulePath; - const nameCamelCase = name[0].toLowerCase() + name.slice(1); - angular - .module(modulePath, []) - .component(nameCamelCase, makeComponentProps(component)); -} - -const theme = getTheme(); -export function makeComponentProps(Component) { - const propTypes = Component.propTypes || {}; - return { - bindings: toBindings(propTypes), - controller: ['$element', function($element) { - /* TODO: once the inf scroll list is converted to React and no longer uses - collection-repeat, we can just set the root here one time - and will not have to reassign it in $onChanges. */ - /* Until then, React will complain everytime we reassign an element's root */ - let root; - this.$onChanges = () => { - root = createRoot($element[0]); - const props = toProps(propTypes, this); - root.render( - - - - - ); - }; - this.$onDestroy = () => root.unmount(); - }] - }; -} export function getAngularService(name: string) { const injector = angular.element(document.body).injector(); From 8ada4f8b43d1751774cb4b446ded869e9590f419 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 28 Sep 2023 01:15:17 -0400 Subject: [PATCH 015/850] add clientStats.js, to replace ClientStats service js/plugin/clientStats.ts will replace js/stats/clientstats.js - camelcase filename convention - we don't need a dedicated folder for stats because clientstats.js was the only thing in it Nothing significant has changed here. I just refactored into modern JS and added type definitions for each function's parameters. mappings of old/new exported methods: - getStatKeys -> statKeys (now a variable, not a function) - getAppVersion -> getAppVersion - getStatsEvent -> getStatsEvent - addReading -> addStatReading - addEvent -> addStatEvent - addError -> addStatError --- www/js/plugin/clientStats.ts | 48 ++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 www/js/plugin/clientStats.ts diff --git a/www/js/plugin/clientStats.ts b/www/js/plugin/clientStats.ts new file mode 100644 index 000000000..b945ee5f8 --- /dev/null +++ b/www/js/plugin/clientStats.ts @@ -0,0 +1,48 @@ +const CLIENT_TIME = "stats/client_time"; +const CLIENT_ERROR = "stats/client_error"; +const CLIENT_NAV_EVENT = "stats/client_nav_event"; + +export const statKeys = { + STATE_CHANGED: "state_changed", + BUTTON_FORCE_SYNC: "button_sync_forced", + CHECKED_DIARY: "checked_diary", + DIARY_TIME: "diary_time", + METRICS_TIME: "metrics_time", + CHECKED_INF_SCROLL: "checked_inf_scroll", + INF_SCROLL_TIME: "inf_scroll_time", + VERIFY_TRIP: "verify_trip", + LABEL_TAB_SWITCH: "label_tab_switch", + SELECT_LABEL: "select_label", + EXPANDED_TRIP: "expanded_trip", + NOTIFICATION_OPEN: "notification_open", + REMINDER_PREFS: "reminder_time_prefs", + MISSING_KEYS: "missing_keys" +}; + +let appVersion; +export const getAppVersion = () => { + appVersion ||= window['cordova']?.plugins?.BEMUserCache?.getAppVersion(); + return appVersion; +} + +export const getStatsEvent = (name: string, reading: any) => { + const ts = Date.now() / 1000; + const client_app_version = getAppVersion(); + const client_os_version = window['device'].version; + return { name, ts, reading, client_app_version, client_os_version }; +} + +export const addStatReading = (name: string, reading: any) => { + const db = window['cordova']?.plugins?.BEMUserCache; + if (db) return db.putMessage(CLIENT_TIME, getStatsEvent(name, reading)); +} + +export const addStatEvent = (name: string) => { + const db = window['cordova']?.plugins?.BEMUserCache; + if (db) return db.putMessage(CLIENT_NAV_EVENT, getStatsEvent(name, null)); +} + +export const addStatError = (name: string, errorStr: string) => { + const db = window['cordova']?.plugins?.BEMUserCache; + if (db) return db.putMessage(CLIENT_ERROR, getStatsEvent(name, errorStr)); +} From 6e59af538d43e17d2a1cf230112c8134866ccc3f Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 28 Sep 2023 01:29:51 -0400 Subject: [PATCH 016/850] use clientStats.ts everywhere, not old ClientStats ClientStats was rewritten into clientStats.ts. This commit simply substitutes in the new methods for the old ones, everywhere that client stats are recorded, as follows: - getStatKeys -> statKeys (now a variable, not a function) - getAppVersion -> getAppVersion - getStatsEvent -> getStatsEvent - addReading -> addStatReading - addEvent -> addStatEvent --- www/js/control/ControlSyncHelper.tsx | 7 ++++--- www/js/control/ProfileSettings.jsx | 3 ++- www/js/controllers.js | 10 ++++------ www/js/plugin/storage.ts | 5 +++-- www/js/splash/notifScheduler.js | 3 ++- www/js/splash/remotenotify.js | 8 ++++---- 6 files changed, 19 insertions(+), 17 deletions(-) diff --git a/www/js/control/ControlSyncHelper.tsx b/www/js/control/ControlSyncHelper.tsx index 490672c4d..992d48562 100644 --- a/www/js/control/ControlSyncHelper.tsx +++ b/www/js/control/ControlSyncHelper.tsx @@ -8,6 +8,7 @@ import ActionMenu from "../components/ActionMenu"; import SettingRow from "./SettingRow"; import AlertBar from "./AlertBar"; import moment from "moment"; +import { addStatEvent, statKeys } from "../plugin/clientStats"; /* * BEGIN: Simple read/write wrappers @@ -61,8 +62,8 @@ export const ForceSyncRow = ({getState}) => { async function forceSync() { try { - let addedEvent = ClientStats.addEvent(ClientStats.getStatKeys().BUTTON_FORCE_SYNC); - console.log("Added "+ClientStats.getStatKeys().BUTTON_FORCE_SYNC+" event"); + let addedEvent = addStatEvent(statKeys.BUTTON_FORCE_SYNC); + console.log("Added "+statKeys.BUTTON_FORCE_SYNC+" event"); let sync = await forcePluginSync(); /* @@ -281,4 +282,4 @@ const ControlSyncHelper = ({ editVis, setEditVis }) => { ); }; -export default ControlSyncHelper; \ No newline at end of file +export default ControlSyncHelper; diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 566c100c1..f86723e46 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -23,6 +23,7 @@ import { resetDataAndRefresh } from "../config/dynamicConfig"; import { AppContext } from "../App"; import { shareQR } from "../components/QrCode"; import { storageClear } from "../plugin/storage"; +import { getAppVersion } from "../plugin/clientStats"; //any pure functions can go outside const ProfileSettings = () => { @@ -96,7 +97,7 @@ const ProfileSettings = () => { getOPCode(); getSyncSettings(); getConnectURL(); - setAppVersion(ClientStats.getAppVersion()); + setAppVersion(getAppVersion()); } //previously not loaded on regular refresh, this ensures it stays caught up diff --git a/www/js/controllers.js b/www/js/controllers.js index 7efc26c09..56e5c984a 100644 --- a/www/js/controllers.js +++ b/www/js/controllers.js @@ -1,6 +1,7 @@ 'use strict'; import angular from 'angular'; +import { addStatError, addStatReading, statKeys } from './plugin/clientStats'; angular.module('emission.controllers', ['emission.splash.startprefs', 'emission.splash.pushnotify', @@ -23,22 +24,19 @@ angular.module('emission.controllers', ['emission.splash.startprefs', $rootScope.$on('$stateChangeSuccess', function(event, toState, toParams, fromState, fromParams){ console.log("Finished changing state from "+JSON.stringify(fromState) + " to "+JSON.stringify(toState)); - ClientStats.addReading(ClientStats.getStatKeys().STATE_CHANGED, - fromState.name + '-2-' + toState.name).then(function() {}, function() {}); + addStatReading(statKeys.STATE_CHANGED, fromState.name + '-2-' + toState.name); }); $rootScope.$on('$stateChangeError', function(event, toState, toParams, fromState, fromParams, error){ console.log("Error "+error+" while changing state from "+JSON.stringify(fromState) +" to "+JSON.stringify(toState)); - ClientStats.addError(ClientStats.getStatKeys().STATE_CHANGED, - fromState.name + '-2-' + toState.name+ "_" + error).then(function() {}, function() {}); + addStatError(statKeys.STATE_CHANGED, fromState.name + '-2-' + toState.name+ "_" + error); }); $rootScope.$on('$stateNotFound', function(event, unfoundState, fromState, fromParams){ console.log("unfoundState.to = "+unfoundState.to); // "lazy.state" console.log("unfoundState.toParams = " + unfoundState.toParams); // {a:1, b:2} console.log("unfoundState.options = " + unfoundState.options); // {inherit:false} + default options - ClientStats.addError(ClientStats.getStatKeys().STATE_CHANGED, - fromState.name + '-2-' + unfoundState.name).then(function() {}, function() {}); + addStatError(statKeys.STATE_CHANGED, fromState.name + '-2-' + unfoundState.name); }); var isInList = function(element, list) { diff --git a/www/js/plugin/storage.ts b/www/js/plugin/storage.ts index 04ca3d539..ba5ffcb4d 100644 --- a/www/js/plugin/storage.ts +++ b/www/js/plugin/storage.ts @@ -1,4 +1,5 @@ import { getAngularService } from "../angular-react-helper"; +import { addStatReading, statKeys } from "./clientStats"; import { displayErrorMsg, logDebug } from "./logger"; const mungeValue = (key, value) => { @@ -158,7 +159,7 @@ export function storageSyncLocalAndNative() { logDebug("STORAGE_PLUGIN: Syncing all missing keys " + allMissing); allMissing.forEach(getUnifiedValue); if (allMissing.length != 0) { - ClientStats.addReading(ClientStats.getStatKeys().MISSING_KEYS, { + addStatReading(statKeys.MISSING_KEYS, { "type": "local_storage_mismatch", "allMissingLength": allMissing.length, "missingWebLength": missingWeb.length, @@ -172,7 +173,7 @@ export function storageSyncLocalAndNative() { const listAllKeys = window['cordova'].plugins.BEMUserCache.listAllUniqueKeys().then((nativeKeys) => { logDebug("STORAGE_PLUGIN: For the record, all unique native keys are " + nativeKeys); if (nativeKeys.length == 0) { - ClientStats.addReading(ClientStats.getStatKeys().MISSING_KEYS, { + addStatReading(statKeys.MISSING_KEYS, { "type": "all_native", }).then(logDebug("Logged all missing native keys to client stats")); } diff --git a/www/js/splash/notifScheduler.js b/www/js/splash/notifScheduler.js index 821b6fb09..e062af8d2 100644 --- a/www/js/splash/notifScheduler.js +++ b/www/js/splash/notifScheduler.js @@ -2,6 +2,7 @@ import angular from 'angular'; import { getConfig } from '../config/dynamicConfig'; +import { addStatReading, statKeys } from '../plugin/clientStats'; angular.module('emission.splash.notifscheduler', ['emission.services', @@ -247,7 +248,7 @@ angular.module('emission.splash.notifscheduler', const { reminder_assignment, reminder_join_date, reminder_time_of_day} = prefs; - ClientStats.addReading(ClientStats.getStatKeys().REMINDER_PREFS, { + addStatReading(statKeys.REMINDER_PREFS, { reminder_assignment, reminder_join_date, reminder_time_of_day diff --git a/www/js/splash/remotenotify.js b/www/js/splash/remotenotify.js index 2074da5b8..289d77532 100644 --- a/www/js/splash/remotenotify.js +++ b/www/js/splash/remotenotify.js @@ -13,6 +13,7 @@ 'use strict'; import angular from 'angular'; +import { addStatEvent, statKeys } from '../plugin/clientStats'; angular.module('emission.splash.remotenotify', ['emission.plugin.logger', 'emission.splash.startprefs', @@ -42,10 +43,9 @@ angular.module('emission.splash.remotenotify', ['emission.plugin.logger', remoteNotify.init = function() { $rootScope.$on('cloud:push:notification', function(event, data) { - ClientStats.addEvent(ClientStats.getStatKeys().NOTIFICATION_OPEN).then( - function() { - console.log("Added "+ClientStats.getStatKeys().NOTIFICATION_OPEN+" event. Data = " + JSON.stringify(data)); - }); + addStatEvent(statKeys.NOTIFICATION_OPEN).then(() => { + console.log("Added "+statKeys.NOTIFICATION_OPEN+" event. Data = " + JSON.stringify(data)); + }); Logger.log("data = "+JSON.stringify(data)); if (angular.isDefined(data.additionalData) && angular.isDefined(data.additionalData.payload) && From 25c7077f8783b42904907e38a904017c72836372 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 28 Sep 2023 10:34:56 -0400 Subject: [PATCH 017/850] remove the old ClientStats / clientstats.js Rewritten into a new file clientStats.ts, this old file is not needed anymore. We can remove the file, remove it from index.js, and remove it as a dependency from all of the Angular modules that previously used it. --- www/index.js | 1 - www/js/control/ControlSyncHelper.tsx | 1 - www/js/control/ProfileSettings.jsx | 1 - www/js/controllers.js | 5 +- www/js/plugin/storage.ts | 1 - www/js/splash/notifScheduler.js | 5 +- www/js/splash/remotenotify.js | 5 +- www/js/stats/clientstats.js | 92 ------------------- .../survey/enketo/enketo-add-note-button.js | 3 +- www/js/survey/enketo/enketo-trip-button.js | 3 +- www/js/survey/multilabel/multi-label-ui.js | 3 +- 11 files changed, 9 insertions(+), 111 deletions(-) delete mode 100644 www/js/stats/clientstats.js diff --git a/www/index.js b/www/index.js index a858ef784..89c3a5e26 100644 --- a/www/index.js +++ b/www/index.js @@ -4,7 +4,6 @@ import './css/main.diary.css'; import 'leaflet/dist/leaflet.css'; import './js/ngApp.js'; -import './js/stats/clientstats.js'; import './js/splash/referral.js'; import './js/splash/customURL.js'; import './js/splash/startprefs.js'; diff --git a/www/js/control/ControlSyncHelper.tsx b/www/js/control/ControlSyncHelper.tsx index 992d48562..5acdf5b2d 100644 --- a/www/js/control/ControlSyncHelper.tsx +++ b/www/js/control/ControlSyncHelper.tsx @@ -54,7 +54,6 @@ type syncConfig = { sync_interval: number, export const ForceSyncRow = ({getState}) => { const { t } = useTranslation(); const { colors } = useTheme(); - const ClientStats = getAngularService('ClientStats'); const Logger = getAngularService('Logger'); const [dataPendingVis, setDataPendingVis] = useState(false); diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index f86723e46..90c5818ee 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -39,7 +39,6 @@ const ProfileSettings = () => { const EmailHelper = getAngularService('EmailHelper'); const NotificationScheduler = getAngularService('NotificationScheduler'); const ControlHelper = getAngularService('ControlHelper'); - const ClientStats = getAngularService('ClientStats'); const StartPrefs = getAngularService('StartPrefs'); //functions that come directly from an Angular service diff --git a/www/js/controllers.js b/www/js/controllers.js index 56e5c984a..75124efce 100644 --- a/www/js/controllers.js +++ b/www/js/controllers.js @@ -7,8 +7,7 @@ angular.module('emission.controllers', ['emission.splash.startprefs', 'emission.splash.pushnotify', 'emission.splash.storedevicesettings', 'emission.splash.localnotify', - 'emission.splash.remotenotify', - 'emission.stats.clientstats']) + 'emission.splash.remotenotify']) .controller('RootCtrl', function($scope) {}) @@ -16,7 +15,7 @@ angular.module('emission.controllers', ['emission.splash.startprefs', .controller('SplashCtrl', function($scope, $state, $interval, $rootScope, StartPrefs, PushNotify, StoreDeviceSettings, - LocalNotify, RemoteNotify, ClientStats) { + LocalNotify, RemoteNotify) { console.log('SplashCtrl invoked'); // alert("attach debugger!"); // PushNotify.startupInit(); diff --git a/www/js/plugin/storage.ts b/www/js/plugin/storage.ts index ba5ffcb4d..87be6de9b 100644 --- a/www/js/plugin/storage.ts +++ b/www/js/plugin/storage.ts @@ -141,7 +141,6 @@ function findMissing(fromKeys, toKeys) { } export function storageSyncLocalAndNative() { - const ClientStats = getAngularService('ClientStats'); console.log("STORAGE_PLUGIN: Called syncAllWebAndNativeValues "); const syncKeys = window['cordova'].plugins.BEMUserCache.listAllLocalStorageKeys().then((nativeKeys) => { console.log("STORAGE_PLUGIN: native plugin returned"); diff --git a/www/js/splash/notifScheduler.js b/www/js/splash/notifScheduler.js index e062af8d2..760e797c7 100644 --- a/www/js/splash/notifScheduler.js +++ b/www/js/splash/notifScheduler.js @@ -6,11 +6,10 @@ import { addStatReading, statKeys } from '../plugin/clientStats'; angular.module('emission.splash.notifscheduler', ['emission.services', - 'emission.plugin.logger', - 'emission.stats.clientstats']) + 'emission.plugin.logger']) .factory('NotificationScheduler', function($http, $window, $ionicPlatform, - ClientStats, CommHelper, Logger) { + CommHelper, Logger) { const scheduler = {}; let _config; diff --git a/www/js/splash/remotenotify.js b/www/js/splash/remotenotify.js index 289d77532..c11783a2b 100644 --- a/www/js/splash/remotenotify.js +++ b/www/js/splash/remotenotify.js @@ -16,10 +16,9 @@ import angular from 'angular'; import { addStatEvent, statKeys } from '../plugin/clientStats'; angular.module('emission.splash.remotenotify', ['emission.plugin.logger', - 'emission.splash.startprefs', - 'emission.stats.clientstats']) + 'emission.splash.startprefs']) -.factory('RemoteNotify', function($http, $window, $ionicPopup, $rootScope, ClientStats, +.factory('RemoteNotify', function($http, $window, $ionicPopup, $rootScope, CommHelper, Logger) { var remoteNotify = {}; diff --git a/www/js/stats/clientstats.js b/www/js/stats/clientstats.js deleted file mode 100644 index 7fe4d9cb3..000000000 --- a/www/js/stats/clientstats.js +++ /dev/null @@ -1,92 +0,0 @@ -'use strict'; - -import angular from 'angular'; - -angular.module('emission.stats.clientstats', []) - -.factory('ClientStats', function($window) { - var clientStat = {}; - - clientStat.CLIENT_TIME = "stats/client_time"; - clientStat.CLIENT_ERROR = "stats/client_error"; - clientStat.CLIENT_NAV_EVENT = "stats/client_nav_event"; - - clientStat.getStatKeys = function() { - return { - STATE_CHANGED: "state_changed", - BUTTON_FORCE_SYNC: "button_sync_forced", - CHECKED_DIARY: "checked_diary", - DIARY_TIME: "diary_time", - METRICS_TIME: "metrics_time", - CHECKED_INF_SCROLL: "checked_inf_scroll", - INF_SCROLL_TIME: "inf_scroll_time", - VERIFY_TRIP: "verify_trip", - LABEL_TAB_SWITCH: "label_tab_switch", - SELECT_LABEL: "select_label", - EXPANDED_TRIP: "expanded_trip", - NOTIFICATION_OPEN: "notification_open", - REMINDER_PREFS: "reminder_time_prefs", - MISSING_KEYS: "missing_keys" - }; - } - - clientStat.getDB = function() { - if (angular.isDefined($window) && angular.isDefined($window.cordova) && - angular.isDefined($window.cordova.plugins)) { - return $window.cordova.plugins.BEMUserCache; - } else { - return; // undefined - } - } - - clientStat.getAppVersion = function() { - if (angular.isDefined(clientStat.appVersion)) { - return clientStat.appVersion; - } else { - if (angular.isDefined($window) && angular.isDefined($window.cordova) && - angular.isDefined($window.cordova.getAppVersion)) { - $window.cordova.getAppVersion.getVersionNumber().then(function(version) { - clientStat.appVersion = version; - }); - } - return; - } - } - - clientStat.getStatsEvent = function(name, reading) { - var ts_sec = Date.now() / 1000; - var appVersion = clientStat.getAppVersion(); - return { - 'name': name, - 'ts': ts_sec, - 'reading': reading, - 'client_app_version': appVersion, - 'client_os_version': $window.device.version - }; - } - clientStat.addReading = function(name, reading) { - var db = clientStat.getDB(); - if (angular.isDefined(db)) { - return db.putMessage(clientStat.CLIENT_TIME, - clientStat.getStatsEvent(name, reading)); - } - } - - clientStat.addEvent = function(name) { - var db = clientStat.getDB(); - if (angular.isDefined(db)) { - return db.putMessage(clientStat.CLIENT_NAV_EVENT, - clientStat.getStatsEvent(name, null)); - } - } - - clientStat.addError = function(name, errorStr) { - var db = clientStat.getDB(); - if (angular.isDefined(db)) { - return db.putMessage(clientStat.CLIENT_ERROR, - clientStat.getStatsEvent(name, errorStr)); - } - } - - return clientStat; -}) diff --git a/www/js/survey/enketo/enketo-add-note-button.js b/www/js/survey/enketo/enketo-add-note-button.js index a2f0d1557..49f7747f6 100644 --- a/www/js/survey/enketo/enketo-add-note-button.js +++ b/www/js/survey/enketo/enketo-add-note-button.js @@ -5,8 +5,7 @@ import angular from 'angular'; angular.module('emission.survey.enketo.add-note-button', - ['emission.stats.clientstats', - 'emission.services', + ['emission.services', 'emission.survey.enketo.answer', 'emission.survey.inputmatcher']) .factory("EnketoNotesButtonService", function(InputMatcher, EnketoSurveyAnswer, Logger, $timeout) { diff --git a/www/js/survey/enketo/enketo-trip-button.js b/www/js/survey/enketo/enketo-trip-button.js index 5b385a1ac..6e710435f 100644 --- a/www/js/survey/enketo/enketo-trip-button.js +++ b/www/js/survey/enketo/enketo-trip-button.js @@ -14,8 +14,7 @@ import angular from 'angular'; angular.module('emission.survey.enketo.trip.button', - ['emission.stats.clientstats', - 'emission.survey.enketo.answer', + ['emission.survey.enketo.answer', 'emission.survey.inputmatcher']) .factory("EnketoTripButtonService", function(InputMatcher, EnketoSurveyAnswer, Logger, $timeout) { var etbs = {}; diff --git a/www/js/survey/multilabel/multi-label-ui.js b/www/js/survey/multilabel/multi-label-ui.js index 313c8a3a9..7d1bc4007 100644 --- a/www/js/survey/multilabel/multi-label-ui.js +++ b/www/js/survey/multilabel/multi-label-ui.js @@ -3,8 +3,7 @@ import { baseLabelInputDetails, getBaseLabelInputs, getFakeEntry, getLabelInputD import { getConfig } from '../../config/dynamicConfig'; angular.module('emission.survey.multilabel.buttons', - ['emission.stats.clientstats', - 'emission.survey.inputmatcher']) + ['emission.survey.inputmatcher']) .factory("MultiLabelService", function($rootScope, InputMatcher, $timeout, $ionicPlatform, Logger) { var mls = {}; From 728a7f53afe24926ac5c909f6ead55320e62a8c1 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 28 Sep 2023 09:13:49 -0600 Subject: [PATCH 018/850] rename the angular service with ng prefix hoping to avoid naming problems when creating the new ts service by the same name --- www/js/control/{uploadService.js => nguploadService.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename www/js/control/{uploadService.js => nguploadService.js} (100%) diff --git a/www/js/control/uploadService.js b/www/js/control/nguploadService.js similarity index 100% rename from www/js/control/uploadService.js rename to www/js/control/nguploadService.js From 2049c59edf4e5987fe283a27fbcf934dafe43660 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 28 Sep 2023 09:14:57 -0600 Subject: [PATCH 019/850] start uploadService.ts and tests starting to convert the service, but running into a fair number of issues, especially because of complex ionic popups --- www/__tests__/uploadService.tests.ts | 2 + www/js/control/uploadService.ts | 180 +++++++++++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 www/__tests__/uploadService.tests.ts create mode 100644 www/js/control/uploadService.ts diff --git a/www/__tests__/uploadService.tests.ts b/www/__tests__/uploadService.tests.ts new file mode 100644 index 000000000..50a253a2e --- /dev/null +++ b/www/__tests__/uploadService.tests.ts @@ -0,0 +1,2 @@ +import {} from "../js/control/uploadService"; + diff --git a/www/js/control/uploadService.ts b/www/js/control/uploadService.ts new file mode 100644 index 000000000..e359bed88 --- /dev/null +++ b/www/js/control/uploadService.ts @@ -0,0 +1,180 @@ +import { logDebug, logInfo, logError, displayError, displayErrorMsg } from "../plugin/logger"; +import { useTranslation } from "react-i18next"; + +/** + * @returns A promise that resolves with an upload URL or rejects with an error + */ +function getUploadConfig() { + return new Promise(function (resolve, reject) { + //logInfo( "About to get email config"); + let url = []; + fetch("json/uploadConfig.json").then( function (uploadConfig) { + //logDebug("uploadConfigString = " + JSON.stringify(uploadConfig['data'])); + url.push(uploadConfig["data"].url); + resolve(url); + }).catch(function (err) { + fetch("json/uploadConfig.json.sample"). then(function (uploadConfig) { + //logDebug("default uploadConfigString = " + JSON.stringify(uploadConfig['data'])); + console.log("default uploadConfigString = " + JSON.stringify(uploadConfig['data'])) + url.push(uploadConfig["data"].url); + resolve(url); + }).catch(function (err) { + //logError("Error while reading default upload config" + err); + reject(err); + }) + }) + }) +} + +function onReadError(err) { + displayError(err, "Error while reading log"); +} + +function onUploadError(err) { + displayError(err, "Error while uploading log"); +} + +function readDBFile(parentDir, database, callbackFn) { + return new Promise(function(resolve, reject) { + window['resolveLocalFileSystemURL'](parentDir, function(fs) { + fs.filesystem.root.getFile(fs.fullPath+database, null, (fileEntry) => { + console.log(fileEntry); + fileEntry.file(function(file) { + console.log(file); + var reader = new FileReader(); + + reader.onprogress = function(report) { + console.log("Current progress is "+JSON.stringify(report)); + if (callbackFn != undefined) { + callbackFn(report.loaded * 100 / report.total); + } + } + + reader.onerror = function(error) { + console.log(this.error); + reject({"error": {"message": this.error}}); + } + + reader.onload = function() { + console.log("Successful file read with " + this.result.byteLength +" characters"); + resolve(new DataView(this.result)); + } + + reader.readAsArrayBuffer(file); + }, reject); + }, reject); + }); + }); +} + +const sendToServer = function upload(url, binArray, params) { + //attempting to replace angular.identity + var identity = function() { + return arguments[0]; + } + + var config = { + method: "POST", + body: binArray, + headers: {'Content-Type': undefined }, + transformRequest: identity, + params: params + }; + return fetch (url, config); +} + + +//only export of this file, used in ProfileSettings and passed the argument (""loggerDB"") +export function uploadFile(database) { + const { t } = useTranslation(); + getUploadConfig().then((uploadConfig) => { + var parentDir = "unknown"; + + if (window['cordova'].platformId.toLowerCase() == "android") { + parentDir = window['cordova'].file.applicationStorageDirectory+"/databases"; + } + else if (window['cordova'].platformId.toLowerCase() == "ios") { + parentDir = window['cordova'].file.dataDirectory + "../LocalDatabase"; + } else { + alert("parentDir unexpectedly = " + parentDir + "!") + } + + const newScope = {}; + newScope["data"] = {}; + newScope["fromDirText"] = t('upload-service.upload-from-dir', {parentDir: parentDir}); + newScope["toServerText"] = t('upload-service.upload-to-server', {serverURL: uploadConfig}); + + let didCancel = true; + let detailsPopup = () => console.log("I need a popup"); + + // const detailsPopup = $ionicPopup.show({ + // title: i18next.t("upload-service.upload-database", { db: database }), + // template: newScope.toServerText + // + '', + // scope: newScope, + // buttons: [ + // { + // text: 'Cancel', + // onTap: function(e) { + // didCancel = true; + // detailsPopup.close(); + // } + // }, + // { + // text: 'Upload', + // type: 'button-positive', + // onTap: function(e) { + // if (!newScope.data.reason) { + // //don't allow the user to close unless he enters wifi password + // didCancel = false; + // e.preventDefault(); + // } else { + // didCancel = false; + // return newScope.data.reason; + // } + // } + // } + // ] + // }); + + logInfo("Going to upload " + database); + const readFileAndInfo = [readDBFile(parentDir, database, detailsPopup)]; + Promise.all(readFileAndInfo).then(([binString, reason]) => { + if(!didCancel) + { + console.log("Uploading file of size "+binString['byteLength']); + const progressScope = {...newScope}; //make a child copy of the current scope + const params = { + reason: reason, + tz: Intl.DateTimeFormat().resolvedOptions().timeZone + } + uploadConfig.forEach((url) => { + alert(t("upload-service.upload-database", {db: database}) + + "\n" + + t("upload-service.upload-progress", {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: uploadConfig}) + ); + // const progressPopup = $ionicPopup.show({ + // title: t("upload-service.upload-database", + // {db: database}), + // template: t("upload-service.upload-progress", + // {filesizemb: binString['byteLength'] / (1000 * 1000), + // serverURL: uploadConfig}) + // + '
', + // scope: progressScope, + // buttons: [ + // { text: 'Cancel', type: 'button-cancel', }, + // ] + // }); + sendToServer(url, binString, params).then((response) => { + console.log(response); + //progressPopup.close(); + displayErrorMsg(t("upload-service.upload-details", + {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: uploadConfig}), + t("upload-service.upload-success")); + }).catch(onUploadError); + }); + } + }).catch(onReadError); + }).catch(onReadError); + }; \ No newline at end of file From 7de88284bd14f8d66d5d3fc45a268fbc36ee3e4b Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 28 Sep 2023 09:16:34 -0600 Subject: [PATCH 020/850] add logError fcn to Logger.ts the upload service called for a log at the error level, adding support for that in logger.ts --- www/js/plugin/logger.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/www/js/plugin/logger.ts b/www/js/plugin/logger.ts index c4e476de1..9f6d41d73 100644 --- a/www/js/plugin/logger.ts +++ b/www/js/plugin/logger.ts @@ -33,6 +33,9 @@ export const logInfo = (message: string) => export const logWarn = (message: string) => window['Logger'].log(window['Logger'].LEVEL_WARN, message); +export const logError = (message: string) => + window['Logger'].log(window['Logger'].LEVEL_ERROR, message); + export function displayError(error: Error, title?: string) { const errorMsg = error.message ? error.message + '\n' + error.stack : JSON.stringify(error); displayErrorMsg(errorMsg, title); From 1a476343c4ae8ca5dc90c10d46cf7033e5d2e846 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 28 Sep 2023 13:05:40 -0400 Subject: [PATCH 021/850] fix getAppVersion in clientstats This is used in ProfileSettings is for the "App Version" row at the very bottom of the Profile page. getAppVersion() was not working correctly - the correct function to call is `getVersionNumber` which returns a promise. In a .then block, we can memoize this value in the local let appVersion. Since it is memoized, we don't need a dedicated state value in ProfileSettings for appVersion. We can just call getAppVersion() directly where it's used in the SettingRow. --- www/js/control/ProfileSettings.jsx | 4 +--- www/js/plugin/clientStats.ts | 6 ++++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 90c5818ee..03523f376 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -69,7 +69,6 @@ const ProfileSettings = () => { const [syncSettings, setSyncSettings] = useState({}); const [cacheResult, setCacheResult] = useState(""); const [connectSettings, setConnectSettings] = useState({}); - const [appVersion, setAppVersion] = useState(""); const [uiConfig, setUiConfig] = useState({}); const [consentDoc, setConsentDoc] = useState({}); const [dumpDate, setDumpDate] = useState(new Date()); @@ -96,7 +95,6 @@ const ProfileSettings = () => { getOPCode(); getSyncSettings(); getConnectURL(); - setAppVersion(getAppVersion()); } //previously not loaded on regular refresh, this ensures it stays caught up @@ -376,7 +374,7 @@ const ProfileSettings = () => { - console.log("")} desc={appVersion}> + console.log("")} desc={getAppVersion()}> {/* menu for "nuke data" */} diff --git a/www/js/plugin/clientStats.ts b/www/js/plugin/clientStats.ts index b945ee5f8..746672dc7 100644 --- a/www/js/plugin/clientStats.ts +++ b/www/js/plugin/clientStats.ts @@ -21,8 +21,10 @@ export const statKeys = { let appVersion; export const getAppVersion = () => { - appVersion ||= window['cordova']?.plugins?.BEMUserCache?.getAppVersion(); - return appVersion; + if (appVersion) return appVersion; + window['cordova']?.getAppVersion.getVersionNumber().then((version) => { + appVersion = version; + }); } export const getStatsEvent = (name: string, reading: any) => { From 598d1fb8ef2b8c4ce773b715474b5c636c2004df Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 28 Sep 2023 13:31:34 -0400 Subject: [PATCH 022/850] add some cordova mocks: cordova,device,appversion There are a few other 'window' variables that are provided in a Cordova/Ionic app and are expected in various few places throughout the codebase. To test, we need to be able to mock these too. mockCordova and mockDevice just provide fake platform/version info. mockGetAppVersion mocks the cordova-plugin-app-version, which is a third-party plugin we use to get the version of the app. This is used in clientStats.ts, so we will need to use this mock when we test that. The mock returns info for a "Mock App", version 1.2.3. --- www/__mocks__/cordovaMocks.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 5f723a5d7..f7d0a6ec6 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -1,3 +1,27 @@ +export const mockCordova = () => { + window['cordova'] ||= {}; + window['cordova'].platformId ||= 'ios'; + window['cordova'].platformVersion ||= '6.2.0'; + window['cordova'].plugins ||= {}; +} + +export const mockDevice = () => { + window['device'] ||= {}; + window['device'].platform ||= 'ios'; + window['device'].version ||= '14.0.0'; +} + +export const mockGetAppVersion = () => { + const mockGetAppVersion = { + getAppName: () => new Promise((rs, rj) => setTimeout(() => rs('Mock App'), 10)), + getPackageName: () => new Promise((rs, rj) => setTimeout(() => rs('com.example.mockapp'), 10)), + getVersionCode: () => new Promise((rs, rj) => setTimeout(() => rs('123'), 10)), + getVersionNumber: () => new Promise((rs, rj) => setTimeout(() => rs('1.2.3'), 10)), + } + window['cordova'] ||= {}; + window['cordova'].getAppVersion = mockGetAppVersion; +} + export const mockBEMUserCache = () => { const _cache = {}; const mockBEMUserCache = { From c0d651cc94c030bc27c3bae9f9be21d0bdc46874 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 28 Sep 2023 11:41:47 -0700 Subject: [PATCH 023/850] Remove old angular service file --- www/index.js | 1 - www/js/splash/customURL.js | 40 -------------------------------------- 2 files changed, 41 deletions(-) delete mode 100644 www/js/splash/customURL.js diff --git a/www/index.js b/www/index.js index 66a0d45df..578b3dc75 100644 --- a/www/index.js +++ b/www/index.js @@ -13,7 +13,6 @@ import './js/config/imperial.js'; import './js/config/server_conn.js'; import './js/stats/clientstats.js'; import './js/splash/referral.js'; -import './js/splash/customURL.js'; import './js/splash/startprefs.js'; import './js/splash/pushnotify.js'; import './js/splash/storedevicesettings.js'; diff --git a/www/js/splash/customURL.js b/www/js/splash/customURL.js deleted file mode 100644 index 521244bc0..000000000 --- a/www/js/splash/customURL.js +++ /dev/null @@ -1,40 +0,0 @@ -'use strict'; - -import angular from 'angular'; - -angular.module('emission.splash.customURLScheme', []) - -.factory('CustomURLScheme', function($rootScope) { - var cus = {}; - - var parseURL = function(url) { - var addr = url.split('//')[1]; - var route = addr.split('?')[0]; - var params = addr.split('?')[1]; - var paramsList = params.split('&'); - var rtn = {route: route}; - for (var i = 0; i < paramsList.length; i++) { - var splitList = paramsList[i].split('='); - rtn[splitList[0]] = splitList[1]; - } - return rtn; - }; - - /* - * Register a custom URL handler. - * handler arguments are: - * - * event: - * url: the url that was passed in - * urlComponents: the URL parsed into multiple components - */ - cus.onLaunch = function(handler) { - console.log("onLaunch method from factory called"); - $rootScope.$on("CUSTOM_URL_LAUNCH", function(event, url) { - var urlComponents = parseURL(url); - handler(event, url, urlComponents); - }); - }; - - return cus; -}); From 46301f7db29f191c2489b8c49ec78ad078a3c0dc Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 28 Sep 2023 11:42:11 -0700 Subject: [PATCH 024/850] write new customURL function --- www/js/splash/customURL.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 www/js/splash/customURL.ts diff --git a/www/js/splash/customURL.ts b/www/js/splash/customURL.ts new file mode 100644 index 000000000..46460a6d7 --- /dev/null +++ b/www/js/splash/customURL.ts @@ -0,0 +1,18 @@ +type UrlComponents = { + [key : string] : string +} + +type OnLaunchCustomURL = (rawUrl: string, callback: (url: string, urlComponents: UrlComponents) => void ) => void; + + +export const onLaunchCustomURL: OnLaunchCustomURL = (rawUrl, handler) => { + const url = rawUrl.split('//')[1]; + const [ route, paramString ] = url.split('?'); + const paramsList = paramString.split('&'); + const urlComponents: UrlComponents = { route : route }; + for (let i = 0; i < paramsList.length; i++) { + const [key, value] = paramsList[i].split('='); + urlComponents[key] = value; + } + handler(url, urlComponents); +}; \ No newline at end of file From ea5b214a4d3976274e1634ed7d5f6aab3a601a53 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 28 Sep 2023 11:42:36 -0700 Subject: [PATCH 025/850] Call onLaunchCustomURL directly in join-ctrl because customURL only gets called in join-ctrl. --- www/js/join/join-ctrl.js | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/www/js/join/join-ctrl.js b/www/js/join/join-ctrl.js index 85d6424c1..e99829b13 100644 --- a/www/js/join/join-ctrl.js +++ b/www/js/join/join-ctrl.js @@ -1,4 +1,5 @@ import angular from 'angular'; +import { onLaunchCustomURL } from '../splash/customURL'; angular.module('emission.join.ctrl', ['emission.splash.startprefs', 'emission.splash.pushnotify', @@ -7,7 +8,7 @@ angular.module('emission.join.ctrl', ['emission.splash.startprefs', 'emission.splash.remotenotify', 'emission.stats.clientstats']) .controller('JoinCtrl', function($scope, $state, $interval, $rootScope, - $ionicPlatform, $ionicPopup, $ionicPopover) { + $ionicPlatform, $ionicPopup, $ionicPopover, ReferralHandler, DynamicConfig) { console.log('JoinCtrl invoked'); // alert("attach debugger!"); // PushNotify.startupInit(); @@ -37,9 +38,15 @@ angular.module('emission.join.ctrl', ['emission.splash.startprefs', function handleOpenURL(url) { console.log("onLaunch method from external function called"); - var c = document.querySelectorAll("[ng-app]")[0]; - var scope = angular.element(c).scope(); - scope.$broadcast("CUSTOM_URL_LAUNCH", url); + onLaunchCustomURL(url, function(url, urlComponents){ + console.log("GOT URL:"+url); + if (urlComponents.route == 'join') { + ReferralHandler.setupGroupReferral(urlComponents); + StartPrefs.loadWithPrefs(); + } else if (urlComponents.route == 'login_token') { + DynamicConfig.initByUser(urlComponents); + } + }) }; $scope.scanCode = function() { From c9f1552feca8c533e2a3d1125402e6e560987fcf Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 28 Sep 2023 11:42:48 -0700 Subject: [PATCH 026/850] Remove calling customURL in app.js because customURL gets called in join-ctrl now --- www/js/app.js | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/www/js/app.js b/www/js/app.js index a52edaa12..2d0b1d08d 100644 --- a/www/js/app.js +++ b/www/js/app.js @@ -27,14 +27,12 @@ import 'ng-i18next'; angular.module('emission', ['ionic', 'jm.i18next', 'emission.controllers','emission.services', 'emission.plugin.logger', - 'emission.splash.customURLScheme', 'emission.splash.referral', - 'emission.services.email', + 'emission.splash.referral','emission.services.email', 'emission.intro', 'emission.main', 'emission.config.dynamic', 'emission.config.server_conn', 'emission.join.ctrl', 'pascalprecht.translate', 'LocalStorageModule']) -.run(function($ionicPlatform, $rootScope, $http, Logger, - CustomURLScheme, ReferralHandler, DynamicConfig, localStorageService, ServerConnConfig) { +.run(function($ionicPlatform, $rootScope, $http, Logger, localStorageService, ServerConnConfig) { console.log("Starting run"); // ensure that plugin events are delivered after the ionicPlatform is ready // https://github.com/katzer/cordova-plugin-local-notifications#launch-details @@ -44,17 +42,6 @@ angular.module('emission', ['ionic', 'jm.i18next', // TODO: Although the onLaunch call doesn't need to wait for the platform the // handlers do. Can we rely on the fact that the event is generated from // native code, so will only be launched after the platform is ready? - CustomURLScheme.onLaunch(function(event, url, urlComponents){ - console.log("GOT URL:"+url); - // alert("GOT URL:"+url); - - if (urlComponents.route == 'join') { - ReferralHandler.setupGroupReferral(urlComponents); - StartPrefs.loadWithPrefs(); - } else if (urlComponents.route == 'login_token') { - DynamicConfig.initByUser(urlComponents); - } - }); // END: Global listeners $ionicPlatform.ready(function() { // Hide the accessory bar by default (remove this to show the accessory bar above the keyboard From 47aceaf986cf281c74b21c73c685f5a7ab3b66a8 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 28 Sep 2023 14:50:22 -0400 Subject: [PATCH 027/850] make getAppVersion work async getAppVersion() calls cordova-plugin-app-version's getVersionNumber, which is asynchronous. So getAppVersion should be asynchronous too. I changed it to return a promise. We handle this in clientStats by adopting async/await syntax. We handle this in ProfileSettings by storing it as a ref once the promise is resolved. Then we just access it as appVersion.current. --- www/js/control/ProfileSettings.jsx | 8 ++++++-- www/js/plugin/clientStats.ts | 24 ++++++++++++++---------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 03523f376..4a263acc5 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useContext } from "react"; +import React, { useState, useEffect, useContext, useRef } from "react"; import { Modal, StyleSheet, ScrollView } from "react-native"; import { Dialog, Button, useTheme, Text, Appbar, IconButton } from "react-native-paper"; import { getAngularService } from "../angular-react-helper"; @@ -72,6 +72,7 @@ const ProfileSettings = () => { const [uiConfig, setUiConfig] = useState({}); const [consentDoc, setConsentDoc] = useState({}); const [dumpDate, setDumpDate] = useState(new Date()); + const appVersion = useRef(); let carbonDatasetString = t('general-settings.carbon-dataset') + ": " + CarbonDatasetHelper.getCurrentCarbonDatasetCode(); const carbonOptions = CarbonDatasetHelper.getCarbonDatasetOptions(); @@ -95,6 +96,9 @@ const ProfileSettings = () => { getOPCode(); getSyncSettings(); getConnectURL(); + getAppVersion().then((version) => { + appVersion.current = version; + }); } //previously not loaded on regular refresh, this ensures it stays caught up @@ -374,7 +378,7 @@ const ProfileSettings = () => { - console.log("")} desc={getAppVersion()}> + console.log("")} desc={appVersion.current}> {/* menu for "nuke data" */} diff --git a/www/js/plugin/clientStats.ts b/www/js/plugin/clientStats.ts index 746672dc7..1e06208eb 100644 --- a/www/js/plugin/clientStats.ts +++ b/www/js/plugin/clientStats.ts @@ -21,30 +21,34 @@ export const statKeys = { let appVersion; export const getAppVersion = () => { - if (appVersion) return appVersion; - window['cordova']?.getAppVersion.getVersionNumber().then((version) => { + if (appVersion) return Promise.resolve(appVersion); + return window['cordova']?.getAppVersion.getVersionNumber().then((version) => { appVersion = version; + return version; }); } -export const getStatsEvent = (name: string, reading: any) => { +const getStatsEvent = async (name: string, reading: any) => { const ts = Date.now() / 1000; - const client_app_version = getAppVersion(); + const client_app_version = await getAppVersion(); const client_os_version = window['device'].version; return { name, ts, reading, client_app_version, client_os_version }; } -export const addStatReading = (name: string, reading: any) => { +export const addStatReading = async (name: string, reading: any) => { const db = window['cordova']?.plugins?.BEMUserCache; - if (db) return db.putMessage(CLIENT_TIME, getStatsEvent(name, reading)); + const event = await getStatsEvent(name, reading); + if (db) return db.putMessage(CLIENT_TIME, event); } -export const addStatEvent = (name: string) => { +export const addStatEvent = async (name: string) => { const db = window['cordova']?.plugins?.BEMUserCache; - if (db) return db.putMessage(CLIENT_NAV_EVENT, getStatsEvent(name, null)); + const event = await getStatsEvent(name, null); + if (db) return db.putMessage(CLIENT_NAV_EVENT, event); } -export const addStatError = (name: string, errorStr: string) => { +export const addStatError = async (name: string, errorStr: string) => { const db = window['cordova']?.plugins?.BEMUserCache; - if (db) return db.putMessage(CLIENT_ERROR, getStatsEvent(name, errorStr)); + const event = await getStatsEvent(name, errorStr); + if (db) return db.putMessage(CLIENT_ERROR, event); } From c7b06b7e4796bdf6ee6c5cd00bd7f2e2e28697d0 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 28 Sep 2023 14:58:30 -0400 Subject: [PATCH 028/850] add tests for clientStats.ts Tests the methods exported from clientStats.ts, including getAppVersion, addStatReading, addStatEvent, and addStatError. The tests record these stats and then query the usercache for the messages, expecting to see a new entry filled in with the fake data. putMessage and getAllMessages needed to be added to the mockBEMUserCache, since the client stats are recorded as 'messages' rather than 'key-value' pairs --- www/__mocks__/cordovaMocks.ts | 16 ++++++++++ www/__tests__/clientStats.test.ts | 52 +++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 www/__tests__/clientStats.test.ts diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index f7d0a6ec6..44c21677c 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -24,6 +24,7 @@ export const mockGetAppVersion = () => { export const mockBEMUserCache = () => { const _cache = {}; + const messages = []; const mockBEMUserCache = { getLocalStorage: (key: string, isSecure: boolean) => { return new Promise((rs, rj) => @@ -69,6 +70,21 @@ export const mockBEMUserCache = () => { rs(Object.keys(_cache)); }, 100) ); + }, + putMessage: (key: string, value: any) => { + return new Promise((rs, rj) => + setTimeout(() => { + messages.push({ key, value }); + rs(); + }, 100) + ); + }, + getAllMessages: (key: string, withMetadata?: boolean) => { + return new Promise((rs, rj) => + setTimeout(() => { + rs(messages.filter(m => m.key == key).map(m => m.value)); + }, 100) + ); } } window['cordova'] ||= {}; diff --git a/www/__tests__/clientStats.test.ts b/www/__tests__/clientStats.test.ts new file mode 100644 index 000000000..d1a054195 --- /dev/null +++ b/www/__tests__/clientStats.test.ts @@ -0,0 +1,52 @@ +import { mockBEMUserCache, mockDevice, mockGetAppVersion } from "../__mocks__/cordovaMocks"; +import { addStatError, addStatEvent, addStatReading, getAppVersion, statKeys } from "../js/plugin/clientStats"; + +mockDevice(); +// this mocks cordova-plugin-app-version, generating a "Mock App", version "1.2.3" +mockGetAppVersion(); +// clientStats.ts uses BEMUserCache to store the stats, so we need to mock that too +mockBEMUserCache(); +const db = window['cordova']?.plugins?.BEMUserCache; + +it('gets the app version', async () => { + const ver = await getAppVersion(); + expect(ver).toEqual('1.2.3'); +}); + +it('stores a client stats reading', async () => { + const reading = { a: 1, b: 2 }; + await addStatReading(statKeys.REMINDER_PREFS, reading); + const storedMessages = await db.getAllMessages('stats/client_time', false); + expect(storedMessages).toContainEqual({ + name: statKeys.REMINDER_PREFS, + ts: expect.any(Number), + reading, + client_app_version: '1.2.3', + client_os_version: '14.0.0' + }); +}); + +it('stores a client stats event', async () => { + await addStatEvent(statKeys.BUTTON_FORCE_SYNC); + const storedMessages = await db.getAllMessages('stats/client_nav_event', false); + expect(storedMessages).toContainEqual({ + name: statKeys.BUTTON_FORCE_SYNC, + ts: expect.any(Number), + reading: null, + client_app_version: '1.2.3', + client_os_version: '14.0.0' + }); +}); + +it('stores a client stats error', async () => { + const errorStr = 'test error'; + await addStatError(statKeys.MISSING_KEYS, errorStr); + const storedMessages = await db.getAllMessages('stats/client_error', false); + expect(storedMessages).toContainEqual({ + name: statKeys.MISSING_KEYS, + ts: expect.any(Number), + reading: errorStr, + client_app_version: '1.2.3', + client_os_version: '14.0.0' + }); +}); From c16bde5ed53e68616efc5d0e50b3ff7620685a30 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 28 Sep 2023 16:21:12 -0400 Subject: [PATCH 029/850] rewrite CommHelper service into commHelper.ts CommHelper from js/services.js is being rewritten into commHelper.ts. This commit moves over the functions from CommHelper that we need. So now the following functions are exported: - fetchUrlCached (was already in commHelper.ts) - getRawEntries - getPipelineRangeTs - getPipelineCompleteTs - getMetrics - getAggregateData - registerUser - updateUser - getUser The following functions were not moved over to the new commHelper.ts because they are not currently used in the codebase and I don't anticipate them being used in the foreseeable future: - putOne - getTimelineForDay - habiticaRegister - habiticaProxy - moment2Localdate - moment2Timestamp - getRawEntriesForLocalDate --- www/js/commHelper.ts | 124 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/www/js/commHelper.ts b/www/js/commHelper.ts index 074093999..d0042b6ab 100644 --- a/www/js/commHelper.ts +++ b/www/js/commHelper.ts @@ -17,3 +17,127 @@ export async function fetchUrlCached(url) { logDebug(`fetchUrlCached: fetched data for url ${url}, returning`); return text; } + +function processErrorMessages(errorMsg) { + if (errorMsg.includes("403")) { + errorMsg = "Error: OPcode does not exist on the server. " + errorMsg; + console.error("Error 403 found. " + errorMsg); + } + return errorMsg; +} + +export function getRawEntries(key_list, start_ts, end_ts, time_key = "metadata.write_ts", + max_entries = undefined, trunc_method = "sample") { + return new Promise((rs, rj) => { + const msgFiller = (message) => { + message.key_list = key_list; + message.start_time = start_ts; + message.end_time = end_ts; + message.key_time = time_key; + if (max_entries !== undefined) { + message.max_entries = max_entries; + message.trunc_method = trunc_method; + } + logDebug(`About to return message ${JSON.stringify(message)}`); + } + logDebug("getRawEntries: about to get pushGetJSON for the timestamp"); + window['cordova'].plugins.BEMServerComm.pushGetJSON("/datastreams/find_entries/timestamp", msgFiller, rs, rj); + }).catch(error => { + error = `While getting raw entries, ${error}`; + error = processErrorMessages(error); + throw(error); + }); +} + +export function getPipelineRangeTs() { + return new Promise((rs, rj) => { + logDebug("getting pipeline range timestamps"); + window['cordova'].plugins.BEMServerComm.getUserPersonalData("/pipeline/get_range_ts", rs, rj); + }).catch(error => { + error = `While getting pipeline range timestamps, ${error}`; + error = processErrorMessages(error); + throw(error); + }); +} + +export function getPipelineCompleteTs() { + return new Promise((rs, rj) => { + logDebug("getting pipeline complete timestamp"); + window['cordova'].plugins.BEMServerComm.getUserPersonalData("/pipeline/get_complete_ts", rs, rj); + }).catch(error => { + error = `While getting pipeline complete timestamp, ${error}`; + error = processErrorMessages(error); + throw(error); + }); +} + +export function getMetrics(timeType: 'timestamp'|'local_date', metricsQuery) { + return new Promise((rs, rj) => { + const msgFiller = (message) => { + for (let key in metricsQuery) { + message[key] = metricsQuery[key]; + } + } + window['cordova'].plugins.BEMServerComm.pushGetJSON(`/result/metrics/${timeType}`, msgFiller, rs, rj); + }).catch(error => { + error = `While getting metrics, ${error}`; + error = processErrorMessages(error); + throw(error); + }); +} + +export function getAggregateData(path: string, data: any) { + return new Promise((rs, rj) => { + const fullUrl = `${window['$rootScope'].connectUrl}/${path}`; + data["aggregate"] = true; + + if (window['$rootScope'].aggregateAuth === "no_auth") { + logDebug(`getting aggregate data without user authentication from ${fullUrl} with arguments ${JSON.stringify(data)}`); + const options = { + method: 'post', + data: data, + responseType: 'json' + } + window['cordova'].plugin.http.sendRequest(fullUrl, options, + (response) => { + rs(response.data); + }, (error) => { + rj(error); + }); + } else { + logDebug(`getting aggregate data with user authentication from ${fullUrl} with arguments ${JSON.stringify(data)}`); + const msgFiller = (message) => { + return Object.assign(message, data); + } + window['cordova'].plugins.BEMServerComm.pushGetJSON(`/${path}`, msgFiller, rs, rj); + } + }).catch(error => { + error = `While getting aggregate data, ${error}`; + error = processErrorMessages(error); + throw(error); + }); +} + +export function registerUser(successCallback, errorCallback) { + window['cordova'].plugins.BEMServerComm.getUserPersonalData("/profile/create", successCallback, errorCallback); +} + +export function updateUser(updateDoc) { + return new Promise((rs, rj) => { + window['cordova'].plugins.BEMServerComm.postUserPersonalData("/profile/update", "update_doc", updateDoc, rs, rj); + }).catch(error => { + error = `While updating user, ${error}`; + error = processErrorMessages(error); + throw(error); + }); +} + +export function getUser() { + return new Promise((rs, rj) => { + window['cordova'].plugins.BEMServerComm.getUserPersonalData("/profile/get", rs, rj); + }).catch(error => { + error = `While getting user, ${error}`; + error = processErrorMessages(error); + throw(error); + }); +} From 1dc7451cad497bd093c58f26f2a5b388984fd20f Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 28 Sep 2023 16:24:04 -0400 Subject: [PATCH 030/850] use commHelper.ts everywhere, not old CommHelper CommHelper from js/services.js was rewritten into js/commHelper.ts. This commit switches all functions calls to the new commHelper.ts. All of the functions are named the same and perform the same logic. --- www/js/control/ControlSyncHelper.tsx | 3 ++- www/js/diary/LabelTab.tsx | 3 ++- www/js/diary/services.js | 3 ++- www/js/metrics/MetricsTab.tsx | 5 +++-- www/js/onboarding/SaveQrPage.tsx | 3 ++- www/js/services.js | 7 ++++--- www/js/splash/notifScheduler.js | 5 +++-- www/js/splash/pushnotify.js | 3 ++- www/js/splash/storedevicesettings.js | 3 ++- 9 files changed, 22 insertions(+), 13 deletions(-) diff --git a/www/js/control/ControlSyncHelper.tsx b/www/js/control/ControlSyncHelper.tsx index 5acdf5b2d..84e1effba 100644 --- a/www/js/control/ControlSyncHelper.tsx +++ b/www/js/control/ControlSyncHelper.tsx @@ -9,6 +9,7 @@ import SettingRow from "./SettingRow"; import AlertBar from "./AlertBar"; import moment from "moment"; import { addStatEvent, statKeys } from "../plugin/clientStats"; +import { updateUser } from "../commHelper"; /* * BEGIN: Simple read/write wrappers @@ -214,7 +215,7 @@ const ControlSyncHelper = ({ editVis, setEditVis }) => { try{ let set = setConfig(localConfig); //NOTE -- we need to make sure we update these settings in ProfileSettings :) -- getting rid of broadcast handling for migration!! - CommHelper.updateUser({ + updateUser({ // TODO: worth thinking about where best to set this // Currently happens in native code. Now that we are switching // away from parse, we can store this from javascript here. diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 42b173017..f8c63da72 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -22,6 +22,7 @@ import { SurveyOptions } from "../survey/survey"; import { getLabelOptions } from "../survey/multilabel/confirmHelper"; import { displayError } from "../plugin/logger"; import { useTheme } from "react-native-paper"; +import { getPipelineRangeTs } from "../commHelper"; let labelPopulateFactory, labelsResultMap, notesResultMap, showPlaces; const ONE_DAY = 24 * 60 * 60; // seconds @@ -105,7 +106,7 @@ const LabelTab = () => { async function loadTimelineEntries() { try { - const pipelineRange = await CommHelper.getPipelineRangeTs(); + const pipelineRange = await getPipelineRangeTs(); [labelsResultMap, notesResultMap] = await getAllUnprocessedInputs(pipelineRange, labelPopulateFactory, enbs); Logger.log("After reading unprocessedInputs, labelsResultMap =" + JSON.stringify(labelsResultMap) + "; notesResultMap = " + JSON.stringify(notesResultMap)); diff --git a/www/js/diary/services.js b/www/js/diary/services.js index c9dfd1bbf..1e877107b 100644 --- a/www/js/diary/services.js +++ b/www/js/diary/services.js @@ -4,6 +4,7 @@ import angular from 'angular'; import { getBaseModeByKey, getBaseModeOfLabeledTrip } from './diaryHelper'; import { SurveyOptions } from '../survey/survey'; import { getConfig } from '../config/dynamicConfig'; +import { getRawEntries } from '../commHelper'; angular.module('emission.main.diary.services', ['emission.plugin.logger', 'emission.services']) @@ -41,7 +42,7 @@ angular.module('emission.main.diary.services', ['emission.plugin.logger', template: i18next.t('service.reading-server') }); const readPromises = [ - CommHelper.getRawEntries(["analysis/composite_trip"], + getRawEntries(["analysis/composite_trip"], startTs, endTs, "data.end_ts"), ]; return Promise.all(readPromises) diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 748db2b99..726d59800 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -16,6 +16,7 @@ import Carousel from "../components/Carousel"; import DailyActiveMinutesCard from "./DailyActiveMinutesCard"; import CarbonTextCard from "./CarbonTextCard"; import ActiveMinutesTableCard from "./ActiveMinutesTableCard"; +import { getAggregateData, getMetrics } from "../commHelper"; export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; @@ -29,8 +30,8 @@ async function fetchMetricsFromServer(type: 'user'|'aggregate', dateRange: DateT is_return_aggregate: (type == 'aggregate'), } if (type == 'user') - return CommHelper.getMetrics('timestamp', query); - return CommHelper.getAggregateData("result/metrics/timestamp", query); + return getMetrics('timestamp', query); + return getAggregateData("result/metrics/timestamp", query); } function getLastTwoWeeksDtRange() { diff --git a/www/js/onboarding/SaveQrPage.tsx b/www/js/onboarding/SaveQrPage.tsx index 57fe8f679..6d4678689 100644 --- a/www/js/onboarding/SaveQrPage.tsx +++ b/www/js/onboarding/SaveQrPage.tsx @@ -11,6 +11,7 @@ import QrCode, { shareQR } from "../components/QrCode"; import { onboardingStyles } from "./OnboardingStack"; import { preloadDemoSurveyResponse } from "./SurveyPage"; import { storageSet } from "../plugin/storage"; +import { registerUser } from "../commHelper"; const SaveQrPage = ({ }) => { @@ -37,7 +38,7 @@ const SaveQrPage = ({ }) => { const EXPECTED_METHOD = "prompted-auth"; const dbStorageObject = {"token": token}; return storageSet(EXPECTED_METHOD, dbStorageObject).then((r) => { - CommHelper.registerUser((successResult) => { + registerUser((successResult) => { refreshOnboardingState(); }, function(errorResult) { displayError(errorResult, "User registration error"); diff --git a/www/js/services.js b/www/js/services.js index 1955cd4bb..ba9b31784 100644 --- a/www/js/services.js +++ b/www/js/services.js @@ -1,6 +1,7 @@ 'use strict'; import angular from 'angular'; +import { getRawEntries } from './commHelper'; angular.module('emission.services', ['emission.plugin.logger']) @@ -345,7 +346,7 @@ angular.module('emission.services', ['emission.plugin.logger']) // Probably in www/json... this.getUnifiedSensorDataForInterval = function(key, tq) { var localPromise = $window.cordova.plugins.BEMUserCache.getSensorDataForInterval(key, tq, true); - var remotePromise = CommHelper.getRawEntries([key], tq.startTs, tq.endTs) + var remotePromise = getRawEntries([key], tq.startTs, tq.endTs) .then(function(serverResponse) { return serverResponse.phone_data; }); @@ -354,7 +355,7 @@ angular.module('emission.services', ['emission.plugin.logger']) this.getUnifiedMessagesForInterval = function(key, tq, withMetadata) { var localPromise = $window.cordova.plugins.BEMUserCache.getMessagesForInterval(key, tq, true); - var remotePromise = CommHelper.getRawEntries([key], tq.startTs, tq.endTs) + var remotePromise = getRawEntries([key], tq.startTs, tq.endTs) .then(function(serverResponse) { return serverResponse.phone_data; }); @@ -456,7 +457,7 @@ angular.module('emission.services', ['emission.plugin.logger']) }); }; - CommHelper.getRawEntries(null, startMoment.unix(), endMoment.unix()) + getRawEntries(null, startMoment.unix(), endMoment.unix()) .then(writeDumpFile) .then(emailData) .then(function() { diff --git a/www/js/splash/notifScheduler.js b/www/js/splash/notifScheduler.js index 760e797c7..530439df8 100644 --- a/www/js/splash/notifScheduler.js +++ b/www/js/splash/notifScheduler.js @@ -3,6 +3,7 @@ import angular from 'angular'; import { getConfig } from '../config/dynamicConfig'; import { addStatReading, statKeys } from '../plugin/clientStats'; +import { getUser, updateUser } from '../commHelper'; angular.module('emission.splash.notifscheduler', ['emission.services', @@ -219,7 +220,7 @@ angular.module('emission.splash.notifscheduler', */ scheduler.getReminderPrefs = async () => { - const user = await CommHelper.getUser(); + const user = await getUser(); if (user?.reminder_assignment && user?.reminder_join_date && user?.reminder_time_of_day) { @@ -232,7 +233,7 @@ angular.module('emission.splash.notifscheduler', } scheduler.setReminderPrefs = async (newPrefs) => { - await CommHelper.updateUser(newPrefs) + await updateUser(newPrefs) const updatePromise = new Promise((resolve, reject) => { //enforcing update before moving on update().then(() => { diff --git a/www/js/splash/pushnotify.js b/www/js/splash/pushnotify.js index 66c70f45c..f846e41ed 100644 --- a/www/js/splash/pushnotify.js +++ b/www/js/splash/pushnotify.js @@ -14,6 +14,7 @@ */ import angular from 'angular'; +import { updateUser } from '../commHelper'; angular.module('emission.splash.pushnotify', ['emission.plugin.logger', 'emission.services', @@ -86,7 +87,7 @@ angular.module('emission.splash.pushnotify', ['emission.plugin.logger', console.log("Got error "+error+" while reading config, returning default = 3600"); return 3600; }).then(function(sync_interval) { - CommHelper.updateUser({ + updateUser({ device_token: t.token, curr_platform: ionic.Platform.platform(), curr_sync_interval: sync_interval diff --git a/www/js/splash/storedevicesettings.js b/www/js/splash/storedevicesettings.js index aaaf82c6b..c9c89d5de 100644 --- a/www/js/splash/storedevicesettings.js +++ b/www/js/splash/storedevicesettings.js @@ -1,4 +1,5 @@ import angular from 'angular'; +import { updateUser } from '../commHelper'; angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', 'emission.services', @@ -21,7 +22,7 @@ angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', client_app_version: appver }; Logger.log("About to update profile with settings = "+JSON.stringify(updateJSON)); - return CommHelper.updateUser(updateJSON); + return updateUser(updateJSON); }).then(function(updateJSON) { // alert("Finished saving token = "+JSON.stringify(t.token)); }).catch(function(error) { From f78dab19e781fa1a42688193ba2ce868fc229291 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 28 Sep 2023 16:27:55 -0400 Subject: [PATCH 031/850] remove the old CommHelper service Rewritten into commHelper.ts, this Angular service is not needed anymore. We can remove the 'factory' declaration from js/services.js and remove it as a dependency from all of the Angular modules that previously used it. --- www/js/control/ControlSyncHelper.tsx | 1 - www/js/diary/LabelTab.tsx | 1 - www/js/diary/services.js | 2 +- www/js/metrics/MetricsTab.tsx | 1 - www/js/onboarding/SaveQrPage.tsx | 1 - www/js/services.js | 263 +-------------------------- www/js/splash/notifScheduler.js | 3 +- www/js/splash/pushnotify.js | 2 +- www/js/splash/remotenotify.js | 3 +- www/js/splash/storedevicesettings.js | 2 +- 10 files changed, 6 insertions(+), 273 deletions(-) diff --git a/www/js/control/ControlSyncHelper.tsx b/www/js/control/ControlSyncHelper.tsx index 84e1effba..edc0e7470 100644 --- a/www/js/control/ControlSyncHelper.tsx +++ b/www/js/control/ControlSyncHelper.tsx @@ -182,7 +182,6 @@ export const ForceSyncRow = ({getState}) => { const ControlSyncHelper = ({ editVis, setEditVis }) => { const { t } = useTranslation(); const { colors } = useTheme(); - const CommHelper = getAngularService("CommHelper"); const Logger = getAngularService("Logger"); const [ localConfig, setLocalConfig ] = useState(); diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index f8c63da72..bb2430481 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -49,7 +49,6 @@ const LabelTab = () => { const $ionicPopup = getAngularService('$ionicPopup'); const Logger = getAngularService('Logger'); const Timeline = getAngularService('Timeline'); - const CommHelper = getAngularService('CommHelper'); const enbs = getAngularService('EnketoNotesButtonService'); // initialization, once the appConfig is loaded diff --git a/www/js/diary/services.js b/www/js/diary/services.js index 1e877107b..774273fa2 100644 --- a/www/js/diary/services.js +++ b/www/js/diary/services.js @@ -8,7 +8,7 @@ import { getRawEntries } from '../commHelper'; angular.module('emission.main.diary.services', ['emission.plugin.logger', 'emission.services']) -.factory('Timeline', function(CommHelper, $http, $ionicLoading, $ionicPlatform, $window, +.factory('Timeline', function($http, $ionicLoading, $ionicPlatform, $window, $rootScope, UnifiedDataLoader, Logger, $injector) { var timeline = {}; // corresponds to the old $scope.data. Contains all state for the current diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 726d59800..450155622 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -21,7 +21,6 @@ import { getAggregateData, getMetrics } from "../commHelper"; export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; async function fetchMetricsFromServer(type: 'user'|'aggregate', dateRange: DateTime[]) { - const CommHelper = getAngularService('CommHelper'); const query = { freq: 'D', start_time: dateRange[0].toSeconds(), diff --git a/www/js/onboarding/SaveQrPage.tsx b/www/js/onboarding/SaveQrPage.tsx index 6d4678689..d8b555f14 100644 --- a/www/js/onboarding/SaveQrPage.tsx +++ b/www/js/onboarding/SaveQrPage.tsx @@ -34,7 +34,6 @@ const SaveQrPage = ({ }) => { }, [overallStatus]); function login(token) { - const CommHelper = getAngularService('CommHelper'); const EXPECTED_METHOD = "prompted-auth"; const dbStorageObject = {"token": token}; return storageSet(EXPECTED_METHOD, dbStorageObject).then((r) => { diff --git a/www/js/services.js b/www/js/services.js index ba9b31784..0c9c6e2ac 100644 --- a/www/js/services.js +++ b/www/js/services.js @@ -5,266 +5,6 @@ import { getRawEntries } from './commHelper'; angular.module('emission.services', ['emission.plugin.logger']) -.service('CommHelper', function($rootScope) { - var getConnectURL = function(successCallback, errorCallback) { - window.cordova.plugins.BEMConnectionSettings.getSettings( - function(settings) { - successCallback(settings.connectUrl); - }, errorCallback); - }; - - var processErrorMessages = function(errorMsg) { - if (errorMsg.includes("403")) { - errorMsg = "Error: OPcode does not exist on the server. " + errorMsg; - console.error("Error 403 found. " + errorMsg); - } - return errorMsg; - } - - this.registerUser = function(successCallback, errorCallback) { - window.cordova.plugins.BEMServerComm.getUserPersonalData("/profile/create", successCallback, errorCallback); - }; - - this.updateUser = function(updateDoc) { - return new Promise(function(resolve, reject) { - window.cordova.plugins.BEMServerComm.postUserPersonalData("/profile/update", "update_doc", updateDoc, resolve, reject); - }) - .catch(error => { - error = "While updating user, " + error; - error = processErrorMessages(error); - throw(error); - }); - }; - - this.getUser = function() { - return new Promise(function(resolve, reject) { - window.cordova.plugins.BEMServerComm.getUserPersonalData("/profile/get", resolve, reject); - }) - .catch(error => { - error = "While getting user, " + error; - error = processErrorMessages(error); - throw(error); - }); - }; - - this.putOne = function(key, data) { - var now = moment().unix(); - var md = { - "write_ts": now, - "read_ts": now, - "time_zone": moment.tz.guess(), - "type": "message", - "key": key, - "platform": ionic.Platform.platform() - }; - var entryToPut = { - "metadata": md, - "data": data - } - return new Promise(function(resolve, reject) { - window.cordova.plugins.BEMServerComm.postUserPersonalData("/usercache/putone", "the_entry", entryToPut, resolve, reject); - }) - .catch(error => { - error = "While putting one entry, " + error; - error = processErrorMessages(error); - throw(error); - }); - }; - - this.getTimelineForDay = function(date) { - return new Promise(function(resolve, reject) { - var dateString = date.startOf('day').format('YYYY-MM-DD'); - window.cordova.plugins.BEMServerComm.getUserPersonalData("/timeline/getTrips/"+dateString, resolve, reject); - }) - .catch(error => { - error = "While getting timeline for day, " + error; - error = processErrorMessages(error); - throw(error); - }); - }; - - /* - * var regConfig = {'username': ....} - * Other fields can be added easily and the server can be modified at the same time. - */ - this.habiticaRegister = function(regConfig) { - return new Promise(function(resolve, reject){ - window.cordova.plugins.BEMServerComm.postUserPersonalData("/habiticaRegister", "regConfig", regConfig, resolve, reject); - }); - }; - - /* - * Example usage: - * Get profile: - * callOpts = {'method': 'GET', 'method_url': "/api/v3/user", - 'method_args': null} - * Go to sleep: - * callOpts = {'method': 'POST', 'method_url': "/api/v3/user/sleep", - 'method_args': {'data': True}} - * Stop sleeping: - * callOpts = {'method': 'POST', 'method_url': "/api/v3/user/sleep", - 'method_args': {'data': False}} - * Get challenges for a user: - * callOpts = {'method': 'GET', 'method_url': "/api/v3/challenges/user", - 'method_args': null} - * .... - */ - - this.habiticaProxy = function(callOpts){ - return new Promise(function(resolve, reject){ - window.cordova.plugins.BEMServerComm.postUserPersonalData("/habiticaProxy", "callOpts", callOpts, resolve, reject); - }) - .catch(error => { - error = "While habitica proxy, " + error; - error = processErrorMessages(error); - throw(error); - }); - }; - - this.getMetrics = function(timeType, metrics_query) { - return new Promise(function(resolve, reject) { - var msgFiller = function(message) { - for (var key in metrics_query) { - message[key] = metrics_query[key] - }; - }; - window.cordova.plugins.BEMServerComm.pushGetJSON("/result/metrics/"+timeType, msgFiller, resolve, reject); - }) - .catch(error => { - error = "While getting metrics, " + error; - error = processErrorMessages(error); - throw(error); - }); - }; - - /* - * key_list = list of keys to retrieve or None for all keys - * start_time = beginning timestamp for range - * end_time = ending timestamp for rangeA - */ - this.moment2Localdate = function(momentObj) { - return { - year: momentObj.year(), - month: momentObj.month() + 1, - day: momentObj.date(), - }; - }; - - this.moment2Timestamp = function(momentObj) { - return momentObj.unix(); - } - - // time_key is typically metadata.write_ts or data.ts - this.getRawEntriesForLocalDate = function(key_list, start_ts, end_ts, - time_key = "metadata.write_ts", max_entries = undefined, trunc_method = "sample") { - return new Promise(function(resolve, reject) { - var msgFiller = function(message) { - message.key_list = key_list; - message.from_local_date = moment2Localdate(moment.unix(start_ts)); - message.to_local_date = moment2Localdate(moment.unix(end_ts)); - message.key_local_date = time_key; - if (max_entries !== undefined) { - message.max_entries = max_entries; - message.trunc_method = trunc_method; - } - console.log("About to return message "+JSON.stringify(message)); - }; - console.log("getRawEntries: about to get pushGetJSON for the timestamp"); - window.cordova.plugins.BEMServerComm.pushGetJSON("/datastreams/find_entries/local_date", msgFiller, resolve, reject); - }) - .catch(error => { - error = "While getting raw entries for local date, " + error; - error = processErrorMessages(error); - throw(error); - }); - }; - - this.getRawEntries = function(key_list, start_ts, end_ts, - time_key = "metadata.write_ts", max_entries = undefined, trunc_method = "sample") { - return new Promise(function(resolve, reject) { - var msgFiller = function(message) { - message.key_list = key_list; - message.start_time = start_ts; - message.end_time = end_ts; - message.key_time = time_key; - if (max_entries !== undefined) { - message.max_entries = max_entries; - message.trunc_method = trunc_method; - } - console.log("About to return message "+JSON.stringify(message)); - }; - console.log("getRawEntries: about to get pushGetJSON for the timestamp"); - window.cordova.plugins.BEMServerComm.pushGetJSON("/datastreams/find_entries/timestamp", msgFiller, resolve, reject); - }) - .catch(error => { - error = "While getting raw entries, " + error; - error = processErrorMessages(error); - throw(error); - }); - }; - - this.getPipelineCompleteTs = function() { - return new Promise(function(resolve, reject) { - console.log("getting pipeline complete timestamp"); - window.cordova.plugins.BEMServerComm.getUserPersonalData("/pipeline/get_complete_ts", resolve, reject); - }) - .catch(error => { - error = "While getting pipeline complete timestamp, " + error; - error = processErrorMessages(error); - throw(error); - }); - }; - - this.getPipelineRangeTs = function() { - return new Promise(function(resolve, reject) { - console.log("getting pipeline range timestamps"); - window.cordova.plugins.BEMServerComm.getUserPersonalData("/pipeline/get_range_ts", resolve, reject); - }) - .catch(error => { - error = "While getting pipeline range timestamps, " + error; - error = processErrorMessages(error); - throw(error); - }); - }; - - - // host is automatically read from $rootScope.connectUrl, which is set in app.js - this.getAggregateData = function(path, data) { - return new Promise(function(resolve, reject) { - const full_url = $rootScope.connectUrl+"/"+path; - data["aggregate"] = true - - if ($rootScope.aggregateAuth === "no_auth") { - console.log("getting aggregate data without user authentication from " - + full_url +" with arguments "+JSON.stringify(data)); - const options = { - method: 'post', - data: data, - responseType: 'json' - } - cordova.plugin.http.sendRequest(full_url, options, - function(response) { - resolve(response.data); - }, function(error) { - reject(error); - }); - } else { - console.log("getting aggregate data with user authentication from " - + full_url +" with arguments "+JSON.stringify(data)); - var msgFiller = function(message) { - return Object.assign(message, data); - }; - window.cordova.plugins.BEMServerComm.pushGetJSON("/"+path, msgFiller, resolve, reject); - } - }) - .catch(error => { - error = "While getting aggregate data, " + error; - error = processErrorMessages(error); - throw(error); - }); - }; -}) - .service('ReferHelper', function($http) { this.habiticaRegister = function(groupid, successCallback, errorCallback) { @@ -282,7 +22,7 @@ angular.module('emission.services', ['emission.plugin.logger']) //}*/ } }) -.service('UnifiedDataLoader', function($window, CommHelper, Logger) { +.service('UnifiedDataLoader', function($window, Logger) { var combineWithDedup = function(list1, list2) { var combinedList = list1.concat(list2); return combinedList.filter(function(value, i, array) { @@ -364,7 +104,6 @@ angular.module('emission.services', ['emission.plugin.logger']) }) .service('ControlHelper', function($window, $ionicPopup, - CommHelper, Logger) { this.writeFile = function(fileEntry, resultList) { diff --git a/www/js/splash/notifScheduler.js b/www/js/splash/notifScheduler.js index 530439df8..22f8407ee 100644 --- a/www/js/splash/notifScheduler.js +++ b/www/js/splash/notifScheduler.js @@ -9,8 +9,7 @@ angular.module('emission.splash.notifscheduler', ['emission.services', 'emission.plugin.logger']) -.factory('NotificationScheduler', function($http, $window, $ionicPlatform, - CommHelper, Logger) { +.factory('NotificationScheduler', function($http, $window, $ionicPlatform, Logger) { const scheduler = {}; let _config; diff --git a/www/js/splash/pushnotify.js b/www/js/splash/pushnotify.js index f846e41ed..40d859f09 100644 --- a/www/js/splash/pushnotify.js +++ b/www/js/splash/pushnotify.js @@ -20,7 +20,7 @@ angular.module('emission.splash.pushnotify', ['emission.plugin.logger', 'emission.services', 'emission.splash.startprefs']) .factory('PushNotify', function($window, $state, $rootScope, $ionicPlatform, - $ionicPopup, Logger, CommHelper, StartPrefs) { + $ionicPopup, Logger, StartPrefs) { var pushnotify = {}; var push = null; diff --git a/www/js/splash/remotenotify.js b/www/js/splash/remotenotify.js index c11783a2b..3e43b6f9f 100644 --- a/www/js/splash/remotenotify.js +++ b/www/js/splash/remotenotify.js @@ -18,8 +18,7 @@ import { addStatEvent, statKeys } from '../plugin/clientStats'; angular.module('emission.splash.remotenotify', ['emission.plugin.logger', 'emission.splash.startprefs']) -.factory('RemoteNotify', function($http, $window, $ionicPopup, $rootScope, - CommHelper, Logger) { +.factory('RemoteNotify', function($http, $window, $ionicPopup, $rootScope, Logger) { var remoteNotify = {}; remoteNotify.options = "location=yes,clearcache=no,toolbar=yes,hideurlbar=yes"; diff --git a/www/js/splash/storedevicesettings.js b/www/js/splash/storedevicesettings.js index c9c89d5de..d307feaa7 100644 --- a/www/js/splash/storedevicesettings.js +++ b/www/js/splash/storedevicesettings.js @@ -5,7 +5,7 @@ angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', 'emission.services', 'emission.splash.startprefs']) .factory('StoreDeviceSettings', function($window, $state, $rootScope, $ionicPlatform, - $ionicPopup, Logger, CommHelper, StartPrefs) { + $ionicPopup, Logger, StartPrefs) { var storedevicesettings = {}; From 7f6b73704748e806c9ed79c9f3d3c18dc8057c50 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 28 Sep 2023 14:23:20 -0700 Subject: [PATCH 032/850] Recentered & Resized "inferred trip" checkmark See [Issue #979](https://github.com/e-mission/e-mission-docs/issues/979#issuecomment-1736386591) for more details. Only slight difference in comment; removed the `-0.2rem` in this commit, the buttons look crowded otherwise. --- www/js/survey/multilabel/MultiLabelButtonGroup.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index cb14c61ea..268d16eb7 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -100,10 +100,10 @@ const MultilabelButtonGroup = ({ trip, buttonsInline=false }) => { ) })} - - + + style={{width: 24, height: 24, margin: 3}}/> dismiss()}> From 92ce70760b3eed6b1c05b4fea9a82f5a1b361649 Mon Sep 17 00:00:00 2001 From: niccolopaganini Date: Fri, 29 Sep 2023 09:54:52 -0600 Subject: [PATCH 033/850] 1. reformatted to the original structure 2. removed floating links --- README.md | 56 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 0b19e492e..ca3e9c93b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ __This is the phone component of the e-mission system.__ -:sparkles: This has now been upgraded to cordova android@12.0.0 and iOS@6.2.0. It has also been upgraded to [android API 33 and the latest iOS versions](https://github.com/e-mission/e-mission-docs/issues/934), [cordova-lib@10.0.0 and the most recent node and npm versions](). It also now supports CI, so we should not have any build issues in the future. __This should be ready to build out of the box.__ +:sparkles: This has now been upgraded to cordova android@12.0.0 and iOS@6.2.0. It has also been upgraded to the **latest Android & iOS versions**, **cordova-lib@10.0.0 and the most recent node and npm versions**. It also now supports CI, so we should not have any build issues in the future. __This should be ready to build out of the box.__ + +For the latest versions, refer [`package.cordovabuild.json`](https://github.com/e-mission/e-mission-phone/blob/fce117ff859abd995613bd405dbc7d27c703b09b/package.cordovabuild.json) ## Additional Documentation Additional documentation has been moved to its own repository [e-mission-docs](https://github.com/e-mission/e-mission-docs). Specific e-mission-phone wikis can be found here: @@ -22,14 +24,6 @@ https://github.com/e-mission/e-mission-docs/tree/master/docs/e-mission-phone --- -## Creating logos - -If you are building your own version of the app, you must have your own logo to -avoid app store conficts. Updating the logo is very simple using the [`ionic -cordova resources`](https://ionicframework.com/docs/v3/cli/cordova/resources/) -command. - -**Note**: You may have to install the [`cordova-res` package](https://github.com/ionic-team/cordova-res) for the command to work ## Updating the UI only [![osx-serve-install](https://github.com/e-mission/e-mission-phone/workflows/osx-serve-install/badge.svg)](https://github.com/e-mission/e-mission-phone/actions?query=workflow%3Aosx-serve-install) @@ -84,6 +78,21 @@ source setup/activate_serve.sh **Note1**: You may need to scroll up, past all the warnings about `Content Security Policy has been added` to find the port that the server is listening to. +## End to End Testing + +A lot of the visualizations that we display in the phone client come from the server. In order to do end to end testing, we need to run a local server and connect to it. Instructions for: + +1. installing a local server, +2. running it, +3. loading it with test data, and +4. running analysis on it + +are available in the [e-mission-server README](https://github.com/e-mission/e-mission-server/blob/master/README.md). + +In order to make end to end testing easy, if the local server is started on a HTTP (versus HTTPS port), it is in development mode. By default, the phone app connects to the local server (localhost on iOS, [10.0.2.2 on android](https://stackoverflow.com/questions/5806220/how-to-connect-to-my-http-localhost-web-server-from-android-emulator-in-eclips)) with the `prompted-auth` authentication method. To connect to a different server, or to use a different authentication method, you need to create a `www/json/connectionConfig.json` file. More details on configuring authentication [can be found in the docs](https://github.com/e-mission/e-mission-docs/blob/master/docs/install/configuring_authentication.md). + +One advantage of using `skip` authentication in development mode is that any user email can be entered without a password. Developers can use one of the emails that they loaded test data for in step (3) above. So if the test data loaded was with `-u shankari@eecs.berkeley.edu`, then the login email for the phone app would also be `shankari@eecs.berkeley.edu`. + ## Updating the e-mission-\* plugins or adding new plugins [![osx-build-ios](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml) @@ -109,7 +118,7 @@ Pre-requisites - **NOTE**: the basic xcode install on Catalina was messed up for me due to a prior installation of command line tools. [These workarounds helped](https://github.com/nodejs/node-gyp/blob/master/macOS_Catalina.md). - git - Java 17. Tested with [OpenJDK 17 (Temurin) using AdoptOpenJDK](https://adoptium.net). -- if you are not on the most recent version of OSX: `homebrew` +- Always use [homebrew](https://brew.sh) in addition to CLI - this allows us to install the current version of cocoapods without running into ruby incompatibilities - e.g. https://github.com/CocoaPods/CocoaPods/issues/11763 @@ -234,21 +243,16 @@ Built the following apk(s): +
-## End to End Testing - -A lot of the visualizations that we display in the phone client come from the server. In order to do end to end testing, we need to run a local server and connect to it. Instructions for: - -1. installing a local server, -2. running it, -3. loading it with test data, and -4. running analysis on it - -are available in the [e-mission-server README](https://github.com/e-mission/e-mission-server/blob/master/README.md). +## Creating logos -In order to make end to end testing easy, if the local server is started on a HTTP (versus HTTPS port), it is in development mode. By default, the phone app connects to the local server (localhost on iOS, [10.0.2.2 on android](https://stackoverflow.com/questions/5806220/how-to-connect-to-my-http-localhost-web-server-from-android-emulator-in-eclips)) with the `prompted-auth` authentication method. To connect to a different server, or to use a different authentication method, you need to create a `www/json/connectionConfig.json` file. More details on configuring authentication [can be found in the docs](https://github.com/e-mission/e-mission-docs/blob/master/docs/install/configuring_authentication.md). +If you are building your own version of the app, you must have your own logo to +avoid app store conficts. Updating the logo is very simple using the [`ionic +cordova resources`](https://ionicframework.com/docs/v3/cli/cordova/resources/) +command. -One advantage of using `skip` authentication in development mode is that any user email can be entered without a password. Developers can use one of the emails that they loaded test data for in step (3) above. So if the test data loaded was with `-u shankari@eecs.berkeley.edu`, then the login email for the phone app would also be `shankari@eecs.berkeley.edu`. +**Note**: You may have to install the [`cordova-res` package](https://github.com/ionic-team/cordova-res) for the command to work ## Beta-testing debugging If users run into problems, they have the ability to email logs to the @@ -288,7 +292,7 @@ less /tmp/loggerDB..withdate.log __\*__Address my review comments__\*__ -Once I merge the pull request, pull the changes to your fork and delete the branch +Once I merge the pull request :smiley: :tada:, pull the changes to your fork and delete the branch ``` git checkout master ``` @@ -304,7 +308,7 @@ git branch -d --- ### Troubleshooting -1. Xcode command line tools +__1. Xcode command line tools__ ``` Warning: No developer tools installed. You should install the Command Line Tools. @@ -313,7 +317,7 @@ You should install the Command Line Tools. xcode-select --install ``` -2. Creating Logos +__2. Creating Logos__ - Make sure to use `npx ionic` and `npx cordova`. This is because the setup script installs all the modules locally in a self-contained environment using `npm install` and not `npm install -g` @@ -323,3 +327,5 @@ xcode-select --install - Another workaround is to delete the local environment and recreate it - javascript errors: `rm -rf node_modules && npm install` - native code compile errors: `rm -rf plugins && rm -rf platforms && npx cordova prepare` + +3. From 3d5e99bb974a641f144f1cbfedf9d7ac7247ea71 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Fri, 29 Sep 2023 10:02:33 -0600 Subject: [PATCH 034/850] Update README.md 1. Removed instructions on JAVA_HOME as it is no longer required 2. Quality of life changes --- README.md | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index ca3e9c93b..34184ab09 100644 --- a/README.md +++ b/README.md @@ -132,19 +132,6 @@ export ANDROID_HOME="/Users//Library/Android/sdk" ``` aka the path where you want the SDK to be installed. -To setup JAVA_HOME (after installing the latest JDK ), run this command: -``` -/usr/libexec/java_home -``` -Find the location of the Java installation (Default will look something like this:) -``` -/Library/Java/JavaVirtualMachines/... -``` -and then export the package as: -``` -export JAVA_HOME="" -``` - - android SDK; install manually or use setup script below (**recommended**). Note that you only need to run this once **per computer**. ``` bash setup/prereq_android_sdk_install.sh @@ -178,7 +165,9 @@ __2. Installing (one time only)__ ``` bash setup/setup_android_native.sh +``` AND/OR +``` bash setup/setup_ios_native.sh ``` From 77fe30a90735db7477df05b7e5009c98a913b319 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 29 Sep 2023 13:13:20 -0400 Subject: [PATCH 035/850] add commHelper.test.ts -Added one test for `fetchUrlCached` - uses a mocked 'fetch' to simulate retreieving data from a URL. It ensures that the second time this URL is queried, the data comes back faster (because it gets cached in the localStorage after the first call) Note at the bottom explains why other functions were not tested. --- www/__tests__/commHelper.test.ts | 41 ++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 www/__tests__/commHelper.test.ts diff --git a/www/__tests__/commHelper.test.ts b/www/__tests__/commHelper.test.ts new file mode 100644 index 000000000..6143381e3 --- /dev/null +++ b/www/__tests__/commHelper.test.ts @@ -0,0 +1,41 @@ +import { mockLogger } from '../__mocks__/globalMocks'; +import { fetchUrlCached } from '../js/commHelper'; + +mockLogger(); + +// mock for JavaScript 'fetch' +// we emulate a 100ms delay when i) fetching data and ii) parsing it as text +global.fetch = (url: string) => new Promise((rs, rj) => { + setTimeout(() => rs({ + text: () => new Promise((rs, rj) => { + setTimeout(() => rs('mock data for ' + url), 100); + }) + })); +}) as any; + +it('fetches text from a URL and caches it so the next call is faster', async () => { + const tsBeforeCalls = Date.now(); + const text1 = await fetchUrlCached('https://raw.githubusercontent.com/e-mission/e-mission-phone/master/README.md'); + const tsBetweenCalls = Date.now(); + const text2 = await fetchUrlCached('https://raw.githubusercontent.com/e-mission/e-mission-phone/master/README.md'); + const tsAfterCalls = Date.now(); + expect(text1).toEqual(expect.stringContaining('mock data')); + expect(text2).toEqual(expect.stringContaining('mock data')); + expect(tsAfterCalls - tsBetweenCalls).toBeLessThan(tsBetweenCalls - tsBeforeCalls); +}); + +/* The following functions from commHelper.ts are not tested because they are just wrappers + around the native functions in BEMServerComm. + If we wanted to test them, we would need to mock the native functions in BEMServerComm, but + this would be of limited value. It would be better to test the native functions directly. + + * - getRawEntries + * - getPipelineRangeTs + * - getPipelineCompleteTs + * - getMetrics + * - getAggregateData + * - registerUser + * - updateUser + * - getUser + +*/ From 9606f57c4a5aee68c57f0a3fdb55eaa1937c9260 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 29 Sep 2023 11:20:27 -0600 Subject: [PATCH 036/850] change the name in index.js could not find file "uploadService.js", needed to carry over the name change to "nguploadService.js" --- www/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/index.js b/www/index.js index 66a0d45df..371d70ed5 100644 --- a/www/index.js +++ b/www/index.js @@ -43,7 +43,7 @@ import './js/survey/enketo/enketo-add-note-button.js'; import './js/metrics.js'; import './js/control/general-settings.js'; import './js/control/emailService.js'; -import './js/control/uploadService.js'; +import './js/control/nguploadService.js'; import './js/control/collect-settings.js'; import './js/control/sync-settings.js'; import './js/metrics-factory.js'; From eaf4be5dfa45925c638ac9dfdcb23f7a4e2b8e53 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 29 Sep 2023 11:27:07 -0600 Subject: [PATCH 037/850] adapt promise handling to async/await using async await is a clearer way of handling the asynchronous processes in this file, so it is easier to figure out what is happening and properly set up the typescript version of this service. --- www/js/control/uploadService.ts | 172 +++++++++++++------------------- 1 file changed, 68 insertions(+), 104 deletions(-) diff --git a/www/js/control/uploadService.ts b/www/js/control/uploadService.ts index e359bed88..0a7b5c96b 100644 --- a/www/js/control/uploadService.ts +++ b/www/js/control/uploadService.ts @@ -4,25 +4,27 @@ import { useTranslation } from "react-i18next"; /** * @returns A promise that resolves with an upload URL or rejects with an error */ -function getUploadConfig() { - return new Promise(function (resolve, reject) { - //logInfo( "About to get email config"); +async function getUploadConfig() { + return new Promise(async function (resolve, reject) { + logInfo( "About to get email config"); let url = []; - fetch("json/uploadConfig.json").then( function (uploadConfig) { - //logDebug("uploadConfigString = " + JSON.stringify(uploadConfig['data'])); + try { + let uploadConfig = await fetch("json/uploadConfig.json"); + logDebug("uploadConfigString = " + JSON.stringify(uploadConfig['data'])); url.push(uploadConfig["data"].url); resolve(url); - }).catch(function (err) { - fetch("json/uploadConfig.json.sample"). then(function (uploadConfig) { - //logDebug("default uploadConfigString = " + JSON.stringify(uploadConfig['data'])); + } catch (err) { + try{ + let uploadConfig = await fetch("json/uploadConfig.json.sample"); + logDebug("default uploadConfigString = " + JSON.stringify(uploadConfig['data'])); console.log("default uploadConfigString = " + JSON.stringify(uploadConfig['data'])) url.push(uploadConfig["data"].url); resolve(url); - }).catch(function (err) { - //logError("Error while reading default upload config" + err); + } catch (err) { + logError("Error while reading default upload config" + err); reject(err); - }) - }) + } + } }) } @@ -80,101 +82,63 @@ const sendToServer = function upload(url, binArray, params) { transformRequest: identity, params: params }; - return fetch (url, config); + return fetch(url, config); } //only export of this file, used in ProfileSettings and passed the argument (""loggerDB"") -export function uploadFile(database) { +export async function uploadFile(database, reason) { const { t } = useTranslation(); - getUploadConfig().then((uploadConfig) => { - var parentDir = "unknown"; - - if (window['cordova'].platformId.toLowerCase() == "android") { - parentDir = window['cordova'].file.applicationStorageDirectory+"/databases"; - } - else if (window['cordova'].platformId.toLowerCase() == "ios") { - parentDir = window['cordova'].file.dataDirectory + "../LocalDatabase"; - } else { - alert("parentDir unexpectedly = " + parentDir + "!") - } - - const newScope = {}; - newScope["data"] = {}; - newScope["fromDirText"] = t('upload-service.upload-from-dir', {parentDir: parentDir}); - newScope["toServerText"] = t('upload-service.upload-to-server', {serverURL: uploadConfig}); - - let didCancel = true; - let detailsPopup = () => console.log("I need a popup"); - - // const detailsPopup = $ionicPopup.show({ - // title: i18next.t("upload-service.upload-database", { db: database }), - // template: newScope.toServerText - // + '', - // scope: newScope, - // buttons: [ - // { - // text: 'Cancel', - // onTap: function(e) { - // didCancel = true; - // detailsPopup.close(); - // } - // }, - // { - // text: 'Upload', - // type: 'button-positive', - // onTap: function(e) { - // if (!newScope.data.reason) { - // //don't allow the user to close unless he enters wifi password - // didCancel = false; - // e.preventDefault(); - // } else { - // didCancel = false; - // return newScope.data.reason; - // } - // } - // } - // ] - // }); - - logInfo("Going to upload " + database); - const readFileAndInfo = [readDBFile(parentDir, database, detailsPopup)]; - Promise.all(readFileAndInfo).then(([binString, reason]) => { - if(!didCancel) - { - console.log("Uploading file of size "+binString['byteLength']); - const progressScope = {...newScope}; //make a child copy of the current scope - const params = { - reason: reason, - tz: Intl.DateTimeFormat().resolvedOptions().timeZone - } - uploadConfig.forEach((url) => { - alert(t("upload-service.upload-database", {db: database}) - + "\n" - + t("upload-service.upload-progress", {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: uploadConfig}) - ); - // const progressPopup = $ionicPopup.show({ - // title: t("upload-service.upload-database", - // {db: database}), - // template: t("upload-service.upload-progress", - // {filesizemb: binString['byteLength'] / (1000 * 1000), - // serverURL: uploadConfig}) - // + '
', - // scope: progressScope, - // buttons: [ - // { text: 'Cancel', type: 'button-cancel', }, - // ] - // }); - sendToServer(url, binString, params).then((response) => { - console.log(response); - //progressPopup.close(); - displayErrorMsg(t("upload-service.upload-details", - {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: uploadConfig}), - t("upload-service.upload-success")); - }).catch(onUploadError); - }); + try { + let uploadConfig = await getUploadConfig(); + var parentDir = "unknown"; + + if (window['cordova'].platformId.toLowerCase() == "android") { + parentDir = window['cordova'].file.applicationStorageDirectory+"/databases"; + } + else if (window['cordova'].platformId.toLowerCase() == "ios") { + parentDir = window['cordova'].file.dataDirectory + "../LocalDatabase"; + } else { + alert("parentDir unexpectedly = " + parentDir + "!") + } + + const newScope = {}; + newScope["data"] = {}; + newScope["fromDirText"] = t('upload-service.upload-from-dir', {parentDir: parentDir}); + newScope["toServerText"] = t('upload-service.upload-to-server', {serverURL: uploadConfig}); + + logInfo("Going to upload " + database); + try { + let binString = await readDBFile(parentDir, database, undefined); + console.log("Uploading file of size "+binString['byteLength']); + const progressScope = {...newScope}; //make a child copy of the current scope + const params = { + reason: reason, + tz: Intl.DateTimeFormat().resolvedOptions().timeZone + } + uploadConfig.forEach(async (url) => { + alert(t("upload-service.upload-database", {db: database}) + + "\n" + + t("upload-service.upload-progress", {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: uploadConfig}) + ); + + window.alert(t("upload-service.upload-database", {db: database})); + + try { + let response = await sendToServer(url, binString, params); + console.log(response); + window.alert(t("upload-service.upload-details", + {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: uploadConfig}) + + t("upload-service.upload-success")); + } catch (error) { + onUploadError(error); + } + }); + } + catch (error){ + onReadError(error); } - }).catch(onReadError); - }).catch(onReadError); - }; \ No newline at end of file + } catch (error) { + onReadError(error); + } +}; \ No newline at end of file From a07e7f5269ad08336c76c7f03c01341860798ca8 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Fri, 29 Sep 2023 14:26:53 -0700 Subject: [PATCH 038/850] add test with valid url and invalid url --- www/__tests__/customURL.test.ts | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 www/__tests__/customURL.test.ts diff --git a/www/__tests__/customURL.test.ts b/www/__tests__/customURL.test.ts new file mode 100644 index 000000000..68ce3c47d --- /dev/null +++ b/www/__tests__/customURL.test.ts @@ -0,0 +1,38 @@ +import { onLaunchCustomURL } from '../js/splash/customURL'; + +describe('onLaunchCustomURL', () => { + let mockHandler; + + beforeEach(() => { + // create a new mock handler before each test case. + mockHandler = jest.fn(); + }); + + it('tests valid url 1 - should call handler callback with valid URL and the handler should be called with correct parameters', () => { + const validURL = 'emission://login_token?token=nrelop_dev-emulator-program'; + const expectedURL = 'login_token?token=nrelop_dev-emulator-program'; + const expectedComponents = { route: 'login_token', token: 'nrelop_dev-emulator-program' }; + onLaunchCustomURL(validURL, mockHandler); + expect(mockHandler).toHaveBeenCalledWith(expectedURL, expectedComponents); + }); + + it('tests valid url 2 - should call handler callback with valid URL and the handler should be called with correct parameters', () => { + const validURL = 'emission://test?param1=first¶m2=second'; + const expectedURL = 'test?param1=first¶m2=second'; + const expectedComponents = { route: 'test', param1: 'first', param2: 'second' }; + onLaunchCustomURL(validURL, mockHandler); + expect(mockHandler).toHaveBeenCalledWith(expectedURL, expectedComponents); + }); + + it('test invalid url 1 - should not call handler callback with invalid URL', () => { + const invalidURL = 'invalid_url'; + onLaunchCustomURL(invalidURL, mockHandler); + expect(mockHandler).not.toHaveBeenCalled(); + }); + + it('tests invalid url 2 - should not call handler callback with invalid URL', () => { + const invalidURL = ''; + onLaunchCustomURL(invalidURL, mockHandler); + expect(mockHandler).not.toHaveBeenCalled(); + }); +}) \ No newline at end of file From b7bc41e1aaeffafdfcfc309aee9354a634ac5eca Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Fri, 29 Sep 2023 14:27:28 -0700 Subject: [PATCH 039/850] add error-handling block --- www/js/splash/customURL.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/www/js/splash/customURL.ts b/www/js/splash/customURL.ts index 46460a6d7..bc3d93f3e 100644 --- a/www/js/splash/customURL.ts +++ b/www/js/splash/customURL.ts @@ -6,13 +6,17 @@ type OnLaunchCustomURL = (rawUrl: string, callback: (url: string, urlComponents: export const onLaunchCustomURL: OnLaunchCustomURL = (rawUrl, handler) => { - const url = rawUrl.split('//')[1]; - const [ route, paramString ] = url.split('?'); - const paramsList = paramString.split('&'); - const urlComponents: UrlComponents = { route : route }; - for (let i = 0; i < paramsList.length; i++) { - const [key, value] = paramsList[i].split('='); - urlComponents[key] = value; + try { + const url = rawUrl.split('//')[1]; + const [ route, paramString ] = url.split('?'); + const paramsList = paramString.split('&'); + const urlComponents: UrlComponents = { route : route }; + for (let i = 0; i < paramsList.length; i++) { + const [key, value] = paramsList[i].split('='); + urlComponents[key] = value; + } + handler(url, urlComponents); + }catch { + console.log('not a valid url'); } - handler(url, urlComponents); }; \ No newline at end of file From 03bc5c368c465c7ba8e6ec68873de51ae2d0a630 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Fri, 29 Sep 2023 16:21:34 -0600 Subject: [PATCH 040/850] Update README.md 1. Changed the content section 2. Updated links in "Contributing" section 3. Updated "end to end testing" section --- README.md | 42 ++++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 34184ab09..6292c41b1 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ # [e-mission phone app](https://github.com/e-mission/e-mission-phone/tree/master) -__This is the phone component of the e-mission system.__ +__This is the phone component of the e-mission system.__ :sparkles: This has now been upgraded to cordova android@12.0.0 and iOS@6.2.0. It has also been upgraded to the **latest Android & iOS versions**, **cordova-lib@10.0.0 and the most recent node and npm versions**. It also now supports CI, so we should not have any build issues in the future. __This should be ready to build out of the box.__ +✨ We constantly upgrade the repo to the latest cordova versions of android, iOS, cordova-lib, and the most recent node and npm versions. The CI will be up-to-date. + For the latest versions, refer [`package.cordovabuild.json`](https://github.com/e-mission/e-mission-phone/blob/fce117ff859abd995613bd405dbc7d27c703b09b/package.cordovabuild.json) ## Additional Documentation @@ -15,12 +17,13 @@ https://github.com/e-mission/e-mission-docs/tree/master/docs/e-mission-phone :sparkles: Check [Contributing](#contributing) if you're interested in contributing for this project :sparkles: ## Contents -#### 1. [Creating logos](#creating-logos) -#### 2. [Updating the UI only](#updating-the-ui-only) -#### 3. [Updating the e-mission-* plugins or adding new plugins](#updating-the-e-mission--plugins-or-adding-new-plugins) +#### 1. [Updating the UI only](#updating-the-ui-only) +#### 2. [Updating the e-mission-* plugins or adding new plugins](#updating-the-e-mission--plugins-or-adding-new-plugins) +#### 3. [Creating logos](#creating-logos) #### 4. [End to End Testing](#end-to-end-testing) #### 5. [Beta-testing debugging](#beta-testing-debugging) #### 6. [Contributing](#contributing) +#### 7. [Troubleshooting](#troubleshooting) --- @@ -80,7 +83,7 @@ source setup/activate_serve.sh ## End to End Testing -A lot of the visualizations that we display in the phone client come from the server. In order to do end to end testing, we need to run a local server and connect to it. Instructions for: +A lot of the visualizations that we display in the phone client come from the server. In order to do end-to-end testing, we need to run a local server and connect to it. Instructions for: 1. installing a local server, 2. running it, @@ -89,7 +92,11 @@ A lot of the visualizations that we display in the phone client come from the se are available in the [e-mission-server README](https://github.com/e-mission/e-mission-server/blob/master/README.md). -In order to make end to end testing easy, if the local server is started on a HTTP (versus HTTPS port), it is in development mode. By default, the phone app connects to the local server (localhost on iOS, [10.0.2.2 on android](https://stackoverflow.com/questions/5806220/how-to-connect-to-my-http-localhost-web-server-from-android-emulator-in-eclips)) with the `prompted-auth` authentication method. To connect to a different server, or to use a different authentication method, you need to create a `www/json/connectionConfig.json` file. More details on configuring authentication [can be found in the docs](https://github.com/e-mission/e-mission-docs/blob/master/docs/install/configuring_authentication.md). +In order to make end-to-end testing easy, if the local server is started on a HTTP (versus HTTPS port), it is in development mode. By default, the phone app connects to the local server (localhost on iOS, [10.0.2.2 on android](https://stackoverflow.com/questions/5806220/how-to-connect-to-my-http-localhost-web-server-from-android-emulator-in-eclips)) with the `prompted-auth` authentication method. To connect to a different server, or to use a different authentication method, you need to modify the [nrel-commute.nrel-op.json](https://github.com/e-mission/nrel-openpath-deploy-configs/blob/482971d9715e8d52862e689658f9b4f2437e6401/configs/nrel-commute.nrel-op.json) file's dynamic config. +``` +"connectUrl": "https://nrel-commute-openpath.nrel.gov/api/" +``` +More details on configuring authentication [can be found in the docs](https://github.com/e-mission/e-mission-docs/blob/master/docs/install/configuring_authentication.md). One advantage of using `skip` authentication in development mode is that any user email can be entered without a password. Developers can use one of the emails that they loaded test data for in step (3) above. So if the test data loaded was with `-u shankari@eecs.berkeley.edu`, then the login email for the phone app would also be `shankari@eecs.berkeley.edu`. @@ -115,7 +122,6 @@ Pre-requisites - The version of xcode used by the CI. - to install a particular version, use [xcode-select](https://www.unix.com/man-page/OSX/1/xcode-select/) - or this [supposedly easier to use repo](https://github.com/xcpretty/xcode-install) - - **NOTE**: the basic xcode install on Catalina was messed up for me due to a prior installation of command line tools. [These workarounds helped](https://github.com/nodejs/node-gyp/blob/master/macOS_Catalina.md). - git - Java 17. Tested with [OpenJDK 17 (Temurin) using AdoptOpenJDK](https://adoptium.net). - Always use [homebrew](https://brew.sh) in addition to CLI @@ -263,21 +269,23 @@ less /tmp/loggerDB..withdate.log 1. Add the main repo as upstream ``` -2. git remote add upstream +git remote add upstream https://github.com/e-mission/e-mission-phone ``` -3. Create a new branch (IMPORTANT). Please do not submit pull requests from master +2. Create a new branch (IMPORTANT). Please do not submit pull requests from master ``` -4. git checkout -b +git checkout -b ``` -5. Make changes to the branch and commit them +3. Make changes to the branch and commit them ``` -6. git commit +git commit ``` - 7. Push the changes to your local fork +4. Push the changes to your local fork ``` -8. git push origin +git push origin ``` -9. Generate a pull request from the UI +5. Generate a pull request from the UI + +
__\*__Address my review comments__\*__ @@ -317,4 +325,6 @@ __2. Creating Logos__ - javascript errors: `rm -rf node_modules && npm install` - native code compile errors: `rm -rf plugins && rm -rf platforms && npx cordova prepare` -3. +3. (For updating the e-mission-plugins or adding new plugins) **NOTE**: the basic xcode install on Catalina was messed up for me due to a prior installation of command line tools. [These workarounds helped](https://github.com/nodejs/node-gyp/blob/master/macOS_Catalina.md). + +4. From 838f6da87095ef35a34aba1ea2b11c4fef921ccb Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 29 Sep 2023 16:38:55 -0600 Subject: [PATCH 041/850] adding user-input component of upload to profile we need to take a reason, so why not take it and then call the function? This workflow attempts to take user input in a modal and then pass it to the service's uploadFile function -- note, currently broken!!! --- www/js/control/ProfileSettings.jsx | 38 ++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index e3dab82e3..6e878296e 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from "react"; import { Modal, StyleSheet, ScrollView } from "react-native"; -import { Dialog, Button, useTheme, Text, Appbar, IconButton } from "react-native-paper"; +import { Dialog, Button, useTheme, Text, Appbar, IconButton, TextInput } from "react-native-paper"; import { angularize, getAngularService } from "../angular-react-helper"; import { useTranslation } from "react-i18next"; import ExpansionSection from "./ExpandMenu"; @@ -15,6 +15,8 @@ import DataDatePicker from "./DataDatePicker"; import AppStatusModal from "./AppStatusModal"; import PrivacyPolicyModal from "./PrivacyPolicyModal"; +import {uploadFile} from "./uploadService"; + let controlUpdateCompleteListenerRegistered = false; //any pure functions can go outside @@ -34,7 +36,6 @@ const ProfileSettings = () => { //angular services needed const CarbonDatasetHelper = getAngularService('CarbonDatasetHelper'); - const UploadHelper = getAngularService('UploadHelper'); const EmailHelper = getAngularService('EmailHelper'); const ControlCollectionHelper = getAngularService('ControlCollectionHelper'); const ControlSyncHelper = getAngularService('ControlSyncHelper'); @@ -73,6 +74,7 @@ const ProfileSettings = () => { const [consentVis, setConsentVis] = useState(false); const [dateDumpVis, setDateDumpVis] = useState(false); const [privacyVis, setPrivacyVis] = useState(false); + const [uploadVis, setUploadVis] = useState(false); const [collectSettings, setCollectSettings] = useState({}); const [notificationSettings, setNotificationSettings] = useState({}); @@ -85,6 +87,7 @@ const ProfileSettings = () => { const [consentDoc, setConsentDoc] = useState({}); const [dumpDate, setDumpDate] = useState(new Date()); const [toggleTime, setToggleTime] = useState(new Date()); + const [uploadReason, setUploadReason] = useState(""); let carbonDatasetString = t('general-settings.carbon-dataset') + ": " + CarbonDatasetHelper.getCurrentCarbonDatasetCode(); const carbonOptions = CarbonDatasetHelper.getCarbonDatasetOptions(); @@ -219,8 +222,12 @@ const ProfileSettings = () => { //methods that control the settings const uploadLog = function () { - UploadHelper.uploadFile("loggerDB") - }; + if(uploadReason != "") { + let reason = uploadReason.split('').join(''); + uploadFile("loggerDB", reason); + setUploadVis(false); + } + } const emailLog = function () { // Passing true, we want to send logs @@ -463,7 +470,7 @@ const ProfileSettings = () => { let logUploadSection; console.debug("appConfg: support_upload:", appConfig?.profile_controls?.support_upload); if (appConfig?.profile_controls?.support_upload) { - logUploadSection = ; + logUploadSection = setUploadVis(true)}>; } let timePicker; @@ -598,6 +605,27 @@ const ProfileSettings = () => {
+ {/* upload reason input */} + setUploadVis(false)} + transparent={true}> + setUploadVis(false)} + style={styles.dialog(colors.elevation.level3)}> + {t('upload-service.upload-database')} + + setUploadReason(uploadReason)} + placeholder={t('upload-service.please-fill-in-what-is-wrong')}> + + + + + + + + + {/* opcode viewing popup */} From 43b1b984ae7ff024df1300a9f163062d4a34a474 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 29 Sep 2023 17:01:48 -0600 Subject: [PATCH 042/850] resolve leftover issue from merge --- www/js/control/ProfileSettings.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index bbb713c57..691215366 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -451,7 +451,7 @@ const ProfileSettings = () => { transparent={true}> setUploadVis(false)} - style={styles.dialog(colors.elevation.level3)}> + style={settingStyles.dialog(colors.elevation.level3)}> {t('upload-service.upload-database')} Date: Fri, 29 Sep 2023 20:48:46 -0600 Subject: [PATCH 043/850] Update README.md 1. Updated buttons to point to the right CI 2. Removed point no. 4 in troubleshooting (it was empty) --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 6292c41b1..80abc7872 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ https://github.com/e-mission/e-mission-docs/tree/master/docs/e-mission-phone ## Updating the UI only -[![osx-serve-install](https://github.com/e-mission/e-mission-phone/workflows/osx-serve-install/badge.svg)](https://github.com/e-mission/e-mission-phone/actions?query=workflow%3Aosx-serve-install) +[![osx-serve-install](https://github.com/e-mission/e-mission-phone/workflows/osx-serve-install/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/serve-install.yml) If you want to make only UI changes, (as opposed to modifying the existing plugins, adding new plugins, etc), you can use the **new and improved** (as of June 2018) [e-mission dev app](https://github.com/e-mission/e-mission-devapp/) and install the most recent version from [releases](https://github.com/e-mission/e-mission-devapp/releases). @@ -326,5 +326,3 @@ __2. Creating Logos__ - native code compile errors: `rm -rf plugins && rm -rf platforms && npx cordova prepare` 3. (For updating the e-mission-plugins or adding new plugins) **NOTE**: the basic xcode install on Catalina was messed up for me due to a prior installation of command line tools. [These workarounds helped](https://github.com/nodejs/node-gyp/blob/master/macOS_Catalina.md). - -4. From bbf18bdb939f9a30670cda10c54de4c66a251c84 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Fri, 29 Sep 2023 20:50:39 -0600 Subject: [PATCH 044/850] Update README.md Made sure the links for the buttons are correct --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 80abc7872..25f54d061 100644 --- a/README.md +++ b/README.md @@ -325,4 +325,4 @@ __2. Creating Logos__ - javascript errors: `rm -rf node_modules && npm install` - native code compile errors: `rm -rf plugins && rm -rf platforms && npx cordova prepare` -3. (For updating the e-mission-plugins or adding new plugins) **NOTE**: the basic xcode install on Catalina was messed up for me due to a prior installation of command line tools. [These workarounds helped](https://github.com/nodejs/node-gyp/blob/master/macOS_Catalina.md). +3. (For updating the e-mission-plugins or adding new plugins) **NOTE**: the basic xcode install on Mac OS Catalina was messed up for me due to a prior installation of command line tools. [These workarounds helped](https://github.com/nodejs/node-gyp/blob/master/macOS_Catalina.md). From 4da919f8c37a0802da85476532888a281ce0e33e Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Sat, 30 Sep 2023 15:51:09 -0600 Subject: [PATCH 045/850] Update README.md 1. Removed manual numbering 2. Removed any form of version numbers 3. Removed certain Permalinks and swapped it with relative PATHs 4. Made sure the structure is same --- README.md | 79 ++++++++++++++++++++++++++----------------------------- 1 file changed, 38 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 25f54d061..7f8b62c29 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ __This is the phone component of the e-mission system.__ -:sparkles: This has now been upgraded to cordova android@12.0.0 and iOS@6.2.0. It has also been upgraded to the **latest Android & iOS versions**, **cordova-lib@10.0.0 and the most recent node and npm versions**. It also now supports CI, so we should not have any build issues in the future. __This should be ready to build out of the box.__ +:sparkles: This has been upgraded to the latest **Android**, **iOS**, **cordova-lib**, **node** and **npm** versions. __This is ready to build out of the box.__ ✨ We constantly upgrade the repo to the latest cordova versions of android, iOS, cordova-lib, and the most recent node and npm versions. The CI will be up-to-date. -For the latest versions, refer [`package.cordovabuild.json`](https://github.com/e-mission/e-mission-phone/blob/fce117ff859abd995613bd405dbc7d27c703b09b/package.cordovabuild.json) +For the latest versions, refer [`package.cordovabuild.json`](package.cordovabuild.json) ## Additional Documentation Additional documentation has been moved to its own repository [e-mission-docs](https://github.com/e-mission/e-mission-docs). Specific e-mission-phone wikis can be found here: @@ -35,7 +35,7 @@ If you want to make only UI changes, (as opposed to modifying the existing plugi ### Installing (one-time) -1. Run the setup script +:point_right:Run the setup script ``` bash setup/setup_serve.sh @@ -50,17 +50,16 @@ cp ..... www/json/connectionConfig.json ``` ### Activation (after install, and in every new shell) -2. Run this to activate ``` source setup/activate_serve.sh ``` ### Running -1. Start the phonegap deployment server and note the URL(s) that the server is listening to. +Start the phonegap deployment server and note the URL(s) that the server is listening to. - ``` - npm run serve + + npm run serve .... [phonegap] listening on 10.0.0.14:3000 [phonegap] listening on 192.168.162.1:3000 @@ -68,12 +67,11 @@ source setup/activate_serve.sh [phonegap] ctrl-c to stop the server [phonegap] .... - ``` -2. Change the devapp connection URL to one of these (e.g. 192.168.162.1:3000) and press "Connect" -3. The app will now display the version of e-mission app that is in your local directory - 4. The console logs will be displayed back in the server window (prefaced by `[console]`) - 5. Breakpoints can be added by connecting through the browser +Change the devapp connection URL to one of these (e.g. 192.168.162.1:3000) and press "Connect" +The app will now display the version of e-mission app that is in your local directory +The console logs will be displayed back in the server window (prefaced by `[console]`) +Breakpoints can be added by connecting through the browser - Safari ([enable develop menu](https://support.apple.com/guide/safari/use-the-safari-develop-menu-sfri20948/mac)): Develop -> Simulator -> index.html - Chrome: chrome://inspect -> Remote target (emulator) @@ -105,18 +103,6 @@ One advantage of using `skip` authentication in development mode is that any use [![osx-build-ios](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml) [![osx-build-android](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml) -__Important__ - -Most of the recent issues encountered have been due to incompatible setup. We -have now: -- locked down the dependencies, -- created setup and teardown scripts to setup self-contained environments with - those dependencies, and -- CI enabled to validate that they continue work. - -If you have setup failures, please compare the configuration in the **passing CI -builds** with your configuration. That is almost certainly the source of the error. - Pre-requisites --- - The version of xcode used by the CI. @@ -129,7 +115,19 @@ Pre-requisites running into ruby incompatibilities - e.g. https://github.com/CocoaPods/CocoaPods/issues/11763 -__1. Export statements__ +:triangular_flag_on_post: __Important__ + +Most of the recent issues encountered have been due to incompatible setup. We +have now: +- locked down the dependencies, +- created setup and teardown scripts to setup self-contained environments with + those dependencies, and +- CI enabled to validate that they continue work. + +If you have setup failures, please compare the configuration in the **passing CI +builds** with your configuration. That is almost certainly the source of the error. + +__Export statements__ ``` export ANDROID_SDK_ROOT="/Users//Library/Android/sdk" ``` @@ -165,7 +163,7 @@ aka the path where you want the SDK to be installed. -__2. Installing (one time only)__ +__Installing (one time only)__ - Run the setup script for the platform you want to build @@ -193,7 +191,7 @@ If connecting to a development server over http, make sure to turn on http suppo ``` -__3. Run this in every new shell for Activation__ +__Run this in every new shell for Activation__ ``` source setup/activate_native.sh @@ -202,9 +200,9 @@ source setup/activate_native.sh ``` Activating nvm -Using version 19.5.0 -Now using node v19.5.0 (npm v9.3.1) -npm version = 9.3.1 +Using version +Now using node (npm ) +npm version = Adding cocoapods to the path Verifying /Users//Library/Android/sk or /Users//Library/Android/sdk is set Activating sdkman, and by default, gradle @@ -216,9 +214,9 @@ Copied config.cordovabuild.xml -> config.xml and package.cordovabuild.json -> pa
- __4. Pick a type of build and execute the following:__ + __Pick a type of build and execute the following:__ -More "versions" are available in [`package.cordovabuild.json`](https://github.com/e-mission/e-mission-phone/blob/fce117ff859abd995613bd405dbc7d27c703b09b/package.cordovabuild.json) +More "versions" are available in [`package.cordovabuild.json`](package.cordovabuild.json) ``` npm run ``` @@ -266,24 +264,23 @@ less /tmp/loggerDB..withdate.log ## Contributing - -1. Add the main repo as upstream +:point_right:Add the main repo as upstream ``` git remote add upstream https://github.com/e-mission/e-mission-phone ``` -2. Create a new branch (IMPORTANT). Please do not submit pull requests from master +:point_right:Create a new branch (IMPORTANT). Please do not submit pull requests from master ``` git checkout -b ``` -3. Make changes to the branch and commit them +:point_right:Make changes to the branch and commit them ``` git commit ``` -4. Push the changes to your local fork +:point_right:Push the changes to your local fork ``` git push origin ``` -5. Generate a pull request from the UI +:point_right:Generate a pull request from the UI
@@ -305,7 +302,7 @@ git branch -d --- ### Troubleshooting -__1. Xcode command line tools__ +:point_right:Xcode command line tools ``` Warning: No developer tools installed. You should install the Command Line Tools. @@ -314,7 +311,7 @@ You should install the Command Line Tools. xcode-select --install ``` -__2. Creating Logos__ +:point_right:Creating Logos - Make sure to use `npx ionic` and `npx cordova`. This is because the setup script installs all the modules locally in a self-contained environment using `npm install` and not `npm install -g` @@ -325,4 +322,4 @@ __2. Creating Logos__ - javascript errors: `rm -rf node_modules && npm install` - native code compile errors: `rm -rf plugins && rm -rf platforms && npx cordova prepare` -3. (For updating the e-mission-plugins or adding new plugins) **NOTE**: the basic xcode install on Mac OS Catalina was messed up for me due to a prior installation of command line tools. [These workarounds helped](https://github.com/nodejs/node-gyp/blob/master/macOS_Catalina.md). +(For updating the e-mission-plugins or adding new plugins) **NOTE**: the basic xcode install on Mac OS Catalina was messed up for me due to a prior installation of command line tools. [These workarounds helped](https://github.com/nodejs/node-gyp/blob/master/macOS_Catalina.md). From e8cbcb5cb4a2feaf20f7d6762e43ba8d47b44533 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Sun, 1 Oct 2023 12:06:04 -0600 Subject: [PATCH 046/850] Update README.md iter 1 - fix end to end testing --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7f8b62c29..f079fe014 100644 --- a/README.md +++ b/README.md @@ -90,11 +90,9 @@ A lot of the visualizations that we display in the phone client come from the se are available in the [e-mission-server README](https://github.com/e-mission/e-mission-server/blob/master/README.md). -In order to make end-to-end testing easy, if the local server is started on a HTTP (versus HTTPS port), it is in development mode. By default, the phone app connects to the local server (localhost on iOS, [10.0.2.2 on android](https://stackoverflow.com/questions/5806220/how-to-connect-to-my-http-localhost-web-server-from-android-emulator-in-eclips)) with the `prompted-auth` authentication method. To connect to a different server, or to use a different authentication method, you need to modify the [nrel-commute.nrel-op.json](https://github.com/e-mission/nrel-openpath-deploy-configs/blob/482971d9715e8d52862e689658f9b4f2437e6401/configs/nrel-commute.nrel-op.json) file's dynamic config. -``` -"connectUrl": "https://nrel-commute-openpath.nrel.gov/api/" -``` -More details on configuring authentication [can be found in the docs](https://github.com/e-mission/e-mission-docs/blob/master/docs/install/configuring_authentication.md). +In order to make end-to-end testing even easier, we have moved from phone app to a dynamic config setting. We use the dynamic [config](configs) where each file upholds a function. Refer [Doc](https://github.com/e-mission/nrel-openpath-deploy-configs) + +If you have the [e-mission-server](https://github.com/e-mission/e-mission-server) running at a specific URL or IP address, and they want to connect to it, you would have to specify that URL or IP in the server field of their dynamic config file. One advantage of using `skip` authentication in development mode is that any user email can be entered without a password. Developers can use one of the emails that they loaded test data for in step (3) above. So if the test data loaded was with `-u shankari@eecs.berkeley.edu`, then the login email for the phone app would also be `shankari@eecs.berkeley.edu`. From 498e04e9da46c5ad55ff2c9df98ff20c59aea840 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Mon, 2 Oct 2023 08:53:27 -0600 Subject: [PATCH 047/850] Delete Build_ss.png Not required anymore --- Build_ss.png | Bin 83719 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 Build_ss.png diff --git a/Build_ss.png b/Build_ss.png deleted file mode 100644 index 18ab48b232d04ae28e72f5677d393045be584659..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 83719 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 From f4f3e6858e6696b3d0cdae4f6f7ab37d90333a44 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Mon, 2 Oct 2023 09:07:07 -0600 Subject: [PATCH 048/850] Update README.md - Better wordings --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f079fe014..6de8fcec1 100644 --- a/README.md +++ b/README.md @@ -90,9 +90,9 @@ A lot of the visualizations that we display in the phone client come from the se are available in the [e-mission-server README](https://github.com/e-mission/e-mission-server/blob/master/README.md). -In order to make end-to-end testing even easier, we have moved from phone app to a dynamic config setting. We use the dynamic [config](configs) where each file upholds a function. Refer [Doc](https://github.com/e-mission/nrel-openpath-deploy-configs) +In order to make end-to-end testing even easier, we have moved from phone app to a dynamic config setting. We use the dynamic [config](configs) to specify the server locations. Refer [Doc](https://github.com/e-mission/nrel-openpath-deploy-configs) -If you have the [e-mission-server](https://github.com/e-mission/e-mission-server) running at a specific URL or IP address, and they want to connect to it, you would have to specify that URL or IP in the server field of their dynamic config file. +If you have the [e-mission-server](https://github.com/e-mission/e-mission-server) running at a specific URL or IP address, and they want to connect to it, you would have to specify that URL or IP in the server of your dynamic config file. One advantage of using `skip` authentication in development mode is that any user email can be entered without a password. Developers can use one of the emails that they loaded test data for in step (3) above. So if the test data loaded was with `-u shankari@eecs.berkeley.edu`, then the login email for the phone app would also be `shankari@eecs.berkeley.edu`. From e1a6a32db1861ce686c85abc81d2a84db4ce3a35 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Mon, 2 Oct 2023 09:08:22 -0600 Subject: [PATCH 049/850] Update README.md - Removed relative PATH for dynamic links as they don't seem to work --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6de8fcec1..db401d00b 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ A lot of the visualizations that we display in the phone client come from the se are available in the [e-mission-server README](https://github.com/e-mission/e-mission-server/blob/master/README.md). -In order to make end-to-end testing even easier, we have moved from phone app to a dynamic config setting. We use the dynamic [config](configs) to specify the server locations. Refer [Doc](https://github.com/e-mission/nrel-openpath-deploy-configs) +In order to make end-to-end testing even easier, we have moved from phone app to a dynamic config setting. We use the dynamic config to specify the server locations. Refer [Doc](https://github.com/e-mission/nrel-openpath-deploy-configs) If you have the [e-mission-server](https://github.com/e-mission/e-mission-server) running at a specific URL or IP address, and they want to connect to it, you would have to specify that URL or IP in the server of your dynamic config file. From d3f8970ef8e6f27d1e56b7b94d7ae02e3b8ebbd1 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Mon, 2 Oct 2023 10:40:04 -0700 Subject: [PATCH 050/850] Checkmark dynamically appears, color matches Updated the button to only appear if you can verify the trip. Button color now matches the "inferred trip" data. --- www/js/survey/multilabel/MultiLabelButtonGroup.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index 268d16eb7..33842f48c 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -100,11 +100,12 @@ const MultilabelButtonGroup = ({ trip, buttonsInline=false }) => { ) })} - - - + {trip.verifiability === 'can-verify' && ( + + + + )} dismiss()}> dismiss()}> From 693852dda0d1310998029a2f1da4ce8a3347b2e8 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Mon, 2 Oct 2023 13:13:58 -0600 Subject: [PATCH 051/850] Update README.md Modified the server to `server` --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index db401d00b..9ac9ceb1d 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ are available in the [e-mission-server README](https://github.com/e-mission/e-mi In order to make end-to-end testing even easier, we have moved from phone app to a dynamic config setting. We use the dynamic config to specify the server locations. Refer [Doc](https://github.com/e-mission/nrel-openpath-deploy-configs) -If you have the [e-mission-server](https://github.com/e-mission/e-mission-server) running at a specific URL or IP address, and they want to connect to it, you would have to specify that URL or IP in the server of your dynamic config file. +If you have the [e-mission-server](https://github.com/e-mission/e-mission-server) running at a specific URL or IP address, and you want to connect to it, you would have to specify that URL or IP in the `server` field of your dynamic config file. One advantage of using `skip` authentication in development mode is that any user email can be entered without a password. Developers can use one of the emails that they loaded test data for in step (3) above. So if the test data loaded was with `-u shankari@eecs.berkeley.edu`, then the login email for the phone app would also be `shankari@eecs.berkeley.edu`. From 5a8d24854e0d507ac6b8505fdb86b856494c55b6 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 2 Oct 2023 15:26:25 -0600 Subject: [PATCH 052/850] correct message in reason modal --- www/js/control/ProfileSettings.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 691215366..cd8906ea3 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -452,7 +452,7 @@ const ProfileSettings = () => { setUploadVis(false)} style={settingStyles.dialog(colors.elevation.level3)}> - {t('upload-service.upload-database')} + {t('upload-service.upload-database', {db: "loggerDB"})} Date: Mon, 2 Oct 2023 15:27:45 -0600 Subject: [PATCH 053/850] upload service working This now appears to be working with the dev server, as the POST requests are going through successfully The next step is testing and potentially polishing the message popups to the user --- www/js/control/uploadService.ts | 61 +++++++++++++-------------------- 1 file changed, 24 insertions(+), 37 deletions(-) diff --git a/www/js/control/uploadService.ts b/www/js/control/uploadService.ts index 0a7b5c96b..e0c00a713 100644 --- a/www/js/control/uploadService.ts +++ b/www/js/control/uploadService.ts @@ -1,6 +1,7 @@ import { logDebug, logInfo, logError, displayError, displayErrorMsg } from "../plugin/logger"; -import { useTranslation } from "react-i18next"; +import i18next from "i18next"; +// const { t } = useTranslation(); /** * @returns A promise that resolves with an upload URL or rejects with an error */ @@ -9,16 +10,18 @@ async function getUploadConfig() { logInfo( "About to get email config"); let url = []; try { - let uploadConfig = await fetch("json/uploadConfig.json"); - logDebug("uploadConfigString = " + JSON.stringify(uploadConfig['data'])); - url.push(uploadConfig["data"].url); + let response = await fetch("json/uploadConfig.json"); + let uploadConfig = await response.json(); + logDebug("uploadConfigString = " + JSON.stringify(uploadConfig['url'])); + url.push(uploadConfig["url"]); resolve(url); } catch (err) { try{ - let uploadConfig = await fetch("json/uploadConfig.json.sample"); - logDebug("default uploadConfigString = " + JSON.stringify(uploadConfig['data'])); - console.log("default uploadConfigString = " + JSON.stringify(uploadConfig['data'])) - url.push(uploadConfig["data"].url); + let response = await fetch("json/uploadConfig.json.sample"); + let uploadConfig = await response.json(); + logDebug("default uploadConfigString = " + JSON.stringify(uploadConfig['url'])); + console.log("default uploadConfigString = " + JSON.stringify(uploadConfig['url'])) + url.push(uploadConfig["url"]); resolve(url); } catch (err) { logError("Error while reading default upload config" + err); @@ -70,25 +73,18 @@ function readDBFile(parentDir, database, callbackFn) { } const sendToServer = function upload(url, binArray, params) { - //attempting to replace angular.identity - var identity = function() { - return arguments[0]; - } - - var config = { - method: "POST", - body: binArray, + //this was the best way I could find to contact the database, + //had to modify the way it gets handled on the other side + //the original way it could not find "reason" + return fetch(url, { + method: 'POST', headers: {'Content-Type': undefined }, - transformRequest: identity, - params: params - }; - return fetch(url, config); + body: JSON.stringify({ data: binArray, params: params }) + } ) } - //only export of this file, used in ProfileSettings and passed the argument (""loggerDB"") export async function uploadFile(database, reason) { - const { t } = useTranslation(); try { let uploadConfig = await getUploadConfig(); var parentDir = "unknown"; @@ -102,34 +98,25 @@ export async function uploadFile(database, reason) { alert("parentDir unexpectedly = " + parentDir + "!") } - const newScope = {}; - newScope["data"] = {}; - newScope["fromDirText"] = t('upload-service.upload-from-dir', {parentDir: parentDir}); - newScope["toServerText"] = t('upload-service.upload-to-server', {serverURL: uploadConfig}); - logInfo("Going to upload " + database); try { let binString = await readDBFile(parentDir, database, undefined); console.log("Uploading file of size "+binString['byteLength']); - const progressScope = {...newScope}; //make a child copy of the current scope const params = { reason: reason, tz: Intl.DateTimeFormat().resolvedOptions().timeZone } uploadConfig.forEach(async (url) => { - alert(t("upload-service.upload-database", {db: database}) - + "\n" - + t("upload-service.upload-progress", {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: uploadConfig}) - ); - - window.alert(t("upload-service.upload-database", {db: database})); + //have alert for starting upload, but not progress + window.alert(i18next.t("upload-service.upload-database", {db: database})); try { + //const binArray = {byteLength: binString.byteLength, byteOffset: binString.byteOffset} let response = await sendToServer(url, binString, params); console.log(response); - window.alert(t("upload-service.upload-details", - {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: uploadConfig}) - + t("upload-service.upload-success")); + window.alert(i18next.t("upload-service.upload-details", + {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: url}) + + i18next.t("upload-service.upload-success")); } catch (error) { onUploadError(error); } From a5451e9bfd377ec511e355984a07c52ae197f1a5 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 2 Oct 2023 16:41:52 -0600 Subject: [PATCH 054/850] resolve errors there were two errors in VScode, which were also tripping the tests up when I tried to run the uploadFile function -- resolving these did not make the tests run, but it did get past the first of the errors! --- www/js/control/uploadService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/js/control/uploadService.ts b/www/js/control/uploadService.ts index e0c00a713..a09fdaed3 100644 --- a/www/js/control/uploadService.ts +++ b/www/js/control/uploadService.ts @@ -61,8 +61,8 @@ function readDBFile(parentDir, database, callbackFn) { } reader.onload = function() { - console.log("Successful file read with " + this.result.byteLength +" characters"); - resolve(new DataView(this.result)); + console.log("Successful file read with " + this.result['byteLength'] +" characters"); + resolve(new DataView(this.result as ArrayBuffer)); } reader.readAsArrayBuffer(file); From 6262d2e6d80a2bff87132649dd4882215f8b3299 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Tue, 3 Oct 2023 08:17:57 -0700 Subject: [PATCH 055/850] Update www/js/survey/multilabel/MultiLabelButtonGroup.tsx See conversation in PR `rem` doesn't work properly in react-native, updated Co-authored-by: Jack Greenlee --- www/js/survey/multilabel/MultiLabelButtonGroup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index 33842f48c..ecfcf7ea6 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -101,7 +101,7 @@ const MultilabelButtonGroup = ({ trip, buttonsInline=false }) => { })} {trip.verifiability === 'can-verify' && ( - + From 9c36f7dfb894122810165443f97c1c4d27d306a1 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Tue, 3 Oct 2023 08:19:43 -0700 Subject: [PATCH 056/850] Update www/js/survey/multilabel/MultiLabelButtonGroup.tsx iconButtonProp & Border Color Changed prop to use native property, adjusted border color. Co-authored-by: Jack Greenlee --- www/js/survey/multilabel/MultiLabelButtonGroup.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index ecfcf7ea6..0ce031bee 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -103,7 +103,8 @@ const MultilabelButtonGroup = ({ trip, buttonsInline=false }) => { {trip.verifiability === 'can-verify' && ( + containerColor={colors.secondaryContainer} + style={{width: 24, height: 24, margin: 3, borderColor: colors.secondary}} /> )} From 1e19ab24d137ad8cfb660c9d57ddefd44e592d3d Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Tue, 3 Oct 2023 10:03:12 -0700 Subject: [PATCH 057/850] Add testId for unit test --- www/js/diary/list/LoadMoreButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/diary/list/LoadMoreButton.tsx b/www/js/diary/list/LoadMoreButton.tsx index 05fa2ecd1..f3d6db082 100644 --- a/www/js/diary/list/LoadMoreButton.tsx +++ b/www/js/diary/list/LoadMoreButton.tsx @@ -6,7 +6,7 @@ const LoadMoreButton = ({ children, onPressFn, ...otherProps }) => { const { colors } = useTheme(); return ( - From 693361f438ade26b027eacbd4196e7f2636588d3 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Tue, 3 Oct 2023 10:04:01 -0700 Subject: [PATCH 058/850] Add LoadMoreButton component unit test --- www/__tests__/LoadMoreButton.test.tsx | 30 +++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 www/__tests__/LoadMoreButton.test.tsx diff --git a/www/__tests__/LoadMoreButton.test.tsx b/www/__tests__/LoadMoreButton.test.tsx new file mode 100644 index 000000000..5acb4a700 --- /dev/null +++ b/www/__tests__/LoadMoreButton.test.tsx @@ -0,0 +1,30 @@ +/** + * @jest-environment jsdom + */ +import React from 'react' +import {render, fireEvent, waitFor, screen} from '@testing-library/react-native' +import LoadMoreButton from '../js/diary/list/LoadMoreButton' + + +describe("LoadMoreButton", () => { + it("renders correctly", async () => { + render( + {}}>{} + ); + await waitFor(() => { + expect(screen.getByTestId("load-button")).toBeTruthy(); + }); + }); + + it("calls onPressFn when clicked", () => { + const mockFn = jest.fn(); + const { getByTestId } = render( + {} + ); + const loadButton = getByTestId("load-button"); + fireEvent.press(loadButton); + expect(mockFn).toHaveBeenCalled(); + }); +}); + + From f0b3a3c3de071b45aaf7f96133731cbd9f16a6f9 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Tue, 3 Oct 2023 10:50:59 -0700 Subject: [PATCH 059/850] Change jest setup for component test --- jest.config.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/jest.config.json b/jest.config.json index 78dc839b4..21e5f6a05 100644 --- a/jest.config.json +++ b/jest.config.json @@ -11,5 +11,10 @@ }, "moduleNameMapper": { "^react-native$": "react-native-web" - } + }, + "preset": "react-native", + "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"], + "transformIgnorePatterns": [ + "node_modules/(?!(jest-)?@?react-native|@react-native-community|@react-navigation)" + ] } From a7d4ec3b07e0f7f472d1a288c9362ad426caeb95 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 3 Oct 2023 16:30:49 -0600 Subject: [PATCH 060/850] set up testing for uploadService tests are not fully running yet, but making progress and need to save before getting the mocks from a remote branch --- www/__tests__/uploadService.tests.ts | 29 +++++++++++++++++++++++++++- www/json/uploadConfig.json.sample | 2 +- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/www/__tests__/uploadService.tests.ts b/www/__tests__/uploadService.tests.ts index 50a253a2e..0544d22cd 100644 --- a/www/__tests__/uploadService.tests.ts +++ b/www/__tests__/uploadService.tests.ts @@ -1,2 +1,29 @@ -import {} from "../js/control/uploadService"; +import {uploadFile} from "../js/control/uploadService"; +import { mockLogger } from '../__mocks__/globalMocks'; +mockLogger(); + +// mock for JavaScript 'fetch' +// we emulate a 100ms delay when i) fetching data and ii) parsing it as text +global.fetch = (url: string, options: {method: string, headers: {}, body: string}) => new Promise((rs, rj) => { + setTimeout(() => rs({ + text: () => new Promise((rs, rj) => { + setTimeout(() => rs(new Response('sent ' + options.method + options.body + ' to ' + url)), 100); + }) + })); +}) as any; + +global.fetch = jest.fn(() => + Promise.resolve({ + json: () => Promise.resolve({ url: "http://localhost:5647/phonelogs" }), + }), +) as any + +//this is never used in production right now +//however, tests are still important to make sure the code works +//at some point we hope to restore this functionality +it('posts the logs to the configured database', async () => { + const posted = await uploadFile("loggerDB", "HelloWorld"); + expect(posted).toEqual(expect.stringContaining("HelloWorld")); + expect(posted).toEqual(expect.stringContaining("POST")); +}); \ No newline at end of file diff --git a/www/json/uploadConfig.json.sample b/www/json/uploadConfig.json.sample index 53cead55e..a3c8b7210 100644 --- a/www/json/uploadConfig.json.sample +++ b/www/json/uploadConfig.json.sample @@ -1,3 +1,3 @@ { - "url": "http://fill.me.in " + "url": "http://localhost:5647/phonelogs" } From 0e3a0e5bb9328d9790f12175342bed97f821c698 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 4 Oct 2023 07:46:42 -0600 Subject: [PATCH 061/850] more mocks for testing needed to mock window['resolvelocalfilesystem'] as well as window['cordova'].file --- www/__mocks__/cordovaMocks.ts | 5 +++++ www/__mocks__/fileSystemMocks.ts | 5 +++++ www/__tests__/uploadService.tests.ts | 9 +++++++++ 3 files changed, 19 insertions(+) create mode 100644 www/__mocks__/fileSystemMocks.ts diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 44c21677c..34cb732bb 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -22,6 +22,11 @@ export const mockGetAppVersion = () => { window['cordova'].getAppVersion = mockGetAppVersion; } +export const mockFile = () => { + window['cordova'].file = { "dataDirectory" : "../path/to/data/directory", + "applicationStorageDirectory" : "../path/to/app/storage/directory"}; +} + export const mockBEMUserCache = () => { const _cache = {}; const messages = []; diff --git a/www/__mocks__/fileSystemMocks.ts b/www/__mocks__/fileSystemMocks.ts new file mode 100644 index 000000000..99682cba9 --- /dev/null +++ b/www/__mocks__/fileSystemMocks.ts @@ -0,0 +1,5 @@ +export const mockFileSystem = () => { + window['resolveLocalFileSystemURL'] = function (parentDir, handleFS) { + return new DataView({byteLength: 100} as ArrayBuffer) + } + } \ No newline at end of file diff --git a/www/__tests__/uploadService.tests.ts b/www/__tests__/uploadService.tests.ts index 0544d22cd..b1efcea1d 100644 --- a/www/__tests__/uploadService.tests.ts +++ b/www/__tests__/uploadService.tests.ts @@ -1,5 +1,14 @@ import {uploadFile} from "../js/control/uploadService"; import { mockLogger } from '../__mocks__/globalMocks'; +import { mockDevice, mockGetAppVersion, mockCordova, mockFile } from "../__mocks__/cordovaMocks"; +import { mockFileSystem } from "../__mocks__/fileSystemMocks"; + +mockDevice(); +// this mocks cordova-plugin-app-version, generating a "Mock App", version "1.2.3" +mockGetAppVersion(); +mockCordova(); +mockFile(); +mockFileSystem(); mockLogger(); From 4f84643999729fe2edb7a62ea8f0fa39babdf5f7 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Wed, 4 Oct 2023 09:40:35 -0700 Subject: [PATCH 062/850] Fixed JSON Object Dump As per issue #994, fixed the `Download JSON Dump`. The previous date-picker formatted the input as 'dd MMM yyyy', used luxon to format the object. This is a bit hacky - going to rewrite the 'ControlHelper' service, and completely remove the `moment()` altogether. --- www/js/services.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/www/js/services.js b/www/js/services.js index 0c9c6e2ac..cea063f60 100644 --- a/www/js/services.js +++ b/www/js/services.js @@ -2,6 +2,7 @@ import angular from 'angular'; import { getRawEntries } from './commHelper'; +import { DateTime } from 'luxon'; angular.module('emission.services', ['emission.plugin.logger']) @@ -114,8 +115,9 @@ angular.module('emission.services', ['emission.plugin.logger']) var fmt = "YYYY-MM-DD"; // We are only retrieving data for a single day to avoid // running out of memory on the phone - var startMoment = moment(startTs); - var endMoment = moment(startTs).endOf("day"); + var adjustedTs = DateTime.fromJSDate(startTs).toFormat('dd MMM yyyy'); + var startMoment = moment(adjustedTs); + var endMoment = moment(adjustedTs).endOf("day"); var dumpFile = startMoment.format(fmt) + "." + endMoment.format(fmt) + ".timeline"; From 019d715e6080ec723831ecd01b19270119ab5dbe Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 4 Oct 2023 16:43:01 -0600 Subject: [PATCH 063/850] working mocks and tests! After a lot of work ironing out a mock for the file system layers, a working mock and test has been established the "reason" passed through remains present throughout the process, confirming in at least a small way that this works --- www/__mocks__/fileSystemMocks.ts | 15 +++++++++- ...Service.tests.ts => uploadService.test.ts} | 28 ++++++++++--------- www/js/control/uploadService.ts | 15 +++++----- 3 files changed, 36 insertions(+), 22 deletions(-) rename www/__tests__/{uploadService.tests.ts => uploadService.test.ts} (62%) diff --git a/www/__mocks__/fileSystemMocks.ts b/www/__mocks__/fileSystemMocks.ts index 99682cba9..fb1258692 100644 --- a/www/__mocks__/fileSystemMocks.ts +++ b/www/__mocks__/fileSystemMocks.ts @@ -1,5 +1,18 @@ export const mockFileSystem = () => { window['resolveLocalFileSystemURL'] = function (parentDir, handleFS) { - return new DataView({byteLength: 100} as ArrayBuffer) + const fs = {"filesystem": + {"root": + {"getFile": (path, options, onSuccess) => { + let fileEntry = {"file": (handleFile) => { + let file = new File(["this is a mock"], "loggerDB"); + handleFile(file); + }} + onSuccess(fileEntry); + } + } + } + } + console.log("in mock, fs is ", fs, " get File is ", fs.filesystem.root.getFile); + handleFS(fs); } } \ No newline at end of file diff --git a/www/__tests__/uploadService.tests.ts b/www/__tests__/uploadService.test.ts similarity index 62% rename from www/__tests__/uploadService.tests.ts rename to www/__tests__/uploadService.test.ts index b1efcea1d..13e343efa 100644 --- a/www/__tests__/uploadService.tests.ts +++ b/www/__tests__/uploadService.test.ts @@ -12,27 +12,29 @@ mockFileSystem(); mockLogger(); -// mock for JavaScript 'fetch' -// we emulate a 100ms delay when i) fetching data and ii) parsing it as text +//use this message to verify that the post went through +let message = ""; + global.fetch = (url: string, options: {method: string, headers: {}, body: string}) => new Promise((rs, rj) => { + if(options) { + console.log("returning with options!", options); + message = "got " + options.method + options.body; + rs('sent ' + options.method + options.body + ' to ' + url); + } else { setTimeout(() => rs({ - text: () => new Promise((rs, rj) => { - setTimeout(() => rs(new Response('sent ' + options.method + options.body + ' to ' + url)), 100); + json: () => new Promise((rs, rj) => { + setTimeout(() => rs('mock data for ' + url), 100); }) })); + } }) as any; -global.fetch = jest.fn(() => - Promise.resolve({ - json: () => Promise.resolve({ url: "http://localhost:5647/phonelogs" }), - }), -) as any - //this is never used in production right now //however, tests are still important to make sure the code works //at some point we hope to restore this functionality it('posts the logs to the configured database', async () => { const posted = await uploadFile("loggerDB", "HelloWorld"); - expect(posted).toEqual(expect.stringContaining("HelloWorld")); - expect(posted).toEqual(expect.stringContaining("POST")); -}); \ No newline at end of file + console.log(posted); + expect(message).toEqual(expect.stringContaining("HelloWorld")); + expect(message).toEqual(expect.stringContaining("POST")); +}, 10000); \ No newline at end of file diff --git a/www/js/control/uploadService.ts b/www/js/control/uploadService.ts index a09fdaed3..cbd182b3c 100644 --- a/www/js/control/uploadService.ts +++ b/www/js/control/uploadService.ts @@ -1,7 +1,5 @@ import { logDebug, logInfo, logError, displayError, displayErrorMsg } from "../plugin/logger"; -import i18next from "i18next"; -// const { t } = useTranslation(); /** * @returns A promise that resolves with an upload URL or rejects with an error */ @@ -42,6 +40,7 @@ function onUploadError(err) { function readDBFile(parentDir, database, callbackFn) { return new Promise(function(resolve, reject) { window['resolveLocalFileSystemURL'](parentDir, function(fs) { + console.log("resolving file system as ", fs); fs.filesystem.root.getFile(fs.fullPath+database, null, (fileEntry) => { console.log(fileEntry); fileEntry.file(function(file) { @@ -108,15 +107,15 @@ export async function uploadFile(database, reason) { } uploadConfig.forEach(async (url) => { //have alert for starting upload, but not progress - window.alert(i18next.t("upload-service.upload-database", {db: database})); + // window.alert(i18next.t("upload-service.upload-database", {db: database})); try { - //const binArray = {byteLength: binString.byteLength, byteOffset: binString.byteOffset} let response = await sendToServer(url, binString, params); - console.log(response); - window.alert(i18next.t("upload-service.upload-details", - {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: url}) - + i18next.t("upload-service.upload-success")); + console.log("after post got", response); + return response; + // window.alert(i18next.t("upload-service.upload-details", + // {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: url}) + // + i18next.t("upload-service.upload-success")); } catch (error) { onUploadError(error); } From d3c08298ce3c5c4f2c1d9d8b107bcd04c34f9127 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 4 Oct 2023 17:14:58 -0600 Subject: [PATCH 064/850] fix bad merge conflifct the dev ui server wouldn't run because there were two imports from react-nativ-paper, probably form a bad merge resolution --- www/js/control/ProfileSettings.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 10cd9f80a..2552b37f0 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -1,7 +1,6 @@ import React, { useState, useEffect, useContext, useRef } from "react"; import { Modal, StyleSheet, ScrollView } from "react-native"; import { Dialog, Button, useTheme, Text, Appbar, IconButton, TextInput } from "react-native-paper"; -import { Dialog, Button, useTheme, Text, Appbar, IconButton } from "react-native-paper"; import { getAngularService } from "../angular-react-helper"; import { useTranslation } from "react-i18next"; import ExpansionSection from "./ExpandMenu"; From 1130508c09fbeff0fc10c4b85313d5205a4f67cf Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Wed, 4 Oct 2023 16:28:45 -0700 Subject: [PATCH 065/850] Replaced moment objects with DateTime `js/services.js` has been rewritten so that it no longer relies on the legacy `moments.js` library, and instead uses `luxon`. --- www/js/services.js | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/www/js/services.js b/www/js/services.js index cea063f60..29f6dc491 100644 --- a/www/js/services.js +++ b/www/js/services.js @@ -112,14 +112,15 @@ angular.module('emission.services', ['emission.plugin.logger']) } this.getMyData = function(startTs) { - var fmt = "YYYY-MM-DD"; // We are only retrieving data for a single day to avoid // running out of memory on the phone - var adjustedTs = DateTime.fromJSDate(startTs).toFormat('dd MMM yyyy'); - var startMoment = moment(adjustedTs); - var endMoment = moment(adjustedTs).endOf("day"); - var dumpFile = startMoment.format(fmt) + "." - + endMoment.format(fmt) + var startTime = DateTime.fromJSDate(startTs); + var endTime = startTime.endOf("day"); + var startTimeString = startTime.toFormat("yyyy'-'MM'-'dd"); + var endTimeString = endTime.toFormat("yyyy'-'MM'-'dd"); + + var dumpFile = startTimeString + "." + + endTimeString + ".timeline"; alert("Going to retrieve data to "+dumpFile); @@ -182,7 +183,7 @@ angular.module('emission.services', ['emission.plugin.logger']) attachments: [ attachFile ], - subject: i18next.t('email-service.email-data.subject-data-dump-from-to', {start: startMoment.format(fmt),end: endMoment.format(fmt)}), + subject: i18next.t('email-service.email-data.subject-data-dump-from-to', {start: startTimeString ,end: endTimeString}), body: i18next.t('email-service.email-data.body-data-consists-of-list-of-entries') } $window.cordova.plugins.email.open(email).then(resolve()); @@ -198,7 +199,13 @@ angular.module('emission.services', ['emission.plugin.logger']) }); }; - getRawEntries(null, startMoment.unix(), endMoment.unix()) + // Simulate old conversion to get correct UnixInteger for fetching data + const getUnixNum = (dateData) => { + var tempDate = dateData.toFormat('dd MMM yyyy'); + return DateTime.fromFormat(tempDate, "dd MMM yyyy").toUnixInteger(); + }; + + getRawEntries(null, getUnixNum(startTime), getUnixNum(endTime)) .then(writeDumpFile) .then(emailData) .then(function() { From 2990b7ba1e52b1a2eb7946d7c634f497c97b5a17 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 5 Oct 2023 12:45:30 -0400 Subject: [PATCH 066/850] use babel-jest instead of ts-jest Some of the core React and React Native code is written in Flow, and the official, public packages have untranspiled Flow code. Bad! Jest can't understand this when it tries to test our React code. So unfortunately we have to transpile it ourselves before running tests, using Babel with a couple different presets and plugins - add babel.config.js - change Jest config from json -> js - add packages to support the Babel transpilation --- babel.config.js | 4 ++++ jest.config.js | 19 +++++++++++++++++++ jest.config.json | 20 -------------------- package.serve.json | 5 ++++- 4 files changed, 27 insertions(+), 21 deletions(-) create mode 100644 babel.config.js create mode 100644 jest.config.js delete mode 100644 jest.config.json diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 000000000..b3c97fe4c --- /dev/null +++ b/babel.config.js @@ -0,0 +1,4 @@ +module.exports = { + presets: ['@babel/preset-env', '@babel/preset-typescript', '@babel/preset-react'], + plugins: ['@babel/plugin-transform-flow-strip-types'], +} diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 000000000..21184bdd7 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,19 @@ +module.exports = { + testEnvironment: 'jsdom', + testPathIgnorePatterns: [ + "/node_modules/", + "/platforms/", + "/plugins/", + "/lib/", + "/manual_lib/" + ], + preset: 'react-native', + transform: { + "^.+\\.(ts|tsx|js|jsx)$": "babel-jest" + }, + transformIgnorePatterns: [ + "node_modules/(?!((jest-)?react-native(-.*)?|@react-native(-community)?)/)" + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + moduleDirectories: ["node_modules", "src"], +}; diff --git a/jest.config.json b/jest.config.json deleted file mode 100644 index 21e5f6a05..000000000 --- a/jest.config.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "testPathIgnorePatterns": [ - "/node_modules/", - "/platforms/", - "/plugins/", - "/lib/", - "/manual_lib/" - ], - "transform": { - "^.+\\.(ts|tsx|js|jsx)$": "ts-jest" - }, - "moduleNameMapper": { - "^react-native$": "react-native-web" - }, - "preset": "react-native", - "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"], - "transformIgnorePatterns": [ - "node_modules/(?!(jest-)?@?react-native|@react-native-community|@react-navigation)" - ] -} diff --git a/package.serve.json b/package.serve.json index a4e3194f3..d74afedef 100644 --- a/package.serve.json +++ b/package.serve.json @@ -18,13 +18,16 @@ "@babel/core": "^7.21.3", "@babel/plugin-proposal-class-properties": "^7.18.6", "@babel/plugin-proposal-object-rest-spread": "^7.20.7", + "@babel/plugin-transform-flow-strip-types": "^7.22.5", "@babel/preset-env": "^7.21.4", "@babel/preset-flow": "^7.21.4", "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.21.4", "@ionic/cli": "6.20.8", + "@testing-library/react-native": "^12.3.0", "@types/luxon": "^3.3.0", "@types/react": "^18.2.20", + "babel-jest": "^29.7.0", "babel-loader": "^9.1.2", "babel-plugin-angularjs-annotate": "^0.10.0", "babel-plugin-optional-require": "^0.3.1", @@ -40,8 +43,8 @@ "sass": "^1.62.1", "sass-loader": "^13.3.1", "style-loader": "^3.3.3", - "ts-jest": "^29.1.1", "ts-loader": "^9.4.2", + "ts-node": "^10.9.1", "typescript": "^5.0.3", "url-loader": "^4.1.1", "webpack": "^5.0.1", From e3ebe8519be4209c5e277f3ec00f62b18f5c6894 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 5 Oct 2023 10:47:58 -0600 Subject: [PATCH 067/850] restored alerts, added mock, added comments wrapping up the basic implementation and testing of this upload service, adding comments for clarity --- www/__tests__/uploadService.test.ts | 24 +++++++++++++++--------- www/js/control/uploadService.ts | 10 +++++----- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/www/__tests__/uploadService.test.ts b/www/__tests__/uploadService.test.ts index 13e343efa..c8c0e6ebc 100644 --- a/www/__tests__/uploadService.test.ts +++ b/www/__tests__/uploadService.test.ts @@ -1,23 +1,25 @@ +//this is never used in production right now +//however, tests are still important to make sure the code works +//at some point we hope to restore this functionality + import {uploadFile} from "../js/control/uploadService"; import { mockLogger } from '../__mocks__/globalMocks'; import { mockDevice, mockGetAppVersion, mockCordova, mockFile } from "../__mocks__/cordovaMocks"; import { mockFileSystem } from "../__mocks__/fileSystemMocks"; mockDevice(); -// this mocks cordova-plugin-app-version, generating a "Mock App", version "1.2.3" mockGetAppVersion(); mockCordova(); -mockFile(); -mockFileSystem(); mockLogger(); +mockFile(); //mocks the base directory +mockFileSystem(); //comnplex mock, allows the readDBFile to work in testing //use this message to verify that the post went through let message = ""; global.fetch = (url: string, options: {method: string, headers: {}, body: string}) => new Promise((rs, rj) => { if(options) { - console.log("returning with options!", options); message = "got " + options.method + options.body; rs('sent ' + options.method + options.body + ' to ' + url); } else { @@ -29,12 +31,16 @@ global.fetch = (url: string, options: {method: string, headers: {}, body: string } }) as any; -//this is never used in production right now -//however, tests are still important to make sure the code works -//at some point we hope to restore this functionality +window.alert = (message) => { + console.log(message); +} + +//very basic tests - difficult to do too much since there's a lot of mocking involved it('posts the logs to the configured database', async () => { - const posted = await uploadFile("loggerDB", "HelloWorld"); - console.log(posted); + let posted = await uploadFile("loggerDB", "HelloWorld"); expect(message).toEqual(expect.stringContaining("HelloWorld")); expect(message).toEqual(expect.stringContaining("POST")); + posted = await uploadFile("loggerDB", "second test"); + expect(message).toEqual(expect.stringContaining("second test")); + expect(message).toEqual(expect.stringContaining("POST")); }, 10000); \ No newline at end of file diff --git a/www/js/control/uploadService.ts b/www/js/control/uploadService.ts index cbd182b3c..7eee1217a 100644 --- a/www/js/control/uploadService.ts +++ b/www/js/control/uploadService.ts @@ -1,4 +1,5 @@ import { logDebug, logInfo, logError, displayError, displayErrorMsg } from "../plugin/logger"; +import i18next from "i18next"; /** * @returns A promise that resolves with an upload URL or rejects with an error @@ -107,15 +108,14 @@ export async function uploadFile(database, reason) { } uploadConfig.forEach(async (url) => { //have alert for starting upload, but not progress - // window.alert(i18next.t("upload-service.upload-database", {db: database})); + window.alert(i18next.t("upload-service.upload-database", {db: database})); try { let response = await sendToServer(url, binString, params); - console.log("after post got", response); + window.alert(i18next.t("upload-service.upload-details", + {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: url}) + + i18next.t("upload-service.upload-success")); return response; - // window.alert(i18next.t("upload-service.upload-details", - // {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: url}) - // + i18next.t("upload-service.upload-success")); } catch (error) { onUploadError(error); } From e0dd682f8afc78ec14aa5276d7419abb5edcedf9 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 5 Oct 2023 11:13:17 -0600 Subject: [PATCH 068/850] remove old angular upload service --- www/index.js | 1 - www/js/control/nguploadService.js | 171 ------------------------------ www/js/main.js | 3 +- 3 files changed, 1 insertion(+), 174 deletions(-) delete mode 100644 www/js/control/nguploadService.js diff --git a/www/index.js b/www/index.js index 17974900c..68c89ea9c 100644 --- a/www/index.js +++ b/www/index.js @@ -26,7 +26,6 @@ import './js/survey/enketo/infinite_scroll_filters.js'; import './js/survey/enketo/enketo-trip-button.js'; import './js/survey/enketo/enketo-add-note-button.js'; import './js/control/emailService.js'; -import './js/control/nguploadService.js'; import './js/metrics-factory.js'; import './js/metrics-mappings.js'; import './js/plugin/logger.ts'; diff --git a/www/js/control/nguploadService.js b/www/js/control/nguploadService.js deleted file mode 100644 index 6f95503c1..000000000 --- a/www/js/control/nguploadService.js +++ /dev/null @@ -1,171 +0,0 @@ -'use strict'; - -import angular from 'angular'; - -angular.module('emission.services.upload', ['emission.plugin.logger']) - - .service('UploadHelper', function ($window, $http, $rootScope, $ionicPopup, Logger) { - const getUploadConfig = function () { - return new Promise(function (resolve, reject) { - Logger.log(Logger.LEVEL_INFO, "About to get email config"); - var url = []; - $http.get("json/uploadConfig.json").then(function (uploadConfig) { - Logger.log(Logger.LEVEL_DEBUG, "uploadConfigString = " + JSON.stringify(uploadConfig.data)); - url.push(uploadConfig.data.url) - resolve(url); - }).catch(function (err) { - $http.get("json/uploadConfig.json.sample").then(function (uploadConfig) { - Logger.log(Logger.LEVEL_DEBUG, "default uploadConfigString = " + JSON.stringify(uploadConfig.data)); - url.push(uploadConfig.data.url) - resolve(url); - }).catch(function (err) { - Logger.log(Logger.LEVEL_ERROR, "Error while reading default upload config" + err); - reject(err); - }); - }); - }); - } - - const onReadError = function(err) { - Logger.displayError("Error while reading log", err); - } - - const onUploadError = function(err) { - Logger.displayError("Error while uploading log", err); - } - - const readDBFile = function(parentDir, database, callbackFn) { - return new Promise(function(resolve, reject) { - window.resolveLocalFileSystemURL(parentDir, function(fs) { - fs.filesystem.root.getFile(fs.fullPath+database, null, (fileEntry) => { - console.log(fileEntry); - fileEntry.file(function(file) { - console.log(file); - var reader = new FileReader(); - - reader.onprogress = function(report) { - console.log("Current progress is "+JSON.stringify(report)); - if (callbackFn != undefined) { - callbackFn(report.loaded * 100 / report.total); - } - } - - reader.onerror = function(error) { - console.log(this.error); - reject({"error": {"message": this.error}}); - } - - reader.onload = function() { - console.log("Successful file read with " + this.result.byteLength +" characters"); - resolve(new DataView(this.result)); - } - - reader.readAsArrayBuffer(file); - }, reject); - }, reject); - }); - }); - } - - const sendToServer = function upload(url, binArray, params) { - var config = { - headers: {'Content-Type': undefined }, - transformRequest: angular.identity, - params: params - }; - return $http.post(url, binArray, config); - } - - this.uploadFile = function (database) { - getUploadConfig().then((uploadConfig) => { - var parentDir = "unknown"; - - if (ionic.Platform.isAndroid()) { - parentDir = cordova.file.applicationStorageDirectory+"/databases"; - } - if (ionic.Platform.isIOS()) { - parentDir = cordova.file.dataDirectory + "../LocalDatabase"; - } - - if (parentDir === "unknown") { - alert("parentDir unexpectedly = " + parentDir + "!") - } - - const newScope = $rootScope.$new(); - newScope.data = {}; - newScope.fromDirText = i18next.t('upload-service.upload-from-dir', {parentDir: parentDir}); - newScope.toServerText = i18next.t('upload-service.upload-to-server', {serverURL: uploadConfig}); - - var didCancel = true; - - const detailsPopup = $ionicPopup.show({ - title: i18next.t("upload-service.upload-database", { db: database }), - template: newScope.toServerText - + '', - scope: newScope, - buttons: [ - { - text: 'Cancel', - onTap: function(e) { - didCancel = true; - detailsPopup.close(); - } - }, - { - text: 'Upload', - type: 'button-positive', - onTap: function(e) { - if (!newScope.data.reason) { - //don't allow the user to close unless he enters wifi password - didCancel = false; - e.preventDefault(); - } else { - didCancel = false; - return newScope.data.reason; - } - } - } - ] - }); - - Logger.log(Logger.LEVEL_INFO, "Going to upload " + database); - const readFileAndInfo = [readDBFile(parentDir, database), detailsPopup]; - Promise.all(readFileAndInfo).then(([binString, reason]) => { - if(!didCancel) - { - console.log("Uploading file of size "+binString.byteLength); - const progressScope = $rootScope.$new(); - const params = { - reason: reason, - tz: Intl.DateTimeFormat().resolvedOptions().timeZone - } - uploadConfig.forEach((url) => { - const progressPopup = $ionicPopup.show({ - title: i18next.t("upload-service.upload-database", - {db: database}), - template: i18next.t("upload-service.upload-progress", - {filesizemb: binString.byteLength / (1000 * 1000), - serverURL: uploadConfig}) - + '
', - scope: progressScope, - buttons: [ - { text: 'Cancel', type: 'button-cancel', }, - ] - }); - sendToServer(url, binString, params).then((response) => { - console.log(response); - progressPopup.close(); - const successPopup = $ionicPopup.alert({ - title: i18next.t("upload-service.upload-success"), - template: i18next.t("upload-service.upload-details", - {filesizemb: binString.byteLength / (1000 * 1000), - serverURL: uploadConfig}) - }); - }).catch(onUploadError); - }); - } - }).catch(onReadError); - }).catch(onReadError); - }; -}); diff --git a/www/js/main.js b/www/js/main.js index 94bb8aeaf..91437a07a 100644 --- a/www/js/main.js +++ b/www/js/main.js @@ -7,8 +7,7 @@ angular.module('emission.main', ['emission.main.diary', 'emission.splash.notifscheduler', 'emission.main.metrics.factory', 'emission.main.metrics.mappings', - 'emission.services', - 'emission.services.upload']) + 'emission.services']) .config(function($stateProvider) { $stateProvider.state('root.main', { From e94532596f9797035ebfc132808966fe11305861 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 5 Oct 2023 10:25:30 -0700 Subject: [PATCH 069/850] Rewrite input-matcher to typescript (not done) --- www/js/survey/input-matcher.ts | 243 +++++++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 www/js/survey/input-matcher.ts diff --git a/www/js/survey/input-matcher.ts b/www/js/survey/input-matcher.ts new file mode 100644 index 000000000..dcc31afd2 --- /dev/null +++ b/www/js/survey/input-matcher.ts @@ -0,0 +1,243 @@ +import { logDebug, displayErrorMsg } from "../plugin/logger" +import moment from "moment"; + +type LocalDt = { + minute: number, + hour: number, + second: number, + day: number, + weekday: number, + month: number, + year: number, + timezone: string +} + +type UserInputForTrip = { + data: { + end_ts: number, + start_ts: number + label: string, + start_local_dt?: LocalDt + end_local_dt?: LocalDt + }, + metadata: { + time_zone: string, + plugin: string, + write_ts: number, + platform: string, + read_ts: number, + key: string + }, + key?: string, +} + +type Trip = { + end_ts: number, + start_ts: number +} + +type TlEntry = { + key: string, + origin_key: string, + start_ts: number, + end_ts: number, + enter_ts: number, + exit_ts: number, + duration: number, + getNextEntry: any +} + +const EPOCH_MAXIMUM = 2**31 - 1; + +const fmtTs = (ts_in_secs: number, tz: string): string => moment(ts_in_secs * 1000).tz(tz).format(); + +const printUserInput = (ui: UserInputForTrip): string => `${fmtTs(ui.data.start_ts, ui.metadata.time_zone)} (${ui.data.start_ts}) -> +${fmtTs(ui.data.end_ts, ui.metadata.time_zone)} (${ui.data.end_ts}) ${ui.data.label} logged at ${ui.metadata.write_ts}`; + +const validUserInputForDraftTrip = (trip: Trip, userInput: UserInputForTrip, logsEnabled: boolean): boolean => { + if(logsEnabled) { + logDebug(`Draft trip: + comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} + -> ${fmtTs(userInput.data.end_ts, userInput.metadata.time_zone)} + trip = ${fmtTs(trip.start_ts, userInput.metadata.time_zone)} + -> ${fmtTs(trip.end_ts, userInput.metadata.time_zone)} + checks are (${userInput.data.start_ts >= trip.start_ts} + && ${userInput.data.start_ts < trip.end_ts} + || ${-(userInput.data.start_ts - trip.start_ts) <= 15 * 60}) + && ${userInput.data.end_ts <= trip.end_ts} + `); + } + + return (userInput.data.start_ts >= trip.start_ts && userInput.data.start_ts < trip.end_ts + || -(userInput.data.start_ts - trip.start_ts) <= 15 * 60) && userInput.data.end_ts <= trip.end_ts; +} + +const validUserInputForTimelineEntry = (tlEntry: TlEntry, userInput: UserInputForTrip, logsEnabled: boolean): boolean => { + if (!tlEntry.origin_key) return false; + if (tlEntry.origin_key.includes('UNPROCESSED')) return validUserInputForDraftTrip(tlEntry, userInput, logsEnabled); + + /* Place-level inputs always have a key starting with 'manual/place', and + trip-level inputs never have a key starting with 'manual/place' + So if these don't match, we can immediately return false */ + const entryIsPlace = tlEntry.origin_key === 'analysis/confirmed_place'; + const isPlaceInput = (userInput.key || userInput.metadata.key).startsWith('manual/place'); + + if (entryIsPlace !== isPlaceInput) return false; + + let entryStart = tlEntry.start_ts || tlEntry.enter_ts; + let entryEnd = tlEntry.end_ts || tlEntry.exit_ts; + + if (!entryStart && entryEnd) { + /* if a place has no enter time, this is the first start_place of the first composite trip object + so we will set the start time to the start of the day of the end time for the purpose of comparison */ + entryStart = moment.unix(entryEnd).startOf('day').unix(); + } + + if (!entryEnd) { + /* if a place has no exit time, the user hasn't left there yet + so we will set the end time as high as possible for the purpose of comparison */ + entryEnd = EPOCH_MAXIMUM; + } + + if (logsEnabled) { + logDebug(`Cleaned trip: + comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} + -> ${fmtTs(userInput.data.end_ts, userInput.metadata.time_zone)} + trip = ${fmtTs(entryStart, userInput.metadata.time_zone)} + -> ${fmtTs(entryStart, userInput.metadata.time_zone)} + start checks are ${userInput.data.start_ts >= entryStart} + && ${userInput.data.start_ts < entryEnd} + end checks are ${userInput.data.end_ts <= entryEnd} + || ${userInput.data.end_ts - entryEnd <= 15 * 60}) + `); + } + + /* For this input to match, it must begin after the start of the timelineEntry (inclusive) + but before the end of the timelineEntry (exclusive) */ + const startChecks = userInput.data.start_ts >= entryStart && userInput.data.start_ts < entryEnd; + + /* A matching user input must also finish before the end of the timelineEntry, + or within 15 minutes. */ + let endChecks = (userInput.data.end_ts <= entryEnd || (userInput.data.end_ts - entryEnd) <= 15 * 60); + + if (startChecks && !endChecks) { + const nextEntryObj = tlEntry.getNextEntry(); + if (nextEntryObj) { + const nextEntryEnd = nextEntryObj.end_ts || nextEntryObj.exit_ts; + if (!nextEntryEnd) { // the last place will not have an exit_ts + endChecks = true; // so we will just skip the end check + } else { + endChecks = userInput.data.end_ts <= nextEntryEnd; + logDebug(`Second level of end checks when the next trip is defined(${userInput.data.end_ts} <= ${nextEntryEnd}) ${endChecks}`); + } + } else { + // next trip is not defined, last trip + endChecks = (userInput.data.end_local_dt.day == userInput.data.start_local_dt.day) + logDebug("Second level of end checks for the last trip of the day"); + logDebug(`compare ${userInput.data.end_local_dt.day} with ${userInput.data.start_local_dt.day} ${endChecks}`); + } + if (endChecks) { + // If we have flipped the values, check to see that there is sufficient overlap + const overlapDuration = Math.min(userInput.data.end_ts, entryEnd) - Math.max(userInput.data.start_ts, entryStart) + logDebug(`Flipped endCheck, overlap(${overlapDuration})/trip(${tlEntry.duration} (${overlapDuration} / ${tlEntry.duration})`); + endChecks = (overlapDuration/tlEntry.duration) > 0.5; + } + } + + return startChecks && endChecks; +} + +// parallels get_not_deleted_candidates() in trip_queries.py +const getNotDeletedCandidates = (candidates:any ):any => { + console.log('getNotDeletedCandidates called with ' + candidates.length + ' candidates'); + + // We want to retain all ACTIVE entries that have not been DELETED + const allActiveList = candidates.filter(c => !c.data.status || c.data.status == 'ACTIVE'); + const allDeletedIds = candidates.filter(c => c.data.status && c.data.status == 'DELETED').map(c => c.data['match_id']); + const notDeletedActive = allActiveList.filter(c => !allDeletedIds.includes(c.data['match_id'])); + + console.log(`Found ${allActiveList.length} active entries, ${allDeletedIds.length} deleted entries -> + ${notDeletedActive.length} non deleted active entries`); + + return notDeletedActive; +} + +export const getUserInputForTrip = (trip: TlEntry, nextTrip, userInputList: UserInputForTrip[]): undefined | UserInputForTrip => { + const logsEnabled = userInputList.length < 20; + + if (userInputList === undefined) { + logDebug("In getUserInputForTrip, no user input, returning undefined"); + return undefined; + } + + if (logsEnabled) console.log(`Input list = ${userInputList.map(printUserInput)}`); + + // undefined !== true, so this covers the label view case as well + const potentialCandidates = userInputList.filter((ui) => validUserInputForTimelineEntry(trip, ui, logsEnabled)); + + if (potentialCandidates.length === 0) { + if (logsEnabled) logDebug("In getUserInputForTripStartEnd, no potential candidates, returning []"); + return undefined; + } + + if (potentialCandidates.length === 1) { + logDebug(`In getUserInputForTripStartEnd, one potential candidate, returning ${printUserInput(potentialCandidates[0])}`); + return potentialCandidates[0]; + } + + logDebug(`potentialCandidates are ${potentialCandidates.map(printUserInput)}`); + + const sortedPC = potentialCandidates.sort((pc1, pc2) => pc2.metadata.write_ts - pc1.metadata.write_ts); + const mostRecentEntry = sortedPC[0]; + logDebug("Returning mostRecentEntry "+printUserInput(mostRecentEntry)); + + return mostRecentEntry; +} + +// return array of matching additions for a trip or place +export const getAdditionsForTimelineEntry = (entry, additionsList) => { + const logsEnabled = additionsList.length < 20; + + if (additionsList === undefined) { + logDebug("In getAdditionsForTimelineEntry, no addition input, returning []"); + return []; + } + + // get additions that have not been deleted and filter out additions that do not start within the bounds of the timeline entry + const notDeleted = getNotDeletedCandidates(additionsList); + const matchingAdditions = notDeleted.filter((ui) => validUserInputForTimelineEntry(entry, ui, logsEnabled)); + + if (logsEnabled) console.log(`Matching Addition list ${matchingAdditions.map(printUserInput)}`); + + return matchingAdditions; +} + +export const getUniqueEntries = (combinedList) => { + /* we should not get any non-ACTIVE entries here + since we have run filtering algorithms on both the phone and the server */ + const allDeleted = combinedList.filter(c => c.data.status && c.data.status == 'DELETED'); + + if (allDeleted.length > 0) { + displayErrorMsg("Found "+allDeletedEntries.length +" non-ACTIVE addition entries while trying to dedup entries", allDeletedEntries); + } + + const uniqueMap = new Map(); + combinedList.forEach((e) => { + const existingVal = uniqueMap.get(e.data.match_id); + /* if the existing entry and the input entry don't match and they are both active, we have an error + let's notify the user for now */ + if (existingVal) { + if ((existingVal.data.start_ts != e.data.start_ts) || + (existingVal.data.end_ts != e.data.end_ts) || + (existingVal.data.write_ts != e.data.write_ts)) { + displayErrorMsg(`Found two ACTIVE entries with the same match ID but different timestamps ${existingVal.data.match_id}` + , `${JSON.stringify(existingVal)} vs ${JSON.stringify(e)}`); + } else { + console.log(`Found two entries with match_id ${existingVal.data.match_id} but they are identical`); + } + } else { + uniqueMap.set(e.data.match_id, e); + } + }); + return Array.from(uniqueMap.values()); +} From c159b2fa0b4be2b61785185cf5cfbbbdb4ebbe74 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 5 Oct 2023 10:43:20 -0700 Subject: [PATCH 070/850] We need jest-environment-jsdom since "jest-environment-jsdom" is no longer shipped by default --- package.serve.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.serve.json b/package.serve.json index d74afedef..ac2162e71 100644 --- a/package.serve.json +++ b/package.serve.json @@ -38,6 +38,7 @@ "expose-loader": "^4.1.0", "file-loader": "^6.2.0", "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "phonegap": "9.0.0+cordova.9.0.0", "process": "^0.11.10", "sass": "^1.62.1", From 2e9173b899fdcfe15a2d1094aca66107c05665a7 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 5 Oct 2023 14:24:16 -0700 Subject: [PATCH 071/850] Fixed issue with UnixInteger conversion See comments on [the PR Draft](https://github.com/e-mission/e-mission-phone/pull/1052). Luxon and Moment.js seem to handle the `endOf('day')` slightly differently. The remedy for this was to simulate the old Unix Integer conversion for the end date, but use the contemporary one for the start date. This adjustment fixed the download, and worked for the multiple timelines tested. --- www/js/services.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/js/services.js b/www/js/services.js index 29f6dc491..99be7d094 100644 --- a/www/js/services.js +++ b/www/js/services.js @@ -199,13 +199,13 @@ angular.module('emission.services', ['emission.plugin.logger']) }); }; - // Simulate old conversion to get correct UnixInteger for fetching data + // Simulate old conversion to get correct UnixInteger for endMoment data const getUnixNum = (dateData) => { var tempDate = dateData.toFormat('dd MMM yyyy'); return DateTime.fromFormat(tempDate, "dd MMM yyyy").toUnixInteger(); }; - getRawEntries(null, getUnixNum(startTime), getUnixNum(endTime)) + getRawEntries(null, getUnixNum(startTime), startTime.toUnixInteger()) .then(writeDumpFile) .then(emailData) .then(function() { From c3c15b44b2942d0563d7f1a49170d677a4f4e002 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Thu, 5 Oct 2023 15:36:11 -0600 Subject: [PATCH 072/850] Added basic testing for useImperialConfig.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useImperialConfig.ts - Added exports to the functions, so that we can test them _(can't use `export function useImperialConfig()` as it gets caught in invalid React hooks with `useState`)_ useImperialConfig.test.ts - Using mock of `useAppConfig` so that the testing doesn't terminate by following the chain of import statements within `useAppConfig` - Added basic testing of the functions **2 of the tests are failing, as they are not rounding properly** 1: ``` convertSpeed › should convert meters per second to miles per hour when imperial flag is true expect(received).toBe(expected) // Object.is equality Expected: 15 Received: 14.99999535936 ``` 2: ``` convertDistance › should convert meters to miles when imperial flag is true expect(received).toBe(expected) // Object.is equality Expected: 1 Received: 0.99999720514 ``` --- www/__tests__/useImperialConfig.test.ts | 49 +++++++++++++++++++++++++ www/js/config/useImperialConfig.ts | 4 +- 2 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 www/__tests__/useImperialConfig.test.ts diff --git a/www/__tests__/useImperialConfig.test.ts b/www/__tests__/useImperialConfig.test.ts new file mode 100644 index 000000000..f8a944c02 --- /dev/null +++ b/www/__tests__/useImperialConfig.test.ts @@ -0,0 +1,49 @@ +import { convertDistance, convertSpeed, formatForDisplay } from '../js/config/useImperialConfig'; + + +// This mock is required, or else the test will dive into the import chain of useAppConfig.ts and fail when it gets to the root +jest.mock('../js/useAppConfig', () => { + return jest.fn(() => ({ + appConfig: { + use_imperial: false + }, + loading: false + })); +}); + +describe('formatForDisplay', () => { + it('should round to the nearest integer when value is >= 100', () => { + expect(formatForDisplay(105)).toBe('105'); + expect(formatForDisplay(119)).toBe('119'); + }); + + it('should round to 3 significant digits when 1 <= value < 100', () => { + expect(formatForDisplay(7.02)).toBe('7.02'); + expect(formatForDisplay(11.3)).toBe('11.3'); + }); + + it('should round to 2 decimal places when value < 1', () => { + expect(formatForDisplay(0.07)).toBe('0.07'); + expect(formatForDisplay(0.75)).toBe('0.75'); + }); +}); + +describe('convertDistance', () => { + it('should convert meters to kilometers by default', () => { + expect(convertDistance(1000, false)).toBe(1); + }); + + it('should convert meters to miles when imperial flag is true', () => { + expect(convertDistance(1609.34, true)).toBe(1); // Approximately 1 mile + }); +}); + +describe('convertSpeed', () => { + it('should convert meters per second to kilometers per hour by default', () => { + expect(convertSpeed(10, false)).toBe(36); + }); + + it('should convert meters per second to miles per hour when imperial flag is true', () => { + expect(convertSpeed(6.7056, true)).toBe(15); // Approximately 15 mph + }); +}); diff --git a/www/js/config/useImperialConfig.ts b/www/js/config/useImperialConfig.ts index 7ad0d37ac..a9680048c 100644 --- a/www/js/config/useImperialConfig.ts +++ b/www/js/config/useImperialConfig.ts @@ -24,13 +24,13 @@ export const formatForDisplay = (value: number): string => { return Intl.NumberFormat(i18next.language, opts).format(value); } -const convertDistance = (distMeters: number, imperial: boolean): number => { +export const convertDistance = (distMeters: number, imperial: boolean): number => { if (imperial) return (distMeters / 1000) * KM_TO_MILES; return distMeters / 1000; } -const convertSpeed = (speedMetersPerSec: number, imperial: boolean): number => { +export const convertSpeed = (speedMetersPerSec: number, imperial: boolean): number => { if (imperial) return speedMetersPerSec * MPS_TO_KMPH * KM_TO_MILES; return speedMetersPerSec * MPS_TO_KMPH; From 76b6eb8b5ae8472a85fd16e82d7b5bf3a9da380e Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Thu, 5 Oct 2023 15:56:04 -0600 Subject: [PATCH 073/850] Fixed test fails due to floating point approximations Changed the `toBe`s for the floating point tests that fail due to rounding differences to `toBeCloseTo`s --- www/__tests__/useImperialConfig.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/__tests__/useImperialConfig.test.ts b/www/__tests__/useImperialConfig.test.ts index f8a944c02..b78fe974d 100644 --- a/www/__tests__/useImperialConfig.test.ts +++ b/www/__tests__/useImperialConfig.test.ts @@ -34,7 +34,7 @@ describe('convertDistance', () => { }); it('should convert meters to miles when imperial flag is true', () => { - expect(convertDistance(1609.34, true)).toBe(1); // Approximately 1 mile + expect(convertDistance(1609.34, true)).toBeCloseTo(1); // Approximately 1 mile }); }); @@ -44,6 +44,6 @@ describe('convertSpeed', () => { }); it('should convert meters per second to miles per hour when imperial flag is true', () => { - expect(convertSpeed(6.7056, true)).toBe(15); // Approximately 15 mph + expect(convertSpeed(6.7056, true)).toBeCloseTo(15); // Approximately 15 mph }); }); From 8825d06bee608149d83575cfb626509c5603a44f Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 5 Oct 2023 15:16:13 -0700 Subject: [PATCH 074/850] Updated Logger usage, cleaned up controlHelper Adjusted `js/services.js` such that it only uses the constants exported from `js/plugins/logger.ts`. Removed the member function `writeFile` from the controlHelper, as it was not being used. --- www/js/services.js | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/www/js/services.js b/www/js/services.js index 99be7d094..1a667c757 100644 --- a/www/js/services.js +++ b/www/js/services.js @@ -3,7 +3,7 @@ import angular from 'angular'; import { getRawEntries } from './commHelper'; import { DateTime } from 'luxon'; - +import { logInfo, displayError } from './plugin/logger' angular.module('emission.services', ['emission.plugin.logger']) .service('ReferHelper', function($http) { @@ -23,7 +23,7 @@ angular.module('emission.services', ['emission.plugin.logger']) //}*/ } }) -.service('UnifiedDataLoader', function($window, Logger) { +.service('UnifiedDataLoader', function($window) { var combineWithDedup = function(list1, list2) { var combinedList = list1.concat(list2); return combinedList.filter(function(value, i, array) { @@ -52,10 +52,10 @@ angular.module('emission.services', ['emission.plugin.logger']) if (localError && remoteError) { reject([localError, remoteError]); } else { - Logger.log("About to dedup localResult = "+localResult.length + logInfo("About to dedup localResult = "+localResult.length +"remoteResult = "+remoteResult.length); var dedupedList = combiner(localResult, remoteResult); - Logger.log("Deduped list = "+dedupedList.length); + logInfo("Deduped list = "+dedupedList.length); resolve(dedupedList); } } @@ -104,13 +104,7 @@ angular.module('emission.services', ['emission.plugin.logger']) } }) .service('ControlHelper', function($window, - $ionicPopup, - Logger) { - - this.writeFile = function(fileEntry, resultList) { - // Create a FileWriter object for our FileEntry (log.txt). - } - + $ionicPopup) { this.getMyData = function(startTs) { // We are only retrieving data for a single day to avoid // running out of memory on the phone @@ -149,7 +143,6 @@ angular.module('emission.services', ['emission.plugin.logger']) { type: 'application/json' }); fileWriter.write(dataObj); }); - // this.writeFile(fileEntry, resultList); }); }); }); @@ -209,10 +202,10 @@ angular.module('emission.services', ['emission.plugin.logger']) .then(writeDumpFile) .then(emailData) .then(function() { - Logger.log("Email queued successfully"); + logInfo("Email queued successfully"); }) .catch(function(error) { - Logger.displayError("Error emailing JSON dump", error); + displayError(error, "Error emailing JSON dump"); }) }; From 51533ef94365f7f1baa96c577ec70e869663cb38 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 5 Oct 2023 16:40:18 -0600 Subject: [PATCH 075/850] update fetch POST need to pass the file in its original form and not stringify it in order for the data to actually make it to the server This change is pending restoration of the reason and tz paramenters --- www/js/control/uploadService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/control/uploadService.ts b/www/js/control/uploadService.ts index 7eee1217a..e51d64068 100644 --- a/www/js/control/uploadService.ts +++ b/www/js/control/uploadService.ts @@ -79,7 +79,7 @@ const sendToServer = function upload(url, binArray, params) { return fetch(url, { method: 'POST', headers: {'Content-Type': undefined }, - body: JSON.stringify({ data: binArray, params: params }) + body: binArray } ) } From 69ada008b37a4f0dfb38fca060b79038f8dcdb74 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 6 Oct 2023 13:20:34 -0600 Subject: [PATCH 076/850] restore url encoding for additional params --- www/js/control/uploadService.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/www/js/control/uploadService.ts b/www/js/control/uploadService.ts index e51d64068..93557bdfe 100644 --- a/www/js/control/uploadService.ts +++ b/www/js/control/uploadService.ts @@ -76,7 +76,8 @@ const sendToServer = function upload(url, binArray, params) { //this was the best way I could find to contact the database, //had to modify the way it gets handled on the other side //the original way it could not find "reason" - return fetch(url, { + const urlParams = "?reason=" + params.reason + "&tz=" + params.tz; + return fetch(url+urlParams, { method: 'POST', headers: {'Content-Type': undefined }, body: binArray From 75aeb48e91ecc2c647c4e3b8d842abdf6e5e172e Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 6 Oct 2023 13:28:37 -0600 Subject: [PATCH 077/850] update mock In the previous commits, I altered the call to POST, so I needed to alter the way I mocked that call! --- www/__tests__/uploadService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/__tests__/uploadService.test.ts b/www/__tests__/uploadService.test.ts index c8c0e6ebc..c0c7f9d04 100644 --- a/www/__tests__/uploadService.test.ts +++ b/www/__tests__/uploadService.test.ts @@ -20,7 +20,7 @@ let message = ""; global.fetch = (url: string, options: {method: string, headers: {}, body: string}) => new Promise((rs, rj) => { if(options) { - message = "got " + options.method + options.body; + message = "sent " + options.method + options.body + " for " + url;; rs('sent ' + options.method + options.body + ' to ' + url); } else { setTimeout(() => rs({ From 32596a8e36174d0f98f7e9ad211627a3f9c99c7e Mon Sep 17 00:00:00 2001 From: niccolopaganini Date: Mon, 9 Oct 2023 12:00:03 -0600 Subject: [PATCH 078/850] Updated README file: Restored to the original - 1. Removed versions 2. Added CI badge 3. Plugins installation guide updated --- README.md | 238 +++++++++++++++++++++++++----------------------------- 1 file changed, 108 insertions(+), 130 deletions(-) diff --git a/README.md b/README.md index 9ac9ceb1d..7fca214aa 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,41 @@ -# [e-mission phone app](https://github.com/e-mission/e-mission-phone/tree/master) +e-mission phone app +-------------------- -__This is the phone component of the e-mission system.__ +This is the phone component of the e-mission system. :sparkles: This has been upgraded to the latest **Android**, **iOS**, **cordova-lib**, **node** and **npm** versions. __This is ready to build out of the box.__ -✨ We constantly upgrade the repo to the latest cordova versions of android, iOS, cordova-lib, and the most recent node and npm versions. The CI will be up-to-date. - For the latest versions, refer [`package.cordovabuild.json`](package.cordovabuild.json) -## Additional Documentation +Additional Documentation +--- Additional documentation has been moved to its own repository [e-mission-docs](https://github.com/e-mission/e-mission-docs). Specific e-mission-phone wikis can be found here: https://github.com/e-mission/e-mission-docs/tree/master/docs/e-mission-phone **Issues:** Since this repository is part of a larger project, all issues are tracked [in the central docs repository](https://github.com/e-mission/e-mission-docs/issues). If you have a question, [as suggested by the open source guide](https://opensource.guide/how-to-contribute/#communicating-effectively), please file an issue instead of sending an email. Since issues are public, other contributors can try to answer the question and benefit from the answer. -:sparkles: Check [Contributing](#contributing) if you're interested in contributing for this project :sparkles: - ## Contents #### 1. [Updating the UI only](#updating-the-ui-only) -#### 2. [Updating the e-mission-* plugins or adding new plugins](#updating-the-e-mission--plugins-or-adding-new-plugins) -#### 3. [Creating logos](#creating-logos) -#### 4. [End to End Testing](#end-to-end-testing) +#### 2. [End to End Testing](#end-to-end-testing) +#### 3. [Updating the e-mission-* plugins or adding new plugins](#updating-the-e-mission--plugins-or-adding-new-plugins) +#### 4. [Creating logos](#creating-logos) #### 5. [Beta-testing debugging](#beta-testing-debugging) #### 6. [Contributing](#contributing) -#### 7. [Troubleshooting](#troubleshooting) +Updating the UI only --- - - -## Updating the UI only -[![osx-serve-install](https://github.com/e-mission/e-mission-phone/workflows/osx-serve-install/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/serve-install.yml) +[![osx-serve-install](https://github.com/e-mission/e-mission-phone/workflows/osx-serve-install/badge.svg)](https://github.com/e-mission/e-mission-phone/actions?query=workflow%3Aosx-serve-install) If you want to make only UI changes, (as opposed to modifying the existing plugins, adding new plugins, etc), you can use the **new and improved** (as of June 2018) [e-mission dev app](https://github.com/e-mission/e-mission-devapp/) and install the most recent version from [releases](https://github.com/e-mission/e-mission-devapp/releases). ### Installing (one-time) -:point_right:Run the setup script +Run the setup script ``` bash setup/setup_serve.sh ``` + **(optional)** Configure by changing the files in `www/json`. Defaults are in `www/json/*.sample` @@ -50,15 +46,16 @@ cp ..... www/json/connectionConfig.json ``` ### Activation (after install, and in every new shell) + ``` source setup/activate_serve.sh ``` ### Running -Start the phonegap deployment server and note the URL(s) that the server is listening to. +1. Start the phonegap deployment server and note the URL(s) that the server is listening to. - + ``` npm run serve .... [phonegap] listening on 10.0.0.14:3000 @@ -67,11 +64,12 @@ Start the phonegap deployment server and note the URL(s) that the server is list [phonegap] ctrl-c to stop the server [phonegap] .... + ``` -Change the devapp connection URL to one of these (e.g. 192.168.162.1:3000) and press "Connect" -The app will now display the version of e-mission app that is in your local directory -The console logs will be displayed back in the server window (prefaced by `[console]`) -Breakpoints can be added by connecting through the browser +1. Change the devapp connection URL to one of these (e.g. 192.168.162.1:3000) and press "Connect" +1. The app will now display the version of e-mission app that is in your local directory + 1. The console logs will be displayed back in the server window (prefaced by `[console]`) + 1. Breakpoints can be added by connecting through the browser - Safari ([enable develop menu](https://support.apple.com/guide/safari/use-the-safari-develop-menu-sfri20948/mac)): Develop -> Simulator -> index.html - Chrome: chrome://inspect -> Remote target (emulator) @@ -79,9 +77,9 @@ Breakpoints can be added by connecting through the browser **Note1**: You may need to scroll up, past all the warnings about `Content Security Policy has been added` to find the port that the server is listening to. -## End to End Testing - -A lot of the visualizations that we display in the phone client come from the server. In order to do end-to-end testing, we need to run a local server and connect to it. Instructions for: +End to end testing +--- +A lot of the visualizations that we display in the phone client come from the server. In order to do end to end testing, we need to run a local server and connect to it. Instructions for: 1. installing a local server, 2. running it, @@ -90,51 +88,25 @@ A lot of the visualizations that we display in the phone client come from the se are available in the [e-mission-server README](https://github.com/e-mission/e-mission-server/blob/master/README.md). -In order to make end-to-end testing even easier, we have moved from phone app to a dynamic config setting. We use the dynamic config to specify the server locations. Refer [Doc](https://github.com/e-mission/nrel-openpath-deploy-configs) - -If you have the [e-mission-server](https://github.com/e-mission/e-mission-server) running at a specific URL or IP address, and you want to connect to it, you would have to specify that URL or IP in the `server` field of your dynamic config file. +In order to make end to end testing easy, if the local server is started on a HTTP (versus HTTPS port), it is in development mode. By default, the phone app connects to the local server (localhost on iOS, [10.0.2.2 on android](https://stackoverflow.com/questions/5806220/how-to-connect-to-my-http-localhost-web-server-from-android-emulator-in-eclips)) with the `prompted-auth` authentication method. To connect to a different server, or to use a different authentication method, you need to create a `www/json/connectionConfig.json` file. More details on configuring authentication [can be found in the docs](https://github.com/e-mission/e-mission-docs/blob/master/docs/install/configuring_authentication.md). One advantage of using `skip` authentication in development mode is that any user email can be entered without a password. Developers can use one of the emails that they loaded test data for in step (3) above. So if the test data loaded was with `-u shankari@eecs.berkeley.edu`, then the login email for the phone app would also be `shankari@eecs.berkeley.edu`. -## Updating the e-mission-\* plugins or adding new plugins - +Updating the e-mission-\* plugins or adding new plugins +--- [![osx-build-ios](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml) [![osx-build-android](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml) +[![osx-android-prereq-sdk-install](https://github.com/e-mission/e-mission-phone/actions/workflows/android-automated-sdk-install.yml/badge.svg)](https://github.com/e-mission/e-mission-phone/actions/workflows/android-automated-sdk-install.yml) Pre-requisites --- -- The version of xcode used by the CI. +- the version of xcode used by the CI - to install a particular version, use [xcode-select](https://www.unix.com/man-page/OSX/1/xcode-select/) - or this [supposedly easier to use repo](https://github.com/xcpretty/xcode-install) + - **NOTE**: the basic xcode install on Catalina was messed up for me due to a prior installation of command line tools. [These workarounds helped](https://github.com/nodejs/node-gyp/blob/master/macOS_Catalina.md). - git -- Java 17. Tested with [OpenJDK 17 (Temurin) using AdoptOpenJDK](https://adoptium.net). -- Always use [homebrew](https://brew.sh) in addition to CLI - - this allows us to install the current version of cocoapods without - running into ruby incompatibilities - e.g. - https://github.com/CocoaPods/CocoaPods/issues/11763 - -:triangular_flag_on_post: __Important__ - -Most of the recent issues encountered have been due to incompatible setup. We -have now: -- locked down the dependencies, -- created setup and teardown scripts to setup self-contained environments with - those dependencies, and -- CI enabled to validate that they continue work. - -If you have setup failures, please compare the configuration in the **passing CI -builds** with your configuration. That is almost certainly the source of the error. - -__Export statements__ -``` -export ANDROID_SDK_ROOT="/Users//Library/Android/sdk" -``` -``` -export ANDROID_HOME="/Users//Library/Android/sdk" -``` -aka the path where you want the SDK to be installed. - -- android SDK; install manually or use setup script below (**recommended**). Note that you only need to run this once **per computer**. +- Java 17. Tested with [OpenJDK 17 (Temurin) using Adoptium](https://adoptium.net). +- android SDK; install manually or use setup script below. Note that you only need to run this once **per computer**. ``` bash setup/prereq_android_sdk_install.sh ``` @@ -159,11 +131,26 @@ aka the path where you want the SDK to be installed. ``` +- if you are not on the most recent version of OSX, `homebrew` + - this allows us to install the current version of cocoapods without + running into ruby incompatibilities - e.g. + https://github.com/CocoaPods/CocoaPods/issues/11763 +Important +--- +Most of the recent issues encountered have been due to incompatible setup. We +have now: +- locked down the dependencies, +- created setup and teardown scripts to setup self-contained environments with + those dependencies, and +- CI enabled to validate that they continue work. -__Installing (one time only)__ +If you have setup failures, please compare the configuration in the passing CI +builds with your configuration. That is almost certainly the source of the error. -- Run the setup script for the platform you want to build +Installing (one time only) +--- +Run the setup script for the platform you want to build ``` bash setup/setup_android_native.sh @@ -182,18 +169,12 @@ cp www/json/startupConfig.json.sample www/json/startupConfig.json cp ..... www/json/connectionConfig.json ``` -If connecting to a development server over http, make sure to turn on http support on android - -``` - - - -``` -__Run this in every new shell for Activation__ +### Activation (after install, and in every new shell) ``` source setup/activate_native.sh ``` +
Expected Output ``` @@ -210,20 +191,30 @@ Copied config.cordovabuild.xml -> config.xml and package.cordovabuild.json -> pa ```
-
- __Pick a type of build and execute the following:__ -More "versions" are available in [`package.cordovabuild.json`](package.cordovabuild.json) +### Activation (after install, and in every new shell) + +If connecting to a development server over http, make sure to turn on http support on android + ``` -npm run + + + ``` -For instance: (build-dev-android) +### Run in the emulator + ``` -npm run build-dev-android +npm run ``` -
Your expected output should look something like this +AND/OR +``` +npm run +``` +for builds, refer [`package.cordovabuild.json`](package.cordovabuild.json) + +
Expected output ``` BUILD SUCCESSFUL in 2m 48s @@ -234,10 +225,8 @@ Built the following apk(s):
-
- -## Creating logos - +Creating logos +--- If you are building your own version of the app, you must have your own logo to avoid app store conficts. Updating the logo is very simple using the [`ionic cordova resources`](https://ionicframework.com/docs/v3/cli/cordova/resources/) @@ -245,7 +234,21 @@ command. **Note**: You may have to install the [`cordova-res` package](https://github.com/ionic-team/cordova-res) for the command to work -## Beta-testing debugging + +Troubleshooting +--- +- Make sure to use `npx ionic` and `npx cordova`. This is + because the setup script installs all the modules locally in a self-contained + environment using `npm install` and not `npm install -g` +- Check the CI to see whether there is a known issue +- Run the commands from the script one by one and see which fails + - compare the failed command with the CI logs +- Another workaround is to delete the local environment and recreate it + - javascript errors: `rm -rf node_modules && npm install` + - native code compile errors: `rm -rf plugins && rm -rf platforms && npx cordova prepare` + +Beta-testing debugging +--- If users run into problems, they have the ability to email logs to the maintainer. These logs are in the form of an sqlite3 database, so they have to be opened using `sqlite3`. Alternatively, you can export it to a csv with @@ -253,38 +256,37 @@ dates using the `bin/csv_export_add_date.py` script. ``` -mv ~/Downloads/loggerDB /tmp/logger. -pwd +$ mv ~/Downloads/loggerDB /tmp/logger. +$ pwd .../e-mission-phone -python bin/csv_export_add_date.py /tmp/loggerDB. -less /tmp/loggerDB..withdate.log +$ python bin/csv_export_add_date.py /tmp/loggerDB. +$ less /tmp/loggerDB..withdate.log ``` -## Contributing +Contributing +--- -:point_right:Add the main repo as upstream -``` -git remote add upstream https://github.com/e-mission/e-mission-phone -``` -:point_right:Create a new branch (IMPORTANT). Please do not submit pull requests from master -``` -git checkout -b -``` -:point_right:Make changes to the branch and commit them -``` -git commit -``` -:point_right:Push the changes to your local fork -``` -git push origin -``` -:point_right:Generate a pull request from the UI +Add the main repo as upstream + + git remote add upstream https://github.com/e-mission/e-mission-phone.git + +Create a new branch (IMPORTANT). Please do not submit pull requests from master + + git checkout -b mybranch -
+Make changes to the branch and commit them -__\*__Address my review comments__\*__ + git commit -Once I merge the pull request :smiley: :tada:, pull the changes to your fork and delete the branch +Push the changes to your local fork + + git push origin mybranch + +Generate a pull request from the UI + +Address my review comments + +Once I merge the pull request, pull the changes to your fork and delete the branch ``` git checkout master ``` @@ -297,27 +299,3 @@ git push origin master ``` git branch -d ``` - ---- -### Troubleshooting -:point_right:Xcode command line tools -``` -Warning: No developer tools installed. -You should install the Command Line Tools. -``` -``` -xcode-select --install -``` - -:point_right:Creating Logos -- Make sure to use `npx ionic` and `npx cordova`. This is - because the setup script installs all the modules locally in a self-contained - environment using `npm install` and not `npm install -g` -- Check the CI to see whether there is a known issue -- Run the commands from the script one by one and see which fails - - compare the failed command with the CI logs -- Another workaround is to delete the local environment and recreate it - - javascript errors: `rm -rf node_modules && npm install` - - native code compile errors: `rm -rf plugins && rm -rf platforms && npx cordova prepare` - -(For updating the e-mission-plugins or adding new plugins) **NOTE**: the basic xcode install on Mac OS Catalina was messed up for me due to a prior installation of command line tools. [These workarounds helped](https://github.com/nodejs/node-gyp/blob/master/macOS_Catalina.md). From 46832c7e8a429c25ec8e361009a583161981e6f4 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Mon, 9 Oct 2023 12:13:36 -0700 Subject: [PATCH 079/850] Rewrote controlHelper as a separate TS file controlHelper no longer utilizes Angular services. The internal functions were separated into their own exports. File write is confirmed to be working, need to write tests and verify all other functions are working as intended. --- www/js/control/DataDatePicker.tsx | 6 +- www/js/control/ProfileSettings.jsx | 6 +- www/js/controlHelper.ts | 125 +++++++++++++++++++++++++++++ www/js/services.js | 119 +-------------------------- 4 files changed, 131 insertions(+), 125 deletions(-) create mode 100644 www/js/controlHelper.ts diff --git a/www/js/control/DataDatePicker.tsx b/www/js/control/DataDatePicker.tsx index 83e0986b2..f2873ef66 100644 --- a/www/js/control/DataDatePicker.tsx +++ b/www/js/control/DataDatePicker.tsx @@ -4,12 +4,10 @@ import React from "react"; import { DatePickerModal } from 'react-native-paper-dates'; import { useTranslation } from "react-i18next"; -import { getAngularService } from "../angular-react-helper"; +import { getMyData } from "../controlHelper"; const DataDatePicker = ({date, setDate, open, setOpen, minDate}) => { const { t, i18n } = useTranslation(); //able to pull lang from this - const ControlHelper = getAngularService("ControlHelper"); - const onDismiss = React.useCallback(() => { setOpen(false); }, [setOpen]); @@ -18,7 +16,7 @@ const DataDatePicker = ({date, setDate, open, setOpen, minDate}) => { (params) => { setOpen(false); setDate(params.date); - ControlHelper.getMyData(params.date); + getMyData(params.date); }, [setOpen, setDate] ); diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 4a263acc5..72d75aa80 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -24,6 +24,7 @@ import { AppContext } from "../App"; import { shareQR } from "../components/QrCode"; import { storageClear } from "../plugin/storage"; import { getAppVersion } from "../plugin/clientStats"; +import { fetchOPCode, getSettings } from "../controlHelper"; //any pure functions can go outside const ProfileSettings = () => { @@ -38,7 +39,6 @@ const ProfileSettings = () => { const UploadHelper = getAngularService('UploadHelper'); const EmailHelper = getAngularService('EmailHelper'); const NotificationScheduler = getAngularService('NotificationScheduler'); - const ControlHelper = getAngularService('ControlHelper'); const StartPrefs = getAngularService('StartPrefs'); //functions that come directly from an Angular service @@ -196,7 +196,7 @@ const ProfileSettings = () => { }, [editSync]); async function getConnectURL() { - ControlHelper.getSettings().then(function(response) { + getSettings().then(function(response) { var newConnectSettings ={} newConnectSettings.url = response.connectUrl; console.log(response); @@ -208,7 +208,7 @@ const ProfileSettings = () => { async function getOPCode() { const newAuthSettings = {}; - const opcode = await ControlHelper.getOPCode(); + const opcode = await fetchOPCode(); if(opcode == null){ newAuthSettings.opcode = "Not logged in"; } else { diff --git a/www/js/controlHelper.ts b/www/js/controlHelper.ts new file mode 100644 index 000000000..2f341a12a --- /dev/null +++ b/www/js/controlHelper.ts @@ -0,0 +1,125 @@ +import { DateTime } from "luxon"; + +import { getRawEntries } from "./commHelper"; +import { logInfo, displayError } from "./plugin/logger"; +import i18next from "./i18nextInit" ; + +interface fsWindow extends Window { + requestFileSystem: ( + type: number, + size: number, + successCallback: (fs: any) => void, + errorCallback?: (error: any) => void + ) => void; + LocalFileSystem: { + TEMPORARY: number; + PERSISTENT: number; + }; +}; + +declare let window: fsWindow; + +export const getMyData = function(startTs: Date) { + // We are only retrieving data for a single day to avoid + // running out of memory on the phone + var startTime = DateTime.fromJSDate(startTs); + var endTime = startTime.endOf("day"); + var startTimeString = startTime.toFormat("yyyy'-'MM'-'dd"); + var endTimeString = endTime.toFormat("yyyy'-'MM'-'dd"); + + var dumpFile = startTimeString + "." + + endTimeString + + ".timeline"; + alert(`Going to retrieve data to ${dumpFile}`); + + const writeDumpFile = function(result) { + var resultList = result.phone_data; + return new Promise(function(resolve, reject) { + window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { + console.log(`file system open: ${fs.name}`); + fs.root.getFile(dumpFile, { create: true, exclusive: false }, function (fileEntry) { + console.log(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`) + fileEntry.createWriter(function (fileWriter) { + fileWriter.onwriteend = function() { + console.log("Successful file write..."); + resolve(); + } + fileWriter.onerror = function(e) { + console.log(`Failed file write: ${e.toString()}`); + reject(); + } + + // if data object is not passed in, create a new blog instead. + var dataObj = new Blob([JSON.stringify(resultList, null, 2)], + { type: "application/json" }); + fileWriter.write(dataObj); + }) + + }); + console.log(`Done!`); + }); + }); + } + + var emailData = function() { + return new Promise(function(resolve, reject) { + window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { + console.log("During email, file system open: " + fs.name); + fs.root.getFile(dumpFile, null, function(fileEntry) { + console.log(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`); + fileEntry.file(function(file) { + var reader = new FileReader(); + + reader.onloadend = function() { + const readResult = this.result as string; + console.log(`Successfull file read with ${readResult.length} characters`); + var dataArray = JSON.parse(readResult); + console.log(`Successfully read resultList of size ${dataArray.length}`); + var attachFile = fileEntry.nativeURL; + if (window['device'].platform === "android") + attachFile = "app://cache/" + dumpFile; + if (window['device'].platform === "ios") + alert(i18next.t("email-service.email-account-mail-app")); + var email = { + attachments: [ + attachFile + ], + subject: i18next.t("email-service.email-data.subject-data-dump-from-to", {start: startTimeString ,end: endTimeString}), + body: i18next.t("email-service.email-data.body-data-consists-of-list-of-entries") + }; + window['cordova'].plugins.email.open(email).then(resolve()); + } + reader.readAsText(file); + }, function(error) { + displayError(error, "Error while downloading JSON dump"); + reject(error); + }) + }); + }); + }); + }; + + // Simulate old conversion to get correct UnixInteger for endMoment data + const getUnixNum = (dateData: DateTime) => { + var tempDate = dateData.toFormat("dd MMM yyyy"); + return DateTime.fromFormat(tempDate, "dd MMM yyyy").toUnixInteger(); + }; + + getRawEntries(null, getUnixNum(startTime), startTime.toUnixInteger()) + .then(writeDumpFile) + .then(emailData) + .then(function() { + logInfo("Email queued successfully"); + }) + .catch(function(error) { + displayError(error, "Error emailing JSON dump"); + }) +}; + +export const fetchOPCode = (() => { + return window["cordova"].plugins.OPCodeAuth.getOPCode(); + }); + +export const getSettings = (() => { + return window["cordova"].plugins.BEMConnectionSettings.getSettings(); +}); \ No newline at end of file diff --git a/www/js/services.js b/www/js/services.js index 1a667c757..e406be203 100644 --- a/www/js/services.js +++ b/www/js/services.js @@ -2,8 +2,7 @@ import angular from 'angular'; import { getRawEntries } from './commHelper'; -import { DateTime } from 'luxon'; -import { logInfo, displayError } from './plugin/logger' +import { logInfo} from './plugin/logger' angular.module('emission.services', ['emission.plugin.logger']) .service('ReferHelper', function($http) { @@ -103,122 +102,6 @@ angular.module('emission.services', ['emission.plugin.logger']) return combinedPromise(localPromise, remotePromise, combineWithDedup); } }) -.service('ControlHelper', function($window, - $ionicPopup) { - this.getMyData = function(startTs) { - // We are only retrieving data for a single day to avoid - // running out of memory on the phone - var startTime = DateTime.fromJSDate(startTs); - var endTime = startTime.endOf("day"); - var startTimeString = startTime.toFormat("yyyy'-'MM'-'dd"); - var endTimeString = endTime.toFormat("yyyy'-'MM'-'dd"); - - var dumpFile = startTimeString + "." - + endTimeString - + ".timeline"; - alert("Going to retrieve data to "+dumpFile); - - var writeDumpFile = function(result) { - return new Promise(function(resolve, reject) { - var resultList = result.phone_data; - window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { - console.log('file system open: ' + fs.name); - fs.root.getFile(dumpFile, { create: true, exclusive: false }, function (fileEntry) { - console.log("fileEntry "+fileEntry.nativeURL+" is file?" + fileEntry.isFile.toString()); - fileEntry.createWriter(function (fileWriter) { - fileWriter.onwriteend = function() { - console.log("Successful file write..."); - resolve(); - // readFile(fileEntry); - }; - - fileWriter.onerror = function (e) { - console.log("Failed file write: " + e.toString()); - reject(); - }; - - // If data object is not passed in, - // create a new Blob instead. - var dataObj = new Blob([JSON.stringify(resultList, null, 2)], - { type: 'application/json' }); - fileWriter.write(dataObj); - }); - }); - }); - }); - } - - - var emailData = function(result) { - return new Promise(function(resolve, reject) { - window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { - console.log("During email, file system open: "+fs.name); - fs.root.getFile(dumpFile, null, function(fileEntry) { - console.log("fileEntry "+fileEntry.nativeURL+" is file?"+fileEntry.isFile.toString()); - fileEntry.file(function (file) { - var reader = new FileReader(); - - reader.onloadend = function() { - console.log("Successful file read with " + this.result.length +" characters"); - var dataArray = JSON.parse(this.result); - console.log("Successfully read resultList of size "+dataArray.length); - // displayFileData(fileEntry.fullPath + ": " + this.result); - var attachFile = fileEntry.nativeURL; - if (ionic.Platform.isAndroid()) { - // At least on nexus, getting a temporary file puts it into - // the cache, so I can hardcode that for now - attachFile = "app://cache/"+dumpFile; - } - if (ionic.Platform.isIOS()) { - alert(i18next.t('email-service.email-account-mail-app')); - } - var email = { - attachments: [ - attachFile - ], - subject: i18next.t('email-service.email-data.subject-data-dump-from-to', {start: startTimeString ,end: endTimeString}), - body: i18next.t('email-service.email-data.body-data-consists-of-list-of-entries') - } - $window.cordova.plugins.email.open(email).then(resolve()); - } - reader.readAsText(file); - }, function(error) { - $ionicPopup.alert({title: "Error while downloading JSON dump", - template: error}); - reject(error); - }); - }); - }); - }); - }; - - // Simulate old conversion to get correct UnixInteger for endMoment data - const getUnixNum = (dateData) => { - var tempDate = dateData.toFormat('dd MMM yyyy'); - return DateTime.fromFormat(tempDate, "dd MMM yyyy").toUnixInteger(); - }; - - getRawEntries(null, getUnixNum(startTime), startTime.toUnixInteger()) - .then(writeDumpFile) - .then(emailData) - .then(function() { - logInfo("Email queued successfully"); - }) - .catch(function(error) { - displayError(error, "Error emailing JSON dump"); - }) - }; - - this.getOPCode = function() { - return window.cordova.plugins.OPCodeAuth.getOPCode(); - }; - - this.getSettings = function() { - return window.cordova.plugins.BEMConnectionSettings.getSettings(); - }; - -}) - .factory('Chats', function() { // Might use a resource here that returns a JSON array From 3d875aa026160195a962bc1802a05843320b0f98 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Tue, 10 Oct 2023 12:00:34 -0600 Subject: [PATCH 080/850] Infinite Scroll Rewrite multilabel/infinite_scroll_filters.js - Removed this file and replaced with .ts file multilabel/infinite_scroll_filters.ts - Rewrote this file in React enketo/infinite_scroll_filters.js - Removed this file and replaced with .ts file enketo/infinite_scroll_filters.ts - Rewrote this file in React index.js - Removed Angular reference to multilabel/infinite_scroll_filters.js - Removed Angular reference to enketo/infinite_scroll_filters.js diary.js - Removed angular reference to multilabel.infscrollfilters - Removed angular reference to enketo.infscrollfilters LabelTab.tsx - Changed the tripFilterFactory to reflect the new way of representing the surveyOpt object from survey.ts survey.ts - imported the multilabel and enketo filters the "React way", and changed the typescript to reflect that the filters property are arrays containing the label filters --- www/index.js | 2 - www/js/diary.js | 2 - www/js/diary/LabelTab.tsx | 4 +- .../survey/enketo/infinite_scroll_filters.js | 40 ----------- .../survey/enketo/infinite_scroll_filters.ts | 31 +++++++++ .../multilabel/infinite_scroll_filters.js | 67 ------------------- .../multilabel/infinite_scroll_filters.ts | 58 ++++++++++++++++ www/js/survey/survey.ts | 9 ++- 8 files changed, 97 insertions(+), 116 deletions(-) delete mode 100644 www/js/survey/enketo/infinite_scroll_filters.js create mode 100644 www/js/survey/enketo/infinite_scroll_filters.ts delete mode 100644 www/js/survey/multilabel/infinite_scroll_filters.js create mode 100644 www/js/survey/multilabel/infinite_scroll_filters.ts diff --git a/www/index.js b/www/index.js index 55cb233b5..06916142d 100644 --- a/www/index.js +++ b/www/index.js @@ -18,12 +18,10 @@ import './js/services.js'; import './js/i18n-utils.js'; import './js/main.js'; import './js/survey/input-matcher.js'; -import './js/survey/multilabel/infinite_scroll_filters.js'; import './js/survey/multilabel/multi-label-ui.js'; import './js/diary.js'; import './js/diary/services.js'; import './js/survey/enketo/answer.js'; -import './js/survey/enketo/infinite_scroll_filters.js'; import './js/survey/enketo/enketo-trip-button.js'; import './js/survey/enketo/enketo-add-note-button.js'; import './js/control/emailService.js'; diff --git a/www/js/diary.js b/www/js/diary.js index 3a150cfff..5e1d191e5 100644 --- a/www/js/diary.js +++ b/www/js/diary.js @@ -3,9 +3,7 @@ import LabelTab from './diary/LabelTab'; angular.module('emission.main.diary',['emission.main.diary.services', 'emission.survey.multilabel.buttons', - 'emission.survey.multilabel.infscrollfilters', 'emission.survey.enketo.add-note-button', - 'emission.survey.enketo.trip.infscrollfilters', 'emission.plugin.logger']) .config(function($stateProvider) { diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 42b173017..1914feda4 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -68,8 +68,8 @@ const LabelTab = () => { // https://github.com/e-mission/e-mission-docs/issues/894 if (appConfig.survey_info?.buttons == undefined) { // initalize filters - const tripFilterFactory = getAngularService(surveyOpt.filter); - const allFalseFilters = tripFilterFactory.configuredFilters.map((f, i) => ({ + const tripFilter = surveyOpt.filter; + const allFalseFilters = tripFilter.map((f, i) => ({ ...f, state: (i == 0 ? true : false) // only the first filter will have state true on init })); setFilterInputs(allFalseFilters); diff --git a/www/js/survey/enketo/infinite_scroll_filters.js b/www/js/survey/enketo/infinite_scroll_filters.js deleted file mode 100644 index 8e45db8e4..000000000 --- a/www/js/survey/enketo/infinite_scroll_filters.js +++ /dev/null @@ -1,40 +0,0 @@ -'use strict'; - -/* - * The general structure of this code is that all the timeline information for - * a particular day is retrieved from the Timeline factory and put into the scope. - * For best performance, all data should be loaded into the in-memory timeline, - * and in addition to writing to storage, the data should be written to memory. - * All UI elements should only use $scope variables. - */ - -import angular from 'angular'; - -angular.module('emission.survey.enketo.trip.infscrollfilters',[ - 'emission.survey.enketo.trip.button', - 'emission.plugin.logger' - ]) -.factory('EnketoTripInfScrollFilters', function(Logger, EnketoTripButtonService){ - var sf = {}; - var unlabeledCheck = function(t) { - return !angular.isDefined(t.userInput[EnketoTripButtonService.SINGLE_KEY]); - } - - sf.UNLABELED = { - key: "unlabeled", - text: i18next.t("diary.unlabeled"), - filter: unlabeledCheck - } - - sf.TO_LABEL = { - key: "to_label", - text: i18next.t("diary.to-label"), - filter: unlabeledCheck - } - - sf.configuredFilters = [ - sf.TO_LABEL, - sf.UNLABELED, - ]; - return sf; -}); diff --git a/www/js/survey/enketo/infinite_scroll_filters.ts b/www/js/survey/enketo/infinite_scroll_filters.ts new file mode 100644 index 000000000..5ec72eae7 --- /dev/null +++ b/www/js/survey/enketo/infinite_scroll_filters.ts @@ -0,0 +1,31 @@ +/* + * The general structure of this code is that all the timeline information for + * a particular day is retrieved from the Timeline factory and put into the scope. + * For best performance, all data should be loaded into the in-memory timeline, + * and in addition to writing to storage, the data should be written to memory. + * All UI elements should only use $scope variables. + */ + +import i18next from "i18next"; +import { getAngularService } from "../../angular-react-helper"; + +const unlabeledCheck = (t) => { + return typeof t.userInput[getAngularService('EnketoTripButtonService').SINGLE_KEY] === 'undefined'; +} + +const UNLABELED = { + key: "unlabeled", + text: i18next.t("diary.unlabeled"), + filter: unlabeledCheck +} + +const TO_LABEL = { + key: "to_label", + text: i18next.t("diary.to-label"), + filter: unlabeledCheck +} + +export const configuredFilters = [ + TO_LABEL, + UNLABELED +]; \ No newline at end of file diff --git a/www/js/survey/multilabel/infinite_scroll_filters.js b/www/js/survey/multilabel/infinite_scroll_filters.js deleted file mode 100644 index bc588ecc2..000000000 --- a/www/js/survey/multilabel/infinite_scroll_filters.js +++ /dev/null @@ -1,67 +0,0 @@ -'use strict'; - -/* - * The general structure of this code is that all the timeline information for - * a particular day is retrieved from the Timeline factory and put into the scope. - * For best performance, all data should be loaded into the in-memory timeline, - * and in addition to writing to storage, the data should be written to memory. - * All UI elements should only use $scope variables. - */ - -import angular from 'angular'; - -angular.module('emission.survey.multilabel.infscrollfilters',[ - 'emission.plugin.logger' - ]) -.factory('MultiLabelInfScrollFilters', function(Logger){ - var sf = {}; - var unlabeledCheck = function(t) { - return t.INPUTS - .map((inputType, index) => !angular.isDefined(t.userInput[inputType])) - .reduce((acc, val) => acc || val, false); - } - - var invalidCheck = function(t) { - const retVal = - (angular.isDefined(t.userInput['MODE']) && t.userInput['MODE'].value === 'pilot_ebike') && - (!angular.isDefined(t.userInput['REPLACED_MODE']) || - t.userInput['REPLACED_MODE'].value === 'pilot_ebike' || - t.userInput['REPLACED_MODE'].value === 'same_mode'); - return retVal; - } - - var toLabelCheck = function(trip) { - if (angular.isDefined(trip.expectation)) { - console.log(trip.expectation.to_label) - return trip.expectation.to_label && unlabeledCheck(trip); - } else { - return true; - } - } - - sf.UNLABELED = { - key: "unlabeled", - text: i18next.t("diary.unlabeled"), - filter: unlabeledCheck, - width: "col-50" - } - - sf.INVALID_EBIKE = { - key: "invalid_ebike", - text: i18next.t("diary.invalid-ebike"), - filter: invalidCheck - } - - sf.TO_LABEL = { - key: "to_label", - text: i18next.t("diary.to-label"), - filter: toLabelCheck, - width: "col-50" - } - - sf.configuredFilters = [ - sf.TO_LABEL, - sf.UNLABELED - ]; - return sf; -}); diff --git a/www/js/survey/multilabel/infinite_scroll_filters.ts b/www/js/survey/multilabel/infinite_scroll_filters.ts new file mode 100644 index 000000000..68c28b35e --- /dev/null +++ b/www/js/survey/multilabel/infinite_scroll_filters.ts @@ -0,0 +1,58 @@ +/* + * The general structure of this code is that all the timeline information for + * a particular day is retrieved from the Timeline factory and put into the scope. + * For best performance, all data should be loaded into the in-memory timeline, + * and in addition to writing to storage, the data should be written to memory. + * All UI elements should only use $scope variables. + */ + +import i18next from "i18next"; + +const unlabeledCheck = (t) => { + return t.INPUTS + .map((inputType, index) => typeof t.userInput[inputType] === 'undefined') + .reduce((acc, val) => acc || val, false); +} + +const invalidCheck = (t) => { + const retVal = + (typeof t.userInput['MODE'] !== 'undefined' && t.userInput['MODE'].value === 'pilot_ebike') && + (typeof t.userInput['REPLACED_MODE'] === 'undefined' || + t.userInput['REPLACED_MODE'].value === 'pilot_ebike' || + t.userInput['REPLACED_MODE'].value === 'same_mode'); + return retVal; +} + +const toLabelCheck = (trip) => { + if (typeof trip.expectation !== 'undefined') { + console.log(trip.expectation.to_label) + return trip.expectation.to_label && unlabeledCheck(trip); + } else { + return true; + } +} + +const UNLABELED = { + key: "unlabeled", + text: i18next.t("diary.unlabeled"), + filter: unlabeledCheck, + width: "col-50" +} + +const INVALID_EBIKE = { + key: "invalid_ebike", + text: i18next.t("diary.invalid-ebike"), + filter: invalidCheck +} + +const TO_LABEL = { + key: "to_label", + text: i18next.t("diary.to-label"), + filter: toLabelCheck, + width: "col-50" +} + +export const configuredFilters = [ + TO_LABEL, + UNLABELED +]; \ No newline at end of file diff --git a/www/js/survey/survey.ts b/www/js/survey/survey.ts index e6692983f..66f662082 100644 --- a/www/js/survey/survey.ts +++ b/www/js/survey/survey.ts @@ -1,12 +1,15 @@ -type SurveyOption = { filter: string, service: string, elementTag: string } +import { configuredFilters as multilabelConfiguredFilters } from "./multilabel/infinite_scroll_filters"; +import { configuredFilters as enketoConfiguredFilters } from "./enketo/infinite_scroll_filters"; + +type SurveyOption = { filter: Array, service: string, elementTag: string } export const SurveyOptions: {[key: string]: SurveyOption} = { MULTILABEL: { - filter: "MultiLabelInfScrollFilters", + filter: multilabelConfiguredFilters, service: "MultiLabelService", elementTag: "multilabel" }, ENKETO: { - filter: "EnketoTripInfScrollFilters", + filter: enketoConfiguredFilters, service: "EnketoTripButtonService", elementTag: "enketo-trip-button" } From a5fcbfe1be3b740cbd849d0722c2f1de1e38269e Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 11 Oct 2023 16:42:12 -0400 Subject: [PATCH 081/850] remove backwards-compat munge in storage.ts per https://github.com/e-mission/e-mission-phone/pull/1040/files#r1351511049, removes the backwards compat to munge when filling in native values from local storage. Also removes the backwards-compat comment, replacing it with other comments describing how we fill in missing local or native values --- www/js/plugin/storage.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/www/js/plugin/storage.ts b/www/js/plugin/storage.ts index 87be6de9b..68380fe86 100644 --- a/www/js/plugin/storage.ts +++ b/www/js/plugin/storage.ts @@ -43,7 +43,7 @@ const localStorageGet = (key: string) => { If a value is present in both, but they are different, it copies the native value to local storage and returns it. */ function getUnifiedValue(key) { - let ls_stored_val = localStorageGet(key); + const ls_stored_val = localStorageGet(key); return window['cordova'].plugins.BEMUserCache.getLocalStorage(key, false).then((uc_stored_val) => { logDebug(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, ls_stored_val = ${JSON.stringify(ls_stored_val)}.`); @@ -56,6 +56,7 @@ function getUnifiedValue(key) { } else { // the values are different if (ls_stored_val == null) { + // local value is missing, fill it in from native console.assert(uc_stored_val != null, "uc_stored_val should be non-null"); logDebug(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, ls_stored_val = ${JSON.stringify(ls_stored_val)}. @@ -63,14 +64,8 @@ function getUnifiedValue(key) { localStorageSet(key, uc_stored_val); return uc_stored_val; } else if (uc_stored_val == null) { + // native value is missing, fill it in from local console.assert(ls_stored_val != null); - /* - * Backwards compatibility ONLY. Right after the first - * update to this version, we may have a local value that - * is not a JSON object. In that case, we want to munge it - * before storage. Remove this after a few releases. - */ - ls_stored_val = mungeValue(key, ls_stored_val); displayErrorMsg(`Local ${key} found, native ${key} missing, writing ${key} to native`); logDebug(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, ls_stored_val = ${JSON.stringify(ls_stored_val)}. @@ -80,6 +75,7 @@ function getUnifiedValue(key) { return ls_stored_val; }); } + // both values are present, but they are different console.assert(ls_stored_val != null && uc_stored_val != null, "ls_stored_val =" + JSON.stringify(ls_stored_val) + "uc_stored_val =" + JSON.stringify(uc_stored_val)); From 40ce2293b344dee2ed4fdca8ebffef53eac8dc7a Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 11 Oct 2023 16:49:27 -0400 Subject: [PATCH 082/850] don't displayErrorMsg in storage.ts A user-facing popup here is likely to just annoy or confuse users. Let's set these log statements at the "WARN" level so it is more visible than other log statements if we do need to debug it, but not intrusive or detrimental to UX --- www/js/plugin/storage.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/www/js/plugin/storage.ts b/www/js/plugin/storage.ts index 68380fe86..3fc67b616 100644 --- a/www/js/plugin/storage.ts +++ b/www/js/plugin/storage.ts @@ -1,6 +1,6 @@ import { getAngularService } from "../angular-react-helper"; import { addStatReading, statKeys } from "./clientStats"; -import { displayErrorMsg, logDebug } from "./logger"; +import { logDebug, logWarn } from "./logger"; const mungeValue = (key, value) => { let store_val = value; @@ -58,7 +58,7 @@ function getUnifiedValue(key) { if (ls_stored_val == null) { // local value is missing, fill it in from native console.assert(uc_stored_val != null, "uc_stored_val should be non-null"); - logDebug(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, + logWarn(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, ls_stored_val = ${JSON.stringify(ls_stored_val)}. copying native ${key} to local...`); localStorageSet(key, uc_stored_val); @@ -66,8 +66,7 @@ function getUnifiedValue(key) { } else if (uc_stored_val == null) { // native value is missing, fill it in from local console.assert(ls_stored_val != null); - displayErrorMsg(`Local ${key} found, native ${key} missing, writing ${key} to native`); - logDebug(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, + logWarn(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, ls_stored_val = ${JSON.stringify(ls_stored_val)}. copying local ${key} to native...`); return window['cordova'].plugins.BEMUserCache.putLocalStorage(key, ls_stored_val).then(() => { @@ -79,9 +78,7 @@ function getUnifiedValue(key) { console.assert(ls_stored_val != null && uc_stored_val != null, "ls_stored_val =" + JSON.stringify(ls_stored_val) + "uc_stored_val =" + JSON.stringify(uc_stored_val)); - displayErrorMsg(`Local ${key} found, native ${key} found, but different, - writing ${key} to local`); - logDebug(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, + logWarn(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, ls_stored_val = ${JSON.stringify(ls_stored_val)}. copying native ${key} to local...`); localStorageSet(key, uc_stored_val); From 37ac06581f3bb364a3c4fb54849e19f90015a853 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 11 Oct 2023 16:51:29 -0400 Subject: [PATCH 083/850] remove unneeded comment It is fairly self-explanatory that {key: value} was the chosen approach --- www/js/plugin/storage.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/www/js/plugin/storage.ts b/www/js/plugin/storage.ts index 3fc67b616..59e535b6e 100644 --- a/www/js/plugin/storage.ts +++ b/www/js/plugin/storage.ts @@ -5,7 +5,6 @@ import { logDebug, logWarn } from "./logger"; const mungeValue = (key, value) => { let store_val = value; if (typeof value != "object") { - // Should this be {"value": value} or {key: value}? store_val = {}; store_val[key] = value; } From cfd782991f59ea9915dcd2665e880e9ae6a4b1e3 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 11 Oct 2023 17:04:55 -0400 Subject: [PATCH 084/850] show error if 'db' not defined in clientStats.ts Per https://github.com/e-mission/e-mission-phone/pull/1040/files#r1351477569, we'll show an error message here if the BEMUserCache 'db' is not defined. We'll also ensure that if the Logger plugin is undefined, we do not try to call it as this would cause an error. --- www/js/plugin/clientStats.ts | 5 +++++ www/js/plugin/logger.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/www/js/plugin/clientStats.ts b/www/js/plugin/clientStats.ts index 1e06208eb..cefaf8f22 100644 --- a/www/js/plugin/clientStats.ts +++ b/www/js/plugin/clientStats.ts @@ -1,3 +1,5 @@ +import { displayErrorMsg } from "./logger"; + const CLIENT_TIME = "stats/client_time"; const CLIENT_ERROR = "stats/client_error"; const CLIENT_NAV_EVENT = "stats/client_nav_event"; @@ -39,16 +41,19 @@ export const addStatReading = async (name: string, reading: any) => { const db = window['cordova']?.plugins?.BEMUserCache; const event = await getStatsEvent(name, reading); if (db) return db.putMessage(CLIENT_TIME, event); + displayErrorMsg("addStatReading: db is not defined"); } export const addStatEvent = async (name: string) => { const db = window['cordova']?.plugins?.BEMUserCache; const event = await getStatsEvent(name, null); if (db) return db.putMessage(CLIENT_NAV_EVENT, event); + displayErrorMsg("addStatEvent: db is not defined"); } export const addStatError = async (name: string, errorStr: string) => { const db = window['cordova']?.plugins?.BEMUserCache; const event = await getStatsEvent(name, errorStr); if (db) return db.putMessage(CLIENT_ERROR, event); + displayErrorMsg("addStatError: db is not defined"); } diff --git a/www/js/plugin/logger.ts b/www/js/plugin/logger.ts index c4e476de1..d127f5549 100644 --- a/www/js/plugin/logger.ts +++ b/www/js/plugin/logger.ts @@ -46,5 +46,5 @@ export function displayErrorMsg(errorMsg: string, title?: string) { const displayMsg = `━━━━\n${title}\n━━━━\n` + errorMsg; window.alert(displayMsg); console.error(displayMsg); - window['Logger'].log(window['Logger'].LEVEL_ERROR, displayMsg); + window['Logger']?.log(window['Logger'].LEVEL_ERROR, displayMsg); } From 0815334ec707ce5804a60f4b3985e7b7f6caa304 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 11 Oct 2023 15:14:02 -0600 Subject: [PATCH 085/850] draft enketoHelper tests --- www/__tests__/enketoHelper.test.ts | 70 ++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 www/__tests__/enketoHelper.test.ts diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts new file mode 100644 index 000000000..537accb79 --- /dev/null +++ b/www/__tests__/enketoHelper.test.ts @@ -0,0 +1,70 @@ +import { getInstanceStr, filterByNameAndVersion } from '../js/survey/enketo/enketoHelper'; +import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; + +mockBEMUserCache(); + + +/** + * @param xmlModel the blank XML model response for the survey + * @param opts object with options like 'prefilledSurveyResponse' or 'prefillFields' + * @returns XML string of an existing or prefilled model response, or null if no response is available + */ +it('gets the model response, if avaliable, or returns null', ()=> { + const xmlModel = '\n \n \n \n \n \n \n ;'; + const filled = '\n \n car\n \n \n \n \n ;'; + const opts = {"prefilledSurveyResponse": filled}; + const opts2 = {"prefillFields": {"travel_mode" : "car"}}; + + //if no xmlModel, returns null + expect(getInstanceStr(null, opts)).toBe(null); + + //if there is a prefilled survey, return it + expect(getInstanceStr(xmlModel, opts)).toBe(filled); + + //if there is a model and fields, return prefilled + // expect(getInstanceStr(xmlModel, opts2)).toBe(filled); + //TODO - figure out how to use the helper function with JEST -- getElementsByTagName is empty? should it be? + + //if none of those things, also return null + expect(getInstanceStr(xmlModel, {})).toBe(null); +}); + +/** + * @param surveyName the name of the survey (e.g. "TimeUseSurvey") + * @param enketoForm the Form object from enketo-core that contains this survey + * @param appConfig the dynamic config file for the app + * @param opts object with SurveyOptions like 'timelineEntry' or 'dataKey' + * @returns Promise of the saved result, or an Error if there was a problem + */ +// export function saveResponse(surveyName: string, enketoForm: Form, appConfig, opts: SurveyOptions) { +it('gets the saved result or throws an error', () => { + +}); + +/* +* We retrieve all the records every time instead of caching because of the +* usage pattern. We assume that the demographic survey is edited fairly +* rarely, so loading it every time will likely do a bunch of unnecessary work. +* Loading it on demand seems like the way to go. If we choose to experiment +* with incremental updates, we may want to revisit this. +*/ +// export function loadPreviousResponseForSurvey(dataKey: string) { +it('loads the previous response to a given survey', () => { + +}); + +/** + * filterByNameAndVersion filter the survey answers by survey name and their version. + * The version for filtering is specified in enketo survey `compatibleWith` config. + * The stored survey answer version must be greater than or equal to `compatibleWith` to be included. + * @param {string} name survey name (defined in enketo survey config) + * @param {EnketoAnswer[]} answers survey answers + * (usually retrieved by calling UnifiedDataLoader.getUnifiedMessagesForInterval('manual/survey_response', tq)) method. + * @return {Promise} filtered survey answers + */ +it('filters the survey answers by their name and version', () => { + const surveyName = "TimeUseSurvey"; + const answers = []; + expect(filterByNameAndVersion(surveyName, answers)).resolves.toBe([]); + +}); From 43b8386acf595c1a9296d032c691feafb7e5255f Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 11 Oct 2023 15:15:38 -0600 Subject: [PATCH 086/850] convert answer.js into enketoHelper moving the methods form answer.js into enketoHelper as a part of the services migration --- www/js/survey/enketo/enketoHelper.ts | 192 +++++++++++++++++++++++++-- 1 file changed, 184 insertions(+), 8 deletions(-) diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index 6e9147cf8..84c057658 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -2,7 +2,10 @@ import { getAngularService } from "../../angular-react-helper"; import { Form } from 'enketo-core'; import { XMLParser } from 'fast-xml-parser'; import i18next from 'i18next'; -import { logDebug } from "../../plugin/logger"; +import MessageFormat from 'messageformat'; +import { logDebug, logInfo } from "../../plugin/logger"; +import { getConfig } from '../../config/dynamicConfig'; +import { DateTime } from "luxon"; export type PrefillFields = {[key: string]: string}; @@ -14,6 +17,129 @@ export type SurveyOptions = { dataKey?: string; }; +type EnketoAnswerData = { + label: string; //display label (this value is use for displaying on the button) + ts: string; //the timestamp at which the survey was filled out (in seconds) + fmt_time: string; //the formatted timestamp at which the survey was filled out + name: string; //survey name + version: string; //survey version + xmlResponse: string; //survey answer XML string + jsonDocResponse: string; //survey answer JSON object +} + +type EnketoAnswer = { + data: EnketoAnswerData; //answer data + labels: [{[labelField:string]: string}]; //virtual labels (populated by populateLabels method) +} + +type EnketoSurveyConfig = { + [surveyName:string]: { + formPath: string + labelFields: string[]; + version: number; + compatibleWith: number; + } +} + +/** @type {EnketoSurveyConfig} _config */ +//TODO find a more appropriate way to store this +let _config: EnketoSurveyConfig; + +const LABEL_FUNCTIONS = { + UseLabelTemplate : async (xmlDoc: XMLDocument, name: string) => { + let configSurveys = await _lazyLoadConfig(); + + const config = configSurveys[name]; // config for this survey + const lang = i18next.resolvedLanguage; + const labelTemplate = config.labelTemplate?.[lang]; + + if (!labelTemplate) return "Answered"; // no template given in config + if (!config.labelVars) return labelTemplate; // if no vars given, nothing to interpolate, + // so we return the unaltered template + + // gather vars that will be interpolated into the template according to the survey config + const labelVars = {} + for (let lblVar in config.labelVars) { + const fieldName = config.labelVars[lblVar].key; + let fieldStr = _getAnswerByTagName(xmlDoc, fieldName); + if (fieldStr == '') fieldStr = null; + if (config.labelVars[lblVar].type == 'length') { + const fieldMatches = fieldStr?.split(' '); + labelVars[lblVar] = fieldMatches?.length || 0; + } else { + throw new Error(`labelVar type ${config.labelVars[lblVar].type } is not supported!`) + } + } + + // use MessageFormat interpolate the label template with the label vars + const mf = new MessageFormat(lang); + const label = mf.compile(labelTemplate)(labelVars); + return label.replace(/^[ ,]+|[ ,]+$/g, ''); // trim leading and trailing spaces and commas + } +} + +/** + * _getAnswerByTagName lookup for the survey answer by tag name form the given XML document. + * @param {XMLDocument} xmlDoc survey answer object + * @param {string} tagName tag name + * @returns {string} answer string. If not found, return "\" + */ + function _getAnswerByTagName(xmlDoc: XMLDocument, tagName: string) { + const vals = xmlDoc.getElementsByTagName(tagName); + const val = vals.length ? vals[0].innerHTML : null; + if (!val) return ''; + return val; +} + +/** + * _lazyLoadConfig load enketo survey config. If already loaded, return the cached config + * @returns {Promise} enketo survey config + */ +function _lazyLoadConfig() { + if (_config !== undefined) { + return Promise.resolve(_config); + } + return getConfig().then((newConfig) => { + logInfo("Resolved UI_CONFIG_READY promise in enketoHelper, filling in templates"); + _config = newConfig.survey_info.surveys; + return _config; + }) +} + +/** + * filterByNameAndVersion filter the survey answers by survey name and their version. + * The version for filtering is specified in enketo survey `compatibleWith` config. + * The stored survey answer version must be greater than or equal to `compatibleWith` to be included. + * @param {string} name survey name (defined in enketo survey config) + * @param {EnketoAnswer[]} answers survey answers + * (usually retrieved by calling UnifiedDataLoader.getUnifiedMessagesForInterval('manual/survey_response', tq)) method. + * @return {Promise} filtered survey answers + */ + export function filterByNameAndVersion(name: string, answers: EnketoAnswer[]) { + return _lazyLoadConfig().then(config => { + console.log("filtering by name and version,", name, config, answers); + answers.filter(answer => + answer.data.name === name && + answer.data.version >= config[name].compatibleWith + )} + ); +} + +/** + * resolve answer label for the survey + * @param {string} name survey name + * @param {XMLDocument} xmlDoc survey answer object + * @returns {Promise} label string Promise + */ +function resolveLabel(name: string, xmlDoc: XMLDocument) { + // Some studies may want a custom label function for their survey. + // Those can be added in LABEL_FUNCTIONS with the survey name as the key. + // Otherwise, UseLabelTemplate will create a label using the template in the config + if (LABEL_FUNCTIONS[name]) + return LABEL_FUNCTIONS[name](xmlDoc); + return LABEL_FUNCTIONS.UseLabelTemplate(xmlDoc, name); +} + /** * @param xmlModel the blank XML model to be prefilled * @param prefillFields an object with keys that are the XML tag names and values that are the values to be prefilled @@ -21,7 +147,7 @@ export type SurveyOptions = { */ function getXmlWithPrefills(xmlModel: string, prefillFields: PrefillFields) { if (!prefillFields) return null; - const xmlParser = new window.DOMParser(); + const xmlParser = new DOMParser(); const xmlDoc = xmlParser.parseFromString(xmlModel, 'text/xml'); for (const [tagName, value] of Object.entries(prefillFields)) { @@ -46,6 +172,57 @@ export function getInstanceStr(xmlModel: string, opts: SurveyOptions): string|nu return null; } +/** + * resolve timestamps label from the survey response + * @param {XMLDocument} xmlDoc survey answer object + * @param {object} trip trip object + * @returns {object} object with `start_ts` and `end_ts` + * - null if no timestamps are resolved + * - undefined if the timestamps are invalid + */ + function resolveTimestamps(xmlDoc, timelineEntry) { + // check for Date and Time fields + const startDate = xmlDoc.getElementsByTagName('Start_date')?.[0]?.innerHTML; + let startTime = xmlDoc.getElementsByTagName('Start_time')?.[0]?.innerHTML; + const endDate = xmlDoc.getElementsByTagName('End_date')?.[0]?.innerHTML; + let endTime = xmlDoc.getElementsByTagName('End_time')?.[0]?.innerHTML; + + // if any of the fields are missing, return null + if (!startDate || !startTime || !endDate || !endTime) return null; + + const timezone = timelineEntry.start_local_dt?.timezone + || timelineEntry.enter_local_dt?.timezone + || timelineEntry.end_local_dt?.timezone + || timelineEntry.exit_local_dt?.timezone; + // split by + or - to get time without offset + startTime = startTime.split(/\-|\+/)[0]; + endTime = endTime.split(/\-|\+/)[0]; + + let additionStartTs = DateTime.fromISO(startDate + "T" + startTime, {zone: timezone}).valueOf(); + let additionEndTs = DateTime.fromISO(endDate + "T" + endTime, {zone: timezone}).valueOf(); + + if (additionStartTs > additionEndTs) { + return undefined; // if the start time is after the end time, this is an invalid response + } + + /* Enketo survey time inputs are only precise to the minute, while trips/places are precise to + the millisecond. To avoid precision issues, we will check if the start/end timestamps from + the survey response are within the same minute as the start/end or enter/exit timestamps. + If so, we will use the exact trip/place timestamps */ + const entryStartTs = timelineEntry.start_ts || timelineEntry.enter_ts; + const entryEndTs = timelineEntry.end_ts || timelineEntry.exit_ts; + if (additionStartTs - (additionStartTs % 60) == entryStartTs - (entryStartTs % 60)) + additionStartTs = entryStartTs; + if (additionEndTs - (additionEndTs % 60) == entryEndTs - (entryEndTs % 60)) + additionEndTs = entryEndTs; + + // return unix timestamps in seconds + return { + start_ts: additionStartTs, + end_ts: additionEndTs + }; +} + /** * @param surveyName the name of the survey (e.g. "TimeUseSurvey") * @param enketoForm the Form object from enketo-core that contains this survey @@ -54,13 +231,13 @@ export function getInstanceStr(xmlModel: string, opts: SurveyOptions): string|nu * @returns Promise of the saved result, or an Error if there was a problem */ export function saveResponse(surveyName: string, enketoForm: Form, appConfig, opts: SurveyOptions) { - const EnketoSurveyAnswer = getAngularService('EnketoSurveyAnswer'); + // const EnketoSurveyAnswer = getAngularService('EnketoSurveyAnswer'); const xmlParser = new window.DOMParser(); const xmlResponse = enketoForm.getDataStr(); const xmlDoc = xmlParser.parseFromString(xmlResponse, 'text/xml'); const xml2js = new XMLParser({ignoreAttributes: false, attributeNamePrefix: 'attr'}); const jsonDocResponse = xml2js.parse(xmlResponse); - return EnketoSurveyAnswer.resolveLabel(surveyName, xmlDoc).then(rsLabel => { + return resolveLabel(surveyName, xmlDoc).then(rsLabel => { const data: any = { label: rsLabel, name: surveyName, @@ -69,15 +246,14 @@ export function saveResponse(surveyName: string, enketoForm: Form, appConfig, op jsonDocResponse, }; if (opts.timelineEntry) { - let timestamps = EnketoSurveyAnswer.resolveTimestamps(xmlDoc, opts.timelineEntry); + let timestamps = resolveTimestamps(xmlDoc, opts.timelineEntry); if (timestamps === undefined) { // timestamps were resolved, but they are invalid return new Error(i18next.t('survey.enketo-timestamps-invalid')); //"Timestamps are invalid. Please ensure that the start time is before the end time."); } // if timestamps were not resolved from the survey, we will use the trip or place timestamps - timestamps ||= opts.timelineEntry; - data.start_ts = timestamps.start_ts || timestamps.enter_ts; - data.end_ts = timestamps.end_ts || timestamps.exit_ts; + data.start_ts = timestamps.start_ts || opts.timelineEntry.enter_ts; + data.end_ts = timestamps.end_ts || opts.timelineEntry.exit_ts; // UUID generated using this method https://stackoverflow.com/a/66332305 data.match_id = URL.createObjectURL(new Blob([])).slice(-36); } else { From 9840b5a2914fcfc11ae932b47cf4563bded3e96f Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 11 Oct 2023 15:22:08 -0600 Subject: [PATCH 087/850] exclude platforms When testing, I was getting an error from Jest about duplicate modules, one of which was in platforms. This change resolves that error --- jest.config.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/jest.config.json b/jest.config.json index 71bc5f5ca..8d194ffd0 100644 --- a/jest.config.json +++ b/jest.config.json @@ -13,6 +13,9 @@ "transformIgnorePatterns": [ "/node_modules/(?!(@react-native|react-native|react-native-vector-icons))" ], + "modulePathIgnorePatterns": [ + "/platforms/" + ], "moduleNameMapper": { "^react-native$": "react-native-web" } From 1eaf8b8e592d42a980168574452b0b1af98b54a9 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 11 Oct 2023 15:58:34 -0600 Subject: [PATCH 088/850] add more tests additional test for filterByNameAndVersion fake answers have been constructed to be filtered --- www/__tests__/enketoHelper.test.ts | 52 ++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index 537accb79..bdb55e112 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -63,8 +63,54 @@ it('loads the previous response to a given survey', () => { * @return {Promise} filtered survey answers */ it('filters the survey answers by their name and version', () => { - const surveyName = "TimeUseSurvey"; - const answers = []; - expect(filterByNameAndVersion(surveyName, answers)).resolves.toBe([]); + //no answers -> no filtered answers + expect(filterByNameAndVersion("TimeUseSurvey", [])).resolves.toBe([]); + const answer = [ + { + data: { + label: "Activity", //display label (this value is use for displaying on the button) + ts: "100000000", //the timestamp at which the survey was filled out (in seconds) + fmt_time: "12:36", //the formatted timestamp at which the survey was filled out + name: "TimeUseSurvey", //survey name + version: "1", //survey version + xmlResponse: "", //survey answer XML string + jsonDocResponse: "this is my json object" //survey answer JSON object + }, + labels: {labelField: "goodbye"} //TODO learn more about answer type + } + ]; + + //one answer -> that answer + expect(filterByNameAndVersion("TimeUseSurvey", answer)).resolves.toBe(answer); + + const answers = [ + { + data: { + label: "Activity", //display label (this value is use for displaying on the button) + ts: "100000000", //the timestamp at which the survey was filled out (in seconds) + fmt_time: "12:36", //the formatted timestamp at which the survey was filled out + name: "TimeUseSurvey", //survey name + version: "1", //survey version + xmlResponse: "", //survey answer XML string + jsonDocResponse: "this is my json object" //survey answer JSON object + }, + labels: {labelField: "goodbye"} + }, + { + data: { + label: "Activity", //display label (this value is use for displaying on the button) + ts: "100000000", //the timestamp at which the survey was filled out (in seconds) + fmt_time: "12:36", //the formatted timestamp at which the survey was filled out + name: "OtherSurvey", //survey name + version: "1", //survey version + xmlResponse: "", //survey answer XML string + jsonDocResponse: "this is my json object" //survey answer JSON object + }, + labels: {labelField: "goodbye"} + } + ]; + + //several answers -> only the one that has a name match + expect(filterByNameAndVersion("TimeUseSurvey", answers)).resolves.toBe(answer); }); From 49608032ef4ecc8bb317229c23ed0c61adfda779 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Tue, 10 Oct 2023 16:01:24 -0700 Subject: [PATCH 089/850] Small code-quality updates to controlHelper.ts Changed all variables that do not mutate to const. --- www/js/controlHelper.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/www/js/controlHelper.ts b/www/js/controlHelper.ts index 2f341a12a..5f6ebd6b9 100644 --- a/www/js/controlHelper.ts +++ b/www/js/controlHelper.ts @@ -22,18 +22,18 @@ declare let window: fsWindow; export const getMyData = function(startTs: Date) { // We are only retrieving data for a single day to avoid // running out of memory on the phone - var startTime = DateTime.fromJSDate(startTs); - var endTime = startTime.endOf("day"); - var startTimeString = startTime.toFormat("yyyy'-'MM'-'dd"); - var endTimeString = endTime.toFormat("yyyy'-'MM'-'dd"); + const startTime = DateTime.fromJSDate(startTs); + const endTime = startTime.endOf("day"); + const startTimeString = startTime.toFormat("yyyy'-'MM'-'dd"); + const endTimeString = endTime.toFormat("yyyy'-'MM'-'dd"); - var dumpFile = startTimeString + "." + const dumpFile = startTimeString + "." + endTimeString + ".timeline"; alert(`Going to retrieve data to ${dumpFile}`); const writeDumpFile = function(result) { - var resultList = result.phone_data; + const resultList = result.phone_data; return new Promise(function(resolve, reject) { window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { console.log(`file system open: ${fs.name}`); @@ -50,7 +50,7 @@ export const getMyData = function(startTs: Date) { } // if data object is not passed in, create a new blog instead. - var dataObj = new Blob([JSON.stringify(resultList, null, 2)], + const dataObj = new Blob([JSON.stringify(resultList, null, 2)], { type: "application/json" }); fileWriter.write(dataObj); }) @@ -61,26 +61,26 @@ export const getMyData = function(startTs: Date) { }); } - var emailData = function() { + const emailData = function() { return new Promise(function(resolve, reject) { window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { console.log("During email, file system open: " + fs.name); fs.root.getFile(dumpFile, null, function(fileEntry) { console.log(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`); fileEntry.file(function(file) { - var reader = new FileReader(); + const reader = new FileReader(); reader.onloadend = function() { const readResult = this.result as string; console.log(`Successfull file read with ${readResult.length} characters`); - var dataArray = JSON.parse(readResult); + const dataArray = JSON.parse(readResult); console.log(`Successfully read resultList of size ${dataArray.length}`); var attachFile = fileEntry.nativeURL; if (window['device'].platform === "android") attachFile = "app://cache/" + dumpFile; if (window['device'].platform === "ios") alert(i18next.t("email-service.email-account-mail-app")); - var email = { + const email = { attachments: [ attachFile ], @@ -101,7 +101,7 @@ export const getMyData = function(startTs: Date) { // Simulate old conversion to get correct UnixInteger for endMoment data const getUnixNum = (dateData: DateTime) => { - var tempDate = dateData.toFormat("dd MMM yyyy"); + const tempDate = dateData.toFormat("dd MMM yyyy"); return DateTime.fromFormat(tempDate, "dd MMM yyyy").toUnixInteger(); }; From 74b847156c420c095ad891b9f15e487e461ec3e8 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 11 Oct 2023 16:12:56 -0600 Subject: [PATCH 090/850] remove answer.js completely remove answer.js and all references to it, replace references with references to enketoHelper.ts --- www/__tests__/enketoHelper.test.ts | 4 - www/index.js | 1 - www/js/survey/enketo/answer.js | 193 ------------------ .../survey/enketo/enketo-add-note-button.js | 8 +- www/js/survey/enketo/enketo-trip-button.js | 8 +- www/js/survey/enketo/enketoHelper.ts | 6 +- 6 files changed, 10 insertions(+), 210 deletions(-) delete mode 100644 www/js/survey/enketo/answer.js diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index bdb55e112..f601f0f27 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -57,10 +57,6 @@ it('loads the previous response to a given survey', () => { * filterByNameAndVersion filter the survey answers by survey name and their version. * The version for filtering is specified in enketo survey `compatibleWith` config. * The stored survey answer version must be greater than or equal to `compatibleWith` to be included. - * @param {string} name survey name (defined in enketo survey config) - * @param {EnketoAnswer[]} answers survey answers - * (usually retrieved by calling UnifiedDataLoader.getUnifiedMessagesForInterval('manual/survey_response', tq)) method. - * @return {Promise} filtered survey answers */ it('filters the survey answers by their name and version', () => { //no answers -> no filtered answers diff --git a/www/index.js b/www/index.js index 89c3a5e26..30070245f 100644 --- a/www/index.js +++ b/www/index.js @@ -21,7 +21,6 @@ import './js/survey/multilabel/infinite_scroll_filters.js'; import './js/survey/multilabel/multi-label-ui.js'; import './js/diary.js'; import './js/diary/services.js'; -import './js/survey/enketo/answer.js'; import './js/survey/enketo/infinite_scroll_filters.js'; import './js/survey/enketo/enketo-trip-button.js'; import './js/survey/enketo/enketo-add-note-button.js'; diff --git a/www/js/survey/enketo/answer.js b/www/js/survey/enketo/answer.js deleted file mode 100644 index e6077c479..000000000 --- a/www/js/survey/enketo/answer.js +++ /dev/null @@ -1,193 +0,0 @@ -import angular from 'angular'; -import MessageFormat from 'messageformat'; -import { getConfig } from '../../config/dynamicConfig'; - -angular.module('emission.survey.enketo.answer', ['ionic']) -.factory('EnketoSurveyAnswer', function($http) { - /** - * @typedef EnketoAnswerData - * @type {object} - * @property {string} label - display label (this value is use for displaying on the button) - * @property {string} ts - the timestamp at which the survey was filled out (in seconds) - * @property {string} fmt_time - the formatted timestamp at which the survey was filled out - * @property {string} name - survey name - * @property {string} version - survey version - * @property {string} xmlResponse - survey answer XML string - * @property {string} jsonDocResponse - survey answer JSON object - */ - - /** - * @typedef EnketoAnswer - * @type {object} - * @property {EnketoAnswerData} data - answer data - * @property {{[labelField:string]: string}} [labels] - virtual labels (populated by populateLabels method) - */ - - /** - * @typedef EnketoSurveyConfig - * @type {{ - * [surveyName:string]: { - * formPath: string; - * labelFields: string[]; - * version: number; - * compatibleWith: number; - * } - * }} - */ - - const LABEL_FUNCTIONS = { - UseLabelTemplate: (xmlDoc, name) => { - - return _lazyLoadConfig().then(configSurveys => { - - const config = configSurveys[name]; // config for this survey - const lang = i18next.resolvedLanguage; - const labelTemplate = config.labelTemplate?.[lang]; - - if (!labelTemplate) return "Answered"; // no template given in config - if (!config.labelVars) return labelTemplate; // if no vars given, nothing to interpolate, - // so we return the unaltered template - - // gather vars that will be interpolated into the template according to the survey config - const labelVars = {} - for (let lblVar in config.labelVars) { - const fieldName = config.labelVars[lblVar].key; - let fieldStr = _getAnswerByTagName(xmlDoc, fieldName); - if (fieldStr == '') fieldStr = null; - if (config.labelVars[lblVar].type == 'length') { - const fieldMatches = fieldStr?.split(' '); - labelVars[lblVar] = fieldMatches?.length || 0; - } else { - throw new Error(`labelVar type ${config.labelVars[lblVar].type } is not supported!`) - } - } - - // use MessageFormat interpolate the label template with the label vars - const mf = new MessageFormat(lang); - const label = mf.compile(labelTemplate)(labelVars); - return label.replace(/^[ ,]+|[ ,]+$/g, ''); // trim leading and trailing spaces and commas - }) - } - }; - - /** @type {EnketoSurveyConfig} _config */ - let _config; - - /** - * _getAnswerByTagName lookup for the survey answer by tag name form the given XML document. - * @param {XMLDocument} xmlDoc survey answer object - * @param {string} tagName tag name - * @returns {string} answer string. If not found, return "\" - */ - function _getAnswerByTagName(xmlDoc, tagName) { - const vals = xmlDoc.getElementsByTagName(tagName); - const val = vals.length ? vals[0].innerHTML : null; - if (!val) return ''; - return val; - } - - /** - * _lazyLoadConfig load enketo survey config. If already loaded, return the cached config - * @returns {Promise} enketo survey config - */ - function _lazyLoadConfig() { - if (_config !== undefined) { - return Promise.resolve(_config); - } - return getConfig().then((newConfig) => { - Logger.log("Resolved UI_CONFIG_READY promise in answer.js, filling in templates"); - _config = newConfig.survey_info.surveys; - return _config; - }) - } - - /** - * filterByNameAndVersion filter the survey answers by survey name and their version. - * The version for filtering is specified in enketo survey `compatibleWith` config. - * The stored survey answer version must be greater than or equal to `compatibleWith` to be included. - * @param {string} name survey name (defined in enketo survey config) - * @param {EnketoAnswer[]} answers survey answers - * (usually retrieved by calling UnifiedDataLoader.getUnifiedMessagesForInterval('manual/survey_response', tq)) method. - * @return {Promise} filtered survey answers - */ - function filterByNameAndVersion(name, answers) { - return _lazyLoadConfig().then(config => - answers.filter(answer => - answer.data.name === name && - answer.data.version >= config[name].compatibleWith - ) - ); - } - - /** - * resolve answer label for the survey - * @param {string} name survey name - * @param {XMLDocument} xmlDoc survey answer object - * @returns {Promise} label string Promise - */ - function resolveLabel(name, xmlDoc) { - // Some studies may want a custom label function for their survey. - // Those can be added in LABEL_FUNCTIONS with the survey name as the key. - // Otherwise, UseLabelTemplate will create a label using the template in the config - if (LABEL_FUNCTIONS[name]) - return LABEL_FUNCTIONS[name](xmlDoc); - return LABEL_FUNCTIONS.UseLabelTemplate(xmlDoc, name); - } - - /** - * resolve timestamps label from the survey response - * @param {XMLDocument} xmlDoc survey answer object - * @param {object} trip trip object - * @returns {object} object with `start_ts` and `end_ts` - * - null if no timestamps are resolved - * - undefined if the timestamps are invalid - */ - function resolveTimestamps(xmlDoc, timelineEntry) { - // check for Date and Time fields - const startDate = xmlDoc.getElementsByTagName('Start_date')?.[0]?.innerHTML; - let startTime = xmlDoc.getElementsByTagName('Start_time')?.[0]?.innerHTML; - const endDate = xmlDoc.getElementsByTagName('End_date')?.[0]?.innerHTML; - let endTime = xmlDoc.getElementsByTagName('End_time')?.[0]?.innerHTML; - - // if any of the fields are missing, return null - if (!startDate || !startTime || !endDate || !endTime) return null; - - const timezone = timelineEntry.start_local_dt?.timezone - || timelineEntry.enter_local_dt?.timezone - || timelineEntry.end_local_dt?.timezone - || timelineEntry.exit_local_dt?.timezone; - // split by + or - to get time without offset - startTime = startTime.split(/\-|\+/)[0]; - endTime = endTime.split(/\-|\+/)[0]; - - let additionStartTs = moment.tz(startDate+'T'+startTime, timezone).unix(); - let additionEndTs = moment.tz(endDate+'T'+endTime, timezone).unix(); - - if (additionStartTs > additionEndTs) { - return undefined; // if the start time is after the end time, this is an invalid response - } - - /* Enketo survey time inputs are only precise to the minute, while trips/places are precise to - the millisecond. To avoid precision issues, we will check if the start/end timestamps from - the survey response are within the same minute as the start/end or enter/exit timestamps. - If so, we will use the exact trip/place timestamps */ - const entryStartTs = timelineEntry.start_ts || timelineEntry.enter_ts; - const entryEndTs = timelineEntry.end_ts || timelineEntry.exit_ts; - if (additionStartTs - (additionStartTs % 60) == entryStartTs - (entryStartTs % 60)) - additionStartTs = entryStartTs; - if (additionEndTs - (additionEndTs % 60) == entryEndTs - (entryEndTs % 60)) - additionEndTs = entryEndTs; - - // return unix timestamps in seconds - return { - start_ts: additionStartTs, - end_ts: additionEndTs - }; - } - - return { - filterByNameAndVersion, - resolveLabel, - resolveTimestamps, - }; -}); diff --git a/www/js/survey/enketo/enketo-add-note-button.js b/www/js/survey/enketo/enketo-add-note-button.js index 49f7747f6..6dc6be7e5 100644 --- a/www/js/survey/enketo/enketo-add-note-button.js +++ b/www/js/survey/enketo/enketo-add-note-button.js @@ -3,12 +3,12 @@ */ import angular from 'angular'; +import { filterByNameAndVersion } from './enketoHelper' angular.module('emission.survey.enketo.add-note-button', ['emission.services', - 'emission.survey.enketo.answer', 'emission.survey.inputmatcher']) -.factory("EnketoNotesButtonService", function(InputMatcher, EnketoSurveyAnswer, Logger, $timeout) { +.factory("EnketoNotesButtonService", function(InputMatcher, Logger, $timeout) { var enbs = {}; console.log("Creating EnketoNotesButtonService"); enbs.SINGLE_KEY="NOTES"; @@ -33,9 +33,9 @@ angular.module('emission.survey.enketo.add-note-button', * Embed 'inputType' to the timelineEntry. */ enbs.extractResult = function(results) { - const resultsPromises = [EnketoSurveyAnswer.filterByNameAndVersion(enbs.timelineEntrySurveyName, results)]; + const resultsPromises = [filterByNameAndVersion(enbs.timelineEntrySurveyName, results)]; if (enbs.timelineEntrySurveyName != enbs.placeSurveyName) { - resultsPromises.push(EnketoSurveyAnswer.filterByNameAndVersion(enbs.placeSurveyName, results)); + resultsPromises.push(filterByNameAndVersion(enbs.placeSurveyName, results)); } return Promise.all(resultsPromises); }; diff --git a/www/js/survey/enketo/enketo-trip-button.js b/www/js/survey/enketo/enketo-trip-button.js index 6e710435f..623137450 100644 --- a/www/js/survey/enketo/enketo-trip-button.js +++ b/www/js/survey/enketo/enketo-trip-button.js @@ -12,11 +12,11 @@ */ import angular from 'angular'; +import { filterByNameAndVersion } from "./enketoHelper"; angular.module('emission.survey.enketo.trip.button', - ['emission.survey.enketo.answer', - 'emission.survey.inputmatcher']) -.factory("EnketoTripButtonService", function(InputMatcher, EnketoSurveyAnswer, Logger, $timeout) { + ['emission.survey.inputmatcher']) +.factory("EnketoTripButtonService", function(InputMatcher, Logger, $timeout) { var etbs = {}; console.log("Creating EnketoTripButtonService"); etbs.key = "manual/trip_user_input"; @@ -26,7 +26,7 @@ angular.module('emission.survey.enketo.trip.button', /** * Embed 'inputType' to the trip. */ - etbs.extractResult = (results) => EnketoSurveyAnswer.filterByNameAndVersion('TripConfirmSurvey', results); + etbs.extractResult = (results) => filterByNameAndVersion('TripConfirmSurvey', results); etbs.processManualInputs = function(manualResults, resultMap) { if (manualResults.length > 1) { diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index 84c057658..83085ebd7 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -116,12 +116,11 @@ function _lazyLoadConfig() { * @return {Promise} filtered survey answers */ export function filterByNameAndVersion(name: string, answers: EnketoAnswer[]) { - return _lazyLoadConfig().then(config => { - console.log("filtering by name and version,", name, config, answers); + return _lazyLoadConfig().then(config => answers.filter(answer => answer.data.name === name && answer.data.version >= config[name].compatibleWith - )} + ) ); } @@ -231,7 +230,6 @@ export function getInstanceStr(xmlModel: string, opts: SurveyOptions): string|nu * @returns Promise of the saved result, or an Error if there was a problem */ export function saveResponse(surveyName: string, enketoForm: Form, appConfig, opts: SurveyOptions) { - // const EnketoSurveyAnswer = getAngularService('EnketoSurveyAnswer'); const xmlParser = new window.DOMParser(); const xmlResponse = enketoForm.getDataStr(); const xmlDoc = xmlParser.parseFromString(xmlResponse, 'text/xml'); From 6fd2e737b19fd9ac0a84799732d049aeb65acbc1 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 12 Oct 2023 08:22:55 -0700 Subject: [PATCH 091/850] Email now uses socialShare library See [PR 1052](https://github.com/e-mission/e-mission-phone/pull/1052). Using the socialShare library, as we do in the OpCode components, allows iOS users to share through apps other than the defauolt mail app. --- www/js/controlHelper.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/www/js/controlHelper.ts b/www/js/controlHelper.ts index 5f6ebd6b9..2458d7e8a 100644 --- a/www/js/controlHelper.ts +++ b/www/js/controlHelper.ts @@ -56,7 +56,6 @@ export const getMyData = function(startTs: Date) { }) }); - console.log(`Done!`); }); }); } @@ -81,13 +80,17 @@ export const getMyData = function(startTs: Date) { if (window['device'].platform === "ios") alert(i18next.t("email-service.email-account-mail-app")); const email = { - attachments: [ - attachFile - ], - subject: i18next.t("email-service.email-data.subject-data-dump-from-to", {start: startTimeString ,end: endTimeString}), - body: i18next.t("email-service.email-data.body-data-consists-of-list-of-entries") - }; - window['cordova'].plugins.email.open(email).then(resolve()); + 'files': [attachFile], + 'message': i18next.t("email-service.email-data.body-data-consists-of-list-of-entries"), + 'subject': i18next.t("email-service.email-data.subject-data-dump-from-to", {start: startTimeString ,end: endTimeString}), + } + window['plugins'].socialsharing.shareWithOptions(email, function (result) { + console.log(`Share Completed? ${result.completed}`); // On Android, most likely returns false + console.log(`Shared to app: ${result.app}`); + resolve(); + }, function (msg) { + console.log(`Sharing failed with message ${msg}`); + }); } reader.readAsText(file); }, function(error) { From ea2b8c59ba0d4c4cfdeac69c74712590d37345ed Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 12 Oct 2023 11:28:41 -0400 Subject: [PATCH 092/850] re-implement functions of commHelper A couple of the functions that were excluded from the rewrite were requested to be added back: https://github.com/e-mission/e-mission-phone/pull/1040#discussion_r1350931942 The logic is the same, but with modernized syntax, and using Luxon instead of Moment to deal with timestamps --- www/js/commHelper.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/www/js/commHelper.ts b/www/js/commHelper.ts index d0042b6ab..85281694f 100644 --- a/www/js/commHelper.ts +++ b/www/js/commHelper.ts @@ -1,3 +1,4 @@ +import { DateTime } from "luxon"; import { logDebug } from "./plugin/logger"; /** @@ -49,6 +50,29 @@ export function getRawEntries(key_list, start_ts, end_ts, time_key = "metadata.w }); } +// time_key is typically metadata.write_ts or data.ts +export function getRawEntriesForLocalDate(key_list, start_ts, end_ts, time_key = "metadata.write_ts", + max_entries = undefined, trunc_method = "sample") { + return new Promise((rs, rj) => { + const msgFiller = (message) => { + message.key_list = key_list; + message.from_local_date = DateTime.fromSeconds(start_ts).toObject(); + message.to_local_date = DateTime.fromSeconds(end_ts).toObject(); + message.key_local_date = time_key; + if (max_entries !== undefined) { + message.max_entries = max_entries; + message.trunc_method = trunc_method; + } + logDebug("About to return message " + JSON.stringify(message)); + }; + logDebug("getRawEntries: about to get pushGetJSON for the timestamp"); + window['cordova'].plugins.BEMServerComm.pushGetJSON("/datastreams/find_entries/local_date", msgFiller, rs, rj); + }).catch(error => { + error = "While getting raw entries for local date, " + error; + throw (error); + }); +}; + export function getPipelineRangeTs() { return new Promise((rs, rj) => { logDebug("getting pipeline range timestamps"); @@ -141,3 +165,22 @@ export function getUser() { throw(error); }); } + +export function putOne(key, data) { + const nowTs = DateTime.now().toUnixInteger(); + const metadata = { + write_ts: nowTs, + read_ts: nowTs, + time_zone: DateTime.local().zoneName, + type: "message", + key: key, + platform: window['device'].platform, + }; + const entryToPut = { metadata, data }; + return new Promise((rs, rj) => { + window['cordova'].plugins.BEMServerComm.postUserPersonalData("/usercache/putone", "the_entry", entryToPut, rs, rj); + }).catch(error => { + error = "While putting one entry, " + error; + throw(error); + }); +}; From 6ffb92633b8b962106985e44b45929005143cf81 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Thu, 12 Oct 2023 10:01:38 -0600 Subject: [PATCH 093/850] Fixing enketo label filters diary.js - Added emission.survey.enketo.trip.button to diary.js dependency array so that it can be used in enketo/infinite_scroll_filters.ts enketo/infinite_scroll_filters.ts - Added a try/catch statement to catch loading errors and make the code more readable --- www/js/diary.js | 1 + www/js/survey/enketo/infinite_scroll_filters.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/www/js/diary.js b/www/js/diary.js index 5e1d191e5..c0b7bce35 100644 --- a/www/js/diary.js +++ b/www/js/diary.js @@ -4,6 +4,7 @@ import LabelTab from './diary/LabelTab'; angular.module('emission.main.diary',['emission.main.diary.services', 'emission.survey.multilabel.buttons', 'emission.survey.enketo.add-note-button', + 'emission.survey.enketo.trip.button', 'emission.plugin.logger']) .config(function($stateProvider) { diff --git a/www/js/survey/enketo/infinite_scroll_filters.ts b/www/js/survey/enketo/infinite_scroll_filters.ts index 5ec72eae7..98eba65db 100644 --- a/www/js/survey/enketo/infinite_scroll_filters.ts +++ b/www/js/survey/enketo/infinite_scroll_filters.ts @@ -10,7 +10,14 @@ import i18next from "i18next"; import { getAngularService } from "../../angular-react-helper"; const unlabeledCheck = (t) => { - return typeof t.userInput[getAngularService('EnketoTripButtonService').SINGLE_KEY] === 'undefined'; + try { + const EnketoTripButtonService = getAngularService("EnketoTripButtonService"); + const etbsSingleKey = EnketoTripButtonService.SINGLE_KEY; + return typeof t.userInput[etbsSingleKey] === 'undefined'; + } + catch (e) { + console.log("Error in retrieving EnketoTripButtonService: ", e); + } } const UNLABELED = { From d259799020bd604f852fc19476af6470e965e41c Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 12 Oct 2023 10:25:44 -0600 Subject: [PATCH 094/850] adding tests for resolveTimestamps resolveTimestamps is a helper function to saveResponse, but still contains a fair amount of its own logic. Testing the edge cases for this function ensures that it will behave as expected within the larger context --- www/__tests__/enketoHelper.test.ts | 21 ++++++++++++++++++++- www/js/survey/enketo/enketoHelper.ts | 2 +- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index f601f0f27..6ba78d170 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -1,4 +1,4 @@ -import { getInstanceStr, filterByNameAndVersion } from '../js/survey/enketo/enketoHelper'; +import { getInstanceStr, filterByNameAndVersion, resolveTimestamps } from '../js/survey/enketo/enketoHelper'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; mockBEMUserCache(); @@ -29,6 +29,25 @@ it('gets the model response, if avaliable, or returns null', ()=> { expect(getInstanceStr(xmlModel, {})).toBe(null); }); +//resolve timestamps +it('resolves the timestamps', () => { + const xmlParser = new window.DOMParser(); + const timelineEntry = { end_local_dt: {timezone: "America/Los_Angeles"}, start_ts: 1469492672.928242, end_ts: 1469493031}; + + //missing data returns null + const missingData = ' 2016-08-28 2016-07-25 17:30:31.000-06:00 '; + const missDataDoc = xmlParser.parseFromString(missingData, 'text/html'); + expect(resolveTimestamps(missDataDoc, timelineEntry)).toBeNull(); + //bad time returns undefined + const badTimes = ' 2016-08-28 2016-07-25 17:32:32.928-06:00 17:30:31.000-06:00 '; + const badTimeDoc = xmlParser.parseFromString(badTimes, 'text/xml'); + expect(resolveTimestamps(badTimeDoc, timelineEntry)).toBeUndefined(); + //good info returns unix start and end timestamps -- TODO : address precise vs less precise? + const timeSurvey = ' 2016-07-25 2016-07-25 17:24:32.928-06:00 17:30:31.000-06:00 '; + const xmlDoc = xmlParser.parseFromString(timeSurvey, 'text/xml'); + expect(resolveTimestamps(xmlDoc, timelineEntry)).toMatchObject({start_ts: 1469492672928, end_ts: 1469493031000}); +}); + /** * @param surveyName the name of the survey (e.g. "TimeUseSurvey") * @param enketoForm the Form object from enketo-core that contains this survey diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index 83085ebd7..99045c222 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -179,7 +179,7 @@ export function getInstanceStr(xmlModel: string, opts: SurveyOptions): string|nu * - null if no timestamps are resolved * - undefined if the timestamps are invalid */ - function resolveTimestamps(xmlDoc, timelineEntry) { + export function resolveTimestamps(xmlDoc, timelineEntry) { // check for Date and Time fields const startDate = xmlDoc.getElementsByTagName('Start_date')?.[0]?.innerHTML; let startTime = xmlDoc.getElementsByTagName('Start_time')?.[0]?.innerHTML; From 641e8aa212b6d9558e5d98e7ec5e312636f927f3 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 12 Oct 2023 13:15:23 -0400 Subject: [PATCH 095/850] don't "processErrorMessages" in commHelper Appending "user-friendy" descriptions to error popups is already handled in logger.ts (see `displayErrorMsg`), so it is unnecessary in this file. --- www/js/commHelper.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/www/js/commHelper.ts b/www/js/commHelper.ts index 85281694f..259677090 100644 --- a/www/js/commHelper.ts +++ b/www/js/commHelper.ts @@ -19,14 +19,6 @@ export async function fetchUrlCached(url) { return text; } -function processErrorMessages(errorMsg) { - if (errorMsg.includes("403")) { - errorMsg = "Error: OPcode does not exist on the server. " + errorMsg; - console.error("Error 403 found. " + errorMsg); - } - return errorMsg; -} - export function getRawEntries(key_list, start_ts, end_ts, time_key = "metadata.write_ts", max_entries = undefined, trunc_method = "sample") { return new Promise((rs, rj) => { @@ -45,7 +37,6 @@ export function getRawEntries(key_list, start_ts, end_ts, time_key = "metadata.w window['cordova'].plugins.BEMServerComm.pushGetJSON("/datastreams/find_entries/timestamp", msgFiller, rs, rj); }).catch(error => { error = `While getting raw entries, ${error}`; - error = processErrorMessages(error); throw(error); }); } @@ -79,7 +70,6 @@ export function getPipelineRangeTs() { window['cordova'].plugins.BEMServerComm.getUserPersonalData("/pipeline/get_range_ts", rs, rj); }).catch(error => { error = `While getting pipeline range timestamps, ${error}`; - error = processErrorMessages(error); throw(error); }); } @@ -90,7 +80,6 @@ export function getPipelineCompleteTs() { window['cordova'].plugins.BEMServerComm.getUserPersonalData("/pipeline/get_complete_ts", rs, rj); }).catch(error => { error = `While getting pipeline complete timestamp, ${error}`; - error = processErrorMessages(error); throw(error); }); } @@ -105,7 +94,6 @@ export function getMetrics(timeType: 'timestamp'|'local_date', metricsQuery) { window['cordova'].plugins.BEMServerComm.pushGetJSON(`/result/metrics/${timeType}`, msgFiller, rs, rj); }).catch(error => { error = `While getting metrics, ${error}`; - error = processErrorMessages(error); throw(error); }); } @@ -137,7 +125,6 @@ export function getAggregateData(path: string, data: any) { } }).catch(error => { error = `While getting aggregate data, ${error}`; - error = processErrorMessages(error); throw(error); }); } @@ -151,7 +138,6 @@ export function updateUser(updateDoc) { window['cordova'].plugins.BEMServerComm.postUserPersonalData("/profile/update", "update_doc", updateDoc, rs, rj); }).catch(error => { error = `While updating user, ${error}`; - error = processErrorMessages(error); throw(error); }); } @@ -161,7 +147,6 @@ export function getUser() { window['cordova'].plugins.BEMServerComm.getUserPersonalData("/profile/get", rs, rj); }).catch(error => { error = `While getting user, ${error}`; - error = processErrorMessages(error); throw(error); }); } From 03dc94e0fa3c6b943c2bc28bf69f72e1df99fc01 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 12 Oct 2023 13:27:12 -0400 Subject: [PATCH 096/850] update comment in commHelper.test.ts - Add the 2 functions to the list that were re-implemented after the initial rewrite - More accurately describe how we would ideally test server comm --- www/__tests__/commHelper.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/www/__tests__/commHelper.test.ts b/www/__tests__/commHelper.test.ts index 6143381e3..2e2dfc6af 100644 --- a/www/__tests__/commHelper.test.ts +++ b/www/__tests__/commHelper.test.ts @@ -26,10 +26,11 @@ it('fetches text from a URL and caches it so the next call is faster', async () /* The following functions from commHelper.ts are not tested because they are just wrappers around the native functions in BEMServerComm. - If we wanted to test them, we would need to mock the native functions in BEMServerComm, but - this would be of limited value. It would be better to test the native functions directly. + If we wanted to test them, we would need to mock the native functions in BEMServerComm. + It would be better to do integration tests that actually call the native functions. * - getRawEntries + * - getRawEntriesForLocalDate * - getPipelineRangeTs * - getPipelineCompleteTs * - getMetrics @@ -37,5 +38,5 @@ it('fetches text from a URL and caches it so the next call is faster', async () * - registerUser * - updateUser * - getUser - + * - putOne */ From 83a4939ad8e8669342a12c0c6e0f6f2b6c6542ab Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 12 Oct 2023 10:50:54 -0700 Subject: [PATCH 097/850] Removed stale code The 'Chats' and 'ReferHelper' services, alongside their derivative services, have not been used for quite some time. These were removed in preparation for the UnifiedDataLoader-rewrite. --- www/js/controllers.js | 28 +------------ www/js/services.js | 93 +------------------------------------------ 2 files changed, 2 insertions(+), 119 deletions(-) diff --git a/www/js/controllers.js b/www/js/controllers.js index 75124efce..70dcf650a 100644 --- a/www/js/controllers.js +++ b/www/js/controllers.js @@ -59,30 +59,4 @@ angular.module('emission.controllers', ['emission.splash.startprefs', } }) console.log('SplashCtrl invoke finished'); -}) - - -.controller('ChatsCtrl', function($scope, Chats) { - // With the new view caching in Ionic, Controllers are only called - // when they are recreated or on app start, instead of every page change. - // To listen for when this page is active (for example, to refresh data), - // listen for the $ionicView.enter event: - // - //$scope.$on('$ionicView.enter', function(e) { - //}); - - $scope.chats = Chats.all(); - $scope.remove = function(chat) { - Chats.remove(chat); - }; -}) - -.controller('ChatDetailCtrl', function($scope, $stateParams, Chats) { - $scope.chat = Chats.get($stateParams.chatId); -}) - -.controller('AccountCtrl', function($scope) { - $scope.settings = { - enableFriends: true - }; -}); +}); \ No newline at end of file diff --git a/www/js/services.js b/www/js/services.js index 0c9c6e2ac..df84881d7 100644 --- a/www/js/services.js +++ b/www/js/services.js @@ -5,23 +5,6 @@ import { getRawEntries } from './commHelper'; angular.module('emission.services', ['emission.plugin.logger']) -.service('ReferHelper', function($http) { - - this.habiticaRegister = function(groupid, successCallback, errorCallback) { - window.cordova.plugins.BEMServerComm.getUserPersonalData("/join.group/"+groupid, successCallback, errorCallback); - }; - this.joinGroup = function(groupid, userid) { - - // TODO: - return new Promise(function(resolve, reject) { - window.cordova.plugins.BEMServerComm.postUserPersonalData("/join.group/"+groupid, "inviter", userid, resolve, reject); - }) - - //function firstUpperCase(string) { - // return string[0].toUpperCase() + string.slice(1); - //}*/ - } -}) .service('UnifiedDataLoader', function($window, Logger) { var combineWithDedup = function(list1, list2) { var combinedList = list1.concat(list2); @@ -215,78 +198,4 @@ angular.module('emission.services', ['emission.plugin.logger']) return window.cordova.plugins.BEMConnectionSettings.getSettings(); }; -}) - -.factory('Chats', function() { - // Might use a resource here that returns a JSON array - - // Some fake testing data - var chats = [{ - id: 0, - name: 'Ben Sparrow', - lastText: 'You on your way?', - face: 'img/ben.png' - }, { - id: 1, - name: 'Max Lynx', - lastText: 'Hey, it\'s me', - face: 'img/max.png' - }, { - id: 2, - name: 'Adam Bradleyson', - lastText: 'I should buy a boat', - face: 'img/adam.jpg' - }, { - id: 3, - name: 'Perry Governor', - lastText: 'Look at my mukluks!', - face: 'img/perry.png' - }, { - id: 4, - name: 'Mike Harrington', - lastText: 'This is wicked good ice cream.', - face: 'img/mike.png' - }, { - id: 5, - name: 'Ben Sparrow', - lastText: 'You on your way again?', - face: 'img/ben.png' - }, { - id: 6, - name: 'Max Lynx', - lastText: 'Hey, it\'s me again', - face: 'img/max.png' - }, { - id: 7, - name: 'Adam Bradleyson', - lastText: 'I should buy a boat again', - face: 'img/adam.jpg' - }, { - id: 8, - name: 'Perry Governor', - lastText: 'Look at my mukluks again!', - face: 'img/perry.png' - }, { - id: 9, - name: 'Mike Harrington', - lastText: 'This is wicked good ice cream again.', - face: 'img/mike.png' - }]; - - return { - all: function() { - return chats; - }, - remove: function(chat) { - chats.splice(chats.indexOf(chat), 1); - }, - get: function(chatId) { - for (var i = 0; i < chats.length; i++) { - if (chats[i].id === parseInt(chatId)) { - return chats[i]; - } - } - return null; - } - }; -}); +}) \ No newline at end of file From acb36aad8434a6d1ff91bf33b19f9ec86a8509aa Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 12 Oct 2023 13:57:18 -0400 Subject: [PATCH 098/850] cordovaMocks: use cordova-ios version from json --- www/__mocks__/cordovaMocks.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 44c21677c..4a9189ecd 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -1,7 +1,9 @@ +import packageJsonBuild from '../../package.cordovabuild.json'; + export const mockCordova = () => { window['cordova'] ||= {}; window['cordova'].platformId ||= 'ios'; - window['cordova'].platformVersion ||= '6.2.0'; + window['cordova'].platformVersion ||= packageJsonBuild.dependencies['cordova-ios']; window['cordova'].plugins ||= {}; } From e546d737a2a67e7e64660dbcb6b2fd4b79e4dfbf Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 12 Oct 2023 14:05:59 -0400 Subject: [PATCH 099/850] commHelper: promisify 'registerUser' Per https://github.com/e-mission/e-mission-phone/pull/1040#discussion_r1350911732 --- www/js/commHelper.ts | 9 +++++++-- www/js/onboarding/SaveQrPage.tsx | 6 +++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/www/js/commHelper.ts b/www/js/commHelper.ts index 259677090..b9584a044 100644 --- a/www/js/commHelper.ts +++ b/www/js/commHelper.ts @@ -129,8 +129,13 @@ export function getAggregateData(path: string, data: any) { }); } -export function registerUser(successCallback, errorCallback) { - window['cordova'].plugins.BEMServerComm.getUserPersonalData("/profile/create", successCallback, errorCallback); +export function registerUser() { + return new Promise((rs, rj) => { + window['cordova'].plugins.BEMServerComm.getUserPersonalData("/profile/create", rs, rj); + }).catch(error => { + error = `While registering user, ${error}`; + throw(error); + }); } export function updateUser(updateDoc) { diff --git a/www/js/onboarding/SaveQrPage.tsx b/www/js/onboarding/SaveQrPage.tsx index d8b555f14..6fcf06e8c 100644 --- a/www/js/onboarding/SaveQrPage.tsx +++ b/www/js/onboarding/SaveQrPage.tsx @@ -37,10 +37,10 @@ const SaveQrPage = ({ }) => { const EXPECTED_METHOD = "prompted-auth"; const dbStorageObject = {"token": token}; return storageSet(EXPECTED_METHOD, dbStorageObject).then((r) => { - registerUser((successResult) => { + registerUser().then((r) => { refreshOnboardingState(); - }, function(errorResult) { - displayError(errorResult, "User registration error"); + }).catch((e) => { + displayError(e, "User registration error"); }); }).catch((e) => { displayError(e, "Sign in error"); From 11a3df66eb4d9d15216800754af3a4c1d2dbdb95 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 12 Oct 2023 12:32:51 -0600 Subject: [PATCH 100/850] testing for _lazyLoadConfig adding a basic test for _loadLazyConfig, in order to ensure that my mock setup for that works, before moving into testing functions that depend on it --- www/__mocks__/cordovaMocks.ts | 27 +++++++++++++++++++++++ www/__tests__/enketoHelper.test.ts | 33 +++++++++++++++++++--------- www/js/survey/enketo/enketoHelper.ts | 4 ++-- 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 44c21677c..31e3e7bf4 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -85,6 +85,33 @@ export const mockBEMUserCache = () => { rs(messages.filter(m => m.key == key).map(m => m.value)); }, 100) ); + }, + getDocument: (key: string, withMetadata?: boolean) => { + // this was mocked specifically for enketoHelper's use, could be expanded if needed + const fakeSurveyConfig = { + survey_info: { + surveys: { + TimeUseSurvey: { compatibleWith: 1, + formPath: "https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json", + labelTemplate: {en: " erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic activities, }", + es: " erea, plural, =0 {} other {# Empleo/Educación, } }{ da, plural, =0 {} other {# Actividades domesticas, }"}, + labelVars: {da: {key: "Domestic_activities", type: "length"}, + erea: {key: "Employment_related_a_Education_activities", type:"length"}}, + version: 9} + } + } + } + + if(key == "config/app_ui_config"){ + return new Promise((rs, rj) => + setTimeout(() => { + rs(fakeSurveyConfig); + }, 100) + ); + } + else { + return null; + } } } window['cordova'] ||= {}; diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index 6ba78d170..54a147904 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -1,14 +1,27 @@ -import { getInstanceStr, filterByNameAndVersion, resolveTimestamps } from '../js/survey/enketo/enketoHelper'; +import { getInstanceStr, filterByNameAndVersion, resolveTimestamps, resolveLabel, _lazyLoadConfig} from '../js/survey/enketo/enketoHelper'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; mockBEMUserCache(); +mockLogger(); + +it('gets the survey config', async () => { + //this is aimed at testing my mock of the config + //mocked getDocument for the case of getting the config + let config = await _lazyLoadConfig(); + let mockSurveys = { + TimeUseSurvey: { compatibleWith: 1, + formPath: "https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json", + labelTemplate: {en: " erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic activities, }", + es: " erea, plural, =0 {} other {# Empleo/Educación, } }{ da, plural, =0 {} other {# Actividades domesticas, }"}, + labelVars: {da: {key: "Domestic_activities", type: "length"}, + erea: {key: "Employment_related_a_Education_activities", type:"length"}}, + version: 9} + } + // console.log(config); + expect(config).toMatchObject(mockSurveys); +}) - -/** - * @param xmlModel the blank XML model response for the survey - * @param opts object with options like 'prefilledSurveyResponse' or 'prefillFields' - * @returns XML string of an existing or prefilled model response, or null if no response is available - */ it('gets the model response, if avaliable, or returns null', ()=> { const xmlModel = '\n \n \n \n \n \n \n ;'; const filled = '\n \n car\n \n \n \n \n ;'; @@ -79,7 +92,7 @@ it('loads the previous response to a given survey', () => { */ it('filters the survey answers by their name and version', () => { //no answers -> no filtered answers - expect(filterByNameAndVersion("TimeUseSurvey", [])).resolves.toBe([]); + expect(filterByNameAndVersion("TimeUseSurvey", [])).resolves.toStrictEqual([]); const answer = [ { @@ -97,7 +110,7 @@ it('filters the survey answers by their name and version', () => { ]; //one answer -> that answer - expect(filterByNameAndVersion("TimeUseSurvey", answer)).resolves.toBe(answer); + expect(filterByNameAndVersion("TimeUseSurvey", answer)).resolves.toStrictEqual(answer); const answers = [ { @@ -127,5 +140,5 @@ it('filters the survey answers by their name and version', () => { ]; //several answers -> only the one that has a name match - expect(filterByNameAndVersion("TimeUseSurvey", answers)).resolves.toBe(answer); + expect(filterByNameAndVersion("TimeUseSurvey", answers)).resolves.toStrictEqual(answer); }); diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index 99045c222..c757ac72b 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -95,7 +95,7 @@ const LABEL_FUNCTIONS = { * _lazyLoadConfig load enketo survey config. If already loaded, return the cached config * @returns {Promise} enketo survey config */ -function _lazyLoadConfig() { +export function _lazyLoadConfig() { if (_config !== undefined) { return Promise.resolve(_config); } @@ -179,7 +179,7 @@ export function getInstanceStr(xmlModel: string, opts: SurveyOptions): string|nu * - null if no timestamps are resolved * - undefined if the timestamps are invalid */ - export function resolveTimestamps(xmlDoc, timelineEntry) { +export function resolveTimestamps(xmlDoc, timelineEntry) { // check for Date and Time fields const startDate = xmlDoc.getElementsByTagName('Start_date')?.[0]?.innerHTML; let startTime = xmlDoc.getElementsByTagName('Start_time')?.[0]?.innerHTML; From 37be42648bf500aca3bf35150c4a2dc563a16716 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Thu, 12 Oct 2023 13:16:09 -0600 Subject: [PATCH 101/850] Adding default label-options confirmHelper.ts - Removed archaic behavior or using old Angular i18nUtils module to get the filename of the translations' trip_confirm_options.json file - The "else" default "no label_options found in config" behavior is almost identical to the if(appConfig.label_options), but it just uses the default label options URL at label-options.json.sample - Pulled the language-specific text handling behavior out of the if statement, because either way (label_options or no label_options) the JSON data model will look the same - The for loop for filling in the translatied text for label-options checks 1. The label-options file first, then 2. The i18next file for the translation label-options.json.sample - Created new file - Replaces trip_confirm_options.json.sample (this location in confirmHelper.ts was the only place in the codebase using this file) - Modeled after https://github.com/e-mission/nrel-openpath-deploy-configs/blob/main/label_options/example-program-label-options.json - Only has translations for EN and ES trip_confirm_options.json.sample - Removed this file --- www/js/survey/multilabel/confirmHelper.ts | 37 +++---- www/json/label-options.json.sample | 124 ++++++++++++++++++++++ www/json/trip_confirm_options.json.sample | 52 --------- 3 files changed, 140 insertions(+), 73 deletions(-) create mode 100644 www/json/label-options.json.sample delete mode 100644 www/json/trip_confirm_options.json.sample diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index 6350745eb..c7cf74c26 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -36,29 +36,24 @@ export async function getLabelOptions(appConfigParam?) { if (appConfig.label_options) { const labelOptionsJson = await fetchUrlCached(appConfig.label_options); + logDebug("label_options found in config, using dynamic label options at " + appConfig.label_options); labelOptions = JSON.parse(labelOptionsJson) as LabelOptions; - /* fill in the translations to the 'text' fields of the labelOptions, - according to the current language */ - const lang = i18next.language; - for (const opt in labelOptions) { - labelOptions[opt]?.forEach?.((o, i) => { - const translationKey = o.value; - const translation = labelOptions.translations[lang][translationKey]; - labelOptions[opt][i].text = translation; - }); - } } else { - // backwards compat: if dynamic config doesn't have label_options, use the old way - const i18nUtils = getAngularService("i18nUtils"); - const optionFileName = await i18nUtils.geti18nFileName("json/", "trip_confirm_options", ".json"); - try { - const optionJson = await fetch(optionFileName).then(r => r.json()); - labelOptions = optionJson as LabelOptions; - } catch (e) { - logDebug("error "+JSON.stringify(e)+" while reading confirm options, reverting to defaults"); - const optionJson = await fetch("json/trip_confirm_options.json.sample").then(r => r.json()); - labelOptions = optionJson as LabelOptions; - } + const defaultLabelOptionsURL = 'json/label-options.json.sample'; + logDebug("No label_options found in config, using default label options at " + defaultLabelOptionsURL); + const defaultLabelOptionsJson = await fetchUrlCached(defaultLabelOptionsURL); + labelOptions = JSON.parse(defaultLabelOptionsJson) as LabelOptions; + } + /* fill in the translations to the 'text' fields of the labelOptions, + according to the current language */ + const lang = i18next.language; + for (const opt in labelOptions) { + labelOptions[opt]?.forEach?.((o, i) => { + const translationKey = o.value; + // If translation exists in i18next, use that. Otherwise, use the one in the labelOptions. + const translation = labelOptions.translations[lang][translationKey] || i18next.t(`multilabel.${translationKey}`); + labelOptions[opt][i].text = translation; + }); } return labelOptions; } diff --git a/www/json/label-options.json.sample b/www/json/label-options.json.sample new file mode 100644 index 000000000..9d3447bda --- /dev/null +++ b/www/json/label-options.json.sample @@ -0,0 +1,124 @@ +{ + "MODE": [ + {"value":"walk", "baseMode":"WALKING", "met_equivalent":"WALKING", "kgCo2PerKm": 0}, + {"value":"e-bike", "baseMode":"E_BIKE", "met": {"ALL": {"range": [0, -1], "mets": 4.9}}, "kgCo2PerKm": 0.00728}, + {"value":"bike", "baseMode":"BICYCLING", "met_equivalent":"BICYCLING", "kgCo2PerKm": 0}, + {"value":"bikeshare", "baseMode":"BICYCLING", "met_equivalent":"BICYCLING", "kgCo2PerKm": 0}, + {"value":"scootershare", "baseMode":"E_SCOOTER", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.00894}, + {"value":"drove_alone", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.22031}, + {"value":"shared_ride", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.11015}, + {"value":"e_car_drove_alone", "baseMode":"E_CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.08216}, + {"value":"e_car_shared_ride", "baseMode":"E_CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.04108}, + {"value":"moped", "baseMode":"MOPED", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.05555}, + {"value":"taxi", "baseMode":"TAXI", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.30741}, + {"value":"bus", "baseMode":"BUS", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.20727}, + {"value":"train", "baseMode":"TRAIN", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.12256}, + {"value":"free_shuttle", "baseMode":"BUS", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.20727}, + {"value":"air", "baseMode":"AIR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.09975}, + {"value":"not_a_trip", "baseMode":"UNKNOWN", "met_equivalent":"UNKNOWN", "kgCo2PerKm": 0}, + {"value":"other", "baseMode":"OTHER", "met_equivalent":"UNKNOWN", "kgCo2PerKm": 0} + ], + "PURPOSE": [ + {"value":"home"}, + {"value":"work"}, + {"value":"at_work"}, + {"value":"school"}, + {"value":"transit_transfer"}, + {"value":"shopping"}, + {"value":"meal"}, + {"value":"pick_drop_person"}, + {"value":"pick_drop_item"}, + {"value":"personal_med"}, + {"value":"access_recreation"}, + {"value":"exercise"}, + {"value":"entertainment"}, + {"value":"religious"}, + {"value":"other"} + ], + "REPLACED_MODE": [ + {"value":"no_travel"}, + {"value":"walk"}, + {"value":"bike"}, + {"value":"bikeshare"}, + {"value":"scootershare"}, + {"value":"drove_alone"}, + {"value":"shared_ride"}, + {"value":"e_car_drove_alone"}, + {"value":"e_car_shared_ride"}, + {"value":"taxi"}, + {"value":"bus"}, + {"value":"train"}, + {"value":"free_shuttle"}, + {"value":"other"} + ], + "translations": { + "en": { + "walk": "Walk", + "e-bike": "E-bike", + "bike": "Regular Bike", + "bikeshare": "Bikeshare", + "scootershare": "Scooter share", + "drove_alone": "Gas Car Drove Alone", + "shared_ride": "Gas Car Shared Ride", + "e_car_drove_alone": "E-Car Drove Alone", + "e_car_shared_ride": "E-Car Shared Ride", + "moped": "Moped", + "taxi": "Taxi/Uber/Lyft", + "bus": "Bus", + "train": "Train", + "free_shuttle": "Free Shuttle", + "air": "Air", + "not_a_trip": "Not a trip", + "no_travel": "No travel", + "home": "Home", + "work": "To Work", + "at_work": "At Work", + "school": "School", + "transit_transfer": "Transit transfer", + "shopping": "Shopping", + "meal": "Meal", + "pick_drop_person": "Pick-up/ Drop off Person", + "pick_drop_item": "Pick-up/ Drop off Item", + "personal_med": "Personal/ Medical", + "access_recreation": "Access Recreation", + "exercise": "Recreation/ Exercise", + "entertainment": "Entertainment/ Social", + "religious": "Religious", + "other": "Other" + }, + "es": { + "walk": "Caminando", + "e-bike": "e-bicicleta", + "bike": "Bicicleta", + "bikeshare": "Bicicleta compartida", + "scootershare": "Motoneta compartida", + "drove_alone": "Coche de Gas, Condujo solo", + "shared_ride": "Coche de Gas, Condujo con otros", + "e_car_drove_alone": "e-coche, Condujo solo", + "e_car_shared_ride": "e-coche, Condujo con ontras", + "moped": "Ciclomotor", + "taxi": "Taxi/Uber/Lyft", + "bus": "Autobús", + "train": "Tren", + "free_shuttle": "Colectivo gratuito", + "air": "Avión", + "not_a_trip": "No es un viaje", + "no_travel": "No viajar", + "home": "Inicio", + "work": "Trabajo", + "at_work": "En el trabajo", + "school": "Escuela", + "transit_transfer": "Transbordo", + "shopping": "Compras", + "meal": "Comida", + "pick_drop_person": "Recoger/ Entregar Individuo", + "pick_drop_item": "Recoger/ Entregar Objeto", + "personal_med": "Personal/ Médico", + "access_recreation": "Acceder a Recreación", + "exercise": "Recreación/ Ejercicio", + "entertainment": "Entretenimiento/ Social", + "religious": "Religioso", + "other": "Otros" + } + } +} \ No newline at end of file diff --git a/www/json/trip_confirm_options.json.sample b/www/json/trip_confirm_options.json.sample deleted file mode 100644 index 1e90bc1bb..000000000 --- a/www/json/trip_confirm_options.json.sample +++ /dev/null @@ -1,52 +0,0 @@ -{ - "MODE" : [ - {"text":"Walk", "value":"walk", "baseMode":"WALKING", "met_equivalent": "WALKING", "kgCo2PerKm": 0}, - {"text":"E-bike","value":"e-bike", "baseMode": "E_BIKE", "met": { - "ALL": {"range": [0, -1], "mets": 4.9} - }, "kgCo2PerKm": 0.00728}, - {"text":"Regular Bike","value":"bike", "baseMode":"BICYCLING", "met_equivalent": "BICYCLING", "kgCo2PerKm": 0}, - {"text":"Bikeshare","value":"bikeshare", "baseMode":"BICYCLING", "met_equivalent": "BICYCLING", "kgCo2PerKm": 0}, - {"text":"Scooter share","value":"scootershare", "baseMode":"E_SCOOTER", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.00894}, - {"text":"Gas Car Drove Alone","value":"drove_alone", "baseMode":"CAR", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.22031}, - {"text":"Gas Car Shared Ride","value":"shared_ride", "baseMode":"CAR", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.11015}, - {"text":"E-Car Drove Alone","value":"e_car_drove_alone", "baseMode":"E_CAR", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.08216}, - {"text":"E-Car Shared Ride","value":"e_car_shared_ride", "baseMode":"E_CAR", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.04108}, - {"text":"Taxi/Uber/Lyft","value":"taxi", "baseMode":"TAXI", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.30741}, - {"text":"Bus","value":"bus", "baseMode":"BUS", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.20727}, - {"text":"Train","value":"train", "baseMode":"TRAIN", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.12256}, - {"text":"Free Shuttle","value":"free_shuttle", "baseMode":"BUS", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.20727}, - {"text":"Air","value":"air", "baseMode":"AIR", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.09975}, - {"text":"Not a Trip","value":"not_a_trip", "baseMode":"UNKNOWN", "met_equivalent": "UNKNOWN", "kgCo2PerKm": 0}, - {"text":"Other","value":"other", "baseMode":"OTHER", "met_equivalent": "UNKNOWN", "kgCo2PerKm": 0}], - "REPLACED_MODE" : [ - {"text":"No travel", "value":"no_travel"}, - {"text":"Walk", "value":"walk"}, - {"text":"Regular Bike","value":"bike"}, - {"text":"Bikeshare","value":"bikeshare"}, - {"text":"Scooter share","value":"scootershare"}, - {"text":"Gas Car, drove alone","value":"drove_alone"}, - {"text":"Gas Car, with others","value":"shared_ride"}, - {"text":"E-Car, drove alone","value":"e_car_drove_alone"}, - {"text":"E-Car, with others","value":"e_car_shared_ride"}, - {"text":"Taxi/Uber/Lyft","value":"taxi"}, - {"text":"Bus","value":"bus"}, - {"text":"Train","value":"train"}, - {"text":"Free Shuttle","value":"free_shuttle"}, - {"text":"Other","value":"other"}], - "PURPOSE" : [ - {"text":"Home", "value":"home"}, - {"text":"To Work","value":"work"}, - {"text":"At Work","value":"at_work"}, - {"text":"School","value":"school"}, - {"text":"Transit transfer", "value":"transit_transfer"}, - {"text":"Shopping","value":"shopping"}, - {"text":"Meal","value":"meal"}, - {"text":"Pick-up/ Drop off Person","value":"pick_drop_person"}, - {"text":"Pick-up/ Drop off Item","value":"pick_drop_item"}, - {"text":"Personal/ Medical","value":"personal_med"}, - {"text":"Access Recreation","value":"access_recreation"}, - {"text":"Recreation/ Exercise","value":"exercise"}, - {"text":"Entertainment/ Social","value":"entertainment"}, - {"text":"Religious", "value":"religious"}, - {"text":"Other","value":"other"}] -} From 650851763f6146ec8cb934ba63d0e1683e37e6b9 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Thu, 12 Oct 2023 13:17:08 -0600 Subject: [PATCH 102/850] Adding examples of multilabel translations to en.json en.json - Added multilabel filed filled with a few multilabel translations in EN --- www/i18n/en.json | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/www/i18n/en.json b/www/i18n/en.json index e47fdd62d..07d31c2b3 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -146,6 +146,41 @@ "no-travel-hint": "To see more, change the filters above or go record some travel!" }, + "multilabel":{ + "walk": "Walk", + "e-bike": "E-bike", + "bike": "Regular Bike", + "bikeshare": "Bikeshare", + "scootershare": "Scooter share", + "drove_alone": "Gas Car Drove Alone", + "shared_ride": "Gas Car Shared Ride", + "e_car_drove_alone": "E-Car Drove Alone", + "e_car_shared_ride": "E-Car Shared Ride", + "moped": "Moped", + "taxi": "Taxi/Uber/Lyft", + "bus": "Bus", + "train": "Train", + "free_shuttle": "Free Shuttle", + "air": "Air", + "not_a_trip": "Not a trip", + "no_travel": "No travel", + "home": "Home", + "work": "To Work", + "at_work": "At Work", + "school": "School", + "transit_transfer": "Transit transfer", + "shopping": "Shopping", + "meal": "Meal", + "pick_drop_person": "Pick-up/ Drop off Person", + "pick_drop_item": "Pick-up/ Drop off Item", + "personal_med": "Personal/ Medical", + "access_recreation": "Access Recreation", + "exercise": "Recreation/ Exercise", + "entertainment": "Entertainment/ Social", + "religious": "Religious", + "other": "Other" + }, + "main-metrics":{ "summary": "My Summary", "chart": "Chart", From 9d83cc18cfce05b03b893131a8f610847a34f951 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 12 Oct 2023 14:28:43 -0700 Subject: [PATCH 103/850] delete old angular service --- www/js/survey/input-matcher.js | 213 --------------------------------- 1 file changed, 213 deletions(-) delete mode 100644 www/js/survey/input-matcher.js diff --git a/www/js/survey/input-matcher.js b/www/js/survey/input-matcher.js deleted file mode 100644 index 2e3d5b908..000000000 --- a/www/js/survey/input-matcher.js +++ /dev/null @@ -1,213 +0,0 @@ -'use strict'; - -import angular from 'angular'; - -angular.module('emission.survey.inputmatcher', ['emission.plugin.logger']) -.factory('InputMatcher', function(Logger){ - var im = {}; - - const EPOCH_MAXIMUM = 2**31 - 1; - const fmtTs = function(ts_in_secs, tz) { - return moment(ts_in_secs * 1000).tz(tz).format(); - } - - var printUserInput = function(ui) { - return fmtTs(ui.data.start_ts, ui.metadata.time_zone) + "("+ui.data.start_ts + ") -> "+ - fmtTs(ui.data.end_ts, ui.metadata.time_zone) + "("+ui.data.end_ts + ")"+ - " " + ui.data.label + " logged at "+ ui.metadata.write_ts; - } - - im.validUserInputForDraftTrip = function(trip, userInput, logsEnabled) { - if (logsEnabled) { - Logger.log(`Draft trip: - comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} - -> ${fmtTs(userInput.data.end_ts, userInput.metadata.time_zone)} - trip = ${fmtTs(trip.start_ts, userInput.metadata.time_zone)} - -> ${fmtTs(trip.end_ts, userInput.metadata.time_zone)} - checks are (${userInput.data.start_ts >= trip.start_ts} - && ${userInput.data.start_ts < trip.end_ts} - || ${-(userInput.data.start_ts - trip.start_ts) <= 15 * 60}) - && ${userInput.data.end_ts <= trip.end_ts} - `); - } - return (userInput.data.start_ts >= trip.start_ts - && userInput.data.start_ts < trip.end_ts - || -(userInput.data.start_ts - trip.start_ts) <= 15 * 60) - && userInput.data.end_ts <= trip.end_ts; - } - - im.validUserInputForTimelineEntry = function(tlEntry, userInput, logsEnabled) { - if (!tlEntry.origin_key) return false; - if (tlEntry.origin_key.includes('UNPROCESSED') == true) - return im.validUserInputForDraftTrip(tlEntry, userInput, logsEnabled); - - /* Place-level inputs always have a key starting with 'manual/place', and - trip-level inputs never have a key starting with 'manual/place' - So if these don't match, we can immediately return false */ - const entryIsPlace = tlEntry.origin_key == 'analysis/confirmed_place'; - const isPlaceInput = (userInput.key || userInput.metadata.key).startsWith('manual/place'); - if (entryIsPlace != isPlaceInput) - return false; - - let entryStart = tlEntry.start_ts || tlEntry.enter_ts; - let entryEnd = tlEntry.end_ts || tlEntry.exit_ts; - if (!entryStart && entryEnd) { - // if a place has no enter time, this is the first start_place of the first composite trip object - // so we will set the start time to the start of the day of the end time for the purpose of comparison - entryStart = moment.unix(entryEnd).startOf('day').unix(); - } - if (!entryEnd) { - // if a place has no exit time, the user hasn't left there yet - // so we will set the end time as high as possible for the purpose of comparison - entryEnd = EPOCH_MAXIMUM; - } - - if (logsEnabled) { - Logger.log(`Cleaned trip: - comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} - -> ${fmtTs(userInput.data.end_ts, userInput.metadata.time_zone)} - trip = ${fmtTs(entryStart, userInput.metadata.time_zone)} - -> ${fmtTs(entryStart, userInput.metadata.time_zone)} - start checks are ${userInput.data.start_ts >= entryStart} - && ${userInput.data.start_ts < entryEnd} - end checks are ${userInput.data.end_ts <= entryEnd} - || ${userInput.data.end_ts - entryEnd <= 15 * 60}) - `); - } - - /* For this input to match, it must begin after the start of the timelineEntry (inclusive) - but before the end of the timelineEntry (exclusive) */ - const startChecks = userInput.data.start_ts >= entryStart && - userInput.data.start_ts < entryEnd; - /* A matching user input must also finish before the end of the timelineEntry, - or within 15 minutes. */ - var endChecks = (userInput.data.end_ts <= entryEnd || - (userInput.data.end_ts - entryEnd) <= 15 * 60); - if (startChecks && !endChecks) { - const nextEntryObj = tlEntry.getNextEntry(); - if (nextEntryObj) { - const nextEntryEnd = nextEntryObj.end_ts || nextEntryObj.exit_ts; - if (!nextEntryEnd) { // the last place will not have an exit_ts - endChecks = true; // so we will just skip the end check - } else { - endChecks = userInput.data.end_ts <= nextEntryEnd; - Logger.log("Second level of end checks when the next trip is defined("+userInput.data.end_ts+" <= "+ nextEntryEnd+") = "+endChecks); - } - } else { - // next trip is not defined, last trip - endChecks = (userInput.data.end_local_dt.day == userInput.data.start_local_dt.day) - Logger.log("Second level of end checks for the last trip of the day"); - Logger.log("compare "+userInput.data.end_local_dt.day + " with " + userInput.data.start_local_dt.day + " = " + endChecks); - } - if (endChecks) { - // If we have flipped the values, check to see that there - // is sufficient overlap - const overlapDuration = Math.min(userInput.data.end_ts, entryEnd) - Math.max(userInput.data.start_ts, entryStart) - Logger.log("Flipped endCheck, overlap("+overlapDuration+ - ")/trip("+tlEntry.duration+") = "+ (overlapDuration / tlEntry.duration)); - endChecks = (overlapDuration/tlEntry.duration) > 0.5; - } - } - return startChecks && endChecks; - } - - // parallels get_not_deleted_candidates() in trip_queries.py - const getNotDeletedCandidates = function(candidates) { - console.log('getNotDeletedCandidates called with ' + candidates.length + ' candidates'); - // We want to retain all ACTIVE entries that have not been DELETED - const allActiveList = candidates.filter(c => !c.data.status || c.data.status == 'ACTIVE'); - const allDeletedIds = candidates.filter(c => c.data.status && c.data.status == 'DELETED').map(c => c.data['match_id']); - const notDeletedActive = allActiveList.filter(c => !allDeletedIds.includes(c.data['match_id'])); - console.log(`Found ${allActiveList.length} active entries, - ${allDeletedIds.length} deleted entries -> - ${notDeletedActive.length} non deleted active entries`); - return notDeletedActive; - } - - im.getUserInputForTrip = function(trip, nextTrip, userInputList) { - const logsEnabled = userInputList.length < 20; - - if (userInputList === undefined) { - Logger.log("In getUserInputForTrip, no user input, returning undefined"); - return undefined; - } - - if (logsEnabled) { - console.log("Input list = "+userInputList.map(printUserInput)); - } - // undefined != true, so this covers the label view case as well - var potentialCandidates = userInputList.filter((ui) => im.validUserInputForTimelineEntry(trip, ui, logsEnabled)); - if (potentialCandidates.length === 0) { - if (logsEnabled) { - Logger.log("In getUserInputForTripStartEnd, no potential candidates, returning []"); - } - return undefined; - } - - if (potentialCandidates.length === 1) { - Logger.log("In getUserInputForTripStartEnd, one potential candidate, returning "+ printUserInput(potentialCandidates[0])); - return potentialCandidates[0]; - } - - Logger.log("potentialCandidates are "+potentialCandidates.map(printUserInput)); - var sortedPC = potentialCandidates.sort(function(pc1, pc2) { - return pc2.metadata.write_ts - pc1.metadata.write_ts; - }); - var mostRecentEntry = sortedPC[0]; - Logger.log("Returning mostRecentEntry "+printUserInput(mostRecentEntry)); - return mostRecentEntry; - } - - // return array of matching additions for a trip or place - im.getAdditionsForTimelineEntry = function(entry, additionsList) { - const logsEnabled = additionsList.length < 20; - - if (additionsList === undefined) { - Logger.log("In getAdditionsForTimelineEntry, no addition input, returning []"); - return []; - } - - // get additions that have not been deleted - // and filter out additions that do not start within the bounds of the timeline entry - const notDeleted = getNotDeletedCandidates(additionsList); - const matchingAdditions = notDeleted.filter((ui) => im.validUserInputForTimelineEntry(entry, ui, logsEnabled)); - - if (logsEnabled) { - console.log("Matching Addition list = "+matchingAdditions.map(printUserInput)); - } - return matchingAdditions; - } - - im.getUniqueEntries = function(combinedList) { - // we should not get any non-ACTIVE entries here - // since we have run filtering algorithms on both the phone and the server - const allDeleted = combinedList.filter(c => c.data.status && c.data.status == 'DELETED'); - if (allDeleted.length > 0) { - Logger.displayError("Found "+allDeletedEntries.length - +" non-ACTIVE addition entries while trying to dedup entries", - allDeletedEntries); - } - const uniqueMap = new Map(); - combinedList.forEach((e) => { - const existingVal = uniqueMap.get(e.data.match_id); - // if the existing entry and the input entry don't match - // and they are both active, we have an error - // let's notify the user for now - if (existingVal) { - if ((existingVal.data.start_ts != e.data.start_ts) || - (existingVal.data.end_ts != e.data.end_ts) || - (existingVal.data.write_ts != e.data.write_ts)) { - Logger.displayError("Found two ACTIVE entries with the same match ID but different timestamps "+existingVal.data.match_id, - JSON.stringify(existingVal) + " vs. "+ JSON.stringify(e)); - } else { - console.log("Found two entries with match_id "+existingVal.data.match_id+" but they are identical"); - } - } else { - uniqueMap.set(e.data.match_id, e); - } - }); - return Array.from(uniqueMap.values()); - } - - return im; -}); From bc74d12d07245cc7e5e6d7fc0f43dcdff61d52c6 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 12 Oct 2023 14:32:29 -0700 Subject: [PATCH 104/850] add new input-matcher service --- www/js/survey/input-matcher.ts | 241 +++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 www/js/survey/input-matcher.ts diff --git a/www/js/survey/input-matcher.ts b/www/js/survey/input-matcher.ts new file mode 100644 index 000000000..8b8e6d277 --- /dev/null +++ b/www/js/survey/input-matcher.ts @@ -0,0 +1,241 @@ +import { logDebug, displayErrorMsg } from "../plugin/logger" +import { DateTime } from "luxon"; + +export type LocalDt = { + minute: number, + hour: number, + second: number, + day: number, + weekday: number, + month: number, + year: number, + timezone: string +} + +export type UserInputForTrip = { + data: { + end_ts: number, + start_ts: number + label: string, + start_local_dt?: LocalDt + end_local_dt?: LocalDt + }, + metadata: { + time_zone: string, + plugin: string, + write_ts: number, + platform: string, + read_ts: number, + key: string + }, + key?: string, +} + +export type Trip = { + end_ts: number, + start_ts: number +} + +export type TlEntry = { + key: string, + origin_key: string, + start_ts: number, + end_ts: number, + enter_ts: number, + exit_ts: number, + duration: number, + getNextEntry: any +} + + +const EPOCH_MAXIMUM = 2**31 - 1; + +export const fmtTs = (ts_in_secs: number, tz: string): string | null => DateTime.fromSeconds(ts_in_secs, {zone : tz}).toISO(); + +export const printUserInput = (ui: UserInputForTrip): string => `${fmtTs(ui.data.start_ts, ui.metadata.time_zone)} (${ui.data.start_ts}) -> +${fmtTs(ui.data.end_ts, ui.metadata.time_zone)} (${ui.data.end_ts}) ${ui.data.label} logged at ${ui.metadata.write_ts}`; + +export const validUserInputForDraftTrip = (trip: Trip, userInput: UserInputForTrip, logsEnabled: boolean): boolean => { + if(logsEnabled) { + logDebug(`Draft trip: + comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} + -> ${fmtTs(userInput.data.end_ts, userInput.metadata.time_zone)} + trip = ${fmtTs(trip.start_ts, userInput.metadata.time_zone)} + -> ${fmtTs(trip.end_ts, userInput.metadata.time_zone)} + checks are (${userInput.data.start_ts >= trip.start_ts} + && ${userInput.data.start_ts < trip.end_ts} + || ${-(userInput.data.start_ts - trip.start_ts) <= 15 * 60}) + && ${userInput.data.end_ts <= trip.end_ts} + `); + } + + return (userInput.data.start_ts >= trip.start_ts && userInput.data.start_ts < trip.end_ts + || -(userInput.data.start_ts - trip.start_ts) <= 15 * 60) && userInput.data.end_ts <= trip.end_ts; +} + +export const validUserInputForTimelineEntry = (tlEntry: TlEntry, userInput: UserInputForTrip, logsEnabled: boolean): boolean => { + if (!tlEntry.origin_key) return false; + if (tlEntry.origin_key.includes('UNPROCESSED')) return validUserInputForDraftTrip(tlEntry, userInput, logsEnabled); + + /* Place-level inputs always have a key starting with 'manual/place', and + trip-level inputs never have a key starting with 'manual/place' + So if these don't match, we can immediately return false */ + const entryIsPlace = tlEntry.origin_key === 'analysis/confirmed_place'; + const isPlaceInput = (userInput.key || userInput.metadata.key).startsWith('manual/place'); + + if (entryIsPlace !== isPlaceInput) return false; + + let entryStart = tlEntry.start_ts || tlEntry.enter_ts; + let entryEnd = tlEntry.end_ts || tlEntry.exit_ts; + + if (!entryStart && entryEnd) { + /* if a place has no enter time, this is the first start_place of the first composite trip object + so we will set the start time to the start of the day of the end time for the purpose of comparison */ + entryStart = DateTime.fromSeconds(entryEnd).startOf('day').toUnixInteger(); + } + + if (!entryEnd) { + /* if a place has no exit time, the user hasn't left there yet + so we will set the end time as high as possible for the purpose of comparison */ + entryEnd = EPOCH_MAXIMUM; + } + + if (logsEnabled) { + logDebug(`Cleaned trip: + comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} + -> ${fmtTs(userInput.data.end_ts, userInput.metadata.time_zone)} + trip = ${fmtTs(entryStart, userInput.metadata.time_zone)} + -> ${fmtTs(entryStart, userInput.metadata.time_zone)} + start checks are ${userInput.data.start_ts >= entryStart} + && ${userInput.data.start_ts < entryEnd} + end checks are ${userInput.data.end_ts <= entryEnd} + || ${userInput.data.end_ts - entryEnd <= 15 * 60}) + `); + } + + /* For this input to match, it must begin after the start of the timelineEntry (inclusive) + but before the end of the timelineEntry (exclusive) */ + const startChecks = userInput.data.start_ts >= entryStart && userInput.data.start_ts < entryEnd; + /* A matching user input must also finish before the end of the timelineEntry, + or within 15 minutes. */ + let endChecks = (userInput.data.end_ts <= entryEnd || (userInput.data.end_ts - entryEnd) <= 15 * 60); + + if (startChecks && !endChecks) { + const nextEntryObj = tlEntry.getNextEntry(); + if (nextEntryObj) { + const nextEntryEnd = nextEntryObj.end_ts || nextEntryObj.exit_ts; + if (!nextEntryEnd) { // the last place will not have an exit_ts + endChecks = true; // so we will just skip the end check + } else { + endChecks = userInput.data.end_ts <= nextEntryEnd; + logDebug(`Second level of end checks when the next trip is defined(${userInput.data.end_ts} <= ${nextEntryEnd}) ${endChecks}`); + } + } else { + // next trip is not defined, last trip + endChecks = (userInput.data.end_local_dt.day == userInput.data.start_local_dt.day) + logDebug("Second level of end checks for the last trip of the day"); + logDebug(`compare ${userInput.data.end_local_dt.day} with ${userInput.data.start_local_dt.day} ${endChecks}`); + } + if (endChecks) { + // If we have flipped the values, check to see that there is sufficient overlap + const overlapDuration = Math.min(userInput.data.end_ts, entryEnd) - Math.max(userInput.data.start_ts, entryStart) + logDebug(`Flipped endCheck, overlap(${overlapDuration})/trip(${tlEntry.duration} (${overlapDuration} / ${tlEntry.duration})`); + endChecks = (overlapDuration/tlEntry.duration) > 0.5; + } + } + return startChecks && endChecks; +} + +// parallels get_not_deleted_candidates() in trip_queries.py +export const getNotDeletedCandidates = (candidates) => { + console.log('getNotDeletedCandidates called with ' + candidates.length + ' candidates'); + + // We want to retain all ACTIVE entries that have not been DELETED + const allActiveList = candidates.filter(c => !c.data.status || c.data.status == 'ACTIVE'); + const allDeletedIds = candidates.filter(c => c.data.status && c.data.status == 'DELETED').map(c => c.data['match_id']); + const notDeletedActive = allActiveList.filter(c => !allDeletedIds.includes(c.data['match_id'])); + + console.log(`Found ${allActiveList.length} active entries, ${allDeletedIds.length} deleted entries -> + ${notDeletedActive.length} non deleted active entries`); + + return notDeletedActive; +} + +export const getUserInputForTrip = (trip: TlEntry, nextTrip: any, userInputList: UserInputForTrip[]): undefined | UserInputForTrip => { + const logsEnabled = userInputList.length < 20; + if (userInputList === undefined) { + logDebug("In getUserInputForTrip, no user input, returning undefined"); + return undefined; + } + + if (logsEnabled) console.log(`Input list = ${userInputList.map(printUserInput)}`); + + // undefined !== true, so this covers the label view case as well + const potentialCandidates = userInputList.filter((ui) => validUserInputForTimelineEntry(trip, ui, logsEnabled)); + + if (potentialCandidates.length === 0) { + if (logsEnabled) logDebug("In getUserInputForTripStartEnd, no potential candidates, returning []"); + return undefined; + } + + if (potentialCandidates.length === 1) { + logDebug(`In getUserInputForTripStartEnd, one potential candidate, returning ${printUserInput(potentialCandidates[0])}`); + return potentialCandidates[0]; + } + + logDebug(`potentialCandidates are ${potentialCandidates.map(printUserInput)}`); + + const sortedPC = potentialCandidates.sort((pc1, pc2) => pc2.metadata.write_ts - pc1.metadata.write_ts); + const mostRecentEntry = sortedPC[0]; + logDebug("Returning mostRecentEntry "+printUserInput(mostRecentEntry)); + + return mostRecentEntry; +} + +// return array of matching additions for a trip or place +export const getAdditionsForTimelineEntry = (entry, additionsList) => { + const logsEnabled = additionsList.length < 20; + + if (additionsList === undefined) { + logDebug("In getAdditionsForTimelineEntry, no addition input, returning []"); + return []; + } + + // get additions that have not been deleted and filter out additions that do not start within the bounds of the timeline entry + const notDeleted = getNotDeletedCandidates(additionsList); + const matchingAdditions = notDeleted.filter((ui) => validUserInputForTimelineEntry(entry, ui, logsEnabled)); + + if (logsEnabled) console.log(`Matching Addition list ${matchingAdditions.map(printUserInput)}`); + + return matchingAdditions; +} + +export const getUniqueEntries = (combinedList) => { + /* we should not get any non-ACTIVE entries here + since we have run filtering algorithms on both the phone and the server */ + const allDeleted = combinedList.filter(c => c.data.status && c.data.status == 'DELETED'); + + if (allDeleted.length > 0) { + displayErrorMsg("Found "+allDeleted.length +" non-ACTIVE addition entries while trying to dedup entries", JSON.stringify(allDeleted)); + } + + const uniqueMap = new Map(); + combinedList.forEach((e) => { + const existingVal = uniqueMap.get(e.data.match_id); + /* if the existing entry and the input entry don't match and they are both active, we have an error + let's notify the user for now */ + if (existingVal) { + if ((existingVal.data.start_ts != e.data.start_ts) || + (existingVal.data.end_ts != e.data.end_ts) || + (existingVal.data.write_ts != e.data.write_ts)) { + displayErrorMsg(`Found two ACTIVE entries with the same match ID but different timestamps ${existingVal.data.match_id}` + , `${JSON.stringify(existingVal)} vs ${JSON.stringify(e)}`); + } else { + console.log(`Found two entries with match_id ${existingVal.data.match_id} but they are identical`); + } + } else { + uniqueMap.set(e.data.match_id, e); + } + }); + return Array.from(uniqueMap.values()); +} From eb0d63e1f07ed32ec7ee59b1b4a5d53e77ae1c28 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 12 Oct 2023 14:33:40 -0700 Subject: [PATCH 105/850] call input matcher functions from the new input-matcher service --- www/js/survey/enketo/enketo-add-note-button.js | 10 +++++----- www/js/survey/enketo/enketo-trip-button.js | 9 ++++----- www/js/survey/multilabel/multi-label-ui.js | 4 ++-- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/www/js/survey/enketo/enketo-add-note-button.js b/www/js/survey/enketo/enketo-add-note-button.js index a2f0d1557..d0433b314 100644 --- a/www/js/survey/enketo/enketo-add-note-button.js +++ b/www/js/survey/enketo/enketo-add-note-button.js @@ -3,13 +3,13 @@ */ import angular from 'angular'; +import { getAdditionsForTimelineEntry, getUniqueEntries } from '../input-matcher'; angular.module('emission.survey.enketo.add-note-button', ['emission.stats.clientstats', 'emission.services', - 'emission.survey.enketo.answer', - 'emission.survey.inputmatcher']) -.factory("EnketoNotesButtonService", function(InputMatcher, EnketoSurveyAnswer, Logger, $timeout) { + 'emission.survey.enketo.answer']) +.factory("EnketoNotesButtonService", function(EnketoSurveyAnswer, Logger, $timeout) { var enbs = {}; console.log("Creating EnketoNotesButtonService"); enbs.SINGLE_KEY="NOTES"; @@ -77,9 +77,9 @@ angular.module('emission.survey.enketo.add-note-button', // be re-matching entries that have already been matched on the server // but the number of matched entries is likely to be small, so we can live // with the performance for now - const unprocessedAdditions = InputMatcher.getAdditionsForTimelineEntry(timelineEntry, inputList); + const unprocessedAdditions = getAdditionsForTimelineEntry(timelineEntry, inputList); const combinedPotentialAdditionList = timelineEntry.additions.concat(unprocessedAdditions); - const dedupedList = InputMatcher.getUniqueEntries(combinedPotentialAdditionList); + const dedupedList = getUniqueEntries(combinedPotentialAdditionList); Logger.log("After combining unprocessed ("+unprocessedAdditions.length+ ") with server ("+timelineEntry.additions.length+ ") for a combined ("+combinedPotentialAdditionList.length+ diff --git a/www/js/survey/enketo/enketo-trip-button.js b/www/js/survey/enketo/enketo-trip-button.js index 5b385a1ac..eacb7c61d 100644 --- a/www/js/survey/enketo/enketo-trip-button.js +++ b/www/js/survey/enketo/enketo-trip-button.js @@ -12,12 +12,12 @@ */ import angular from 'angular'; +import { getUserInputForTrip } from '../input-matcher'; angular.module('emission.survey.enketo.trip.button', ['emission.stats.clientstats', - 'emission.survey.enketo.answer', - 'emission.survey.inputmatcher']) -.factory("EnketoTripButtonService", function(InputMatcher, EnketoSurveyAnswer, Logger, $timeout) { + 'emission.survey.enketo.answer']) +.factory("EnketoTripButtonService", function(EnketoSurveyAnswer, Logger, $timeout) { var etbs = {}; console.log("Creating EnketoTripButtonService"); etbs.key = "manual/trip_user_input"; @@ -62,8 +62,7 @@ angular.module('emission.survey.enketo.trip.button', */ etbs.populateManualInputs = function (trip, nextTrip, inputType, inputList) { // Check unprocessed labels first since they are more recent - const unprocessedLabelEntry = InputMatcher.getUserInputForTrip(trip, nextTrip, - inputList); + const unprocessedLabelEntry = getUserInputForTrip(trip, nextTrip,inputList); var userInputEntry = unprocessedLabelEntry; if (!angular.isDefined(userInputEntry)) { userInputEntry = trip.user_input?.[etbs.inputType2retKey(inputType)]; diff --git a/www/js/survey/multilabel/multi-label-ui.js b/www/js/survey/multilabel/multi-label-ui.js index 313c8a3a9..8857f75c4 100644 --- a/www/js/survey/multilabel/multi-label-ui.js +++ b/www/js/survey/multilabel/multi-label-ui.js @@ -1,6 +1,7 @@ import angular from 'angular'; import { baseLabelInputDetails, getBaseLabelInputs, getFakeEntry, getLabelInputDetails, getLabelInputs, getLabelOptions } from './confirmHelper'; import { getConfig } from '../../config/dynamicConfig'; +import { getUserInputForTrip } from '../input-matcher'; angular.module('emission.survey.multilabel.buttons', ['emission.stats.clientstats', @@ -66,8 +67,7 @@ angular.module('emission.survey.multilabel.buttons', */ mls.populateManualInputs = function (trip, nextTrip, inputType, inputList) { // Check unprocessed labels first since they are more recent - const unprocessedLabelEntry = InputMatcher.getUserInputForTrip(trip, nextTrip, - inputList); + const unprocessedLabelEntry = getUserInputForTrip(trip, nextTrip, inputList); var userInputLabel = unprocessedLabelEntry? unprocessedLabelEntry.data.label : undefined; if (!angular.isDefined(userInputLabel)) { userInputLabel = trip.user_input?.[mls.inputType2retKey(inputType)]; From 9c518f65bd128140988f62597fe8d96d2f014efa Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 12 Oct 2023 14:35:32 -0700 Subject: [PATCH 106/850] add input-matcher unit test (not done) --- www/__tests__/input-matcher.test.ts | 143 ++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 www/__tests__/input-matcher.test.ts diff --git a/www/__tests__/input-matcher.test.ts b/www/__tests__/input-matcher.test.ts new file mode 100644 index 000000000..8dbb98a79 --- /dev/null +++ b/www/__tests__/input-matcher.test.ts @@ -0,0 +1,143 @@ +import { + TlEntry, + Trip, + UserInputForTrip, + fmtTs, + printUserInput, + validUserInputForDraftTrip, + validUserInputForTimelineEntry, + getNotDeletedCandidates +} from '../js/survey/input-matcher'; + +describe('input-matcher', () => { + let userTrip: UserInputForTrip; + + beforeEach(() => { + // create a userTrip object before each test case. + userTrip = { + data: { + end_ts: 1437604764, + start_ts: 1437601247, + label: "FOO" + }, + metadata: { + time_zone: "America/Los_Angeles", + plugin: "none", + write_ts: 1695921991.013001, + platform: "ios", + read_ts: 0, + key: "manual/mode_confirm" + }, + key: "manual/place" + } + }); + + it('tests fmtTs with valid input', () => { + const pstTime = fmtTs(1437601247.8459613, "America/Los_Angeles"); + const estTime = fmtTs(1437601247.8459613, "America/New_York"); + // Check if it contains correct year-mm-dd hr:mm + expect(pstTime).toContain("2015-07-22T14:40"); + expect(estTime).toContain("2015-07-22T17:40"); + }); + + it('tests fmtTs with invalid input', () => { + const formattedTeim = fmtTs(0, ""); + // Check if it contains correct year-mm-dd hr:mm + expect(formattedTeim).toBeFalsy(); + }); + + it('tests printUserInput prints the trip log correctly', () => { + const userTripLog = printUserInput(userTrip); + expect(userTripLog).toContain("1437604764"); + expect(userTripLog).toContain("1437601247"); + expect(userTripLog).toContain("FOO"); + }); + + it('tests validUserInputForDraftTrip with valid trip', () => { + const validTrp = { + end_ts: 1437604764, + start_ts: 1437601247 + } + const validUserInput = validUserInputForDraftTrip(validTrp, userTrip, false); + expect(validUserInput).toBeTruthy(); + }); + + it('tests validUserInputForDraftTrip with invalid trip', () => { + const invalidTrip = { + end_ts: 0, + start_ts: 0 + } + const invalidUserInput = validUserInputForDraftTrip(invalidTrip, userTrip, false); + expect(invalidUserInput).toBeFalsy(); + }); + + it('tests validUserInputForTimelineEntry with valid tlEntry object', () => { + const tlEntry: TlEntry = { + key: "analysis/confirmed_place", + origin_key: "analysis/confirmed_place", + start_ts: 1437601000, + end_ts: 1437605000, + enter_ts: 1437605000, + exit_ts: 1437605000, + duration: 100, + getNextEntry: jest.fn() + } + + const validTimelineEntry = validUserInputForTimelineEntry(tlEntry, userTrip, false); + expect(validTimelineEntry).toBeTruthy(); + }); + + it('tests validUserInputForTimelineEntry with invalid tlEntry key', () => { + const tlEntry: TlEntry = { + key: "FOO", + origin_key: "FOO", + start_ts: 1437601000, + end_ts: 1437605000, + enter_ts: 1437605000, + exit_ts: 1437605000, + duration: 100, + getNextEntry: jest.fn() + } + + const invalidTimelineEntry = validUserInputForTimelineEntry(tlEntry, userTrip, false); + expect(invalidTimelineEntry).toBeFalsy(); + }); + + it('tests validUserInputForTimelineEntry with invalid tlEntry start & end time', () => { + const tlEntry: TlEntry = { + key: "analysis/confirmed_place", + origin_key: "analysis/confirmed_place", + start_ts: 1, + end_ts: 1, + enter_ts: 1, + exit_ts: 1, + duration: 1, + getNextEntry: jest.fn() + } + + const invalidTimelineEntry = validUserInputForTimelineEntry(tlEntry, userTrip, false); + expect(invalidTimelineEntry).toBeFalsy(); + }); + + it('tests getNotDeletedCandidates called with 0 candidates', () => { + jest.spyOn(console, 'log'); + const candidates = getNotDeletedCandidates([]); + + // check if the log printed collectly with + expect(console.log).toHaveBeenCalledWith('getNotDeletedCandidates called with 0 candidates'); + expect(candidates).toStrictEqual([]); + + }); + + it('tests getUserInputForTrip', () => { + + }); + + it('tests getAdditionsForTimelineEntry', () => { + + }); + + it('tests getUniqueEntries', () => { + + }); +}) \ No newline at end of file From bc42845ecfb91e16355cce92a8bad3b3435ab0e7 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 12 Oct 2023 16:27:51 -0600 Subject: [PATCH 107/850] attempting to add more tests currently struggling with i18next and MessageFormat, as I can't get either of those mocked and working --- www/__mocks__/i18nextMocks.ts | 8 +++++++ www/__mocks__/messageFormatMocks.ts | 32 +++++++++++++++++++++++++ www/__tests__/enketoHelper.test.ts | 36 +++++++++++++++++++++++++--- www/js/survey/enketo/enketoHelper.ts | 2 +- 4 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 www/__mocks__/i18nextMocks.ts create mode 100644 www/__mocks__/messageFormatMocks.ts diff --git a/www/__mocks__/i18nextMocks.ts b/www/__mocks__/i18nextMocks.ts new file mode 100644 index 000000000..dc0d3f2b4 --- /dev/null +++ b/www/__mocks__/i18nextMocks.ts @@ -0,0 +1,8 @@ +const i18next = jest.createMockFromModule('i18next'); + +let resolvedLanugage; + +function _setUpLanguage(language) { + console.log("setting resolved language to ", language, " for testing"); + resolvedLanugage = language; +} diff --git a/www/__mocks__/messageFormatMocks.ts b/www/__mocks__/messageFormatMocks.ts new file mode 100644 index 000000000..f32c07ed4 --- /dev/null +++ b/www/__mocks__/messageFormatMocks.ts @@ -0,0 +1,32 @@ +//call signature MessageFormat.compile(templage)(vars); +//in - template an vars -- {... pca: 0, ...} +//out - 1 Personal Care, + +export default class MessageFormat{ + + constructor( locale: string ) { } + + compile(message: string) { + return (vars: {}) => { + let label = ""; + const brokenList = message.split("}{"); + console.log(brokenList); + + for (let key in vars) { + brokenList.forEach((item) => { + let brokenItem = item.split(","); + if(brokenItem[0] == key) { + let getLabel = brokenItem[2].split("#"); + console.log(getLabel); + label = vars[key] + " " + getLabel[1]; + return label; + } + }) + } + + } + } +} + +exports.MessageFormat = MessageFormat; + \ No newline at end of file diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index 54a147904..96475bd58 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -1,9 +1,15 @@ -import { getInstanceStr, filterByNameAndVersion, resolveTimestamps, resolveLabel, _lazyLoadConfig} from '../js/survey/enketo/enketoHelper'; +import { getInstanceStr, filterByNameAndVersion, resolveTimestamps, resolveLabel, _lazyLoadConfig, loadPreviousResponseForSurvey} from '../js/survey/enketo/enketoHelper'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; +import i18next from "i18next"; + mockBEMUserCache(); mockLogger(); +// jest.mock('../__mocks__/messageFormatMocks'); +// jest.mock("i18next"); + +// global.i18next = { resolvedLanguage : "en" } it('gets the survey config', async () => { //this is aimed at testing my mock of the config @@ -18,7 +24,6 @@ it('gets the survey config', async () => { erea: {key: "Employment_related_a_Education_activities", type:"length"}}, version: 9} } - // console.log(config); expect(config).toMatchObject(mockSurveys); }) @@ -61,6 +66,30 @@ it('resolves the timestamps', () => { expect(resolveTimestamps(xmlDoc, timelineEntry)).toMatchObject({start_ts: 1469492672928, end_ts: 1469493031000}); }); +//resolve label +// it('resolves the label', async () => { +// i18next.init({ +// fallbackLng: 'en', +// debug: true +// }, (err, t) => { +// if (err) return console.log('something went wrong loading', err); +// t('key'); // -> same as i18next.t +// }); + +// console.log("language in tests", i18next.resolvedLanguage); +// const xmlParser = new window.DOMParser(); +// //have a custom survey label function TODO: we currently don't have custome label functions, but should test when we do + +// //no custom function, fallback to UseLabelTemplate +// const xmlString = ' option_1/Domestic_activities> option_2 '; +// const xmlDoc = xmlParser.parseFromString(xmlString, 'text/html'); + +// //if no template, returns "Answered" +// expect(await resolveLabel("TimeUseSurvey", xmlDoc)).toBe(""); +// //if no labelVars, returns template +// //else interpolates +// }); + /** * @param surveyName the name of the survey (e.g. "TimeUseSurvey") * @param enketoForm the Form object from enketo-core that contains this survey @@ -82,7 +111,8 @@ it('gets the saved result or throws an error', () => { */ // export function loadPreviousResponseForSurvey(dataKey: string) { it('loads the previous response to a given survey', () => { - + //not really sure if I can test this yet given that it relies on an angular service... + loadPreviousResponseForSurvey("manual/demographic_survey"); }); /** diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index c757ac72b..f72921582 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -130,7 +130,7 @@ export function _lazyLoadConfig() { * @param {XMLDocument} xmlDoc survey answer object * @returns {Promise} label string Promise */ -function resolveLabel(name: string, xmlDoc: XMLDocument) { +export function resolveLabel(name: string, xmlDoc: XMLDocument) { // Some studies may want a custom label function for their survey. // Those can be added in LABEL_FUNCTIONS with the survey name as the key. // Otherwise, UseLabelTemplate will create a label using the template in the config From 2963e125d37f3a2251d63d2b39346c44b9f6e5ff Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 12 Oct 2023 16:22:33 -0700 Subject: [PATCH 108/850] Rewrote unifiedDataLoader into typscript functions - Moved UnifiedDataLoader to a separate typescript file. - The method is no longer an angular service, and instead exports two 'getUnified' methods. - Updated other files that rely on this service. - Tested getUnifiedMessagesForoInterval, as used in enketoHelper. --- www/js/diary/services.js | 8 +-- www/js/diary/timelineHelper.ts | 6 +- www/js/services.js | 83 +------------------------ www/js/survey/enketo/enketoHelper.ts | 4 +- www/js/unifiedDataLoader.ts | 90 ++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 91 deletions(-) create mode 100644 www/js/unifiedDataLoader.ts diff --git a/www/js/diary/services.js b/www/js/diary/services.js index 774273fa2..792c640a3 100644 --- a/www/js/diary/services.js +++ b/www/js/diary/services.js @@ -1,15 +1,15 @@ 'use strict'; import angular from 'angular'; -import { getBaseModeByKey, getBaseModeOfLabeledTrip } from './diaryHelper'; import { SurveyOptions } from '../survey/survey'; import { getConfig } from '../config/dynamicConfig'; import { getRawEntries } from '../commHelper'; +import { getUnifiedSensorDataForInterval, getUnifiedMessagesForInterval } from '../unifiedDataLoader' angular.module('emission.main.diary.services', ['emission.plugin.logger', 'emission.services']) .factory('Timeline', function($http, $ionicLoading, $ionicPlatform, $window, - $rootScope, UnifiedDataLoader, Logger, $injector) { + $rootScope, Logger, $injector) { var timeline = {}; // corresponds to the old $scope.data. Contains all state for the current // day, including the indication of the current day @@ -232,7 +232,7 @@ angular.module('emission.main.diary.services', ['emission.plugin.logger', Logger.log("About to pull location data for range " + moment.unix(tripStartTransition.data.ts).toString() + " -> " + moment.unix(tripEndTransition.data.ts).toString()); - return UnifiedDataLoader.getUnifiedSensorDataForInterval("background/filtered_location", tq).then(function(locationList) { + return getUnifiedSensorDataForInterval("background/filtered_location", tq).then(function(locationList) { if (locationList.length == 0) { return undefined; } @@ -304,7 +304,7 @@ angular.module('emission.main.diary.services', ['emission.plugin.logger', } Logger.log("about to query for unprocessed trips from " +moment.unix(tq.startTs).toString()+" -> "+moment.unix(tq.endTs).toString()); - return UnifiedDataLoader.getUnifiedMessagesForInterval("statemachine/transition", tq) + return getUnifiedMessagesForInterval("statemachine/transition", tq) .then(function(transitionList) { if (transitionList.length == 0) { Logger.log("No unprocessed trips. yay!"); diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 579e3ac4f..c4d517c40 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -2,6 +2,7 @@ import moment from "moment"; import { getAngularService } from "../angular-react-helper"; import { displayError, logDebug } from "../plugin/logger"; import { getBaseModeByKey, getBaseModeOfLabeledTrip } from "./diaryHelper"; +import { getUnifiedMessagesForInterval } from "../unifiedDataLoader"; import i18next from "i18next"; const cachedGeojsons = new Map(); @@ -147,13 +148,12 @@ export function getLocalUnprocessedInputs(pipelineRange, labelsFactory, notesFac * @returns Promise an array with 1) results for labels and 2) results for notes */ export function getAllUnprocessedInputs(pipelineRange, labelsFactory, notesFactory) { - const UnifiedDataLoader = getAngularService('UnifiedDataLoader'); const tq = getUnprocessedInputQuery(pipelineRange); const labelsPromises = labelsFactory.MANUAL_KEYS.map((key) => - UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true).then(labelsFactory.extractResult) + getUnifiedMessagesForInterval(key, tq).then(labelsFactory.extractResult) ); const notesPromises = notesFactory.MANUAL_KEYS.map((key) => - UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true).then(notesFactory.extractResult) + getUnifiedMessagesForInterval(key, tq).then(notesFactory.extractResult) ); return getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, notesPromises); } diff --git a/www/js/services.js b/www/js/services.js index df84881d7..118e98811 100644 --- a/www/js/services.js +++ b/www/js/services.js @@ -4,87 +4,6 @@ import angular from 'angular'; import { getRawEntries } from './commHelper'; angular.module('emission.services', ['emission.plugin.logger']) - -.service('UnifiedDataLoader', function($window, Logger) { - var combineWithDedup = function(list1, list2) { - var combinedList = list1.concat(list2); - return combinedList.filter(function(value, i, array) { - var firstIndexOfValue = array.findIndex(function(element, index, array) { - return element.metadata.write_ts == value.metadata.write_ts; - }); - return firstIndexOfValue == i; - }); - }; - - // TODO: generalize to iterable of promises - var combinedPromise = function(localPromise, remotePromise, combiner) { - return new Promise(function(resolve, reject) { - var localResult = []; - var localError = null; - - var remoteResult = []; - var remoteError = null; - - var localPromiseDone = false; - var remotePromiseDone = false; - - var checkAndResolve = function() { - if (localPromiseDone && remotePromiseDone) { - // time to return from this promise - if (localError && remoteError) { - reject([localError, remoteError]); - } else { - Logger.log("About to dedup localResult = "+localResult.length - +"remoteResult = "+remoteResult.length); - var dedupedList = combiner(localResult, remoteResult); - Logger.log("Deduped list = "+dedupedList.length); - resolve(dedupedList); - } - } - }; - - localPromise.then(function(currentLocalResult) { - localResult = currentLocalResult; - localPromiseDone = true; - }, function(error) { - localResult = []; - localError = error; - localPromiseDone = true; - }).then(checkAndResolve); - - remotePromise.then(function(currentRemoteResult) { - remoteResult = currentRemoteResult; - remotePromiseDone = true; - }, function(error) { - remoteResult = []; - remoteError = error; - remotePromiseDone = true; - }).then(checkAndResolve); - }) - } - - // TODO: Generalize this to work for both sensor data and messages - // Do we even need to separate the two kinds of data? - // Alternatively, we can maintain another mapping between key -> type - // Probably in www/json... - this.getUnifiedSensorDataForInterval = function(key, tq) { - var localPromise = $window.cordova.plugins.BEMUserCache.getSensorDataForInterval(key, tq, true); - var remotePromise = getRawEntries([key], tq.startTs, tq.endTs) - .then(function(serverResponse) { - return serverResponse.phone_data; - }); - return combinedPromise(localPromise, remotePromise, combineWithDedup); - }; - - this.getUnifiedMessagesForInterval = function(key, tq, withMetadata) { - var localPromise = $window.cordova.plugins.BEMUserCache.getMessagesForInterval(key, tq, true); - var remotePromise = getRawEntries([key], tq.startTs, tq.endTs) - .then(function(serverResponse) { - return serverResponse.phone_data; - }); - return combinedPromise(localPromise, remotePromise, combineWithDedup); - } -}) .service('ControlHelper', function($window, $ionicPopup, Logger) { @@ -198,4 +117,4 @@ angular.module('emission.services', ['emission.plugin.logger']) return window.cordova.plugins.BEMConnectionSettings.getSettings(); }; -}) \ No newline at end of file +}); \ No newline at end of file diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index 6e9147cf8..aecf3af93 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -3,6 +3,7 @@ import { Form } from 'enketo-core'; import { XMLParser } from 'fast-xml-parser'; import i18next from 'i18next'; import { logDebug } from "../../plugin/logger"; +import { getUnifiedMessagesForInterval } from "../../unifiedDataLoader"; export type PrefillFields = {[key: string]: string}; @@ -107,9 +108,8 @@ const _getMostRecent = (answers) => { * with incremental updates, we may want to revisit this. */ export function loadPreviousResponseForSurvey(dataKey: string) { - const UnifiedDataLoader = getAngularService('UnifiedDataLoader'); const tq = window['cordova'].plugins.BEMUserCache.getAllTimeQuery(); logDebug("loadPreviousResponseForSurvey: dataKey = " + dataKey + "; tq = " + tq); - return UnifiedDataLoader.getUnifiedMessagesForInterval(dataKey, tq) + return getUnifiedMessagesForInterval(dataKey, tq) .then(answers => _getMostRecent(answers)) } diff --git a/www/js/unifiedDataLoader.ts b/www/js/unifiedDataLoader.ts new file mode 100644 index 000000000..32c12ea3e --- /dev/null +++ b/www/js/unifiedDataLoader.ts @@ -0,0 +1,90 @@ +import { logInfo } from './plugin/logger' +import { getRawEntries } from './commHelper'; + +// Helper Functions for the getUnified methods. +const combineWithDedup = function(list1, list2) { + const combinedList = list1.concat(list2); + return combinedList.filter(function(value, i, array) { + const firstIndexOfValue = array.findIndex(function(element, index, array) { + return element.metadata.write_ts == value.metadata.write_ts; + }); + return firstIndexOfValue == i; + }); +}; + +// TODO: generalize to iterable of promises +const combinedPromise = function(localPromise, remotePromise, combiner) { + return new Promise(function(resolve, reject) { + var localResult = []; + var localError = null; + + var remoteResult = []; + var remoteError = null; + + var localPromiseDone = false; + var remotePromiseDone = false; + + const checkAndResolve = function() { + if (localPromiseDone && remotePromiseDone) { + // time to return from this promise + if (localError && remoteError) { + reject([localError, remoteError]); + } else { + logInfo("About to dedup localResult = "+localResult.length + +"remoteResult = "+remoteResult.length); + const dedupedList = combiner(localResult, remoteResult); + logInfo("Deduped list = "+dedupedList.length); + resolve(dedupedList); + } + } + }; + + localPromise.then(function(currentLocalResult) { + localResult = currentLocalResult; + localPromiseDone = true; + }, function(error) { + localResult = []; + localError = error; + localPromiseDone = true; + }).then(checkAndResolve); + + remotePromise.then(function(currentRemoteResult) { + remoteResult = currentRemoteResult; + remotePromiseDone = true; + }, function(error) { + remoteResult = []; + remoteError = error; + remotePromiseDone = true; + }).then(checkAndResolve); + }) +} + +interface serverData { + phone_data: Array; +} +interface tQ { + key: string; + startTs: number; + endTs: number; +} +// TODO: Generalize this to work for both sensor data and messages +// Do we even need to separate the two kinds of data? +export const getUnifiedSensorDataForInterval = function(key: string, tq: tQ) { + const localPromise = window['cordova'].plugins.BEMUserCache.getSensorDataForInterval(key, tq, true); + const remotePromise = getRawEntries([key], tq.startTs, tq.endTs) + .then(function(serverResponse: serverData) { + console.log(`\n\n\n TQ : ${JSON.stringify(tq)}`) + return serverResponse.phone_data; + }); + return combinedPromise(localPromise, remotePromise, combineWithDedup); +}; + +export const getUnifiedMessagesForInterval = function(key: string, tq: tQ) { + const localPromise = window['cordova'].plugins.BEMUserCache.getMessagesForInterval(key, tq, true); + const remotePromise = getRawEntries([key], tq.startTs, tq.endTs) + .then(function(serverResponse: serverData) { + console.log('==>', JSON.stringify(tq.endTs), ':',typeof tq.endTs); + return serverResponse.phone_data; + }); + return combinedPromise(localPromise, remotePromise, combineWithDedup); + } From 4d86b3727623413f113eb3207ac1691ad3f9499e Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 13 Oct 2023 01:37:28 -0400 Subject: [PATCH 109/850] Merge branch 'service_rewrite_2023' of https://github.com/e-mission/e-mission-phone into rewrite_angular_service --- .bowerrc | 3 - .gitignore | 4 - bin/download_settings_controls.js | 33 - package.cordovabuild.json | 5 +- package.serve.json | 4 +- resources/android/ic_mood_question.png | Bin 516 -> 0 bytes .../drawable-hdpi-v11/ic_mood_question.png | Bin 1005 -> 0 bytes .../drawable-hdpi-v9/ic_mood_question.png | Bin 671 -> 0 bytes .../drawable-hdpi/ic_mood_question.png | Bin 1455 -> 0 bytes .../drawable-mdpi-v11/ic_mood_question.png | Bin 571 -> 0 bytes .../drawable-mdpi-v9/ic_mood_question.png | Bin 469 -> 0 bytes .../drawable-mdpi/ic_mood_question.png | Bin 834 -> 0 bytes .../drawable-xhdpi-v11/ic_mood_question.png | Bin 1540 -> 0 bytes .../drawable-xhdpi-v9/ic_mood_question.png | Bin 1272 -> 0 bytes .../drawable-xhdpi/ic_mood_question.png | Bin 2140 -> 0 bytes .../drawable-xxhdpi-v11/ic_mood_question.png | Bin 1632 -> 0 bytes .../drawable-xxhdpi-v9/ic_mood_question.png | Bin 1359 -> 0 bytes .../drawable-xxhdpi/ic_mood_question.png | Bin 1706 -> 0 bytes .../drawable-hdpi-v11/ic_question_answer.png | Bin 318 -> 0 bytes .../drawable-hdpi-v9/ic_question_answer.png | Bin 236 -> 0 bytes .../drawable-hdpi/ic_question_answer.png | Bin 895 -> 0 bytes .../drawable-mdpi-v11/ic_question_answer.png | Bin 238 -> 0 bytes .../drawable-mdpi-v9/ic_question_answer.png | Bin 265 -> 0 bytes .../drawable-mdpi/ic_question_answer.png | Bin 631 -> 0 bytes .../drawable-xhdpi-v11/ic_question_answer.png | Bin 351 -> 0 bytes .../drawable-xhdpi-v9/ic_question_answer.png | Bin 390 -> 0 bytes .../drawable-xhdpi/ic_question_answer.png | Bin 1218 -> 0 bytes .../ic_question_answer.png | Bin 509 -> 0 bytes .../drawable-xxhdpi-v9/ic_question_answer.png | Bin 357 -> 0 bytes .../drawable-xxhdpi/ic_question_answer.png | Bin 439 -> 0 bytes resources/minus.gif | Bin 4635 -> 0 bytes resources/plus.gif | Bin 4633 -> 0 bytes scss/ionic.app.scss | 23 - setup/setup_shared_native.sh | 2 - webpack.config.js | 1 + www/css/appstatus.css | 12 - www/css/intro.css | 87 - www/css/main.recent.css | 12 - www/css/style.css | 17 - www/i18n/en.json | 86 +- .../enketo => img}/enketo_bare_150x56.png | Bin www/index.html | 15 +- www/index.js | 21 +- www/js/App.tsx | 88 + www/js/app.js | 111 - www/js/appTheme.ts | 5 +- www/js/appstatus/PermissionsControls.tsx | 65 + www/js/appstatus/permissioncheck.js | 429 -- www/js/components/ActionMenu.tsx | 41 + www/js/components/BarChart.tsx | 206 +- www/js/components/Carousel.tsx | 42 + www/js/components/Chart.tsx | 196 + .../{LeafletView.jsx => LeafletView.tsx} | 13 +- www/js/components/LineChart.tsx | 11 + www/js/components/QrCode.jsx | 19 - www/js/components/QrCode.tsx | 41 + www/js/components/charting.ts | 161 + www/js/config/dynamicConfig.ts | 248 + www/js/config/dynamic_config.js | 348 -- www/js/config/imperial.js | 51 - www/js/config/serverConn.ts | 13 + www/js/config/server_conn.js | 36 - www/js/config/useImperialConfig.ts | 65 +- www/js/control/AlertBar.jsx | 2 +- www/js/control/AppStatusModal.tsx | 466 +- www/js/control/ControlCollectionHelper.tsx | 285 + www/js/control/ControlSyncHelper.tsx | 284 + www/js/control/DemographicsSettingRow.jsx | 42 +- www/js/control/LogPage.tsx | 153 + www/js/control/PopOpCode.jsx | 7 +- www/js/control/PrivacyPolicyModal.tsx | 182 +- www/js/control/ProfileSettings.jsx | 352 +- www/js/control/SensedPage.tsx | 91 + www/js/control/SettingRow.jsx | 2 +- www/js/control/general-settings.js | 44 - www/js/diary.js | 4 +- www/js/diary/LabelTab.tsx | 27 +- www/js/diary/cards/PlaceCard.tsx | 2 +- www/js/diary/cards/TimestampBadge.tsx | 16 +- www/js/diary/cards/TripCard.tsx | 2 +- www/js/diary/diaryHelper.ts | 29 +- www/js/diary/list/LabelListScreen.tsx | 2 +- www/js/diary/services.js | 5 +- www/js/intro.js | 243 - www/js/join/join-ctrl.js | 109 - www/js/main.js | 97 +- www/js/metrics-factory.js | 9 +- www/js/metrics-mappings.js | 10 +- www/js/metrics.js | 1449 ----- www/js/metrics/ActiveMinutesTableCard.tsx | 99 + www/js/metrics/CarbonFootprintCard.tsx | 168 + www/js/metrics/CarbonTextCard.tsx | 151 + www/js/metrics/ChangeIndicator.tsx | 79 + www/js/metrics/DailyActiveMinutesCard.tsx | 64 + www/js/metrics/MetricsCard.tsx | 133 + www/js/metrics/MetricsDateSelect.tsx | 71 + www/js/metrics/MetricsTab.tsx | 152 + www/js/metrics/WeeklyActiveMinutesCard.tsx | 78 + www/js/metrics/metricsHelper.ts | 212 + www/js/metrics/metricsTypes.ts | 14 + www/js/ngApp.js | 82 + www/js/onboarding/ConsentPage.tsx | 43 + www/js/onboarding/OnboardingStack.tsx | 53 + www/js/onboarding/PrivacyPolicy.tsx | 177 + www/js/onboarding/SaveQrPage.tsx | 92 + www/js/onboarding/StudySummary.tsx | 47 + www/js/onboarding/SummaryPage.tsx | 34 + www/js/onboarding/SurveyPage.tsx | 95 + www/js/onboarding/WelcomePage.tsx | 202 + www/js/onboarding/onboardingHelper.ts | 61 + www/js/recent.js | 157 - www/js/splash/notifScheduler.js | 8 +- www/js/splash/startprefs.js | 92 +- www/js/survey/enketo/EnketoModal.tsx | 20 +- www/js/survey/enketo/answer.js | 10 +- .../survey/enketo/enketo-add-note-button.js | 2 - www/js/survey/enketo/enketo-demographics.js | 150 - www/js/survey/enketo/enketo-preview.js | 67 - www/js/survey/enketo/enketo-trip-button.js | 1 - www/js/survey/enketo/enketoHelper.ts | 23 + www/js/survey/enketo/launch.js | 147 - www/js/survey/enketo/service.js | 243 - www/js/survey/external/launch.js | 250 - www/js/survey/external/time_insert.js | 22 - www/js/survey/external/uuid_insert_id.js | 22 - www/js/survey/external/uuid_insert_xpath.js | 27 - .../multilabel/MultiLabelButtonGroup.tsx | 2 +- www/js/survey/multilabel/confirmHelper.ts | 10 +- www/js/survey/multilabel/multi-label-ui.js | 5 +- www/js/useAppConfig.ts | 28 +- www/js/usePermissionStatus.ts | 357 ++ www/manual_lib/fontawesome/css/all.min.css | 5 - .../fontawesome/webfonts/fa-brands-400.eot | Bin 129352 -> 0 bytes .../fontawesome/webfonts/fa-brands-400.svg | 3442 ------------ .../fontawesome/webfonts/fa-brands-400.ttf | Bin 129048 -> 0 bytes .../fontawesome/webfonts/fa-brands-400.woff | Bin 87352 -> 0 bytes .../fontawesome/webfonts/fa-brands-400.woff2 | Bin 74508 -> 0 bytes .../fontawesome/webfonts/fa-regular-400.eot | Bin 34388 -> 0 bytes .../fontawesome/webfonts/fa-regular-400.svg | 803 --- .../fontawesome/webfonts/fa-regular-400.ttf | Bin 34092 -> 0 bytes .../fontawesome/webfonts/fa-regular-400.woff | Bin 16804 -> 0 bytes .../fontawesome/webfonts/fa-regular-400.woff2 | Bin 13580 -> 0 bytes .../fontawesome/webfonts/fa-solid-900.eot | Bin 192116 -> 0 bytes .../fontawesome/webfonts/fa-solid-900.svg | 4649 ----------------- .../fontawesome/webfonts/fa-solid-900.ttf | Bin 191832 -> 0 bytes .../fontawesome/webfonts/fa-solid-900.woff | Bin 98020 -> 0 bytes .../fontawesome/webfonts/fa-solid-900.woff2 | Bin 75440 -> 0 bytes www/templates/appstatus/permissioncheck.html | 80 - www/templates/caloriePopup.html | 24 - www/templates/control/app-status-modal.html | 15 - www/templates/control/main-consent.html | 5 - www/templates/control/qrc.html | 28 - www/templates/intro/changes.html | 21 - www/templates/intro/consent-text.html | 136 - www/templates/intro/consent.html | 21 - www/templates/intro/intro.html | 19 - www/templates/intro/reconsent.html | 10 - www/templates/intro/saveTokenFile.html | 31 - www/templates/intro/sensor_explanation.html | 21 - www/templates/intro/summary.html | 24 - www/templates/intro/survey.html | 1 - www/templates/join/about-app.html | 42 - www/templates/join/request_join.html | 39 - www/templates/main-metrics.html | 225 - www/templates/main.html | 26 - .../metrics/arrow-greater-lesser.html | 38 - www/templates/metrics/metrics-control.html | 80 - www/templates/metrics/range-display.html | 2 - www/templates/recent/log.html | 21 - www/templates/recent/sensedData.html | 21 - www/templates/splash/splash.html | 7 - .../survey/enketo/demographics-button.html | 6 - www/templates/survey/enketo/form-base.html | 43 - www/templates/survey/enketo/inline.html | 40 - www/templates/survey/enketo/modal.html | 13 - www/templates/survey/enketo/preview.html | 6 - 176 files changed, 4564 insertions(+), 15558 deletions(-) delete mode 100644 .bowerrc delete mode 100755 bin/download_settings_controls.js delete mode 100644 resources/android/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-hdpi-v11/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-hdpi-v9/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-hdpi/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-mdpi-v11/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-mdpi-v9/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-mdpi/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-xhdpi-v11/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-xhdpi-v9/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-xhdpi/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-xxhdpi-v11/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-xxhdpi-v9/ic_mood_question.png delete mode 100644 resources/android/ic_mood_question/drawable-xxhdpi/ic_mood_question.png delete mode 100644 resources/android/ic_question_answer/drawable-hdpi-v11/ic_question_answer.png delete mode 100644 resources/android/ic_question_answer/drawable-hdpi-v9/ic_question_answer.png delete mode 100644 resources/android/ic_question_answer/drawable-hdpi/ic_question_answer.png delete mode 100644 resources/android/ic_question_answer/drawable-mdpi-v11/ic_question_answer.png delete mode 100644 resources/android/ic_question_answer/drawable-mdpi-v9/ic_question_answer.png delete mode 100644 resources/android/ic_question_answer/drawable-mdpi/ic_question_answer.png delete mode 100644 resources/android/ic_question_answer/drawable-xhdpi-v11/ic_question_answer.png delete mode 100644 resources/android/ic_question_answer/drawable-xhdpi-v9/ic_question_answer.png delete mode 100644 resources/android/ic_question_answer/drawable-xhdpi/ic_question_answer.png delete mode 100644 resources/android/ic_question_answer/drawable-xxhdpi-v11/ic_question_answer.png delete mode 100644 resources/android/ic_question_answer/drawable-xxhdpi-v9/ic_question_answer.png delete mode 100644 resources/android/ic_question_answer/drawable-xxhdpi/ic_question_answer.png delete mode 100644 resources/minus.gif delete mode 100644 resources/plus.gif delete mode 100644 scss/ionic.app.scss delete mode 100644 www/css/appstatus.css delete mode 100644 www/css/intro.css delete mode 100644 www/css/main.recent.css rename www/{templates/survey/enketo => img}/enketo_bare_150x56.png (100%) create mode 100644 www/js/App.tsx delete mode 100644 www/js/app.js create mode 100644 www/js/appstatus/PermissionsControls.tsx delete mode 100644 www/js/appstatus/permissioncheck.js create mode 100644 www/js/components/ActionMenu.tsx create mode 100644 www/js/components/Carousel.tsx create mode 100644 www/js/components/Chart.tsx rename www/js/components/{LeafletView.jsx => LeafletView.tsx} (92%) create mode 100644 www/js/components/LineChart.tsx delete mode 100644 www/js/components/QrCode.jsx create mode 100644 www/js/components/QrCode.tsx create mode 100644 www/js/components/charting.ts create mode 100644 www/js/config/dynamicConfig.ts delete mode 100644 www/js/config/dynamic_config.js delete mode 100644 www/js/config/imperial.js create mode 100644 www/js/config/serverConn.ts delete mode 100644 www/js/config/server_conn.js create mode 100644 www/js/control/ControlCollectionHelper.tsx create mode 100644 www/js/control/ControlSyncHelper.tsx create mode 100644 www/js/control/LogPage.tsx create mode 100644 www/js/control/SensedPage.tsx delete mode 100644 www/js/control/general-settings.js delete mode 100644 www/js/intro.js delete mode 100644 www/js/join/join-ctrl.js delete mode 100644 www/js/metrics.js create mode 100644 www/js/metrics/ActiveMinutesTableCard.tsx create mode 100644 www/js/metrics/CarbonFootprintCard.tsx create mode 100644 www/js/metrics/CarbonTextCard.tsx create mode 100644 www/js/metrics/ChangeIndicator.tsx create mode 100644 www/js/metrics/DailyActiveMinutesCard.tsx create mode 100644 www/js/metrics/MetricsCard.tsx create mode 100644 www/js/metrics/MetricsDateSelect.tsx create mode 100644 www/js/metrics/MetricsTab.tsx create mode 100644 www/js/metrics/WeeklyActiveMinutesCard.tsx create mode 100644 www/js/metrics/metricsHelper.ts create mode 100644 www/js/metrics/metricsTypes.ts create mode 100644 www/js/ngApp.js create mode 100644 www/js/onboarding/ConsentPage.tsx create mode 100644 www/js/onboarding/OnboardingStack.tsx create mode 100644 www/js/onboarding/PrivacyPolicy.tsx create mode 100644 www/js/onboarding/SaveQrPage.tsx create mode 100644 www/js/onboarding/StudySummary.tsx create mode 100644 www/js/onboarding/SummaryPage.tsx create mode 100644 www/js/onboarding/SurveyPage.tsx create mode 100644 www/js/onboarding/WelcomePage.tsx create mode 100644 www/js/onboarding/onboardingHelper.ts delete mode 100644 www/js/recent.js delete mode 100644 www/js/survey/enketo/enketo-demographics.js delete mode 100644 www/js/survey/enketo/enketo-preview.js delete mode 100644 www/js/survey/enketo/launch.js delete mode 100644 www/js/survey/enketo/service.js delete mode 100644 www/js/survey/external/launch.js delete mode 100644 www/js/survey/external/time_insert.js delete mode 100644 www/js/survey/external/uuid_insert_id.js delete mode 100644 www/js/survey/external/uuid_insert_xpath.js create mode 100644 www/js/usePermissionStatus.ts delete mode 100644 www/manual_lib/fontawesome/css/all.min.css delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-brands-400.eot delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-brands-400.svg delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-brands-400.ttf delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-brands-400.woff delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-brands-400.woff2 delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-regular-400.eot delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-regular-400.svg delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-regular-400.ttf delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-regular-400.woff delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-regular-400.woff2 delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-solid-900.eot delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-solid-900.svg delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-solid-900.ttf delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-solid-900.woff delete mode 100644 www/manual_lib/fontawesome/webfonts/fa-solid-900.woff2 delete mode 100644 www/templates/appstatus/permissioncheck.html delete mode 100644 www/templates/caloriePopup.html delete mode 100644 www/templates/control/app-status-modal.html delete mode 100644 www/templates/control/main-consent.html delete mode 100644 www/templates/control/qrc.html delete mode 100644 www/templates/intro/changes.html delete mode 100644 www/templates/intro/consent-text.html delete mode 100644 www/templates/intro/consent.html delete mode 100644 www/templates/intro/intro.html delete mode 100644 www/templates/intro/reconsent.html delete mode 100644 www/templates/intro/saveTokenFile.html delete mode 100644 www/templates/intro/sensor_explanation.html delete mode 100644 www/templates/intro/summary.html delete mode 100644 www/templates/intro/survey.html delete mode 100644 www/templates/join/about-app.html delete mode 100644 www/templates/join/request_join.html delete mode 100644 www/templates/main-metrics.html delete mode 100644 www/templates/main.html delete mode 100644 www/templates/metrics/arrow-greater-lesser.html delete mode 100644 www/templates/metrics/metrics-control.html delete mode 100644 www/templates/metrics/range-display.html delete mode 100644 www/templates/recent/log.html delete mode 100644 www/templates/recent/sensedData.html delete mode 100644 www/templates/splash/splash.html delete mode 100644 www/templates/survey/enketo/demographics-button.html delete mode 100644 www/templates/survey/enketo/form-base.html delete mode 100644 www/templates/survey/enketo/inline.html delete mode 100644 www/templates/survey/enketo/modal.html delete mode 100644 www/templates/survey/enketo/preview.html diff --git a/.bowerrc b/.bowerrc deleted file mode 100644 index e28246d45..000000000 --- a/.bowerrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "directory": "www/lib" -} diff --git a/.gitignore b/.gitignore index e626e9a48..6801f890d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,9 +14,5 @@ app-settings.json *.app.zip *.ipa www/dist/ -www/js/control/collect-settings.js -www/templates/control/main-collect-settings.html -www/js/control/sync-settings.js -www/templates/control/main-sync-settings.html config.xml package.json diff --git a/bin/download_settings_controls.js b/bin/download_settings_controls.js deleted file mode 100755 index fce3fb675..000000000 --- a/bin/download_settings_controls.js +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env node - -var https = require('https'); -var fs = require('fs'); - -var download = function(url, dest, cb) { - var file = fs.createWriteStream(dest); - var request = https.get(url, function(response) { - response.pipe(file); - file.on('finish', function() { - file.close(cb); // close() is async, call cb after close completes. - }); - }).on('error', function(err) { // Handle errors - fs.unlink(dest); // Delete the file async. (But we don't check the result) - if (cb) cb(err.message); - }); -}; - -download("https://raw.githubusercontent.com/e-mission/e-mission-data-collection/master/www/ui/ionic/js/collect-settings.js", "www/js/control/collect-settings.js", function(message) { - console.log("Data collection settings javascript updated"); -}); - -download("https://raw.githubusercontent.com/e-mission/e-mission-data-collection/master/www/ui/ionic/templates/main-collect-settings.html", "www/templates/control/main-collect-settings.html", function(message) { - console.log("Data collection settings template updated"); -}); - -download("https://raw.githubusercontent.com/e-mission/cordova-server-sync/master/www/ui/ionic/js/sync-settings.js", "www/js/control/sync-settings.js", function(message) { - console.log("Sync collection settings javascript updated"); -}); - -download("https://raw.githubusercontent.com/e-mission/cordova-server-sync/master/www/ui/ionic/templates/main-sync-settings.html", "www/templates/control/main-sync-settings.html", function(message) { - console.log("Sync collection settings template updated"); -}); diff --git a/package.cordovabuild.json b/package.cordovabuild.json index 20672bad0..b5d69872f 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -8,7 +8,6 @@ "url": "git+https://github.com/e-mission/e-mission-phone.git" }, "scripts": { - "setup-native": "./bin/download_settings_controls.js", "build": "npx webpack --config webpack.prod.js && npx cordova build", "build-dev": "npx webpack --config webpack.dev.js && npx cordova build", "build-dev-android": "npx webpack --config webpack.dev.js && npx cordova build android", @@ -109,7 +108,6 @@ "angular": "1.6.7", "angular-animate": "1.6.7", "angular-local-storage": "^0.7.1", - "angular-nvd3": "^1.0.7", "angular-sanitize": "1.6.7", "angular-simple-logger": "^0.1.7", "angular-translate": "^2.18.1", @@ -127,7 +125,7 @@ "cordova-plugin-app-version": "0.1.14", "cordova-plugin-customurlscheme": "5.0.2", "cordova-plugin-device": "2.1.0", - "cordova-plugin-em-datacollection": "git+https://github.com/e-mission/e-mission-data-collection.git#v1.7.9", + "cordova-plugin-em-datacollection": "git+https://github.com/e-mission/e-mission-data-collection.git#v1.8.0", "cordova-plugin-em-opcodeauth": "git+https://github.com/e-mission/cordova-jwt-auth.git#v1.7.2", "cordova-plugin-em-server-communication": "git+https://github.com/e-mission/cordova-server-communication.git#v1.2.6", "cordova-plugin-em-serversync": "git+https://github.com/e-mission/cordova-server-sync.git#v1.3.2", @@ -158,7 +156,6 @@ "moment-timezone": "^0.5.43", "ng-i18next": "^1.0.7", "npm": "^9.6.3", - "nvd3": "^1.8.6", "phonegap-plugin-barcodescanner": "git+https://github.com/phonegap/phonegap-plugin-barcodescanner#v8.1.0", "prop-types": "^15.8.1", "react": "^18.2.*", diff --git a/package.serve.json b/package.serve.json index f16c8bd66..1a2ef6cb0 100644 --- a/package.serve.json +++ b/package.serve.json @@ -8,7 +8,7 @@ "url": "git+https://github.com/e-mission/e-mission-phone.git" }, "scripts": { - "setup-serve": "./bin/download_settings_controls.js && ./bin/setup_autodeploy.js", + "setup-serve": "./bin/setup_autodeploy.js", "serve": "webpack --config webpack.dev.js && concurrently -k \"phonegap --verbose serve\" \"webpack --config webpack.dev.js --watch\"", "serve-prod": "webpack --config webpack.prod.js && concurrently -k \"phonegap --verbose serve\" \"webpack --config webpack.prod.js --watch\"", "serve-only": "phonegap --verbose serve", @@ -55,7 +55,6 @@ "angular": "1.6.7", "angular-animate": "1.6.7", "angular-local-storage": "^0.7.1", - "angular-nvd3": "^1.0.7", "angular-sanitize": "1.6.7", "angular-simple-logger": "^0.1.7", "angular-translate": "^2.18.1", @@ -83,7 +82,6 @@ "moment-timezone": "^0.5.43", "ng-i18next": "^1.0.7", "npm": "^9.6.3", - "nvd3": "^1.8.6", "prop-types": "^15.8.1", "react": "^18.2.*", "react-chartjs-2": "^5.2.0", diff --git a/resources/android/ic_mood_question.png b/resources/android/ic_mood_question.png deleted file mode 100644 index 8c7790f2e60045b85171650b80d7d8345213b95e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 516 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 JPDHLkV1kv7`E&pP diff --git a/resources/android/ic_mood_question/drawable-mdpi-v9/ic_mood_question.png b/resources/android/ic_mood_question/drawable-mdpi-v9/ic_mood_question.png deleted file mode 100644 index ecbbe1b3a0502af4ba0faf0efbcab8e10567620c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 469 zcmV;`0V@89P)X1^@s6IQ*`u0004;NklsQ_IkZu)HKa(x7)Rx$Vg6VTg{zL=a_iJJa04_ zlUl9zA|YpTHlSgv)e3JjKO2TIE0s!#N~IFS=$DXlxm->Hn*kcFL1eUcyZwOqUBsIv zl$ta^d%v=d>_@?_R;!76y&hszooOzW`jzCQ1|%g~COPo{jFe@>3FIK>IHP_xn@vrr z>zLjE#00{MQ*s#(fWZPFm*p=OiviAYbro_U0Oz?}Zol8}A5b5Q#{)3Bg<%O$#y(`C z7@cm2GdC{)^kejQ9suHU1+^=%Q^KT^$?vP1cUM54Rsm6xHpQfR>#`0|gZe-Lr$kFW zvho@Wg~Cu?(YM_Lx~^YiI&!;H|3Euf`zq-Y#xO!WlP)4AZh;!?eBtE8{3Mt9<_Jle=#t^Xy00000 LNkvXXu0mjfROZi7 diff --git a/resources/android/ic_mood_question/drawable-mdpi/ic_mood_question.png b/resources/android/ic_mood_question/drawable-mdpi/ic_mood_question.png deleted file mode 100644 index 5030bedfcd9a6b672dc338c84cdad464ebe43a1a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 834 zcmV-I1HJr-P)P000>X1^@s6#OZ}&0009CNklI?F|%@+k`j_AER-m_cTYsQ z4pVBT`~}bJ`PQ6_Gv0;SnR+_T_wsqp?|0@L6BYIAJ|>gNoSvTkH!Upt&CN}z|J+YrxQ$1BeZ3MP1nlqc3*d7u zD8`v*ozv+I$&sg-R99D{uC7kh+S-cy`+J<8p32{2V`Cz&j(Lv37@zFy>hlAoTQmio!bNyQ*fGbt@CMR|F- zXl-pxHkFTERaJ$ZogM7%?xLoqM)DjtHa0LbGlPnX3Nd9p`+^MeG?U`uVtkxZ5jUK# zi>nF7{wLP4ersz>#mLi43JVJ*L9MK;;Njr`X0utvsPXY}*{73}6STLttGLBtk^1H3 zWfdb&Gs(-#LqS1-XlQ6i_F;HN1G4eE%tgI~L z=H`m{xYyU$I5;>!NlA%{b9Hlbb2vIWl52x8<_`}K#rShAd74RTYASC}rGbF~`2BvY zuCAh}s7PY@`T6oSt*op>Mn;CjJsywL_4oIO^pmHV7>!0GCnqcAwpCE52Xeth_pwmX>7Yot>S?%*_0@hdj+BIyxE&2?>aaiHTU` zkf)iXq@;Mck+HF{Fc=I!7W>H|FWf(^KOc9HZ+3PTk%}Dhe%~*le}oZ{T6Y2~j{pDw M07*qoM6N<$g5>L*Q~&?~ diff --git a/resources/android/ic_mood_question/drawable-xhdpi-v11/ic_mood_question.png b/resources/android/ic_mood_question/drawable-xhdpi-v11/ic_mood_question.png deleted file mode 100644 index 78f2f253891c9254efe40bbec325da0ef7712e03..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1540 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 MvA#Kyp!)##Urr=Em;e9( diff --git a/resources/android/ic_mood_question/drawable-xxhdpi-v11/ic_mood_question.png b/resources/android/ic_mood_question/drawable-xxhdpi-v11/ic_mood_question.png deleted file mode 100644 index 913025e64ba5fe8dc71d5502ac69a4295387e688..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1632 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 diff --git a/resources/android/ic_mood_question/drawable-xxhdpi/ic_mood_question.png b/resources/android/ic_mood_question/drawable-xxhdpi/ic_mood_question.png deleted file mode 100644 index cd2b1614093dd2ee6e3826a61ea7c73ba85a6a45..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1706 zcmV;b237fqP)002t}1^@s6I8J)%000JYNkl3CH#EBCpPMkP#;>3v)Cr+F=apJ_u-y{?Y#R`LA20Sh-6IKiBgesxNLO-t+%7qf4 zShyCnuK?SHNreF_lg}zN;+SdC`=b#giqms z@V(G3922^PZi_Ci9}$`vZ|1IqsX~T6XCNpmMYvUX0L3W%AqU}J;bviYP+zw=VU#dS zxLepLd?fq~ZLkgBR3W@3lnKvSlySX+aoeo9Kfz|9QYaL1cy4SUUQZMiFHGW<+Ze_w z_P~?EB4LVfr7%J`&te4Er!Y>P{9f>i`o0ys@^L{?)+iM9pinF96ix}9LIXStxmZIw z^OA*gERwmN&bVC5>dd|LzCA*tP%Ye#!bS!`S+lUHT7Gf;A}53&g@-|Z`M7^!xymEZ zBAgU@dEQpx1y!!!cfAzLN9{yW9l|c*6Cn@!Ls@g6j?kh5g*EU>-lb08m%AjUKiBe6 zr?9A91d*9%>A6`1xJ_7C56`benG^iB`zh1#7ay|NPF_8=sLZpqXkx4^EUbm+s(c{a zBV_w#cO>OVAV6$pJ6G8uJS5EikJh19v;ar1a#Z+^@I%)(*eNSu%2I!H8!1SLfN>9@ z!C#JC{v9Tig30|}Aq*H3;TdC@_o_A5gb>3#@tuy_N1mxtPX;mFDJx*gS|Yqjk{co= z(a1d4Gj6nS2zsGa_#x6x$p&$V@n){cGt)d{^C}_FOglCLn-UjMhN)aS^#-^j(3f9w33|8(3Wv$25J6N1n39j(wX80`Q zXN-ltjBktNv&Jy*B{BnL0wj3GrU_RGt60x53XALfpsXsKdKZf;qgpV;GcLjUf_K0q zJ|{(ZMiS#ROSlm(fH=?CA;N`(G9lKb>#OpEvZ}F?ZWdR{mrMMg){{t{TFQF5SzonN zR(NG4Tgv*KO~ffH%zDm6S<5YDsXyHs7@rm49;w15B;)x6xmsc=;ByZDpBBZvhI)Kf zC-K>)!0d)mWC7aVdY|C7jq;Ak?>?sdb3+vO^7U>QRh$*!9yihb(OBvW;d9|LVK))J zh6+nHQI6tX>9ofxtl-r*aaIWp(B{)c@Dc8@h`g^1pEsA-s+eNlNq&soQI@qAV~wU| zrrqwPeo$61onkvTEb-=s5$>Tyx@WMSC0N>6OIauIJ55pCOLN1gNT5T!g4+IH?3b#; zJWthOFA;bAnbg`zjV=?OAZgaE|10f1MySpARdrBHo#vDkFlE`5(t}o|w1HODoPThy z%V|DT;HPxsc$yyMNDWC&Ggm{5g`P_30UEEm#z9V50aKRg?&x+spR)%h7;K^IqTQE_ zC>5W`Yj`mV8cMD=DBKsOyEB>RwDR0ug5@Gw|Nd&_v*>&R%aFC@H(@PV{qduOxvq-3 z4l6uKzp|Ht*D~JBJqlIUshOGVpusr#7e;w0IO6%%Ine%) z15m{48N4&4ee9AM#9`)yv3**(nKDYa)5@Km)5`nVCF=<`R}e>y_w}xKhOehNyKc_1 zuVI^Sq8Hd^wdaqs(|t`nYdy8ITVTNd(SZ4a=C34*Dd&_^__P1QLTme*xc)ljo5!%w zseMKCPMkP#;>3v)Cr+F=apJ^@6DLlbIQdil0MMkZ?jm*9iU0rr07*qoM6N<$f@%^+ AdH?_b diff --git a/resources/android/ic_question_answer/drawable-hdpi-v11/ic_question_answer.png b/resources/android/ic_question_answer/drawable-hdpi-v11/ic_question_answer.png deleted file mode 100644 index 3ae9173bdd553eedb13988839bfa12a6f82283b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 318 zcmV-E0m1%>P)l?jFF&)lE8j0B3PWXGYw@u7XO*ApdRf^Nn5uDDx>C`^^FzK2WX#Aamse+ z!Uke^yDDf#FN=K;p^AjMIssD7sEV=+(ol&LAnoyWUHXq84|>7>QOWeY0G=nP)bo?- Q;Q#;t07*qoM6N<$f*F^CHUIzs diff --git a/resources/android/ic_question_answer/drawable-hdpi-v9/ic_question_answer.png b/resources/android/ic_question_answer/drawable-hdpi-v9/ic_question_answer.png deleted file mode 100644 index 3d580d05ff1bf03248c59ad96cba3e6bd9ebff6e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 236 zcmVCF5JmIp*=T&B3qj)-HT|lomP%7v|6%0D`Sb zV2lB!R5R&bl>k$osS-#j0n23q?>!`(b6XcdK5MPNrxHQ{mQu&{0?s*LDRumm;8rDI zj6r5umKpQ8*>@a1#Fn25V(~}0wH8`=bv!8v=7JE->A3Nwt*f9lgpiq13YjiTYcJ`x m8IvReUDt1tjGvH^k#`=!sa_-i diff --git a/resources/android/ic_question_answer/drawable-hdpi/ic_question_answer.png b/resources/android/ic_question_answer/drawable-hdpi/ic_question_answer.png deleted file mode 100644 index 4ddd1ed8b33fcc3a67b969812ee55c8bce4d1057..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 895 zcmeAS@N?(olHy`uVBq!ia0vp^Y9P$P1|(P5zFfz^!0hJf;uunK>+KA0@2o(HHtD&E zDHFW{FS2$wu{jE6UijG-SfJyYuXt$%Gwzg@Iwx2w2$QfN8{Ag)iV4Et?r@OlMDJ5)9N={CGQnq{PoO$yuUA`>ry7;31^H;CBu3fuUGWR7@*|O@M zWy_W=v9YteH2>W1-`2A7@~^EfvjWX4DGAY8X;$&vjV&oFeOJTTuHK%W38$Yl zA#?Gf`Q0~{Ss=`Jyjf2)LB5~Dw<96-X@uOtRZ9c0|kPWf1vZf{`9+vj@%U`~HdFA!q zy|zYH+qZAG0!m8Q$y~X1&FkR5MYVf(?$ne^?>Te!Y;PYhSi&MBf8O>t{qg$djT-{e z&)kd4%G^Fy#0UjfA51%X>h$TOto7Cm$<4h-lUCM0dSwcX@TE(ahJ=Mpn;H@x-tGJC z?OWfDmCu1{*Mwv+G~{{QrcDLOoUPxRXlalnkl;OXk; Jvd$@?2>`KctTq4u diff --git a/resources/android/ic_question_answer/drawable-mdpi-v11/ic_question_answer.png b/resources/android/ic_question_answer/drawable-mdpi-v11/ic_question_answer.png deleted file mode 100644 index f21a94577c984e9cc5fe22ec3b5b5a5147570413..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 238 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjTRmMILn>}1CrFq*`2YXEkHZ>< zBezr%d=;LlU1OT?aQ(l@Z2y=FSvQJh{8#7qk(Co;$kbQJV|bLJ@N7XVYw*H9_6trd zU&^Ymz&499RObi7*@_<@j1~G+k2hU*&~Z)*bl$?M-VnhVRbnxr;ks}#>c(#MY=!&0t1*)FP+T itETgIM7H@SGchnqnnrwCcYF@e0}P(7elF{r5}E)bcUS!Y diff --git a/resources/android/ic_question_answer/drawable-mdpi-v9/ic_question_answer.png b/resources/android/ic_question_answer/drawable-mdpi-v9/ic_question_answer.png deleted file mode 100644 index ccc3c7f0a94480b006503f94628145245f0090de..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 265 zcmV+k0rvihP)X1^@s6IQ*`u0002cNkl|42C&D57C~2gD~_qUBVlI0^%WcZ{>@1?g`xALs$Y>q8&P5=~FBv{*ow$AUNVr zVjRcoG)=Sx);uv@76onFQqwf7(f56{D8M9@Wyu<2ffUDa9BMs-yGK~-3GS}A z>pI>-`oR-eYfmmulH>(g1ARfe+TIv*!)fJJX!o}xlCK03Xr}P^4gDxSEZbQH<-g$d P00000NkvXXu0mjfGrerw diff --git a/resources/android/ic_question_answer/drawable-mdpi/ic_question_answer.png b/resources/android/ic_question_answer/drawable-mdpi/ic_question_answer.png deleted file mode 100644 index a5943266e768a7cdcb5ae8651b90607587b68953..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 631 zcmV--0*L*IP)P000>X1^@s6#OZ}&0006$Nkl(2-83`P?b!JGbx<4u^^9 zbSk(6uYI%Ggw19HjYflappPl>`Fs!v1c=FGBDf?Pjlys^q&em|2l|*2m&-*5koWg@ z!6m_95c>T-%`wM0(8rWG91d{1-Nbl27F^=@`=Q(I(j0S~1AR=1)oO*mPbq=7rjW7U z@4@5oz}wp!ZDWpeppPkeczB=#$Y3y_(003pVzEdM*EwRb7}#t!Xt&#RKIS+F`j`@v z$pmJzndtR;u-onE4k?vNP%f9h<@58CN-)Pc(8rV*3g{wdJTuefl6LqU-=yRm=dK@32L?azrk1g%gYND z;o5Nf(Z`g?<#JFc6c?dVso?qf8CtCtzYl#(iBu{DnM`&uD0VuX{5JZS5|Ky*cXxLn z7K^V2J?LXfG#brsi^T#Gi3DzMZ!ZS(=t1APkJhhBrSgRztZP9J`hMP@&>z?&44JB~ Ra|Zwb002ovPDHLkV1nebD@yFVk1214BWrKyp#-D6lzgtI5lSJKn z&6&=rZ>hUEpSor-6N|~n$V6|HQc2W6UF2D;gQ_TQ8lab-p}Vp>H#9{B(}1pn<^717 zX@IBPPF(;35I{r#`gh>$f+ncU6`<#g+MEG;JOKzm00Izz0Kx`%pbe_H1RT)9P;=Bn zDOZ3k8d`p#U=6U=%cBHGz>1bnJ9wpI9I&M2v%d3@#l|K9diep$1fq4oL2zJ2!bh$vY%_As3mAr|<@D15#E&GBjfbZKp^^tRH^T zB&7eZE1m9Svk{3zB9UlR6h#+p+uk)zV=#Ezb=|cV0EL%j`BoGKFnFlz`bi4_!;LTu zAqWB%r)k=218{|tlO*|L09lq9zV8E<&k6u1WM5SkjDF4Y{B}_QYES2JqJLo4!)gFz z%9;i6JP)`W$MGJ3+Uhj{I11ppE^rwq+a7@0>a~9XFyLzVs0Dzpis3j89C&BjwxMeb z+2Zj^1yK8?tZxCZlOA`p8;);{-vI2m!nFYoM~>?@oNC$~e*}Qf?aRpV9B_LUfP&}B zjyDX8034HLSuflWS^%fYG|gum#5)=$FA9K<^>Tz*2Vkxes{l-uK+`_4Jb+pWTo>+F k1E5Nf{&-&ssI2007*qoM6N<$f{j0(;{X5v diff --git a/resources/android/ic_question_answer/drawable-xhdpi/ic_question_answer.png b/resources/android/ic_question_answer/drawable-xhdpi/ic_question_answer.png deleted file mode 100644 index c2b8a6368ba8ed857670b6abc30377b29b20e3ac..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1218 zcmeAS@N?(olHy`uVBq!ia0vp^Mj*_=1|;R|J2o;fuylI5IEGZ*dOPc+mx{MY+i%7r zkuF??VRHfs7r6*N7ks{O&Z$a=5El`V!lg|eU0lC7lsfz$x!u`Ocyo2ru_C6U5AXcq zzkWd-v}3>%|i%3Z7=N?|(km$Rj*loR^R9(uE5XQXAvT%gr^LEoAtlBqTQ2 z0L^&$=FO9CuO<8U&reHB%gD@p=^YUhv*hK=%z5G4<_Uf<+2yuk`}Xj|SFVV}#>HKF z{aU-$b?=@%7mglfo&R~$*Pe#uMjqMO+Vks`xVgFG#UAZ7xPRnGONp&A$SHU3oOwR~ z#GgMlGXs;8lP^J3CnqbHmXu`7m_6HAmBUZ>W|iB4%P-na+*UKPw$Anv*b)R136@;4 zZe8DYUl!TZJxJS6;1*9~E?JJxk2SK-8i_Obr%w3ur$$Rh=gNnT zwsolsnG@?|QrAX4V*&=$q}j7iXS$}m{g}VsATBnxQ2OYfty{NVx~2nm?#XlKo^4Uu z@wuzCy!?3iqqAqv`fuL2@!~4G6 zn?KG61qLeSo~?TP5NOT3d47?Rky5vA-Kx`nP_{d>ynOnnPoI_~|7R&t+PGoay$WEk z0Yf#&&8@BN`?nuIew?sw3}#xte$AQ-H*emIFRrW8%Z2*o$&(Z>Z|}C?%pn7%yo8k zExK{zMn0=-Mt*+(ym|Bf3A20`+xs1u8dQvoCM{aLc>mlZM_lq=nwy*NZ+@_Dy}2CF zShLx^?3K)0uFRb)TUA&0OrfIUk~B9kJ~}!$>Sfe?svL`RBRqH|j04klFu% zr`P_{Z4~@VaocY6_*>CyfZM$|=?b)~Q-(}{=j`<&&=D+_RT=UiUD}T(Z WwnvY-U)%teO$?r{elF{r5}E)K;zwKn diff --git a/resources/android/ic_question_answer/drawable-xxhdpi-v11/ic_question_answer.png b/resources/android/ic_question_answer/drawable-xxhdpi-v11/ic_question_answer.png deleted file mode 100644 index 2586cd25db2bb2d923f489356fa5c56d56f5957b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 509 zcmV;3a?)f|vHdccooT0y1|ftHLI?>a&-14ASlX7R(#%oyp3bEQF@Xx4 zv2>RE%0&7oy^IM|;CRiuv>OvBbvSb7^C^9f36$mj(*p<~fB*srAb@HGB-d{P+mqB5 zzel=_syk3G@ZOniQSKE9pz`SUsSQwhbR?w!ElvgyKmY**5I_I{1Q0*~0R#|0n~46h z+5xB>rz^dy0?FkX~;&KRy@*=*>os&N+L5AL@FJI^8xH9 zr}cZz0qFZfw^n_W=l)N`t{>=+n0l;a0#G@Sp1ILuCF6>Wq^$!r4JqdTI&{8#aS7yP z^?Wk-4q&`69P^X480BG?#E(2(_Ezn0(EA9jdLI@#*kZ`^Lp)u;_`+pyu00000NkvXXu0mjfg^SpE diff --git a/resources/android/ic_question_answer/drawable-xxhdpi-v9/ic_question_answer.png b/resources/android/ic_question_answer/drawable-xxhdpi-v9/ic_question_answer.png deleted file mode 100644 index e80a4e042cf3c8247bb276ced0f6638a2759f006..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 357 zcmeAS@N?(olHy`uVBq!ia0vp^20-l1!3HGFp0l(vFfi(Px;TbZ+F=$=RT3s-mums#*G5mm4x}&6xK*Z1Fb}wS?Vf!tXfZ`jo7nwluEX`xFSEAg zUf=h=){N=*>sO|$S7jw?Z}8k8cHvjpB@mYZ$G)Q#!N9+D+t-QE_>6&^{gqGRcLP-j)rbKTI73It+MT>gcrTyjQv5({)ZIqIY+n%8j#c xz6t5D_j@_Dh1~G-H|M;joS@q1XaVtW!ed7MHBGL*F_Tn50-mmZF6*2UngC|cjHLho diff --git a/resources/android/ic_question_answer/drawable-xxhdpi/ic_question_answer.png b/resources/android/ic_question_answer/drawable-xxhdpi/ic_question_answer.png deleted file mode 100644 index 799f0e8bad39bb70ca1dfcac4a7068d8796bd3c4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 439 zcmeAS@N?(olHy`uVBq!ia0vp^-XP4u1|%)~s$MZLFxGjxIEGZ*dV6!P7qcUS>%#^Y zT^8+)0*e;4PL1dkxVWM>H}25$4I2d3)G%!f&e`Il7~SEY$Pp1C{V~4fBcD$~zYSa6JhksC<8G$Y z{HMjh%HV`$^4<*54VS8~p4+-I@nBZA`8(b()yHqGGPB}|$o-y^Z~gc+C*NyL{uKfn z=gD3P^Sdt0b8U9>t)994xA%YNDHmkAHoZB@f6nzY2_^v%Vp(fXTSjoMG0uJ3U%Vkn zqFT&Lno(Oe+e!O>aa`&5n1srNa~bTnlp|h7q_T(k+GxJ}&bRgVf{jLh0<>=Jy9RV# z&b>y{gP)kA3lho`em3fsF*2WKUc(udAAeFTV(;G<^A|m=kKWkLA_xVdE9JfB%xRHu SvNQok2!p4qpUXO@geCxd=CM)$ diff --git a/resources/minus.gif b/resources/minus.gif deleted file mode 100644 index 0115810b967dea899c60d902875799227699703e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4635 zcma);=U>u`n29p7n}hw03JjE`uh5to0|jzAv`?1 zu&@w?LgDfF-rnBD#l?w<2`m|KtDvWcT<$0DuBc{y*h^ zO@N<=RY>{Ey}+%e;mJmi*U1S#&&8mI&9F@#v&hT%q+}|q%xC0@P9e9-&$Rv?ZMrUC z^h-$Z5;`RabGQ4&#O48yNQ#JzijIkmi%&>QN=`{lBfm_~ z$jqWpvvYFu@(T)!ic3n%$}1|Xs%vWN>KkbE#-`?$*0y#=M`u^}tDatFU;n`1>!D%R z2zzvlGd{t6Gx>Jv?|1K~XJ+T-KP)UREw8Np^YPQaYyYipeE#xvb8GwC_njXw= z9~}Pv!vi1)X=8d(UnKmfssoE&JP?bLH_bC{EP0)X(e#*SHI@#iVy`Ahn>3Y;q$}Uz z)i{ham5);JF6=y$<_b=399VjKL=(6X1P>6#Ac~ZUFynO9`<+wD#9~N*+-|T1jsyY& z$^>?6?R%QB7=XePnV>=&vWy!O&nvhif1&gEBW>#|Fc`81w!r22yVT+ ztQ(!!wz=qqyfvPm4!A6_(gV*Cb9b@eSD9voJv(i_mj(C+E5`FfK_G#KMFFn9@2~G4 z#Ll8lEm#EXY^`1f77K((rQg1n!t{%JvH=LSa*;T%f(JtXc#{AgWDdPX2;|4(9U|W4WUshq#qQ6*)!;3OEm6O{*)ys2t3Nv;yd@q{yMlrA@>v?@G%vd=C2=Qq2LAFar?s{CWWPNY~B`k3i^lvpLF@ApJvq3cE|Z*rkBLYaG76*zL58AQU>84ze_z+qqJQo@$R%q z=?z@lkrFa)XG}{@4AG>LAMa^lekuiC;2GslJZhD49N-6}Eg@PV*UBsJv`ayepYYPp z4}R_&8u8jLtfYRqZ?wj|;Ayql^F%@A;>!0)8ovwoQn>fle;lqPO z-XGY-8BzW4Jpk*P=Nij5{*V+vaP$bcjuI*{dLj0YAoL4xdB&2CohwcL{fS`Ek;E0= z$Q6R~uK+vS#nIluvVx?mbkfqz=;zGh^?lUUnT|6N*&AaBwDOh9qUdwd^RckZU6^dd zwb-Db755vgBd4qz6D$rR#V<4u@!;JQRiFS4xgsp&8<0z-uze~{_mUasaYKn24$r5SY`bR@>;YTowQ z&XciHQr}ru_?lJX@d}cwXp6WP}? zj;j3Y_fSOUyQUu>(LI7xDz~sl3|e2N=2epPA49xgri zggOF@in8dn@?W!UpKjegeX(ARAnSaq19e9-CjX>@=)t@3H8F{>GMtk32Ao_FllC`p zolnzXHojyi>piNDaH;D}lcQX7?sl!F-K?!e%R6)vzo13E)y%B)IaIG_l!w;QyDpza zRXRV^JyJwFwB3NvcnkA}KA8u%7~H=xsnKSB<&WpgZ30hIEQWsEfY>kIuoW47KIZN1 z{cb+sK^OBH$1b~Xj?wb|)kltDn3U$cD}kc9D&|4t=-RfH#IzlRZ7O(ePxoEyCPI{ANk7 zm;2Y#pY|wxz0-q5+Rq#W>4FH`v!dr>qS9S!llFC>Wv3kRxI223-@=ijX=2whJOy3; zVg+epk2R;Nkeq2tR56{e2{A{zJ_-LqOAu0!@u`9=tD;>t%T3cZNH9VDI+%6hQjEJ+ zPJZn2azk0RXlhDSQMQJwJ@wvr1^m21$i+2ha|t{rN~2mumc!9! zi2a@Qtf}&%z-?Wn*=}Mh@@Da85!yXWQA#5D`sf!EWr>qj1%^~5p{TdqE@;fUiOGG7 zoVSK68j@-h)Un@~LH_@ zE@`ghf3Yj08hj!PJyx-YJbWIh{n$qM zWH{d&-cf^%y&d9`#HmgfJ5GPWO}P(VeH|yDNsh;D^zc{C7`!F>cxotoXg%(_@8OUA z%7D*UJsuvIky8IzW+{WRgPBcR(Qnn47JW6|`yumb9A0HFjdtYp(J5CTOx)|uCs;js za2$NVb{D#E%)HxqX}2s)TKJ`t;Xm#mOh&U%ui$@yM1_c!ql|H^9p(z#H{Ex7b-y*-;H39oY)5X?Z^N zw*-p<8={1$aY+QwQ!kh_e%SDrIAZ1<21kWWx!zSvi)f;Nu|a;u*CWWx2&Xq8+Hum9 z*O6uhxXV>Oj+VsDh*165D3zipQ}ytM)u@}wQDkiNEjzui_Yyd6l+=6IT7a0G7KsP~ zAGQP^S<@miZ~8Bb`q{+>1jPnXV~u2S{`io(9k>Tv+~Mr)|NQ{>m)=P6UaSpsp<+x{r zXcK2w0vu#LozSqGK=6*Y!zLOV2vo;CVZ4s97RqLBm~TXmv4h~IoB!{l2AFM^wX>}aC83gVuM zl-&im(}+wNi@#!8tQ?<}kIsrB`dDk|dZxt_i?UMtvani!R{ZIB2Gr*Q1^<8&hgS;G zrbgYR#&pD<>40(>sTKN^H}ID-q?pPjst<^&v>aWfpS=ammgytgfVy=lt0g`ilK_359 zsA^EyC?U8Rprc!oq+W%5`ptPE`(J63Dakea*kY|i8 zwO&V>?u(xbDR0{sjwrYwez~*KBtY^GqN*8RafDkD#TKZ=RmPj%s3cb;vk|U3;&GDIZF>b-s>tUx zk=d$9cAV&^_tp8tjDE$M#;s~uw;Gy$4UgDYRkIJP3Mr$72-NM17wgngh&2-NwOjm^ z##YtcYIy4Vv!*(Ps2SuBCO?#Ul$wbhD zi)axBb$oO1)qQb74m}j>>jSD5Mus*oHyrJ{dc(hw3|Qti`bu`$K~>2o4&XtEVzF|3#har$*t@x{>Ok89g~((E&uPzo4i{ahP~=^TO<@rZLog~1uM5Ynel_&T3vFl>G`#V0;K8c zHvFEH<3^j)68V8z`$=%ipVv(RX7YLm&h4G;j`(IUlHu!h>8=^Wr>Mzcxg`z>ZM+hl zym9>bMjUw#`n1C!#J+?2KHa~z&3eR8MgdNhGE0|%HDwO+l5 z_NLYDU3>5cYcg+pu2&6p>d2K-U}-+SL+s~JrkL z#ibz`azJ{m^xL^UWrA|tO9ixfzZ?MvA>!OMyjP_}^}NOYdY}ZL`h}Y)-CfupNCg{> z9$DN=6VSkn8Uu8ppZ$1n(@kS-gLXylL`5!LQc(W9e6Ll!?Cqpp?L)b{NxjHiCG>;F zIJ3@JH>_1s6Fpvfx1=A1fBj8_*}gHfReap_WQ)&dnVr5N9I*v8*4rHVy7R4cNF8|C z6x*-PI^#7=^Oy4PZl74!739(t>Ux{EB_fho=Yhsfd5H&lEI4<-><;E-(g+R(Bsho% zsr?Bz0yz%w!@z3+) zUk}H(q$j@VP3$;K{0y7e&70Woo;aMJ_;WY`$Z)|IxqSDz(C1uuJ{R$dD<~u)3jn(R E2mOAAEdT%j diff --git a/resources/plus.gif b/resources/plus.gif deleted file mode 100644 index 6879c87437a5e0f5e79cf1227dd074e178f87a69..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4633 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 div { --accent-dark: hsl(200 100% 30%); } -body.platform-ios { - padding-top: calc(env(safe-area-inset-top) / 2); - margin: 0 10px 0 0; -} - .view-container.tab-content { height: auto !important; bottom: 50px !important; @@ -746,15 +738,6 @@ timestamp-badge[light-bg] { padding: 5% 10%; } -svg { - display: block; -} -#chart, #chart svg { - margin-right: 10px; -} -.nvd3, nv-noData { - font-weight: 300 !important; -} .metric-datepicker { /*height: 33px;*/ display: flex; /* establish flex container */ diff --git a/www/i18n/en.json b/www/i18n/en.json index 758960ade..3299a2207 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -12,7 +12,7 @@ }, "control":{ - "profile": "Profile", + "profile-tab": "Profile", "edit-demographics": "Edit Demographics", "tracking": "Tracking", "app-status": "App Status", @@ -36,7 +36,9 @@ "nuke-all": "Nuke all buffers and cache", "test-notification": "Test local notification", "check-log": "Check log", + "log-title" : "Log", "check-sensed-data": "Check sensed data", + "sensed-title": "Sensed Data: Transitions", "collection": "Collection", "sync": "Sync", "button-accept": "I accept", @@ -71,6 +73,7 @@ }, "metrics":{ + "dashboard-tab": "Dashboard", "cancel": "Cancel", "confirm": "Confirm", "get": "Get", @@ -101,13 +104,16 @@ "less-than": " less than ", "less": " less ", "week-before": "vs. week before", + "this-week": "this week", "pick-a-date": "Pick a date", "trips": "trips", "hours": "hours", + "minutes": "minutes", "custom": "Custom" }, "diary": { + "label-tab": "Label", "distance-in-time": "{{distance}} {{distsuffix}} in {{time}}", "distance": "Distance", "time": "Time", @@ -140,46 +146,42 @@ "no-travel-hint": "To see more, change the filters above or go record some travel!" }, - "user-gender": "Gender", - "gender-male": "Male", - "gender-female": "Female", - "user-height": "Height", - "user-weight": "Weight", - "user-age": "Age", - "main-metrics":{ - "dashboard": "Dashboard", "summary": "My Summary", "chart": "Chart", "change-data": "Change dates:", - "distance": "My Distance", - "trips": "My Trips", - "duration": "My Duration", + "distance": "Distance", + "trips": "Trips", + "duration": "Duration", "fav-mode": "My Favorite Mode", "speed": "My Speed", "footprint": "My Footprint", + "estimated-emissions": "Estimated CO₂ emissions", "how-it-compares": "Ballpark comparisons", - "optimal": "Optimal (perfect mode choice for all my trips):", - "average": "Average for group:", - "avoided": "CO₂ avoided (vs. all 'taxi'):", + "optimal": "Optimal (perfect mode choice for all my trips)", + "average": "Group Avg.", + "worst-case": "Worse Case", "label-to-squish": "Label trips to collapse the range into a single number", + "range-uncertain-footnote": "²Due to the uncertainty of unlabeled trips, estimates may fall anywhere within the shown range. Label more trips for richer estimates.", "lastweek": "My last week value:", - "us-2030-goal": "US 2030 Goal Estimate:", - "us-2050-goal": "US 2050 Goal Estimate:", - "calories": "My Calories", - "calibrate": "Calibrate", + "us-2030-goal": "2030 Guideline¹", + "us-2050-goal": "2050 Guideline¹", + "us-goals-footnote": "¹Guidelines based on US decarbonization goals, scaled to per-capita travel-related emissions.", + "past-week" : "Past Week", + "prev-week" : "Prev. Week", "no-summary-data": "No summary data", "mean-speed": "My Average Speed", - "equals-cookies_one": "Equals at least {{count}} homemade chocolate chip cookie", - "equals-cookies_other": "Equals at least {{count}} homemade chocolate chip cookies", - "equals-icecream_one": "Equals at least {{count}} half cup vanilla ice cream", - "equals-icecream_other": "Equals at least {{count}} half cups vanilla ice cream", - "equals-bananas_one": "Equals at least {{count}} banana", - "equals-bananas_other": "Equals at least {{count}} bananas" - }, - - "main-inf-scroll" : { - "tab": "Label" + "user-totals": "My Totals", + "group-totals": "Group Totals", + "active-minutes": "Active Minutes", + "weekly-active-minutes": "Weekly minutes of active travel", + "daily-active-minutes": "Daily minutes of active travel", + "active-minutes-table": "Table of active minutes metrics", + "weekly-goal": "Weekly Goal³", + "weekly-goal-footnote": "³Weekly goal based on CDC recommendation of 150 minutes of moderate activity per week.", + "labeled": "Labeled", + "unlabeled": "Unlabeled²", + "footprint-label": "Footprint (kg CO₂)" }, "details":{ @@ -222,6 +224,7 @@ }, "intro": { + "proceed": "Proceed", "appstatus": { "fix": "Fix", "refresh":"Refresh", @@ -327,22 +330,23 @@ "enketo-timestamps-invalid": "The times you entered are invalid. Please ensure that the start time is before the end time." }, "join": { - "welcome-to-nrel-openpath": "Welcome to NREL OpenPATH", - "proceed-further": "To proceed further, you need to enter a valid OPcode (token)", - "what-is-opcode": "The OPcode is a long string starting with 'nrelop' that has been provided by your program admin through a website, email, text or printout.", - "or": "or", - "scan-button": "Scan the QR code ", - "scan-details": "The OPcode will be written at the top of the image", - "paste-button": "Paste the OPcode", - "paste-details": "We suggest copy-pasting instead of typing since the OPcode is long and jumbled", + "welcome-to-app": "Welcome to {{appName}}!", + "app-name": "NREL OpenPATH", + "to-proceed-further": "To proceed further, please scan or paste an access code that has been provided by your program administrator through a website, email, text message, or printout.", + "code-hint": "The code begins with ‘nrelop’ and may be in barcode or text format.", + "scan-code": "Scan code", + "paste-code": "Paste code", + "scan-hint": "Scan the barcode with your phone camera", + "paste-hint": "Or, paste the code as text", + "about-app-title": "About {{appName}}", "about-app-para-1": "The National Renewable Energy Laboratory’s Open Platform for Agile Trip Heuristics (NREL OpenPATH) enables people to track their travel modes—car, bus, bike, walking, etc.—and measure their associated energy use and carbon footprint.", "about-app-para-2": "The app empowers communities to understand their travel mode choices and patterns, experiment with options to make them more sustainable, and evaluate the results. Such results can inform effective transportation policy and planning and be used to build more sustainable and accessible cities.", "about-app-para-3": "It does so by building an automatic diary of all your trips, across all transportation modes. It reads multiple sensors, including location, in the background, and turns GPS tracking on and off automatically for minimal power consumption. The choice of the travel pattern information and the carbon footprint display style are study-specific.", + "tips-title": "Tip(s) for correct operation:", "all-green-status": "Make sure that all status checks are green", "dont-force-kill": "Do not force kill the app", "background-restrictions": "On Samsung and Huwaei phones, make sure that background restrictions are turned off", - "close": "Close", - "tips-title": "Tip(s) for correct operation:" + "close": "Close" }, "config": { "unable-read-saved-config": "Unable to read saved config", @@ -360,7 +364,9 @@ "errors": { "while-populating-composite": "Error while populating composite trips", "while-loading-another-week": "Error while loading travel of {{when}} week", - "while-loading-specific-week": "Error while loading travel for the week of {{day}}" + "while-loading-specific-week": "Error while loading travel for the week of {{day}}", + "while-log-messages": "While getting messages from the log ", + "while-max-index" : "While getting max index " }, "consent-text": { "title":"NREL OPENPATH PRIVACY POLICY/TERMS OF USE", diff --git a/www/templates/survey/enketo/enketo_bare_150x56.png b/www/img/enketo_bare_150x56.png similarity index 100% rename from www/templates/survey/enketo/enketo_bare_150x56.png rename to www/img/enketo_bare_150x56.png diff --git a/www/index.html b/www/index.html index d5d3266ad..451c3047f 100644 --- a/www/index.html +++ b/www/index.html @@ -11,19 +11,8 @@ - - - - - - - + +
diff --git a/www/index.js b/www/index.js index 578b3dc75..d91357d18 100644 --- a/www/index.js +++ b/www/index.js @@ -1,16 +1,9 @@ import './manual_lib/ionic/css/ionic.css'; import './css/style.css'; -import './css/intro.css'; -import './css/appstatus.css'; -import './css/main.recent.css'; import './css/main.diary.css'; -import './manual_lib/fontawesome/css/all.min.css'; import 'leaflet/dist/leaflet.css'; -import './js/app.js'; -import './js/config/dynamic_config.js'; -import './js/config/imperial.js'; -import './js/config/server_conn.js'; +import './js/ngApp.js'; import './js/stats/clientstats.js'; import './js/splash/referral.js'; import './js/splash/startprefs.js'; @@ -19,34 +12,22 @@ import './js/splash/storedevicesettings.js'; import './js/splash/localnotify.js'; import './js/splash/remotenotify.js'; import './js/splash/notifScheduler.js'; -import './js/join/join-ctrl.js'; import './js/controllers.js'; import './js/services.js'; import './js/i18n-utils.js'; -import './js/intro.js'; import './js/main.js'; import './js/survey/input-matcher.js'; import './js/survey/multilabel/infinite_scroll_filters.js'; import './js/survey/multilabel/multi-label-ui.js'; import './js/diary.js'; -import './js/recent.js'; import './js/diary/services.js'; -import './js/survey/external/launch.js'; import './js/survey/enketo/answer.js'; -import './js/survey/enketo/launch.js'; -import './js/survey/enketo/service.js'; import './js/survey/enketo/infinite_scroll_filters.js'; import './js/survey/enketo/enketo-trip-button.js'; -import './js/survey/enketo/enketo-demographics.js'; import './js/survey/enketo/enketo-add-note-button.js'; -import './js/metrics.js'; -import './js/control/general-settings.js'; import './js/control/emailService.js'; import './js/control/uploadService.js'; -import './js/control/collect-settings.js'; -import './js/control/sync-settings.js'; import './js/metrics-factory.js'; import './js/metrics-mappings.js'; import './js/plugin/logger.ts'; import './js/plugin/storage.js'; -import './js/appstatus/permissioncheck.js'; diff --git a/www/js/App.tsx b/www/js/App.tsx new file mode 100644 index 000000000..1ace61531 --- /dev/null +++ b/www/js/App.tsx @@ -0,0 +1,88 @@ +import React, { useEffect, useState, createContext, useMemo } from 'react'; +import { getAngularService } from './angular-react-helper'; +import { BottomNavigation, Button, useTheme } from 'react-native-paper'; +import { useTranslation } from 'react-i18next'; +import LabelTab from './diary/LabelTab'; +import MetricsTab from './metrics/MetricsTab'; +import ProfileSettings from './control/ProfileSettings'; +import useAppConfig from './useAppConfig'; +import OnboardingStack from './onboarding/OnboardingStack'; +import { OnboardingRoute, OnboardingState, getPendingOnboardingState } from './onboarding/onboardingHelper'; +import { setServerConnSettings } from './config/serverConn'; +import AppStatusModal from './control/AppStatusModal'; + +const defaultRoutes = (t) => [ + { key: 'label', title: t('diary.label-tab'), focusedIcon: 'check-bold', unfocusedIcon: 'check-outline' }, + { key: 'metrics', title: t('metrics.dashboard-tab'), focusedIcon: 'chart-box', unfocusedIcon: 'chart-box-outline' }, + { key: 'control', title: t('control.profile-tab'), focusedIcon: 'account', unfocusedIcon: 'account-outline' }, +]; + +export const AppContext = createContext({}); + +const App = () => { + + const [index, setIndex] = useState(0); + const [pendingOnboardingState, setPendingOnboardingState] = useState(null); + const [permissionsPopupVis, setPermissionsPopupVis] = useState(false); + const appConfig = useAppConfig(); + const { colors } = useTheme(); + const { t } = useTranslation(); + + const StartPrefs = getAngularService('StartPrefs'); + + const routes = useMemo(() => { + const showMetrics = appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL'; + return showMetrics ? defaultRoutes(t) : defaultRoutes(t).filter(r => r.key != 'metrics'); + }, [appConfig, t]); + + const renderScene = BottomNavigation.SceneMap({ + label: LabelTab, + metrics: MetricsTab, + control: ProfileSettings, + }); + + const refreshOnboardingState = () => getPendingOnboardingState().then(setPendingOnboardingState); + useEffect(() => { refreshOnboardingState() }, []); + + useEffect(() => { + if (!appConfig) return; + setServerConnSettings(appConfig).then(() => { + refreshOnboardingState(); + }); + }, [appConfig]); + + const appContextValue = { + appConfig, + pendingOnboardingState, setPendingOnboardingState, refreshOnboardingState, + permissionsPopupVis, setPermissionsPopupVis, + } + + console.debug('pendingOnboardingState in App', pendingOnboardingState); + + return (<> + + {pendingOnboardingState == null ? + + : + + } + { /* if onboarding is done (state == null), or if is in progress but we are past the + consent page (route > CONSENT), the permissions popup can show if needed */ } + {(pendingOnboardingState == null || pendingOnboardingState.route > OnboardingRoute.CONSENT) && + + } + + ); +} + +export default App; diff --git a/www/js/app.js b/www/js/app.js deleted file mode 100644 index 2d0b1d08d..000000000 --- a/www/js/app.js +++ /dev/null @@ -1,111 +0,0 @@ -// Ionic E-Mission App - -'use strict'; - -import angular from 'angular'; -import 'angular-animate'; -import 'angular-sanitize'; -import 'angular-translate'; -import '../manual_lib/angular-ui-router/angular-ui-router.js'; -import 'angular-local-storage'; -import 'angular-translate-loader-static-files'; - -import 'moment'; -import 'moment-timezone'; -import 'chartjs-adapter-luxon'; - -import 'ionic-toast'; -import 'ionic-datepicker'; -import 'angular-simple-logger'; - -import '../manual_lib/ionic/js/ionic.js'; -import '../manual_lib/ionic/js/ionic-angular.js'; - -import initializedI18next from './i18nextInit'; -window.i18next = initializedI18next; -import 'ng-i18next'; - -angular.module('emission', ['ionic', 'jm.i18next', - 'emission.controllers','emission.services', 'emission.plugin.logger', - 'emission.splash.referral','emission.services.email', - 'emission.intro', 'emission.main', 'emission.config.dynamic', - 'emission.config.server_conn', 'emission.join.ctrl', - 'pascalprecht.translate', 'LocalStorageModule']) - -.run(function($ionicPlatform, $rootScope, $http, Logger, localStorageService, ServerConnConfig) { - console.log("Starting run"); - // ensure that plugin events are delivered after the ionicPlatform is ready - // https://github.com/katzer/cordova-plugin-local-notifications#launch-details - window.skipLocalNotificationReady = true; - // alert("Starting run"); - // BEGIN: Global listeners, no need to wait for the platform - // TODO: Although the onLaunch call doesn't need to wait for the platform the - // handlers do. Can we rely on the fact that the event is generated from - // native code, so will only be launched after the platform is ready? - // END: Global listeners - $ionicPlatform.ready(function() { - // Hide the accessory bar by default (remove this to show the accessory bar above the keyboard - // for form inputs) - Logger.log("ionicPlatform is ready"); - - if (window.StatusBar) { - // org.apache.cordova.statusbar required - StatusBar.styleDefault(); - } - cordova.plugin.http.setDataSerializer('json'); - // backwards compat hack to be consistent with - // https://github.com/e-mission/e-mission-data-collection/commit/92f41145e58c49e3145a9222a78d1ccacd16d2a7#diff-962320754eba07107ecd413954411f725c98fd31cddbb5defd4a542d1607e5a3R160 - // remove during migration to react native - localStorageService.remove("OP_GEOFENCE_CFG"); - cordova.plugins.BEMUserCache.removeLocalStorage("OP_GEOFENCE_CFG"); - }); - console.log("Ending run"); -}) - -.config(function($stateProvider, $urlRouterProvider, $compileProvider) { - console.log("Starting config"); - // alert("config"); - - // Ionic uses AngularUI Router which uses the concept of states - // Learn more here: https://github.com/angular-ui/ui-router - // Set a few states which the app can be in. - // The 'intro' and 'diary' states are found in their respective modules - // Each state's controller can be found in controllers.js - $compileProvider.imgSrcSanitizationWhitelist(/^\s*(https?|ftp|file|blob|ionic):|data:image/); - $stateProvider - // set up a state for the splash screen. This has no parents and no children - // because it is basically just used to load the user's preferred screen. - // This cannot directly use plugins - has to check for them first. - .state('splash', { - url: '/splash', - templateUrl: 'templates/splash/splash.html', - controller: 'SplashCtrl' - }) - - // add the join screen to the list of initially defined states - // we can't put it in intro since it comes before it - // we can't put it in main because it is also a temporary screen that only - // shows up when we have no config. - // so we put it in here - .state('root.join', { - url: '/join', - templateUrl: 'templates/join/request_join.html', - controller: 'JoinCtrl' - }) - - // setup an abstract state for the root. Only children of this can be loaded - // as preferred screens, and all children of this can assume that the device - // is ready. - .state('root', { - url: '/root', - abstract: true, - template: '', - controller: 'RootCtrl' - }); - - // alert("about to fall back to otherwise"); - // if none of the above states are matched, use this as the fallback - $urlRouterProvider.otherwise('/splash'); - - console.log("Ending config"); -}); diff --git a/www/js/appTheme.ts b/www/js/appTheme.ts index 7571a564c..a8660e811 100644 --- a/www/js/appTheme.ts +++ b/www/js/appTheme.ts @@ -8,7 +8,7 @@ const AppTheme = { colors: { ...DefaultTheme.colors, primary: '#0080b9', // lch(50% 50 250) - primaryContainer: '#90ceff', // lch(80% 40 250) + primaryContainer: '#c0e2ff', // lch(88% 30 250) onPrimaryContainer: '#001e30', // lch(10% 50 250) secondary: '#c08331', // lch(60% 55 70) secondaryContainer: '#fcefda', // lch(95% 12 80) @@ -26,7 +26,8 @@ const AppTheme = { level4: '#e0f0ff', // lch(94% 50 250) level5: '#d6ebff', // lch(92% 50 250) }, - success: '#38872e', // lch(50% 55 135) + success: '#00a665', // lch(60% 55 155) + warn: '#f8cf53', //lch(85% 65 85) danger: '#f23934' // lch(55% 85 35) }, roundness: 5, diff --git a/www/js/appstatus/PermissionsControls.tsx b/www/js/appstatus/PermissionsControls.tsx new file mode 100644 index 000000000..ded51b898 --- /dev/null +++ b/www/js/appstatus/PermissionsControls.tsx @@ -0,0 +1,65 @@ +//component to view and manage permission settings +import React, { useState } from "react"; +import { StyleSheet, ScrollView, View } from "react-native"; +import { Button, Text } from 'react-native-paper'; +import { useTranslation } from "react-i18next"; +import PermissionItem from "./PermissionItem"; +import usePermissionStatus, { refreshAllChecks } from "../usePermissionStatus"; +import ExplainPermissions from "./ExplainPermissions"; +import AlertBar from "../control/AlertBar"; + +const PermissionsControls = ({ onAccept }) => { + const { t } = useTranslation(); + const [explainVis, setExplainVis] = useState(false); + const { checkList, overallStatus, error, errorVis, setErrorVis, explanationList } = usePermissionStatus(); + + return ( + <> + {t('consent.permissions')} + + {t('intro.appstatus.overall-description')} + + + {checkList?.map((lc) => + + + )} + + + + + + + + + ) +} + +const styles = StyleSheet.create({ + title: { + fontWeight: "bold", + fontSize: 22, + paddingBottom: 10 + }, + buttonBox: { + paddingHorizontal: 15, + paddingVertical: 10, + flexDirection: "row", + justifyContent: "space-evenly" + } + }); + +export default PermissionsControls; \ No newline at end of file diff --git a/www/js/appstatus/permissioncheck.js b/www/js/appstatus/permissioncheck.js deleted file mode 100644 index 84067a701..000000000 --- a/www/js/appstatus/permissioncheck.js +++ /dev/null @@ -1,429 +0,0 @@ -/* - * Directive to enable the permissions required for the app to function properly. - */ - -import angular from 'angular'; - -angular.module('emission.appstatus.permissioncheck', - []) -.directive('permissioncheck', function() { - return { - scope: { - overallstatus: "=", - }, - controller: "PermissionCheckControl", - templateUrl: "templates/appstatus/permissioncheck.html" - }; -}). -controller("PermissionCheckControl", function($scope, $element, $attrs, - $ionicPlatform, $ionicPopup, $window) { - console.log("PermissionCheckControl initialized with status "+$scope.overallstatus); - - $scope.setupLocChecks = function(platform, version) { - if (platform.toLowerCase() == "android") { - return $scope.setupAndroidLocChecks(version); - } else if (platform.toLowerCase() == "ios") { - return $scope.setupIOSLocChecks(version); - } else { - alert("Unknown platform, no tracking"); - } - } - - $scope.setupFitnessChecks = function(platform, version) { - if (platform.toLowerCase() == "android") { - return $scope.setupAndroidFitnessChecks(version); - } else if (platform.toLowerCase() == "ios") { - return $scope.setupIOSFitnessChecks(version); - } else { - alert("Unknown platform, no tracking"); - } - } - - $scope.setupNotificationChecks = function(platform, version) { - return $scope.setupAndroidNotificationChecks(version); - } - - $scope.setupBackgroundRestrictionChecks = function(platform, version) { - if (platform.toLowerCase() == "android") { - $scope.backgroundUnrestrictionsNeeded = true; - return $scope.setupAndroidBackgroundRestrictionChecks(version); - } else if (platform.toLowerCase() == "ios") { - $scope.backgroundUnrestrictionsNeeded = false; - $scope.overallBackgroundRestrictionStatus = true; - $scope.backgroundRestrictionChecks = []; - return true; - } else { - alert("Unknown platform, no tracking"); - } - } - - let iconMap = (statusState) => statusState? "✅" : "❌"; - let classMap = (statusState) => statusState? "status-green" : "status-red"; - - $scope.recomputeOverallStatus = function() { - $scope.overallstatus = $scope.overallLocStatus - && $scope.overallFitnessStatus - && $scope.overallNotificationStatus - && $scope.overallBackgroundRestrictionStatus; - } - - $scope.recomputeLocStatus = function() { - $scope.locChecks.forEach((lc) => { - lc.statusIcon = iconMap(lc.statusState); - lc.statusClass = classMap(lc.statusState) - }); - $scope.overallLocStatus = $scope.locChecks.map((lc) => lc.statusState).reduce((pv, cv) => pv && cv); - console.log("overallLocStatus = "+$scope.overallLocStatus+" from ", $scope.locChecks); - $scope.overallLocStatusIcon = iconMap($scope.overallLocStatus); - $scope.overallLocStatusClass = classMap($scope.overallLocStatus); - $scope.recomputeOverallStatus(); - } - - $scope.recomputeFitnessStatus = function() { - $scope.fitnessChecks.forEach((fc) => { - fc.statusIcon = iconMap(fc.statusState); - fc.statusClass = classMap(fc.statusState) - }); - $scope.overallFitnessStatus = $scope.fitnessChecks.map((fc) => fc.statusState).reduce((pv, cv) => pv && cv); - console.log("overallFitnessStatus = "+$scope.overallFitnessStatus+" from ", $scope.fitnessChecks); - $scope.overallFitnessStatusIcon = iconMap($scope.overallFitnessStatus); - $scope.overallFitnessStatusClass = classMap($scope.overallFitnessStatus); - $scope.recomputeOverallStatus(); - } - - $scope.recomputeNotificationStatus = function() { - $scope.notificationChecks.forEach((nc) => { - nc.statusIcon = iconMap(nc.statusState); - nc.statusClass = classMap(nc.statusState) - }); - $scope.overallNotificationStatus = $scope.notificationChecks.map((nc) => nc.statusState).reduce((pv, cv) => pv && cv); - console.log("overallNotificationStatus = "+$scope.overallNotificationStatus+" from ", $scope.notificationChecks); - $scope.overallNotificationStatusIcon = iconMap($scope.overallNotificationStatus); - $scope.overallNotificationStatusClass = classMap($scope.overallNotificationStatus); - $scope.recomputeOverallStatus(); - } - - $scope.recomputeBackgroundRestrictionStatus = function() { - if (!$scope.backgroundRestrictionChecks) return; - $scope.backgroundRestrictionChecks.forEach((brc) => { - brc.statusIcon = iconMap(brc.statusState); - brc.statusClass = classMap(brc.statusState) - }); - $scope.overallBackgroundRestrictionStatus = $scope.backgroundRestrictionChecks.map((nc) => nc.statusState).reduce((pv, cv) => pv && cv); - console.log("overallBackgroundRestrictionStatus = "+$scope.overallBackgroundRestrictionStatus+" from ", $scope.backgroundRestrictionChecks); - $scope.overallBackgroundRestrictionStatusIcon = iconMap($scope.overallBackgroundRestrictionStatus); - $scope.overallBackgroundRestrictionStatusClass = classMap($scope.overallBackgroundRestrictionStatus); - $scope.recomputeOverallStatus(); - } - - let checkOrFix = function(checkObj, nativeFn, recomputeFn, showError=true) { - return nativeFn() - .then((status) => { - console.log("availability ", status) - $scope.$apply(() => { - checkObj.statusState = true; - recomputeFn(); - }); - return status; - }).catch((error) => { - console.log("Error", error) - if (showError) { - $ionicPopup.alert({ - title: "Error", - template: "
"+error+"
", - okText: "Please fix again" - }); - }; - $scope.$apply(() => { - checkObj.statusState = false; - recomputeFn(); - }); - return error; - }); - } - - let refreshChecks = function(checksList, recomputeFn) { - // without this, even if the checksList is [] - // the reduce in the recomputeFn fails because it is called on a zero - // length array without a default value - // we should be able to also specify a default value of True - // but I don't want to mess with that at this last minute - if (!checksList || checksList.length == 0) { - return Promise.resolve(true); - } - let checkPromises = checksList?.map((lc) => lc.refresh()); - console.log(checkPromises); - return Promise.all(checkPromises) - .then((result) => recomputeFn()) - .catch((error) => recomputeFn()) - } - - $scope.setupAndroidLocChecks = function(platform, version) { - let fixSettings = function() { - console.log("Fix and refresh location settings"); - return checkOrFix(locSettingsCheck, $window.cordova.plugins.BEMDataCollection.fixLocationSettings, - $scope.recomputeLocStatus, true); - }; - let checkSettings = function() { - console.log("Refresh location settings"); - return checkOrFix(locSettingsCheck, $window.cordova.plugins.BEMDataCollection.isValidLocationSettings, - $scope.recomputeLocStatus, false); - }; - let fixPerms = function() { - console.log("fix and refresh location permissions"); - return checkOrFix(locPermissionsCheck, $window.cordova.plugins.BEMDataCollection.fixLocationPermissions, - $scope.recomputeLocStatus, true).then((error) => locPermissionsCheck.desc = error); - }; - let checkPerms = function() { - console.log("fix and refresh location permissions"); - return checkOrFix(locPermissionsCheck, $window.cordova.plugins.BEMDataCollection.isValidLocationPermissions, - $scope.recomputeLocStatus, false); - }; - var androidSettingsDescTag = "intro.appstatus.locsettings.description.android-gte-9"; - if (version < 9) { - androidSettingsDescTag = "intro.appstatus.locsettings.description.android-lt-9"; - } - var androidPermDescTag = "intro.appstatus.locperms.description.android-gte-12"; - if($scope.osver < 6) { - androidPermDescTag = 'intro.appstatus.locperms.description.android-lt-6'; - } else if ($scope.osver < 10) { - androidPermDescTag = "intro.appstatus.locperms.description.android-6-9"; - } else if ($scope.osver < 11) { - androidPermDescTag= "intro.appstatus.locperms.description.android-10"; - } else if ($scope.osver < 12) { - androidPermDescTag= "intro.appstatus.locperms.description.android-11"; - } - console.log("description tags are "+androidSettingsDescTag+" "+androidPermDescTag); - // location settings - let locSettingsCheck = { - name: i18next.t("intro.appstatus.locsettings.name"), - desc: i18next.t(androidSettingsDescTag), - statusState: false, - fix: fixSettings, - refresh: checkSettings - } - let locPermissionsCheck = { - name: i18next.t("intro.appstatus.locperms.name"), - desc: i18next.t(androidPermDescTag), - statusState: false, - fix: fixPerms, - refresh: checkPerms - } - $scope.locChecks = [locSettingsCheck, locPermissionsCheck]; - refreshChecks($scope.locChecks, $scope.recomputeLocStatus); - } - - $scope.setupIOSLocChecks = function(platform, version) { - let fixSettings = function() { - console.log("Fix and refresh location settings"); - return checkOrFix(locSettingsCheck, $window.cordova.plugins.BEMDataCollection.fixLocationSettings, - $scope.recomputeLocStatus, true); - }; - let checkSettings = function() { - console.log("Refresh location settings"); - return checkOrFix(locSettingsCheck, $window.cordova.plugins.BEMDataCollection.isValidLocationSettings, - $scope.recomputeLocStatus, false); - }; - let fixPerms = function() { - console.log("fix and refresh location permissions"); - return checkOrFix(locPermissionsCheck, $window.cordova.plugins.BEMDataCollection.fixLocationPermissions, - $scope.recomputeLocStatus, true).then((error) => locPermissionsCheck.desc = error); - }; - let checkPerms = function() { - console.log("fix and refresh location permissions"); - return checkOrFix(locPermissionsCheck, $window.cordova.plugins.BEMDataCollection.isValidLocationPermissions, - $scope.recomputeLocStatus, false); - }; - var iOSSettingsDescTag = "intro.appstatus.locsettings.description.ios"; - var iOSPermDescTag = "intro.appstatus.locperms.description.ios-gte-13"; - if($scope.osver < 13) { - iOSPermDescTag = 'intro.appstatus.locperms.description.ios-lt-13'; - } - console.log("description tags are "+iOSSettingsDescTag+" "+iOSPermDescTag); - // location settings - let locSettingsCheck = { - name: i18next.t("intro.appstatus.locsettings.name"), - desc: i18next.t(iOSSettingsDescTag), - statusState: false, - fix: fixSettings, - refresh: checkSettings - } - let locPermissionsCheck = { - name: i18next.t("intro.appstatus.locperms.name"), - desc: i18next.t(iOSPermDescTag), - statusState: false, - fix: fixPerms, - refresh: checkPerms - } - $scope.locChecks = [locSettingsCheck, locPermissionsCheck]; - refreshChecks($scope.locChecks, $scope.recomputeLocStatus); - } - - $scope.setupAndroidFitnessChecks = function(platform, version) { - $scope.fitnessPermNeeded = ($scope.osver >= 10); - - let fixPerms = function() { - console.log("fix and refresh fitness permissions"); - return checkOrFix(fitnessPermissionsCheck, $window.cordova.plugins.BEMDataCollection.fixFitnessPermissions, - $scope.recomputeFitnessStatus, true).then((error) => fitnessPermissionsCheck.desc = error); - }; - let checkPerms = function() { - console.log("fix and refresh fitness permissions"); - return checkOrFix(fitnessPermissionsCheck, $window.cordova.plugins.BEMDataCollection.isValidFitnessPermissions, - $scope.recomputeFitnessStatus, false); - }; - - let fitnessPermissionsCheck = { - name: i18next.t("intro.appstatus.fitnessperms.name"), - desc: i18next.t("intro.appstatus.fitnessperms.description.android"), - fix: fixPerms, - refresh: checkPerms - } - $scope.overallFitnessName = i18next.t("intro.appstatus.overall-fitness-name-android"); - $scope.fitnessChecks = [fitnessPermissionsCheck]; - refreshChecks($scope.fitnessChecks, $scope.recomputeFitnessStatus); - } - - $scope.setupIOSFitnessChecks = function(platform, version) { - $scope.fitnessPermNeeded = true; - - let fixPerms = function() { - console.log("fix and refresh fitness permissions"); - return checkOrFix(fitnessPermissionsCheck, $window.cordova.plugins.BEMDataCollection.fixFitnessPermissions, - $scope.recomputeFitnessStatus, true).then((error) => fitnessPermissionsCheck.desc = error); - }; - let checkPerms = function() { - console.log("fix and refresh fitness permissions"); - return checkOrFix(fitnessPermissionsCheck, $window.cordova.plugins.BEMDataCollection.isValidFitnessPermissions, - $scope.recomputeFitnessStatus, false); - }; - - let fitnessPermissionsCheck = { - name: i18next.t("intro.appstatus.fitnessperms.name"), - desc: i18next.t("intro.appstatus.fitnessperms.description.ios"), - fix: fixPerms, - refresh: checkPerms - } - $scope.overallFitnessName = i18next.t("intro.appstatus.overall-fitness-name-ios"); - $scope.fitnessChecks = [fitnessPermissionsCheck]; - refreshChecks($scope.fitnessChecks, $scope.recomputeFitnessStatus); - } - - $scope.setupAndroidNotificationChecks = function() { - let fixPerms = function() { - console.log("fix and refresh notification permissions"); - return checkOrFix(appAndChannelNotificationsCheck, $window.cordova.plugins.BEMDataCollection.fixShowNotifications, - $scope.recomputeNotificationStatus, true); - }; - let checkPerms = function() { - console.log("fix and refresh notification permissions"); - return checkOrFix(appAndChannelNotificationsCheck, $window.cordova.plugins.BEMDataCollection.isValidShowNotifications, - $scope.recomputeNotificationStatus, false); - }; - let appAndChannelNotificationsCheck = { - name: i18next.t("intro.appstatus.notificationperms.app-enabled-name"), - desc: i18next.t("intro.appstatus.notificationperms.description.android-enable"), - fix: fixPerms, - refresh: checkPerms - } - $scope.notificationChecks = [appAndChannelNotificationsCheck]; - refreshChecks($scope.notificationChecks, $scope.recomputeNotificationStatus); - } - - $scope.setupAndroidBackgroundRestrictionChecks = function() { - let fixPerms = function() { - console.log("fix and refresh backgroundRestriction permissions"); - return checkOrFix(unusedAppsUnrestrictedCheck, $window.cordova.plugins.BEMDataCollection.fixUnusedAppRestrictions, - $scope.recomputeBackgroundRestrictionStatus, true); - }; - let checkPerms = function() { - console.log("fix and refresh backgroundRestriction permissions"); - return checkOrFix(unusedAppsUnrestrictedCheck, $window.cordova.plugins.BEMDataCollection.isUnusedAppUnrestricted, - $scope.recomputeBackgroundRestrictionStatus, false); - }; - let fixBatteryOpt = function() { - console.log("fix and refresh battery optimization permissions"); - return checkOrFix(ignoreBatteryOptCheck, $window.cordova.plugins.BEMDataCollection.fixIgnoreBatteryOptimizations, - $scope.recomputeBackgroundRestrictionStatus, true); - }; - let checkBatteryOpt = function() { - console.log("fix and refresh battery optimization permissions"); - return checkOrFix(ignoreBatteryOptCheck, $window.cordova.plugins.BEMDataCollection.isIgnoreBatteryOptimizations, - $scope.recomputeBackgroundRestrictionStatus, false); - }; - var androidUnusedDescTag = "intro.appstatus.unusedapprestrict.description.android-disable-gte-13"; - if ($scope.osver == 12) { - androidUnusedDescTag= "intro.appstatus.unusedapprestrict.description.android-disable-12"; - } - else if ($scope.osver < 12) { - androidUnusedDescTag= "intro.appstatus.unusedapprestrict.description.android-disable-lt-12"; - } - let unusedAppsUnrestrictedCheck = { - name: i18next.t("intro.appstatus.unusedapprestrict.name"), - desc: i18next.t(androidUnusedDescTag), - fix: fixPerms, - refresh: checkPerms - } - let ignoreBatteryOptCheck = { - name: i18next.t("intro.appstatus.ignorebatteryopt.name"), - desc: i18next.t("intro.appstatus.ignorebatteryopt.description.android-disable"), - fix: fixBatteryOpt, - refresh: checkBatteryOpt - } - $scope.backgroundRestrictionChecks = [unusedAppsUnrestrictedCheck, ignoreBatteryOptCheck]; - refreshChecks($scope.backgroundRestrictionChecks, $scope.recomputeBackgroundRestrictionStatus); - } - - $scope.setupPermissionText = function() { - if($scope.platform.toLowerCase() == "ios") { - if($scope.osver < 13) { - $scope.locationPermExplanation = i18next.t("intro.permissions.locationPermExplanation-ios-lt-13"); - } else { - $scope.locationPermExplanation = i18next.t("intro.permissions.locationPermExplanation-ios-gte-13"); - } - } - - $scope.backgroundRestricted = false; - if($window.device.manufacturer.toLowerCase() == "samsung") { - $scope.backgroundRestricted = true; - $scope.allowBackgroundInstructions = i18next.t("intro.allow_background.samsung"); - } - - console.log("Explanation = "+$scope.locationPermExplanation); - } - - $scope.checkLocationServicesEnabled = function() { - console.log("About to see if location services are enabled"); - } - $ionicPlatform.ready().then(function() { - console.log("app is launched, should refresh"); - $scope.platform = $window.device.platform; - $scope.osver = $window.device.version.split(".")[0]; - $scope.setupPermissionText(); - $scope.setupLocChecks($scope.platform, $scope.osver); - $scope.setupFitnessChecks($scope.platform, $scope.osver); - $scope.setupNotificationChecks($scope.platform, $scope.osver); - $scope.setupBackgroundRestrictionChecks($scope.platform, $scope.osver); - }); - - $ionicPlatform.on("resume", function() { - console.log("PERMISSION CHECK: app has resumed, should refresh"); - refreshChecks($scope.locChecks, $scope.recomputeLocStatus); - refreshChecks($scope.fitnessChecks, $scope.recomputeFitnessStatus); - refreshChecks($scope.notificationChecks, $scope.recomputeNotificationStatus); - refreshChecks($scope.backgroundRestrictionChecks, $scope.recomputeBackgroundRestrictionStatus); - }); - - $scope.$on("recomputeAppStatus", function(e, callback) { - console.log("PERMISSION CHECK: recomputing state"); - Promise.all([ - refreshChecks($scope.locChecks, $scope.recomputeLocStatus), - refreshChecks($scope.fitnessChecks, $scope.recomputeFitnessStatus), - refreshChecks($scope.notificationChecks, $scope.recomputeNotificationStatus), - refreshChecks($scope.backgroundRestrictionChecks, $scope.recomputeBackgroundRestrictionStatus) - ]).then( () => { - callback($scope.overallstatus) - } - ); - }); -}); diff --git a/www/js/components/ActionMenu.tsx b/www/js/components/ActionMenu.tsx new file mode 100644 index 000000000..296717a00 --- /dev/null +++ b/www/js/components/ActionMenu.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { Modal } from "react-native"; +import { Dialog, Button, useTheme } from "react-native-paper"; +import { useTranslation } from "react-i18next"; +import { settingStyles } from "../control/ProfileSettings"; + +const ActionMenu = ({vis, setVis, title, actionSet, onAction, onExit}) => { + + const { t } = useTranslation(); + const { colors } = useTheme(); + + return ( + setVis(false)} + transparent={true}> + setVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + {title} + + {actionSet?.map((e) => + + )} + + + + + + + ) +} + +export default ActionMenu; \ No newline at end of file diff --git a/www/js/components/BarChart.tsx b/www/js/components/BarChart.tsx index 6da3d2a2b..1e957923b 100644 --- a/www/js/components/BarChart.tsx +++ b/www/js/components/BarChart.tsx @@ -1,201 +1,27 @@ +import React from "react"; +import Chart, { Props as ChartProps } from "./Chart"; +import { useTheme } from "react-native-paper"; +import { getGradient } from "./charting"; -import React, { useRef, useState } from 'react'; -import { array, string, bool } from 'prop-types'; -import { angularize } from '../angular-react-helper'; -import { View } from 'react-native'; -import { useTheme } from 'react-native-paper'; -import { Chart, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, TimeScale } from 'chart.js'; -import { Bar } from 'react-chartjs-2'; -import Annotation, { AnnotationOptions } from 'chartjs-plugin-annotation'; - -Chart.register( - CategoryScale, - LinearScale, - TimeScale, - BarElement, - Title, - Tooltip, - Legend, - Annotation, -); - -const BarChart = ({ chartData, axisTitle, lineAnnotations=null, isHorizontal=false }) => { +type Props = Omit & { + meter?: {high: number, middle: number, dash_key: string}, +} +const BarChart = ({ meter, ...rest }: Props) => { const { colors } = useTheme(); - const [ numVisibleDatasets, setNumVisibleDatasets ] = useState(1); - - const barChartRef = useRef(null); - - const defaultPalette = [ - '#c95465', // red oklch(60% 0.15 14) - '#4a71b1', // blue oklch(55% 0.11 260) - '#d2824e', // orange oklch(68% 0.12 52) - '#856b5d', // brown oklch(55% 0.04 50) - '#59894f', // green oklch(58% 0.1 140) - '#e0cc55', // yellow oklch(84% 0.14 100) - '#b273ac', // purple oklch(64% 0.11 330) - '#f09da6', // pink oklch(78% 0.1 12) - '#b3aca8', // grey oklch(75% 0.01 55) - '#80afad', // teal oklch(72% 0.05 192) - ] - - const indexAxis = isHorizontal ? 'y' : 'x'; - function getChartHeight() { - /* when horizontal charts have more data, they should get taller - so they don't look squished */ - if (isHorizontal) { - // 'ideal' chart height is based on the number of datasets and number of unique index values - const uniqueIndexVals = []; - chartData.forEach(e => e.records.forEach(r => { - if (!uniqueIndexVals.includes(r[indexAxis])) uniqueIndexVals.push(r[indexAxis]); - })); - const numIndexVals = uniqueIndexVals.length; - const idealChartHeight = numVisibleDatasets * numIndexVals * 8; - - /* each index val should be at least 20px tall for visibility, - and the graph itself should be at least 250px tall */ - const minChartHeight = Math.max(numIndexVals * 20, 250); - - // return whichever is greater - return { height: Math.max(idealChartHeight, minChartHeight) }; + if (meter) { + rest.getColorForChartEl = (chart, dataset, ctx, colorFor) => { + const darkenDegree = colorFor == 'border' ? 0.25 : 0; + const alpha = colorFor == 'border' ? 1 : 0; + return getGradient(chart, meter, dataset, ctx, alpha, darkenDegree); } - // vertical charts will just match the parent container - return { height: '100%' }; + rest.borderWidth = 3; } return ( - - ({ - label: d.label, - data: d.records, - // cycle through the default palette, repeat if necessary - backgroundColor: defaultPalette[i % defaultPalette.length], - })) - }} - options={{ - indexAxis: indexAxis, - responsive: true, - maintainAspectRatio: false, - resizeDelay: 1, - scales: { - ...(isHorizontal ? { - y: { - offset: true, - type: 'time', - adapters: { - date: { zone: 'utc' }, - }, - time: { - unit: 'day', - tooltipFormat: 'DDD', // Luxon "localized date with full month": e.g. August 6, 2014 - }, - beforeUpdate: (axis) => { - setNumVisibleDatasets(axis.chart.getVisibleDatasetCount()) - }, - reverse: true, - }, - x: { - title: { display: true, text: axisTitle }, - }, - } : { - x: { - offset: true, - type: 'time', - adapters: { - date: { zone: 'utc' }, - }, - time: { - unit: 'day', - tooltipFormat: 'DDD', // Luxon "localized date with full month": e.g. August 6, 2014 - }, - }, - y: { - title: { display: true, text: axisTitle }, - }, - }), - }, - plugins: { - ...(lineAnnotations?.length > 0 && { - annotation: { - annotations: lineAnnotations.map((a, i) => ({ - type: 'line', - label: { - display: true, - padding: { x: 3, y: 1 }, - borderRadius: 0, - backgroundColor: 'rgba(0,0,0,.7)', - color: 'rgba(255,255,255,1)', - font: { size: 10 }, - position: 'start', - content: a.label, - }, - ...(isHorizontal ? { xMin: a.value, xMax: a.value } - : { yMin: a.value, yMax: a.value }), - borderColor: colors.onBackground, - borderWidth: 2, - borderDash: [3, 3], - } satisfies AnnotationOptions)), - } - }), - } - }} /> - - ) + + ); } -BarChart.propTypes = { - chartData: array, - axisTitle: string, - lineAnnotations: array, - isHorizontal: bool, -}; - -angularize(BarChart, 'BarChart', 'emission.main.barchart'); export default BarChart; - -// const sampleAnnotations = [ -// { value: 35, label: 'Target1' }, -// { value: 65, label: 'Target2' }, -// ]; - -// const sampleChartData = [ -// { -// label: 'Primary', -// records: [ -// { x: moment('2023-06-20'), y: 20 }, -// { x: moment('2023-06-21'), y: 30 }, -// { x: moment('2023-06-23'), y: 80 }, -// { x: moment('2023-06-24'), y: 40 }, -// ], -// }, -// { -// label: 'Secondary', -// records: [ -// { x: moment('2023-06-21'), y: 10 }, -// { x: moment('2023-06-22'), y: 50 }, -// { x: moment('2023-06-23'), y: 30 }, -// { x: moment('2023-06-25'), y: 40 }, -// ], -// }, -// { -// label: 'Tertiary', -// records: [ -// { x: moment('2023-06-20'), y: 30 }, -// { x: moment('2023-06-22'), y: 40 }, -// { x: moment('2023-06-24'), y: 10 }, -// { x: moment('2023-06-25'), y: 60 }, -// ], -// }, -// { -// label: 'Quaternary', -// records: [ -// { x: moment('2023-06-22'), y: 10 }, -// { x: moment('2023-06-23'), y: 20 }, -// { x: moment('2023-06-24'), y: 30 }, -// { x: moment('2023-06-25'), y: 40 }, -// ], -// }, -// ]; diff --git a/www/js/components/Carousel.tsx b/www/js/components/Carousel.tsx new file mode 100644 index 000000000..28a31ff6a --- /dev/null +++ b/www/js/components/Carousel.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { ScrollView, View } from 'react-native'; + +type Props = { + children: React.ReactNode, + cardWidth: number, + cardMargin: number, +} +const Carousel = ({ children, cardWidth, cardMargin }: Props) => { + const numCards = React.Children.count(children); + return ( + + {React.Children.map(children, (child, i) => ( + + {child} + + ))} + + ) +}; + +export const s = { + carouselScroll: (cardMargin) => ({ + // @ts-ignore, RN doesn't recognize `scrollSnapType`, but it does work on RN Web + scrollSnapType: 'x mandatory', + paddingVertical: 10, + }), + carouselCard: (cardWidth, cardMargin, isFirst, isLast) => ({ + marginLeft: isFirst ? cardMargin : cardMargin/2, + marginRight: isLast ? cardMargin : cardMargin/2, + width: cardWidth, + scrollSnapAlign: 'center', + scrollSnapStop: 'always', + }), +}; + +export default Carousel; diff --git a/www/js/components/Chart.tsx b/www/js/components/Chart.tsx new file mode 100644 index 000000000..79c6e40e4 --- /dev/null +++ b/www/js/components/Chart.tsx @@ -0,0 +1,196 @@ + +import React, { useEffect, useRef, useState, useMemo } from 'react'; +import { View } from 'react-native'; +import { useTheme } from 'react-native-paper'; +import { Chart as ChartJS, registerables } from 'chart.js'; +import { Chart as ChartJSChart } from 'react-chartjs-2'; +import Annotation, { AnnotationOptions, LabelPosition } from 'chartjs-plugin-annotation'; +import { dedupColors, getChartHeight, darkenOrLighten } from './charting'; + +ChartJS.register(...registerables, Annotation); + +type XYPair = { x: number|string, y: number|string }; +type ChartDataset = { + label: string, + data: XYPair[], +}; + +export type Props = { + records: { label: string, x: number|string, y: number|string }[], + axisTitle: string, + type: 'bar'|'line', + getColorForLabel?: (label: string) => string, + getColorForChartEl?: (chart, currDataset: ChartDataset, ctx: ScriptableContext<'bar'|'line'>, colorFor: 'background'|'border') => string|CanvasGradient|null, + borderWidth?: number, + lineAnnotations?: { value: number, label?: string, color?:string, position?: LabelPosition }[], + isHorizontal?: boolean, + timeAxis?: boolean, + stacked?: boolean, +} +const Chart = ({ records, axisTitle, type, getColorForLabel, getColorForChartEl, borderWidth, lineAnnotations, isHorizontal, timeAxis, stacked }: Props) => { + + const { colors } = useTheme(); + const [ numVisibleDatasets, setNumVisibleDatasets ] = useState(1); + + const indexAxis = isHorizontal ? 'y' : 'x'; + const chartRef = useRef>(null); + const [chartDatasets, setChartDatasets] = useState([]); + + const chartData = useMemo>(() => { + let labelColorMap; // object mapping labels to colors + if (getColorForLabel) { + const colorEntries = chartDatasets.map(d => [d.label, getColorForLabel(d.label)] ); + labelColorMap = dedupColors(colorEntries); + } + return { + datasets: chartDatasets.map((e, i) => ({ + ...e, + backgroundColor: (barCtx) => ( + labelColorMap?.[e.label] || getColorForChartEl(chartRef.current, e, barCtx, 'background') + ), + borderColor: (barCtx) => ( + darkenOrLighten(labelColorMap?.[e.label], -.5) || getColorForChartEl(chartRef.current, e, barCtx, 'border') + ), + borderWidth: borderWidth || 2, + borderRadius: 3, + })), + }; + }, [chartDatasets, getColorForLabel]); + + // group records by label (this is the format that Chart.js expects) + useEffect(() => { + const d = records?.reduce((acc, record) => { + const existing = acc.find(e => e.label == record.label); + if (!existing) { + acc.push({ + label: record.label, + data: [{ + x: record.x, + y: record.y, + }], + }); + } else { + existing.data.push({ + x: record.x, + y: record.y, + }); + } + return acc; + }, [] as ChartDataset[]); + setChartDatasets(d); + }, [records]); + + const annotationsAtTop = isHorizontal && lineAnnotations?.some(a => (!a.position || a.position == 'start')); + + return ( + + { + setNumVisibleDatasets(axis.chart.getVisibleDatasetCount()) + }, + ticks: timeAxis ? {} : { + callback: (value, i) => { + const label = chartDatasets[0].data[i].y; + if (typeof label == 'string' && label.includes('\n')) + return label.split('\n'); + return label; + }, + font: { size: 11 }, // default is 12, we want a tad smaller + }, + reverse: true, + stacked, + }, + x: { + title: { display: true, text: axisTitle }, + stacked, + }, + } : { + x: { + offset: true, + type: timeAxis ? 'time' : 'category', + adapters: timeAxis ? { + date: { zone: 'utc' }, + } : {}, + time: timeAxis ? { + unit: 'day', + tooltipFormat: 'DDD', // Luxon "localized date with full month": e.g. August 6, 2014 + } : {}, + ticks: timeAxis ? {} : { + callback: (value, i) => { + console.log("testing vertical", chartData, i); + const label = chartDatasets[0].data[i].x; + if (typeof label == 'string' && label.includes('\n')) + return label.split('\n'); + return label; + }, + }, + stacked, + }, + y: { + title: { display: true, text: axisTitle }, + stacked, + }, + }), + }, + plugins: { + ...(lineAnnotations?.length > 0 && { + annotation: { + clip: false, + annotations: lineAnnotations.map((a, i) => ({ + type: 'line', + label: { + display: true, + padding: { x: 3, y: 1 }, + borderRadius: 0, + backgroundColor: 'rgba(0,0,0,.7)', + color: 'rgba(255,255,255,1)', + font: { size: 10 }, + position: a.position || 'start', + content: a.label, + yAdjust: annotationsAtTop ? -12 : 0, + }, + ...(isHorizontal ? { xMin: a.value, xMax: a.value } + : { yMin: a.value, yMax: a.value }), + borderColor: a.color || colors.onBackground, + borderWidth: 3, + borderDash: [3, 3], + } satisfies AnnotationOptions)), + } + }), + } + }} + // if there are annotations at the top of the chart, it overlaps with the legend + // so we need to increase the spacing between the legend and the chart + // https://stackoverflow.com/a/73498454 + plugins={annotationsAtTop && [{ + id: "increase-legend-spacing", + beforeInit(chart) { + const originalFit = (chart.legend as any).fit; + (chart.legend as any).fit = function fit() { + originalFit.bind(chart.legend)(); + this.height += 12; + }; + } + }]} /> + + ) +} +export default Chart; diff --git a/www/js/components/LeafletView.jsx b/www/js/components/LeafletView.tsx similarity index 92% rename from www/js/components/LeafletView.jsx rename to www/js/components/LeafletView.tsx index eb0c0bb78..cf26cb933 100644 --- a/www/js/components/LeafletView.jsx +++ b/www/js/components/LeafletView.tsx @@ -1,10 +1,9 @@ import React, { useEffect, useRef, useState } from "react"; -import { angularize } from "../angular-react-helper"; -import { object, string } from "prop-types"; import { View } from "react-native"; import { useTheme } from "react-native-paper"; +import L from "leaflet"; -const mapSet = new Set(); +const mapSet = new Set(); export function invalidateMaps() { mapSet.forEach(map => map.invalidateSize()); } @@ -55,7 +54,7 @@ const LeafletView = ({ geojson, opts, ...otherProps }) => { const mapElId = `map-${geojson.data.id.replace(/[^a-zA-Z0-9]/g, '')}`; return ( - + + + + + + ); + }); + console.log("Ending run"); +}); diff --git a/www/js/onboarding/ConsentPage.tsx b/www/js/onboarding/ConsentPage.tsx new file mode 100644 index 000000000..08aa3ab48 --- /dev/null +++ b/www/js/onboarding/ConsentPage.tsx @@ -0,0 +1,43 @@ +import React, { useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import { View, ScrollView } from 'react-native'; +import { Button, Surface } from 'react-native-paper'; +import { resetDataAndRefresh } from '../config/dynamicConfig'; +import { AppContext } from '../App'; +import { getAngularService } from '../angular-react-helper'; +import PrivacyPolicy from './PrivacyPolicy'; +import { onboardingStyles } from './OnboardingStack'; + +const ConsentPage = () => { + + const { t } = useTranslation(); + const context = useContext(AppContext); + const { refreshOnboardingState } = context; + + /* If the user does not consent, we boot them back out to the join screen */ + function disagree() { + resetDataAndRefresh(); + }; + + function agree() { + const StartPrefs = getAngularService('StartPrefs'); + StartPrefs.markConsented().then((response) => { + refreshOnboardingState(); + }); + }; + + // privacy policy and data collection info, followed by accept/reject buttons + return (<> + + + + + + + + + + ); +} + +export default ConsentPage; diff --git a/www/js/onboarding/OnboardingStack.tsx b/www/js/onboarding/OnboardingStack.tsx new file mode 100644 index 000000000..643744ed3 --- /dev/null +++ b/www/js/onboarding/OnboardingStack.tsx @@ -0,0 +1,53 @@ +import React, { useContext } from "react"; +import { StyleSheet } from "react-native"; +import { AppContext } from "../App"; +import WelcomePage from "./WelcomePage"; +import ConsentPage from "./ConsentPage"; +import SurveyPage from "./SurveyPage"; +import SaveQrPage from "./SaveQrPage"; +import SummaryPage from "./SummaryPage"; +import { OnboardingRoute } from "./onboardingHelper"; +import { displayErrorMsg } from "../plugin/logger"; + +const OnboardingStack = () => { + + const { pendingOnboardingState } = useContext(AppContext); + + console.debug('pendingOnboardingState in OnboardingStack', pendingOnboardingState); + + if (pendingOnboardingState.route == OnboardingRoute.WELCOME) { + return ; + } else if (pendingOnboardingState.route == OnboardingRoute.SUMMARY) { + return ; + } else if (pendingOnboardingState.route == OnboardingRoute.CONSENT) { + return ; + } else if (pendingOnboardingState.route == OnboardingRoute.SAVE_QR) { + return ; + } else if (pendingOnboardingState.route == OnboardingRoute.SURVEY) { + return ; + } else { + displayErrorMsg('OnboardingStack: unknown route', pendingOnboardingState.route); + } +} + +export const onboardingStyles = StyleSheet.create({ + page: { + flex: 1, + paddingHorizontal: 15, + paddingVertical: 20, + }, + pageSection: { + marginVertical: 15, + alignItems: 'center', + }, + buttonRow: { + flexDirection: 'row', + flexWrap: 'wrap', + marginVertical: 15, + alignItems: 'center', + gap: 8, + margin: 'auto', + }, +}); + +export default OnboardingStack diff --git a/www/js/onboarding/PrivacyPolicy.tsx b/www/js/onboarding/PrivacyPolicy.tsx new file mode 100644 index 000000000..f237e359c --- /dev/null +++ b/www/js/onboarding/PrivacyPolicy.tsx @@ -0,0 +1,177 @@ +import React, { useMemo } from "react"; +import { StyleSheet, Text } from "react-native"; +import { useTranslation } from "react-i18next"; +import useAppConfig from "../useAppConfig"; +import { getTemplateText } from "./StudySummary"; + +const PrivacyPolicy = () => { + const { t, i18n } = useTranslation(); + const appConfig = useAppConfig(); + + let opCodeText; + if(appConfig?.opcode?.autogen) { + opCodeText = {t('consent-text.opcode.autogen')}; + + } else { + opCodeText = {t('consent-text.opcode.not-autogen')}; + } + + let yourRightsText; + if(appConfig?.intro?.app_required) { + yourRightsText = {t('consent-text.rights.app-required', {program_admin_contact: appConfig?.intro?.program_admin_contact})}; + + } else { + yourRightsText = {t('consent-text.rights.app-not-required', {program_or_study: appConfig?.intro?.program_or_study})}; + } + + const templateText = useMemo(() => getTemplateText(appConfig, i18n.language), [appConfig]); + + return ( + <> + {t('consent-text.title')} + {t('consent-text.introduction.header')} + {templateText?.short_textual_description} + {'\n'} + {t('consent-text.introduction.what-is-openpath')} + {'\n'} + {t('consent-text.introduction.what-is-NREL', {program_or_study: appConfig?.intro?.program_or_study})} + {'\n'} + {t('consent-text.introduction.if-disagree')} + {'\n'} + + {t('consent-text.why.header')} + {templateText?.why_we_collect} + {'\n'} + + {t('consent-text.what.header')} + {t('consent-text.what.no-pii')} + {'\n'} + {t('consent-text.what.phone-sensor')} + {'\n'} + {t('consent-text.what.labeling')} + {'\n'} + {t('consent-text.what.demographics')} + {'\n'} + {t('consent-text.what.on-nrel-site')} + {/* Linking is broken, look into enabling after migration + + {t('consent-text.what.open-source-data')} + { + Linking.openURL('https://github.com/e-mission/e-mission-data-collection.git'); + }}> + {' '}https://github.com/e-mission/e-mission-data-collection.git{' '} + + {t('consent-text.what.open-source-analysis')} + { + Linking.openURL('https://github.com/e-mission/e-mission-server.git'); + }}> + {' '}https://github.com/e-mission/e-mission-server.git{' '} + + {t('consent-text.what.open-source-dashboard')} + { + Linking.openURL('https://github.com/e-mission/em-public-dashboard.git'); + }}> + {' '}https://github.com/e-mission/em-public-dashboard.git{' '} + + */} + {'\n'} + + {t('consent-text.opcode.header')} + {opCodeText} + {'\n'} + + {t('consent-text.who-sees.header')} + {t('consent-text.who-sees.public-dash')} + {'\n'} + {t('consent-text.who-sees.individual-info')} + {'\n'} + {t('consent-text.who-sees.program-admins', { + deployment_partner_name: appConfig?.intro?.deployment_partner_name, + raw_data_use: templateText?.raw_data_use})} + {t('consent-text.who-sees.nrel-devs')} + {'\n'} + {t('consent-text.who-sees.TSDC-info')} + {/* Linking is broken, look into enabling after migration + { + Linking.openURL('https://nrel.gov/tsdc'); + }}> + {t('consent-text.who-sees.on-website')} + + {t('consent-text.who-sees.and-in')} + { + Linking.openURL('https://www.sciencedirect.com/science/article/pii/S2352146515002999'); + }}> + {t('consent-text.who-sees.this-pub')} + + {t('consent-text.who-sees.and')} + { + Linking.openURL('https://www.nrel.gov/docs/fy18osti/70723.pdf'); + }}> + {t('consent-text.who-sees.fact-sheet')} + */} + {t('consent-text.who-sees.on-nrel-site')} + + {'\n'} + + {t('consent-text.rights.header')} + {yourRightsText} + {'\n'} + {t('consent-text.rights.destroy-data-pt1')} + {/* Linking is broken, look into enabling after migration + { + Linking.openURL("mailto:k.shankari@nrel.gov"); + }}> + k.shankari@nrel.gov + */} + (k.shankari@nrel.gov) + {t('consent-text.rights.destroy-data-pt2')} + + {'\n'} + + {t('consent-text.questions.header')} + {t('consent-text.questions.for-questions', {program_admin_contact: appConfig?.intro?.program_admin_contact})} + {'\n'} + + {t('consent-text.consent.header')} + {t('consent-text.consent.press-button-to-consent', {program_or_study: appConfig?.intro?.program_or_study})} + + ) +} + +const styles = StyleSheet.create({ + hyperlinkStyle: (linkColor) => ({ + color: linkColor + }), + text: { + fontSize: 14, + }, + header: { + fontWeight: "bold", + fontSize: 18 + }, + title: { + fontWeight: "bold", + fontSize: 22, + paddingBottom: 10, + textAlign: "center" + }, + divider: { + marginVertical: 10 + } + }); + +export default PrivacyPolicy; diff --git a/www/js/onboarding/SaveQrPage.tsx b/www/js/onboarding/SaveQrPage.tsx new file mode 100644 index 000000000..157ff4093 --- /dev/null +++ b/www/js/onboarding/SaveQrPage.tsx @@ -0,0 +1,92 @@ +import React, { useContext, useEffect, useState } from "react"; +import { View, StyleSheet } from "react-native"; +import { ActivityIndicator, Button, Surface, Text } from "react-native-paper"; +import { registerUserDone, setRegisterUserDone, setSaveQrDone } from "./onboardingHelper"; +import { AppContext } from "../App"; +import usePermissionStatus from "../usePermissionStatus"; +import { getAngularService } from "../angular-react-helper"; +import { displayError, logDebug } from "../plugin/logger"; +import { useTranslation } from "react-i18next"; +import QrCode, { shareQR } from "../components/QrCode"; +import { onboardingStyles } from "./OnboardingStack"; +import { preloadDemoSurveyResponse } from "./SurveyPage"; + +const SaveQrPage = ({ }) => { + + const { t } = useTranslation(); + const { pendingOnboardingState, refreshOnboardingState } = useContext(AppContext); + const { overallStatus } = usePermissionStatus(); + + useEffect(() => { + if (overallStatus == true && !registerUserDone) { + logDebug('permissions done, going to log in'); + login(pendingOnboardingState.opcode).then((response) => { + logDebug('login done, refreshing onboarding state'); + setRegisterUserDone(true); + preloadDemoSurveyResponse(); + refreshOnboardingState(); + }); + } else { + logDebug('permissions not done, waiting'); + } + }, [overallStatus]); + + function login(token) { + const CommHelper = getAngularService('CommHelper'); + const KVStore = getAngularService('KVStore'); + const EXPECTED_METHOD = "prompted-auth"; + const dbStorageObject = {"token": token}; + return KVStore.set(EXPECTED_METHOD, dbStorageObject).then((r) => { + CommHelper.registerUser((successResult) => { + refreshOnboardingState(); + }, function(errorResult) { + displayError(errorResult, "User registration error"); + }); + }).catch((e) => { + displayError(e, "Sign in error"); + }); + }; + + function onFinish() { + setSaveQrDone(true); + refreshOnboardingState(); + } + + return ( + + + + {t('login.make-sure-save-your-opcode')} + + + {t('login.cannot-retrieve')} + + + + + + {pendingOnboardingState.opcode} + + + + + + + + ); +} + +const s = StyleSheet.create({ + opcodeText: { + fontFamily: 'monospace', + marginVertical: 8, + maxWidth: '100%', + wordBreak: 'break-all', + }, +}); + +export default SaveQrPage; diff --git a/www/js/onboarding/StudySummary.tsx b/www/js/onboarding/StudySummary.tsx new file mode 100644 index 000000000..02a1797b4 --- /dev/null +++ b/www/js/onboarding/StudySummary.tsx @@ -0,0 +1,47 @@ +import React, { useMemo } from "react"; +import { View, StyleSheet } from "react-native"; +import { Text } from "react-native-paper"; +import { useTranslation } from "react-i18next"; +import useAppConfig from "../useAppConfig"; + +export function getTemplateText(configObject, lang) { + if (configObject && (configObject.name)) { + return configObject.intro.translated_text[lang]; + } +} + +const StudySummary = () => { + + const { i18n } = useTranslation(); + const appConfig = useAppConfig(); + + const templateText = useMemo(() => getTemplateText(appConfig, i18n.language), [appConfig]); + + return (<> + {templateText?.deployment_name} + {appConfig?.intro?.deployment_partner_name + " " + templateText?.deployment_name} + + {"✔️ " + templateText?.summary_line_1} + {"✔️ " + templateText?.summary_line_2} + {"✔️ " + templateText?.summary_line_3} + + ) +}; + +const styles = StyleSheet.create({ + title: { + fontWeight: "bold", + fontSize: 22, + paddingBottom: 10, + textAlign: "center" + }, + text: { + fontSize: 14, + }, + studyName: { + fontWeight: "bold", + fontSize: 16 + }, +}); + +export default StudySummary; diff --git a/www/js/onboarding/SummaryPage.tsx b/www/js/onboarding/SummaryPage.tsx new file mode 100644 index 000000000..90e8d858e --- /dev/null +++ b/www/js/onboarding/SummaryPage.tsx @@ -0,0 +1,34 @@ +import React, { useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import { View, ScrollView } from 'react-native'; +import { Button, Surface } from 'react-native-paper'; +import { AppContext } from '../App'; +import { onboardingStyles } from './OnboardingStack'; +import StudySummary from './StudySummary'; +import { setSummaryDone } from './onboardingHelper'; + +const SummaryPage = () => { + + const { t } = useTranslation(); + const context = useContext(AppContext); + const { refreshOnboardingState } = context; + + function next() { + setSummaryDone(true); + refreshOnboardingState(); + }; + + // summary of the study, followed by 'next' button + return (<> + + + + + + + + + ); +} + +export default SummaryPage; diff --git a/www/js/onboarding/SurveyPage.tsx b/www/js/onboarding/SurveyPage.tsx new file mode 100644 index 000000000..11e58c94a --- /dev/null +++ b/www/js/onboarding/SurveyPage.tsx @@ -0,0 +1,95 @@ +import React, { useState, useEffect, useContext, useMemo } from "react"; +import { View, StyleSheet } from "react-native"; +import { ActivityIndicator, Button, Surface, Text } from "react-native-paper"; +import EnketoModal from "../survey/enketo/EnketoModal"; +import { DEMOGRAPHIC_SURVEY_DATAKEY, DEMOGRAPHIC_SURVEY_NAME } from "../control/DemographicsSettingRow"; +import { loadPreviousResponseForSurvey } from "../survey/enketo/enketoHelper"; +import { AppContext } from "../App"; +import { markIntroDone } from "./onboardingHelper"; +import { useTranslation } from "react-i18next"; +import { DateTime } from "luxon"; +import { onboardingStyles } from "./OnboardingStack"; + +let preloadedResponsePromise: Promise = null; +export const preloadDemoSurveyResponse = () => { + if (!preloadedResponsePromise) { + preloadedResponsePromise = loadPreviousResponseForSurvey(DEMOGRAPHIC_SURVEY_DATAKEY); + } + return preloadedResponsePromise; +} + +const SurveyPage = () => { + + const { t } = useTranslation(); + const { refreshOnboardingState } = useContext(AppContext); + const [surveyModalVisible, setSurveyModalVisible] = useState(false); + const [prevSurveyResponse, setPrevSurveyResponse] = useState(null); + const prevSurveyResponseDate = useMemo(() => { + if (prevSurveyResponse) { + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(prevSurveyResponse, "text/xml"); + const surveyEndDt = xmlDoc.querySelector('end')?.textContent; // ISO datetime of survey completion + return DateTime.fromISO(surveyEndDt).toLocaleString(DateTime.DATE_FULL); + } + }, [prevSurveyResponse]); + + useEffect(() => { + /* If we came from the SaveQrPage, we should have already initiated loading the previous survey + response from there, and preloadDemographicsSurvey() will just return the promise that was + already started. + Otherwise, it will start a new promise. Either way, we wait for it to finish before proceeding. */ + preloadDemoSurveyResponse().then((lastSurvey) => { + if (lastSurvey?.data?.xmlResponse) { + setPrevSurveyResponse(lastSurvey.data.xmlResponse); + } else { + // if there is no prev response, we show the blank survey to be filled out for the first time + setSurveyModalVisible(true); + } + }); + }, []); + + function onFinish() { + setSurveyModalVisible(false); + markIntroDone(); + refreshOnboardingState(); + } + + return (<> + + {prevSurveyResponse ? + + + {t('survey.prev-survey-found')} + {prevSurveyResponseDate} + + + + + + + : + + + + {t('survey.loading-prior-survey')} + + + } + + setSurveyModalVisible(false)} + onResponseSaved={onFinish} surveyName={DEMOGRAPHIC_SURVEY_NAME} + opts={{ + /* If there is no prev response, we need an initial response from the user and should + not allow them to dismiss the modal by the "<- Dismiss" button */ + undismissable: !prevSurveyResponse, + prefilledSurveyResponse: prevSurveyResponse, + dataKey: DEMOGRAPHIC_SURVEY_DATAKEY, + }} /> + ); +}; + +export default SurveyPage; diff --git a/www/js/onboarding/WelcomePage.tsx b/www/js/onboarding/WelcomePage.tsx new file mode 100644 index 000000000..8e7e43425 --- /dev/null +++ b/www/js/onboarding/WelcomePage.tsx @@ -0,0 +1,202 @@ +import React, { useContext, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { View, Image, Modal, ScrollView, StyleSheet, ViewStyle, useWindowDimensions } from 'react-native'; +import { Button, Dialog, Divider, IconButton, Surface, Text, TextInput, TouchableRipple, useTheme } from 'react-native-paper'; +import color from 'color'; +import { initByUser } from '../config/dynamicConfig'; +import { AppContext } from '../App'; +import { displayError } from "../plugin/logger"; +import { onboardingStyles } from './OnboardingStack'; +import { Icon } from '../components/Icon'; + +const WelcomePage = () => { + + const { t } = useTranslation(); + const { colors } = useTheme(); + const { width: windowWidth } = useWindowDimensions(); + const context = useContext(AppContext); + const { refreshOnboardingState } = context; + const [pasteModalVis, setPasteModalVis] = useState(false); + const [infoPopupVis, setInfoPopupVis] = useState(false); + const [existingToken, setExistingToken] = useState(''); + + const scanCode = function() { + window.cordova.plugins.barcodeScanner.scan( + function (result) { + console.debug("scanned code", result); + if (result.format == "QR_CODE" && + result.cancelled == false) { + let text = result.text.split("=")[1]; + console.log("found code", text); + loginWithToken(text); + } else { + displayError(result.text, "invalid study reference") ; + } + }, + function (error) { + displayError(error, "Scanning failed: "); + }); + }; + + function loginWithToken(token) { + initByUser({token}).then((configUpdated) => { + if (configUpdated) { + setPasteModalVis(false); + refreshOnboardingState(); + } + }).catch(err => { + console.error('Error logging in with token', err); + setExistingToken(''); + }); + } + + return (<> + + + setInfoPopupVis(true)} /> + + + + + }} /> + + + {t('join.to-proceed-further')} + {t('join.code-hint')} + + + + + {t('join.scan-code')} + + {t('join.scan-hint')} + + + + setPasteModalVis(true)} icon='content-paste'> + {t('join.paste-code')} + + {t('join.paste-hint')} + + + + setPasteModalVis(false)}> + setPasteModalVis(false)}> + + + + + + + + setInfoPopupVis(false)}> + setInfoPopupVis(false)}> + + {t('join.about-app-title', {appName: t('join.app-name')})} + + + + {t('join.about-app-para-1')} + {t('join.about-app-para-2')} + {t('join.about-app-para-3')} + {t('join.tips-title')} + - {t('join.all-green-status')} + - {t('join.dont-force-kill')} + - {t('join.background-restrictions')} + + + + + + + + ); +} + +const s: any = StyleSheet.create({ + headerArea: ((windowWidth, colors) => ({ + width: windowWidth * 2.5, + height: windowWidth, + left: -windowWidth * .75, + borderBottomRightRadius: '50%', + borderBottomLeftRadius: '50%', + position: 'absolute', + top: windowWidth * -2/3, + backgroundColor: colors.primary, + boxShadow: `0 16px ${color(colors.primary).alpha(0.3).rgb().string()}`, + })) as ViewStyle, + appIconWrapper: ((colors): ViewStyle => ({ + marginTop: 20, + width: 200, + height: 200, + alignSelf: 'center', + backgroundColor: color(colors.onPrimary).darken(0.1).alpha(0.4).rgb().string(), + padding: 10, + borderRadius: 32, + })) as ViewStyle, + infoButton: { + position: 'absolute', + top: 10, + right: 10, + width: 40, + height: 40, + elevation: 2, + }, + appIcon: ((colors): ViewStyle => ({ + width: '100%', + height: '100%', + backgroundColor: colors.onPrimary, + borderRadius: 24, + })) as ViewStyle, + welcomeTitle: { + marginTop: 20, + textAlign: 'center', + paddingVertical: 20, + }, + buttonsSection: { + flexDirection: 'row', + justifyContent: 'center', + marginVertical: 20, + }, +}); + + +const WelcomePageButton = ({ onPress, icon, children }) => { + + const { colors } = useTheme(); + const { width: windowWidth } = useWindowDimensions(); + + return ( + + + + + {children} + + + + ); +} + +const welcomeButtonStyles: any = StyleSheet.create({ + btn: ((colors): ViewStyle => ({ + justifyContent: 'center', + alignItems: 'center', + backgroundColor: colors.primary, + borderRadius: 21, + padding: 20, + gap: 8, + })) as ViewStyle, + wrapper: ((colors): ViewStyle => ({ + borderRadius: 26, + padding: 5, + backgroundColor: color(colors.primary).alpha(0.4).rgb().string(), + })) as ViewStyle, +}); + +export default WelcomePage; diff --git a/www/js/onboarding/onboardingHelper.ts b/www/js/onboarding/onboardingHelper.ts new file mode 100644 index 000000000..7874ab9f8 --- /dev/null +++ b/www/js/onboarding/onboardingHelper.ts @@ -0,0 +1,61 @@ +import { DateTime } from "luxon"; +import { getAngularService } from "../angular-react-helper"; +import { getConfig } from "../config/dynamicConfig"; + +export const INTRO_DONE_KEY = 'intro_done'; + +// state = null if onboarding is done +// route = WELCOME if no config present +// route = SUMMARY if config present, but not consented and summary not done +// route = CONSENT if config present, but not consented and summary done +// route = SAVE_QR if config present, consented, but save qr not done +// route = SURVEY if config present, consented and save qr done +export enum OnboardingRoute { WELCOME, SUMMARY, CONSENT, SAVE_QR, SURVEY, NONE }; +export type OnboardingState = { + opcode: string, + route: OnboardingRoute, +} + +export let summaryDone = false; +export const setSummaryDone = (b) => summaryDone = b; + +export let saveQrDone = false; +export const setSaveQrDone = (b) => saveQrDone = b; + +export let registerUserDone = false; +export const setRegisterUserDone = (b) => registerUserDone = b; + +export function getPendingOnboardingState(): Promise { + return Promise.all([getConfig(), readConsented(), readIntroDone()]).then(([config, isConsented, isIntroDone]) => { + if (isIntroDone) return null; // onboarding is done; no pending state + let route: OnboardingRoute = OnboardingRoute.NONE; + if (!config) { + route = OnboardingRoute.WELCOME; + } else if (!isConsented && !summaryDone) { + route = OnboardingRoute.SUMMARY; + } else if (!isConsented) { + route = OnboardingRoute.CONSENT; + } else if (!saveQrDone) { + route = OnboardingRoute.SAVE_QR; + } else { + route = OnboardingRoute.SURVEY; + } + return { route, opcode: config?.joined?.opcode }; + }); +}; + +async function readConsented() { + const StartPrefs = getAngularService('StartPrefs'); + return StartPrefs.readConsentState().then(StartPrefs.isConsented) as Promise; +} + +async function readIntroDone() { + const KVStore = getAngularService('KVStore'); + return KVStore.get(INTRO_DONE_KEY).then((read_val) => !!read_val) as Promise; +} + +export async function markIntroDone() { + const currDateTime = DateTime.now().toISO(); + const KVStore = getAngularService('KVStore'); + return KVStore.set(INTRO_DONE_KEY, currDateTime); +} diff --git a/www/js/recent.js b/www/js/recent.js deleted file mode 100644 index 5fbfbf66c..000000000 --- a/www/js/recent.js +++ /dev/null @@ -1,157 +0,0 @@ -angular.module('emission.main.recent', ['emission.services']) - -.controller('logCtrl', function(ControlHelper, $scope, EmailHelper) { - console.log("Launching logCtr"); - var RETRIEVE_COUNT = 100; - $scope.logCtrl = {}; - - $scope.refreshEntries = function() { - window.Logger.getMaxIndex().then(function(maxIndex) { - console.log("maxIndex = "+maxIndex); - $scope.logCtrl.currentStart = maxIndex; - $scope.logCtrl.gotMaxIndex = true; - $scope.logCtrl.reachedEnd = false; - $scope.entries = []; - $scope.addEntries(); - }, function (e) { - var errStr = "While getting max index "+JSON.stringify(e, null, 2); - console.log(errStr); - alert(errStr); - }); - } - - $scope.moreDataCanBeLoaded = function() { - return $scope.logCtrl.gotMaxIndex && !($scope.logCtrl.reachedEnd); - } - - $scope.clear = function() { - window.Logger.clearAll(); - window.Logger.log(window.Logger.LEVEL_INFO, "Finished clearing entries from unified log"); - $scope.refreshEntries(); - } - - $scope.addEntries = function() { - console.log("calling addEntries"); - window.Logger.getMessagesFromIndex($scope.logCtrl.currentStart, RETRIEVE_COUNT) - .then(function(entryList) { - $scope.$apply($scope.processEntries(entryList)); - console.log("entry list size = "+$scope.entries.length); - console.log("Broadcasting infinite scroll complete"); - $scope.$broadcast('scroll.infiniteScrollComplete') - }, function(e) { - var errStr = "While getting messages from the log "+JSON.stringify(e, null, 2); - console.log(errStr); - alert(errStr); - $scope.$broadcast('scroll.infiniteScrollComplete') - } - ) - } - - $scope.processEntries = function(entryList) { - for (let i = 0; i < entryList.length; i++) { - var currEntry = entryList[i]; - currEntry.fmt_time = moment.unix(currEntry.ts).format("llll"); - $scope.entries.push(currEntry); - } - if (entryList.length == 0) { - console.log("Reached the end of the scrolling"); - $scope.logCtrl.reachedEnd = true; - } else { - $scope.logCtrl.currentStart = entryList[entryList.length-1].ID - console.log("new start index = "+$scope.logCtrl.currentStart); - } - } - - $scope.emailLog = function () { - EmailHelper.sendEmail("loggerDB"); - } - - $scope.refreshEntries(); -}) - -.controller('sensedDataCtrl', function($scope, $ionicActionSheet, EmailHelper) { - var currentStart = 0; - - /* Let's keep a reference to the database for convenience */ - var db = window.cordova.plugins.BEMUserCache; - - $scope.config = {} - $scope.config.key_data_mapping = { - "Transitions": { - fn: db.getAllMessages, - key: "statemachine/transition" - }, - "Locations": { - fn: db.getAllSensorData, - key: "background/location" - }, - "Motion Type": { - fn: db.getAllSensorData, - key: "background/motion_activity" - }, - } - - $scope.emailCache = function () { - EmailHelper.sendEmail("userCacheDB"); - } - - $scope.config.keys = [] - for (let key in $scope.config.key_data_mapping) { - $scope.config.keys.push(key); - } - - $scope.selected = {} - $scope.selected.key = $scope.config.keys[0] - - $scope.changeSelection = function() { - $ionicActionSheet.show({ - buttons: [ - { text: 'Locations' }, - { text: 'Motion Type' }, - { text: 'Transitions' }, - ], - buttonClicked: function(index, button) { - $scope.setSelected(button.text); - return true; - } - }); - } - - $scope.setSelected = function(newVal) { - $scope.selected.key = newVal; - $scope.updateEntries(); - } - - $scope.updateEntries = function() { - let usercacheFn, usercacheKey; - if (angular.isUndefined($scope.selected.key)) { - usercacheFn = db.getAllMessages; - usercacheKey = "statemachine/transition"; - } else { - usercacheFn = $scope.config.key_data_mapping[$scope.selected.key]["fn"] - usercacheKey = $scope.config.key_data_mapping[$scope.selected.key]["key"] - } - usercacheFn(usercacheKey, true).then(function(entryList) { - $scope.entries = []; - $scope.$apply(function() { - for (let i = 0; i < entryList.length; i++) { - // $scope.entries.push({metadata: {write_ts: 1, write_fmt_time: "1"}, data: "1"}) - var currEntry = entryList[i]; - currEntry.metadata.write_fmt_time = moment.unix(currEntry.metadata.write_ts) - .tz(currEntry.metadata.time_zone) - .format("llll"); - currEntry.data = JSON.stringify(currEntry.data, null, 2); - // window.Logger.log(window.Logger.LEVEL_DEBUG, - // "currEntry.data = "+currEntry.data); - $scope.entries.push(currEntry); - } - }) - // This should really be within a try/catch/finally block - $scope.$broadcast('scroll.refreshComplete'); - }, function(error) { - window.Logger.log(window.Logger.LEVEL_ERROR, "Error updating entries"+ error); - }) - } - - $scope.updateEntries(); -}) diff --git a/www/js/splash/notifScheduler.js b/www/js/splash/notifScheduler.js index 069af7a18..821b6fb09 100644 --- a/www/js/splash/notifScheduler.js +++ b/www/js/splash/notifScheduler.js @@ -1,15 +1,15 @@ 'use strict'; import angular from 'angular'; +import { getConfig } from '../config/dynamicConfig'; angular.module('emission.splash.notifscheduler', ['emission.services', 'emission.plugin.logger', - 'emission.stats.clientstats', - 'emission.config.dynamic']) + 'emission.stats.clientstats']) .factory('NotificationScheduler', function($http, $window, $ionicPlatform, - ClientStats, DynamicConfig, CommHelper, Logger) { + ClientStats, CommHelper, Logger) { const scheduler = {}; let _config; @@ -258,7 +258,7 @@ angular.module('emission.splash.notifscheduler', } $ionicPlatform.ready().then(async () => { - _config = await DynamicConfig.configReady(); + _config = await getConfig(); if (!_config.reminderSchemes) { Logger.log("No reminder schemes found in config, not scheduling notifications"); return; diff --git a/www/js/splash/startprefs.js b/www/js/splash/startprefs.js index 223c82579..e535d179a 100644 --- a/www/js/splash/startprefs.js +++ b/www/js/splash/startprefs.js @@ -1,14 +1,13 @@ import angular from 'angular'; +import { getConfig } from '../config/dynamicConfig'; angular.module('emission.splash.startprefs', ['emission.plugin.logger', 'emission.splash.referral', - 'emission.plugin.kvstore', - 'emission.config.dynamic']) + 'emission.plugin.kvstore']) .factory('StartPrefs', function($window, $state, $interval, $rootScope, $ionicPlatform, - $ionicPopup, KVStore, $http, Logger, ReferralHandler, DynamicConfig) { + $ionicPopup, KVStore, $http, Logger, ReferralHandler) { var logger = Logger; - var nTimesCalled = 0; var startprefs = {}; // Boolean: represents that the "intro" - the one page summary // and the login are done @@ -95,7 +94,7 @@ angular.module('emission.splash.startprefs', ['emission.plugin.logger', } startprefs.readConfig = function() { - return DynamicConfig.loadSavedConfig().then((savedConfig) => $rootScope.app_ui_label = savedConfig); + return getConfig().then((savedConfig) => $rootScope.app_ui_label = savedConfig); } startprefs.hasConfig = function() { @@ -112,33 +111,6 @@ angular.module('emission.splash.startprefs', ['emission.plugin.logger', } } - /* - * getNextState() returns a promise, since reading the startupConfig is - * async. The promise returns an onboarding state to navigate to, or - * null for the default state - */ - - startprefs.getPendingOnboardingState = function() { - return startprefs.readStartupState().then(function([is_intro_done, is_consented, has_config]) { - if (!has_config) { - console.assert(!$rootScope.has_config, "in getPendingOnboardingState first check, $rootScope.has_config", JSON.stringify($rootScope.has_config)); - return 'root.join'; - } else if (!is_intro_done) { - console.assert(!$rootScope.intro_done, "in getPendingOnboardingState second check, $rootScope.intro_done", JSON.stringify($rootScope.intro_done)); - return 'root.intro'; - } else { - // intro is done. Now let's check consent - console.assert(is_intro_done, "in getPendingOnboardingState, local is_intro_done", is_intro_done); - console.assert($rootScope.is_intro_done, "in getPendingOnboardingState, $rootScope.intro_done", $rootScope.intro_done); - if (is_consented) { - return null; - } else { - return 'root.reconsent'; - } - } - }); - }; - /* * Read the intro_done and consent_done variables into the $rootScope so that * we can use them without making multiple native calls @@ -179,28 +151,6 @@ angular.module('emission.splash.startprefs', ['emission.plugin.logger', }); } - startprefs.getNextState = function() { - return startprefs.getPendingOnboardingState().then(function(result){ - if (result == null) { - if (angular.isDefined($rootScope.redirectTo)) { - var redirState = $rootScope.redirectTo; - var redirParams = $rootScope.redirectParams; - $rootScope.redirectTo = undefined; - $rootScope.redirectParams = undefined; - return {state: redirState, params: redirParams}; - } else { - return {state: 'root.main.inf_scroll', params: {}}; - } - } else { - return {state: result, params: {}}; - } - }) - .catch((err) => { - Logger.displayError("error getting next state", err); - return "root.intro"; - }); - }; - var changeState = function(destState) { logger.log('changing state to '+destState); console.log("loading "+destState); @@ -217,39 +167,5 @@ angular.module('emission.splash.startprefs', ['emission.plugin.logger', }); }; - // Currently loads main or intro based on whether onboarding is complete. - // But easily extensible to storing the last screen that the user was on, - // or the users' preferred screen - - startprefs.loadPreferredScreen = function() { - logger.log("About to navigate to preferred tab"); - startprefs.getNextState().then(changeState).catch(function(error) { - logger.displayError("Error loading preferred tab, loading root.intro", error); - // logger.log("error "+error+" loading finding tab, loading root.intro"); - changeState('root.intro'); - }); - }; - - startprefs.loadWithPrefs = function() { - // alert("attach debugger!"); - console.log("Checking to see whether we are ready to load the screen"); - if (!angular.isDefined($window.Logger)) { - alert("ionic is ready, but logger not present?"); - } - logger = Logger; - startprefs.loadPreferredScreen(); - }; - - startprefs.startWithPrefs = function() { - startprefs.loadWithPrefs(); - } - - $ionicPlatform.ready().then(function() { - Logger.log("ionicPlatform.ready() called " + nTimesCalled+" times!"); - nTimesCalled = nTimesCalled + 1; - startprefs.startWithPrefs(); - Logger.log("startprefs startup done"); - }); - return startprefs; }); diff --git a/www/js/survey/enketo/EnketoModal.tsx b/www/js/survey/enketo/EnketoModal.tsx index b4bf8f024..8b80b6dfe 100644 --- a/www/js/survey/enketo/EnketoModal.tsx +++ b/www/js/survey/enketo/EnketoModal.tsx @@ -21,7 +21,7 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest } : Props) => const headerEl = useRef(null); const surveyJson = useRef(null); const enketoForm = useRef
(null); - const { appConfig, loading } = useAppConfig(); + const appConfig = useAppConfig(); async function fetchSurveyJson(url) { const responseText = await fetchUrlCached(url); @@ -76,9 +76,9 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest } : Props) => useEffect(() => { if (!rest.visible) return; - if (!appConfig || loading) return console.error('App config not loaded yet'); + if (!appConfig) return console.error('App config not loaded yet'); initSurvey(); - }, [appConfig, loading, rest.visible]); + }, [appConfig, rest.visible]); /* adapted from the template given by enketo-core: https://github.com/enketo/enketo-core/blob/master/src/index.html */ @@ -89,11 +89,13 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest } : Props) => Just make sure to keep a .form-language-selector element into which the form language selector ( -
-
cm
-
ft
-
- -
{{'user-weight'}}
-
- -
-
kg
-
lb
-
-
-
{{'user-age'}}
- diff --git a/www/templates/control/app-status-modal.html b/www/templates/control/app-status-modal.html deleted file mode 100644 index 965b2857d..000000000 --- a/www/templates/control/app-status-modal.html +++ /dev/null @@ -1,15 +0,0 @@ - - -

Permissions

-
- - - -
diff --git a/www/templates/control/main-consent.html b/www/templates/control/main-consent.html deleted file mode 100644 index f991eab7f..000000000 --- a/www/templates/control/main-consent.html +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/www/templates/control/qrc.html b/www/templates/control/qrc.html deleted file mode 100644 index 3d189cece..000000000 --- a/www/templates/control/qrc.html +++ /dev/null @@ -1,28 +0,0 @@ - - - -
-
-
-

{{'general-settings.qrcode'}}

-
-
-
-
- -
-

-
-
- -
-
-

-
-
- -
-
- - -
diff --git a/www/templates/intro/changes.html b/www/templates/intro/changes.html deleted file mode 100644 index 686076b52..000000000 --- a/www/templates/intro/changes.html +++ /dev/null @@ -1,21 +0,0 @@ - - -
-

E-Mission: Data driven carbon emission reduction

-
- -
- Changes between the previously approved protocol and the current one are: -
-
    -
  1. Switch from moves to our own data collection
  2. -
  3. Define policies when used as a platform for an external study
  4. -
  5. Specify policies for time-delayed access of datasets
  6. -
-
- -
diff --git a/www/templates/intro/consent-text.html b/www/templates/intro/consent-text.html deleted file mode 100644 index fa4c2f6d9..000000000 --- a/www/templates/intro/consent-text.html +++ /dev/null @@ -1,136 +0,0 @@ - -
diff --git a/www/templates/intro/consent.html b/www/templates/intro/consent.html deleted file mode 100644 index 3c33eeca3..000000000 --- a/www/templates/intro/consent.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - diff --git a/www/templates/intro/intro.html b/www/templates/intro/intro.html deleted file mode 100644 index eaa806b6d..000000000 --- a/www/templates/intro/intro.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/www/templates/intro/reconsent.html b/www/templates/intro/reconsent.html deleted file mode 100644 index b7adfe168..000000000 --- a/www/templates/intro/reconsent.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/www/templates/intro/saveTokenFile.html b/www/templates/intro/saveTokenFile.html deleted file mode 100644 index b1bbd9d51..000000000 --- a/www/templates/intro/saveTokenFile.html +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - diff --git a/www/templates/intro/sensor_explanation.html b/www/templates/intro/sensor_explanation.html deleted file mode 100644 index 9f1d725ab..000000000 --- a/www/templates/intro/sensor_explanation.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - diff --git a/www/templates/intro/summary.html b/www/templates/intro/summary.html deleted file mode 100644 index fcff5f5d2..000000000 --- a/www/templates/intro/summary.html +++ /dev/null @@ -1,24 +0,0 @@ - - -
-

{{template_text.deployment_name}}

-
- -
- The {{ui_config.intro.deployment_partner_name}} {{template_text.deployment_name}}: -
-
    -
  1. ✔️ {{template_text.summary_line_1}} -
    -
  2. ✔️ {{template_text.summary_line_2}} -
    -
  3. ✔️ {{template_text.summary_line_3}} -
-
-
- - - - diff --git a/www/templates/intro/survey.html b/www/templates/intro/survey.html deleted file mode 100644 index 14416ee75..000000000 --- a/www/templates/intro/survey.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/www/templates/join/about-app.html b/www/templates/join/about-app.html deleted file mode 100644 index 63ab20ba1..000000000 --- a/www/templates/join/about-app.html +++ /dev/null @@ -1,42 +0,0 @@ - - -
{{'join.about-app-para-1'}}
- -
{{'join.about-app-para-2'}}
- -
{{'join.about-app-para-3'}}
- -
- {{'join.tips-title'}} -
- - - - {{'join.all-green-status'}} - - - - {{'join.dont-force-kill'}} - - -
-
- -
- - {{'join.all-green-status'}} - -
- - {{'join.background-restrictions'}} - - -
-
- - - - - diff --git a/www/templates/join/request_join.html b/www/templates/join/request_join.html deleted file mode 100644 index 6afd6940d..000000000 --- a/www/templates/join/request_join.html +++ /dev/null @@ -1,39 +0,0 @@ - - - -
-
NREL OpenPATH icon
-
-
-
-

{{'join.proceed-further'}}

- -

{{'join.what-is-opcode'}}

- -
- - - -
- - {{'join.scan-details'}} -
-
- -
- {{'join.or'}} -
-
- -
- - {{'join.paste-details'}} -
-
-
-
-
-
-
diff --git a/www/templates/main-metrics.html b/www/templates/main-metrics.html deleted file mode 100644 index d3c8b7ce3..000000000 --- a/www/templates/main-metrics.html +++ /dev/null @@ -1,225 +0,0 @@ - - - - - - - - - - -
-
-
{{'main-metrics.summary'}}
-
{{'main-metrics.chart'}}
-
-
-
-
{{'main-metrics.change-data'}}
-
-
-
-
{{ selectCtrl.fromDateTimestamp.format('ll') }} ➡️ {{ selectCtrl.toDateTimestamp.format('ll') }}
-
-
-
-
- - - -
-
-
-
-
{{'main-metrics.distance'}}
-
{{'main-metrics.trips'}}
-
{{'main-metrics.duration'}}
-
{{'main-metrics.speed'}}
-
-
-
-
- - - - -
-
-
-
-
-
-
-
-

{{'main-metrics.footprint'}}

-
-
-
kg CO₂
-
{{ 'main-metrics.label-to-squish' | i18next }}
- -
-
-
-
{{'main-metrics.how-it-compares'}}
- -
{{'main-metrics.average'}} kg CO₂
-
{{'main-metrics.avoided'}} kg CO₂
-
{{'main-metrics.lastweek'}} kg CO₂
- -
{{'main-metrics.us-2030-goal'}} {{carbonData.us2030 | number}} kg CO₂
-
{{'main-metrics.us-2050-goal'}} {{carbonData.us2050 | number}} kg CO₂
-
-
-
- -
-
-
-
-
- -
-
- -
-
- -
-

{{'main-metrics.calories'}}

-
- -
-
-
kcal
-
{{'main-metrics.equals-cookies' | i18next:{count: numberOfCookies.low} }}
-
{{'main-metrics.equals-icecream' | i18next:{count: numberOfIceCreams.low} }}
-
{{'main-metrics.equals-bananas' | i18next:{count: numberOfBananas.low} }}
- -
-
-
-
{{'main-metrics.average'}} cal
-
{{'main-metrics.lastweek'}} cal
-
-
- -
-
- -
-
- -
-
-
-
- -
-
- - -
-

{{'main-metrics.distance'}}

-
{{'main-metrics.no-summary-data'}}
-
-
- -
- -
-
-
- {{ summaryData.defaultSummary.distance[dIndex + i].key }} -
-
- {{ formatDistance(summaryData.defaultSummary.distance[dIndex + i].values).slice(-1)[0] }} -
-
-
-
-
-
- -
-

{{'main-metrics.trips'}}

-
{{'main-metrics.no-summary-data'}}
-
-
- -
- -
-
-
- {{ summaryData.defaultSummary.count[dIndex + i].key }} -
-
- {{ formatCount(summaryData.defaultSummary.count[dIndex + i].values).slice(-1)[0] }} -
-
-
-
-
-
- -
-

{{'main-metrics.duration'}}

-
{{'main-metrics.no-summary-data'}}
-
-
- -
- -
-
-
- {{ summaryData.defaultSummary.duration[dIndex + i].key }} -
-
- {{ formatDuration(summaryData.defaultSummary.duration[dIndex + i].values).slice(-1)[0] }} -
-
-
-
-
-
- -
-

{{'main-metrics.mean-speed'}}

-
{{'main-metrics.no-summary-data'}}
-
-
- -
- -
-
-
- {{ summaryData.defaultSummary.mean_speed[dIndex + i].key }} -
-
- {{ formatMeanSpeed(summaryData.defaultSummary.mean_speed[dIndex + i].values).slice(-1)[0] }} -
-
-
-
-
-
-
-
-
-
-
diff --git a/www/templates/main.html b/www/templates/main.html deleted file mode 100644 index c3de4adcb..000000000 --- a/www/templates/main.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - diff --git a/www/templates/metrics/arrow-greater-lesser.html b/www/templates/metrics/arrow-greater-lesser.html deleted file mode 100644 index bdb0cf940..000000000 --- a/www/templates/metrics/arrow-greater-lesser.html +++ /dev/null @@ -1,38 +0,0 @@ - - -
- -
-
-
{{ change.low | number:0 }}% {{'metrics.greater-than' | i18next }} {{ 'metrics.last-week' | i18next }}
-
-
-
-
{{ (-1) * change.low | number:0 }}% {{'metrics.less-than' | i18next }} {{ 'metrics.last-week' | i18next }}
-
-
-
- -
-
-
-
-
=
-
{{ (-1) * change.low | number:0 }}% {{'metrics.less' | i18next }}
-
{{ change.low | number:0 }}% {{'metrics.greater' | i18next }}
-
-
-
{{'metrics.or' | i18next }}
-
{{'metrics.week-before' | i18next }}
-
-
-
-
-
=
-
{{ (-1) * change.high | number:0 }}% {{'metrics.less' | i18next }}
-
{{ change.high | number:0 }}% {{'metrics.greater' | i18next }}
-
-
-
diff --git a/www/templates/metrics/metrics-control.html b/www/templates/metrics/metrics-control.html deleted file mode 100644 index 6d2be64dc..000000000 --- a/www/templates/metrics/metrics-control.html +++ /dev/null @@ -1,80 +0,0 @@ - - - - - - -
-
-
{{ uictrl.currentString }}
- -
-
-
-
-
-
-
{{'metrics.from'}}
-
-
- -
-
-
- - -
-
-
-
{{'metrics.to'}} -
-
-
-
-
-
-
-
{{'metrics.frequency'}}
-
-
-
{{selectCtrl.pandaFreqString}}
-
-
- - - -
-
diff --git a/www/templates/metrics/range-display.html b/www/templates/metrics/range-display.html deleted file mode 100644 index fa8c3581e..000000000 --- a/www/templates/metrics/range-display.html +++ /dev/null @@ -1,2 +0,0 @@ -{{ lowFmt }} -{{ lowFmt }} - {{ highFmt }} diff --git a/www/templates/recent/log.html b/www/templates/recent/log.html deleted file mode 100644 index 455294705..000000000 --- a/www/templates/recent/log.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - -
- - - -
- - -
{{entry.fmt_time}}
-
{{entry.ID}} | {{entry.level}} | {{entry.message}}
-
- -
-
-
diff --git a/www/templates/recent/sensedData.html b/www/templates/recent/sensedData.html deleted file mode 100644 index 5f631635f..000000000 --- a/www/templates/recent/sensedData.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - -
- - -
- - -
{{entry.metadata.write_fmt_time}}
-
{{entry.data}}
-
-
-
-
diff --git a/www/templates/splash/splash.html b/www/templates/splash/splash.html deleted file mode 100644 index 901f6359c..000000000 --- a/www/templates/splash/splash.html +++ /dev/null @@ -1,7 +0,0 @@ - - -
- -
-
-
diff --git a/www/templates/survey/enketo/demographics-button.html b/www/templates/survey/enketo/demographics-button.html deleted file mode 100644 index 4396410e2..000000000 --- a/www/templates/survey/enketo/demographics-button.html +++ /dev/null @@ -1,6 +0,0 @@ -
-
{{'control.edit-demographics'}}
- -
diff --git a/www/templates/survey/enketo/form-base.html b/www/templates/survey/enketo/form-base.html deleted file mode 100644 index bd933cc4a..000000000 --- a/www/templates/survey/enketo/form-base.html +++ /dev/null @@ -1,43 +0,0 @@ -
- -
diff --git a/www/templates/survey/enketo/inline.html b/www/templates/survey/enketo/inline.html deleted file mode 100644 index 34b61e282..000000000 --- a/www/templates/survey/enketo/inline.html +++ /dev/null @@ -1,40 +0,0 @@ - - -
{{'survey.loading-prior-survey'}}
-
-
- -
- -
{{'survey.prev-survey-found'}}
-
-
- -
-
- -
-
- -
-
- - -
-
- -
- -
-
-
-
- - -
-
-
diff --git a/www/templates/survey/enketo/modal.html b/www/templates/survey/enketo/modal.html deleted file mode 100644 index cc9c6c02d..000000000 --- a/www/templates/survey/enketo/modal.html +++ /dev/null @@ -1,13 +0,0 @@ - - -

{{'survey.survey'}}

- -
- - - -
diff --git a/www/templates/survey/enketo/preview.html b/www/templates/survey/enketo/preview.html deleted file mode 100644 index 0a51a4141..000000000 --- a/www/templates/survey/enketo/preview.html +++ /dev/null @@ -1,6 +0,0 @@ - From 608d97ddaa9447076259905374a933e61d34146b Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 13 Oct 2023 09:29:04 -0600 Subject: [PATCH 110/850] update messageformat plugin the message format plugin moved! https://github.com/messageformat/messageformat/tree/main/packages/core --- package.cordovabuild.json | 2 +- package.serve.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.cordovabuild.json b/package.cordovabuild.json index b5d69872f..306362726 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -102,6 +102,7 @@ }, "dependencies": { "@havesource/cordova-plugin-push": "git+https://github.com/havesource/cordova-plugin-push.git#4.0.0-dev.0", + "@messageformat/core": "^3.2.0", "@react-navigation/native": "^6.1.7", "@react-navigation/stack": "^6.3.17", "@shopify/flash-list": "^1.3.1", @@ -151,7 +152,6 @@ "klaw-sync": "^6.0.0", "leaflet": "^1.9.4", "luxon": "^3.3.0", - "messageformat": "^2.3.0", "moment": "^2.29.4", "moment-timezone": "^0.5.43", "ng-i18next": "^1.0.7", diff --git a/package.serve.json b/package.serve.json index 57470bc2d..c5ad26404 100644 --- a/package.serve.json +++ b/package.serve.json @@ -49,6 +49,7 @@ "webpack-cli": "^5.0.1" }, "dependencies": { + "@messageformat/core": "^3.2.0", "@react-navigation/native": "^6.1.7", "@react-navigation/stack": "^6.3.17", "@shopify/flash-list": "^1.3.1", @@ -78,7 +79,6 @@ "klaw-sync": "^6.0.0", "leaflet": "^1.9.4", "luxon": "^3.3.0", - "messageformat": "^2.3.0", "moment": "^2.29.4", "moment-timezone": "^0.5.43", "ng-i18next": "^1.0.7", From 8b901f6294c387bf69b08f6ec3eb3ec3cde5b494 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 13 Oct 2023 10:12:46 -0600 Subject: [PATCH 111/850] start to configure i18next for tests setting __DEV__ to false in globals, so that it can be used throught the testing suit calling the i18n setup in the tests should work once we incorporate the React testing changes --- jest.config.json | 3 +++ www/__tests__/enketoHelper.test.ts | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/jest.config.json b/jest.config.json index 8d194ffd0..21ba92e12 100644 --- a/jest.config.json +++ b/jest.config.json @@ -18,5 +18,8 @@ ], "moduleNameMapper": { "^react-native$": "react-native-web" + }, + "globals" : { + "__DEV__": true } } diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index 96475bd58..367cfd8bc 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -2,7 +2,8 @@ import { getInstanceStr, filterByNameAndVersion, resolveTimestamps, resolveLabel import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; -import i18next from "i18next"; +// import initializedI18next from '../js/i18nextInit'; +// window['i18next'] = initializedI18next; mockBEMUserCache(); mockLogger(); From cea96dd3b3ea9030a98f6b17358be6a83586ddff Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 13 Oct 2023 10:13:14 -0600 Subject: [PATCH 112/850] update the message format import --- www/js/survey/enketo/enketoHelper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index f72921582..fa7d300e3 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -2,7 +2,7 @@ import { getAngularService } from "../../angular-react-helper"; import { Form } from 'enketo-core'; import { XMLParser } from 'fast-xml-parser'; import i18next from 'i18next'; -import MessageFormat from 'messageformat'; +import MessageFormat from '@messageformat/core'; import { logDebug, logInfo } from "../../plugin/logger"; import { getConfig } from '../../config/dynamicConfig'; import { DateTime } from "luxon"; From b7f6d68d5385f87ce0066aeabfc5fec831afa78c Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 13 Oct 2023 10:16:31 -0600 Subject: [PATCH 113/850] updates to testing --- www/__tests__/enketoHelper.test.ts | 40 ++++++++++++------------------ 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index 367cfd8bc..ab3ef963e 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -68,28 +68,20 @@ it('resolves the timestamps', () => { }); //resolve label -// it('resolves the label', async () => { -// i18next.init({ -// fallbackLng: 'en', -// debug: true -// }, (err, t) => { -// if (err) return console.log('something went wrong loading', err); -// t('key'); // -> same as i18next.t -// }); - -// console.log("language in tests", i18next.resolvedLanguage); -// const xmlParser = new window.DOMParser(); -// //have a custom survey label function TODO: we currently don't have custome label functions, but should test when we do - -// //no custom function, fallback to UseLabelTemplate -// const xmlString = ' option_1/Domestic_activities> option_2 '; -// const xmlDoc = xmlParser.parseFromString(xmlString, 'text/html'); +it('resolves the label', async () => { + const xmlParser = new window.DOMParser(); -// //if no template, returns "Answered" -// expect(await resolveLabel("TimeUseSurvey", xmlDoc)).toBe(""); -// //if no labelVars, returns template -// //else interpolates -// }); + //have a custom survey label function TODO: we currently don't have custome label functions, but should test when we do + + //no custom function, fallback to UseLabelTemplate + const xmlString = ' option_1/Domestic_activities> option_2 '; + const xmlDoc = xmlParser.parseFromString(xmlString, 'text/html'); + + //if no template, returns "Answered" + expect(await resolveLabel("TimeUseSurvey", xmlDoc)).toBe(""); + //if no labelVars, returns template + //else interpolates +}); /** * @param surveyName the name of the survey (e.g. "TimeUseSurvey") @@ -136,7 +128,7 @@ it('filters the survey answers by their name and version', () => { xmlResponse: "", //survey answer XML string jsonDocResponse: "this is my json object" //survey answer JSON object }, - labels: {labelField: "goodbye"} //TODO learn more about answer type + labels: [{labelField: "goodbye"}] //TODO learn more about answer type } ]; @@ -154,7 +146,7 @@ it('filters the survey answers by their name and version', () => { xmlResponse: "", //survey answer XML string jsonDocResponse: "this is my json object" //survey answer JSON object }, - labels: {labelField: "goodbye"} + labels: [{labelField: "goodbye"}] }, { data: { @@ -166,7 +158,7 @@ it('filters the survey answers by their name and version', () => { xmlResponse: "", //survey answer XML string jsonDocResponse: "this is my json object" //survey answer JSON object }, - labels: {labelField: "goodbye"} + labels: [{labelField: "goodbye"}] } ]; From 75a0371ae8de99b5cb80bcbe797c7371b3d4255f Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 13 Oct 2023 10:48:46 -0600 Subject: [PATCH 114/850] updates to tests remove old i18n code, update types, comment out broken tests --- www/__tests__/enketoHelper.test.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index ab3ef963e..5603a010e 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -7,10 +7,6 @@ import { mockLogger } from '../__mocks__/globalMocks'; mockBEMUserCache(); mockLogger(); -// jest.mock('../__mocks__/messageFormatMocks'); -// jest.mock("i18next"); - -// global.i18next = { resolvedLanguage : "en" } it('gets the survey config', async () => { //this is aimed at testing my mock of the config @@ -78,7 +74,7 @@ it('resolves the label', async () => { const xmlDoc = xmlParser.parseFromString(xmlString, 'text/html'); //if no template, returns "Answered" - expect(await resolveLabel("TimeUseSurvey", xmlDoc)).toBe(""); + // expect(await resolveLabel("TimeUseSurvey", xmlDoc)).toBe(""); //if no labelVars, returns template //else interpolates }); @@ -105,7 +101,7 @@ it('gets the saved result or throws an error', () => { // export function loadPreviousResponseForSurvey(dataKey: string) { it('loads the previous response to a given survey', () => { //not really sure if I can test this yet given that it relies on an angular service... - loadPreviousResponseForSurvey("manual/demographic_survey"); + // loadPreviousResponseForSurvey("manual/demographic_survey"); }); /** @@ -128,7 +124,7 @@ it('filters the survey answers by their name and version', () => { xmlResponse: "", //survey answer XML string jsonDocResponse: "this is my json object" //survey answer JSON object }, - labels: [{labelField: "goodbye"}] //TODO learn more about answer type + metadata: {} } ]; @@ -146,7 +142,7 @@ it('filters the survey answers by their name and version', () => { xmlResponse: "", //survey answer XML string jsonDocResponse: "this is my json object" //survey answer JSON object }, - labels: [{labelField: "goodbye"}] + metadata: {} }, { data: { @@ -158,7 +154,7 @@ it('filters the survey answers by their name and version', () => { xmlResponse: "", //survey answer XML string jsonDocResponse: "this is my json object" //survey answer JSON object }, - labels: [{labelField: "goodbye"}] + metadata: {} } ]; From 09f21bc92cd1c9bad45986f465a0071a00f7c021 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 13 Oct 2023 10:49:14 -0600 Subject: [PATCH 115/850] update types from log statements, these answers have data and metadata, no labels --- www/js/survey/enketo/enketoHelper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index fa7d300e3..af578b2d9 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -29,7 +29,7 @@ type EnketoAnswerData = { type EnketoAnswer = { data: EnketoAnswerData; //answer data - labels: [{[labelField:string]: string}]; //virtual labels (populated by populateLabels method) + metadata: any; } type EnketoSurveyConfig = { From e7fe7a8005c039240c6a4bab32141ecee4a612d5 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 13 Oct 2023 11:03:30 -0600 Subject: [PATCH 116/850] don't mock i18n, use the real thing with the changes from #1049, we are now able to test using i18n, no need to mock! --- www/__mocks__/i18nextMocks.ts | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 www/__mocks__/i18nextMocks.ts diff --git a/www/__mocks__/i18nextMocks.ts b/www/__mocks__/i18nextMocks.ts deleted file mode 100644 index dc0d3f2b4..000000000 --- a/www/__mocks__/i18nextMocks.ts +++ /dev/null @@ -1,8 +0,0 @@ -const i18next = jest.createMockFromModule('i18next'); - -let resolvedLanugage; - -function _setUpLanguage(language) { - console.log("setting resolved language to ", language, " for testing"); - resolvedLanugage = language; -} From 70f98cd87adce8ad9408b76ec2d24c40a4c6d60d Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 13 Oct 2023 11:38:17 -0600 Subject: [PATCH 117/850] set up baseline testing for resolveLabels introduced i18next for the tests updated config in mock, and test of loading config to be accurate to what is expected (missing some '{' ) adjust formatting of function indentation --- www/__mocks__/cordovaMocks.ts | 4 +-- www/__tests__/enketoHelper.test.ts | 38 +++++++++++------------ www/js/survey/enketo/enketoHelper.ts | 45 ++++++++++++++-------------- 3 files changed, 44 insertions(+), 43 deletions(-) diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 31e3e7bf4..7590d0422 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -93,8 +93,8 @@ export const mockBEMUserCache = () => { surveys: { TimeUseSurvey: { compatibleWith: 1, formPath: "https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json", - labelTemplate: {en: " erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic activities, }", - es: " erea, plural, =0 {} other {# Empleo/Educación, } }{ da, plural, =0 {} other {# Actividades domesticas, }"}, + labelTemplate: {en: "{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }", + es: "{ erea, plural, =0 {} other {# Empleo/Educación, } }{ da, plural, =0 {} other {# Actividades domesticas, }}"}, labelVars: {da: {key: "Domestic_activities", type: "length"}, erea: {key: "Employment_related_a_Education_activities", type:"length"}}, version: 9} diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index 5603a010e..7a13303a8 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -2,8 +2,8 @@ import { getInstanceStr, filterByNameAndVersion, resolveTimestamps, resolveLabel import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; -// import initializedI18next from '../js/i18nextInit'; -// window['i18next'] = initializedI18next; +import initializedI18next from '../js/i18nextInit'; +window['i18next'] = initializedI18next; mockBEMUserCache(); mockLogger(); @@ -13,14 +13,14 @@ it('gets the survey config', async () => { //mocked getDocument for the case of getting the config let config = await _lazyLoadConfig(); let mockSurveys = { - TimeUseSurvey: { compatibleWith: 1, - formPath: "https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json", - labelTemplate: {en: " erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic activities, }", - es: " erea, plural, =0 {} other {# Empleo/Educación, } }{ da, plural, =0 {} other {# Actividades domesticas, }"}, - labelVars: {da: {key: "Domestic_activities", type: "length"}, - erea: {key: "Employment_related_a_Education_activities", type:"length"}}, - version: 9} - } + TimeUseSurvey: { compatibleWith: 1, + formPath: "https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json", + labelTemplate: {en: "{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }", + es: "{ erea, plural, =0 {} other {# Empleo/Educación, } }{ da, plural, =0 {} other {# Actividades domesticas, }}"}, + labelVars: {da: {key: "Domestic_activities", type: "length"}, + erea: {key: "Employment_related_a_Education_activities", type:"length"}}, + version: 9} + } expect(config).toMatchObject(mockSurveys); }) @@ -66,17 +66,17 @@ it('resolves the timestamps', () => { //resolve label it('resolves the label', async () => { const xmlParser = new window.DOMParser(); - - //have a custom survey label function TODO: we currently don't have custome label functions, but should test when we do - - //no custom function, fallback to UseLabelTemplate - const xmlString = ' option_1/Domestic_activities> option_2 '; + const xmlString = ' option_1 '; const xmlDoc = xmlParser.parseFromString(xmlString, 'text/html'); + const xmlString2 = ' option_1 option_3 '; + const xmlDoc2 = xmlParser.parseFromString(xmlString2, 'text/xml'); - //if no template, returns "Answered" - // expect(await resolveLabel("TimeUseSurvey", xmlDoc)).toBe(""); - //if no labelVars, returns template - //else interpolates + //if no template, returns "Answered" TODO: find a way to engineer this case + //if no labelVars, returns template TODO: find a way to engineer this case + //have a custom survey label function TODO: we currently don't have custome label functions, but should test when we do + //no custom function, fallback to UseLabelTemplate (standard case) + expect(await resolveLabel("TimeUseSurvey", xmlDoc)).toBe("3 Domestic"); + expect(await resolveLabel("TimeUseSurvey", xmlDoc2)).toBe("3 Employment/Education, 3 Domestic"); }); /** diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index af578b2d9..133789a48 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -50,31 +50,32 @@ const LABEL_FUNCTIONS = { let configSurveys = await _lazyLoadConfig(); const config = configSurveys[name]; // config for this survey - const lang = i18next.resolvedLanguage; - const labelTemplate = config.labelTemplate?.[lang]; + const lang = i18next.resolvedLanguage; + const labelTemplate = config.labelTemplate?.[lang]; - if (!labelTemplate) return "Answered"; // no template given in config - if (!config.labelVars) return labelTemplate; // if no vars given, nothing to interpolate, - // so we return the unaltered template + if (!labelTemplate) return "Answered"; // no template given in config + if (!config.labelVars) return labelTemplate; // if no vars given, nothing to interpolate, + // so we return the unaltered template - // gather vars that will be interpolated into the template according to the survey config - const labelVars = {} - for (let lblVar in config.labelVars) { - const fieldName = config.labelVars[lblVar].key; - let fieldStr = _getAnswerByTagName(xmlDoc, fieldName); - if (fieldStr == '') fieldStr = null; - if (config.labelVars[lblVar].type == 'length') { - const fieldMatches = fieldStr?.split(' '); - labelVars[lblVar] = fieldMatches?.length || 0; - } else { - throw new Error(`labelVar type ${config.labelVars[lblVar].type } is not supported!`) - } - } + // gather vars that will be interpolated into the template according to the survey config + const labelVars = {} + for (let lblVar in config.labelVars) { + const fieldName = config.labelVars[lblVar].key; + let fieldStr = _getAnswerByTagName(xmlDoc, fieldName); + if (fieldStr == '') fieldStr = null; + if (config.labelVars[lblVar].type == 'length') { + const fieldMatches = fieldStr?.split(' '); + labelVars[lblVar] = fieldMatches?.length || 0; + } else { + throw new Error(`labelVar type ${config.labelVars[lblVar].type } is not supported!`) + } + } - // use MessageFormat interpolate the label template with the label vars - const mf = new MessageFormat(lang); - const label = mf.compile(labelTemplate)(labelVars); - return label.replace(/^[ ,]+|[ ,]+$/g, ''); // trim leading and trailing spaces and commas + // use MessageFormat interpolate the label template with the label vars + const mf = new MessageFormat(lang); + console.log(labelTemplate); + const label = mf.compile(labelTemplate)(labelVars); + return label.replace(/^[ ,]+|[ ,]+$/g, ''); // trim leading and trailing spaces and commas } } From 36cfff64668d819576c276a97f1c8052f332063f Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Fri, 13 Oct 2023 11:18:03 -0700 Subject: [PATCH 118/850] Improved TS, generalized getUnifiedData - Generalized getUnified functions into one method that takes a higher order function as input, and uses that to fetch required data. - Updated calls to the getUnified functions - Added an interface for the combineWithDedup method, because it requires objects with the 'metadata.writets' property to run - Updated other unifiedDataLoader functions so the parameters were appropriately typed --- www/js/diary/services.js | 12 ++++--- www/js/diary/timelineHelper.ts | 13 +++---- www/js/survey/enketo/enketoHelper.ts | 7 ++-- www/js/unifiedDataLoader.ts | 53 +++++++++++++++------------- 4 files changed, 47 insertions(+), 38 deletions(-) diff --git a/www/js/diary/services.js b/www/js/diary/services.js index 792c640a3..e0f1b5349 100644 --- a/www/js/diary/services.js +++ b/www/js/diary/services.js @@ -4,7 +4,7 @@ import angular from 'angular'; import { SurveyOptions } from '../survey/survey'; import { getConfig } from '../config/dynamicConfig'; import { getRawEntries } from '../commHelper'; -import { getUnifiedSensorDataForInterval, getUnifiedMessagesForInterval } from '../unifiedDataLoader' +import { getUnifiedDataForInterval } from '../unifiedDataLoader' angular.module('emission.main.diary.services', ['emission.plugin.logger', 'emission.services']) @@ -232,7 +232,9 @@ angular.module('emission.main.diary.services', ['emission.plugin.logger', Logger.log("About to pull location data for range " + moment.unix(tripStartTransition.data.ts).toString() + " -> " + moment.unix(tripEndTransition.data.ts).toString()); - return getUnifiedSensorDataForInterval("background/filtered_location", tq).then(function(locationList) { + const getSensorData = window['cordova'].plugins.BEMUserCache.getSensorDataForInterval; + return getUnifiedDataForInterval("background/filtered_location", tq, getSensorData) + .then(function(locationList) { if (locationList.length == 0) { return undefined; } @@ -304,7 +306,9 @@ angular.module('emission.main.diary.services', ['emission.plugin.logger', } Logger.log("about to query for unprocessed trips from " +moment.unix(tq.startTs).toString()+" -> "+moment.unix(tq.endTs).toString()); - return getUnifiedMessagesForInterval("statemachine/transition", tq) + + const getMessageMethod = window['cordova'].plugins.BEMUserCache.getMessagesForInterval; + return getUnifiedDataForInterval("statemachine/transition", tq, getMessageMethod) .then(function(transitionList) { if (transitionList.length == 0) { Logger.log("No unprocessed trips. yay!"); @@ -356,7 +360,7 @@ angular.module('emission.main.diary.services', ['emission.plugin.logger', return trip_gj_list; }); } - }); + }); } var localCacheReadFn = timeline.updateFromDatabase; diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index c4d517c40..287484a7a 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -1,8 +1,7 @@ import moment from "moment"; -import { getAngularService } from "../angular-react-helper"; import { displayError, logDebug } from "../plugin/logger"; import { getBaseModeByKey, getBaseModeOfLabeledTrip } from "./diaryHelper"; -import { getUnifiedMessagesForInterval } from "../unifiedDataLoader"; +import { getUnifiedDataForInterval} from "../unifiedDataLoader"; import i18next from "i18next"; const cachedGeojsons = new Map(); @@ -149,11 +148,13 @@ export function getLocalUnprocessedInputs(pipelineRange, labelsFactory, notesFac */ export function getAllUnprocessedInputs(pipelineRange, labelsFactory, notesFactory) { const tq = getUnprocessedInputQuery(pipelineRange); - const labelsPromises = labelsFactory.MANUAL_KEYS.map((key) => - getUnifiedMessagesForInterval(key, tq).then(labelsFactory.extractResult) + const getMethod = window['cordova'].plugins.BEMUserCache.getMessagesForInterval; + + const labelsPromises = labelsFactory.MANUAL_KEYS.map((key) => + getUnifiedDataForInterval(key, tq, getMethod).then(labelsFactory.extractResult) ); - const notesPromises = notesFactory.MANUAL_KEYS.map((key) => - getUnifiedMessagesForInterval(key, tq).then(notesFactory.extractResult) + const notesPromises = notesFactory.MANUAL_KEYS.map((key) => + getUnifiedDataForInterval(key, tq, getMethod).then(notesFactory.extractResult) ); return getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, notesPromises); } diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index aecf3af93..c35dd03a3 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -3,7 +3,7 @@ import { Form } from 'enketo-core'; import { XMLParser } from 'fast-xml-parser'; import i18next from 'i18next'; import { logDebug } from "../../plugin/logger"; -import { getUnifiedMessagesForInterval } from "../../unifiedDataLoader"; +import { getUnifiedDataForInterval} from "../../unifiedDataLoader"; export type PrefillFields = {[key: string]: string}; @@ -110,6 +110,7 @@ const _getMostRecent = (answers) => { export function loadPreviousResponseForSurvey(dataKey: string) { const tq = window['cordova'].plugins.BEMUserCache.getAllTimeQuery(); logDebug("loadPreviousResponseForSurvey: dataKey = " + dataKey + "; tq = " + tq); - return getUnifiedMessagesForInterval(dataKey, tq) - .then(answers => _getMostRecent(answers)) + const getMethod = window['cordova'].plugins.BEMUserCache.getSensorDataForInterval; + return getUnifiedDataForInterval(dataKey, tq, getMethod) + .then(answers => _getMostRecent(answers)); } diff --git a/www/js/unifiedDataLoader.ts b/www/js/unifiedDataLoader.ts index 32c12ea3e..87ae3380d 100644 --- a/www/js/unifiedDataLoader.ts +++ b/www/js/unifiedDataLoader.ts @@ -1,11 +1,22 @@ import { logInfo } from './plugin/logger' import { getRawEntries } from './commHelper'; -// Helper Functions for the getUnified methods. -const combineWithDedup = function(list1, list2) { +interface dataObj { + data: any; + metadata: { + plugin: string; + write_ts: number; + platform: string; + read_ts: number; + key: string; + type: string; + } +} +const combineWithDedup = function(list1: Array, list2: Array) { const combinedList = list1.concat(list2); return combinedList.filter(function(value, i, array) { - const firstIndexOfValue = array.findIndex(function(element, index, array) { + const firstIndexOfValue = array.findIndex(function(element) { + console.log(`==> Element: ${JSON.stringify(element, null, 4)} `); return element.metadata.write_ts == value.metadata.write_ts; }); return firstIndexOfValue == i; @@ -13,7 +24,8 @@ const combineWithDedup = function(list1, list2) { }; // TODO: generalize to iterable of promises -const combinedPromise = function(localPromise, remotePromise, combiner) { +const combinedPromise = function(localPromise: Promise, remotePromise: Promise, + combiner: (list1: Array, list2: Array) => Array) { return new Promise(function(resolve, reject) { var localResult = []; var localError = null; @@ -59,6 +71,8 @@ const combinedPromise = function(localPromise, remotePromise, combiner) { }) } +// This is an generalized get function for data; example uses could be with +// the getSensorDataForInterval or getMessagesForInterval functions. interface serverData { phone_data: Array; } @@ -67,24 +81,13 @@ interface tQ { startTs: number; endTs: number; } -// TODO: Generalize this to work for both sensor data and messages -// Do we even need to separate the two kinds of data? -export const getUnifiedSensorDataForInterval = function(key: string, tq: tQ) { - const localPromise = window['cordova'].plugins.BEMUserCache.getSensorDataForInterval(key, tq, true); - const remotePromise = getRawEntries([key], tq.startTs, tq.endTs) - .then(function(serverResponse: serverData) { - console.log(`\n\n\n TQ : ${JSON.stringify(tq)}`) - return serverResponse.phone_data; - }); - return combinedPromise(localPromise, remotePromise, combineWithDedup); -}; - -export const getUnifiedMessagesForInterval = function(key: string, tq: tQ) { - const localPromise = window['cordova'].plugins.BEMUserCache.getMessagesForInterval(key, tq, true); - const remotePromise = getRawEntries([key], tq.startTs, tq.endTs) - .then(function(serverResponse: serverData) { - console.log('==>', JSON.stringify(tq.endTs), ':',typeof tq.endTs); - return serverResponse.phone_data; - }); - return combinedPromise(localPromise, remotePromise, combineWithDedup); - } +export const getUnifiedDataForInterval = function(key: string, tq: tQ, + getMethod: (key: string, tq: tQ, flag: boolean) => Promise) { + const test = true; + const localPromise = getMethod(key, tq, test); + const remotePromise = getRawEntries([key], tq.startTs, tq.endTs) + .then(function(serverResponse: serverData) { + return serverResponse.phone_data; + }); + return combinedPromise(localPromise, remotePromise, combineWithDedup); +}; \ No newline at end of file From d9936f1b67e9b1bf1eb7bf38a1f5790475e794bb Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Fri, 13 Oct 2023 13:15:48 -0700 Subject: [PATCH 119/850] delete old input-matcher angular service --- www/js/survey/multilabel/multi-label-ui.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/www/js/survey/multilabel/multi-label-ui.js b/www/js/survey/multilabel/multi-label-ui.js index 8857f75c4..f9aa3b323 100644 --- a/www/js/survey/multilabel/multi-label-ui.js +++ b/www/js/survey/multilabel/multi-label-ui.js @@ -4,10 +4,9 @@ import { getConfig } from '../../config/dynamicConfig'; import { getUserInputForTrip } from '../input-matcher'; angular.module('emission.survey.multilabel.buttons', - ['emission.stats.clientstats', - 'emission.survey.inputmatcher']) + ['emission.stats.clientstats']) -.factory("MultiLabelService", function($rootScope, InputMatcher, $timeout, $ionicPlatform, Logger) { +.factory("MultiLabelService", function($rootScope, $timeout, $ionicPlatform, Logger) { var mls = {}; console.log("Creating MultiLabelService"); mls.init = function(config) { From 631de70d6cce9905608dec10ba4a2062a599f154 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Fri, 13 Oct 2023 13:17:26 -0700 Subject: [PATCH 120/850] Add getNotDeletedCandidates input & output type --- www/js/survey/input-matcher.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/www/js/survey/input-matcher.ts b/www/js/survey/input-matcher.ts index 8b8e6d277..f318ac255 100644 --- a/www/js/survey/input-matcher.ts +++ b/www/js/survey/input-matcher.ts @@ -19,6 +19,8 @@ export type UserInputForTrip = { label: string, start_local_dt?: LocalDt end_local_dt?: LocalDt + status?: string, + match_id?: string }, metadata: { time_zone: string, @@ -147,7 +149,7 @@ export const validUserInputForTimelineEntry = (tlEntry: TlEntry, userInput: User } // parallels get_not_deleted_candidates() in trip_queries.py -export const getNotDeletedCandidates = (candidates) => { +export const getNotDeletedCandidates = (candidates: UserInputForTrip[]): UserInputForTrip[] => { console.log('getNotDeletedCandidates called with ' + candidates.length + ' candidates'); // We want to retain all ACTIVE entries that have not been DELETED @@ -162,7 +164,7 @@ export const getNotDeletedCandidates = (candidates) => { } export const getUserInputForTrip = (trip: TlEntry, nextTrip: any, userInputList: UserInputForTrip[]): undefined | UserInputForTrip => { - const logsEnabled = userInputList.length < 20; + const logsEnabled = userInputList?.length < 20; if (userInputList === undefined) { logDebug("In getUserInputForTrip, no user input, returning undefined"); return undefined; @@ -193,8 +195,8 @@ export const getUserInputForTrip = (trip: TlEntry, nextTrip: any, userInputList } // return array of matching additions for a trip or place -export const getAdditionsForTimelineEntry = (entry, additionsList) => { - const logsEnabled = additionsList.length < 20; +export const getAdditionsForTimelineEntry = (entry: TlEntry, additionsList: UserInputForTrip[]): UserInputForTrip[] => { + const logsEnabled = additionsList?.length < 20; if (additionsList === undefined) { logDebug("In getAdditionsForTimelineEntry, no addition input, returning []"); From 7148ac6e23aa10a54f059a134c9866600ce2bc26 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Fri, 13 Oct 2023 13:17:48 -0700 Subject: [PATCH 121/850] remove input-matcher.js file --- www/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/www/index.js b/www/index.js index 55cb233b5..791c5f64a 100644 --- a/www/index.js +++ b/www/index.js @@ -17,7 +17,6 @@ import './js/controllers.js'; import './js/services.js'; import './js/i18n-utils.js'; import './js/main.js'; -import './js/survey/input-matcher.js'; import './js/survey/multilabel/infinite_scroll_filters.js'; import './js/survey/multilabel/multi-label-ui.js'; import './js/diary.js'; From 16673e805621566835ebef1321514fb21d2a88f1 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Fri, 13 Oct 2023 13:18:09 -0700 Subject: [PATCH 122/850] Done with input-matcher.test --- www/__tests__/input-matcher.test.ts | 222 ++++++++++++++++++++-------- 1 file changed, 164 insertions(+), 58 deletions(-) diff --git a/www/__tests__/input-matcher.test.ts b/www/__tests__/input-matcher.test.ts index 8dbb98a79..0a3fe4562 100644 --- a/www/__tests__/input-matcher.test.ts +++ b/www/__tests__/input-matcher.test.ts @@ -1,59 +1,76 @@ import { TlEntry, - Trip, UserInputForTrip, fmtTs, printUserInput, validUserInputForDraftTrip, validUserInputForTimelineEntry, - getNotDeletedCandidates + getNotDeletedCandidates, + getUserInputForTrip, + getAdditionsForTimelineEntry, + getUniqueEntries } from '../js/survey/input-matcher'; describe('input-matcher', () => { let userTrip: UserInputForTrip; + let trip: TlEntry; beforeEach(() => { - // create a userTrip object before each test case. + // create valid userTrip and trip object before each test case. userTrip = { data: { end_ts: 1437604764, start_ts: 1437601247, - label: "FOO" + label: 'FOO', + status: 'ACTIVE' }, metadata: { - time_zone: "America/Los_Angeles", - plugin: "none", - write_ts: 1695921991.013001, - platform: "ios", + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695921991, + platform: 'ios', read_ts: 0, - key: "manual/mode_confirm" + key: 'manual/mode_confirm' }, - key: "manual/place" + key: 'manual/place' } + trip = { + key: 'FOO', + origin_key: 'FOO', + start_ts: 1437601000, + end_ts: 1437605000, + enter_ts: 1437605000, + exit_ts: 1437605000, + duration: 100, + getNextEntry: jest.fn() + } + + // mock Logger + window['Logger'] = { log: console.log }; }); it('tests fmtTs with valid input', () => { - const pstTime = fmtTs(1437601247.8459613, "America/Los_Angeles"); - const estTime = fmtTs(1437601247.8459613, "America/New_York"); + const pstTime = fmtTs(1437601247.8459613, 'America/Los_Angeles'); + const estTime = fmtTs(1437601247.8459613, 'America/New_York'); + // Check if it contains correct year-mm-dd hr:mm - expect(pstTime).toContain("2015-07-22T14:40"); - expect(estTime).toContain("2015-07-22T17:40"); + expect(pstTime).toContain('2015-07-22T14:40'); + expect(estTime).toContain('2015-07-22T17:40'); }); it('tests fmtTs with invalid input', () => { - const formattedTeim = fmtTs(0, ""); - // Check if it contains correct year-mm-dd hr:mm - expect(formattedTeim).toBeFalsy(); + const formattedTime = fmtTs(0, ''); + expect(formattedTime).toBeFalsy(); }); it('tests printUserInput prints the trip log correctly', () => { const userTripLog = printUserInput(userTrip); - expect(userTripLog).toContain("1437604764"); - expect(userTripLog).toContain("1437601247"); - expect(userTripLog).toContain("FOO"); + expect(userTripLog).toContain('1437604764'); + expect(userTripLog).toContain('1437601247'); + expect(userTripLog).toContain('FOO'); }); - it('tests validUserInputForDraftTrip with valid trip', () => { + it('tests validUserInputForDraftTrip with valid trip input', () => { const validTrp = { end_ts: 1437604764, start_ts: 1437601247 @@ -62,7 +79,7 @@ describe('input-matcher', () => { expect(validUserInput).toBeTruthy(); }); - it('tests validUserInputForDraftTrip with invalid trip', () => { + it('tests validUserInputForDraftTrip with invalid trip input', () => { const invalidTrip = { end_ts: 0, start_ts: 0 @@ -71,42 +88,24 @@ describe('input-matcher', () => { expect(invalidUserInput).toBeFalsy(); }); - it('tests validUserInputForTimelineEntry with valid tlEntry object', () => { - const tlEntry: TlEntry = { - key: "analysis/confirmed_place", - origin_key: "analysis/confirmed_place", - start_ts: 1437601000, - end_ts: 1437605000, - enter_ts: 1437605000, - exit_ts: 1437605000, - duration: 100, - getNextEntry: jest.fn() - } - - const validTimelineEntry = validUserInputForTimelineEntry(tlEntry, userTrip, false); + it('tests validUserInputForTimelineEntry with valid trip object', () => { + // we need valid key and origin_key for validUserInputForTimelineEntry test + trip['key'] = 'analysis/confirmed_place'; + trip['origin_key'] = 'analysis/confirmed_place'; + const validTimelineEntry = validUserInputForTimelineEntry(trip, userTrip, false); expect(validTimelineEntry).toBeTruthy(); }); - it('tests validUserInputForTimelineEntry with invalid tlEntry key', () => { - const tlEntry: TlEntry = { - key: "FOO", - origin_key: "FOO", - start_ts: 1437601000, - end_ts: 1437605000, - enter_ts: 1437605000, - exit_ts: 1437605000, - duration: 100, - getNextEntry: jest.fn() - } - - const invalidTimelineEntry = validUserInputForTimelineEntry(tlEntry, userTrip, false); + it('tests validUserInputForTimelineEntry with tlEntry with invalid key and origin_key', () => { + const invalidTlEntry = trip; + const invalidTimelineEntry = validUserInputForTimelineEntry(invalidTlEntry, userTrip, false); expect(invalidTimelineEntry).toBeFalsy(); }); - it('tests validUserInputForTimelineEntry with invalid tlEntry start & end time', () => { - const tlEntry: TlEntry = { - key: "analysis/confirmed_place", - origin_key: "analysis/confirmed_place", + it('tests validUserInputForTimelineEntry with tlEntry with invalie start & end time', () => { + const invalidTlEntry: TlEntry = { + key: 'analysis/confirmed_place', + origin_key: 'analysis/confirmed_place', start_ts: 1, end_ts: 1, enter_ts: 1, @@ -114,8 +113,7 @@ describe('input-matcher', () => { duration: 1, getNextEntry: jest.fn() } - - const invalidTimelineEntry = validUserInputForTimelineEntry(tlEntry, userTrip, false); + const invalidTimelineEntry = validUserInputForTimelineEntry(invalidTlEntry, userTrip, false); expect(invalidTimelineEntry).toBeFalsy(); }); @@ -129,15 +127,123 @@ describe('input-matcher', () => { }); - it('tests getUserInputForTrip', () => { - + it('tests getNotDeletedCandidates called with multiple candidates', () => { + const activeTrip = userTrip; + const deletedTrip = { + data: { + end_ts: 1437604764, + start_ts: 1437601247, + label: 'FOO', + status: 'DELETED', + match_id: 'FOO' + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695921991, + platform: 'ios', + read_ts: 0, + key: 'manual/mode_confirm' + }, + key: 'manual/place' + } + const candidates = [ activeTrip, deletedTrip ]; + const validCandidates = getNotDeletedCandidates(candidates); + + // check if the result has only 'ACTIVE' data + expect(validCandidates).toHaveLength(1); + expect(validCandidates[0]).toMatchObject(userTrip); + }); - it('tests getAdditionsForTimelineEntry', () => { + it('tests getUserInputForTrip with valid userInputList', () => { + const userInputWriteFirst = { + data: { + end_ts: 1437607732, + label: 'bus', + start_ts: 1437606026 + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695830232, + platform: 'ios', + read_ts: 0, + key:'manual/mode_confirm', + type:'message' + } + } + const userInputWriteSecond = { + data: { + end_ts: 1437598393, + label: 'e-bike', + start_ts: 1437596745 + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695838268, + platform: 'ios', + read_ts: 0, + key:'manual/mode_confirm', + type:'message' + } + } + const userInputWriteThird = { + data: { + end_ts: 1437604764, + label: 'e-bike', + start_ts: 1437601247 + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695921991, + platform: 'ios', + read_ts: 0, + key:'manual/mode_confirm', + type:'message' + } + } + + // make the linst unsorted and then check if userInputWriteThird(latest one) is return output + const userInputList = [userInputWriteSecond, userInputWriteThird, userInputWriteFirst]; + const mostRecentEntry = getUserInputForTrip(trip, {}, userInputList); + expect(mostRecentEntry).toMatchObject(userInputWriteThird); + }); + it('tests getUserInputForTrip with invalid userInputList', () => { + const userInputList = undefined; + const mostRecentEntry = getUserInputForTrip(trip, {}, userInputList); + expect(mostRecentEntry).toBe(undefined); }); - it('tests getUniqueEntries', () => { + it('tests getAdditionsForTimelineEntry with valid additionsList', () => { + const additionsList = new Array(5).fill(userTrip); + trip['key'] = 'analysis/confirmed_place'; + trip['origin_key'] = 'analysis/confirmed_place'; + + // check if the result keep the all valid userTrip items + const matchingAdditions = getAdditionsForTimelineEntry(trip, additionsList); + expect(matchingAdditions).toHaveLength(5); + }); + + it('tests getAdditionsForTimelineEntry with invalid additionsList', () => { + const additionsList = undefined; + const matchingAdditions = getAdditionsForTimelineEntry(trip, additionsList); + expect(matchingAdditions).toMatchObject([]); + }); + + it('tests getUniqueEntries with valid combinedList', () => { + const combinedList = new Array(5).fill(userTrip); + + // check if the result keeps only unique userTrip items + const uniqueEntires = getUniqueEntries(combinedList); + expect(uniqueEntires).toHaveLength(1); + }); + it('tests getUniqueEntries with empty combinedList', () => { + const uniqueEntires = getUniqueEntries([]); + expect(uniqueEntires).toMatchObject([]); }); }) \ No newline at end of file From 719403a231c50aeca6b694d03faf59bf16e77618 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 13 Oct 2023 14:39:52 -0600 Subject: [PATCH 123/850] rework getInstanceStr tests now that I understand how this function works, I got the xml (filled and unfilled) directly from console.log statements. The tests are now accurate, and cover each of the cases. --- www/__tests__/enketoHelper.test.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index 7a13303a8..55c0aceef 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -25,21 +25,17 @@ it('gets the survey config', async () => { }) it('gets the model response, if avaliable, or returns null', ()=> { - const xmlModel = '\n \n \n \n \n \n \n ;'; - const filled = '\n \n car\n \n \n \n \n ;'; + const xmlModel = ''; + const filled = '2016-07-2517:24:32.928-06:002016-07-2517:30:31.000-06:00'; const opts = {"prefilledSurveyResponse": filled}; - const opts2 = {"prefillFields": {"travel_mode" : "car"}}; + const opts2 = {"prefillFields": {"Start_date":"2016-07-25", "Start_time": "17:24:32.928-06:00", "End_date": "2016-07-25", "End_time": "17:30:31.000-06:00"}}; //if no xmlModel, returns null expect(getInstanceStr(null, opts)).toBe(null); - //if there is a prefilled survey, return it expect(getInstanceStr(xmlModel, opts)).toBe(filled); - //if there is a model and fields, return prefilled - // expect(getInstanceStr(xmlModel, opts2)).toBe(filled); - //TODO - figure out how to use the helper function with JEST -- getElementsByTagName is empty? should it be? - + expect(getInstanceStr(xmlModel, opts2)).toBe(filled); //if none of those things, also return null expect(getInstanceStr(xmlModel, {})).toBe(null); }); From 252e42f45a64a66322ba3363ca7df7c10ce418cf Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 13 Oct 2023 16:25:06 -0600 Subject: [PATCH 124/850] tests for saveResponse testing for saving the response, both when it works and when the timestamps are invalid, resulting in an error --- www/__tests__/enketoHelper.test.ts | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index 55c0aceef..576b77e18 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -1,4 +1,4 @@ -import { getInstanceStr, filterByNameAndVersion, resolveTimestamps, resolveLabel, _lazyLoadConfig, loadPreviousResponseForSurvey} from '../js/survey/enketo/enketoHelper'; +import { getInstanceStr, filterByNameAndVersion, resolveTimestamps, resolveLabel, _lazyLoadConfig, loadPreviousResponseForSurvey, saveResponse} from '../js/survey/enketo/enketoHelper'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; @@ -8,6 +8,9 @@ window['i18next'] = initializedI18next; mockBEMUserCache(); mockLogger(); +global.URL = require('url').URL; +global.Blob = require('node:buffer').Blob; + it('gets the survey config', async () => { //this is aimed at testing my mock of the config //mocked getDocument for the case of getting the config @@ -84,7 +87,27 @@ it('resolves the label', async () => { */ // export function saveResponse(surveyName: string, enketoForm: Form, appConfig, opts: SurveyOptions) { it('gets the saved result or throws an error', () => { - + const surveyName = "TimeUseSurvey"; + const form = { getDataStr: () => { return '2023-10-13T15:05:48.890-06:002023-10-13T15:05:48.892-06:002016-07-2517:24:32.928-06:002016-07-2517:30:31.000-06:00personal_care_activitiesdoing_sportuuid:dc16c287-08b2-4435-95aa-e4d7838b4225'}}; + const badForm = { getDataStr: () => { return '2023-10-13T15:05:48.890-06:002023-10-13T15:05:48.892-06:002016-08-2517:24:32.928-06:002016-07-2517:30:31.000-06:00personal_care_activitiesdoing_sportuuid:dc16c287-08b2-4435-95aa-e4d7838b4225'}}; + const config = { + survey_info: { + surveys: { + TimeUseSurvey: { compatibleWith: 1, + formPath: "https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json", + labelTemplate: {en: "{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }", + es: "{ erea, plural, =0 {} other {# Empleo/Educación, } }{ da, plural, =0 {} other {# Actividades domesticas, }}"}, + labelVars: {da: {key: "Domestic_activities", type: "length"}, + erea: {key: "Employment_related_a_Education_activities", type:"length"}}, + version: 9} + } + } + }; + const opts = { timelineEntry: { end_local_dt: {timezone: "America/Los_Angeles"}, start_ts: 1469492672.928242, end_ts: 1469493031}}; + + console.log(config); + expect(saveResponse(surveyName, form, config, opts)).resolves.toMatchObject({label: "1 Personal Care", name: "TimeUseSurvey"}); + expect(saveResponse(surveyName, badForm, config, opts)).resolves.toMatchObject({message: "The times you entered are invalid. Please ensure that the start time is before the end time."}); }); /* From 9dc93844a9554d391434e7f39471ab5e4714195a Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 13 Oct 2023 16:25:19 -0600 Subject: [PATCH 125/850] take out old console.log --- www/js/survey/enketo/enketoHelper.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index 133789a48..3fa02f60c 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -73,7 +73,6 @@ const LABEL_FUNCTIONS = { // use MessageFormat interpolate the label template with the label vars const mf = new MessageFormat(lang); - console.log(labelTemplate); const label = mf.compile(labelTemplate)(labelVars); return label.replace(/^[ ,]+|[ ,]+$/g, ''); // trim leading and trailing spaces and commas } From 4d24d3e2a4a97492759c1f8c74da96c31fc3954d Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 13 Oct 2023 16:51:03 -0600 Subject: [PATCH 126/850] update types based on looking at these variables in breakpoints, these typings are more accurate --- www/js/survey/enketo/enketoHelper.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index 3fa02f60c..147ffede8 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -35,7 +35,8 @@ type EnketoAnswer = { type EnketoSurveyConfig = { [surveyName:string]: { formPath: string - labelFields: string[]; + labelTemplate: {[lang: string] : string}; + labelVars: {[activity: string]: {[key: string]: string, type:string}}, version: number; compatibleWith: number; } From b7abd98676dbb85b9a03bb59a27c7249968c1f56 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Fri, 13 Oct 2023 16:29:00 -0700 Subject: [PATCH 127/850] Generalized combinePromises, improved TS - Updated combinePromise to now take a list of one or more promises as input, and recursively combine them to a single promise. - Added parameter explanations to each function - Cleaned up debuging console.log statements --- www/js/unifiedDataLoader.ts | 94 +++++++++++++++++++++++-------------- 1 file changed, 60 insertions(+), 34 deletions(-) diff --git a/www/js/unifiedDataLoader.ts b/www/js/unifiedDataLoader.ts index 87ae3380d..379c30165 100644 --- a/www/js/unifiedDataLoader.ts +++ b/www/js/unifiedDataLoader.ts @@ -12,67 +12,85 @@ interface dataObj { type: string; } } +/** + * combineWithDedup is a helper function for combinedPromises + * @param list1 values evaluated from a BEMUserCache promise + * @param list2 same as list1 + * @returns a dedup array generated from the input lists + */ const combineWithDedup = function(list1: Array, list2: Array) { const combinedList = list1.concat(list2); return combinedList.filter(function(value, i, array) { const firstIndexOfValue = array.findIndex(function(element) { - console.log(`==> Element: ${JSON.stringify(element, null, 4)} `); return element.metadata.write_ts == value.metadata.write_ts; }); return firstIndexOfValue == i; }); }; -// TODO: generalize to iterable of promises -const combinedPromise = function(localPromise: Promise, remotePromise: Promise, - combiner: (list1: Array, list2: Array) => Array) { +/** + * combinedPromises is a recursive function that joins multiple promises + * @param promiseList 1 or more promises + * @param combiner a function that takes two arrays and joins them + * @returns A promise which evaluates to a combined list of values or errors + */ +const combinedPromises = function(promiseList: Array>, + combiner: (list1: Array, list2: Array) => Array ) { return new Promise(function(resolve, reject) { - var localResult = []; - var localError = null; + var firstResult = []; + var firstError = null; - var remoteResult = []; - var remoteError = null; + var nextResult = []; + var nextError = null; - var localPromiseDone = false; - var remotePromiseDone = false; + var firstPromiseDone = false; + var nextPromiseDone = false; const checkAndResolve = function() { - if (localPromiseDone && remotePromiseDone) { - // time to return from this promise - if (localError && remoteError) { - reject([localError, remoteError]); + if (firstPromiseDone && nextPromiseDone) { + if (firstError && nextError) { + reject([firstError, nextError]); } else { - logInfo("About to dedup localResult = "+localResult.length - +"remoteResult = "+remoteResult.length); - const dedupedList = combiner(localResult, remoteResult); + logInfo("About to dedup localResult = "+firstResult.length + +"remoteResult = "+nextResult.length); + + const dedupedList = combiner(firstResult, nextResult); logInfo("Deduped list = "+dedupedList.length); resolve(dedupedList); } } }; + + if (promiseList.length === 1) { + return promiseList[0].then(function(result: Array) { + resolve(result); + }, function (err) { + reject([err]); + }); + } - localPromise.then(function(currentLocalResult) { - localResult = currentLocalResult; - localPromiseDone = true; + const firstPromise = promiseList[0]; + const nextPromise = combinedPromises(promiseList.slice(1), combiner); + + firstPromise.then(function(currentFirstResult: Array) { + firstResult = currentFirstResult; + firstPromiseDone = true; }, function(error) { - localResult = []; - localError = error; - localPromiseDone = true; + firstResult = []; + firstError = error; + nextPromiseDone = true; }).then(checkAndResolve); - remotePromise.then(function(currentRemoteResult) { - remoteResult = currentRemoteResult; - remotePromiseDone = true; + nextPromise.then(function(currentNextResult: Array) { + nextResult = currentNextResult; + nextPromiseDone = true; }, function(error) { - remoteResult = []; - remoteError = error; - remotePromiseDone = true; + nextResult = []; + nextError = error; }).then(checkAndResolve); - }) -} + }); +}; -// This is an generalized get function for data; example uses could be with -// the getSensorDataForInterval or getMessagesForInterval functions. interface serverData { phone_data: Array; } @@ -81,6 +99,13 @@ interface tQ { startTs: number; endTs: number; } +/** + * getUnifiedDataForInterval is a generalized method to fetch data by its timestamps + * @param key string corresponding to a data entry + * @param tq an object that contains interval start and end times + * @param getMethod a BEMUserCache method that fetches certain data via a promise + * @returns A promise that evaluates to the all values found within the queried data + */ export const getUnifiedDataForInterval = function(key: string, tq: tQ, getMethod: (key: string, tq: tQ, flag: boolean) => Promise) { const test = true; @@ -89,5 +114,6 @@ export const getUnifiedDataForInterval = function(key: string, tq: tQ, .then(function(serverResponse: serverData) { return serverResponse.phone_data; }); - return combinedPromise(localPromise, remotePromise, combineWithDedup); + var promiseList = [localPromise, remotePromise] + return combinedPromises(promiseList, combineWithDedup); }; \ No newline at end of file From 1977258a45cab6738fbfd33ac05effa9a08602e4 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 16 Oct 2023 11:34:03 -0600 Subject: [PATCH 128/850] carry through async nature of the label functions --- www/js/survey/enketo/enketoHelper.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index 147ffede8..773e3b7cb 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -131,13 +131,13 @@ export function _lazyLoadConfig() { * @param {XMLDocument} xmlDoc survey answer object * @returns {Promise} label string Promise */ -export function resolveLabel(name: string, xmlDoc: XMLDocument) { +export async function resolveLabel(name: string, xmlDoc: XMLDocument) { // Some studies may want a custom label function for their survey. // Those can be added in LABEL_FUNCTIONS with the survey name as the key. // Otherwise, UseLabelTemplate will create a label using the template in the config if (LABEL_FUNCTIONS[name]) - return LABEL_FUNCTIONS[name](xmlDoc); - return LABEL_FUNCTIONS.UseLabelTemplate(xmlDoc, name); + return await LABEL_FUNCTIONS[name](xmlDoc); + return await LABEL_FUNCTIONS.UseLabelTemplate(xmlDoc, name); } /** From a269a55ab8b94c3644a1e2788e71efe9801b4056 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 16 Oct 2023 11:34:47 -0600 Subject: [PATCH 129/850] correct precision of enketo dates the mismatch of precision and expected precision here is what was causing the added time entries to fail --- www/js/survey/enketo/enketoHelper.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index 773e3b7cb..0aab833b6 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -198,8 +198,8 @@ export function resolveTimestamps(xmlDoc, timelineEntry) { startTime = startTime.split(/\-|\+/)[0]; endTime = endTime.split(/\-|\+/)[0]; - let additionStartTs = DateTime.fromISO(startDate + "T" + startTime, {zone: timezone}).valueOf(); - let additionEndTs = DateTime.fromISO(endDate + "T" + endTime, {zone: timezone}).valueOf(); + let additionStartTs = DateTime.fromISO(startDate + "T" + startTime, {zone: timezone}).toSeconds(); + let additionEndTs = DateTime.fromISO(endDate + "T" + endTime, {zone: timezone}).toSeconds(); if (additionStartTs > additionEndTs) { return undefined; // if the start time is after the end time, this is an invalid response From 4f3d9fb1a1ecc2982ce0b81f2f80d80cc262d502 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 16 Oct 2023 11:52:34 -0600 Subject: [PATCH 130/850] fix indentations now consistent 2-space indentation --- www/__mocks__/fileSystemMocks.ts | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/www/__mocks__/fileSystemMocks.ts b/www/__mocks__/fileSystemMocks.ts index fb1258692..a7610cba7 100644 --- a/www/__mocks__/fileSystemMocks.ts +++ b/www/__mocks__/fileSystemMocks.ts @@ -1,18 +1,17 @@ export const mockFileSystem = () => { - window['resolveLocalFileSystemURL'] = function (parentDir, handleFS) { - const fs = {"filesystem": - {"root": - {"getFile": (path, options, onSuccess) => { - let fileEntry = {"file": (handleFile) => { - let file = new File(["this is a mock"], "loggerDB"); - handleFile(file); - }} - onSuccess(fileEntry); - } - } - } - } - console.log("in mock, fs is ", fs, " get File is ", fs.filesystem.root.getFile); - handleFS(fs); + window['resolveLocalFileSystemURL'] = function (parentDir, handleFS) { + const fs = {"filesystem": + {"root": + {"getFile": (path, options, onSuccess) => { + let fileEntry = {"file": (handleFile) => { + let file = new File(["this is a mock"], "loggerDB"); + handleFile(file); + }} + onSuccess(fileEntry); + }} + } } - } \ No newline at end of file + console.log("in mock, fs is ", fs, " get File is ", fs.filesystem.root.getFile); + handleFS(fs); + } +} \ No newline at end of file From 3420577d81fabb010f30a80dbffed4efa1cbe2ea Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Mon, 16 Oct 2023 10:54:20 -0700 Subject: [PATCH 131/850] Updated debug statements, format changes Per discussion in PR, made adjustments to debug logs and funciton names. --- www/js/controlHelper.ts | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/www/js/controlHelper.ts b/www/js/controlHelper.ts index 2458d7e8a..6feaec85e 100644 --- a/www/js/controlHelper.ts +++ b/www/js/controlHelper.ts @@ -1,7 +1,7 @@ import { DateTime } from "luxon"; import { getRawEntries } from "./commHelper"; -import { logInfo, displayError } from "./plugin/logger"; +import { logInfo, displayError, logDebug } from "./plugin/logger"; import i18next from "./i18nextInit" ; interface fsWindow extends Window { @@ -36,16 +36,16 @@ export const getMyData = function(startTs: Date) { const resultList = result.phone_data; return new Promise(function(resolve, reject) { window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { - console.log(`file system open: ${fs.name}`); + logDebug(`file system open: ${fs.name}`); fs.root.getFile(dumpFile, { create: true, exclusive: false }, function (fileEntry) { - console.log(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`) + logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`) fileEntry.createWriter(function (fileWriter) { fileWriter.onwriteend = function() { - console.log("Successful file write..."); + logDebug("Successful file write..."); resolve(); } fileWriter.onerror = function(e) { - console.log(`Failed file write: ${e.toString()}`); + logDebug(`Failed file write: ${e.toString()}`); reject(); } @@ -60,21 +60,21 @@ export const getMyData = function(startTs: Date) { }); } - const emailData = function() { + const shareData = function() { return new Promise(function(resolve, reject) { window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { - console.log("During email, file system open: " + fs.name); + logDebug("During email, file system open: " + fs.name); fs.root.getFile(dumpFile, null, function(fileEntry) { - console.log(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`); + logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`); fileEntry.file(function(file) { const reader = new FileReader(); reader.onloadend = function() { const readResult = this.result as string; - console.log(`Successfull file read with ${readResult.length} characters`); + logDebug(`Successfull file read with ${readResult.length} characters`); const dataArray = JSON.parse(readResult); - console.log(`Successfully read resultList of size ${dataArray.length}`); - var attachFile = fileEntry.nativeURL; + logDebug(`Successfully read resultList of size ${dataArray.length}`); + let attachFile = fileEntry.nativeURL; if (window['device'].platform === "android") attachFile = "app://cache/" + dumpFile; if (window['device'].platform === "ios") @@ -85,11 +85,11 @@ export const getMyData = function(startTs: Date) { 'subject': i18next.t("email-service.email-data.subject-data-dump-from-to", {start: startTimeString ,end: endTimeString}), } window['plugins'].socialsharing.shareWithOptions(email, function (result) { - console.log(`Share Completed? ${result.completed}`); // On Android, most likely returns false - console.log(`Shared to app: ${result.app}`); + logDebug(`Share Completed? ${result.completed}`); // On Android, most likely returns false + logDebug(`Shared to app: ${result.app}`); resolve(); }, function (msg) { - console.log(`Sharing failed with message ${msg}`); + logDebug(`Sharing failed with message ${msg}`); }); } reader.readAsText(file); @@ -110,7 +110,7 @@ export const getMyData = function(startTs: Date) { getRawEntries(null, getUnixNum(startTime), startTime.toUnixInteger()) .then(writeDumpFile) - .then(emailData) + .then(shareData) .then(function() { logInfo("Email queued successfully"); }) From 185813f17ba6e030616b08da58d0bbf3c3ebc775 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 16 Oct 2023 11:59:12 -0600 Subject: [PATCH 132/850] better object declaration https://github.com/e-mission/e-mission-phone/pull/1053#discussion_r1361009142 --- www/__mocks__/fileSystemMocks.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/www/__mocks__/fileSystemMocks.ts b/www/__mocks__/fileSystemMocks.ts index a7610cba7..4b0e8cd3f 100644 --- a/www/__mocks__/fileSystemMocks.ts +++ b/www/__mocks__/fileSystemMocks.ts @@ -1,9 +1,9 @@ export const mockFileSystem = () => { window['resolveLocalFileSystemURL'] = function (parentDir, handleFS) { - const fs = {"filesystem": - {"root": - {"getFile": (path, options, onSuccess) => { - let fileEntry = {"file": (handleFile) => { + const fs = {filesystem: + {root: + {getFile: (path, options, onSuccess) => { + let fileEntry = {file: (handleFile) => { let file = new File(["this is a mock"], "loggerDB"); handleFile(file); }} From 655ddda715cff56b491a12b4cd68946ac9e427cc Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 16 Oct 2023 12:01:15 -0600 Subject: [PATCH 133/850] additional indentation improvement --- www/__mocks__/fileSystemMocks.ts | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/www/__mocks__/fileSystemMocks.ts b/www/__mocks__/fileSystemMocks.ts index 4b0e8cd3f..d7c2743ac 100644 --- a/www/__mocks__/fileSystemMocks.ts +++ b/www/__mocks__/fileSystemMocks.ts @@ -1,17 +1,23 @@ export const mockFileSystem = () => { window['resolveLocalFileSystemURL'] = function (parentDir, handleFS) { - const fs = {filesystem: - {root: - {getFile: (path, options, onSuccess) => { - let fileEntry = {file: (handleFile) => { - let file = new File(["this is a mock"], "loggerDB"); - handleFile(file); - }} - onSuccess(fileEntry); - }} + const fs = { + filesystem: + { + root: + { + getFile: (path, options, onSuccess) => { + let fileEntry = { + file: (handleFile) => { + let file = new File(["this is a mock"], "loggerDB"); + handleFile(file); + } + } + onSuccess(fileEntry); + } + } } } - console.log("in mock, fs is ", fs, " get File is ", fs.filesystem.root.getFile); - handleFS(fs); + console.log("in mock, fs is ", fs, " get File is ", fs.filesystem.root.getFile); + handleFS(fs); } } \ No newline at end of file From 4026423b36f5098f3f2d1121742177cd74e36174 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 16 Oct 2023 12:16:08 -0600 Subject: [PATCH 134/850] remove logError https://github.com/e-mission/e-mission-phone/pull/1053#discussion_r1361017447 --- www/js/control/uploadService.ts | 4 ++-- www/js/plugin/logger.ts | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/www/js/control/uploadService.ts b/www/js/control/uploadService.ts index 93557bdfe..c3a7a520e 100644 --- a/www/js/control/uploadService.ts +++ b/www/js/control/uploadService.ts @@ -1,4 +1,4 @@ -import { logDebug, logInfo, logError, displayError, displayErrorMsg } from "../plugin/logger"; +import { logDebug, logInfo, displayError } from "../plugin/logger"; import i18next from "i18next"; /** @@ -23,7 +23,7 @@ async function getUploadConfig() { url.push(uploadConfig["url"]); resolve(url); } catch (err) { - logError("Error while reading default upload config" + err); + displayError(err, "Error while reading default upload config"); reject(err); } } diff --git a/www/js/plugin/logger.ts b/www/js/plugin/logger.ts index a8a461bb6..d127f5549 100644 --- a/www/js/plugin/logger.ts +++ b/www/js/plugin/logger.ts @@ -33,9 +33,6 @@ export const logInfo = (message: string) => export const logWarn = (message: string) => window['Logger'].log(window['Logger'].LEVEL_WARN, message); -export const logError = (message: string) => - window['Logger'].log(window['Logger'].LEVEL_ERROR, message); - export function displayError(error: Error, title?: string) { const errorMsg = error.message ? error.message + '\n' + error.stack : JSON.stringify(error); displayErrorMsg(errorMsg, title); From b8ec27d7121d9386ec64281c6206376941287ed4 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Mon, 16 Oct 2023 11:52:02 -0700 Subject: [PATCH 135/850] add comments for test inputs --- www/__tests__/input-matcher.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/www/__tests__/input-matcher.test.ts b/www/__tests__/input-matcher.test.ts index 0a3fe4562..6a6c4c82a 100644 --- a/www/__tests__/input-matcher.test.ts +++ b/www/__tests__/input-matcher.test.ts @@ -16,7 +16,12 @@ describe('input-matcher', () => { let trip: TlEntry; beforeEach(() => { - // create valid userTrip and trip object before each test case. + /* + Create a valid userTrip and trip object before each test case. + The trip data is from the 'real_examples' data (shankari_2015-07-22) on the server. + For some test cases, I need to generate fake data, such as labels, keys, and origin_keys. + In such cases, I referred to 'TestUserInputFakeData.py' on the server. + */ userTrip = { data: { end_ts: 1437604764, From 0c1bac8c9198c415673887a86d2918a6d59cfa59 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 16 Oct 2023 13:52:28 -0600 Subject: [PATCH 136/850] remove uneeded code --- www/js/control/ProfileSettings.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 2552b37f0..99c7c4a8a 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -222,7 +222,7 @@ const ProfileSettings = () => { //methods that control the settings const uploadLog = function () { if(uploadReason != "") { - let reason = uploadReason.split('').join(''); + let reason = uploadReason; uploadFile("loggerDB", reason); setUploadVis(false); } From 92cd6fbac863af18b09642ac6e1101fe1cf07a94 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Mon, 16 Oct 2023 13:30:45 -0700 Subject: [PATCH 137/850] Split getData into multiple functions By extracting the `writeFile` and `shareData` methods, we can now write unit tests for controlHelper; specifically, we are able to test the `writeFile` portion, as the `shareData` method relies on external plugins that are difficult to mock. --- www/js/controlHelper.ts | 137 +++++++++++++++++++++++----------------- 1 file changed, 79 insertions(+), 58 deletions(-) diff --git a/www/js/controlHelper.ts b/www/js/controlHelper.ts index 6feaec85e..bfb439e55 100644 --- a/www/js/controlHelper.ts +++ b/www/js/controlHelper.ts @@ -19,25 +19,18 @@ interface fsWindow extends Window { declare let window: fsWindow; -export const getMyData = function(startTs: Date) { - // We are only retrieving data for a single day to avoid - // running out of memory on the phone - const startTime = DateTime.fromJSDate(startTs); - const endTime = startTime.endOf("day"); - const startTimeString = startTime.toFormat("yyyy'-'MM'-'dd"); - const endTimeString = endTime.toFormat("yyyy'-'MM'-'dd"); - - const dumpFile = startTimeString + "." - + endTimeString - + ".timeline"; - alert(`Going to retrieve data to ${dumpFile}`); - - const writeDumpFile = function(result) { - const resultList = result.phone_data; +/** + * createWriteFile is a factory method for the JSON dump file creation + * @param fileName is the name of the file to be created + * @returns a function that returns a promise, which writes the file upon evaluation. + */ +const createWriteFile = function (fileName: string) { + return function(result) { + const resultList = result.phone_data; return new Promise(function(resolve, reject) { window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { logDebug(`file system open: ${fs.name}`); - fs.root.getFile(dumpFile, { create: true, exclusive: false }, function (fileEntry) { + fs.root.getFile(fileName, { create: true, exclusive: false }, function (fileEntry) { logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`) fileEntry.createWriter(function (fileWriter) { fileWriter.onwriteend = function() { @@ -54,53 +47,77 @@ export const getMyData = function(startTs: Date) { { type: "application/json" }); fileWriter.write(dataObj); }) - }); }); - }); - } + }); +}}; - const shareData = function() { - return new Promise(function(resolve, reject) { - window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { - logDebug("During email, file system open: " + fs.name); - fs.root.getFile(dumpFile, null, function(fileEntry) { - logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`); - fileEntry.file(function(file) { - const reader = new FileReader(); +/** + * createShareData returns a shareData method, with the input parameters captured. + * @param fileName is the existing file to be sent + * @param startTimeString timestamp used to identify the file + * @param endTimeString " " + * @returns a function which returns a promise, which shares an existing file upon evaluation. + */ +const createShareData = function(fileName: string, startTimeString: string, endTimeString: string) { + return function() { + return new Promise(function(resolve, reject) { + window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { + logDebug("During email, file system open: " + fs.name); + fs.root.getFile(fileName, null, function(fileEntry) { + logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`); + fileEntry.file(function(file) { + const reader = new FileReader(); - reader.onloadend = function() { - const readResult = this.result as string; - logDebug(`Successfull file read with ${readResult.length} characters`); - const dataArray = JSON.parse(readResult); - logDebug(`Successfully read resultList of size ${dataArray.length}`); - let attachFile = fileEntry.nativeURL; - if (window['device'].platform === "android") - attachFile = "app://cache/" + dumpFile; - if (window['device'].platform === "ios") - alert(i18next.t("email-service.email-account-mail-app")); - const email = { - 'files': [attachFile], - 'message': i18next.t("email-service.email-data.body-data-consists-of-list-of-entries"), - 'subject': i18next.t("email-service.email-data.subject-data-dump-from-to", {start: startTimeString ,end: endTimeString}), - } - window['plugins'].socialsharing.shareWithOptions(email, function (result) { - logDebug(`Share Completed? ${result.completed}`); // On Android, most likely returns false - logDebug(`Shared to app: ${result.app}`); - resolve(); - }, function (msg) { - logDebug(`Sharing failed with message ${msg}`); - }); - } - reader.readAsText(file); - }, function(error) { - displayError(error, "Error while downloading JSON dump"); - reject(error); - }) - }); - }); + reader.onloadend = function() { + const readResult = this.result as string; + logDebug(`Successfull file read with ${readResult.length} characters`); + const dataArray = JSON.parse(readResult); + logDebug(`Successfully read resultList of size ${dataArray.length}`); + let attachFile = fileEntry.nativeURL; + if (window['device'].platform === "android") + attachFile = "app://cache/" + fileName; + if (window['device'].platform === "ios") + alert(i18next.t("email-service.email-account-mail-app")); + const email = { + 'files': [attachFile], + 'message': i18next.t("email-service.email-data.body-data-consists-of-list-of-entries"), + 'subject': i18next.t("email-service.email-data.subject-data-dump-from-to", {start: startTimeString ,end: endTimeString}), + } + window['plugins'].socialsharing.shareWithOptions(email, function (result) { + logDebug(`Share Completed? ${result.completed}`); // On Android, most likely returns false + logDebug(`Shared to app: ${result.app}`); + resolve(); + }, function (msg) { + logDebug(`Sharing failed with message ${msg}`); + }); + } + reader.readAsText(file); + }, function(error) { + displayError(error, "Error while downloading JSON dump"); + reject(error); + }) }); - }; + }); + }); +}}; + +/** + * getMyData fetches timeline data for a given day, and then gives the user a prompt to share the data + * @param startTs initial timestamp of the timeline to be fetched. + */ +export const getMyData = function(startTs: Date) { + // We are only retrieving data for a single day to avoid + // running out of memory on the phone + const startTime = DateTime.fromJSDate(startTs); + const endTime = startTime.endOf("day"); + const startTimeString = startTime.toFormat("yyyy'-'MM'-'dd"); + const endTimeString = endTime.toFormat("yyyy'-'MM'-'dd"); + + const dumpFile = startTimeString + "." + + endTimeString + + ".timeline"; + alert(`Going to retrieve data to ${dumpFile}`); // Simulate old conversion to get correct UnixInteger for endMoment data const getUnixNum = (dateData: DateTime) => { @@ -108,7 +125,11 @@ export const getMyData = function(startTs: Date) { return DateTime.fromFormat(tempDate, "dd MMM yyyy").toUnixInteger(); }; + const writeDumpFile = createWriteFile(dumpFile); + const shareData = createShareData(dumpFile, startTimeString, endTimeString); + getRawEntries(null, getUnixNum(startTime), startTime.toUnixInteger()) + .then(result => Promise.resolve(dumpFile).then(() => result)) .then(writeDumpFile) .then(shareData) .then(function() { From e12ee3d326046ec4d356158d7f2579e2252f6c47 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 16 Oct 2023 15:24:38 -0600 Subject: [PATCH 138/850] resolve vscode errors there were errors because I was accessing with window.cordova instead of window['cordova'] --- www/js/control/ControlSyncHelper.tsx | 40 ++++++++++++++-------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/www/js/control/ControlSyncHelper.tsx b/www/js/control/ControlSyncHelper.tsx index edc0e7470..7802caaeb 100644 --- a/www/js/control/ControlSyncHelper.tsx +++ b/www/js/control/ControlSyncHelper.tsx @@ -15,7 +15,7 @@ import { updateUser } from "../commHelper"; * BEGIN: Simple read/write wrappers */ export function forcePluginSync() { - return window.cordova.plugins.BEMServerSync.forceSync(); + return window['cordova'].plugins.BEMServerSync.forceSync(); }; const formatConfigForDisplay = (configToFormat) => { @@ -27,11 +27,11 @@ const formatConfigForDisplay = (configToFormat) => { } const setConfig = function(config) { - return window.cordova.plugins.BEMServerSync.setConfig(config); + return window['cordova'].plugins.BEMServerSync.setConfig(config); }; const getConfig = function() { - return window.cordova.plugins.BEMServerSync.getConfig(); + return window['cordova'].plugins.BEMServerSync.getConfig(); }; export async function getHelperSyncSettings() { @@ -40,10 +40,10 @@ export async function getHelperSyncSettings() { } const getEndTransitionKey = function() { - if(window.cordova.platformId == 'android') { + if(window['cordova'].platformId == 'android') { return "local.transition.stopped_moving"; } - else if(window.cordova.platformId == 'ios') { + else if(window['cordova'].platformId == 'ios') { return "T_TRIP_ENDED"; } } @@ -62,7 +62,7 @@ export const ForceSyncRow = ({getState}) => { async function forceSync() { try { - let addedEvent = addStatEvent(statKeys.BUTTON_FORCE_SYNC); + let addedEvent = await addStatEvent(statKeys.BUTTON_FORCE_SYNC); console.log("Added "+statKeys.BUTTON_FORCE_SYNC+" event"); let sync = await forcePluginSync(); @@ -72,7 +72,7 @@ export const ForceSyncRow = ({getState}) => { * See https://github.com/e-mission/e-mission-phone/issues/279 for details */ var sensorKey = "statemachine/transition"; - let sensorDataList = await window.cordova.plugins.BEMUserCache.getAllMessages(sensorKey, true); + let sensorDataList = await window['cordova'].plugins.BEMUserCache.getAllMessages(sensorKey, true); // If everything has been pushed, we should // have no more trip end transitions left @@ -98,28 +98,28 @@ export const ForceSyncRow = ({getState}) => { }; const getStartTransitionKey = function() { - if(window.cordova.platformId == 'android') { + if(window['cordova'].platformId == 'android') { return "local.transition.exited_geofence"; } - else if(window.cordova.platformId == 'ios') { + else if(window['cordova'].platformId == 'ios') { return "T_EXITED_GEOFENCE"; } } const getEndTransitionKey = function() { - if(window.cordova.platformId == 'android') { + if(window['cordova'].platformId == 'android') { return "local.transition.stopped_moving"; } - else if(window.cordova.platformId == 'ios') { + else if(window['cordova'].platformId == 'ios') { return "T_TRIP_ENDED"; } } const getOngoingTransitionState = function() { - if(window.cordova.platformId == 'android') { + if(window['cordova'].platformId == 'android') { return "local.state.ongoing_trip"; } - else if(window.cordova.platformId == 'ios') { + else if(window['cordova'].platformId == 'ios') { return "STATE_ONGOING_TRIP"; } } @@ -127,12 +127,12 @@ export const ForceSyncRow = ({getState}) => { async function getTransition(transKey) { var entry_data = {}; const curr_state = await getState(); - entry_data.curr_state = curr_state; + entry_data['curr_state'] = curr_state; if (transKey == getEndTransitionKey()) { - entry_data.curr_state = getOngoingTransitionState(); + entry_data['curr_state'] = getOngoingTransitionState(); } - entry_data.transition = transKey; - entry_data.ts = moment().unix(); + entry_data['transition'] = transKey; + entry_data['ts'] = moment().unix(); return entry_data; } @@ -141,9 +141,9 @@ export const ForceSyncRow = ({getState}) => { * result for start so that we ensure ordering */ var sensorKey = "statemachine/transition"; let entry_data = await getTransition(getStartTransitionKey()); - let messagePut = await window.cordova.plugins.BEMUserCache.putMessage(sensorKey, entry_data); + let messagePut = await window['cordova'].plugins.BEMUserCache.putMessage(sensorKey, entry_data); entry_data = await getTransition(getEndTransitionKey()); - messagePut = await window.cordova.plugins.BEMUserCache.putMessage(sensorKey, entry_data); + messagePut = await window['cordova'].plugins.BEMUserCache.putMessage(sensorKey, entry_data); forceSync(); }; @@ -246,7 +246,7 @@ const ControlSyncHelper = ({ editVis, setEditVis }) => { * configure the UI */ let toggle; - if(window.cordova.platformId == 'ios'){ + if(window['cordova'].platformId == 'ios'){ toggle = Use Remote Push From ff30d3240e4d5d2b01891789628df1d5e4953cda Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 16 Oct 2023 15:39:05 -0600 Subject: [PATCH 139/850] update test after the change from miliseconds to seconds, the expected output here changed https://github.com/e-mission/e-mission-phone/pull/1063#issuecomment-1764950924 --- www/__tests__/enketoHelper.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index 576b77e18..2c0abd225 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -59,7 +59,7 @@ it('resolves the timestamps', () => { //good info returns unix start and end timestamps -- TODO : address precise vs less precise? const timeSurvey = ' 2016-07-25 2016-07-25 17:24:32.928-06:00 17:30:31.000-06:00 '; const xmlDoc = xmlParser.parseFromString(timeSurvey, 'text/xml'); - expect(resolveTimestamps(xmlDoc, timelineEntry)).toMatchObject({start_ts: 1469492672928, end_ts: 1469493031000}); + expect(resolveTimestamps(xmlDoc, timelineEntry)).toMatchObject({start_ts: 1469492672.928242, end_ts: 1469493031}); }); //resolve label From 82f7be6f8d1236a4b995ce9edb666ffab688664e Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Mon, 16 Oct 2023 17:09:58 -0700 Subject: [PATCH 140/850] json.config.json --- jest.config.json | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 jest.config.json diff --git a/jest.config.json b/jest.config.json new file mode 100644 index 000000000..e69de29bb From 6b594916ce5aa16a708bebe2d72be599e1bc9a27 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Mon, 16 Oct 2023 17:11:12 -0700 Subject: [PATCH 141/850] delete jest.config.json --- jest.config.json | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 jest.config.json diff --git a/jest.config.json b/jest.config.json deleted file mode 100644 index e69de29bb..000000000 From 140ff671122b5523090142a65ee7bc3026bbdd0e Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Mon, 16 Oct 2023 17:14:58 -0700 Subject: [PATCH 142/850] add file to resolve deleted file merge conflict --- jest.config.json | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 jest.config.json diff --git a/jest.config.json b/jest.config.json new file mode 100644 index 000000000..e69de29bb From a289789ca5620c23319f6a7fdbcfa41e132e68f1 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Mon, 16 Oct 2023 17:16:50 -0700 Subject: [PATCH 143/850] delete jest.config.json --- jest.config.json | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 jest.config.json diff --git a/jest.config.json b/jest.config.json deleted file mode 100644 index e69de29bb..000000000 From b6e3c65f74fa31f6dc7e0c2d9044875da82db8e7 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Tue, 17 Oct 2023 10:42:13 -0600 Subject: [PATCH 144/850] Update README.md Adding suggested changes Co-authored-by: shankari --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7fca214aa..6fdf01891 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ This is the phone component of the e-mission system. :sparkles: This has been upgraded to the latest **Android**, **iOS**, **cordova-lib**, **node** and **npm** versions. __This is ready to build out of the box.__ -For the latest versions, refer [`package.cordovabuild.json`](package.cordovabuild.json) +The currently supported versions are in [`package.cordovabuild.json`](package.cordovabuild.json) Additional Documentation --- From 2b5735e411a514e0e5aa1aa74dadf2902ee6cc38 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Tue, 17 Oct 2023 10:43:10 -0600 Subject: [PATCH 145/850] Update README.md Committing suggested changes Co-authored-by: Jack Greenlee --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6fdf01891..9db9d3798 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ AND/OR ``` npm run ``` -for builds, refer [`package.cordovabuild.json`](package.cordovabuild.json) +For other options of build scripts, refer to [`package.cordovabuild.json`](package.cordovabuild.json)
Expected output From cb954d1604a34cbc897992835539ebaaecc2bdd2 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Tue, 17 Oct 2023 10:54:17 -0600 Subject: [PATCH 146/850] Update README.md Committing changes based on suggestions - Wording - Listing common build types --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 9db9d3798..59f725934 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,12 @@ AND/OR npm run ``` For other options of build scripts, refer to [`package.cordovabuild.json`](package.cordovabuild.json) +Common ones listed below (both, Android, iOS) +``` +npm run build +npm run build-prod-android +npm run build-prod-ios +```
Expected output From ebb6eae5071af6fdfdd354febbe187c1ad9a01c4 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Tue, 17 Oct 2023 10:59:20 -0600 Subject: [PATCH 147/850] Adding more thorough ImperialConfig testing useImperialConfig.test.ts - Used more values to test the limit more on the formatForDisplay unit tests --- www/__tests__/useImperialConfig.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/www/__tests__/useImperialConfig.test.ts b/www/__tests__/useImperialConfig.test.ts index b78fe974d..cab1b5a11 100644 --- a/www/__tests__/useImperialConfig.test.ts +++ b/www/__tests__/useImperialConfig.test.ts @@ -14,17 +14,24 @@ jest.mock('../js/useAppConfig', () => { describe('formatForDisplay', () => { it('should round to the nearest integer when value is >= 100', () => { expect(formatForDisplay(105)).toBe('105'); - expect(formatForDisplay(119)).toBe('119'); + expect(formatForDisplay(119.01)).toBe('119'); + expect(formatForDisplay(119.91)).toBe('120'); }); it('should round to 3 significant digits when 1 <= value < 100', () => { expect(formatForDisplay(7.02)).toBe('7.02'); - expect(formatForDisplay(11.3)).toBe('11.3'); + expect(formatForDisplay(9.6262)).toBe('9.63'); + expect(formatForDisplay(11.333)).toBe('11.3'); + expect(formatForDisplay(99.99)).toBe('100'); }); it('should round to 2 decimal places when value < 1', () => { - expect(formatForDisplay(0.07)).toBe('0.07'); + expect(formatForDisplay(0.07178)).toBe('0.07'); + expect(formatForDisplay(0.08978)).toBe('0.09'); expect(formatForDisplay(0.75)).toBe('0.75'); + expect(formatForDisplay(0.001)).toBe('0'); + expect(formatForDisplay(0.006)).toBe('0.01'); + expect(formatForDisplay(0.00001)).toBe('0'); }); }); From 36b56daad2be26a6ce0ed1241797f6048707a65e Mon Sep 17 00:00:00 2001 From: Sebastian Barry <61334340+sebastianbarry@users.noreply.github.com> Date: Tue, 17 Oct 2023 11:07:17 -0600 Subject: [PATCH 148/850] Correct text in comment > JGreenlee > It's the other way around. labelOptions first, then i18next. > The code is correct but the comment is not --- www/js/survey/multilabel/confirmHelper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index c7cf74c26..cb94c1f2e 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -50,7 +50,7 @@ export async function getLabelOptions(appConfigParam?) { for (const opt in labelOptions) { labelOptions[opt]?.forEach?.((o, i) => { const translationKey = o.value; - // If translation exists in i18next, use that. Otherwise, use the one in the labelOptions. + // If translation exists in labelOptions, use that. Otherwise, use the one in the i18next. const translation = labelOptions.translations[lang][translationKey] || i18next.t(`multilabel.${translationKey}`); labelOptions[opt][i].text = translation; }); From 202f6dbbe71db89cce6e9e83a9a4a1dc5362d082 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Tue, 17 Oct 2023 11:11:45 -0600 Subject: [PATCH 149/850] Adding spaces between slashes in text > JGreenlee > While we are rewriting and have this opportunity, can we make all the text with slashes look "like / this" instead of "like/ this"? > I believe they were made like that a while ago to force a line break. But we don't do that anymore in the UI so the slashes end up looking weird. --- www/i18n/en.json | 12 ++++++------ www/json/label-options.json.sample | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/www/i18n/en.json b/www/i18n/en.json index 07d31c2b3..9217339f7 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -157,7 +157,7 @@ "e_car_drove_alone": "E-Car Drove Alone", "e_car_shared_ride": "E-Car Shared Ride", "moped": "Moped", - "taxi": "Taxi/Uber/Lyft", + "taxi": "Taxi / Uber / Lyft", "bus": "Bus", "train": "Train", "free_shuttle": "Free Shuttle", @@ -171,12 +171,12 @@ "transit_transfer": "Transit transfer", "shopping": "Shopping", "meal": "Meal", - "pick_drop_person": "Pick-up/ Drop off Person", - "pick_drop_item": "Pick-up/ Drop off Item", - "personal_med": "Personal/ Medical", + "pick_drop_person": "Pick-up / Drop off Person", + "pick_drop_item": "Pick-up / Drop off Item", + "personal_med": "Personal / Medical", "access_recreation": "Access Recreation", - "exercise": "Recreation/ Exercise", - "entertainment": "Entertainment/ Social", + "exercise": "Recreation / Exercise", + "entertainment": "Entertainment / Social", "religious": "Religious", "other": "Other" }, diff --git a/www/json/label-options.json.sample b/www/json/label-options.json.sample index 9d3447bda..0f4292c7f 100644 --- a/www/json/label-options.json.sample +++ b/www/json/label-options.json.sample @@ -63,7 +63,7 @@ "e_car_drove_alone": "E-Car Drove Alone", "e_car_shared_ride": "E-Car Shared Ride", "moped": "Moped", - "taxi": "Taxi/Uber/Lyft", + "taxi": "Taxi / Uber / Lyft", "bus": "Bus", "train": "Train", "free_shuttle": "Free Shuttle", @@ -77,12 +77,12 @@ "transit_transfer": "Transit transfer", "shopping": "Shopping", "meal": "Meal", - "pick_drop_person": "Pick-up/ Drop off Person", - "pick_drop_item": "Pick-up/ Drop off Item", - "personal_med": "Personal/ Medical", + "pick_drop_person": "Pick-up / Drop off Person", + "pick_drop_item": "Pick-up / Drop off Item", + "personal_med": "Personal / Medical", "access_recreation": "Access Recreation", - "exercise": "Recreation/ Exercise", - "entertainment": "Entertainment/ Social", + "exercise": "Recreation / Exercise", + "entertainment": "Entertainment / Social", "religious": "Religious", "other": "Other" }, @@ -97,7 +97,7 @@ "e_car_drove_alone": "e-coche, Condujo solo", "e_car_shared_ride": "e-coche, Condujo con ontras", "moped": "Ciclomotor", - "taxi": "Taxi/Uber/Lyft", + "taxi": "Taxi / Uber / Lyft", "bus": "Autobús", "train": "Tren", "free_shuttle": "Colectivo gratuito", @@ -111,12 +111,12 @@ "transit_transfer": "Transbordo", "shopping": "Compras", "meal": "Comida", - "pick_drop_person": "Recoger/ Entregar Individuo", - "pick_drop_item": "Recoger/ Entregar Objeto", - "personal_med": "Personal/ Médico", + "pick_drop_person": "Recoger / Entregar Individuo", + "pick_drop_item": "Recoger / Entregar Objeto", + "personal_med": "Personal / Médico", "access_recreation": "Acceder a Recreación", - "exercise": "Recreación/ Ejercicio", - "entertainment": "Entretenimiento/ Social", + "exercise": "Recreación / Ejercicio", + "entertainment": "Entretenimiento / Social", "religious": "Religioso", "other": "Otros" } From b43b6a0703a2d4ef59b05f6868d650698d629038 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Tue, 17 Oct 2023 10:45:27 -0700 Subject: [PATCH 150/850] remane input-matcher to inputMatcher (follow camelCase rule) --- www/__tests__/{input-matcher.test.ts => inputMatcher.test.ts} | 2 +- www/js/survey/enketo/enketo-add-note-button.js | 2 +- www/js/survey/enketo/enketo-trip-button.js | 2 +- www/js/survey/{input-matcher.ts => inputMatcher.ts} | 0 www/js/survey/multilabel/multi-label-ui.js | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) rename www/__tests__/{input-matcher.test.ts => inputMatcher.test.ts} (99%) rename www/js/survey/{input-matcher.ts => inputMatcher.ts} (100%) diff --git a/www/__tests__/input-matcher.test.ts b/www/__tests__/inputMatcher.test.ts similarity index 99% rename from www/__tests__/input-matcher.test.ts rename to www/__tests__/inputMatcher.test.ts index 6a6c4c82a..503eb456e 100644 --- a/www/__tests__/input-matcher.test.ts +++ b/www/__tests__/inputMatcher.test.ts @@ -9,7 +9,7 @@ import { getUserInputForTrip, getAdditionsForTimelineEntry, getUniqueEntries -} from '../js/survey/input-matcher'; +} from '../js/survey/inputMatcher'; describe('input-matcher', () => { let userTrip: UserInputForTrip; diff --git a/www/js/survey/enketo/enketo-add-note-button.js b/www/js/survey/enketo/enketo-add-note-button.js index d0433b314..e1db494d0 100644 --- a/www/js/survey/enketo/enketo-add-note-button.js +++ b/www/js/survey/enketo/enketo-add-note-button.js @@ -3,7 +3,7 @@ */ import angular from 'angular'; -import { getAdditionsForTimelineEntry, getUniqueEntries } from '../input-matcher'; +import { getAdditionsForTimelineEntry, getUniqueEntries } from '../inputMatcher'; angular.module('emission.survey.enketo.add-note-button', ['emission.stats.clientstats', diff --git a/www/js/survey/enketo/enketo-trip-button.js b/www/js/survey/enketo/enketo-trip-button.js index eacb7c61d..91c2d2a56 100644 --- a/www/js/survey/enketo/enketo-trip-button.js +++ b/www/js/survey/enketo/enketo-trip-button.js @@ -12,7 +12,7 @@ */ import angular from 'angular'; -import { getUserInputForTrip } from '../input-matcher'; +import { getUserInputForTrip } from '../inputMatcher'; angular.module('emission.survey.enketo.trip.button', ['emission.stats.clientstats', diff --git a/www/js/survey/input-matcher.ts b/www/js/survey/inputMatcher.ts similarity index 100% rename from www/js/survey/input-matcher.ts rename to www/js/survey/inputMatcher.ts diff --git a/www/js/survey/multilabel/multi-label-ui.js b/www/js/survey/multilabel/multi-label-ui.js index f9aa3b323..09d52e492 100644 --- a/www/js/survey/multilabel/multi-label-ui.js +++ b/www/js/survey/multilabel/multi-label-ui.js @@ -1,7 +1,7 @@ import angular from 'angular'; import { baseLabelInputDetails, getBaseLabelInputs, getFakeEntry, getLabelInputDetails, getLabelInputs, getLabelOptions } from './confirmHelper'; import { getConfig } from '../../config/dynamicConfig'; -import { getUserInputForTrip } from '../input-matcher'; +import { getUserInputForTrip } from '../inputMatcher'; angular.module('emission.survey.multilabel.buttons', ['emission.stats.clientstats']) From ffcc871d90ad78a3b95eac339292948632c04d02 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Tue, 17 Oct 2023 12:44:34 -0700 Subject: [PATCH 151/850] move trip related types to 'dairyTypes' file in 'types' directory --- www/__tests__/inputMatcher.test.ts | 7 ++- www/js/survey/inputMatcher.ts | 62 +++------------------------ www/js/{diary => types}/diaryTypes.ts | 56 ++++++++++++++++++++++-- 3 files changed, 62 insertions(+), 63 deletions(-) rename www/js/{diary => types}/diaryTypes.ts (73%) diff --git a/www/__tests__/inputMatcher.test.ts b/www/__tests__/inputMatcher.test.ts index 503eb456e..ac14a506b 100644 --- a/www/__tests__/inputMatcher.test.ts +++ b/www/__tests__/inputMatcher.test.ts @@ -1,6 +1,4 @@ import { - TlEntry, - UserInputForTrip, fmtTs, printUserInput, validUserInputForDraftTrip, @@ -10,9 +8,10 @@ import { getAdditionsForTimelineEntry, getUniqueEntries } from '../js/survey/inputMatcher'; +import { TlEntry, UserInput } from '../js/types/diaryTypes'; describe('input-matcher', () => { - let userTrip: UserInputForTrip; + let userTrip: UserInput; let trip: TlEntry; beforeEach(() => { @@ -251,4 +250,4 @@ describe('input-matcher', () => { const uniqueEntires = getUniqueEntries([]); expect(uniqueEntires).toMatchObject([]); }); -}) \ No newline at end of file +}) diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index f318ac255..c6c8ed61c 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -1,63 +1,15 @@ import { logDebug, displayErrorMsg } from "../plugin/logger" import { DateTime } from "luxon"; - -export type LocalDt = { - minute: number, - hour: number, - second: number, - day: number, - weekday: number, - month: number, - year: number, - timezone: string -} - -export type UserInputForTrip = { - data: { - end_ts: number, - start_ts: number - label: string, - start_local_dt?: LocalDt - end_local_dt?: LocalDt - status?: string, - match_id?: string - }, - metadata: { - time_zone: string, - plugin: string, - write_ts: number, - platform: string, - read_ts: number, - key: string - }, - key?: string, -} - -export type Trip = { - end_ts: number, - start_ts: number -} - -export type TlEntry = { - key: string, - origin_key: string, - start_ts: number, - end_ts: number, - enter_ts: number, - exit_ts: number, - duration: number, - getNextEntry: any -} - +import { UserInput, Trip, TlEntry } from "../types/diaryTypes"; const EPOCH_MAXIMUM = 2**31 - 1; export const fmtTs = (ts_in_secs: number, tz: string): string | null => DateTime.fromSeconds(ts_in_secs, {zone : tz}).toISO(); -export const printUserInput = (ui: UserInputForTrip): string => `${fmtTs(ui.data.start_ts, ui.metadata.time_zone)} (${ui.data.start_ts}) -> +export const printUserInput = (ui: UserInput): string => `${fmtTs(ui.data.start_ts, ui.metadata.time_zone)} (${ui.data.start_ts}) -> ${fmtTs(ui.data.end_ts, ui.metadata.time_zone)} (${ui.data.end_ts}) ${ui.data.label} logged at ${ui.metadata.write_ts}`; -export const validUserInputForDraftTrip = (trip: Trip, userInput: UserInputForTrip, logsEnabled: boolean): boolean => { +export const validUserInputForDraftTrip = (trip: Trip, userInput: UserInput, logsEnabled: boolean): boolean => { if(logsEnabled) { logDebug(`Draft trip: comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} @@ -75,7 +27,7 @@ export const validUserInputForDraftTrip = (trip: Trip, userInput: UserInputForTr || -(userInput.data.start_ts - trip.start_ts) <= 15 * 60) && userInput.data.end_ts <= trip.end_ts; } -export const validUserInputForTimelineEntry = (tlEntry: TlEntry, userInput: UserInputForTrip, logsEnabled: boolean): boolean => { +export const validUserInputForTimelineEntry = (tlEntry: TlEntry, userInput: UserInput, logsEnabled: boolean): boolean => { if (!tlEntry.origin_key) return false; if (tlEntry.origin_key.includes('UNPROCESSED')) return validUserInputForDraftTrip(tlEntry, userInput, logsEnabled); @@ -149,7 +101,7 @@ export const validUserInputForTimelineEntry = (tlEntry: TlEntry, userInput: User } // parallels get_not_deleted_candidates() in trip_queries.py -export const getNotDeletedCandidates = (candidates: UserInputForTrip[]): UserInputForTrip[] => { +export const getNotDeletedCandidates = (candidates: UserInput[]): UserInput[] => { console.log('getNotDeletedCandidates called with ' + candidates.length + ' candidates'); // We want to retain all ACTIVE entries that have not been DELETED @@ -163,7 +115,7 @@ export const getNotDeletedCandidates = (candidates: UserInputForTrip[]): UserInp return notDeletedActive; } -export const getUserInputForTrip = (trip: TlEntry, nextTrip: any, userInputList: UserInputForTrip[]): undefined | UserInputForTrip => { +export const getUserInputForTrip = (trip: TlEntry, nextTrip: any, userInputList: UserInput[]): undefined | UserInput => { const logsEnabled = userInputList?.length < 20; if (userInputList === undefined) { logDebug("In getUserInputForTrip, no user input, returning undefined"); @@ -195,7 +147,7 @@ export const getUserInputForTrip = (trip: TlEntry, nextTrip: any, userInputList } // return array of matching additions for a trip or place -export const getAdditionsForTimelineEntry = (entry: TlEntry, additionsList: UserInputForTrip[]): UserInputForTrip[] => { +export const getAdditionsForTimelineEntry = (entry: TlEntry, additionsList: UserInput[]): UserInput[] => { const logsEnabled = additionsList?.length < 20; if (additionsList === undefined) { diff --git a/www/js/diary/diaryTypes.ts b/www/js/types/diaryTypes.ts similarity index 73% rename from www/js/diary/diaryTypes.ts rename to www/js/types/diaryTypes.ts index bcaeb83ae..b12e58543 100644 --- a/www/js/diary/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -21,7 +21,7 @@ export type CompositeTrip = { end_confirmed_place: ConfirmedPlace, end_fmt_time: string, end_loc: {type: string, coordinates: number[]}, - end_local_dt: any, // TODO + end_local_dt: LocalDt, end_place: {$oid: string}, end_ts: number, expectation: any, // TODO "{to_label: boolean}" @@ -38,10 +38,10 @@ export type CompositeTrip = { start_confirmed_place: ConfirmedPlace, start_fmt_time: string, start_loc: {type: string, coordinates: number[]}, - start_local_dt: any, // TODO + start_local_dt: LocalDt, start_place: {$oid: string}, start_ts: number, - user_input: any, // TODO + user_input: UserInput, } /* These properties aren't received from the server, but are derived from the above properties. @@ -67,6 +67,54 @@ export type PopulatedTrip = CompositeTrip & { finalInference?: any, // TODO geojson?: any, // TODO getNextEntry?: () => PopulatedTrip | ConfirmedPlace, - userInput?: any, // TODO + userInput?: UserInput, verifiability?: string, } + +export type UserInput = { + data: { + end_ts: number, + start_ts: number + label: string, + start_local_dt?: LocalDt + end_local_dt?: LocalDt + status?: string, + match_id?: string, + }, + metadata: { + time_zone: string, + plugin: string, + write_ts: number, + platform: string, + read_ts: number, + key: string, + }, + key?: string +} + +export type LocalDt = { + minute: number, + hour: number, + second: number, + day: number, + weekday: number, + month: number, + year: number, + timezone: string, +} + +export type Trip = { + end_ts: number, + start_ts: number, +} + +export type TlEntry = { + key: string, + origin_key: string, + start_ts: number, + end_ts: number, + enter_ts: number, + exit_ts: number, + duration: number, + getNextEntry?: () => PopulatedTrip | ConfirmedPlace, +} From 9be110d47e85b9d3aeecad5ee68502eabae535f6 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 17 Oct 2023 15:45:54 -0400 Subject: [PATCH 152/850] i18n: fix merge translations if structure differs If the structure is different between lang and fallbackLang such that a key is an object in fallbackLang and a string in lang, we experience an error when we attempt to assign fill in the property to the string. This change will skip filling in if the key is not an object in *both* lang and fallbackLang, thus preventing the error. --- www/js/i18nextInit.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/js/i18nextInit.ts b/www/js/i18nextInit.ts index 48177caf5..a2688d66e 100644 --- a/www/js/i18nextInit.ts +++ b/www/js/i18nextInit.ts @@ -22,14 +22,14 @@ const mergeInTranslations = (lang, fallbackLang) => { if (__DEV__) { if (typeof value === 'string') { lang[key] = `🌐${value}` - } else if (typeof value === 'object') { + } else if (typeof value === 'object' && typeof lang[key] === 'object') { lang[key] = {}; mergeInTranslations(lang[key], value); } } else { lang[key] = value; } - } else if (typeof value === 'object') { + } else if (typeof value === 'object' && typeof lang[key] === 'object') { mergeInTranslations(lang[key], fallbackLang[key]) } }); From 259778a0d994846693de0ec405e051f8ab09f040 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Tue, 17 Oct 2023 14:22:47 -0600 Subject: [PATCH 153/850] Changed "typeof x === 'undefined'" to !x This handles all falsy/truthy values instead of only checking for undefined --- www/js/survey/multilabel/infinite_scroll_filters.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/www/js/survey/multilabel/infinite_scroll_filters.ts b/www/js/survey/multilabel/infinite_scroll_filters.ts index 68c28b35e..8d71266d9 100644 --- a/www/js/survey/multilabel/infinite_scroll_filters.ts +++ b/www/js/survey/multilabel/infinite_scroll_filters.ts @@ -10,21 +10,21 @@ import i18next from "i18next"; const unlabeledCheck = (t) => { return t.INPUTS - .map((inputType, index) => typeof t.userInput[inputType] === 'undefined') + .map((inputType, index) => !t.userInput[inputType]) .reduce((acc, val) => acc || val, false); } const invalidCheck = (t) => { const retVal = - (typeof t.userInput['MODE'] !== 'undefined' && t.userInput['MODE'].value === 'pilot_ebike') && - (typeof t.userInput['REPLACED_MODE'] === 'undefined' || + (t.userInput['MODE'] && t.userInput['MODE'].value === 'pilot_ebike') && + (!t.userInput['REPLACED_MODE'] || t.userInput['REPLACED_MODE'].value === 'pilot_ebike' || t.userInput['REPLACED_MODE'].value === 'same_mode'); return retVal; } const toLabelCheck = (trip) => { - if (typeof trip.expectation !== 'undefined') { + if (trip.expectation) { console.log(trip.expectation.to_label) return trip.expectation.to_label && unlabeledCheck(trip); } else { From 6ef2725b21a5bd613752904b93af346fd543702a Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 17 Oct 2023 15:00:21 -0600 Subject: [PATCH 154/850] move the declaration of _config https://github.com/e-mission/e-mission-phone/pull/1063#discussion_r1362757509 --- www/js/survey/enketo/enketoHelper.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index 0aab833b6..f9a6ddf7a 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -42,10 +42,6 @@ type EnketoSurveyConfig = { } } -/** @type {EnketoSurveyConfig} _config */ -//TODO find a more appropriate way to store this -let _config: EnketoSurveyConfig; - const LABEL_FUNCTIONS = { UseLabelTemplate : async (xmlDoc: XMLDocument, name: string) => { let configSurveys = await _lazyLoadConfig(); @@ -92,6 +88,9 @@ const LABEL_FUNCTIONS = { return val; } +/** @type {EnketoSurveyConfig} _config */ +let _config: EnketoSurveyConfig; + /** * _lazyLoadConfig load enketo survey config. If already loaded, return the cached config * @returns {Promise} enketo survey config From b48027958eb25a1e2f79f75dec53abd3f93a2ab8 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 17 Oct 2023 15:13:41 -0600 Subject: [PATCH 155/850] move the fake config, add on Moving the fake config into it's own file so that we can easily add onto it. I kept the survey portion that I set up for the enketoHelper tests, and added the rest of the sections found in a typical config. https://github.com/e-mission/e-mission-phone/pull/1063#discussion_r1362749725 --- www/__mocks__/cordovaMocks.ts | 15 +----- www/__mocks__/fakeConfig.json | 94 +++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 13 deletions(-) create mode 100644 www/__mocks__/fakeConfig.json diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index bae65ad88..db22627f5 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -1,4 +1,5 @@ import packageJsonBuild from '../../package.cordovabuild.json'; +import fakeConfig from "./fakeConfig.json"; export const mockCordova = () => { window['cordova'] ||= {}; @@ -90,19 +91,7 @@ export const mockBEMUserCache = () => { }, getDocument: (key: string, withMetadata?: boolean) => { // this was mocked specifically for enketoHelper's use, could be expanded if needed - const fakeSurveyConfig = { - survey_info: { - surveys: { - TimeUseSurvey: { compatibleWith: 1, - formPath: "https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json", - labelTemplate: {en: "{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }", - es: "{ erea, plural, =0 {} other {# Empleo/Educación, } }{ da, plural, =0 {} other {# Actividades domesticas, }}"}, - labelVars: {da: {key: "Domestic_activities", type: "length"}, - erea: {key: "Employment_related_a_Education_activities", type:"length"}}, - version: 9} - } - } - } + const fakeSurveyConfig = fakeConfig; if(key == "config/app_ui_config"){ return new Promise((rs, rj) => diff --git a/www/__mocks__/fakeConfig.json b/www/__mocks__/fakeConfig.json new file mode 100644 index 000000000..40471718d --- /dev/null +++ b/www/__mocks__/fakeConfig.json @@ -0,0 +1,94 @@ +{ + "version": 1, + "ts": 1655143472, + "server": { + "connectUrl": "https://openpath-test.nrel.gov/api/", + "aggregate_call_auth": "user_only" + }, + "intro": { + "program_or_study": "study", + "start_month": "10", + "start_year": "2023", + "program_admin_contact": "K. Shankari", + "deployment_partner_name": "NREL", + "translated_text": { + "en": { + "deployment_partner_name": "NREL", + "deployment_name": "Testing environment for Jest testing", + "summary_line_1": "", + "summary_line_2": "", + "summary_line_3": "", + "short_textual_description": "", + "why_we_collect": "", + "research_questions": [ + "", + "" + ] + }, + "es": { + "deployment_partner_name": "NREL", + "deployment_name": "Ambiente prueba para las pruebas de Jest", + "summary_line_1": "", + "summary_line_2": "", + "summary_line_3": "", + "short_textual_description": "", + "why_we_collect": "", + "research_questions": [ + "", + "" + ] + } + } + }, + "survey_info": { + "surveys": { + "TimeUseSurvey": { + "compatibleWith": 1, + "formPath": "https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json", + "labelTemplate": { + "en": "{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }", + "es": "{ erea, plural, =0 {} other {# Empleo/Educación, } }{ da, plural, =0 {} other {# Actividades domesticas, }}" + }, + "labelVars": { + "da": { + "key": "Domestic_activities", + "type": "length" + }, + "erea": { + "key": "Employment_related_a_Education_activities", + "type": "length" + } + }, + "version": 9 + } + }, + "trip-labels": "ENKETO" + }, + "display_config": { + "use_imperial": false + }, + "profile_controls": { + "support_upload": true, + "trip_end_notification": false + }, + "admin_dashboard": { + "overview_users": true, + "overview_active_users": true, + "overview_trips": true, + "overview_signup_trends": true, + "overview_trips_trend": true, + "data_uuids": true, + "data_trips": true, + "data_trips_columns_exclude": [], + "additional_trip_columns": [], + "data_uuids_columns_exclude": [], + "token_generate": true, + "token_prefix": "nrelop", + "map_heatmap": true, + "map_bubble": true, + "map_trip_lines": true, + "push_send": true, + "options_uuids": true, + "options_emails": true + } +} \ No newline at end of file From 0e31d1549159114e542d70a6f16f6bdbe1b1b232 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 17 Oct 2023 16:18:26 -0600 Subject: [PATCH 156/850] remove unused imports from startprefts.js --- www/js/splash/startprefs.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/www/js/splash/startprefs.js b/www/js/splash/startprefs.js index 92a07e624..1bc01af3d 100644 --- a/www/js/splash/startprefs.js +++ b/www/js/splash/startprefs.js @@ -2,11 +2,10 @@ import angular from 'angular'; import { getConfig } from '../config/dynamicConfig'; import { storageGet, storageSet } from '../plugin/storage'; -angular.module('emission.splash.startprefs', ['emission.plugin.logger', - 'emission.splash.referral']) +angular.module('emission.splash.startprefs', ['emission.plugin.logger']) -.factory('StartPrefs', function($window, $state, $interval, $rootScope, $ionicPlatform, - $ionicPopup, $http, Logger, ReferralHandler) { +.factory('StartPrefs', function($window, $state, $rootScope, + $ionicPopup, $http, Logger) { var logger = Logger; var startprefs = {}; // Boolean: represents that the "intro" - the one page summary From d31a7a18d64384b0dab73d5ef4fdecb04802d5ad Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Tue, 17 Oct 2023 16:25:13 -0700 Subject: [PATCH 157/850] Set Up tests for controlHelper - Wrote interface for the objects that return from getRawData - Wrote small function to initialize test data - Minor adjustments to `controlHelper.ts` - Initial promise check; passes test data - TODO: Write tests to check if file is actually created, verify the contents of the file, test the function on empty data. --- www/__tests__/controlHelper.test.ts | 56 +++++++++++++++++++++++++++++ www/js/controlHelper.ts | 7 ++-- 2 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 www/__tests__/controlHelper.test.ts diff --git a/www/__tests__/controlHelper.test.ts b/www/__tests__/controlHelper.test.ts new file mode 100644 index 000000000..599114676 --- /dev/null +++ b/www/__tests__/controlHelper.test.ts @@ -0,0 +1,56 @@ +import { mockLogger } from "../__mocks__/globalMocks"; +import { createWriteFile } from "../js/controlHelper"; + +mockLogger(); + +// See PR 1052 for a detailed interface +interface dataObj { + data: { + name: string, + ts: number, + reading: number + }, + metadata: any, + user_id: { + $uuid: string + }, + _id: { + $oid: string + } +} + +// These are fake values; createWriteFile does not require these objects +// specifically, but it is better to test with similar data - using real data +// would take up too much code space, and we cannot use getRawEnteries() in testing +const generateDummyValues = (arraySize: number) => { + const sampleDataObj = { + data: { + name: 'MODE', + ts: 1234567890.9876543, + reading: 0.1234567891011121 + }, + metadata: 'testValue #', + user_id: { + $uuid: '41t0l8e00s914tval1234567u9658699' + }, + _id: { + $oid: '12341x123afe3fbf541524d8' + } + }; + // The parse/stringify lets us "deep copy" the objects, to quickly populate/change the data + let values = Array.from({length: arraySize}, e => JSON.parse(JSON.stringify(sampleDataObj))); + values.forEach((element, index) => { + values[index].metadata = element.metadata + index.toString() + }); + + return values; +}; + +it(`writes a file for an array of objects`, async () => { + const testPhoneObj = { phone_data: generateDummyValues(100) }; + const writeFile = createWriteFile('testFile.temp'); + const testPromise = new Promise(() => { + return testPhoneObj; + }); + expect(testPromise.then(writeFile)).resolves.not.toThrow(); +}); diff --git a/www/js/controlHelper.ts b/www/js/controlHelper.ts index bfb439e55..c51e58b25 100644 --- a/www/js/controlHelper.ts +++ b/www/js/controlHelper.ts @@ -24,7 +24,7 @@ declare let window: fsWindow; * @param fileName is the name of the file to be created * @returns a function that returns a promise, which writes the file upon evaluation. */ -const createWriteFile = function (fileName: string) { +export const createWriteFile = function (fileName: string) { return function(result) { const resultList = result.phone_data; return new Promise(function(resolve, reject) { @@ -59,7 +59,7 @@ const createWriteFile = function (fileName: string) { * @param endTimeString " " * @returns a function which returns a promise, which shares an existing file upon evaluation. */ -const createShareData = function(fileName: string, startTimeString: string, endTimeString: string) { +export const createShareData = function(fileName: string, startTimeString: string, endTimeString: string) { return function() { return new Promise(function(resolve, reject) { window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { @@ -129,7 +129,6 @@ export const getMyData = function(startTs: Date) { const shareData = createShareData(dumpFile, startTimeString, endTimeString); getRawEntries(null, getUnixNum(startTime), startTime.toUnixInteger()) - .then(result => Promise.resolve(dumpFile).then(() => result)) .then(writeDumpFile) .then(shareData) .then(function() { @@ -146,4 +145,4 @@ export const fetchOPCode = (() => { export const getSettings = (() => { return window["cordova"].plugins.BEMConnectionSettings.getSettings(); -}); \ No newline at end of file +}); From 4fc39827f8260b32abaaa66f7dc203f44ab1fb87 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 17 Oct 2023 17:53:33 -0600 Subject: [PATCH 158/850] convert startprefs from .js to .ts first attempt at converting to a ts file, not fully functional, but did find several functions that were no longer used and so have been removed --- www/js/splash/startprefs.js | 170 ------------------------------------ www/js/splash/startprefs.ts | 119 +++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 170 deletions(-) delete mode 100644 www/js/splash/startprefs.js create mode 100644 www/js/splash/startprefs.ts diff --git a/www/js/splash/startprefs.js b/www/js/splash/startprefs.js deleted file mode 100644 index 1bc01af3d..000000000 --- a/www/js/splash/startprefs.js +++ /dev/null @@ -1,170 +0,0 @@ -import angular from 'angular'; -import { getConfig } from '../config/dynamicConfig'; -import { storageGet, storageSet } from '../plugin/storage'; - -angular.module('emission.splash.startprefs', ['emission.plugin.logger']) - -.factory('StartPrefs', function($window, $state, $rootScope, - $ionicPopup, $http, Logger) { - var logger = Logger; - var startprefs = {}; - // Boolean: represents that the "intro" - the one page summary - // and the login are done - var INTRO_DONE_KEY = 'intro_done'; - // data collection consented protocol: string, represents the date on - // which the consented protocol was approved by the IRB - var DATA_COLLECTION_CONSENTED_PROTOCOL = 'data_collection_consented_protocol'; - - var CONSENTED_KEY = "config/consent"; - - startprefs.CONSENTED_EVENT = "data_collection_consented"; - startprefs.INTRO_DONE_EVENT = "intro_done"; - - var writeConsentToNative = function() { - return $window.cordova.plugins.BEMDataCollection.markConsented($rootScope.req_consent); - }; - - startprefs.markConsented = function() { - logger.log("changing consent from "+ - $rootScope.curr_consented+" -> "+JSON.stringify($rootScope.req_consent)); - // mark in native storage - return startprefs.readConsentState().then(writeConsentToNative).then(function(response) { - // mark in local storage - storageSet(DATA_COLLECTION_CONSENTED_PROTOCOL, - $rootScope.req_consent); - // mark in local variable as well - $rootScope.curr_consented = angular.copy($rootScope.req_consent); - $rootScope.$emit(startprefs.CONSENTED_EVENT, $rootScope.req_consent); - }); - }; - - startprefs.markIntroDone = function() { - var currTime = moment().format(); - storageSet(INTRO_DONE_KEY, currTime); - $rootScope.$emit(startprefs.INTRO_DONE_EVENT, currTime); - } - - // returns boolean - startprefs.readIntroDone = function() { - return storageGet(INTRO_DONE_KEY).then(function(read_val) { - logger.log("in readIntroDone, read_val = "+JSON.stringify(read_val)); - $rootScope.intro_done = read_val; - }); - } - - startprefs.isIntroDone = function() { - if ($rootScope.intro_done == null || $rootScope.intro_done == "") { - logger.log("in isIntroDone, returning false"); - $rootScope.is_intro_done = false; - return false; - } else { - logger.log("in isIntroDone, returning true"); - $rootScope.is_intro_done = true; - return true; - } - } - - startprefs.isConsented = function() { - if ($rootScope.curr_consented == null || $rootScope.curr_consented == "" || - $rootScope.curr_consented.approval_date != $rootScope.req_consent.approval_date) { - console.log("Not consented in local storage, need to show consent"); - $rootScope.is_consented = false; - return false; - } else { - console.log("Consented in local storage, no need to show consent"); - $rootScope.is_consented = true; - return true; - } - } - - startprefs.readConsentState = function() { - // read consent state from the file and populate it - return $http.get("json/startupConfig.json") - .then(function(startupConfigResult) { - $rootScope.req_consent = startupConfigResult.data.emSensorDataCollectionProtocol; - logger.log("required consent version = " + JSON.stringify($rootScope.req_consent)); - return storageGet(DATA_COLLECTION_CONSENTED_PROTOCOL); - }).then(function(kv_store_consent) { - $rootScope.curr_consented = kv_store_consent; - console.assert(angular.isDefined($rootScope.req_consent), "in readConsentState $rootScope.req_consent", JSON.stringify($rootScope.req_consent)); - // we can just launch this, we don't need to wait for it - startprefs.checkNativeConsent(); - }); - } - - startprefs.readConfig = function() { - return getConfig().then((savedConfig) => $rootScope.app_ui_label = savedConfig); - } - - startprefs.hasConfig = function() { - if ($rootScope.app_ui_label == undefined || - $rootScope.app_ui_label == null || - $rootScope.app_ui_label == "") { - logger.log("Config not downloaded, need to show join screen"); - $rootScope.has_config = false; - return false; - } else { - $rootScope.has_config = true; - logger.log("Config downloaded, skipping join screen"); - return true; - } - } - - /* - * Read the intro_done and consent_done variables into the $rootScope so that - * we can use them without making multiple native calls - */ - startprefs.readStartupState = function() { - console.log("STARTPREFS: about to read startup state"); - var readIntroPromise = startprefs.readIntroDone() - .then(startprefs.isIntroDone); - var readConsentPromise = startprefs.readConsentState() - .then(startprefs.isConsented); - var readConfigPromise = startprefs.readConfig() - .then(startprefs.hasConfig); - return Promise.all([readIntroPromise, readConsentPromise, readConfigPromise]); - }; - - startprefs.getConsentDocument = function() { - return $window.cordova.plugins.BEMUserCache.getDocument("config/consent", false) - .then(function(resultDoc) { - if ($window.cordova.plugins.BEMUserCache.isEmptyDoc(resultDoc)) { - return null; - } else { - return resultDoc; - } - }); - }; - - startprefs.checkNativeConsent = function() { - startprefs.getConsentDocument().then(function(resultDoc) { - if (resultDoc == null) { - if(startprefs.isConsented()) { - logger.log("Local consent found, native consent missing, writing consent to native"); - $ionicPopup.alert({template: "Local consent found, native consent missing, writing consent to native"}); - return writeConsentToNative(); - } else { - logger.log("Both local and native consent not found, nothing to sync"); - } - } - }); - } - - var changeState = function(destState) { - logger.log('changing state to '+destState); - console.log("loading "+destState); - // TODO: Fix this the right way when we fix the FSM - // https://github.com/e-mission/e-mission-phone/issues/146#issuecomment-251061736 - var reload = false; - if (($state.$current == destState.state) && ($state.$current.name == 'root.main.goals')) { - reload = true; - } - $state.go(destState.state, destState.params).then(function() { - if (reload) { - $rootScope.$broadcast("RELOAD_GOAL_PAGE_FOR_REFERRAL") - } - }); - }; - - return startprefs; -}); diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts new file mode 100644 index 000000000..d8c422568 --- /dev/null +++ b/www/js/splash/startprefs.ts @@ -0,0 +1,119 @@ +import { storageGet, storageSet } from '../plugin/storage'; +import { logInfo, logDebug, displayErrorMsg } from '../plugin/logger'; + +type StartPrefs = { + CONSENTED_EVENT: string, + INTRO_DONE_EVENT: string, +} + +export const startPrefs: StartPrefs = { + CONSENTED_EVENT: "data_collection_consented", + INTRO_DONE_EVENT: "intro_done", +}; +// Boolean: represents that the "intro" - the one page summary +// and the login are done +const INTRO_DONE_KEY = 'intro_done'; +// data collection consented protocol: string, represents the date on +// which the consented protocol was approved by the IRB +const DATA_COLLECTION_CONSENTED_PROTOCOL = 'data_collection_consented_protocol'; + +const CONSENTED_KEY = "config/consent"; + +let _req_consent; +let _curr_consented; + +function writeConsentToNative() { + return window['cordiva'].plugins.BEMDataCollection.markConsented(_req_consent); +}; + +//used in ConsentPage +export function markConsented() { + logInfo("changing consent from " + + _curr_consented + " -> " + JSON.stringify(_req_consent)); + // mark in native storage + return readConsentState().then(writeConsentToNative).then(function (response) { + // mark in local storage + storageSet(DATA_COLLECTION_CONSENTED_PROTOCOL, + _req_consent); + // mark in local variable as well + //TODO - make a copy here + _curr_consented = _req_consent; + //TODO - find out how this is used and how to replace + //emit(startPrefs.CONSENTED_EVENT, _req_consent); + }); +}; + +//used in several places - storedevice, pushnotify -- onboardingHelper has other style... +let _intro_done = null; +let _is_intro_done; +export function isIntroDone() { + if (_intro_done == null || _intro_done == "") { + logDebug("in isIntroDone, returning false"); + _is_intro_done = false; + return false; + } else { + logDebug("in isIntroDone, returning true"); + _is_intro_done = true; + return true; + } +} + +let _is_consented; +//used in onboardingHelper +export function isConsented() { + if (_curr_consented == null || _curr_consented == "" || + _curr_consented.approval_date != _req_consent.approval_date) { + console.log("Not consented in local storage, need to show consent"); + _is_consented = false; + return false; + } else { + console.log("Consented in local storage, no need to show consent"); + _is_consented = true; + return true; + } +} + +//used in onboardingHelper +export function readConsentState() { + // read consent state from the file and populate it + return fetch("json/startupConfig.json") + .then(response => response.json()) + .then(function (startupConfigResult) { + // let startupConfigJson = await startupConfigResult.json(); + console.log(startupConfigResult); + _req_consent = startupConfigResult.emSensorDataCollectionProtocol; + logInfo("required consent version = " + JSON.stringify(_req_consent)); + return storageGet(DATA_COLLECTION_CONSENTED_PROTOCOL); + }).then(function (kv_store_consent) { + _curr_consented = kv_store_consent; + console.assert(((_req_consent != undefined) && (_req_consent != null)), "in readConsentState $rootScope.req_consent", JSON.stringify(_req_consent)); + // we can just launch this, we don't need to wait for it + checkNativeConsent(); + }); +} + +//used in ProfileSettings +export function getConsentDocument() { + return window['cordova'].plugins.BEMUserCache.getDocument("config/consent", false) + .then(function (resultDoc) { + if (window['cordova'].plugins.BEMUserCache.isEmptyDoc(resultDoc)) { + return null; + } else { + return resultDoc; + } + }); +}; + +function checkNativeConsent() { + getConsentDocument().then(function (resultDoc) { + if (resultDoc == null) { + if (isConsented()) { + logDebug("Local consent found, native consent missing, writing consent to native"); + displayErrorMsg("Local consent found, native consent missing, writing consent to native"); + return writeConsentToNative(); + } else { + logDebug("Both local and native consent not found, nothing to sync"); + } + } + }); +} From 416a111b05d3c749e47dd025d771514ef7bab029 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 17 Oct 2023 17:54:29 -0600 Subject: [PATCH 159/850] remove all references to angular service, replace with imports we are now not using the angular service anymore, so should access everything from the new ts file --- www/index.js | 1 - www/js/App.tsx | 2 -- www/js/control/ProfileSettings.jsx | 4 ++-- www/js/controllers.js | 8 ++++---- www/js/onboarding/ConsentPage.tsx | 5 ++--- www/js/onboarding/onboardingHelper.ts | 5 ++--- www/js/splash/localnotify.js | 1 - www/js/splash/pushnotify.js | 16 ++++++++-------- www/js/splash/remotenotify.js | 3 +-- www/js/splash/storedevicesettings.js | 16 ++++++++-------- 10 files changed, 27 insertions(+), 34 deletions(-) diff --git a/www/index.js b/www/index.js index 89c3a5e26..b4dd4da3d 100644 --- a/www/index.js +++ b/www/index.js @@ -6,7 +6,6 @@ import 'leaflet/dist/leaflet.css'; import './js/ngApp.js'; import './js/splash/referral.js'; import './js/splash/customURL.js'; -import './js/splash/startprefs.js'; import './js/splash/pushnotify.js'; import './js/splash/storedevicesettings.js'; import './js/splash/localnotify.js'; diff --git a/www/js/App.tsx b/www/js/App.tsx index 2187118fa..b3d823b5b 100644 --- a/www/js/App.tsx +++ b/www/js/App.tsx @@ -31,8 +31,6 @@ const App = () => { const { colors } = useTheme(); const { t } = useTranslation(); - const StartPrefs = getAngularService('StartPrefs'); - const routes = useMemo(() => { const showMetrics = appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL'; return showMetrics ? defaultRoutes(t) : defaultRoutes(t).filter(r => r.key != 'metrics'); diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 4a263acc5..ceebe586c 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -24,6 +24,7 @@ import { AppContext } from "../App"; import { shareQR } from "../components/QrCode"; import { storageClear } from "../plugin/storage"; import { getAppVersion } from "../plugin/clientStats"; +import { getConsentDocument } from "../splash/startprefs"; //any pure functions can go outside const ProfileSettings = () => { @@ -39,7 +40,6 @@ const ProfileSettings = () => { const EmailHelper = getAngularService('EmailHelper'); const NotificationScheduler = getAngularService('NotificationScheduler'); const ControlHelper = getAngularService('ControlHelper'); - const StartPrefs = getAngularService('StartPrefs'); //functions that come directly from an Angular service const editCollectionConfig = () => setEditCollection(true); @@ -301,7 +301,7 @@ const ProfileSettings = () => { //in ProfileSettings in DevZone (above two functions are helpers) async function checkConsent() { - StartPrefs.getConsentDocument().then(function(resultDoc){ + getConsentDocument().then(function(resultDoc){ setConsentDoc(resultDoc); if (resultDoc == null) { setNoConsentVis(true); diff --git a/www/js/controllers.js b/www/js/controllers.js index 75124efce..5a4de0cb4 100644 --- a/www/js/controllers.js +++ b/www/js/controllers.js @@ -2,9 +2,9 @@ import angular from 'angular'; import { addStatError, addStatReading, statKeys } from './plugin/clientStats'; +import { getPendingOnboardingState } from './onboarding/onboardingHelper'; -angular.module('emission.controllers', ['emission.splash.startprefs', - 'emission.splash.pushnotify', +angular.module('emission.controllers', ['emission.splash.pushnotify', 'emission.splash.storedevicesettings', 'emission.splash.localnotify', 'emission.splash.remotenotify']) @@ -14,7 +14,7 @@ angular.module('emission.controllers', ['emission.splash.startprefs', .controller('DashCtrl', function($scope) {}) .controller('SplashCtrl', function($scope, $state, $interval, $rootScope, - StartPrefs, PushNotify, StoreDeviceSettings, + PushNotify, StoreDeviceSettings, LocalNotify, RemoteNotify) { console.log('SplashCtrl invoked'); // alert("attach debugger!"); @@ -49,7 +49,7 @@ angular.module('emission.controllers', ['emission.splash.startprefs', 'root.main.metrics'] if (isInList(toState.name, personalTabs)) { // toState is in the personalTabs list - StartPrefs.getPendingOnboardingState().then(function(result) { + getPendingOnboardingState().then(function(result) { if (result != null) { event.preventDefault(); $state.go(result); diff --git a/www/js/onboarding/ConsentPage.tsx b/www/js/onboarding/ConsentPage.tsx index 08aa3ab48..2a098b3a9 100644 --- a/www/js/onboarding/ConsentPage.tsx +++ b/www/js/onboarding/ConsentPage.tsx @@ -4,9 +4,9 @@ import { View, ScrollView } from 'react-native'; import { Button, Surface } from 'react-native-paper'; import { resetDataAndRefresh } from '../config/dynamicConfig'; import { AppContext } from '../App'; -import { getAngularService } from '../angular-react-helper'; import PrivacyPolicy from './PrivacyPolicy'; import { onboardingStyles } from './OnboardingStack'; +import { markConsented } from '../splash/startprefs'; const ConsentPage = () => { @@ -20,8 +20,7 @@ const ConsentPage = () => { }; function agree() { - const StartPrefs = getAngularService('StartPrefs'); - StartPrefs.markConsented().then((response) => { + markConsented().then((response) => { refreshOnboardingState(); }); }; diff --git a/www/js/onboarding/onboardingHelper.ts b/www/js/onboarding/onboardingHelper.ts index 40fb15155..78f5aa4d1 100644 --- a/www/js/onboarding/onboardingHelper.ts +++ b/www/js/onboarding/onboardingHelper.ts @@ -1,8 +1,8 @@ import { DateTime } from "luxon"; -import { getAngularService } from "../angular-react-helper"; import { getConfig, resetDataAndRefresh } from "../config/dynamicConfig"; import { storageGet, storageSet } from "../plugin/storage"; import { logDebug } from "../plugin/logger"; +import { readConsentState, isConsented, isIntroDone } from "../splash/startprefs"; export const INTRO_DONE_KEY = 'intro_done'; @@ -58,8 +58,7 @@ export function getPendingOnboardingState(): Promise { }; async function readConsented() { - const StartPrefs = getAngularService('StartPrefs'); - return StartPrefs.readConsentState().then(StartPrefs.isConsented) as Promise; + return readConsentState().then(isConsented) as Promise; } async function readIntroDone() { diff --git a/www/js/splash/localnotify.js b/www/js/splash/localnotify.js index 6a4241f2c..c96ba827d 100644 --- a/www/js/splash/localnotify.js +++ b/www/js/splash/localnotify.js @@ -8,7 +8,6 @@ import angular from 'angular'; angular.module('emission.splash.localnotify', ['emission.plugin.logger', - 'emission.splash.startprefs', 'ionic-toast']) .factory('LocalNotify', function($window, $ionicPlatform, $ionicPopup, $state, $rootScope, ionicToast, Logger) { diff --git a/www/js/splash/pushnotify.js b/www/js/splash/pushnotify.js index 40d859f09..874a539a5 100644 --- a/www/js/splash/pushnotify.js +++ b/www/js/splash/pushnotify.js @@ -15,12 +15,12 @@ import angular from 'angular'; import { updateUser } from '../commHelper'; +import { readConsentState, isIntroDone, isConsented, startPrefs } from './startprefs'; angular.module('emission.splash.pushnotify', ['emission.plugin.logger', - 'emission.services', - 'emission.splash.startprefs']) + 'emission.services']) .factory('PushNotify', function($window, $state, $rootScope, $ionicPlatform, - $ionicPopup, Logger, StartPrefs) { + $ionicPopup, Logger) { var pushnotify = {}; var push = null; @@ -159,8 +159,8 @@ angular.module('emission.splash.pushnotify', ['emission.plugin.logger', $ionicPlatform.ready().then(function() { pushnotify.datacollect = $window.cordova.plugins.BEMDataCollection; - StartPrefs.readConsentState() - .then(StartPrefs.isConsented) + readConsentState() + .then(isConsented) .then(function(consentState) { if (consentState == true) { pushnotify.registerPush(); @@ -172,16 +172,16 @@ angular.module('emission.splash.pushnotify', ['emission.plugin.logger', Logger.log("pushnotify startup done"); }); - $rootScope.$on(StartPrefs.CONSENTED_EVENT, function(event, data) { + $rootScope.$on(startPrefs.CONSENTED_EVENT, function(event, data) { console.log("got consented event "+JSON.stringify(event.name) +" with data "+ JSON.stringify(data)); - if (StartPrefs.isIntroDone()) { + if (isIntroDone()) { console.log("intro is done -> reconsent situation, we already have a token -> register"); pushnotify.registerPush(); } }); - $rootScope.$on(StartPrefs.INTRO_DONE_EVENT, function(event, data) { + $rootScope.$on(startPrefs.INTRO_DONE_EVENT, function(event, data) { console.log("intro is done -> original consent situation, we should have a token by now -> register"); pushnotify.registerPush(); }); diff --git a/www/js/splash/remotenotify.js b/www/js/splash/remotenotify.js index 3e43b6f9f..f08921fdd 100644 --- a/www/js/splash/remotenotify.js +++ b/www/js/splash/remotenotify.js @@ -15,8 +15,7 @@ import angular from 'angular'; import { addStatEvent, statKeys } from '../plugin/clientStats'; -angular.module('emission.splash.remotenotify', ['emission.plugin.logger', - 'emission.splash.startprefs']) +angular.module('emission.splash.remotenotify', ['emission.plugin.logger']) .factory('RemoteNotify', function($http, $window, $ionicPopup, $rootScope, Logger) { diff --git a/www/js/splash/storedevicesettings.js b/www/js/splash/storedevicesettings.js index d307feaa7..2b60fbee4 100644 --- a/www/js/splash/storedevicesettings.js +++ b/www/js/splash/storedevicesettings.js @@ -1,11 +1,11 @@ import angular from 'angular'; import { updateUser } from '../commHelper'; +import { isConsented, readConsentState, startPrefs, isIntroDone } from "./startprefs"; angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', - 'emission.services', - 'emission.splash.startprefs']) + 'emission.services']) .factory('StoreDeviceSettings', function($window, $state, $rootScope, $ionicPlatform, - $ionicPopup, Logger, StartPrefs) { + $ionicPopup, Logger ) { var storedevicesettings = {}; @@ -32,8 +32,8 @@ angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', $ionicPlatform.ready().then(function() { storedevicesettings.datacollect = $window.cordova.plugins.BEMDataCollection; - StartPrefs.readConsentState() - .then(StartPrefs.isConsented) + readConsentState() + .then(isConsented) .then(function(consentState) { if (consentState == true) { storedevicesettings.storeDeviceSettings(); @@ -44,16 +44,16 @@ angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', Logger.log("storedevicesettings startup done"); }); - $rootScope.$on(StartPrefs.CONSENTED_EVENT, function(event, data) { + $rootScope.$on(startPrefs.CONSENTED_EVENT, function(event, data) { console.log("got consented event "+JSON.stringify(event.name) +" with data "+ JSON.stringify(data)); - if (StartPrefs.isIntroDone()) { + if (isIntroDone()) { console.log("intro is done -> reconsent situation, we already have a token -> register"); storedevicesettings.storeDeviceSettings(); } }); - $rootScope.$on(StartPrefs.INTRO_DONE_EVENT, function(event, data) { + $rootScope.$on(startPrefs.INTRO_DONE_EVENT, function(event, data) { console.log("intro is done -> original consent situation, we should have a token by now -> register"); storedevicesettings.storeDeviceSettings(); }); From 46439496147ce1dac70a4c82bbe7615f5357fce6 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Oct 2023 00:18:41 -0400 Subject: [PATCH 160/850] Apply suggestions from code review --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 59f725934..28f6fe615 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ If connecting to a development server over http, make sure to turn on http suppo ``` -### Run in the emulator +### Building the app ``` npm run From 685e1b65b718f04f224945514ade63c43743da0e Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Oct 2023 00:33:28 -0400 Subject: [PATCH 161/850] Update README.md Reword the "Building the app" section to make it clearer what scripts are available and what they do --- README.md | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 28f6fe615..9e3b865c0 100644 --- a/README.md +++ b/README.md @@ -205,22 +205,17 @@ If connecting to a development server over http, make sure to turn on http suppo ### Building the app -``` -npm run -``` -AND/OR -``` -npm run -``` -For other options of build scripts, refer to [`package.cordovabuild.json`](package.cordovabuild.json) -Common ones listed below (both, Android, iOS) -``` -npm run build -npm run build-prod-android -npm run build-prod-ios -``` +We offer a set of build scripts you can pick from, each of which i) bundle the JS with Webpack and then ii) proceed with a Cordova build. +You can bundle JS in 'production' or 'dev' mode, and you can build Android or iOS or both. +The common use cases will be: + +- `npm run build` (to build for production on both Android and iOS platforms) +- `npm run build-prod-android` (to build for production on Android platform only) +- `npm run build-prod-ios` (to build for production on iOS platform only) + +Find the full list of these scripts in [`package.cordovabuild.json`](package.cordovabuild.json) -
Expected output +
Expected output (Android build) ``` BUILD SUCCESSFUL in 2m 48s From 047c2b72150bcd373f08100b569376ca82a3efca Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Oct 2023 00:38:52 -0400 Subject: [PATCH 162/850] Update README.md Remove the note about putting your own configuration files in the `/json` directory. We use the dynamic config now and encourage people to use it rather than making local code changes. --- README.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/README.md b/README.md index 9e3b865c0..28d2940df 100644 --- a/README.md +++ b/README.md @@ -36,15 +36,6 @@ Run the setup script bash setup/setup_serve.sh ``` -**(optional)** Configure by changing the files in `www/json`. -Defaults are in `www/json/*.sample` - -``` -ls www/json/*.sample -cp www/json/startupConfig.json.sample www/json/startupConfig.json -cp ..... www/json/connectionConfig.json -``` - ### Activation (after install, and in every new shell) ``` From 5f9d189fb6e1723587aa2907dc1f8b9fffd5e51a Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Oct 2023 00:59:57 -0400 Subject: [PATCH 163/850] Update README.md update outdated comments about connectionConfig.json to reflect that we don't use connectionConfig.json anymore; this is replaced by the 'server' field of the dynamic config. If not present, it uses localhost. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 28d2940df..f15d413c5 100644 --- a/README.md +++ b/README.md @@ -79,9 +79,9 @@ A lot of the visualizations that we display in the phone client come from the se are available in the [e-mission-server README](https://github.com/e-mission/e-mission-server/blob/master/README.md). -In order to make end to end testing easy, if the local server is started on a HTTP (versus HTTPS port), it is in development mode. By default, the phone app connects to the local server (localhost on iOS, [10.0.2.2 on android](https://stackoverflow.com/questions/5806220/how-to-connect-to-my-http-localhost-web-server-from-android-emulator-in-eclips)) with the `prompted-auth` authentication method. To connect to a different server, or to use a different authentication method, you need to create a `www/json/connectionConfig.json` file. More details on configuring authentication [can be found in the docs](https://github.com/e-mission/e-mission-docs/blob/master/docs/install/configuring_authentication.md). +The dynamic config (see https://github.com/e-mission/nrel-openpath-deploy-configs) controls the server endpoint that the phone app will connect to. If you are running the app in an emulator on the same machine as your local server (i.e. they share a `localhost`), you can use one of the `dev-emulator-*` configs (these configs have no `server` specified so `localhost` is assumed). -One advantage of using `skip` authentication in development mode is that any user email can be entered without a password. Developers can use one of the emails that they loaded test data for in step (3) above. So if the test data loaded was with `-u shankari@eecs.berkeley.edu`, then the login email for the phone app would also be `shankari@eecs.berkeley.edu`. +If you wish to connect to a different server, create your own config file according to https://github.com/e-mission/nrel-openpath-deploy-configs and specify the `server` field accordingly. Updating the e-mission-\* plugins or adding new plugins --- From c6dc2e5ab2bceb4081835383d537861714648860 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Oct 2023 01:04:57 -0400 Subject: [PATCH 164/850] Update README.md reword "Building the app" --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f15d413c5..1282b2c6e 100644 --- a/README.md +++ b/README.md @@ -196,14 +196,14 @@ If connecting to a development server over http, make sure to turn on http suppo ### Building the app -We offer a set of build scripts you can pick from, each of which i) bundle the JS with Webpack and then ii) proceed with a Cordova build. -You can bundle JS in 'production' or 'dev' mode, and you can build Android or iOS or both. +We offer a set of build scripts to pick from, each of which: (i) bundle the JS with Webpack, and then (ii) proceed with a Cordova build. The common use cases will be: - `npm run build` (to build for production on both Android and iOS platforms) - `npm run build-prod-android` (to build for production on Android platform only) - `npm run build-prod-ios` (to build for production on iOS platform only) +There are a variety of options because Webpack can bundle the JS in 'production' or 'dev' mode, and you can build Android or iOS or both. Find the full list of these scripts in [`package.cordovabuild.json`](package.cordovabuild.json)
Expected output (Android build) From 96d29e6c887489381e910cad8441df3791a798b8 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Oct 2023 01:15:50 -0400 Subject: [PATCH 165/850] Update README.md make "Updating the UI only" a bit clearer: - you don't have to manually type the IP address in the devapp if it's on localhost - you do have to type it manually if it's on a different device, and you should make sure that it's on the same network --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1282b2c6e..0f2b4642e 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,9 @@ source setup/activate_serve.sh .... ``` -1. Change the devapp connection URL to one of these (e.g. 192.168.162.1:3000) and press "Connect" +1. Change the devapp connection URL and press "Connect" + - If you are running the devapp in an emulator on the same machine as the devapp server, you may simply use localhost, which would be `127.0.0.1:3000` on iOS and `10.0.2.2:3000` on Android. + - If you are running the devapp on a different device, you must type the address manually (e.g. `192.168.162.1:3000`). Note that this is a local IP address; the devices must be on the same network 1. The app will now display the version of e-mission app that is in your local directory 1. The console logs will be displayed back in the server window (prefaced by `[console]`) 1. Breakpoints can be added by connecting through the browser @@ -66,7 +68,7 @@ source setup/activate_serve.sh **Ta-da!** :gift: If you change any of the files in the `www` directory, the app will automatically be re-loaded without manually restarting either the server or the app :tada: -**Note1**: You may need to scroll up, past all the warnings about `Content Security Policy has been added` to find the port that the server is listening to. +**Note**: You may need to scroll up, past all the warnings about `Content Security Policy has been added` to find the port that the server is listening to. End to end testing --- From 079f4ea667a876b06a23475f52041d78c58ffe4f Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 18 Oct 2023 01:22:00 -0400 Subject: [PATCH 166/850] Update README.md Remove the note about putting your own configuration files in the `/json` directory. We use the dynamic config now and encourage people to use it rather than making local code changes. --- README.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/README.md b/README.md index 0f2b4642e..3c5a12e8c 100644 --- a/README.md +++ b/README.md @@ -153,15 +153,6 @@ AND/OR bash setup/setup_ios_native.sh ``` -**(optional)** Configure by changing the files in `www/json`. -Defaults are in `www/json/*.sample` - -``` -ls www/json/*.sample -cp www/json/startupConfig.json.sample www/json/startupConfig.json -cp ..... www/json/connectionConfig.json -``` - ### Activation (after install, and in every new shell) ``` From b8404da8154cae034f3396d6a6d81fbd5361a1af Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Wed, 18 Oct 2023 12:32:05 -0700 Subject: [PATCH 167/850] Set up types folder, added new tests As discussed in PR #1061, Jiji is working on setting up a folder in `www/js/` to contain many of our type interfaces and templates. For now, I've added my own `fileIOTypes.ts`. We discussed the types added so far, and found we were working with overlapping types! As such, I plan to copy over her version of `DiaryTypes.ts` found in commit ffcc871, and will use that to replace the current dataObj interface in `controlHelper.test.ts`. Changes made: - Set up `www/js/types`, moved fsWindow interface there - Added basic tests for createWriteFile --- www/__tests__/controlHelper.test.ts | 73 +++++++++++++++++++++++------ www/js/controlHelper.ts | 15 +----- www/js/types/fileIOTypes.ts | 12 +++++ 3 files changed, 72 insertions(+), 28 deletions(-) create mode 100644 www/js/types/fileIOTypes.ts diff --git a/www/__tests__/controlHelper.test.ts b/www/__tests__/controlHelper.test.ts index 599114676..6842b44bf 100644 --- a/www/__tests__/controlHelper.test.ts +++ b/www/__tests__/controlHelper.test.ts @@ -1,7 +1,9 @@ import { mockLogger } from "../__mocks__/globalMocks"; import { createWriteFile } from "../js/controlHelper"; +import { fsWindow } from "../js/types/fileIOTypes" mockLogger(); +declare let window: fsWindow; // See PR 1052 for a detailed interface interface dataObj { @@ -19,22 +21,24 @@ interface dataObj { } } -// These are fake values; createWriteFile does not require these objects -// specifically, but it is better to test with similar data - using real data -// would take up too much code space, and we cannot use getRawEnteries() in testing -const generateDummyValues = (arraySize: number) => { +// createWriteFile does not require these objects specifically, but it +// is better to test with similar data - using real data would take +// up too much code space, and we cannot use getRawEnteries() in testing +const generateFakeValues = (arraySize: number) => { + if (arraySize <= 0) + return new Promise (() => {return []}); const sampleDataObj = { data: { name: 'MODE', ts: 1234567890.9876543, - reading: 0.1234567891011121 + reading: 0.1234567891011121, }, metadata: 'testValue #', user_id: { - $uuid: '41t0l8e00s914tval1234567u9658699' + $uuid: '41t0l8e00s914tval1234567u9658699', }, _id: { - $oid: '12341x123afe3fbf541524d8' + $oid: '12341x123afe3fbf541524d8', } }; // The parse/stringify lets us "deep copy" the objects, to quickly populate/change the data @@ -43,14 +47,53 @@ const generateDummyValues = (arraySize: number) => { values[index].metadata = element.metadata + index.toString() }); - return values; + return new Promise(() => { + return { phone_data: values }; + }); }; -it(`writes a file for an array of objects`, async () => { - const testPhoneObj = { phone_data: generateDummyValues(100) }; - const writeFile = createWriteFile('testFile.temp'); - const testPromise = new Promise(() => { - return testPhoneObj; - }); - expect(testPromise.then(writeFile)).resolves.not.toThrow(); +// A variation of createShareData; confirms the file has been written, +// without sharing the data. +const confirmFileExists = (fileName: string) => { + return function() { + return new Promise(function() { + window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { + fs.root.getFile(fileName, null, function(fileEntry) { + return fileEntry.isFile; + }); + }); + }); + }; +}; + +it('writes a file for an array of objects', async () => { + const testPromiseOne = generateFakeValues(1); + const testPromiseTwo= generateFakeValues(222); + const writeFile = createWriteFile('test_one.temp'); + + expect(testPromiseOne.then(writeFile)).resolves.not.toThrow(); + expect(testPromiseTwo.then(writeFile)).resolves.not.toThrow(); }); + +it('correctly writes the files', async () => { + const fileName = 'test_two.timeline' + const fileExists = confirmFileExists(fileName); + const testPromise = generateFakeValues(1); + const writeFile = createWriteFile(fileName); + expect(testPromise.then(writeFile).then(fileExists)).resolves.not.toThrow(); + expect(testPromise.then(writeFile).then(fileExists)).resolves.toEqual(true); +}); + +it('rejects an empty input', async () => { + const writeFile = createWriteFile('test_one.temp'); + const testPromise = generateFakeValues(0); + expect(testPromise.then(writeFile)).rejects.toThrow(); +}); + +/* + createShareData() is not tested, because it relies on the phoneGap social + sharing plugin, which cannot be mocked. + + getMyData relies on createShareData, and likewise cannot be tested - it also + relies on getRawEnteries(). +*/ diff --git a/www/js/controlHelper.ts b/www/js/controlHelper.ts index c51e58b25..ebdf42398 100644 --- a/www/js/controlHelper.ts +++ b/www/js/controlHelper.ts @@ -2,21 +2,9 @@ import { DateTime } from "luxon"; import { getRawEntries } from "./commHelper"; import { logInfo, displayError, logDebug } from "./plugin/logger"; +import { fsWindow } from "./types/fileIOTypes" import i18next from "./i18nextInit" ; -interface fsWindow extends Window { - requestFileSystem: ( - type: number, - size: number, - successCallback: (fs: any) => void, - errorCallback?: (error: any) => void - ) => void; - LocalFileSystem: { - TEMPORARY: number; - PERSISTENT: number; - }; -}; - declare let window: fsWindow; /** @@ -65,6 +53,7 @@ export const createShareData = function(fileName: string, startTimeString: strin window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { logDebug("During email, file system open: " + fs.name); fs.root.getFile(fileName, null, function(fileEntry) { + logDebug(`fileEntry is type ${typeof fileEntry.isFile}`) logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`); fileEntry.file(function(file) { const reader = new FileReader(); diff --git a/www/js/types/fileIOTypes.ts b/www/js/types/fileIOTypes.ts new file mode 100644 index 000000000..12797e6b8 --- /dev/null +++ b/www/js/types/fileIOTypes.ts @@ -0,0 +1,12 @@ +export interface fsWindow extends Window { + requestFileSystem: ( + type: number, + size: number, + successCallback: (fs: any) => void, + errorCallback?: (error: any) => void + ) => void; + LocalFileSystem: { + TEMPORARY: number; + PERSISTENT: number; + }; +}; From 0584278c258326cdadc17199fa40258030a20de8 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 18 Oct 2023 16:45:19 -0600 Subject: [PATCH 168/850] add a comment about the notifications we had a bug related to this call, so adding a note to future development to make sure we're aware of the ramifications that this can cause. --- www/js/splash/startprefs.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index d8c422568..a89b9aec8 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -23,6 +23,9 @@ let _req_consent; let _curr_consented; function writeConsentToNative() { + //note that this calls to the notification API, + //so should not be called until we have notification permissions + //see https://github.com/e-mission/e-mission-docs/issues/1006 return window['cordiva'].plugins.BEMDataCollection.markConsented(_req_consent); }; From 1d4752d58d65b189b3c0f944d0fd83736a8cbc51 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 18 Oct 2023 16:58:34 -0600 Subject: [PATCH 169/850] remove extra line --- www/js/splash/startprefs.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index a89b9aec8..3b0455c19 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -82,7 +82,6 @@ export function readConsentState() { return fetch("json/startupConfig.json") .then(response => response.json()) .then(function (startupConfigResult) { - // let startupConfigJson = await startupConfigResult.json(); console.log(startupConfigResult); _req_consent = startupConfigResult.emSensorDataCollectionProtocol; logInfo("required consent version = " + JSON.stringify(_req_consent)); From e0dc3bb0419aa3a8470db597ab444d3ba94f0847 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 18 Oct 2023 16:58:58 -0600 Subject: [PATCH 170/850] correct typo was breaking because I spelled cordova wrong --- www/js/splash/startprefs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index 3b0455c19..0b8580e16 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -26,7 +26,7 @@ function writeConsentToNative() { //note that this calls to the notification API, //so should not be called until we have notification permissions //see https://github.com/e-mission/e-mission-docs/issues/1006 - return window['cordiva'].plugins.BEMDataCollection.markConsented(_req_consent); + return window['cordova'].plugins.BEMDataCollection.markConsented(_req_consent); }; //used in ConsentPage From c22a605cb74a7b570d463104ab0201555922c27d Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Wed, 18 Oct 2023 16:37:03 -0700 Subject: [PATCH 171/850] Updated type files, improved tests As mentioned in commit b8404da, a rudimentary version of `diaryTypes.ts` was added, so that controlHelper's typing was consistent with the new types being implemented. Further improvements include: - Checking file contents to confirm they match the input rawDataCluster - Better naming conventions - RawData, and RawDataCluster better reflect where the data comes from --- www/__tests__/controlHelper.test.ts | 78 +++++++++++++++++------------ www/js/controlHelper.ts | 7 ++- www/js/types/diaryTypes.ts | 12 +++++ www/js/types/fileIOTypes.ts | 12 ----- www/js/types/fileShareTypes.ts | 43 ++++++++++++++++ 5 files changed, 104 insertions(+), 48 deletions(-) create mode 100644 www/js/types/diaryTypes.ts delete mode 100644 www/js/types/fileIOTypes.ts create mode 100644 www/js/types/fileShareTypes.ts diff --git a/www/__tests__/controlHelper.test.ts b/www/__tests__/controlHelper.test.ts index 6842b44bf..172186664 100644 --- a/www/__tests__/controlHelper.test.ts +++ b/www/__tests__/controlHelper.test.ts @@ -1,25 +1,11 @@ import { mockLogger } from "../__mocks__/globalMocks"; import { createWriteFile } from "../js/controlHelper"; -import { fsWindow } from "../js/types/fileIOTypes" +import { FsWindow, RawData, RawDataCluster } from "../js/types/fileShareTypes" mockLogger(); -declare let window: fsWindow; - -// See PR 1052 for a detailed interface -interface dataObj { - data: { - name: string, - ts: number, - reading: number - }, - metadata: any, - user_id: { - $uuid: string - }, - _id: { - $oid: string - } -} +declare let window: FsWindow; +const fileName = 'test.timeline' +const writeFile = createWriteFile(fileName); // createWriteFile does not require these objects specifically, but it // is better to test with similar data - using real data would take @@ -27,13 +13,30 @@ interface dataObj { const generateFakeValues = (arraySize: number) => { if (arraySize <= 0) return new Promise (() => {return []}); - const sampleDataObj = { + + const sampleDataObj : RawData = { data: { - name: 'MODE', + name: 'testValue #', ts: 1234567890.9876543, reading: 0.1234567891011121, }, - metadata: 'testValue #', + metadata: { + key: 'MyKey/test', + platform: 'dev_testing', + time_zone: 'America/Los_Angeles', + write_fmt_time: '2023-04-14T00:09:10.80023-07:00', + write_local_dt: { + minute: 1, + hour: 2, + second: 3, + day: 4, + weekday: 5, + month: 6, + year: 7, + timezone: 'America/Los_Angeles', + }, + write_ts: 12345.6789, + }, user_id: { $uuid: '41t0l8e00s914tval1234567u9658699', }, @@ -41,25 +44,33 @@ const generateFakeValues = (arraySize: number) => { $oid: '12341x123afe3fbf541524d8', } }; + // The parse/stringify lets us "deep copy" the objects, to quickly populate/change the data let values = Array.from({length: arraySize}, e => JSON.parse(JSON.stringify(sampleDataObj))); values.forEach((element, index) => { - values[index].metadata = element.metadata + index.toString() + values[index].data.name = element.data.name + index.toString() }); - return new Promise(() => { + return new Promise(() => { return { phone_data: values }; }); }; // A variation of createShareData; confirms the file has been written, // without sharing the data. -const confirmFileExists = (fileName: string) => { +const confirmFileExists = (fileName: string, dataCluster: RawDataCluster) => { return function() { return new Promise(function() { window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { fs.root.getFile(fileName, null, function(fileEntry) { - return fileEntry.isFile; + if (!fileEntry.isFile) + return fileEntry.isFile; + const reader = new FileReader(); + reader.onloadend = function () { + const readResult = this.result as string; + const expectedResult = JSON.stringify(dataCluster); + return (readResult === expectedResult); + } }); }); }); @@ -68,26 +79,29 @@ const confirmFileExists = (fileName: string) => { it('writes a file for an array of objects', async () => { const testPromiseOne = generateFakeValues(1); - const testPromiseTwo= generateFakeValues(222); - const writeFile = createWriteFile('test_one.temp'); + const testPromiseTwo = generateFakeValues(2222); expect(testPromiseOne.then(writeFile)).resolves.not.toThrow(); expect(testPromiseTwo.then(writeFile)).resolves.not.toThrow(); }); it('correctly writes the files', async () => { - const fileName = 'test_two.timeline' - const fileExists = confirmFileExists(fileName); const testPromise = generateFakeValues(1); - const writeFile = createWriteFile(fileName); + let dataCluster = null; + testPromise.then((result) => {dataCluster = result}); + const fileExists = confirmFileExists(fileName, dataCluster); expect(testPromise.then(writeFile).then(fileExists)).resolves.not.toThrow(); expect(testPromise.then(writeFile).then(fileExists)).resolves.toEqual(true); }); it('rejects an empty input', async () => { - const writeFile = createWriteFile('test_one.temp'); const testPromise = generateFakeValues(0); - expect(testPromise.then(writeFile)).rejects.toThrow(); + expect(testPromise.then(writeFile)).rejects.toThrow(); + + let dataCluster = null; + testPromise.then((result) => {dataCluster = result}); + const fileExists = confirmFileExists(fileName, dataCluster); + expect(testPromise.then(writeFile).then(fileExists)).resolves.toEqual(false); }); /* diff --git a/www/js/controlHelper.ts b/www/js/controlHelper.ts index ebdf42398..1b1755395 100644 --- a/www/js/controlHelper.ts +++ b/www/js/controlHelper.ts @@ -2,10 +2,10 @@ import { DateTime } from "luxon"; import { getRawEntries } from "./commHelper"; import { logInfo, displayError, logDebug } from "./plugin/logger"; -import { fsWindow } from "./types/fileIOTypes" +import { FsWindow, RawDataCluster } from "./types/fileShareTypes" import i18next from "./i18nextInit" ; -declare let window: fsWindow; +declare let window: FsWindow; /** * createWriteFile is a factory method for the JSON dump file creation @@ -13,7 +13,7 @@ declare let window: fsWindow; * @returns a function that returns a promise, which writes the file upon evaluation. */ export const createWriteFile = function (fileName: string) { - return function(result) { + return function(result: RawDataCluster) { const resultList = result.phone_data; return new Promise(function(resolve, reject) { window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { @@ -53,7 +53,6 @@ export const createShareData = function(fileName: string, startTimeString: strin window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { logDebug("During email, file system open: " + fs.name); fs.root.getFile(fileName, null, function(fileEntry) { - logDebug(`fileEntry is type ${typeof fileEntry.isFile}`) logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`); fileEntry.file(function(file) { const reader = new FileReader(); diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts new file mode 100644 index 000000000..1ecbd26cd --- /dev/null +++ b/www/js/types/diaryTypes.ts @@ -0,0 +1,12 @@ +/* This draft of diaryTypes was added to PR 1052, so that the LocalDT type is + consistent across PRs. Only LocalDt is needed for the controlHelper rewrite */ +export type LocalDt = { + minute: number, + hour: number, + second: number, + day: number, + weekday: number, + month: number, + year: number, + timezone: string, +} diff --git a/www/js/types/fileIOTypes.ts b/www/js/types/fileIOTypes.ts deleted file mode 100644 index 12797e6b8..000000000 --- a/www/js/types/fileIOTypes.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface fsWindow extends Window { - requestFileSystem: ( - type: number, - size: number, - successCallback: (fs: any) => void, - errorCallback?: (error: any) => void - ) => void; - LocalFileSystem: { - TEMPORARY: number; - PERSISTENT: number; - }; -}; diff --git a/www/js/types/fileShareTypes.ts b/www/js/types/fileShareTypes.ts new file mode 100644 index 000000000..e2b60bf49 --- /dev/null +++ b/www/js/types/fileShareTypes.ts @@ -0,0 +1,43 @@ +import { LocalDt } from "./diaryTypes"; + +export interface FsWindow extends Window { + requestFileSystem: ( + type: number, + size: number, + successCallback: (fs: any) => void, + errorCallback?: (error: any) => void + ) => void; + LocalFileSystem: { + TEMPORARY: number; + PERSISTENT: number; + }; +}; + +/* These are the objects returned from getRawEnteries when it is called by + the getMyData() method. */ +export interface RawDataCluster { + phone_data: Array +} + +export interface RawData { + data: { + name: string, + ts: number, + reading: number, + }, + metadata: { + key: string, + platform: string, + write_ts: number, + time_zone: string, + write_fmt_time: string, + write_local_dt: LocalDt, + }, + user_id: { + $uuid: string, + }, + _id: { + $oid: string, + } +} + From 8a3320d84b59e14174d912b6501282360e5cad9e Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 19 Oct 2023 09:15:10 -0600 Subject: [PATCH 172/850] make a deep copy for storage --- www/js/splash/startprefs.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index 0b8580e16..71490f499 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -39,8 +39,7 @@ export function markConsented() { storageSet(DATA_COLLECTION_CONSENTED_PROTOCOL, _req_consent); // mark in local variable as well - //TODO - make a copy here - _curr_consented = _req_consent; + _curr_consented = {..._req_consent}; //TODO - find out how this is used and how to replace //emit(startPrefs.CONSENTED_EVENT, _req_consent); }); From 86fe118eb42a0f5db940d8b4fbd4acdf60fcd0ea Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 19 Oct 2023 09:16:48 -0600 Subject: [PATCH 173/850] correct outdated comments https://github.com/e-mission/e-mission-phone/pull/1053#discussion_r1365711315 --- www/js/control/uploadService.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/www/js/control/uploadService.ts b/www/js/control/uploadService.ts index c3a7a520e..038bd8efc 100644 --- a/www/js/control/uploadService.ts +++ b/www/js/control/uploadService.ts @@ -73,9 +73,7 @@ function readDBFile(parentDir, database, callbackFn) { } const sendToServer = function upload(url, binArray, params) { - //this was the best way I could find to contact the database, - //had to modify the way it gets handled on the other side - //the original way it could not find "reason" + //use url encoding to pass additional params in the post const urlParams = "?reason=" + params.reason + "&tz=" + params.tz; return fetch(url+urlParams, { method: 'POST', From cac185f788d580c9f09fa4a7b4b1bf5f07f9ba50 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 19 Oct 2023 09:33:33 -0600 Subject: [PATCH 174/850] add comments and timeout this mock was confusing, so I've added comments to indicate what the two parts do. Perviously, there was no timeout in the if, only in the else, now they are consistent --- www/__tests__/uploadService.test.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/www/__tests__/uploadService.test.ts b/www/__tests__/uploadService.test.ts index c0c7f9d04..b845d1ba9 100644 --- a/www/__tests__/uploadService.test.ts +++ b/www/__tests__/uploadService.test.ts @@ -18,16 +18,22 @@ mockFileSystem(); //comnplex mock, allows the readDBFile to work in testing //use this message to verify that the post went through let message = ""; -global.fetch = (url: string, options: {method: string, headers: {}, body: string}) => new Promise((rs, rj) => { - if(options) { - message = "sent " + options.method + options.body + " for " + url;; - rs('sent ' + options.method + options.body + ' to ' + url); - } else { - setTimeout(() => rs({ - json: () => new Promise((rs, rj) => { - setTimeout(() => rs('mock data for ' + url), 100); - }) - })); +//each have a slight delay to mimic a real fetch request +global.fetch = (url: string, options: { method: string, headers: {}, body: string }) => new Promise((rs, rj) => { + //if there's options, that means there is a post request + if (options) { + setTimeout(() => { + message = "sent " + options.method + options.body + " for " + url; + rs('sent ' + options.method + options.body + ' to ' + url); + }, 100); + } + //else it is a fetch request + else { + setTimeout(() => rs({ + json: () => new Promise((rs, rj) => { + setTimeout(() => rs('mock data for ' + url), 100); + }) + })); } }) as any; From 20f6d04889eb801e147bfa11f1d76147870ff409 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 19 Oct 2023 08:46:56 -0700 Subject: [PATCH 175/850] Minor adjustments to controlHelper tests --- www/__tests__/controlHelper.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/www/__tests__/controlHelper.test.ts b/www/__tests__/controlHelper.test.ts index 172186664..5e8836e80 100644 --- a/www/__tests__/controlHelper.test.ts +++ b/www/__tests__/controlHelper.test.ts @@ -57,7 +57,7 @@ const generateFakeValues = (arraySize: number) => { }; // A variation of createShareData; confirms the file has been written, -// without sharing the data. +// without calling the sharing components const confirmFileExists = (fileName: string, dataCluster: RawDataCluster) => { return function() { return new Promise(function() { @@ -90,6 +90,9 @@ it('correctly writes the files', async () => { let dataCluster = null; testPromise.then((result) => {dataCluster = result}); const fileExists = confirmFileExists(fileName, dataCluster); + const temp = createWriteFile('badFile.test') + + expect(testPromise.then(temp).then(fileExists)).resolves.toEqual(false); expect(testPromise.then(writeFile).then(fileExists)).resolves.not.toThrow(); expect(testPromise.then(writeFile).then(fileExists)).resolves.toEqual(true); }); From 4b148c224a61f658f5d26156a0c03d4b426e89a1 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 19 Oct 2023 10:00:54 -0600 Subject: [PATCH 176/850] fix location of timeout we want to set the message right away, then have a delay on the response itself --- www/__tests__/uploadService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/__tests__/uploadService.test.ts b/www/__tests__/uploadService.test.ts index b845d1ba9..6aea5805d 100644 --- a/www/__tests__/uploadService.test.ts +++ b/www/__tests__/uploadService.test.ts @@ -22,8 +22,8 @@ let message = ""; global.fetch = (url: string, options: { method: string, headers: {}, body: string }) => new Promise((rs, rj) => { //if there's options, that means there is a post request if (options) { + message = "sent " + options.method + options.body + " for " + url; setTimeout(() => { - message = "sent " + options.method + options.body + " for " + url; rs('sent ' + options.method + options.body + ' to ' + url); }, 100); } From d8a3b33768b06840e9de146fc4bcd90ad7312d29 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 19 Oct 2023 10:24:54 -0600 Subject: [PATCH 177/850] revert uploadSample json file https://github.com/e-mission/e-mission-phone/pull/1053/files/0c1bac8c9198c415673887a86d2918a6d59cfa59#r1364808637 --- www/json/uploadConfig.json.sample | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/json/uploadConfig.json.sample b/www/json/uploadConfig.json.sample index a3c8b7210..53cead55e 100644 --- a/www/json/uploadConfig.json.sample +++ b/www/json/uploadConfig.json.sample @@ -1,3 +1,3 @@ { - "url": "http://localhost:5647/phonelogs" + "url": "http://fill.me.in " } From 58a50a6b941204a93e344305c32cd153d00afe6c Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Thu, 19 Oct 2023 12:05:19 -0600 Subject: [PATCH 178/850] Update README.md Committing suggested change Co-authored-by: K. Shankari --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3c5a12e8c..609777cc5 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ Copied config.cordovabuild.xml -> config.xml and package.cordovabuild.json -> pa
-### Activation (after install, and in every new shell) +### Enable HTTP support on android by editing `config.xml` If connecting to a development server over http, make sure to turn on http support on android From 9d3965a1f9607837cc6989cebfdfdad8eaf28743 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Thu, 19 Oct 2023 12:11:56 -0600 Subject: [PATCH 179/850] Update README.md Committing change based on suggestion (adding a link that points to Noel-openpath-deploy-configs) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 609777cc5..111e0d82a 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ are available in the [e-mission-server README](https://github.com/e-mission/e-mi The dynamic config (see https://github.com/e-mission/nrel-openpath-deploy-configs) controls the server endpoint that the phone app will connect to. If you are running the app in an emulator on the same machine as your local server (i.e. they share a `localhost`), you can use one of the `dev-emulator-*` configs (these configs have no `server` specified so `localhost` is assumed). -If you wish to connect to a different server, create your own config file according to https://github.com/e-mission/nrel-openpath-deploy-configs and specify the `server` field accordingly. +If you wish to connect to a different server, create your own config file according to https://github.com/e-mission/nrel-openpath-deploy-configs and specify the `server` field accordingly. [deploy-configs](https://github.com/e-mission/nrel-openpath-deploy-configs/#testing-configs) has more information on this. Updating the e-mission-\* plugins or adding new plugins --- From ca0d569db01f2b7fdd825423fe4f212938494384 Mon Sep 17 00:00:00 2001 From: "K. Shankari" Date: Thu, 19 Oct 2023 13:00:14 -0700 Subject: [PATCH 180/850] clarify link to repo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 111e0d82a..121684e0a 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ are available in the [e-mission-server README](https://github.com/e-mission/e-mi The dynamic config (see https://github.com/e-mission/nrel-openpath-deploy-configs) controls the server endpoint that the phone app will connect to. If you are running the app in an emulator on the same machine as your local server (i.e. they share a `localhost`), you can use one of the `dev-emulator-*` configs (these configs have no `server` specified so `localhost` is assumed). -If you wish to connect to a different server, create your own config file according to https://github.com/e-mission/nrel-openpath-deploy-configs and specify the `server` field accordingly. [deploy-configs](https://github.com/e-mission/nrel-openpath-deploy-configs/#testing-configs) has more information on this. +If you wish to connect to a different server, create your own config file according to https://github.com/e-mission/nrel-openpath-deploy-configs and specify the `server` field accordingly. The [deploy-configs](https://github.com/e-mission/nrel-openpath-deploy-configs/#testing-configs) repo has more information on this. Updating the e-mission-\* plugins or adding new plugins --- From a26536c0d422d229e871da91bae3222220a9a9c5 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 19 Oct 2023 14:39:47 -0600 Subject: [PATCH 181/850] updating "keys" remove unused key, update to single source of truth for INTRO_DONE_KEY --- www/js/splash/startprefs.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index 71490f499..a69a970ec 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -1,5 +1,6 @@ import { storageGet, storageSet } from '../plugin/storage'; import { logInfo, logDebug, displayErrorMsg } from '../plugin/logger'; +import { INTRO_DONE_KEY } from '../onboarding/onboardingHelper'; type StartPrefs = { CONSENTED_EVENT: string, @@ -10,15 +11,11 @@ export const startPrefs: StartPrefs = { CONSENTED_EVENT: "data_collection_consented", INTRO_DONE_EVENT: "intro_done", }; -// Boolean: represents that the "intro" - the one page summary -// and the login are done -const INTRO_DONE_KEY = 'intro_done'; + // data collection consented protocol: string, represents the date on // which the consented protocol was approved by the IRB const DATA_COLLECTION_CONSENTED_PROTOCOL = 'data_collection_consented_protocol'; -const CONSENTED_KEY = "config/consent"; - let _req_consent; let _curr_consented; From d8d1a3779a2fcf56c99a4d0c112a93a269948a93 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 19 Oct 2023 14:43:05 -0600 Subject: [PATCH 182/850] update event protocol Pre-migration, there was a "consent done" event broadcasted from startprefs whenever the user marked consent. However, Reach and typescript does not handle events like this. Instead, we are extracting the code into a function, retrieving it from the plugin (for now), and then calling those functions when consent is marked. As a part of this, unify the reading of "isIntroDone" to the method in onboardingHelper, rather than storing a variable in startPrefs --- www/js/onboarding/onboardingHelper.ts | 4 ++-- www/js/splash/pushnotify.js | 17 ++++++++++------- www/js/splash/startprefs.ts | 24 +++++++----------------- www/js/splash/storedevicesettings.js | 14 ++++++++------ 4 files changed, 27 insertions(+), 32 deletions(-) diff --git a/www/js/onboarding/onboardingHelper.ts b/www/js/onboarding/onboardingHelper.ts index 78f5aa4d1..daec90cb1 100644 --- a/www/js/onboarding/onboardingHelper.ts +++ b/www/js/onboarding/onboardingHelper.ts @@ -2,7 +2,7 @@ import { DateTime } from "luxon"; import { getConfig, resetDataAndRefresh } from "../config/dynamicConfig"; import { storageGet, storageSet } from "../plugin/storage"; import { logDebug } from "../plugin/logger"; -import { readConsentState, isConsented, isIntroDone } from "../splash/startprefs"; +import { readConsentState, isConsented } from "../splash/startprefs"; export const INTRO_DONE_KEY = 'intro_done'; @@ -61,7 +61,7 @@ async function readConsented() { return readConsentState().then(isConsented) as Promise; } -async function readIntroDone() { +export async function readIntroDone() { return storageGet(INTRO_DONE_KEY).then((read_val) => !!read_val) as Promise; } diff --git a/www/js/splash/pushnotify.js b/www/js/splash/pushnotify.js index 874a539a5..0a5f9f18e 100644 --- a/www/js/splash/pushnotify.js +++ b/www/js/splash/pushnotify.js @@ -15,7 +15,8 @@ import angular from 'angular'; import { updateUser } from '../commHelper'; -import { readConsentState, isIntroDone, isConsented, startPrefs } from './startprefs'; +import { readConsentState, isConsented, startPrefs } from './startprefs'; +import { readIntroDone } from '../onboarding/onboardingHelper'; angular.module('emission.splash.pushnotify', ['emission.plugin.logger', 'emission.services']) @@ -172,14 +173,16 @@ angular.module('emission.splash.pushnotify', ['emission.plugin.logger', Logger.log("pushnotify startup done"); }); - $rootScope.$on(startPrefs.CONSENTED_EVENT, function(event, data) { - console.log("got consented event "+JSON.stringify(event.name) - +" with data "+ JSON.stringify(data)); - if (isIntroDone()) { + //new way of handling this, called in startprefs by markConsent + pushnotify.afterConsent = function () { + console.log("in pushnotify, executing after consent is received"); + readIntroDone().then((intro_done) => { + if (intro_done) { console.log("intro is done -> reconsent situation, we already have a token -> register"); pushnotify.registerPush(); - } - }); + } + }) + } $rootScope.$on(startPrefs.INTRO_DONE_EVENT, function(event, data) { console.log("intro is done -> original consent situation, we should have a token by now -> register"); diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index a69a970ec..5bcfac39a 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -1,6 +1,7 @@ import { storageGet, storageSet } from '../plugin/storage'; import { logInfo, logDebug, displayErrorMsg } from '../plugin/logger'; import { INTRO_DONE_KEY } from '../onboarding/onboardingHelper'; +import { getAngularService } from "../angular-react-helper"; type StartPrefs = { CONSENTED_EVENT: string, @@ -37,26 +38,15 @@ export function markConsented() { _req_consent); // mark in local variable as well _curr_consented = {..._req_consent}; - //TODO - find out how this is used and how to replace - //emit(startPrefs.CONSENTED_EVENT, _req_consent); + + //handle consent in other plugins - previously used $emit + const PushNotify = getAngularService("PushNotify"); + PushNotify.afterConsent(); + const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); + StoreSeviceSettings.afterConsent(); }); }; -//used in several places - storedevice, pushnotify -- onboardingHelper has other style... -let _intro_done = null; -let _is_intro_done; -export function isIntroDone() { - if (_intro_done == null || _intro_done == "") { - logDebug("in isIntroDone, returning false"); - _is_intro_done = false; - return false; - } else { - logDebug("in isIntroDone, returning true"); - _is_intro_done = true; - return true; - } -} - let _is_consented; //used in onboardingHelper export function isConsented() { diff --git a/www/js/splash/storedevicesettings.js b/www/js/splash/storedevicesettings.js index 2b60fbee4..f9ff0e00c 100644 --- a/www/js/splash/storedevicesettings.js +++ b/www/js/splash/storedevicesettings.js @@ -1,6 +1,7 @@ import angular from 'angular'; import { updateUser } from '../commHelper'; -import { isConsented, readConsentState, startPrefs, isIntroDone } from "./startprefs"; +import { isConsented, readConsentState, startPrefs } from "./startprefs"; +import { readIntroDone } from '../onboarding/onboardingHelper'; angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', 'emission.services']) @@ -44,14 +45,15 @@ angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', Logger.log("storedevicesettings startup done"); }); - $rootScope.$on(startPrefs.CONSENTED_EVENT, function(event, data) { - console.log("got consented event "+JSON.stringify(event.name) - +" with data "+ JSON.stringify(data)); - if (isIntroDone()) { + storedevicesettings.afterConsent = function() { + console.log("in storedevicesettings, executing after consent is received"); + readIntroDone().then((intro_done) => { + if (intro_done) { console.log("intro is done -> reconsent situation, we already have a token -> register"); storedevicesettings.storeDeviceSettings(); } - }); + }) + } $rootScope.$on(startPrefs.INTRO_DONE_EVENT, function(event, data) { console.log("intro is done -> original consent situation, we should have a token by now -> register"); From 8a99ba93f74345b97942983a4dd27b178fa7ee9c Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 19 Oct 2023 15:23:18 -0600 Subject: [PATCH 183/850] move plugin calls we actually need to wait until fully consented, and the user registered before we can take the actions in the plugins I noticed this because of an error message that appeared when logging in, indicating that the user was not registered, so the device settings could not be stored moving these calls to after the user has FULLY consented, resolved that issue --- www/js/onboarding/SaveQrPage.tsx | 7 +++++++ www/js/splash/startprefs.ts | 6 ------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/www/js/onboarding/SaveQrPage.tsx b/www/js/onboarding/SaveQrPage.tsx index 406376cfa..387ee4dc3 100644 --- a/www/js/onboarding/SaveQrPage.tsx +++ b/www/js/onboarding/SaveQrPage.tsx @@ -28,6 +28,13 @@ const SaveQrPage = ({ }) => { setRegisterUserDone(true); preloadDemoSurveyResponse(); refreshOnboardingState(); + + //fully consented, so can handle other aspects + //other plugins - previously used $emit + const PushNotify = getAngularService("PushNotify"); + PushNotify.afterConsent(); + const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); + StoreSeviceSettings.afterConsent(); }); } else { logDebug('permissions not done, waiting'); diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index 5bcfac39a..cb48b9769 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -38,12 +38,6 @@ export function markConsented() { _req_consent); // mark in local variable as well _curr_consented = {..._req_consent}; - - //handle consent in other plugins - previously used $emit - const PushNotify = getAngularService("PushNotify"); - PushNotify.afterConsent(); - const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); - StoreSeviceSettings.afterConsent(); }); }; From e6abae8396da8cc08a88618b8ae16371639bfa07 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 19 Oct 2023 14:40:09 -0700 Subject: [PATCH 184/850] Added `types` directory, updated TS - While the types themselves may be subject to change, the goal is to set up a good foundation for strict typing in the future! As such, I added some to a basic version of Jiji's diaryTypes (see PR #1061), and will be updating those regularly. --- www/js/types/diaryTypes.ts | 41 ++++++++++++++++++++++++++++++++++ www/js/types/fileShareTypes.ts | 12 ++++++++++ www/js/unifiedDataLoader.ts | 28 +++++------------------ 3 files changed, 58 insertions(+), 23 deletions(-) create mode 100644 www/js/types/diaryTypes.ts create mode 100644 www/js/types/fileShareTypes.ts diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts new file mode 100644 index 000000000..142629000 --- /dev/null +++ b/www/js/types/diaryTypes.ts @@ -0,0 +1,41 @@ +/* This is a draft of diaryTypes, originally added in PR #1061. This file appears + in #1052 as well; these three will most likely be consolidated and rewritten in a + future PR */ +export type LocalDt = { + minute: number, + hour: number, + second: number, + day: number, + weekday: number, + month: number, + year: number, + timezone: string, +} + +export type MetaData = { + key: string, + platform: string, + write_ts: number, + time_zone: string, + write_fmt_time: string, + write_local_dt: LocalDt, +} + +export type ServerDataPoint = { + data: any, + metadata: MetaData, + key?: string, + user_id?: { $uuid: string, }, + _id?: { $oid: string, }, +} + +/* These are the objects returned from BEMUserCache calls */ +export type ServerData = { + phone_data: Array +} + +export type TimeQuery = { + key: string; + startTs: number; + endTs: number; +} \ No newline at end of file diff --git a/www/js/types/fileShareTypes.ts b/www/js/types/fileShareTypes.ts new file mode 100644 index 000000000..7f0354f8c --- /dev/null +++ b/www/js/types/fileShareTypes.ts @@ -0,0 +1,12 @@ +export interface FsWindow extends Window { + requestFileSystem: ( + type: number, + size: number, + successCallback: (fs: any) => void, + errorCallback?: (error: any) => void + ) => void; + LocalFileSystem: { + TEMPORARY: number; + PERSISTENT: number; + }; +}; diff --git a/www/js/unifiedDataLoader.ts b/www/js/unifiedDataLoader.ts index 379c30165..f2e8c672c 100644 --- a/www/js/unifiedDataLoader.ts +++ b/www/js/unifiedDataLoader.ts @@ -1,24 +1,14 @@ import { logInfo } from './plugin/logger' import { getRawEntries } from './commHelper'; +import { ServerDataPoint, ServerData, TimeQuery } from './types/diaryTypes' -interface dataObj { - data: any; - metadata: { - plugin: string; - write_ts: number; - platform: string; - read_ts: number; - key: string; - type: string; - } -} /** * combineWithDedup is a helper function for combinedPromises * @param list1 values evaluated from a BEMUserCache promise * @param list2 same as list1 * @returns a dedup array generated from the input lists */ -const combineWithDedup = function(list1: Array, list2: Array) { +const combineWithDedup = function(list1: Array, list2: Array) { const combinedList = list1.concat(list2); return combinedList.filter(function(value, i, array) { const firstIndexOfValue = array.findIndex(function(element) { @@ -91,14 +81,6 @@ const combinedPromises = function(promiseList: Array>, }); }; -interface serverData { - phone_data: Array; -} -interface tQ { - key: string; - startTs: number; - endTs: number; -} /** * getUnifiedDataForInterval is a generalized method to fetch data by its timestamps * @param key string corresponding to a data entry @@ -106,12 +88,12 @@ interface tQ { * @param getMethod a BEMUserCache method that fetches certain data via a promise * @returns A promise that evaluates to the all values found within the queried data */ -export const getUnifiedDataForInterval = function(key: string, tq: tQ, - getMethod: (key: string, tq: tQ, flag: boolean) => Promise) { +export const getUnifiedDataForInterval = function(key: string, tq: TimeQuery, + getMethod: (key: string, tq: TimeQuery, flag: boolean) => Promise) { const test = true; const localPromise = getMethod(key, tq, test); const remotePromise = getRawEntries([key], tq.startTs, tq.endTs) - .then(function(serverResponse: serverData) { + .then(function(serverResponse: ServerData) { return serverResponse.phone_data; }); var promiseList = [localPromise, remotePromise] From 1edfc8c70c058a2e6ca64a367681ddfb2dc33115 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 19 Oct 2023 15:56:32 -0600 Subject: [PATCH 185/850] add docstrings for functions adding descriptions to the functions, and removed some unused imports --- www/js/splash/startprefs.ts | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index cb48b9769..f8626b9e8 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -1,7 +1,5 @@ import { storageGet, storageSet } from '../plugin/storage'; import { logInfo, logDebug, displayErrorMsg } from '../plugin/logger'; -import { INTRO_DONE_KEY } from '../onboarding/onboardingHelper'; -import { getAngularService } from "../angular-react-helper"; type StartPrefs = { CONSENTED_EVENT: string, @@ -20,6 +18,10 @@ const DATA_COLLECTION_CONSENTED_PROTOCOL = 'data_collection_consented_protocol'; let _req_consent; let _curr_consented; +/** + * @function writes the consent document to native storage + * @returns Promise to execute the write to storage +*/ function writeConsentToNative() { //note that this calls to the notification API, //so should not be called until we have notification permissions @@ -27,7 +29,10 @@ function writeConsentToNative() { return window['cordova'].plugins.BEMDataCollection.markConsented(_req_consent); }; -//used in ConsentPage +/** + * @function marks consent in native storage, local storage, and local var + * @returns Promise for marking the consent in native and local storage + */ export function markConsented() { logInfo("changing consent from " + _curr_consented + " -> " + JSON.stringify(_req_consent)); @@ -42,7 +47,11 @@ export function markConsented() { }; let _is_consented; -//used in onboardingHelper + +/** + * @function checking for consent locally + * @returns {boolean} if the consent is marked in the local var + */ export function isConsented() { if (_curr_consented == null || _curr_consented == "" || _curr_consented.approval_date != _req_consent.approval_date) { @@ -56,9 +65,11 @@ export function isConsented() { } } -//used in onboardingHelper +/** + * @function reads the consent state from the file and populates it + * @returns Promise for the stored consent file + */ export function readConsentState() { - // read consent state from the file and populate it return fetch("json/startupConfig.json") .then(response => response.json()) .then(function (startupConfigResult) { @@ -74,6 +85,10 @@ export function readConsentState() { }); } +/** + * @function gets the consent document from storage + * @returns Promise for the consent document or null if the doc is empty + */ //used in ProfileSettings export function getConsentDocument() { return window['cordova'].plugins.BEMUserCache.getDocument("config/consent", false) @@ -86,6 +101,10 @@ export function getConsentDocument() { }); }; +/** + * @function checks the consent doc in native storage + * @returns if doc not stored in native, a promise to write it there + */ function checkNativeConsent() { getConsentDocument().then(function (resultDoc) { if (resultDoc == null) { From d4a29b1f1e8b23d67b00af39eb2708867c8d661a Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 20 Oct 2023 10:16:02 -0600 Subject: [PATCH 186/850] first test for startprefs after adding more mocked functions, the startprefs now have one test, for getting the consent doc from storage --- www/__mocks__/cordovaMocks.ts | 20 ++++++++++++++++ www/__tests__/startprefs.test.ts | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 www/__tests__/startprefs.test.ts diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 4a9189ecd..424d4f424 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -87,6 +87,26 @@ export const mockBEMUserCache = () => { rs(messages.filter(m => m.key == key).map(m => m.value)); }, 100) ); + }, + getDocument: (key: string, withMetadata?: boolean) => { + return new Promise((rs, rj) => + setTimeout(() => { + rs(_cache[key]); + }, 100) + ); + }, + markConsented: (consentDoc) => { + _cache['config/consent'] = consentDoc; + }, + isEmptyDoc: (doc) => { + console.log(doc); + if (doc == undefined) { return true } + let string = doc.toString(); + if (string.length == 0) { + return true; + } else { + return false; + } } } window['cordova'] ||= {}; diff --git a/www/__tests__/startprefs.test.ts b/www/__tests__/startprefs.test.ts new file mode 100644 index 000000000..765637dc2 --- /dev/null +++ b/www/__tests__/startprefs.test.ts @@ -0,0 +1,39 @@ +import { markConsented, isConsented, readConsentState, getConsentDocument } from '../js/splash/startprefs'; + +import { mockBEMUserCache } from "../__mocks__/cordovaMocks"; +import { mockLogger } from "../__mocks__/globalMocks"; + +mockBEMUserCache(); +mockLogger(); + +global.fetch = (url: string) => new Promise((rs, rj) => { + setTimeout(() => rs({ + json: () => new Promise((rs, rj) => { + let jsonString = '{ "emSensorDataCollectionProtocol": { "protocol_id": "2014-04-6267", "approval_date": "2016-07-14" } }'; + setTimeout(() => rs(jsonString), 100); + }) + })); +}) as any; + +it('marks consent in local and native storage', async () => { + +}); + +it('checks local vars for consent, returns boolean', async () => { + +}); + +it('reads the required and current consent', async () => { + //gets the info from the json file + //then sets the local req_consent variable + //then storageGets the data_collection_consented_protocol + //then uses that to store in local curr_consent var + //and launches checkNativeConsent + //let consentFile = await readConsentState(); + //expect(consentFile).toBeUndefined(); +}); + +it('gets the consent document from storage', async () => { + let consentDoc = await getConsentDocument(); + expect(consentDoc).toBeNull(); +}); \ No newline at end of file From 40f6b93a7ca29b99383dcde84f977120f1a9e714 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 20 Oct 2023 10:49:23 -0600 Subject: [PATCH 187/850] test markConsented this test needed a mock of markConsented in the BEMDataCollection plugin, I have added _storage so that when the document is written in, it can also be retrieved -- even though that happens in two different plugins --- www/__mocks__/cordovaMocks.ts | 19 ++++++++++++++----- www/__tests__/startprefs.test.ts | 16 +++++++++------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 424d4f424..3e66cb163 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -24,6 +24,9 @@ export const mockGetAppVersion = () => { window['cordova'].getAppVersion = mockGetAppVersion; } +//for consent document +const _storage = {}; + export const mockBEMUserCache = () => { const _cache = {}; const messages = []; @@ -91,15 +94,11 @@ export const mockBEMUserCache = () => { getDocument: (key: string, withMetadata?: boolean) => { return new Promise((rs, rj) => setTimeout(() => { - rs(_cache[key]); + rs(_storage[key]); }, 100) ); }, - markConsented: (consentDoc) => { - _cache['config/consent'] = consentDoc; - }, isEmptyDoc: (doc) => { - console.log(doc); if (doc == undefined) { return true } let string = doc.toString(); if (string.length == 0) { @@ -113,3 +112,13 @@ export const mockBEMUserCache = () => { window['cordova'].plugins ||= {}; window['cordova'].plugins.BEMUserCache = mockBEMUserCache; } + +export const mockBEMDataCollection = () => { + const mockBEMDataCollection = { + markConsented: (consentDoc) => { + _storage['config/consent'] = consentDoc; + } + } + window['cordova'] ||= {}; + window['cordova'].plugins.BEMDataCollection = mockBEMDataCollection; +} diff --git a/www/__tests__/startprefs.test.ts b/www/__tests__/startprefs.test.ts index 765637dc2..994cfb284 100644 --- a/www/__tests__/startprefs.test.ts +++ b/www/__tests__/startprefs.test.ts @@ -1,9 +1,10 @@ import { markConsented, isConsented, readConsentState, getConsentDocument } from '../js/splash/startprefs'; -import { mockBEMUserCache } from "../__mocks__/cordovaMocks"; +import { mockBEMUserCache, mockBEMDataCollection } from "../__mocks__/cordovaMocks"; import { mockLogger } from "../__mocks__/globalMocks"; mockBEMUserCache(); +mockBEMDataCollection(); mockLogger(); global.fetch = (url: string) => new Promise((rs, rj) => { @@ -16,11 +17,12 @@ global.fetch = (url: string) => new Promise((rs, rj) => { }) as any; it('marks consent in local and native storage', async () => { - + let marked = markConsented(); + console.log("marked is", marked); }); it('checks local vars for consent, returns boolean', async () => { - + expect(isConsented()).toBeFalsy(); }); it('reads the required and current consent', async () => { @@ -29,11 +31,11 @@ it('reads the required and current consent', async () => { //then storageGets the data_collection_consented_protocol //then uses that to store in local curr_consent var //and launches checkNativeConsent - //let consentFile = await readConsentState(); - //expect(consentFile).toBeUndefined(); + // let consentFile = await readConsentState(); + expect(await readConsentState()).toBeUndefined(); }); it('gets the consent document from storage', async () => { - let consentDoc = await getConsentDocument(); - expect(consentDoc).toBeNull(); +// let consentDoc = await getConsentDocument(); + expect(await getConsentDocument()).toBeNull(); }); \ No newline at end of file From 9849fa21530c5f13ce6042e1776f7b27c23daaca Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 20 Oct 2023 12:09:16 -0600 Subject: [PATCH 188/850] test all functions in sequence since testing separately affects the local storage, testing in sequence allows for easier testing. return json from fetch rather than string to make it parseable in storage, had to check for the string undefined, since that was returned in one of the tests --- www/__tests__/startprefs.test.ts | 31 +++++++------------------------ www/js/plugin/storage.ts | 2 +- www/js/splash/startprefs.ts | 3 ++- 3 files changed, 10 insertions(+), 26 deletions(-) diff --git a/www/__tests__/startprefs.test.ts b/www/__tests__/startprefs.test.ts index 994cfb284..1e62e7b5e 100644 --- a/www/__tests__/startprefs.test.ts +++ b/www/__tests__/startprefs.test.ts @@ -10,32 +10,15 @@ mockLogger(); global.fetch = (url: string) => new Promise((rs, rj) => { setTimeout(() => rs({ json: () => new Promise((rs, rj) => { - let jsonString = '{ "emSensorDataCollectionProtocol": { "protocol_id": "2014-04-6267", "approval_date": "2016-07-14" } }'; - setTimeout(() => rs(jsonString), 100); + let myJSON = { "emSensorDataCollectionProtocol": { "protocol_id": "2014-04-6267", "approval_date": "2016-07-14" } }; + setTimeout(() => rs(myJSON), 100); }) })); }) as any; -it('marks consent in local and native storage', async () => { - let marked = markConsented(); - console.log("marked is", marked); -}); - -it('checks local vars for consent, returns boolean', async () => { - expect(isConsented()).toBeFalsy(); -}); - -it('reads the required and current consent', async () => { - //gets the info from the json file - //then sets the local req_consent variable - //then storageGets the data_collection_consented_protocol - //then uses that to store in local curr_consent var - //and launches checkNativeConsent - // let consentFile = await readConsentState(); - expect(await readConsentState()).toBeUndefined(); -}); - -it('gets the consent document from storage', async () => { -// let consentDoc = await getConsentDocument(); - expect(await getConsentDocument()).toBeNull(); +it('checks state of consent before and after marking consent', async () => { + expect(await readConsentState().then(isConsented)).toBeFalsy(); + let marked = await markConsented(); + expect(await readConsentState().then(isConsented)).toBeTruthy(); + expect(await getConsentDocument()).toEqual({"approval_date": "2016-07-14", "protocol_id": "2014-04-6267"}); }); \ No newline at end of file diff --git a/www/js/plugin/storage.ts b/www/js/plugin/storage.ts index 59e535b6e..e649d504d 100644 --- a/www/js/plugin/storage.ts +++ b/www/js/plugin/storage.ts @@ -30,7 +30,7 @@ const localStorageSet = (key: string, value: {[k: string]: any}) => { const localStorageGet = (key: string) => { const value = localStorage.getItem(key); - if (value) { + if (value && value != "undefined") { return JSON.parse(value); } else { return null; diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index f8626b9e8..2acda39b1 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -53,6 +53,7 @@ let _is_consented; * @returns {boolean} if the consent is marked in the local var */ export function isConsented() { + console.log("curr consented is", _curr_consented); if (_curr_consented == null || _curr_consented == "" || _curr_consented.approval_date != _req_consent.approval_date) { console.log("Not consented in local storage, need to show consent"); @@ -67,7 +68,7 @@ export function isConsented() { /** * @function reads the consent state from the file and populates it - * @returns Promise for the stored consent file + * @returns nothing, just reads into local variables */ export function readConsentState() { return fetch("json/startupConfig.json") From 2c6739a185c109fb24d70ea20ff57236dfa80c4d Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Fri, 20 Oct 2023 12:41:24 -0600 Subject: [PATCH 189/850] Remove translations from label-options.json.sample Since the default translations are in 18next files, we don't need a duplicate of the default label options translations in this file Also changed the logic because I didn't have it right when labelOptions.translations is undefined --- www/js/survey/multilabel/confirmHelper.ts | 4 +- www/json/label-options.json.sample | 72 +---------------------- 2 files changed, 3 insertions(+), 73 deletions(-) diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index cb94c1f2e..b668669bf 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -50,8 +50,8 @@ export async function getLabelOptions(appConfigParam?) { for (const opt in labelOptions) { labelOptions[opt]?.forEach?.((o, i) => { const translationKey = o.value; - // If translation exists in labelOptions, use that. Otherwise, use the one in the i18next. - const translation = labelOptions.translations[lang][translationKey] || i18next.t(`multilabel.${translationKey}`); + // If translation exists in labelOptions, use that. Otherwise, use the one in the i18next. If there is not "translations" field in labelOptions, defaultly use the one in the i18next. + const translation = labelOptions.translations ? labelOptions.translations[lang][translationKey] || i18next.t(`multilabel.${translationKey}`) : i18next.t(`multilabel.${translationKey}`); labelOptions[opt][i].text = translation; }); } diff --git a/www/json/label-options.json.sample b/www/json/label-options.json.sample index 0f4292c7f..9870b744f 100644 --- a/www/json/label-options.json.sample +++ b/www/json/label-options.json.sample @@ -50,75 +50,5 @@ {"value":"train"}, {"value":"free_shuttle"}, {"value":"other"} - ], - "translations": { - "en": { - "walk": "Walk", - "e-bike": "E-bike", - "bike": "Regular Bike", - "bikeshare": "Bikeshare", - "scootershare": "Scooter share", - "drove_alone": "Gas Car Drove Alone", - "shared_ride": "Gas Car Shared Ride", - "e_car_drove_alone": "E-Car Drove Alone", - "e_car_shared_ride": "E-Car Shared Ride", - "moped": "Moped", - "taxi": "Taxi / Uber / Lyft", - "bus": "Bus", - "train": "Train", - "free_shuttle": "Free Shuttle", - "air": "Air", - "not_a_trip": "Not a trip", - "no_travel": "No travel", - "home": "Home", - "work": "To Work", - "at_work": "At Work", - "school": "School", - "transit_transfer": "Transit transfer", - "shopping": "Shopping", - "meal": "Meal", - "pick_drop_person": "Pick-up / Drop off Person", - "pick_drop_item": "Pick-up / Drop off Item", - "personal_med": "Personal / Medical", - "access_recreation": "Access Recreation", - "exercise": "Recreation / Exercise", - "entertainment": "Entertainment / Social", - "religious": "Religious", - "other": "Other" - }, - "es": { - "walk": "Caminando", - "e-bike": "e-bicicleta", - "bike": "Bicicleta", - "bikeshare": "Bicicleta compartida", - "scootershare": "Motoneta compartida", - "drove_alone": "Coche de Gas, Condujo solo", - "shared_ride": "Coche de Gas, Condujo con otros", - "e_car_drove_alone": "e-coche, Condujo solo", - "e_car_shared_ride": "e-coche, Condujo con ontras", - "moped": "Ciclomotor", - "taxi": "Taxi / Uber / Lyft", - "bus": "Autobús", - "train": "Tren", - "free_shuttle": "Colectivo gratuito", - "air": "Avión", - "not_a_trip": "No es un viaje", - "no_travel": "No viajar", - "home": "Inicio", - "work": "Trabajo", - "at_work": "En el trabajo", - "school": "Escuela", - "transit_transfer": "Transbordo", - "shopping": "Compras", - "meal": "Comida", - "pick_drop_person": "Recoger / Entregar Individuo", - "pick_drop_item": "Recoger / Entregar Objeto", - "personal_med": "Personal / Médico", - "access_recreation": "Acceder a Recreación", - "exercise": "Recreación / Ejercicio", - "entertainment": "Entretenimiento / Social", - "religious": "Religioso", - "other": "Otros" - } - } + ] } \ No newline at end of file From 1674d461f438c9e50faff6d15b54787239b202d3 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Fri, 20 Oct 2023 11:42:50 -0700 Subject: [PATCH 190/850] Added first unifiedDataLoader test --- www/__tests__/unifiedDataLoader.test.ts | 39 +++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 www/__tests__/unifiedDataLoader.test.ts diff --git a/www/__tests__/unifiedDataLoader.test.ts b/www/__tests__/unifiedDataLoader.test.ts new file mode 100644 index 000000000..c814997a9 --- /dev/null +++ b/www/__tests__/unifiedDataLoader.test.ts @@ -0,0 +1,39 @@ +import { mockLogger } from '../__mocks__/globalMocks'; +import { combineWithDedup, combinedPromises, getUnifiedDataForInterval } from '../js/unifiedDataLoader' +import { ServerDataPoint } from '../js/types/diaryTypes'; + +mockLogger(); + +describe('combineWithDedup can', () => { + const testOne : ServerDataPoint = { + data: '', + metadata: { + key: '', + platform: '', + write_ts: 1, // the only value checked by combineWithDedup + time_zone: '', + write_fmt_time: '', + write_local_dt: null, + }, + }; + const testTwo = JSON.parse(JSON.stringify(testOne)); + testTwo.metadata.write_ts = 2; + const testThree = JSON.parse(JSON.stringify(testOne)); + testThree.metadata.write_ts = 3; + + it('work with empty arrays', () => { + expect(combineWithDedup([], [])).toEqual([]); + expect(combineWithDedup([], [testOne])).toEqual([testOne]); + expect(combineWithDedup([testOne, testTwo], [])).toEqual([testOne, testTwo]); + }); + it('Can work with arrays of len 1', () => { + expect(combineWithDedup([testOne], [testOne])).toEqual([testOne]); + expect(combineWithDedup([testOne], [testTwo])).toEqual([testOne, testTwo]); + }); + it('Can work with arrays of len > 1', () => { + expect(combineWithDedup([testOne], [testOne, testTwo])).toEqual([testOne, testTwo]); + expect(combineWithDedup([testOne], [testTwo, testTwo])).toEqual([testOne, testTwo]); + expect(combineWithDedup([testOne, testTwo], [testTwo, testTwo])).toEqual([testOne, testTwo]); + expect(combineWithDedup([testOne, testTwo, testThree], [testOne, testTwo])).toEqual([testOne, testTwo, testThree]); + }); +}); From 8ab5bf1099c40a3fd6d09715350dc595bac3c782 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 20 Oct 2023 13:56:58 -0600 Subject: [PATCH 191/850] add timeout for consistency, since this is also a function that is async --- www/__mocks__/cordovaMocks.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 3e66cb163..2ac50e229 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -116,7 +116,9 @@ export const mockBEMUserCache = () => { export const mockBEMDataCollection = () => { const mockBEMDataCollection = { markConsented: (consentDoc) => { - _storage['config/consent'] = consentDoc; + setTimeout(() => { + _storage['config/consent'] = consentDoc; + }, 100) } } window['cordova'] ||= {}; From 8d765d0bc9738af07fa16a6380969a76673d2161 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Fri, 20 Oct 2023 14:25:13 -0700 Subject: [PATCH 192/850] Added rudimentary type interfaces for server data --- www/js/types/serverData.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 www/js/types/serverData.ts diff --git a/www/js/types/serverData.ts b/www/js/types/serverData.ts new file mode 100644 index 000000000..7358cacf8 --- /dev/null +++ b/www/js/types/serverData.ts @@ -0,0 +1,28 @@ +export interface ServerData { + data: any, + metadata: MetaData, + key?: string, + user_id?: { $uuid: string, }, + _id?: { $oid: string, }, +}; + +export type MetaData = { + key: string, + platform: string, + write_ts: number, + time_zone: string, + write_fmt_time: string, + write_local_dt: LocalDt, +}; + +export type LocalDt = { + minute: number, + hour: number, + second: number, + day: number, + weekday: number, + month: number, + year: number, + timezone: string, +}; + \ No newline at end of file From 0bb95b27e235b1b2777d1105896532b9c2608515 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Fri, 20 Oct 2023 15:36:37 -0700 Subject: [PATCH 193/850] Added types for `/diary/` services! Many thanks to Jiji for doing the heavy lifting on this one in PR 1061! - Fixed indents - Switched ServerData from an interface to a Generic Type. After doing some further reading, the [TS Docs](https://www.typescriptlang.org/docs/handbook/2/objects.html#interfaces-vs-intersections) seem to suggest that this is a better way of handling container-style types. - Added basic ServerReponse type - this is often how data is wrapped when returned from functions like `getRawData()` --- www/js/types/diaryTypes.ts | 75 ++++++++++++++++++++++++++++++++++++++ www/js/types/serverData.ts | 44 ++++++++++++---------- 2 files changed, 99 insertions(+), 20 deletions(-) create mode 100644 www/js/types/diaryTypes.ts diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts new file mode 100644 index 000000000..b51725977 --- /dev/null +++ b/www/js/types/diaryTypes.ts @@ -0,0 +1,75 @@ +import { LocalDt, ServerData } from './serverData' + +export type UserInput = ServerData + +export type UserInputData = { + end_ts: number, + start_ts: number + label: string, + start_local_dt?: LocalDt + end_local_dt?: LocalDt + status?: string, + match_id?: string, +} + +type ConfirmedPlace = any; // TODO + +export type CompositeTrip = { + _id: {$oid: string}, + additions: any[], // TODO + cleaned_section_summary: any, // TODO + cleaned_trip: {$oid: string}, + confidence_threshold: number, + confirmed_trip: {$oid: string}, + distance: number, + duration: number, + end_confirmed_place: ConfirmedPlace, + end_fmt_time: string, + end_loc: {type: string, coordinates: number[]}, + end_local_dt: LocalDt, + end_place: {$oid: string}, + end_ts: number, + expectation: any, // TODO "{to_label: boolean}" + expected_trip: {$oid: string}, + inferred_labels: any[], // TODO + inferred_section_summary: any, // TODO + inferred_trip: {$oid: string}, + key: string, + locations: any[], // TODO + origin_key: string, + raw_trip: {$oid: string}, + sections: any[], // TODO + source: string, + start_confirmed_place: ConfirmedPlace, + start_fmt_time: string, + start_loc: {type: string, coordinates: number[]}, + start_local_dt: LocalDt, + start_place: {$oid: string}, + start_ts: number, + user_input: UserInput, +} + +export type PopulatedTrip = CompositeTrip & { + additionsList?: any[], // TODO + finalInference?: any, // TODO + geojson?: any, // TODO + getNextEntry?: () => PopulatedTrip | ConfirmedPlace, + userInput?: UserInput, + verifiability?: string, +} + +export type Trip = { + end_ts: number, + start_ts: number, +} + +export type TlEntry = { + key: string, + origin_key: string, + start_ts: number, + end_ts: number, + enter_ts: number, + exit_ts: number, + duration: number, +getNextEntry?: () => PopulatedTrip | ConfirmedPlace, +} \ No newline at end of file diff --git a/www/js/types/serverData.ts b/www/js/types/serverData.ts index 7358cacf8..35bd283bb 100644 --- a/www/js/types/serverData.ts +++ b/www/js/types/serverData.ts @@ -1,28 +1,32 @@ -export interface ServerData { - data: any, - metadata: MetaData, - key?: string, - user_id?: { $uuid: string, }, - _id?: { $oid: string, }, +export type ServerResponse = { + phone_data: Array>, +} + +export type ServerData = { + data: Type, + metadata: MetaData, + key?: string, + user_id?: { $uuid: string, }, + _id?: { $oid: string, }, }; export type MetaData = { - key: string, - platform: string, - write_ts: number, - time_zone: string, - write_fmt_time: string, - write_local_dt: LocalDt, + key: string, + platform: string, + write_ts: number, + time_zone: string, + write_fmt_time: string, + write_local_dt: LocalDt, }; export type LocalDt = { - minute: number, - hour: number, - second: number, - day: number, - weekday: number, - month: number, - year: number, - timezone: string, + minute: number, + hour: number, + second: number, + day: number, + weekday: number, + month: number, + year: number, + timezone: string, }; \ No newline at end of file From 49e1757809780eff193adf552f992532e8b4cee3 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Fri, 20 Oct 2023 15:56:21 -0700 Subject: [PATCH 194/850] Added types for `controlHelper.ts` --- www/js/types/fileShareTypes.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 www/js/types/fileShareTypes.ts diff --git a/www/js/types/fileShareTypes.ts b/www/js/types/fileShareTypes.ts new file mode 100644 index 000000000..89481624d --- /dev/null +++ b/www/js/types/fileShareTypes.ts @@ -0,0 +1,22 @@ +import { ServerData } from './serverData'; + +export type TimeStampData = ServerData; + +export type RawTimelineData = { + name: string, + ts: number, + reading: number, +}; + +export interface FsWindow extends Window { + requestFileSystem: ( + type: number, + size: number, + successCallback: (fs: any) => void, + errorCallback?: (error: any) => void + ) => void; + LocalFileSystem: { + TEMPORARY: number; + PERSISTENT: number; + }; +}; From f2f0cfb94dd8675635b622627aec73ca31941b47 Mon Sep 17 00:00:00 2001 From: "K. Shankari" Date: Fri, 20 Oct 2023 16:17:45 -0700 Subject: [PATCH 195/850] FETCH -> GET to match HTTP request types --- www/__tests__/uploadService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/__tests__/uploadService.test.ts b/www/__tests__/uploadService.test.ts index 6aea5805d..5c64fae0e 100644 --- a/www/__tests__/uploadService.test.ts +++ b/www/__tests__/uploadService.test.ts @@ -27,7 +27,7 @@ global.fetch = (url: string, options: { method: string, headers: {}, body: strin rs('sent ' + options.method + options.body + ' to ' + url); }, 100); } - //else it is a fetch request + //else it is a get request else { setTimeout(() => rs({ json: () => new Promise((rs, rj) => { From aebb1c33aec2ac370507bf496dd3c71d2f51e5a2 Mon Sep 17 00:00:00 2001 From: Shankari Date: Sat, 21 Oct 2023 11:23:26 -0700 Subject: [PATCH 196/850] Build the release version of android when creating the build to upload --- bin/sign_and_align_keys.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/sign_and_align_keys.sh b/bin/sign_and_align_keys.sh index 261058bd5..74c04c020 100644 --- a/bin/sign_and_align_keys.sh +++ b/bin/sign_and_align_keys.sh @@ -10,7 +10,7 @@ fi # Sign and release the L+ version # Make sure the highest supported version has the biggest version code -npm run build-prod-android +npm run build-prod-android-release # cp platforms/android/app/build/outputs/apk/release/app-release-unsigned.aab platforms/android/app/build/outputs/apk/app-release-signed-unaligned.apk jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 -keystore ../config_files/production.keystore ./platforms/android/app/build/outputs/bundle/release/app-release.aab androidproductionkey cp platforms/android/app/build/outputs/bundle/release/app-release.aab $1-build-$2.aab From 284c47c4dfd0e1a2fe7a97d0b15175fedfc2397b Mon Sep 17 00:00:00 2001 From: Shankari Date: Sat, 21 Oct 2023 11:24:12 -0700 Subject: [PATCH 197/850] Improve error message --- bin/sign_and_align_keys.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/sign_and_align_keys.sh b/bin/sign_and_align_keys.sh index 74c04c020..9b60c3ade 100644 --- a/bin/sign_and_align_keys.sh +++ b/bin/sign_and_align_keys.sh @@ -4,7 +4,7 @@ PROJECT=$1 VERSION=$2 if [[ $# -eq 0 ]]; then - echo "No arguments supplied" + echo "sign_and_align_keys " exit 1 fi From a26698fb044c839ab9d83541609780535fe9d7d7 Mon Sep 17 00:00:00 2001 From: Shankari Date: Sat, 21 Oct 2023 11:33:30 -0700 Subject: [PATCH 198/850] Run the CI on UI feature branches as well So that we can check that the tests are passing before we merge --- .github/workflows/serve-install.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/serve-install.yml b/.github/workflows/serve-install.yml index a5e634821..d180a9384 100644 --- a/.github/workflows/serve-install.yml +++ b/.github/workflows/serve-install.yml @@ -9,10 +9,12 @@ on: branches: - master - maint_upgrade_** + - ui_feature_** pull_request: branches: - master - maint_upgrade_** + - ui_feature_** schedule: # * is a special character in YAML so you have to quote this string - cron: '5 4 * * 0' From 34cd182fefa5e89d0596d01a1ed7109bf4e5f9db Mon Sep 17 00:00:00 2001 From: Shankari Date: Sat, 21 Oct 2023 11:53:52 -0700 Subject: [PATCH 199/850] Temporarily add `service_rewrite_2023` to the list of branches that we run the CI on To avoid having to make a new branch and making everybody switch --- .github/workflows/serve-install.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/serve-install.yml b/.github/workflows/serve-install.yml index d180a9384..51852ed71 100644 --- a/.github/workflows/serve-install.yml +++ b/.github/workflows/serve-install.yml @@ -15,6 +15,7 @@ on: - master - maint_upgrade_** - ui_feature_** + - service_rewrite_2023 schedule: # * is a special character in YAML so you have to quote this string - cron: '5 4 * * 0' From 07b7d20ba7bbd385cc0c74600b72928234158146 Mon Sep 17 00:00:00 2001 From: Shankari Date: Sat, 21 Oct 2023 11:57:21 -0700 Subject: [PATCH 200/850] Replace tabs with spaces --- .github/workflows/serve-install.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/serve-install.yml b/.github/workflows/serve-install.yml index 51852ed71..c78ce1f86 100644 --- a/.github/workflows/serve-install.yml +++ b/.github/workflows/serve-install.yml @@ -9,13 +9,13 @@ on: branches: - master - maint_upgrade_** - - ui_feature_** + - ui_feature_** pull_request: branches: - master - maint_upgrade_** - - ui_feature_** - - service_rewrite_2023 + - ui_feature_** + - service_rewrite_2023 schedule: # * is a special character in YAML so you have to quote this string - cron: '5 4 * * 0' From d54731fdef2ed1c691eaaf71bd0e713f3e363dc2 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Sat, 21 Oct 2023 16:22:41 -0600 Subject: [PATCH 201/850] add hybrid car added hybrid car to label-options file and to en i18n --- www/i18n/en.json | 2 ++ www/json/label-options.json.sample | 2 ++ 2 files changed, 4 insertions(+) diff --git a/www/i18n/en.json b/www/i18n/en.json index 9217339f7..a3f4642f3 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -154,6 +154,8 @@ "scootershare": "Scooter share", "drove_alone": "Gas Car Drove Alone", "shared_ride": "Gas Car Shared Ride", + "hybrid_drove_alone": "Hybrid Drove Alone", + "hybrid_shared_ride": "Hybrid Shared Ride", "e_car_drove_alone": "E-Car Drove Alone", "e_car_shared_ride": "E-Car Shared Ride", "moped": "Moped", diff --git a/www/json/label-options.json.sample b/www/json/label-options.json.sample index 9870b744f..bd428628c 100644 --- a/www/json/label-options.json.sample +++ b/www/json/label-options.json.sample @@ -7,6 +7,8 @@ {"value":"scootershare", "baseMode":"E_SCOOTER", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.00894}, {"value":"drove_alone", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.22031}, {"value":"shared_ride", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.11015}, + {"value":"hybrid_drove_alone", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.138}, + {"value":"hybrid_shared_ride", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.069}, {"value":"e_car_drove_alone", "baseMode":"E_CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.08216}, {"value":"e_car_shared_ride", "baseMode":"E_CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.04108}, {"value":"moped", "baseMode":"MOPED", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.05555}, From a9d98ed20e3ecda235e81b2f80f81e91826c9b6d Mon Sep 17 00:00:00 2001 From: louisg1337 Date: Sun, 22 Oct 2023 22:03:50 -0400 Subject: [PATCH 202/850] Bumped up version for new location permissions release --- package.cordovabuild.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.cordovabuild.json b/package.cordovabuild.json index 943f06520..25145b5ed 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -126,7 +126,7 @@ "cordova-plugin-app-version": "0.1.14", "cordova-plugin-customurlscheme": "5.0.2", "cordova-plugin-device": "2.1.0", - "cordova-plugin-em-datacollection": "git+https://github.com/e-mission/e-mission-data-collection.git#v1.7.9", + "cordova-plugin-em-datacollection": "git+https://github.com/e-mission/e-mission-data-collection.git#v1.8.1", "cordova-plugin-em-opcodeauth": "git+https://github.com/e-mission/cordova-jwt-auth.git#v1.7.2", "cordova-plugin-em-server-communication": "git+https://github.com/e-mission/cordova-server-communication.git#v1.2.6", "cordova-plugin-em-serversync": "git+https://github.com/e-mission/cordova-server-sync.git#v1.3.2", From 60caee5ab2a7a598110e79ef2f294d7e66868b8c Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 23 Oct 2023 09:06:47 -0600 Subject: [PATCH 203/850] remove "protocol id" since we no longer work with the IRB protocol within the app, we don't need to present it to the user --- www/i18n/en.json | 2 +- www/js/control/ProfileSettings.jsx | 2 +- www/json/startupConfig.json | 1 - www/json/startupConfig.json.sample | 1 - 4 files changed, 2 insertions(+), 4 deletions(-) diff --git a/www/i18n/en.json b/www/i18n/en.json index 9217339f7..ed4ea2d2e 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -66,7 +66,7 @@ "consent-not-found": "Consent for data collection not found, consent now?", "no-consent-message": "OK! Note that you won't get any personalized stats until you do!", "consent-found": "Consent found!", - "consented-to": "Consented to protocol {{protocol_id}}, {{approval_date}}", + "consented-to": "Consented on {{approval_date}}", "consented-ok": "OK", "qrcode": "My OPcode", "qrcode-share-title": "You can save your OPcode to login easily in the future!" diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index de5469519..32e4c2a8d 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -496,7 +496,7 @@ const ProfileSettings = () => { setConsentVis(false)} style={settingStyles.dialog(colors.elevation.level3)}> - {t('general-settings.consented-to', {protocol_id: consentDoc.protocol_id, approval_date: consentDoc.approval_date})} + {t('general-settings.consented-to', {approval_date: consentDoc.approval_date})} - From 6dc7e4f674e7f55fdceb0f32f676201ad62fbb30 Mon Sep 17 00:00:00 2001 From: Abby Wheelis <54848919+Abby-Wheelis@users.noreply.github.com> Date: Mon, 23 Oct 2023 10:52:06 -0600 Subject: [PATCH 205/850] update hybrid kgCo2PerKm https://github.com/e-mission/e-mission-docs/issues/1013#issuecomment-1773461173 --- www/json/label-options.json.sample | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/www/json/label-options.json.sample b/www/json/label-options.json.sample index bd428628c..a2b49258c 100644 --- a/www/json/label-options.json.sample +++ b/www/json/label-options.json.sample @@ -7,8 +7,8 @@ {"value":"scootershare", "baseMode":"E_SCOOTER", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.00894}, {"value":"drove_alone", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.22031}, {"value":"shared_ride", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.11015}, - {"value":"hybrid_drove_alone", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.138}, - {"value":"hybrid_shared_ride", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.069}, + {"value":"hybrid_drove_alone", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.127}, + {"value":"hybrid_shared_ride", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.0635}, {"value":"e_car_drove_alone", "baseMode":"E_CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.08216}, {"value":"e_car_shared_ride", "baseMode":"E_CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.04108}, {"value":"moped", "baseMode":"MOPED", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.05555}, @@ -53,4 +53,4 @@ {"value":"free_shuttle"}, {"value":"other"} ] -} \ No newline at end of file +} From f0da15e627b16a0172c0cff1530271d54c9e8314 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Mon, 23 Oct 2023 15:48:05 -0700 Subject: [PATCH 206/850] Changed time conversion, fixed minor issues - Removed getUnixNumber, fixed time conversions - Fixed minor types, formatting issues - Removed redundant code --- www/js/controlHelper.ts | 48 ++++++++++++++++------------------------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/www/js/controlHelper.ts b/www/js/controlHelper.ts index 1b1755395..feade8622 100644 --- a/www/js/controlHelper.ts +++ b/www/js/controlHelper.ts @@ -30,7 +30,7 @@ export const createWriteFile = function (fileName: string) { reject(); } - // if data object is not passed in, create a new blog instead. + // if data object is not passed in, create a new blob instead. const dataObj = new Blob([JSON.stringify(resultList, null, 2)], { type: "application/json" }); fileWriter.write(dataObj); @@ -51,7 +51,7 @@ export const createShareData = function(fileName: string, startTimeString: strin return function() { return new Promise(function(resolve, reject) { window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { - logDebug("During email, file system open: " + fs.name); + logDebug(`During email, file system open: ${fs.name}`); fs.root.getFile(fileName, null, function(fileEntry) { logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`); fileEntry.file(function(file) { @@ -63,22 +63,18 @@ export const createShareData = function(fileName: string, startTimeString: strin const dataArray = JSON.parse(readResult); logDebug(`Successfully read resultList of size ${dataArray.length}`); let attachFile = fileEntry.nativeURL; - if (window['device'].platform === "android") - attachFile = "app://cache/" + fileName; - if (window['device'].platform === "ios") - alert(i18next.t("email-service.email-account-mail-app")); - const email = { - 'files': [attachFile], - 'message': i18next.t("email-service.email-data.body-data-consists-of-list-of-entries"), - 'subject': i18next.t("email-service.email-data.subject-data-dump-from-to", {start: startTimeString ,end: endTimeString}), - } - window['plugins'].socialsharing.shareWithOptions(email, function (result) { - logDebug(`Share Completed? ${result.completed}`); // On Android, most likely returns false - logDebug(`Shared to app: ${result.app}`); - resolve(); - }, function (msg) { - logDebug(`Sharing failed with message ${msg}`); - }); + const email = { + 'files': [attachFile], + 'message': i18next.t("email-service.email-data.body-data-consists-of-list-of-entries"), + 'subject': i18next.t("email-service.email-data.subject-data-dump-from-to", {start: startTimeString ,end: endTimeString}), + } + window['plugins'].socialsharing.shareWithOptions(email, function (result) { + logDebug(`Share Completed? ${result.completed}`); // On Android, most likely returns false + logDebug(`Shared to app: ${result.app}`); + resolve(); + }, function (msg) { + logDebug(`Sharing failed with message ${msg}`); + }); } reader.readAsText(file); }, function(error) { @@ -92,13 +88,13 @@ export const createShareData = function(fileName: string, startTimeString: strin /** * getMyData fetches timeline data for a given day, and then gives the user a prompt to share the data - * @param startTs initial timestamp of the timeline to be fetched. + * @param timeStamp initial timestamp of the timeline to be fetched. */ -export const getMyData = function(startTs: Date) { +export const getMyData = function(timeStamp: Date) { // We are only retrieving data for a single day to avoid // running out of memory on the phone - const startTime = DateTime.fromJSDate(startTs); - const endTime = startTime.endOf("day"); + const endTime = DateTime.fromJSDate(timeStamp); + const startTime = endTime.startOf('day'); const startTimeString = startTime.toFormat("yyyy'-'MM'-'dd"); const endTimeString = endTime.toFormat("yyyy'-'MM'-'dd"); @@ -107,16 +103,10 @@ export const getMyData = function(startTs: Date) { + ".timeline"; alert(`Going to retrieve data to ${dumpFile}`); - // Simulate old conversion to get correct UnixInteger for endMoment data - const getUnixNum = (dateData: DateTime) => { - const tempDate = dateData.toFormat("dd MMM yyyy"); - return DateTime.fromFormat(tempDate, "dd MMM yyyy").toUnixInteger(); - }; - const writeDumpFile = createWriteFile(dumpFile); const shareData = createShareData(dumpFile, startTimeString, endTimeString); - getRawEntries(null, getUnixNum(startTime), startTime.toUnixInteger()) + getRawEntries(null, startTime.toUnixInteger(), endTime.toUnixInteger()) .then(writeDumpFile) .then(shareData) .then(function() { From 7dd8ffb7d64cb83f0a523a1ee9953ee45ec45029 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Tue, 24 Oct 2023 12:28:31 -0700 Subject: [PATCH 207/850] Added tests for combinedPromises - Minor changes to unifiedDataLoader: adjusted logInfo, exports - Wrote test cases for combinedPromises --- www/__tests__/unifiedDataLoader.test.ts | 95 ++++++++++++++++++++----- www/js/unifiedDataLoader.ts | 13 ++-- 2 files changed, 85 insertions(+), 23 deletions(-) diff --git a/www/__tests__/unifiedDataLoader.test.ts b/www/__tests__/unifiedDataLoader.test.ts index c814997a9..db0994519 100644 --- a/www/__tests__/unifiedDataLoader.test.ts +++ b/www/__tests__/unifiedDataLoader.test.ts @@ -4,36 +4,95 @@ import { ServerDataPoint } from '../js/types/diaryTypes'; mockLogger(); -describe('combineWithDedup can', () => { - const testOne : ServerDataPoint = { - data: '', - metadata: { - key: '', - platform: '', - write_ts: 1, // the only value checked by combineWithDedup - time_zone: '', - write_fmt_time: '', - write_local_dt: null, - }, - }; - const testTwo = JSON.parse(JSON.stringify(testOne)); - testTwo.metadata.write_ts = 2; - const testThree = JSON.parse(JSON.stringify(testOne)); - testThree.metadata.write_ts = 3; +const testOne : ServerDataPoint = { + data: '', + metadata: { + key: '', + platform: '', + write_ts: 1, // the only value checked by combineWithDedup + time_zone: '', + write_fmt_time: '', + write_local_dt: null, + }, +}; + +const testTwo = JSON.parse(JSON.stringify(testOne)); +testTwo.metadata.write_ts = 2; +const testThree = JSON.parse(JSON.stringify(testOne)); +testThree.metadata.write_ts = 3; +const testFour= JSON.parse(JSON.stringify(testOne)); +testFour.metadata.write_ts = 4; +describe('combineWithDedup can', () => { it('work with empty arrays', () => { expect(combineWithDedup([], [])).toEqual([]); expect(combineWithDedup([], [testOne])).toEqual([testOne]); expect(combineWithDedup([testOne, testTwo], [])).toEqual([testOne, testTwo]); }); - it('Can work with arrays of len 1', () => { + it('work with arrays of len 1', () => { expect(combineWithDedup([testOne], [testOne])).toEqual([testOne]); expect(combineWithDedup([testOne], [testTwo])).toEqual([testOne, testTwo]); }); - it('Can work with arrays of len > 1', () => { + it('work with arrays of len > 1', () => { expect(combineWithDedup([testOne], [testOne, testTwo])).toEqual([testOne, testTwo]); expect(combineWithDedup([testOne], [testTwo, testTwo])).toEqual([testOne, testTwo]); expect(combineWithDedup([testOne, testTwo], [testTwo, testTwo])).toEqual([testOne, testTwo]); expect(combineWithDedup([testOne, testTwo, testThree], [testOne, testTwo])).toEqual([testOne, testTwo, testThree]); }); }); + +// combinedPromises tests +const promiseGenerator = (values: Array) => { + return Promise.resolve(values); +}; + +it('throws an error on an empty input', async () => { + expect(() => {combinedPromises([], combineWithDedup)}).toThrow(); +}); + +it('work with arrays of len 1', async () => { + const promiseArrayOne = [promiseGenerator([testOne])]; + const promiseArrayTwo = [promiseGenerator([testOne, testTwo])]; + const testResultOne = await combinedPromises(promiseArrayOne, combineWithDedup); + const testResultTwo = await combinedPromises(promiseArrayTwo, combineWithDedup); + + expect(testResultOne).toEqual([testOne]); + expect(testResultTwo).toEqual([testOne, testTwo]); +}); + +it('works with arrays of len 2', async () => { + const promiseArrayOne = [promiseGenerator([testOne]), promiseGenerator([testTwo])]; + const promiseArrayTwo = [promiseGenerator([testOne, testTwo]), promiseGenerator([testThree])]; + const promiseArrayThree = [promiseGenerator([testOne]), promiseGenerator([testTwo, testThree])]; + const promiseArrayFour = [promiseGenerator([testOne, testTwo]), promiseGenerator([testThree, testFour])]; + const promiseArrayFive = [promiseGenerator([testOne, testTwo]), promiseGenerator([testTwo, testThree])]; + + const testResultOne = await combinedPromises(promiseArrayOne, combineWithDedup); + const testResultTwo = await combinedPromises(promiseArrayTwo, combineWithDedup); + const testResultThree = await combinedPromises(promiseArrayThree, combineWithDedup); + const testResultFour = await combinedPromises(promiseArrayFour, combineWithDedup); + const testResultFive = await combinedPromises(promiseArrayFive, combineWithDedup); + + expect(testResultOne).toEqual([testOne, testTwo]); + expect(testResultTwo).toEqual([testOne, testTwo, testThree]); + expect(testResultThree).toEqual([testOne, testTwo, testThree]); + expect(testResultFour).toEqual([testOne, testTwo, testThree, testFour]); + expect(testResultFive).toEqual([testOne, testTwo, testThree]); +}); + +it('works with arrays of len >= 2', async () => { + const promiseArrayOne = [promiseGenerator([testOne]), promiseGenerator([testTwo]), promiseGenerator([testThree])]; + const promiseArrayTwo = [promiseGenerator([testOne]), promiseGenerator([testTwo]), promiseGenerator([testTwo])]; + const promiseArrayThree = [promiseGenerator([testOne]), promiseGenerator([testTwo]), promiseGenerator([testThree, testFour])]; + const promiseArrayFour = [promiseGenerator([testOne]), promiseGenerator([testTwo, testThree]), promiseGenerator([testFour])]; + + const testResultOne = await combinedPromises(promiseArrayOne, combineWithDedup); + const testResultTwo = await combinedPromises(promiseArrayTwo, combineWithDedup); + const testResultThree = await combinedPromises(promiseArrayThree, combineWithDedup); + const testResultFour = await combinedPromises(promiseArrayFour, combineWithDedup); + + expect(testResultOne).toEqual([testOne, testTwo, testThree]); + expect(testResultTwo).toEqual([testOne, testTwo]); + expect(testResultThree).toEqual([testOne, testTwo, testThree, testFour]); + expect(testResultFour).toEqual([testOne, testTwo, testThree, testFour]); +}); diff --git a/www/js/unifiedDataLoader.ts b/www/js/unifiedDataLoader.ts index f2e8c672c..f7036663a 100644 --- a/www/js/unifiedDataLoader.ts +++ b/www/js/unifiedDataLoader.ts @@ -1,4 +1,4 @@ -import { logInfo } from './plugin/logger' +import { logDebug } from './plugin/logger' import { getRawEntries } from './commHelper'; import { ServerDataPoint, ServerData, TimeQuery } from './types/diaryTypes' @@ -8,7 +8,7 @@ import { ServerDataPoint, ServerData, TimeQuery } from './types/diaryTypes' * @param list2 same as list1 * @returns a dedup array generated from the input lists */ -const combineWithDedup = function(list1: Array, list2: Array) { +export const combineWithDedup = function(list1: Array, list2: Array) { const combinedList = list1.concat(list2); return combinedList.filter(function(value, i, array) { const firstIndexOfValue = array.findIndex(function(element) { @@ -24,8 +24,11 @@ const combineWithDedup = function(list1: Array, list2: Array>, +export const combinedPromises = function(promiseList: Array>, combiner: (list1: Array, list2: Array) => Array ) { + if (promiseList.length === 0) { + throw new RangeError('combinedPromises needs input array.length >= 1'); + } return new Promise(function(resolve, reject) { var firstResult = []; var firstError = null; @@ -41,11 +44,11 @@ const combinedPromises = function(promiseList: Array>, if (firstError && nextError) { reject([firstError, nextError]); } else { - logInfo("About to dedup localResult = "+firstResult.length + logDebug("About to dedup localResult = "+firstResult.length +"remoteResult = "+nextResult.length); const dedupedList = combiner(firstResult, nextResult); - logInfo("Deduped list = "+dedupedList.length); + logDebug("Deduped list = "+dedupedList.length); resolve(dedupedList); } } From 1907966240ad940a60b9169a077eba10bd6d7b33 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Tue, 24 Oct 2023 15:29:03 -0700 Subject: [PATCH 208/850] Updated tests, formatted test file using Prettier --- www/__tests__/unifiedDataLoader.test.ts | 57 +++++++++++++++++++------ 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/www/__tests__/unifiedDataLoader.test.ts b/www/__tests__/unifiedDataLoader.test.ts index db0994519..5db23982b 100644 --- a/www/__tests__/unifiedDataLoader.test.ts +++ b/www/__tests__/unifiedDataLoader.test.ts @@ -1,10 +1,10 @@ import { mockLogger } from '../__mocks__/globalMocks'; -import { combineWithDedup, combinedPromises, getUnifiedDataForInterval } from '../js/unifiedDataLoader' +import { combineWithDedup, combinedPromises } from '../js/unifiedDataLoader'; import { ServerDataPoint } from '../js/types/diaryTypes'; mockLogger(); -const testOne : ServerDataPoint = { +const testOne: ServerDataPoint = { data: '', metadata: { key: '', @@ -20,7 +20,7 @@ const testTwo = JSON.parse(JSON.stringify(testOne)); testTwo.metadata.write_ts = 2; const testThree = JSON.parse(JSON.stringify(testOne)); testThree.metadata.write_ts = 3; -const testFour= JSON.parse(JSON.stringify(testOne)); +const testFour = JSON.parse(JSON.stringify(testOne)); testFour.metadata.write_ts = 4; describe('combineWithDedup can', () => { @@ -37,7 +37,11 @@ describe('combineWithDedup can', () => { expect(combineWithDedup([testOne], [testOne, testTwo])).toEqual([testOne, testTwo]); expect(combineWithDedup([testOne], [testTwo, testTwo])).toEqual([testOne, testTwo]); expect(combineWithDedup([testOne, testTwo], [testTwo, testTwo])).toEqual([testOne, testTwo]); - expect(combineWithDedup([testOne, testTwo, testThree], [testOne, testTwo])).toEqual([testOne, testTwo, testThree]); + expect(combineWithDedup([testOne, testTwo, testThree], [testOne, testTwo])).toEqual([ + testOne, + testTwo, + testThree, + ]); }); }); @@ -47,7 +51,9 @@ const promiseGenerator = (values: Array) => { }; it('throws an error on an empty input', async () => { - expect(() => {combinedPromises([], combineWithDedup)}).toThrow(); + expect(() => { + combinedPromises([], combineWithDedup); + }).toThrow(); }); it('work with arrays of len 1', async () => { @@ -55,7 +61,7 @@ it('work with arrays of len 1', async () => { const promiseArrayTwo = [promiseGenerator([testOne, testTwo])]; const testResultOne = await combinedPromises(promiseArrayOne, combineWithDedup); const testResultTwo = await combinedPromises(promiseArrayTwo, combineWithDedup); - + expect(testResultOne).toEqual([testOne]); expect(testResultTwo).toEqual([testOne, testTwo]); }); @@ -64,8 +70,14 @@ it('works with arrays of len 2', async () => { const promiseArrayOne = [promiseGenerator([testOne]), promiseGenerator([testTwo])]; const promiseArrayTwo = [promiseGenerator([testOne, testTwo]), promiseGenerator([testThree])]; const promiseArrayThree = [promiseGenerator([testOne]), promiseGenerator([testTwo, testThree])]; - const promiseArrayFour = [promiseGenerator([testOne, testTwo]), promiseGenerator([testThree, testFour])]; - const promiseArrayFive = [promiseGenerator([testOne, testTwo]), promiseGenerator([testTwo, testThree])]; + const promiseArrayFour = [ + promiseGenerator([testOne, testTwo]), + promiseGenerator([testThree, testFour]), + ]; + const promiseArrayFive = [ + promiseGenerator([testOne, testTwo]), + promiseGenerator([testTwo, testThree]), + ]; const testResultOne = await combinedPromises(promiseArrayOne, combineWithDedup); const testResultTwo = await combinedPromises(promiseArrayTwo, combineWithDedup); @@ -81,10 +93,26 @@ it('works with arrays of len 2', async () => { }); it('works with arrays of len >= 2', async () => { - const promiseArrayOne = [promiseGenerator([testOne]), promiseGenerator([testTwo]), promiseGenerator([testThree])]; - const promiseArrayTwo = [promiseGenerator([testOne]), promiseGenerator([testTwo]), promiseGenerator([testTwo])]; - const promiseArrayThree = [promiseGenerator([testOne]), promiseGenerator([testTwo]), promiseGenerator([testThree, testFour])]; - const promiseArrayFour = [promiseGenerator([testOne]), promiseGenerator([testTwo, testThree]), promiseGenerator([testFour])]; + const promiseArrayOne = [ + promiseGenerator([testOne]), + promiseGenerator([testTwo]), + promiseGenerator([testThree]), + ]; + const promiseArrayTwo = [ + promiseGenerator([testOne]), + promiseGenerator([testTwo]), + promiseGenerator([testTwo]), + ]; + const promiseArrayThree = [ + promiseGenerator([testOne]), + promiseGenerator([testTwo]), + promiseGenerator([testThree, testFour]), + ]; + const promiseArrayFour = [ + promiseGenerator([testOne]), + promiseGenerator([testTwo, testThree]), + promiseGenerator([testFour]), + ]; const testResultOne = await combinedPromises(promiseArrayOne, combineWithDedup); const testResultTwo = await combinedPromises(promiseArrayTwo, combineWithDedup); @@ -96,3 +124,8 @@ it('works with arrays of len >= 2', async () => { expect(testResultThree).toEqual([testOne, testTwo, testThree, testFour]); expect(testResultFour).toEqual([testOne, testTwo, testThree, testFour]); }); + +/* + TO-DO: Once getRawEnteries can be tested via end-to-end testing, we will be able to + test getUnifiedDataForInterval as well. +*/ From 46c1429dda6097f5e6555a0befbf8abdd10b6b3d Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Tue, 24 Oct 2023 15:43:39 -0700 Subject: [PATCH 209/850] Code format changes to unifiedDataLoader.ts --- www/js/unifiedDataLoader.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/www/js/unifiedDataLoader.ts b/www/js/unifiedDataLoader.ts index f7036663a..81b156c7a 100644 --- a/www/js/unifiedDataLoader.ts +++ b/www/js/unifiedDataLoader.ts @@ -44,11 +44,10 @@ export const combinedPromises = function(promiseList: Array>, if (firstError && nextError) { reject([firstError, nextError]); } else { - logDebug("About to dedup localResult = "+firstResult.length - +"remoteResult = "+nextResult.length); - + logDebug(`About to dedup firstResult = ${firstResult.length}` + + ` nextResult = ${nextResult.length}`); const dedupedList = combiner(firstResult, nextResult); - logDebug("Deduped list = "+dedupedList.length); + logDebug(`Deduped list = ${dedupedList.length}`); resolve(dedupedList); } } @@ -94,11 +93,11 @@ export const combinedPromises = function(promiseList: Array>, export const getUnifiedDataForInterval = function(key: string, tq: TimeQuery, getMethod: (key: string, tq: TimeQuery, flag: boolean) => Promise) { const test = true; - const localPromise = getMethod(key, tq, test); + const getPromise = getMethod(key, tq, test); const remotePromise = getRawEntries([key], tq.startTs, tq.endTs) - .then(function(serverResponse: ServerData) { - return serverResponse.phone_data; - }); - var promiseList = [localPromise, remotePromise] + .then(function(serverResponse: ServerData) { + return serverResponse.phone_data; + }); + var promiseList = [getPromise, remotePromise] return combinedPromises(promiseList, combineWithDedup); }; \ No newline at end of file From 8712380a29af1d748fd4e7bb284353b7393a7e98 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 25 Oct 2023 15:05:34 -0400 Subject: [PATCH 210/850] use section summary for getDetectedModes https://github.com/e-mission/e-mission-phone/pull/1014#discussion_r1315241966 Confirmed and composite trips have section summaries that are computed on the server. We can use these here instead of using the raw 'sections'. Let's also add type definitions for the section summaries. --- www/js/diary/diaryHelper.ts | 49 ++++++++++++++----------------------- www/js/types/diaryTypes.ts | 12 +++++++-- 2 files changed, 29 insertions(+), 32 deletions(-) diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts index 0b834a485..b12c89738 100644 --- a/www/js/diary/diaryHelper.ts +++ b/www/js/diary/diaryHelper.ts @@ -4,6 +4,7 @@ import moment from "moment"; import { DateTime } from "luxon"; import { LabelOptions, readableLabelToKey } from "../survey/multilabel/confirmHelper"; +import { CompositeTrip } from '../types/diaryTypes'; export const modeColors = { pink: '#c32e85', // oklch(56% 0.2 350) // e-car @@ -24,7 +25,7 @@ type BaseMode = { } // parallels the server-side MotionTypes enum: https://github.com/e-mission/e-mission-server/blob/94e7478e627fa8c171323662f951c611c0993031/emission/core/wrapper/motionactivity.py#L12 -type MotionTypeKey = 'IN_VEHICLE' | 'BICYCLING' | 'ON_FOOT' | 'STILL' | 'UNKNOWN' | 'TILTING' +export type MotionTypeKey = 'IN_VEHICLE' | 'BICYCLING' | 'ON_FOOT' | 'STILL' | 'UNKNOWN' | 'TILTING' | 'WALKING' | 'RUNNING' | 'NONE' | 'STOPPED_WHILE_IN_VEHICLE' | 'AIR_OR_HSR'; const BaseModes: {[k: string]: BaseMode} = { @@ -54,7 +55,7 @@ const BaseModes: {[k: string]: BaseMode} = { OTHER: { name: "OTHER", icon: "pencil-circle", color: modeColors.taupe }, }; -type BaseModeKey = keyof typeof BaseModes; +export type BaseModeKey = keyof typeof BaseModes; /** * @param motionName A string like "WALKING" or "MotionTypes.WALKING" * @returns A BaseMode object containing the name, icon, and color of the motion type @@ -138,37 +139,25 @@ export function getFormattedTimeRange(beginFmtTime: string, endFmtTime: string) const beginMoment = moment.parseZone(beginFmtTime); const endMoment = moment.parseZone(endFmtTime); return endMoment.to(beginMoment, true); -}; +} -// Temporary function to avoid repear in getDetectedModes ret val. -const filterRunning = (mode) => - (mode == 'MotionTypes.RUNNING') ? 'MotionTypes.WALKING' : mode; - -export function getDetectedModes(trip) { - if (!trip.sections?.length) return []; - - // sum up the distances for each mode, as well as the total distance - let totalDist = 0; - const dists: Record = {}; - trip.sections.forEach((section) => { - const filteredMode = filterRunning(section.sensed_mode_str); - dists[filteredMode] = (dists[filteredMode] || 0) + section.distance; - totalDist += section.distance; - }); - - // sort modes by the distance traveled (descending) - const sortedKeys = Object.entries(dists).sort((a, b) => b[1] - a[1]).map(e => e[0]); - let sectionPcts = sortedKeys.map(function (mode) { - const fract = dists[mode] / totalDist; - return { - mode: mode, +/** + * @param trip A composite trip object + * @returns An array of objects containing the mode key, icon, color, and percentage for each mode + * detected in the trip + */ +export function getDetectedModes(trip: CompositeTrip) { + const sectionSummary = trip?.inferred_section_summary || trip?.cleaned_section_summary; + if (!sectionSummary?.distance) return []; + + return Object.entries(sectionSummary.distance) + .sort(([modeA, distA], [modeB, distB]) => distB - distA) // sort by distance (highest first) + .map(([mode, dist]: [MotionTypeKey, number]) => ({ + mode, icon: getBaseModeByKey(mode)?.icon, color: getBaseModeByKey(mode)?.color || 'black', - pct: Math.round(fract * 100) || '<1' // if rounds to 0%, show <1% - }; - }); - - return sectionPcts; + pct: Math.round((dist / trip.distance) * 100) || '<1', // if rounds to 0%, show <1% + })); } export function getFormattedSectionProperties(trip, ImperialConfig) { diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index b12e58543..f4d53a4a9 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -3,6 +3,8 @@ Since we are using TypeScript now, we should strive to enforce type safety and also benefit from IntelliSense and other IDE features. */ +import { BaseModeKey, MotionTypeKey } from "../diary/diaryHelper"; + // Since it is WIP, these types are not used anywhere yet. type ConfirmedPlace = any; // TODO @@ -12,7 +14,7 @@ type ConfirmedPlace = any; // TODO export type CompositeTrip = { _id: {$oid: string}, additions: any[], // TODO - cleaned_section_summary: any, // TODO + cleaned_section_summary: SectionSummary, cleaned_trip: {$oid: string}, confidence_threshold: number, confirmed_trip: {$oid: string}, @@ -27,7 +29,7 @@ export type CompositeTrip = { expectation: any, // TODO "{to_label: boolean}" expected_trip: {$oid: string}, inferred_labels: any[], // TODO - inferred_section_summary: any, // TODO + inferred_section_summary: SectionSummary, inferred_trip: {$oid: string}, key: string, locations: any[], // TODO @@ -71,6 +73,12 @@ export type PopulatedTrip = CompositeTrip & { verifiability?: string, } +export type SectionSummary = { + count: {[k: MotionTypeKey | BaseModeKey]: number}, + distance: {[k: MotionTypeKey | BaseModeKey]: number}, + duration: {[k: MotionTypeKey | BaseModeKey]: number}, +} + export type UserInput = { data: { end_ts: number, From 4f58aa8f9efeb6fab4a4435a5d980fc3a46d7b0a Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 25 Oct 2023 14:12:32 -0600 Subject: [PATCH 211/850] re-work methods for checking code In a cleanup pass, we're working towards using URL methods rather than the "hacky" parts that I had before https://developer.mozilla.org/en-US/docs/Web/API/URL Also re-work the method to pull out the token from the url components and return the code if it's good, or false if the url is bad --- www/js/onboarding/WelcomePage.tsx | 34 ++++++++++++++++++------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/www/js/onboarding/WelcomePage.tsx b/www/js/onboarding/WelcomePage.tsx index 5653218d7..cb317c5bc 100644 --- a/www/js/onboarding/WelcomePage.tsx +++ b/www/js/onboarding/WelcomePage.tsx @@ -20,28 +20,34 @@ const WelcomePage = () => { const [infoPopupVis, setInfoPopupVis] = useState(false); const [existingToken, setExistingToken] = useState(''); - const checkURL = function (result) { + const getCode = function (result) { + let url = new window.URL(result.text); let notCancelled = result.cancelled == false; let isQR = result.format == "QR_CODE"; - let hasPrefix = result.text.split(":")[0] == "emission"; - let hasToken = result.text.includes("login_token?token"); + let hasPrefix = url.protocol == "emission:"; + let hasToken = url.searchParams.has("token"); + let code = url.searchParams.get("token"); - logDebug("QR code " + result.text + " checks: cancel, format, prefix, params " + notCancelled + isQR + hasPrefix + hasToken); + logDebug("QR code " + result.text + " checks: cancel, format, prefix, params, code " + notCancelled + isQR + hasPrefix + hasToken + code); - return notCancelled && isQR && hasPrefix && hasToken; - } + if (notCancelled && isQR && hasPrefix && hasToken) { + return code; + } else { + return false; + } + }; - const scanCode = function() { + const scanCode = function () { window['cordova'].plugins.barcodeScanner.scan( function (result) { console.debug("scanned code", result); - if (checkURL(result)) { - let text = result.text.split("=")[1]; - console.log("found code", text); - loginWithToken(text); - } else { - displayError(result.text, "invalid study reference") ; - } + let code = getCode(result); + if (code != false) { + console.log("found code", code); + loginWithToken(code); + } else { + displayError(result.text, "invalid study reference"); + } }, function (error) { displayError(error, "Scanning failed: "); From 313074123bf6c9e5b0470ef210eece70ebd97c41 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 25 Oct 2023 15:42:06 -0600 Subject: [PATCH 212/850] remove unneeded type declaration https://github.com/e-mission/e-mission-phone/pull/1072#discussion_r1372324523 --- www/js/splash/startprefs.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index 2acda39b1..d085289ff 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -1,12 +1,7 @@ import { storageGet, storageSet } from '../plugin/storage'; import { logInfo, logDebug, displayErrorMsg } from '../plugin/logger'; -type StartPrefs = { - CONSENTED_EVENT: string, - INTRO_DONE_EVENT: string, -} - -export const startPrefs: StartPrefs = { +export const startPrefs = { CONSENTED_EVENT: "data_collection_consented", INTRO_DONE_EVENT: "intro_done", }; From 39aa2f254c7d3e6b928887ee9ed9e47e5aea88ff Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 25 Oct 2023 16:32:57 -0600 Subject: [PATCH 213/850] move "after consent" saveQR was a bad place to handle this, moving to markConsented As Jack pointed out, on a reconsent (mark consent after intro done) we want to call registerPush and storeDeviceSettings - that is what is done here centralizing the logic to markConsented eliminated the need for the functions in the other files, since we check for consent done just once --- www/js/onboarding/SaveQrPage.tsx | 7 ------- www/js/splash/pushnotify.js | 11 ----------- www/js/splash/startprefs.ts | 29 +++++++++++++++++++++------- www/js/splash/storedevicesettings.js | 10 ---------- 4 files changed, 22 insertions(+), 35 deletions(-) diff --git a/www/js/onboarding/SaveQrPage.tsx b/www/js/onboarding/SaveQrPage.tsx index 3c5c0b954..3bfc93bb4 100644 --- a/www/js/onboarding/SaveQrPage.tsx +++ b/www/js/onboarding/SaveQrPage.tsx @@ -31,13 +31,6 @@ const SaveQrPage = ({ }) => { setRegisterUserDone(true); preloadDemoSurveyResponse(); refreshOnboardingState(); - - //fully consented, so can handle other aspects - //other plugins - previously used $emit - const PushNotify = getAngularService("PushNotify"); - PushNotify.afterConsent(); - const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); - StoreSeviceSettings.afterConsent(); }) ); } else { diff --git a/www/js/splash/pushnotify.js b/www/js/splash/pushnotify.js index 0a5f9f18e..06bf433f8 100644 --- a/www/js/splash/pushnotify.js +++ b/www/js/splash/pushnotify.js @@ -173,17 +173,6 @@ angular.module('emission.splash.pushnotify', ['emission.plugin.logger', Logger.log("pushnotify startup done"); }); - //new way of handling this, called in startprefs by markConsent - pushnotify.afterConsent = function () { - console.log("in pushnotify, executing after consent is received"); - readIntroDone().then((intro_done) => { - if (intro_done) { - console.log("intro is done -> reconsent situation, we already have a token -> register"); - pushnotify.registerPush(); - } - }) - } - $rootScope.$on(startPrefs.INTRO_DONE_EVENT, function(event, data) { console.log("intro is done -> original consent situation, we should have a token by now -> register"); pushnotify.registerPush(); diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index d085289ff..ec3bd9a14 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -1,5 +1,7 @@ +import { getAngularService } from "../angular-react-helper"; import { storageGet, storageSet } from '../plugin/storage'; import { logInfo, logDebug, displayErrorMsg } from '../plugin/logger'; +import { readIntroDone } from "../onboarding/onboardingHelper"; export const startPrefs = { CONSENTED_EVENT: "data_collection_consented", @@ -32,13 +34,26 @@ export function markConsented() { logInfo("changing consent from " + _curr_consented + " -> " + JSON.stringify(_req_consent)); // mark in native storage - return readConsentState().then(writeConsentToNative).then(function (response) { - // mark in local storage - storageSet(DATA_COLLECTION_CONSENTED_PROTOCOL, - _req_consent); - // mark in local variable as well - _curr_consented = {..._req_consent}; - }); + return readConsentState() + .then(writeConsentToNative) + .then(function (response) { + // mark in local storage + storageSet(DATA_COLLECTION_CONSENTED_PROTOCOL, + _req_consent); + // mark in local variable as well + _curr_consented = { ..._req_consent }; + }) + //check for reconsent + .then(readIntroDone) + .then((isIntroDone) => { + if(isIntroDone) { + console.debug("reconsent scenario - marked consent after intro done - registering pushnoify and storing device settings") + const PushNotify = getAngularService("PushNotify"); + const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); + PushNotify.registerPush(); + StoreSeviceSettings.storeDeviceSettings(); + } + }); }; let _is_consented; diff --git a/www/js/splash/storedevicesettings.js b/www/js/splash/storedevicesettings.js index f9ff0e00c..a53f7b76e 100644 --- a/www/js/splash/storedevicesettings.js +++ b/www/js/splash/storedevicesettings.js @@ -45,16 +45,6 @@ angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', Logger.log("storedevicesettings startup done"); }); - storedevicesettings.afterConsent = function() { - console.log("in storedevicesettings, executing after consent is received"); - readIntroDone().then((intro_done) => { - if (intro_done) { - console.log("intro is done -> reconsent situation, we already have a token -> register"); - storedevicesettings.storeDeviceSettings(); - } - }) - } - $rootScope.$on(startPrefs.INTRO_DONE_EVENT, function(event, data) { console.log("intro is done -> original consent situation, we should have a token by now -> register"); storedevicesettings.storeDeviceSettings(); From 657ae34b1c65cb7ae204b81038bb11ea2056cfe0 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 25 Oct 2023 16:37:40 -0600 Subject: [PATCH 214/850] rework "intro done event" We no longer emit this event, but still need to handle the logic On marking intro done, we will registerPush and storeDeviceSettings https://github.com/e-mission/e-mission-phone/pull/1072#discussion_r1372339180 --- www/js/onboarding/onboardingHelper.ts | 11 ++++++++++- www/js/splash/pushnotify.js | 5 ----- www/js/splash/storedevicesettings.js | 5 ----- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/www/js/onboarding/onboardingHelper.ts b/www/js/onboarding/onboardingHelper.ts index 9bca0fdd5..b73810ee5 100644 --- a/www/js/onboarding/onboardingHelper.ts +++ b/www/js/onboarding/onboardingHelper.ts @@ -3,6 +3,7 @@ import { getConfig, resetDataAndRefresh } from "../config/dynamicConfig"; import { storageGet, storageSet } from "../plugin/storage"; import { logDebug } from "../plugin/logger"; import { readConsentState, isConsented } from "../splash/startprefs"; +import { getAngularService } from "../angular-react-helper"; export const INTRO_DONE_KEY = 'intro_done'; @@ -70,5 +71,13 @@ export async function readIntroDone() { export async function markIntroDone() { const currDateTime = DateTime.now().toISO(); - return storageSet(INTRO_DONE_KEY, currDateTime); + return storageSet(INTRO_DONE_KEY, currDateTime) + .then(() => { + //handle "on intro" events + console.log("intro done, calling registerPush and storeDeviceSettings"); + const PushNotify = getAngularService("PushNotify"); + const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); + PushNotify.registerPush(); + StoreSeviceSettings.storeDeviceSettings(); + }); } diff --git a/www/js/splash/pushnotify.js b/www/js/splash/pushnotify.js index 06bf433f8..dd31811ab 100644 --- a/www/js/splash/pushnotify.js +++ b/www/js/splash/pushnotify.js @@ -173,10 +173,5 @@ angular.module('emission.splash.pushnotify', ['emission.plugin.logger', Logger.log("pushnotify startup done"); }); - $rootScope.$on(startPrefs.INTRO_DONE_EVENT, function(event, data) { - console.log("intro is done -> original consent situation, we should have a token by now -> register"); - pushnotify.registerPush(); - }); - return pushnotify; }); diff --git a/www/js/splash/storedevicesettings.js b/www/js/splash/storedevicesettings.js index a53f7b76e..c32083295 100644 --- a/www/js/splash/storedevicesettings.js +++ b/www/js/splash/storedevicesettings.js @@ -45,10 +45,5 @@ angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', Logger.log("storedevicesettings startup done"); }); - $rootScope.$on(startPrefs.INTRO_DONE_EVENT, function(event, data) { - console.log("intro is done -> original consent situation, we should have a token by now -> register"); - storedevicesettings.storeDeviceSettings(); - }); - return storedevicesettings; }); From a7a75d1dde7f5621074a514b9ec9c0a71235e732 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 25 Oct 2023 16:38:06 -0600 Subject: [PATCH 215/850] remove unneeded object --- www/js/splash/startprefs.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index ec3bd9a14..277946629 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -3,11 +3,6 @@ import { storageGet, storageSet } from '../plugin/storage'; import { logInfo, logDebug, displayErrorMsg } from '../plugin/logger'; import { readIntroDone } from "../onboarding/onboardingHelper"; -export const startPrefs = { - CONSENTED_EVENT: "data_collection_consented", - INTRO_DONE_EVENT: "intro_done", -}; - // data collection consented protocol: string, represents the date on // which the consented protocol was approved by the IRB const DATA_COLLECTION_CONSENTED_PROTOCOL = 'data_collection_consented_protocol'; From 6c23b2d7fcc141f5095b9d082f34d81088d10884 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 26 Oct 2023 10:34:47 -0700 Subject: [PATCH 216/850] Updated `diaryHelper.ts`, now uses luxon diaryHelper now only uses luxon, and does not rely on the momentjs library. --- www/js/diary/diaryHelper.ts | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts index 0b834a485..e2816e153 100644 --- a/www/js/diary/diaryHelper.ts +++ b/www/js/diary/diaryHelper.ts @@ -1,9 +1,8 @@ // here we have some helper functions used throughout the label tab // these functions are being gradually migrated out of services.js -import moment from "moment"; import { DateTime } from "luxon"; -import { LabelOptions, readableLabelToKey } from "../survey/multilabel/confirmHelper"; +import { LabelOptions } from "../survey/multilabel/confirmHelper"; export const modeColors = { pink: '#c32e85', // oklch(56% 0.2 350) // e-car @@ -90,7 +89,7 @@ export function getBaseModeByText(text, labelOptions: LabelOptions) { */ export function isMultiDay(beginFmtTime: string, endFmtTime: string) { if (!beginFmtTime || !endFmtTime) return false; - return moment.parseZone(beginFmtTime).format('YYYYMMDD') != moment.parseZone(endFmtTime).format('YYYYMMDD'); + return DateTime.fromISO(beginFmtTime).toFormat('YYYYMMDD') != DateTime.fromISO(endFmtTime).toFormat('YYYYMMDD'); } /** @@ -105,11 +104,10 @@ export function getFormattedDate(beginFmtTime: string, endFmtTime?: string) { return `${getFormattedDate(beginFmtTime)} - ${getFormattedDate(endFmtTime)}`; } // only one day given, or both are the same day - const t = moment.parseZone(beginFmtTime || endFmtTime); - // We use ddd LL to get Wed, May 3, 2023 or equivalent - // LL only has the date, month and year - // LLLL has the day of the week, but also the time - return t.format('ddd LL'); + const t = DateTime.fromISO(beginFmtTime || endFmtTime); + // We use toLocale to get Wed May 3, 2023 or equivalent, + const tConversion = t.toLocaleString({weekday: 'short', month: 'long', day: '2-digit', year: 'numeric'}); + return tConversion.replace(',', ''); } /** @@ -135,9 +133,12 @@ export function getFormattedDateAbbr(beginFmtTime: string, endFmtTime?: string) */ export function getFormattedTimeRange(beginFmtTime: string, endFmtTime: string) { if (!beginFmtTime || !endFmtTime) return; - const beginMoment = moment.parseZone(beginFmtTime); - const endMoment = moment.parseZone(endFmtTime); - return endMoment.to(beginMoment, true); + const beginTime = DateTime.fromISO(beginFmtTime); + const endTime = DateTime.fromISO(endFmtTime); + const range = endTime.diff(beginTime, ['hours']); + const roundedHours = Math.round(range.as('hours')); // Round up or down to nearest hour + const formattedRange = `${roundedHours} hour${roundedHours !== 1 ? 's': ''}`; + return formattedRange; }; // Temporary function to avoid repear in getDetectedModes ret val. @@ -184,8 +185,9 @@ export function getFormattedSectionProperties(trip, ImperialConfig) { export function getLocalTimeString(dt) { if (!dt) return; - /* correcting the date of the processed trips knowing that local_dt months are from 1 -> 12 - and for the moment function they need to be between 0 -> 11 */ - const mdt = { ...dt, month: dt.month-1 }; - return moment(mdt).format("LT"); + const dateTime = DateTime.fromObject({ + hour: dt.hour, + minute: dt.minute, + }); + return dateTime.toFormat('hh:mm a') } From 062828ee6a2cb734d81148eb481434fb1574961c Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 26 Oct 2023 12:12:54 -0700 Subject: [PATCH 217/850] Removed moment from timelineHelper --- www/js/diary/timelineHelper.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 579e3ac4f..50ef75ade 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -1,8 +1,8 @@ -import moment from "moment"; import { getAngularService } from "../angular-react-helper"; import { displayError, logDebug } from "../plugin/logger"; import { getBaseModeByKey, getBaseModeOfLabeledTrip } from "./diaryHelper"; import i18next from "i18next"; +import { DateTime } from "luxon"; const cachedGeojsons = new Map(); /** @@ -99,7 +99,7 @@ export function populateCompositeTrips(ctList, showPlaces, labelsFactory, labels const getUnprocessedInputQuery = (pipelineRange) => ({ key: "write_ts", startTs: pipelineRange.end_ts - 10, - endTs: moment().unix() + 10 + endTs: DateTime.now().toUnixInteger() + 10 }); function getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, notesPromises) { From 5ade8b77081bee894d4faf8b1b734f256b1d6a8e Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 26 Oct 2023 13:19:33 -0700 Subject: [PATCH 218/850] Updated readAllCompositeTrips function --- www/js/diary/LabelTab.tsx | 4 ++-- www/js/diary/services.js | 39 -------------------------------- www/js/diary/timelineHelper.ts | 41 ++++++++++++++++++++++++++++++++++ www/js/types/serverData.ts | 33 +++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 41 deletions(-) create mode 100644 www/js/types/serverData.ts diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index f4677766d..f3924c691 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -16,7 +16,7 @@ import LabelListScreen from "./list/LabelListScreen"; import { createStackNavigator } from "@react-navigation/stack"; import LabelScreenDetails from "./details/LabelDetailsScreen"; import { NavigationContainer } from "@react-navigation/native"; -import { compositeTrips2TimelineMap, getAllUnprocessedInputs, getLocalUnprocessedInputs, populateCompositeTrips } from "./timelineHelper"; +import { compositeTrips2TimelineMap, getAllUnprocessedInputs, getLocalUnprocessedInputs, populateCompositeTrips, readAllCompositeTrips } from "./timelineHelper"; import { fillLocationNamesOfTrip, resetNominatimLimiter } from "./addressNamesHelper"; import { SurveyOptions } from "../survey/survey"; import { getLabelOptions } from "../survey/multilabel/confirmHelper"; @@ -202,7 +202,7 @@ const LabelTab = () => { return; } - const readCompositePromise = Timeline.readAllCompositeTrips(startTs, endTs); + const readCompositePromise = readAllCompositeTrips(startTs, endTs); let readUnprocessedPromise; if (endTs >= pipelineRange.end_ts) { const nowTs = new Date().getTime() / 1000; diff --git a/www/js/diary/services.js b/www/js/diary/services.js index 774273fa2..94d5fe292 100644 --- a/www/js/diary/services.js +++ b/www/js/diary/services.js @@ -27,45 +27,6 @@ angular.module('emission.main.diary.services', ['emission.plugin.logger', }); }); - // DB entries retrieved from the server have '_id', 'metadata', and 'data' fields. - // This function returns a shallow copy of the obj, which flattens the - // 'data' field into the top level, while also including '_id' and 'metadata.key' - const unpack = (obj) => ({ - ...obj.data, - _id: obj._id, - key: obj.metadata.key, - origin_key: obj.metadata.origin_key || obj.metadata.key, - }); - - timeline.readAllCompositeTrips = function(startTs, endTs) { - $ionicLoading.show({ - template: i18next.t('service.reading-server') - }); - const readPromises = [ - getRawEntries(["analysis/composite_trip"], - startTs, endTs, "data.end_ts"), - ]; - return Promise.all(readPromises) - .then(([ctList]) => { - $ionicLoading.hide(); - return ctList.phone_data.map((ct) => { - const unpackedCt = unpack(ct); - return { - ...unpackedCt, - start_confirmed_place: unpack(unpackedCt.start_confirmed_place), - end_confirmed_place: unpack(unpackedCt.end_confirmed_place), - locations: unpackedCt.locations?.map(unpack), - sections: unpackedCt.sections?.map(unpack), - } - }); - }) - .catch((err) => { - Logger.displayError("while reading confirmed trips", err); - $ionicLoading.hide(); - return []; - }); - }; - /* * This is going to be a bit tricky. As we can see from * https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163, diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 50ef75ade..974baab35 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -1,6 +1,9 @@ import { getAngularService } from "../angular-react-helper"; import { displayError, logDebug } from "../plugin/logger"; import { getBaseModeByKey, getBaseModeOfLabeledTrip } from "./diaryHelper"; +import { getRawEntries } from "../commHelper"; +import { ServerResponse, ServerData } from "../types/serverData"; + import i18next from "i18next"; import { DateTime } from "luxon"; @@ -210,3 +213,41 @@ const locations2GeojsonTrajectory = (trip, locationList, trajectoryColor?) => { } }); } + +// Remaining functions from /diary/services.js +const unpackServerData = (obj: ServerData) => ({ + ...obj.data, + _id: obj._id, + key: obj.metadata.key, + origin_key: obj.metadata.origin_key || obj.metadata.key, +}); + +export const readAllCompositeTrips = function(startTs: number, endTs: number) { + const $ionicLoading = getAngularService('$ionicLoading'); + $ionicLoading.show({ + template: i18next.t('service.reading-server') + }); + const readPromises = [ + getRawEntries(["analysis/composite_trip"], + startTs, endTs, "data.end_ts"), + ]; + return Promise.all(readPromises) + .then(([ctList]: [ServerResponse]) => { + $ionicLoading.hide(); + return ctList.phone_data.map((ct) => { + const unpackedCt = unpackServerData(ct); + return { + ...unpackedCt, + start_confirmed_place: unpackServerData(unpackedCt.start_confirmed_place), + end_confirmed_place: unpackServerData(unpackedCt.end_confirmed_place), + locations: unpackedCt.locations?.map(unpackServerData), + sections: unpackedCt.sections?.map(unpackServerData), + } + }); + }) + .catch((err) => { + displayError(err, "while reading confirmed trips"); + $ionicLoading.hide(); + return []; + }); +}; \ No newline at end of file diff --git a/www/js/types/serverData.ts b/www/js/types/serverData.ts new file mode 100644 index 000000000..4b569206d --- /dev/null +++ b/www/js/types/serverData.ts @@ -0,0 +1,33 @@ +export type ServerResponse = { + phone_data: Array>, +} + +export type ServerData = { + data: Type, + metadata: MetaData, + key?: string, + user_id?: { $uuid: string, }, + _id?: { $oid: string, }, +}; + +export type MetaData = { + key: string, + platform: string, + write_ts: number, + time_zone: string, + write_fmt_time: string, + write_local_dt: LocalDt, + origin_key?: string, +}; + +export type LocalDt = { + minute: number, + hour: number, + second: number, + day: number, + weekday: number, + month: number, + year: number, + timezone: string, +}; + \ No newline at end of file From f0da5eaa5f833c21e04ee8a4cc7cd57e49ab4fe9 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 26 Oct 2023 16:02:37 -0700 Subject: [PATCH 219/850] Updated Object Types - Updated types to match PR #1073 - Part of the motivaiton for this is that `/dairy/services.js` also relies on unifiedDataLoader; if this is written using those types, then that rewrite can build off of this rewrite without any additional merge conflicts down the road. --- www/__tests__/unifiedDataLoader.test.ts | 6 +-- www/js/types/fileShareTypes.ts | 12 ----- www/js/types/{diaryTypes.ts => serverData.ts} | 48 +++++++++---------- www/js/unifiedDataLoader.ts | 6 +-- 4 files changed, 28 insertions(+), 44 deletions(-) delete mode 100644 www/js/types/fileShareTypes.ts rename www/js/types/{diaryTypes.ts => serverData.ts} (57%) diff --git a/www/__tests__/unifiedDataLoader.test.ts b/www/__tests__/unifiedDataLoader.test.ts index 5db23982b..34f98bcd1 100644 --- a/www/__tests__/unifiedDataLoader.test.ts +++ b/www/__tests__/unifiedDataLoader.test.ts @@ -1,10 +1,10 @@ import { mockLogger } from '../__mocks__/globalMocks'; import { combineWithDedup, combinedPromises } from '../js/unifiedDataLoader'; -import { ServerDataPoint } from '../js/types/diaryTypes'; +import { ServerData } from '../js/types/serverData'; mockLogger(); -const testOne: ServerDataPoint = { +const testOne: ServerData = { data: '', metadata: { key: '', @@ -46,7 +46,7 @@ describe('combineWithDedup can', () => { }); // combinedPromises tests -const promiseGenerator = (values: Array) => { +const promiseGenerator = (values: Array>) => { return Promise.resolve(values); }; diff --git a/www/js/types/fileShareTypes.ts b/www/js/types/fileShareTypes.ts deleted file mode 100644 index 7f0354f8c..000000000 --- a/www/js/types/fileShareTypes.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface FsWindow extends Window { - requestFileSystem: ( - type: number, - size: number, - successCallback: (fs: any) => void, - errorCallback?: (error: any) => void - ) => void; - LocalFileSystem: { - TEMPORARY: number; - PERSISTENT: number; - }; -}; diff --git a/www/js/types/diaryTypes.ts b/www/js/types/serverData.ts similarity index 57% rename from www/js/types/diaryTypes.ts rename to www/js/types/serverData.ts index 142629000..9f274992c 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/serverData.ts @@ -1,17 +1,15 @@ -/* This is a draft of diaryTypes, originally added in PR #1061. This file appears - in #1052 as well; these three will most likely be consolidated and rewritten in a - future PR */ -export type LocalDt = { - minute: number, - hour: number, - second: number, - day: number, - weekday: number, - month: number, - year: number, - timezone: string, +export type ServerResponse = { + phone_data: Array>, } +export type ServerData = { + data: Type, + metadata: MetaData, + key?: string, + user_id?: { $uuid: string, }, + _id?: { $oid: string, }, +}; + export type MetaData = { key: string, platform: string, @@ -19,20 +17,18 @@ export type MetaData = { time_zone: string, write_fmt_time: string, write_local_dt: LocalDt, -} - -export type ServerDataPoint = { - data: any, - metadata: MetaData, - key?: string, - user_id?: { $uuid: string, }, - _id?: { $oid: string, }, -} - -/* These are the objects returned from BEMUserCache calls */ -export type ServerData = { - phone_data: Array -} +}; + +export type LocalDt = { + minute: number, + hour: number, + second: number, + day: number, + weekday: number, + month: number, + year: number, + timezone: string, +}; export type TimeQuery = { key: string; diff --git a/www/js/unifiedDataLoader.ts b/www/js/unifiedDataLoader.ts index 81b156c7a..15bcd341d 100644 --- a/www/js/unifiedDataLoader.ts +++ b/www/js/unifiedDataLoader.ts @@ -1,6 +1,6 @@ import { logDebug } from './plugin/logger' import { getRawEntries } from './commHelper'; -import { ServerDataPoint, ServerData, TimeQuery } from './types/diaryTypes' +import { ServerResponse, ServerData, TimeQuery } from './types/serverData'; /** * combineWithDedup is a helper function for combinedPromises @@ -8,7 +8,7 @@ import { ServerDataPoint, ServerData, TimeQuery } from './types/diaryTypes' * @param list2 same as list1 * @returns a dedup array generated from the input lists */ -export const combineWithDedup = function(list1: Array, list2: Array) { +export const combineWithDedup = function(list1: Array>, list2: Array) { const combinedList = list1.concat(list2); return combinedList.filter(function(value, i, array) { const firstIndexOfValue = array.findIndex(function(element) { @@ -95,7 +95,7 @@ export const getUnifiedDataForInterval = function(key: string, tq: TimeQuery, const test = true; const getPromise = getMethod(key, tq, test); const remotePromise = getRawEntries([key], tq.startTs, tq.endTs) - .then(function(serverResponse: ServerData) { + .then(function(serverResponse: ServerResponse) { return serverResponse.phone_data; }); var promiseList = [getPromise, remotePromise] From c54f939f467ca43c3a2fc45796dd3bf5e2cc9f4b Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 27 Oct 2023 08:51:40 -0600 Subject: [PATCH 220/850] console.log -> logDebug There were several places where I had other log statements, but logDebug was more appropriate, see review for details https://github.com/e-mission/e-mission-phone/pull/1072#discussion_r1373749123 --- www/js/control/ProfileSettings.jsx | 3 ++- www/js/onboarding/onboardingHelper.ts | 2 +- www/js/splash/startprefs.ts | 10 +++++----- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 8c7510618..cecc4f84c 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -26,6 +26,7 @@ import { shareQR } from "../components/QrCode"; import { storageClear } from "../plugin/storage"; import { getAppVersion } from "../plugin/clientStats"; import { getConsentDocument } from "../splash/startprefs"; +import { logDebug } from "../plugin/logger"; //any pure functions can go outside const ProfileSettings = () => { @@ -309,7 +310,7 @@ const ProfileSettings = () => { async function checkConsent() { getConsentDocument().then(function(resultDoc){ setConsentDoc(resultDoc); - console.debug("In profile settings, consent doc found", resultDoc); + logDebug("In profile settings, consent doc found", resultDoc); if (resultDoc == null) { setNoConsentVis(true); } else { diff --git a/www/js/onboarding/onboardingHelper.ts b/www/js/onboarding/onboardingHelper.ts index b73810ee5..4110c2394 100644 --- a/www/js/onboarding/onboardingHelper.ts +++ b/www/js/onboarding/onboardingHelper.ts @@ -74,7 +74,7 @@ export async function markIntroDone() { return storageSet(INTRO_DONE_KEY, currDateTime) .then(() => { //handle "on intro" events - console.log("intro done, calling registerPush and storeDeviceSettings"); + logDebug("intro done, calling registerPush and storeDeviceSettings"); const PushNotify = getAngularService("PushNotify"); const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); PushNotify.registerPush(); diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index 277946629..8a0b28415 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -42,7 +42,7 @@ export function markConsented() { .then(readIntroDone) .then((isIntroDone) => { if(isIntroDone) { - console.debug("reconsent scenario - marked consent after intro done - registering pushnoify and storing device settings") + logDebug("reconsent scenario - marked consent after intro done - registering pushnoify and storing device settings") const PushNotify = getAngularService("PushNotify"); const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); PushNotify.registerPush(); @@ -58,14 +58,14 @@ let _is_consented; * @returns {boolean} if the consent is marked in the local var */ export function isConsented() { - console.log("curr consented is", _curr_consented); + logDebug("curr consented is" + JSON.stringify(_curr_consented)); if (_curr_consented == null || _curr_consented == "" || _curr_consented.approval_date != _req_consent.approval_date) { - console.log("Not consented in local storage, need to show consent"); + logDebug("Not consented in local storage, need to show consent"); _is_consented = false; return false; } else { - console.log("Consented in local storage, no need to show consent"); + logDebug("Consented in local storage, no need to show consent"); _is_consented = true; return true; } @@ -81,7 +81,7 @@ export function readConsentState() { .then(function (startupConfigResult) { console.log(startupConfigResult); _req_consent = startupConfigResult.emSensorDataCollectionProtocol; - logInfo("required consent version = " + JSON.stringify(_req_consent)); + logDebug("required consent version = " + JSON.stringify(_req_consent)); return storageGet(DATA_COLLECTION_CONSENTED_PROTOCOL); }).then(function (kv_store_consent) { _curr_consented = kv_store_consent; From 2516b2dfea0acb264f1946b3251ef4198a4282e8 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 27 Oct 2023 08:55:53 -0600 Subject: [PATCH 221/850] _is_consented assigned but never used https://github.com/e-mission/e-mission-phone/pull/1072#discussion_r1373756101 --- www/js/splash/startprefs.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index 8a0b28415..4feccdb4a 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -51,8 +51,6 @@ export function markConsented() { }); }; -let _is_consented; - /** * @function checking for consent locally * @returns {boolean} if the consent is marked in the local var @@ -62,11 +60,9 @@ export function isConsented() { if (_curr_consented == null || _curr_consented == "" || _curr_consented.approval_date != _req_consent.approval_date) { logDebug("Not consented in local storage, need to show consent"); - _is_consented = false; return false; } else { logDebug("Consented in local storage, no need to show consent"); - _is_consented = true; return true; } } From 8bbfb82bad6d1aee88a046e7f7c89c8c73a73af8 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 27 Oct 2023 09:53:36 -0600 Subject: [PATCH 222/850] add a catch block https://github.com/e-mission/e-mission-phone/pull/1072#discussion_r1373739450 --- www/js/splash/startprefs.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index 4feccdb4a..43f29c692 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -41,13 +41,16 @@ export function markConsented() { //check for reconsent .then(readIntroDone) .then((isIntroDone) => { - if(isIntroDone) { + if (isIntroDone) { logDebug("reconsent scenario - marked consent after intro done - registering pushnoify and storing device settings") const PushNotify = getAngularService("PushNotify"); const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); PushNotify.registerPush(); StoreSeviceSettings.storeDeviceSettings(); } + }) + .catch((error) => { + displayErrorMsg(error, "Error while while wrting consent to storage"); }); }; From 3ddf403a7b0f8521f19d9c5af9a4b4219a58a7e0 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Fri, 27 Oct 2023 10:04:36 -0700 Subject: [PATCH 223/850] Moved services to separate directory - Moved `commHelper`, `UnifiedDataLoader` into a new `/services/` directory - Updated imports on other files --- www/__tests__/commHelper.test.ts | 2 +- www/__tests__/unifiedDataLoader.test.ts | 2 +- www/js/config/dynamicConfig.ts | 2 +- www/js/control/ControlSyncHelper.tsx | 2 +- www/js/diary/LabelTab.tsx | 2 +- www/js/diary/services.js | 4 ++-- www/js/diary/timelineHelper.ts | 2 +- www/js/metrics/MetricsTab.tsx | 2 +- www/js/onboarding/SaveQrPage.tsx | 2 +- www/js/services.js | 2 +- www/js/{ => services}/commHelper.ts | 2 +- www/js/{ => services}/unifiedDataLoader.ts | 4 ++-- www/js/splash/notifScheduler.js | 2 +- www/js/splash/pushnotify.js | 2 +- www/js/splash/storedevicesettings.js | 2 +- www/js/survey/enketo/EnketoModal.tsx | 2 +- www/js/survey/enketo/enketoHelper.ts | 2 +- www/js/survey/multilabel/confirmHelper.ts | 2 +- 18 files changed, 20 insertions(+), 20 deletions(-) rename www/js/{ => services}/commHelper.ts (99%) rename www/js/{ => services}/unifiedDataLoader.ts (96%) diff --git a/www/__tests__/commHelper.test.ts b/www/__tests__/commHelper.test.ts index 2e2dfc6af..ec8d8b4ff 100644 --- a/www/__tests__/commHelper.test.ts +++ b/www/__tests__/commHelper.test.ts @@ -1,5 +1,5 @@ import { mockLogger } from '../__mocks__/globalMocks'; -import { fetchUrlCached } from '../js/commHelper'; +import { fetchUrlCached } from '../js/services/commHelper'; mockLogger(); diff --git a/www/__tests__/unifiedDataLoader.test.ts b/www/__tests__/unifiedDataLoader.test.ts index 34f98bcd1..6e1c41316 100644 --- a/www/__tests__/unifiedDataLoader.test.ts +++ b/www/__tests__/unifiedDataLoader.test.ts @@ -1,5 +1,5 @@ import { mockLogger } from '../__mocks__/globalMocks'; -import { combineWithDedup, combinedPromises } from '../js/unifiedDataLoader'; +import { combineWithDedup, combinedPromises } from '../js/services/unifiedDataLoader'; import { ServerData } from '../js/types/serverData'; mockLogger(); diff --git a/www/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts index 7994391a4..049b0343f 100644 --- a/www/js/config/dynamicConfig.ts +++ b/www/js/config/dynamicConfig.ts @@ -1,7 +1,7 @@ import i18next from "i18next"; import { displayError, logDebug, logWarn } from "../plugin/logger"; import { getAngularService } from "../angular-react-helper"; -import { fetchUrlCached } from "../commHelper"; +import { fetchUrlCached } from "../services/commHelper"; import { storageClear, storageGet, storageSet } from "../plugin/storage"; export const CONFIG_PHONE_UI="config/app_ui_config"; diff --git a/www/js/control/ControlSyncHelper.tsx b/www/js/control/ControlSyncHelper.tsx index edc0e7470..44bc661b2 100644 --- a/www/js/control/ControlSyncHelper.tsx +++ b/www/js/control/ControlSyncHelper.tsx @@ -9,7 +9,7 @@ import SettingRow from "./SettingRow"; import AlertBar from "./AlertBar"; import moment from "moment"; import { addStatEvent, statKeys } from "../plugin/clientStats"; -import { updateUser } from "../commHelper"; +import { updateUser } from "../services/commHelper"; /* * BEGIN: Simple read/write wrappers diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index bb2430481..b98c0eb6a 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -22,7 +22,7 @@ import { SurveyOptions } from "../survey/survey"; import { getLabelOptions } from "../survey/multilabel/confirmHelper"; import { displayError } from "../plugin/logger"; import { useTheme } from "react-native-paper"; -import { getPipelineRangeTs } from "../commHelper"; +import { getPipelineRangeTs } from "../services/commHelper"; let labelPopulateFactory, labelsResultMap, notesResultMap, showPlaces; const ONE_DAY = 24 * 60 * 60; // seconds diff --git a/www/js/diary/services.js b/www/js/diary/services.js index e0f1b5349..04136ccc3 100644 --- a/www/js/diary/services.js +++ b/www/js/diary/services.js @@ -3,8 +3,8 @@ import angular from 'angular'; import { SurveyOptions } from '../survey/survey'; import { getConfig } from '../config/dynamicConfig'; -import { getRawEntries } from '../commHelper'; -import { getUnifiedDataForInterval } from '../unifiedDataLoader' +import { getRawEntries } from '../services/commHelper'; +import { getUnifiedDataForInterval } from '../services/unifiedDataLoader' angular.module('emission.main.diary.services', ['emission.plugin.logger', 'emission.services']) diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 287484a7a..d760148aa 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -1,7 +1,7 @@ import moment from "moment"; import { displayError, logDebug } from "../plugin/logger"; import { getBaseModeByKey, getBaseModeOfLabeledTrip } from "./diaryHelper"; -import { getUnifiedDataForInterval} from "../unifiedDataLoader"; +import { getUnifiedDataForInterval } from "../services/unifiedDataLoader"; import i18next from "i18next"; const cachedGeojsons = new Map(); diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 450155622..8c9e2faee 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -16,7 +16,7 @@ import Carousel from "../components/Carousel"; import DailyActiveMinutesCard from "./DailyActiveMinutesCard"; import CarbonTextCard from "./CarbonTextCard"; import ActiveMinutesTableCard from "./ActiveMinutesTableCard"; -import { getAggregateData, getMetrics } from "../commHelper"; +import { getAggregateData, getMetrics } from "../services/commHelper"; export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; diff --git a/www/js/onboarding/SaveQrPage.tsx b/www/js/onboarding/SaveQrPage.tsx index d8b555f14..f73b3ac49 100644 --- a/www/js/onboarding/SaveQrPage.tsx +++ b/www/js/onboarding/SaveQrPage.tsx @@ -11,7 +11,7 @@ import QrCode, { shareQR } from "../components/QrCode"; import { onboardingStyles } from "./OnboardingStack"; import { preloadDemoSurveyResponse } from "./SurveyPage"; import { storageSet } from "../plugin/storage"; -import { registerUser } from "../commHelper"; +import { registerUser } from "../services/commHelper"; const SaveQrPage = ({ }) => { diff --git a/www/js/services.js b/www/js/services.js index 118e98811..eff5cc340 100644 --- a/www/js/services.js +++ b/www/js/services.js @@ -1,7 +1,7 @@ 'use strict'; import angular from 'angular'; -import { getRawEntries } from './commHelper'; +import { getRawEntries } from './services/commHelper'; angular.module('emission.services', ['emission.plugin.logger']) .service('ControlHelper', function($window, diff --git a/www/js/commHelper.ts b/www/js/services/commHelper.ts similarity index 99% rename from www/js/commHelper.ts rename to www/js/services/commHelper.ts index 259677090..d62cf576d 100644 --- a/www/js/commHelper.ts +++ b/www/js/services/commHelper.ts @@ -1,5 +1,5 @@ import { DateTime } from "luxon"; -import { logDebug } from "./plugin/logger"; +import { logDebug } from "../plugin/logger"; /** * @param url URL endpoint for the request diff --git a/www/js/unifiedDataLoader.ts b/www/js/services/unifiedDataLoader.ts similarity index 96% rename from www/js/unifiedDataLoader.ts rename to www/js/services/unifiedDataLoader.ts index 15bcd341d..359109619 100644 --- a/www/js/unifiedDataLoader.ts +++ b/www/js/services/unifiedDataLoader.ts @@ -1,6 +1,6 @@ -import { logDebug } from './plugin/logger' +import { logDebug } from '../plugin/logger' import { getRawEntries } from './commHelper'; -import { ServerResponse, ServerData, TimeQuery } from './types/serverData'; +import { ServerResponse, ServerData, TimeQuery } from '../types/serverData'; /** * combineWithDedup is a helper function for combinedPromises diff --git a/www/js/splash/notifScheduler.js b/www/js/splash/notifScheduler.js index 22f8407ee..0b7721c38 100644 --- a/www/js/splash/notifScheduler.js +++ b/www/js/splash/notifScheduler.js @@ -3,7 +3,7 @@ import angular from 'angular'; import { getConfig } from '../config/dynamicConfig'; import { addStatReading, statKeys } from '../plugin/clientStats'; -import { getUser, updateUser } from '../commHelper'; +import { getUser, updateUser } from '../services/commHelper'; angular.module('emission.splash.notifscheduler', ['emission.services', diff --git a/www/js/splash/pushnotify.js b/www/js/splash/pushnotify.js index 40d859f09..d38f66755 100644 --- a/www/js/splash/pushnotify.js +++ b/www/js/splash/pushnotify.js @@ -14,7 +14,7 @@ */ import angular from 'angular'; -import { updateUser } from '../commHelper'; +import { updateUser } from '../services/commHelper'; angular.module('emission.splash.pushnotify', ['emission.plugin.logger', 'emission.services', diff --git a/www/js/splash/storedevicesettings.js b/www/js/splash/storedevicesettings.js index d307feaa7..5fb3f8513 100644 --- a/www/js/splash/storedevicesettings.js +++ b/www/js/splash/storedevicesettings.js @@ -1,5 +1,5 @@ import angular from 'angular'; -import { updateUser } from '../commHelper'; +import { updateUser } from '../services/commHelper'; angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', 'emission.services', diff --git a/www/js/survey/enketo/EnketoModal.tsx b/www/js/survey/enketo/EnketoModal.tsx index 8b80b6dfe..82a1cef7e 100644 --- a/www/js/survey/enketo/EnketoModal.tsx +++ b/www/js/survey/enketo/EnketoModal.tsx @@ -5,7 +5,7 @@ import { ModalProps } from 'react-native-paper'; import useAppConfig from '../../useAppConfig'; import { useTranslation } from 'react-i18next'; import { SurveyOptions, getInstanceStr, saveResponse } from './enketoHelper'; -import { fetchUrlCached } from '../../commHelper'; +import { fetchUrlCached } from '../../services/commHelper'; import { displayError, displayErrorMsg } from '../../plugin/logger'; // import { transform } from 'enketo-transformer/web'; diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index c35dd03a3..17846f28c 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -3,7 +3,7 @@ import { Form } from 'enketo-core'; import { XMLParser } from 'fast-xml-parser'; import i18next from 'i18next'; import { logDebug } from "../../plugin/logger"; -import { getUnifiedDataForInterval} from "../../unifiedDataLoader"; +import { getUnifiedDataForInterval} from "../../services/unifiedDataLoader"; export type PrefillFields = {[key: string]: string}; diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index 6350745eb..329b660e9 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -1,7 +1,7 @@ // may refactor this into a React hook once it's no longer used by any Angular screens import { getAngularService } from "../../angular-react-helper"; -import { fetchUrlCached } from "../../commHelper"; +import { fetchUrlCached } from "../../services/commHelper"; import i18next from "i18next"; import { logDebug } from "../../plugin/logger"; From 5e61cb91066d7fb0d7d90b17c2cff518896e8d30 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Fri, 27 Oct 2023 10:53:41 -0700 Subject: [PATCH 224/850] Updated types to match UnifiedDataLoader, PR #1073 --- www/__tests__/controlHelper.test.ts | 9 ++++--- www/js/controlHelper.ts | 5 ++-- www/js/types/diaryTypes.ts | 12 --------- www/js/types/fileShareTypes.ts | 39 +++++++---------------------- www/js/types/serverData.ts | 32 +++++++++++++++++++++++ 5 files changed, 49 insertions(+), 48 deletions(-) delete mode 100644 www/js/types/diaryTypes.ts create mode 100644 www/js/types/serverData.ts diff --git a/www/__tests__/controlHelper.test.ts b/www/__tests__/controlHelper.test.ts index 5e8836e80..dca43c039 100644 --- a/www/__tests__/controlHelper.test.ts +++ b/www/__tests__/controlHelper.test.ts @@ -1,6 +1,7 @@ import { mockLogger } from "../__mocks__/globalMocks"; import { createWriteFile } from "../js/controlHelper"; -import { FsWindow, RawData, RawDataCluster } from "../js/types/fileShareTypes" +import { FsWindow } from "../js/types/fileShareTypes" +import { ServerData, ServerResponse} from "../js/types/serverData" mockLogger(); declare let window: FsWindow; @@ -14,7 +15,7 @@ const generateFakeValues = (arraySize: number) => { if (arraySize <= 0) return new Promise (() => {return []}); - const sampleDataObj : RawData = { + const sampleDataObj : ServerData= { data: { name: 'testValue #', ts: 1234567890.9876543, @@ -51,14 +52,14 @@ const generateFakeValues = (arraySize: number) => { values[index].data.name = element.data.name + index.toString() }); - return new Promise(() => { + return new Promise>(() => { return { phone_data: values }; }); }; // A variation of createShareData; confirms the file has been written, // without calling the sharing components -const confirmFileExists = (fileName: string, dataCluster: RawDataCluster) => { +const confirmFileExists = (fileName: string, dataCluster: ServerResponse) => { return function() { return new Promise(function() { window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { diff --git a/www/js/controlHelper.ts b/www/js/controlHelper.ts index feade8622..01f1e39f2 100644 --- a/www/js/controlHelper.ts +++ b/www/js/controlHelper.ts @@ -2,7 +2,8 @@ import { DateTime } from "luxon"; import { getRawEntries } from "./commHelper"; import { logInfo, displayError, logDebug } from "./plugin/logger"; -import { FsWindow, RawDataCluster } from "./types/fileShareTypes" +import { FsWindow } from "./types/fileShareTypes" +import { ServerResponse} from "./types/serverData"; import i18next from "./i18nextInit" ; declare let window: FsWindow; @@ -13,7 +14,7 @@ declare let window: FsWindow; * @returns a function that returns a promise, which writes the file upon evaluation. */ export const createWriteFile = function (fileName: string) { - return function(result: RawDataCluster) { + return function(result: ServerResponse) { const resultList = result.phone_data; return new Promise(function(resolve, reject) { window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts deleted file mode 100644 index 1ecbd26cd..000000000 --- a/www/js/types/diaryTypes.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* This draft of diaryTypes was added to PR 1052, so that the LocalDT type is - consistent across PRs. Only LocalDt is needed for the controlHelper rewrite */ -export type LocalDt = { - minute: number, - hour: number, - second: number, - day: number, - weekday: number, - month: number, - year: number, - timezone: string, -} diff --git a/www/js/types/fileShareTypes.ts b/www/js/types/fileShareTypes.ts index e2b60bf49..89481624d 100644 --- a/www/js/types/fileShareTypes.ts +++ b/www/js/types/fileShareTypes.ts @@ -1,4 +1,12 @@ -import { LocalDt } from "./diaryTypes"; +import { ServerData } from './serverData'; + +export type TimeStampData = ServerData; + +export type RawTimelineData = { + name: string, + ts: number, + reading: number, +}; export interface FsWindow extends Window { requestFileSystem: ( @@ -12,32 +20,3 @@ export interface FsWindow extends Window { PERSISTENT: number; }; }; - -/* These are the objects returned from getRawEnteries when it is called by - the getMyData() method. */ -export interface RawDataCluster { - phone_data: Array -} - -export interface RawData { - data: { - name: string, - ts: number, - reading: number, - }, - metadata: { - key: string, - platform: string, - write_ts: number, - time_zone: string, - write_fmt_time: string, - write_local_dt: LocalDt, - }, - user_id: { - $uuid: string, - }, - _id: { - $oid: string, - } -} - diff --git a/www/js/types/serverData.ts b/www/js/types/serverData.ts new file mode 100644 index 000000000..35bd283bb --- /dev/null +++ b/www/js/types/serverData.ts @@ -0,0 +1,32 @@ +export type ServerResponse = { + phone_data: Array>, +} + +export type ServerData = { + data: Type, + metadata: MetaData, + key?: string, + user_id?: { $uuid: string, }, + _id?: { $oid: string, }, +}; + +export type MetaData = { + key: string, + platform: string, + write_ts: number, + time_zone: string, + write_fmt_time: string, + write_local_dt: LocalDt, +}; + +export type LocalDt = { + minute: number, + hour: number, + second: number, + day: number, + weekday: number, + month: number, + year: number, + timezone: string, +}; + \ No newline at end of file From 905e52c23f48e06a239e7c28742381bc299e020b Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Fri, 27 Oct 2023 11:00:51 -0700 Subject: [PATCH 225/850] Moved rewrites into `www/js/services` - As discussed in PR #1064 - This should make merging easier down the road, and should help clean up the `/js/` directory. --- www/__tests__/commHelper.test.ts | 2 +- www/js/config/dynamicConfig.ts | 2 +- www/js/control/ControlSyncHelper.tsx | 2 +- www/js/diary/LabelTab.tsx | 2 +- www/js/diary/services.js | 2 +- www/js/metrics/MetricsTab.tsx | 2 +- www/js/onboarding/SaveQrPage.tsx | 2 +- www/js/services.js | 2 +- www/js/{ => services}/commHelper.ts | 2 +- www/js/{ => services}/controlHelper.ts | 0 www/js/splash/notifScheduler.js | 2 +- www/js/splash/pushnotify.js | 2 +- www/js/splash/storedevicesettings.js | 2 +- www/js/survey/enketo/EnketoModal.tsx | 2 +- www/js/survey/multilabel/confirmHelper.ts | 2 +- 15 files changed, 14 insertions(+), 14 deletions(-) rename www/js/{ => services}/commHelper.ts (99%) rename www/js/{ => services}/controlHelper.ts (100%) diff --git a/www/__tests__/commHelper.test.ts b/www/__tests__/commHelper.test.ts index 2e2dfc6af..ec8d8b4ff 100644 --- a/www/__tests__/commHelper.test.ts +++ b/www/__tests__/commHelper.test.ts @@ -1,5 +1,5 @@ import { mockLogger } from '../__mocks__/globalMocks'; -import { fetchUrlCached } from '../js/commHelper'; +import { fetchUrlCached } from '../js/services/commHelper'; mockLogger(); diff --git a/www/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts index 6d9b2b372..3bbd6906d 100644 --- a/www/js/config/dynamicConfig.ts +++ b/www/js/config/dynamicConfig.ts @@ -1,7 +1,7 @@ import i18next from "i18next"; import { displayError, logDebug, logWarn } from "../plugin/logger"; import { getAngularService } from "../angular-react-helper"; -import { fetchUrlCached } from "../commHelper"; +import { fetchUrlCached } from "../services/commHelper"; import { storageClear, storageGet, storageSet } from "../plugin/storage"; export const CONFIG_PHONE_UI="config/app_ui_config"; diff --git a/www/js/control/ControlSyncHelper.tsx b/www/js/control/ControlSyncHelper.tsx index edc0e7470..44bc661b2 100644 --- a/www/js/control/ControlSyncHelper.tsx +++ b/www/js/control/ControlSyncHelper.tsx @@ -9,7 +9,7 @@ import SettingRow from "./SettingRow"; import AlertBar from "./AlertBar"; import moment from "moment"; import { addStatEvent, statKeys } from "../plugin/clientStats"; -import { updateUser } from "../commHelper"; +import { updateUser } from "../services/commHelper"; /* * BEGIN: Simple read/write wrappers diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index bb2430481..b98c0eb6a 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -22,7 +22,7 @@ import { SurveyOptions } from "../survey/survey"; import { getLabelOptions } from "../survey/multilabel/confirmHelper"; import { displayError } from "../plugin/logger"; import { useTheme } from "react-native-paper"; -import { getPipelineRangeTs } from "../commHelper"; +import { getPipelineRangeTs } from "../services/commHelper"; let labelPopulateFactory, labelsResultMap, notesResultMap, showPlaces; const ONE_DAY = 24 * 60 * 60; // seconds diff --git a/www/js/diary/services.js b/www/js/diary/services.js index 774273fa2..d5dfc40ce 100644 --- a/www/js/diary/services.js +++ b/www/js/diary/services.js @@ -4,7 +4,7 @@ import angular from 'angular'; import { getBaseModeByKey, getBaseModeOfLabeledTrip } from './diaryHelper'; import { SurveyOptions } from '../survey/survey'; import { getConfig } from '../config/dynamicConfig'; -import { getRawEntries } from '../commHelper'; +import { getRawEntries } from '../services/commHelper'; angular.module('emission.main.diary.services', ['emission.plugin.logger', 'emission.services']) diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 450155622..8c9e2faee 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -16,7 +16,7 @@ import Carousel from "../components/Carousel"; import DailyActiveMinutesCard from "./DailyActiveMinutesCard"; import CarbonTextCard from "./CarbonTextCard"; import ActiveMinutesTableCard from "./ActiveMinutesTableCard"; -import { getAggregateData, getMetrics } from "../commHelper"; +import { getAggregateData, getMetrics } from "../services/commHelper"; export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; diff --git a/www/js/onboarding/SaveQrPage.tsx b/www/js/onboarding/SaveQrPage.tsx index 406376cfa..dd9663f82 100644 --- a/www/js/onboarding/SaveQrPage.tsx +++ b/www/js/onboarding/SaveQrPage.tsx @@ -10,7 +10,7 @@ import QrCode, { shareQR } from "../components/QrCode"; import { onboardingStyles } from "./OnboardingStack"; import { preloadDemoSurveyResponse } from "./SurveyPage"; import { storageSet } from "../plugin/storage"; -import { registerUser } from "../commHelper"; +import { registerUser } from "../services/commHelper"; import { resetDataAndRefresh } from "../config/dynamicConfig"; import i18next from "i18next"; diff --git a/www/js/services.js b/www/js/services.js index e406be203..1b6a5b756 100644 --- a/www/js/services.js +++ b/www/js/services.js @@ -1,7 +1,7 @@ 'use strict'; import angular from 'angular'; -import { getRawEntries } from './commHelper'; +import { getRawEntries } from './services/commHelper'; import { logInfo} from './plugin/logger' angular.module('emission.services', ['emission.plugin.logger']) diff --git a/www/js/commHelper.ts b/www/js/services/commHelper.ts similarity index 99% rename from www/js/commHelper.ts rename to www/js/services/commHelper.ts index b9584a044..68e064abb 100644 --- a/www/js/commHelper.ts +++ b/www/js/services/commHelper.ts @@ -1,5 +1,5 @@ import { DateTime } from "luxon"; -import { logDebug } from "./plugin/logger"; +import { logDebug } from "../plugin/logger"; /** * @param url URL endpoint for the request diff --git a/www/js/controlHelper.ts b/www/js/services/controlHelper.ts similarity index 100% rename from www/js/controlHelper.ts rename to www/js/services/controlHelper.ts diff --git a/www/js/splash/notifScheduler.js b/www/js/splash/notifScheduler.js index 22f8407ee..0b7721c38 100644 --- a/www/js/splash/notifScheduler.js +++ b/www/js/splash/notifScheduler.js @@ -3,7 +3,7 @@ import angular from 'angular'; import { getConfig } from '../config/dynamicConfig'; import { addStatReading, statKeys } from '../plugin/clientStats'; -import { getUser, updateUser } from '../commHelper'; +import { getUser, updateUser } from '../services/commHelper'; angular.module('emission.splash.notifscheduler', ['emission.services', diff --git a/www/js/splash/pushnotify.js b/www/js/splash/pushnotify.js index 40d859f09..d38f66755 100644 --- a/www/js/splash/pushnotify.js +++ b/www/js/splash/pushnotify.js @@ -14,7 +14,7 @@ */ import angular from 'angular'; -import { updateUser } from '../commHelper'; +import { updateUser } from '../services/commHelper'; angular.module('emission.splash.pushnotify', ['emission.plugin.logger', 'emission.services', diff --git a/www/js/splash/storedevicesettings.js b/www/js/splash/storedevicesettings.js index d307feaa7..5fb3f8513 100644 --- a/www/js/splash/storedevicesettings.js +++ b/www/js/splash/storedevicesettings.js @@ -1,5 +1,5 @@ import angular from 'angular'; -import { updateUser } from '../commHelper'; +import { updateUser } from '../services/commHelper'; angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', 'emission.services', diff --git a/www/js/survey/enketo/EnketoModal.tsx b/www/js/survey/enketo/EnketoModal.tsx index 8b80b6dfe..82a1cef7e 100644 --- a/www/js/survey/enketo/EnketoModal.tsx +++ b/www/js/survey/enketo/EnketoModal.tsx @@ -5,7 +5,7 @@ import { ModalProps } from 'react-native-paper'; import useAppConfig from '../../useAppConfig'; import { useTranslation } from 'react-i18next'; import { SurveyOptions, getInstanceStr, saveResponse } from './enketoHelper'; -import { fetchUrlCached } from '../../commHelper'; +import { fetchUrlCached } from '../../services/commHelper'; import { displayError, displayErrorMsg } from '../../plugin/logger'; // import { transform } from 'enketo-transformer/web'; diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index 6350745eb..329b660e9 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -1,7 +1,7 @@ // may refactor this into a React hook once it's no longer used by any Angular screens import { getAngularService } from "../../angular-react-helper"; -import { fetchUrlCached } from "../../commHelper"; +import { fetchUrlCached } from "../../services/commHelper"; import i18next from "i18next"; import { logDebug } from "../../plugin/logger"; From e19a346457af3d97c926b308ecc9133e7a289843 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 27 Oct 2023 12:28:08 -0600 Subject: [PATCH 226/850] less hacky undefined catch --- www/js/plugin/storage.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/www/js/plugin/storage.ts b/www/js/plugin/storage.ts index e649d504d..a8c503f0e 100644 --- a/www/js/plugin/storage.ts +++ b/www/js/plugin/storage.ts @@ -25,12 +25,14 @@ const unmungeValue = (key, retData) => { } const localStorageSet = (key: string, value: {[k: string]: any}) => { - localStorage.setItem(key, JSON.stringify(value)); + if (value) { + localStorage.setItem(key, JSON.stringify(value)); + } } const localStorageGet = (key: string) => { const value = localStorage.getItem(key); - if (value && value != "undefined") { + if (value) { return JSON.parse(value); } else { return null; From 9f5c02e92be6af2fab9774cdd7bc60a449e13d87 Mon Sep 17 00:00:00 2001 From: "K. Shankari" Date: Sat, 28 Oct 2023 16:18:33 -0700 Subject: [PATCH 227/850] Change the path where we expect gems to be installed This fixes https://github.com/e-mission/e-mission-docs/issues/1022 --- setup/export_shared_dep_versions.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup/export_shared_dep_versions.sh b/setup/export_shared_dep_versions.sh index 930216b6e..2ac27d61b 100644 --- a/setup/export_shared_dep_versions.sh +++ b/setup/export_shared_dep_versions.sh @@ -11,4 +11,4 @@ export GRADLE_VERSION=7.6 export OSX_EXP_VERSION=12 export NVM_DIR="$HOME/.nvm" -export RUBY_PATH=$HOME/.gem/ruby/$RUBY_VERSION.0/bin +export RUBY_PATH=$HOME/.local/share/gem/ruby/$RUBY_VERSION.0/bin From f7716cf791fbec89625cdb7d168ab8b148ab3f38 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Sun, 29 Oct 2023 19:49:32 -0400 Subject: [PATCH 228/850] refactor filters: prep to remove buttons services Preparing to remove the buttons services (https://github.com/e-mission/e-mission-phone/pull/1086), the filter functions need to be tweaked. `user_input` will no longer be stored on the trip object, it will be accessible in the LabelTabContext, not from these functions, so it will be passed in as a second param to these functions as userInputForTrip. Also, we'll remove the surveyOpt variable from LabelTabContext since (i) it can just be read directly from appConfig and (ii) we don't want to keep more variables than necessary in LabelTabContext to keep it from getting too cluttered. - the INVALID_EBIKE filter (along with invalidCheck) was removed as it is not used - logic for toLabelCheck was written more concisely - 'width' is no longer needed on the configuredFilters - this was for the old UI --- www/js/diary/LabelTab.tsx | 14 ++--- www/js/diary/cards/TripCard.tsx | 11 ++-- www/js/diary/details/LabelDetailsScreen.tsx | 13 ++-- .../survey/enketo/infinite_scroll_filters.ts | 29 +++------ .../multilabel/infinite_scroll_filters.ts | 59 +++++++------------ 5 files changed, 48 insertions(+), 78 deletions(-) diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index f4677766d..b67dec542 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -18,7 +18,6 @@ import LabelScreenDetails from "./details/LabelDetailsScreen"; import { NavigationContainer } from "@react-navigation/native"; import { compositeTrips2TimelineMap, getAllUnprocessedInputs, getLocalUnprocessedInputs, populateCompositeTrips } from "./timelineHelper"; import { fillLocationNamesOfTrip, resetNominatimLimiter } from "./addressNamesHelper"; -import { SurveyOptions } from "../survey/survey"; import { getLabelOptions } from "../survey/multilabel/confirmHelper"; import { displayError } from "../plugin/logger"; import { useTheme } from "react-native-paper"; @@ -34,7 +33,6 @@ const LabelTab = () => { const { t } = useTranslation(); const { colors } = useTheme(); - const [surveyOpt, setSurveyOpt] = useState(null); const [labelOptions, setLabelOptions] = useState(null); const [filterInputs, setFilterInputs] = useState([]); const [pipelineRange, setPipelineRange] = useState(null); @@ -54,9 +52,6 @@ const LabelTab = () => { // initialization, once the appConfig is loaded useEffect(() => { if (!appConfig) return; - const surveyOptKey = appConfig.survey_info['trip-labels']; - const surveyOpt = SurveyOptions[surveyOptKey]; - setSurveyOpt(surveyOpt); showPlaces = appConfig.survey_info?.buttons?.['place-notes']; getLabelOptions(appConfig).then((labelOptions) => setLabelOptions(labelOptions)); labelPopulateFactory = getAngularService(surveyOpt.service); @@ -68,8 +63,11 @@ const LabelTab = () => { // https://github.com/e-mission/e-mission-docs/issues/894 if (appConfig.survey_info?.buttons == undefined) { // initalize filters - const tripFilter = surveyOpt.filter; - const allFalseFilters = tripFilter.map((f, i) => ({ + const tripFilters = + appConfig.survey_info?.['trip-labels'] == 'ENKETO' + ? enketoConfiguredFilters + : multilabelConfiguredFilters; + const allFalseFilters = tripFilters.map((f, i) => ({ ...f, state: (i == 0 ? true : false) // only the first filter will have state true on init })); setFilterInputs(allFalseFilters); @@ -86,7 +84,7 @@ const LabelTab = () => { let entriesToDisplay = allEntries; if (activeFilter) { const entriesAfterFilter = allEntries.filter( - t => t.justRepopulated || activeFilter?.filter(t) + t => t.justRepopulated || activeFilter?.filter(t, t.user_input) ); /* next, filter out any untracked time if the trips that came before and after it are no longer displayed */ diff --git a/www/js/diary/cards/TripCard.tsx b/www/js/diary/cards/TripCard.tsx index 08e02bca4..9aeb4a61e 100644 --- a/www/js/diary/cards/TripCard.tsx +++ b/www/js/diary/cards/TripCard.tsx @@ -34,7 +34,7 @@ const TripCard = ({ trip }: Props) => { distanceSuffix, displayTime, detectedModes } = useDerivedProperties(trip); let [ tripStartDisplayName, tripEndDisplayName ] = useAddressNames(trip); const navigation = useNavigation(); - const { surveyOpt, labelOptions } = useContext(LabelTabContext); + const { labelOptions } = useContext(LabelTabContext); const tripGeojson = useGeojsonForTrip(trip, labelOptions, trip?.userInput?.MODE?.value); const isDraft = trip.key.includes('UNPROCESSED'); @@ -70,10 +70,11 @@ const TripCard = ({ trip }: Props) => { displayEndName={tripEndDisplayName} /> {/* mode and purpose buttons / survey button */} - {surveyOpt?.elementTag == 'multilabel' && - } - {surveyOpt?.elementTag == 'enketo-trip-button' - && } + {appConfig?.survey_info?.['trip-labels'] == 'ENKETO' ? ( + + ) : ( + + )} {/* left panel */} diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx index ffed9a300..b25c8e50c 100644 --- a/www/js/diary/details/LabelDetailsScreen.tsx +++ b/www/js/diary/details/LabelDetailsScreen.tsx @@ -18,12 +18,14 @@ import { useGeojsonForTrip } from "../timelineHelper"; import TripSectionsDescriptives from "./TripSectionsDescriptives"; import OverallTripDescriptives from "./OverallTripDescriptives"; import ToggleSwitch from "../../components/ToggleSwitch"; +import useAppConfig from "../../useAppConfig"; const LabelScreenDetails = ({ route, navigation }) => { - const { surveyOpt, timelineMap, labelOptions } = useContext(LabelTabContext); + const { timelineMap, labelOptions } = useContext(LabelTabContext); const { t } = useTranslation(); const { height: windowHeight } = useWindowDimensions(); + const appConfig = useAppConfig(); const { tripId, flavoredTheme } = route.params; const trip = timelineMap.get(tripId); const { colors } = flavoredTheme || useTheme(); @@ -51,10 +53,11 @@ const LabelScreenDetails = ({ route, navigation }) => { style={{margin: 10, paddingHorizontal: 10, rowGap: 12, borderRadius: 15 }}> {/* MultiLabel or UserInput button, inline on one row */} - {surveyOpt?.elementTag == 'multilabel' && - } - {surveyOpt?.elementTag == 'enketo-trip-button' - && } + {appConfig?.survey_info?.['trip-labels'] == 'ENKETO' ? ( + + ) : ( + + )} {/* Full-size Leaflet map, with zoom controls */} diff --git a/www/js/survey/enketo/infinite_scroll_filters.ts b/www/js/survey/enketo/infinite_scroll_filters.ts index 98eba65db..363cfaa85 100644 --- a/www/js/survey/enketo/infinite_scroll_filters.ts +++ b/www/js/survey/enketo/infinite_scroll_filters.ts @@ -7,32 +7,17 @@ */ import i18next from "i18next"; -import { getAngularService } from "../../angular-react-helper"; -const unlabeledCheck = (t) => { - try { - const EnketoTripButtonService = getAngularService("EnketoTripButtonService"); - const etbsSingleKey = EnketoTripButtonService.SINGLE_KEY; - return typeof t.userInput[etbsSingleKey] === 'undefined'; - } - catch (e) { - console.log("Error in retrieving EnketoTripButtonService: ", e); - } -} - -const UNLABELED = { - key: "unlabeled", - text: i18next.t("diary.unlabeled"), - filter: unlabeledCheck +const unlabeledCheck = (trip, userInputForTrip) => { + return !userInputForTrip?.['SURVEY']; } const TO_LABEL = { - key: "to_label", - text: i18next.t("diary.to-label"), - filter: unlabeledCheck + key: "to_label", + text: i18next.t("diary.to-label"), + filter: unlabeledCheck, } export const configuredFilters = [ - TO_LABEL, - UNLABELED -]; \ No newline at end of file + TO_LABEL, +]; diff --git a/www/js/survey/multilabel/infinite_scroll_filters.ts b/www/js/survey/multilabel/infinite_scroll_filters.ts index 8d71266d9..62fe2cd20 100644 --- a/www/js/survey/multilabel/infinite_scroll_filters.ts +++ b/www/js/survey/multilabel/infinite_scroll_filters.ts @@ -7,52 +7,35 @@ */ import i18next from "i18next"; - -const unlabeledCheck = (t) => { - return t.INPUTS - .map((inputType, index) => !t.userInput[inputType]) - .reduce((acc, val) => acc || val, false); -} - -const invalidCheck = (t) => { - const retVal = - (t.userInput['MODE'] && t.userInput['MODE'].value === 'pilot_ebike') && - (!t.userInput['REPLACED_MODE'] || - t.userInput['REPLACED_MODE'].value === 'pilot_ebike' || - t.userInput['REPLACED_MODE'].value === 'same_mode'); - return retVal; +import { labelInputDetailsForTrip } from "./confirmHelper"; +import { logDebug } from "../../plugin/logger"; + +const unlabeledCheck = (trip, userInputForTrip) => { + const tripInputDetails = labelInputDetailsForTrip(userInputForTrip); + return Object.keys(tripInputDetails) + .map((inputType) => !userInputForTrip?.[inputType]) + .reduce((acc, val) => acc || val, false); } -const toLabelCheck = (trip) => { - if (trip.expectation) { - console.log(trip.expectation.to_label) - return trip.expectation.to_label && unlabeledCheck(trip); - } else { - return true; - } +const toLabelCheck = (trip, userInputForTrip) => { + logDebug('Expectation: '+trip.expectation); + if (!trip.expectation) return true; + return trip.expectation.to_label && unlabeledCheck(trip, userInputForTrip); } const UNLABELED = { - key: "unlabeled", - text: i18next.t("diary.unlabeled"), - filter: unlabeledCheck, - width: "col-50" -} - -const INVALID_EBIKE = { - key: "invalid_ebike", - text: i18next.t("diary.invalid-ebike"), - filter: invalidCheck + key: "unlabeled", + text: i18next.t("diary.unlabeled"), + filter: unlabeledCheck, } const TO_LABEL = { - key: "to_label", - text: i18next.t("diary.to-label"), - filter: toLabelCheck, - width: "col-50" + key: "to_label", + text: i18next.t("diary.to-label"), + filter: toLabelCheck, } export const configuredFilters = [ - TO_LABEL, - UNLABELED -]; \ No newline at end of file + TO_LABEL, + UNLABELED, +]; From 4d48aeebd2b1734e3d08dc5de6726f1faf4499bc Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Sun, 29 Oct 2023 20:11:58 -0400 Subject: [PATCH 229/850] map user inputs instead of populating tlEntry objs This is a major refactor of how we handle user inputs that removes the need for having separate button services (https://github.com/e-mission/e-mission-phone/pull/1086) Instead of populating fields called "userInput" and "additionsList" on trip/place objects, we will store all inputs of each kind in a single object mapping trip/place IDs to inputs. These will be the single source of truth for what inputs are matched to what trips/places and will remove the need for populating/repopulating trips. These maps will be accessible in LabelTabContext and read downstream wherever user inputs need to be read. Specific notes on the changes: - In LabelTab, matching inputs now happens not as a part of 'populating' trips/places, but in a useEffect so that whenever timelineMap is updated, timelineLabelMap and timelineNotesMap are also updated. - in timelineHelper, we read unprocessedInputs from local and/or server and instead of returning them from 'get' methods, we'll cache them here in timelineHelper and update those caches with 'update' methods. - Type definition for UserInput renamed to UnprocessedUserInput to avoid confusion - this only applies to unprocessed inputs and is not necessarily the structure that processed user inputs will have - in diaryHelper, getBaseModeOfLabeledTrip was removed since it was basically just a shortcut to getBaseModeByValue with a trip as param instead of its userInput. All usages replaced with getBaseModeByValue --- www/__tests__/inputMatcher.test.ts | 8 +- www/js/diary.js | 6 +- www/js/diary/LabelTab.tsx | 52 +++++---- www/js/diary/cards/ModesIndicator.tsx | 11 +- www/js/diary/cards/PlaceCard.tsx | 6 +- www/js/diary/cards/TripCard.tsx | 8 +- www/js/diary/details/LabelDetailsScreen.tsx | 6 +- .../details/TripSectionsDescriptives.tsx | 13 +-- www/js/diary/diaryHelper.ts | 7 -- www/js/diary/services.js | 12 --- www/js/diary/timelineHelper.ts | 92 ++++++++++------ www/js/survey/enketo/AddNoteButton.tsx | 6 +- www/js/survey/enketo/UserInputButton.tsx | 9 +- www/js/survey/inputMatcher.ts | 68 ++++++++++-- .../multilabel/MultiLabelButtonGroup.tsx | 24 +++-- www/js/survey/multilabel/confirmHelper.ts | 101 ++++++++++++++++-- www/js/types/diaryTypes.ts | 7 +- 17 files changed, 294 insertions(+), 142 deletions(-) diff --git a/www/__tests__/inputMatcher.test.ts b/www/__tests__/inputMatcher.test.ts index ac14a506b..3179c9700 100644 --- a/www/__tests__/inputMatcher.test.ts +++ b/www/__tests__/inputMatcher.test.ts @@ -8,10 +8,10 @@ import { getAdditionsForTimelineEntry, getUniqueEntries } from '../js/survey/inputMatcher'; -import { TlEntry, UserInput } from '../js/types/diaryTypes'; +import { TlEntry, UnprocessedUserInput } from '../js/types/diaryTypes'; describe('input-matcher', () => { - let userTrip: UserInput; + let userTrip: UnprocessedUserInput; let trip: TlEntry; beforeEach(() => { @@ -212,13 +212,13 @@ describe('input-matcher', () => { // make the linst unsorted and then check if userInputWriteThird(latest one) is return output const userInputList = [userInputWriteSecond, userInputWriteThird, userInputWriteFirst]; - const mostRecentEntry = getUserInputForTrip(trip, {}, userInputList); + const mostRecentEntry = getUserInputForTrip(trip, userInputList); expect(mostRecentEntry).toMatchObject(userInputWriteThird); }); it('tests getUserInputForTrip with invalid userInputList', () => { const userInputList = undefined; - const mostRecentEntry = getUserInputForTrip(trip, {}, userInputList); + const mostRecentEntry = getUserInputForTrip(trip, userInputList); expect(mostRecentEntry).toBe(undefined); }); diff --git a/www/js/diary.js b/www/js/diary.js index c0b7bce35..cc996f811 100644 --- a/www/js/diary.js +++ b/www/js/diary.js @@ -2,10 +2,8 @@ import angular from 'angular'; import LabelTab from './diary/LabelTab'; angular.module('emission.main.diary',['emission.main.diary.services', - 'emission.survey.multilabel.buttons', - 'emission.survey.enketo.add-note-button', - 'emission.survey.enketo.trip.button', - 'emission.plugin.logger']) + 'emission.plugin.logger', + 'emission.survey.enketo.answer']) .config(function($stateProvider) { $stateProvider diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index b67dec542..b794a8257 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -16,14 +16,18 @@ import LabelListScreen from "./list/LabelListScreen"; import { createStackNavigator } from "@react-navigation/stack"; import LabelScreenDetails from "./details/LabelDetailsScreen"; import { NavigationContainer } from "@react-navigation/native"; -import { compositeTrips2TimelineMap, getAllUnprocessedInputs, getLocalUnprocessedInputs, populateCompositeTrips } from "./timelineHelper"; +import { compositeTrips2TimelineMap, updateAllUnprocessedInputs, updateLocalUnprocessedInputs, populateCompositeTrips, unprocessedLabels, unprocessedNotes } from "./timelineHelper"; import { fillLocationNamesOfTrip, resetNominatimLimiter } from "./addressNamesHelper"; import { getLabelOptions } from "../survey/multilabel/confirmHelper"; -import { displayError } from "../plugin/logger"; +import { displayError, logDebug } from "../plugin/logger"; import { useTheme } from "react-native-paper"; import { getPipelineRangeTs } from "../commHelper"; +import { UnprocessedUserInput } from "../types/diaryTypes"; +import { mapInputsToTimelineEntries } from "../survey/inputMatcher"; +import { configuredFilters as multilabelConfiguredFilters } from "../survey/multilabel/infinite_scroll_filters"; +import { configuredFilters as enketoConfiguredFilters } from "../survey/enketo/infinite_scroll_filters"; -let labelPopulateFactory, labelsResultMap, notesResultMap, showPlaces; +let showPlaces; const ONE_DAY = 24 * 60 * 60; // seconds const ONE_WEEK = ONE_DAY * 7; // seconds export const LabelTabContext = React.createContext(null); @@ -37,7 +41,9 @@ const LabelTab = () => { const [filterInputs, setFilterInputs] = useState([]); const [pipelineRange, setPipelineRange] = useState(null); const [queriedRange, setQueriedRange] = useState(null); - const [timelineMap, setTimelineMap] = useState(null); + const [timelineMap, setTimelineMap] = useState>(null); + const [timelineLabelMap, setTimelineLabelMap] = useState<{[k: string]: {[k: string]: UnprocessedUserInput}}>(null); + const [timelineNotesMap, setTimelineNotesMap] = useState<{[k: string]: UnprocessedUserInput[]}>(null); const [displayedEntries, setDisplayedEntries] = useState(null); const [refreshTime, setRefreshTime] = useState(null); const [isLoading, setIsLoading] = useState('replace'); @@ -47,17 +53,12 @@ const LabelTab = () => { const $ionicPopup = getAngularService('$ionicPopup'); const Logger = getAngularService('Logger'); const Timeline = getAngularService('Timeline'); - const enbs = getAngularService('EnketoNotesButtonService'); // initialization, once the appConfig is loaded useEffect(() => { if (!appConfig) return; showPlaces = appConfig.survey_info?.buttons?.['place-notes']; getLabelOptions(appConfig).then((labelOptions) => setLabelOptions(labelOptions)); - labelPopulateFactory = getAngularService(surveyOpt.service); - const tripSurveyName = appConfig.survey_info?.buttons?.['trip-notes']?.surveyName; - const placeSurveyName = appConfig.survey_info?.buttons?.['place-notes']?.surveyName; - enbs.initConfig(tripSurveyName, placeSurveyName); // we will show filters if 'additions' are not configured // https://github.com/e-mission/e-mission-docs/issues/894 @@ -75,16 +76,24 @@ const LabelTab = () => { loadTimelineEntries(); }, [appConfig, refreshTime]); - // whenever timelineMap is updated, update the displayedEntries - // according to the active filter + // whenever timelineMap is updated, map unprocessed inputs to timeline entries, and + // update the displayedEntries according to the active filter useEffect(() => { if (!timelineMap) return setDisplayedEntries(null); const allEntries = Array.from(timelineMap.values()); + const [newTimelineLabelMap, newTimelineNotesMap] = mapInputsToTimelineEntries( + allEntries, + appConfig, + ); + + setTimelineLabelMap(newTimelineLabelMap); + setTimelineNotesMap(newTimelineNotesMap); + const activeFilter = filterInputs?.find((f) => f.state == true); let entriesToDisplay = allEntries; if (activeFilter) { const entriesAfterFilter = allEntries.filter( - t => t.justRepopulated || activeFilter?.filter(t, t.user_input) + t => t.justRepopulated || activeFilter?.filter(t, newTimelineLabelMap[t._id.$oid]) ); /* next, filter out any untracked time if the trips that came before and after it are no longer displayed */ @@ -104,9 +113,13 @@ const LabelTab = () => { async function loadTimelineEntries() { try { const pipelineRange = await getPipelineRangeTs(); - [labelsResultMap, notesResultMap] = await getAllUnprocessedInputs(pipelineRange, labelPopulateFactory, enbs); - Logger.log("After reading unprocessedInputs, labelsResultMap =" + JSON.stringify(labelsResultMap) - + "; notesResultMap = " + JSON.stringify(notesResultMap)); + await updateAllUnprocessedInputs(pipelineRange, appConfig); + logDebug( + 'After updating unprocessed inputs, unprocessedLabels = ' + + JSON.stringify(unprocessedLabels) + + '; unprocessedNotes = ' + + JSON.stringify(unprocessedNotes), + ); setPipelineRange(pipelineRange); } catch (error) { Logger.displayError("Error while loading pipeline range", error); @@ -177,7 +190,7 @@ const LabelTab = () => { function handleFetchedTrips(ctList, utList, mode: 'prepend' | 'append' | 'replace') { const tripsRead = ctList.concat(utList); - populateCompositeTrips(tripsRead, showPlaces, labelPopulateFactory, labelsResultMap, enbs, notesResultMap); + populateCompositeTrips(tripsRead, showPlaces); // Fill place names on a reversed copy of the list so we fill from the bottom up tripsRead.slice().reverse().forEach(function (trip, index) { fillLocationNamesOfTrip(trip); @@ -224,11 +237,9 @@ const LabelTab = () => { const timelineMapRef = useRef(timelineMap); async function repopulateTimelineEntry(oid: string) { if (!timelineMap.has(oid)) return console.error("Item with oid: " + oid + " not found in timeline"); - const [newLabels, newNotes] = await getLocalUnprocessedInputs(pipelineRange, labelPopulateFactory, enbs); + await updateLocalUnprocessedInputs(pipelineRange, appConfig); const repopTime = new Date().getTime(); const newEntry = {...timelineMap.get(oid), justRepopulated: repopTime}; - labelPopulateFactory.populateInputsAndInferences(newEntry, newLabels); - enbs.populateInputsAndInferences(newEntry, newNotes); const newTimelineMap = new Map(timelineMap).set(oid, newEntry); setTimelineMap(newTimelineMap); @@ -246,9 +257,10 @@ const LabelTab = () => { } const contextVals = { - surveyOpt, labelOptions, timelineMap, + timelineLabelMap, + timelineNotesMap, displayedEntries, filterInputs, setFilterInputs, diff --git a/www/js/diary/cards/ModesIndicator.tsx b/www/js/diary/cards/ModesIndicator.tsx index 5211f7ed4..db873a71e 100644 --- a/www/js/diary/cards/ModesIndicator.tsx +++ b/www/js/diary/cards/ModesIndicator.tsx @@ -3,7 +3,7 @@ import { View, StyleSheet } from 'react-native'; import color from "color"; import { LabelTabContext } from '../LabelTab'; import { logDebug } from '../../plugin/logger'; -import { getBaseModeOfLabeledTrip } from '../diaryHelper'; +import { getBaseModeByValue } from '../diaryHelper'; import { Icon } from '../../components/Icon'; import { Text, useTheme } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; @@ -11,15 +11,16 @@ import { useTranslation } from 'react-i18next'; const ModesIndicator = ({ trip, detectedModes, }) => { const { t } = useTranslation(); - const { labelOptions } = useContext(LabelTabContext); + const { labelOptions, timelineLabelMap } = useContext(LabelTabContext); const { colors } = useTheme(); const indicatorBackgroundColor = color(colors.onPrimary).alpha(.8).rgb().string(); let indicatorBorderColor = color('black').alpha(.5).rgb().string(); let modeViews; - if (trip.userInput.MODE) { - const baseMode = getBaseModeOfLabeledTrip(trip, labelOptions); + const labeledModeForTrip = timelineLabelMap[trip._id.$oid]?.['MODE']; + if (labeledModeForTrip?.value) { + const baseMode = getBaseModeByValue(labeledModeForTrip.value, labelOptions); indicatorBorderColor = baseMode.color; logDebug(`TripCard: got baseMode = ${JSON.stringify(baseMode)}`); modeViews = ( @@ -27,7 +28,7 @@ const ModesIndicator = ({ trip, detectedModes, }) => { - {trip.userInput.MODE.text} + {timelineLabelMap[trip._id.$oid]?.MODE.text} ); diff --git a/www/js/diary/cards/PlaceCard.tsx b/www/js/diary/cards/PlaceCard.tsx index cd1d9c10e..31ea5c789 100644 --- a/www/js/diary/cards/PlaceCard.tsx +++ b/www/js/diary/cards/PlaceCard.tsx @@ -6,7 +6,7 @@ PlaceCards use the blueish 'place' theme flavor. */ -import React from "react"; +import React, { useContext } from "react"; import { View, StyleSheet } from 'react-native'; import { Text } from 'react-native-paper'; import useAppConfig from "../../useAppConfig"; @@ -17,11 +17,13 @@ import { DiaryCard, cardStyles } from "./DiaryCard"; import { useAddressNames } from "../addressNamesHelper"; import useDerivedProperties from "../useDerivedProperties"; import StartEndLocations from "../components/StartEndLocations"; +import { LabelTabContext } from "../LabelTab"; type Props = { place: {[key: string]: any} }; const PlaceCard = ({ place }: Props) => { const appConfig = useAppConfig(); + const { timelineNotesMap } = useContext(LabelTabContext); const { displayStartTime, displayEndTime, displayDate } = useDerivedProperties(place); let [ placeDisplayName ] = useAddressNames(place); @@ -49,7 +51,7 @@ const PlaceCard = ({ place }: Props) => { - + ); diff --git a/www/js/diary/cards/TripCard.tsx b/www/js/diary/cards/TripCard.tsx index 9aeb4a61e..dc1e44ce8 100644 --- a/www/js/diary/cards/TripCard.tsx +++ b/www/js/diary/cards/TripCard.tsx @@ -34,8 +34,8 @@ const TripCard = ({ trip }: Props) => { distanceSuffix, displayTime, detectedModes } = useDerivedProperties(trip); let [ tripStartDisplayName, tripEndDisplayName ] = useAddressNames(trip); const navigation = useNavigation(); - const { labelOptions } = useContext(LabelTabContext); - const tripGeojson = useGeojsonForTrip(trip, labelOptions, trip?.userInput?.MODE?.value); + const { labelOptions, timelineLabelMap, timelineNotesMap } = useContext(LabelTabContext); + const tripGeojson = useGeojsonForTrip(trip, labelOptions, timelineLabelMap[trip._id.$oid]?.MODE?.value); const isDraft = trip.key.includes('UNPROCESSED'); const flavoredTheme = getTheme(isDraft ? 'draft' : undefined); @@ -92,9 +92,9 @@ const TripCard = ({ trip }: Props) => { } - {trip.additionsList?.length != 0 && + {timelineNotesMap[trip._id.$oid]?.length != 0 && - + } diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx index b25c8e50c..11875545a 100644 --- a/www/js/diary/details/LabelDetailsScreen.tsx +++ b/www/js/diary/details/LabelDetailsScreen.tsx @@ -22,7 +22,7 @@ import useAppConfig from "../../useAppConfig"; const LabelScreenDetails = ({ route, navigation }) => { - const { timelineMap, labelOptions } = useContext(LabelTabContext); + const { timelineMap, labelOptions, timelineLabelMap } = useContext(LabelTabContext); const { t } = useTranslation(); const { height: windowHeight } = useWindowDimensions(); const appConfig = useAppConfig(); @@ -33,7 +33,7 @@ const LabelScreenDetails = ({ route, navigation }) => { const [ tripStartDisplayName, tripEndDisplayName ] = useAddressNames(trip); const [ modesShown, setModesShown ] = useState<'labeled'|'detected'>('labeled'); - const tripGeojson = useGeojsonForTrip(trip, labelOptions, modesShown=='labeled' && trip?.userInput?.MODE?.value); + const tripGeojson = useGeojsonForTrip(trip, labelOptions, modesShown=='labeled' && timelineLabelMap[trip._id.$oid]?.MODE?.value); const mapOpts = {minZoom: 3, maxZoom: 17}; const modal = ( @@ -65,7 +65,7 @@ const LabelScreenDetails = ({ route, navigation }) => { {/* If trip is labeled, show a toggle to switch between "Labeled Mode" and "Detected Modes" otherwise, just show "Detected" */} - {trip?.userInput?.MODE?.value ? + {timelineLabelMap[trip._id.$oid]?.MODE?.value ? setModesShown(v)} value={modesShown} density='medium' buttons={[{label: t('diary.labeled-mode'), value: 'labeled'}, {label: t('diary.detected-modes'), value: 'detected'}]} /> : diff --git a/www/js/diary/details/TripSectionsDescriptives.tsx b/www/js/diary/details/TripSectionsDescriptives.tsx index 6d172fed4..c89481f44 100644 --- a/www/js/diary/details/TripSectionsDescriptives.tsx +++ b/www/js/diary/details/TripSectionsDescriptives.tsx @@ -3,24 +3,25 @@ import { View } from 'react-native'; import { Text, useTheme } from 'react-native-paper' import { Icon } from '../../components/Icon'; import useDerivedProperties from '../useDerivedProperties'; -import { getBaseModeByKey, getBaseModeOfLabeledTrip } from '../diaryHelper'; +import { getBaseModeByKey, getBaseModeByValue } from '../diaryHelper'; import { LabelTabContext } from '../LabelTab'; const TripSectionsDescriptives = ({ trip, showLabeledMode=false }) => { - const { labelOptions } = useContext(LabelTabContext); + const { labelOptions, timelineLabelMap } = useContext(LabelTabContext); const { displayStartTime, displayTime, formattedDistance, distanceSuffix, formattedSectionProperties } = useDerivedProperties(trip); const { colors } = useTheme(); + const labeledModeForTrip = timelineLabelMap[trip._id.$oid]?.MODE; let sections = formattedSectionProperties; /* if we're only showing the labeled mode, or there are no sections (i.e. unprocessed trip), we treat this as unimodal and use trip-level attributes to construct a single section */ - if (showLabeledMode && trip?.userInput?.MODE || !trip.sections?.length) { + if (showLabeledMode && labeledModeForTrip || !trip.sections?.length) { let baseMode; - if (showLabeledMode && trip?.userInput?.MODE) { - baseMode = getBaseModeOfLabeledTrip(trip, labelOptions); + if (showLabeledMode && labeledModeForTrip) { + baseMode = getBaseModeByValue(labeledModeForTrip.value, labelOptions); } else { baseMode = getBaseModeByKey('UNPROCESSED'); } @@ -30,7 +31,7 @@ const TripSectionsDescriptives = ({ trip, showLabeledMode=false }) => { distance: formattedDistance, color: baseMode.color, icon: baseMode.icon, - text: showLabeledMode && trip.userInput?.MODE?.text, // label text only shown for labeled trips + text: showLabeledMode && labeledModeForTrip?.text, // label text only shown for labeled trips }]; } return ( diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts index b12c89738..e042531d7 100644 --- a/www/js/diary/diaryHelper.ts +++ b/www/js/diary/diaryHelper.ts @@ -66,13 +66,6 @@ export function getBaseModeByKey(motionName: BaseModeKey | MotionTypeKey | `Moti return BaseModes[key] || BaseModes.UNKNOWN; } -export function getBaseModeOfLabeledTrip(trip, labelOptions) { - const modeKey = trip?.userInput?.MODE?.value; - if (!modeKey) return null; // trip has no MODE label - const modeOption = labelOptions?.MODE?.find(opt => opt.value == modeKey); - return getBaseModeByKey(modeOption?.baseMode || "OTHER"); -} - export function getBaseModeByValue(value, labelOptions: LabelOptions) { const modeOption = labelOptions?.MODE?.find(opt => opt.value == value); return getBaseModeByKey(modeOption?.baseMode || "OTHER"); diff --git a/www/js/diary/services.js b/www/js/diary/services.js index 774273fa2..383d8d0bc 100644 --- a/www/js/diary/services.js +++ b/www/js/diary/services.js @@ -1,8 +1,6 @@ 'use strict'; import angular from 'angular'; -import { getBaseModeByKey, getBaseModeOfLabeledTrip } from './diaryHelper'; -import { SurveyOptions } from '../survey/survey'; import { getConfig } from '../config/dynamicConfig'; import { getRawEntries } from '../commHelper'; @@ -17,16 +15,6 @@ angular.module('emission.main.diary.services', ['emission.plugin.logger', timeline.data.unifiedConfirmsResults = null; timeline.UPDATE_DONE = "TIMELINE_UPDATE_DONE"; - let manualInputFactory; - $ionicPlatform.ready(function () { - getConfig().then((configObj) => { - const surveyOptKey = configObj.survey_info['trip-labels']; - const surveyOpt = SurveyOptions[surveyOptKey]; - console.log('surveyOpt in services.js is', surveyOpt); - manualInputFactory = $injector.get(surveyOpt.service); - }); - }); - // DB entries retrieved from the server have '_id', 'metadata', and 'data' fields. // This function returns a shallow copy of the obj, which flattens the // 'data' field into the top level, while also including '_id' and 'metadata.key' diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 579e3ac4f..14f003178 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -1,8 +1,11 @@ import moment from "moment"; import { getAngularService } from "../angular-react-helper"; import { displayError, logDebug } from "../plugin/logger"; -import { getBaseModeByKey, getBaseModeOfLabeledTrip } from "./diaryHelper"; +import { getBaseModeByKey, getBaseModeByValue } from "./diaryHelper"; import i18next from "i18next"; +import { UnprocessedUserInput } from "../types/diaryTypes"; +import { getLabelInputDetails, getLabelInputs } from "../survey/multilabel/confirmHelper"; +import { getNotDeletedCandidates, getUniqueEntries } from "../survey/inputMatcher"; const cachedGeojsons = new Map(); /** @@ -17,7 +20,7 @@ export function useGeojsonForTrip(trip, labelOptions, labeledMode?) { let trajectoryColor: string|null; if (labeledMode) { - trajectoryColor = getBaseModeOfLabeledTrip(trip, labelOptions)?.color; + trajectoryColor = getBaseModeByValue(labeledMode, labelOptions)?.color; } logDebug("Reading trip's " + trip.locations.length + " location points at " + (new Date())); @@ -70,47 +73,53 @@ export function compositeTrips2TimelineMap(ctList: any[], unpackPlaces?: boolean return timelineEntriesMap; } -export function populateCompositeTrips(ctList, showPlaces, labelsFactory, labelsResultMap, notesFactory, notesResultMap) { +export function populateCompositeTrips(ctList, showPlaces) { try { ctList.forEach((ct, i) => { if (showPlaces && ct.start_confirmed_place) { const cp = ct.start_confirmed_place; cp.getNextEntry = () => ctList[i]; - labelsFactory.populateInputsAndInferences(cp, labelsResultMap); - notesFactory.populateInputsAndInferences(cp, notesResultMap); } if (showPlaces && ct.end_confirmed_place) { const cp = ct.end_confirmed_place; cp.getNextEntry = () => ctList[i + 1]; - labelsFactory.populateInputsAndInferences(cp, labelsResultMap); - notesFactory.populateInputsAndInferences(cp, notesResultMap); ct.getNextEntry = () => cp; } else { ct.getNextEntry = () => ctList[i + 1]; } - labelsFactory.populateInputsAndInferences(ct, labelsResultMap); - notesFactory.populateInputsAndInferences(ct, notesResultMap); }); } catch (e) { displayError(e, i18next.t('errors.while-populating-composite')); } } +/* 'LABELS' are 1:1 - each trip or place has a single label for each label type + (e.g. 'MODE' and 'PURPOSE' for MULTILABEL configuration, or 'SURVEY' for ENKETO configuration) */ +export let unprocessedLabels: { [key: string]: UnprocessedUserInput[] } = {}; +/* 'NOTES' are 1:n - each trip or place can have any number of notes */ +export let unprocessedNotes: UnprocessedUserInput[] = []; + const getUnprocessedInputQuery = (pipelineRange) => ({ key: "write_ts", startTs: pipelineRange.end_ts - 10, endTs: moment().unix() + 10 }); -function getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, notesPromises) { - return Promise.all([...labelsPromises, ...notesPromises]).then((comboResults) => { - const labelsConfirmResults = {}; - const notesConfirmResults = {}; +function updateUnprocessedInputs(labelsPromises, notesPromises, appConfig) { + Promise.all([...labelsPromises, ...notesPromises]).then((comboResults) => { const labelResults = comboResults.slice(0, labelsPromises.length); - const notesResults = comboResults.slice(labelsPromises.length); - labelsFactory.processManualInputs(labelResults, labelsConfirmResults); - notesFactory.processManualInputs(notesResults, notesConfirmResults); - return [labelsConfirmResults, notesConfirmResults]; + const notesResults = comboResults.slice(labelsPromises.length).flat(2); + // fill in the unprocessedLabels object with the labels we just read + labelResults.forEach((r, i) => { + if (appConfig.survey_info?.['trip-labels'] == 'ENKETO') { + unprocessedLabels['SURVEY'] = r; + } else { + unprocessedLabels[getLabelInputs()[i]] = r; + } + }); + // merge the notes we just read into the existing unprocessedNotes, removing duplicates + const combinedNotes = [...unprocessedNotes, ...notesResults]; + unprocessedNotes = getUniqueEntries(getNotDeletedCandidates(combinedNotes)); }); } @@ -119,21 +128,19 @@ function getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, note * pipeline range and have not yet been pushed to the server. * @param pipelineRange an object with start_ts and end_ts representing the range of time * for which travel data has been processed through the pipeline on the server - * @param labelsFactory the Angular factory for processing labels (MultilabelService or - * EnketoTripButtonService) - * @param notesFactory the Angular factory for processing notes (EnketoNotesButtonService) +* @param appConfig the app configuration * @returns Promise an array with 1) results for labels and 2) results for notes */ -export function getLocalUnprocessedInputs(pipelineRange, labelsFactory, notesFactory) { +export async function updateLocalUnprocessedInputs(pipelineRange, appConfig) { const BEMUserCache = window['cordova'].plugins.BEMUserCache; const tq = getUnprocessedInputQuery(pipelineRange); - const labelsPromises = labelsFactory.MANUAL_KEYS.map((key) => - BEMUserCache.getMessagesForInterval(key, tq, true).then(labelsFactory.extractResult) + const labelsPromises = keysForLabelInputs(appConfig).map((key) => + BEMUserCache.getMessagesForInterval(key, tq, true) ); - const notesPromises = notesFactory.MANUAL_KEYS.map((key) => - BEMUserCache.getMessagesForInterval(key, tq, true).then(notesFactory.extractResult) + const notesPromises = keysForNotesInputs(appConfig).map((key) => + BEMUserCache.getMessagesForInterval(key, tq, true) ); - return getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, notesPromises); + await updateUnprocessedInputs(labelsPromises, notesPromises, appConfig); } /** @@ -141,21 +148,36 @@ export function getLocalUnprocessedInputs(pipelineRange, labelsFactory, notesFac * pipeline range, including those on the phone and that and have been pushed to the server but not yet processed. * @param pipelineRange an object with start_ts and end_ts representing the range of time * for which travel data has been processed through the pipeline on the server - * @param labelsFactory the Angular factory for processing labels (MultilabelService or - * EnketoTripButtonService) - * @param notesFactory the Angular factory for processing notes (EnketoNotesButtonService) + * @param appConfig the app configuration * @returns Promise an array with 1) results for labels and 2) results for notes */ -export function getAllUnprocessedInputs(pipelineRange, labelsFactory, notesFactory) { +export async function updateAllUnprocessedInputs(pipelineRange, appConfig) { const UnifiedDataLoader = getAngularService('UnifiedDataLoader'); const tq = getUnprocessedInputQuery(pipelineRange); - const labelsPromises = labelsFactory.MANUAL_KEYS.map((key) => - UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true).then(labelsFactory.extractResult) + const labelsPromises = keysForLabelInputs(appConfig).map((key) => + UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true) ); - const notesPromises = notesFactory.MANUAL_KEYS.map((key) => - UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true).then(notesFactory.extractResult) + const notesPromises = keysForNotesInputs(appConfig).map((key) => + UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true) ); - return getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, notesPromises); + await updateUnprocessedInputs(labelsPromises, notesPromises, appConfig); +} + +function keysForLabelInputs(appConfig) { + if (appConfig.survey_info?.['trip-labels'] == 'ENKETO') { + return ['manual/trip_user_input']; + } else { + return Object.values(getLabelInputDetails(appConfig)).map((inp) => inp.key); + } +} + +function keysForNotesInputs(appConfig) { + const notesKeys = []; + if (appConfig.survey_info?.buttons?.['trip-notes']) + notesKeys.push('manual/trip_addition_input'); + if (appConfig.survey_info?.buttons?.['place-notes']) + notesKeys.push('manual/place_addition_input'); + return notesKeys; } /** diff --git a/www/js/survey/enketo/AddNoteButton.tsx b/www/js/survey/enketo/AddNoteButton.tsx index 1b85c728e..08ca239cc 100644 --- a/www/js/survey/enketo/AddNoteButton.tsx +++ b/www/js/survey/enketo/AddNoteButton.tsx @@ -23,12 +23,12 @@ type Props = { const AddNoteButton = ({ timelineEntry, notesConfig, storeKey }: Props) => { const { t, i18n } = useTranslation(); const [displayLabel, setDisplayLabel] = useState(''); - const { repopulateTimelineEntry } = useContext(LabelTabContext) + const { repopulateTimelineEntry, timelineNotesMap } = useContext(LabelTabContext) useEffect(() => { let newLabel: string; const localeCode = i18n.resolvedLanguage; - if (notesConfig?.['filled-in-label'] && timelineEntry.additionsList?.length > 0) { + if (notesConfig?.['filled-in-label'] && timelineNotesMap[timelineEntry._id.$oid]?.length > 0) { newLabel = notesConfig?.['filled-in-label']?.[localeCode]; setDisplayLabel(newLabel); } else { @@ -44,7 +44,7 @@ const AddNoteButton = ({ timelineEntry, notesConfig, storeKey }: Props) => { let stop = timelineEntry.end_ts || timelineEntry.exit_ts; // if addition(s) already present on this timeline entry, `begin` where the last one left off - timelineEntry.additionsList.forEach(a => { + timelineNotesMap[timelineEntry._id.$oid]?.forEach(a => { if (a.data.end_ts > (begin || 0) && a.data.end_ts != stop) begin = a.data.end_ts; }); diff --git a/www/js/survey/enketo/UserInputButton.tsx b/www/js/survey/enketo/UserInputButton.tsx index 68d0ae944..acb129e6a 100644 --- a/www/js/survey/enketo/UserInputButton.tsx +++ b/www/js/survey/enketo/UserInputButton.tsx @@ -26,19 +26,16 @@ const UserInputButton = ({ timelineEntry }: Props) => { const [prevSurveyResponse, setPrevSurveyResponse] = useState(null); const [modalVisible, setModalVisible] = useState(false); - const { repopulateTimelineEntry } = useContext(LabelTabContext); - - const EnketoTripButtonService = getAngularService("EnketoTripButtonService"); - const etbsSingleKey = EnketoTripButtonService.SINGLE_KEY; + const { repopulateTimelineEntry, timelineLabelMap } = useContext(LabelTabContext); // the label resolved from the survey response, or null if there is no response yet const responseLabel = useMemo(() => ( - timelineEntry.userInput?.[etbsSingleKey]?.data?.label || null + timelineLabelMap[timelineEntry._id.$oid]?.['SURVEY']?.data?.label || null ), [timelineEntry]); function launchUserInputSurvey() { logDebug('UserInputButton: About to launch survey'); - const prevResponse = timelineEntry.userInput?.[etbsSingleKey]; + const prevResponse = timelineLabelMap[timelineEntry._id.$oid]?.['SURVEY']; setPrevSurveyResponse(prevResponse?.data?.xmlResponse); setModalVisible(true); } diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index c6c8ed61c..6fdc01d90 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -1,15 +1,17 @@ import { logDebug, displayErrorMsg } from "../plugin/logger" import { DateTime } from "luxon"; -import { UserInput, Trip, TlEntry } from "../types/diaryTypes"; +import { UnprocessedUserInput, Trip, TlEntry } from "../types/diaryTypes"; +import { unprocessedLabels, unprocessedNotes } from "../diary/timelineHelper"; +import { LabelOption, MultilabelKey, getLabelInputDetails, getLabelInputs, getLabelOptions, inputType2retKey, labelKeyToRichMode, labelOptions } from "./multilabel/confirmHelper"; const EPOCH_MAXIMUM = 2**31 - 1; export const fmtTs = (ts_in_secs: number, tz: string): string | null => DateTime.fromSeconds(ts_in_secs, {zone : tz}).toISO(); -export const printUserInput = (ui: UserInput): string => `${fmtTs(ui.data.start_ts, ui.metadata.time_zone)} (${ui.data.start_ts}) -> +export const printUserInput = (ui: UnprocessedUserInput): string => `${fmtTs(ui.data.start_ts, ui.metadata.time_zone)} (${ui.data.start_ts}) -> ${fmtTs(ui.data.end_ts, ui.metadata.time_zone)} (${ui.data.end_ts}) ${ui.data.label} logged at ${ui.metadata.write_ts}`; -export const validUserInputForDraftTrip = (trip: Trip, userInput: UserInput, logsEnabled: boolean): boolean => { +export const validUserInputForDraftTrip = (trip: Trip, userInput: UnprocessedUserInput, logsEnabled: boolean): boolean => { if(logsEnabled) { logDebug(`Draft trip: comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} @@ -27,7 +29,7 @@ export const validUserInputForDraftTrip = (trip: Trip, userInput: UserInput, log || -(userInput.data.start_ts - trip.start_ts) <= 15 * 60) && userInput.data.end_ts <= trip.end_ts; } -export const validUserInputForTimelineEntry = (tlEntry: TlEntry, userInput: UserInput, logsEnabled: boolean): boolean => { +export const validUserInputForTimelineEntry = (tlEntry: TlEntry, userInput: UnprocessedUserInput, logsEnabled: boolean): boolean => { if (!tlEntry.origin_key) return false; if (tlEntry.origin_key.includes('UNPROCESSED')) return validUserInputForDraftTrip(tlEntry, userInput, logsEnabled); @@ -101,7 +103,7 @@ export const validUserInputForTimelineEntry = (tlEntry: TlEntry, userInput: User } // parallels get_not_deleted_candidates() in trip_queries.py -export const getNotDeletedCandidates = (candidates: UserInput[]): UserInput[] => { +export const getNotDeletedCandidates = (candidates: UnprocessedUserInput[]): UnprocessedUserInput[] => { console.log('getNotDeletedCandidates called with ' + candidates.length + ' candidates'); // We want to retain all ACTIVE entries that have not been DELETED @@ -115,7 +117,7 @@ export const getNotDeletedCandidates = (candidates: UserInput[]): UserInput[] => return notDeletedActive; } -export const getUserInputForTrip = (trip: TlEntry, nextTrip: any, userInputList: UserInput[]): undefined | UserInput => { +export const getUserInputForTrip = (trip: TlEntry, userInputList: UnprocessedUserInput[]): undefined | UnprocessedUserInput => { const logsEnabled = userInputList?.length < 20; if (userInputList === undefined) { logDebug("In getUserInputForTrip, no user input, returning undefined"); @@ -147,7 +149,7 @@ export const getUserInputForTrip = (trip: TlEntry, nextTrip: any, userInputList } // return array of matching additions for a trip or place -export const getAdditionsForTimelineEntry = (entry: TlEntry, additionsList: UserInput[]): UserInput[] => { +export const getAdditionsForTimelineEntry = (entry: TlEntry, additionsList: UnprocessedUserInput[]): UnprocessedUserInput[] => { const logsEnabled = additionsList?.length < 20; if (additionsList === undefined) { @@ -193,3 +195,55 @@ export const getUniqueEntries = (combinedList) => { }); return Array.from(uniqueMap.values()); } + +/** + * @param allEntries the array of timeline entries to map inputs to + * @returns an array containing: (i) an object mapping timeline entry IDs to label inputs, + * and (ii) an object mapping timeline entry IDs to note inputs + */ +export function mapInputsToTimelineEntries(allEntries: TlEntry[], appConfig): [{ [k: string]: { [k: string]: UnprocessedUserInput | LabelOption } }, { [k: string]: UnprocessedUserInput[] }] { + const timelineLabelMap: { [k: string]: { [k: string]: UnprocessedUserInput | LabelOption } } = {}; + const timelineNotesMap: { [k: string]: UnprocessedUserInput[] } = {}; + + allEntries.forEach((tlEntry, i) => { + if (appConfig?.survey_info?.['trip-labels'] == 'ENKETO') { + // ENKETO configuration: just look for the 'SURVEY' key in the unprocessedInputs + const userInputForTrip = getUserInputForTrip(tlEntry, unprocessedLabels['SURVEY']); + if (userInputForTrip) { + timelineLabelMap[tlEntry._id.$oid] = { SURVEY: userInputForTrip }; + } + } else { + // MULTILABEL configuration: use the label inputs from the labelOptions to determine which + // keys to look for in the unprocessedInputs + const labelsForTrip: { [k: string]: LabelOption } = {}; + Object.keys(getLabelInputDetails()).forEach((label: MultilabelKey) => { + // Check unprocessed labels first since they are more recent + const userInputForTrip = getUserInputForTrip(tlEntry, unprocessedLabels[label]); + if (userInputForTrip) { + labelsForTrip[label] = labelOptions[label].find((opt: LabelOption) => opt.value == userInputForTrip.data.label); + } else { + const processedLabelValue = tlEntry.user_input?.[inputType2retKey(label)]; + labelsForTrip[label] = labelOptions[label].find((opt: LabelOption) => opt.value == processedLabelValue); + } + }); + if (Object.keys(labelsForTrip).length) { + timelineLabelMap[tlEntry._id.$oid] = labelsForTrip; + } + } + }); + + if ( + appConfig?.survey_info?.buttons?.['trip-notes'] || + appConfig?.survey_info?.buttons?.['place-notes'] + ) { + // trip-level or place-level notes are configured, so we need to match additions too + allEntries.forEach((tlEntry, i) => { + const additionsForTrip = getAdditionsForTimelineEntry(tlEntry, unprocessedNotes); + if (additionsForTrip?.length) { + timelineNotesMap[tlEntry._id.$oid] = additionsForTrip; + } + }); + } + + return [timelineLabelMap, timelineNotesMap]; +} diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index ca71721a7..1021039a5 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -10,12 +10,14 @@ import DiaryButton from "../../components/DiaryButton"; import { useTranslation } from "react-i18next"; import { LabelTabContext } from "../../diary/LabelTab"; import { displayErrorMsg, logDebug } from "../../plugin/logger"; -import { getLabelInputDetails, getLabelInputs, readableLabelToKey } from "./confirmHelper"; +import { getLabelInputDetails, getLabelInputs, inferFinalLabels, labelInputDetailsForTrip, labelKeyToRichMode, readableLabelToKey } from "./confirmHelper"; +import useAppConfig from "../../useAppConfig"; const MultilabelButtonGroup = ({ trip, buttonsInline=false }) => { const { colors } = useTheme(); const { t } = useTranslation(); - const { repopulateTimelineEntry, labelOptions } = useContext(LabelTabContext); + const appConfig = useAppConfig(); + const { repopulateTimelineEntry, labelOptions, timelineLabelMap } = useContext(LabelTabContext); const { height: windowHeight } = useWindowDimensions(); // modal visible for which input type? (mode or purpose or replaced_mode, null if not visible) @@ -23,14 +25,16 @@ const MultilabelButtonGroup = ({ trip, buttonsInline=false }) => { const [otherLabel, setOtherLabel] = useState(null); const chosenLabel = useMemo(() => { if (otherLabel != null) return 'other'; - return trip.userInput[modalVisibleFor]?.value + return timelineLabelMap[trip._id.$oid]?.[modalVisibleFor]?.value; }, [modalVisibleFor, otherLabel]); // to mark 'inferred' labels as 'confirmed'; turn yellow labels blue function verifyTrip() { + const inferredLabelsForTrip = inferFinalLabels(trip, timelineLabelMap[trip._id.$oid]); for (const inputType of getLabelInputs()) { - const inferred = trip.finalInference[inputType]; - if (inferred?.value && !trip.userInput[inputType]) { + const inferred = inferredLabelsForTrip?.[inputType]; + // if the is an inferred label that is not already confirmed, confirm it now by storing it + if (inferred?.value && !timelineLabelMap[trip._id.$oid]?.[inputType]) { store(inputType, inferred.value, false); } } @@ -71,14 +75,14 @@ const MultilabelButtonGroup = ({ trip, buttonsInline=false }) => { }); } - const inputKeys = Object.keys(trip.inputDetails); + const tripInputDetails = labelInputDetailsForTrip(timelineLabelMap[trip._id.$oid], appConfig); return (<> - {inputKeys.map((key, i) => { - const input = trip.inputDetails[key]; - const inputIsConfirmed = trip.userInput[input.name]; - const inputIsInferred = trip.finalInference[input.name]; + {Object.keys(tripInputDetails).map((key, i) => { + const input = tripInputDetails[key]; + const inputIsConfirmed = timelineLabelMap[trip._id.$oid]?.[input.name]; + const inputIsInferred = inferFinalLabels(trip, timelineLabelMap[trip._id.$oid])[input.name]; let fillColor, textColor, borderColor; if (inputIsConfirmed) { fillColor = colors.primary; diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index b668669bf..5bb092c6b 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -13,22 +13,24 @@ type InputDetails = { key: string, } }; -export type LabelOptions = { - [k in T]: { - value: string, - baseMode: string, - met?: {range: any[], mets: number} - met_equivalent?: string, - kgCo2PerKm: number, - text?: string, - }[] +export type LabelOption = { + value: string, + baseMode: string, + met?: {range: any[], mets: number} + met_equivalent?: string, + kgCo2PerKm: number, + text?: string, +}; +export type MultilabelKey = 'MODE'|'PURPOSE'|'REPLACED_MODE'; +export type LabelOptions = { + [k in T]: LabelOption[] } & { translations: { [lang: string]: { [translationKey: string]: string } }}; let appConfig; -export let labelOptions: LabelOptions<'MODE'|'PURPOSE'|'REPLACED_MODE'>; -export let inputDetails: InputDetails<'MODE'|'PURPOSE'|'REPLACED_MODE'>; +export let labelOptions: LabelOptions; +export let inputDetails: InputDetails; export async function getLabelOptions(appConfigParam?) { if (appConfigParam) appConfig = appConfigParam; @@ -94,6 +96,22 @@ export function getLabelInputDetails(appConfigParam?) { return inputDetails; } +export function labelInputDetailsForTrip(userInputForTrip, appConfigParam?) { + if (appConfigParam) appConfig = appConfigParam; + if (appConfig.intro.mode_studied) { + if (userInputForTrip?.['MODE']?.value == appConfig.intro.mode_studied) { + logDebug("Found trip labeled with mode of study "+appConfig.intro.mode_studied+". Needs REPLACED_MODE"); + return getLabelInputDetails(); + } else { + logDebug("Found trip not labeled with mode of study "+appConfig.intro.mode_studied+". Doesn't need REPLACED_MODE"); + return baseLabelInputDetails; + } + } else { + logDebug("No mode of study, so there is no REPLACED_MODE label option"); + return getLabelInputDetails(); + } +} + export const getLabelInputs = () => Object.keys(getLabelInputDetails()); export const getBaseLabelInputs = () => Object.keys(baseLabelInputDetails); @@ -117,3 +135,64 @@ export const getFakeEntry = (otherValue) => ({ export const labelKeyToRichMode = (labelKey: string) => labelOptions?.MODE?.find(m => m.value == labelKey)?.text || labelKeyToReadable(labelKey); + +/* manual/mode_confirm becomes mode_confirm */ +export const inputType2retKey = (inputType) => getLabelInputDetails()[inputType].key.split('/')[1]; + +export function inferFinalLabels(trip, userInputForTrip) { + // Deep copy the possibility tuples + let labelsList = []; + if (trip.inferred_labels) { + labelsList = JSON.parse(JSON.stringify(trip.inferred_labels)); + } + + // Capture the level of certainty so we can reconstruct it later + const totalCertainty = labelsList.map((item) => item.p).reduce((item, rest) => item + rest, 0); + + // Filter out the tuples that are inconsistent with existing green labels + for (const inputType of getLabelInputs()) { + const userInput = userInputForTrip?.[inputType]; + if (userInput) { + const retKey = inputType2retKey(inputType); + labelsList = labelsList.filter((item) => item.labels[retKey] == userInput.value); + } + } + + const finalInference = {}; + + // Red labels if we have no possibilities left + if (labelsList.length == 0) { + for (const inputType of getLabelInputs()) { + finalInference[inputType] = undefined; + } + return finalInference; + } else { + // Normalize probabilities to previous level of certainty + const certaintyScalar = + totalCertainty / labelsList.map((item) => item.p).reduce((item, rest) => item + rest); + labelsList.forEach((item) => (item.p *= certaintyScalar)); + + for (const inputType of getLabelInputs()) { + // For each label type, find the most probable value by binning by label value and summing + const retKey = inputType2retKey(inputType); + let valueProbs = new Map(); + for (const tuple of labelsList) { + const labelValue = tuple.labels[retKey]; + if (!valueProbs.has(labelValue)) valueProbs.set(labelValue, 0); + valueProbs.set(labelValue, valueProbs.get(labelValue) + tuple.p); + } + let max = { p: 0, labelValue: undefined }; + for (const [thisLabelValue, thisP] of valueProbs) { + // In the case of a tie, keep the label with earlier first appearance in the labelsList (we used a Map to preserve this order) + if (thisP > max.p) max = { p: thisP, labelValue: thisLabelValue }; + } + + // Display a label as red if its most probable inferred value has a probability less than or equal to the trip's confidence_threshold + // Fails safe if confidence_threshold doesn't exist + if (max.p <= trip.confidence_threshold) max.labelValue = undefined; + + finalInference[inputType] = labelOptions[inputType].find((opt) => opt.value == max.labelValue); + } + return finalInference; + } +} diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index f4d53a4a9..150f829a3 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -43,7 +43,7 @@ export type CompositeTrip = { start_local_dt: LocalDt, start_place: {$oid: string}, start_ts: number, - user_input: UserInput, + user_input: UnprocessedUserInput, } /* These properties aren't received from the server, but are derived from the above properties. @@ -69,7 +69,7 @@ export type PopulatedTrip = CompositeTrip & { finalInference?: any, // TODO geojson?: any, // TODO getNextEntry?: () => PopulatedTrip | ConfirmedPlace, - userInput?: UserInput, + userInput?: UnprocessedUserInput, verifiability?: string, } @@ -79,7 +79,7 @@ export type SectionSummary = { duration: {[k: MotionTypeKey | BaseModeKey]: number}, } -export type UserInput = { +export type UnprocessedUserInput = { data: { end_ts: number, start_ts: number @@ -117,6 +117,7 @@ export type Trip = { } export type TlEntry = { + _id: { $oid: string }, key: string, origin_key: string, start_ts: number, From 3855a039c5ed2ba9f090455afc1a6b9090bb33ea Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Sun, 29 Oct 2023 20:14:22 -0400 Subject: [PATCH 230/850] remove btn services and survey.ts These are not needed since f7716cf791fbec89625cdb7d168ab8b148ab3f38 and 4d48aeebd2b1734e3d08dc5de6726f1faf4499bc --- www/index.js | 3 - .../survey/enketo/enketo-add-note-button.js | 105 --------- www/js/survey/enketo/enketo-trip-button.js | 110 ---------- www/js/survey/multilabel/multi-label-ui.js | 204 ------------------ www/js/survey/survey.ts | 16 -- 5 files changed, 438 deletions(-) delete mode 100644 www/js/survey/enketo/enketo-add-note-button.js delete mode 100644 www/js/survey/enketo/enketo-trip-button.js delete mode 100644 www/js/survey/multilabel/multi-label-ui.js delete mode 100644 www/js/survey/survey.ts diff --git a/www/index.js b/www/index.js index 1e90692f1..67c6d34bb 100644 --- a/www/index.js +++ b/www/index.js @@ -15,12 +15,9 @@ import './js/controllers.js'; import './js/services.js'; import './js/i18n-utils.js'; import './js/main.js'; -import './js/survey/multilabel/multi-label-ui.js'; import './js/diary.js'; import './js/diary/services.js'; import './js/survey/enketo/answer.js'; -import './js/survey/enketo/enketo-trip-button.js'; -import './js/survey/enketo/enketo-add-note-button.js'; import './js/control/emailService.js'; import './js/metrics-factory.js'; import './js/metrics-mappings.js'; diff --git a/www/js/survey/enketo/enketo-add-note-button.js b/www/js/survey/enketo/enketo-add-note-button.js deleted file mode 100644 index 2585638c9..000000000 --- a/www/js/survey/enketo/enketo-add-note-button.js +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Directive to display a survey to add notes to a timeline entry (trip or place) - */ - -import angular from 'angular'; -import { getAdditionsForTimelineEntry, getUniqueEntries } from '../inputMatcher'; - -angular.module('emission.survey.enketo.add-note-button', - ['emission.services', - 'emission.survey.enketo.answer']) -.factory("EnketoNotesButtonService", function(EnketoSurveyAnswer, Logger, $timeout) { - var enbs = {}; - console.log("Creating EnketoNotesButtonService"); - enbs.SINGLE_KEY="NOTES"; - enbs.MANUAL_KEYS = []; - - /** - * Set the keys for trip and/or place additions whichever will be enabled, - * and sets the name of the surveys they will use. - */ - enbs.initConfig = function(tripSurveyName, placeSurveyName) { - enbs.tripSurveyName = tripSurveyName; - if (tripSurveyName) { - enbs.MANUAL_KEYS.push("manual/trip_addition_input") - } - enbs.placeSurveyName = placeSurveyName; - if (placeSurveyName) { - enbs.MANUAL_KEYS.push("manual/place_addition_input") - } - } - - /** - * Embed 'inputType' to the timelineEntry. - */ - enbs.extractResult = function(results) { - const resultsPromises = [EnketoSurveyAnswer.filterByNameAndVersion(enbs.timelineEntrySurveyName, results)]; - if (enbs.timelineEntrySurveyName != enbs.placeSurveyName) { - resultsPromises.push(EnketoSurveyAnswer.filterByNameAndVersion(enbs.placeSurveyName, results)); - } - return Promise.all(resultsPromises); - }; - - enbs.processManualInputs = function(manualResults, resultMap) { - console.log("ENKETO: processManualInputs with ", manualResults, " and ", resultMap); - const surveyResults = manualResults.flat(2); - resultMap[enbs.SINGLE_KEY] = surveyResults; - } - - enbs.populateInputsAndInferences = function(timelineEntry, manualResultMap) { - console.log("ENKETO: populating timelineEntry,", timelineEntry, " with result map", manualResultMap); - if (angular.isDefined(timelineEntry)) { - // initialize additions array as empty if it doesn't already exist - timelineEntry.additionsList ||= []; - enbs.populateManualInputs(timelineEntry, enbs.SINGLE_KEY, manualResultMap[enbs.SINGLE_KEY]); - } else { - console.log("timelineEntry information not yet bound, skipping fill"); - } - } - - /** - * Embed 'inputType' to the timelineEntry - * This is the version that is called from the list, which focuses only on - * manual inputs. It also sets some additional values - */ - enbs.populateManualInputs = function (timelineEntry, inputType, inputList) { - // there is not necessarily just one addition per timeline entry, - // so unlike user inputs, we don't want to replace the server entry with - // the unprocessed entry - // but we also don't want to blindly append the unprocessed entry; what - // if it was a deletion. - // what we really want to do is to merge the unprocessed and processed entries - // taking deletion into account - // one option for that is to just combine the processed and unprocessed entries - // into a single list - // note that this is not necessarily the most performant approach, since we will - // be re-matching entries that have already been matched on the server - // but the number of matched entries is likely to be small, so we can live - // with the performance for now - const unprocessedAdditions = getAdditionsForTimelineEntry(timelineEntry, inputList); - const combinedPotentialAdditionList = timelineEntry.additions.concat(unprocessedAdditions); - const dedupedList = getUniqueEntries(combinedPotentialAdditionList); - Logger.log("After combining unprocessed ("+unprocessedAdditions.length+ - ") with server ("+timelineEntry.additions.length+ - ") for a combined ("+combinedPotentialAdditionList.length+ - "), deduped entries are ("+dedupedList.length+")"); - - enbs.populateInput(timelineEntry.additionsList, inputType, dedupedList); - // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); - enbs.editingTrip = angular.undefined; - } - - /** - * Insert the given userInputLabel into the given inputType's slot in inputField - */ - enbs.populateInput = function(timelineEntryField, inputType, userInputEntry) { - if (angular.isDefined(userInputEntry)) { - timelineEntryField.length = 0; - userInputEntry.forEach(ta => { - timelineEntryField.push(ta); - }); - } - } - - return enbs; -}); diff --git a/www/js/survey/enketo/enketo-trip-button.js b/www/js/survey/enketo/enketo-trip-button.js deleted file mode 100644 index 536ce6c34..000000000 --- a/www/js/survey/enketo/enketo-trip-button.js +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Directive to display a survey for each trip - * Assumptions: - * - The directive is embedded within an ion-view - * - The controller for the ion-view has a function called - * 'recomputeListEntries` which modifies the *list* of trips and places - * as necessary. An example with the label view is removing the labeled trips from - * the "toLabel" filter. Function can be a no-op (for example, in the diary view) - * - The view is associated with a state which we can record in the client stats. - * - The directive implements a `verifyTrip` function that can be invoked by - * other components. - */ - -import angular from 'angular'; -import { getUserInputForTrip } from '../inputMatcher'; - -angular.module('emission.survey.enketo.trip.button', - ['emission.survey.enketo.answer']) -.factory("EnketoTripButtonService", function(EnketoSurveyAnswer, Logger, $timeout) { - var etbs = {}; - console.log("Creating EnketoTripButtonService"); - etbs.key = "manual/trip_user_input"; - etbs.SINGLE_KEY="SURVEY"; - etbs.MANUAL_KEYS = [etbs.key]; - - /** - * Embed 'inputType' to the trip. - */ - etbs.extractResult = (results) => EnketoSurveyAnswer.filterByNameAndVersion('TripConfirmSurvey', results); - - etbs.processManualInputs = function(manualResults, resultMap) { - if (manualResults.length > 1) { - Logger.displayError("Found "+manualResults.length+" results expected 1", manualResults); - } else { - console.log("ENKETO: processManualInputs with ", manualResults, " and ", resultMap); - const surveyResult = manualResults[0]; - resultMap[etbs.SINGLE_KEY] = surveyResult; - } - } - - etbs.populateInputsAndInferences = function(trip, manualResultMap) { - console.log("ENKETO: populating trip,", trip, " with result map", manualResultMap); - if (angular.isDefined(trip)) { - // console.log("Expectation: "+JSON.stringify(trip.expectation)); - // console.log("Inferred labels from server: "+JSON.stringify(trip.inferred_labels)); - trip.userInput = {}; - etbs.populateManualInputs(trip, trip.getNextEntry(), etbs.SINGLE_KEY, - manualResultMap[etbs.SINGLE_KEY]); - trip.finalInference = {}; - etbs.inferFinalLabels(trip); - etbs.updateVerifiability(trip); - } else { - console.log("Trip information not yet bound, skipping fill"); - } - } - - /** - * Embed 'inputType' to the trip - * This is the version that is called from the list, which focuses only on - * manual inputs. It also sets some additional values - */ - etbs.populateManualInputs = function (trip, nextTrip, inputType, inputList) { - // Check unprocessed labels first since they are more recent - const unprocessedLabelEntry = getUserInputForTrip(trip, nextTrip,inputList); - var userInputEntry = unprocessedLabelEntry; - if (!angular.isDefined(userInputEntry)) { - userInputEntry = trip.user_input?.[etbs.inputType2retKey(inputType)]; - } - etbs.populateInput(trip.userInput, inputType, userInputEntry); - // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); - etbs.editingTrip = angular.undefined; - } - - /** - * Insert the given userInputLabel into the given inputType's slot in inputField - */ - etbs.populateInput = function(tripField, inputType, userInputEntry) { - if (angular.isDefined(userInputEntry)) { - tripField[inputType] = userInputEntry; - } - } - - /** - * Given the list of possible label tuples we've been sent and what the user has already input for the trip, choose the best labels to actually present to the user. - * The algorithm below operationalizes these principles: - * - Never consider label tuples that contradict a green label - * - Obey "conservation of uncertainty": the sum of probabilities after filtering by green labels must equal the sum of probabilities before - * - After filtering, predict the most likely choices at the level of individual labels, not label tuples - * - Never show user yellow labels that have a lower probability of being correct than confidenceThreshold - */ - etbs.inferFinalLabels = function(trip) { - // currently a NOP since we don't have any other trip properties - return; - } - - /** - * MODE (becomes manual/mode_confirm) becomes mode_confirm - */ - etbs.inputType2retKey = function(inputType) { - return etbs.key.split("/")[1]; - } - - etbs.updateVerifiability = function(trip) { - // currently a NOP since we don't have any other trip properties - trip.verifiability = "cannot-verify"; - return; - } - - return etbs; -}); diff --git a/www/js/survey/multilabel/multi-label-ui.js b/www/js/survey/multilabel/multi-label-ui.js deleted file mode 100644 index 28ab5ee12..000000000 --- a/www/js/survey/multilabel/multi-label-ui.js +++ /dev/null @@ -1,204 +0,0 @@ -import angular from 'angular'; -import { baseLabelInputDetails, getBaseLabelInputs, getFakeEntry, getLabelInputDetails, getLabelInputs, getLabelOptions } from './confirmHelper'; -import { getConfig } from '../../config/dynamicConfig'; -import { getUserInputForTrip } from '../inputMatcher'; - -angular.module('emission.survey.multilabel.buttons', []) - -.factory("MultiLabelService", function($rootScope, $timeout, $ionicPlatform, Logger) { - var mls = {}; - console.log("Creating MultiLabelService"); - mls.init = function(config) { - Logger.log("About to initialize the MultiLabelService"); - mls.ui_config = config; - getLabelOptions(config).then((inputParams) => mls.inputParams = inputParams); - mls.MANUAL_KEYS = Object.values(getLabelInputDetails(config)).map((inp) => inp.key); - Logger.log("finished initializing the MultiLabelService"); - }; - - $ionicPlatform.ready().then(function() { - Logger.log("UI_CONFIG: about to call configReady function in MultiLabelService"); - getConfig().then((newConfig) => { - mls.init(newConfig); - }).catch((err) => Logger.displayError("Error while handling config in MultiLabelService", err)); - }); - - /** - * Embed 'inputType' to the trip. - */ - - mls.extractResult = (results) => results; - - mls.processManualInputs = function(manualResults, resultMap) { - var mrString = 'unprocessed manual inputs ' - + manualResults.map(function(item, index) { - return ` ${item.length} ${getLabelInputs()[index]}`; - }); - console.log(mrString); - manualResults.forEach(function(mr, index) { - resultMap[getLabelInputs()[index]] = mr; - }); - } - - mls.populateInputsAndInferences = function(trip, manualResultMap) { - if (angular.isDefined(trip)) { - // console.log("Expectation: "+JSON.stringify(trip.expectation)); - // console.log("Inferred labels from server: "+JSON.stringify(trip.inferred_labels)); - trip.userInput = {}; - getLabelInputs().forEach(function(item, index) { - mls.populateManualInputs(trip, trip.nextTrip, item, - manualResultMap[item]); - }); - trip.finalInference = {}; - mls.inferFinalLabels(trip); - mls.expandInputsIfNecessary(trip); - mls.updateVerifiability(trip); - } else { - console.log("Trip information not yet bound, skipping fill"); - } - } - - /** - * Embed 'inputType' to the trip - * This is the version that is called from the list, which focuses only on - * manual inputs. It also sets some additional values - */ - mls.populateManualInputs = function (trip, nextTrip, inputType, inputList) { - // Check unprocessed labels first since they are more recent - const unprocessedLabelEntry = getUserInputForTrip(trip, nextTrip, inputList); - var userInputLabel = unprocessedLabelEntry? unprocessedLabelEntry.data.label : undefined; - if (!angular.isDefined(userInputLabel)) { - userInputLabel = trip.user_input?.[mls.inputType2retKey(inputType)]; - } - mls.populateInput(trip.userInput, inputType, userInputLabel); - // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); - mls.editingTrip = angular.undefined; - } - - /** - * Insert the given userInputLabel into the given inputType's slot in inputField - */ - mls.populateInput = function(tripField, inputType, userInputLabel) { - if (angular.isDefined(userInputLabel)) { - console.log("populateInput: looking in map of "+inputType+" for userInputLabel"+userInputLabel); - var userInputEntry = mls.inputParams[inputType].find(o => o.value == userInputLabel); - if (!angular.isDefined(userInputEntry)) { - userInputEntry = getFakeEntry(userInputLabel); - mls.inputParams[inputType].push(userInputEntry); - } - console.log("Mapped label "+userInputLabel+" to entry "+JSON.stringify(userInputEntry)); - tripField[inputType] = userInputEntry; - } - } - - /** - * Given the list of possible label tuples we've been sent and what the user has already input for the trip, choose the best labels to actually present to the user. - * The algorithm below operationalizes these principles: - * - Never consider label tuples that contradict a green label - * - Obey "conservation of uncertainty": the sum of probabilities after filtering by green labels must equal the sum of probabilities before - * - After filtering, predict the most likely choices at the level of individual labels, not label tuples - * - Never show user yellow labels that have a lower probability of being correct than confidenceThreshold - */ - mls.inferFinalLabels = function(trip) { - // Deep copy the possibility tuples - let labelsList = []; - if (angular.isDefined(trip.inferred_labels)) { - labelsList = JSON.parse(JSON.stringify(trip.inferred_labels)); - } - - // Capture the level of certainty so we can reconstruct it later - const totalCertainty = labelsList.map(item => item.p).reduce(((item, rest) => item + rest), 0); - - // Filter out the tuples that are inconsistent with existing green labels - for (const inputType of getLabelInputs()) { - const userInput = trip.userInput[inputType]; - if (userInput) { - const retKey = mls.inputType2retKey(inputType); - labelsList = labelsList.filter(item => item.labels[retKey] == userInput.value); - } - } - - // Red labels if we have no possibilities left - if (labelsList.length == 0) { - for (const inputType of getLabelInputs()) mls.populateInput(trip.finalInference, inputType, undefined); - } - else { - // Normalize probabilities to previous level of certainty - const certaintyScalar = totalCertainty/labelsList.map(item => item.p).reduce((item, rest) => item + rest); - labelsList.forEach(item => item.p*=certaintyScalar); - - for (const inputType of getLabelInputs()) { - // For each label type, find the most probable value by binning by label value and summing - const retKey = mls.inputType2retKey(inputType); - let valueProbs = new Map(); - for (const tuple of labelsList) { - const labelValue = tuple.labels[retKey]; - if (!valueProbs.has(labelValue)) valueProbs.set(labelValue, 0); - valueProbs.set(labelValue, valueProbs.get(labelValue) + tuple.p); - } - let max = {p: 0, labelValue: undefined}; - for (const [thisLabelValue, thisP] of valueProbs) { - // In the case of a tie, keep the label with earlier first appearance in the labelsList (we used a Map to preserve this order) - if (thisP > max.p) max = {p: thisP, labelValue: thisLabelValue}; - } - - // Display a label as red if its most probable inferred value has a probability less than or equal to the trip's confidence_threshold - // Fails safe if confidence_threshold doesn't exist - if (max.p <= trip.confidence_threshold) max.labelValue = undefined; - - mls.populateInput(trip.finalInference, inputType, max.labelValue); - } - } - } - - /* - * Uses either 2 or 3 labels depending on the type of install (program vs. study) - * and the primary mode. - * This used to be in the controller, where it really should be, but we had - * to move it to the service because we need to invoke it from the list view - * as part of filtering "To Label" entries. - * - * TODO: Move it back later after the diary vs. label unification - */ - mls.expandInputsIfNecessary = function(trip) { - console.log("Reading expanding inputs for ", trip); - const inputValue = trip.userInput["MODE"]? trip.userInput["MODE"].value : undefined; - console.log("Experimenting with expanding inputs for mode "+inputValue); - if (mls.ui_config.intro.mode_studied) { - if (inputValue == mls.ui_config.intro.mode_studied) { - Logger.log("Found "+mls.ui_config.intro.mode_studied+" mode in a program, displaying full details"); - trip.inputDetails = getLabelInputDetails(); - trip.INPUTS = getLabelInputs(); - } else { - Logger.log("Found non "+mls.ui_config.intro.mode_studied+" mode in a program, displaying base details"); - trip.inputDetails = baseLabelInputDetails; - trip.INPUTS = getBaseLabelInputs(); - } - } else { - Logger.log("study, not program, displaying full details"); - trip.INPUTS = getLabelInputs(); - trip.inputDetails = getLabelInputDetails(); - } - } - - /** - * MODE (becomes manual/mode_confirm) becomes mode_confirm - */ - mls.inputType2retKey = function(inputType) { - return getLabelInputDetails()[inputType].key.split("/")[1]; - } - - mls.updateVerifiability = function(trip) { - var allGreen = true; - var someYellow = false; - for (const inputType of trip.INPUTS) { - const green = trip.userInput[inputType]; - const yellow = trip.finalInference[inputType] && !green; - if (yellow) someYellow = true; - if (!green) allGreen = false; - } - trip.verifiability = someYellow ? "can-verify" : (allGreen ? "already-verified" : "cannot-verify"); - } - - return mls; -}); diff --git a/www/js/survey/survey.ts b/www/js/survey/survey.ts deleted file mode 100644 index 66f662082..000000000 --- a/www/js/survey/survey.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { configuredFilters as multilabelConfiguredFilters } from "./multilabel/infinite_scroll_filters"; -import { configuredFilters as enketoConfiguredFilters } from "./enketo/infinite_scroll_filters"; - -type SurveyOption = { filter: Array, service: string, elementTag: string } -export const SurveyOptions: {[key: string]: SurveyOption} = { - MULTILABEL: { - filter: multilabelConfiguredFilters, - service: "MultiLabelService", - elementTag: "multilabel" - }, - ENKETO: { - filter: enketoConfiguredFilters, - service: "EnketoTripButtonService", - elementTag: "enketo-trip-button" - } -} From 40d3f75392a24653dee78d9fddb7adc6c020fe18 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Sun, 29 Oct 2023 20:33:50 -0400 Subject: [PATCH 231/850] add back verifiability with verifiabilityForTrip() As a result of 4d48aeebd2b1734e3d08dc5de6726f1faf4499bc, trips are not populated with the 'verifiability' property. We can handle this with a function call --- www/js/survey/multilabel/MultiLabelButtonGroup.tsx | 4 ++-- www/js/survey/multilabel/confirmHelper.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index 1021039a5..54c9be531 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -10,7 +10,7 @@ import DiaryButton from "../../components/DiaryButton"; import { useTranslation } from "react-i18next"; import { LabelTabContext } from "../../diary/LabelTab"; import { displayErrorMsg, logDebug } from "../../plugin/logger"; -import { getLabelInputDetails, getLabelInputs, inferFinalLabels, labelInputDetailsForTrip, labelKeyToRichMode, readableLabelToKey } from "./confirmHelper"; +import { getLabelInputDetails, getLabelInputs, inferFinalLabels, labelInputDetailsForTrip, labelKeyToRichMode, readableLabelToKey, verifiabilityForTrip } from "./confirmHelper"; import useAppConfig from "../../useAppConfig"; const MultilabelButtonGroup = ({ trip, buttonsInline=false }) => { @@ -104,7 +104,7 @@ const MultilabelButtonGroup = ({ trip, buttonsInline=false }) => { ) })} - {trip.verifiability === 'can-verify' && ( + {verifiabilityForTrip(trip, timelineLabelMap[trip._id.$oid]) == 'can-verify' && ( /* manual/mode_confirm becomes mode_confirm */ export const inputType2retKey = (inputType) => getLabelInputDetails()[inputType].key.split('/')[1]; +export function verifiabilityForTrip(trip, userInputForTrip) { + let allConfirmed = true; + let someInferred = false; + const inputsForTrip = Object.keys(labelInputDetailsForTrip(userInputForTrip)); + for (const inputType of inputsForTrip) { + const confirmed = userInputForTrip[inputType]; + const inferred = inferFinalLabels(trip, userInputForTrip)[inputType] && !confirmed; + if (inferred) someInferred = true; + if (!confirmed) allConfirmed = false; + } + return someInferred ? 'can-verify' : allConfirmed ? 'already-verified' : 'cannot-verify'; +} + export function inferFinalLabels(trip, userInputForTrip) { // Deep copy the possibility tuples let labelsList = []; From 49ff3853d7580cd5a728d0e2d9de8a80e5ccca03 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Sun, 29 Oct 2023 21:30:02 -0400 Subject: [PATCH 232/850] remove populateCompositeTrips; update types The last property on the trip/place objects that differs between phone and server is 'getNextEntry' called from the input matching function. We can instead pass 'nextEntry' as a second parameter to the input matching functions. This allows us to significantly simplify our typings and ensure that 'CompositeTrip' and 'ConfirmedPlace' here will be the same as they are on the server. Hooray! -- While adjusting inputMatcher, I also renamed 'getUserInputForTrip' to 'getUserInputForTimelineEntry' (i) to be more consistent and (ii) in case we support a 'place user input' in the future --- www/__tests__/inputMatcher.test.ts | 42 +++++++------ www/js/diary/LabelTab.tsx | 7 +-- www/js/diary/timelineHelper.ts | 20 ------- www/js/survey/inputMatcher.ts | 94 +++++++++++++++++++----------- www/js/types/diaryTypes.ts | 42 +++---------- 5 files changed, 95 insertions(+), 110 deletions(-) diff --git a/www/__tests__/inputMatcher.test.ts b/www/__tests__/inputMatcher.test.ts index 3179c9700..566df0cd7 100644 --- a/www/__tests__/inputMatcher.test.ts +++ b/www/__tests__/inputMatcher.test.ts @@ -4,15 +4,16 @@ import { validUserInputForDraftTrip, validUserInputForTimelineEntry, getNotDeletedCandidates, - getUserInputForTrip, + getUserInputForTimelineEntry, getAdditionsForTimelineEntry, getUniqueEntries } from '../js/survey/inputMatcher'; -import { TlEntry, UnprocessedUserInput } from '../js/types/diaryTypes'; +import { CompositeTrip, TimelineEntry, UnprocessedUserInput } from '../js/types/diaryTypes'; describe('input-matcher', () => { let userTrip: UnprocessedUserInput; - let trip: TlEntry; + let trip: TimelineEntry; + let nextTrip: TimelineEntry; beforeEach(() => { /* @@ -37,7 +38,7 @@ describe('input-matcher', () => { key: 'manual/mode_confirm' }, key: 'manual/place' - } + }, trip = { key: 'FOO', origin_key: 'FOO', @@ -46,8 +47,16 @@ describe('input-matcher', () => { enter_ts: 1437605000, exit_ts: 1437605000, duration: 100, - getNextEntry: jest.fn() - } + }, + nextTrip = { + key: 'BAR', + origin_key: 'BAR', + start_ts: 1437606000, + end_ts: 1437607000, + enter_ts: 1437607000, + exit_ts: 1437607000, + duration: 100, + }, // mock Logger window['Logger'] = { log: console.log }; @@ -78,7 +87,7 @@ describe('input-matcher', () => { const validTrp = { end_ts: 1437604764, start_ts: 1437601247 - } + } as CompositeTrip; const validUserInput = validUserInputForDraftTrip(validTrp, userTrip, false); expect(validUserInput).toBeTruthy(); }); @@ -87,7 +96,7 @@ describe('input-matcher', () => { const invalidTrip = { end_ts: 0, start_ts: 0 - } + } as CompositeTrip; const invalidUserInput = validUserInputForDraftTrip(invalidTrip, userTrip, false); expect(invalidUserInput).toBeFalsy(); }); @@ -96,18 +105,18 @@ describe('input-matcher', () => { // we need valid key and origin_key for validUserInputForTimelineEntry test trip['key'] = 'analysis/confirmed_place'; trip['origin_key'] = 'analysis/confirmed_place'; - const validTimelineEntry = validUserInputForTimelineEntry(trip, userTrip, false); + const validTimelineEntry = validUserInputForTimelineEntry(trip, nextTrip, userTrip, false); expect(validTimelineEntry).toBeTruthy(); }); it('tests validUserInputForTimelineEntry with tlEntry with invalid key and origin_key', () => { const invalidTlEntry = trip; - const invalidTimelineEntry = validUserInputForTimelineEntry(invalidTlEntry, userTrip, false); + const invalidTimelineEntry = validUserInputForTimelineEntry(invalidTlEntry, null, userTrip, false); expect(invalidTimelineEntry).toBeFalsy(); }); it('tests validUserInputForTimelineEntry with tlEntry with invalie start & end time', () => { - const invalidTlEntry: TlEntry = { + const invalidTlEntry: TimelineEntry = { key: 'analysis/confirmed_place', origin_key: 'analysis/confirmed_place', start_ts: 1, @@ -115,9 +124,8 @@ describe('input-matcher', () => { enter_ts: 1, exit_ts: 1, duration: 1, - getNextEntry: jest.fn() } - const invalidTimelineEntry = validUserInputForTimelineEntry(invalidTlEntry, userTrip, false); + const invalidTimelineEntry = validUserInputForTimelineEntry(invalidTlEntry, null, userTrip, false); expect(invalidTimelineEntry).toBeFalsy(); }); @@ -212,13 +220,13 @@ describe('input-matcher', () => { // make the linst unsorted and then check if userInputWriteThird(latest one) is return output const userInputList = [userInputWriteSecond, userInputWriteThird, userInputWriteFirst]; - const mostRecentEntry = getUserInputForTrip(trip, userInputList); + const mostRecentEntry = getUserInputForTimelineEntry(trip, nextTrip, userInputList); expect(mostRecentEntry).toMatchObject(userInputWriteThird); }); it('tests getUserInputForTrip with invalid userInputList', () => { const userInputList = undefined; - const mostRecentEntry = getUserInputForTrip(trip, userInputList); + const mostRecentEntry = getUserInputForTimelineEntry(trip, nextTrip, userInputList); expect(mostRecentEntry).toBe(undefined); }); @@ -228,13 +236,13 @@ describe('input-matcher', () => { trip['origin_key'] = 'analysis/confirmed_place'; // check if the result keep the all valid userTrip items - const matchingAdditions = getAdditionsForTimelineEntry(trip, additionsList); + const matchingAdditions = getAdditionsForTimelineEntry(trip, nextTrip, additionsList); expect(matchingAdditions).toHaveLength(5); }); it('tests getAdditionsForTimelineEntry with invalid additionsList', () => { const additionsList = undefined; - const matchingAdditions = getAdditionsForTimelineEntry(trip, additionsList); + const matchingAdditions = getAdditionsForTimelineEntry(trip, nextTrip, additionsList); expect(matchingAdditions).toMatchObject([]); }); diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index b794a8257..60b462139 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -16,9 +16,9 @@ import LabelListScreen from "./list/LabelListScreen"; import { createStackNavigator } from "@react-navigation/stack"; import LabelScreenDetails from "./details/LabelDetailsScreen"; import { NavigationContainer } from "@react-navigation/native"; -import { compositeTrips2TimelineMap, updateAllUnprocessedInputs, updateLocalUnprocessedInputs, populateCompositeTrips, unprocessedLabels, unprocessedNotes } from "./timelineHelper"; +import { compositeTrips2TimelineMap, updateAllUnprocessedInputs, updateLocalUnprocessedInputs, unprocessedLabels, unprocessedNotes } from "./timelineHelper"; import { fillLocationNamesOfTrip, resetNominatimLimiter } from "./addressNamesHelper"; -import { getLabelOptions } from "../survey/multilabel/confirmHelper"; +import { LabelOption, getLabelOptions } from "../survey/multilabel/confirmHelper"; import { displayError, logDebug } from "../plugin/logger"; import { useTheme } from "react-native-paper"; import { getPipelineRangeTs } from "../commHelper"; @@ -42,7 +42,7 @@ const LabelTab = () => { const [pipelineRange, setPipelineRange] = useState(null); const [queriedRange, setQueriedRange] = useState(null); const [timelineMap, setTimelineMap] = useState>(null); - const [timelineLabelMap, setTimelineLabelMap] = useState<{[k: string]: {[k: string]: UnprocessedUserInput}}>(null); + const [timelineLabelMap, setTimelineLabelMap] = useState<{[k: string]: {[k: string]: UnprocessedUserInput | LabelOption}}>(null); const [timelineNotesMap, setTimelineNotesMap] = useState<{[k: string]: UnprocessedUserInput[]}>(null); const [displayedEntries, setDisplayedEntries] = useState(null); const [refreshTime, setRefreshTime] = useState(null); @@ -190,7 +190,6 @@ const LabelTab = () => { function handleFetchedTrips(ctList, utList, mode: 'prepend' | 'append' | 'replace') { const tripsRead = ctList.concat(utList); - populateCompositeTrips(tripsRead, showPlaces); // Fill place names on a reversed copy of the list so we fill from the bottom up tripsRead.slice().reverse().forEach(function (trip, index) { fillLocationNamesOfTrip(trip); diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 14f003178..08f5a734d 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -73,26 +73,6 @@ export function compositeTrips2TimelineMap(ctList: any[], unpackPlaces?: boolean return timelineEntriesMap; } -export function populateCompositeTrips(ctList, showPlaces) { - try { - ctList.forEach((ct, i) => { - if (showPlaces && ct.start_confirmed_place) { - const cp = ct.start_confirmed_place; - cp.getNextEntry = () => ctList[i]; - } - if (showPlaces && ct.end_confirmed_place) { - const cp = ct.end_confirmed_place; - cp.getNextEntry = () => ctList[i + 1]; - ct.getNextEntry = () => cp; - } else { - ct.getNextEntry = () => ctList[i + 1]; - } - }); - } catch (e) { - displayError(e, i18next.t('errors.while-populating-composite')); - } -} - /* 'LABELS' are 1:1 - each trip or place has a single label for each label type (e.g. 'MODE' and 'PURPOSE' for MULTILABEL configuration, or 'SURVEY' for ENKETO configuration) */ export let unprocessedLabels: { [key: string]: UnprocessedUserInput[] } = {}; diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index 6fdc01d90..8745b4385 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -1,6 +1,6 @@ import { logDebug, displayErrorMsg } from "../plugin/logger" import { DateTime } from "luxon"; -import { UnprocessedUserInput, Trip, TlEntry } from "../types/diaryTypes"; +import { CompositeTrip, TimelineEntry, UnprocessedUserInput } from "../types/diaryTypes"; import { unprocessedLabels, unprocessedNotes } from "../diary/timelineHelper"; import { LabelOption, MultilabelKey, getLabelInputDetails, getLabelInputs, getLabelOptions, inputType2retKey, labelKeyToRichMode, labelOptions } from "./multilabel/confirmHelper"; @@ -11,7 +11,7 @@ export const fmtTs = (ts_in_secs: number, tz: string): string | null => DateTime export const printUserInput = (ui: UnprocessedUserInput): string => `${fmtTs(ui.data.start_ts, ui.metadata.time_zone)} (${ui.data.start_ts}) -> ${fmtTs(ui.data.end_ts, ui.metadata.time_zone)} (${ui.data.end_ts}) ${ui.data.label} logged at ${ui.metadata.write_ts}`; -export const validUserInputForDraftTrip = (trip: Trip, userInput: UnprocessedUserInput, logsEnabled: boolean): boolean => { +export const validUserInputForDraftTrip = (trip: CompositeTrip, userInput: UnprocessedUserInput, logsEnabled: boolean): boolean => { if(logsEnabled) { logDebug(`Draft trip: comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} @@ -29,7 +29,12 @@ export const validUserInputForDraftTrip = (trip: Trip, userInput: UnprocessedUse || -(userInput.data.start_ts - trip.start_ts) <= 15 * 60) && userInput.data.end_ts <= trip.end_ts; } -export const validUserInputForTimelineEntry = (tlEntry: TlEntry, userInput: UnprocessedUserInput, logsEnabled: boolean): boolean => { +export const validUserInputForTimelineEntry = ( + tlEntry: TimelineEntry, + nextEntry: TimelineEntry | null, + userInput: UnprocessedUserInput, + logsEnabled: boolean, +): boolean => { if (!tlEntry.origin_key) return false; if (tlEntry.origin_key.includes('UNPROCESSED')) return validUserInputForDraftTrip(tlEntry, userInput, logsEnabled); @@ -77,29 +82,28 @@ export const validUserInputForTimelineEntry = (tlEntry: TlEntry, userInput: Unpr let endChecks = (userInput.data.end_ts <= entryEnd || (userInput.data.end_ts - entryEnd) <= 15 * 60); if (startChecks && !endChecks) { - const nextEntryObj = tlEntry.getNextEntry(); - if (nextEntryObj) { - const nextEntryEnd = nextEntryObj.end_ts || nextEntryObj.exit_ts; - if (!nextEntryEnd) { // the last place will not have an exit_ts - endChecks = true; // so we will just skip the end check - } else { - endChecks = userInput.data.end_ts <= nextEntryEnd; - logDebug(`Second level of end checks when the next trip is defined(${userInput.data.end_ts} <= ${nextEntryEnd}) ${endChecks}`); - } + if (nextEntry) { + const nextEntryEnd = nextEntry.end_ts || nextEntry.exit_ts; + if (!nextEntryEnd) { // the last place will not have an exit_ts + endChecks = true; // so we will just skip the end check } else { - // next trip is not defined, last trip - endChecks = (userInput.data.end_local_dt.day == userInput.data.start_local_dt.day) - logDebug("Second level of end checks for the last trip of the day"); - logDebug(`compare ${userInput.data.end_local_dt.day} with ${userInput.data.start_local_dt.day} ${endChecks}`); - } - if (endChecks) { - // If we have flipped the values, check to see that there is sufficient overlap - const overlapDuration = Math.min(userInput.data.end_ts, entryEnd) - Math.max(userInput.data.start_ts, entryStart) - logDebug(`Flipped endCheck, overlap(${overlapDuration})/trip(${tlEntry.duration} (${overlapDuration} / ${tlEntry.duration})`); - endChecks = (overlapDuration/tlEntry.duration) > 0.5; + endChecks = userInput.data.end_ts <= nextEntryEnd; + logDebug(`Second level of end checks when the next trip is defined(${userInput.data.end_ts} <= ${nextEntryEnd}) ${endChecks}`); } + } else { + // next trip is not defined, last trip + endChecks = (userInput.data.end_local_dt.day == userInput.data.start_local_dt.day) + logDebug("Second level of end checks for the last trip of the day"); + logDebug(`compare ${userInput.data.end_local_dt.day} with ${userInput.data.start_local_dt.day} ${endChecks}`); + } + if (endChecks) { + // If we have flipped the values, check to see that there is sufficient overlap + const overlapDuration = Math.min(userInput.data.end_ts, entryEnd) - Math.max(userInput.data.start_ts, entryStart) + logDebug(`Flipped endCheck, overlap(${overlapDuration})/trip(${tlEntry.duration} (${overlapDuration} / ${tlEntry.duration})`); + endChecks = (overlapDuration/tlEntry.duration) > 0.5; } - return startChecks && endChecks; + } + return startChecks && endChecks; } // parallels get_not_deleted_candidates() in trip_queries.py @@ -117,25 +121,31 @@ export const getNotDeletedCandidates = (candidates: UnprocessedUserInput[]): Unp return notDeletedActive; } -export const getUserInputForTrip = (trip: TlEntry, userInputList: UnprocessedUserInput[]): undefined | UnprocessedUserInput => { +export const getUserInputForTimelineEntry = ( + entry: TimelineEntry, + nextEntry: TimelineEntry | null, + userInputList: UnprocessedUserInput[], +): undefined | UnprocessedUserInput => { const logsEnabled = userInputList?.length < 20; if (userInputList === undefined) { - logDebug("In getUserInputForTrip, no user input, returning undefined"); + logDebug("In getUserInputForTimelineEntry, no user input, returning undefined"); return undefined; } if (logsEnabled) console.log(`Input list = ${userInputList.map(printUserInput)}`); // undefined !== true, so this covers the label view case as well - const potentialCandidates = userInputList.filter((ui) => validUserInputForTimelineEntry(trip, ui, logsEnabled)); + const potentialCandidates = userInputList.filter((ui) => + validUserInputForTimelineEntry(entry, nextEntry, ui, logsEnabled), + ); if (potentialCandidates.length === 0) { - if (logsEnabled) logDebug("In getUserInputForTripStartEnd, no potential candidates, returning []"); + if (logsEnabled) logDebug("In getUserInputForTimelineEntry, no potential candidates, returning []"); return undefined; } if (potentialCandidates.length === 1) { - logDebug(`In getUserInputForTripStartEnd, one potential candidate, returning ${printUserInput(potentialCandidates[0])}`); + logDebug(`In getUserInputForTimelineEntry, one potential candidate, returning ${printUserInput(potentialCandidates[0])}`); return potentialCandidates[0]; } @@ -149,7 +159,11 @@ export const getUserInputForTrip = (trip: TlEntry, userInputList: UnprocessedUs } // return array of matching additions for a trip or place -export const getAdditionsForTimelineEntry = (entry: TlEntry, additionsList: UnprocessedUserInput[]): UnprocessedUserInput[] => { +export const getAdditionsForTimelineEntry = ( + entry: TimelineEntry, + nextEntry: TimelineEntry | null, + additionsList: UnprocessedUserInput[], +): UnprocessedUserInput[] => { const logsEnabled = additionsList?.length < 20; if (additionsList === undefined) { @@ -159,7 +173,9 @@ export const getAdditionsForTimelineEntry = (entry: TlEntry, additionsList: Unpr // get additions that have not been deleted and filter out additions that do not start within the bounds of the timeline entry const notDeleted = getNotDeletedCandidates(additionsList); - const matchingAdditions = notDeleted.filter((ui) => validUserInputForTimelineEntry(entry, ui, logsEnabled)); + const matchingAdditions = notDeleted.filter((ui) => + validUserInputForTimelineEntry(entry, nextEntry, ui, logsEnabled), + ); if (logsEnabled) console.log(`Matching Addition list ${matchingAdditions.map(printUserInput)}`); @@ -201,14 +217,19 @@ export const getUniqueEntries = (combinedList) => { * @returns an array containing: (i) an object mapping timeline entry IDs to label inputs, * and (ii) an object mapping timeline entry IDs to note inputs */ -export function mapInputsToTimelineEntries(allEntries: TlEntry[], appConfig): [{ [k: string]: { [k: string]: UnprocessedUserInput | LabelOption } }, { [k: string]: UnprocessedUserInput[] }] { +export function mapInputsToTimelineEntries(allEntries: TimelineEntry[], appConfig): [{ [k: string]: { [k: string]: UnprocessedUserInput | LabelOption } }, { [k: string]: UnprocessedUserInput[] }] { const timelineLabelMap: { [k: string]: { [k: string]: UnprocessedUserInput | LabelOption } } = {}; const timelineNotesMap: { [k: string]: UnprocessedUserInput[] } = {}; allEntries.forEach((tlEntry, i) => { + const nextEntry = i + 1 < allEntries.length ? allEntries[i + 1] : null; if (appConfig?.survey_info?.['trip-labels'] == 'ENKETO') { // ENKETO configuration: just look for the 'SURVEY' key in the unprocessedInputs - const userInputForTrip = getUserInputForTrip(tlEntry, unprocessedLabels['SURVEY']); + const userInputForTrip = getUserInputForTimelineEntry( + tlEntry, + nextEntry, + unprocessedLabels['SURVEY'], + ); if (userInputForTrip) { timelineLabelMap[tlEntry._id.$oid] = { SURVEY: userInputForTrip }; } @@ -218,7 +239,11 @@ export function mapInputsToTimelineEntries(allEntries: TlEntry[], appConfig): [{ const labelsForTrip: { [k: string]: LabelOption } = {}; Object.keys(getLabelInputDetails()).forEach((label: MultilabelKey) => { // Check unprocessed labels first since they are more recent - const userInputForTrip = getUserInputForTrip(tlEntry, unprocessedLabels[label]); + const userInputForTrip = getUserInputForTimelineEntry( + tlEntry, + nextEntry, + unprocessedLabels[label], + ); if (userInputForTrip) { labelsForTrip[label] = labelOptions[label].find((opt: LabelOption) => opt.value == userInputForTrip.data.label); } else { @@ -238,7 +263,8 @@ export function mapInputsToTimelineEntries(allEntries: TlEntry[], appConfig): [{ ) { // trip-level or place-level notes are configured, so we need to match additions too allEntries.forEach((tlEntry, i) => { - const additionsForTrip = getAdditionsForTimelineEntry(tlEntry, unprocessedNotes); + const nextEntry = i + 1 < allEntries.length ? allEntries[i + 1] : null; + const additionsForTrip = getAdditionsForTimelineEntry(tlEntry, nextEntry, unprocessedNotes); if (additionsForTrip?.length) { timelineNotesMap[tlEntry._id.$oid] = additionsForTrip; } diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 150f829a3..cdb42d64d 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -1,12 +1,9 @@ -/* These type definitions are a work in progress. The goal is to have a single source of truth for - the types of the trip / place / untracked objects and all properties they contain. - Since we are using TypeScript now, we should strive to enforce type safety and also benefit from - IntelliSense and other IDE features. */ +/* This file provides typings for use in '/diary', including timeline objects (trips and places) + and user input objects. + As much as possible, these types parallel the types used in the server code. */ import { BaseModeKey, MotionTypeKey } from "../diary/diaryHelper"; -// Since it is WIP, these types are not used anywhere yet. - type ConfirmedPlace = any; // TODO /* These are the properties received from the server (basically matches Python code) @@ -46,6 +43,10 @@ export type CompositeTrip = { user_input: UnprocessedUserInput, } +/* The 'timeline' for a user is a list of their trips and places, + so a 'timeline entry' is either a trip or a place. */ +export type TimelineEntry = ConfirmedPlace | CompositeTrip; + /* These properties aren't received from the server, but are derived from the above properties. They are used in the UI to display trip/place details and are computed by the useDerivedProperties hook. */ export type DerivedProperties = { @@ -61,18 +62,6 @@ export type DerivedProperties = { detectedModes: { mode: string, icon: string, color: string, pct: number|string }[], } -/* These are the properties that are still filled in by some kind of 'populate' mechanism. - It would simplify the codebase to just compute them where they're needed - (using memoization when apt so performance is not impacted). */ -export type PopulatedTrip = CompositeTrip & { - additionsList?: any[], // TODO - finalInference?: any, // TODO - geojson?: any, // TODO - getNextEntry?: () => PopulatedTrip | ConfirmedPlace, - userInput?: UnprocessedUserInput, - verifiability?: string, -} - export type SectionSummary = { count: {[k: MotionTypeKey | BaseModeKey]: number}, distance: {[k: MotionTypeKey | BaseModeKey]: number}, @@ -110,20 +99,3 @@ export type LocalDt = { year: number, timezone: string, } - -export type Trip = { - end_ts: number, - start_ts: number, -} - -export type TlEntry = { - _id: { $oid: string }, - key: string, - origin_key: string, - start_ts: number, - end_ts: number, - enter_ts: number, - exit_ts: number, - duration: number, - getNextEntry?: () => PopulatedTrip | ConfirmedPlace, -} From a34c219a04909b3143acd52db064aca0b16d044b Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Sun, 29 Oct 2023 21:37:18 -0400 Subject: [PATCH 233/850] TripCard: don't show footer if no notes On trips with no notes/additions, an empty was being rendered (causing a 10px gap due to padding) If there were no notes, `timelineNotesMap[trip._id.$oid]` would be `undefined`, which does not equal 0. We should only show notes if it is defined AND the length is not 0, so we can just say `timelineNotesMap[trip._id.$oid]?.length`. --- www/js/diary/cards/TripCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/diary/cards/TripCard.tsx b/www/js/diary/cards/TripCard.tsx index dc1e44ce8..e87da3adc 100644 --- a/www/js/diary/cards/TripCard.tsx +++ b/www/js/diary/cards/TripCard.tsx @@ -92,7 +92,7 @@ const TripCard = ({ trip }: Props) => { } - {timelineNotesMap[trip._id.$oid]?.length != 0 && + {timelineNotesMap[trip._id.$oid]?.length && From c262f6af33efcea367244b5fb501b98e745d9ad9 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Sun, 29 Oct 2023 21:49:14 -0400 Subject: [PATCH 234/850] remove unused vars in LabelTab Replaced one use of `Logger` with `displayError`. Now we don't need to use these Angular services here anymore. The only Angular service still used in this file is TimelineHelper. --- www/i18n/en.json | 1 + www/js/diary/LabelTab.tsx | 8 ++------ 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/www/i18n/en.json b/www/i18n/en.json index 9217339f7..00490aa3e 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -396,6 +396,7 @@ "errors": { "registration-check-token": "User registration error. Please check your token and try again.", "not-registered-cant-contact": "User is not registered, so the server cannot be contacted.", + "while-loading-pipeline-range": "Error while loading pipeline range", "while-populating-composite": "Error while populating composite trips", "while-loading-another-week": "Error while loading travel of {{when}} week", "while-loading-specific-week": "Error while loading travel for the week of {{day}}", diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 60b462139..669150009 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -48,10 +48,6 @@ const LabelTab = () => { const [refreshTime, setRefreshTime] = useState(null); const [isLoading, setIsLoading] = useState('replace'); - const $rootScope = getAngularService('$rootScope'); - const $state = getAngularService('$state'); - const $ionicPopup = getAngularService('$ionicPopup'); - const Logger = getAngularService('Logger'); const Timeline = getAngularService('Timeline'); // initialization, once the appConfig is loaded @@ -121,8 +117,8 @@ const LabelTab = () => { JSON.stringify(unprocessedNotes), ); setPipelineRange(pipelineRange); - } catch (error) { - Logger.displayError("Error while loading pipeline range", error); + } catch (e) { + displayError(e, t('errors.while-loading-pipeline-range')); setIsLoading(false); } } From b2d6aa912f152515d791f136654b696acff125d1 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 30 Oct 2023 09:42:37 -0600 Subject: [PATCH 235/850] add comment and discussion link --- www/js/plugin/storage.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/www/js/plugin/storage.ts b/www/js/plugin/storage.ts index a8c503f0e..643e985e1 100644 --- a/www/js/plugin/storage.ts +++ b/www/js/plugin/storage.ts @@ -25,6 +25,9 @@ const unmungeValue = (key, retData) => { } const localStorageSet = (key: string, value: {[k: string]: any}) => { + //checking for a value to prevent storing undefined + //case where local was null and native was undefined stored "undefined" + //see discussion: https://github.com/e-mission/e-mission-phone/pull/1072#discussion_r1373753945 if (value) { localStorage.setItem(key, JSON.stringify(value)); } From fcbefb1b4a97605f62a602c309a6c3cea13328d4 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 30 Oct 2023 10:16:19 -0600 Subject: [PATCH 236/850] add comments about naming --- www/js/splash/pushnotify.js | 4 ++++ www/js/splash/remotenotify.js | 3 +++ 2 files changed, 7 insertions(+) diff --git a/www/js/splash/pushnotify.js b/www/js/splash/pushnotify.js index dd31811ab..b107349fc 100644 --- a/www/js/splash/pushnotify.js +++ b/www/js/splash/pushnotify.js @@ -1,3 +1,7 @@ +//naming of this file can be a little confusing - "pushnotifysettings" for rewritten file +//https://github.com/e-mission/e-mission-phone/pull/1072#discussion_r1375360832 + + /* * This module deals with the interaction with the push plugin, the redirection * of silent push notifications and the re-parsing of iOS pushes. It then diff --git a/www/js/splash/remotenotify.js b/www/js/splash/remotenotify.js index f08921fdd..f67cb9d87 100644 --- a/www/js/splash/remotenotify.js +++ b/www/js/splash/remotenotify.js @@ -1,3 +1,6 @@ +//naming of this module can be confusing "remotenotifyhandler" for rewritten file +//https://github.com/e-mission/e-mission-phone/pull/1072#discussion_r1375360832 + /* * This module deals with handling specific push messages that open web pages * or popups. It does not interface with the push plugin directly. Instead, it From 3c23808bc45a836e056c30998e10fa08dbdbcf1d Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 30 Oct 2023 11:30:56 -0600 Subject: [PATCH 237/850] remove old imports --- www/js/splash/pushnotify.js | 3 +-- www/js/splash/storedevicesettings.js | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/www/js/splash/pushnotify.js b/www/js/splash/pushnotify.js index b107349fc..775ddc4bd 100644 --- a/www/js/splash/pushnotify.js +++ b/www/js/splash/pushnotify.js @@ -19,8 +19,7 @@ import angular from 'angular'; import { updateUser } from '../commHelper'; -import { readConsentState, isConsented, startPrefs } from './startprefs'; -import { readIntroDone } from '../onboarding/onboardingHelper'; +import { readConsentState, isConsented } from './startprefs'; angular.module('emission.splash.pushnotify', ['emission.plugin.logger', 'emission.services']) diff --git a/www/js/splash/storedevicesettings.js b/www/js/splash/storedevicesettings.js index c32083295..31543bc6c 100644 --- a/www/js/splash/storedevicesettings.js +++ b/www/js/splash/storedevicesettings.js @@ -1,7 +1,6 @@ import angular from 'angular'; import { updateUser } from '../commHelper'; -import { isConsented, readConsentState, startPrefs } from "./startprefs"; -import { readIntroDone } from '../onboarding/onboardingHelper'; +import { isConsented, readConsentState } from "./startprefs"; angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', 'emission.services']) From 4b7b9d23abff444c2bceb4e7c3792a46abdc7f94 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Mon, 30 Oct 2023 11:57:14 -0700 Subject: [PATCH 238/850] Updated `controlHelper` imports --- www/__tests__/controlHelper.test.ts | 2 +- www/js/control/DataDatePicker.tsx | 2 +- www/js/services/controlHelper.ts | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/www/__tests__/controlHelper.test.ts b/www/__tests__/controlHelper.test.ts index dca43c039..f205121f0 100644 --- a/www/__tests__/controlHelper.test.ts +++ b/www/__tests__/controlHelper.test.ts @@ -1,5 +1,5 @@ import { mockLogger } from "../__mocks__/globalMocks"; -import { createWriteFile } from "../js/controlHelper"; +import { createWriteFile } from "../js/services/controlHelper"; import { FsWindow } from "../js/types/fileShareTypes" import { ServerData, ServerResponse} from "../js/types/serverData" diff --git a/www/js/control/DataDatePicker.tsx b/www/js/control/DataDatePicker.tsx index f2873ef66..454e0dca0 100644 --- a/www/js/control/DataDatePicker.tsx +++ b/www/js/control/DataDatePicker.tsx @@ -4,7 +4,7 @@ import React from "react"; import { DatePickerModal } from 'react-native-paper-dates'; import { useTranslation } from "react-i18next"; -import { getMyData } from "../controlHelper"; +import { getMyData } from "../services/controlHelper"; const DataDatePicker = ({date, setDate, open, setOpen, minDate}) => { const { t, i18n } = useTranslation(); //able to pull lang from this diff --git a/www/js/services/controlHelper.ts b/www/js/services/controlHelper.ts index 01f1e39f2..d297c24c0 100644 --- a/www/js/services/controlHelper.ts +++ b/www/js/services/controlHelper.ts @@ -1,10 +1,10 @@ import { DateTime } from "luxon"; import { getRawEntries } from "./commHelper"; -import { logInfo, displayError, logDebug } from "./plugin/logger"; -import { FsWindow } from "./types/fileShareTypes" -import { ServerResponse} from "./types/serverData"; -import i18next from "./i18nextInit" ; +import { logInfo, displayError, logDebug } from "../plugin/logger"; +import { FsWindow } from "../types/fileShareTypes" +import { ServerResponse} from "../types/serverData"; +import i18next from "../i18nextInit" ; declare let window: FsWindow; From 651e983359d06798edd8d63115801dd606ceebe4 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Mon, 30 Oct 2023 15:02:34 -0700 Subject: [PATCH 239/850] Ran prettier on serverData, added notes on types --- www/js/types/serverData.ts | 54 ++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 23 deletions(-) diff --git a/www/js/types/serverData.ts b/www/js/types/serverData.ts index 9f274992c..46fa9214b 100644 --- a/www/js/types/serverData.ts +++ b/www/js/types/serverData.ts @@ -1,37 +1,45 @@ export type ServerResponse = { - phone_data: Array>, -} + phone_data: Array>; +}; export type ServerData = { - data: Type, - metadata: MetaData, - key?: string, - user_id?: { $uuid: string, }, - _id?: { $oid: string, }, + data: Type; + metadata: MetaData; + key?: string; + user_id?: { $uuid: string }; + _id?: { $oid: string }; }; export type MetaData = { - key: string, - platform: string, - write_ts: number, - time_zone: string, - write_fmt_time: string, - write_local_dt: LocalDt, + key: string; + platform: string; + write_ts: number; + time_zone: string; + write_fmt_time: string; + write_local_dt: LocalDt; }; - + export type LocalDt = { - minute: number, - hour: number, - second: number, - day: number, - weekday: number, - month: number, - year: number, - timezone: string, + minute: number; + hour: number; + second: number; + day: number; + weekday: number; + month: number; + year: number; + timezone: string; }; +/* + * The server also supports queries via TimeQueryComponents, which can be split into multiple + * dates. The TimeQuery type was designed for UserCache calls, which only query via the + * `write_ts` time. For more details, please see the following files in /e-mission-server/: + * - /emission/storage/timeseries/tcquery.py : additional timeQueryComponent + * - /emission/storage/timeseries/timeQuery.py : timeQuery object used for `write_ts` queries + * - /emission/net/api/cfc_webapp.py : implementation of `/datastreams/find_enteries/` + */ export type TimeQuery = { key: string; startTs: number; endTs: number; -} \ No newline at end of file +}; From 772f5089d496ba6daac573a3dc9544aa19dcbb73 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Mon, 30 Oct 2023 15:50:22 -0700 Subject: [PATCH 240/850] Fixed PromiseResolution checks --- www/js/services/unifiedDataLoader.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/www/js/services/unifiedDataLoader.ts b/www/js/services/unifiedDataLoader.ts index 359109619..63b7cf89a 100644 --- a/www/js/services/unifiedDataLoader.ts +++ b/www/js/services/unifiedDataLoader.ts @@ -70,7 +70,7 @@ export const combinedPromises = function(promiseList: Array>, }, function(error) { firstResult = []; firstError = error; - nextPromiseDone = true; + firstPromiseDone = true; }).then(checkAndResolve); nextPromise.then(function(currentNextResult: Array) { @@ -79,6 +79,7 @@ export const combinedPromises = function(promiseList: Array>, }, function(error) { nextResult = []; nextError = error; + nextPromiseDone = true; }).then(checkAndResolve); }); }; @@ -87,13 +88,13 @@ export const combinedPromises = function(promiseList: Array>, * getUnifiedDataForInterval is a generalized method to fetch data by its timestamps * @param key string corresponding to a data entry * @param tq an object that contains interval start and end times - * @param getMethod a BEMUserCache method that fetches certain data via a promise + * @param localGetMethod a BEMUserCache method that fetches certain data via a promise * @returns A promise that evaluates to the all values found within the queried data */ export const getUnifiedDataForInterval = function(key: string, tq: TimeQuery, - getMethod: (key: string, tq: TimeQuery, flag: boolean) => Promise) { + localGetMethod: (key: string, tq: TimeQuery, flag: boolean) => Promise) { const test = true; - const getPromise = getMethod(key, tq, test); + const getPromise = localGetMethod(key, tq, test); const remotePromise = getRawEntries([key], tq.startTs, tq.endTs) .then(function(serverResponse: ServerResponse) { return serverResponse.phone_data; From 90095174fc0ff99ba5cedf37f455d68087ffb58a Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 31 Oct 2023 11:29:02 -0400 Subject: [PATCH 241/850] fix label button showing before appConfig loaded Before appConfig is loaded, we we are unsure whether MultilabelButtonGroup or UserInputButton should be shown neither. As long as appConfig is undefined we want to show neither. So a simple if/else is not sufficient here, we should specifically check for the presence of either MULTILABEL or ENKETO --- www/js/diary/cards/TripCard.tsx | 7 ++++--- www/js/diary/details/LabelDetailsScreen.tsx | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/www/js/diary/cards/TripCard.tsx b/www/js/diary/cards/TripCard.tsx index e87da3adc..ad35d48a8 100644 --- a/www/js/diary/cards/TripCard.tsx +++ b/www/js/diary/cards/TripCard.tsx @@ -70,10 +70,11 @@ const TripCard = ({ trip }: Props) => { displayEndName={tripEndDisplayName} /> {/* mode and purpose buttons / survey button */} - {appConfig?.survey_info?.['trip-labels'] == 'ENKETO' ? ( + {appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL' && ( + + )} + {appConfig?.survey_info?.['trip-labels'] == 'ENKETO' && ( - ) : ( - )} diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx index 11875545a..c0f1cc6bd 100644 --- a/www/js/diary/details/LabelDetailsScreen.tsx +++ b/www/js/diary/details/LabelDetailsScreen.tsx @@ -53,10 +53,11 @@ const LabelScreenDetails = ({ route, navigation }) => { style={{margin: 10, paddingHorizontal: 10, rowGap: 12, borderRadius: 15 }}> {/* MultiLabel or UserInput button, inline on one row */} - {appConfig?.survey_info?.['trip-labels'] == 'ENKETO' ? ( + {appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL' && ( + + )} + {appConfig?.survey_info?.['trip-labels'] == 'ENKETO' && ( - ) : ( - )} From fb46586925a92252bf45f763544fcae2f00ce86d Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 31 Oct 2023 11:37:12 -0400 Subject: [PATCH 242/850] combine processed+unprocessed survey responses In inputMatcher -> mapInputsToTimelineEntries, adds back the logic for handling processed survey response inputs that are already matched to the trip/place on the server. For user_input, we only use it if there is no unprocessed user input (since unprocessed entries will be newer). For additions, we have to merge processed+unprocessed and then from this, get the non-deleted, unique entries. in timelineHelper, when storing unprocessedNotes we should remove duplicates according to their write_ts, not by getUniqueEntries(getNotDeletedCandidates(...)), because we need to retain the "DELETED" entries so they can be matched to any processed entries that they may refer to --- www/js/diary/timelineHelper.ts | 6 ++++-- www/js/survey/inputMatcher.ts | 25 +++++++++++++++++++++---- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 08f5a734d..ba5dd9a7b 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -99,7 +99,9 @@ function updateUnprocessedInputs(labelsPromises, notesPromises, appConfig) { }); // merge the notes we just read into the existing unprocessedNotes, removing duplicates const combinedNotes = [...unprocessedNotes, ...notesResults]; - unprocessedNotes = getUniqueEntries(getNotDeletedCandidates(combinedNotes)); + unprocessedNotes = combinedNotes.filter((note, i, self) => + self.findIndex(n => n.metadata.write_ts == note.metadata.write_ts) == i + ); }); } @@ -143,7 +145,7 @@ export async function updateAllUnprocessedInputs(pipelineRange, appConfig) { await updateUnprocessedInputs(labelsPromises, notesPromises, appConfig); } -function keysForLabelInputs(appConfig) { +export function keysForLabelInputs(appConfig) { if (appConfig.survey_info?.['trip-labels'] == 'ENKETO') { return ['manual/trip_user_input']; } else { diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index 8745b4385..2dc937949 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -1,7 +1,7 @@ import { logDebug, displayErrorMsg } from "../plugin/logger" import { DateTime } from "luxon"; import { CompositeTrip, TimelineEntry, UnprocessedUserInput } from "../types/diaryTypes"; -import { unprocessedLabels, unprocessedNotes } from "../diary/timelineHelper"; +import { keysForLabelInputs, unprocessedLabels, unprocessedNotes } from "../diary/timelineHelper"; import { LabelOption, MultilabelKey, getLabelInputDetails, getLabelInputs, getLabelOptions, inputType2retKey, labelKeyToRichMode, labelOptions } from "./multilabel/confirmHelper"; const EPOCH_MAXIMUM = 2**31 - 1; @@ -232,6 +232,15 @@ export function mapInputsToTimelineEntries(allEntries: TimelineEntry[], appConfi ); if (userInputForTrip) { timelineLabelMap[tlEntry._id.$oid] = { SURVEY: userInputForTrip }; + } else { + let processedSurveyResponse; + for (const key of keysForLabelInputs(appConfig)) { + if (tlEntry.user_input?.[key]) { + processedSurveyResponse = tlEntry.user_input[key]; + break; + } + } + timelineLabelMap[tlEntry._id.$oid] = { SURVEY: processedSurveyResponse }; } } else { // MULTILABEL configuration: use the label inputs from the labelOptions to determine which @@ -263,10 +272,18 @@ export function mapInputsToTimelineEntries(allEntries: TimelineEntry[], appConfi ) { // trip-level or place-level notes are configured, so we need to match additions too allEntries.forEach((tlEntry, i) => { + /* With additions/notes, we can have multiple entries for a single trip or place. + So, we will read both the processed additions and unprocessed additions + and merge them together, removing duplicates. */ const nextEntry = i + 1 < allEntries.length ? allEntries[i + 1] : null; - const additionsForTrip = getAdditionsForTimelineEntry(tlEntry, nextEntry, unprocessedNotes); - if (additionsForTrip?.length) { - timelineNotesMap[tlEntry._id.$oid] = additionsForTrip; + const unprocessedAdditions = getAdditionsForTimelineEntry(tlEntry, nextEntry, unprocessedNotes); + const processedAdditions = tlEntry.additions || []; + + const mergedAdditions = getUniqueEntries( + getNotDeletedCandidates([...unprocessedAdditions, ...processedAdditions]), + ); + if (mergedAdditions?.length) { + timelineNotesMap[tlEntry._id.$oid] = mergedAdditions; } }); } From aa34ac3058ce933c0b2e23b9757ae33b3c2b4ba9 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Tue, 31 Oct 2023 09:14:51 -0700 Subject: [PATCH 243/850] Added tests for promise rejection --- www/__tests__/unifiedDataLoader.test.ts | 12 ++++++++++++ www/js/services/unifiedDataLoader.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/www/__tests__/unifiedDataLoader.test.ts b/www/__tests__/unifiedDataLoader.test.ts index 6e1c41316..cbbede1a6 100644 --- a/www/__tests__/unifiedDataLoader.test.ts +++ b/www/__tests__/unifiedDataLoader.test.ts @@ -49,6 +49,9 @@ describe('combineWithDedup can', () => { const promiseGenerator = (values: Array>) => { return Promise.resolve(values); }; +const badPromiseGenerator = (input: string) => { + return Promise.reject(input); +} it('throws an error on an empty input', async () => { expect(() => { @@ -56,6 +59,15 @@ it('throws an error on an empty input', async () => { }).toThrow(); }); +it('catches when all promises fails', async () => { + expect(combinedPromises([badPromiseGenerator('')], combineWithDedup)).rejects.toEqual(['']); + expect(combinedPromises([badPromiseGenerator('bad'), badPromiseGenerator('promise')], combineWithDedup)).rejects.toEqual(['bad','promise']); + expect(combinedPromises([badPromiseGenerator('very'), badPromiseGenerator('bad'), badPromiseGenerator('promise')], combineWithDedup)).rejects.toEqual(['very','bad','promise']); + + expect(combinedPromises([badPromiseGenerator('bad'), promiseGenerator([testOne])], combineWithDedup)).resolves.toEqual([testOne]); + expect(combinedPromises([promiseGenerator([testOne]), badPromiseGenerator('bad')], combineWithDedup)).resolves.toEqual([testOne]); +}); + it('work with arrays of len 1', async () => { const promiseArrayOne = [promiseGenerator([testOne])]; const promiseArrayTwo = [promiseGenerator([testOne, testTwo])]; diff --git a/www/js/services/unifiedDataLoader.ts b/www/js/services/unifiedDataLoader.ts index 63b7cf89a..45144ff56 100644 --- a/www/js/services/unifiedDataLoader.ts +++ b/www/js/services/unifiedDataLoader.ts @@ -42,7 +42,7 @@ export const combinedPromises = function(promiseList: Array>, const checkAndResolve = function() { if (firstPromiseDone && nextPromiseDone) { if (firstError && nextError) { - reject([firstError, nextError]); + reject([firstError].concat(nextError)); } else { logDebug(`About to dedup firstResult = ${firstResult.length}` + ` nextResult = ${nextResult.length}`); From 2e5a61ee83c89a2d724f402d6aaed573571f9657 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Tue, 31 Oct 2023 09:16:28 -0700 Subject: [PATCH 244/850] Ran unifiedDataLoader files through Prettier --- www/__tests__/unifiedDataLoader.test.ts | 26 +++- www/js/services/unifiedDataLoader.ts | 169 +++++++++++++----------- 2 files changed, 115 insertions(+), 80 deletions(-) diff --git a/www/__tests__/unifiedDataLoader.test.ts b/www/__tests__/unifiedDataLoader.test.ts index cbbede1a6..285971f0a 100644 --- a/www/__tests__/unifiedDataLoader.test.ts +++ b/www/__tests__/unifiedDataLoader.test.ts @@ -51,7 +51,7 @@ const promiseGenerator = (values: Array>) => { }; const badPromiseGenerator = (input: string) => { return Promise.reject(input); -} +}; it('throws an error on an empty input', async () => { expect(() => { @@ -61,11 +61,25 @@ it('throws an error on an empty input', async () => { it('catches when all promises fails', async () => { expect(combinedPromises([badPromiseGenerator('')], combineWithDedup)).rejects.toEqual(['']); - expect(combinedPromises([badPromiseGenerator('bad'), badPromiseGenerator('promise')], combineWithDedup)).rejects.toEqual(['bad','promise']); - expect(combinedPromises([badPromiseGenerator('very'), badPromiseGenerator('bad'), badPromiseGenerator('promise')], combineWithDedup)).rejects.toEqual(['very','bad','promise']); - - expect(combinedPromises([badPromiseGenerator('bad'), promiseGenerator([testOne])], combineWithDedup)).resolves.toEqual([testOne]); - expect(combinedPromises([promiseGenerator([testOne]), badPromiseGenerator('bad')], combineWithDedup)).resolves.toEqual([testOne]); + expect( + combinedPromises( + [badPromiseGenerator('bad'), badPromiseGenerator('promise')], + combineWithDedup, + ), + ).rejects.toEqual(['bad', 'promise']); + expect( + combinedPromises( + [badPromiseGenerator('very'), badPromiseGenerator('bad'), badPromiseGenerator('promise')], + combineWithDedup, + ), + ).rejects.toEqual(['very', 'bad', 'promise']); + + expect( + combinedPromises([badPromiseGenerator('bad'), promiseGenerator([testOne])], combineWithDedup), + ).resolves.toEqual([testOne]); + expect( + combinedPromises([promiseGenerator([testOne]), badPromiseGenerator('bad')], combineWithDedup), + ).resolves.toEqual([testOne]); }); it('work with arrays of len 1', async () => { diff --git a/www/js/services/unifiedDataLoader.ts b/www/js/services/unifiedDataLoader.ts index 45144ff56..4d644b998 100644 --- a/www/js/services/unifiedDataLoader.ts +++ b/www/js/services/unifiedDataLoader.ts @@ -1,104 +1,125 @@ -import { logDebug } from '../plugin/logger' +import { logDebug } from '../plugin/logger'; import { getRawEntries } from './commHelper'; import { ServerResponse, ServerData, TimeQuery } from '../types/serverData'; /** - * combineWithDedup is a helper function for combinedPromises + * combineWithDedup is a helper function for combinedPromises * @param list1 values evaluated from a BEMUserCache promise - * @param list2 same as list1 + * @param list2 same as list1 * @returns a dedup array generated from the input lists */ -export const combineWithDedup = function(list1: Array>, list2: Array) { - const combinedList = list1.concat(list2); - return combinedList.filter(function(value, i, array) { - const firstIndexOfValue = array.findIndex(function(element) { - return element.metadata.write_ts == value.metadata.write_ts; - }); - return firstIndexOfValue == i; +export const combineWithDedup = function (list1: Array>, list2: Array) { + const combinedList = list1.concat(list2); + return combinedList.filter(function (value, i, array) { + const firstIndexOfValue = array.findIndex(function (element) { + return element.metadata.write_ts == value.metadata.write_ts; }); + return firstIndexOfValue == i; + }); }; /** - * combinedPromises is a recursive function that joins multiple promises - * @param promiseList 1 or more promises + * combinedPromises is a recursive function that joins multiple promises + * @param promiseList 1 or more promises * @param combiner a function that takes two arrays and joins them * @returns A promise which evaluates to a combined list of values or errors */ -export const combinedPromises = function(promiseList: Array>, - combiner: (list1: Array, list2: Array) => Array ) { - if (promiseList.length === 0) { - throw new RangeError('combinedPromises needs input array.length >= 1'); - } - return new Promise(function(resolve, reject) { - var firstResult = []; - var firstError = null; +export const combinedPromises = function ( + promiseList: Array>, + combiner: (list1: Array, list2: Array) => Array, +) { + if (promiseList.length === 0) { + throw new RangeError('combinedPromises needs input array.length >= 1'); + } + return new Promise(function (resolve, reject) { + var firstResult = []; + var firstError = null; - var nextResult = []; - var nextError = null; + var nextResult = []; + var nextError = null; - var firstPromiseDone = false; - var nextPromiseDone = false; + var firstPromiseDone = false; + var nextPromiseDone = false; - const checkAndResolve = function() { - if (firstPromiseDone && nextPromiseDone) { - if (firstError && nextError) { - reject([firstError].concat(nextError)); - } else { - logDebug(`About to dedup firstResult = ${firstResult.length}` + - ` nextResult = ${nextResult.length}`); - const dedupedList = combiner(firstResult, nextResult); - logDebug(`Deduped list = ${dedupedList.length}`); - resolve(dedupedList); - } + const checkAndResolve = function () { + if (firstPromiseDone && nextPromiseDone) { + if (firstError && nextError) { + reject([firstError].concat(nextError)); + } else { + logDebug( + `About to dedup firstResult = ${firstResult.length}` + + ` nextResult = ${nextResult.length}`, + ); + const dedupedList = combiner(firstResult, nextResult); + logDebug(`Deduped list = ${dedupedList.length}`); + resolve(dedupedList); } - }; - - if (promiseList.length === 1) { - return promiseList[0].then(function(result: Array) { + } + }; + + if (promiseList.length === 1) { + return promiseList[0].then( + function (result: Array) { resolve(result); - }, function (err) { + }, + function (err) { reject([err]); - }); - } + }, + ); + } - const firstPromise = promiseList[0]; - const nextPromise = combinedPromises(promiseList.slice(1), combiner); - - firstPromise.then(function(currentFirstResult: Array) { - firstResult = currentFirstResult; - firstPromiseDone = true; - }, function(error) { - firstResult = []; - firstError = error; - firstPromiseDone = true; - }).then(checkAndResolve); + const firstPromise = promiseList[0]; + const nextPromise = combinedPromises(promiseList.slice(1), combiner); - nextPromise.then(function(currentNextResult: Array) { - nextResult = currentNextResult; - nextPromiseDone = true; - }, function(error) { - nextResult = []; - nextError = error; - nextPromiseDone = true; - }).then(checkAndResolve); - }); + firstPromise + .then( + function (currentFirstResult: Array) { + firstResult = currentFirstResult; + firstPromiseDone = true; + }, + function (error) { + firstResult = []; + firstError = error; + firstPromiseDone = true; + }, + ) + .then(checkAndResolve); + + nextPromise + .then( + function (currentNextResult: Array) { + nextResult = currentNextResult; + nextPromiseDone = true; + }, + function (error) { + nextResult = []; + nextError = error; + nextPromiseDone = true; + }, + ) + .then(checkAndResolve); + }); }; /** - * getUnifiedDataForInterval is a generalized method to fetch data by its timestamps + * getUnifiedDataForInterval is a generalized method to fetch data by its timestamps * @param key string corresponding to a data entry * @param tq an object that contains interval start and end times * @param localGetMethod a BEMUserCache method that fetches certain data via a promise * @returns A promise that evaluates to the all values found within the queried data */ -export const getUnifiedDataForInterval = function(key: string, tq: TimeQuery, - localGetMethod: (key: string, tq: TimeQuery, flag: boolean) => Promise) { - const test = true; - const getPromise = localGetMethod(key, tq, test); - const remotePromise = getRawEntries([key], tq.startTs, tq.endTs) - .then(function(serverResponse: ServerResponse) { - return serverResponse.phone_data; - }); - var promiseList = [getPromise, remotePromise] - return combinedPromises(promiseList, combineWithDedup); -}; \ No newline at end of file +export const getUnifiedDataForInterval = function ( + key: string, + tq: TimeQuery, + localGetMethod: (key: string, tq: TimeQuery, flag: boolean) => Promise, +) { + const test = true; + const getPromise = localGetMethod(key, tq, test); + const remotePromise = getRawEntries([key], tq.startTs, tq.endTs).then(function ( + serverResponse: ServerResponse, + ) { + return serverResponse.phone_data; + }); + var promiseList = [getPromise, remotePromise]; + return combinedPromises(promiseList, combineWithDedup); +}; From af5948bb8ea111e5750b7f678ffe2229e1bd870e Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 31 Oct 2023 12:46:27 -0400 Subject: [PATCH 245/850] move LabelTabContext to own file to expand types --- www/js/diary/LabelTab.tsx | 2 +- www/js/diary/LabelTabContext.ts | 38 +++++++++++++++++++ www/js/diary/cards/ModesIndicator.tsx | 2 +- www/js/diary/cards/PlaceCard.tsx | 2 +- www/js/diary/cards/TripCard.tsx | 2 +- www/js/diary/details/LabelDetailsScreen.tsx | 2 +- .../details/TripSectionsDescriptives.tsx | 2 +- www/js/diary/list/DateSelect.tsx | 2 +- www/js/diary/list/LabelListScreen.tsx | 2 +- www/js/survey/enketo/AddNoteButton.tsx | 2 +- www/js/survey/enketo/AddedNotesList.tsx | 2 +- www/js/survey/enketo/UserInputButton.tsx | 2 +- .../multilabel/MultiLabelButtonGroup.tsx | 2 +- 13 files changed, 50 insertions(+), 12 deletions(-) create mode 100644 www/js/diary/LabelTabContext.ts diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 669150009..1908411f5 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -26,11 +26,11 @@ import { UnprocessedUserInput } from "../types/diaryTypes"; import { mapInputsToTimelineEntries } from "../survey/inputMatcher"; import { configuredFilters as multilabelConfiguredFilters } from "../survey/multilabel/infinite_scroll_filters"; import { configuredFilters as enketoConfiguredFilters } from "../survey/enketo/infinite_scroll_filters"; +import LabelTabContext, { TimelineLabelMap, TimelineMap, TimelineNotesMap } from "./LabelTabContext"; let showPlaces; const ONE_DAY = 24 * 60 * 60; // seconds const ONE_WEEK = ONE_DAY * 7; // seconds -export const LabelTabContext = React.createContext(null); const LabelTab = () => { const appConfig = useAppConfig(); diff --git a/www/js/diary/LabelTabContext.ts b/www/js/diary/LabelTabContext.ts new file mode 100644 index 000000000..e5715bf52 --- /dev/null +++ b/www/js/diary/LabelTabContext.ts @@ -0,0 +1,38 @@ +import { createContext } from 'react'; +import { TimelineEntry, UnprocessedUserInput } from '../types/diaryTypes'; +import { LabelOption } from '../survey/multilabel/confirmHelper'; + +export type TimelineMap = Map; +export type TimelineLabelMap = { + [k: string]: { + /* if the key here is 'SURVEY', we are in the ENKETO configuration, meaning the user input + value is a raw survey response */ + SURVEY?: UnprocessedUserInput; + } & { + /* all other keys, (e.g. 'MODE', 'PURPOSE') are from the MULTILABEL configuration + and use a LabelOption for the user input value */ + [k: string]: LabelOption; + }; +}; +export type TimelineNotesMap = { + [k: string]: UnprocessedUserInput[]; +}; + +type ContextProps = { + labelOptions: any, + timelineMap: TimelineMap, + timelineLabelMap: TimelineLabelMap, + timelineNotesMap: TimelineNotesMap, + displayedEntries: TimelineEntry[], + filterInputs: any, // TODO + setFilterInputs: any, // TODO + queriedRange: any, // TODO + pipelineRange: any, // TODO + isLoading: string|false, + loadAnotherWeek: any, // TODO + loadSpecificWeek: any, // TODO + refresh: any, // TODO + repopulateTimelineEntry: any, // TODO +}; + +export default createContext(null); diff --git a/www/js/diary/cards/ModesIndicator.tsx b/www/js/diary/cards/ModesIndicator.tsx index db873a71e..00967b43e 100644 --- a/www/js/diary/cards/ModesIndicator.tsx +++ b/www/js/diary/cards/ModesIndicator.tsx @@ -1,7 +1,7 @@ import React, { useContext } from 'react'; import { View, StyleSheet } from 'react-native'; import color from "color"; -import { LabelTabContext } from '../LabelTab'; +import LabelTabContext from '../LabelTabContext'; import { logDebug } from '../../plugin/logger'; import { getBaseModeByValue } from '../diaryHelper'; import { Icon } from '../../components/Icon'; diff --git a/www/js/diary/cards/PlaceCard.tsx b/www/js/diary/cards/PlaceCard.tsx index 31ea5c789..009cc22cf 100644 --- a/www/js/diary/cards/PlaceCard.tsx +++ b/www/js/diary/cards/PlaceCard.tsx @@ -17,7 +17,7 @@ import { DiaryCard, cardStyles } from "./DiaryCard"; import { useAddressNames } from "../addressNamesHelper"; import useDerivedProperties from "../useDerivedProperties"; import StartEndLocations from "../components/StartEndLocations"; -import { LabelTabContext } from "../LabelTab"; +import LabelTabContext from '../LabelTabContext'; type Props = { place: {[key: string]: any} }; const PlaceCard = ({ place }: Props) => { diff --git a/www/js/diary/cards/TripCard.tsx b/www/js/diary/cards/TripCard.tsx index ad35d48a8..69abbfb29 100644 --- a/www/js/diary/cards/TripCard.tsx +++ b/www/js/diary/cards/TripCard.tsx @@ -18,7 +18,7 @@ import { getTheme } from "../../appTheme"; import { DiaryCard, cardStyles } from "./DiaryCard"; import { useNavigation } from "@react-navigation/native"; import { useAddressNames } from "../addressNamesHelper"; -import { LabelTabContext } from "../LabelTab"; +import LabelTabContext from '../LabelTabContext'; import useDerivedProperties from "../useDerivedProperties"; import StartEndLocations from "../components/StartEndLocations"; import ModesIndicator from "./ModesIndicator"; diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx index c0f1cc6bd..48fc67430 100644 --- a/www/js/diary/details/LabelDetailsScreen.tsx +++ b/www/js/diary/details/LabelDetailsScreen.tsx @@ -5,7 +5,7 @@ import React, { useContext, useState } from "react"; import { View, Modal, ScrollView, useWindowDimensions } from "react-native"; import { PaperProvider, Appbar, SegmentedButtons, Button, Surface, Text, useTheme } from "react-native-paper"; -import { LabelTabContext } from "../LabelTab"; +import LabelTabContext from '../LabelTabContext'; import LeafletView from "../../components/LeafletView"; import { useTranslation } from "react-i18next"; import MultilabelButtonGroup from "../../survey/multilabel/MultiLabelButtonGroup"; diff --git a/www/js/diary/details/TripSectionsDescriptives.tsx b/www/js/diary/details/TripSectionsDescriptives.tsx index c89481f44..db9efb3ca 100644 --- a/www/js/diary/details/TripSectionsDescriptives.tsx +++ b/www/js/diary/details/TripSectionsDescriptives.tsx @@ -4,7 +4,7 @@ import { Text, useTheme } from 'react-native-paper' import { Icon } from '../../components/Icon'; import useDerivedProperties from '../useDerivedProperties'; import { getBaseModeByKey, getBaseModeByValue } from '../diaryHelper'; -import { LabelTabContext } from '../LabelTab'; +import LabelTabContext from '../LabelTabContext'; const TripSectionsDescriptives = ({ trip, showLabeledMode=false }) => { diff --git a/www/js/diary/list/DateSelect.tsx b/www/js/diary/list/DateSelect.tsx index 1c28cdc2c..210de20be 100644 --- a/www/js/diary/list/DateSelect.tsx +++ b/www/js/diary/list/DateSelect.tsx @@ -9,7 +9,7 @@ import React, { useEffect, useState, useMemo, useContext } from "react"; import { StyleSheet } from "react-native"; import moment from "moment"; -import { LabelTabContext } from "../LabelTab"; +import LabelTabContext from '../LabelTabContext'; import { DatePickerModal } from "react-native-paper-dates"; import { Text, Divider, useTheme } from "react-native-paper"; import i18next from "i18next"; diff --git a/www/js/diary/list/LabelListScreen.tsx b/www/js/diary/list/LabelListScreen.tsx index 4fb1702b2..ae3cefd5f 100644 --- a/www/js/diary/list/LabelListScreen.tsx +++ b/www/js/diary/list/LabelListScreen.tsx @@ -4,7 +4,7 @@ import { Appbar, useTheme } from "react-native-paper"; import DateSelect from "./DateSelect"; import FilterSelect from "./FilterSelect"; import TimelineScrollList from "./TimelineScrollList"; -import { LabelTabContext } from "../LabelTab"; +import LabelTabContext from '../LabelTabContext'; const LabelListScreen = () => { diff --git a/www/js/survey/enketo/AddNoteButton.tsx b/www/js/survey/enketo/AddNoteButton.tsx index 08ca239cc..8efd8901e 100644 --- a/www/js/survey/enketo/AddNoteButton.tsx +++ b/www/js/survey/enketo/AddNoteButton.tsx @@ -11,7 +11,7 @@ import React, { useEffect, useState, useContext } from "react"; import DiaryButton from "../../components/DiaryButton"; import { useTranslation } from "react-i18next"; import moment from "moment"; -import { LabelTabContext } from "../../diary/LabelTab"; +import LabelTabContext from "../../diary/LabelTabContext"; import EnketoModal from "./EnketoModal"; import { displayErrorMsg, logDebug } from "../../plugin/logger"; diff --git a/www/js/survey/enketo/AddedNotesList.tsx b/www/js/survey/enketo/AddedNotesList.tsx index e29278cca..1a3899baf 100644 --- a/www/js/survey/enketo/AddedNotesList.tsx +++ b/www/js/survey/enketo/AddedNotesList.tsx @@ -6,7 +6,7 @@ import React, { useContext, useState } from "react"; import moment from "moment"; import { Modal } from "react-native" import { Text, Button, DataTable, Dialog } from "react-native-paper"; -import { LabelTabContext } from "../../diary/LabelTab"; +import LabelTabContext from "../../diary/LabelTabContext"; import { getFormattedDateAbbr, isMultiDay } from "../../diary/diaryHelper"; import { Icon } from "../../components/Icon"; import EnketoModal from "./EnketoModal"; diff --git a/www/js/survey/enketo/UserInputButton.tsx b/www/js/survey/enketo/UserInputButton.tsx index acb129e6a..6693e5cf7 100644 --- a/www/js/survey/enketo/UserInputButton.tsx +++ b/www/js/survey/enketo/UserInputButton.tsx @@ -15,7 +15,7 @@ import { useTranslation } from "react-i18next"; import { useTheme } from "react-native-paper"; import { displayErrorMsg, logDebug } from "../../plugin/logger"; import EnketoModal from "./EnketoModal"; -import { LabelTabContext } from "../../diary/LabelTab"; +import LabelTabContext from "../../diary/LabelTabContext"; type Props = { timelineEntry: any, diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index 54c9be531..d47ef3f17 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -8,7 +8,7 @@ import { View, Modal, ScrollView, Pressable, useWindowDimensions } from "react-n import { IconButton, Text, Dialog, useTheme, RadioButton, Button, TextInput } from "react-native-paper"; import DiaryButton from "../../components/DiaryButton"; import { useTranslation } from "react-i18next"; -import { LabelTabContext } from "../../diary/LabelTab"; +import LabelTabContext from "../../diary/LabelTabContext"; import { displayErrorMsg, logDebug } from "../../plugin/logger"; import { getLabelInputDetails, getLabelInputs, inferFinalLabels, labelInputDetailsForTrip, labelKeyToRichMode, readableLabelToKey, verifiabilityForTrip } from "./confirmHelper"; import useAppConfig from "../../useAppConfig"; From d96ff8d0d7c785f6914b2d080885df9030f6d033 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 31 Oct 2023 13:16:29 -0400 Subject: [PATCH 246/850] expand types -added type def for ConfirmedPlace -create ObjectId instead of repeating `{ $oid: string }` -expanded user input typings: -- this was tricky because the structure of the user input received from the server differs by key. For labels, we just get a string value for the label chosen. For survey responses, we get a actual raw data entry. For the purpose of typing these, I am differentiating them based on whether the key ends in 'user_input' (used for surveys) or if it ends in 'confirm' (used for labels) -use these new typings in LabelTab and inputMatcher --- www/__tests__/inputMatcher.test.ts | 4 +-- www/js/diary/LabelTab.tsx | 11 +++---- www/js/diary/LabelTabContext.ts | 11 ++++--- www/js/diary/timelineHelper.ts | 6 ++-- www/js/survey/inputMatcher.ts | 25 +++++++------- www/js/types/diaryTypes.ts | 53 +++++++++++++++++++++++------- 6 files changed, 70 insertions(+), 40 deletions(-) diff --git a/www/__tests__/inputMatcher.test.ts b/www/__tests__/inputMatcher.test.ts index 566df0cd7..1550686eb 100644 --- a/www/__tests__/inputMatcher.test.ts +++ b/www/__tests__/inputMatcher.test.ts @@ -8,10 +8,10 @@ import { getAdditionsForTimelineEntry, getUniqueEntries } from '../js/survey/inputMatcher'; -import { CompositeTrip, TimelineEntry, UnprocessedUserInput } from '../js/types/diaryTypes'; +import { CompositeTrip, TimelineEntry, UserInputEntry } from '../js/types/diaryTypes'; describe('input-matcher', () => { - let userTrip: UnprocessedUserInput; + let userTrip: UserInputEntry; let trip: TimelineEntry; let nextTrip: TimelineEntry; diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 1908411f5..3c214dbee 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -18,11 +18,10 @@ import LabelScreenDetails from "./details/LabelDetailsScreen"; import { NavigationContainer } from "@react-navigation/native"; import { compositeTrips2TimelineMap, updateAllUnprocessedInputs, updateLocalUnprocessedInputs, unprocessedLabels, unprocessedNotes } from "./timelineHelper"; import { fillLocationNamesOfTrip, resetNominatimLimiter } from "./addressNamesHelper"; -import { LabelOption, getLabelOptions } from "../survey/multilabel/confirmHelper"; +import { getLabelOptions } from "../survey/multilabel/confirmHelper"; import { displayError, logDebug } from "../plugin/logger"; import { useTheme } from "react-native-paper"; import { getPipelineRangeTs } from "../commHelper"; -import { UnprocessedUserInput } from "../types/diaryTypes"; import { mapInputsToTimelineEntries } from "../survey/inputMatcher"; import { configuredFilters as multilabelConfiguredFilters } from "../survey/multilabel/infinite_scroll_filters"; import { configuredFilters as enketoConfiguredFilters } from "../survey/enketo/infinite_scroll_filters"; @@ -41,9 +40,9 @@ const LabelTab = () => { const [filterInputs, setFilterInputs] = useState([]); const [pipelineRange, setPipelineRange] = useState(null); const [queriedRange, setQueriedRange] = useState(null); - const [timelineMap, setTimelineMap] = useState>(null); - const [timelineLabelMap, setTimelineLabelMap] = useState<{[k: string]: {[k: string]: UnprocessedUserInput | LabelOption}}>(null); - const [timelineNotesMap, setTimelineNotesMap] = useState<{[k: string]: UnprocessedUserInput[]}>(null); + const [timelineMap, setTimelineMap] = useState(null); + const [timelineLabelMap, setTimelineLabelMap] = useState(null); + const [timelineNotesMap, setTimelineNotesMap] = useState(null); const [displayedEntries, setDisplayedEntries] = useState(null); const [refreshTime, setRefreshTime] = useState(null); const [isLoading, setIsLoading] = useState('replace'); @@ -76,7 +75,7 @@ const LabelTab = () => { // update the displayedEntries according to the active filter useEffect(() => { if (!timelineMap) return setDisplayedEntries(null); - const allEntries = Array.from(timelineMap.values()); + const allEntries = Array.from(timelineMap.values()); const [newTimelineLabelMap, newTimelineNotesMap] = mapInputsToTimelineEntries( allEntries, appConfig, diff --git a/www/js/diary/LabelTabContext.ts b/www/js/diary/LabelTabContext.ts index e5715bf52..944ba19df 100644 --- a/www/js/diary/LabelTabContext.ts +++ b/www/js/diary/LabelTabContext.ts @@ -1,5 +1,5 @@ import { createContext } from 'react'; -import { TimelineEntry, UnprocessedUserInput } from '../types/diaryTypes'; +import { TimelineEntry, UserInputEntry } from '../types/diaryTypes'; import { LabelOption } from '../survey/multilabel/confirmHelper'; export type TimelineMap = Map; @@ -7,15 +7,16 @@ export type TimelineLabelMap = { [k: string]: { /* if the key here is 'SURVEY', we are in the ENKETO configuration, meaning the user input value is a raw survey response */ - SURVEY?: UnprocessedUserInput; - } & { + SURVEY?: UserInputEntry; /* all other keys, (e.g. 'MODE', 'PURPOSE') are from the MULTILABEL configuration and use a LabelOption for the user input value */ - [k: string]: LabelOption; + MODE?: LabelOption; + PURPOSE?: LabelOption; + REPLACED_MODE?: LabelOption; }; }; export type TimelineNotesMap = { - [k: string]: UnprocessedUserInput[]; + [k: string]: UserInputEntry[]; }; type ContextProps = { diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index ba5dd9a7b..faa123803 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -3,7 +3,7 @@ import { getAngularService } from "../angular-react-helper"; import { displayError, logDebug } from "../plugin/logger"; import { getBaseModeByKey, getBaseModeByValue } from "./diaryHelper"; import i18next from "i18next"; -import { UnprocessedUserInput } from "../types/diaryTypes"; +import { UserInputEntry } from "../types/diaryTypes"; import { getLabelInputDetails, getLabelInputs } from "../survey/multilabel/confirmHelper"; import { getNotDeletedCandidates, getUniqueEntries } from "../survey/inputMatcher"; @@ -75,9 +75,9 @@ export function compositeTrips2TimelineMap(ctList: any[], unpackPlaces?: boolean /* 'LABELS' are 1:1 - each trip or place has a single label for each label type (e.g. 'MODE' and 'PURPOSE' for MULTILABEL configuration, or 'SURVEY' for ENKETO configuration) */ -export let unprocessedLabels: { [key: string]: UnprocessedUserInput[] } = {}; +export let unprocessedLabels: { [key: string]: UserInputEntry[] } = {}; /* 'NOTES' are 1:n - each trip or place can have any number of notes */ -export let unprocessedNotes: UnprocessedUserInput[] = []; +export let unprocessedNotes: UserInputEntry[] = []; const getUnprocessedInputQuery = (pipelineRange) => ({ key: "write_ts", diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index 2dc937949..929fb269a 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -1,17 +1,18 @@ import { logDebug, displayErrorMsg } from "../plugin/logger" import { DateTime } from "luxon"; -import { CompositeTrip, TimelineEntry, UnprocessedUserInput } from "../types/diaryTypes"; +import { CompositeTrip, TimelineEntry, UserInputEntry } from "../types/diaryTypes"; import { keysForLabelInputs, unprocessedLabels, unprocessedNotes } from "../diary/timelineHelper"; import { LabelOption, MultilabelKey, getLabelInputDetails, getLabelInputs, getLabelOptions, inputType2retKey, labelKeyToRichMode, labelOptions } from "./multilabel/confirmHelper"; +import { TimelineLabelMap, TimelineNotesMap } from "../diary/LabelTabContext"; const EPOCH_MAXIMUM = 2**31 - 1; export const fmtTs = (ts_in_secs: number, tz: string): string | null => DateTime.fromSeconds(ts_in_secs, {zone : tz}).toISO(); -export const printUserInput = (ui: UnprocessedUserInput): string => `${fmtTs(ui.data.start_ts, ui.metadata.time_zone)} (${ui.data.start_ts}) -> +export const printUserInput = (ui: UserInputEntry): string => `${fmtTs(ui.data.start_ts, ui.metadata.time_zone)} (${ui.data.start_ts}) -> ${fmtTs(ui.data.end_ts, ui.metadata.time_zone)} (${ui.data.end_ts}) ${ui.data.label} logged at ${ui.metadata.write_ts}`; -export const validUserInputForDraftTrip = (trip: CompositeTrip, userInput: UnprocessedUserInput, logsEnabled: boolean): boolean => { +export const validUserInputForDraftTrip = (trip: CompositeTrip, userInput: UserInputEntry, logsEnabled: boolean): boolean => { if(logsEnabled) { logDebug(`Draft trip: comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} @@ -32,7 +33,7 @@ export const validUserInputForDraftTrip = (trip: CompositeTrip, userInput: Unpro export const validUserInputForTimelineEntry = ( tlEntry: TimelineEntry, nextEntry: TimelineEntry | null, - userInput: UnprocessedUserInput, + userInput: UserInputEntry, logsEnabled: boolean, ): boolean => { if (!tlEntry.origin_key) return false; @@ -107,7 +108,7 @@ export const validUserInputForTimelineEntry = ( } // parallels get_not_deleted_candidates() in trip_queries.py -export const getNotDeletedCandidates = (candidates: UnprocessedUserInput[]): UnprocessedUserInput[] => { +export const getNotDeletedCandidates = (candidates: UserInputEntry[]): UserInputEntry[] => { console.log('getNotDeletedCandidates called with ' + candidates.length + ' candidates'); // We want to retain all ACTIVE entries that have not been DELETED @@ -124,8 +125,8 @@ export const getNotDeletedCandidates = (candidates: UnprocessedUserInput[]): Unp export const getUserInputForTimelineEntry = ( entry: TimelineEntry, nextEntry: TimelineEntry | null, - userInputList: UnprocessedUserInput[], -): undefined | UnprocessedUserInput => { + userInputList: UserInputEntry[], +): undefined | UserInputEntry => { const logsEnabled = userInputList?.length < 20; if (userInputList === undefined) { logDebug("In getUserInputForTimelineEntry, no user input, returning undefined"); @@ -162,8 +163,8 @@ export const getUserInputForTimelineEntry = ( export const getAdditionsForTimelineEntry = ( entry: TimelineEntry, nextEntry: TimelineEntry | null, - additionsList: UnprocessedUserInput[], -): UnprocessedUserInput[] => { + additionsList: UserInputEntry[], +): UserInputEntry[] => { const logsEnabled = additionsList?.length < 20; if (additionsList === undefined) { @@ -217,9 +218,9 @@ export const getUniqueEntries = (combinedList) => { * @returns an array containing: (i) an object mapping timeline entry IDs to label inputs, * and (ii) an object mapping timeline entry IDs to note inputs */ -export function mapInputsToTimelineEntries(allEntries: TimelineEntry[], appConfig): [{ [k: string]: { [k: string]: UnprocessedUserInput | LabelOption } }, { [k: string]: UnprocessedUserInput[] }] { - const timelineLabelMap: { [k: string]: { [k: string]: UnprocessedUserInput | LabelOption } } = {}; - const timelineNotesMap: { [k: string]: UnprocessedUserInput[] } = {}; +export function mapInputsToTimelineEntries(allEntries: TimelineEntry[], appConfig): [TimelineLabelMap, TimelineNotesMap] { + const timelineLabelMap: TimelineLabelMap = {}; + const timelineNotesMap: TimelineNotesMap = {}; allEntries.forEach((tlEntry, i) => { const nextEntry = i + 1 < allEntries.length ? allEntries[i + 1] : null; diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index cdb42d64d..cd3f8cd8a 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -4,43 +4,72 @@ import { BaseModeKey, MotionTypeKey } from "../diary/diaryHelper"; -type ConfirmedPlace = any; // TODO +type ObjectId = { $oid: string }; +type ConfirmedPlace = { + _id: ObjectId; + additions: UserInputEntry[]; + cleaned_place: ObjectId; + ending_trip: ObjectId; + enter_fmt_time: string; // ISO string 2023-10-31T12:00:00.000-04:00 + enter_local_dt: LocalDt; + enter_ts: number; // Unix timestamp + key: string; + location: { type: string; coordinates: number[] }; + origin_key: string; + raw_places: ObjectId[]; + source: string; + user_input: { + /* for keys ending in 'user_input' (e.g. 'trip_user_input'), the server gives us the raw user + input object with 'data' and 'metadata' */ + [k: `${string}user_input`]: UserInputEntry; + /* for keys ending in 'confirm' (e.g. 'mode_confirm'), the server just gives us the user input value + as a string (e.g. 'walk', 'drove_alone') */ + [k: `${string}confirm`]: string; + }; +}; /* These are the properties received from the server (basically matches Python code) This should match what Timeline.readAllCompositeTrips returns (an array of these objects) */ export type CompositeTrip = { - _id: {$oid: string}, - additions: any[], // TODO + _id: ObjectId, + additions: UserInputEntry[], cleaned_section_summary: SectionSummary, - cleaned_trip: {$oid: string}, + cleaned_trip: ObjectId, confidence_threshold: number, - confirmed_trip: {$oid: string}, + confirmed_trip: ObjectId, distance: number, duration: number, end_confirmed_place: ConfirmedPlace, end_fmt_time: string, end_loc: {type: string, coordinates: number[]}, end_local_dt: LocalDt, - end_place: {$oid: string}, + end_place: ObjectId, end_ts: number, expectation: any, // TODO "{to_label: boolean}" - expected_trip: {$oid: string}, + expected_trip: ObjectId, inferred_labels: any[], // TODO inferred_section_summary: SectionSummary, - inferred_trip: {$oid: string}, + inferred_trip: ObjectId, key: string, locations: any[], // TODO origin_key: string, - raw_trip: {$oid: string}, + raw_trip: ObjectId, sections: any[], // TODO source: string, start_confirmed_place: ConfirmedPlace, start_fmt_time: string, start_loc: {type: string, coordinates: number[]}, start_local_dt: LocalDt, - start_place: {$oid: string}, + start_place: ObjectId, start_ts: number, - user_input: UnprocessedUserInput, + user_input: { + /* for keys ending in 'user_input' (e.g. 'trip_user_input'), the server gives us the raw user + input object with 'data' and 'metadata' */ + [k: `${string}user_input`]: UserInputEntry; + /* for keys ending in 'confirm' (e.g. 'mode_confirm'), the server just gives us the user input value + as a string (e.g. 'walk', 'drove_alone') */ + [k: `${string}confirm`]: string; + }; } /* The 'timeline' for a user is a list of their trips and places, @@ -68,7 +97,7 @@ export type SectionSummary = { duration: {[k: MotionTypeKey | BaseModeKey]: number}, } -export type UnprocessedUserInput = { +export type UserInputEntry = { data: { end_ts: number, start_ts: number From 22b80f97e508fe89857fac1d8b6f850c8161ba9d Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 31 Oct 2023 13:44:18 -0400 Subject: [PATCH 247/850] fix diaryHelper.test.ts Since 8712380a29af1d748fd4e7bb284353b7393a7e98, the getDetectedModes function no longer goes through the sections of a trip and sums up distances to get percentages. We now use cleaned_section_summary and inferred_section_summary which are computed on the server for us. Updating the test to reflect this causes it to pass again. --- www/__tests__/diaryHelper.test.ts | 35 ++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/www/__tests__/diaryHelper.test.ts b/www/__tests__/diaryHelper.test.ts index 822b19bba..6f49c2720 100644 --- a/www/__tests__/diaryHelper.test.ts +++ b/www/__tests__/diaryHelper.test.ts @@ -30,15 +30,30 @@ it('returns true/false is multi day', () => { expect(isMultiDay("", "2023-09-18T00:00:00-09:00")).toBeFalsy(); }); -//created a fake trip with relevant sections by examining log statements -let myFakeTrip = {sections: [ - { "sensed_mode_str": "BICYCLING", "distance": 6013.73657416706 }, - { "sensed_mode_str": "WALKING", "distance": 715.3078629361006 } -]}; -let myFakeTrip2 = {sections: [ - { "sensed_mode_str": "BICYCLING", "distance": 6013.73657416706 }, - { "sensed_mode_str": "BICYCLING", "distance": 715.3078629361006 } -]}; +/* fake trips with 'distance' in their section summaries + ('count' and 'duration' are not used bygetDetectedModes) */ +let myFakeTrip = { + distance: 6729.0444371031606, + cleaned_section_summary: { + // count: {...} + // duration: {...} + distance: { + BICYCLING: 6013.73657416706, + WALKING: 715.3078629361006, + }, + }, +} as any; + +let myFakeTrip2 = { + ...myFakeTrip, + inferred_section_summary: { + // count: {...} + // duration: {...} + distance: { + BICYCLING: 6729.0444371031606, + }, + }, +}; let myFakeDetectedModes = [ { mode: "BICYCLING", @@ -59,5 +74,5 @@ let myFakeDetectedModes2 = [ it('returns the detected modes, with percentages, for a trip', () => { expect(getDetectedModes(myFakeTrip)).toEqual(myFakeDetectedModes); expect(getDetectedModes(myFakeTrip2)).toEqual(myFakeDetectedModes2); - expect(getDetectedModes({})).toEqual([]); // empty trip, no sections, no modes + expect(getDetectedModes({} as any)).toEqual([]); // empty trip, no sections, no modes }) From b29dc133224b39ee072e62d0fed84d22b7cb8839 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Tue, 31 Oct 2023 13:27:23 -0600 Subject: [PATCH 248/850] Remove default translation for moped in en.json Via https://github.com/e-mission/e-mission-docs/issues/1013#issuecomment-1787627109 --- www/i18n/en.json | 1 - 1 file changed, 1 deletion(-) diff --git a/www/i18n/en.json b/www/i18n/en.json index a3f4642f3..6ec8ddf35 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -158,7 +158,6 @@ "hybrid_shared_ride": "Hybrid Shared Ride", "e_car_drove_alone": "E-Car Drove Alone", "e_car_shared_ride": "E-Car Shared Ride", - "moped": "Moped", "taxi": "Taxi / Uber / Lyft", "bus": "Bus", "train": "Train", From 60abf8442a2e5d0451869304aa176911a2e09da2 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Tue, 31 Oct 2023 13:28:01 -0600 Subject: [PATCH 249/850] Remove moped as a default mode from the default label-options.json.sample file Via https://github.com/e-mission/e-mission-docs/issues/1013#issuecomment-1787627109 --- www/json/label-options.json.sample | 1 - 1 file changed, 1 deletion(-) diff --git a/www/json/label-options.json.sample b/www/json/label-options.json.sample index a2b49258c..7947e2149 100644 --- a/www/json/label-options.json.sample +++ b/www/json/label-options.json.sample @@ -11,7 +11,6 @@ {"value":"hybrid_shared_ride", "baseMode":"CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.0635}, {"value":"e_car_drove_alone", "baseMode":"E_CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.08216}, {"value":"e_car_shared_ride", "baseMode":"E_CAR", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.04108}, - {"value":"moped", "baseMode":"MOPED", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.05555}, {"value":"taxi", "baseMode":"TAXI", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.30741}, {"value":"bus", "baseMode":"BUS", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.20727}, {"value":"train", "baseMode":"TRAIN", "met_equivalent":"IN_VEHICLE", "kgCo2PerKm": 0.12256}, From 4abe86d00b363ade15a87600a268743100456453 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Tue, 31 Oct 2023 14:09:12 -0600 Subject: [PATCH 250/850] Add notifScheduler.ts Translated notifScheduler.js from Angular to React Typescript commHelper.ts - Added a temporary "any" return type for getUser because the TS needed it ProfileSettings.jsx - Imported the new useSchedulerHelper function (name in progress - need to find a good name for this hook) - Replaced any instances of the old Angular NotificationScheduler module with the new schedulerHelper hook notifScheduler.ts - Translated the file from Angular to React TS - This file exports a single function which initializes the appConfig and then returns an object containing the functions that the Notification Scheduler functions need (only ProfileSettings.jsx is using this file currently) - Replaced any instances of moment with Luxon equivalent --- www/js/commHelper.ts | 2 +- www/js/control/ProfileSettings.jsx | 8 +- www/js/splash/notifScheduler.ts | 286 +++++++++++++++++++++++++++++ 3 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 www/js/splash/notifScheduler.ts diff --git a/www/js/commHelper.ts b/www/js/commHelper.ts index b9584a044..1e42633cc 100644 --- a/www/js/commHelper.ts +++ b/www/js/commHelper.ts @@ -147,7 +147,7 @@ export function updateUser(updateDoc) { }); } -export function getUser() { +export function getUser(): any { return new Promise((rs, rj) => { window['cordova'].plugins.BEMServerComm.getUserPersonalData("/profile/get", rs, rj); }).catch(error => { diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 7678cae6b..5b2cac94c 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -25,6 +25,7 @@ import { AppContext } from "../App"; import { shareQR } from "../components/QrCode"; import { storageClear } from "../plugin/storage"; import { getAppVersion } from "../plugin/clientStats"; +import { useSchedulerHelper } from "../splash/notifScheduler"; //any pure functions can go outside const ProfileSettings = () => { @@ -33,6 +34,7 @@ const ProfileSettings = () => { const appConfig = useAppConfig(); const { colors } = useTheme(); const { setPermissionsPopupVis } = useContext(AppContext); + const schedulerHelper = useSchedulerHelper(); //angular services needed const CarbonDatasetHelper = getAngularService('CarbonDatasetHelper'); @@ -173,13 +175,13 @@ const ProfileSettings = () => { const newNotificationSettings ={}; if (uiConfig?.reminderSchemes) { - const prefs = await NotificationScheduler.getReminderPrefs(); + const prefs = await schedulerHelper.getReminderPrefs(); const m = moment(prefs.reminder_time_of_day, 'HH:mm'); newNotificationSettings.prefReminderTimeVal = m.toDate(); const n = moment(newNotificationSettings.prefReminderTimeVal); newNotificationSettings.prefReminderTime = n.format('LT'); newNotificationSettings.prefReminderTimeOnLoad = prefs.reminder_time_of_day; - newNotificationSettings.scheduledNotifs = await NotificationScheduler.getScheduledNotifs(); + newNotificationSettings.scheduledNotifs = await schedulerHelper.getScheduledNotifs(); updatePrefReminderTime(false); } @@ -243,7 +245,7 @@ const ProfileSettings = () => { if(storeNewVal){ const m = moment(newTime); // store in HH:mm - NotificationScheduler.setReminderPrefs({ reminder_time_of_day: m.format('HH:mm') }).then(() => { + schedulerHelper.setReminderPrefs({ reminder_time_of_day: m.format('HH:mm') }).then(() => { refreshNotificationSettings(); }); } diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts new file mode 100644 index 000000000..0474fca5d --- /dev/null +++ b/www/js/splash/notifScheduler.ts @@ -0,0 +1,286 @@ +import angular from 'angular'; +import React, { useEffect, useState } from "react"; +import { getConfig } from '../config/dynamicConfig'; +import useAppConfig from "../useAppConfig"; +import { addStatReading, statKeys } from '../plugin/clientStats'; +import { getUser, updateUser } from '../commHelper'; +import { logDebug } from "../plugin/logger"; +import { DateTime } from "luxon"; +import i18next from 'i18next'; + +let scheduledPromise = new Promise((rs) => rs()); +let scheduledNotifs = []; +let isScheduling = false; + +// like python range() +function range(start, stop, step) { + let a = [start], b = start; + while (b < stop) + a.push(b += step || 1); + return a; +} + +// returns an array of moment objects, for all times that notifications should be sent +const calcNotifTimes = (scheme, dayZeroDate, timeOfDay) => { + const notifTimes = []; + for (const s of scheme.schedule) { + // the days to send notifications, as integers, relative to day zero + const notifDays = range(s.start, s.end, s.intervalInDays); + for (const d of notifDays) { + const date = DateTime.fromFormat(dayZeroDate, 'yyyy-MM-dd').plus({ days: d}).toFormat('yyyy-MM-dd') + const notifTime = DateTime.fromFormat(date+' '+timeOfDay, 'yyyy-MM-dd HH:mm'); + notifTimes.push(notifTime); + } + } + return notifTimes; +} + +// returns true if all expected times are already scheduled +const areAlreadyScheduled = (notifs, expectedTimes) => { + for (const t of expectedTimes) { + if (!notifs.some((n) => DateTime.fromMillis(n.trigger.at).equals(t))) { + return false; + } + } + return true; +} + +/* remove notif actions as they do not work, can restore post routing migration */ +// const setUpActions = () => { +// const action = { +// id: 'action', +// title: 'Change Time', +// launch: true +// }; +// return new Promise((rs) => { +// cordova.plugins.notification.local.addActions('reminder-actions', [action], rs); +// }); +// } +function debugGetScheduled(prefix) { + window['cordova'].plugins.notification.local.getScheduled((notifs) => { + if (!notifs?.length) + return logDebug(`${prefix}, there are no scheduled notifications`); + const time = DateTime.fromMillis(notifs?.[0].trigger.at).toFormat('HH:mm'); + //was in plugin, changed to scheduler + scheduledNotifs = notifs.map((n) => { + const time = DateTime.fromMillis(n.trigger.at).toFormat('t'); + const date = DateTime.fromMillis(n.trigger.at).toFormat('DDD'); + return { + key: date, + val: time + } + }); + //have the list of scheduled show up in this log + logDebug(`${prefix}, there are ${notifs.length} scheduled notifications at ${time} first is ${scheduledNotifs[0].key} at ${scheduledNotifs[0].val}`); + }); +} + +//new method to fetch notifications +const getScheduledNotifs = function() { + return new Promise((resolve, reject) => { + /* if the notifications are still in active scheduling it causes problems + anywhere from 0-n of the scheduled notifs are displayed + if actively scheduling, wait for the scheduledPromise to resolve before fetching prevents such errors + */ + if(isScheduling) + { + console.log("requesting fetch while still actively scheduling, waiting on scheduledPromise"); + scheduledPromise.then(() => { + getNotifs().then((notifs) => { + console.log("done scheduling notifs", notifs); + resolve(notifs); + }) + }) + } + else{ + getNotifs().then((notifs) => { + resolve(notifs); + }) + } + }) +} + +//get scheduled notifications from cordova plugin and format them +const getNotifs = function() { + return new Promise((resolve, reject) => { + window['cordova'].plugins.notification.local.getScheduled((notifs) => { + if (!notifs?.length){ + console.log("there are no notifications"); + resolve([]); //if none, return empty array + } + + const notifSubset = notifs.slice(0, 5); //prevent near-infinite listing + let scheduledNotifs = []; + scheduledNotifs = notifSubset.map((n) => { + const time = DateTime.fromMillis(n.trigger.at).toFormat('t'); + const date = DateTime.fromMillis(n.trigger.at).toFormat('DDD'); + return { + key: date, + val: time + } + }); + resolve(scheduledNotifs); + }); + }) +} + +// schedules the notifications using the cordova plugin +const scheduleNotifs = (scheme, notifTimes) => { + return new Promise((rs) => { + isScheduling = true; + const localeCode = i18next.resolvedLanguage; + console.error("notifTimes: ", notifTimes, " - type: ", typeof(notifTimes)); + const nots = notifTimes.map((n) => { + console.error("n: ", n, " - type: ", typeof(n)); + const nDate = n.toDate(); + const seconds = nDate.getTime() / 1000; + return { + id: seconds, + title: scheme.title[localeCode], + text: scheme.text[localeCode], + trigger: {at: nDate}, + // actions: 'reminder-actions', + // data: { + // action: { + // redirectTo: 'root.main.control', + // redirectParams: { + // openTimeOfDayPicker: true + // } + // } + // } + } + }); + window['cordova'].plugins.notification.local.cancelAll(() => { + debugGetScheduled("After cancelling"); + window['cordova'].plugins.notification.local.schedule(nots, () => { + debugGetScheduled("After scheduling"); + isScheduling = false; + rs(); //scheduling promise resolved here + }); + }); + }); +} + +// determines when notifications are needed, and schedules them if not already scheduled +const update = async (reminderSchemes) => { + const { reminder_assignment, + reminder_join_date, + reminder_time_of_day} = await getReminderPrefs(reminderSchemes); + var scheme = {}; + try { + scheme = reminderSchemes[reminder_assignment]; + } catch (e) { + console.log("ERROR: Could not find reminder scheme for assignment " + reminderSchemes + " - " + reminder_assignment); + } + const notifTimes = calcNotifTimes(scheme, reminder_join_date, reminder_time_of_day); + return new Promise((resolve, reject) => { + window['cordova'].plugins.notification.local.getScheduled((notifs) => { + if (areAlreadyScheduled(notifs, notifTimes)) { + logDebug("Already scheduled, not scheduling again"); + } else { + // to ensure we don't overlap with the last scheduling() request, + // we'll wait for the previous one to finish before scheduling again + scheduledPromise.then(() => { + if (isScheduling) { + console.log("ERROR: Already scheduling notifications, not scheduling again") + } else { + scheduledPromise = scheduleNotifs(scheme, notifTimes); + //enforcing end of scheduling to conisder update through + scheduledPromise.then(() => { + resolve(); + }) + } + }); + } + }); + }); +} + +/* Randomly assign a scheme, set the join date to today, + and use the default time of day from config (or noon if not specified) + This is only called once when the user first joins the study +*/ +const initReminderPrefs = (reminderSchemes) => { + // randomly assign from the schemes listed in config + const schemes = Object.keys(reminderSchemes); + const randAssignment = schemes[Math.floor(Math.random() * schemes.length)]; + const todayDate = DateTime.local().toFormat('yyyy-MM-dd'); + const defaultTime = reminderSchemes[randAssignment]?.defaultTime || '12:00'; + return { + reminder_assignment: randAssignment, + reminder_join_date: todayDate, + reminder_time_of_day: defaultTime, + }; +} + +/* EXAMPLE VALUES - present in user profile object + reminder_assignment: 'passive', + reminder_join_date: '2023-05-09', + reminder_time_of_day: '21:00', +*/ +// interface ReminderPrefs { +// reminder_assignment: string; +// reminder_join_date: string; +// reminder_time_of_day: string; +// } + +const getReminderPrefs = async (reminderSchemes): Promise => { + const user = await getUser(); + if (user?.reminder_assignment && + user?.reminder_join_date && + user?.reminder_time_of_day) { + console.log("User already has reminder prefs, returning them", user) + return user; + } + // if no prefs, user just joined, so initialize them + console.log("User just joined, Initializing reminder prefs") + const initPrefs = initReminderPrefs(reminderSchemes); + console.log("Initialized reminder prefs: ", initPrefs); + await setReminderPrefs(initPrefs, reminderSchemes); + return { ...user, ...initPrefs }; // user profile + the new prefs +} +const setReminderPrefs = async (newPrefs, reminderSchemes) => { + await updateUser(newPrefs) + const updatePromise = new Promise((resolve, reject) => { + //enforcing update before moving on + update(reminderSchemes).then(() => { + resolve(); + }); + }); + // record the new prefs in client stats + getReminderPrefs(reminderSchemes).then((prefs) => { + // extract only the relevant fields from the prefs, + // and add as a reading to client stats + const { reminder_assignment, + reminder_join_date, + reminder_time_of_day} = prefs; + addStatReading(statKeys.REMINDER_PREFS, { + reminder_assignment, + reminder_join_date, + reminder_time_of_day + }).then(logDebug("Added reminder prefs to client stats")); + }); + return updatePromise; +} + +export function useSchedulerHelper() { + const appConfig = useAppConfig(); + const [reminderSchemes, setReminderSchemes] = useState(); + + useEffect(() => { + if (!appConfig) { + logDebug("No reminder schemes found in config, not scheduling notifications"); + return; + } + setReminderSchemes(appConfig.reminderSchemes); + }, [appConfig]); + + //setUpActions(); + update(reminderSchemes); + + return { + setReminderPrefs: (newPrefs) => setReminderPrefs(newPrefs, reminderSchemes), + getReminderPrefs: () => getReminderPrefs(reminderSchemes), + getScheduledNotifs: () => getScheduledNotifs(), + } +} \ No newline at end of file From ea3fb10d468689dddb6c89a72d947030b1668272 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 31 Oct 2023 14:11:31 -0600 Subject: [PATCH 251/850] create event handler React doesn't quite support custom events, so we're writing our own event handling code this will apply to our custom events, rather than the built-in events like 'onClick' --- www/js/customEventHandler.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 www/js/customEventHandler.ts diff --git a/www/js/customEventHandler.ts b/www/js/customEventHandler.ts new file mode 100644 index 000000000..4284501b2 --- /dev/null +++ b/www/js/customEventHandler.ts @@ -0,0 +1,30 @@ +/** + * since react doesn't quite support custom events, writing our own handler + * having the ability to broadcast and emit events prevents files from being tightly coupled + * if we want something else to happen when an event is emitted, we can just listen for it + * instead of having to change the code at the point the event is emitted + * + * looser coupling = point of broadcast doesn't 'know' what is triggered by that event + * leads to more extensible code + * consistent event names help us know what happens when + * + * code based on: https://blog.logrocket.com/using-custom-events-react/ + */ + +import { logDebug } from './plugin/logger'; + +export function subscribe(eventName: string, listener) { + logDebug("adding " + eventName + " listener"); + document.addEventListener(eventName, listener); +} + +export function unsubscribe(eventName: string, listener){ + logDebug("removing " + eventName + " listener"); + document.removeEventListener(eventName, listener); +} + +export function publish(eventName, data) { + logDebug("publishing " + eventName); + const event = new CustomEvent(eventName, { detail: data }); + document.dispatchEvent(event); +} \ No newline at end of file From d267fcfbef9b15b3ed8965184f1a5b2463a3a6ae Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 31 Oct 2023 14:13:28 -0600 Subject: [PATCH 252/850] change file name in preparation for full de-angularization, renaming the file to preserve some of the blame history --- www/js/splash/{pushnotify.js => pushNotifySettings.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename www/js/splash/{pushnotify.js => pushNotifySettings.ts} (100%) diff --git a/www/js/splash/pushnotify.js b/www/js/splash/pushNotifySettings.ts similarity index 100% rename from www/js/splash/pushnotify.js rename to www/js/splash/pushNotifySettings.ts From 30c534c523937c7be3957e002a63554e165aca3b Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Tue, 31 Oct 2023 13:17:58 -0700 Subject: [PATCH 253/850] Cleaned out `www/img/` directory --- www/img/adam.jpg | Bin 42497 -> 0 bytes www/img/avatar_1.png | Bin 5090 -> 0 bytes www/img/banana.png | Bin 17036 -> 0 bytes www/img/ben.png | Bin 266479 -> 0 bytes www/img/cookie.png | Bin 19056 -> 0 bytes www/img/ic_header_gem.png | Bin 3775 -> 0 bytes www/img/ic_header_gold.png | Bin 2946 -> 0 bytes www/img/ic_header_healer.png | Bin 21425 -> 0 bytes www/img/ic_header_mage.png | Bin 20533 -> 0 bytes www/img/ic_header_rogue.png | Bin 21230 -> 0 bytes www/img/ic_header_silver.png | Bin 3491 -> 0 bytes www/img/ic_header_warrior.png | Bin 5654 -> 0 bytes www/img/ic_navigation_black_24dp.png | Bin 803 -> 0 bytes www/img/icecream.png | Bin 16271 -> 0 bytes www/img/intro/splash_screen_logo.png | Bin 30923 -> 0 bytes www/img/ionic.png | Bin 4757 -> 0 bytes www/img/max.png | Bin 18611 -> 0 bytes www/img/mike.png | Bin 238136 -> 0 bytes www/img/minus.gif | Bin 4635 -> 0 bytes www/img/nileredsea_126b5740_small.png | Bin 36171 -> 0 bytes www/img/pacman.gif | Bin 80346 -> 0 bytes www/img/perry.png | Bin 571942 -> 0 bytes www/img/plus.gif | Bin 4633 -> 0 bytes 23 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 www/img/adam.jpg delete mode 100644 www/img/avatar_1.png delete mode 100644 www/img/banana.png delete mode 100644 www/img/ben.png delete mode 100644 www/img/cookie.png delete mode 100644 www/img/ic_header_gem.png delete mode 100644 www/img/ic_header_gold.png delete mode 100644 www/img/ic_header_healer.png delete mode 100644 www/img/ic_header_mage.png delete mode 100644 www/img/ic_header_rogue.png delete mode 100644 www/img/ic_header_silver.png delete mode 100644 www/img/ic_header_warrior.png delete mode 100644 www/img/ic_navigation_black_24dp.png delete mode 100644 www/img/icecream.png delete mode 100644 www/img/intro/splash_screen_logo.png delete mode 100644 www/img/ionic.png delete mode 100644 www/img/max.png delete mode 100644 www/img/mike.png delete mode 100644 www/img/minus.gif delete mode 100644 www/img/nileredsea_126b5740_small.png delete mode 100644 www/img/pacman.gif delete mode 100644 www/img/perry.png delete mode 100644 www/img/plus.gif diff --git a/www/img/adam.jpg b/www/img/adam.jpg deleted file mode 100644 index 5a5d37ccc65392c4337b4988440719109caef3c9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 42497 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 diff --git a/www/img/banana.png b/www/img/banana.png deleted file mode 100644 index 0a7beedf6b04ca8ad9534f9b16f02ed6c1d79ecb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17036 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 VMopW00KN?3{H6wQ4^9b-{|`#9@+bfR diff --git a/www/img/ben.png b/www/img/ben.png deleted file mode 100644 index 374d32ef1b52729664e12ade5a8e5ce2ec668229..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 266479 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
Whh#p5;r!@rw=@2=(~cW8 z0-UA#{Rl@J&W50^?tszWS^s+d+@76>+LF8Obe9ZjW4gu7T`I;RPqLw>Zz)&OBDq@s z4cTPO&=heH(7JtFe%N2X#Mm_IF~6(SYpqQSphdMGfyBK#ltl@D3+eQ@8--W4Y6#WRcSVQ-BV_m(_8IHTdub@ zo(TNh-^qCT*QI3x&^XQB{-?fe!FaPsytc6U+XwY#_B$vw0wL_)yM{MO z=2YAnRbWn0omX(yZ3284o%Q$bJEwiNGPmIATz6;Xp(~lfonx`jC1dn61CV8s$;Dk= zECJ$xXKVkp*uBL{#h%~Go#DWN8DCqT+#Sy%DjLZQwruZz|GV&u-{AJmg0%Lrxju0d zRvOdPd{<6K-%zIfZp*Sjfa0QsdCT^i54WwVs$Ogf&;&}SmX`fAeH2@?D7KOP^haWp zQ`HoG-SF0kg)-2T{@Hm{_x1OBqR(`5^WWJ;+x@F2oxv+b-%`Ixj@EKbTMO90&8 zXqgL5GrA+c=u0zArZcQT-%=s?uk;>FD)%NgqaHmo5-pj$(z} zlXRrO*`A1APv1IzB7jPbm!wp8Z2AUm?(eoA()(kd?wpC5BReMdHvBq=OqFyL7r2p? zU7No&+f;34C31&pws=W(cUMD`YifS-_QnnE^Sr6oln31-S314;)DC~&G*_{HM_RV0 zWqJCPd1cY3#$&MZaOXIyYFT!9$5&9yZyhxjXmS$-pH=^Ci))$?EWTXf*5GgZx1EDuH*jzI}0opn@cA zsxCvfJLingr7EmuYLaC|3G|~q{6=oi)cUvPmy0!n8B;~|)ywu1Hl_ e`AxT+E|q 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
m9J(>h;wtHZQ@X*)t4JRvXN6v z9o6;<8RG=Am(0i^w_ba{=&#zL)nSboN!1FQo0H71r!dNIOFwxW4|=q8!snlG3mU6? z-NGHKw1U<+1&Hb1^GN)E_!pUxyjO`5s5row818C9|Z9M`;)L`9{A^ zdmQ|#-QVPPZs>L$yxoXD%G-#G+U-5$0qx8&adFubNN#fUFWz2W-X|Sjo;8+O!Q}`m zgG$Yv>7qjIDz?=iHdAlu3n#3Pe?j)I!W)J}kAP?iPxi0(5Uiz%RY&{et@L~9wyiCN z`4kjpgZB2la}zyxF%JOnlMGhDx^CwF&pa~;P5}d!oiv=uTYVa;<5xU5R4c(6UZ&_1}@GRLvaKA&E8~W;>ZVoIy9H~ zq4nEXrouw3;oTDAc(`>-8$NQ4W+1HIk3c&n+UWZN5q(RByqWg;a261sj?sr7t|`|- zQie=%eIqe++b<2Ur4gM!b}ZuDJmTX%5coR>031f!v9?wmbB&w}-7fXz_2^`V8YpMR zj6aTG6!Yiv@lRX()Dofft?jhT^u+a;Xoj4a+EDf9@3rM6#j+upDKWAOfhU6)^DT{d zc47*gKlpFS@_8S!cSqr5!?}CU5Xgxz-aMk5Mr4_X_EW$;$sgW}**M39OFgRf)IViD z1~a0iTuM5#FC}md)N^Fp#CA-3H15W1xkIW#7Tzn#k@iMw)8&|7yYdQrWjZD-f^oZL zt;*r%wi68I23x^AvwZ3xVLW+KGbK^j26>?VxHmgPK^>fMHOSdtOc3vi7XXyEIDYfs z=*-{_iyXfn8F$Ry*ymj^ijM}uQ2cxPPW3~B*B-kcb6yuL%KXuc<>PweIB|bap${-2 zG^j%=U{ilV0XxsSt|!O>=UzVt`h4FI*loM@dgugKJn|kj!z^bT3@-CtXDhvlG*Ir%A1QjNh7{Ou#B{Oq%aQ6g^k4* zuaXsp6`Upu)Qpg0ymLpAJ8O@0TvI)V_O%r zORCTd6_MQZu;AJqYiS^3H&0SImvxBD%i6Lbsmx0-h!Vf_XVCLR7+T8u|bIaps z%*CTC#(S0;7HqV85Z1NL*ocNjl2a1>yc~8olrU0r#phD0g%_iS=Gl)+ zodeA8naz$U+lRtI;~#*f{>%3FRPLswT^bChZd~M(fwRiBoI+tU4_ZjPwQfhN^_~6M*yXlg3T%NWh@lsE53^!lF zIWKB%EME|lj_+tE$4h6PcUrp<1^KIx+D!WO=!JwQ(yox(M%(@IG&j*HuH2#7%#sdl zh;Dx~q#5f83Yznad+HWpV2D-j5w~^(e85#rj`h?w;PL*x`vc_k2QT>vdYnC;Xl#v z0eU|De4e#k`Ml1oaBj)P?&29Tl%^nl!EK}|aG6tW>TP1*p65+t)^|j0lT`c{$_>wq zu7i-{4^qUSxN>Uq>L`s8L>JCc8Z6@)GL5CBKHlfw#W-9rG5dDI(-#to%Ncm)B;ow} zX4CZa){5xa#(s~{@fpI;o`s#oy$>6Q(@RI+Esh6-efJ2WY`+uXzSyw6P4@89z1EId zpaA~rHqXwyA)6-Y(pNAZBb#oSl=k&h!51p!O`!VxlW43dhhgKNa^75`cP{^t z+B)jd+XV2unf5vjVpyC%LamGp8cc7tg{qj0v<(BP^JFTdxzw<-nPkoD&jrp=s+9y# zHl#P9CNBeJ!AKxfl6b#cBW-<}hX2i8Umb0W#H)ir8xeqLxiL>)MS+0S7+393?DB95 zd=@TYK6>LyL^M_ul`aX*w146vr%l!U{Z&eXA+bu6X!Ru~Y)CZ8rY<%v^(>K*eogO@ z)i0{>UO=c4ohM^yLXhZ1N-9FnJO_8J-F@S6uwQj+lGQ+X9`Vd@)ZVYjh2xFadPe(s zAA+O%-i5c>R{OI%{XKp%S+y4lyL_Xqg08Is!%oFv1yIW*AD6Tg9smZLoOF+zi~TvF zqs?svcy2jm-s>SeiB7-~yS_fm{AQ}gW{9MPTeDFfo zUSo+{2bg~F>1nE!0gauiK0VEWWR9(HvhBTjS>E4A8|7Pm#oJjOzl_oJoBbE?S()3K J(M;|q{6F)}&lLaw diff --git a/www/img/cookie.png b/www/img/cookie.png deleted file mode 100644 index ec7f5a5c7941e18ae35d72790e40e3d363f2eb9d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19056 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 T(7v9T{|RMmXl{_L=XCI2#@@KT diff --git a/www/img/ic_header_gem.png b/www/img/ic_header_gem.png deleted file mode 100644 index 30f8b4deafa93e05caabfe2e76beb7c72a2c71af..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3775 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 diff --git a/www/img/ic_header_healer.png b/www/img/ic_header_healer.png deleted file mode 100644 index c52dc41ebed8d23022ca7a31c208734f330f8d67..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21425 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 diff --git a/www/img/ic_header_mage.png b/www/img/ic_header_mage.png deleted file mode 100644 index 3395fd5fcd0e6a11636513b9d392639d24000159..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20533 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 diff --git a/www/img/ic_header_rogue.png b/www/img/ic_header_rogue.png deleted file mode 100644 index 7ac2929c75e5649d90e01bf32ce0562935ac38cb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21230 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 Rsi*(|002ovPDHLkV1l!G(JKG| diff --git a/www/img/ic_header_warrior.png b/www/img/ic_header_warrior.png deleted file mode 100644 index 0b76436de49cfabab02b06a4773b9d5a8a7eb433..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5654 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 diff --git a/www/img/ic_navigation_black_24dp.png b/www/img/ic_navigation_black_24dp.png deleted file mode 100644 index 760fcd19398dcff80e8fc4d24ada394ac6c61aee..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 803 zcmV+;1Kj+HP)p;xj>8*|pnx966Fb<40{V>xyl_1Vj_dHk$0#^H!UHFx z3bo_BXHbQn#&J28P=%Jr;k294Hud4O8MIAb;;=JOh|a)SFQE{P;HcgFheDKRH%__( zh3s}5lwux*Y@QU(xeyV)3vkYBi159NV~%DG5xzB!!YTKmD!UhlWLZE}wm=qVT#ahz zDxC2?s-gFA#EER68rt9lobUvyvBz;hBY&bA`-20CT@R|G8;RYgsE$4%W}T?cI*8S? zsLqCn(ZMXEI{Sx%h|L~kpnhUAiwrbNOwLAzI*VA0BSVc5gCi**L#=QGk-G~S>`o$< z=6htY??@A=OOfF&AyRK3!%Y#PV_8RrTjv-e^ANJogG8i(Uy+3tIe0@J_^}9{p2W@V-Me= zke!Edub>cpKsRZEL7K~%Mj?6`!&+EH6WL0NuA1UE}Bjntx%KIRa?{X_<1u0(;o%|)cBM~aJi7X@|&rUWeV4AF)u?F^Gg zWxazjW2mHG7~n8WJDfolQAwj1lVuWZ^#wQ3fGs=dVFqnAP6mUrOz=8gMB!Yf7$w6t h5{X12kw_#G{{u$Q=OTm$M?(Mr002ovPDHLkV1j13WN!cf diff --git a/www/img/icecream.png b/www/img/icecream.png deleted file mode 100644 index 5a4f464363e8d42383dc46649414a669a6ebeb1b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16271 zcmeI3dsGuw9><55C~DPO6-6}0Vtq|!@+M;lg+v84$YRu>rwfzG1g0dJm<$k*-9>3t z>}t>YYPGdRrB($Wt1epEt=aX_MO%An-3s)GxYhb7N2Tj?Y3)uD2;l;=?zVgO?4HRv z$;|Km?)P_RKEHc^ck;)Sr>D&v7&R&if}nw^DY^{ysP=yQ_F;dq?;4!!p`Rlq(*;4I z@!oHU`@O^h2%5H?G8h@7ehx;^HW6;7O{BxpXf>2Hd?4%;rR|4~*U8_1bJb4((#5 z7qAH0k@l!`8HyYvl0mxYd?!IpFJNzFd{1}JP?pZ*bcy8k^m^^2o9ad8_4sAJso>9$ zn!Q&{>m+f8b{c5fs`1aV^bUjqt=2cI1QYc*LD{`rC#exo)S26a~eQ!eWI1m0=P&ChHO-s3#bMT|s7?!JjHh zaNnRfW&*R&P8-f>C>x$bA`W|w8tH5d>KT|f4;wm2-YxW8C&kt}+#1x! zd-eb75IuzHHdG4bVrOVk(9C7$udkZTBbRh-?QmErUsZD8P8aE2a|yzZMHeVzfhy(I z_|`6l6W)5JA-qe1G^>#=t(}(@P`1mc?g@N4%KrF4^4`ZN%q8ty3(6nL*Q@s+F51Er z;!ZLthn*8ox7z)2y9&Vp)QEQnuu}hlRJibhAD0MHY_gb0aXd_z;w7+5qEf*)t}?Md zs6}Qn$xWo0U8nzEB08#)Sk4xvj>`7mOho5QCUS9m4r$gP9rxDJ@V}J1r>lZLE_Ypa zl>dv|`B!7t%Gj}2bg!gp?*j$KR$;ax;J)f7RwLcoI%kc)ztLMwt@*zGQq=cB#NI=@ zHrNkpGyjP;*bizurSt6+qmi@^2Q&w%yw5%UEqNaMh)!x0a=BElM!;rJkxscut5HX> zd#B60BPiG#^JzdwkHXB}gh7sWp+8wc57WLH>ThKq)!BauNZ@ZmxAGd;hknYF&O7=k zqXMz`K>#4!CXb7o58&l-0YJD-9v3$sz{}$TfN+~UE^a=6m&XMF;Wl|(+ZYY19*8{01$4I$HmPD@bb6-AlxR8i<=MN<#7Q(xJ@1xHy^;u;{t$i zn>;RVK7g0U1pwhTIdMgGzIaR8*|%*A+1F}Ss^kmoE4cz9B|{HE#c>c+S_VOne_)R{ zAt)b(pj+AOi@t9{&pZ4Z9B43>ucS?YWW% z4O!`f$9^~R$+-P{@9%q5VLVW=>xLFyu30tQV5t6GQ`|k%;`LiD#g0|%O+~~LYTvqK zi%d>FD7{{i^YzIi~L8hwmSr*tmS3tm?+qY0C%I2;#;r-9A8*?U_*WS>Kiv zd zFQ-keP1Vk#M(JK0b|LIO{?YBms?`ywVpZk~O(k~|>YEO=?m873U9}yVUo5g-K9#t2 zTlLMO4f_3+8<8^?w%VrDEN#2;@Y6k0!dujmeEO@$_a11AG)CFsZ_^;-%&ettNq^{f z^6x*CU(7p+ocn#UGq#zz^2?*IJ#5wdm04C7QXC$gJMYF?fiwEZE5~Nn|043Y4@N_m zr@vuYSh9U>_AqtJZpp2bFwfuCHxF;synf|-_lo$~Y}MI!9HsrT>6p?%AxDOU*@xWU zgidQiFSVfwZDj`*{_3UXV=FhsZ+#@Cmn5zcJ{F%;dbh4oHF=fbvHkhuhn%Zk?*EsJ?_GCe z9GV&R4?U*}E}r;^2p?0iX6~3x*CHI_`_!wNnG9^q5_eJGH`Ai-hBf0SJ@21DpS83) zB+2zvo^L8cvLc#hgqH}XYA5U-TD5Me$C$BfWodF{`HVl6jwqFmYmA9pv3|e+$Ld=< zXMI!NCpGe&aM8-_>r)(xfgU_gSoY#1*fV#*tfOC7J~#j7Nb=&h#{S1flp<8vhY_;2 zoKOCIux3}=`A=G|;mt*hmLZ|AGe4R_0%W0$WaHuup)etddk?(-p!Mxdb^mc6xPkp1&9AEaMDxz_Mx a05r332zj#n(j4zA^QqI*bo-LCm;VEocsL&b diff --git a/www/img/intro/splash_screen_logo.png b/www/img/intro/splash_screen_logo.png deleted file mode 100644 index ffabdd3774b6a40e37d249e36f2eae85d94f66d2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 30923 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
;xGU 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 i00mVT@%kmc!U*I1<^S(5R@!u0{5U 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 V)A2w7aX)_)nHgO`RT#L({STJyPAvcc diff --git a/www/img/max.png b/www/img/max.png deleted file mode 100644 index a4ab62b2f589518a1b800c78ce47c3c33d44a26d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18611 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 diff --git a/www/img/mike.png b/www/img/mike.png deleted file mode 100644 index e9abd4ddd38063a46c5418108d8b1dfafff96e2b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 238136 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
  • !Td^)&?RW?zkthHV&2pv^Z+GuT}r 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�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 z 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 diff --git a/www/img/plus.gif b/www/img/plus.gif deleted file mode 100644 index 6879c87437a5e0f5e79cf1227dd074e178f87a69..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4633 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 Date: Tue, 31 Oct 2023 13:54:34 -0700 Subject: [PATCH 254/850] Refactored `controlHelper.ts`, updated fileIO - Changed fileIO to match `uplaodService.ts`, should allow proper jest mocking & testing - Refactored function structures; rather than 3 separate higher order functions, `getMyDataHelpers()` now returns an object containing each of the closures. --- www/js/control/ProfileSettings.jsx | 2 +- www/js/services/controlHelper.ts | 82 +++++++++++++++++------------- 2 files changed, 49 insertions(+), 35 deletions(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index f5b2b93d6..64d80c22e 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -25,7 +25,7 @@ import { AppContext } from "../App"; import { shareQR } from "../components/QrCode"; import { storageClear } from "../plugin/storage"; import { getAppVersion } from "../plugin/clientStats"; -import { fetchOPCode, getSettings } from "../controlHelper"; +import { fetchOPCode, getSettings } from "../services/controlHelper"; //any pure functions can go outside const ProfileSettings = () => { diff --git a/www/js/services/controlHelper.ts b/www/js/services/controlHelper.ts index d297c24c0..c88d92bf3 100644 --- a/www/js/services/controlHelper.ts +++ b/www/js/services/controlHelper.ts @@ -1,25 +1,20 @@ import { DateTime } from "luxon"; import { getRawEntries } from "./commHelper"; -import { logInfo, displayError, logDebug } from "../plugin/logger"; +import { logInfo, displayError, logDebug, logWarn } from "../plugin/logger"; import { FsWindow } from "../types/fileShareTypes" import { ServerResponse} from "../types/serverData"; import i18next from "../i18nextInit" ; declare let window: FsWindow; -/** - * createWriteFile is a factory method for the JSON dump file creation - * @param fileName is the name of the file to be created - * @returns a function that returns a promise, which writes the file upon evaluation. - */ -export const createWriteFile = function (fileName: string) { - return function(result: ServerResponse) { +export const getMyDataHelpers = function(fileName: string, startTimeString: string, endTimeString: string) { + const localWriteFile = function (result: ServerResponse) { const resultList = result.phone_data; return new Promise(function(resolve, reject) { - window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { + window['resolveLocalFileSystemURL'](window['cordova'].file.tempDirectory, function(fs) { logDebug(`file system open: ${fs.name}`); - fs.root.getFile(fileName, { create: true, exclusive: false }, function (fileEntry) { + fs.filesystem.root.getFile(fileName, { create: true, exclusive: false }, function (fileEntry) { logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`) fileEntry.createWriter(function (fileWriter) { fileWriter.onwriteend = function() { @@ -39,21 +34,13 @@ export const createWriteFile = function (fileName: string) { }); }); }); -}}; + }; -/** - * createShareData returns a shareData method, with the input parameters captured. - * @param fileName is the existing file to be sent - * @param startTimeString timestamp used to identify the file - * @param endTimeString " " - * @returns a function which returns a promise, which shares an existing file upon evaluation. - */ -export const createShareData = function(fileName: string, startTimeString: string, endTimeString: string) { - return function() { + const localShareData = function () { return new Promise(function(resolve, reject) { - window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { - logDebug(`During email, file system open: ${fs.name}`); - fs.root.getFile(fileName, null, function(fileEntry) { + window['resolveLocalFileSystemURL'](window['cordova'].file.tempDirectory, function(fs) { + logDebug(`During share, file system open: ${fs.name}`); + fs.filesystem.root.getFile(fileName, null, function(fileEntry) { logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`); fileEntry.file(function(file) { const reader = new FileReader(); @@ -64,12 +51,12 @@ export const createShareData = function(fileName: string, startTimeString: strin const dataArray = JSON.parse(readResult); logDebug(`Successfully read resultList of size ${dataArray.length}`); let attachFile = fileEntry.nativeURL; - const email = { + const shareObj = { 'files': [attachFile], 'message': i18next.t("email-service.email-data.body-data-consists-of-list-of-entries"), - 'subject': i18next.t("email-service.email-data.subject-data-dump-from-to", {start: startTimeString ,end: endTimeString}), + 'subject': i18next.t("email-service.email-data.subject-data-dump-from-to", {start: startTimeString, end: endTimeString}), } - window['plugins'].socialsharing.shareWithOptions(email, function (result) { + window['plugins'].socialsharing.shareWithOptions(shareObj, function (result) { logDebug(`Share Completed? ${result.completed}`); // On Android, most likely returns false logDebug(`Shared to app: ${result.app}`); resolve(); @@ -81,11 +68,38 @@ export const createShareData = function(fileName: string, startTimeString: strin }, function(error) { displayError(error, "Error while downloading JSON dump"); reject(error); - }) + }); + }); }); + }) + }; + + // window['cordova'].file.TempDirectory is not guaranteed to free up memory, + // so it's good practice to remove the file right after it's used! + const localClearData = function() { + return new Promise(function(resolve, reject) { + window['resolveLocalFileSystemURL'](window['cordova'].file.tempDirectory, function(fs) { + fs.filesystem.root.getFile(fileName, null, function(fileEntry) { + fileEntry.remove(() => { + logDebug(`Successfully cleaned up file ${fileName}`); + resolve(); + }, + (err) => { + logWarn(`Error deleting ${fileName} : ${err}`); + reject(err); + }); + }); + }); }); -}}; + } + + return { + writeFile: localWriteFile, + shareData: localShareData, + clearData: localClearData, + }; +} /** * getMyData fetches timeline data for a given day, and then gives the user a prompt to share the data @@ -104,17 +118,17 @@ export const getMyData = function(timeStamp: Date) { + ".timeline"; alert(`Going to retrieve data to ${dumpFile}`); - const writeDumpFile = createWriteFile(dumpFile); - const shareData = createShareData(dumpFile, startTimeString, endTimeString); + const getDataMethods = getMyDataHelpers(dumpFile, startTimeString, endTimeString); getRawEntries(null, startTime.toUnixInteger(), endTime.toUnixInteger()) - .then(writeDumpFile) - .then(shareData) + .then(getDataMethods.writeFile) + .then(getDataMethods.shareData) + .then(getDataMethods.clearData) .then(function() { - logInfo("Email queued successfully"); + logInfo("Share queued successfully"); }) .catch(function(error) { - displayError(error, "Error emailing JSON dump"); + displayError(error, "Error sharing JSON dump"); }) }; From 7d69847752ce448f94c7a70cfdcd7e31b6d77e60 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 31 Oct 2023 14:57:45 -0600 Subject: [PATCH 255/850] rewrite pushNotify in react updating to React, including the event subscription model usage --- www/js/splash/pushNotifySettings.ts | 303 ++++++++++++++-------------- 1 file changed, 150 insertions(+), 153 deletions(-) diff --git a/www/js/splash/pushNotifySettings.ts b/www/js/splash/pushNotifySettings.ts index 40d859f09..c20db243b 100644 --- a/www/js/splash/pushNotifySettings.ts +++ b/www/js/splash/pushNotifySettings.ts @@ -15,176 +15,173 @@ import angular from 'angular'; import { updateUser } from '../commHelper'; +import { logDebug, displayError } from '../plugin/logger'; +import { publish, subscribe, EVENT_NAMES } from '../customEventHandler'; +import { getAngularService } from '../angular-react-helper'; -angular.module('emission.splash.pushnotify', ['emission.plugin.logger', - 'emission.services', - 'emission.splash.startprefs']) -.factory('PushNotify', function($window, $state, $rootScope, $ionicPlatform, - $ionicPopup, Logger, StartPrefs) { +let push = null; - var pushnotify = {}; - var push = null; - pushnotify.CLOUD_NOTIFICATION_EVENT = 'cloud:push:notification'; - - pushnotify.startupInit = function() { - push = $window.PushNotification.init({ - "ios": { - "badge": true, - "sound": true, - "vibration": true, - "clearBadge": true - }, - "android": { - "iconColor": "#008acf", - "icon": "ic_mood_question", - "clearNotifications": true +const startupInit = function () { + push = window['PushNotification'].init({ + "ios": { + "badge": true, + "sound": true, + "vibration": true, + "clearBadge": true + }, + "android": { + "iconColor": "#008acf", + "icon": "ic_mood_question", + "clearNotifications": true + } + }); + push.on('notification', function (data) { + if (window['cordova'].platformId == 'ios') { + // Parse the iOS values that are returned as strings + if (angular.isDefined(data) && + angular.isDefined(data.additionalData)) { + if (angular.isDefined(data.additionalData.payload)) { + data.additionalData.payload = JSON.parse(data.additionalData.payload); } - }); - push.on('notification', function(data) { - if ($ionicPlatform.is('ios')) { - // Parse the iOS values that are returned as strings - if(angular.isDefined(data) && - angular.isDefined(data.additionalData)) { - if(angular.isDefined(data.additionalData.payload)) { - data.additionalData.payload = JSON.parse(data.additionalData.payload); - } - if(angular.isDefined(data.additionalData.data) && typeof(data.additionalData.data) == "string") { - data.additionalData.data = JSON.parse(data.additionalData.data); - } else { - console.log("additionalData is already an object, no need to parse it"); - } - } else { - Logger.log("No additional data defined, nothing to parse"); - } + if (angular.isDefined(data.additionalData.data) && typeof (data.additionalData.data) == "string") { + data.additionalData.data = JSON.parse(data.additionalData.data); + } else { + console.log("additionalData is already an object, no need to parse it"); } - $rootScope.$emit(pushnotify.CLOUD_NOTIFICATION_EVENT, data); - }); + } else { + logDebug("No additional data defined, nothing to parse"); + } } + publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, data); + }); +} - pushnotify.registerPromise = function() { - return new Promise(function(resolve, reject) { - pushnotify.startupInit(); - push.on("registration", function(data) { - console.log("Got registration " + data); - resolve({token: data.registrationId, - type: data.registrationType}); - }); - push.on("error", function(error) { - console.log("Got push error " + error); - reject(error); - }); - console.log("push notify = "+push); - }); - } +const registerPromise = function () { + return new Promise(function (resolve, reject) { + startupInit(); + push.on("registration", function (data) { + console.log("Got registration " + data); + resolve({ + token: data.registrationId, + type: data.registrationType + }); + }); + push.on("error", function (error) { + console.log("Got push error " + error); + reject(error); + }); + console.log("push notify = " + push); + }); +} - pushnotify.registerPush = function() { - pushnotify.registerPromise().then(function(t) { - // alert("Token = "+JSON.stringify(t)); - Logger.log("Token = "+JSON.stringify(t)); - return $window.cordova.plugins.BEMServerSync.getConfig().then(function(config) { - return config.sync_interval; - }, function(error) { - console.log("Got error "+error+" while reading config, returning default = 3600"); - return 3600; - }).then(function(sync_interval) { - updateUser({ - device_token: t.token, - curr_platform: ionic.Platform.platform(), - curr_sync_interval: sync_interval - }); - return t; - }); - }).then(function(t) { - // alert("Finished saving token = "+JSON.stringify(t.token)); - Logger.log("Finished saving token = "+JSON.stringify(t.token)); - }).catch(function(error) { - Logger.displayError("Error in registering push notifications", error); +const registerPush = function () { + registerPromise().then(function (t) { + // alert("Token = "+JSON.stringify(t)); + logDebug("Token = " + JSON.stringify(t)); + return window['cordova'].plugins.BEMServerSync.getConfig().then(function (config) { + return config.sync_interval; + }, function (error) { + console.log("Got error " + error + " while reading config, returning default = 3600"); + return 3600; + }).then(function (sync_interval) { + updateUser({ + device_token: t['token'], + curr_platform: window['cordova'].platformId, + curr_sync_interval: sync_interval }); - } + return t; + }); + }).then(function (t) { + // alert("Finished saving token = "+JSON.stringify(t.token)); + logDebug("Finished saving token = " + JSON.stringify(t.token)); + }).catch(function (error) { + displayError(error, "Error in registering push notifications"); + }); +} - var redirectSilentPush = function(event, data) { - Logger.log("Found silent push notification, for platform "+ionic.Platform.platform()); - if (!$ionicPlatform.is('ios')) { - Logger.log("Platform is not ios, handleSilentPush is not implemented or needed"); - // doesn't matter if we finish or not because platforms other than ios don't care - return; - } - Logger.log("Platform is ios, calling handleSilentPush on DataCollection"); - var notId = data.additionalData.payload.notId; - var finishErrFn = function(error) { - Logger.log("in push.finish, error = "+error); - }; +const redirectSilentPush = function (event, data) { + logDebug("Found silent push notification, for platform " + window['cordova'].platformId); + if (window['cordova'].platformId != 'ios') { + logDebug("Platform is not ios, handleSilentPush is not implemented or needed"); + // doesn't matter if we finish or not because platforms other than ios don't care + return; + } + logDebug("Platform is ios, calling handleSilentPush on DataCollection"); + var notId = data.additionalData.payload.notId; + var finishErrFn = function (error) { + logDebug("in push.finish, error = " + error); + }; - pushnotify.datacollect.getConfig().then(function(config) { - if(config.ios_use_remote_push_for_sync) { - pushnotify.datacollect.handleSilentPush() - .then(function() { - Logger.log("silent push finished successfully, calling push.finish"); - showDebugLocalNotification("silent push finished, calling push.finish"); - push.finish(function(){}, finishErrFn, notId); - }) - } else { - Logger.log("Using background fetch for sync, no need to redirect push"); - push.finish(function(){}, finishErrFn, notId); - }; + window['cordova'].plugins.BEMDataCollection.getConfig().then(function (config) { + if (config.ios_use_remote_push_for_sync) { + window['cordova'].plugins.BEMDataCollection.handleSilentPush() + .then(function () { + logDebug("silent push finished successfully, calling push.finish"); + showDebugLocalNotification("silent push finished, calling push.finish"); + push.finish(function () { }, finishErrFn, notId); }) - .catch(function(error) { - push.finish(function(){}, finishErrFn, notId); - Logger.displayError("Error while redirecting silent push", error); - }); - } + } else { + logDebug("Using background fetch for sync, no need to redirect push"); + push.finish(function () { }, finishErrFn, notId); + }; + }) + .catch(function (error) { + push.finish(function () { }, finishErrFn, notId); + displayError(error, "Error while redirecting silent push"); + }); +} - var showDebugLocalNotification = function(message) { - pushnotify.datacollect.getConfig().then(function(config) { - if(config.simulate_user_interaction) { - cordova.plugins.notification.local.schedule({ - id: 1, - title: "Debug javascript notification", - text: message, - actions: [], - category: 'SIGN_IN_TO_CLASS' - }); - } - }); +var showDebugLocalNotification = function (message) { + window['cordova'].plugins.BEMDataCollection.getConfig().then(function (config) { + if (config.simulate_user_interaction) { + window['cordova'].plugins.notification.local.schedule({ + id: 1, + title: "Debug javascript notification", + text: message, + actions: [], + category: 'SIGN_IN_TO_CLASS' + }); } + }); +} - pushnotify.registerNotificationHandler = function() { - $rootScope.$on(pushnotify.CLOUD_NOTIFICATION_EVENT, function(event, data) { - Logger.log("data = "+JSON.stringify(data)); - if (data.additionalData["content-available"] == 1) { - redirectSilentPush(event, data); - }; // else no need to call finish - }); - }; +const onCloudEvent = function (event, data) { + logDebug("data = " + JSON.stringify(data)); + if (data.additionalData["content-available"] == 1) { + redirectSilentPush(event, data); + }; // else no need to call finish +} - $ionicPlatform.ready().then(function() { - pushnotify.datacollect = $window.cordova.plugins.BEMDataCollection; - StartPrefs.readConsentState() - .then(StartPrefs.isConsented) - .then(function(consentState) { - if (consentState == true) { - pushnotify.registerPush(); - } else { - Logger.log("no consent yet, waiting to sign up for remote push"); - } - }); - pushnotify.registerNotificationHandler(); - Logger.log("pushnotify startup done"); - }); +const onConsentEvent = function (event, data) { + const StartPrefs = getAngularService('StartPrefs'); + console.log("got consented event " + JSON.stringify(event['name']) + + " with data " + JSON.stringify(data)); + if (StartPrefs.isIntroDone()) { + console.log("intro is done -> reconsent situation, we already have a token -> register"); + registerPush(); + } +} - $rootScope.$on(StartPrefs.CONSENTED_EVENT, function(event, data) { - console.log("got consented event "+JSON.stringify(event.name) - +" with data "+ JSON.stringify(data)); - if (StartPrefs.isIntroDone()) { - console.log("intro is done -> reconsent situation, we already have a token -> register"); - pushnotify.registerPush(); +const onIntroEvent = function (event, data) { + console.log("intro is done -> original consent situation, we should have a token by now -> register"); + registerPush(); +} + +const initPushNotify = function () { + const StartPrefs = getAngularService('StartPrefs'); + StartPrefs.readConsentState() + .then(StartPrefs.isConsented) + .then(function (consentState) { + if (consentState == true) { + registerPush(); + } else { + logDebug("no consent yet, waiting to sign up for remote push"); } }); - $rootScope.$on(StartPrefs.INTRO_DONE_EVENT, function(event, data) { - console.log("intro is done -> original consent situation, we should have a token by now -> register"); - pushnotify.registerPush(); - }); + subscribe(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, (event) => onCloudEvent(event, event['detail'].data)); + subscribe(StartPrefs.CONSENTED_EVENT, (event) => onConsentEvent(event, event['detail'].data)); + subscribe(StartPrefs.INTRO_DONE_EVENT, (event) => onIntroEvent(event, event['detail'].data)); - return pushnotify; -}); + logDebug("pushnotify startup done"); +} From 56156a0255076db3e00a84729332baa46c4a03ca Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 31 Oct 2023 15:03:03 -0600 Subject: [PATCH 256/850] remove references to old file --- www/index.js | 1 - www/js/controllers.js | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/www/index.js b/www/index.js index 0a0c63708..429085657 100644 --- a/www/index.js +++ b/www/index.js @@ -6,7 +6,6 @@ import 'leaflet/dist/leaflet.css'; import './js/ngApp.js'; import './js/splash/referral.js'; import './js/splash/startprefs.js'; -import './js/splash/pushnotify.js'; import './js/splash/storedevicesettings.js'; import './js/splash/localnotify.js'; import './js/splash/remotenotify.js'; diff --git a/www/js/controllers.js b/www/js/controllers.js index 75124efce..323359ad0 100644 --- a/www/js/controllers.js +++ b/www/js/controllers.js @@ -4,7 +4,6 @@ import angular from 'angular'; import { addStatError, addStatReading, statKeys } from './plugin/clientStats'; angular.module('emission.controllers', ['emission.splash.startprefs', - 'emission.splash.pushnotify', 'emission.splash.storedevicesettings', 'emission.splash.localnotify', 'emission.splash.remotenotify']) @@ -14,7 +13,7 @@ angular.module('emission.controllers', ['emission.splash.startprefs', .controller('DashCtrl', function($scope) {}) .controller('SplashCtrl', function($scope, $state, $interval, $rootScope, - StartPrefs, PushNotify, StoreDeviceSettings, + StartPrefs, StoreDeviceSettings, LocalNotify, RemoteNotify) { console.log('SplashCtrl invoked'); // alert("attach debugger!"); From 332bfbe50444545148e928e0e8aec1eb21217817 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 31 Oct 2023 15:03:14 -0600 Subject: [PATCH 257/850] centralize the event names --- www/js/customEventHandler.ts | 6 ++++++ www/js/splash/pushNotifySettings.ts | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/www/js/customEventHandler.ts b/www/js/customEventHandler.ts index 4284501b2..9f24243ff 100644 --- a/www/js/customEventHandler.ts +++ b/www/js/customEventHandler.ts @@ -13,6 +13,12 @@ import { logDebug } from './plugin/logger'; +export const EVENT_NAMES = { + CLOUD_NOTIFICATION_EVENT: 'cloud:push:notification', + CONSENTED_EVENT: "data_collection_consented", + INTRO_DONE_EVENT: "intro_done", +} + export function subscribe(eventName: string, listener) { logDebug("adding " + eventName + " listener"); document.addEventListener(eventName, listener); diff --git a/www/js/splash/pushNotifySettings.ts b/www/js/splash/pushNotifySettings.ts index c20db243b..e7a142264 100644 --- a/www/js/splash/pushNotifySettings.ts +++ b/www/js/splash/pushNotifySettings.ts @@ -180,8 +180,8 @@ const initPushNotify = function () { }); subscribe(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, (event) => onCloudEvent(event, event['detail'].data)); - subscribe(StartPrefs.CONSENTED_EVENT, (event) => onConsentEvent(event, event['detail'].data)); - subscribe(StartPrefs.INTRO_DONE_EVENT, (event) => onIntroEvent(event, event['detail'].data)); + subscribe(EVENT_NAMES.CONSENTED_EVENT, (event) => onConsentEvent(event, event['detail'].data)); + subscribe(EVENT_NAMES.INTRO_DONE_EVENT, (event) => onIntroEvent(event, event['detail'].data)); logDebug("pushnotify startup done"); } From a9918140c7d848c0bc733a55294fb005141349a7 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 31 Oct 2023 15:08:32 -0600 Subject: [PATCH 258/850] emit AND publish events for now, so they get picked up both ways --- www/js/splash/startprefs.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/www/js/splash/startprefs.js b/www/js/splash/startprefs.js index 92a07e624..96e049b35 100644 --- a/www/js/splash/startprefs.js +++ b/www/js/splash/startprefs.js @@ -1,5 +1,6 @@ import angular from 'angular'; import { getConfig } from '../config/dynamicConfig'; +import { EVENT_NAMES, publish } from '../customEventHandler'; import { storageGet, storageSet } from '../plugin/storage'; angular.module('emission.splash.startprefs', ['emission.plugin.logger', @@ -36,6 +37,7 @@ angular.module('emission.splash.startprefs', ['emission.plugin.logger', // mark in local variable as well $rootScope.curr_consented = angular.copy($rootScope.req_consent); $rootScope.$emit(startprefs.CONSENTED_EVENT, $rootScope.req_consent); + publish(EVENT_NAMES.CONSENTED_EVENT, $rootScope.req_consent); }); }; @@ -43,6 +45,7 @@ angular.module('emission.splash.startprefs', ['emission.plugin.logger', var currTime = moment().format(); storageSet(INTRO_DONE_KEY, currTime); $rootScope.$emit(startprefs.INTRO_DONE_EVENT, currTime); + publish(EVENT_NAMES.INTRO_DONE_EVENT, currTime); } // returns boolean From a90af2f6ecdd515ba6bd4496e70b41cf16614ad7 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 31 Oct 2023 15:27:20 -0600 Subject: [PATCH 259/850] add function descriptions --- www/js/splash/pushNotifySettings.ts | 43 +++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/www/js/splash/pushNotifySettings.ts b/www/js/splash/pushNotifySettings.ts index e7a142264..2a112364c 100644 --- a/www/js/splash/pushNotifySettings.ts +++ b/www/js/splash/pushNotifySettings.ts @@ -21,6 +21,10 @@ import { getAngularService } from '../angular-react-helper'; let push = null; +/** + * @function initializes the PushNotification in window, + * assigns on 'notification' functionality + */ const startupInit = function () { push = window['PushNotification'].init({ "ios": { @@ -56,6 +60,12 @@ const startupInit = function () { }); } +/** + * @function registers notifications and handles result + * @returns Promise for initialization logic, + * resolves on registration with token + * rejects on error with error + */ const registerPromise = function () { return new Promise(function (resolve, reject) { startupInit(); @@ -74,6 +84,10 @@ const registerPromise = function () { }); } +/** + * @function registers for notifications and updates user + * currently called on reconsent and on intro done + */ const registerPush = function () { registerPromise().then(function (t) { // alert("Token = "+JSON.stringify(t)); @@ -99,6 +113,12 @@ const registerPush = function () { }); } +/** + * @function handles silent push notifications + * works with BEMDataCollection plugin + * @param data from the notification + * @returns early if platform is not ios + */ const redirectSilentPush = function (event, data) { logDebug("Found silent push notification, for platform " + window['cordova'].platformId); if (window['cordova'].platformId != 'ios') { @@ -131,6 +151,10 @@ const redirectSilentPush = function (event, data) { }); } +/** + * @function shows debug notifications if simulating user interaction + * @param message string to display in the degug notif + */ var showDebugLocalNotification = function (message) { window['cordova'].plugins.BEMDataCollection.getConfig().then(function (config) { if (config.simulate_user_interaction) { @@ -145,6 +169,11 @@ var showDebugLocalNotification = function (message) { }); } +/** + * @function handles pushNotification intitially + * @param event that called this function + * @param data from the notification + */ const onCloudEvent = function (event, data) { logDebug("data = " + JSON.stringify(data)); if (data.additionalData["content-available"] == 1) { @@ -152,6 +181,11 @@ const onCloudEvent = function (event, data) { }; // else no need to call finish } +/** + * @function registers push on reconsent + * @param event that called this function + * @param data data from the conesnt event + */ const onConsentEvent = function (event, data) { const StartPrefs = getAngularService('StartPrefs'); console.log("got consented event " + JSON.stringify(event['name']) @@ -162,11 +196,20 @@ const onConsentEvent = function (event, data) { } } +/** + * @function registers push after intro received + * @param event that called this function + * @param data from the event + */ const onIntroEvent = function (event, data) { console.log("intro is done -> original consent situation, we should have a token by now -> register"); registerPush(); } +/** + * startup code - + * @function registers push if consented, subscribes event listeners for local handline + */ const initPushNotify = function () { const StartPrefs = getAngularService('StartPrefs'); StartPrefs.readConsentState() From 09cbaa14459c4c1f5b36ab36742ce28deab4c531 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 31 Oct 2023 16:11:53 -0600 Subject: [PATCH 260/850] ensure proper initialization --- www/js/App.tsx | 2 ++ www/js/onboarding/onboardingHelper.ts | 2 ++ www/js/splash/pushNotifySettings.ts | 2 +- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/www/js/App.tsx b/www/js/App.tsx index 3c6c8bec9..72bdc4712 100644 --- a/www/js/App.tsx +++ b/www/js/App.tsx @@ -11,6 +11,7 @@ import { OnboardingRoute, OnboardingState, getPendingOnboardingState } from './o import { setServerConnSettings } from './config/serverConn'; import AppStatusModal from './control/AppStatusModal'; import usePermissionStatus from './usePermissionStatus'; +import { initPushNotify } from './splash/pushNotifySettings'; const defaultRoutes = (t) => [ { key: 'label', title: t('diary.label-tab'), focusedIcon: 'check-bold', unfocusedIcon: 'check-outline' }, @@ -52,6 +53,7 @@ const App = () => { setServerConnSettings(appConfig).then(() => { refreshOnboardingState(); }); + initPushNotify(); }, [appConfig]); const appContextValue = { diff --git a/www/js/onboarding/onboardingHelper.ts b/www/js/onboarding/onboardingHelper.ts index abd8e78aa..05ace6068 100644 --- a/www/js/onboarding/onboardingHelper.ts +++ b/www/js/onboarding/onboardingHelper.ts @@ -3,6 +3,7 @@ import { getAngularService } from "../angular-react-helper"; import { getConfig, resetDataAndRefresh } from "../config/dynamicConfig"; import { storageGet, storageSet } from "../plugin/storage"; import { logDebug } from "../plugin/logger"; +import { EVENT_NAMES, publish } from "../customEventHandler"; export const INTRO_DONE_KEY = 'intro_done'; @@ -71,5 +72,6 @@ async function readIntroDone() { export async function markIntroDone() { const currDateTime = DateTime.now().toISO(); + publish(EVENT_NAMES.INTRO_DONE_EVENT, currDateTime); return storageSet(INTRO_DONE_KEY, currDateTime); } diff --git a/www/js/splash/pushNotifySettings.ts b/www/js/splash/pushNotifySettings.ts index 2a112364c..4e98d669f 100644 --- a/www/js/splash/pushNotifySettings.ts +++ b/www/js/splash/pushNotifySettings.ts @@ -210,7 +210,7 @@ const onIntroEvent = function (event, data) { * startup code - * @function registers push if consented, subscribes event listeners for local handline */ -const initPushNotify = function () { +export const initPushNotify = function () { const StartPrefs = getAngularService('StartPrefs'); StartPrefs.readConsentState() .then(StartPrefs.isConsented) From b1838aa43a3867543340032171279f8937ee56e1 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 31 Oct 2023 16:38:53 -0600 Subject: [PATCH 261/850] add tests for customEventHandler tests gleaned from https://blog.logrocket.com/using-custom-events-react/ --- www/__tests__/customEventHandler.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 www/__tests__/customEventHandler.test.ts diff --git a/www/__tests__/customEventHandler.test.ts b/www/__tests__/customEventHandler.test.ts new file mode 100644 index 000000000..d45dba01b --- /dev/null +++ b/www/__tests__/customEventHandler.test.ts @@ -0,0 +1,24 @@ +import { publish, subscribe, unsubscribe } from "../js/customEventHandler"; +import { mockLogger } from "../__mocks__/globalMocks"; + +mockLogger(); + +it('subscribes and publishes to an event', () => { + const listener = jest.fn(); + subscribe("test", listener); + publish("test", "test data"); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + type: "test", + detail: "test data", + }) + ); +}) + +it('can unsubscribe', () => { + const listener = jest.fn(); + subscribe("test", listener); + unsubscribe("test", listener); + publish("test", "test data"); + expect(listener).not.toHaveBeenCalled(); +}) \ No newline at end of file From 2dca41e2b424e72d5188bd5411baf0646e07aa2d Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Tue, 31 Oct 2023 16:55:45 -0600 Subject: [PATCH 262/850] finish resolving merge conflicts after merging, needed to fix the differences in the was startprefs is used --- www/js/splash/pushNotifySettings.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/www/js/splash/pushNotifySettings.ts b/www/js/splash/pushNotifySettings.ts index 4e98d669f..b6d4f8986 100644 --- a/www/js/splash/pushNotifySettings.ts +++ b/www/js/splash/pushNotifySettings.ts @@ -17,7 +17,8 @@ import angular from 'angular'; import { updateUser } from '../commHelper'; import { logDebug, displayError } from '../plugin/logger'; import { publish, subscribe, EVENT_NAMES } from '../customEventHandler'; -import { getAngularService } from '../angular-react-helper'; +import { isConsented, readConsentState } from './startprefs'; +import { readIntroDone } from '../onboarding/onboardingHelper'; let push = null; @@ -187,13 +188,14 @@ const onCloudEvent = function (event, data) { * @param data data from the conesnt event */ const onConsentEvent = function (event, data) { - const StartPrefs = getAngularService('StartPrefs'); console.log("got consented event " + JSON.stringify(event['name']) + " with data " + JSON.stringify(data)); - if (StartPrefs.isIntroDone()) { - console.log("intro is done -> reconsent situation, we already have a token -> register"); - registerPush(); - } + readIntroDone().then((isIntroDone) => { + if (isIntroDone) { + console.log("intro is done -> reconsent situation, we already have a token -> register"); + registerPush(); + } + }) } /** @@ -211,9 +213,8 @@ const onIntroEvent = function (event, data) { * @function registers push if consented, subscribes event listeners for local handline */ export const initPushNotify = function () { - const StartPrefs = getAngularService('StartPrefs'); - StartPrefs.readConsentState() - .then(StartPrefs.isConsented) + readConsentState() + .then(isConsented) .then(function (consentState) { if (consentState == true) { registerPush(); From 24a9d95e71abad94e63a168f738b8ac6a80b1604 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Tue, 31 Oct 2023 16:09:02 -0700 Subject: [PATCH 263/850] Updated jest testing, cordovaMocks - With the change in the previous commit, controlHelper methods can now be mocked via fileSystemMocks and cordovaMocks --- www/__mocks__/cordovaMocks.ts | 3 +- www/__mocks__/fileSystemMocks.ts | 15 +++++++- www/__tests__/controlHelper.test.ts | 55 +++++++++-------------------- 3 files changed, 33 insertions(+), 40 deletions(-) diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index c8f4b4c0f..181f6c07d 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -26,7 +26,8 @@ export const mockGetAppVersion = () => { export const mockFile = () => { window['cordova'].file = { "dataDirectory" : "../path/to/data/directory", - "applicationStorageDirectory" : "../path/to/app/storage/directory"}; + "applicationStorageDirectory" : "../path/to/app/storage/directory", + "tempDirectory" : "../path/to/temp/directory"}; } export const mockBEMUserCache = () => { diff --git a/www/__mocks__/fileSystemMocks.ts b/www/__mocks__/fileSystemMocks.ts index d7c2743ac..6c822311f 100644 --- a/www/__mocks__/fileSystemMocks.ts +++ b/www/__mocks__/fileSystemMocks.ts @@ -10,7 +10,20 @@ export const mockFileSystem = () => { file: (handleFile) => { let file = new File(["this is a mock"], "loggerDB"); handleFile(file); - } + }, + nativeURL: 'file:///Users/Jest/test/URL/', + isFile: true, + createWriter: (handleWriter) => { + const mockFileWriter = { + fileWriter: { + write: (myObect) => { + console.log(`Wrote: ${myObect}`) + }, + onwriteend: () => {}, + onerror: (error) => { return error; } + } + } + }, } onSuccess(fileEntry); } diff --git a/www/__tests__/controlHelper.test.ts b/www/__tests__/controlHelper.test.ts index f205121f0..941839c2f 100644 --- a/www/__tests__/controlHelper.test.ts +++ b/www/__tests__/controlHelper.test.ts @@ -1,19 +1,31 @@ import { mockLogger } from "../__mocks__/globalMocks"; -import { createWriteFile } from "../js/services/controlHelper"; +import { mockFileSystem } from "../__mocks__/fileSystemMocks"; +import { mockDevice, mockCordova, mockFile } from "../__mocks__/cordovaMocks"; + +import { getMyDataHelpers } from "../js/services/controlHelper"; import { FsWindow } from "../js/types/fileShareTypes" import { ServerData, ServerResponse} from "../js/types/serverData" +mockDevice(); +mockCordova(); +mockFile(); +mockFileSystem(); mockLogger(); declare let window: FsWindow; -const fileName = 'test.timeline' -const writeFile = createWriteFile(fileName); + +// Test constants: +const fileName = 'testOne' +const startTime = '1969-06-16' +const endTime = '1969-06-24' +const getDataMethodsOne = getMyDataHelpers(fileName, startTime, endTime); +const writeFile = getDataMethodsOne.writeFile; // createWriteFile does not require these objects specifically, but it // is better to test with similar data - using real data would take // up too much code space, and we cannot use getRawEnteries() in testing const generateFakeValues = (arraySize: number) => { if (arraySize <= 0) - return new Promise (() => {return []}); + return Promise.reject(); const sampleDataObj : ServerData= { data: { @@ -51,10 +63,7 @@ const generateFakeValues = (arraySize: number) => { values.forEach((element, index) => { values[index].data.name = element.data.name + index.toString() }); - - return new Promise>(() => { - return { phone_data: values }; - }); + return Promise.resolve({ phone_data: values }); }; // A variation of createShareData; confirms the file has been written, @@ -81,37 +90,7 @@ const confirmFileExists = (fileName: string, dataCluster: ServerResponse) = it('writes a file for an array of objects', async () => { const testPromiseOne = generateFakeValues(1); const testPromiseTwo = generateFakeValues(2222); - expect(testPromiseOne.then(writeFile)).resolves.not.toThrow(); expect(testPromiseTwo.then(writeFile)).resolves.not.toThrow(); }); -it('correctly writes the files', async () => { - const testPromise = generateFakeValues(1); - let dataCluster = null; - testPromise.then((result) => {dataCluster = result}); - const fileExists = confirmFileExists(fileName, dataCluster); - const temp = createWriteFile('badFile.test') - - expect(testPromise.then(temp).then(fileExists)).resolves.toEqual(false); - expect(testPromise.then(writeFile).then(fileExists)).resolves.not.toThrow(); - expect(testPromise.then(writeFile).then(fileExists)).resolves.toEqual(true); -}); - -it('rejects an empty input', async () => { - const testPromise = generateFakeValues(0); - expect(testPromise.then(writeFile)).rejects.toThrow(); - - let dataCluster = null; - testPromise.then((result) => {dataCluster = result}); - const fileExists = confirmFileExists(fileName, dataCluster); - expect(testPromise.then(writeFile).then(fileExists)).resolves.toEqual(false); -}); - -/* - createShareData() is not tested, because it relies on the phoneGap social - sharing plugin, which cannot be mocked. - - getMyData relies on createShareData, and likewise cannot be tested - it also - relies on getRawEnteries(). -*/ From bb8f1ec3e3777e511fa6bf63a998605189aad429 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 1 Nov 2023 09:35:44 -0600 Subject: [PATCH 264/850] add rough tests, correct data access the way to access the data from the event is with event.detail, not event.detail.data I discovered and fixed this, as well as adding mocks, when I started a draft test currently runs the code, but does not check that it runs correctly - used to know mocks were sufficient --- www/__mocks__/pushNotificationMocks.ts | 29 ++++++++++++++++++++++++ www/__tests__/pushNotifySettings.test.ts | 27 ++++++++++++++++++++++ www/js/customEventHandler.ts | 2 +- www/js/splash/pushNotifySettings.ts | 6 ++--- 4 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 www/__mocks__/pushNotificationMocks.ts create mode 100644 www/__tests__/pushNotifySettings.test.ts diff --git a/www/__mocks__/pushNotificationMocks.ts b/www/__mocks__/pushNotificationMocks.ts new file mode 100644 index 000000000..66bd550ca --- /dev/null +++ b/www/__mocks__/pushNotificationMocks.ts @@ -0,0 +1,29 @@ +let notifSettings; +let onList = {}; + +export const mockPushNotification = () => { + window['PushNotification'] = { + init: (settings: Object) => { + notifSettings = settings; + return { + on: (event: string, callback: Function) => { + onList[event] = callback; + } + }; + }, + }; +} + +export const clearNotifMock = function () { + notifSettings = {}; + onList = {}; +} + +export const getOnList = function () { + return onList; +} + +export const fakeEvent = function (eventName : string) { + //fake the event by executing whatever we have stored for it + onList[eventName](); +} \ No newline at end of file diff --git a/www/__tests__/pushNotifySettings.test.ts b/www/__tests__/pushNotifySettings.test.ts new file mode 100644 index 000000000..06621087c --- /dev/null +++ b/www/__tests__/pushNotifySettings.test.ts @@ -0,0 +1,27 @@ +import { EVENT_NAMES, publish } from '../js/customEventHandler'; +import { initPushNotify } from '../js/splash/pushNotifySettings'; +import { mockCordova, mockBEMUserCache } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; +import { mockPushNotification } from '../__mocks__/pushNotificationMocks'; + +mockCordova(); +mockLogger(); +mockPushNotification(); +mockBEMUserCache(); + +global.fetch = (url: string) => new Promise((rs, rj) => { + setTimeout(() => rs({ + json: () => new Promise((rs, rj) => { + let myJSON = { "emSensorDataCollectionProtocol": { "protocol_id": "2014-04-6267", "approval_date": "2016-07-14" } }; + setTimeout(() => rs(myJSON), 100); + }) + })); +}) as any; + +it('initializes the push notifications', () => { + initPushNotify(); + + publish(EVENT_NAMES.CONSENTED_EVENT, "test data"); + publish(EVENT_NAMES.INTRO_DONE_EVENT, "test data"); + publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, {additionalData: {'content-avaliable': 1}}); +}) \ No newline at end of file diff --git a/www/js/customEventHandler.ts b/www/js/customEventHandler.ts index 9f24243ff..e84fe8123 100644 --- a/www/js/customEventHandler.ts +++ b/www/js/customEventHandler.ts @@ -30,7 +30,7 @@ export function unsubscribe(eventName: string, listener){ } export function publish(eventName, data) { - logDebug("publishing " + eventName); + logDebug("publishing " + eventName + " with data " + JSON.stringify(data)); const event = new CustomEvent(eventName, { detail: data }); document.dispatchEvent(event); } \ No newline at end of file diff --git a/www/js/splash/pushNotifySettings.ts b/www/js/splash/pushNotifySettings.ts index b6d4f8986..29c036a85 100644 --- a/www/js/splash/pushNotifySettings.ts +++ b/www/js/splash/pushNotifySettings.ts @@ -223,9 +223,9 @@ export const initPushNotify = function () { } }); - subscribe(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, (event) => onCloudEvent(event, event['detail'].data)); - subscribe(EVENT_NAMES.CONSENTED_EVENT, (event) => onConsentEvent(event, event['detail'].data)); - subscribe(EVENT_NAMES.INTRO_DONE_EVENT, (event) => onIntroEvent(event, event['detail'].data)); + subscribe(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, (event) => onCloudEvent(event, event.detail)); + subscribe(EVENT_NAMES.CONSENTED_EVENT, (event) => onConsentEvent(event, event.detail)); + subscribe(EVENT_NAMES.INTRO_DONE_EVENT, (event) => onIntroEvent(event, event.detail)); logDebug("pushnotify startup done"); } From c2ab799f46a0526bbbf0eaa5b0c78b24d0dce1e6 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Wed, 1 Nov 2023 08:48:07 -0700 Subject: [PATCH 265/850] Added rejection test --- www/__tests__/controlHelper.test.ts | 25 +++++++++++++++---------- www/js/services/controlHelper.ts | 2 -- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/www/__tests__/controlHelper.test.ts b/www/__tests__/controlHelper.test.ts index 941839c2f..730e6e69f 100644 --- a/www/__tests__/controlHelper.test.ts +++ b/www/__tests__/controlHelper.test.ts @@ -13,19 +13,12 @@ mockFileSystem(); mockLogger(); declare let window: FsWindow; -// Test constants: -const fileName = 'testOne' -const startTime = '1969-06-16' -const endTime = '1969-06-24' -const getDataMethodsOne = getMyDataHelpers(fileName, startTime, endTime); -const writeFile = getDataMethodsOne.writeFile; - // createWriteFile does not require these objects specifically, but it // is better to test with similar data - using real data would take // up too much code space, and we cannot use getRawEnteries() in testing const generateFakeValues = (arraySize: number) => { if (arraySize <= 0) - return Promise.reject(); + return Promise.reject('reject'); const sampleDataObj : ServerData= { data: { @@ -87,10 +80,22 @@ const confirmFileExists = (fileName: string, dataCluster: ServerResponse) = }; }; +// Test constants: +const fileName = 'testOne' +const startTime = '1969-06-16' +const endTime = '1969-06-24' +const getDataMethodsOne = getMyDataHelpers(fileName, startTime, endTime); +const writeFile = getDataMethodsOne.writeFile; + +const testPromiseOne = generateFakeValues(1); +const testPromiseTwo = generateFakeValues(2222); +const badPromise = generateFakeValues(0); + it('writes a file for an array of objects', async () => { - const testPromiseOne = generateFakeValues(1); - const testPromiseTwo = generateFakeValues(2222); expect(testPromiseOne.then(writeFile)).resolves.not.toThrow(); expect(testPromiseTwo.then(writeFile)).resolves.not.toThrow(); }); +it('rejects an empty input', async () => { + expect(badPromise.then(writeFile)).rejects.toEqual('reject'); +}); \ No newline at end of file diff --git a/www/js/services/controlHelper.ts b/www/js/services/controlHelper.ts index c88d92bf3..22390a5dd 100644 --- a/www/js/services/controlHelper.ts +++ b/www/js/services/controlHelper.ts @@ -13,7 +13,6 @@ export const getMyDataHelpers = function(fileName: string, startTimeString: stri const resultList = result.phone_data; return new Promise(function(resolve, reject) { window['resolveLocalFileSystemURL'](window['cordova'].file.tempDirectory, function(fs) { - logDebug(`file system open: ${fs.name}`); fs.filesystem.root.getFile(fileName, { create: true, exclusive: false }, function (fileEntry) { logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`) fileEntry.createWriter(function (fileWriter) { @@ -39,7 +38,6 @@ export const getMyDataHelpers = function(fileName: string, startTimeString: stri const localShareData = function () { return new Promise(function(resolve, reject) { window['resolveLocalFileSystemURL'](window['cordova'].file.tempDirectory, function(fs) { - logDebug(`During share, file system open: ${fs.name}`); fs.filesystem.root.getFile(fileName, null, function(fileEntry) { logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`); fileEntry.file(function(file) { From 5fbeac2954ba30f1a02fe2c3d2201c56ee4fddd5 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 1 Nov 2023 11:17:54 -0600 Subject: [PATCH 266/850] increase tests now all but one feature has a passing test, working on testing the reconsent situation --- www/__mocks__/pushNotificationMocks.ts | 2 +- www/__tests__/pushNotifySettings.test.ts | 63 ++++++++++++++++++++++-- www/js/splash/pushNotifySettings.ts | 1 + 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/www/__mocks__/pushNotificationMocks.ts b/www/__mocks__/pushNotificationMocks.ts index 66bd550ca..c6f8548ed 100644 --- a/www/__mocks__/pushNotificationMocks.ts +++ b/www/__mocks__/pushNotificationMocks.ts @@ -1,5 +1,5 @@ let notifSettings; -let onList = {}; +let onList : any = {}; export const mockPushNotification = () => { window['PushNotification'] = { diff --git a/www/__tests__/pushNotifySettings.test.ts b/www/__tests__/pushNotifySettings.test.ts index 06621087c..7d35a6c11 100644 --- a/www/__tests__/pushNotifySettings.test.ts +++ b/www/__tests__/pushNotifySettings.test.ts @@ -1,8 +1,9 @@ import { EVENT_NAMES, publish } from '../js/customEventHandler'; +import { markIntroDone } from '../js/onboarding/onboardingHelper'; import { initPushNotify } from '../js/splash/pushNotifySettings'; import { mockCordova, mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; -import { mockPushNotification } from '../__mocks__/pushNotificationMocks'; +import { clearNotifMock, getOnList, mockPushNotification } from '../__mocks__/pushNotificationMocks'; mockCordova(); mockLogger(); @@ -18,10 +19,64 @@ global.fetch = (url: string) => new Promise((rs, rj) => { })); }) as any; -it('initializes the push notifications', () => { - initPushNotify(); +it('intro done does nothing if not registered', () => { + clearNotifMock(); + expect(getOnList()).toStrictEqual({}); + publish(EVENT_NAMES.INTRO_DONE_EVENT, "test data"); + expect(getOnList()).toStrictEqual({}); +}) - publish(EVENT_NAMES.CONSENTED_EVENT, "test data"); +it('intro done initializes the push notifications', () => { + clearNotifMock(); + expect(getOnList()).toStrictEqual({}); + + initPushNotify(); publish(EVENT_NAMES.INTRO_DONE_EVENT, "test data"); + // setTimeout(() => {}, 100); + expect(getOnList()).toStrictEqual(expect.objectContaining({ + notification: expect.any(Function), + error: expect.any(Function), + registration: expect.any(Function) + })); +}) + +it('cloud event does nothing if not registered', () => { + publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, {additionalData: {'content-avaliable': 1}}); + //how to test did nothing? +}) + +it('cloud event handles notification if registered', () => { + initPushNotify(); publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, {additionalData: {'content-avaliable': 1}}); + //how to test did something? +}) + +it('consent event does nothing if not registered', () => { + clearNotifMock(); + expect(getOnList()).toStrictEqual({}); + publish(EVENT_NAMES.CONSENTED_EVENT, "test data"); + expect(getOnList()).toStrictEqual({}); +}) + +// it('consent event registers if intro done', () => { +// clearNotifMock(); +// expect(getOnList()).toStrictEqual({}); +// initPushNotify(); +// markIntroDone(); +// // setTimeout(() => {}, 100); +// publish(EVENT_NAMES.CONSENTED_EVENT, "test data"); +// setTimeout(() => {}, 200); +// expect(getOnList()).toStrictEqual(expect.objectContaining({ +// notification: expect.any(Function), +// error: expect.any(Function), +// registration: expect.any(Function) +// })); +// }) + +it('consent event does not register if intro not done', () => { + clearNotifMock(); + expect(getOnList()).toStrictEqual({}); + initPushNotify(); + publish(EVENT_NAMES.CONSENTED_EVENT, "test data"); + expect(getOnList()).toStrictEqual({}); //nothing, intro not done }) \ No newline at end of file diff --git a/www/js/splash/pushNotifySettings.ts b/www/js/splash/pushNotifySettings.ts index 29c036a85..a7a4ec001 100644 --- a/www/js/splash/pushNotifySettings.ts +++ b/www/js/splash/pushNotifySettings.ts @@ -217,6 +217,7 @@ export const initPushNotify = function () { .then(isConsented) .then(function (consentState) { if (consentState == true) { + logDebug("already consented, signing up for remote push"); registerPush(); } else { logDebug("no consent yet, waiting to sign up for remote push"); From c40698893d0d861c3b85b48ba2e6841611cd66cb Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 1 Nov 2023 11:24:04 -0600 Subject: [PATCH 267/850] pushnotify relies on events now moving to event-driven paradigm, so we don't call the method from pushNotify directly. this will apply to storedevicesettings once it gets migrated --- www/js/onboarding/ProtocolPage.tsx | 1 - www/js/splash/startprefs.ts | 8 +++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/www/js/onboarding/ProtocolPage.tsx b/www/js/onboarding/ProtocolPage.tsx index a047a0aae..1630748b4 100644 --- a/www/js/onboarding/ProtocolPage.tsx +++ b/www/js/onboarding/ProtocolPage.tsx @@ -6,7 +6,6 @@ import { resetDataAndRefresh } from '../config/dynamicConfig'; import { AppContext } from '../App'; import PrivacyPolicy from './PrivacyPolicy'; import { onboardingStyles } from './OnboardingStack'; -import { markConsented } from '../splash/startprefs'; import { setProtocolDone } from './onboardingHelper'; const ProtocolPage = () => { diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index 43f29c692..3c4823af7 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -2,6 +2,7 @@ import { getAngularService } from "../angular-react-helper"; import { storageGet, storageSet } from '../plugin/storage'; import { logInfo, logDebug, displayErrorMsg } from '../plugin/logger'; import { readIntroDone } from "../onboarding/onboardingHelper"; +import { EVENT_NAMES, publish } from "../customEventHandler"; // data collection consented protocol: string, represents the date on // which the consented protocol was approved by the IRB @@ -37,15 +38,16 @@ export function markConsented() { _req_consent); // mark in local variable as well _curr_consented = { ..._req_consent }; + // publish event + publish(EVENT_NAMES.CONSENTED_EVENT, _req_consent); }) //check for reconsent .then(readIntroDone) .then((isIntroDone) => { if (isIntroDone) { - logDebug("reconsent scenario - marked consent after intro done - registering pushnoify and storing device settings") - const PushNotify = getAngularService("PushNotify"); + logDebug("reconsent scenario - marked consent after intro done - registering pushnoify and storing device settings"); + //pushnotify uses events now const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); - PushNotify.registerPush(); StoreSeviceSettings.storeDeviceSettings(); } }) From d27aeddf5bb00872a40e391967a995b9277ca486 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 1 Nov 2023 12:01:42 -0600 Subject: [PATCH 268/850] update tests after working on the consent event and process for checking for reconsent, we now have a test for that module that passes --- www/__tests__/pushNotifySettings.test.ts | 45 ++++++++++++++++-------- www/js/splash/pushNotifySettings.ts | 13 +++---- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/www/__tests__/pushNotifySettings.test.ts b/www/__tests__/pushNotifySettings.test.ts index 7d35a6c11..166b0ac7c 100644 --- a/www/__tests__/pushNotifySettings.test.ts +++ b/www/__tests__/pushNotifySettings.test.ts @@ -1,5 +1,7 @@ +import { DateTime } from 'luxon'; import { EVENT_NAMES, publish } from '../js/customEventHandler'; -import { markIntroDone } from '../js/onboarding/onboardingHelper'; +import { INTRO_DONE_KEY, markIntroDone, readIntroDone } from '../js/onboarding/onboardingHelper'; +import { storageSet } from '../js/plugin/storage'; import { initPushNotify } from '../js/splash/pushNotifySettings'; import { mockCordova, mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; @@ -58,20 +60,33 @@ it('consent event does nothing if not registered', () => { expect(getOnList()).toStrictEqual({}); }) -// it('consent event registers if intro done', () => { -// clearNotifMock(); -// expect(getOnList()).toStrictEqual({}); -// initPushNotify(); -// markIntroDone(); -// // setTimeout(() => {}, 100); -// publish(EVENT_NAMES.CONSENTED_EVENT, "test data"); -// setTimeout(() => {}, 200); -// expect(getOnList()).toStrictEqual(expect.objectContaining({ -// notification: expect.any(Function), -// error: expect.any(Function), -// registration: expect.any(Function) -// })); -// }) +it('consent event registers if intro done', async () => { + //make sure the mock is clear + clearNotifMock(); + expect(getOnList()).toStrictEqual({}); + + //initialize the pushNotify, to subscribe to events + initPushNotify(); + console.log("initialized"); + + //mark the intro as done + const currDateTime = DateTime.now().toISO(); + let marked = await storageSet(INTRO_DONE_KEY, currDateTime); + console.log("marked intro"); + let introDone = await readIntroDone(); + expect(introDone).toBeTruthy(); + + //publish consent event and check results + publish(EVENT_NAMES.CONSENTED_EVENT, "test data"); + //have to wait a beat since event response is async + setTimeout(() => { + expect(getOnList()).toStrictEqual(expect.objectContaining({ + notification: expect.any(Function), + error: expect.any(Function), + registration: expect.any(Function) + })); + }, 100); +}) it('consent event does not register if intro not done', () => { clearNotifMock(); diff --git a/www/js/splash/pushNotifySettings.ts b/www/js/splash/pushNotifySettings.ts index a7a4ec001..b252508db 100644 --- a/www/js/splash/pushNotifySettings.ts +++ b/www/js/splash/pushNotifySettings.ts @@ -190,12 +190,13 @@ const onCloudEvent = function (event, data) { const onConsentEvent = function (event, data) { console.log("got consented event " + JSON.stringify(event['name']) + " with data " + JSON.stringify(data)); - readIntroDone().then((isIntroDone) => { - if (isIntroDone) { - console.log("intro is done -> reconsent situation, we already have a token -> register"); - registerPush(); - } - }) + readIntroDone() + .then((isIntroDone) => { + if (isIntroDone) { + console.log("intro is done -> reconsent situation, we already have a token -> register"); + registerPush(); + } + }); } /** From 82e864bea38c071802810a3eed7a06ddf917df51 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 1 Nov 2023 12:06:40 -0600 Subject: [PATCH 269/850] add docstrings to customEventHandler --- www/js/customEventHandler.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/www/js/customEventHandler.ts b/www/js/customEventHandler.ts index e84fe8123..f6606cf8f 100644 --- a/www/js/customEventHandler.ts +++ b/www/js/customEventHandler.ts @@ -13,23 +13,42 @@ import { logDebug } from './plugin/logger'; +/** + * central source for event names + */ export const EVENT_NAMES = { CLOUD_NOTIFICATION_EVENT: 'cloud:push:notification', CONSENTED_EVENT: "data_collection_consented", INTRO_DONE_EVENT: "intro_done", } +/** + * @function starts listening to an event + * @param eventName {string} the name of the event + * @param listener event listener, function to execute on event + */ export function subscribe(eventName: string, listener) { logDebug("adding " + eventName + " listener"); document.addEventListener(eventName, listener); } +/** + * @function stops listening to an event + * @param eventName {string} the name of the event + * @param listener event listener, function to execute on event + */ export function unsubscribe(eventName: string, listener){ logDebug("removing " + eventName + " listener"); document.removeEventListener(eventName, listener); } -export function publish(eventName, data) { +/** + * @function broadcasts an event + * the data is stored in the "detail" of the event + * @param eventName {string} the name of the event + * @param data any additional data to be added to event + */ +export function publish(eventName: string, data) { logDebug("publishing " + eventName + " with data " + JSON.stringify(data)); const event = new CustomEvent(eventName, { detail: data }); document.dispatchEvent(event); From 0eb7b4bb2f42ed6da5f00559f4a5a65ff2e0e744 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 1 Nov 2023 13:46:33 -0600 Subject: [PATCH 270/850] test cloud event added in test for the cloud event when subscribed and when not subscribed. additional mocking was required to handle the silent push functions --- www/__mocks__/cordovaMocks.ts | 12 ++++++++++++ www/__mocks__/pushNotificationMocks.ts | 9 +++++++++ www/__tests__/pushNotifySettings.test.ts | 23 +++++++++++++++-------- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index c00377120..2c45e2a20 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -124,6 +124,18 @@ export const mockBEMDataCollection = () => { setTimeout(() => { _storage['config/consent'] = consentDoc; }, 100) + }, + getConfig: () => { + return new Promise((rs, rj) => { + rs({ 'ios_use_remote_push_for_sync': true }); + }); + }, + handleSilentPush: () => { + return new Promise((rs, rj) => + setTimeout(() => { + rs(); + }, 100) + ); } } window['cordova'] ||= {}; diff --git a/www/__mocks__/pushNotificationMocks.ts b/www/__mocks__/pushNotificationMocks.ts index c6f8548ed..47b7bd940 100644 --- a/www/__mocks__/pushNotificationMocks.ts +++ b/www/__mocks__/pushNotificationMocks.ts @@ -1,5 +1,6 @@ let notifSettings; let onList : any = {}; +let called = null; export const mockPushNotification = () => { window['PushNotification'] = { @@ -8,6 +9,9 @@ export const mockPushNotification = () => { return { on: (event: string, callback: Function) => { onList[event] = callback; + }, + finish: (content: any, errorFcn: Function, notID: any) => { + called = notID; } }; }, @@ -17,12 +21,17 @@ export const mockPushNotification = () => { export const clearNotifMock = function () { notifSettings = {}; onList = {}; + called = null; } export const getOnList = function () { return onList; } +export const getCalled = function() { + return called; +} + export const fakeEvent = function (eventName : string) { //fake the event by executing whatever we have stored for it onList[eventName](); diff --git a/www/__tests__/pushNotifySettings.test.ts b/www/__tests__/pushNotifySettings.test.ts index 166b0ac7c..22579f536 100644 --- a/www/__tests__/pushNotifySettings.test.ts +++ b/www/__tests__/pushNotifySettings.test.ts @@ -1,21 +1,22 @@ import { DateTime } from 'luxon'; import { EVENT_NAMES, publish } from '../js/customEventHandler'; -import { INTRO_DONE_KEY, markIntroDone, readIntroDone } from '../js/onboarding/onboardingHelper'; +import { INTRO_DONE_KEY, readIntroDone } from '../js/onboarding/onboardingHelper'; import { storageSet } from '../js/plugin/storage'; import { initPushNotify } from '../js/splash/pushNotifySettings'; -import { mockCordova, mockBEMUserCache } from '../__mocks__/cordovaMocks'; +import { mockCordova, mockBEMUserCache, mockBEMDataCollection } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; -import { clearNotifMock, getOnList, mockPushNotification } from '../__mocks__/pushNotificationMocks'; +import { clearNotifMock, getOnList, mockPushNotification, getCalled } from '../__mocks__/pushNotificationMocks'; mockCordova(); mockLogger(); mockPushNotification(); mockBEMUserCache(); +mockBEMDataCollection(); global.fetch = (url: string) => new Promise((rs, rj) => { setTimeout(() => rs({ json: () => new Promise((rs, rj) => { - let myJSON = { "emSensorDataCollectionProtocol": { "protocol_id": "2014-04-6267", "approval_date": "2016-07-14" } }; + let myJSON = { "emSensorDataCollectionProtocol": { "protocol_id": "2014-04-6267", "approval_date": "2016-07-14" }, }; setTimeout(() => rs(myJSON), 100); }) })); @@ -43,14 +44,20 @@ it('intro done initializes the push notifications', () => { }) it('cloud event does nothing if not registered', () => { - publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, {additionalData: {'content-avaliable': 1}}); - //how to test did nothing? + expect(window['cordova'].platformId).toEqual('ios'); + publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, {additionalData: {'content-available': 1, 'payload' : {'notID' : 3}}}); + expect(getCalled()).toBeNull(); }) it('cloud event handles notification if registered', () => { + clearNotifMock(); + expect(window['cordova'].platformId).toEqual('ios'); initPushNotify(); - publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, {additionalData: {'content-avaliable': 1}}); - //how to test did something? + publish(EVENT_NAMES.INTRO_DONE_EVENT, "intro done"); + publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, {additionalData: {'content-available': 1, 'payload' : {'notID' : 3}}}); + setTimeout(() => { + expect(getCalled()).toEqual(3); + }, 300) }) it('consent event does nothing if not registered', () => { From 24784db6fae4c5f0425f3e77dcac7a3ad122ef2a Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 1 Nov 2023 13:48:55 -0600 Subject: [PATCH 271/850] add timeout to config mock I had originally omitted the timeout because it seemed to break the test, but upon having the tests fully working, I was able to restore it this change makes the test code a little more realistic --- www/__mocks__/cordovaMocks.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 2c45e2a20..9b8980c37 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -127,7 +127,9 @@ export const mockBEMDataCollection = () => { }, getConfig: () => { return new Promise((rs, rj) => { - rs({ 'ios_use_remote_push_for_sync': true }); + setTimeout(() => { + rs({ 'ios_use_remote_push_for_sync': true }); + }, 100) }); }, handleSilentPush: () => { From 46d5ed8960d263b91ac42fdc536b2ac336d81188 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 1 Nov 2023 14:33:50 -0600 Subject: [PATCH 272/850] update tests moved the clear plugin into an "afterEach" call to clean up the code corrected a key spelling that fixed one of the tests, and updated the way I wait for the event handling to happen https://stackoverflow.com/questions/45478730/jest-react-testing-check-state-after-delay --- www/__tests__/pushNotifySettings.test.ts | 26 +++++++++--------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/www/__tests__/pushNotifySettings.test.ts b/www/__tests__/pushNotifySettings.test.ts index 22579f536..abaaa0954 100644 --- a/www/__tests__/pushNotifySettings.test.ts +++ b/www/__tests__/pushNotifySettings.test.ts @@ -22,20 +22,21 @@ global.fetch = (url: string) => new Promise((rs, rj) => { })); }) as any; -it('intro done does nothing if not registered', () => { +afterEach(() => { clearNotifMock(); +}); + +it('intro done does nothing if not registered', () => { expect(getOnList()).toStrictEqual({}); publish(EVENT_NAMES.INTRO_DONE_EVENT, "test data"); expect(getOnList()).toStrictEqual({}); }) it('intro done initializes the push notifications', () => { - clearNotifMock(); expect(getOnList()).toStrictEqual({}); initPushNotify(); publish(EVENT_NAMES.INTRO_DONE_EVENT, "test data"); - // setTimeout(() => {}, 100); expect(getOnList()).toStrictEqual(expect.objectContaining({ notification: expect.any(Function), error: expect.any(Function), @@ -45,23 +46,20 @@ it('intro done initializes the push notifications', () => { it('cloud event does nothing if not registered', () => { expect(window['cordova'].platformId).toEqual('ios'); - publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, {additionalData: {'content-available': 1, 'payload' : {'notID' : 3}}}); + publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, {additionalData: {'content-available': 1, 'payload' : {'notId' : 3}}}); expect(getCalled()).toBeNull(); }) -it('cloud event handles notification if registered', () => { - clearNotifMock(); +it('cloud event handles notification if registered', async () => { expect(window['cordova'].platformId).toEqual('ios'); initPushNotify(); publish(EVENT_NAMES.INTRO_DONE_EVENT, "intro done"); - publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, {additionalData: {'content-available': 1, 'payload' : {'notID' : 3}}}); - setTimeout(() => { - expect(getCalled()).toEqual(3); - }, 300) -}) + publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, {additionalData: {'content-available': 1, 'payload' : {'notId' : 3}}}); + await new Promise((r) => setTimeout(r, 1000)); + expect(getCalled()).toEqual(3); +}, 10000) it('consent event does nothing if not registered', () => { - clearNotifMock(); expect(getOnList()).toStrictEqual({}); publish(EVENT_NAMES.CONSENTED_EVENT, "test data"); expect(getOnList()).toStrictEqual({}); @@ -69,17 +67,14 @@ it('consent event does nothing if not registered', () => { it('consent event registers if intro done', async () => { //make sure the mock is clear - clearNotifMock(); expect(getOnList()).toStrictEqual({}); //initialize the pushNotify, to subscribe to events initPushNotify(); - console.log("initialized"); //mark the intro as done const currDateTime = DateTime.now().toISO(); let marked = await storageSet(INTRO_DONE_KEY, currDateTime); - console.log("marked intro"); let introDone = await readIntroDone(); expect(introDone).toBeTruthy(); @@ -96,7 +91,6 @@ it('consent event registers if intro done', async () => { }) it('consent event does not register if intro not done', () => { - clearNotifMock(); expect(getOnList()).toStrictEqual({}); initPushNotify(); publish(EVENT_NAMES.CONSENTED_EVENT, "test data"); From fffcd004517290d78047427dc2e13b8cc0fa8fca Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Wed, 1 Nov 2023 13:53:31 -0700 Subject: [PATCH 273/850] Expanded mocks, added tests - The controlHelper tests are _broken_ in this commit! Please see PR 1052 for discussion. --- www/__mocks__/fileSystemMocks.ts | 20 ++++++++++++-------- www/__tests__/controlHelper.test.ts | 23 +---------------------- www/js/services/controlHelper.ts | 2 +- 3 files changed, 14 insertions(+), 31 deletions(-) diff --git a/www/__mocks__/fileSystemMocks.ts b/www/__mocks__/fileSystemMocks.ts index 6c822311f..996fa4450 100644 --- a/www/__mocks__/fileSystemMocks.ts +++ b/www/__mocks__/fileSystemMocks.ts @@ -1,4 +1,9 @@ export const mockFileSystem = () => { + type MockFileWriter = { + onreadend: any, + onerror: (e: any) => void, + write: (obj: Blob) => void, + } window['resolveLocalFileSystemURL'] = function (parentDir, handleFS) { const fs = { filesystem: @@ -14,15 +19,14 @@ export const mockFileSystem = () => { nativeURL: 'file:///Users/Jest/test/URL/', isFile: true, createWriter: (handleWriter) => { - const mockFileWriter = { - fileWriter: { - write: (myObect) => { - console.log(`Wrote: ${myObect}`) - }, - onwriteend: () => {}, - onerror: (error) => { return error; } - } + var mockFileWriter : MockFileWriter = { + onreadend: null, + onerror: null, + write: (obj) => { + console.log(`Mock this: ${obj}`); + }, } + handleWriter(mockFileWriter); }, } onSuccess(fileEntry); diff --git a/www/__tests__/controlHelper.test.ts b/www/__tests__/controlHelper.test.ts index 730e6e69f..c62267d64 100644 --- a/www/__tests__/controlHelper.test.ts +++ b/www/__tests__/controlHelper.test.ts @@ -51,7 +51,7 @@ const generateFakeValues = (arraySize: number) => { } }; - // The parse/stringify lets us "deep copy" the objects, to quickly populate/change the data + // The parse/stringify lets us "deep copy" the objects, to quickly populate/change test data let values = Array.from({length: arraySize}, e => JSON.parse(JSON.stringify(sampleDataObj))); values.forEach((element, index) => { values[index].data.name = element.data.name + index.toString() @@ -59,27 +59,6 @@ const generateFakeValues = (arraySize: number) => { return Promise.resolve({ phone_data: values }); }; -// A variation of createShareData; confirms the file has been written, -// without calling the sharing components -const confirmFileExists = (fileName: string, dataCluster: ServerResponse) => { - return function() { - return new Promise(function() { - window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { - fs.root.getFile(fileName, null, function(fileEntry) { - if (!fileEntry.isFile) - return fileEntry.isFile; - const reader = new FileReader(); - reader.onloadend = function () { - const readResult = this.result as string; - const expectedResult = JSON.stringify(dataCluster); - return (readResult === expectedResult); - } - }); - }); - }); - }; -}; - // Test constants: const fileName = 'testOne' const startTime = '1969-06-16' diff --git a/www/js/services/controlHelper.ts b/www/js/services/controlHelper.ts index 22390a5dd..58d6fe738 100644 --- a/www/js/services/controlHelper.ts +++ b/www/js/services/controlHelper.ts @@ -24,7 +24,7 @@ export const getMyDataHelpers = function(fileName: string, startTimeString: stri logDebug(`Failed file write: ${e.toString()}`); reject(); } - + logDebug(`fileWriter is: ${JSON.stringify(fileWriter.onwriteend, null, 2)}`) // if data object is not passed in, create a new blob instead. const dataObj = new Blob([JSON.stringify(resultList, null, 2)], { type: "application/json" }); From 4f5e85341db1d3de60e90faedbece48d672c021c Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 1 Nov 2023 14:56:46 -0600 Subject: [PATCH 274/850] sync up test runs syncing up the ways that tests run, replacing the way timeouts are set, removing unneeded timeout extension --- www/__tests__/pushNotifySettings.test.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/www/__tests__/pushNotifySettings.test.ts b/www/__tests__/pushNotifySettings.test.ts index abaaa0954..58c6bb48e 100644 --- a/www/__tests__/pushNotifySettings.test.ts +++ b/www/__tests__/pushNotifySettings.test.ts @@ -57,7 +57,7 @@ it('cloud event handles notification if registered', async () => { publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, {additionalData: {'content-available': 1, 'payload' : {'notId' : 3}}}); await new Promise((r) => setTimeout(r, 1000)); expect(getCalled()).toEqual(3); -}, 10000) +}) it('consent event does nothing if not registered', () => { expect(getOnList()).toStrictEqual({}); @@ -81,13 +81,12 @@ it('consent event registers if intro done', async () => { //publish consent event and check results publish(EVENT_NAMES.CONSENTED_EVENT, "test data"); //have to wait a beat since event response is async - setTimeout(() => { - expect(getOnList()).toStrictEqual(expect.objectContaining({ - notification: expect.any(Function), - error: expect.any(Function), - registration: expect.any(Function) - })); - }, 100); + await new Promise((r) => setTimeout(r, 1000)); + expect(getOnList()).toStrictEqual(expect.objectContaining({ + notification: expect.any(Function), + error: expect.any(Function), + registration: expect.any(Function) + })); }) it('consent event does not register if intro not done', () => { From 7a4b6fb528f037b726b075efaa409c7426ec679c Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 2 Nov 2023 09:54:26 -0700 Subject: [PATCH 275/850] Rewrote readUnprocessedTrips, helper functions - moved functions to timelineHelper - updated momentJS code to luxon --- www/js/diary/LabelTab.tsx | 4 +- www/js/diary/services.js | 297 +------------------------------- www/js/diary/timelineHelper.ts | 299 ++++++++++++++++++++++++++++++++- www/js/types/serverData.ts | 1 + 4 files changed, 302 insertions(+), 299 deletions(-) diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index f3924c691..9f76e891c 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -16,7 +16,7 @@ import LabelListScreen from "./list/LabelListScreen"; import { createStackNavigator } from "@react-navigation/stack"; import LabelScreenDetails from "./details/LabelDetailsScreen"; import { NavigationContainer } from "@react-navigation/native"; -import { compositeTrips2TimelineMap, getAllUnprocessedInputs, getLocalUnprocessedInputs, populateCompositeTrips, readAllCompositeTrips } from "./timelineHelper"; +import { compositeTrips2TimelineMap, getAllUnprocessedInputs, getLocalUnprocessedInputs, populateCompositeTrips, readAllCompositeTrips, readUnprocessedTrips } from "./timelineHelper"; import { fillLocationNamesOfTrip, resetNominatimLimiter } from "./addressNamesHelper"; import { SurveyOptions } from "../survey/survey"; import { getLabelOptions } from "../survey/multilabel/confirmHelper"; @@ -209,7 +209,7 @@ const LabelTab = () => { const lastProcessedTrip = timelineMap && [...timelineMap?.values()].reverse().find( trip => trip.origin_key.includes('confirmed_trip') ); - readUnprocessedPromise = Timeline.readUnprocessedTrips(pipelineRange.end_ts, nowTs, lastProcessedTrip); + readUnprocessedPromise = readUnprocessedTrips(pipelineRange.end_ts, nowTs, lastProcessedTrip); } else { readUnprocessedPromise = Promise.resolve([]); } diff --git a/www/js/diary/services.js b/www/js/diary/services.js index c6fa90267..aebaf2518 100644 --- a/www/js/diary/services.js +++ b/www/js/diary/services.js @@ -3,8 +3,6 @@ import angular from 'angular'; import { SurveyOptions } from '../survey/survey'; import { getConfig } from '../config/dynamicConfig'; -import { getRawEntries } from '../commHelper'; -import { getUnifiedDataForInterval } from '../unifiedDataLoader' angular.module('emission.main.diary.services', ['emission.plugin.logger', 'emission.services']) @@ -27,304 +25,12 @@ angular.module('emission.main.diary.services', ['emission.plugin.logger', }); }); - /* - * This is going to be a bit tricky. As we can see from - * https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163, - * when we read local transitions, they have a string for the transition - * (e.g. `T_DATA_PUSHED`), while the remote transitions have an integer - * (e.g. `2`). - * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286338606 - * - * Also, at least on iOS, it is possible for trip end to be detected way - * after the end of the trip, so the trip end transition of a processed - * trip may actually show up as an unprocessed transition. - * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163 - * - * Let's abstract this out into our own minor state machine. - */ - var transitions2Trips = function(transitionList) { - var inTrip = false; - var tripList = [] - var currStartTransitionIndex = -1; - var currEndTransitionIndex = -1; - var processedUntil = 0; - - while(processedUntil < transitionList.length) { - // Logger.log("searching within list = "+JSON.stringify(transitionList.slice(processedUntil))); - if(inTrip == false) { - var foundStartTransitionIndex = transitionList.slice(processedUntil).findIndex(isStartingTransition); - if (foundStartTransitionIndex == -1) { - Logger.log("No further unprocessed trips started, exiting loop"); - processedUntil = transitionList.length; - } else { - currStartTransitionIndex = processedUntil + foundStartTransitionIndex; - processedUntil = currStartTransitionIndex; - Logger.log("Unprocessed trip started at "+JSON.stringify(transitionList[currStartTransitionIndex])); - inTrip = true; - } - } else { - // Logger.log("searching within list = "+JSON.stringify(transitionList.slice(processedUntil))); - var foundEndTransitionIndex = transitionList.slice(processedUntil).findIndex(isEndingTransition); - if (foundEndTransitionIndex == -1) { - Logger.log("Can't find end for trip starting at "+JSON.stringify(transitionList[currStartTransitionIndex])+" dropping it"); - processedUntil = transitionList.length; - } else { - currEndTransitionIndex = processedUntil + foundEndTransitionIndex; - processedUntil = currEndTransitionIndex; - Logger.log("currEndTransitionIndex = "+currEndTransitionIndex); - Logger.log("Unprocessed trip starting at "+JSON.stringify(transitionList[currStartTransitionIndex])+" ends at "+JSON.stringify(transitionList[currEndTransitionIndex])); - tripList.push([transitionList[currStartTransitionIndex], - transitionList[currEndTransitionIndex]]) - inTrip = false; - } - } - } - return tripList; - } - - var isStartingTransition = function(transWrapper) { - // Logger.log("isStartingTransition: transWrapper.data.transition = "+transWrapper.data.transition); - if(transWrapper.data.transition == 'local.transition.exited_geofence' || - transWrapper.data.transition == 'T_EXITED_GEOFENCE' || - transWrapper.data.transition == 1) { - // Logger.log("Returning true"); - return true; - } - // Logger.log("Returning false"); - return false; - } - - var isEndingTransition = function(transWrapper) { - // Logger.log("isEndingTransition: transWrapper.data.transition = "+transWrapper.data.transition); - if(transWrapper.data.transition == 'T_TRIP_ENDED' || - transWrapper.data.transition == 'local.transition.stopped_moving' || - transWrapper.data.transition == 2) { - // Logger.log("Returning true"); - return true; - } - // Logger.log("Returning false"); - return false; - } - /* * Fill out place geojson after pulling trip location points. * Place is only partially filled out because we haven't linked the timeline yet */ - var moment2localdate = function(currMoment, tz) { - return { - timezone: tz, - year: currMoment.year(), - //the months of the draft trips match the one format needed for - //moment function however now that is modified we need to also - //modify the months value here - month: currMoment.month() + 1, - day: currMoment.date(), - weekday: currMoment.weekday(), - hour: currMoment.hour(), - minute: currMoment.minute(), - second: currMoment.second() - }; - } - - var points2TripProps = function(locationPoints) { - var startPoint = locationPoints[0]; - var endPoint = locationPoints[locationPoints.length - 1]; - var tripAndSectionId = "unprocessed_"+startPoint.data.ts+"_"+endPoint.data.ts; - var startMoment = moment.unix(startPoint.data.ts).tz(startPoint.metadata.time_zone); - var endMoment = moment.unix(endPoint.data.ts).tz(endPoint.metadata.time_zone); - - const speeds = [], dists = []; - let loc, locLatLng; - locationPoints.forEach((pt) => { - const ptLatLng = L.latLng([pt.data.latitude, pt.data.longitude]); - if (loc) { - const dist = locLatLng.distanceTo(ptLatLng); - const timeDelta = pt.data.ts - loc.data.ts; - dists.push(dist); - speeds.push(dist / timeDelta); - } - loc = pt; - locLatLng = ptLatLng; - }); - - const locations = locationPoints.map((point, i) => ({ - loc: { - coordinates: [point.data.longitude, point.data.latitude] - }, - ts: point.data.ts, - speed: speeds[i], - })); - - return { - _id: {$oid: tripAndSectionId}, - key: "UNPROCESSED_trip", - origin_key: "UNPROCESSED_trip", - additions: [], - confidence_threshold: 0, - distance: dists.reduce((a, b) => a + b, 0), - duration: endPoint.data.ts - startPoint.data.ts, - end_fmt_time: endMoment.format(), - end_local_dt: moment2localdate(endMoment, endPoint.metadata.time_zone), - end_ts: endPoint.data.ts, - expectation: {to_label: true}, - inferred_labels: [], - locations: locations, - source: "unprocessed", - start_fmt_time: startMoment.format(), - start_local_dt: moment2localdate(startMoment, startPoint.metadata.time_zone), - start_ts: startPoint.data.ts, - user_input: {}, - } - } - - var tsEntrySort = function(e1, e2) { - // compare timestamps - return e1.data.ts - e2.data.ts; - } - - var transitionTrip2TripObj = function(trip) { - var tripStartTransition = trip[0]; - var tripEndTransition = trip[1]; - var tq = {key: "write_ts", - startTs: tripStartTransition.data.ts, - endTs: tripEndTransition.data.ts - } - Logger.log("About to pull location data for range " - + moment.unix(tripStartTransition.data.ts).toString() + " -> " - + moment.unix(tripEndTransition.data.ts).toString()); - const getSensorData = window['cordova'].plugins.BEMUserCache.getSensorDataForInterval; - return getUnifiedDataForInterval("background/filtered_location", tq, getSensorData) - .then(function(locationList) { - if (locationList.length == 0) { - return undefined; - } - var sortedLocationList = locationList.sort(tsEntrySort); - var retainInRange = function(loc) { - return (tripStartTransition.data.ts <= loc.data.ts) && - (loc.data.ts <= tripEndTransition.data.ts) - } - - var filteredLocationList = sortedLocationList.filter(retainInRange); - - // Fix for https://github.com/e-mission/e-mission-docs/issues/417 - if (filteredLocationList.length == 0) { - return undefined; - } - - var tripStartPoint = filteredLocationList[0]; - var tripEndPoint = filteredLocationList[filteredLocationList.length-1]; - Logger.log("tripStartPoint = "+JSON.stringify(tripStartPoint)+"tripEndPoint = "+JSON.stringify(tripEndPoint)); - // if we get a list but our start and end are undefined - // let's print out the complete original list to get a clue - // this should help with debugging - // https://github.com/e-mission/e-mission-docs/issues/417 - // if it ever occurs again - if (angular.isUndefined(tripStartPoint) || angular.isUndefined(tripEndPoint)) { - Logger.log("BUG 417 check: locationList = "+JSON.stringify(locationList)); - Logger.log("transitions: start = "+JSON.stringify(tripStartTransition.data) - + " end = "+JSON.stringify(tripEndTransition.data.ts)); - } - - const tripProps = points2TripProps(filteredLocationList); - - return { - ...tripProps, - start_loc: { - type: "Point", - coordinates: [tripStartPoint.data.longitude, tripStartPoint.data.latitude] - }, - end_loc: { - type: "Point", - coordinates: [tripEndPoint.data.longitude, tripEndPoint.data.latitude], - }, - } - }); - } - - var linkTrips = function(trip1, trip2) { - // complete trip1 - trip1.starting_trip = {$oid: trip2.id}; - trip1.exit_fmt_time = trip2.enter_fmt_time; - trip1.exit_local_dt = trip2.enter_local_dt; - trip1.exit_ts = trip2.enter_ts; - - // start trip2 - trip2.ending_trip = {$oid: trip1.id}; - trip2.enter_fmt_time = trip1.exit_fmt_time; - trip2.enter_local_dt = trip1.exit_local_dt; - trip2.enter_ts = trip1.exit_ts; - } - - timeline.readUnprocessedTrips = function(startTs, endTs, lastProcessedTrip) { - $ionicLoading.show({ - template: i18next.t('service.reading-unprocessed-data') - }); - - var tq = {key: "write_ts", - startTs, - endTs - } - Logger.log("about to query for unprocessed trips from " - +moment.unix(tq.startTs).toString()+" -> "+moment.unix(tq.endTs).toString()); - - const getMessageMethod = window['cordova'].plugins.BEMUserCache.getMessagesForInterval; - return getUnifiedDataForInterval("statemachine/transition", tq, getMessageMethod) - .then(function(transitionList) { - if (transitionList.length == 0) { - Logger.log("No unprocessed trips. yay!"); - $ionicLoading.hide(); - return []; - } else { - Logger.log("Found "+transitionList.length+" transitions. yay!"); - var sortedTransitionList = transitionList.sort(tsEntrySort); - /* - sortedTransitionList.forEach(function(transition) { - console.log(moment(transition.data.ts * 1000).format()+":" + JSON.stringify(transition.data)); - }); - */ - var tripsList = transitions2Trips(transitionList); - Logger.log("Mapped into"+tripsList.length+" trips. yay!"); - tripsList.forEach(function(trip) { - console.log(JSON.stringify(trip)); - }); - var tripFillPromises = tripsList.map(transitionTrip2TripObj); - return Promise.all(tripFillPromises).then(function(raw_trip_gj_list) { - // Now we need to link up the trips. linking unprocessed trips - // to one another is fairly simple, but we need to link the - // first unprocessed trip to the last processed trip. - // This might be challenging if we don't have any processed - // trips for the day. I don't want to go back forever until - // I find a trip. So if this is the first trip, we will start a - // new chain for now, since this is with unprocessed data - // anyway. - - Logger.log("mapped trips to trip_gj_list of size "+raw_trip_gj_list.length); - /* Filtering: we will keep trips that are 1) defined and 2) have a distance >= 100m or duration >= 5 minutes - https://github.com/e-mission/e-mission-docs/issues/966#issuecomment-1709112578 */ - const trip_gj_list = raw_trip_gj_list.filter((trip) => - trip && (trip.distance >= 100 || trip.duration >= 300) - ); - Logger.log("after filtering undefined and distance < 100, trip_gj_list size = "+raw_trip_gj_list.length); - // Link 0th trip to first, first to second, ... - for (var i = 0; i < trip_gj_list.length-1; i++) { - linkTrips(trip_gj_list[i], trip_gj_list[i+1]); - } - Logger.log("finished linking trips for list of size "+trip_gj_list.length); - if (lastProcessedTrip && trip_gj_list.length != 0) { - // Need to link the entire chain above to the processed data - Logger.log("linking unprocessed and processed trip chains"); - linkTrips(lastProcessedTrip, trip_gj_list[0]); - } - $ionicLoading.hide(); - Logger.log("Returning final list of size "+trip_gj_list.length); - return trip_gj_list; - }); - } - }); - } - - var localCacheReadFn = timeline.updateFromDatabase; + var localCacheReadFn = timeline.updateFromDatabase; timeline.getTrip = function(tripId) { return angular.isDefined(timeline.data.tripMap)? timeline.data.tripMap[tripId] : undefined; @@ -350,4 +56,3 @@ angular.module('emission.main.diary.services', ['emission.plugin.logger', return timeline; }) - diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 754de1a24..ca699499b 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -2,6 +2,9 @@ import { getAngularService } from "../angular-react-helper"; import { displayError, logDebug } from "../plugin/logger"; import { getBaseModeByKey, getBaseModeOfLabeledTrip } from "./diaryHelper"; import { getUnifiedDataForInterval} from "../unifiedDataLoader"; +import { getRawEntries } from "../commHelper"; +import { ServerResponse, ServerData } from "../types/serverData"; +import L from 'leaflet'; import i18next from "i18next"; import { DateTime } from "luxon"; @@ -249,4 +252,298 @@ export const readAllCompositeTrips = function(startTs: number, endTs: number) { $ionicLoading.hide(); return []; }); -}; \ No newline at end of file +}; + +const dateTime2localdate = function(currtime: DateTime, tz: string) { + return { + timezone: tz, + year: currtime.get('year'), + //the months of the draft trips match the one format needed for + //moment function however now that is modified we need to also + //modify the months value here + month: currtime.get('month') + 1, + day: currtime.get('day'), + weekday: currtime.get('weekday'), + hour: currtime.get('hour'), + minute: currtime.get('minute'), + second: currtime.get('second'), + }; +} +/* locationPoints are of form: + * ServerData + * Point = { + * currentState: string, + * transition: string, + * ts: number, // 1698433683.712 + * } + */ +const points2TripProps = function(locationPoints) { + const startPoint = locationPoints[0]; + const endPoint = locationPoints[locationPoints.length - 1]; + const tripAndSectionId = `unprocessed_${startPoint.data.ts}_${endPoint.data.ts}`; + const startTime = DateTime.fromSeconds(startPoint.data.ts).setZone(startPoint.metadata.time_zone); + const endTime = DateTime.fromSeconds(endPoint.data.ts).setZone(endPoint.metadata.time_zone); + + const speeds = [], dists = []; + var loc, locLatLng; + locationPoints.forEach((pt) => { + const ptLatLng = L.latLng([pt.data.latitude, pt.data.longitude]); + if (loc) { + const dist = locLatLng.distanceTo(ptLatLng); + const timeDelta = pt.data.ts - loc.data.ts; + dists.push(dist); + speeds.push(dist / timeDelta); + } + loc = pt; + locLatLng = ptLatLng; + }); + + const locations = locationPoints.map((point, i) => ({ + loc: { + coordinates: [point.data.longitude, point.data.latitude] + }, + ts: point.data.ts, + speed: speeds[i], + })); + + // used to mimic old momentJS moment.format() + const formatString = "yyyy-MM-dd'T'HH:mm:ssZZ"; + return { + _id: {$oid: tripAndSectionId}, + key: "UNPROCESSED_trip", + origin_key: "UNPROCESSED_trip", + additions: [], + confidence_threshold: 0, + distance: dists.reduce((a, b) => a + b, 0), + duration: endPoint.data.ts - startPoint.data.ts, + end_fmt_time: endTime.toFormat(formatString), + end_local_dt: dateTime2localdate(endTime, endPoint.metadata.time_zone), + end_ts: endPoint.data.ts, + expectation: {to_label: true}, + inferred_labels: [], + locations: locations, + source: "unprocessed", + start_fmt_time: startTime.toFormat(formatString), + start_local_dt: dateTime2localdate(startTime, startPoint.metadata.time_zone), + start_ts: startPoint.data.ts, + user_input: {}, + } +} +const tsEntrySort = function(e1, e2) { + // compare timestamps + return e1.data.ts - e2.data.ts; +} + +const transitionTrip2TripObj = function(trip) { + const tripStartTransition = trip[0]; + const tripEndTransition = trip[1]; + const tq = {key: "write_ts", + startTs: tripStartTransition.data.ts, + endTs: tripEndTransition.data.ts + } + logDebug('About to pull location data for range' + + DateTime.fromSeconds(tripStartTransition.data.ts) + .toLocaleString(DateTime.DATETIME_MED) + + DateTime.fromSeconds(tripEndTransition.data.ts) + .toLocaleString(DateTime.DATETIME_MED)); + const getSensorData = window['cordova'].plugins.BEMUserCache.getSensorDataForInterval; + return getUnifiedDataForInterval("background/filtered_location", tq, getSensorData) + .then(function(locationList: Array) { // change 'any' later + if (locationList.length == 0) { + return undefined; + } + const sortedLocationList = locationList.sort(tsEntrySort); + const retainInRange = function(loc) { + return (tripStartTransition.data.ts <= loc.data.ts) && + (loc.data.ts <= tripEndTransition.data.ts) + } + + var filteredLocationList = sortedLocationList.filter(retainInRange); + + // Fix for https://github.com/e-mission/e-mission-docs/issues/417 + if (filteredLocationList.length == 0) { + return undefined; + } + + const tripStartPoint = filteredLocationList[0]; + const tripEndPoint = filteredLocationList[filteredLocationList.length-1]; + logDebug("tripStartPoint = "+JSON.stringify(tripStartPoint)+"tripEndPoint = "+JSON.stringify(tripEndPoint)); + // if we get a list but our start and end are undefined + // let's print out the complete original list to get a clue + // this should help with debugging + // https://github.com/e-mission/e-mission-docs/issues/417 + // if it ever occurs again + if (tripStartPoint === undefined || tripEndPoint === undefined) { + logDebug("BUG 417 check: locationList = "+JSON.stringify(locationList)); + logDebug("transitions: start = "+JSON.stringify(tripStartTransition.data) + + ' end = ' + JSON.stringify(tripEndTransition.data.ts)); + } + + const tripProps = points2TripProps(filteredLocationList); + + return { + ...tripProps, + start_loc: { + type: "Point", + coordinates: [tripStartPoint.data.longitude, tripStartPoint.data.latitude] + }, + end_loc: { + type: "Point", + coordinates: [tripEndPoint.data.longitude, tripEndPoint.data.latitude], + }, + } + }); +} +const isStartingTransition = function(transWrapper) { + if(transWrapper.data.transition == 'local.transition.exited_geofence' || + transWrapper.data.transition == 'T_EXITED_GEOFENCE' || + transWrapper.data.transition == 1) { + return true; + } + return false; +} + +const isEndingTransition = function(transWrapper) { + // Logger.log("isEndingTransition: transWrapper.data.transition = "+transWrapper.data.transition); + if(transWrapper.data.transition == 'T_TRIP_ENDED' || + transWrapper.data.transition == 'local.transition.stopped_moving' || + transWrapper.data.transition == 2) { + // Logger.log("Returning true"); + return true; + } + // Logger.log("Returning false"); + return false; +} +/* + * This is going to be a bit tricky. As we can see from + * https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163, + * when we read local transitions, they have a string for the transition + * (e.g. `T_DATA_PUSHED`), while the remote transitions have an integer + * (e.g. `2`). + * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286338606 + * + * Also, at least on iOS, it is possible for trip end to be detected way + * after the end of the trip, so the trip end transition of a processed + * trip may actually show up as an unprocessed transition. + * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163 + * + * Let's abstract this out into our own minor state machine. + */ +const transitions2Trips = function(transitionList) { + var inTrip = false; + var tripList = [] + var currStartTransitionIndex = -1; + var currEndTransitionIndex = -1; + var processedUntil = 0; + + while(processedUntil < transitionList.length) { + // Logger.log("searching within list = "+JSON.stringify(transitionList.slice(processedUntil))); + if(inTrip == false) { + const foundStartTransitionIndex = transitionList.slice(processedUntil).findIndex(isStartingTransition); + if (foundStartTransitionIndex == -1) { + logDebug("No further unprocessed trips started, exiting loop"); + processedUntil = transitionList.length; + } else { + currStartTransitionIndex = processedUntil + foundStartTransitionIndex; + processedUntil = currStartTransitionIndex; + logDebug("Unprocessed trip started at "+JSON.stringify(transitionList[currStartTransitionIndex])); + inTrip = true; + } + } else { + const foundEndTransitionIndex = transitionList.slice(processedUntil).findIndex(isEndingTransition); + if (foundEndTransitionIndex == -1) { + logDebug("Can't find end for trip starting at "+JSON.stringify(transitionList[currStartTransitionIndex])+" dropping it"); + processedUntil = transitionList.length; + } else { + currEndTransitionIndex = processedUntil + foundEndTransitionIndex; + processedUntil = currEndTransitionIndex; + logDebug(`currEndTransitionIndex ${currEndTransitionIndex}`); + logDebug("Unprocessed trip starting at "+JSON.stringify(transitionList[currStartTransitionIndex])+" ends at "+JSON.stringify(transitionList[currEndTransitionIndex])); + tripList.push([transitionList[currStartTransitionIndex], + transitionList[currEndTransitionIndex]]); + inTrip = false; + } + } + } + return tripList; +} + +const linkTrips = function(trip1, trip2) { + // complete trip1 + trip1.starting_trip = {$oid: trip2.id}; + trip1.exit_fmt_time = trip2.enter_fmt_time; + trip1.exit_local_dt = trip2.enter_local_dt; + trip1.exit_ts = trip2.enter_ts; + + // start trip2 + trip2.ending_trip = {$oid: trip1.id}; + trip2.enter_fmt_time = trip1.exit_fmt_time; + trip2.enter_local_dt = trip1.exit_local_dt; + trip2.enter_ts = trip1.exit_ts; +} + + +export const readUnprocessedTrips = function(startTs, endTs, lastProcessedTrip) { + const $ionicLoading = getAngularService('$ionicLoading'); + $ionicLoading.show({ + template: i18next.t('service.reading-unprocessed-data') + }); + + var tq = {key: 'write_ts', + startTs, + endTs + } + logDebug('about to query for unprocessed trips from ' + + DateTime.fromSeconds(tq.startTs).toLocaleString(DateTime.DATETIME_MED) + + DateTime.fromSeconds(tq.endTs).toLocaleString(DateTime.DATETIME_MED) + ); + + const getMessageMethod = window['cordova'].plugins.BEMUserCache.getMessagesForInterval; + return getUnifiedDataForInterval("statemachine/transition", tq, getMessageMethod) + .then(function(transitionList: Array) { + if (transitionList.length == 0) { + logDebug('No unprocessed trips. yay!'); + $ionicLoading.hide(); + return []; + } else { + logDebug(`Found ${transitionList.length} transitions. yay!`); + const tripsList = transitions2Trips(transitionList); + logDebug(`Mapped into ${tripsList.length} trips. yay!`); + tripsList.forEach(function(trip) { + console.log(JSON.stringify(trip)); + }); + var tripFillPromises = tripsList.map(transitionTrip2TripObj); + return Promise.all(tripFillPromises).then(function(raw_trip_gj_list) { + // Now we need to link up the trips. linking unprocessed trips + // to one another is fairly simple, but we need to link the + // first unprocessed trip to the last processed trip. + // This might be challenging if we don't have any processed + // trips for the day. I don't want to go back forever until + // I find a trip. So if this is the first trip, we will start a + // new chain for now, since this is with unprocessed data + // anyway. + + logDebug(`mapped trips to trip_gj_list of size ${raw_trip_gj_list.length}`); + /* Filtering: we will keep trips that are 1) defined and 2) have a distance >= 100m or duration >= 5 minutes + https://github.com/e-mission/e-mission-docs/issues/966#issuecomment-1709112578 */ + const trip_gj_list = raw_trip_gj_list.filter((trip) => + trip && (trip.distance >= 100 || trip.duration >= 300) + ); + logDebug(`after filtering undefined and distance < 100, trip_gj_list size = ${raw_trip_gj_list.length}`); + // Link 0th trip to first, first to second, ... + for (var i = 0; i < trip_gj_list.length-1; i++) { + linkTrips(trip_gj_list[i], trip_gj_list[i+1]); + } + logDebug(`finished linking trips for list of size ${trip_gj_list.length}`); + if (lastProcessedTrip && trip_gj_list.length != 0) { + // Need to link the entire chain above to the processed data + logDebug("linking unprocessed and processed trip chains"); + linkTrips(lastProcessedTrip, trip_gj_list[0]); + } + $ionicLoading.hide(); + logDebug(`Returning final list of size ${trip_gj_list.length}`); + return trip_gj_list; + }); + } + }); +}; diff --git a/www/js/types/serverData.ts b/www/js/types/serverData.ts index 5d10ddcbf..8b14b79df 100644 --- a/www/js/types/serverData.ts +++ b/www/js/types/serverData.ts @@ -18,6 +18,7 @@ export type MetaData = { write_fmt_time: string, write_local_dt: LocalDt, origin_key?: string, + read_ts?: number, }; export type LocalDt = { From e1999fd85f63fe1b6eab9685ac055323b7ffbc77 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 2 Nov 2023 11:05:13 -0600 Subject: [PATCH 276/850] rename file --- www/js/splash/{storedevicesettings.js => storeDeviceSettings.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename www/js/splash/{storedevicesettings.js => storeDeviceSettings.ts} (100%) diff --git a/www/js/splash/storedevicesettings.js b/www/js/splash/storeDeviceSettings.ts similarity index 100% rename from www/js/splash/storedevicesettings.js rename to www/js/splash/storeDeviceSettings.ts From ede23473eed48016128db1f95fa6d4958ee234e9 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 2 Nov 2023 11:16:17 -0600 Subject: [PATCH 277/850] rewrite service to typscript in this rewrite, also include the subscription to events so that events can be called on consent and on intro done --- www/js/splash/storeDeviceSettings.ts | 104 ++++++++++++++++----------- 1 file changed, 63 insertions(+), 41 deletions(-) diff --git a/www/js/splash/storeDeviceSettings.ts b/www/js/splash/storeDeviceSettings.ts index 31543bc6c..0077499e5 100644 --- a/www/js/splash/storeDeviceSettings.ts +++ b/www/js/splash/storeDeviceSettings.ts @@ -1,48 +1,70 @@ -import angular from 'angular'; + import { updateUser } from '../commHelper'; import { isConsented, readConsentState } from "./startprefs"; +import i18next from 'i18next'; +import { displayError, logDebug } from '../plugin/logger'; +import { readIntroDone } from '../onboarding/onboardingHelper'; +import { subscribe, EVENT_NAMES } from '../customEventHandler'; -angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', - 'emission.services']) -.factory('StoreDeviceSettings', function($window, $state, $rootScope, $ionicPlatform, - $ionicPopup, Logger ) { +const storeDeviceSettings = function () { + var lang = i18next.resolvedLanguage; + var manufacturer = window['device'].manufacturer; + var osver = window['device'].version; + return window['cordova'].getAppVersion.getVersionNumber().then(function (appver) { + var updateJSON = { + phone_lang: lang, + curr_platform: window['cordova'].platformId, + manufacturer: manufacturer, + client_os_version: osver, + client_app_version: appver + }; + logDebug("About to update profile with settings = " + JSON.stringify(updateJSON)); + return updateUser(updateJSON); + }).then(function (updateJSON) { + // alert("Finished saving token = "+JSON.stringify(t.token)); + }).catch(function (error) { + displayError(error, "Error in updating profile to store device settings"); + }); +} - var storedevicesettings = {}; +/** + * @function stores device settings on reconsent + * @param event that called this function + * @param data data from the conesnt event + */ + const onConsentEvent = function (event, data) { + console.log("got consented event " + JSON.stringify(event['name']) + + " with data " + JSON.stringify(data)); + readIntroDone() + .then((isIntroDone) => { + if (isIntroDone) { + logDebug("intro is done -> reconsent situation, we already have a token -> store device settings"); + storeDeviceSettings(); + } + }); +} - storedevicesettings.storeDeviceSettings = function() { - var lang = i18next.resolvedLanguage; - var manufacturer = $window.device.manufacturer; - var osver = $window.device.version; - return $window.cordova.getAppVersion.getVersionNumber().then(function(appver) { - var updateJSON = { - phone_lang: lang, - curr_platform: ionic.Platform.platform(), - manufacturer: manufacturer, - client_os_version: osver, - client_app_version: appver - }; - Logger.log("About to update profile with settings = "+JSON.stringify(updateJSON)); - return updateUser(updateJSON); - }).then(function(updateJSON) { - // alert("Finished saving token = "+JSON.stringify(t.token)); - }).catch(function(error) { - Logger.displayError("Error in updating profile to store device settings", error); - }); - } +/** + * @function stores device settings after intro received + * @param event that called this function + * @param data from the event + */ +const onIntroEvent = function (event, data) { + logDebug("intro is done -> original consent situation, we should have a token by now -> store device settings"); + storeDeviceSettings(); +} - $ionicPlatform.ready().then(function() { - storedevicesettings.datacollect = $window.cordova.plugins.BEMDataCollection; - readConsentState() - .then(isConsented) - .then(function(consentState) { - if (consentState == true) { - storedevicesettings.storeDeviceSettings(); - } else { - Logger.log("no consent yet, waiting to store device settings in profile"); - } - }); - Logger.log("storedevicesettings startup done"); +const initStoreDeviceSettings = function () { + readConsentState() + .then(isConsented) + .then(function (consentState) { + if (consentState == true) { + storeDeviceSettings(); + } else { + logDebug("no consent yet, waiting to store device settings in profile"); + } + subscribe(EVENT_NAMES.CONSENTED_EVENT, (event) => onConsentEvent(event, event.detail)); + subscribe(EVENT_NAMES.INTRO_DONE_EVENT, (event) => onIntroEvent(event, event.detail)); }); - - return storedevicesettings; -}); + logDebug("storedevicesettings startup done"); +} From cd6dc05a14d8e031f0dd9de1fd56e0bc8610b55a Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 2 Nov 2023 11:16:53 -0600 Subject: [PATCH 278/850] remove / update references remove references to the angular service, events replace explicit calls on consent and on intro done --- www/index.js | 1 - www/js/controllers.js | 5 ++--- www/js/onboarding/onboardingHelper.ts | 5 +---- www/js/splash/startprefs.ts | 10 ---------- 4 files changed, 3 insertions(+), 18 deletions(-) diff --git a/www/index.js b/www/index.js index 8bcbe32a6..ca1bbcfbb 100644 --- a/www/index.js +++ b/www/index.js @@ -5,7 +5,6 @@ import 'leaflet/dist/leaflet.css'; import './js/ngApp.js'; import './js/splash/referral.js'; -import './js/splash/storedevicesettings.js'; import './js/splash/localnotify.js'; import './js/splash/remotenotify.js'; import './js/splash/notifScheduler.js'; diff --git a/www/js/controllers.js b/www/js/controllers.js index d645c01d7..4e6be3777 100644 --- a/www/js/controllers.js +++ b/www/js/controllers.js @@ -4,8 +4,7 @@ import angular from 'angular'; import { addStatError, addStatReading, statKeys } from './plugin/clientStats'; import { getPendingOnboardingState } from './onboarding/onboardingHelper'; -angular.module('emission.controllers', ['emission.splash.storedevicesettings', - 'emission.splash.localnotify', +angular.module('emission.controllers', ['emission.splash.localnotify', 'emission.splash.remotenotify']) .controller('RootCtrl', function($scope) {}) @@ -13,7 +12,7 @@ angular.module('emission.controllers', ['emission.splash.storedevicesettings', .controller('DashCtrl', function($scope) {}) .controller('SplashCtrl', function($scope, $state, $interval, $rootScope, - StoreDeviceSettings, LocalNotify, RemoteNotify) { + LocalNotify, RemoteNotify) { console.log('SplashCtrl invoked'); // alert("attach debugger!"); // PushNotify.startupInit(); diff --git a/www/js/onboarding/onboardingHelper.ts b/www/js/onboarding/onboardingHelper.ts index 88c5fd934..70ccb4bf2 100644 --- a/www/js/onboarding/onboardingHelper.ts +++ b/www/js/onboarding/onboardingHelper.ts @@ -4,7 +4,6 @@ import { storageGet, storageSet } from "../plugin/storage"; import { logDebug } from "../plugin/logger"; import { EVENT_NAMES, publish } from "../customEventHandler"; import { readConsentState, isConsented } from "../splash/startprefs"; -import { getAngularService } from "../angular-react-helper"; export const INTRO_DONE_KEY = 'intro_done'; @@ -75,9 +74,7 @@ export async function markIntroDone() { return storageSet(INTRO_DONE_KEY, currDateTime) .then(() => { //handle "on intro" events - logDebug("intro done, calling registerPush and storeDeviceSettings"); - const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); + logDebug("intro done, publishing event"); publish(EVENT_NAMES.INTRO_DONE_EVENT, currDateTime); - StoreSeviceSettings.storeDeviceSettings(); }); } diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index 3c4823af7..bb4054d65 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -41,16 +41,6 @@ export function markConsented() { // publish event publish(EVENT_NAMES.CONSENTED_EVENT, _req_consent); }) - //check for reconsent - .then(readIntroDone) - .then((isIntroDone) => { - if (isIntroDone) { - logDebug("reconsent scenario - marked consent after intro done - registering pushnoify and storing device settings"); - //pushnotify uses events now - const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); - StoreSeviceSettings.storeDeviceSettings(); - } - }) .catch((error) => { displayErrorMsg(error, "Error while while wrting consent to storage"); }); From 7b7efc870579e652177597c2b5e520b8e6f4e2fe Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 2 Nov 2023 11:19:08 -0600 Subject: [PATCH 279/850] add initStoreDeviceSettings call this initializes the file, and conducts the subscription to onConsent and onIntroDone events --- www/js/App.tsx | 2 ++ www/js/splash/storeDeviceSettings.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/www/js/App.tsx b/www/js/App.tsx index 15e530e9c..04e049938 100644 --- a/www/js/App.tsx +++ b/www/js/App.tsx @@ -12,6 +12,7 @@ import { setServerConnSettings } from './config/serverConn'; import AppStatusModal from './control/AppStatusModal'; import usePermissionStatus from './usePermissionStatus'; import { initPushNotify } from './splash/pushNotifySettings'; +import { initStoreDeviceSettings } from './splash/storeDeviceSettings'; const defaultRoutes = (t) => [ { key: 'label', title: t('diary.label-tab'), focusedIcon: 'check-bold', unfocusedIcon: 'check-outline' }, @@ -52,6 +53,7 @@ const App = () => { refreshOnboardingState(); }); initPushNotify(); + initStoreDeviceSettings(); }, [appConfig]); const appContextValue = { diff --git a/www/js/splash/storeDeviceSettings.ts b/www/js/splash/storeDeviceSettings.ts index 0077499e5..e91af6962 100644 --- a/www/js/splash/storeDeviceSettings.ts +++ b/www/js/splash/storeDeviceSettings.ts @@ -54,7 +54,7 @@ const onIntroEvent = function (event, data) { storeDeviceSettings(); } -const initStoreDeviceSettings = function () { +export const initStoreDeviceSettings = function () { readConsentState() .then(isConsented) .then(function (consentState) { From b5736a1a45f18cb2aade5ad02904142d198b6b18 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 2 Nov 2023 12:06:25 -0700 Subject: [PATCH 280/850] Fully removed angular service --- www/index.js | 1 - www/js/diary.js | 3 +- www/js/diary/LabelTab.tsx | 1 - www/js/diary/services.js | 58 --------------------------------------- 4 files changed, 1 insertion(+), 62 deletions(-) delete mode 100644 www/js/diary/services.js diff --git a/www/index.js b/www/index.js index 0a0c63708..952436799 100644 --- a/www/index.js +++ b/www/index.js @@ -18,7 +18,6 @@ import './js/main.js'; import './js/survey/input-matcher.js'; import './js/survey/multilabel/multi-label-ui.js'; import './js/diary.js'; -import './js/diary/services.js'; import './js/survey/enketo/answer.js'; import './js/survey/enketo/enketo-trip-button.js'; import './js/survey/enketo/enketo-add-note-button.js'; diff --git a/www/js/diary.js b/www/js/diary.js index c0b7bce35..d83aaee3e 100644 --- a/www/js/diary.js +++ b/www/js/diary.js @@ -1,8 +1,7 @@ import angular from 'angular'; import LabelTab from './diary/LabelTab'; -angular.module('emission.main.diary',['emission.main.diary.services', - 'emission.survey.multilabel.buttons', +angular.module('emission.main.diary',['emission.survey.multilabel.buttons', 'emission.survey.enketo.add-note-button', 'emission.survey.enketo.trip.button', 'emission.plugin.logger']) diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 9f76e891c..92d2d1ab0 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -48,7 +48,6 @@ const LabelTab = () => { const $state = getAngularService('$state'); const $ionicPopup = getAngularService('$ionicPopup'); const Logger = getAngularService('Logger'); - const Timeline = getAngularService('Timeline'); const enbs = getAngularService('EnketoNotesButtonService'); // initialization, once the appConfig is loaded diff --git a/www/js/diary/services.js b/www/js/diary/services.js deleted file mode 100644 index aebaf2518..000000000 --- a/www/js/diary/services.js +++ /dev/null @@ -1,58 +0,0 @@ -'use strict'; - -import angular from 'angular'; -import { SurveyOptions } from '../survey/survey'; -import { getConfig } from '../config/dynamicConfig'; - -angular.module('emission.main.diary.services', ['emission.plugin.logger', - 'emission.services']) -.factory('Timeline', function($http, $ionicLoading, $ionicPlatform, $window, - $rootScope, Logger, $injector) { - var timeline = {}; - // corresponds to the old $scope.data. Contains all state for the current - // day, including the indication of the current day - timeline.data = {}; - timeline.data.unifiedConfirmsResults = null; - timeline.UPDATE_DONE = "TIMELINE_UPDATE_DONE"; - - let manualInputFactory; - $ionicPlatform.ready(function () { - getConfig().then((configObj) => { - const surveyOptKey = configObj.survey_info['trip-labels']; - const surveyOpt = SurveyOptions[surveyOptKey]; - console.log('surveyOpt in services.js is', surveyOpt); - manualInputFactory = $injector.get(surveyOpt.service); - }); - }); - - /* - * Fill out place geojson after pulling trip location points. - * Place is only partially filled out because we haven't linked the timeline yet - */ - - var localCacheReadFn = timeline.updateFromDatabase; - - timeline.getTrip = function(tripId) { - return angular.isDefined(timeline.data.tripMap)? timeline.data.tripMap[tripId] : undefined; - }; - - timeline.getTripWrapper = function(tripId) { - return angular.isDefined(timeline.data.tripWrapperMap)? timeline.data.tripWrapperMap[tripId] : undefined; - }; - - timeline.getCompositeTrip = function(tripId) { - return angular.isDefined(timeline.data.infScrollCompositeTripMap)? timeline.data.infScrollCompositeTripMap[tripId] : undefined; - }; - - timeline.setInfScrollCompositeTripList = function(compositeTripList) { - timeline.data.infScrollCompositeTripList = compositeTripList; - - timeline.data.infScrollCompositeTripMap = {}; - - timeline.data.infScrollCompositeTripList.forEach(function(trip, index, array) { - timeline.data.infScrollCompositeTripMap[trip._id.$oid] = trip; - }); - } - - return timeline; - }) From 78eb704621ad2096baa2d1643374db3a0a46ad98 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 2 Nov 2023 13:35:02 -0600 Subject: [PATCH 281/850] add more docstrings --- www/js/splash/storeDeviceSettings.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/www/js/splash/storeDeviceSettings.ts b/www/js/splash/storeDeviceSettings.ts index e91af6962..f4269355e 100644 --- a/www/js/splash/storeDeviceSettings.ts +++ b/www/js/splash/storeDeviceSettings.ts @@ -6,6 +6,10 @@ import { displayError, logDebug } from '../plugin/logger'; import { readIntroDone } from '../onboarding/onboardingHelper'; import { subscribe, EVENT_NAMES } from '../customEventHandler'; +/** + * @function Gathers information about the user's device and stores it + * @returns promise to updateUser in comm settings with device info + */ const storeDeviceSettings = function () { var lang = i18next.resolvedLanguage; var manufacturer = window['device'].manufacturer; @@ -54,6 +58,10 @@ const onIntroEvent = function (event, data) { storeDeviceSettings(); } +/** + * @function initializes store device: subscribes to events + * stores settings if already consented + */ export const initStoreDeviceSettings = function () { readConsentState() .then(isConsented) From 61ab8817b9d50daba54008a5f935cc2a86fa997d Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 2 Nov 2023 13:00:42 -0700 Subject: [PATCH 282/850] we no longer need this file with prettier --- .editorconfig | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index aca25232d..000000000 --- a/.editorconfig +++ /dev/null @@ -1,14 +0,0 @@ -# http://editorconfig.org -root = true - -[*] -charset = utf-8 -indent_style = space -indent_size = 2 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true - -[*.md] -insert_final_newline = false -trim_trailing_whitespace = false \ No newline at end of file From 23cba1cd2a1913cd5ae00932d753113d3c3127b6 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 2 Nov 2023 13:01:16 -0700 Subject: [PATCH 283/850] only check 'www' directory except dist, manual_lib, and json --- .prettierignore | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .prettierignore diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..be7b1726d --- /dev/null +++ b/.prettierignore @@ -0,0 +1,10 @@ +# Ignore www/dist, manual_lib, json +www/dist +www/manual_lib +www/json + +# This is the pattern to check only www directory +# Ignore all +/* +# but don't ignore all the files in www directory +!/www From b92f993adf50080181feaf49e14b2f5904c8429f Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 2 Nov 2023 13:01:38 -0700 Subject: [PATCH 284/850] update prettier config --- .prettierrc | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .prettierrc diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..5875d605a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "singleQuote": true, + "trailingComma": "all", + "bracketSpacing": true, + "bracketSameLine": true, + "endOfLine": "lf", + "semi": true +} From 4707d827f7fe0fe188514c5da022e3b809d55bde Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 2 Nov 2023 13:02:42 -0700 Subject: [PATCH 285/850] add prettier package --- package.serve.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.serve.json b/package.serve.json index 8da81f941..6315b6a46 100644 --- a/package.serve.json +++ b/package.serve.json @@ -49,7 +49,8 @@ "typescript": "^5.0.3", "url-loader": "^4.1.1", "webpack": "^5.0.1", - "webpack-cli": "^5.0.1" + "webpack-cli": "^5.0.1", + "prettier": "3.0.3" }, "dependencies": { "@react-navigation/native": "^6.1.7", From bb73676c7245b68b9a8784204b68d71ba0af5267 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 2 Nov 2023 13:05:36 -0700 Subject: [PATCH 286/850] format the files with prettier --- www/__mocks__/cordovaMocks.ts | 54 +- www/__mocks__/fileSystemMocks.ts | 26 +- www/__mocks__/globalMocks.ts | 2 +- www/__tests__/LoadMoreButton.test.tsx | 27 +- www/__tests__/clientStats.test.ts | 16 +- www/__tests__/commHelper.test.ts | 26 +- www/__tests__/customURL.test.ts | 60 +- www/__tests__/diaryHelper.test.ts | 101 +- www/__tests__/startprefs.test.ts | 43 +- www/__tests__/storage.test.ts | 6 +- www/__tests__/uploadService.test.ts | 64 +- www/__tests__/useImperialConfig.test.ts | 7 +- www/build/app.css | 3147 +++++++++++++---- www/build/static.css | 70 +- www/css/main.diary.css | 169 +- www/css/style.css | 497 +-- www/i18n/en.json | 876 ++--- www/index.html | 14 +- www/js/App.tsx | 76 +- www/js/angular-react-helper.tsx | 4 +- www/js/appTheme.ts | 28 +- www/js/appstatus/ExplainPermissions.tsx | 63 +- www/js/appstatus/PermissionItem.tsx | 30 +- www/js/appstatus/PermissionsControls.tsx | 110 +- www/js/commHelper.ts | 171 +- www/js/components/ActionMenu.tsx | 79 +- www/js/components/BarChart.tsx | 21 +- www/js/components/Carousel.tsx | 23 +- www/js/components/Chart.tsx | 290 +- www/js/components/DiaryButton.tsx | 21 +- www/js/components/Icon.tsx | 15 +- www/js/components/LeafletView.tsx | 51 +- www/js/components/LineChart.tsx | 12 +- www/js/components/NavBarButton.tsx | 56 +- www/js/components/QrCode.tsx | 47 +- www/js/components/ToggleSwitch.tsx | 21 +- www/js/components/charting.ts | 90 +- www/js/config/dynamicConfig.ts | 231 +- www/js/config/enketo-config.js | 10 +- www/js/config/serverConn.ts | 9 +- www/js/config/useImperialConfig.ts | 43 +- www/js/control/AlertBar.jsx | 65 +- www/js/control/AppStatusModal.tsx | 66 +- www/js/control/ControlCollectionHelper.tsx | 532 +-- www/js/control/ControlDataTable.jsx | 14 +- www/js/control/ControlSyncHelper.tsx | 533 +-- www/js/control/DataDatePicker.tsx | 40 +- www/js/control/DemographicsSettingRow.jsx | 45 +- www/js/control/ExpandMenu.jsx | 24 +- www/js/control/LogPage.tsx | 322 +- www/js/control/PopOpCode.jsx | 151 +- www/js/control/PrivacyPolicyModal.tsx | 57 +- www/js/control/ProfileSettings.jsx | 1194 ++++--- www/js/control/ReminderTime.tsx | 99 +- www/js/control/SensedPage.tsx | 164 +- www/js/control/SettingRow.jsx | 93 +- www/js/control/emailService.js | 179 +- www/js/control/uploadService.ts | 202 +- www/js/controllers.js | 171 +- www/js/diary.js | 28 +- www/js/diary/LabelTab.tsx | 164 +- www/js/diary/addressNamesHelper.ts | 58 +- www/js/diary/cards/DiaryCard.tsx | 46 +- www/js/diary/cards/ModesIndicator.tsx | 72 +- www/js/diary/cards/PlaceCard.tsx | 51 +- www/js/diary/cards/TimestampBadge.tsx | 32 +- www/js/diary/cards/TripCard.tsx | 142 +- www/js/diary/cards/UntrackedTimeCard.tsx | 53 +- www/js/diary/components/StartEndLocations.tsx | 103 +- www/js/diary/details/LabelDetailsScreen.tsx | 142 +- .../diary/details/OverallTripDescriptives.tsx | 31 +- .../details/TripSectionsDescriptives.tsx | 73 +- www/js/diary/diaryHelper.ts | 136 +- www/js/diary/diaryTypes.ts | 102 +- www/js/diary/list/DateSelect.tsx | 75 +- www/js/diary/list/FilterSelect.tsx | 88 +- www/js/diary/list/LabelListScreen.tsx | 87 +- www/js/diary/list/LoadMoreButton.tsx | 22 +- www/js/diary/list/TimelineScrollList.tsx | 79 +- www/js/diary/services.js | 574 +-- www/js/diary/timelineHelper.ts | 73 +- www/js/diary/useDerivedProperties.tsx | 21 +- www/js/i18n-utils.js | 47 +- www/js/i18nextInit.ts | 26 +- www/js/main.js | 59 +- www/js/metrics-factory.js | 409 ++- www/js/metrics-mappings.js | 722 ++-- www/js/metrics/ActiveMinutesTableCard.tsx | 100 +- www/js/metrics/CarbonFootprintCard.tsx | 382 +- www/js/metrics/CarbonTextCard.tsx | 246 +- www/js/metrics/ChangeIndicator.tsx | 129 +- www/js/metrics/DailyActiveMinutesCard.tsx | 46 +- www/js/metrics/MetricsCard.tsx | 146 +- www/js/metrics/MetricsDateSelect.tsx | 94 +- www/js/metrics/MetricsTab.tsx | 153 +- www/js/metrics/WeeklyActiveMinutesCard.tsx | 71 +- www/js/metrics/metricsHelper.ts | 163 +- www/js/metrics/metricsTypes.ts | 20 +- www/js/ngApp.js | 86 +- www/js/onboarding/OnboardingStack.tsx | 25 +- www/js/onboarding/PrivacyPolicy.tsx | 239 +- www/js/onboarding/ProtocolPage.tsx | 41 +- www/js/onboarding/SaveQrPage.tsx | 102 +- www/js/onboarding/StudySummary.tsx | 41 +- www/js/onboarding/SummaryPage.tsx | 34 +- www/js/onboarding/SurveyPage.tsx | 107 +- www/js/onboarding/WelcomePage.tsx | 241 +- www/js/onboarding/onboardingHelper.ts | 112 +- www/js/plugin/clientStats.ts | 52 +- www/js/plugin/logger.ts | 51 +- www/js/plugin/storage.ts | 173 +- www/js/services.js | 518 +-- www/js/splash/customURL.ts | 36 +- www/js/splash/localnotify.js | 212 +- www/js/splash/notifScheduler.js | 374 +- www/js/splash/pushnotify.js | 285 +- www/js/splash/referral.js | 60 +- www/js/splash/remotenotify.js | 84 +- www/js/splash/startprefs.ts | 107 +- www/js/splash/storedevicesettings.js | 87 +- www/js/survey/enketo/AddNoteButton.tsx | 75 +- www/js/survey/enketo/AddedNotesList.tsx | 182 +- www/js/survey/enketo/EnketoModal.tsx | 98 +- www/js/survey/enketo/UserInputButton.tsx | 65 +- www/js/survey/enketo/answer.js | 357 +- .../survey/enketo/enketo-add-note-button.js | 206 +- www/js/survey/enketo/enketo-trip-button.js | 184 +- www/js/survey/enketo/enketoHelper.ts | 99 +- .../survey/enketo/infinite_scroll_filters.ts | 42 +- www/js/survey/input-matcher.js | 366 +- .../multilabel/MultiLabelButtonGroup.tsx | 198 +- www/js/survey/multilabel/confirmHelper.ts | 104 +- .../multilabel/infinite_scroll_filters.ts | 75 +- www/js/survey/multilabel/multi-label-ui.js | 415 ++- www/js/survey/survey.ts | 20 +- www/js/useAppConfig.ts | 11 +- www/js/useAppStateChange.ts | 35 +- www/js/usePermissionStatus.ts | 708 ++-- 138 files changed, 12778 insertions(+), 9251 deletions(-) diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index c00377120..62aa9be1a 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -5,13 +5,13 @@ export const mockCordova = () => { window['cordova'].platformId ||= 'ios'; window['cordova'].platformVersion ||= packageJsonBuild.dependencies['cordova-ios']; window['cordova'].plugins ||= {}; -} +}; export const mockDevice = () => { window['device'] ||= {}; window['device'].platform ||= 'ios'; window['device'].version ||= '14.0.0'; -} +}; export const mockGetAppVersion = () => { const mockGetAppVersion = { @@ -19,15 +19,17 @@ export const mockGetAppVersion = () => { getPackageName: () => new Promise((rs, rj) => setTimeout(() => rs('com.example.mockapp'), 10)), getVersionCode: () => new Promise((rs, rj) => setTimeout(() => rs('123'), 10)), getVersionNumber: () => new Promise((rs, rj) => setTimeout(() => rs('1.2.3'), 10)), - } + }; window['cordova'] ||= {}; window['cordova'].getAppVersion = mockGetAppVersion; -} +}; export const mockFile = () => { - window['cordova'].file = { "dataDirectory" : "../path/to/data/directory", - "applicationStorageDirectory" : "../path/to/app/storage/directory"}; -} + window['cordova'].file = { + dataDirectory: '../path/to/data/directory', + applicationStorageDirectory: '../path/to/app/storage/directory', + }; +}; //for consent document const _storage = {}; @@ -40,7 +42,7 @@ export const mockBEMUserCache = () => { return new Promise((rs, rj) => setTimeout(() => { rs(_cache[key]); - }, 100) + }, 100), ); }, putLocalStorage: (key: string, value: any) => { @@ -48,7 +50,7 @@ export const mockBEMUserCache = () => { setTimeout(() => { _cache[key] = value; rs(); - }, 100) + }, 100), ); }, removeLocalStorage: (key: string) => { @@ -56,7 +58,7 @@ export const mockBEMUserCache = () => { setTimeout(() => { delete _cache[key]; rs(); - }, 100) + }, 100), ); }, clearAll: () => { @@ -64,21 +66,21 @@ export const mockBEMUserCache = () => { setTimeout(() => { for (let p in _cache) delete _cache[p]; rs(); - }, 100) + }, 100), ); }, listAllLocalStorageKeys: () => { return new Promise((rs, rj) => setTimeout(() => { rs(Object.keys(_cache)); - }, 100) + }, 100), ); }, listAllUniqueKeys: () => { return new Promise((rs, rj) => setTimeout(() => { rs(Object.keys(_cache)); - }, 100) + }, 100), ); }, putMessage: (key: string, value: any) => { @@ -86,46 +88,48 @@ export const mockBEMUserCache = () => { setTimeout(() => { messages.push({ key, value }); rs(); - }, 100) + }, 100), ); }, getAllMessages: (key: string, withMetadata?: boolean) => { return new Promise((rs, rj) => setTimeout(() => { - rs(messages.filter(m => m.key == key).map(m => m.value)); - }, 100) + rs(messages.filter((m) => m.key == key).map((m) => m.value)); + }, 100), ); }, getDocument: (key: string, withMetadata?: boolean) => { return new Promise((rs, rj) => setTimeout(() => { rs(_storage[key]); - }, 100) + }, 100), ); }, isEmptyDoc: (doc) => { - if (doc == undefined) { return true } + if (doc == undefined) { + return true; + } let string = doc.toString(); if (string.length == 0) { return true; } else { return false; } - } - } + }, + }; window['cordova'] ||= {}; window['cordova'].plugins ||= {}; window['cordova'].plugins.BEMUserCache = mockBEMUserCache; -} +}; export const mockBEMDataCollection = () => { const mockBEMDataCollection = { markConsented: (consentDoc) => { setTimeout(() => { _storage['config/consent'] = consentDoc; - }, 100) - } - } + }, 100); + }, + }; window['cordova'] ||= {}; window['cordova'].plugins.BEMDataCollection = mockBEMDataCollection; -} +}; diff --git a/www/__mocks__/fileSystemMocks.ts b/www/__mocks__/fileSystemMocks.ts index d7c2743ac..70b532507 100644 --- a/www/__mocks__/fileSystemMocks.ts +++ b/www/__mocks__/fileSystemMocks.ts @@ -1,23 +1,21 @@ export const mockFileSystem = () => { window['resolveLocalFileSystemURL'] = function (parentDir, handleFS) { const fs = { - filesystem: - { - root: - { + filesystem: { + root: { getFile: (path, options, onSuccess) => { let fileEntry = { file: (handleFile) => { - let file = new File(["this is a mock"], "loggerDB"); + let file = new File(['this is a mock'], 'loggerDB'); handleFile(file); - } - } + }, + }; onSuccess(fileEntry); - } - } - } - } - console.log("in mock, fs is ", fs, " get File is ", fs.filesystem.root.getFile); + }, + }, + }, + }; + console.log('in mock, fs is ', fs, ' get File is ', fs.filesystem.root.getFile); handleFS(fs); - } -} \ No newline at end of file + }; +}; diff --git a/www/__mocks__/globalMocks.ts b/www/__mocks__/globalMocks.ts index 3d9b71507..f13cb274b 100644 --- a/www/__mocks__/globalMocks.ts +++ b/www/__mocks__/globalMocks.ts @@ -1,3 +1,3 @@ export const mockLogger = () => { window['Logger'] = { log: console.log }; -} +}; diff --git a/www/__tests__/LoadMoreButton.test.tsx b/www/__tests__/LoadMoreButton.test.tsx index 5acb4a700..100cf19fc 100644 --- a/www/__tests__/LoadMoreButton.test.tsx +++ b/www/__tests__/LoadMoreButton.test.tsx @@ -1,30 +1,23 @@ /** * @jest-environment jsdom */ -import React from 'react' -import {render, fireEvent, waitFor, screen} from '@testing-library/react-native' -import LoadMoreButton from '../js/diary/list/LoadMoreButton' +import React from 'react'; +import { render, fireEvent, waitFor, screen } from '@testing-library/react-native'; +import LoadMoreButton from '../js/diary/list/LoadMoreButton'; - -describe("LoadMoreButton", () => { - it("renders correctly", async () => { - render( - {}}>{} - ); +describe('LoadMoreButton', () => { + it('renders correctly', async () => { + render( {}}>{}); await waitFor(() => { - expect(screen.getByTestId("load-button")).toBeTruthy(); + expect(screen.getByTestId('load-button')).toBeTruthy(); }); }); - it("calls onPressFn when clicked", () => { + it('calls onPressFn when clicked', () => { const mockFn = jest.fn(); - const { getByTestId } = render( - {} - ); - const loadButton = getByTestId("load-button"); + const { getByTestId } = render({}); + const loadButton = getByTestId('load-button'); fireEvent.press(loadButton); expect(mockFn).toHaveBeenCalled(); }); }); - - diff --git a/www/__tests__/clientStats.test.ts b/www/__tests__/clientStats.test.ts index d1a054195..a3a953582 100644 --- a/www/__tests__/clientStats.test.ts +++ b/www/__tests__/clientStats.test.ts @@ -1,5 +1,11 @@ -import { mockBEMUserCache, mockDevice, mockGetAppVersion } from "../__mocks__/cordovaMocks"; -import { addStatError, addStatEvent, addStatReading, getAppVersion, statKeys } from "../js/plugin/clientStats"; +import { mockBEMUserCache, mockDevice, mockGetAppVersion } from '../__mocks__/cordovaMocks'; +import { + addStatError, + addStatEvent, + addStatReading, + getAppVersion, + statKeys, +} from '../js/plugin/clientStats'; mockDevice(); // this mocks cordova-plugin-app-version, generating a "Mock App", version "1.2.3" @@ -22,7 +28,7 @@ it('stores a client stats reading', async () => { ts: expect.any(Number), reading, client_app_version: '1.2.3', - client_os_version: '14.0.0' + client_os_version: '14.0.0', }); }); @@ -34,7 +40,7 @@ it('stores a client stats event', async () => { ts: expect.any(Number), reading: null, client_app_version: '1.2.3', - client_os_version: '14.0.0' + client_os_version: '14.0.0', }); }); @@ -47,6 +53,6 @@ it('stores a client stats error', async () => { ts: expect.any(Number), reading: errorStr, client_app_version: '1.2.3', - client_os_version: '14.0.0' + client_os_version: '14.0.0', }); }); diff --git a/www/__tests__/commHelper.test.ts b/www/__tests__/commHelper.test.ts index 2e2dfc6af..8bc52a408 100644 --- a/www/__tests__/commHelper.test.ts +++ b/www/__tests__/commHelper.test.ts @@ -5,19 +5,27 @@ mockLogger(); // mock for JavaScript 'fetch' // we emulate a 100ms delay when i) fetching data and ii) parsing it as text -global.fetch = (url: string) => new Promise((rs, rj) => { - setTimeout(() => rs({ - text: () => new Promise((rs, rj) => { - setTimeout(() => rs('mock data for ' + url), 100); - }) - })); -}) as any; +global.fetch = (url: string) => + new Promise((rs, rj) => { + setTimeout(() => + rs({ + text: () => + new Promise((rs, rj) => { + setTimeout(() => rs('mock data for ' + url), 100); + }), + }), + ); + }) as any; it('fetches text from a URL and caches it so the next call is faster', async () => { const tsBeforeCalls = Date.now(); - const text1 = await fetchUrlCached('https://raw.githubusercontent.com/e-mission/e-mission-phone/master/README.md'); + const text1 = await fetchUrlCached( + 'https://raw.githubusercontent.com/e-mission/e-mission-phone/master/README.md', + ); const tsBetweenCalls = Date.now(); - const text2 = await fetchUrlCached('https://raw.githubusercontent.com/e-mission/e-mission-phone/master/README.md'); + const text2 = await fetchUrlCached( + 'https://raw.githubusercontent.com/e-mission/e-mission-phone/master/README.md', + ); const tsAfterCalls = Date.now(); expect(text1).toEqual(expect.stringContaining('mock data')); expect(text2).toEqual(expect.stringContaining('mock data')); diff --git a/www/__tests__/customURL.test.ts b/www/__tests__/customURL.test.ts index 68ce3c47d..c06345679 100644 --- a/www/__tests__/customURL.test.ts +++ b/www/__tests__/customURL.test.ts @@ -1,38 +1,38 @@ import { onLaunchCustomURL } from '../js/splash/customURL'; describe('onLaunchCustomURL', () => { - let mockHandler; + let mockHandler; - beforeEach(() => { - // create a new mock handler before each test case. - mockHandler = jest.fn(); - }); + beforeEach(() => { + // create a new mock handler before each test case. + mockHandler = jest.fn(); + }); - it('tests valid url 1 - should call handler callback with valid URL and the handler should be called with correct parameters', () => { - const validURL = 'emission://login_token?token=nrelop_dev-emulator-program'; - const expectedURL = 'login_token?token=nrelop_dev-emulator-program'; - const expectedComponents = { route: 'login_token', token: 'nrelop_dev-emulator-program' }; - onLaunchCustomURL(validURL, mockHandler); - expect(mockHandler).toHaveBeenCalledWith(expectedURL, expectedComponents); - }); + it('tests valid url 1 - should call handler callback with valid URL and the handler should be called with correct parameters', () => { + const validURL = 'emission://login_token?token=nrelop_dev-emulator-program'; + const expectedURL = 'login_token?token=nrelop_dev-emulator-program'; + const expectedComponents = { route: 'login_token', token: 'nrelop_dev-emulator-program' }; + onLaunchCustomURL(validURL, mockHandler); + expect(mockHandler).toHaveBeenCalledWith(expectedURL, expectedComponents); + }); - it('tests valid url 2 - should call handler callback with valid URL and the handler should be called with correct parameters', () => { - const validURL = 'emission://test?param1=first¶m2=second'; - const expectedURL = 'test?param1=first¶m2=second'; - const expectedComponents = { route: 'test', param1: 'first', param2: 'second' }; - onLaunchCustomURL(validURL, mockHandler); - expect(mockHandler).toHaveBeenCalledWith(expectedURL, expectedComponents); - }); + it('tests valid url 2 - should call handler callback with valid URL and the handler should be called with correct parameters', () => { + const validURL = 'emission://test?param1=first¶m2=second'; + const expectedURL = 'test?param1=first¶m2=second'; + const expectedComponents = { route: 'test', param1: 'first', param2: 'second' }; + onLaunchCustomURL(validURL, mockHandler); + expect(mockHandler).toHaveBeenCalledWith(expectedURL, expectedComponents); + }); - it('test invalid url 1 - should not call handler callback with invalid URL', () => { - const invalidURL = 'invalid_url'; - onLaunchCustomURL(invalidURL, mockHandler); - expect(mockHandler).not.toHaveBeenCalled(); - }); + it('test invalid url 1 - should not call handler callback with invalid URL', () => { + const invalidURL = 'invalid_url'; + onLaunchCustomURL(invalidURL, mockHandler); + expect(mockHandler).not.toHaveBeenCalled(); + }); - it('tests invalid url 2 - should not call handler callback with invalid URL', () => { - const invalidURL = ''; - onLaunchCustomURL(invalidURL, mockHandler); - expect(mockHandler).not.toHaveBeenCalled(); - }); -}) \ No newline at end of file + it('tests invalid url 2 - should not call handler callback with invalid URL', () => { + const invalidURL = ''; + onLaunchCustomURL(invalidURL, mockHandler); + expect(mockHandler).not.toHaveBeenCalled(); + }); +}); diff --git a/www/__tests__/diaryHelper.test.ts b/www/__tests__/diaryHelper.test.ts index 822b19bba..1ac143334 100644 --- a/www/__tests__/diaryHelper.test.ts +++ b/www/__tests__/diaryHelper.test.ts @@ -1,63 +1,86 @@ -import { getFormattedDate, isMultiDay, getFormattedDateAbbr, getFormattedTimeRange, getDetectedModes, getBaseModeByKey, modeColors } from "../js/diary/diaryHelper"; +import { + getFormattedDate, + isMultiDay, + getFormattedDateAbbr, + getFormattedTimeRange, + getDetectedModes, + getBaseModeByKey, + modeColors, +} from '../js/diary/diaryHelper'; it('returns a formatted date', () => { - expect(getFormattedDate("2023-09-18T00:00:00-07:00")).toBe("Mon September 18, 2023"); - expect(getFormattedDate("")).toBeUndefined(); - expect(getFormattedDate("2023-09-18T00:00:00-07:00", "2023-09-21T00:00:00-07:00")).toBe("Mon September 18, 2023 - Thu September 21, 2023"); + expect(getFormattedDate('2023-09-18T00:00:00-07:00')).toBe('Mon September 18, 2023'); + expect(getFormattedDate('')).toBeUndefined(); + expect(getFormattedDate('2023-09-18T00:00:00-07:00', '2023-09-21T00:00:00-07:00')).toBe( + 'Mon September 18, 2023 - Thu September 21, 2023', + ); }); it('returns an abbreviated formatted date', () => { - expect(getFormattedDateAbbr("2023-09-18T00:00:00-07:00")).toBe("Mon, Sep 18"); - expect(getFormattedDateAbbr("")).toBeUndefined(); - expect(getFormattedDateAbbr("2023-09-18T00:00:00-07:00", "2023-09-21T00:00:00-07:00")).toBe("Mon, Sep 18 - Thu, Sep 21"); + expect(getFormattedDateAbbr('2023-09-18T00:00:00-07:00')).toBe('Mon, Sep 18'); + expect(getFormattedDateAbbr('')).toBeUndefined(); + expect(getFormattedDateAbbr('2023-09-18T00:00:00-07:00', '2023-09-21T00:00:00-07:00')).toBe( + 'Mon, Sep 18 - Thu, Sep 21', + ); }); it('returns a human readable time range', () => { - expect(getFormattedTimeRange("2023-09-18T00:00:00-07:00", "2023-09-18T00:00:00-09:20")).toBe("2 hours"); - expect(getFormattedTimeRange("2023-09-18T00:00:00-07:00", "2023-09-18T00:00:00-09:30")).toBe("3 hours"); - expect(getFormattedTimeRange("", "2023-09-18T00:00:00-09:30")).toBeFalsy(); + expect(getFormattedTimeRange('2023-09-18T00:00:00-07:00', '2023-09-18T00:00:00-09:20')).toBe( + '2 hours', + ); + expect(getFormattedTimeRange('2023-09-18T00:00:00-07:00', '2023-09-18T00:00:00-09:30')).toBe( + '3 hours', + ); + expect(getFormattedTimeRange('', '2023-09-18T00:00:00-09:30')).toBeFalsy(); }); -it("returns a Base Mode for a given key", () => { - expect(getBaseModeByKey("WALKING")).toEqual({ name: "WALKING", icon: "walk", color: modeColors.blue }); - expect(getBaseModeByKey("MotionTypes.WALKING")).toEqual({ name: "WALKING", icon: "walk", color: modeColors.blue }); - expect(getBaseModeByKey("I made this type up")).toEqual({ name: "UNKNOWN", icon: "help", color: modeColors.grey }); +it('returns a Base Mode for a given key', () => { + expect(getBaseModeByKey('WALKING')).toEqual({ + name: 'WALKING', + icon: 'walk', + color: modeColors.blue, + }); + expect(getBaseModeByKey('MotionTypes.WALKING')).toEqual({ + name: 'WALKING', + icon: 'walk', + color: modeColors.blue, + }); + expect(getBaseModeByKey('I made this type up')).toEqual({ + name: 'UNKNOWN', + icon: 'help', + color: modeColors.grey, + }); }); it('returns true/false is multi day', () => { - expect(isMultiDay("2023-09-18T00:00:00-07:00", "2023-09-19T00:00:00-07:00")).toBeTruthy(); - expect(isMultiDay("2023-09-18T00:00:00-07:00", "2023-09-18T00:00:00-09:00")).toBeFalsy(); - expect(isMultiDay("", "2023-09-18T00:00:00-09:00")).toBeFalsy(); + expect(isMultiDay('2023-09-18T00:00:00-07:00', '2023-09-19T00:00:00-07:00')).toBeTruthy(); + expect(isMultiDay('2023-09-18T00:00:00-07:00', '2023-09-18T00:00:00-09:00')).toBeFalsy(); + expect(isMultiDay('', '2023-09-18T00:00:00-09:00')).toBeFalsy(); }); //created a fake trip with relevant sections by examining log statements -let myFakeTrip = {sections: [ - { "sensed_mode_str": "BICYCLING", "distance": 6013.73657416706 }, - { "sensed_mode_str": "WALKING", "distance": 715.3078629361006 } -]}; -let myFakeTrip2 = {sections: [ - { "sensed_mode_str": "BICYCLING", "distance": 6013.73657416706 }, - { "sensed_mode_str": "BICYCLING", "distance": 715.3078629361006 } -]}; +let myFakeTrip = { + sections: [ + { sensed_mode_str: 'BICYCLING', distance: 6013.73657416706 }, + { sensed_mode_str: 'WALKING', distance: 715.3078629361006 }, + ], +}; +let myFakeTrip2 = { + sections: [ + { sensed_mode_str: 'BICYCLING', distance: 6013.73657416706 }, + { sensed_mode_str: 'BICYCLING', distance: 715.3078629361006 }, + ], +}; let myFakeDetectedModes = [ - { mode: "BICYCLING", - icon: "bike", - color: modeColors.green, - pct: 89 }, - { mode: "WALKING", - icon: "walk", - color: modeColors.blue, - pct: 11 }]; + { mode: 'BICYCLING', icon: 'bike', color: modeColors.green, pct: 89 }, + { mode: 'WALKING', icon: 'walk', color: modeColors.blue, pct: 11 }, +]; -let myFakeDetectedModes2 = [ - { mode: "BICYCLING", - icon: "bike", - color: modeColors.green, - pct: 100 }]; +let myFakeDetectedModes2 = [{ mode: 'BICYCLING', icon: 'bike', color: modeColors.green, pct: 100 }]; it('returns the detected modes, with percentages, for a trip', () => { expect(getDetectedModes(myFakeTrip)).toEqual(myFakeDetectedModes); expect(getDetectedModes(myFakeTrip2)).toEqual(myFakeDetectedModes2); expect(getDetectedModes({})).toEqual([]); // empty trip, no sections, no modes -}) +}); diff --git a/www/__tests__/startprefs.test.ts b/www/__tests__/startprefs.test.ts index 1e62e7b5e..75ed707dc 100644 --- a/www/__tests__/startprefs.test.ts +++ b/www/__tests__/startprefs.test.ts @@ -1,24 +1,41 @@ -import { markConsented, isConsented, readConsentState, getConsentDocument } from '../js/splash/startprefs'; +import { + markConsented, + isConsented, + readConsentState, + getConsentDocument, +} from '../js/splash/startprefs'; -import { mockBEMUserCache, mockBEMDataCollection } from "../__mocks__/cordovaMocks"; -import { mockLogger } from "../__mocks__/globalMocks"; +import { mockBEMUserCache, mockBEMDataCollection } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; mockBEMUserCache(); mockBEMDataCollection(); mockLogger(); -global.fetch = (url: string) => new Promise((rs, rj) => { - setTimeout(() => rs({ - json: () => new Promise((rs, rj) => { - let myJSON = { "emSensorDataCollectionProtocol": { "protocol_id": "2014-04-6267", "approval_date": "2016-07-14" } }; - setTimeout(() => rs(myJSON), 100); - }) - })); -}) as any; +global.fetch = (url: string) => + new Promise((rs, rj) => { + setTimeout(() => + rs({ + json: () => + new Promise((rs, rj) => { + let myJSON = { + emSensorDataCollectionProtocol: { + protocol_id: '2014-04-6267', + approval_date: '2016-07-14', + }, + }; + setTimeout(() => rs(myJSON), 100); + }), + }), + ); + }) as any; it('checks state of consent before and after marking consent', async () => { expect(await readConsentState().then(isConsented)).toBeFalsy(); let marked = await markConsented(); expect(await readConsentState().then(isConsented)).toBeTruthy(); - expect(await getConsentDocument()).toEqual({"approval_date": "2016-07-14", "protocol_id": "2014-04-6267"}); -}); \ No newline at end of file + expect(await getConsentDocument()).toEqual({ + approval_date: '2016-07-14', + protocol_id: '2014-04-6267', + }); +}); diff --git a/www/__tests__/storage.test.ts b/www/__tests__/storage.test.ts index 6fea4f8b9..ca6d71dec 100644 --- a/www/__tests__/storage.test.ts +++ b/www/__tests__/storage.test.ts @@ -1,6 +1,6 @@ -import { mockBEMUserCache } from "../__mocks__/cordovaMocks"; -import { mockLogger } from "../__mocks__/globalMocks"; -import { storageClear, storageGet, storageRemove, storageSet } from "../js/plugin/storage"; +import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; +import { storageClear, storageGet, storageRemove, storageSet } from '../js/plugin/storage'; // mocks used - storage.ts uses BEMUserCache and logging. // localStorage is already mocked for us by Jest :) diff --git a/www/__tests__/uploadService.test.ts b/www/__tests__/uploadService.test.ts index 5c64fae0e..b9bede9fd 100644 --- a/www/__tests__/uploadService.test.ts +++ b/www/__tests__/uploadService.test.ts @@ -2,51 +2,55 @@ //however, tests are still important to make sure the code works //at some point we hope to restore this functionality -import {uploadFile} from "../js/control/uploadService"; +import { uploadFile } from '../js/control/uploadService'; import { mockLogger } from '../__mocks__/globalMocks'; -import { mockDevice, mockGetAppVersion, mockCordova, mockFile } from "../__mocks__/cordovaMocks"; -import { mockFileSystem } from "../__mocks__/fileSystemMocks"; +import { mockDevice, mockGetAppVersion, mockCordova, mockFile } from '../__mocks__/cordovaMocks'; +import { mockFileSystem } from '../__mocks__/fileSystemMocks'; mockDevice(); mockGetAppVersion(); mockCordova(); mockLogger(); -mockFile(); //mocks the base directory +mockFile(); //mocks the base directory mockFileSystem(); //comnplex mock, allows the readDBFile to work in testing //use this message to verify that the post went through -let message = ""; +let message = ''; //each have a slight delay to mimic a real fetch request -global.fetch = (url: string, options: { method: string, headers: {}, body: string }) => new Promise((rs, rj) => { - //if there's options, that means there is a post request - if (options) { - message = "sent " + options.method + options.body + " for " + url; - setTimeout(() => { - rs('sent ' + options.method + options.body + ' to ' + url); - }, 100); - } - //else it is a get request - else { - setTimeout(() => rs({ - json: () => new Promise((rs, rj) => { - setTimeout(() => rs('mock data for ' + url), 100); - }) - })); - } -}) as any; +global.fetch = (url: string, options: { method: string; headers: {}; body: string }) => + new Promise((rs, rj) => { + //if there's options, that means there is a post request + if (options) { + message = 'sent ' + options.method + options.body + ' for ' + url; + setTimeout(() => { + rs('sent ' + options.method + options.body + ' to ' + url); + }, 100); + } + //else it is a get request + else { + setTimeout(() => + rs({ + json: () => + new Promise((rs, rj) => { + setTimeout(() => rs('mock data for ' + url), 100); + }), + }), + ); + } + }) as any; window.alert = (message) => { console.log(message); -} +}; //very basic tests - difficult to do too much since there's a lot of mocking involved it('posts the logs to the configured database', async () => { - let posted = await uploadFile("loggerDB", "HelloWorld"); - expect(message).toEqual(expect.stringContaining("HelloWorld")); - expect(message).toEqual(expect.stringContaining("POST")); - posted = await uploadFile("loggerDB", "second test"); - expect(message).toEqual(expect.stringContaining("second test")); - expect(message).toEqual(expect.stringContaining("POST")); -}, 10000); \ No newline at end of file + let posted = await uploadFile('loggerDB', 'HelloWorld'); + expect(message).toEqual(expect.stringContaining('HelloWorld')); + expect(message).toEqual(expect.stringContaining('POST')); + posted = await uploadFile('loggerDB', 'second test'); + expect(message).toEqual(expect.stringContaining('second test')); + expect(message).toEqual(expect.stringContaining('POST')); +}, 10000); diff --git a/www/__tests__/useImperialConfig.test.ts b/www/__tests__/useImperialConfig.test.ts index cab1b5a11..593498aae 100644 --- a/www/__tests__/useImperialConfig.test.ts +++ b/www/__tests__/useImperialConfig.test.ts @@ -1,16 +1,15 @@ import { convertDistance, convertSpeed, formatForDisplay } from '../js/config/useImperialConfig'; - // This mock is required, or else the test will dive into the import chain of useAppConfig.ts and fail when it gets to the root jest.mock('../js/useAppConfig', () => { return jest.fn(() => ({ appConfig: { - use_imperial: false + use_imperial: false, }, - loading: false + loading: false, })); }); - + describe('formatForDisplay', () => { it('should round to the nearest integer when value is >= 100', () => { expect(formatForDisplay(105)).toBe('105'); diff --git a/www/build/app.css b/www/build/app.css index d7ec98c10..97de0161f 100644 --- a/www/build/app.css +++ b/www/build/app.css @@ -19,7 +19,58 @@ * ======================================================================== */ -.tour-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1100;background-color:#000;opacity:.8;filter:alpha(opacity=80)}.tour-step-backdrop{position:relative;z-index:1101;background:inherit}.tour-step-backdrop>td{position:relative;z-index:1101}.tour-step-background{position:absolute!important;z-index:1100;background:inherit;border-radius:6px}.popover[class*=tour-]{z-index:1100}.popover[class*=tour-] .popover-navigation{padding:9px 14px}.popover[class*=tour-] .popover-navigation [data-role=end]{float:right}.popover[class*=tour-] .popover-navigation [data-role=prev],.popover[class*=tour-] .popover-navigation [data-role=next],.popover[class*=tour-] .popover-navigation [data-role=end]{cursor:pointer}.popover[class*=tour-] .popover-navigation [data-role=prev].disabled,.popover[class*=tour-] .popover-navigation [data-role=next].disabled,.popover[class*=tour-] .popover-navigation [data-role=end].disabled{cursor:default}.popover[class*=tour-].orphan{position:fixed;margin-top:0}.popover[class*=tour-].orphan .arrow{display:none} +.tour-backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1100; + background-color: #000; + opacity: 0.8; + filter: alpha(opacity=80); +} +.tour-step-backdrop { + position: relative; + z-index: 1101; + background: inherit; +} +.tour-step-backdrop > td { + position: relative; + z-index: 1101; +} +.tour-step-background { + position: absolute !important; + z-index: 1100; + background: inherit; + border-radius: 6px; +} +.popover[class*='tour-'] { + z-index: 1100; +} +.popover[class*='tour-'] .popover-navigation { + padding: 9px 14px; +} +.popover[class*='tour-'] .popover-navigation [data-role='end'] { + float: right; +} +.popover[class*='tour-'] .popover-navigation [data-role='prev'], +.popover[class*='tour-'] .popover-navigation [data-role='next'], +.popover[class*='tour-'] .popover-navigation [data-role='end'] { + cursor: pointer; +} +.popover[class*='tour-'] .popover-navigation [data-role='prev'].disabled, +.popover[class*='tour-'] .popover-navigation [data-role='next'].disabled, +.popover[class*='tour-'] .popover-navigation [data-role='end'].disabled { + cursor: default; +} +.popover[class*='tour-'].orphan { + position: fixed; + margin-top: 0; +} +.popover[class*='tour-'].orphan .arrow { + display: none; +} /*! * angular-loading-bar v0.6.0 * https://chieffancypants.github.io/angular-loading-bar @@ -76,7 +127,7 @@ right: 0; top: 0; height: 2px; - opacity: .45; + opacity: 0.45; -moz-box-shadow: #29d 1px 0 6px 1px; -ms-box-shadow: #29d 1px 0 6px 1px; -webkit-box-shadow: #29d 1px 0 6px 1px; @@ -98,50 +149,80 @@ width: 14px; height: 14px; - border: solid 2px transparent; - border-top-color: #29d; + border: solid 2px transparent; + border-top-color: #29d; border-left-color: #29d; border-radius: 10px; -webkit-animation: loading-bar-spinner 400ms linear infinite; - -moz-animation: loading-bar-spinner 400ms linear infinite; - -ms-animation: loading-bar-spinner 400ms linear infinite; - -o-animation: loading-bar-spinner 400ms linear infinite; - animation: loading-bar-spinner 400ms linear infinite; + -moz-animation: loading-bar-spinner 400ms linear infinite; + -ms-animation: loading-bar-spinner 400ms linear infinite; + -o-animation: loading-bar-spinner 400ms linear infinite; + animation: loading-bar-spinner 400ms linear infinite; } @-webkit-keyframes loading-bar-spinner { - 0% { -webkit-transform: rotate(0deg); transform: rotate(0deg); } - 100% { -webkit-transform: rotate(360deg); transform: rotate(360deg); } + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } } @-moz-keyframes loading-bar-spinner { - 0% { -moz-transform: rotate(0deg); transform: rotate(0deg); } - 100% { -moz-transform: rotate(360deg); transform: rotate(360deg); } + 0% { + -moz-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -moz-transform: rotate(360deg); + transform: rotate(360deg); + } } @-o-keyframes loading-bar-spinner { - 0% { -o-transform: rotate(0deg); transform: rotate(0deg); } - 100% { -o-transform: rotate(360deg); transform: rotate(360deg); } + 0% { + -o-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -o-transform: rotate(360deg); + transform: rotate(360deg); + } } @-ms-keyframes loading-bar-spinner { - 0% { -ms-transform: rotate(0deg); transform: rotate(0deg); } - 100% { -ms-transform: rotate(360deg); transform: rotate(360deg); } + 0% { + -ms-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -ms-transform: rotate(360deg); + transform: rotate(360deg); + } } @keyframes loading-bar-spinner { - 0% { transform: rotate(0deg); transform: rotate(0deg); } - 100% { transform: rotate(360deg); transform: rotate(360deg); } + 0% { + transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + transform: rotate(360deg); + } } /* Version: 3.4.8 Timestamp: Thu May 1 09:50:32 EDT 2014 */ .select2-container { - margin: 0; - position: relative; - display: inline-block; - /* inline-block for ie7 */ - zoom: 1; - *display: inline; - vertical-align: middle; + margin: 0; + position: relative; + display: inline-block; + /* inline-block for ie7 */ + zoom: 1; + *display: inline; + vertical-align: middle; } .select2-container, @@ -154,379 +235,438 @@ Version: 3.4.8 Timestamp: Thu May 1 09:50:32 EDT 2014 More Info : http://www.quirksmode.org/css/box.html */ -webkit-box-sizing: border-box; /* webkit */ - -moz-box-sizing: border-box; /* firefox */ - box-sizing: border-box; /* css3 */ + -moz-box-sizing: border-box; /* firefox */ + box-sizing: border-box; /* css3 */ } .select2-container .select2-choice { - display: block; - height: 26px; - padding: 0 0 0 8px; - overflow: hidden; - position: relative; + display: block; + height: 26px; + padding: 0 0 0 8px; + overflow: hidden; + position: relative; - border: 1px solid #aaa; - white-space: nowrap; - line-height: 26px; - color: #444; - text-decoration: none; + border: 1px solid #aaa; + white-space: nowrap; + line-height: 26px; + color: #444; + text-decoration: none; - border-radius: 4px; + border-radius: 4px; - background-clip: padding-box; + background-clip: padding-box; - -webkit-touch-callout: none; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; - background-color: #fff; - background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #eee), color-stop(0.5, #fff)); - background-image: -webkit-linear-gradient(center bottom, #eee 0%, #fff 50%); - background-image: -moz-linear-gradient(center bottom, #eee 0%, #fff 50%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr = '#ffffff', endColorstr = '#eeeeee', GradientType = 0); - background-image: linear-gradient(to top, #eee 0%, #fff 50%); + background-color: #fff; + background-image: -webkit-gradient( + linear, + left bottom, + left top, + color-stop(0, #eee), + color-stop(0.5, #fff) + ); + background-image: -webkit-linear-gradient(center bottom, #eee 0%, #fff 50%); + background-image: -moz-linear-gradient(center bottom, #eee 0%, #fff 50%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr = '#ffffff', endColorstr = '#eeeeee', GradientType = 0); + background-image: linear-gradient(to top, #eee 0%, #fff 50%); } .select2-container.select2-drop-above .select2-choice { - border-bottom-color: #aaa; + border-bottom-color: #aaa; - border-radius: 0 0 4px 4px; + border-radius: 0 0 4px 4px; - background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #eee), color-stop(0.9, #fff)); - background-image: -webkit-linear-gradient(center bottom, #eee 0%, #fff 90%); - background-image: -moz-linear-gradient(center bottom, #eee 0%, #fff 90%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#eeeeee', GradientType=0); - background-image: linear-gradient(to bottom, #eee 0%, #fff 90%); + background-image: -webkit-gradient( + linear, + left bottom, + left top, + color-stop(0, #eee), + color-stop(0.9, #fff) + ); + background-image: -webkit-linear-gradient(center bottom, #eee 0%, #fff 90%); + background-image: -moz-linear-gradient(center bottom, #eee 0%, #fff 90%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffff', endColorstr='#eeeeee', GradientType=0); + background-image: linear-gradient(to bottom, #eee 0%, #fff 90%); } .select2-container.select2-allowclear .select2-choice .select2-chosen { - margin-right: 42px; + margin-right: 42px; } .select2-container .select2-choice > .select2-chosen { - margin-right: 26px; - display: block; - overflow: hidden; + margin-right: 26px; + display: block; + overflow: hidden; - white-space: nowrap; + white-space: nowrap; - text-overflow: ellipsis; - float: none; - width: auto; + text-overflow: ellipsis; + float: none; + width: auto; } .select2-container .select2-choice abbr { - display: none; - width: 12px; - height: 12px; - position: absolute; - right: 24px; - top: 8px; + display: none; + width: 12px; + height: 12px; + position: absolute; + right: 24px; + top: 8px; - font-size: 1px; - text-decoration: none; + font-size: 1px; + text-decoration: none; - border: 0; - background: url('select2.png') right top no-repeat; - cursor: pointer; - outline: 0; + border: 0; + background: url('select2.png') right top no-repeat; + cursor: pointer; + outline: 0; } .select2-container.select2-allowclear .select2-choice abbr { - display: inline-block; + display: inline-block; } .select2-container .select2-choice abbr:hover { - background-position: right -11px; - cursor: pointer; + background-position: right -11px; + cursor: pointer; } .select2-drop-mask { - border: 0; - margin: 0; - padding: 0; - position: fixed; - left: 0; - top: 0; - min-height: 100%; - min-width: 100%; - height: auto; - width: auto; - opacity: 0; - z-index: 9998; - /* styles required for IE to work */ - background-color: #fff; - filter: alpha(opacity=0); + border: 0; + margin: 0; + padding: 0; + position: fixed; + left: 0; + top: 0; + min-height: 100%; + min-width: 100%; + height: auto; + width: auto; + opacity: 0; + z-index: 9998; + /* styles required for IE to work */ + background-color: #fff; + filter: alpha(opacity=0); } .select2-drop { - width: 100%; - margin-top: -1px; - position: absolute; - z-index: 9999; - top: 100%; + width: 100%; + margin-top: -1px; + position: absolute; + z-index: 9999; + top: 100%; - background: #fff; - color: #000; - border: 1px solid #aaa; - border-top: 0; + background: #fff; + color: #000; + border: 1px solid #aaa; + border-top: 0; - border-radius: 0 0 4px 4px; + border-radius: 0 0 4px 4px; - -webkit-box-shadow: 0 4px 5px rgba(0, 0, 0, .15); - box-shadow: 0 4px 5px rgba(0, 0, 0, .15); + -webkit-box-shadow: 0 4px 5px rgba(0, 0, 0, 0.15); + box-shadow: 0 4px 5px rgba(0, 0, 0, 0.15); } .select2-drop.select2-drop-above { - margin-top: 1px; - border-top: 1px solid #aaa; - border-bottom: 0; + margin-top: 1px; + border-top: 1px solid #aaa; + border-bottom: 0; - border-radius: 4px 4px 0 0; + border-radius: 4px 4px 0 0; - -webkit-box-shadow: 0 -4px 5px rgba(0, 0, 0, .15); - box-shadow: 0 -4px 5px rgba(0, 0, 0, .15); + -webkit-box-shadow: 0 -4px 5px rgba(0, 0, 0, 0.15); + box-shadow: 0 -4px 5px rgba(0, 0, 0, 0.15); } .select2-drop-active { - border: 1px solid #5897fb; - border-top: none; + border: 1px solid #5897fb; + border-top: none; } .select2-drop.select2-drop-above.select2-drop-active { - border-top: 1px solid #5897fb; + border-top: 1px solid #5897fb; } .select2-drop-auto-width { - border-top: 1px solid #aaa; - width: auto; + border-top: 1px solid #aaa; + width: auto; } .select2-drop-auto-width .select2-search { - padding-top: 4px; + padding-top: 4px; } .select2-container .select2-choice .select2-arrow { - display: inline-block; - width: 18px; - height: 100%; - position: absolute; - right: 0; - top: 0; + display: inline-block; + width: 18px; + height: 100%; + position: absolute; + right: 0; + top: 0; - border-left: 1px solid #aaa; - border-radius: 0 4px 4px 0; + border-left: 1px solid #aaa; + border-radius: 0 4px 4px 0; - background-clip: padding-box; + background-clip: padding-box; - background: #ccc; - background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #ccc), color-stop(0.6, #eee)); - background-image: -webkit-linear-gradient(center bottom, #ccc 0%, #eee 60%); - background-image: -moz-linear-gradient(center bottom, #ccc 0%, #eee 60%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr = '#eeeeee', endColorstr = '#cccccc', GradientType = 0); - background-image: linear-gradient(to top, #ccc 0%, #eee 60%); + background: #ccc; + background-image: -webkit-gradient( + linear, + left bottom, + left top, + color-stop(0, #ccc), + color-stop(0.6, #eee) + ); + background-image: -webkit-linear-gradient(center bottom, #ccc 0%, #eee 60%); + background-image: -moz-linear-gradient(center bottom, #ccc 0%, #eee 60%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr = '#eeeeee', endColorstr = '#cccccc', GradientType = 0); + background-image: linear-gradient(to top, #ccc 0%, #eee 60%); } .select2-container .select2-choice .select2-arrow b { - display: block; - width: 100%; - height: 100%; - background: url('select2.png') no-repeat 0 1px; + display: block; + width: 100%; + height: 100%; + background: url('select2.png') no-repeat 0 1px; } .select2-search { - display: inline-block; - width: 100%; - min-height: 26px; - margin: 0; - padding-left: 4px; - padding-right: 4px; + display: inline-block; + width: 100%; + min-height: 26px; + margin: 0; + padding-left: 4px; + padding-right: 4px; - position: relative; - z-index: 10000; + position: relative; + z-index: 10000; - white-space: nowrap; + white-space: nowrap; } .select2-search input { - width: 100%; - height: auto !important; - min-height: 26px; - padding: 4px 20px 4px 5px; - margin: 0; + width: 100%; + height: auto !important; + min-height: 26px; + padding: 4px 20px 4px 5px; + margin: 0; - outline: 0; - font-family: sans-serif; - font-size: 1em; + outline: 0; + font-family: sans-serif; + font-size: 1em; - border: 1px solid #aaa; - border-radius: 0; + border: 1px solid #aaa; + border-radius: 0; - -webkit-box-shadow: none; - box-shadow: none; + -webkit-box-shadow: none; + box-shadow: none; - background: #fff url('select2.png') no-repeat 100% -22px; - background: url('select2.png') no-repeat 100% -22px, -webkit-gradient(linear, left bottom, left top, color-stop(0.85, #fff), color-stop(0.99, #eee)); - background: url('select2.png') no-repeat 100% -22px, -webkit-linear-gradient(center bottom, #fff 85%, #eee 99%); - background: url('select2.png') no-repeat 100% -22px, -moz-linear-gradient(center bottom, #fff 85%, #eee 99%); - background: url('select2.png') no-repeat 100% -22px, linear-gradient(to bottom, #fff 85%, #eee 99%) 0 0; + background: #fff url('select2.png') no-repeat 100% -22px; + background: + url('select2.png') no-repeat 100% -22px, + -webkit-gradient(linear, left bottom, left top, color-stop(0.85, #fff), color-stop(0.99, #eee)); + background: + url('select2.png') no-repeat 100% -22px, + -webkit-linear-gradient(center bottom, #fff 85%, #eee 99%); + background: + url('select2.png') no-repeat 100% -22px, + -moz-linear-gradient(center bottom, #fff 85%, #eee 99%); + background: + url('select2.png') no-repeat 100% -22px, + linear-gradient(to bottom, #fff 85%, #eee 99%) 0 0; } .select2-drop.select2-drop-above .select2-search input { - margin-top: 4px; + margin-top: 4px; } .select2-search input.select2-active { - background: #fff url('select2-spinner.gif') no-repeat 100%; - background: url('select2-spinner.gif') no-repeat 100%, -webkit-gradient(linear, left bottom, left top, color-stop(0.85, #fff), color-stop(0.99, #eee)); - background: url('select2-spinner.gif') no-repeat 100%, -webkit-linear-gradient(center bottom, #fff 85%, #eee 99%); - background: url('select2-spinner.gif') no-repeat 100%, -moz-linear-gradient(center bottom, #fff 85%, #eee 99%); - background: url('select2-spinner.gif') no-repeat 100%, linear-gradient(to bottom, #fff 85%, #eee 99%) 0 0; + background: #fff url('select2-spinner.gif') no-repeat 100%; + background: + url('select2-spinner.gif') no-repeat 100%, + -webkit-gradient(linear, left bottom, left top, color-stop(0.85, #fff), color-stop(0.99, #eee)); + background: + url('select2-spinner.gif') no-repeat 100%, + -webkit-linear-gradient(center bottom, #fff 85%, #eee 99%); + background: + url('select2-spinner.gif') no-repeat 100%, + -moz-linear-gradient(center bottom, #fff 85%, #eee 99%); + background: + url('select2-spinner.gif') no-repeat 100%, + linear-gradient(to bottom, #fff 85%, #eee 99%) 0 0; } .select2-container-active .select2-choice, .select2-container-active .select2-choices { - border: 1px solid #5897fb; - outline: none; + border: 1px solid #5897fb; + outline: none; - -webkit-box-shadow: 0 0 5px rgba(0, 0, 0, .3); - box-shadow: 0 0 5px rgba(0, 0, 0, .3); + -webkit-box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); + box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); } .select2-dropdown-open .select2-choice { - border-bottom-color: transparent; - -webkit-box-shadow: 0 1px 0 #fff inset; - box-shadow: 0 1px 0 #fff inset; + border-bottom-color: transparent; + -webkit-box-shadow: 0 1px 0 #fff inset; + box-shadow: 0 1px 0 #fff inset; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; - background-color: #eee; - background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0, #fff), color-stop(0.5, #eee)); - background-image: -webkit-linear-gradient(center bottom, #fff 0%, #eee 50%); - background-image: -moz-linear-gradient(center bottom, #fff 0%, #eee 50%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#eeeeee', endColorstr='#ffffff', GradientType=0); - background-image: linear-gradient(to top, #fff 0%, #eee 50%); + background-color: #eee; + background-image: -webkit-gradient( + linear, + left bottom, + left top, + color-stop(0, #fff), + color-stop(0.5, #eee) + ); + background-image: -webkit-linear-gradient(center bottom, #fff 0%, #eee 50%); + background-image: -moz-linear-gradient(center bottom, #fff 0%, #eee 50%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#eeeeee', endColorstr='#ffffff', GradientType=0); + background-image: linear-gradient(to top, #fff 0%, #eee 50%); } .select2-dropdown-open.select2-drop-above .select2-choice, .select2-dropdown-open.select2-drop-above .select2-choices { - border: 1px solid #5897fb; - border-top-color: transparent; + border: 1px solid #5897fb; + border-top-color: transparent; - background-image: -webkit-gradient(linear, left top, left bottom, color-stop(0, #fff), color-stop(0.5, #eee)); - background-image: -webkit-linear-gradient(center top, #fff 0%, #eee 50%); - background-image: -moz-linear-gradient(center top, #fff 0%, #eee 50%); - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#eeeeee', endColorstr='#ffffff', GradientType=0); - background-image: linear-gradient(to bottom, #fff 0%, #eee 50%); + background-image: -webkit-gradient( + linear, + left top, + left bottom, + color-stop(0, #fff), + color-stop(0.5, #eee) + ); + background-image: -webkit-linear-gradient(center top, #fff 0%, #eee 50%); + background-image: -moz-linear-gradient(center top, #fff 0%, #eee 50%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#eeeeee', endColorstr='#ffffff', GradientType=0); + background-image: linear-gradient(to bottom, #fff 0%, #eee 50%); } .select2-dropdown-open .select2-choice .select2-arrow { - background: transparent; - border-left: none; - filter: none; + background: transparent; + border-left: none; + filter: none; } .select2-dropdown-open .select2-choice .select2-arrow b { - background-position: -18px 1px; + background-position: -18px 1px; } .select2-hidden-accessible { - border: 0; - clip: rect(0 0 0 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - width: 1px; + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; } /* results */ .select2-results { - max-height: 200px; - padding: 0 0 0 4px; - margin: 4px 4px 4px 0; - position: relative; - overflow-x: hidden; - overflow-y: auto; - -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + max-height: 200px; + padding: 0 0 0 4px; + margin: 4px 4px 4px 0; + position: relative; + overflow-x: hidden; + overflow-y: auto; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); } .select2-results ul.select2-result-sub { - margin: 0; - padding-left: 0; + margin: 0; + padding-left: 0; } .select2-results li { - list-style: none; - display: list-item; - background-image: none; + list-style: none; + display: list-item; + background-image: none; } .select2-results li.select2-result-with-children > .select2-result-label { - font-weight: bold; + font-weight: bold; } .select2-results .select2-result-label { - padding: 3px 7px 4px; - margin: 0; - cursor: pointer; + padding: 3px 7px 4px; + margin: 0; + cursor: pointer; - min-height: 1em; + min-height: 1em; - -webkit-touch-callout: none; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; } -.select2-results-dept-1 .select2-result-label { padding-left: 20px } -.select2-results-dept-2 .select2-result-label { padding-left: 40px } -.select2-results-dept-3 .select2-result-label { padding-left: 60px } -.select2-results-dept-4 .select2-result-label { padding-left: 80px } -.select2-results-dept-5 .select2-result-label { padding-left: 100px } -.select2-results-dept-6 .select2-result-label { padding-left: 110px } -.select2-results-dept-7 .select2-result-label { padding-left: 120px } +.select2-results-dept-1 .select2-result-label { + padding-left: 20px; +} +.select2-results-dept-2 .select2-result-label { + padding-left: 40px; +} +.select2-results-dept-3 .select2-result-label { + padding-left: 60px; +} +.select2-results-dept-4 .select2-result-label { + padding-left: 80px; +} +.select2-results-dept-5 .select2-result-label { + padding-left: 100px; +} +.select2-results-dept-6 .select2-result-label { + padding-left: 110px; +} +.select2-results-dept-7 .select2-result-label { + padding-left: 120px; +} .select2-results .select2-highlighted { - background: #3875d7; - color: #fff; + background: #3875d7; + color: #fff; } .select2-results li em { - background: #feffde; - font-style: normal; + background: #feffde; + font-style: normal; } .select2-results .select2-highlighted em { - background: transparent; + background: transparent; } .select2-results .select2-highlighted ul { - background: #fff; - color: #000; + background: #fff; + color: #000; } - .select2-results .select2-no-results, .select2-results .select2-searching, .select2-results .select2-selection-limit { - background: #f4f4f4; - display: list-item; - padding-left: 5px; + background: #f4f4f4; + display: list-item; + padding-left: 5px; } /* disabled look for disabled choices in the results dropdown */ .select2-results .select2-disabled.select2-highlighted { - color: #666; - background: #f4f4f4; - display: list-item; - cursor: default; + color: #666; + background: #f4f4f4; + display: list-item; + cursor: default; } .select2-results .select2-disabled { background: #f4f4f4; @@ -535,56 +675,61 @@ disabled look for disabled choices in the results dropdown } .select2-results .select2-selected { - display: none; + display: none; } .select2-more-results.select2-active { - background: #f4f4f4 url('select2-spinner.gif') no-repeat 100%; + background: #f4f4f4 url('select2-spinner.gif') no-repeat 100%; } .select2-more-results { - background: #f4f4f4; - display: list-item; + background: #f4f4f4; + display: list-item; } /* disabled styles */ .select2-container.select2-container-disabled .select2-choice { - background-color: #f4f4f4; - background-image: none; - border: 1px solid #ddd; - cursor: default; + background-color: #f4f4f4; + background-image: none; + border: 1px solid #ddd; + cursor: default; } .select2-container.select2-container-disabled .select2-choice .select2-arrow { - background-color: #f4f4f4; - background-image: none; - border-left: 0; + background-color: #f4f4f4; + background-image: none; + border-left: 0; } .select2-container.select2-container-disabled .select2-choice abbr { - display: none; + display: none; } - /* multiselect */ .select2-container-multi .select2-choices { - height: auto !important; - height: 1%; - margin: 0; - padding: 0; - position: relative; + height: auto !important; + height: 1%; + margin: 0; + padding: 0; + position: relative; - border: 1px solid #aaa; - cursor: text; - overflow: hidden; + border: 1px solid #aaa; + cursor: text; + overflow: hidden; - background-color: #fff; - background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, color-stop(1%, #eee), color-stop(15%, #fff)); - background-image: -webkit-linear-gradient(top, #eee 1%, #fff 15%); - background-image: -moz-linear-gradient(top, #eee 1%, #fff 15%); - background-image: linear-gradient(to bottom, #eee 1%, #fff 15%); + background-color: #fff; + background-image: -webkit-gradient( + linear, + 0% 0%, + 0% 100%, + color-stop(1%, #eee), + color-stop(15%, #fff) + ); + background-image: -webkit-linear-gradient(top, #eee 1%, #fff 15%); + background-image: -moz-linear-gradient(top, #eee 1%, #fff 15%); + background-image: linear-gradient(to bottom, #eee 1%, #fff 15%); } .select2-locked { @@ -592,197 +737,218 @@ disabled look for disabled choices in the results dropdown } .select2-container-multi .select2-choices { - min-height: 26px; + min-height: 26px; } .select2-container-multi.select2-container-active .select2-choices { - border: 1px solid #5897fb; - outline: none; + border: 1px solid #5897fb; + outline: none; - -webkit-box-shadow: 0 0 5px rgba(0, 0, 0, .3); - box-shadow: 0 0 5px rgba(0, 0, 0, .3); + -webkit-box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); + box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); } .select2-container-multi .select2-choices li { - float: left; - list-style: none; + float: left; + list-style: none; } -html[dir="rtl"] .select2-container-multi .select2-choices li -{ - float: right; +html[dir='rtl'] .select2-container-multi .select2-choices li { + float: right; } .select2-container-multi .select2-choices .select2-search-field { - margin: 0; - padding: 0; - white-space: nowrap; + margin: 0; + padding: 0; + white-space: nowrap; } .select2-container-multi .select2-choices .select2-search-field input { - padding: 5px; - margin: 1px 0; + padding: 5px; + margin: 1px 0; - font-family: sans-serif; - font-size: 100%; - color: #666; - outline: 0; - border: 0; - -webkit-box-shadow: none; - box-shadow: none; - background: transparent !important; + font-family: sans-serif; + font-size: 100%; + color: #666; + outline: 0; + border: 0; + -webkit-box-shadow: none; + box-shadow: none; + background: transparent !important; } .select2-container-multi .select2-choices .select2-search-field input.select2-active { - background: #fff url('select2-spinner.gif') no-repeat 100% !important; + background: #fff url('select2-spinner.gif') no-repeat 100% !important; } .select2-default { - color: #999 !important; + color: #999 !important; } .select2-container-multi .select2-choices .select2-search-choice { - padding: 3px 5px 3px 18px; - margin: 3px 0 3px 5px; - position: relative; + padding: 3px 5px 3px 18px; + margin: 3px 0 3px 5px; + position: relative; - line-height: 13px; - color: #333; - cursor: default; - border: 1px solid #aaaaaa; + line-height: 13px; + color: #333; + cursor: default; + border: 1px solid #aaaaaa; - border-radius: 3px; + border-radius: 3px; - -webkit-box-shadow: 0 0 2px #fff inset, 0 1px 0 rgba(0, 0, 0, 0.05); - box-shadow: 0 0 2px #fff inset, 0 1px 0 rgba(0, 0, 0, 0.05); + -webkit-box-shadow: + 0 0 2px #fff inset, + 0 1px 0 rgba(0, 0, 0, 0.05); + box-shadow: + 0 0 2px #fff inset, + 0 1px 0 rgba(0, 0, 0, 0.05); - background-clip: padding-box; + background-clip: padding-box; - -webkit-touch-callout: none; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; + -webkit-touch-callout: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; - background-color: #e4e4e4; - filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#eeeeee', endColorstr='#f4f4f4', GradientType=0); - background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, color-stop(20%, #f4f4f4), color-stop(50%, #f0f0f0), color-stop(52%, #e8e8e8), color-stop(100%, #eee)); - background-image: -webkit-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eee 100%); - background-image: -moz-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eee 100%); - background-image: linear-gradient(to top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eee 100%); -} -html[dir="rtl"] .select2-container-multi .select2-choices .select2-search-choice -{ - margin-left: 0; - margin-right: 5px; + background-color: #e4e4e4; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#eeeeee', endColorstr='#f4f4f4', GradientType=0); + background-image: -webkit-gradient( + linear, + 0% 0%, + 0% 100%, + color-stop(20%, #f4f4f4), + color-stop(50%, #f0f0f0), + color-stop(52%, #e8e8e8), + color-stop(100%, #eee) + ); + background-image: -webkit-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eee 100%); + background-image: -moz-linear-gradient(top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eee 100%); + background-image: linear-gradient(to top, #f4f4f4 20%, #f0f0f0 50%, #e8e8e8 52%, #eee 100%); +} +html[dir='rtl'] .select2-container-multi .select2-choices .select2-search-choice { + margin-left: 0; + margin-right: 5px; } .select2-container-multi .select2-choices .select2-search-choice .select2-chosen { - cursor: default; + cursor: default; } .select2-container-multi .select2-choices .select2-search-choice-focus { - background: #d4d4d4; + background: #d4d4d4; } .select2-search-choice-close { - display: block; - width: 12px; - height: 13px; - position: absolute; - right: 3px; - top: 4px; + display: block; + width: 12px; + height: 13px; + position: absolute; + right: 3px; + top: 4px; - font-size: 1px; - outline: none; - background: url('select2.png') right top no-repeat; + font-size: 1px; + outline: none; + background: url('select2.png') right top no-repeat; } -html[dir="rtl"] .select2-search-choice-close { - right: auto; - left: 3px; +html[dir='rtl'] .select2-search-choice-close { + right: auto; + left: 3px; } .select2-container-multi .select2-search-choice-close { - left: 3px; + left: 3px; } -.select2-container-multi .select2-choices .select2-search-choice .select2-search-choice-close:hover { +.select2-container-multi + .select2-choices + .select2-search-choice + .select2-search-choice-close:hover { background-position: right -11px; } -.select2-container-multi .select2-choices .select2-search-choice-focus .select2-search-choice-close { - background-position: right -11px; +.select2-container-multi + .select2-choices + .select2-search-choice-focus + .select2-search-choice-close { + background-position: right -11px; } /* disabled styles */ .select2-container-multi.select2-container-disabled .select2-choices { - background-color: #f4f4f4; - background-image: none; - border: 1px solid #ddd; - cursor: default; + background-color: #f4f4f4; + background-image: none; + border: 1px solid #ddd; + cursor: default; } .select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice { - padding: 3px 5px 3px 5px; - border: 1px solid #ddd; - background-image: none; - background-color: #f4f4f4; + padding: 3px 5px 3px 5px; + border: 1px solid #ddd; + background-image: none; + background-color: #f4f4f4; } -.select2-container-multi.select2-container-disabled .select2-choices .select2-search-choice .select2-search-choice-close { display: none; - background: none; +.select2-container-multi.select2-container-disabled + .select2-choices + .select2-search-choice + .select2-search-choice-close { + display: none; + background: none; } /* end multiselect */ - .select2-result-selectable .select2-match, .select2-result-unselectable .select2-match { - text-decoration: underline; + text-decoration: underline; } -.select2-offscreen, .select2-offscreen:focus { - clip: rect(0 0 0 0) !important; - width: 1px !important; - height: 1px !important; - border: 0 !important; - margin: 0 !important; - padding: 0 !important; - overflow: hidden !important; - position: absolute !important; - outline: 0 !important; - left: 0px !important; - top: 0px !important; +.select2-offscreen, +.select2-offscreen:focus { + clip: rect(0 0 0 0) !important; + width: 1px !important; + height: 1px !important; + border: 0 !important; + margin: 0 !important; + padding: 0 !important; + overflow: hidden !important; + position: absolute !important; + outline: 0 !important; + left: 0px !important; + top: 0px !important; } .select2-display-none { - display: none; + display: none; } .select2-measure-scrollbar { - position: absolute; - top: -10000px; - left: -10000px; - width: 100px; - height: 100px; - overflow: scroll; + position: absolute; + top: -10000px; + left: -10000px; + width: 100px; + height: 100px; + overflow: scroll; } /* Retina-ize icons */ -@media only screen and (-webkit-min-device-pixel-ratio: 1.5), only screen and (min-resolution: 2dppx) { - .select2-search input, - .select2-search-choice-close, - .select2-container .select2-choice abbr, - .select2-container .select2-choice .select2-arrow b { - background-image: url('select2x2.png') !important; - background-repeat: no-repeat !important; - background-size: 60px 40px !important; - } +@media only screen and (-webkit-min-device-pixel-ratio: 1.5), + only screen and (min-resolution: 2dppx) { + .select2-search input, + .select2-search-choice-close, + .select2-container .select2-choice abbr, + .select2-container .select2-choice .select2-arrow b { + background-image: url('select2x2.png') !important; + background-repeat: no-repeat !important; + background-size: 60px 40px !important; + } - .select2-search input { - background-position: 100% -21px !important; - } + .select2-search input { + background-position: 100% -21px !important; + } } .task-checklist-edit > .checklist-form li:after, .task-filter:after, .filters:after, .filters .filters-controls:after { - content: ""; + content: ''; display: table; clear: both; } @@ -2232,7 +2398,11 @@ html[dir="rtl"] .select2-search-choice-close { font-weight: bold; letter-spacing: 0.0618em; color: #fff; - text-shadow: -1px -1px 1px #333, 1px -1px 1px #333, -1px 1px 1px #333, 1px 1px 1px #333; + text-shadow: + -1px -1px 1px #333, + 1px -1px 1px #333, + -1px 1px 1px #333, + 1px 1px 1px #333; } .herobox .avatar-level > a, .herobox .avatar-name > a, @@ -2270,8 +2440,8 @@ html[dir="rtl"] .select2-search-choice-close { left: 2%; width: 96%; height: 96%; - -webkit-box-shadow: 0 0 0 30px rgba(0,0,0,0.63); - box-shadow: 0 0 0 30px rgba(0,0,0,0.63); + -webkit-box-shadow: 0 0 0 30px rgba(0, 0, 0, 0.63); + box-shadow: 0 0 0 30px rgba(0, 0, 0, 0.63); } .toolbar-mobile > div h4:before, .toolbar-nav .toolbar-button-dropdown > div h4:before, @@ -2439,7 +2609,7 @@ html[dir="rtl"] .select2-search-choice-close { } @media screen and (max-width: 767px) { .toolbar-controls, -.toolbar-controls { + .toolbar-controls { width: 96%; position: fixed; bottom: 2%; @@ -2449,7 +2619,7 @@ html[dir="rtl"] .select2-search-choice-close { } @media screen and (min-width: 768px) { .toolbar-controls, -.toolbar-controls { + .toolbar-controls { display: none; } } @@ -2460,8 +2630,8 @@ html[dir="rtl"] .select2-search-choice-close { @media screen and (min-width: 768px) { .options-menu, .options-submenu, -.options-menu, -.options-submenu { + .options-menu, + .options-submenu { padding: 1em 1em 0em 1em; } } @@ -2550,26 +2720,66 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #763225 !important; outline: none; } -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > input + button:focus, +.task-column:not(.rewards) + .color-worst:not(.completed) + .priority-multiplier + li + > input + + button:focus, .task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > input + button:focus, .task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > input + button:focus, .task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > input + button:focus, -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li textarea + button:focus, +.task-column:not(.rewards) + .color-worst:not(.completed) + .priority-multiplier + li + textarea + + button:focus, .task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li textarea + button:focus, .task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li textarea + button:focus, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li textarea + button:focus { +.task-column:not(.rewards) + .color-worst:not(.completed) + .repeat-frequency + li + textarea + + button:focus { border-color: #c65e4a !important; background-color: #e0a79c !important; outline: none; } -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > input + button:active, +.task-column:not(.rewards) + .color-worst:not(.completed) + .priority-multiplier + li + > input + + button:active, .task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > input + button:active, .task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > input + button:active, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > input + button:active, -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li textarea + button:active, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li textarea + button:active, +.task-column:not(.rewards) + .color-worst:not(.completed) + .repeat-frequency + li + > input + + button:active, +.task-column:not(.rewards) + .color-worst:not(.completed) + .priority-multiplier + li + textarea + + button:active, +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-attributes + li + textarea + + button:active, .task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li textarea + button:active, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li textarea + button:active { +.task-column:not(.rewards) + .color-worst:not(.completed) + .repeat-frequency + li + textarea + + button:active { background-color: #d68c7d !important; } .task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > a:nth-of-type(2), @@ -2594,14 +2804,54 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #e6b8af; border-color: #d68c7d; } -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > div > ul:first-child:before, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > div > ul:first-child:before, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > div > ul:first-child:before, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > div > ul:first-child:before, -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > div > div:first-child:before, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > div > div:first-child:before, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > div > div:first-child:before, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > div > div:first-child:before { +.task-column:not(.rewards) + .color-worst:not(.completed) + .priority-multiplier + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-attributes + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-worst:not(.completed) + .repeat-days + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-worst:not(.completed) + .repeat-frequency + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-worst:not(.completed) + .priority-multiplier + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-attributes + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-worst:not(.completed) + .repeat-days + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-worst:not(.completed) + .repeat-frequency + li + > div + > div:first-child:before { background-color: #fff; border-color: #d68c7d; } @@ -2655,26 +2905,77 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #e2aea3 !important; border-color: #9a4230 !important; } -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li.active.filters-tags a, +.task-column:not(.rewards) + .color-worst:not(.completed) + .priority-multiplier + li.active.filters-tags + a, .task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li.active.filters-tags a, .task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li.active.filters-tags a, .task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li.active.filters-tags a, -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li.active.filters-tags button, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li.active.filters-tags button, +.task-column:not(.rewards) + .color-worst:not(.completed) + .priority-multiplier + li.active.filters-tags + button, +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-attributes + li.active.filters-tags + button, .task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li.active.filters-tags button, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li.active.filters-tags button { +.task-column:not(.rewards) + .color-worst:not(.completed) + .repeat-frequency + li.active.filters-tags + button { background-color: #c65e4a !important; border-color: #e6b8af !important; color: #fff !important; } -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li.active.filters-tags a span, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li.active.filters-tags a span, +.task-column:not(.rewards) + .color-worst:not(.completed) + .priority-multiplier + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-attributes + li.active.filters-tags + a + span, .task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li.active.filters-tags a span, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li.active.filters-tags a span, -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li.active.filters-tags button span, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li.active.filters-tags button span, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li.active.filters-tags button span, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li.active.filters-tags button span { +.task-column:not(.rewards) + .color-worst:not(.completed) + .repeat-frequency + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-worst:not(.completed) + .priority-multiplier + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-attributes + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-worst:not(.completed) + .repeat-days + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-worst:not(.completed) + .repeat-frequency + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li button.active, @@ -2695,7 +2996,12 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-worst:not(.completed) .plusminus .task-checker label:after { border: 1px solid #c96652 !important; } -.task-column:not(.rewards) .color-worst:not(.completed) .plusminus .task-checker input[type=checkbox]:checked + label:after { +.task-column:not(.rewards) + .color-worst:not(.completed) + .plusminus + .task-checker + input[type='checkbox']:checked + + label:after { -webkit-box-shadow: inset 0 0 0 1px #b94f3a !important; box-shadow: inset 0 0 0 1px #b94f3a !important; background-color: #e1aaa0 !important; @@ -2745,17 +3051,37 @@ html[dir="rtl"] .select2-search-choice-close { outline: none; } .task-column:not(.rewards) .color-worst:not(.completed) .save-close > input + button:focus, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > input + button:focus, +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-checklist-edit + li + > input + + button:focus, .task-column:not(.rewards) .color-worst:not(.completed) .save-close textarea + button:focus, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li textarea + button:focus { +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-checklist-edit + li + textarea + + button:focus { border-color: #c35440 !important; background-color: #db9a8e !important; outline: none; } .task-column:not(.rewards) .color-worst:not(.completed) .save-close > input + button:active, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > input + button:active, +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-checklist-edit + li + > input + + button:active, .task-column:not(.rewards) .color-worst:not(.completed) .save-close textarea + button:active, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li textarea + button:active { +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-checklist-edit + li + textarea + + button:active { background-color: #d28071 !important; } .task-column:not(.rewards) .color-worst:not(.completed) .save-close > a:nth-of-type(2), @@ -2775,9 +3101,19 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #d28071; } .task-column:not(.rewards) .color-worst:not(.completed) .save-close > div > ul:first-child:before, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > div > ul:first-child:before, +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-checklist-edit + li + > div + > ul:first-child:before, .task-column:not(.rewards) .color-worst:not(.completed) .save-close > div > div:first-child:before, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > div > div:first-child:before { +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-checklist-edit + li + > div + > div:first-child:before { background-color: #fff; border-color: #d28071; } @@ -2814,17 +3150,35 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #923e2e !important; } .task-column:not(.rewards) .color-worst:not(.completed) .save-close.active.filters-tags a, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li.active.filters-tags a, +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-checklist-edit + li.active.filters-tags + a, .task-column:not(.rewards) .color-worst:not(.completed) .save-close.active.filters-tags button, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li.active.filters-tags button { +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-checklist-edit + li.active.filters-tags + button { background-color: #c35440 !important; border-color: #e1aaa0 !important; color: #fff !important; } .task-column:not(.rewards) .color-worst:not(.completed) .save-close.active.filters-tags a span, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li.active.filters-tags a span, +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-checklist-edit + li.active.filters-tags + a + span, .task-column:not(.rewards) .color-worst:not(.completed) .save-close.active.filters-tags button span, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li.active.filters-tags button span { +.task-column:not(.rewards) + .color-worst:not(.completed) + .task-checklist-edit + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-worst:not(.completed) .save-close button:focus, @@ -2848,7 +3202,10 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-worst:not(.completed) .task-actions a:focus { background-color: #b94f3a; } -.task-column:not(.rewards) .color-worst:not(.completed) input[type=checkbox].task-input:focus + label, +.task-column:not(.rewards) + .color-worst:not(.completed) + input[type='checkbox'].task-input:focus + + label, .task-column:not(.rewards) .color-worst:not(.completed) input.habit:focus + a { background-color: #b94f3a; } @@ -2947,26 +3304,66 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #8d1e1e !important; outline: none; } -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > input + button:focus, +.task-column:not(.rewards) + .color-worse:not(.completed) + .priority-multiplier + li + > input + + button:focus, .task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > input + button:focus, .task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > input + button:focus, .task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > input + button:focus, -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li textarea + button:focus, +.task-column:not(.rewards) + .color-worse:not(.completed) + .priority-multiplier + li + textarea + + button:focus, .task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li textarea + button:focus, .task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li textarea + button:focus, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li textarea + button:focus { +.task-column:not(.rewards) + .color-worse:not(.completed) + .repeat-frequency + li + textarea + + button:focus { border-color: #da5353 !important; background-color: #efb5b5 !important; outline: none; } -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > input + button:active, +.task-column:not(.rewards) + .color-worse:not(.completed) + .priority-multiplier + li + > input + + button:active, .task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > input + button:active, .task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > input + button:active, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > input + button:active, -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li textarea + button:active, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li textarea + button:active, +.task-column:not(.rewards) + .color-worse:not(.completed) + .repeat-frequency + li + > input + + button:active, +.task-column:not(.rewards) + .color-worse:not(.completed) + .priority-multiplier + li + textarea + + button:active, +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-attributes + li + textarea + + button:active, .task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li textarea + button:active, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li textarea + button:active { +.task-column:not(.rewards) + .color-worse:not(.completed) + .repeat-frequency + li + textarea + + button:active { background-color: #e79090 !important; } .task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > a:nth-of-type(2), @@ -2991,14 +3388,54 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #f4cccc; border-color: #e79090; } -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > div > ul:first-child:before, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > div > ul:first-child:before, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > div > ul:first-child:before, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > div > ul:first-child:before, -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > div > div:first-child:before, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > div > div:first-child:before, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > div > div:first-child:before, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > div > div:first-child:before { +.task-column:not(.rewards) + .color-worse:not(.completed) + .priority-multiplier + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-attributes + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-worse:not(.completed) + .repeat-days + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-worse:not(.completed) + .repeat-frequency + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-worse:not(.completed) + .priority-multiplier + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-attributes + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-worse:not(.completed) + .repeat-days + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-worse:not(.completed) + .repeat-frequency + li + > div + > div:first-child:before { background-color: #fff; border-color: #e79090; } @@ -3052,26 +3489,77 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #f1bebe !important; border-color: #b82828 !important; } -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li.active.filters-tags a, +.task-column:not(.rewards) + .color-worse:not(.completed) + .priority-multiplier + li.active.filters-tags + a, .task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li.active.filters-tags a, .task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li.active.filters-tags a, .task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li.active.filters-tags a, -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li.active.filters-tags button, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li.active.filters-tags button, +.task-column:not(.rewards) + .color-worse:not(.completed) + .priority-multiplier + li.active.filters-tags + button, +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-attributes + li.active.filters-tags + button, .task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li.active.filters-tags button, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li.active.filters-tags button { +.task-column:not(.rewards) + .color-worse:not(.completed) + .repeat-frequency + li.active.filters-tags + button { background-color: #da5353 !important; border-color: #f4cccc !important; color: #fff !important; } -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li.active.filters-tags a span, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li.active.filters-tags a span, +.task-column:not(.rewards) + .color-worse:not(.completed) + .priority-multiplier + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-attributes + li.active.filters-tags + a + span, .task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li.active.filters-tags a span, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li.active.filters-tags a span, -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li.active.filters-tags button span, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li.active.filters-tags button span, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li.active.filters-tags button span, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li.active.filters-tags button span { +.task-column:not(.rewards) + .color-worse:not(.completed) + .repeat-frequency + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-worse:not(.completed) + .priority-multiplier + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-attributes + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-worse:not(.completed) + .repeat-days + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-worse:not(.completed) + .repeat-frequency + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li button.active, @@ -3092,7 +3580,12 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-worse:not(.completed) .plusminus .task-checker label:after { border: 1px solid #dc5d5d !important; } -.task-column:not(.rewards) .color-worse:not(.completed) .plusminus .task-checker input[type=checkbox]:checked + label:after { +.task-column:not(.rewards) + .color-worse:not(.completed) + .plusminus + .task-checker + input[type='checkbox']:checked + + label:after { -webkit-box-shadow: inset 0 0 0 1px #d43939 !important; box-shadow: inset 0 0 0 1px #d43939 !important; background-color: #f0baba !important; @@ -3142,17 +3635,37 @@ html[dir="rtl"] .select2-search-choice-close { outline: none; } .task-column:not(.rewards) .color-worse:not(.completed) .save-close > input + button:focus, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > input + button:focus, +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-checklist-edit + li + > input + + button:focus, .task-column:not(.rewards) .color-worse:not(.completed) .save-close textarea + button:focus, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li textarea + button:focus { +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-checklist-edit + li + textarea + + button:focus { border-color: #d74747 !important; background-color: #eba4a4 !important; outline: none; } .task-column:not(.rewards) .color-worse:not(.completed) .save-close > input + button:active, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > input + button:active, +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-checklist-edit + li + > input + + button:active, .task-column:not(.rewards) .color-worse:not(.completed) .save-close textarea + button:active, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li textarea + button:active { +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-checklist-edit + li + textarea + + button:active { background-color: #e48181 !important; } .task-column:not(.rewards) .color-worse:not(.completed) .save-close > a:nth-of-type(2), @@ -3172,9 +3685,19 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #e48181; } .task-column:not(.rewards) .color-worse:not(.completed) .save-close > div > ul:first-child:before, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > div > ul:first-child:before, +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-checklist-edit + li + > div + > ul:first-child:before, .task-column:not(.rewards) .color-worse:not(.completed) .save-close > div > div:first-child:before, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > div > div:first-child:before { +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-checklist-edit + li + > div + > div:first-child:before { background-color: #fff; border-color: #e48181; } @@ -3211,17 +3734,35 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #af2626 !important; } .task-column:not(.rewards) .color-worse:not(.completed) .save-close.active.filters-tags a, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li.active.filters-tags a, +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-checklist-edit + li.active.filters-tags + a, .task-column:not(.rewards) .color-worse:not(.completed) .save-close.active.filters-tags button, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li.active.filters-tags button { +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-checklist-edit + li.active.filters-tags + button { background-color: #d74747 !important; border-color: #f0baba !important; color: #fff !important; } .task-column:not(.rewards) .color-worse:not(.completed) .save-close.active.filters-tags a span, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li.active.filters-tags a span, +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-checklist-edit + li.active.filters-tags + a + span, .task-column:not(.rewards) .color-worse:not(.completed) .save-close.active.filters-tags button span, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li.active.filters-tags button span { +.task-column:not(.rewards) + .color-worse:not(.completed) + .task-checklist-edit + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-worse:not(.completed) .save-close button:focus, @@ -3245,7 +3786,10 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-worse:not(.completed) .task-actions a:focus { background-color: #d43939; } -.task-column:not(.rewards) .color-worse:not(.completed) input[type=checkbox].task-input:focus + label, +.task-column:not(.rewards) + .color-worse:not(.completed) + input[type='checkbox'].task-input:focus + + label, .task-column:not(.rewards) .color-worse:not(.completed) input.habit:focus + a { background-color: #d43939; } @@ -3344,11 +3888,21 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #a5590a !important; outline: none; } -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > input + button:focus, +.task-column:not(.rewards) + .color-bad:not(.completed) + .priority-multiplier + li + > input + + button:focus, .task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > input + button:focus, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > input + button:focus, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > input + button:focus, -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li textarea + button:focus, +.task-column:not(.rewards) + .color-bad:not(.completed) + .priority-multiplier + li + textarea + + button:focus, .task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li textarea + button:focus, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li textarea + button:focus, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li textarea + button:focus { @@ -3356,14 +3910,29 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #fad7b2 !important; outline: none; } -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > input + button:active, +.task-column:not(.rewards) + .color-bad:not(.completed) + .priority-multiplier + li + > input + + button:active, .task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > input + button:active, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > input + button:active, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > input + button:active, -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li textarea + button:active, +.task-column:not(.rewards) + .color-bad:not(.completed) + .priority-multiplier + li + textarea + + button:active, .task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li textarea + button:active, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li textarea + button:active, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li textarea + button:active { +.task-column:not(.rewards) + .color-bad:not(.completed) + .repeat-frequency + li + textarea + + button:active { background-color: #f8c187 !important; } .task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > a:nth-of-type(2), @@ -3388,14 +3957,49 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #fce5cd; border-color: #f8c187; } -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > div > ul:first-child:before, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > div > ul:first-child:before, +.task-column:not(.rewards) + .color-bad:not(.completed) + .priority-multiplier + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-attributes + li + > div + > ul:first-child:before, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > div > ul:first-child:before, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > div > ul:first-child:before, -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > div > div:first-child:before, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > div > div:first-child:before, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > div > div:first-child:before, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > div > div:first-child:before { +.task-column:not(.rewards) + .color-bad:not(.completed) + .repeat-frequency + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-bad:not(.completed) + .priority-multiplier + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-attributes + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-bad:not(.completed) + .repeat-days + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-bad:not(.completed) + .repeat-frequency + li + > div + > div:first-child:before { background-color: #fff; border-color: #f8c187; } @@ -3453,22 +4057,69 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li.active.filters-tags a, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li.active.filters-tags a, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li.active.filters-tags a, -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li.active.filters-tags button, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li.active.filters-tags button, +.task-column:not(.rewards) + .color-bad:not(.completed) + .priority-multiplier + li.active.filters-tags + button, +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-attributes + li.active.filters-tags + button, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li.active.filters-tags button, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li.active.filters-tags button { +.task-column:not(.rewards) + .color-bad:not(.completed) + .repeat-frequency + li.active.filters-tags + button { background-color: #f49b40 !important; border-color: #fce5cd !important; color: #fff !important; } -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li.active.filters-tags a span, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li.active.filters-tags a span, +.task-column:not(.rewards) + .color-bad:not(.completed) + .priority-multiplier + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-attributes + li.active.filters-tags + a + span, .task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li.active.filters-tags a span, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li.active.filters-tags a span, -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li.active.filters-tags button span, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li.active.filters-tags button span, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li.active.filters-tags button span, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li.active.filters-tags button span { +.task-column:not(.rewards) + .color-bad:not(.completed) + .repeat-frequency + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-bad:not(.completed) + .priority-multiplier + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-attributes + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-bad:not(.completed) + .repeat-days + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-bad:not(.completed) + .repeat-frequency + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li button.active, @@ -3489,7 +4140,12 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-bad:not(.completed) .plusminus .task-checker label:after { border: 1px solid #f4a24c !important; } -.task-column:not(.rewards) .color-bad:not(.completed) .plusminus .task-checker input[type=checkbox]:checked + label:after { +.task-column:not(.rewards) + .color-bad:not(.completed) + .plusminus + .task-checker + input[type='checkbox']:checked + + label:after { -webkit-box-shadow: inset 0 0 0 1px #f28b21 !important; box-shadow: inset 0 0 0 1px #f28b21 !important; background-color: #fbdab7 !important; @@ -3539,17 +4195,37 @@ html[dir="rtl"] .select2-search-choice-close { outline: none; } .task-column:not(.rewards) .color-bad:not(.completed) .save-close > input + button:focus, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > input + button:focus, +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-checklist-edit + li + > input + + button:focus, .task-column:not(.rewards) .color-bad:not(.completed) .save-close textarea + button:focus, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li textarea + button:focus { +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-checklist-edit + li + textarea + + button:focus { border-color: #f49530 !important; background-color: #facd9e !important; outline: none; } .task-column:not(.rewards) .color-bad:not(.completed) .save-close > input + button:active, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > input + button:active, +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-checklist-edit + li + > input + + button:active, .task-column:not(.rewards) .color-bad:not(.completed) .save-close textarea + button:active, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li textarea + button:active { +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-checklist-edit + li + textarea + + button:active { background-color: #f7b874 !important; } .task-column:not(.rewards) .color-bad:not(.completed) .save-close > a:nth-of-type(2), @@ -3569,9 +4245,19 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #f7b874; } .task-column:not(.rewards) .color-bad:not(.completed) .save-close > div > ul:first-child:before, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > div > ul:first-child:before, +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-checklist-edit + li + > div + > ul:first-child:before, .task-column:not(.rewards) .color-bad:not(.completed) .save-close > div > div:first-child:before, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > div > div:first-child:before { +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-checklist-edit + li + > div + > div:first-child:before { background-color: #fff; border-color: #f7b874; } @@ -3610,15 +4296,29 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-bad:not(.completed) .save-close.active.filters-tags a, .task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li.active.filters-tags a, .task-column:not(.rewards) .color-bad:not(.completed) .save-close.active.filters-tags button, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li.active.filters-tags button { +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-checklist-edit + li.active.filters-tags + button { background-color: #f49530 !important; border-color: #fbdab7 !important; color: #fff !important; } .task-column:not(.rewards) .color-bad:not(.completed) .save-close.active.filters-tags a span, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li.active.filters-tags a span, +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-checklist-edit + li.active.filters-tags + a + span, .task-column:not(.rewards) .color-bad:not(.completed) .save-close.active.filters-tags button span, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li.active.filters-tags button span { +.task-column:not(.rewards) + .color-bad:not(.completed) + .task-checklist-edit + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-bad:not(.completed) .save-close button:focus, @@ -3642,7 +4342,10 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-bad:not(.completed) .task-actions a:focus { background-color: #f28b21; } -.task-column:not(.rewards) .color-bad:not(.completed) input[type=checkbox].task-input:focus + label, +.task-column:not(.rewards) + .color-bad:not(.completed) + input[type='checkbox'].task-input:focus + + label, .task-column:not(.rewards) .color-bad:not(.completed) input.habit:focus + a { background-color: #f28b21; } @@ -3741,29 +4444,93 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #af8300 !important; outline: none; } -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > input + button:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > input + button:focus, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .priority-multiplier + li + > input + + button:focus, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-attributes + li + > input + + button:focus, .task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > input + button:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > input + button:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li textarea + button:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li textarea + button:focus, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-frequency + li + > input + + button:focus, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .priority-multiplier + li + textarea + + button:focus, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-attributes + li + textarea + + button:focus, .task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li textarea + button:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li textarea + button:focus { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-frequency + li + textarea + + button:focus { border-color: #ffcc35 !important; background-color: #ffebb0 !important; outline: none; } -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > input + button:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > input + button:active, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .priority-multiplier + li + > input + + button:active, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-attributes + li + > input + + button:active, .task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > input + button:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > input + button:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li textarea + button:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li textarea + button:active, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-frequency + li + > input + + button:active, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .priority-multiplier + li + textarea + + button:active, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-attributes + li + textarea + + button:active, .task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li textarea + button:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li textarea + button:active { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-frequency + li + textarea + + button:active { background-color: #ffdf82 !important; } -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > a:nth-of-type(2), +.task-column:not(.rewards) + .color-neutral:not(.completed) + .priority-multiplier + li + > a:nth-of-type(2), .task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > a:nth-of-type(2), .task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > a:nth-of-type(2), .task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > a:nth-of-type(2) { @@ -3785,14 +4552,54 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #fff2cc; border-color: #ffdf82; } -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > div > ul:first-child:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > div > ul:first-child:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > div > ul:first-child:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > div > ul:first-child:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > div > div:first-child:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > div > div:first-child:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > div > div:first-child:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > div > div:first-child:before { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .priority-multiplier + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-attributes + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-days + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-frequency + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .priority-multiplier + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-attributes + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-days + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-frequency + li + > div + > div:first-child:before { background-color: #fff; border-color: #ffdf82; } @@ -3846,26 +4653,90 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #ffeeba !important; border-color: #e6ab00 !important; } -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li.active.filters-tags a, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .priority-multiplier + li.active.filters-tags + a, .task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li.active.filters-tags a, .task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li.active.filters-tags a, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li.active.filters-tags a, -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li.active.filters-tags button, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li.active.filters-tags button, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li.active.filters-tags button, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li.active.filters-tags button { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-frequency + li.active.filters-tags + a, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .priority-multiplier + li.active.filters-tags + button, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-attributes + li.active.filters-tags + button, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-days + li.active.filters-tags + button, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-frequency + li.active.filters-tags + button { background-color: #ffcc35 !important; border-color: #fff2cc !important; color: #fff !important; } -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li.active.filters-tags a span, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li.active.filters-tags a span, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li.active.filters-tags a span, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li.active.filters-tags a span, -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li.active.filters-tags button span, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li.active.filters-tags button span, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li.active.filters-tags button span, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li.active.filters-tags button span { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .priority-multiplier + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-attributes + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-days + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-frequency + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .priority-multiplier + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-attributes + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-days + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .repeat-frequency + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li button.active, @@ -3886,7 +4757,12 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-neutral:not(.completed) .plusminus .task-checker label:after { border: 1px solid #ffcf42 !important; } -.task-column:not(.rewards) .color-neutral:not(.completed) .plusminus .task-checker input[type=checkbox]:checked + label:after { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .plusminus + .task-checker + input[type='checkbox']:checked + + label:after { -webkit-box-shadow: inset 0 0 0 1px #ffc314 !important; box-shadow: inset 0 0 0 1px #ffc314 !important; background-color: #ffecb5 !important; @@ -3936,21 +4812,45 @@ html[dir="rtl"] .select2-search-choice-close { outline: none; } .task-column:not(.rewards) .color-neutral:not(.completed) .save-close > input + button:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > input + button:focus, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-checklist-edit + li + > input + + button:focus, .task-column:not(.rewards) .color-neutral:not(.completed) .save-close textarea + button:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li textarea + button:focus { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-checklist-edit + li + textarea + + button:focus { border-color: #ffc726 !important; background-color: #ffe59a !important; outline: none; } .task-column:not(.rewards) .color-neutral:not(.completed) .save-close > input + button:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > input + button:active, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-checklist-edit + li + > input + + button:active, .task-column:not(.rewards) .color-neutral:not(.completed) .save-close textarea + button:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li textarea + button:active { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-checklist-edit + li + textarea + + button:active { background-color: #ffda6e !important; } .task-column:not(.rewards) .color-neutral:not(.completed) .save-close > a:nth-of-type(2), -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > a:nth-of-type(2) { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-checklist-edit + li + > a:nth-of-type(2) { border-left: 1px solid #ffe8a4 !important; } @media screen and (min-width: 768px) { @@ -3966,9 +4866,23 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #ffda6e; } .task-column:not(.rewards) .color-neutral:not(.completed) .save-close > div > ul:first-child:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > div > ul:first-child:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close > div > div:first-child:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > div > div:first-child:before { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-checklist-edit + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .save-close + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-checklist-edit + li + > div + > div:first-child:before { background-color: #fff; border-color: #ffda6e; } @@ -4005,17 +4919,39 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #daa200 !important; } .task-column:not(.rewards) .color-neutral:not(.completed) .save-close.active.filters-tags a, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li.active.filters-tags a, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-checklist-edit + li.active.filters-tags + a, .task-column:not(.rewards) .color-neutral:not(.completed) .save-close.active.filters-tags button, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li.active.filters-tags button { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-checklist-edit + li.active.filters-tags + button { background-color: #ffc726 !important; border-color: #ffecb5 !important; color: #fff !important; } .task-column:not(.rewards) .color-neutral:not(.completed) .save-close.active.filters-tags a span, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li.active.filters-tags a span, -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close.active.filters-tags button span, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li.active.filters-tags button span { +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-checklist-edit + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .save-close.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-neutral:not(.completed) + .task-checklist-edit + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-neutral:not(.completed) .save-close button:focus, @@ -4039,7 +4975,10 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-neutral:not(.completed) .task-actions a:focus { background-color: #ffc314; } -.task-column:not(.rewards) .color-neutral:not(.completed) input[type=checkbox].task-input:focus + label, +.task-column:not(.rewards) + .color-neutral:not(.completed) + input[type='checkbox'].task-input:focus + + label, .task-column:not(.rewards) .color-neutral:not(.completed) input.habit:focus + a { background-color: #ffc314; } @@ -4138,26 +5077,56 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #477337 !important; outline: none; } -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > input + button:focus, +.task-column:not(.rewards) + .color-good:not(.completed) + .priority-multiplier + li + > input + + button:focus, .task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > input + button:focus, .task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > input + button:focus, .task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > input + button:focus, -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li textarea + button:focus, +.task-column:not(.rewards) + .color-good:not(.completed) + .priority-multiplier + li + textarea + + button:focus, .task-column:not(.rewards) .color-good:not(.completed) .task-attributes li textarea + button:focus, .task-column:not(.rewards) .color-good:not(.completed) .repeat-days li textarea + button:focus, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li textarea + button:focus { +.task-column:not(.rewards) + .color-good:not(.completed) + .repeat-frequency + li + textarea + + button:focus { border-color: #84bb70 !important; background-color: #c9e1c0 !important; outline: none; } -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > input + button:active, +.task-column:not(.rewards) + .color-good:not(.completed) + .priority-multiplier + li + > input + + button:active, .task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > input + button:active, .task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > input + button:active, .task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > input + button:active, -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li textarea + button:active, +.task-column:not(.rewards) + .color-good:not(.completed) + .priority-multiplier + li + textarea + + button:active, .task-column:not(.rewards) .color-good:not(.completed) .task-attributes li textarea + button:active, .task-column:not(.rewards) .color-good:not(.completed) .repeat-days li textarea + button:active, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li textarea + button:active { +.task-column:not(.rewards) + .color-good:not(.completed) + .repeat-frequency + li + textarea + + button:active { background-color: #afd3a2 !important; } .task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > a:nth-of-type(2), @@ -4182,14 +5151,54 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #d9ead3; border-color: #afd3a2; } -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > div > ul:first-child:before, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > div > ul:first-child:before, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > div > ul:first-child:before, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > div > ul:first-child:before, -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > div > div:first-child:before, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > div > div:first-child:before, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > div > div:first-child:before, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > div > div:first-child:before { +.task-column:not(.rewards) + .color-good:not(.completed) + .priority-multiplier + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-good:not(.completed) + .task-attributes + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-good:not(.completed) + .repeat-days + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-good:not(.completed) + .repeat-frequency + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-good:not(.completed) + .priority-multiplier + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-good:not(.completed) + .task-attributes + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-good:not(.completed) + .repeat-days + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-good:not(.completed) + .repeat-frequency + li + > div + > div:first-child:before { background-color: #fff; border-color: #afd3a2; } @@ -4243,26 +5252,77 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #cfe5c7 !important; border-color: #5c9748 !important; } -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li.active.filters-tags a, +.task-column:not(.rewards) + .color-good:not(.completed) + .priority-multiplier + li.active.filters-tags + a, .task-column:not(.rewards) .color-good:not(.completed) .task-attributes li.active.filters-tags a, .task-column:not(.rewards) .color-good:not(.completed) .repeat-days li.active.filters-tags a, .task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li.active.filters-tags a, -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li.active.filters-tags button, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li.active.filters-tags button, +.task-column:not(.rewards) + .color-good:not(.completed) + .priority-multiplier + li.active.filters-tags + button, +.task-column:not(.rewards) + .color-good:not(.completed) + .task-attributes + li.active.filters-tags + button, .task-column:not(.rewards) .color-good:not(.completed) .repeat-days li.active.filters-tags button, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li.active.filters-tags button { +.task-column:not(.rewards) + .color-good:not(.completed) + .repeat-frequency + li.active.filters-tags + button { background-color: #84bb70 !important; border-color: #d9ead3 !important; color: #fff !important; } -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li.active.filters-tags a span, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li.active.filters-tags a span, +.task-column:not(.rewards) + .color-good:not(.completed) + .priority-multiplier + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-good:not(.completed) + .task-attributes + li.active.filters-tags + a + span, .task-column:not(.rewards) .color-good:not(.completed) .repeat-days li.active.filters-tags a span, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li.active.filters-tags a span, -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li.active.filters-tags button span, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li.active.filters-tags button span, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li.active.filters-tags button span, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li.active.filters-tags button span { +.task-column:not(.rewards) + .color-good:not(.completed) + .repeat-frequency + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-good:not(.completed) + .priority-multiplier + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-good:not(.completed) + .task-attributes + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-good:not(.completed) + .repeat-days + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-good:not(.completed) + .repeat-frequency + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li button.active, @@ -4283,7 +5343,12 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-good:not(.completed) .plusminus .task-checker label:after { border: 1px solid #8bbf79 !important; } -.task-column:not(.rewards) .color-good:not(.completed) .plusminus .task-checker input[type=checkbox]:checked + label:after { +.task-column:not(.rewards) + .color-good:not(.completed) + .plusminus + .task-checker + input[type='checkbox']:checked + + label:after { -webkit-box-shadow: inset 0 0 0 1px #71b05b !important; box-shadow: inset 0 0 0 1px #71b05b !important; background-color: #cce3c4 !important; @@ -4333,17 +5398,37 @@ html[dir="rtl"] .select2-search-choice-close { outline: none; } .task-column:not(.rewards) .color-good:not(.completed) .save-close > input + button:focus, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > input + button:focus, +.task-column:not(.rewards) + .color-good:not(.completed) + .task-checklist-edit + li + > input + + button:focus, .task-column:not(.rewards) .color-good:not(.completed) .save-close textarea + button:focus, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li textarea + button:focus { +.task-column:not(.rewards) + .color-good:not(.completed) + .task-checklist-edit + li + textarea + + button:focus { border-color: #7bb666 !important; background-color: #bddbb2 !important; outline: none; } .task-column:not(.rewards) .color-good:not(.completed) .save-close > input + button:active, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > input + button:active, +.task-column:not(.rewards) + .color-good:not(.completed) + .task-checklist-edit + li + > input + + button:active, .task-column:not(.rewards) .color-good:not(.completed) .save-close textarea + button:active, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li textarea + button:active { +.task-column:not(.rewards) + .color-good:not(.completed) + .task-checklist-edit + li + textarea + + button:active { background-color: #a4cd96 !important; } .task-column:not(.rewards) .color-good:not(.completed) .save-close > a:nth-of-type(2), @@ -4363,9 +5448,19 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #a4cd96; } .task-column:not(.rewards) .color-good:not(.completed) .save-close > div > ul:first-child:before, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > div > ul:first-child:before, +.task-column:not(.rewards) + .color-good:not(.completed) + .task-checklist-edit + li + > div + > ul:first-child:before, .task-column:not(.rewards) .color-good:not(.completed) .save-close > div > div:first-child:before, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > div > div:first-child:before { +.task-column:not(.rewards) + .color-good:not(.completed) + .task-checklist-edit + li + > div + > div:first-child:before { background-color: #fff; border-color: #a4cd96; } @@ -4402,17 +5497,35 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #588f44 !important; } .task-column:not(.rewards) .color-good:not(.completed) .save-close.active.filters-tags a, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li.active.filters-tags a, +.task-column:not(.rewards) + .color-good:not(.completed) + .task-checklist-edit + li.active.filters-tags + a, .task-column:not(.rewards) .color-good:not(.completed) .save-close.active.filters-tags button, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li.active.filters-tags button { +.task-column:not(.rewards) + .color-good:not(.completed) + .task-checklist-edit + li.active.filters-tags + button { background-color: #7bb666 !important; border-color: #cce3c4 !important; color: #fff !important; } .task-column:not(.rewards) .color-good:not(.completed) .save-close.active.filters-tags a span, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li.active.filters-tags a span, +.task-column:not(.rewards) + .color-good:not(.completed) + .task-checklist-edit + li.active.filters-tags + a + span, .task-column:not(.rewards) .color-good:not(.completed) .save-close.active.filters-tags button span, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li.active.filters-tags button span { +.task-column:not(.rewards) + .color-good:not(.completed) + .task-checklist-edit + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-good:not(.completed) .save-close button:focus, @@ -4436,7 +5549,10 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-good:not(.completed) .task-actions a:focus { background-color: #71b05b; } -.task-column:not(.rewards) .color-good:not(.completed) input[type=checkbox].task-input:focus + label, +.task-column:not(.rewards) + .color-good:not(.completed) + input[type='checkbox'].task-input:focus + + label, .task-column:not(.rewards) .color-good:not(.completed) input.habit:focus + a { background-color: #71b05b; } @@ -4535,26 +5651,81 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #3e6168 !important; outline: none; } -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > input + button:focus, +.task-column:not(.rewards) + .color-better:not(.completed) + .priority-multiplier + li + > input + + button:focus, .task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > input + button:focus, .task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > input + button:focus, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > input + button:focus, -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li textarea + button:focus, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li textarea + button:focus, +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-frequency + li + > input + + button:focus, +.task-column:not(.rewards) + .color-better:not(.completed) + .priority-multiplier + li + textarea + + button:focus, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-attributes + li + textarea + + button:focus, .task-column:not(.rewards) .color-better:not(.completed) .repeat-days li textarea + button:focus, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li textarea + button:focus { +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-frequency + li + textarea + + button:focus { border-color: #77a5ae !important; background-color: #bfd5d9 !important; outline: none; } -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > input + button:active, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > input + button:active, +.task-column:not(.rewards) + .color-better:not(.completed) + .priority-multiplier + li + > input + + button:active, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-attributes + li + > input + + button:active, .task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > input + button:active, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > input + button:active, -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li textarea + button:active, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li textarea + button:active, +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-frequency + li + > input + + button:active, +.task-column:not(.rewards) + .color-better:not(.completed) + .priority-multiplier + li + textarea + + button:active, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-attributes + li + textarea + + button:active, .task-column:not(.rewards) .color-better:not(.completed) .repeat-days li textarea + button:active, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li textarea + button:active { +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-frequency + li + textarea + + button:active { background-color: #a4c3c9 !important; } .task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > a:nth-of-type(2), @@ -4579,14 +5750,54 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #d0e0e3; border-color: #a4c3c9; } -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > div > ul:first-child:before, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > div > ul:first-child:before, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > div > ul:first-child:before, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > div > ul:first-child:before, -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > div > div:first-child:before, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > div > div:first-child:before, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > div > div:first-child:before, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > div > div:first-child:before { +.task-column:not(.rewards) + .color-better:not(.completed) + .priority-multiplier + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-attributes + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-days + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-frequency + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-better:not(.completed) + .priority-multiplier + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-attributes + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-days + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-frequency + li + > div + > div:first-child:before { background-color: #fff; border-color: #a4c3c9; } @@ -4640,26 +5851,77 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #c6d9dd !important; border-color: #518088 !important; } -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li.active.filters-tags a, +.task-column:not(.rewards) + .color-better:not(.completed) + .priority-multiplier + li.active.filters-tags + a, .task-column:not(.rewards) .color-better:not(.completed) .task-attributes li.active.filters-tags a, .task-column:not(.rewards) .color-better:not(.completed) .repeat-days li.active.filters-tags a, .task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li.active.filters-tags a, -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li.active.filters-tags button, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li.active.filters-tags button, +.task-column:not(.rewards) + .color-better:not(.completed) + .priority-multiplier + li.active.filters-tags + button, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-attributes + li.active.filters-tags + button, .task-column:not(.rewards) .color-better:not(.completed) .repeat-days li.active.filters-tags button, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li.active.filters-tags button { +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-frequency + li.active.filters-tags + button { background-color: #77a5ae !important; border-color: #d0e0e3 !important; color: #fff !important; } -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li.active.filters-tags a span, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li.active.filters-tags a span, +.task-column:not(.rewards) + .color-better:not(.completed) + .priority-multiplier + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-attributes + li.active.filters-tags + a + span, .task-column:not(.rewards) .color-better:not(.completed) .repeat-days li.active.filters-tags a span, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li.active.filters-tags a span, -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li.active.filters-tags button span, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li.active.filters-tags button span, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li.active.filters-tags button span, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li.active.filters-tags button span { +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-frequency + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-better:not(.completed) + .priority-multiplier + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-attributes + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-days + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-better:not(.completed) + .repeat-frequency + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li button.active, @@ -4680,7 +5942,12 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-better:not(.completed) .plusminus .task-checker label:after { border: 1px solid #7eaab2 !important; } -.task-column:not(.rewards) .color-better:not(.completed) .plusminus .task-checker input[type=checkbox]:checked + label:after { +.task-column:not(.rewards) + .color-better:not(.completed) + .plusminus + .task-checker + input[type='checkbox']:checked + + label:after { -webkit-box-shadow: inset 0 0 0 1px #6398a2 !important; box-shadow: inset 0 0 0 1px #6398a2 !important; background-color: #c2d7db !important; @@ -4730,21 +5997,45 @@ html[dir="rtl"] .select2-search-choice-close { outline: none; } .task-column:not(.rewards) .color-better:not(.completed) .save-close > input + button:focus, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > input + button:focus, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-checklist-edit + li + > input + + button:focus, .task-column:not(.rewards) .color-better:not(.completed) .save-close textarea + button:focus, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li textarea + button:focus { +.task-column:not(.rewards) + .color-better:not(.completed) + .task-checklist-edit + li + textarea + + button:focus { border-color: #6d9fa9 !important; background-color: #b2ccd2 !important; outline: none; } .task-column:not(.rewards) .color-better:not(.completed) .save-close > input + button:active, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > input + button:active, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-checklist-edit + li + > input + + button:active, .task-column:not(.rewards) .color-better:not(.completed) .save-close textarea + button:active, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li textarea + button:active { +.task-column:not(.rewards) + .color-better:not(.completed) + .task-checklist-edit + li + textarea + + button:active { background-color: #98bbc2 !important; } .task-column:not(.rewards) .color-better:not(.completed) .save-close > a:nth-of-type(2), -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > a:nth-of-type(2) { +.task-column:not(.rewards) + .color-better:not(.completed) + .task-checklist-edit + li + > a:nth-of-type(2) { border-left: 1px solid #b8d0d5 !important; } @media screen and (min-width: 768px) { @@ -4760,9 +6051,19 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #98bbc2; } .task-column:not(.rewards) .color-better:not(.completed) .save-close > div > ul:first-child:before, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > div > ul:first-child:before, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-checklist-edit + li + > div + > ul:first-child:before, .task-column:not(.rewards) .color-better:not(.completed) .save-close > div > div:first-child:before, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > div > div:first-child:before { +.task-column:not(.rewards) + .color-better:not(.completed) + .task-checklist-edit + li + > div + > div:first-child:before { background-color: #fff; border-color: #98bbc2; } @@ -4799,17 +6100,39 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #4d7982 !important; } .task-column:not(.rewards) .color-better:not(.completed) .save-close.active.filters-tags a, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li.active.filters-tags a, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-checklist-edit + li.active.filters-tags + a, .task-column:not(.rewards) .color-better:not(.completed) .save-close.active.filters-tags button, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li.active.filters-tags button { +.task-column:not(.rewards) + .color-better:not(.completed) + .task-checklist-edit + li.active.filters-tags + button { background-color: #6d9fa9 !important; border-color: #c2d7db !important; color: #fff !important; } .task-column:not(.rewards) .color-better:not(.completed) .save-close.active.filters-tags a span, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li.active.filters-tags a span, -.task-column:not(.rewards) .color-better:not(.completed) .save-close.active.filters-tags button span, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li.active.filters-tags button span { +.task-column:not(.rewards) + .color-better:not(.completed) + .task-checklist-edit + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-better:not(.completed) + .save-close.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-better:not(.completed) + .task-checklist-edit + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-better:not(.completed) .save-close button:focus, @@ -4833,7 +6156,10 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-better:not(.completed) .task-actions a:focus { background-color: #6398a2; } -.task-column:not(.rewards) .color-better:not(.completed) input[type=checkbox].task-input:focus + label, +.task-column:not(.rewards) + .color-better:not(.completed) + input[type='checkbox'].task-input:focus + + label, .task-column:not(.rewards) .color-better:not(.completed) input.habit:focus + a { background-color: #6398a2; } @@ -4932,26 +6258,56 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #144398 !important; outline: none; } -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > input + button:focus, +.task-column:not(.rewards) + .color-best:not(.completed) + .priority-multiplier + li + > input + + button:focus, .task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > input + button:focus, .task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > input + button:focus, .task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > input + button:focus, -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li textarea + button:focus, +.task-column:not(.rewards) + .color-best:not(.completed) + .priority-multiplier + li + textarea + + button:focus, .task-column:not(.rewards) .color-best:not(.completed) .task-attributes li textarea + button:focus, .task-column:not(.rewards) .color-best:not(.completed) .repeat-days li textarea + button:focus, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li textarea + button:focus { +.task-column:not(.rewards) + .color-best:not(.completed) + .repeat-frequency + li + textarea + + button:focus { border-color: #4781e7 !important; background-color: #b0c9f5 !important; outline: none; } -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > input + button:active, +.task-column:not(.rewards) + .color-best:not(.completed) + .priority-multiplier + li + > input + + button:active, .task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > input + button:active, .task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > input + button:active, .task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > input + button:active, -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li textarea + button:active, +.task-column:not(.rewards) + .color-best:not(.completed) + .priority-multiplier + li + textarea + + button:active, .task-column:not(.rewards) .color-best:not(.completed) .task-attributes li textarea + button:active, .task-column:not(.rewards) .color-best:not(.completed) .repeat-days li textarea + button:active, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li textarea + button:active { +.task-column:not(.rewards) + .color-best:not(.completed) + .repeat-frequency + li + textarea + + button:active { background-color: #89aef0 !important; } .task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > a:nth-of-type(2), @@ -4976,14 +6332,54 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #c9daf8; border-color: #89aef0; } -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > div > ul:first-child:before, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > div > ul:first-child:before, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > div > ul:first-child:before, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > div > ul:first-child:before, -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > div > div:first-child:before, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > div > div:first-child:before, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > div > div:first-child:before, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > div > div:first-child:before { +.task-column:not(.rewards) + .color-best:not(.completed) + .priority-multiplier + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-best:not(.completed) + .task-attributes + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-best:not(.completed) + .repeat-days + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-best:not(.completed) + .repeat-frequency + li + > div + > ul:first-child:before, +.task-column:not(.rewards) + .color-best:not(.completed) + .priority-multiplier + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-best:not(.completed) + .task-attributes + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-best:not(.completed) + .repeat-days + li + > div + > div:first-child:before, +.task-column:not(.rewards) + .color-best:not(.completed) + .repeat-frequency + li + > div + > div:first-child:before { background-color: #fff; border-color: #89aef0; } @@ -5037,26 +6433,77 @@ html[dir="rtl"] .select2-search-choice-close { background-color: #bad0f6 !important; border-color: #1a58c7 !important; } -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li.active.filters-tags a, +.task-column:not(.rewards) + .color-best:not(.completed) + .priority-multiplier + li.active.filters-tags + a, .task-column:not(.rewards) .color-best:not(.completed) .task-attributes li.active.filters-tags a, .task-column:not(.rewards) .color-best:not(.completed) .repeat-days li.active.filters-tags a, .task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li.active.filters-tags a, -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li.active.filters-tags button, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li.active.filters-tags button, +.task-column:not(.rewards) + .color-best:not(.completed) + .priority-multiplier + li.active.filters-tags + button, +.task-column:not(.rewards) + .color-best:not(.completed) + .task-attributes + li.active.filters-tags + button, .task-column:not(.rewards) .color-best:not(.completed) .repeat-days li.active.filters-tags button, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li.active.filters-tags button { +.task-column:not(.rewards) + .color-best:not(.completed) + .repeat-frequency + li.active.filters-tags + button { background-color: #4781e7 !important; border-color: #c9daf8 !important; color: #fff !important; } -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li.active.filters-tags a span, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li.active.filters-tags a span, +.task-column:not(.rewards) + .color-best:not(.completed) + .priority-multiplier + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-best:not(.completed) + .task-attributes + li.active.filters-tags + a + span, .task-column:not(.rewards) .color-best:not(.completed) .repeat-days li.active.filters-tags a span, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li.active.filters-tags a span, -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li.active.filters-tags button span, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li.active.filters-tags button span, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li.active.filters-tags button span, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li.active.filters-tags button span { +.task-column:not(.rewards) + .color-best:not(.completed) + .repeat-frequency + li.active.filters-tags + a + span, +.task-column:not(.rewards) + .color-best:not(.completed) + .priority-multiplier + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-best:not(.completed) + .task-attributes + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-best:not(.completed) + .repeat-days + li.active.filters-tags + button + span, +.task-column:not(.rewards) + .color-best:not(.completed) + .repeat-frequency + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li button.active, @@ -5077,7 +6524,12 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-best:not(.completed) .plusminus .task-checker label:after { border: 1px solid #5288e9 !important; } -.task-column:not(.rewards) .color-best:not(.completed) .plusminus .task-checker input[type=checkbox]:checked + label:after { +.task-column:not(.rewards) + .color-best:not(.completed) + .plusminus + .task-checker + input[type='checkbox']:checked + + label:after { -webkit-box-shadow: inset 0 0 0 1px #2a6de3 !important; box-shadow: inset 0 0 0 1px #2a6de3 !important; background-color: #b5ccf5 !important; @@ -5127,17 +6579,37 @@ html[dir="rtl"] .select2-search-choice-close { outline: none; } .task-column:not(.rewards) .color-best:not(.completed) .save-close > input + button:focus, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > input + button:focus, +.task-column:not(.rewards) + .color-best:not(.completed) + .task-checklist-edit + li + > input + + button:focus, .task-column:not(.rewards) .color-best:not(.completed) .save-close textarea + button:focus, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li textarea + button:focus { +.task-column:not(.rewards) + .color-best:not(.completed) + .task-checklist-edit + li + textarea + + button:focus { border-color: #3a77e4 !important; background-color: #9ebcf2 !important; outline: none; } .task-column:not(.rewards) .color-best:not(.completed) .save-close > input + button:active, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > input + button:active, +.task-column:not(.rewards) + .color-best:not(.completed) + .task-checklist-edit + li + > input + + button:active, .task-column:not(.rewards) .color-best:not(.completed) .save-close textarea + button:active, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li textarea + button:active { +.task-column:not(.rewards) + .color-best:not(.completed) + .task-checklist-edit + li + textarea + + button:active { background-color: #78a2ed !important; } .task-column:not(.rewards) .color-best:not(.completed) .save-close > a:nth-of-type(2), @@ -5157,9 +6629,19 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #78a2ed; } .task-column:not(.rewards) .color-best:not(.completed) .save-close > div > ul:first-child:before, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > div > ul:first-child:before, +.task-column:not(.rewards) + .color-best:not(.completed) + .task-checklist-edit + li + > div + > ul:first-child:before, .task-column:not(.rewards) .color-best:not(.completed) .save-close > div > div:first-child:before, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > div > div:first-child:before { +.task-column:not(.rewards) + .color-best:not(.completed) + .task-checklist-edit + li + > div + > div:first-child:before { background-color: #fff; border-color: #78a2ed; } @@ -5196,17 +6678,35 @@ html[dir="rtl"] .select2-search-choice-close { border-color: #1954bc !important; } .task-column:not(.rewards) .color-best:not(.completed) .save-close.active.filters-tags a, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li.active.filters-tags a, +.task-column:not(.rewards) + .color-best:not(.completed) + .task-checklist-edit + li.active.filters-tags + a, .task-column:not(.rewards) .color-best:not(.completed) .save-close.active.filters-tags button, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li.active.filters-tags button { +.task-column:not(.rewards) + .color-best:not(.completed) + .task-checklist-edit + li.active.filters-tags + button { background-color: #3a77e4 !important; border-color: #b5ccf5 !important; color: #fff !important; } .task-column:not(.rewards) .color-best:not(.completed) .save-close.active.filters-tags a span, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li.active.filters-tags a span, +.task-column:not(.rewards) + .color-best:not(.completed) + .task-checklist-edit + li.active.filters-tags + a + span, .task-column:not(.rewards) .color-best:not(.completed) .save-close.active.filters-tags button span, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li.active.filters-tags button span { +.task-column:not(.rewards) + .color-best:not(.completed) + .task-checklist-edit + li.active.filters-tags + button + span { color: #fff !important; } .task-column:not(.rewards) .color-best:not(.completed) .save-close button:focus, @@ -5230,7 +6730,10 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:not(.rewards) .color-best:not(.completed) .task-actions a:focus { background-color: #2a6de3; } -.task-column:not(.rewards) .color-best:not(.completed) input[type=checkbox].task-input:focus + label, +.task-column:not(.rewards) + .color-best:not(.completed) + input[type='checkbox'].task-input:focus + + label, .task-column:not(.rewards) .color-best:not(.completed) input.habit:focus + a { background-color: #2a6de3; } @@ -5260,7 +6763,7 @@ html[dir="rtl"] .select2-search-choice-close { } .completed .task-text .habitica-emoji { opacity: 0.39; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=39)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=39)'; filter: alpha(opacity=39); } .completed .priority-multiplier li > a, @@ -5480,7 +6983,7 @@ html[dir="rtl"] .select2-search-choice-close { .completed .plusminus .task-checker label:after { border: 1px solid #989898 !important; } -.completed .plusminus .task-checker input[type=checkbox]:checked + label:after { +.completed .plusminus .task-checker input[type='checkbox']:checked + label:after { -webkit-box-shadow: inset 0 0 0 1px #828282 !important; box-shadow: inset 0 0 0 1px #828282 !important; background-color: #cecece !important; @@ -5627,7 +7130,7 @@ html[dir="rtl"] .select2-search-choice-close { .completed .task-action-btn:focus { background-color: #828282; } -.completed input[type=checkbox]:focus + label { +.completed input[type='checkbox']:focus + label { background-color: #828282; } .completed .task-options { @@ -5668,7 +7171,7 @@ html[dir="rtl"] .select2-search-choice-close { .task-column:after { clear: both; display: block; - content: ""; + content: ''; } .task-column h2 { color: #4c666e; @@ -5840,8 +7343,8 @@ html[dir="rtl"] .select2-search-choice-close { .task label { font-weight: 400; } -.task input[type="text"], -.task input[type="number"], +.task input[type='text'], +.task input[type='number'], .task textarea.option-content { border: 1px solid #aaa; -webkit-border-radius: 0.382em; @@ -5865,14 +7368,18 @@ html[dir="rtl"] .select2-search-choice-close { margin-left: 20px; } .task.ui-sortable-helper { - -webkit-box-shadow: 0 0 3px rgba(0,0,0,0.15), 0 0 5px rgba(0,0,0,0.1); - box-shadow: 0 0 3px rgba(0,0,0,0.15), 0 0 5px rgba(0,0,0,0.1); + -webkit-box-shadow: + 0 0 3px rgba(0, 0, 0, 0.15), + 0 0 5px rgba(0, 0, 0, 0.1); + box-shadow: + 0 0 3px rgba(0, 0, 0, 0.15), + 0 0 5px rgba(0, 0, 0, 0.1); -webkit-transform: scale(1.05); -moz-transform: scale(1.05); -o-transform: scale(1.05); -ms-transform: scale(1.05); transform: scale(1.05); - outline: 1px solid rgba(0,0,0,0.2); + outline: 1px solid rgba(0, 0, 0, 0.2); } .task-controls { display: inline-block; @@ -5898,7 +7405,7 @@ html[dir="rtl"] .select2-search-choice-close { text-align: center; color: #222; vertical-align: top; - border-right: 1px solid rgba(0,0,0,0.25); + border-right: 1px solid rgba(0, 0, 0, 0.25); } .task-action-btn:last-child { border: 0; @@ -5908,14 +7415,14 @@ html[dir="rtl"] .select2-search-choice-close { color: #222; text-decoration: none; } -.task-checker input[type=checkbox], -.task-checker input[type=checkbox]:focus { +.task-checker input[type='checkbox'], +.task-checker input[type='checkbox']:focus { position: absolute; margin: 0; padding: 0; height: 10px; opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)'; filter: alpha(opacity=0); width: 10px; } @@ -5941,10 +7448,10 @@ html[dir="rtl"] .select2-search-choice-close { width: 2em; line-height: 1.5; } -.plusminus .task-checker label[for$="plus"]:after { +.plusminus .task-checker label[for$='plus']:after { content: '+'; } -.plusminus .task-checker label[for$="minus"]:after { +.plusminus .task-checker label[for$='minus']:after { content: '−'; } .action-yesno { @@ -5961,7 +7468,7 @@ html[dir="rtl"] .select2-search-choice-close { text-align: center; color: #000; opacity: 0.2; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=20)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=20)'; filter: alpha(opacity=20); } .action-yesno label:after { @@ -5978,15 +7485,15 @@ html[dir="rtl"] .select2-search-choice-close { .action-yesno label:focus:before { content: ''; } -.action-yesno input[type=checkbox]:focus + label { +.action-yesno input[type='checkbox']:focus + label { opacity: 1 !important; -ms-filter: none; filter: none; border: none; } .action-yesno label:hover:after { - content: "\E013"; - font-family: "Glyphicons Halflings"; + content: '\E013'; + font-family: 'Glyphicons Halflings'; border: none; margin: 0; line-height: 1.714285714em; @@ -5994,17 +7501,17 @@ html[dir="rtl"] .select2-search-choice-close { width: 1.714285714em; text-align: center; opacity: 0.5 !important; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=50)" !important; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=50)' !important; filter: alpha(opacity=50) !important; } .action-yesno label:active:after { opacity: 0.75 !important; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=75)" !important; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=75)' !important; filter: alpha(opacity=75) !important; } -.action-yesno input[type=checkbox]:checked + label:after { - content: "\E013"; - font-family: "Glyphicons Halflings"; +.action-yesno input[type='checkbox']:checked + label:after { + content: '\E013'; + font-family: 'Glyphicons Halflings'; border: none; margin: 0; line-height: 1.714285714em; @@ -6012,7 +7519,7 @@ html[dir="rtl"] .select2-search-choice-close { width: 1.714285714em; text-align: center; opacity: 0.75; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=75)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=75)'; filter: alpha(opacity=75); } .task-meta-controls { @@ -6020,7 +7527,7 @@ html[dir="rtl"] .select2-search-choice-close { margin: 0.75em 0.5em 0 0.5em; height: 1em; opacity: 0.25; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=25)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=25)'; filter: alpha(opacity=25); } .task-meta-controls a { @@ -6064,17 +7571,17 @@ form { color: #333; position: relative; }*/ -[class$="-options"] .option-group { +[class$='-options'] .option-group { padding: 0 0 1em; margin-bottom: 1em; margin-top: 1em; } -[class$="-options"] button.advanced-options-toggle { +[class$='-options'] button.advanced-options-toggle { display: block; width: 100%; background: none; } -[class$="-options"] .option-title { +[class$='-options'] .option-title { font-size: 1em; margin: 0.5em 0 0.5em; line-height: 1; @@ -6083,17 +7590,17 @@ form { font-weight: bold; text-align: center; } -[class$="-options"] .option-title.mega { +[class$='-options'] .option-title.mega { cursor: pointer; } -[class$="-options"] .option-title.mega:after { - font-family: "Glyphicons Halflings"; +[class$='-options'] .option-title.mega:after { + font-family: 'Glyphicons Halflings'; font-size: 0.75em; - content: "\E114"; + content: '\E114'; padding-left: 0.75em; } -[class$="-options"] .option-title.mega.active:after { - content: "\E113"; +[class$='-options'] .option-title.mega.active:after { + content: '\E113'; } .option-content { height: 2.5em; @@ -6135,12 +7642,12 @@ textarea.option-content { border: 0; font-size: 1.15em; font-weight: 300; - outline: 1px solid rgba(0,0,0,0.2); + outline: 1px solid rgba(0, 0, 0, 0.2); outline-offset: -1px; margin: 0 0 0 3px; text-align: inherit; opacity: 0.5; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=50)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=50)'; filter: alpha(opacity=50); width: auto; padding: 0 0.5em; @@ -6171,7 +7678,7 @@ textarea.option-content { } .tile.flush { margin-left: 0; - border: 1px solid rgba(0,0,0,0.2); + border: 1px solid rgba(0, 0, 0, 0.2); outline: 0; line-height: 2em; } @@ -6266,7 +7773,7 @@ textarea.option-content { } .task-checklist-edit > .checklist-form input { width: 70%; -/* Add interaction cues on hover and focus */ + /* Add interaction cues on hover and focus */ } .task-checklist-edit > .checklist-form input:hover, .task-checklist-edit > .checklist-form input:focus { @@ -6279,7 +7786,7 @@ textarea.option-content { } .task-checklist-edit > .checklist-form .checklist-icon { opacity: 0.25; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=25)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=25)'; filter: alpha(opacity=25); text-align: center; line-height: 1.5; @@ -6434,7 +7941,7 @@ textarea.option-content { -webkit-box-shadow: none; box-shadow: none; opacity: 0.65; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=65)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=65)'; filter: alpha(opacity=65); } .rewards .btn-buy span { @@ -6463,7 +7970,7 @@ textarea.option-content { margin: 15px auto; border: 1px solid #222; opacity: 0.2 !important; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=20)" !important; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=20)' !important; filter: alpha(opacity=20) !important; } .locked-task .action-yesno label:focus, @@ -6533,7 +8040,7 @@ textarea.option-content { top: 4px; left: 4px; opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)'; filter: alpha(opacity=0); -webkit-transition: opacity 0.2s ease-out; -moz-transition: opacity 0.2s ease-out; @@ -6589,7 +8096,7 @@ textarea.option-content { left: 4px; z-index: 2; opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)'; filter: alpha(opacity=0); } .herobox:hover .addthis_native_toolbox { @@ -6716,7 +8223,7 @@ menu { } .btn-buy input:focus { opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)'; filter: alpha(opacity=0); } input:focus + a.btn-buy { @@ -6730,7 +8237,7 @@ input:focus + a.btn-buy { .rewards { margin-bottom: 1.5em; padding-bottom: 1.5em; - border-bottom: 1px solid rgba(0,0,0,0.1); + border-bottom: 1px solid rgba(0, 0, 0, 0.1); } .reward-item { background: #fff; @@ -6755,8 +8262,8 @@ input:focus + a.btn-buy { padding-left: 0.25em; background-color: #d0e0e3; cursor: pointer; - -webkit-box-shadow: inset -1px -1px 0 rgba(0,0,0,0.1); - box-shadow: inset -1px -1px 0 rgba(0,0,0,0.1); + -webkit-box-shadow: inset -1px -1px 0 rgba(0, 0, 0, 0.1); + box-shadow: inset -1px -1px 0 rgba(0, 0, 0, 0.1); } .btn-reroll:hover, .btn-reroll:focus { @@ -6781,7 +8288,7 @@ input:focus + a.btn-buy { } .gem-wallet .add-gems-btn { opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)'; filter: alpha(opacity=0); } .gem-wallet:hover .add-gems-btn, @@ -6890,9 +8397,9 @@ menu.pets .customize-menu .progress { .mount-not-owned { width: 81px; height: 99px; -/* Would use css3 filters and just display the original pet image with a black hue, + /* Would use css3 filters and just display the original pet image with a black hue, but doesn't seem to work in Firefox or Opera */ -/*filter: brightness(0%) + /*filter: brightness(0%) -webkit-filter: brightness(0%) -moz-filter: brightness(0%) -o-filter: brightness(0%) @@ -6904,7 +8411,7 @@ menu.pets .customize-menu .progress { } .pet-evolved { opacity: 0.1; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=10)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=10)'; filter: alpha(opacity=10); } .selectableInventory { @@ -6925,7 +8432,7 @@ menu.pets .customize-menu .progress { height: 0; z-index: 1010; } -.new-stuff> .alert { +.new-stuff > .alert { border-top: 0; -webkit-border-radius: 0 0 4px 4px; border-radius: 0 0 4px 4px; @@ -7034,7 +8541,7 @@ menu.pets .customize-menu .progress { } .transparent { opacity: 0.5; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=50)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=50)'; filter: alpha(opacity=50); } .col-centered { @@ -7075,7 +8582,7 @@ menu.pets .customize-menu .progress { z-index: 100; padding: 46px 0 0 0; background-color: #c5d3d6; - border-bottom: 1px solid rgba(0,0,0,0.2); + border-bottom: 1px solid rgba(0, 0, 0, 0.2); overflow-y: hidden; overflow-x: auto; } @@ -7104,7 +8611,7 @@ menu.pets .customize-menu .progress { cursor: pointer; font-weight: 400; color: #494949; - color: rgba(38,38,38,0.8); + color: rgba(38, 38, 38, 0.8); background-color: #c2d7db; } .user-menu .tile:hover, @@ -7152,7 +8659,7 @@ menu.pets .customize-menu .progress { } .stacked .tile { outline: 0; - border: 1px solid rgba(0,0,0,0.2); + border: 1px solid rgba(0, 0, 0, 0.2); border-top: 0; } .site-header { @@ -7182,7 +8689,11 @@ menu.pets .customize-menu .progress { .hero-stats .meter-label { float: left; background-color: #b0c3c7 !important; - text-shadow: -1px -1px 1px #2f3f42, 1px -1px 1px #2f3f42, -1px 1px 1px #2f3f42, 1px 1px 1px #2f3f42; + text-shadow: + -1px -1px 1px #2f3f42, + 1px -1px 1px #2f3f42, + -1px 1px 1px #2f3f42, + 1px 1px 1px #2f3f42; width: 2.618em; text-align: center; margin-right: 0.618em; @@ -7243,7 +8754,7 @@ menu.pets .customize-menu .progress { .hero-stats .meter-text.value { right: 0.382em; } -[class^="quest_"] + .hero-stats { +[class^='quest_'] + .hero-stats { min-width: 220px; padding: 1.618em 0 1em; } @@ -7706,25 +9217,33 @@ button.party-invite { border-width: 4px 0 0; } .task-column::-webkit-scrollbar-track:hover { - background-color: rgba(150,150,150,0.05); - -webkit-box-shadow: inset 1px 0 0 rgba(150,150,150,0.1); - box-shadow: inset 1px 0 0 rgba(150,150,150,0.1); + background-color: rgba(150, 150, 150, 0.05); + -webkit-box-shadow: inset 1px 0 0 rgba(150, 150, 150, 0.1); + box-shadow: inset 1px 0 0 rgba(150, 150, 150, 0.1); } .task-column::-webkit-scrollbar-track:horizontal:hover { - -webkit-box-shadow: inset 0 1px 0 rgba(150,150,150,0.1); - box-shadow: inset 0 1px 0 rgba(150,150,150,0.1); + -webkit-box-shadow: inset 0 1px 0 rgba(150, 150, 150, 0.1); + box-shadow: inset 0 1px 0 rgba(150, 150, 150, 0.1); } .task-column::-webkit-scrollbar-track:active { - background-color: rgba(150,150,150,0.05); - -webkit-box-shadow: inset 1px 0 0 rgba(150,150,150,0.14), inset -1px 0 0 rgba(150,150,150,0.07); - box-shadow: inset 1px 0 0 rgba(150,150,150,0.14), inset -1px 0 0 rgba(150,150,150,0.07); + background-color: rgba(150, 150, 150, 0.05); + -webkit-box-shadow: + inset 1px 0 0 rgba(150, 150, 150, 0.14), + inset -1px 0 0 rgba(150, 150, 150, 0.07); + box-shadow: + inset 1px 0 0 rgba(150, 150, 150, 0.14), + inset -1px 0 0 rgba(150, 150, 150, 0.07); } .task-column::-webkit-scrollbar-track:horizontal:active { - -webkit-box-shadow: inset 0 1px 0 rgba(150,150,150,0.14), inset 0 -1px 0 rgba(150,150,150,0.07); - box-shadow: inset 0 1px 0 rgba(150,150,150,0.14), inset 0 -1px 0 rgba(150,150,150,0.07); + -webkit-box-shadow: + inset 0 1px 0 rgba(150, 150, 150, 0.14), + inset 0 -1px 0 rgba(150, 150, 150, 0.07); + box-shadow: + inset 0 1px 0 rgba(150, 150, 150, 0.14), + inset 0 -1px 0 rgba(150, 150, 150, 0.07); } .task-column::-webkit-scrollbar-thumb { - background-color: rgba(150,150,150,0.2); + background-color: rgba(150, 150, 150, 0.2); -webkit-background-clip: padding; -moz-background-clip: padding; background-clip: padding-box; @@ -7732,24 +9251,32 @@ button.party-invite { border-width: 1px 1px 1px 6px; min-height: 28px; padding: 100px 0 0; - -webkit-box-shadow: inset 1px 1px 0 rgba(150,150,150,0.1), inset 0 -1px 0 rgba(150,150,150,0.07); - box-shadow: inset 1px 1px 0 rgba(150,150,150,0.1), inset 0 -1px 0 rgba(150,150,150,0.07); + -webkit-box-shadow: + inset 1px 1px 0 rgba(150, 150, 150, 0.1), + inset 0 -1px 0 rgba(150, 150, 150, 0.07); + box-shadow: + inset 1px 1px 0 rgba(150, 150, 150, 0.1), + inset 0 -1px 0 rgba(150, 150, 150, 0.07); } .task-column::-webkit-scrollbar-thumb:horizontal { border-width: 6px 1px 1px; padding: 0 0 0 100px; - -webkit-box-shadow: inset 1px 1px 0 rgba(150,150,150,0.1), inset -1px 0 0 rgba(150,150,150,0.07); - box-shadow: inset 1px 1px 0 rgba(150,150,150,0.1), inset -1px 0 0 rgba(150,150,150,0.07); + -webkit-box-shadow: + inset 1px 1px 0 rgba(150, 150, 150, 0.1), + inset -1px 0 0 rgba(150, 150, 150, 0.07); + box-shadow: + inset 1px 1px 0 rgba(150, 150, 150, 0.1), + inset -1px 0 0 rgba(150, 150, 150, 0.07); } .task-column::-webkit-scrollbar-thumb:hover { - background-color: rgba(150,150,150,0.4); - -webkit-box-shadow: inset 1px 1px 1px rgba(150,150,150,0.25); - box-shadow: inset 1px 1px 1px rgba(150,150,150,0.25); + background-color: rgba(150, 150, 150, 0.4); + -webkit-box-shadow: inset 1px 1px 1px rgba(150, 150, 150, 0.25); + box-shadow: inset 1px 1px 1px rgba(150, 150, 150, 0.25); } .task-column::-webkit-scrollbar-thumb:active { - background-color: rgba(150,150,150,0.5); - -webkit-box-shadow: inset 1px 1px 3px rgba(150,150,150,0.35); - box-shadow: inset 1px 1px 3px rgba(150,150,150,0.35); + background-color: rgba(150, 150, 150, 0.5); + -webkit-box-shadow: inset 1px 1px 3px rgba(150, 150, 150, 0.35); + box-shadow: inset 1px 1px 3px rgba(150, 150, 150, 0.35); } .task-column::-webkit-scrollbar-track { border-width: 0 1px 0 6px; @@ -7758,9 +9285,13 @@ button.party-invite { border-width: 6px 0 1px; } .task-column::-webkit-scrollbar-track:hover { - background-color: rgba(150,150,150,0.035); - -webkit-box-shadow: inset 1px 1px 0 rgba(150,150,150,0.14), inset -1px -1px 0 rgba(150,150,150,0.07); - box-shadow: inset 1px 1px 0 rgba(150,150,150,0.14), inset -1px -1px 0 rgba(150,150,150,0.07); + background-color: rgba(150, 150, 150, 0.035); + -webkit-box-shadow: + inset 1px 1px 0 rgba(150, 150, 150, 0.14), + inset -1px -1px 0 rgba(150, 150, 150, 0.07); + box-shadow: + inset 1px 1px 0 rgba(150, 150, 150, 0.14), + inset -1px -1px 0 rgba(150, 150, 150, 0.07); } .task-column::-webkit-scrollbar-thumb { border-width: 0 1px 0 6px; @@ -7817,7 +9348,7 @@ button.party-invite { } .chat-message .chat-plus-one { opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)'; filter: alpha(opacity=0); background-color: #eee; padding: 3px 3px 0px 3px; @@ -7922,8 +9453,8 @@ button.party-invite { .party-chat markdown blockquote p:first-child { display: block; } -.tavern-chat markdown blockquote>:last-child, -.party-chat markdown blockquote>:last-child { +.tavern-chat markdown blockquote > :last-child, +.party-chat markdown blockquote > :last-child { margin-bottom: 0; } .panel-tiers div { @@ -7932,43 +9463,83 @@ button.party-invite { } .label-contributor-1 { background-color: #f57a9d !important; - text-shadow: -1px -1px 1px #660823, 1px -1px 1px #660823, -1px 1px 1px #660823, 1px 1px 1px #660823; + text-shadow: + -1px -1px 1px #660823, + 1px -1px 1px #660823, + -1px 1px 1px #660823, + 1px 1px 1px #660823; } .label-contributor-2 { background-color: #b93030 !important; - text-shadow: -1px -1px 1px #380e0e, 1px -1px 1px #380e0e, -1px 1px 1px #380e0e, 1px 1px 1px #380e0e; + text-shadow: + -1px -1px 1px #380e0e, + 1px -1px 1px #380e0e, + -1px 1px 1px #380e0e, + 1px 1px 1px #380e0e; } .label-contributor-3 { background-color: #f30 !important; - text-shadow: -1px -1px 1px #4d0f00, 1px -1px 1px #4d0f00, -1px 1px 1px #4d0f00, 1px 1px 1px #4d0f00; + text-shadow: + -1px -1px 1px #4d0f00, + 1px -1px 1px #4d0f00, + -1px 1px 1px #4d0f00, + 1px 1px 1px #4d0f00; } .label-contributor-4 { background-color: #ff9500 !important; - text-shadow: -1px -1px 1px #4d2d00, 1px -1px 1px #4d2d00, -1px 1px 1px #4d2d00, 1px 1px 1px #4d2d00; + text-shadow: + -1px -1px 1px #4d2d00, + 1px -1px 1px #4d2d00, + -1px 1px 1px #4d2d00, + 1px 1px 1px #4d2d00; } .label-contributor-5 { background-color: #fff700 !important; - text-shadow: -1px -1px 1px #4d4a00, 1px -1px 1px #4d4a00, -1px 1px 1px #4d4a00, 1px 1px 1px #4d4a00; + text-shadow: + -1px -1px 1px #4d4a00, + 1px -1px 1px #4d4a00, + -1px 1px 1px #4d4a00, + 1px 1px 1px #4d4a00; } .label-contributor-6 { background-color: #5eff00 !important; - text-shadow: -1px -1px 1px #1c4d00, 1px -1px 1px #1c4d00, -1px 1px 1px #1c4d00, 1px 1px 1px #1c4d00; + text-shadow: + -1px -1px 1px #1c4d00, + 1px -1px 1px #1c4d00, + -1px 1px 1px #1c4d00, + 1px 1px 1px #1c4d00; } .label-contributor-7 { background-color: #0af !important; - text-shadow: -1px -1px 1px #00334d, 1px -1px 1px #00334d, -1px 1px 1px #00334d, 1px 1px 1px #00334d; + text-shadow: + -1px -1px 1px #00334d, + 1px -1px 1px #00334d, + -1px 1px 1px #00334d, + 1px 1px 1px #00334d; } .label-contributor-8 { background-color: #130ead !important; - text-shadow: -1px -1px 1px #060434, 1px -1px 1px #060434, -1px 1px 1px #060434, 1px 1px 1px #060434; + text-shadow: + -1px -1px 1px #060434, + 1px -1px 1px #060434, + -1px 1px 1px #060434, + 1px 1px 1px #060434; } .label-contributor-9 { background-color: #88108f !important; - text-shadow: -1px -1px 1px #29052b, 1px -1px 1px #29052b, -1px 1px 1px #29052b, 1px 1px 1px #29052b; + text-shadow: + -1px -1px 1px #29052b, + 1px -1px 1px #29052b, + -1px 1px 1px #29052b, + 1px 1px 1px #29052b; } .label-npc { background-color: #000 !important; - text-shadow: -1px -1px 1px #000, 1px -1px 1px #000, -1px 1px 1px #000, 1px 1px 1px #000; + text-shadow: + -1px -1px 1px #000, + 1px -1px 1px #000, + -1px 1px 1px #000, + 1px 1px 1px #000; color: #0f0 !important; } #market-tab { @@ -8951,7 +10522,7 @@ li.spaced { } .toolbar-notifs > a span.inactive { opacity: 0.236 !important; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=24)" !important; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=24)' !important; filter: alpha(opacity=24) !important; } .toolbar-notifs div { @@ -9540,14 +11111,16 @@ noscript.banner { animation-delay: -0.2s; } @-moz-keyframes sk-bouncedelay { - 0%, 80%, 100% { + 0%, + 80%, + 100% { -webkit-transform: scale(0); -moz-transform: scale(0); -o-transform: scale(0); -ms-transform: scale(0); transform: scale(0); opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)'; filter: alpha(opacity=0); } 40% { @@ -9562,14 +11135,16 @@ noscript.banner { } } @-webkit-keyframes sk-bouncedelay { - 0%, 80%, 100% { + 0%, + 80%, + 100% { -webkit-transform: scale(0); -moz-transform: scale(0); -o-transform: scale(0); -ms-transform: scale(0); transform: scale(0); opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)'; filter: alpha(opacity=0); } 40% { @@ -9584,14 +11159,16 @@ noscript.banner { } } @-o-keyframes sk-bouncedelay { - 0%, 80%, 100% { + 0%, + 80%, + 100% { -webkit-transform: scale(0); -moz-transform: scale(0); -o-transform: scale(0); -ms-transform: scale(0); transform: scale(0); opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)'; filter: alpha(opacity=0); } 40% { @@ -9606,14 +11183,16 @@ noscript.banner { } } @keyframes sk-bouncedelay { - 0%, 80%, 100% { + 0%, + 80%, + 100% { -webkit-transform: scale(0); -moz-transform: scale(0); -o-transform: scale(0); -ms-transform: scale(0); transform: scale(0); opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=0)'; filter: alpha(opacity=0); } 40% { @@ -9646,12 +11225,12 @@ td { -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box; - font-family: "Lato", sans-serif; + font-family: 'Lato', sans-serif; } hr { border-top: 0; border-bottom: 1px solid #ddd; - border-color: rgba(0,0,0,0.1); + border-color: rgba(0, 0, 0, 0.1); } /* Customizations to make footer sticky */ html, @@ -9688,7 +11267,7 @@ body { position: relative; } .gem-cost::before { - content: ""; + content: ''; display: block; width: 0; height: 0; @@ -9701,7 +11280,7 @@ body { margin-top: -6px; } .gem-cost::after { - content: ""; + content: ''; display: block; width: 0; height: 0; @@ -9786,7 +11365,7 @@ a.label { .buy-gems .gem-wallet .task-action-btn { -webkit-border-radius: 0 4px 0 0; border-radius: 0 4px 0 0; - border: 1px solid rgba(0,0,0,0.2); + border: 1px solid rgba(0, 0, 0, 0.2); } .badge-info { background-color: #428bca; diff --git a/www/build/static.css b/www/build/static.css index 70f4de911..51f435fe8 100644 --- a/www/build/static.css +++ b/www/build/static.css @@ -54,7 +54,7 @@ right: 0; top: 0; height: 2px; - opacity: .45; + opacity: 0.45; -moz-box-shadow: #29d 1px 0 6px 1px; -ms-box-shadow: #29d 1px 0 6px 1px; -webkit-box-shadow: #29d 1px 0 6px 1px; @@ -76,37 +76,67 @@ width: 14px; height: 14px; - border: solid 2px transparent; - border-top-color: #29d; + border: solid 2px transparent; + border-top-color: #29d; border-left-color: #29d; border-radius: 10px; -webkit-animation: loading-bar-spinner 400ms linear infinite; - -moz-animation: loading-bar-spinner 400ms linear infinite; - -ms-animation: loading-bar-spinner 400ms linear infinite; - -o-animation: loading-bar-spinner 400ms linear infinite; - animation: loading-bar-spinner 400ms linear infinite; + -moz-animation: loading-bar-spinner 400ms linear infinite; + -ms-animation: loading-bar-spinner 400ms linear infinite; + -o-animation: loading-bar-spinner 400ms linear infinite; + animation: loading-bar-spinner 400ms linear infinite; } @-webkit-keyframes loading-bar-spinner { - 0% { -webkit-transform: rotate(0deg); transform: rotate(0deg); } - 100% { -webkit-transform: rotate(360deg); transform: rotate(360deg); } + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } } @-moz-keyframes loading-bar-spinner { - 0% { -moz-transform: rotate(0deg); transform: rotate(0deg); } - 100% { -moz-transform: rotate(360deg); transform: rotate(360deg); } + 0% { + -moz-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -moz-transform: rotate(360deg); + transform: rotate(360deg); + } } @-o-keyframes loading-bar-spinner { - 0% { -o-transform: rotate(0deg); transform: rotate(0deg); } - 100% { -o-transform: rotate(360deg); transform: rotate(360deg); } + 0% { + -o-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -o-transform: rotate(360deg); + transform: rotate(360deg); + } } @-ms-keyframes loading-bar-spinner { - 0% { -ms-transform: rotate(0deg); transform: rotate(0deg); } - 100% { -ms-transform: rotate(360deg); transform: rotate(360deg); } + 0% { + -ms-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -ms-transform: rotate(360deg); + transform: rotate(360deg); + } } @keyframes loading-bar-spinner { - 0% { transform: rotate(0deg); transform: rotate(0deg); } - 100% { transform: rotate(360deg); transform: rotate(360deg); } + 0% { + transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + transform: rotate(360deg); + } } .subscription-features tr td { @@ -256,7 +286,7 @@ body { .muted i, i.muted { opacity: 0.5; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=50)"; + -ms-filter: 'progid:DXImageTransform.Microsoft.Alpha(Opacity=50)'; filter: alpha(opacity=50); } #header-play-button { @@ -302,13 +332,13 @@ a.h2.accordion { a.h2.accordion:before { font-family: 'Glyphicons Halflings'; color: #808080; - content: "\e114"; + content: '\e114'; margin-right: 0.5em; } a.h2.accordion.collapsed:before { font-family: 'Glyphicons Halflings'; color: #808080; - content: "\e080"; + content: '\e080'; margin-right: 0.5em; } .merch-block { diff --git a/www/css/main.diary.css b/www/css/main.diary.css index 474d57256..4fad2e975 100644 --- a/www/css/main.diary.css +++ b/www/css/main.diary.css @@ -1,78 +1,72 @@ .no-margin { - margin:0px !important; - padding:0px !important; + margin: 0px !important; + padding: 0px !important; } .item { - border:0px !important; + border: 0px !important; border-color: #fff; padding: 0 10px; /* Changed from 16px. This change was to ensure the correct alignment of the diary card */ } .main { - padding-top:50px; + padding-top: 50px; min-height: 100%; } .row { - padding:0px; + padding: 0px; } .col { - padding:0px !important; + padding: 0px !important; } .small { - font-size:7px; + font-size: 7px; } -.bg-color{ - background:#71bc98!important; - color:whitesmoke !important; +.bg-color { + background: #71bc98 !important; + color: whitesmoke !important; } -.summary-color{ - background:#1b9e77!important; - color:whitesmoke !important; +.summary-color { + background: #1b9e77 !important; + color: whitesmoke !important; } -.place-color{ - background:#7570b3!important; - color:whitesmoke !important; +.place-color { + background: #7570b3 !important; + color: whitesmoke !important; } - /* leaflet */ /* ----------- iPhone 5 and 5S ----------- */ /* Portrait and Landscape */ -@media only screen - and (min-device-width: 320px) - and (max-device-width: 568px) - and (-webkit-min-device-pixel-ratio: 2) { - .angular-leaflet-map { width: 100%; } +@media only screen and (min-device-width: 320px) and (max-device-width: 568px) and (-webkit-min-device-pixel-ratio: 2) { + .angular-leaflet-map { + width: 100%; + } } /* ----------- iPhone 6 ----------- */ /* Portrait and Landscape */ -@media only screen - and (min-device-width: 375px) - and (max-device-width: 667px) - and (-webkit-min-device-pixel-ratio: 2) { - .angular-leaflet-map { width: 100%; } +@media only screen and (min-device-width: 375px) and (max-device-width: 667px) and (-webkit-min-device-pixel-ratio: 2) { + .angular-leaflet-map { + width: 100%; + } } /* ----------- iPhone 6+ ----------- */ /* Portrait and Landscape */ -@media only screen - and (min-device-width: 414px) - and (max-device-width: 736px) - and (-webkit-min-device-pixel-ratio: 3) { - .angular-leaflet-map { width: 100%; } +@media only screen and (min-device-width: 414px) and (max-device-width: 736px) and (-webkit-min-device-pixel-ratio: 3) { + .angular-leaflet-map { + width: 100%; + } } - .list .item.item-accordion { - transition: 0.09s all linear; } .list .item.item-accordion.ng-hide { @@ -110,16 +104,18 @@ a.item-content { font-size: 13px; line-height: 1; font-weight: 500; - box-shadow: 0 1px 2px rgb(0 0 0 / .1), 0 2px 3px rgb(0 0 0 / .12); + box-shadow: + 0 1px 2px rgb(0 0 0 / 0.1), + 0 2px 3px rgb(0 0 0 / 0.12); background-color: white; color: var(--accent-dark); - border: .12em solid var(--accent); + border: 0.12em solid var(--accent); } .diary-btn-yellow, .diary-btn-yellow:hover, .diary-btn-yellow:active { - background-color: #FFC108; /* tentatively orange for now */ + background-color: #ffc108; /* tentatively orange for now */ color: white; border: 2px solid rgba(0, 136, 206, 0.2); } @@ -135,7 +131,7 @@ a.item-content { .diary-btn:before { font-weight: bold; scale: 1.7; - margin-right: .6em; + margin-right: 0.6em; line-height: 100%; } @@ -188,19 +184,20 @@ a.item-content { font-size: 13px; line-height: 1.2; margin: 0; - border: 1px solid rgb(0 0 0 / .2); + border: 1px solid rgb(0 0 0 / 0.2); box-sizing: border-box; border-radius: 30px; position: relative; - box-shadow: 0 3px 4px rgb(0 0 0 / 5%), 0 4px 4px rgb(0 0 0 / 8%); + box-shadow: + 0 3px 4px rgb(0 0 0 / 5%), + 0 4px 4px rgb(0 0 0 / 8%); display: flex; flex-wrap: wrap; - background: linear-gradient(40deg, - hsla(200, 30%, 97%, 1) 40%, - hsla(0, 0%, 100%, 1)), + background: linear-gradient(40deg, hsla(200, 30%, 97%, 1) 40%, hsla(0, 0%, 100%, 1)); } -.diary-card.place, .diary-card.untracked { +.diary-card.place, +.diary-card.untracked { color: #222; background: hsl(200 100% 85%); border: 1px solid hsl(200 100% 10% / 0.2); @@ -209,23 +206,23 @@ a.item-content { } .diary-card.untracked { - color: #333; - /* untracked time will have a reddish color */ - --accent: hsl(350, 25%, 50%); - --accent-light: hsl(350, 65%, 85%); - --accent-dark: hsl(350, 65%, 30%); - - --grid: hsla(350, 25%, 80%, .2); - /* subtle x-grid lines in the background, fading to white */ - background: linear-gradient(15deg, - hsla(350, 10%, 92%, 1) 40%, - hsla(350, 10%, 100%, 0.5)), - repeating-linear-gradient(45deg, - var(--grid), var(--grid) 0px, - transparent 2px, transparent 20px), - repeating-linear-gradient(-45deg, - var(--grid), var(--grid) 0px, - #fff 2px, #fff 21px); + color: #333; + /* untracked time will have a reddish color */ + --accent: hsl(350, 25%, 50%); + --accent-light: hsl(350, 65%, 85%); + --accent-dark: hsl(350, 65%, 30%); + + --grid: hsla(350, 25%, 80%, 0.2); + /* subtle x-grid lines in the background, fading to white */ + background: linear-gradient(15deg, hsla(350, 10%, 92%, 1) 40%, hsla(350, 10%, 100%, 0.5)), + repeating-linear-gradient( + 45deg, + var(--grid), + var(--grid) 0px, + transparent 2px, + transparent 20px + ), + repeating-linear-gradient(-45deg, var(--grid), var(--grid) 0px, #fff 2px, #fff 21px); } .diary-card.untracked .card-title b { @@ -236,23 +233,24 @@ a.item-content { border-radius: 5px; } -.diary-card.draft, .diary-details.draft { +.diary-card.draft, +.diary-details.draft { /* draft trips will have a muted, greenish color */ --accent: hsl(150, 15%, 40%); --accent-light: hsl(150, 25%, 72%); --accent-dark: hsl(150, 35%, 30%); - --grid: hsla(150, 25%, 70%, .3); + --grid: hsla(150, 25%, 70%, 0.3); /* subtle grid lines in the background, fading to white */ - background: linear-gradient(30deg, - hsla(150, 4%, 94%, .9) 50%, - hsla(0, 0%, 100%, .5)), - repeating-linear-gradient(90deg, - var(--grid), var(--grid) 0px, - transparent 2px, transparent 20px), - repeating-linear-gradient(0deg, - var(--grid), var(--grid) 0px, - #fff 2px, #fff 21px); + background: linear-gradient(30deg, hsla(150, 4%, 94%, 0.9) 50%, hsla(0, 0%, 100%, 0.5)), + repeating-linear-gradient( + 90deg, + var(--grid), + var(--grid) 0px, + transparent 2px, + transparent 20px + ), + repeating-linear-gradient(0deg, var(--grid), var(--grid) 0px, #fff 2px, #fff 21px); } .card-title { @@ -304,7 +302,7 @@ a.item-content { @media screen and (orientation: portrait) { .hr-lines:before { - content: " "; + content: ' '; display: block; height: 2px; width: 30%; @@ -315,7 +313,7 @@ a.item-content { } .hr-lines:after { - content: " "; + content: ' '; display: block; height: 2px; width: 30%; @@ -327,7 +325,7 @@ a.item-content { } @media screen and (orientation: landscape) { .hr-lines:before { - content: " "; + content: ' '; display: block; height: 2px; width: 41%; @@ -338,7 +336,7 @@ a.item-content { } .hr-lines:after { - content: " "; + content: ' '; display: block; height: 2px; width: 41%; @@ -387,12 +385,13 @@ a.item-content { z-index: 0; } -.diary-map, .diary-map * { +.diary-map, +.diary-map * { pointer-events: none !important; } /* when trip notes are enabled, the map has rounded right corners */ -.enhanced-trip-item .diary-map > div{ +.enhanced-trip-item .diary-map > div { border-radius: 30px 0px 30px 0px; } @@ -479,11 +478,13 @@ a.item-content { background-color: var(--accent) !important; } -.ionic_datepicker_popup .popup-body .month_select, .ionic_datepicker_popup .popup-body .year_select { +.ionic_datepicker_popup .popup-body .month_select, +.ionic_datepicker_popup .popup-body .year_select { border-bottom: 1px solid var(--accent) !important; } -.ionic_datepicker_popup .popup-body .month_select:after, .ionic_datepicker_popup .popup-body .year_select:after { +.ionic_datepicker_popup .popup-body .month_select:after, +.ionic_datepicker_popup .popup-body .year_select:after { color: var(--accent) !important; } @@ -502,13 +503,15 @@ div.labelfilterlist { color: var(--accent); border-radius: 0px; border-width: 0; - box-shadow: 0 1px 2px rgba(0,0,0,0.16), 0 2px 2px rgba(0,0,0,0.23); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.16), + 0 2px 2px rgba(0, 0, 0, 0.23); padding: 0 0.1em !important; } .button.labelfilter.on { - background-color: var(--accent); - color: white; + background-color: var(--accent); + color: white; } .labelfilter:first-of-type { diff --git a/www/css/style.css b/www/css/style.css index dea003e7b..a2ac29368 100644 --- a/www/css/style.css +++ b/www/css/style.css @@ -6,8 +6,8 @@ .question.non-select { display: inline-block; } - .question input[name*="_date"], - .question input[name*="_time"] { + .question input[name*='_date'], + .question input[name*='_time'] { width: calc(40vw - 10px); margin-right: 5px; display: flex; @@ -15,7 +15,7 @@ } .enketo-plugin .form-header { - max-height: 50px; + max-height: 50px; } .fill-container { @@ -67,19 +67,21 @@ label-tab > div { text-align: center; } -[ng\:cloak], [ng-cloak], .ng-cloak { +[ng\:cloak], +[ng-cloak], +.ng-cloak { display: none !important; } .popup-title { - color: #6e6e6e; + color: #6e6e6e; } .pull-right { - float: right + float: right; } .pull-left { - float: left + float: left; } .button.button-icon.ion-help:before { @@ -87,14 +89,13 @@ label-tab > div { } .popup-buttons.row { - height: 40px !important; + height: 40px !important; } .popup-buttons.button { height: 40px !important; } .button.ng-binding.button-stable { height: 40px; - } .button.ng-binding.button-positive { background-color: var(--accent); @@ -103,31 +104,29 @@ label-tab > div { .button.ng-binding.button-assertive { background-color: var(--accent); height: 40px; - } .button.ng-binding.button-cancel { background-color: #d02001; height: 40px; - color: #ffffff + color: #ffffff; } .selected_date_full.ng-binding { color: var(--accent); } .icon.ion-chevron-left { - color: var(--accent); + color: var(--accent); } .icon.ion-chevron-right { - color: var(--accent); + color: var(--accent); } .date_col.date_selected { - background-color: var(--accent) !important; - + background-color: var(--accent) !important; } .date_col:active { - background-color: var(--accent) !important; + background-color: var(--accent) !important; } .customButtomIconSize:before { - font-size: 25px !important; + font-size: 25px !important; } #dashboard-footprint.card { @@ -138,7 +137,9 @@ label-tab > div { margin: 10px; margin-top: 55px; position: relative; - box-shadow: 0 2px 3px rgba(0, 0, 0, 0.03), 0 2px 3px rgba(0, 0, 0, 0.05); + box-shadow: + 0 2px 3px rgba(0, 0, 0, 0.03), + 0 2px 3px rgba(0, 0, 0, 0.05); /*background: rgba(40,218,183,1); background: -moz-linear-gradient(-45deg, rgba(40,218,183,1) 0%, rgba(99,230,199,1) 30%, rgba(94,235,212,1) 51%, rgba(87,235,223,1) 69%, rgba(99,237,226,1) 85%, rgba(127,250,250,1) 100%); background: -webkit-gradient(left top, right bottom, color-stop(0%, rgba(40,218,183,1)), color-stop(30%, rgba(99,230,199,1)), color-stop(51%, rgba(94,235,212,1)), color-stop(69%, rgba(87,235,223,1)), color-stop(85%, rgba(99,237,226,1)), color-stop(100%, rgba(127,250,250,1))); @@ -152,11 +153,11 @@ label-tab > div { overflow: hidden; } -.small-footprint-card{ +.small-footprint-card { height: 140px !important; } -.expanded-footprint-card{ +.expanded-footprint-card { height: 460px !important; } @@ -167,7 +168,9 @@ label-tab > div { display: block; margin: 10px; position: relative; - box-shadow: 0 2px 3px rgba(0, 0, 0, 0.03), 0 2px 3px rgba(0, 0, 0, 0.05); + box-shadow: + 0 2px 3px rgba(0, 0, 0, 0.03), + 0 2px 3px rgba(0, 0, 0, 0.05); /*background: rgba(40,218,183,1); background: -moz-linear-gradient(-45deg, rgba(40,218,183,1) 0%, rgba(99,230,199,1) 30%, rgba(94,235,212,1) 51%, rgba(87,235,223,1) 69%, rgba(99,237,226,1) 85%, rgba(127,250,250,1) 100%); background: -webkit-gradient(left top, right bottom, color-stop(0%, rgba(40,218,183,1)), color-stop(30%, rgba(99,230,199,1)), color-stop(51%, rgba(94,235,212,1)), color-stop(69%, rgba(87,235,223,1)), color-stop(85%, rgba(99,237,226,1)), color-stop(100%, rgba(127,250,250,1))); @@ -181,11 +184,11 @@ label-tab > div { overflow: hidden; } -.small-calorie-card{ +.small-calorie-card { height: 140px !important; } -.expanded-calorie-card{ +.expanded-calorie-card { height: 370px !important; } @@ -210,10 +213,12 @@ label-tab > div { display: block; /* height: 140px; */ margin: 10px; - margin-top:0px; + margin-top: 0px; position: relative; margin-bottom: 5px !important; - box-shadow: 0 2px 3px rgba(0, 0, 0, 0.03), 0 2px 3px rgba(0, 0, 0, 0.05); + box-shadow: + 0 2px 3px rgba(0, 0, 0, 0.03), + 0 2px 3px rgba(0, 0, 0, 0.05); /*background: rgba(40,218,183,1); background: -moz-linear-gradient(-45deg, rgba(40,218,183,1) 0%, rgba(99,230,199,1) 30%, rgba(94,235,212,1) 51%, rgba(87,235,223,1) 69%, rgba(99,237,226,1) 85%, rgba(127,250,250,1) 100%); background: -webkit-gradient(left top, right bottom, color-stop(0%, rgba(40,218,183,1)), color-stop(30%, rgba(99,230,199,1)), color-stop(51%, rgba(94,235,212,1)), color-stop(69%, rgba(87,235,223,1)), color-stop(85%, rgba(99,237,226,1)), color-stop(100%, rgba(127,250,250,1))); @@ -225,12 +230,12 @@ label-tab > div { text-align: center; } -#arrow-color{ +#arrow-color { color: var(--accent); font-size: 25px !important; } -h4.dashboard-headers{ +h4.dashboard-headers { color: #fff; background: var(--accent); padding-top: 5px; @@ -241,55 +246,55 @@ h4.dashboard-headers{ margin-bottom: 0px !important; } -.user-carbon-no-percentage{ +.user-carbon-no-percentage { padding-top: 30px; position: absolute; width: 100%; } -.user-carbon-percentage{ +.user-carbon-percentage { padding-top: 10px; position: absolute; width: 100%; } -.user-carbon{ +.user-carbon { font-weight: 700; color: var(--accent); font-size: 16px; } -.user-calorie-no-percentage{ +.user-calorie-no-percentage { padding-top: 30px; position: absolute; width: 100%; } -.user-calorie-percentage{ +.user-calorie-percentage { padding-top: 10px; position: absolute; width: 100%; } -.user-calorie{ +.user-calorie { font-weight: 700; color: var(--accent); font-size: 18px; } -.percentage-change{ +.percentage-change { font-weight: 700; color: var(--accent); margin-bottom: 20px; } -.calorie-change{ +.calorie-change { padding-top: 5px; font-weight: 700; color: var(--accent); } -.dashboard-list{ +.dashboard-list { padding-top: 10px; font-weight: 700; color: #fff; @@ -306,7 +311,9 @@ h4.dashboard-headers{ width: 60px; height: 60px; background: #fff; - box-shadow: 0 2px 3px rgba(0, 0, 0, 0.03), 0 2px 3px rgba(0, 0, 0, 0.05); + box-shadow: + 0 2px 3px rgba(0, 0, 0, 0.03), + 0 2px 3px rgba(0, 0, 0, 0.05); -moz-border-radius: 50px; -webkit-border-radius: 50px; border-radius: 50px; @@ -316,43 +323,47 @@ h4.dashboard-headers{ margin-top: 10px; } -#circle-food.circle{ +#circle-food.circle { position: relative !important; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1), 0 2px 3px rgba(0, 0, 0, 0.05) !important; - margin:auto; + box-shadow: + 0 2px 6px rgba(0, 0, 0, 0.1), + 0 2px 3px rgba(0, 0, 0, 0.05) !important; + margin: auto; float: none; display: inline-block; margin-right: 20px; margin-top: 20px; } -#circle-food.circle:active{ +#circle-food.circle:active { background-color: #eeeeee; - box-shadow: 0 0px 0px rgba(0, 0, 0, 0.1), 0 0px 0px rgba(0, 0, 0, 0.05) !important; + box-shadow: + 0 0px 0px rgba(0, 0, 0, 0.1), + 0 0px 0px rgba(0, 0, 0, 0.05) !important; } -#green-leaf{ +#green-leaf { color: var(--accent-light); font-size: 45px; padding-top: 5px; } -#food{ +#food { width: 45px; padding-top: 7px; } -#foodB{ +#foodB { width: 45px; padding-top: 7px; padding-right: 4px; } -.arrow-position{ +.arrow-position { position: absolute; bottom: 5px; right: 10px; - color:#b2b2b2; + color: #b2b2b2; font-size: 20px; } @@ -360,9 +371,9 @@ h4.dashboard-headers{ height:245px !important; } */ -#modes.slider-slide{ +#modes.slider-slide { padding-top: 0 !important; - background-color:transparent; + background-color: transparent; } /*.ion-view-background-dashboard{ @@ -376,12 +387,14 @@ h4.dashboard-headers{ filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#87f5f5', endColorstr='#5ffad8', GradientType=0 ); }*/ -.bar.bar-header.no-bgColor, .bar.bar-footer.no-bgColor{ -border: 0px !important; -border-color: transparent !important; -border-top: transparent !important; -border-bottom: transparent !important; -background-image: none !important; } +.bar.bar-header.no-bgColor, +.bar.bar-footer.no-bgColor { + border: 0px !important; + border-color: transparent !important; + border-top: transparent !important; + border-bottom: transparent !important; + background-image: none !important; +} .list .item.item-accordion { line-height: 38px; @@ -414,24 +427,24 @@ background-image: none !important; } background: white; border-radius: 50px; color: #222; - border: 1px solid rgb(0 0 0 / .2); + border: 1px solid rgb(0 0 0 / 0.2); padding: 3px 20px; margin: auto; display: block; } /* Light theme */ -.control-icon-button{ +.control-icon-button { text-align: center; max-height: 56px; background-color: #6c757d; color: #fff; padding-top: 16px; width: 64px; - font-size:20px; + font-size: 20px; } -.diary-button{ +.diary-button { text-align: center; float: right; height: 48px; @@ -442,7 +455,7 @@ background-image: none !important; } width: 48px; /* Changed to fit the diary card in full view */ } -.control-version-number{ +.control-version-number { text-align: center; float: right; height: 100%; @@ -450,32 +463,37 @@ background-image: none !important; } padding-top: 16px; width: 64px; - font-size:20px; + font-size: 20px; } -#switch-user.control-icon-button{ +#switch-user.control-icon-button { background-color: #dc3545 !important; } -.gray-icon.control-icon-button{ - background-color: #CCCCCC !important; +.gray-icon.control-icon-button { + background-color: #cccccc !important; } -.toggle-on-ourcolor-bg{ +.toggle-on-ourcolor-bg { border-color: var(--accent) !important; background-color: var(--accent) !important; } -.control-info{ +.control-info { padding: 2px 4px !important; } -.tab-nav{ - background-color: #f5f5f5 !important; background-size: 0 !important; - box-shadow: 0 1px 2px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); -} -.tab-item.tab-item-active, .tab-item.active, .tab-item.activated { - color: var(--accent); +.tab-nav { + background-color: #f5f5f5 !important; + background-size: 0 !important; + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.16), + 0 3px 6px rgba(0, 0, 0, 0.23); +} +.tab-item.tab-item-active, +.tab-item.active, +.tab-item.activated { + color: var(--accent); } .platform-ios.platform-cordova:not(.fullscreen) .bar-header:not(.bar-subheader) > * { margin-top: 15px; @@ -485,7 +503,9 @@ background-image: none !important; } } ion-header-bar { background-color: #f5f5f5 !important; - box-shadow: 0 1px 2px rgb(0 0 0 / 8%), 0 3px 6px rgb(0 0 0 / 12%); + box-shadow: + 0 1px 2px rgb(0 0 0 / 8%), + 0 3px 6px rgb(0 0 0 / 12%); } ion-nav-view { z-index: 10; @@ -496,29 +516,37 @@ ion-nav-view { } .tabs-custom > .tabs, .tabs.tabs-custom { - border-color: #5D3A23; - background-color: #5D3A23; + border-color: #5d3a23; + background-color: #5d3a23; background-image: linear-gradient(0deg, #0c60ee, #0c60ee 70%, transparent 70%); - color: #999; } - .tabs-custom > .tabs .tab-item .badge, - .tabs.tabs-custom .tab-item .badge { - background-color: #999; - color: #387ef5; } + color: #999; +} +.tabs-custom > .tabs .tab-item .badge, +.tabs.tabs-custom .tab-item .badge { + background-color: #999; + color: #387ef5; +} .tabs-striped.tabs-custom .tabs { - background-color: #5D3A23; } + background-color: #5d3a23; +} .tabs-striped.tabs-custom .tab-item { color: rgba(255, 255, 255, 0.7); - opacity: 1; } - .tabs-striped.tabs-custom .tab-item .badge { - opacity: 0.7; } - .tabs-striped.tabs-custom .tab-item.tab-item-active, .tabs-striped.tabs-custom .tab-item.active, .tabs-striped.tabs-positive .tab-item.activated { - margin-top: -2px; - color: #fff; - border-style: solid; - border-width: 2px 0 0 0; - border-color: #fff; } + opacity: 1; +} +.tabs-striped.tabs-custom .tab-item .badge { + opacity: 0.7; +} +.tabs-striped.tabs-custom .tab-item.tab-item-active, +.tabs-striped.tabs-custom .tab-item.active, +.tabs-striped.tabs-positive .tab-item.activated { + margin-top: -2px; + color: #fff; + border-style: solid; + border-width: 2px 0 0 0; + border-color: #fff; +} .title.title-center.header-item { color: #303030; } @@ -526,9 +554,15 @@ ion-nav-view { display: none; } .date-picker-button { - color: var(--accent) !important; padding: 0 15px; border-color: transparent; margin-top: 4px; - /* box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); */ - border-style: solid; border-color: white; border-width: 0px; border-radius: 5px; + color: var(--accent) !important; + padding: 0 15px; + border-color: transparent; + margin-top: 4px; + /* box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); */ + border-style: solid; + border-color: white; + border-width: 0px; + border-radius: 5px; } .button.date-picker-button { @@ -536,11 +570,12 @@ ion-nav-view { } .date-picker-arrow { - color: #303030 !important; margin-top: 4px; background-color: transparent !important; + color: #303030 !important; + margin-top: 4px; + background-color: transparent !important; } /* Light theme ends */ - .earlier-later-expand { color: #303030; margin: 16px 16px 0 6px; @@ -555,7 +590,7 @@ ion-nav-view { padding-bottom: 5px; padding-left: 30px; margin-top: 0 !important; - margin-bottom: 0!important; + margin-bottom: 0 !important; } p.list-text { color: #303030; @@ -570,38 +605,52 @@ a.list-text { } .card-1 { - box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); - transition: all 0.3s cubic-bezier(.25,.8,.25,1); + box-shadow: + 0 1px 3px rgba(0, 0, 0, 0.12), + 0 1px 2px rgba(0, 0, 0, 0.24); + transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); } .card-1:hover { - box-shadow: 0 14px 28px rgba(0,0,0,0.25), 0 10px 10px rgba(0,0,0,0.22); + box-shadow: + 0 14px 28px rgba(0, 0, 0, 0.25), + 0 10px 10px rgba(0, 0, 0, 0.22); } .card-2 { - box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); + box-shadow: + 0 3px 6px rgba(0, 0, 0, 0.16), + 0 3px 6px rgba(0, 0, 0, 0.23); } .card-3 { - box-shadow: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23); + box-shadow: + 0 10px 20px rgba(0, 0, 0, 0.19), + 0 6px 6px rgba(0, 0, 0, 0.23); } .card-4 { - box-shadow: 0 14px 28px rgba(0,0,0,0.25), 0 10px 10px rgba(0,0,0,0.22); + box-shadow: + 0 14px 28px rgba(0, 0, 0, 0.25), + 0 10px 10px rgba(0, 0, 0, 0.22); } .card-5 { - box-shadow: 0 19px 38px rgba(0,0,0,0.30), 0 15px 12px rgba(0,0,0,0.22); + box-shadow: + 0 19px 38px rgba(0, 0, 0, 0.3), + 0 15px 12px rgba(0, 0, 0, 0.22); } button.button.ng-binding i.icon.ion-edit { font-size: 12px; } button.button.back-button.buttons.button-clear.header-item { - color: #303030; opacity: 0.7; + color: #303030; + opacity: 0.7; } .nav-bar-title { - color: #303030; opacity: 0.7; + color: #303030; + opacity: 0.7; } /* Profile tab */ .control-list-item { @@ -619,20 +668,25 @@ button.button.back-button.buttons.button-clear.header-item { display: -webkit-box; line-height: 1.1; -webkit-line-clamp: 5; /* number of lines to show */ - line-clamp: 5; + line-clamp: 5; -webkit-box-orient: vertical; text-overflow: ellipsis; } .control-list-toggle { - float: right; margin-top: 5px; margin-right: 2px; + float: right; + margin-top: 5px; + margin-right: 2px; } /* Diary list tab */ .lightrail { - color: blue + color: blue; } .dev-zone-input { - padding: 7px 0; font-size: 16px; line-height: 22px; height: 36px; + padding: 7px 0; + font-size: 16px; + line-height: 22px; + height: 36px; } .dev-zone-title { padding: 18px 16px; @@ -644,14 +698,16 @@ button.button.back-button.buttons.button-clear.header-item { } .list-card { margin: 16px 0; - box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23); + box-shadow: + 0 3px 6px rgba(0, 0, 0, 0.16), + 0 3px 6px rgba(0, 0, 0, 0.23); border: 1px solid #ccc; } .bg-light { - background-color: #ffffff; + background-color: #ffffff; } .bg-unprocessed { - background-color: #9eb2aa; + background-color: #9eb2aa; } .list-card-sm { width: 95%; @@ -660,20 +716,38 @@ button.button.back-button.buttons.button-clear.header-item { width: 95%; } .list-card-lg { - width: 95%; + width: 95%; } .list-card .row { padding-left: 5px; padding-right: 5px; } .list-col-left-margin { - text-align: center; padding: 0.7em 0.8em 0.4em 0.8em; border-right-width: 0.5px; border-right-color: #ccc; border-right-style: solid; border-bottom-color: #ccc; border-bottom-width: 0.5px; border-bottom-style: solid; + text-align: center; + padding: 0.7em 0.8em 0.4em 0.8em; + border-right-width: 0.5px; + border-right-color: #ccc; + border-right-style: solid; + border-bottom-color: #ccc; + border-bottom-width: 0.5px; + border-bottom-style: solid; } .list-col-left { - text-align: center; padding: 1.1em 0.8em 0.6em 0.8em; border-right-width: 0.5px; border-right-color: #ccc; border-right-style: solid; border-bottom-color: #ccc; border-bottom-width: 0.5px; border-bottom-style: solid; + text-align: center; + padding: 1.1em 0.8em 0.6em 0.8em; + border-right-width: 0.5px; + border-right-color: #ccc; + border-right-style: solid; + border-bottom-color: #ccc; + border-bottom-width: 0.5px; + border-bottom-style: solid; } .list-col-right { - text-align: center; padding: 0.25em 0.8em; border-bottom-color: #ccc; border-bottom-width: 0.5px; border-bottom-style: solid; + text-align: center; + padding: 0.25em 0.8em; + border-bottom-color: #ccc; + border-bottom-width: 0.5px; + border-bottom-style: solid; } timestamp-badge { @@ -703,15 +777,15 @@ timestamp-badge[light-bg] { } .diary-checkmark-container i.can-verify { - color: #30A64A; + color: #30a64a; background-color: #ddd; border-radius: 5px; } .diary-checkmark-container i.cannot-verify { - color: #E6B8B8; + color: #e6b8b8; } .diary-checkmark-container i.already-verified { - color: #B8E6C2; + color: #b8e6c2; } /* .diary-checkmark-container i.already-verified, .diary-checkmark-container i.cannot-verify { color: #BFBFBF; @@ -740,22 +814,24 @@ timestamp-badge[light-bg] { .metric-datepicker { /*height: 33px;*/ - display: flex; /* establish flex container */ + display: flex; /* establish flex container */ /*flex-direction: column; make main axis vertical */ justify-content: center; /* center items vertically, in this case */ - align-items: center; /* center items horizontally, in this case */ + align-items: center; /* center items horizontally, in this case */ border-radius: 5px; background-color: white; - box-shadow: 0 1px 1px rgba(0,0,0,0.03), 0 1px 1px rgba(0,0,0,0.05); + box-shadow: + 0 1px 1px rgba(0, 0, 0, 0.03), + 0 1px 1px rgba(0, 0, 0, 0.05); color: var(--accent); height: 35px; } .metric-title { height: 35px; - display: flex; /* establish flex container */ - flex-direction: column; /* make main axis vertical */ + display: flex; /* establish flex container */ + flex-direction: column; /* make main axis vertical */ justify-content: center; /* center items vertically, in this case */ - align-items: left; /* center items horizontally, in this case */ + align-items: left; /* center items horizontally, in this case */ padding-left: 10px; } @@ -768,7 +844,9 @@ timestamp-badge[light-bg] { float: right; border-radius: 5px; background-color: white; - box-shadow: 0 1px 1px rgba(0,0,0,0.03), 0 1px 1px rgba(0,0,0,0.05); + box-shadow: + 0 1px 1px rgba(0, 0, 0, 0.03), + 0 1px 1px rgba(0, 0, 0, 0.05); color: var(--accent); height: 35px; } @@ -778,7 +856,9 @@ timestamp-badge[light-bg] { top: 40px; border-radius: 5px; background-color: white; - box-shadow: 0 1px 1px rgba(0,0,0,0.03), 0 1px 1px rgba(0,0,0,0.05); + box-shadow: + 0 1px 1px rgba(0, 0, 0, 0.03), + 0 1px 1px rgba(0, 0, 0, 0.05); color: var(--accent); height: 35px; } @@ -796,17 +876,19 @@ timestamp-badge[light-bg] { width: 75%; float: right; } -.metric-change-data-button{ +.metric-change-data-button { margin: auto; width: 120px; border-radius: 20px; background-color: white; - box-shadow: 0 2px 3px rgba(0, 0, 0, 0.03), 0 2px 3px rgba(0, 0, 0, 0.05); + box-shadow: + 0 2px 3px rgba(0, 0, 0, 0.03), + 0 2px 3px rgba(0, 0, 0, 0.05); color: var(--accent); font-weight: 700; height: 30px; } -.metric-change-data-button:active{ +.metric-change-data-button:active { background-color: var(--accent); color: white; box-shadow: none; @@ -816,7 +898,9 @@ timestamp-badge[light-bg] { width: 49%; border-radius: 5px; background-color: white; - box-shadow: 0 1px 2px rgba(0,0,0,0.03), 0 1px 2px rgba(0,0,0,0.05); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.03), + 0 1px 2px rgba(0, 0, 0, 0.05); color: var(--accent); height: 35px; } @@ -825,22 +909,23 @@ timestamp-badge[light-bg] { width: 33%; border-radius: 5px; background-color: white; - box-shadow: 0 1px 2px rgba(0,0,0,0.03), 0 1px 2px rgba(0,0,0,0.05); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.03), + 0 1px 2px rgba(0, 0, 0, 0.05); color: var(--accent); height: 35px; - } .current-mode-button { border: none; - background-color:#2D9CDB; - display:inline-block; - cursor:pointer; - color:#ffffff; + background-color: #2d9cdb; + display: inline-block; + cursor: pointer; + color: #ffffff; opacity: 0.4; - font-size:28px; + font-size: 28px; width: 100%; - text-decoration:none; + text-decoration: none; height: 80px; z-index: 1; position: relative; @@ -854,11 +939,13 @@ timestamp-badge[light-bg] { display: block; width: 40%; height: 25px; - background-color: #f5f5f5;; + background-color: #f5f5f5; border-radius: 10px; - color: #6A6A6A; + color: #6a6a6a; left: 30%; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.05); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.05), + 0 1px 2px rgba(0, 0, 0, 0.05); } #current-start-time-text { @@ -876,8 +963,10 @@ timestamp-badge[light-bg] { } #current-speed { - background-color: #8F8F8F; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2), 0 2px 4px rgba(0, 0, 0, 0.05); + background-color: #8f8f8f; + box-shadow: + 0 2px 4px rgba(0, 0, 0, 0.2), + 0 2px 4px rgba(0, 0, 0, 0.05); opacity: 0.9; color: white; width: 60px; @@ -885,7 +974,7 @@ timestamp-badge[light-bg] { height: 60px; border-style: solid; border-radius: 50%; - border-color: #6A6A6A; + border-color: #6a6a6a; border-width: 4px; } @@ -901,7 +990,7 @@ timestamp-badge[light-bg] { } #current-direction-text { - color:#6A6A6A; + color: #6a6a6a; font-size: 20px; font-weight: 600; margin-top: 5px; @@ -917,21 +1006,25 @@ timestamp-badge[light-bg] { .report-button { border-radius: 10px; border: none; - background-color: #E34949; + background-color: #e34949; color: #ffffff; font-size: 20px; width: 60%; height: 35px; z-index: 1; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.05); + box-shadow: + 0 4px 8px rgba(0, 0, 0, 0.3), + 0 2px 4px rgba(0, 0, 0, 0.05); position: absolute; display: block; bottom: 40px; left: 20%; } -.report-button:active{ - box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1), 0 1px 1px rgba(0, 0, 0, 0.05) !important; +.report-button:active { + box-shadow: + 0 1px 1px rgba(0, 0, 0, 0.1), + 0 1px 1px rgba(0, 0, 0, 0.05) !important; } .metric-freq-button { @@ -939,7 +1032,9 @@ timestamp-badge[light-bg] { width: 100%; border-radius: 5px; background-color: white; - box-shadow: 0 1px 2px rgba(0,0,0,0.03), 0 1px 2px rgba(0,0,0,0.05); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.03), + 0 1px 2px rgba(0, 0, 0, 0.05); color: var(--accent); height: 35px; } @@ -959,25 +1054,30 @@ timestamp-badge[light-bg] { height: 35px; } .hvcenter { - display: flex; /* establish flex container */ - flex-direction: column; /* make main axis vertical */ + display: flex; /* establish flex container */ + flex-direction: column; /* make main axis vertical */ justify-content: center; /* center items vertically, in this case */ - align-items: center; /* center items horizontally, in this case */ + align-items: center; /* center items horizontally, in this case */ } .metric-basic { width: 100%; border-radius: 5px; background-color: white; - box-shadow: 0 1px 2px rgba(0,0,0,0.03), 0 1px 2px rgba(0,0,0,0.05); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.03), + 0 1px 2px rgba(0, 0, 0, 0.05); color: var(--accent); height: 35px; } .metric-half { - float: left; + float: left; width: 100%; border-radius: 5px; background-color: white; - box-shadow: 0 2px 3px rgba(0, 0, 0, 0.03), 0 2px 3px rgba(0, 0, 0, 0.05); color: #01D0A7; + box-shadow: + 0 2px 3px rgba(0, 0, 0, 0.03), + 0 2px 3px rgba(0, 0, 0, 0.05); + color: #01d0a7; height: 30px; overflow: hidden; position: relative; @@ -1095,8 +1195,8 @@ timestamp-badge[light-bg] { border-top-left-radius: 5px; border-bottom-left-radius: 5px; } -.distance-button{ - width:25%; +.distance-button { + width: 25%; font-size: 12px; font-weight: 700; overflow: hidden; @@ -1105,8 +1205,8 @@ timestamp-badge[light-bg] { float: left; height: 30px; } -.speed-button{ - width:25%; +.speed-button { + width: 25%; font-size: 12px; font-weight: 700; overflow: hidden; @@ -1115,24 +1215,24 @@ timestamp-badge[light-bg] { float: left; height: 30px; } -.trips-button{ - width:25%; +.trips-button { + width: 25%; font-size: 12px; font-weight: 700; overflow: hidden; float: left; height: 30px; } -.duration-button{ - width:25%; +.duration-button { + width: 25%; font-size: 12px; font-weight: 700; overflow: hidden; float: left; height: 30px; } -.distance-button-active{ - width:25%; +.distance-button-active { + width: 25%; font-size: 12px; font-weight: 700; background-color: var(--accent); @@ -1143,8 +1243,8 @@ timestamp-badge[light-bg] { float: left; height: 30px; } -.speed-button-active{ - width:25%; +.speed-button-active { + width: 25%; font-size: 12px; font-weight: 700; overflow: hidden; @@ -1155,8 +1255,8 @@ timestamp-badge[light-bg] { float: left; height: 30px; } -.trips-button-active{ - width:25%; +.trips-button-active { + width: 25%; font-size: 12px; font-weight: 700; background-color: var(--accent); @@ -1165,8 +1265,8 @@ timestamp-badge[light-bg] { float: left; height: 30px; } -.duration-button-active{ - width:25%; +.duration-button-active { + width: 25%; font-size: 12px; font-weight: 700; background-color: var(--accent); @@ -1182,7 +1282,6 @@ timestamp-badge[light-bg] { height: 33px; } .metric-me-toggle { - } .metric-icon { color: #ccc; @@ -1192,10 +1291,10 @@ timestamp-badge[light-bg] { position: absolute; width: 15%; height: 35px; - display: flex; /* establish flex container */ - flex-direction: column; /* make main axis vertical */ + display: flex; /* establish flex container */ + flex-direction: column; /* make main axis vertical */ justify-content: center; /* center items vertically, in this case */ - align-items: left; /* center items horizontally, in this case */ + align-items: left; /* center items horizontally, in this case */ padding-left: 10px; } .metric-filter-year { @@ -1204,7 +1303,9 @@ timestamp-badge[light-bg] { left: 21%; height: 35px; border-width: 0; - box-shadow: 0 1px 1px rgba(0,0,0,0.03), 0 1px 1px rgba(0,0,0,0.05) !important; + box-shadow: + 0 1px 1px rgba(0, 0, 0, 0.03), + 0 1px 1px rgba(0, 0, 0, 0.05) !important; } .metric-filter-month { position: absolute; @@ -1212,7 +1313,9 @@ timestamp-badge[light-bg] { left: 42%; height: 35px; border-width: 0; - box-shadow: 0 1px 1px rgba(0,0,0,0.03), 0 1px 1px rgba(0,0,0,0.05) !important; + box-shadow: + 0 1px 1px rgba(0, 0, 0, 0.03), + 0 1px 1px rgba(0, 0, 0, 0.05) !important; } .metric-filter-day { position: absolute; @@ -1220,7 +1323,9 @@ timestamp-badge[light-bg] { left: 57%; height: 35px; border-width: 0; - box-shadow: 0 1px 1px rgba(0,0,0,0.03), 0 1px 1px rgba(0,0,0,0.05) !important; + box-shadow: + 0 1px 1px rgba(0, 0, 0, 0.03), + 0 1px 1px rgba(0, 0, 0, 0.05) !important; } .metric-summary-title { padding: 2px; @@ -1237,7 +1342,6 @@ timestamp-badge[light-bg] { margin-top: 10px; width: 40px !important; margin-left: 10px; - } .metric-summary-right { margin-left: 40px; @@ -1259,12 +1363,12 @@ timestamp-badge[light-bg] { width: 80px; margin-right: 5px; margin-top: -28px; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1), 0 1px 1px rgba(0, 0, 0, 0.05); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.1), + 0 1px 1px rgba(0, 0, 0, 0.05); } .full-toggle-container { - height: 35px; - - + height: 35px; } .full-toggle-left { width: 50%; @@ -1317,7 +1421,7 @@ timestamp-badge[light-bg] { color: white; } .unit-toggle-container { - height: 35px; + height: 35px; } .unit-toggle-left { width: 50%; @@ -1374,7 +1478,7 @@ timestamp-badge[light-bg] { #no-border.item { border-width: 0 !important; } -#goal-signup-field{ +#goal-signup-field { width: 50%; margin-left: auto; margin-right: auto; @@ -1387,16 +1491,16 @@ timestamp-badge[light-bg] { opacity: 0.7; } .full-toggle-left:active { - opacity: 0.7; + opacity: 0.7; } .full-toggle-right:active { opacity: 0.7; } #iframe { - /*width: 375px !important;*/ - height: 100%; - -webkit-overflow-scrolling: touch !important; - overflow: scroll !important; + /*width: 375px !important;*/ + height: 100%; + -webkit-overflow-scrolling: touch !important; + overflow: scroll !important; } .buttons { @@ -1414,7 +1518,7 @@ timestamp-badge[light-bg] { .buttons input, .buttons select { text-align: center; - border: 1px solid rgb(20 20 20 / .2); + border: 1px solid rgb(20 20 20 / 0.2); border-radius: 10px; font-size: 13px; color: #222; @@ -1422,7 +1526,7 @@ timestamp-badge[light-bg] { min-width: 11ch; } -.buttons input[type="date"] { +.buttons input[type='date'] { color: transparent; } @@ -1440,15 +1544,14 @@ timestamp-badge[light-bg] { text-decoration: underline; } - .date-input-wrapper:after { - content: ""; + content: ''; position: absolute; top: 50%; translate: 0 -50%; right: 8px; font-size: 14px; - font-family: "Ionicons"; + font-family: 'Ionicons'; } .date-input-divider { diff --git a/www/i18n/en.json b/www/i18n/en.json index 4d9aea168..7f3798f16 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -1,464 +1,464 @@ { - "loading" : "Loading...", - "pull-to-refresh": "Pull to refresh", + "loading": "Loading...", + "pull-to-refresh": "Pull to refresh", - "weekdays-all": "All", - "weekdays-select": "Select day of the week", + "weekdays-all": "All", + "weekdays-select": "Select day of the week", - "trip-confirm": { - "services-please-fill-in": "Please fill in the {{text}} not listed.", - "services-cancel": "Cancel", - "services-save": "Save" - }, + "trip-confirm": { + "services-please-fill-in": "Please fill in the {{text}} not listed.", + "services-cancel": "Cancel", + "services-save": "Save" + }, - "control":{ - "profile-tab": "Profile", - "edit-demographics": "Edit Demographics", - "tracking": "Tracking", - "app-status": "App Status", - "incorrect-app-status": "Please update permissions", - "fix-app-status": "Click to view and fix app status", - "fix": "Fix", - "medium-accuracy": "Medium accuracy", - "force-sync": "Force sync", - "share": "Share", - "download-json-dump": "Download json dump", - "email-log": "Email log", - "upload-log": "Upload log", - "view-privacy": "View Privacy Policy", - "user-data": "User data", - "erase-data": "Erase data", - "dev-zone": "Developer zone", - "refresh": "Refresh", - "end-trip-sync": "End trip + sync", - "check-consent": "Check consent", - "invalidate-cached-docs": "Invalidate cached docs", - "nuke-all": "Nuke all buffers and cache", - "test-notification": "Test local notification", - "check-log": "Check log", - "log-title" : "Log", - "check-sensed-data": "Check sensed data", - "sensed-title": "Sensed Data: Transitions", - "collection": "Collection", - "sync": "Sync", - "button-accept": "I accept", - "view-qrc": "My OPcode", - "app-version": "App Version", - "reminders-time-of-day": "Time of Day for Reminders ({{time}})", - "upcoming-notifications": "Upcoming Notifications", - "dummy-notification" : "Dummy Notification in 5 Seconds", - "log-out": "Log Out" - }, + "control": { + "profile-tab": "Profile", + "edit-demographics": "Edit Demographics", + "tracking": "Tracking", + "app-status": "App Status", + "incorrect-app-status": "Please update permissions", + "fix-app-status": "Click to view and fix app status", + "fix": "Fix", + "medium-accuracy": "Medium accuracy", + "force-sync": "Force sync", + "share": "Share", + "download-json-dump": "Download json dump", + "email-log": "Email log", + "upload-log": "Upload log", + "view-privacy": "View Privacy Policy", + "user-data": "User data", + "erase-data": "Erase data", + "dev-zone": "Developer zone", + "refresh": "Refresh", + "end-trip-sync": "End trip + sync", + "check-consent": "Check consent", + "invalidate-cached-docs": "Invalidate cached docs", + "nuke-all": "Nuke all buffers and cache", + "test-notification": "Test local notification", + "check-log": "Check log", + "log-title": "Log", + "check-sensed-data": "Check sensed data", + "sensed-title": "Sensed Data: Transitions", + "collection": "Collection", + "sync": "Sync", + "button-accept": "I accept", + "view-qrc": "My OPcode", + "app-version": "App Version", + "reminders-time-of-day": "Time of Day for Reminders ({{time}})", + "upcoming-notifications": "Upcoming Notifications", + "dummy-notification": "Dummy Notification in 5 Seconds", + "log-out": "Log Out" + }, - "general-settings":{ - "choose-date" : "Choose date to download data", - "choose-dataset" : "Choose a dataset for carbon footprint calculations", - "carbon-dataset" : "Carbon dataset", - "nuke-ui-state-only" : "UI state only", - "nuke-native-cache-only" : "Native cache only", - "nuke-everything" : "Everything", - "clear-data": "Clear data", - "are-you-sure": "Are you sure?", - "log-out-warning": "You will be logged out and your credentials will not be saved. Unsynced data may be lost.", - "cancel": "Cancel", - "confirm": "Confirm", - "user-data-erased": "User data erased.", - "consent-not-found": "Consent for data collection not found, consent now?", - "no-consent-logout": "Consent for data collection not found, please save your opcode, log out, and log back in with the same opcode. Note that you won't get any personalized stats until you do!", - "no-consent-message": "OK! Note that you won't get any personalized stats until you do!", - "consent-found": "Consent found!", - "consented-to": "Consented to protocol last updated on {{approval_date}}", - "consented-ok": "OK", - "qrcode": "My OPcode", - "qrcode-share-title": "You can save your OPcode to login easily in the future!" - }, + "general-settings": { + "choose-date": "Choose date to download data", + "choose-dataset": "Choose a dataset for carbon footprint calculations", + "carbon-dataset": "Carbon dataset", + "nuke-ui-state-only": "UI state only", + "nuke-native-cache-only": "Native cache only", + "nuke-everything": "Everything", + "clear-data": "Clear data", + "are-you-sure": "Are you sure?", + "log-out-warning": "You will be logged out and your credentials will not be saved. Unsynced data may be lost.", + "cancel": "Cancel", + "confirm": "Confirm", + "user-data-erased": "User data erased.", + "consent-not-found": "Consent for data collection not found, consent now?", + "no-consent-logout": "Consent for data collection not found, please save your opcode, log out, and log back in with the same opcode. Note that you won't get any personalized stats until you do!", + "no-consent-message": "OK! Note that you won't get any personalized stats until you do!", + "consent-found": "Consent found!", + "consented-to": "Consented to protocol last updated on {{approval_date}}", + "consented-ok": "OK", + "qrcode": "My OPcode", + "qrcode-share-title": "You can save your OPcode to login easily in the future!" + }, - "metrics":{ - "dashboard-tab": "Dashboard", - "cancel": "Cancel", - "confirm": "Confirm", - "get": "Get", - "range": "Range", - "filter": "Filter", - "from": "From:", - "to": "To:", - "last-week": "last week", - "frequency": "Frequency:", - "pandafreqoptions-daily": "DAILY", - "pandafreqoptions-weekly": "WEEKLY", - "pandafreqoptions-biweekly": "BIWEEKLY", - "pandafreqoptions-monthly": "MONTHLY", - "pandafreqoptions-yearly": "YEARLY", - "freqoptions-daily": "DAILY", - "freqoptions-monthly": "MONTHLY", - "freqoptions-yearly": "YEARLY", - "select-pandafrequency": "Select summary freqency", - "select-frequency": "Select summary freqency", - "chart-xaxis-date": "Date", - "chart-no-data": "No Data Available", - "trips-yaxis-number": "Number", - "calorie-data-change": " change", - "calorie-data-unknown": "Unknown...", - "greater-than": " greater than ", - "greater": " greater ", - "or": "or", - "less-than": " less than ", - "less": " less ", - "week-before": "vs. week before", - "this-week": "this week", - "pick-a-date": "Pick a date", - "trips": "trips", - "hours": "hours", - "minutes": "minutes", - "custom": "Custom" - }, - - "diary": { - "label-tab": "Label", - "distance-in-time": "{{distance}} {{distsuffix}} in {{time}}", - "distance": "Distance", - "time": "Time", - "mode": "Mode", - "replaces": "Replaces", - "purpose": "Purpose", - "survey": "Details", - "untracked-time-range": "Untracked: {{start}} - {{end}}", - "unlabeled": "All Unlabeled", - "invalid-ebike": "Invalid", - "to-label": "To Label", - "show-all": "All Trips", - "no-trips-found": "No trips found", - "choose-mode": "Mode 📝 ", - "choose-replaced-mode": "Replaces 📝", - "choose-purpose": "Purpose 📝", - "choose-survey": "Add Trip Details 📝 ", - "select-mode-scroll": "Mode (👇 for more)", - "select-replaced-mode-scroll": "Replaces (👇 for more)", - "select-purpose-scroll": "Purpose (👇 for more)", - "delete-entry-confirm": "Are you sure you wish to delete this entry?", - "detected": "Detected:", - "labeled-mode": "Labeled Mode", - "detected-modes": "Detected Modes", - "today": "Today", - "no-more-travel": "No more travel to show", - "show-more-travel": "Show More Travel", - "show-older-travel": "Show Older Travel", - "no-travel": "No travel to show", - "no-travel-hint": "To see more, change the filters above or go record some travel!" - }, + "metrics": { + "dashboard-tab": "Dashboard", + "cancel": "Cancel", + "confirm": "Confirm", + "get": "Get", + "range": "Range", + "filter": "Filter", + "from": "From:", + "to": "To:", + "last-week": "last week", + "frequency": "Frequency:", + "pandafreqoptions-daily": "DAILY", + "pandafreqoptions-weekly": "WEEKLY", + "pandafreqoptions-biweekly": "BIWEEKLY", + "pandafreqoptions-monthly": "MONTHLY", + "pandafreqoptions-yearly": "YEARLY", + "freqoptions-daily": "DAILY", + "freqoptions-monthly": "MONTHLY", + "freqoptions-yearly": "YEARLY", + "select-pandafrequency": "Select summary freqency", + "select-frequency": "Select summary freqency", + "chart-xaxis-date": "Date", + "chart-no-data": "No Data Available", + "trips-yaxis-number": "Number", + "calorie-data-change": " change", + "calorie-data-unknown": "Unknown...", + "greater-than": " greater than ", + "greater": " greater ", + "or": "or", + "less-than": " less than ", + "less": " less ", + "week-before": "vs. week before", + "this-week": "this week", + "pick-a-date": "Pick a date", + "trips": "trips", + "hours": "hours", + "minutes": "minutes", + "custom": "Custom" + }, - "multilabel":{ - "walk": "Walk", - "e-bike": "E-bike", - "bike": "Regular Bike", - "bikeshare": "Bikeshare", - "scootershare": "Scooter share", - "drove_alone": "Gas Car Drove Alone", - "shared_ride": "Gas Car Shared Ride", - "hybrid_drove_alone": "Hybrid Drove Alone", - "hybrid_shared_ride": "Hybrid Shared Ride", - "e_car_drove_alone": "E-Car Drove Alone", - "e_car_shared_ride": "E-Car Shared Ride", - "taxi": "Taxi / Uber / Lyft", - "bus": "Bus", - "train": "Train", - "free_shuttle": "Free Shuttle", - "air": "Air", - "not_a_trip": "Not a trip", - "no_travel": "No travel", - "home": "Home", - "work": "To Work", - "at_work": "At Work", - "school": "School", - "transit_transfer": "Transit transfer", - "shopping": "Shopping", - "meal": "Meal", - "pick_drop_person": "Pick-up / Drop off Person", - "pick_drop_item": "Pick-up / Drop off Item", - "personal_med": "Personal / Medical", - "access_recreation": "Access Recreation", - "exercise": "Recreation / Exercise", - "entertainment": "Entertainment / Social", - "religious": "Religious", - "other": "Other" - }, + "diary": { + "label-tab": "Label", + "distance-in-time": "{{distance}} {{distsuffix}} in {{time}}", + "distance": "Distance", + "time": "Time", + "mode": "Mode", + "replaces": "Replaces", + "purpose": "Purpose", + "survey": "Details", + "untracked-time-range": "Untracked: {{start}} - {{end}}", + "unlabeled": "All Unlabeled", + "invalid-ebike": "Invalid", + "to-label": "To Label", + "show-all": "All Trips", + "no-trips-found": "No trips found", + "choose-mode": "Mode 📝 ", + "choose-replaced-mode": "Replaces 📝", + "choose-purpose": "Purpose 📝", + "choose-survey": "Add Trip Details 📝 ", + "select-mode-scroll": "Mode (👇 for more)", + "select-replaced-mode-scroll": "Replaces (👇 for more)", + "select-purpose-scroll": "Purpose (👇 for more)", + "delete-entry-confirm": "Are you sure you wish to delete this entry?", + "detected": "Detected:", + "labeled-mode": "Labeled Mode", + "detected-modes": "Detected Modes", + "today": "Today", + "no-more-travel": "No more travel to show", + "show-more-travel": "Show More Travel", + "show-older-travel": "Show Older Travel", + "no-travel": "No travel to show", + "no-travel-hint": "To see more, change the filters above or go record some travel!" + }, - "main-metrics":{ - "summary": "My Summary", - "chart": "Chart", - "change-data": "Change dates:", - "distance": "Distance", - "trips": "Trips", - "duration": "Duration", - "fav-mode": "My Favorite Mode", - "speed": "My Speed", - "footprint": "My Footprint", - "estimated-emissions": "Estimated CO₂ emissions", - "how-it-compares": "Ballpark comparisons", - "optimal": "Optimal (perfect mode choice for all my trips)", - "average": "Group Avg.", - "worst-case": "Worse Case", - "label-to-squish": "Label trips to collapse the range into a single number", - "range-uncertain-footnote": "²Due to the uncertainty of unlabeled trips, estimates may fall anywhere within the shown range. Label more trips for richer estimates.", - "lastweek": "My last week value:", - "us-2030-goal": "2030 Guideline¹", - "us-2050-goal": "2050 Guideline¹", - "us-goals-footnote": "¹Guidelines based on US decarbonization goals, scaled to per-capita travel-related emissions.", - "past-week" : "Past Week", - "prev-week" : "Prev. Week", - "no-summary-data": "No summary data", - "mean-speed": "My Average Speed", - "user-totals": "My Totals", - "group-totals": "Group Totals", - "active-minutes": "Active Minutes", - "weekly-active-minutes": "Weekly minutes of active travel", - "daily-active-minutes": "Daily minutes of active travel", - "active-minutes-table": "Table of active minutes metrics", - "weekly-goal": "Weekly Goal³", - "weekly-goal-footnote": "³Weekly goal based on CDC recommendation of 150 minutes of moderate activity per week.", - "labeled": "Labeled", - "unlabeled": "Unlabeled²", - "footprint-label": "Footprint (kg CO₂)" - }, + "multilabel": { + "walk": "Walk", + "e-bike": "E-bike", + "bike": "Regular Bike", + "bikeshare": "Bikeshare", + "scootershare": "Scooter share", + "drove_alone": "Gas Car Drove Alone", + "shared_ride": "Gas Car Shared Ride", + "hybrid_drove_alone": "Hybrid Drove Alone", + "hybrid_shared_ride": "Hybrid Shared Ride", + "e_car_drove_alone": "E-Car Drove Alone", + "e_car_shared_ride": "E-Car Shared Ride", + "taxi": "Taxi / Uber / Lyft", + "bus": "Bus", + "train": "Train", + "free_shuttle": "Free Shuttle", + "air": "Air", + "not_a_trip": "Not a trip", + "no_travel": "No travel", + "home": "Home", + "work": "To Work", + "at_work": "At Work", + "school": "School", + "transit_transfer": "Transit transfer", + "shopping": "Shopping", + "meal": "Meal", + "pick_drop_person": "Pick-up / Drop off Person", + "pick_drop_item": "Pick-up / Drop off Item", + "personal_med": "Personal / Medical", + "access_recreation": "Access Recreation", + "exercise": "Recreation / Exercise", + "entertainment": "Entertainment / Social", + "religious": "Religious", + "other": "Other" + }, - "details":{ - "speed": "Speed", - "time": "Time" - }, + "main-metrics": { + "summary": "My Summary", + "chart": "Chart", + "change-data": "Change dates:", + "distance": "Distance", + "trips": "Trips", + "duration": "Duration", + "fav-mode": "My Favorite Mode", + "speed": "My Speed", + "footprint": "My Footprint", + "estimated-emissions": "Estimated CO₂ emissions", + "how-it-compares": "Ballpark comparisons", + "optimal": "Optimal (perfect mode choice for all my trips)", + "average": "Group Avg.", + "worst-case": "Worse Case", + "label-to-squish": "Label trips to collapse the range into a single number", + "range-uncertain-footnote": "²Due to the uncertainty of unlabeled trips, estimates may fall anywhere within the shown range. Label more trips for richer estimates.", + "lastweek": "My last week value:", + "us-2030-goal": "2030 Guideline¹", + "us-2050-goal": "2050 Guideline¹", + "us-goals-footnote": "¹Guidelines based on US decarbonization goals, scaled to per-capita travel-related emissions.", + "past-week": "Past Week", + "prev-week": "Prev. Week", + "no-summary-data": "No summary data", + "mean-speed": "My Average Speed", + "user-totals": "My Totals", + "group-totals": "Group Totals", + "active-minutes": "Active Minutes", + "weekly-active-minutes": "Weekly minutes of active travel", + "daily-active-minutes": "Daily minutes of active travel", + "active-minutes-table": "Table of active minutes metrics", + "weekly-goal": "Weekly Goal³", + "weekly-goal-footnote": "³Weekly goal based on CDC recommendation of 150 minutes of moderate activity per week.", + "labeled": "Labeled", + "unlabeled": "Unlabeled²", + "footprint-label": "Footprint (kg CO₂)" + }, - "list-datepicker-today": "Today", - "list-datepicker-close": "Close", - "list-datepicker-set": "Set", + "details": { + "speed": "Speed", + "time": "Time" + }, - "service":{ - "reading-server": "Reading from server...", - "reading-unprocessed-data": "Reading unprocessed data..." - }, + "list-datepicker-today": "Today", + "list-datepicker-close": "Close", + "list-datepicker-set": "Set", - "email-service":{ - "email-account-not-configured": "Email account is not configured, cannot send email", - "email-account-mail-app": "You must have the mail app on your phone configured with an email address. Otherwise, this won't work", - "going-to-email": "Going to email database from {{parentDir}}", - "email-log":{ - "subject-logs": "emission logs", - "body-please-fill-in-what-is-wrong": "please fill in what is wrong" - }, - "no-email-address-configured": "No email address configured.", - "email-data":{ - "subject-data-dump-from-to": "Data dump from {{start}} to {{end}}", - "body-data-consists-of-list-of-entries": "Data consists of a list of entries.\nEntry formats are at https://github.com/e-mission/e-mission-server/tree/master/emission/core/wrapper \nData can be loaded locally using instructions at https://github.com/e-mission/e-mission-server#loading-test-data \n and can be manipulated using the example at https://github.com/e-mission/e-mission-server/blob/master/Timeseries_Sample.ipynb" - } - }, + "service": { + "reading-server": "Reading from server...", + "reading-unprocessed-data": "Reading unprocessed data..." + }, - "upload-service":{ - "upload-database": "Uploading database {{db}}", - "upload-from-dir": "from directory {{parentDir}}", - "upload-to-server": "to servers {{serverURL}}", - "please-fill-in-what-is-wrong": "please fill in what is wrong", - "upload-success": "Upload successful", - "upload-progress": "Sending {{filesizemb | number}} MB to {{serverURL}}", - "upload-details": "Sent {{filesizemb | number}} MB to {{serverURL}}" + "email-service": { + "email-account-not-configured": "Email account is not configured, cannot send email", + "email-account-mail-app": "You must have the mail app on your phone configured with an email address. Otherwise, this won't work", + "going-to-email": "Going to email database from {{parentDir}}", + "email-log": { + "subject-logs": "emission logs", + "body-please-fill-in-what-is-wrong": "please fill in what is wrong" }, + "no-email-address-configured": "No email address configured.", + "email-data": { + "subject-data-dump-from-to": "Data dump from {{start}} to {{end}}", + "body-data-consists-of-list-of-entries": "Data consists of a list of entries.\nEntry formats are at https://github.com/e-mission/e-mission-server/tree/master/emission/core/wrapper \nData can be loaded locally using instructions at https://github.com/e-mission/e-mission-server#loading-test-data \n and can be manipulated using the example at https://github.com/e-mission/e-mission-server/blob/master/Timeseries_Sample.ipynb" + } + }, + + "upload-service": { + "upload-database": "Uploading database {{db}}", + "upload-from-dir": "from directory {{parentDir}}", + "upload-to-server": "to servers {{serverURL}}", + "please-fill-in-what-is-wrong": "please fill in what is wrong", + "upload-success": "Upload successful", + "upload-progress": "Sending {{filesizemb | number}} MB to {{serverURL}}", + "upload-details": "Sent {{filesizemb | number}} MB to {{serverURL}}" + }, - "intro": { - "proceed": "Proceed", - "appstatus": { - "fix": "Fix", - "refresh":"Refresh", - "overall-description": "This app works in the background to automatically build a travel diary for you. Make sure that all the settings below are green so that the app can work properly!", - "explanation-title": "What are these used for?", - "overall-loc-name": "Location", - "overall-loc-description": "We use the background location permission to track your location in the background, even when the app is closed. Reading background locations removes the need to turn tracking on and off, making the app easier to use and preventing battery drain.", - "locsettings": { - "name": "Location Settings", - "description": { - "android-lt-9": "Location services should be enabled and set to High Accuracy. This allows us to accurately record the trajectory of the travel", - "android-gte-9": "Location services should be enabled. This allows us to access location data and generate the trip log", - "ios": "Location services should be enabled. This allows us to access location data and generate the trip log" - } - }, - "locperms": { - "name": "Location Permissions", - "description": { - "android-lt-6": "Enabled during app installation.", - "android-6-9": "Please select 'allow'", - "android-10": "Please select 'Allow all the time'", - "android-11": "On the app settings page, choose the 'Location' permission and set it to 'Allow all the time'", - "android-gte-12": "On the app settings page, choose the 'Location' permission and set it to 'Allow all the time' and 'Precise'", - "ios-lt-13": "Please select 'Always allow'", - "ios-gte-13": "On the app settings page, please select 'Always' and 'Precise' and return here to continue" - } - }, - "overall-fitness-name-android": "Physical activity", - "overall-fitness-name-ios": "Motion and Fitness", - "overall-fitness-description": "The fitness sensors distinguish between walking, bicycling and motorized modes. We use this data in order to separate the parts of multi-modal travel such as transit. We also use it to as a cross-check potentially spurious trips - if the location sensor jumps across town but the fitness sensor is stationary, we can guess that the trip was invalid.", - "fitnessperms": { - "name": "Fitness Permission", - "description": { - "android": "Please allow.", - "ios": "Please allow." - } - }, - "overall-notification-name": "Notifications", - "overall-notification-description": "We need to use notifications to inform you if the settings are incorrect. We also use hourly invisible push notifications to wake up the app and allow it to upload data and check app status. We also use notifications to remind you to label your trips.", - "notificationperms": { - "app-enabled-name": "App Notifications", - "description": { - "android-enable": "On the app settings page, ensure that all notifications and channels are enabled.", - "ios-enable": "Please allow, on the popup or the app settings page if necessary" - } - }, - "overall-background-restrictions-name": "Background restrictions", - "overall-background-restrictions-description": "The app runs in the background most of the time to make your life easier. You only need to open it periodically to label trips. Android sometimes restricts apps from working in the background. This prevents us from generating an accurate trip diary. Please remove background restrictions on this app.", - "unusedapprestrict": { - "name": "Unused apps disabled", - "description": { - "android-disable-lt-12": "On the app settings page, go to 'Permissions' and ensure that the app permissions will not be automatically reset.", - "android-disable-12": "On the app settings page, turn off 'Remove permissions and free up space.'", - "android-disable-gte-13": "On the app settings page, turn off 'Pause app activity if unused.'", - "ios": "Please allow." - } - }, - "ignorebatteryopt": { - "name": "Ignore battery optimizations", - "description": "Please allow." - } - }, - "permissions": { - "locationPermExplanation-ios-lt-13": "please select 'Always allow'. This allows us to understand your travel even when you are not actively using the app", - "locationPermExplanation-ios-gte-13": "please select 'always' and 'precise' in the app settings page and return here to continue" + "intro": { + "proceed": "Proceed", + "appstatus": { + "fix": "Fix", + "refresh": "Refresh", + "overall-description": "This app works in the background to automatically build a travel diary for you. Make sure that all the settings below are green so that the app can work properly!", + "explanation-title": "What are these used for?", + "overall-loc-name": "Location", + "overall-loc-description": "We use the background location permission to track your location in the background, even when the app is closed. Reading background locations removes the need to turn tracking on and off, making the app easier to use and preventing battery drain.", + "locsettings": { + "name": "Location Settings", + "description": { + "android-lt-9": "Location services should be enabled and set to High Accuracy. This allows us to accurately record the trajectory of the travel", + "android-gte-9": "Location services should be enabled. This allows us to access location data and generate the trip log", + "ios": "Location services should be enabled. This allows us to access location data and generate the trip log" + } + }, + "locperms": { + "name": "Location Permissions", + "description": { + "android-lt-6": "Enabled during app installation.", + "android-6-9": "Please select 'allow'", + "android-10": "Please select 'Allow all the time'", + "android-11": "On the app settings page, choose the 'Location' permission and set it to 'Allow all the time'", + "android-gte-12": "On the app settings page, choose the 'Location' permission and set it to 'Allow all the time' and 'Precise'", + "ios-lt-13": "Please select 'Always allow'", + "ios-gte-13": "On the app settings page, please select 'Always' and 'Precise' and return here to continue" + } + }, + "overall-fitness-name-android": "Physical activity", + "overall-fitness-name-ios": "Motion and Fitness", + "overall-fitness-description": "The fitness sensors distinguish between walking, bicycling and motorized modes. We use this data in order to separate the parts of multi-modal travel such as transit. We also use it to as a cross-check potentially spurious trips - if the location sensor jumps across town but the fitness sensor is stationary, we can guess that the trip was invalid.", + "fitnessperms": { + "name": "Fitness Permission", + "description": { + "android": "Please allow.", + "ios": "Please allow." } + }, + "overall-notification-name": "Notifications", + "overall-notification-description": "We need to use notifications to inform you if the settings are incorrect. We also use hourly invisible push notifications to wake up the app and allow it to upload data and check app status. We also use notifications to remind you to label your trips.", + "notificationperms": { + "app-enabled-name": "App Notifications", + "description": { + "android-enable": "On the app settings page, ensure that all notifications and channels are enabled.", + "ios-enable": "Please allow, on the popup or the app settings page if necessary" + } + }, + "overall-background-restrictions-name": "Background restrictions", + "overall-background-restrictions-description": "The app runs in the background most of the time to make your life easier. You only need to open it periodically to label trips. Android sometimes restricts apps from working in the background. This prevents us from generating an accurate trip diary. Please remove background restrictions on this app.", + "unusedapprestrict": { + "name": "Unused apps disabled", + "description": { + "android-disable-lt-12": "On the app settings page, go to 'Permissions' and ensure that the app permissions will not be automatically reset.", + "android-disable-12": "On the app settings page, turn off 'Remove permissions and free up space.'", + "android-disable-gte-13": "On the app settings page, turn off 'Pause app activity if unused.'", + "ios": "Please allow." + } + }, + "ignorebatteryopt": { + "name": "Ignore battery optimizations", + "description": "Please allow." + } }, - "allow_background": { - "samsung": "Disable 'Medium power saving mode'" + "permissions": { + "locationPermExplanation-ios-lt-13": "please select 'Always allow'. This allows us to understand your travel even when you are not actively using the app", + "locationPermExplanation-ios-gte-13": "please select 'always' and 'precise' in the app settings page and return here to continue" + } + }, + "allow_background": { + "samsung": "Disable 'Medium power saving mode'" + }, + "consent": { + "permissions": "Permissions", + "button-accept": "I accept", + "button-decline": "I refuse" + }, + "login": { + "make-sure-save-your-opcode": "Make sure to save your OPcode!", + "cannot-retrieve": "NREL cannot retrieve it for you later!", + "save": "Save", + "continue": "Continue", + "enter-existing-token": "Enter the existing token that you have", + "button-accept": "OK", + "button-decline": "Cancel" + }, + "survey": { + "loading-prior-survey": "Loading prior survey responses...", + "prev-survey-found": "Found previous survey response", + "use-prior-response": "Use prior response", + "edit-response": "Edit response", + "move-on": "Move on", + "survey": "Survey", + "save": "Save", + "back": "Back", + "next": "Next", + "powered-by": "Powered by", + "dismiss": "Dismiss", + "return-to-beginning": "Return to beginning", + "go-to-end": "Go to End", + "enketo-form-errors": "Form contains errors. Please see fields marked in red.", + "enketo-timestamps-invalid": "The times you entered are invalid. Please ensure that the start time is before the end time." + }, + "join": { + "welcome-to-app": "Welcome to {{appName}}!", + "app-name": "NREL OpenPATH", + "to-proceed-further": "To proceed further, please scan or paste an access code that has been provided by your program administrator through a website, email, text message, or printout.", + "code-hint": "The code begins with ‘nrelop’ and may be in barcode or text format.", + "scan-code": "Scan code", + "paste-code": "Paste code", + "scan-hint": "Scan the barcode with your phone camera", + "paste-hint": "Or, paste the code as text", + "about-app-title": "About {{appName}}", + "about-app-para-1": "The National Renewable Energy Laboratory’s Open Platform for Agile Trip Heuristics (NREL OpenPATH) enables people to track their travel modes—car, bus, bike, walking, etc.—and measure their associated energy use and carbon footprint.", + "about-app-para-2": "The app empowers communities to understand their travel mode choices and patterns, experiment with options to make them more sustainable, and evaluate the results. Such results can inform effective transportation policy and planning and be used to build more sustainable and accessible cities.", + "about-app-para-3": "It does so by building an automatic diary of all your trips, across all transportation modes. It reads multiple sensors, including location, in the background, and turns GPS tracking on and off automatically for minimal power consumption. The choice of the travel pattern information and the carbon footprint display style are study-specific.", + "tips-title": "Tip(s) for correct operation:", + "all-green-status": "Make sure that all status checks are green", + "dont-force-kill": "Do not force kill the app", + "background-restrictions": "On Samsung and Huwaei phones, make sure that background restrictions are turned off", + "close": "Close" + }, + "config": { + "unable-read-saved-config": "Unable to read saved config", + "unable-to-store-config": "Unable to store downladed config", + "not-enough-parts-old-style": "OPcode {{token}} does not have at least two '_' characters", + "no-nrelop-start": "OPcode {{token}} does not start with 'nrelop'", + "not-enough-parts": "OPcode {{token}} does not have at least three '_' characters", + "invalid-subgroup": "Invalid OPcode {{token}}, subgroup {{subgroup}} not found in list {{config_subgroups}}", + "invalid-subgroup-no-default": "Invalid OPcode {{token}}, no subgroups, expected 'default' subgroup", + "unable-download-config": "Unable to download study config", + "invalid-opcode-format": "Invalid OPcode format", + "error-loading-config-app-start": "Error loading config on app start", + "survey-missing-formpath": "Error while fetching resources in config: survey_info.surveys has a survey without a formPath" + }, + "errors": { + "registration-check-token": "User registration error. Please check your token and try again.", + "not-registered-cant-contact": "User is not registered, so the server cannot be contacted.", + "while-populating-composite": "Error while populating composite trips", + "while-loading-another-week": "Error while loading travel of {{when}} week", + "while-loading-specific-week": "Error while loading travel for the week of {{day}}", + "while-log-messages": "While getting messages from the log ", + "while-max-index": "While getting max index " + }, + "consent-text": { + "title": "NREL OPENPATH PRIVACY POLICY/TERMS OF USE", + "introduction": { + "header": "Introduction and Purpose", + "what-is-openpath": "This data is being collected through OpenPATH, an NREL open-sourced platform. The smart phone application, NREL OpenPATH (“App”), combines data from smartphone sensors, semantic user labels and a short demographic survey.", + "what-is-NREL": "NREL is a national laboratory of the U.S. Department of Energy, Office of Energy Efficiency and Renewable Energy, operated by Alliance for Sustainable Energy, LLC under Prime Contract No. DE-AC36-08GO28308. This Privacy Policy applies to the App provided by Alliance for Sustainable Energy, LLC. This App is provided solely for the purposes of collecting travel behavior data for the {{program_or_study}} and for research to inform public policy. None of the data collected by the App will never be sold or used for any commercial purposes, including advertising.", + "if-disagree": "IF YOU DO NOT AGREE WITH THE TERMS OF THIS PRIVACY POLICY, PLEASE DELETE THE APP" }, - "consent":{ - "permissions" : "Permissions", - "button-accept": "I accept", - "button-decline": "I refuse" + "why": { + "header": "Why we collect this information" }, - "login":{ - "make-sure-save-your-opcode":"Make sure to save your OPcode!", - "cannot-retrieve":"NREL cannot retrieve it for you later!", - "save":"Save", - "continue": "Continue", - "enter-existing-token": "Enter the existing token that you have", - "button-accept": "OK", - "button-decline": "Cancel" + "what": { + "header": "What information we collect", + "no-pii": "The App will never ask for any Personally Identifying Information (PII) such as name, email, address, or phone number.", + "phone-sensor": "It collects phone sensor data pertaining to your location (including background location), accelerometer, device-generated activity and mode recognition, App usage time, and battery usage. The App will create a “travel diary” based on your background location data to determine your travel patterns and location history.", + "labeling": "It will also ask you to periodically annotate these sensed trips with semantic labels, such as the trip mode, purpose, and replaced mode.", + "demographics": "It will also request sociodemographic information such as your approximate age, gender, and household type. The sociodemographic factors can be used to understand the influence of lifestyle on travel behavior, as well as generalize the results to a broader population.", + "open-source-data": "For the greatest transparency, the App is based on an open source platform, NREL’s OpenPATH. you can inspect the data that OpenPATH collects in the background at", + "open-source-analysis": "the analysis pipeline at", + "open-source-dashboard": "and the dashboard metrics at", + "on-nrel-site": "For the greatest transparency, the App is based on an open source platform, NREL’s OpenPATH. you can inspect the data that OpenPATH collects in the background, the analysis pipeline, and the dashboard metrics through links on the NREL OpenPATH website." }, - "survey": { - "loading-prior-survey": "Loading prior survey responses...", - "prev-survey-found": "Found previous survey response", - "use-prior-response": "Use prior response", - "edit-response": "Edit response", - "move-on": "Move on", - "survey": "Survey", - "save": "Save", - "back": "Back", - "next": "Next", - "powered-by": "Powered by", - "dismiss": "Dismiss", - "return-to-beginning": "Return to beginning", - "go-to-end": "Go to End", - "enketo-form-errors": "Form contains errors. Please see fields marked in red.", - "enketo-timestamps-invalid": "The times you entered are invalid. Please ensure that the start time is before the end time." + "opcode": { + "header": "How we associate information with you", + "not-autogen": "Program administrators will provide you with a 'opcode' that you will use to log in to the system. This long random string will be used for all further communication with the server. If you forget or lose your opcode, you may request it by providing your name and/or email address to the program administrator. Please do not contact NREL staff with opcode retrieval requests since we do not have access to the connection between your name/email and your opcode. The data that NREL automatically collects (phone sensor data, semidemographic data, etc.) will only be associated with your 'opcode'", + "autogen": "You are logging in with a randomly generated 'opcode' that has been generated by the platform. This long random string will used for all further communication with the server. Only you know the opcode that is associated with you. There is no “Forgot password” option, and NREL staff cannot retrieve your opcode, even if you provide your name or email address. This means that, unless you store your opcode in a safe place, you will not have access to your prior data if you switch phones or uninstall and reinstall the App." }, - "join": { - "welcome-to-app": "Welcome to {{appName}}!", - "app-name": "NREL OpenPATH", - "to-proceed-further": "To proceed further, please scan or paste an access code that has been provided by your program administrator through a website, email, text message, or printout.", - "code-hint": "The code begins with ‘nrelop’ and may be in barcode or text format.", - "scan-code": "Scan code", - "paste-code": "Paste code", - "scan-hint": "Scan the barcode with your phone camera", - "paste-hint": "Or, paste the code as text", - "about-app-title": "About {{appName}}", - "about-app-para-1": "The National Renewable Energy Laboratory’s Open Platform for Agile Trip Heuristics (NREL OpenPATH) enables people to track their travel modes—car, bus, bike, walking, etc.—and measure their associated energy use and carbon footprint.", - "about-app-para-2": "The app empowers communities to understand their travel mode choices and patterns, experiment with options to make them more sustainable, and evaluate the results. Such results can inform effective transportation policy and planning and be used to build more sustainable and accessible cities.", - "about-app-para-3": "It does so by building an automatic diary of all your trips, across all transportation modes. It reads multiple sensors, including location, in the background, and turns GPS tracking on and off automatically for minimal power consumption. The choice of the travel pattern information and the carbon footprint display style are study-specific.", - "tips-title": "Tip(s) for correct operation:", - "all-green-status": "Make sure that all status checks are green", - "dont-force-kill": "Do not force kill the app", - "background-restrictions": "On Samsung and Huwaei phones, make sure that background restrictions are turned off", - "close": "Close" + "who-sees": { + "header": "Who gets to see the information", + "public-dash": "Aggregate metrics derived from the travel patterns will be made available on a public dashboard to provide transparency into the impact of the program. These metrics will focus on information summaries such as counts, distances and durations, and will not display individual travel locations or times.", + "individual-info": "Individual labeling rates and trip level information will only be made available to:", + "program-admins": "🧑 Program administrators from {{deployment_partner_name}} to {{raw_data_use}}, and", + "nrel-devs": "💻 NREL OpenPATH developers for debugging", + "TSDC-info": "The data will also be periodically archived in NREL’s Transportation Secure Data Center (TSDC) after a delay of 3 to 6 months. It will then be made available for legitimate research through existing, privacy-preserving TSDC operating procedures. Further information on the procedures is available", + "on-website": " on the website ", + "and-in": "and in", + "this-pub": " this publication ", + "and": "and", + "fact-sheet": " fact sheet", + "on-nrel-site": " through links on the NREL OpenPATH website." }, - "config": { - "unable-read-saved-config": "Unable to read saved config", - "unable-to-store-config": "Unable to store downladed config", - "not-enough-parts-old-style": "OPcode {{token}} does not have at least two '_' characters", - "no-nrelop-start": "OPcode {{token}} does not start with 'nrelop'", - "not-enough-parts": "OPcode {{token}} does not have at least three '_' characters", - "invalid-subgroup": "Invalid OPcode {{token}}, subgroup {{subgroup}} not found in list {{config_subgroups}}", - "invalid-subgroup-no-default": "Invalid OPcode {{token}}, no subgroups, expected 'default' subgroup", - "unable-download-config": "Unable to download study config", - "invalid-opcode-format": "Invalid OPcode format", - "error-loading-config-app-start": "Error loading config on app start", - "survey-missing-formpath": "Error while fetching resources in config: survey_info.surveys has a survey without a formPath" + "rights": { + "header": "Your rights", + "app-required": "You are required to track your travel patterns using the App as a condition of participation in the Program. If you wish to withdraw from the Program, you should contact the program administrator, {{program_admin_contact}} to discuss termination options. If you wish to stay in the program but not use the app, please contact your program administrator to negotiate an alternative data collection procedure before uninstalling the app. If you uninstall the app without approval from the program administrator, you may not have access to the benefits provided by the program.", + "app-not-required": "Participation in the {{program_or_study}} is completely voluntary. You have the right to decline to participate or to withdraw at any point in the Study without providing notice to NREL or the point of contact. If you do not wish to participate in the Study or to discontinue your participation in the Study, please delete the App.", + "destroy-data-pt1": "If you would like to have your data destroyed, please contact K. Shankari ", + "destroy-data-pt2": " requesting deletion. You must include your token in the request for deletion. Because we do not connect your identity with your token, we cannot delete your information without obtaining the token as part of the deletion request. We will then destroy all data associated with that deletion request, both in the online and archived datasets." }, - "errors": { - "registration-check-token": "User registration error. Please check your token and try again.", - "not-registered-cant-contact": "User is not registered, so the server cannot be contacted.", - "while-populating-composite": "Error while populating composite trips", - "while-loading-another-week": "Error while loading travel of {{when}} week", - "while-loading-specific-week": "Error while loading travel for the week of {{day}}", - "while-log-messages": "While getting messages from the log ", - "while-max-index" : "While getting max index " + "questions": { + "header": "Questions", + "for-questions": "If you have any questions about the data collection goals and results, please contact the primary point of contact for the study, {{program_admin_contact}}. If you have any technical questions about app operation, please contact NREL’s K. Shankari (k.shankari@nrel.gov)." }, - "consent-text": { - "title":"NREL OPENPATH PRIVACY POLICY/TERMS OF USE", - "introduction":{ - "header":"Introduction and Purpose", - "what-is-openpath":"This data is being collected through OpenPATH, an NREL open-sourced platform. The smart phone application, NREL OpenPATH (“App”), combines data from smartphone sensors, semantic user labels and a short demographic survey.", - "what-is-NREL":"NREL is a national laboratory of the U.S. Department of Energy, Office of Energy Efficiency and Renewable Energy, operated by Alliance for Sustainable Energy, LLC under Prime Contract No. DE-AC36-08GO28308. This Privacy Policy applies to the App provided by Alliance for Sustainable Energy, LLC. This App is provided solely for the purposes of collecting travel behavior data for the {{program_or_study}} and for research to inform public policy. None of the data collected by the App will never be sold or used for any commercial purposes, including advertising.", - "if-disagree":"IF YOU DO NOT AGREE WITH THE TERMS OF THIS PRIVACY POLICY, PLEASE DELETE THE APP" - }, - "why":{ - "header":"Why we collect this information" - }, - "what":{ - "header":"What information we collect", - "no-pii":"The App will never ask for any Personally Identifying Information (PII) such as name, email, address, or phone number.", - "phone-sensor":"It collects phone sensor data pertaining to your location (including background location), accelerometer, device-generated activity and mode recognition, App usage time, and battery usage. The App will create a “travel diary” based on your background location data to determine your travel patterns and location history.", - "labeling":"It will also ask you to periodically annotate these sensed trips with semantic labels, such as the trip mode, purpose, and replaced mode.", - "demographics":"It will also request sociodemographic information such as your approximate age, gender, and household type. The sociodemographic factors can be used to understand the influence of lifestyle on travel behavior, as well as generalize the results to a broader population.", - "open-source-data":"For the greatest transparency, the App is based on an open source platform, NREL’s OpenPATH. you can inspect the data that OpenPATH collects in the background at", - "open-source-analysis":"the analysis pipeline at", - "open-source-dashboard":"and the dashboard metrics at", - "on-nrel-site": "For the greatest transparency, the App is based on an open source platform, NREL’s OpenPATH. you can inspect the data that OpenPATH collects in the background, the analysis pipeline, and the dashboard metrics through links on the NREL OpenPATH website." - }, - "opcode":{ - "header":"How we associate information with you", - "not-autogen":"Program administrators will provide you with a 'opcode' that you will use to log in to the system. This long random string will be used for all further communication with the server. If you forget or lose your opcode, you may request it by providing your name and/or email address to the program administrator. Please do not contact NREL staff with opcode retrieval requests since we do not have access to the connection between your name/email and your opcode. The data that NREL automatically collects (phone sensor data, semidemographic data, etc.) will only be associated with your 'opcode'", - "autogen":"You are logging in with a randomly generated 'opcode' that has been generated by the platform. This long random string will used for all further communication with the server. Only you know the opcode that is associated with you. There is no “Forgot password” option, and NREL staff cannot retrieve your opcode, even if you provide your name or email address. This means that, unless you store your opcode in a safe place, you will not have access to your prior data if you switch phones or uninstall and reinstall the App." - }, - "who-sees":{ - "header":"Who gets to see the information", - "public-dash":"Aggregate metrics derived from the travel patterns will be made available on a public dashboard to provide transparency into the impact of the program. These metrics will focus on information summaries such as counts, distances and durations, and will not display individual travel locations or times.", - "individual-info":"Individual labeling rates and trip level information will only be made available to:", - "program-admins":"🧑 Program administrators from {{deployment_partner_name}} to {{raw_data_use}}, and", - "nrel-devs":"💻 NREL OpenPATH developers for debugging", - "TSDC-info":"The data will also be periodically archived in NREL’s Transportation Secure Data Center (TSDC) after a delay of 3 to 6 months. It will then be made available for legitimate research through existing, privacy-preserving TSDC operating procedures. Further information on the procedures is available", - "on-website":" on the website ", - "and-in":"and in", - "this-pub":" this publication ", - "and":"and", - "fact-sheet":" fact sheet", - "on-nrel-site": " through links on the NREL OpenPATH website." - }, - "rights":{ - "header":"Your rights", - "app-required":"You are required to track your travel patterns using the App as a condition of participation in the Program. If you wish to withdraw from the Program, you should contact the program administrator, {{program_admin_contact}} to discuss termination options. If you wish to stay in the program but not use the app, please contact your program administrator to negotiate an alternative data collection procedure before uninstalling the app. If you uninstall the app without approval from the program administrator, you may not have access to the benefits provided by the program.", - "app-not-required":"Participation in the {{program_or_study}} is completely voluntary. You have the right to decline to participate or to withdraw at any point in the Study without providing notice to NREL or the point of contact. If you do not wish to participate in the Study or to discontinue your participation in the Study, please delete the App.", - "destroy-data-pt1":"If you would like to have your data destroyed, please contact K. Shankari ", - "destroy-data-pt2":" requesting deletion. You must include your token in the request for deletion. Because we do not connect your identity with your token, we cannot delete your information without obtaining the token as part of the deletion request. We will then destroy all data associated with that deletion request, both in the online and archived datasets." - }, - "questions":{ - "header":"Questions", - "for-questions":"If you have any questions about the data collection goals and results, please contact the primary point of contact for the study, {{program_admin_contact}}. If you have any technical questions about app operation, please contact NREL’s K. Shankari (k.shankari@nrel.gov)." - }, - "consent":{ - "header":"Consent", - "press-button-to-consent":"Please select the button below to indicate that you have read and agree to this Privacy Policy, consent to the collection of your information, and want to participate in the {{program_or_study}}." - } + "consent": { + "header": "Consent", + "press-button-to-consent": "Please select the button below to indicate that you have read and agree to this Privacy Policy, consent to the collection of your information, and want to participate in the {{program_or_study}}." } + } } diff --git a/www/index.html b/www/index.html index 451c3047f..b46904cca 100644 --- a/www/index.html +++ b/www/index.html @@ -1,9 +1,13 @@ - + - - - + + + @@ -12,7 +16,7 @@ -

    +
    diff --git a/www/js/App.tsx b/www/js/App.tsx index b1806a5cc..ab4caebf7 100644 --- a/www/js/App.tsx +++ b/www/js/App.tsx @@ -7,24 +7,42 @@ import MetricsTab from './metrics/MetricsTab'; import ProfileSettings from './control/ProfileSettings'; import useAppConfig from './useAppConfig'; import OnboardingStack from './onboarding/OnboardingStack'; -import { OnboardingRoute, OnboardingState, getPendingOnboardingState } from './onboarding/onboardingHelper'; +import { + OnboardingRoute, + OnboardingState, + getPendingOnboardingState, +} from './onboarding/onboardingHelper'; import { setServerConnSettings } from './config/serverConn'; import AppStatusModal from './control/AppStatusModal'; import usePermissionStatus from './usePermissionStatus'; const defaultRoutes = (t) => [ - { key: 'label', title: t('diary.label-tab'), focusedIcon: 'check-bold', unfocusedIcon: 'check-outline' }, - { key: 'metrics', title: t('metrics.dashboard-tab'), focusedIcon: 'chart-box', unfocusedIcon: 'chart-box-outline' }, - { key: 'control', title: t('control.profile-tab'), focusedIcon: 'account', unfocusedIcon: 'account-outline' }, + { + key: 'label', + title: t('diary.label-tab'), + focusedIcon: 'check-bold', + unfocusedIcon: 'check-outline', + }, + { + key: 'metrics', + title: t('metrics.dashboard-tab'), + focusedIcon: 'chart-box', + unfocusedIcon: 'chart-box-outline', + }, + { + key: 'control', + title: t('control.profile-tab'), + focusedIcon: 'account', + unfocusedIcon: 'account-outline', + }, ]; export const AppContext = createContext({}); const App = () => { - const [index, setIndex] = useState(0); // will remain null while the onboarding state is still being determined - const [onboardingState, setOnboardingState] = useState(null); + const [onboardingState, setOnboardingState] = useState(null); const [permissionsPopupVis, setPermissionsPopupVis] = useState(false); const appConfig = useAppConfig(); const permissionStatus = usePermissionStatus(); @@ -33,7 +51,7 @@ const App = () => { const routes = useMemo(() => { const showMetrics = appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL'; - return showMetrics ? defaultRoutes(t) : defaultRoutes(t).filter(r => r.key != 'metrics'); + return showMetrics ? defaultRoutes(t) : defaultRoutes(t).filter((r) => r.key != 'metrics'); }, [appConfig, t]); const renderScene = BottomNavigation.SceneMap({ @@ -43,7 +61,9 @@ const App = () => { }); const refreshOnboardingState = () => getPendingOnboardingState().then(setOnboardingState); - useEffect(() => { refreshOnboardingState() }, []); + useEffect(() => { + refreshOnboardingState(); + }, []); useEffect(() => { if (!appConfig) return; @@ -54,17 +74,20 @@ const App = () => { const appContextValue = { appConfig, - onboardingState, setOnboardingState, refreshOnboardingState, + onboardingState, + setOnboardingState, + refreshOnboardingState, permissionStatus, - permissionsPopupVis, setPermissionsPopupVis, - } + permissionsPopupVis, + setPermissionsPopupVis, + }; console.debug('onboardingState in App', onboardingState); let appContent; if (onboardingState == null) { // if onboarding state is not yet determined, show a loading spinner - appContent = + appContent = ; } else if (onboardingState?.route == OnboardingRoute.DONE) { // if onboarding route is DONE, show the main app with navigation between tabs appContent = ( @@ -78,24 +101,27 @@ const App = () => { barStyle={{ height: 68, justifyContent: 'center', backgroundColor: 'rgba(0,0,0,0)' }} // BottomNavigation uses secondaryContainer color for the background, but we want primaryContainer // (light blue), so we override here. - theme={{ colors: { secondaryContainer: colors.primaryContainer } }} /> + theme={{ colors: { secondaryContainer: colors.primaryContainer } }} + /> ); } else { // if there is an onboarding route that is not DONE, show the onboarding stack - appContent = + appContent = ; } - return (<> - - {appContent} + return ( + <> + + {appContent} - { /* If we are fully consented, (route > PROTOCOL), the permissions popup can show if needed. - This also includes if onboarding is DONE altogether (because "DONE" is > "PROTOCOL") */ } - {(onboardingState && onboardingState.route > OnboardingRoute.PROTOCOL) && - - } - - ); -} + {/* If we are fully consented, (route > PROTOCOL), the permissions popup can show if needed. + This also includes if onboarding is DONE altogether (because "DONE" is > "PROTOCOL") */} + {onboardingState && onboardingState.route > OnboardingRoute.PROTOCOL && ( + + )} + + + ); +}; export default App; diff --git a/www/js/angular-react-helper.tsx b/www/js/angular-react-helper.tsx index 4813ae2e9..3cf891666 100644 --- a/www/js/angular-react-helper.tsx +++ b/www/js/angular-react-helper.tsx @@ -15,11 +15,11 @@ export function getAngularService(name: string) { throw new Error(`Couldn't find "${name}" angular service`); } - return (service as any); // casting to 'any' because not all Angular services are typed + return service as any; // casting to 'any' because not all Angular services are typed } export function createScopeWithVars(vars) { - const scope = getAngularService("$rootScope").$new(); + const scope = getAngularService('$rootScope').$new(); Object.assign(scope, vars); return scope; } diff --git a/www/js/appTheme.ts b/www/js/appTheme.ts index a8660e811..641d1f680 100644 --- a/www/js/appTheme.ts +++ b/www/js/appTheme.ts @@ -28,7 +28,7 @@ const AppTheme = { }, success: '#00a665', // lch(60% 55 155) warn: '#f8cf53', //lch(85% 65 85) - danger: '#f23934' // lch(55% 85 35) + danger: '#f23934', // lch(55% 85 35) }, roundness: 5, }; @@ -47,23 +47,26 @@ type DPartial = { [P in keyof T]?: DPartial }; // https://stackoverflow type PartialTheme = DPartial; const flavorOverrides = { - place: { // for PlaceCards; a blueish color scheme + place: { + // for PlaceCards; a blueish color scheme colors: { elevation: { level1: '#cbe6ff', // lch(90, 20, 250) }, - } + }, }, - untracked: { // for UntrackedTimeCards; a reddish color scheme + untracked: { + // for UntrackedTimeCards; a reddish color scheme colors: { primary: '#8c4a57', // lch(40 30 10) primaryContainer: '#e3bdc2', // lch(80 15 10) elevation: { level1: '#f8ebec', // lch(94 5 10) }, - } + }, }, - draft: { // for TripCards and LabelDetailsScreen of draft trips; a greyish color scheme + draft: { + // for TripCards and LabelDetailsScreen of draft trips; a greyish color scheme colors: { primary: '#616971', // lch(44 6 250) primaryContainer: '#b6bcc2', // lch(76 4 250) @@ -74,7 +77,7 @@ const flavorOverrides = { level1: '#e1e3e4', // lch(90 1 250) level2: '#d2d5d8', // lch(85 2 250) }, - } + }, }, } satisfies Record; @@ -83,7 +86,10 @@ const flavorOverrides = { export const getTheme = (flavor?: keyof typeof flavorOverrides) => { if (!flavorOverrides[flavor]) return AppTheme; const typeStyle = flavorOverrides[flavor]; - const scopedElevation = {...AppTheme.colors.elevation, ...typeStyle?.colors?.elevation}; - const scopedColors = {...AppTheme.colors, ...{...typeStyle.colors, elevation: scopedElevation}}; - return {...AppTheme, colors: scopedColors}; -} + const scopedElevation = { ...AppTheme.colors.elevation, ...typeStyle?.colors?.elevation }; + const scopedColors = { + ...AppTheme.colors, + ...{ ...typeStyle.colors, elevation: scopedElevation }, + }; + return { ...AppTheme, colors: scopedColors }; +}; diff --git a/www/js/appstatus/ExplainPermissions.tsx b/www/js/appstatus/ExplainPermissions.tsx index cb0db4bba..d0d63ebe7 100644 --- a/www/js/appstatus/ExplainPermissions.tsx +++ b/www/js/appstatus/ExplainPermissions.tsx @@ -1,41 +1,34 @@ -import React from "react"; -import { Modal, ScrollView, useWindowDimensions, View } from "react-native"; +import React from 'react'; +import { Modal, ScrollView, useWindowDimensions, View } from 'react-native'; import { Button, Dialog, Text } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; +import { useTranslation } from 'react-i18next'; const ExplainPermissions = ({ explanationList, visible, setVisible }) => { - const { t } = useTranslation(); - const { height: windowHeight } = useWindowDimensions(); + const { t } = useTranslation(); + const { height: windowHeight } = useWindowDimensions(); - return ( - setVisible(false)} > - setVisible(false)} > - {t('intro.appstatus.explanation-title')} - - - {explanationList?.map((li) => - - - {li.name} - - - {li.desc} - - - )} - - - - - - - - ); + return ( + setVisible(false)}> + setVisible(false)}> + {t('intro.appstatus.explanation-title')} + + + {explanationList?.map((li) => ( + + + {li.name} + + {li.desc} + + ))} + + + + + + + + ); }; -export default ExplainPermissions; \ No newline at end of file +export default ExplainPermissions; diff --git a/www/js/appstatus/PermissionItem.tsx b/www/js/appstatus/PermissionItem.tsx index 2899943f1..cd111f3b3 100644 --- a/www/js/appstatus/PermissionItem.tsx +++ b/www/js/appstatus/PermissionItem.tsx @@ -1,21 +1,19 @@ -import React from "react"; +import React from 'react'; import { List, Button } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; +import { useTranslation } from 'react-i18next'; const PermissionItem = ({ check }) => { - const { t } = useTranslation(); + const { t } = useTranslation(); - return ( - } - right={() => } - /> - ); + return ( + } + right={() => } + /> + ); }; - -export default PermissionItem; \ No newline at end of file + +export default PermissionItem; diff --git a/www/js/appstatus/PermissionsControls.tsx b/www/js/appstatus/PermissionsControls.tsx index 97ce7081a..39d5386d3 100644 --- a/www/js/appstatus/PermissionsControls.tsx +++ b/www/js/appstatus/PermissionsControls.tsx @@ -1,67 +1,63 @@ //component to view and manage permission settings -import React, { useContext, useState } from "react"; -import { StyleSheet, ScrollView, View } from "react-native"; +import React, { useContext, useState } from 'react'; +import { StyleSheet, ScrollView, View } from 'react-native'; import { Button, Text } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; -import PermissionItem from "./PermissionItem"; -import { refreshAllChecks } from "../usePermissionStatus"; -import ExplainPermissions from "./ExplainPermissions"; -import AlertBar from "../control/AlertBar"; -import { AppContext } from "../App"; +import { useTranslation } from 'react-i18next'; +import PermissionItem from './PermissionItem'; +import { refreshAllChecks } from '../usePermissionStatus'; +import ExplainPermissions from './ExplainPermissions'; +import AlertBar from '../control/AlertBar'; +import { AppContext } from '../App'; const PermissionsControls = ({ onAccept }) => { - const { t } = useTranslation(); - const [explainVis, setExplainVis] = useState(false); - const { permissionStatus } = useContext(AppContext); - const { checkList, overallStatus, error, errorVis, setErrorVis, explanationList } = permissionStatus; + const { t } = useTranslation(); + const [explainVis, setExplainVis] = useState(false); + const { permissionStatus } = useContext(AppContext); + const { checkList, overallStatus, error, errorVis, setErrorVis, explanationList } = + permissionStatus; - return ( - <> - {t('consent.permissions')} - - {t('intro.appstatus.overall-description')} - - - {checkList?.map((lc) => - - - )} - - - - - + return ( + <> + {t('consent.permissions')} + + {t('intro.appstatus.overall-description')} + + + {checkList?.map((lc) => )} + + + + + - - - ) -} + + + ); +}; const styles = StyleSheet.create({ - title: { - fontWeight: "bold", - fontSize: 22, - paddingBottom: 10 - }, - buttonBox: { - paddingHorizontal: 15, - paddingVertical: 10, - flexDirection: "row", - justifyContent: "space-evenly" - } - }); + title: { + fontWeight: 'bold', + fontSize: 22, + paddingBottom: 10, + }, + buttonBox: { + paddingHorizontal: 15, + paddingVertical: 10, + flexDirection: 'row', + justifyContent: 'space-evenly', + }, +}); export default PermissionsControls; diff --git a/www/js/commHelper.ts b/www/js/commHelper.ts index b9584a044..5f144888b 100644 --- a/www/js/commHelper.ts +++ b/www/js/commHelper.ts @@ -1,5 +1,5 @@ -import { DateTime } from "luxon"; -import { logDebug } from "./plugin/logger"; +import { DateTime } from 'luxon'; +import { logDebug } from './plugin/logger'; /** * @param url URL endpoint for the request @@ -19,8 +19,14 @@ export async function fetchUrlCached(url) { return text; } -export function getRawEntries(key_list, start_ts, end_ts, time_key = "metadata.write_ts", - max_entries = undefined, trunc_method = "sample") { +export function getRawEntries( + key_list, + start_ts, + end_ts, + time_key = 'metadata.write_ts', + max_entries = undefined, + trunc_method = 'sample', +) { return new Promise((rs, rj) => { const msgFiller = (message) => { message.key_list = key_list; @@ -32,18 +38,29 @@ export function getRawEntries(key_list, start_ts, end_ts, time_key = "metadata.w message.trunc_method = trunc_method; } logDebug(`About to return message ${JSON.stringify(message)}`); - } - logDebug("getRawEntries: about to get pushGetJSON for the timestamp"); - window['cordova'].plugins.BEMServerComm.pushGetJSON("/datastreams/find_entries/timestamp", msgFiller, rs, rj); - }).catch(error => { + }; + logDebug('getRawEntries: about to get pushGetJSON for the timestamp'); + window['cordova'].plugins.BEMServerComm.pushGetJSON( + '/datastreams/find_entries/timestamp', + msgFiller, + rs, + rj, + ); + }).catch((error) => { error = `While getting raw entries, ${error}`; - throw(error); + throw error; }); } // time_key is typically metadata.write_ts or data.ts -export function getRawEntriesForLocalDate(key_list, start_ts, end_ts, time_key = "metadata.write_ts", - max_entries = undefined, trunc_method = "sample") { +export function getRawEntriesForLocalDate( + key_list, + start_ts, + end_ts, + time_key = 'metadata.write_ts', + max_entries = undefined, + trunc_method = 'sample', +) { return new Promise((rs, rj) => { const msgFiller = (message) => { message.key_list = key_list; @@ -54,105 +71,137 @@ export function getRawEntriesForLocalDate(key_list, start_ts, end_ts, time_key = message.max_entries = max_entries; message.trunc_method = trunc_method; } - logDebug("About to return message " + JSON.stringify(message)); + logDebug('About to return message ' + JSON.stringify(message)); }; - logDebug("getRawEntries: about to get pushGetJSON for the timestamp"); - window['cordova'].plugins.BEMServerComm.pushGetJSON("/datastreams/find_entries/local_date", msgFiller, rs, rj); - }).catch(error => { - error = "While getting raw entries for local date, " + error; - throw (error); + logDebug('getRawEntries: about to get pushGetJSON for the timestamp'); + window['cordova'].plugins.BEMServerComm.pushGetJSON( + '/datastreams/find_entries/local_date', + msgFiller, + rs, + rj, + ); + }).catch((error) => { + error = 'While getting raw entries for local date, ' + error; + throw error; }); -}; +} export function getPipelineRangeTs() { return new Promise((rs, rj) => { - logDebug("getting pipeline range timestamps"); - window['cordova'].plugins.BEMServerComm.getUserPersonalData("/pipeline/get_range_ts", rs, rj); - }).catch(error => { + logDebug('getting pipeline range timestamps'); + window['cordova'].plugins.BEMServerComm.getUserPersonalData('/pipeline/get_range_ts', rs, rj); + }).catch((error) => { error = `While getting pipeline range timestamps, ${error}`; - throw(error); + throw error; }); } export function getPipelineCompleteTs() { return new Promise((rs, rj) => { - logDebug("getting pipeline complete timestamp"); - window['cordova'].plugins.BEMServerComm.getUserPersonalData("/pipeline/get_complete_ts", rs, rj); - }).catch(error => { + logDebug('getting pipeline complete timestamp'); + window['cordova'].plugins.BEMServerComm.getUserPersonalData( + '/pipeline/get_complete_ts', + rs, + rj, + ); + }).catch((error) => { error = `While getting pipeline complete timestamp, ${error}`; - throw(error); + throw error; }); } -export function getMetrics(timeType: 'timestamp'|'local_date', metricsQuery) { +export function getMetrics(timeType: 'timestamp' | 'local_date', metricsQuery) { return new Promise((rs, rj) => { const msgFiller = (message) => { for (let key in metricsQuery) { message[key] = metricsQuery[key]; } - } - window['cordova'].plugins.BEMServerComm.pushGetJSON(`/result/metrics/${timeType}`, msgFiller, rs, rj); - }).catch(error => { + }; + window['cordova'].plugins.BEMServerComm.pushGetJSON( + `/result/metrics/${timeType}`, + msgFiller, + rs, + rj, + ); + }).catch((error) => { error = `While getting metrics, ${error}`; - throw(error); + throw error; }); } export function getAggregateData(path: string, data: any) { return new Promise((rs, rj) => { const fullUrl = `${window['$rootScope'].connectUrl}/${path}`; - data["aggregate"] = true; + data['aggregate'] = true; - if (window['$rootScope'].aggregateAuth === "no_auth") { - logDebug(`getting aggregate data without user authentication from ${fullUrl} with arguments ${JSON.stringify(data)}`); + if (window['$rootScope'].aggregateAuth === 'no_auth') { + logDebug( + `getting aggregate data without user authentication from ${fullUrl} with arguments ${JSON.stringify( + data, + )}`, + ); const options = { method: 'post', data: data, - responseType: 'json' - } - window['cordova'].plugin.http.sendRequest(fullUrl, options, + responseType: 'json', + }; + window['cordova'].plugin.http.sendRequest( + fullUrl, + options, (response) => { rs(response.data); - }, (error) => { + }, + (error) => { rj(error); - }); + }, + ); } else { - logDebug(`getting aggregate data with user authentication from ${fullUrl} with arguments ${JSON.stringify(data)}`); + logDebug( + `getting aggregate data with user authentication from ${fullUrl} with arguments ${JSON.stringify( + data, + )}`, + ); const msgFiller = (message) => { return Object.assign(message, data); - } + }; window['cordova'].plugins.BEMServerComm.pushGetJSON(`/${path}`, msgFiller, rs, rj); } - }).catch(error => { + }).catch((error) => { error = `While getting aggregate data, ${error}`; - throw(error); + throw error; }); } export function registerUser() { return new Promise((rs, rj) => { - window['cordova'].plugins.BEMServerComm.getUserPersonalData("/profile/create", rs, rj); - }).catch(error => { + window['cordova'].plugins.BEMServerComm.getUserPersonalData('/profile/create', rs, rj); + }).catch((error) => { error = `While registering user, ${error}`; - throw(error); + throw error; }); } export function updateUser(updateDoc) { return new Promise((rs, rj) => { - window['cordova'].plugins.BEMServerComm.postUserPersonalData("/profile/update", "update_doc", updateDoc, rs, rj); - }).catch(error => { + window['cordova'].plugins.BEMServerComm.postUserPersonalData( + '/profile/update', + 'update_doc', + updateDoc, + rs, + rj, + ); + }).catch((error) => { error = `While updating user, ${error}`; - throw(error); + throw error; }); } export function getUser() { return new Promise((rs, rj) => { - window['cordova'].plugins.BEMServerComm.getUserPersonalData("/profile/get", rs, rj); - }).catch(error => { + window['cordova'].plugins.BEMServerComm.getUserPersonalData('/profile/get', rs, rj); + }).catch((error) => { error = `While getting user, ${error}`; - throw(error); + throw error; }); } @@ -162,15 +211,21 @@ export function putOne(key, data) { write_ts: nowTs, read_ts: nowTs, time_zone: DateTime.local().zoneName, - type: "message", + type: 'message', key: key, platform: window['device'].platform, }; const entryToPut = { metadata, data }; return new Promise((rs, rj) => { - window['cordova'].plugins.BEMServerComm.postUserPersonalData("/usercache/putone", "the_entry", entryToPut, rs, rj); - }).catch(error => { - error = "While putting one entry, " + error; - throw(error); + window['cordova'].plugins.BEMServerComm.postUserPersonalData( + '/usercache/putone', + 'the_entry', + entryToPut, + rs, + rj, + ); + }).catch((error) => { + error = 'While putting one entry, ' + error; + throw error; }); -}; +} diff --git a/www/js/components/ActionMenu.tsx b/www/js/components/ActionMenu.tsx index 296717a00..0693acc8b 100644 --- a/www/js/components/ActionMenu.tsx +++ b/www/js/components/ActionMenu.tsx @@ -1,41 +1,44 @@ -import React from "react"; -import { Modal } from "react-native"; -import { Dialog, Button, useTheme } from "react-native-paper"; -import { useTranslation } from "react-i18next"; -import { settingStyles } from "../control/ProfileSettings"; +import React from 'react'; +import { Modal } from 'react-native'; +import { Dialog, Button, useTheme } from 'react-native-paper'; +import { useTranslation } from 'react-i18next'; +import { settingStyles } from '../control/ProfileSettings'; -const ActionMenu = ({vis, setVis, title, actionSet, onAction, onExit}) => { +const ActionMenu = ({ vis, setVis, title, actionSet, onAction, onExit }) => { + const { t } = useTranslation(); + const { colors } = useTheme(); - const { t } = useTranslation(); - const { colors } = useTheme(); + return ( + setVis(false)} transparent={true}> + setVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + {title} + + {actionSet?.map((e) => ( + + ))} + + + + + + + ); +}; - return ( - setVis(false)} - transparent={true}> - setVis(false)} - style={settingStyles.dialog(colors.elevation.level3)}> - {title} - - {actionSet?.map((e) => - - )} - - - - - - - ) -} - -export default ActionMenu; \ No newline at end of file +export default ActionMenu; diff --git a/www/js/components/BarChart.tsx b/www/js/components/BarChart.tsx index 1e957923b..ccf1a6f74 100644 --- a/www/js/components/BarChart.tsx +++ b/www/js/components/BarChart.tsx @@ -1,13 +1,12 @@ -import React from "react"; -import Chart, { Props as ChartProps } from "./Chart"; -import { useTheme } from "react-native-paper"; -import { getGradient } from "./charting"; +import React from 'react'; +import Chart, { Props as ChartProps } from './Chart'; +import { useTheme } from 'react-native-paper'; +import { getGradient } from './charting'; type Props = Omit & { - meter?: {high: number, middle: number, dash_key: string}, -} + meter?: { high: number; middle: number; dash_key: string }; +}; const BarChart = ({ meter, ...rest }: Props) => { - const { colors } = useTheme(); if (meter) { @@ -15,13 +14,11 @@ const BarChart = ({ meter, ...rest }: Props) => { const darkenDegree = colorFor == 'border' ? 0.25 : 0; const alpha = colorFor == 'border' ? 1 : 0; return getGradient(chart, meter, dataset, ctx, alpha, darkenDegree); - } + }; rest.borderWidth = 3; } - return ( - - ); -} + return ; +}; export default BarChart; diff --git a/www/js/components/Carousel.tsx b/www/js/components/Carousel.tsx index 28a31ff6a..92febb32b 100644 --- a/www/js/components/Carousel.tsx +++ b/www/js/components/Carousel.tsx @@ -2,26 +2,27 @@ import React from 'react'; import { ScrollView, View } from 'react-native'; type Props = { - children: React.ReactNode, - cardWidth: number, - cardMargin: number, -} + children: React.ReactNode; + cardWidth: number; + cardMargin: number; +}; const Carousel = ({ children, cardWidth, cardMargin }: Props) => { const numCards = React.Children.count(children); return ( - + contentContainerStyle={{ alignItems: 'flex-start' }}> {React.Children.map(children, (child, i) => ( - + {child} ))} - ) + ); }; export const s = { @@ -31,8 +32,8 @@ export const s = { paddingVertical: 10, }), carouselCard: (cardWidth, cardMargin, isFirst, isLast) => ({ - marginLeft: isFirst ? cardMargin : cardMargin/2, - marginRight: isLast ? cardMargin : cardMargin/2, + marginLeft: isFirst ? cardMargin : cardMargin / 2, + marginRight: isLast ? cardMargin : cardMargin / 2, width: cardWidth, scrollSnapAlign: 'center', scrollSnapStop: 'always', diff --git a/www/js/components/Chart.tsx b/www/js/components/Chart.tsx index 79c6e40e4..d7687e424 100644 --- a/www/js/components/Chart.tsx +++ b/www/js/components/Chart.tsx @@ -1,4 +1,3 @@ - import React, { useEffect, useRef, useState, useMemo } from 'react'; import { View } from 'react-native'; import { useTheme } from 'react-native-paper'; @@ -9,48 +8,62 @@ import { dedupColors, getChartHeight, darkenOrLighten } from './charting'; ChartJS.register(...registerables, Annotation); -type XYPair = { x: number|string, y: number|string }; +type XYPair = { x: number | string; y: number | string }; type ChartDataset = { - label: string, - data: XYPair[], + label: string; + data: XYPair[]; }; export type Props = { - records: { label: string, x: number|string, y: number|string }[], - axisTitle: string, - type: 'bar'|'line', - getColorForLabel?: (label: string) => string, - getColorForChartEl?: (chart, currDataset: ChartDataset, ctx: ScriptableContext<'bar'|'line'>, colorFor: 'background'|'border') => string|CanvasGradient|null, - borderWidth?: number, - lineAnnotations?: { value: number, label?: string, color?:string, position?: LabelPosition }[], - isHorizontal?: boolean, - timeAxis?: boolean, - stacked?: boolean, -} -const Chart = ({ records, axisTitle, type, getColorForLabel, getColorForChartEl, borderWidth, lineAnnotations, isHorizontal, timeAxis, stacked }: Props) => { - + records: { label: string; x: number | string; y: number | string }[]; + axisTitle: string; + type: 'bar' | 'line'; + getColorForLabel?: (label: string) => string; + getColorForChartEl?: ( + chart, + currDataset: ChartDataset, + ctx: ScriptableContext<'bar' | 'line'>, + colorFor: 'background' | 'border', + ) => string | CanvasGradient | null; + borderWidth?: number; + lineAnnotations?: { value: number; label?: string; color?: string; position?: LabelPosition }[]; + isHorizontal?: boolean; + timeAxis?: boolean; + stacked?: boolean; +}; +const Chart = ({ + records, + axisTitle, + type, + getColorForLabel, + getColorForChartEl, + borderWidth, + lineAnnotations, + isHorizontal, + timeAxis, + stacked, +}: Props) => { const { colors } = useTheme(); - const [ numVisibleDatasets, setNumVisibleDatasets ] = useState(1); + const [numVisibleDatasets, setNumVisibleDatasets] = useState(1); const indexAxis = isHorizontal ? 'y' : 'x'; - const chartRef = useRef>(null); + const chartRef = useRef>(null); const [chartDatasets, setChartDatasets] = useState([]); - - const chartData = useMemo>(() => { + + const chartData = useMemo>(() => { let labelColorMap; // object mapping labels to colors if (getColorForLabel) { - const colorEntries = chartDatasets.map(d => [d.label, getColorForLabel(d.label)] ); + const colorEntries = chartDatasets.map((d) => [d.label, getColorForLabel(d.label)]); labelColorMap = dedupColors(colorEntries); } return { datasets: chartDatasets.map((e, i) => ({ ...e, - backgroundColor: (barCtx) => ( - labelColorMap?.[e.label] || getColorForChartEl(chartRef.current, e, barCtx, 'background') - ), - borderColor: (barCtx) => ( - darkenOrLighten(labelColorMap?.[e.label], -.5) || getColorForChartEl(chartRef.current, e, barCtx, 'border') - ), + backgroundColor: (barCtx) => + labelColorMap?.[e.label] || getColorForChartEl(chartRef.current, e, barCtx, 'background'), + borderColor: (barCtx) => + darkenOrLighten(labelColorMap?.[e.label], -0.5) || + getColorForChartEl(chartRef.current, e, barCtx, 'border'), borderWidth: borderWidth || 2, borderRadius: 3, })), @@ -60,14 +73,16 @@ const Chart = ({ records, axisTitle, type, getColorForLabel, getColorForChartEl, // group records by label (this is the format that Chart.js expects) useEffect(() => { const d = records?.reduce((acc, record) => { - const existing = acc.find(e => e.label == record.label); + const existing = acc.find((e) => e.label == record.label); if (!existing) { acc.push({ label: record.label, - data: [{ - x: record.x, - y: record.y, - }], + data: [ + { + x: record.x, + y: record.y, + }, + ], }); } else { existing.data.push({ @@ -80,11 +95,15 @@ const Chart = ({ records, axisTitle, type, getColorForLabel, getColorForChartEl, setChartDatasets(d); }, [records]); - const annotationsAtTop = isHorizontal && lineAnnotations?.some(a => (!a.position || a.position == 'start')); + const annotationsAtTop = + isHorizontal && lineAnnotations?.some((a) => !a.position || a.position == 'start'); return ( - - + { - setNumVisibleDatasets(axis.chart.getVisibleDatasetCount()) - }, - ticks: timeAxis ? {} : { - callback: (value, i) => { - const label = chartDatasets[0].data[i].y; - if (typeof label == 'string' && label.includes('\n')) - return label.split('\n'); - return label; + ...(isHorizontal + ? { + y: { + offset: true, + type: timeAxis ? 'time' : 'category', + adapters: timeAxis + ? { + date: { zone: 'utc' }, + } + : {}, + time: timeAxis + ? { + unit: 'day', + tooltipFormat: 'DDD', // Luxon "localized date with full month": e.g. August 6, 2014 + } + : {}, + beforeUpdate: (axis) => { + setNumVisibleDatasets(axis.chart.getVisibleDatasetCount()); + }, + ticks: timeAxis + ? {} + : { + callback: (value, i) => { + const label = chartDatasets[0].data[i].y; + if (typeof label == 'string' && label.includes('\n')) + return label.split('\n'); + return label; + }, + font: { size: 11 }, // default is 12, we want a tad smaller + }, + reverse: true, + stacked, }, - font: { size: 11 }, // default is 12, we want a tad smaller - }, - reverse: true, - stacked, - }, - x: { - title: { display: true, text: axisTitle }, - stacked, - }, - } : { - x: { - offset: true, - type: timeAxis ? 'time' : 'category', - adapters: timeAxis ? { - date: { zone: 'utc' }, - } : {}, - time: timeAxis ? { - unit: 'day', - tooltipFormat: 'DDD', // Luxon "localized date with full month": e.g. August 6, 2014 - } : {}, - ticks: timeAxis ? {} : { - callback: (value, i) => { - console.log("testing vertical", chartData, i); - const label = chartDatasets[0].data[i].x; - if (typeof label == 'string' && label.includes('\n')) - return label.split('\n'); - return label; + x: { + title: { display: true, text: axisTitle }, + stacked, }, - }, - stacked, - }, - y: { - title: { display: true, text: axisTitle }, - stacked, - }, - }), + } + : { + x: { + offset: true, + type: timeAxis ? 'time' : 'category', + adapters: timeAxis + ? { + date: { zone: 'utc' }, + } + : {}, + time: timeAxis + ? { + unit: 'day', + tooltipFormat: 'DDD', // Luxon "localized date with full month": e.g. August 6, 2014 + } + : {}, + ticks: timeAxis + ? {} + : { + callback: (value, i) => { + console.log('testing vertical', chartData, i); + const label = chartDatasets[0].data[i].x; + if (typeof label == 'string' && label.includes('\n')) + return label.split('\n'); + return label; + }, + }, + stacked, + }, + y: { + title: { display: true, text: axisTitle }, + stacked, + }, + }), }, plugins: { ...(lineAnnotations?.length > 0 && { annotation: { clip: false, - annotations: lineAnnotations.map((a, i) => ({ - type: 'line', - label: { - display: true, - padding: { x: 3, y: 1 }, - borderRadius: 0, - backgroundColor: 'rgba(0,0,0,.7)', - color: 'rgba(255,255,255,1)', - font: { size: 10 }, - position: a.position || 'start', - content: a.label, - yAdjust: annotationsAtTop ? -12 : 0, - }, - ...(isHorizontal ? { xMin: a.value, xMax: a.value } - : { yMin: a.value, yMax: a.value }), - borderColor: a.color || colors.onBackground, - borderWidth: 3, - borderDash: [3, 3], - } satisfies AnnotationOptions)), - } + annotations: lineAnnotations.map( + (a, i) => + ({ + type: 'line', + label: { + display: true, + padding: { x: 3, y: 1 }, + borderRadius: 0, + backgroundColor: 'rgba(0,0,0,.7)', + color: 'rgba(255,255,255,1)', + font: { size: 10 }, + position: a.position || 'start', + content: a.label, + yAdjust: annotationsAtTop ? -12 : 0, + }, + ...(isHorizontal + ? { xMin: a.value, xMax: a.value } + : { yMin: a.value, yMax: a.value }), + borderColor: a.color || colors.onBackground, + borderWidth: 3, + borderDash: [3, 3], + }) satisfies AnnotationOptions, + ), + }, }), - } + }, }} // if there are annotations at the top of the chart, it overlaps with the legend // so we need to increase the spacing between the legend and the chart // https://stackoverflow.com/a/73498454 - plugins={annotationsAtTop && [{ - id: "increase-legend-spacing", - beforeInit(chart) { - const originalFit = (chart.legend as any).fit; - (chart.legend as any).fit = function fit() { - originalFit.bind(chart.legend)(); - this.height += 12; - }; - } - }]} /> + plugins={ + annotationsAtTop && [ + { + id: 'increase-legend-spacing', + beforeInit(chart) { + const originalFit = (chart.legend as any).fit; + (chart.legend as any).fit = function fit() { + originalFit.bind(chart.legend)(); + this.height += 12; + }; + }, + }, + ] + } + /> - ) -} + ); +}; export default Chart; diff --git a/www/js/components/DiaryButton.tsx b/www/js/components/DiaryButton.tsx index 16c716f93..6a04cb079 100644 --- a/www/js/components/DiaryButton.tsx +++ b/www/js/components/DiaryButton.tsx @@ -1,28 +1,25 @@ -import React from "react"; +import React from 'react'; import { StyleSheet } from 'react-native'; import { Button, ButtonProps, useTheme } from 'react-native-paper'; import color from 'color'; -import { Icon } from "./Icon"; - -type Props = ButtonProps & { fillColor?: string, borderColor?: string }; -const DiaryButton = ({ children, fillColor, borderColor, icon, ...rest } : Props) => { +import { Icon } from './Icon'; +type Props = ButtonProps & { fillColor?: string; borderColor?: string }; +const DiaryButton = ({ children, fillColor, borderColor, icon, ...rest }: Props) => { const { colors } = useTheme(); const textColor = rest.textColor || (fillColor ? colors.onPrimary : colors.primary); return ( - @@ -51,7 +48,7 @@ const s = StyleSheet.create({ icon: { marginRight: 4, verticalAlign: 'middle', - } + }, }); export default DiaryButton; diff --git a/www/js/components/Icon.tsx b/www/js/components/Icon.tsx index 0b4c7253e..3d13d0996 100644 --- a/www/js/components/Icon.tsx +++ b/www/js/components/Icon.tsx @@ -7,14 +7,19 @@ import React from 'react'; import { StyleSheet } from 'react-native'; import { IconButton } from 'react-native-paper'; -import { Props as IconButtonProps } from 'react-native-paper/lib/typescript/src/components/IconButton/IconButton' +import { Props as IconButtonProps } from 'react-native-paper/lib/typescript/src/components/IconButton/IconButton'; -export const Icon = ({style, ...rest}: IconButtonProps) => { +export const Icon = ({ style, ...rest }: IconButtonProps) => { return ( - + ); -} +}; const s = StyleSheet.create({ icon: { diff --git a/www/js/components/LeafletView.tsx b/www/js/components/LeafletView.tsx index cf26cb933..b0b60912b 100644 --- a/www/js/components/LeafletView.tsx +++ b/www/js/components/LeafletView.tsx @@ -1,15 +1,14 @@ -import React, { useEffect, useRef, useState } from "react"; -import { View } from "react-native"; -import { useTheme } from "react-native-paper"; -import L from "leaflet"; +import React, { useEffect, useRef, useState } from 'react'; +import { View } from 'react-native'; +import { useTheme } from 'react-native-paper'; +import L from 'leaflet'; const mapSet = new Set(); export function invalidateMaps() { - mapSet.forEach(map => map.invalidateSize()); + mapSet.forEach((map) => map.invalidateSize()); } const LeafletView = ({ geojson, opts, ...otherProps }) => { - const mapElRef = useRef(null); const leafletMapRef = useRef(null); const geoJsonIdRef = useRef(null); @@ -23,7 +22,7 @@ const LeafletView = ({ geojson, opts, ...otherProps }) => { }).addTo(map); const gj = L.geoJson(geojson.data, { pointToLayer: pointToLayer, - style: (feature) => feature.style + style: (feature) => feature.style, }).addTo(map); const gjBounds = gj.getBounds().pad(0.2); map.fitBounds(gjBounds); @@ -46,7 +45,7 @@ const LeafletView = ({ geojson, opts, ...otherProps }) => { (happens because of FlashList's view recycling on the trip cards: https://shopify.github.io/flash-list/docs/recycling) */ if (geoJsonIdRef.current && geoJsonIdRef.current !== geojson.data.id) { - leafletMapRef.current.eachLayer(layer => leafletMapRef.current.removeLayer(layer)); + leafletMapRef.current.eachLayer((layer) => leafletMapRef.current.removeLayer(layer)); initMap(leafletMapRef.current); } @@ -54,7 +53,7 @@ const LeafletView = ({ geojson, opts, ...otherProps }) => { const mapElId = `map-${geojson.data.id.replace(/[^a-zA-Z0-9]/g, '')}`; return ( - + -
    +
    ); }; -const startIcon = L.divIcon({className: 'leaflet-div-icon-start', iconSize: [18, 18]}); -const stopIcon = L.divIcon({className: 'leaflet-div-icon-stop', iconSize: [18, 18]}); +const startIcon = L.divIcon({ className: 'leaflet-div-icon-start', iconSize: [18, 18] }); +const stopIcon = L.divIcon({ className: 'leaflet-div-icon-stop', iconSize: [18, 18] }); - const pointToLayer = (feature, latlng) => { - switch(feature.properties.feature_type) { - case "start_place": return L.marker(latlng, {icon: startIcon}); - case "end_place": return L.marker(latlng, {icon: stopIcon}); +const pointToLayer = (feature, latlng) => { + switch (feature.properties.feature_type) { + case 'start_place': + return L.marker(latlng, { icon: startIcon }); + case 'end_place': + return L.marker(latlng, { icon: stopIcon }); // case "stop": return L.circleMarker(latlng); - default: alert("Found unknown type in feature" + feature); return L.marker(latlng) + default: + alert('Found unknown type in feature' + feature); + return L.marker(latlng); } }; diff --git a/www/js/components/LineChart.tsx b/www/js/components/LineChart.tsx index 66d21aac2..456642a63 100644 --- a/www/js/components/LineChart.tsx +++ b/www/js/components/LineChart.tsx @@ -1,11 +1,9 @@ -import React from "react"; -import Chart, { Props as ChartProps } from "./Chart"; +import React from 'react'; +import Chart, { Props as ChartProps } from './Chart'; -type Props = Omit & { } +type Props = Omit & {}; const LineChart = ({ ...rest }: Props) => { - return ( - - ); -} + return ; +}; export default LineChart; diff --git a/www/js/components/NavBarButton.tsx b/www/js/components/NavBarButton.tsx index 7e9cb1217..294015152 100644 --- a/www/js/components/NavBarButton.tsx +++ b/www/js/components/NavBarButton.tsx @@ -1,31 +1,39 @@ -import React from "react"; -import { View, StyleSheet } from "react-native"; -import color from "color"; -import { Button, useTheme } from "react-native-paper"; -import { Icon } from "./Icon"; +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import color from 'color'; +import { Button, useTheme } from 'react-native-paper'; +import { Icon } from './Icon'; const NavBarButton = ({ children, icon, onPressAction, ...otherProps }) => { - const { colors } = useTheme(); - const buttonColor = color(colors.onBackground).alpha(.07).rgb().string(); - const outlineColor = color(colors.onBackground).alpha(.2).rgb().string(); + const buttonColor = color(colors.onBackground).alpha(0.07).rgb().string(); + const outlineColor = color(colors.onBackground).alpha(0.2).rgb().string(); - return (<> - - ); + return ( + <> + + + ); }; export const s = StyleSheet.create({ diff --git a/www/js/components/QrCode.tsx b/www/js/components/QrCode.tsx index 74c66863f..83498f5da 100644 --- a/www/js/components/QrCode.tsx +++ b/www/js/components/QrCode.tsx @@ -2,45 +2,56 @@ Once the parent components, anyplace this is used, are converted to React, we can remove this wrapper and just use the QRCode component directly */ -import React from "react"; -import QRCode from "react-qr-code"; +import React from 'react'; +import QRCode from 'react-qr-code'; export function shareQR(message) { /*code adapted from demo of react-qr-code*/ - const svg = document.querySelector(".qr-code"); + const svg = document.querySelector('.qr-code'); const svgData = new XMLSerializer().serializeToString(svg); const img = new Image(); img.onload = () => { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0); - const pngFile = canvas.toDataURL("image/png"); + const pngFile = canvas.toDataURL('image/png'); var prepopulateQRMessage = {}; prepopulateQRMessage['files'] = [pngFile]; prepopulateQRMessage['url'] = message; prepopulateQRMessage['message'] = message; //text saved to files with image! - window['plugins'].socialsharing.shareWithOptions(prepopulateQRMessage, function (result) { - console.log("Share completed? " + result.completed); // On Android apps mostly return false even while it's true - console.log("Shared to app: " + result.app); // On Android result.app is currently empty. On iOS it's empty when sharing is cancelled (result.completed=false) - }, function (msg) { - console.log("Sharing failed with message: " + msg); - }); - } + window['plugins'].socialsharing.shareWithOptions( + prepopulateQRMessage, + function (result) { + console.log('Share completed? ' + result.completed); // On Android apps mostly return false even while it's true + console.log('Shared to app: ' + result.app); // On Android result.app is currently empty. On iOS it's empty when sharing is cancelled (result.completed=false) + }, + function (msg) { + console.log('Sharing failed with message: ' + msg); + }, + ); + }; img.src = `data:image/svg+xml;base64,${btoa(svgData)}`; } const QrCode = ({ value, ...rest }) => { - let hasLink = value.toString().includes("//"); - if(!hasLink) { - value = "emission://login_token?token=" + value; + let hasLink = value.toString().includes('//'); + if (!hasLink) { + value = 'emission://login_token?token=' + value; } - - return ; + + return ( + + ); }; export default QrCode; diff --git a/www/js/components/ToggleSwitch.tsx b/www/js/components/ToggleSwitch.tsx index 5fdf1cc46..7f753a9a0 100644 --- a/www/js/components/ToggleSwitch.tsx +++ b/www/js/components/ToggleSwitch.tsx @@ -1,13 +1,14 @@ import React from 'react'; -import { SegmentedButtons, SegmentedButtonsProps, useTheme } from "react-native-paper"; - -const ToggleSwitch = ({ value, buttons, ...rest}: SegmentedButtonsProps) => { +import { SegmentedButtons, SegmentedButtonsProps, useTheme } from 'react-native-paper'; +const ToggleSwitch = ({ value, buttons, ...rest }: SegmentedButtonsProps) => { const { colors } = useTheme(); return ( - rest.onValueChange(v as any)} - buttons={buttons.map(o => ({ + rest.onValueChange(v as any)} + buttons={buttons.map((o) => ({ value: o.value, icon: o.icon, uncheckedColor: colors.onSurfaceDisabled, @@ -18,9 +19,11 @@ const ToggleSwitch = ({ value, buttons, ...rest}: SegmentedButtonsProps) => { borderBottomWidth: rest.density == 'high' ? 0 : 1, backgroundColor: value == o.value ? colors.elevation.level1 : colors.surfaceDisabled, }, - ...o - }))} {...rest} /> - ) -} + ...o, + }))} + {...rest} + /> + ); +}; export default ToggleSwitch; diff --git a/www/js/components/charting.ts b/www/js/components/charting.ts index f0da14619..77490f7ff 100644 --- a/www/js/components/charting.ts +++ b/www/js/components/charting.ts @@ -15,15 +15,23 @@ export const defaultPalette = [ '#80afad', // teal oklch(72% 0.05 192) ]; -export function getChartHeight(chartDatasets, numVisibleDatasets, indexAxis, isHorizontal, stacked) { +export function getChartHeight( + chartDatasets, + numVisibleDatasets, + indexAxis, + isHorizontal, + stacked, +) { /* when horizontal charts have more data, they should get taller so they don't look squished */ if (isHorizontal) { // 'ideal' chart height is based on the number of datasets and number of unique index values const uniqueIndexVals = []; - chartDatasets.forEach(e => e.data.forEach(r => { - if (!uniqueIndexVals.includes(r[indexAxis])) uniqueIndexVals.push(r[indexAxis]); - })); + chartDatasets.forEach((e) => + e.data.forEach((r) => { + if (!uniqueIndexVals.includes(r[indexAxis])) uniqueIndexVals.push(r[indexAxis]); + }), + ); const numIndexVals = uniqueIndexVals.length; const heightPerIndexVal = stacked ? 36 : numVisibleDatasets * 8; const idealChartHeight = heightPerIndexVal * numIndexVals; @@ -41,11 +49,11 @@ export function getChartHeight(chartDatasets, numVisibleDatasets, indexAxis, isH function getBarHeight(stacks) { let totalHeight = 0; - console.log("ctx stacks", stacks.x); - for(let val in stacks.x) { - if(!val.startsWith('_')){ + console.log('ctx stacks', stacks.x); + for (let val in stacks.x) { + if (!val.startsWith('_')) { totalHeight += stacks.x[val]; - console.log("ctx added ", val ); + console.log('ctx added ', val); } } return totalHeight; @@ -54,27 +62,34 @@ function getBarHeight(stacks) { //fill pattern creation //https://stackoverflow.com/questions/28569667/fill-chart-js-bar-chart-with-diagonal-stripes-or-other-patterns function createDiagonalPattern(color = 'black') { - let shape = document.createElement('canvas') - shape.width = 10 - shape.height = 10 - let c = shape.getContext('2d') - c.strokeStyle = color - c.lineWidth = 2 - c.beginPath() - c.moveTo(2, 0) - c.lineTo(10, 8) - c.stroke() - c.beginPath() - c.moveTo(0, 8) - c.lineTo(2, 10) - c.stroke() - return c.createPattern(shape, 'repeat') + let shape = document.createElement('canvas'); + shape.width = 10; + shape.height = 10; + let c = shape.getContext('2d'); + c.strokeStyle = color; + c.lineWidth = 2; + c.beginPath(); + c.moveTo(2, 0); + c.lineTo(10, 8); + c.stroke(); + c.beginPath(); + c.moveTo(0, 8); + c.lineTo(2, 10); + c.stroke(); + return c.createPattern(shape, 'repeat'); } -export function getMeteredBackgroundColor(meter, currDataset, barCtx, colors, darken=0) { +export function getMeteredBackgroundColor(meter, currDataset, barCtx, colors, darken = 0) { if (!barCtx || !currDataset) return; let bar_height = getBarHeight(barCtx.parsed._stacks); - console.debug("bar height for", barCtx.raw.y, " is ", bar_height, "which in chart is", currDataset); + console.debug( + 'bar height for', + barCtx.raw.y, + ' is ', + bar_height, + 'which in chart is', + currDataset, + ); let meteredColor; if (bar_height > meter.high) meteredColor = colors.danger; else if (bar_height > meter.middle) meteredColor = colors.warn; @@ -95,7 +110,7 @@ const meterColors = { // https://www.joshwcomeau.com/gradient-generator?colors=fcab00|ba0000&angle=90&colorMode=lab&precision=3&easingCurve=0.25|0.75|0.75|0.25 between: ['#fcab00', '#ef8215', '#db5e0c', '#ce3d03', '#b70100'], // yellow-orange-red above: '#440000', // dark red -} +}; export function getGradient(chart, meter, currDataset, barCtx, alpha = null, darken = 0) { const { ctx, chartArea, scales } = chart; @@ -104,19 +119,26 @@ export function getGradient(chart, meter, currDataset, barCtx, alpha = null, dar const total = getBarHeight(barCtx.parsed._stacks); alpha = alpha || (currDataset.label == meter.dash_key ? 0.2 : 1); if (total < meter.middle) { - const adjColor = darken||alpha ? color(meterColors.below).darken(darken).alpha(alpha).rgb().string() : meterColors.below; + const adjColor = + darken || alpha + ? color(meterColors.below).darken(darken).alpha(alpha).rgb().string() + : meterColors.below; return adjColor; } const scaleMaxX = scales.x._range.max; gradient = ctx.createLinearGradient(chartArea.left, 0, chartArea.right, 0); meterColors.between.forEach((clr, i) => { - const clrPosition = ((i + 1) / meterColors.between.length) * (meter.high - meter.middle) + meter.middle; + const clrPosition = + ((i + 1) / meterColors.between.length) * (meter.high - meter.middle) + meter.middle; const adjColor = darken || alpha ? color(clr).darken(darken).alpha(alpha).rgb().string() : clr; gradient.addColorStop(Math.min(clrPosition, scaleMaxX) / scaleMaxX, adjColor); }); if (scaleMaxX > meter.high + 20) { - const adjColor = darken||alpha ? color(meterColors.above).darken(0.2).alpha(alpha).rgb().string() : meterColors.above; - gradient.addColorStop((meter.high+20) / scaleMaxX, adjColor); + const adjColor = + darken || alpha + ? color(meterColors.above).darken(0.2).alpha(alpha).rgb().string() + : meterColors.above; + gradient.addColorStop((meter.high + 20) / scaleMaxX, adjColor); } return gradient; } @@ -129,9 +151,9 @@ export function getGradient(chart, meter, currDataset, barCtx, alpha = null, dar export function darkenOrLighten(baseColor: string, change: number) { if (!baseColor) return baseColor; let colorObj = color(baseColor); - if(change < 0) { + if (change < 0) { // darkening appears more drastic than lightening, so we will be less aggressive (scale change by .5) - return colorObj.darken(Math.abs(change * .5)).hex(); + return colorObj.darken(Math.abs(change * 0.5)).hex(); } else { return colorObj.lighten(Math.abs(change)).hex(); } @@ -150,7 +172,7 @@ export const dedupColors = (colors: string[][]) => { if (duplicates.length > 1) { // there are duplicates; calculate an evenly-spaced adjustment for each one duplicates.forEach(([k, c], i) => { - const change = -maxAdjustment + (maxAdjustment*2 / (duplicates.length - 1)) * i; + const change = -maxAdjustment + ((maxAdjustment * 2) / (duplicates.length - 1)) * i; dedupedColors[k] = darkenOrLighten(clr, change); }); } else if (!dedupedColors[key]) { @@ -158,4 +180,4 @@ export const dedupColors = (colors: string[][]) => { } } return dedupedColors; -} +}; diff --git a/www/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts index 6d9b2b372..9c28958ac 100644 --- a/www/js/config/dynamicConfig.ts +++ b/www/js/config/dynamicConfig.ts @@ -1,55 +1,59 @@ -import i18next from "i18next"; -import { displayError, logDebug, logWarn } from "../plugin/logger"; -import { getAngularService } from "../angular-react-helper"; -import { fetchUrlCached } from "../commHelper"; -import { storageClear, storageGet, storageSet } from "../plugin/storage"; +import i18next from 'i18next'; +import { displayError, logDebug, logWarn } from '../plugin/logger'; +import { getAngularService } from '../angular-react-helper'; +import { fetchUrlCached } from '../commHelper'; +import { storageClear, storageGet, storageSet } from '../plugin/storage'; -export const CONFIG_PHONE_UI="config/app_ui_config"; -export const CONFIG_PHONE_UI_KVSTORE ="CONFIG_PHONE_UI"; +export const CONFIG_PHONE_UI = 'config/app_ui_config'; +export const CONFIG_PHONE_UI_KVSTORE = 'CONFIG_PHONE_UI'; export let storedConfig = null; export let configChanged = false; -export const setConfigChanged = (b) => configChanged = b; +export const setConfigChanged = (b) => (configChanged = b); const _getStudyName = function (connectUrl) { const orig_host = new URL(connectUrl).hostname; - const first_domain = orig_host.split(".")[0]; - if (first_domain == "openpath-stage") { return "stage"; } - const openpath_index = first_domain.search("-openpath"); - if (openpath_index == -1) { return undefined; } + const first_domain = orig_host.split('.')[0]; + if (first_domain == 'openpath-stage') { + return 'stage'; + } + const openpath_index = first_domain.search('-openpath'); + if (openpath_index == -1) { + return undefined; + } const study_name = first_domain.substr(0, openpath_index); return study_name; -} +}; const _fillStudyName = function (config) { if (!config.name) { if (config.server) { config.name = _getStudyName(config.server.connectUrl); } else { - config.name = "dev"; + config.name = 'dev'; } } -} +}; const _backwardsCompatSurveyFill = function (config) { if (!config.survey_info) { config.survey_info = { - "surveys": { - "UserProfileSurvey": { - "formPath": "json/demo-survey-v2.json", - "version": 1, - "compatibleWith": 1, - "dataKey": "manual/demographic_survey", - "labelTemplate": { - "en": "Answered", - "es": "Contestada" - } - } + surveys: { + UserProfileSurvey: { + formPath: 'json/demo-survey-v2.json', + version: 1, + compatibleWith: 1, + dataKey: 'manual/demographic_survey', + labelTemplate: { + en: 'Answered', + es: 'Contestada', + }, + }, }, - "trip-labels": "MULTILABEL" - } + 'trip-labels': 'MULTILABEL', + }; } -} +}; /* Fetch and cache any surveys resources that are referenced by URL in the config, as well as the label_options config if it is present. @@ -58,54 +62,57 @@ const _backwardsCompatSurveyFill = function (config) { const cacheResourcesFromConfig = (config) => { if (config.survey_info?.surveys) { Object.values(config.survey_info.surveys).forEach((survey) => { - if (!survey?.['formPath']) - throw new Error(i18next.t('config.survey-missing-formpath')); + if (!survey?.['formPath']) throw new Error(i18next.t('config.survey-missing-formpath')); fetchUrlCached(survey['formPath']); }); } if (config.label_options) { fetchUrlCached(config.label_options); } -} +}; const readConfigFromServer = async (label) => { const config = await fetchConfig(label); - logDebug("Successfully found config, result is " + JSON.stringify(config).substring(0, 10)); + logDebug('Successfully found config, result is ' + JSON.stringify(config).substring(0, 10)); // fetch + cache any resources referenced in the config, but don't 'await' them so we don't block // the config loading process cacheResourcesFromConfig(config); - const connectionURL = config.server ? config.server.connectUrl : "dev defaults"; + const connectionURL = config.server ? config.server.connectUrl : 'dev defaults'; _fillStudyName(config); _backwardsCompatSurveyFill(config); - logDebug("Successfully downloaded config with version " + config.version - + " for " + config.intro.translated_text.en.deployment_name - + " and data collection URL " + connectionURL); + logDebug( + 'Successfully downloaded config with version ' + + config.version + + ' for ' + + config.intro.translated_text.en.deployment_name + + ' and data collection URL ' + + connectionURL, + ); return config; -} +}; const fetchConfig = async (label, alreadyTriedLocal = false) => { - logDebug("Received request to join " + label); + logDebug('Received request to join ' + label); const downloadURL = `https://raw.githubusercontent.com/e-mission/nrel-openpath-deploy-configs/main/configs/${label}.nrel-op.json`; if (!__DEV__ || alreadyTriedLocal) { - logDebug("Fetching config from github"); + logDebug('Fetching config from github'); const r = await fetch(downloadURL); if (!r.ok) throw new Error('Unable to fetch config from github'); return r.json(); - } - else { - logDebug("Running in dev environment, checking for locally hosted config"); + } else { + logDebug('Running in dev environment, checking for locally hosted config'); try { const r = await fetch('http://localhost:9090/configs/' + label + '.nrel-op.json'); if (!r.ok) throw new Error('Local config not found'); return r.json(); } catch (err) { - logDebug("Local config not found"); + logDebug('Local config not found'); return fetchConfig(label, true); } } -} +}; /* * We want to support both old style and new style tokens. @@ -120,12 +127,12 @@ const fetchConfig = async (label, alreadyTriedLocal = false) => { * So let's support two separate functions here - extractStudyName and extractSubgroup */ function extractStudyName(token) { - const tokenParts = token.split("_"); + const tokenParts = token.split('_'); if (tokenParts.length < 3) { // all tokens must have at least nrelop_[study name]_... - throw new Error(i18next.t('config.not-enough-parts-old-style', { "token": token })); + throw new Error(i18next.t('config.not-enough-parts-old-style', { token: token })); } - if (tokenParts[0] != "nrelop") { + if (tokenParts[0] != 'nrelop') { throw new Error(i18next.t('config.no-nrelop-start', { token: token })); } return tokenParts[1]; @@ -134,20 +141,27 @@ function extractStudyName(token) { function extractSubgroup(token, config) { if (config.opcode) { // new style study, expects token with sub-group - const tokenParts = token.split("_"); - if (tokenParts.length <= 3) { // no subpart defined + const tokenParts = token.split('_'); + if (tokenParts.length <= 3) { + // no subpart defined throw new Error(i18next.t('config.not-enough-parts', { token: token })); } if (config.opcode.subgroups) { if (config.opcode.subgroups.indexOf(tokenParts[2]) == -1) { // subpart not in config list - throw new Error(i18next.t('config.invalid-subgroup', { token: token, subgroup: tokenParts[2], config_subgroups: config.opcode.subgroups })); + throw new Error( + i18next.t('config.invalid-subgroup', { + token: token, + subgroup: tokenParts[2], + config_subgroups: config.opcode.subgroups, + }), + ); } else { - console.log("subgroup " + tokenParts[2] + " found in list " + config.opcode.subgroups); + console.log('subgroup ' + tokenParts[2] + ' found in list ' + config.opcode.subgroups); return tokenParts[2]; } } else { - if (tokenParts[2] != "default") { + if (tokenParts[2] != 'default') { // subpart not in config list throw new Error(i18next.t('config.invalid-subgroup-no-default', { token: token })); } else { @@ -161,59 +175,66 @@ function extractSubgroup(token, config) { * only validation required is `nrelop_` and valid study name * first is already handled in extractStudyName, second is handled * by default since download will fail if it is invalid - */ - console.log("Old-style study, expecting token without a subgroup..."); + */ + console.log('Old-style study, expecting token without a subgroup...'); return undefined; } } /** -* loadNewConfig download and load a new config from the server if it is a differ -* @param {[]} newToken the new token, which includes parts for the study label, subgroup, and user -* @param {} thenGoToIntro whether to go to the intro screen after loading the config -* @param {} [existingVersion=null] if the new config's version is the same, we won't update -* @returns {boolean} boolean representing whether the config was updated or not -*/ + * loadNewConfig download and load a new config from the server if it is a differ + * @param {[]} newToken the new token, which includes parts for the study label, subgroup, and user + * @param {} thenGoToIntro whether to go to the intro screen after loading the config + * @param {} [existingVersion=null] if the new config's version is the same, we won't update + * @returns {boolean} boolean representing whether the config was updated or not + */ function loadNewConfig(newToken, existingVersion = null) { const newStudyLabel = extractStudyName(newToken); - return readConfigFromServer(newStudyLabel).then((downloadedConfig) => { - if (downloadedConfig.version == existingVersion) { - logDebug("UI_CONFIG: Not updating config because version is the same"); - return Promise.resolve(false); - } - // we want to validate before saving because we don't want to save - // an invalid configuration - const subgroup = extractSubgroup(newToken, downloadedConfig); - const toSaveConfig = { - ...downloadedConfig, - joined: { opcode: newToken, study_name: newStudyLabel, subgroup: subgroup } - } - const storeConfigPromise = window['cordova'].plugins.BEMUserCache.putRWDocument( - CONFIG_PHONE_UI, toSaveConfig); - const storeInKVStorePromise = storageSet(CONFIG_PHONE_UI_KVSTORE, toSaveConfig); - logDebug("UI_CONFIG: about to store " + JSON.stringify(toSaveConfig)); - // loaded new config, so it is both ready and changed - return Promise.all([storeConfigPromise, storeInKVStorePromise]).then( - ([result, kvStoreResult]) => { - logDebug("UI_CONFIG: Stored dynamic config in KVStore successfully, result = " + JSON.stringify(kvStoreResult)); - storedConfig = toSaveConfig; - configChanged = true; - return true; - }).catch((storeError) => - displayError(storeError, i18next.t('config.unable-to-store-config')) + return readConfigFromServer(newStudyLabel) + .then((downloadedConfig) => { + if (downloadedConfig.version == existingVersion) { + logDebug('UI_CONFIG: Not updating config because version is the same'); + return Promise.resolve(false); + } + // we want to validate before saving because we don't want to save + // an invalid configuration + const subgroup = extractSubgroup(newToken, downloadedConfig); + const toSaveConfig = { + ...downloadedConfig, + joined: { opcode: newToken, study_name: newStudyLabel, subgroup: subgroup }, + }; + const storeConfigPromise = window['cordova'].plugins.BEMUserCache.putRWDocument( + CONFIG_PHONE_UI, + toSaveConfig, ); - }).catch((fetchErr) => { - displayError(fetchErr, i18next.t('config.unable-download-config')); - }); + const storeInKVStorePromise = storageSet(CONFIG_PHONE_UI_KVSTORE, toSaveConfig); + logDebug('UI_CONFIG: about to store ' + JSON.stringify(toSaveConfig)); + // loaded new config, so it is both ready and changed + return Promise.all([storeConfigPromise, storeInKVStorePromise]) + .then(([result, kvStoreResult]) => { + logDebug( + 'UI_CONFIG: Stored dynamic config in KVStore successfully, result = ' + + JSON.stringify(kvStoreResult), + ); + storedConfig = toSaveConfig; + configChanged = true; + return true; + }) + .catch((storeError) => + displayError(storeError, i18next.t('config.unable-to-store-config')), + ); + }) + .catch((fetchErr) => { + displayError(fetchErr, i18next.t('config.unable-download-config')); + }); } export function initByUser(urlComponents) { const { token } = urlComponents; try { - return loadNewConfig(token) - .catch((fetchErr) => { - displayError(fetchErr, i18next.t('config.unable-download-config')); - }); + return loadNewConfig(token).catch((fetchErr) => { + displayError(fetchErr, i18next.t('config.unable-download-config')); + }); } catch (error) { displayError(error, i18next.t('config.invalid-opcode-format')); return Promise.reject(error); @@ -229,19 +250,21 @@ export function getConfig() { if (storedConfig) return Promise.resolve(storedConfig); return storageGet(CONFIG_PHONE_UI_KVSTORE).then((config) => { if (config && Object.keys(config).length) { - logDebug("Got config from KVStore: " + JSON.stringify(config)); + logDebug('Got config from KVStore: ' + JSON.stringify(config)); storedConfig = config; return config; } - logDebug("No config found in KVStore, fetching from native storage"); - return window['cordova'].plugins.BEMUserCache.getDocument(CONFIG_PHONE_UI, false).then((config) => { - if (config && Object.keys(config).length) { - logDebug("Got config from native storage: " + JSON.stringify(config)); - storedConfig = config; - return config; - } - logWarn("No config found in native storage either. Returning null"); - return null; - }); + logDebug('No config found in KVStore, fetching from native storage'); + return window['cordova'].plugins.BEMUserCache.getDocument(CONFIG_PHONE_UI, false).then( + (config) => { + if (config && Object.keys(config).length) { + logDebug('Got config from native storage: ' + JSON.stringify(config)); + storedConfig = config; + return config; + } + logWarn('No config found in native storage either. Returning null'); + return null; + }, + ); }); } diff --git a/www/js/config/enketo-config.js b/www/js/config/enketo-config.js index 00ea6f4be..07ac599c2 100644 --- a/www/js/config/enketo-config.js +++ b/www/js/config/enketo-config.js @@ -1,10 +1,10 @@ // https://github.com/enketo/enketo-core#global-configuration const enketoConfig = { - swipePage: false, /* Enketo's use of swipe gestures depends on jquery-touchswipe, + swipePage: false /* Enketo's use of swipe gestures depends on jquery-touchswipe, which is a legacy package, and problematic to load in webpack. - Let's just turn it off. */ - experimentalOptimizations: {}, /* We aren't using any experimental optimizations, - but it has to be defined to avoid errors */ -} + Let's just turn it off. */, + experimentalOptimizations: {} /* We aren't using any experimental optimizations, + but it has to be defined to avoid errors */, +}; export default enketoConfig; diff --git a/www/js/config/serverConn.ts b/www/js/config/serverConn.ts index e3371270b..b0850974e 100644 --- a/www/js/config/serverConn.ts +++ b/www/js/config/serverConn.ts @@ -1,13 +1,14 @@ -import { logDebug } from "../plugin/logger"; +import { logDebug } from '../plugin/logger'; export async function setServerConnSettings(config) { if (!config) return Promise.resolve(null); if (config.server) { - logDebug("connectionConfig = " + JSON.stringify(config.server)); + logDebug('connectionConfig = ' + JSON.stringify(config.server)); return window['cordova'].plugins.BEMConnectionSettings.setSettings(config.server); } else { - const defaultConfig = await window['cordova'].plugins.BEMConnectionSettings.getDefaultSettings(); - logDebug("defaultConfig = " + JSON.stringify(defaultConfig)); + const defaultConfig = + await window['cordova'].plugins.BEMConnectionSettings.getDefaultSettings(); + logDebug('defaultConfig = ' + JSON.stringify(defaultConfig)); return window['cordova'].plugins.BEMConnectionSettings.setSettings(defaultConfig); } } diff --git a/www/js/config/useImperialConfig.ts b/www/js/config/useImperialConfig.ts index a9680048c..1b7e2c346 100644 --- a/www/js/config/useImperialConfig.ts +++ b/www/js/config/useImperialConfig.ts @@ -1,6 +1,6 @@ -import React, { useEffect, useState } from "react"; -import useAppConfig from "../useAppConfig"; -import i18next from "i18next"; +import React, { useEffect, useState } from 'react'; +import useAppConfig from '../useAppConfig'; +import i18next from 'i18next'; const KM_TO_MILES = 0.621371; const MPS_TO_KMPH = 3.6; @@ -15,26 +15,21 @@ const MPS_TO_KMPH = 3.6; e.g. "0.07 mi", "0.75 km" */ export const formatForDisplay = (value: number): string => { let opts: Intl.NumberFormatOptions = {}; - if (value >= 100) - opts.maximumFractionDigits = 0; - else if (value >= 1) - opts.maximumSignificantDigits = 3; - else - opts.maximumFractionDigits = 2; + if (value >= 100) opts.maximumFractionDigits = 0; + else if (value >= 1) opts.maximumSignificantDigits = 3; + else opts.maximumFractionDigits = 2; return Intl.NumberFormat(i18next.language, opts).format(value); -} +}; export const convertDistance = (distMeters: number, imperial: boolean): number => { - if (imperial) - return (distMeters / 1000) * KM_TO_MILES; + if (imperial) return (distMeters / 1000) * KM_TO_MILES; return distMeters / 1000; -} +}; export const convertSpeed = (speedMetersPerSec: number, imperial: boolean): number => { - if (imperial) - return speedMetersPerSec * MPS_TO_KMPH * KM_TO_MILES; + if (imperial) return speedMetersPerSec * MPS_TO_KMPH * KM_TO_MILES; return speedMetersPerSec * MPS_TO_KMPH; -} +}; export function useImperialConfig() { const appConfig = useAppConfig(); @@ -46,11 +41,13 @@ export function useImperialConfig() { }, [appConfig]); return { - distanceSuffix: useImperial ? "mi" : "km", - speedSuffix: useImperial ? "mph" : "kmph", - getFormattedDistance: useImperial ? (d) => formatForDisplay(convertDistance(d, true)) - : (d) => formatForDisplay(convertDistance(d, false)), - getFormattedSpeed: useImperial ? (s) => formatForDisplay(convertSpeed(s, true)) - : (s) => formatForDisplay(convertSpeed(s, false)), - } + distanceSuffix: useImperial ? 'mi' : 'km', + speedSuffix: useImperial ? 'mph' : 'kmph', + getFormattedDistance: useImperial + ? (d) => formatForDisplay(convertDistance(d, true)) + : (d) => formatForDisplay(convertDistance(d, false)), + getFormattedSpeed: useImperial + ? (s) => formatForDisplay(convertSpeed(s, true)) + : (s) => formatForDisplay(convertSpeed(s, false)), + }; } diff --git a/www/js/control/AlertBar.jsx b/www/js/control/AlertBar.jsx index fbac80056..c86401b03 100644 --- a/www/js/control/AlertBar.jsx +++ b/www/js/control/AlertBar.jsx @@ -1,38 +1,37 @@ -import React from "react"; -import { Modal } from "react-native"; +import React from 'react'; +import { Modal } from 'react-native'; import { Snackbar } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; -import { SafeAreaView } from "react-native-safe-area-context"; +import { useTranslation } from 'react-i18next'; +import { SafeAreaView } from 'react-native-safe-area-context'; -const AlertBar = ({visible, setVisible, messageKey, messageAddition=undefined}) => { - const { t } = useTranslation(); - const onDismissSnackBar = () => setVisible(false); +const AlertBar = ({ visible, setVisible, messageKey, messageAddition = undefined }) => { + const { t } = useTranslation(); + const onDismissSnackBar = () => setVisible(false); - let text = ""; - if(messageAddition){ - text = t(messageKey) + messageAddition; - } - else { - text = t(messageKey); - } - - return ( - setVisible(false)} transparent={true}> - - { - onDismissSnackBar() - }, + let text = ''; + if (messageAddition) { + text = t(messageKey) + messageAddition; + } else { + text = t(messageKey); + } + + return ( + setVisible(false)} transparent={true}> + + { + onDismissSnackBar(); + }, }}> - {text} - - + {text} + + - ); - }; - -export default AlertBar; \ No newline at end of file + ); +}; + +export default AlertBar; diff --git a/www/js/control/AppStatusModal.tsx b/www/js/control/AppStatusModal.tsx index e7f5aa97b..8666f9ccf 100644 --- a/www/js/control/AppStatusModal.tsx +++ b/www/js/control/AppStatusModal.tsx @@ -1,40 +1,44 @@ -import React, { useContext, useEffect } from "react"; -import { Modal, useWindowDimensions } from "react-native"; +import React, { useContext, useEffect } from 'react'; +import { Modal, useWindowDimensions } from 'react-native'; import { Dialog, useTheme } from 'react-native-paper'; -import PermissionsControls from "../appstatus/PermissionsControls"; -import { settingStyles } from "./ProfileSettings"; -import { AppContext } from "../App"; +import PermissionsControls from '../appstatus/PermissionsControls'; +import { settingStyles } from './ProfileSettings'; +import { AppContext } from '../App'; //TODO -- import settings styles for dialog const AppStatusModal = ({ permitVis, setPermitVis }) => { - const { height: windowHeight } = useWindowDimensions(); - const { permissionStatus } = useContext(AppContext); - const { overallStatus, checkList } = permissionStatus; - const { colors } = useTheme(); + const { height: windowHeight } = useWindowDimensions(); + const { permissionStatus } = useContext(AppContext); + const { overallStatus, checkList } = permissionStatus; + const { colors } = useTheme(); - /* Listen for permissions status changes to determine if we should show the modal. */ - useEffect(() => { - if (overallStatus === false) { - setPermitVis(true); - } + /* Listen for permissions status changes to determine if we should show the modal. */ + useEffect(() => { + if (overallStatus === false) { + setPermitVis(true); + } }, [overallStatus, checkList]); - return ( - { - if(overallStatus){(setPermitVis(false))} - }} - transparent={true}> - setPermitVis(false)} - style={settingStyles.dialog(colors.elevation.level3)}> - - setPermitVis(false)}> - - - - - ) -} + return ( + { + if (overallStatus) { + setPermitVis(false); + } + }} + transparent={true}> + setPermitVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + + setPermitVis(false)}> + + + + ); +}; export default AppStatusModal; diff --git a/www/js/control/ControlCollectionHelper.tsx b/www/js/control/ControlCollectionHelper.tsx index d93c498a9..cc3efa8c1 100644 --- a/www/js/control/ControlCollectionHelper.tsx +++ b/www/js/control/ControlCollectionHelper.tsx @@ -1,284 +1,362 @@ -import React, { useEffect, useState } from "react"; -import { Modal, View } from "react-native"; +import React, { useEffect, useState } from 'react'; +import { Modal, View } from 'react-native'; import { Dialog, Button, Switch, Text, useTheme, TextInput } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; -import ActionMenu from "../components/ActionMenu"; -import { settingStyles } from "./ProfileSettings"; -import { getAngularService } from "../angular-react-helper"; +import { useTranslation } from 'react-i18next'; +import ActionMenu from '../components/ActionMenu'; +import { settingStyles } from './ProfileSettings'; +import { getAngularService } from '../angular-react-helper'; -type collectionConfig = { - is_duty_cycling: boolean, - simulate_user_interaction: boolean, - accuracy: number, - accuracy_threshold: number, - filter_distance: number, - filter_time: number, - geofence_radius: number, - ios_use_visit_notifications_for_detection: boolean, - ios_use_remote_push_for_sync: boolean, - android_geofence_responsiveness: number +type collectionConfig = { + is_duty_cycling: boolean; + simulate_user_interaction: boolean; + accuracy: number; + accuracy_threshold: number; + filter_distance: number; + filter_time: number; + geofence_radius: number; + ios_use_visit_notifications_for_detection: boolean; + ios_use_remote_push_for_sync: boolean; + android_geofence_responsiveness: number; }; export async function forceTransition(transition) { - try { - let result = await forceTransitionWrapper(transition); - window.alert('success -> '+result); - } catch (err) { - window.alert('error -> '+err); - console.log("error forcing state", err); - } + try { + let result = await forceTransitionWrapper(transition); + window.alert('success -> ' + result); + } catch (err) { + window.alert('error -> ' + err); + console.log('error forcing state', err); + } } async function accuracy2String(config) { - var accuracy = config.accuracy; - let accuracyOptions = await getAccuracyOptions(); - for (var k in accuracyOptions) { - if (accuracyOptions[k] == accuracy) { - return k; - } + var accuracy = config.accuracy; + let accuracyOptions = await getAccuracyOptions(); + for (var k in accuracyOptions) { + if (accuracyOptions[k] == accuracy) { + return k; } - return accuracy; + } + return accuracy; } export async function isMediumAccuracy() { - let config = await getConfig(); - if (!config || config == null) { - return undefined; // config not loaded when loading ui, set default as false + let config = await getConfig(); + if (!config || config == null) { + return undefined; // config not loaded when loading ui, set default as false + } else { + var v = await accuracy2String(config); + console.log('window platform is', window['cordova'].platformId); + if (window['cordova'].platformId == 'ios') { + return ( + v != 'kCLLocationAccuracyBestForNavigation' && + v != 'kCLLocationAccuracyBest' && + v != 'kCLLocationAccuracyTenMeters' + ); + } else if (window['cordova'].platformId == 'android') { + return v != 'PRIORITY_HIGH_ACCURACY'; } else { - var v = await accuracy2String(config); - console.log("window platform is", window['cordova'].platformId); - if (window['cordova'].platformId == 'ios') { - return v != "kCLLocationAccuracyBestForNavigation" && v != "kCLLocationAccuracyBest" && v != "kCLLocationAccuracyTenMeters"; - } else if (window['cordova'].platformId == 'android') { - return v != "PRIORITY_HIGH_ACCURACY"; - } else { - window.alert("Emission does not support this platform"); - } + window.alert('Emission does not support this platform'); } + } } export async function helperToggleLowAccuracy() { - const Logger = getAngularService("Logger"); - let tempConfig = await getConfig(); - let accuracyOptions = await getAccuracyOptions(); - let medium = await isMediumAccuracy(); - if (medium) { - if (window['cordova'].platformId == 'ios') { - tempConfig.accuracy = accuracyOptions["kCLLocationAccuracyBest"]; - } else if (window['cordova'].platformId == 'android') { - tempConfig.accuracy = accuracyOptions["PRIORITY_HIGH_ACCURACY"]; - } - } else { - if (window['cordova'].platformId == 'ios') { - tempConfig.accuracy = accuracyOptions["kCLLocationAccuracyHundredMeters"]; - } else if (window['cordova'].platformId == 'android') { - tempConfig.accuracy = accuracyOptions["PRIORITY_BALANCED_POWER_ACCURACY"]; - } + const Logger = getAngularService('Logger'); + let tempConfig = await getConfig(); + let accuracyOptions = await getAccuracyOptions(); + let medium = await isMediumAccuracy(); + if (medium) { + if (window['cordova'].platformId == 'ios') { + tempConfig.accuracy = accuracyOptions['kCLLocationAccuracyBest']; + } else if (window['cordova'].platformId == 'android') { + tempConfig.accuracy = accuracyOptions['PRIORITY_HIGH_ACCURACY']; } - try{ - let set = await setConfig(tempConfig); - console.log("setConfig Sucess"); - } catch (err) { - Logger.displayError("Error while setting collection config", err); + } else { + if (window['cordova'].platformId == 'ios') { + tempConfig.accuracy = accuracyOptions['kCLLocationAccuracyHundredMeters']; + } else if (window['cordova'].platformId == 'android') { + tempConfig.accuracy = accuracyOptions['PRIORITY_BALANCED_POWER_ACCURACY']; } + } + try { + let set = await setConfig(tempConfig); + console.log('setConfig Sucess'); + } catch (err) { + Logger.displayError('Error while setting collection config', err); + } } /* -* Simple read/write wrappers -*/ + * Simple read/write wrappers + */ -export const getState = function() { - return window['cordova'].plugins.BEMDataCollection.getState(); +export const getState = function () { + return window['cordova'].plugins.BEMDataCollection.getState(); }; export async function getHelperCollectionSettings() { - let promiseList = []; - promiseList.push(getConfig()); - promiseList.push(getAccuracyOptions()); - let resultList = await Promise.all(promiseList); - let tempConfig = resultList[0]; - let tempAccuracyOptions = resultList[1]; - return formatConfigForDisplay(tempConfig, tempAccuracyOptions); + let promiseList = []; + promiseList.push(getConfig()); + promiseList.push(getAccuracyOptions()); + let resultList = await Promise.all(promiseList); + let tempConfig = resultList[0]; + let tempAccuracyOptions = resultList[1]; + return formatConfigForDisplay(tempConfig, tempAccuracyOptions); } -const setConfig = function(config) { - return window['cordova'].plugins.BEMDataCollection.setConfig(config); +const setConfig = function (config) { + return window['cordova'].plugins.BEMDataCollection.setConfig(config); }; -const getConfig = function() { - return window['cordova'].plugins.BEMDataCollection.getConfig(); +const getConfig = function () { + return window['cordova'].plugins.BEMDataCollection.getConfig(); }; -const getAccuracyOptions = function() { - return window['cordova'].plugins.BEMDataCollection.getAccuracyOptions(); +const getAccuracyOptions = function () { + return window['cordova'].plugins.BEMDataCollection.getAccuracyOptions(); }; -export const forceTransitionWrapper = function(transition) { - return window['cordova'].plugins.BEMDataCollection.forceTransition(transition); +export const forceTransitionWrapper = function (transition) { + return window['cordova'].plugins.BEMDataCollection.forceTransition(transition); }; -const formatConfigForDisplay = function(config, accuracyOptions) { - var retVal = []; - for (var prop in config) { - if (prop == "accuracy") { - for (var name in accuracyOptions) { - if (accuracyOptions[name] == config[prop]) { - retVal.push({'key': prop, 'val': name}); - } - } - } else { - retVal.push({'key': prop, 'val': config[prop]}); +const formatConfigForDisplay = function (config, accuracyOptions) { + var retVal = []; + for (var prop in config) { + if (prop == 'accuracy') { + for (var name in accuracyOptions) { + if (accuracyOptions[name] == config[prop]) { + retVal.push({ key: prop, val: name }); } + } + } else { + retVal.push({ key: prop, val: config[prop] }); } - return retVal; -} + } + return retVal; +}; const ControlCollectionHelper = ({ editVis, setEditVis }) => { - const {colors} = useTheme(); - const Logger = getAngularService("Logger"); + const { colors } = useTheme(); + const Logger = getAngularService('Logger'); - const [ localConfig, setLocalConfig ] = useState(); - const [ accuracyActions, setAccuracyActions ] = useState([]); - const [ accuracyVis, setAccuracyVis ] = useState(false); - - async function getCollectionSettings() { - let promiseList = []; - promiseList.push(getConfig()); - promiseList.push(getAccuracyOptions()); - let resultList = await Promise.all(promiseList); - let tempConfig = resultList[0]; - setLocalConfig(tempConfig); - let tempAccuracyOptions = resultList[1]; - setAccuracyActions(formatAccuracyForActions(tempAccuracyOptions)); - return formatConfigForDisplay(tempConfig, tempAccuracyOptions); - } + const [localConfig, setLocalConfig] = useState(); + const [accuracyActions, setAccuracyActions] = useState([]); + const [accuracyVis, setAccuracyVis] = useState(false); - useEffect(() => { - getCollectionSettings(); - }, [editVis]) + async function getCollectionSettings() { + let promiseList = []; + promiseList.push(getConfig()); + promiseList.push(getAccuracyOptions()); + let resultList = await Promise.all(promiseList); + let tempConfig = resultList[0]; + setLocalConfig(tempConfig); + let tempAccuracyOptions = resultList[1]; + setAccuracyActions(formatAccuracyForActions(tempAccuracyOptions)); + return formatConfigForDisplay(tempConfig, tempAccuracyOptions); + } + + useEffect(() => { + getCollectionSettings(); + }, [editVis]); - const formatAccuracyForActions = function(accuracyOptions) { - let tempAccuracyActions = []; - for (var name in accuracyOptions) { - tempAccuracyActions.push({text: name, value: accuracyOptions[name]}); - } - return tempAccuracyActions; + const formatAccuracyForActions = function (accuracyOptions) { + let tempAccuracyActions = []; + for (var name in accuracyOptions) { + tempAccuracyActions.push({ text: name, value: accuracyOptions[name] }); } + return tempAccuracyActions; + }; - /* - * Functions to edit and save values - */ + /* + * Functions to edit and save values + */ - async function saveAndReload() { - console.log("new config = ", localConfig); - try{ - let set = await setConfig(localConfig); - setEditVis(false); - } catch(err) { - Logger.displayError("Error while setting collection config", err); - } + async function saveAndReload() { + console.log('new config = ', localConfig); + try { + let set = await setConfig(localConfig); + setEditVis(false); + } catch (err) { + Logger.displayError('Error while setting collection config', err); } + } - const onToggle = function(config_key) { - let tempConfig = {...localConfig}; - tempConfig[config_key] = !localConfig[config_key]; - setLocalConfig(tempConfig); - } + const onToggle = function (config_key) { + let tempConfig = { ...localConfig }; + tempConfig[config_key] = !localConfig[config_key]; + setLocalConfig(tempConfig); + }; - const onChooseAccuracy = function(accuracyOption) { - let tempConfig = {...localConfig}; - tempConfig.accuracy = accuracyOption.value; - setLocalConfig(tempConfig); - } + const onChooseAccuracy = function (accuracyOption) { + let tempConfig = { ...localConfig }; + tempConfig.accuracy = accuracyOption.value; + setLocalConfig(tempConfig); + }; - const onChangeText = function(newText, config_key) { - let tempConfig = {...localConfig}; - tempConfig[config_key] = parseInt(newText); - setLocalConfig(tempConfig); - } + const onChangeText = function (newText, config_key) { + let tempConfig = { ...localConfig }; + tempConfig[config_key] = parseInt(newText); + setLocalConfig(tempConfig); + }; - /*ios vs android*/ - let filterComponent; - if(window['cordova'].platformId == 'ios') { - filterComponent = - Filter Distance - onChangeText(text, "filter_distance")}/> - - } else { - filterComponent = - Filter Interval - onChangeText(text, "filter_time")}/> - - } - let iosToggles; - if(window['cordova'].platformId == 'ios') { - iosToggles = <> + /*ios vs android*/ + let filterComponent; + if (window['cordova'].platformId == 'ios') { + filterComponent = ( + + Filter Distance + onChangeText(text, 'filter_distance')} + /> + + ); + } else { + filterComponent = ( + + Filter Interval + onChangeText(text, 'filter_time')} + /> + + ); + } + let iosToggles; + if (window['cordova'].platformId == 'ios') { + iosToggles = ( + <> {/* use visit notifications toggle NO ANDROID */} - - Use Visit Notifications - onToggle("ios_use_visit_notifications_for_detection")}> + + Use Visit Notifications + onToggle('ios_use_visit_notifications_for_detection')}> {/* sync on remote push toggle NO ANDROID */} - - Sync on remote push - onToggle("ios_use_remote_push_for_sync}")}> + + Sync on remote push + onToggle('ios_use_remote_push_for_sync}')}> - - } - let geofenceComponent; - if(window['cordova'].platformId == 'android') { - geofenceComponent = - Geofence Responsiveness - onChangeText(text, "android_geofence_responsiveness")}/> - - } + + ); + } + let geofenceComponent; + if (window['cordova'].platformId == 'android') { + geofenceComponent = ( + + Geofence Responsiveness + onChangeText(text, 'android_geofence_responsiveness')} + /> + + ); + } - return ( - <> - setEditVis(false)} transparent={true}> - setEditVis(false)} style={settingStyles.dialog(colors.elevation.level3)}> - Edit Collection Settings - - {/* duty cycling toggle */} - - Duty Cycling - onToggle("is_duty_cycling")}> - - {/* simulate user toggle */} - - Simulate User - onToggle("simulate_user_interaction")}> - - {/* accuracy */} - - Accuracy - - - {/* accuracy threshold not editable*/} - - Accuracy Threshold - {localConfig?.accuracy_threshold} - - {filterComponent} - {/* geofence radius */} - - Geofence Radius - onChangeText(text, "geofence_radius")}/> - - {iosToggles} - {geofenceComponent} - - - - - - - + return ( + <> + setEditVis(false)} transparent={true}> + setEditVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + Edit Collection Settings + + {/* duty cycling toggle */} + + Duty Cycling + onToggle('is_duty_cycling')}> + + {/* simulate user toggle */} + + Simulate User + onToggle('simulate_user_interaction')}> + + {/* accuracy */} + + Accuracy + + + {/* accuracy threshold not editable*/} + + Accuracy Threshold + {localConfig?.accuracy_threshold} + + {filterComponent} + {/* geofence radius */} + + Geofence Radius + onChangeText(text, 'geofence_radius')} + /> + + {iosToggles} + {geofenceComponent} + + + + + + + + + {}}> + + ); +}; - {}}> - - ); - }; - export default ControlCollectionHelper; diff --git a/www/js/control/ControlDataTable.jsx b/www/js/control/ControlDataTable.jsx index 796b057ec..932762400 100644 --- a/www/js/control/ControlDataTable.jsx +++ b/www/js/control/ControlDataTable.jsx @@ -1,18 +1,18 @@ -import React from "react"; +import React from 'react'; import { DataTable } from 'react-native-paper'; // val with explicit call toString() to resolve bool values not showing const ControlDataTable = ({ controlData }) => { - console.log("Printing data trying to tabulate", controlData); + console.log('Printing data trying to tabulate', controlData); return ( //rows require unique keys! - {controlData?.map((e) => - + {controlData?.map((e) => ( + {e.key} {e.val.toString()} - )} + ))} ); }; @@ -23,7 +23,7 @@ const styles = { borderColor: 'rgba(0,0,0,0.25)', borderLeftWidth: 15, borderLeftColor: 'rgba(0,0,0,0.25)', - } -} + }, +}; export default ControlDataTable; diff --git a/www/js/control/ControlSyncHelper.tsx b/www/js/control/ControlSyncHelper.tsx index edc0e7470..7fdf3fa37 100644 --- a/www/js/control/ControlSyncHelper.tsx +++ b/www/js/control/ControlSyncHelper.tsx @@ -1,284 +1,317 @@ -import React, { useEffect, useState } from "react"; -import { Modal, View } from "react-native"; +import React, { useEffect, useState } from 'react'; +import { Modal, View } from 'react-native'; import { Dialog, Button, Switch, Text, useTheme } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; -import { settingStyles } from "./ProfileSettings"; -import { getAngularService } from "../angular-react-helper"; -import ActionMenu from "../components/ActionMenu"; -import SettingRow from "./SettingRow"; -import AlertBar from "./AlertBar"; -import moment from "moment"; -import { addStatEvent, statKeys } from "../plugin/clientStats"; -import { updateUser } from "../commHelper"; +import { useTranslation } from 'react-i18next'; +import { settingStyles } from './ProfileSettings'; +import { getAngularService } from '../angular-react-helper'; +import ActionMenu from '../components/ActionMenu'; +import SettingRow from './SettingRow'; +import AlertBar from './AlertBar'; +import moment from 'moment'; +import { addStatEvent, statKeys } from '../plugin/clientStats'; +import { updateUser } from '../commHelper'; /* -* BEGIN: Simple read/write wrappers -*/ + * BEGIN: Simple read/write wrappers + */ export function forcePluginSync() { - return window.cordova.plugins.BEMServerSync.forceSync(); -}; + return window.cordova.plugins.BEMServerSync.forceSync(); +} const formatConfigForDisplay = (configToFormat) => { - var formatted = []; - for (let prop in configToFormat) { - formatted.push({'key': prop, 'val': configToFormat[prop]}); - } - return formatted; -} + var formatted = []; + for (let prop in configToFormat) { + formatted.push({ key: prop, val: configToFormat[prop] }); + } + return formatted; +}; -const setConfig = function(config) { - return window.cordova.plugins.BEMServerSync.setConfig(config); - }; +const setConfig = function (config) { + return window.cordova.plugins.BEMServerSync.setConfig(config); +}; -const getConfig = function() { - return window.cordova.plugins.BEMServerSync.getConfig(); +const getConfig = function () { + return window.cordova.plugins.BEMServerSync.getConfig(); }; export async function getHelperSyncSettings() { - let tempConfig = await getConfig(); - return formatConfigForDisplay(tempConfig); + let tempConfig = await getConfig(); + return formatConfigForDisplay(tempConfig); } -const getEndTransitionKey = function() { - if(window.cordova.platformId == 'android') { - return "local.transition.stopped_moving"; - } - else if(window.cordova.platformId == 'ios') { - return "T_TRIP_ENDED"; - } -} +const getEndTransitionKey = function () { + if (window.cordova.platformId == 'android') { + return 'local.transition.stopped_moving'; + } else if (window.cordova.platformId == 'ios') { + return 'T_TRIP_ENDED'; + } +}; -type syncConfig = { sync_interval: number, - ios_use_remote_push: boolean }; +type syncConfig = { sync_interval: number; ios_use_remote_push: boolean }; //forceSync and endForceSync SettingRows & their actions -export const ForceSyncRow = ({getState}) => { - const { t } = useTranslation(); - const { colors } = useTheme(); - const Logger = getAngularService('Logger'); - - const [dataPendingVis, setDataPendingVis] = useState(false); - const [dataPushedVis, setDataPushedVis] = useState(false); - - async function forceSync() { - try { - let addedEvent = addStatEvent(statKeys.BUTTON_FORCE_SYNC); - console.log("Added "+statKeys.BUTTON_FORCE_SYNC+" event"); - - let sync = await forcePluginSync(); - /* - * Change to sensorKey to "background/location" after fixing issues - * with getLastSensorData and getLastMessages in the usercache - * See https://github.com/e-mission/e-mission-phone/issues/279 for details - */ - var sensorKey = "statemachine/transition"; - let sensorDataList = await window.cordova.plugins.BEMUserCache.getAllMessages(sensorKey, true); - - // If everything has been pushed, we should - // have no more trip end transitions left - let isTripEnd = function(entry) { - return entry.metadata == getEndTransitionKey(); - } - let syncLaunchedCalls = sensorDataList.filter(isTripEnd); - let syncPending = syncLaunchedCalls.length > 0; - Logger.log("sensorDataList.length = "+sensorDataList.length+ - ", syncLaunchedCalls.length = "+syncLaunchedCalls.length+ - ", syncPending? = "+syncPending); - Logger.log("sync launched = "+syncPending); - - if(syncPending) { - Logger.log(Logger.log("data is pending, showing confirm dialog")); - setDataPendingVis(true); //consent handling in modal - } else { - setDataPushedVis(true); - } - } catch (error) { - Logger.displayError("Error while forcing sync", error); - } - }; - - const getStartTransitionKey = function() { - if(window.cordova.platformId == 'android') { - return "local.transition.exited_geofence"; - } - else if(window.cordova.platformId == 'ios') { - return "T_EXITED_GEOFENCE"; - } +export const ForceSyncRow = ({ getState }) => { + const { t } = useTranslation(); + const { colors } = useTheme(); + const Logger = getAngularService('Logger'); + + const [dataPendingVis, setDataPendingVis] = useState(false); + const [dataPushedVis, setDataPushedVis] = useState(false); + + async function forceSync() { + try { + let addedEvent = addStatEvent(statKeys.BUTTON_FORCE_SYNC); + console.log('Added ' + statKeys.BUTTON_FORCE_SYNC + ' event'); + + let sync = await forcePluginSync(); + /* + * Change to sensorKey to "background/location" after fixing issues + * with getLastSensorData and getLastMessages in the usercache + * See https://github.com/e-mission/e-mission-phone/issues/279 for details + */ + var sensorKey = 'statemachine/transition'; + let sensorDataList = await window.cordova.plugins.BEMUserCache.getAllMessages( + sensorKey, + true, + ); + + // If everything has been pushed, we should + // have no more trip end transitions left + let isTripEnd = function (entry) { + return entry.metadata == getEndTransitionKey(); + }; + let syncLaunchedCalls = sensorDataList.filter(isTripEnd); + let syncPending = syncLaunchedCalls.length > 0; + Logger.log( + 'sensorDataList.length = ' + + sensorDataList.length + + ', syncLaunchedCalls.length = ' + + syncLaunchedCalls.length + + ', syncPending? = ' + + syncPending, + ); + Logger.log('sync launched = ' + syncPending); + + if (syncPending) { + Logger.log(Logger.log('data is pending, showing confirm dialog')); + setDataPendingVis(true); //consent handling in modal + } else { + setDataPushedVis(true); + } + } catch (error) { + Logger.displayError('Error while forcing sync', error); } + } - const getEndTransitionKey = function() { - if(window.cordova.platformId == 'android') { - return "local.transition.stopped_moving"; - } - else if(window.cordova.platformId == 'ios') { - return "T_TRIP_ENDED"; - } + const getStartTransitionKey = function () { + if (window.cordova.platformId == 'android') { + return 'local.transition.exited_geofence'; + } else if (window.cordova.platformId == 'ios') { + return 'T_EXITED_GEOFENCE'; } + }; - const getOngoingTransitionState = function() { - if(window.cordova.platformId == 'android') { - return "local.state.ongoing_trip"; - } - else if(window.cordova.platformId == 'ios') { - return "STATE_ONGOING_TRIP"; - } + const getEndTransitionKey = function () { + if (window.cordova.platformId == 'android') { + return 'local.transition.stopped_moving'; + } else if (window.cordova.platformId == 'ios') { + return 'T_TRIP_ENDED'; } + }; - async function getTransition(transKey) { - var entry_data = {}; - const curr_state = await getState(); - entry_data.curr_state = curr_state; - if (transKey == getEndTransitionKey()) { - entry_data.curr_state = getOngoingTransitionState(); - } - entry_data.transition = transKey; - entry_data.ts = moment().unix(); - return entry_data; + const getOngoingTransitionState = function () { + if (window.cordova.platformId == 'android') { + return 'local.state.ongoing_trip'; + } else if (window.cordova.platformId == 'ios') { + return 'STATE_ONGOING_TRIP'; } + }; - async function endForceSync() { - /* First, quickly start and end the trip. Let's listen to the promise - * result for start so that we ensure ordering */ - var sensorKey = "statemachine/transition"; - let entry_data = await getTransition(getStartTransitionKey()); - let messagePut = await window.cordova.plugins.BEMUserCache.putMessage(sensorKey, entry_data); - entry_data = await getTransition(getEndTransitionKey()); - messagePut = await window.cordova.plugins.BEMUserCache.putMessage(sensorKey, entry_data); - forceSync(); - }; - - return ( - <> - - - - {/* dataPending */} - setDataPendingVis(false)} transparent={true}> - setDataPendingVis(false)} - style={settingStyles.dialog(colors.elevation.level3)}> - {t('data pending for push')} - - - - - - - - - - ) -} + async function getTransition(transKey) { + var entry_data = {}; + const curr_state = await getState(); + entry_data.curr_state = curr_state; + if (transKey == getEndTransitionKey()) { + entry_data.curr_state = getOngoingTransitionState(); + } + entry_data.transition = transKey; + entry_data.ts = moment().unix(); + return entry_data; + } + + async function endForceSync() { + /* First, quickly start and end the trip. Let's listen to the promise + * result for start so that we ensure ordering */ + var sensorKey = 'statemachine/transition'; + let entry_data = await getTransition(getStartTransitionKey()); + let messagePut = await window.cordova.plugins.BEMUserCache.putMessage(sensorKey, entry_data); + entry_data = await getTransition(getEndTransitionKey()); + messagePut = await window.cordova.plugins.BEMUserCache.putMessage(sensorKey, entry_data); + forceSync(); + } + + return ( + <> + + + + {/* dataPending */} + setDataPendingVis(false)} transparent={true}> + setDataPendingVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + {t('data pending for push')} + + + + + + + + + + ); +}; //UI for editing the sync config const ControlSyncHelper = ({ editVis, setEditVis }) => { - const { t } = useTranslation(); - const { colors } = useTheme(); - const Logger = getAngularService("Logger"); - - const [ localConfig, setLocalConfig ] = useState(); - const [ intervalVis, setIntervalVis ] = useState(false); - - /* - * Functions to read and format values for display - */ - async function getSyncSettings() { - let tempConfig = await getConfig(); - setLocalConfig(tempConfig); - } + const { t } = useTranslation(); + const { colors } = useTheme(); + const Logger = getAngularService('Logger'); - useEffect(() => { - getSyncSettings(); - }, [editVis]) - - const syncIntervalActions = [ - {text: "1 min", value: 60}, - {text: "10 min", value: 10 * 60}, - {text: "30 min", value: 30 * 60}, - {text: "1 hr", value: 60 * 60} - ] - - /* - * Functions to edit and save values - */ - async function saveAndReload() { - console.log("new config = "+localConfig); - try{ - let set = setConfig(localConfig); - //NOTE -- we need to make sure we update these settings in ProfileSettings :) -- getting rid of broadcast handling for migration!! - updateUser({ - // TODO: worth thinking about where best to set this - // Currently happens in native code. Now that we are switching - // away from parse, we can store this from javascript here. - // or continue to store from native - // this is easier for people to see, but means that calls to - // native, even through the javascript interface are not complete - curr_sync_interval: localConfig.sync_interval - }); - } catch (err) - { - console.log("error with setting sync config", err); - Logger.displayError("Error while setting sync config", err); - } - } + const [localConfig, setLocalConfig] = useState(); + const [intervalVis, setIntervalVis] = useState(false); - const onChooseInterval = function(interval) { - let tempConfig = {...localConfig}; - tempConfig.sync_interval = interval.value; - setLocalConfig(tempConfig); - } + /* + * Functions to read and format values for display + */ + async function getSyncSettings() { + let tempConfig = await getConfig(); + setLocalConfig(tempConfig); + } + + useEffect(() => { + getSyncSettings(); + }, [editVis]); - const onTogglePush = function() { - let tempConfig = {...localConfig}; - tempConfig.ios_use_remote_push = !localConfig.ios_use_remote_push; - setLocalConfig(tempConfig); + const syncIntervalActions = [ + { text: '1 min', value: 60 }, + { text: '10 min', value: 10 * 60 }, + { text: '30 min', value: 30 * 60 }, + { text: '1 hr', value: 60 * 60 }, + ]; + + /* + * Functions to edit and save values + */ + async function saveAndReload() { + console.log('new config = ' + localConfig); + try { + let set = setConfig(localConfig); + //NOTE -- we need to make sure we update these settings in ProfileSettings :) -- getting rid of broadcast handling for migration!! + updateUser({ + // TODO: worth thinking about where best to set this + // Currently happens in native code. Now that we are switching + // away from parse, we can store this from javascript here. + // or continue to store from native + // this is easier for people to see, but means that calls to + // native, even through the javascript interface are not complete + curr_sync_interval: localConfig.sync_interval, + }); + } catch (err) { + console.log('error with setting sync config', err); + Logger.displayError('Error while setting sync config', err); } + } - /* - * configure the UI - */ - let toggle; - if(window.cordova.platformId == 'ios'){ - toggle = - Use Remote Push - - - } - - return ( - <> - {/* popup to show when we want to edit */} - setEditVis(false)} transparent={true}> - setEditVis(false)} style={settingStyles.dialog(colors.elevation.level3)}> - Edit Sync Settings - - - Sync Interval - - - {toggle} - - - - - - - - - {}}> - - ); + const onChooseInterval = function (interval) { + let tempConfig = { ...localConfig }; + tempConfig.sync_interval = interval.value; + setLocalConfig(tempConfig); }; - + + const onTogglePush = function () { + let tempConfig = { ...localConfig }; + tempConfig.ios_use_remote_push = !localConfig.ios_use_remote_push; + setLocalConfig(tempConfig); + }; + + /* + * configure the UI + */ + let toggle; + if (window.cordova.platformId == 'ios') { + toggle = ( + + Use Remote Push + + + ); + } + + return ( + <> + {/* popup to show when we want to edit */} + setEditVis(false)} transparent={true}> + setEditVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + Edit Sync Settings + + + Sync Interval + + + {toggle} + + + + + + + + + {}}> + + ); +}; + export default ControlSyncHelper; diff --git a/www/js/control/DataDatePicker.tsx b/www/js/control/DataDatePicker.tsx index 83e0986b2..7f143f3bd 100644 --- a/www/js/control/DataDatePicker.tsx +++ b/www/js/control/DataDatePicker.tsx @@ -1,14 +1,14 @@ // this date picker element is set up to handle the "download data from day" in ProfileSettings // it relies on an angular service (Control Helper) but when we migrate that we might want to download a range instead of single -import React from "react"; +import React from 'react'; import { DatePickerModal } from 'react-native-paper-dates'; -import { useTranslation } from "react-i18next"; -import { getAngularService } from "../angular-react-helper"; +import { useTranslation } from 'react-i18next'; +import { getAngularService } from '../angular-react-helper'; -const DataDatePicker = ({date, setDate, open, setOpen, minDate}) => { +const DataDatePicker = ({ date, setDate, open, setOpen, minDate }) => { const { t, i18n } = useTranslation(); //able to pull lang from this - const ControlHelper = getAngularService("ControlHelper"); + const ControlHelper = getAngularService('ControlHelper'); const onDismiss = React.useCallback(() => { setOpen(false); @@ -20,27 +20,27 @@ const DataDatePicker = ({date, setDate, open, setOpen, minDate}) => { setDate(params.date); ControlHelper.getMyData(params.date); }, - [setOpen, setDate] + [setOpen, setDate], ); const maxDate = new Date(); return ( <> - + ); -} +}; -export default DataDatePicker; \ No newline at end of file +export default DataDatePicker; diff --git a/www/js/control/DemographicsSettingRow.jsx b/www/js/control/DemographicsSettingRow.jsx index be02dd6d3..c8a0a7297 100644 --- a/www/js/control/DemographicsSettingRow.jsx +++ b/www/js/control/DemographicsSettingRow.jsx @@ -1,13 +1,12 @@ -import React, { useState } from "react"; -import SettingRow from "./SettingRow"; -import { loadPreviousResponseForSurvey } from "../survey/enketo/enketoHelper"; -import EnketoModal from "../survey/enketo/EnketoModal"; +import React, { useState } from 'react'; +import SettingRow from './SettingRow'; +import { loadPreviousResponseForSurvey } from '../survey/enketo/enketoHelper'; +import EnketoModal from '../survey/enketo/EnketoModal'; -export const DEMOGRAPHIC_SURVEY_NAME = "UserProfileSurvey"; -export const DEMOGRAPHIC_SURVEY_DATAKEY = "manual/demographic_survey"; - -const DemographicsSettingRow = ({ }) => { +export const DEMOGRAPHIC_SURVEY_NAME = 'UserProfileSurvey'; +export const DEMOGRAPHIC_SURVEY_DATAKEY = 'manual/demographic_survey'; +const DemographicsSettingRow = ({}) => { const [surveyModalVisible, setSurveyModalVisible] = useState(false); const [prevSurveyResponse, setPrevSurveyResponse] = useState(null); @@ -20,16 +19,26 @@ const DemographicsSettingRow = ({ }) => { }); } - return (<> - - setSurveyModalVisible(false)} - onResponseSaved={() => setSurveyModalVisible(false)} surveyName={DEMOGRAPHIC_SURVEY_NAME} - opts={{ - prefilledSurveyResponse: prevSurveyResponse, - dataKey: DEMOGRAPHIC_SURVEY_DATAKEY, - }} /> - ); + return ( + <> + + setSurveyModalVisible(false)} + onResponseSaved={() => setSurveyModalVisible(false)} + surveyName={DEMOGRAPHIC_SURVEY_NAME} + opts={{ + prefilledSurveyResponse: prevSurveyResponse, + dataKey: DEMOGRAPHIC_SURVEY_DATAKEY, + }} + /> + + ); }; export default DemographicsSettingRow; diff --git a/www/js/control/ExpandMenu.jsx b/www/js/control/ExpandMenu.jsx index 2f8bb8ef1..65c2fb3b3 100644 --- a/www/js/control/ExpandMenu.jsx +++ b/www/js/control/ExpandMenu.jsx @@ -1,15 +1,15 @@ -import React from "react"; +import React from 'react'; import { StyleSheet } from 'react-native'; import { List, useTheme } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; -import { styles as rowStyles } from "./SettingRow"; +import { useTranslation } from 'react-i18next'; +import { styles as rowStyles } from './SettingRow'; const ExpansionSection = (props) => { - const { t } = useTranslation(); //this accesses the translations - const { colors } = useTheme(); // use this to get the theme colors instead of hardcoded #hex colors - const [expanded, setExpanded] = React.useState(false); + const { t } = useTranslation(); //this accesses the translations + const { colors } = useTheme(); // use this to get the theme colors instead of hardcoded #hex colors + const [expanded, setExpanded] = React.useState(false); - const handlePress = () => setExpanded(!expanded); + const handlePress = () => setExpanded(!expanded); return ( { titleStyle={rowStyles.title} expanded={expanded} onPress={handlePress}> - {props.children} + {props.children} ); }; const styles = StyleSheet.create({ section: (surfaceColor) => ({ - justifyContent: 'space-between', - backgroundColor: surfaceColor, - margin: 1, + justifyContent: 'space-between', + backgroundColor: surfaceColor, + margin: 1, }), }); -export default ExpansionSection; \ No newline at end of file +export default ExpansionSection; diff --git a/www/js/control/LogPage.tsx b/www/js/control/LogPage.tsx index e33d2f9a3..ad369fbff 100644 --- a/www/js/control/LogPage.tsx +++ b/www/js/control/LogPage.tsx @@ -1,153 +1,183 @@ -import React, { useState, useMemo, useEffect } from "react"; -import { View, StyleSheet, SafeAreaView, Modal } from "react-native"; -import { useTheme, Text, Appbar, IconButton } from "react-native-paper"; -import { getAngularService } from "../angular-react-helper"; -import { useTranslation } from "react-i18next"; +import React, { useState, useMemo, useEffect } from 'react'; +import { View, StyleSheet, SafeAreaView, Modal } from 'react-native'; +import { useTheme, Text, Appbar, IconButton } from 'react-native-paper'; +import { getAngularService } from '../angular-react-helper'; +import { useTranslation } from 'react-i18next'; import { FlashList } from '@shopify/flash-list'; -import moment from "moment"; -import AlertBar from "./AlertBar"; - -type loadStats = { currentStart: number, gotMaxIndex: boolean, reachedEnd: boolean }; - -const LogPage = ({pageVis, setPageVis}) => { - const { t } = useTranslation(); - const { colors } = useTheme(); - const EmailHelper = getAngularService('EmailHelper'); - - const [ loadStats, setLoadStats ] = useState(); - const [ entries, setEntries ] = useState([]); - const [ maxErrorVis, setMaxErrorVis ] = useState(false); - const [ logErrorVis, setLogErrorVis ] = useState(false); - const [ maxMessage, setMaxMessage ] = useState(""); - const [ logMessage, setLogMessage ] = useState(""); - const [ isFetching, setIsFetching ] = useState(false); - - var RETRIEVE_COUNT = 100; - - //when opening the modal, load the entries - useEffect(() => { - refreshEntries(); - }, [pageVis]); - - async function refreshEntries() { - try { - let maxIndex = await window.Logger.getMaxIndex(); - console.log("maxIndex = "+maxIndex); - let tempStats = {} as loadStats; - tempStats.currentStart = maxIndex; - tempStats.gotMaxIndex = true; - tempStats.reachedEnd = false; - setLoadStats(tempStats); - setEntries([]); - } catch(error) { - let errorString = t('errors.while-max-index')+JSON.stringify(error, null, 2); - console.log(errorString); - setMaxMessage(errorString); - setMaxErrorVis(true); - } finally { - addEntries(); - } +import moment from 'moment'; +import AlertBar from './AlertBar'; + +type loadStats = { currentStart: number; gotMaxIndex: boolean; reachedEnd: boolean }; + +const LogPage = ({ pageVis, setPageVis }) => { + const { t } = useTranslation(); + const { colors } = useTheme(); + const EmailHelper = getAngularService('EmailHelper'); + + const [loadStats, setLoadStats] = useState(); + const [entries, setEntries] = useState([]); + const [maxErrorVis, setMaxErrorVis] = useState(false); + const [logErrorVis, setLogErrorVis] = useState(false); + const [maxMessage, setMaxMessage] = useState(''); + const [logMessage, setLogMessage] = useState(''); + const [isFetching, setIsFetching] = useState(false); + + var RETRIEVE_COUNT = 100; + + //when opening the modal, load the entries + useEffect(() => { + refreshEntries(); + }, [pageVis]); + + async function refreshEntries() { + try { + let maxIndex = await window.Logger.getMaxIndex(); + console.log('maxIndex = ' + maxIndex); + let tempStats = {} as loadStats; + tempStats.currentStart = maxIndex; + tempStats.gotMaxIndex = true; + tempStats.reachedEnd = false; + setLoadStats(tempStats); + setEntries([]); + } catch (error) { + let errorString = t('errors.while-max-index') + JSON.stringify(error, null, 2); + console.log(errorString); + setMaxMessage(errorString); + setMaxErrorVis(true); + } finally { + addEntries(); } - - const moreDataCanBeLoaded = useMemo(() => { - return loadStats?.gotMaxIndex && !loadStats?.reachedEnd; - }, [loadStats]) - - const clear = function() { - window?.Logger.clearAll(); - window?.Logger.log(window.Logger.LEVEL_INFO, "Finished clearing entries from unified log"); - refreshEntries(); + } + + const moreDataCanBeLoaded = useMemo(() => { + return loadStats?.gotMaxIndex && !loadStats?.reachedEnd; + }, [loadStats]); + + const clear = function () { + window?.Logger.clearAll(); + window?.Logger.log(window.Logger.LEVEL_INFO, 'Finished clearing entries from unified log'); + refreshEntries(); + }; + + async function addEntries() { + console.log('calling addEntries'); + setIsFetching(true); + let start = loadStats.currentStart ? loadStats.currentStart : 0; //set a default start to prevent initial fetch error + try { + let entryList = await window.Logger.getMessagesFromIndex(start, RETRIEVE_COUNT); + processEntries(entryList); + console.log('entry list size = ' + entries.length); + setIsFetching(false); + } catch (error) { + let errStr = t('errors.while-log-messages') + JSON.stringify(error, null, 2); + console.log(errStr); + setLogMessage(errStr); + setLogErrorVis(true); + setIsFetching(false); } - - async function addEntries() { - console.log("calling addEntries"); - setIsFetching(true); - let start = loadStats.currentStart ? loadStats.currentStart : 0; //set a default start to prevent initial fetch error - try { - let entryList = await window.Logger.getMessagesFromIndex(start, RETRIEVE_COUNT); - processEntries(entryList); - console.log("entry list size = "+ entries.length); - setIsFetching(false); - } catch(error) { - let errStr = t('errors.while-log-messages')+JSON.stringify(error, null, 2); - console.log(errStr); - setLogMessage(errStr); - setLogErrorVis(true); - setIsFetching(false); - } - } - - const processEntries = function(entryList) { - let tempEntries = []; - let tempLoadStats = {...loadStats}; - entryList.forEach(e => { - e.fmt_time = moment.unix(e.ts).format("llll"); - tempEntries.push(e); - }); - if (entryList.length == 0) { - console.log("Reached the end of the scrolling"); - tempLoadStats.reachedEnd = true; - } else { - tempLoadStats.currentStart = entryList[entryList.length-1].ID; - console.log("new start index = "+loadStats.currentStart); - } - setEntries([...entries].concat(tempEntries)); //push the new entries onto the list - setLoadStats(tempLoadStats); + } + + const processEntries = function (entryList) { + let tempEntries = []; + let tempLoadStats = { ...loadStats }; + entryList.forEach((e) => { + e.fmt_time = moment.unix(e.ts).format('llll'); + tempEntries.push(e); + }); + if (entryList.length == 0) { + console.log('Reached the end of the scrolling'); + tempLoadStats.reachedEnd = true; + } else { + tempLoadStats.currentStart = entryList[entryList.length - 1].ID; + console.log('new start index = ' + loadStats.currentStart); } - - const emailLog = function () { - EmailHelper.sendEmail("loggerDB"); - } - - const separator = () => - const logItem = ({item: logItem}) => ( - {logItem.fmt_time} - {logItem.ID + "|" + logItem.level + "|" + logItem.message} - ); - - return ( - setPageVis(false)}> - - - {setPageVis(false)}}/> - - - - - refreshEntries()}/> - clear()}/> - emailLog()}/> - - - item.ID} - ItemSeparatorComponent={separator} - onEndReachedThreshold={0.5} - refreshing={isFetching} - onRefresh={() => {if(moreDataCanBeLoaded){addEntries()}}} - onEndReached={() => {if(moreDataCanBeLoaded){addEntries()}}} - /> - - - - - - ); + setEntries([...entries].concat(tempEntries)); //push the new entries onto the list + setLoadStats(tempLoadStats); + }; + + const emailLog = function () { + EmailHelper.sendEmail('loggerDB'); + }; + + const separator = () => ; + const logItem = ({ item: logItem }) => ( + + + {logItem.fmt_time} + + + {logItem.ID + '|' + logItem.level + '|' + logItem.message} + + + ); + + return ( + setPageVis(false)}> + + + { + setPageVis(false); + }} + /> + + + + + refreshEntries()} /> + clear()} /> + emailLog()} /> + + + item.ID} + ItemSeparatorComponent={separator} + onEndReachedThreshold={0.5} + refreshing={isFetching} + onRefresh={() => { + if (moreDataCanBeLoaded) { + addEntries(); + } + }} + onEndReached={() => { + if (moreDataCanBeLoaded) { + addEntries(); + } + }} + /> + + + + + + ); }; const styles = StyleSheet.create({ - date: (surfaceColor) => ({ - backgroundColor: surfaceColor, - }), - details: { - fontFamily: "monospace", - }, - entry: (surfaceColor) => ({ - backgroundColor: surfaceColor, - marginLeft: 5, - }), - }); - + date: (surfaceColor) => ({ + backgroundColor: surfaceColor, + }), + details: { + fontFamily: 'monospace', + }, + entry: (surfaceColor) => ({ + backgroundColor: surfaceColor, + marginLeft: 5, + }), +}); + export default LogPage; diff --git a/www/js/control/PopOpCode.jsx b/www/js/control/PopOpCode.jsx index 21ce227c0..510ee84fd 100644 --- a/www/js/control/PopOpCode.jsx +++ b/www/js/control/PopOpCode.jsx @@ -1,79 +1,92 @@ -import React, { useState } from "react"; +import React, { useState } from 'react'; import { Modal, StyleSheet } from 'react-native'; import { Button, Text, IconButton, Dialog, useTheme } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; -import QrCode from "../components/QrCode"; -import AlertBar from "./AlertBar"; -import { settingStyles } from "./ProfileSettings"; +import { useTranslation } from 'react-i18next'; +import QrCode from '../components/QrCode'; +import AlertBar from './AlertBar'; +import { settingStyles } from './ProfileSettings'; -const PopOpCode = ({visibilityValue, tokenURL, action, setVis}) => { - const { t } = useTranslation(); - const { colors } = useTheme(); +const PopOpCode = ({ visibilityValue, tokenURL, action, setVis }) => { + const { t } = useTranslation(); + const { colors } = useTheme(); - const opcodeList = tokenURL.split("="); - const opcode = opcodeList[opcodeList.length - 1]; - - const [copyAlertVis, setCopyAlertVis] = useState(false); + const opcodeList = tokenURL.split('='); + const opcode = opcodeList[opcodeList.length - 1]; - const copyText = function(textToCopy){ - navigator.clipboard.writeText(textToCopy).then(() => { - setCopyAlertvis(true); - }) - } + const [copyAlertVis, setCopyAlertVis] = useState(false); - let copyButton; - if (window.cordova.platformId == "ios"){ - copyButton = {copyText(opcode); setCopyAlertVis(true)}} style={styles.button}/> - } + const copyText = function (textToCopy) { + navigator.clipboard.writeText(textToCopy).then(() => { + setCopyAlertvis(true); + }); + }; - return ( - <> - setVis(false)} - transparent={true}> - setVis(false)} - style={settingStyles.dialog(colors.elevation.level3)}> - {t("general-settings.qrcode")} - - {t("general-settings.qrcode-share-title")} - - {opcode} - - - action()} style={styles.button}/> - {copyButton} - - - - + let copyButton; + if (window.cordova.platformId == 'ios') { + copyButton = ( + { + copyText(opcode); + setCopyAlertVis(true); + }} + style={styles.button} + /> + ); + } - - - ) -} + return ( + <> + setVis(false)} transparent={true}> + setVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + {t('general-settings.qrcode')} + + {t('general-settings.qrcode-share-title')} + + {opcode} + + + action()} style={styles.button} /> + {copyButton} + + + + + + + + ); +}; const styles = StyleSheet.create({ - title: - { - alignItems: 'center', - justifyContent: 'center', - }, - content: { - alignItems: 'center', - justifyContent: 'center', - margin: 5 - }, - button: { - margin: 'auto', - }, - opcode: { - fontFamily: "monospace", - wordBreak: "break-word", - marginTop: 5 - }, - text : { - fontWeight: 'bold', - marginBottom: 5 - } - }); + title: { + alignItems: 'center', + justifyContent: 'center', + }, + content: { + alignItems: 'center', + justifyContent: 'center', + margin: 5, + }, + button: { + margin: 'auto', + }, + opcode: { + fontFamily: 'monospace', + wordBreak: 'break-word', + marginTop: 5, + }, + text: { + fontWeight: 'bold', + marginBottom: 5, + }, +}); -export default PopOpCode; \ No newline at end of file +export default PopOpCode; diff --git a/www/js/control/PrivacyPolicyModal.tsx b/www/js/control/PrivacyPolicyModal.tsx index 7a67426ac..27cb907dd 100644 --- a/www/js/control/PrivacyPolicyModal.tsx +++ b/www/js/control/PrivacyPolicyModal.tsx @@ -1,35 +1,34 @@ -import React from "react"; -import { Modal, useWindowDimensions, ScrollView } from "react-native"; +import React from 'react'; +import { Modal, useWindowDimensions, ScrollView } from 'react-native'; import { Dialog, Button, useTheme } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; -import PrivacyPolicy from "../onboarding/PrivacyPolicy"; -import { settingStyles } from "./ProfileSettings"; +import { useTranslation } from 'react-i18next'; +import PrivacyPolicy from '../onboarding/PrivacyPolicy'; +import { settingStyles } from './ProfileSettings'; const PrivacyPolicyModal = ({ privacyVis, setPrivacyVis }) => { - const { height: windowHeight } = useWindowDimensions(); - const { t } = useTranslation(); - const { colors } = useTheme(); + const { height: windowHeight } = useWindowDimensions(); + const { t } = useTranslation(); + const { colors } = useTheme(); - return ( - <> - setPrivacyVis(false)} transparent={true}> - setPrivacyVis(false)} - style={settingStyles.dialog(colors.elevation.level3)}> - - - - - - - - - - - - ) -} + return ( + <> + setPrivacyVis(false)} transparent={true}> + setPrivacyVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + + + + + + + + + + + + ); +}; export default PrivacyPolicyModal; diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index e2e6d04fd..b081e642a 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -1,544 +1,696 @@ -import React, { useState, useEffect, useContext, useRef } from "react"; -import { Modal, StyleSheet, ScrollView } from "react-native"; -import { Dialog, Button, useTheme, Text, Appbar, IconButton, TextInput } from "react-native-paper"; -import { getAngularService } from "../angular-react-helper"; -import { useTranslation } from "react-i18next"; -import ExpansionSection from "./ExpandMenu"; -import SettingRow from "./SettingRow"; -import ControlDataTable from "./ControlDataTable"; -import DemographicsSettingRow from "./DemographicsSettingRow"; -import PopOpCode from "./PopOpCode"; -import ReminderTime from "./ReminderTime" -import useAppConfig from "../useAppConfig"; -import AlertBar from "./AlertBar"; -import DataDatePicker from "./DataDatePicker"; -import PrivacyPolicyModal from "./PrivacyPolicyModal"; - -import {uploadFile} from "./uploadService"; -import ActionMenu from "../components/ActionMenu"; -import SensedPage from "./SensedPage" -import LogPage from "./LogPage"; -import ControlSyncHelper, {ForceSyncRow, getHelperSyncSettings} from "./ControlSyncHelper"; -import ControlCollectionHelper, {getHelperCollectionSettings, getState, isMediumAccuracy, helperToggleLowAccuracy, forceTransition} from "./ControlCollectionHelper"; -import { resetDataAndRefresh } from "../config/dynamicConfig"; -import { AppContext } from "../App"; -import { shareQR } from "../components/QrCode"; -import { storageClear } from "../plugin/storage"; -import { getAppVersion } from "../plugin/clientStats"; -import { getConsentDocument } from "../splash/startprefs"; -import { logDebug } from "../plugin/logger"; +import React, { useState, useEffect, useContext, useRef } from 'react'; +import { Modal, StyleSheet, ScrollView } from 'react-native'; +import { Dialog, Button, useTheme, Text, Appbar, IconButton, TextInput } from 'react-native-paper'; +import { getAngularService } from '../angular-react-helper'; +import { useTranslation } from 'react-i18next'; +import ExpansionSection from './ExpandMenu'; +import SettingRow from './SettingRow'; +import ControlDataTable from './ControlDataTable'; +import DemographicsSettingRow from './DemographicsSettingRow'; +import PopOpCode from './PopOpCode'; +import ReminderTime from './ReminderTime'; +import useAppConfig from '../useAppConfig'; +import AlertBar from './AlertBar'; +import DataDatePicker from './DataDatePicker'; +import PrivacyPolicyModal from './PrivacyPolicyModal'; + +import { uploadFile } from './uploadService'; +import ActionMenu from '../components/ActionMenu'; +import SensedPage from './SensedPage'; +import LogPage from './LogPage'; +import ControlSyncHelper, { ForceSyncRow, getHelperSyncSettings } from './ControlSyncHelper'; +import ControlCollectionHelper, { + getHelperCollectionSettings, + getState, + isMediumAccuracy, + helperToggleLowAccuracy, + forceTransition, +} from './ControlCollectionHelper'; +import { resetDataAndRefresh } from '../config/dynamicConfig'; +import { AppContext } from '../App'; +import { shareQR } from '../components/QrCode'; +import { storageClear } from '../plugin/storage'; +import { getAppVersion } from '../plugin/clientStats'; +import { getConsentDocument } from '../splash/startprefs'; +import { logDebug } from '../plugin/logger'; //any pure functions can go outside const ProfileSettings = () => { - // anything that mutates must go in --- depend on props or state... - const { t } = useTranslation(); - const appConfig = useAppConfig(); - const { colors } = useTheme(); - const { setPermissionsPopupVis } = useContext(AppContext); - - //angular services needed - const CarbonDatasetHelper = getAngularService('CarbonDatasetHelper'); - const EmailHelper = getAngularService('EmailHelper'); - const NotificationScheduler = getAngularService('NotificationScheduler'); - const ControlHelper = getAngularService('ControlHelper'); - - //functions that come directly from an Angular service - const editCollectionConfig = () => setEditCollectionVis(true); - const editSyncConfig = () => setEditSync(true); - - //states and variables used to control/create the settings - const [opCodeVis, setOpCodeVis] = useState(false); - const [nukeSetVis, setNukeVis] = useState(false); - const [carbonDataVis, setCarbonDataVis] = useState(false); - const [forceStateVis, setForceStateVis] = useState(false); - const [logoutVis, setLogoutVis] = useState(false); - const [invalidateSuccessVis, setInvalidateSuccessVis] = useState(false); - const [noConsentVis, setNoConsentVis] = useState(false); - const [noConsentMessageVis, setNoConsentMessageVis] = useState(false); - const [consentVis, setConsentVis] = useState(false); - const [dateDumpVis, setDateDumpVis] = useState(false); - const [privacyVis, setPrivacyVis] = useState(false); - const [uploadVis, setUploadVis] = useState(false); - const [showingSensed, setShowingSensed] = useState(false); - const [showingLog, setShowingLog] = useState(false); - const [editSync, setEditSync] = useState(false); - const [editCollectionVis, setEditCollectionVis] = useState(false); - - // const [collectConfig, setCollectConfig] = useState({}); - const [collectSettings, setCollectSettings] = useState({}); - const [notificationSettings, setNotificationSettings] = useState({}); - const [authSettings, setAuthSettings] = useState({}); - const [syncSettings, setSyncSettings] = useState({}); - const [cacheResult, setCacheResult] = useState(""); - const [connectSettings, setConnectSettings] = useState({}); - const [uiConfig, setUiConfig] = useState({}); - const [consentDoc, setConsentDoc] = useState({}); - const [dumpDate, setDumpDate] = useState(new Date()); - const [uploadReason, setUploadReason] = useState(""); - const appVersion = useRef(); - - let carbonDatasetString = t('general-settings.carbon-dataset') + ": " + CarbonDatasetHelper.getCurrentCarbonDatasetCode(); - const carbonOptions = CarbonDatasetHelper.getCarbonDatasetOptions(); - const stateActions = [{text: "Initialize", transition: "INITIALIZE"}, - {text: 'Start trip', transition: "EXITED_GEOFENCE"}, - {text: 'End trip', transition: "STOPPED_MOVING"}, - {text: 'Visit ended', transition: "VISIT_ENDED"}, - {text: 'Visit started', transition: "VISIT_STARTED"}, - {text: 'Remote push', transition: "RECEIVED_SILENT_PUSH"}] - - useEffect(() => { - //added appConfig.name needed to be defined because appConfig was defined but empty - if (appConfig && (appConfig.name)) { - whenReady(appConfig); - } - }, [appConfig]); - - const refreshScreen = function() { - refreshCollectSettings(); - refreshNotificationSettings(); - getOPCode(); - getSyncSettings(); - getConnectURL(); - getAppVersion().then((version) => { - appVersion.current = version; - }); - } - - //previously not loaded on regular refresh, this ensures it stays caught up - useEffect(() => { - refreshNotificationSettings(); - }, [uiConfig]) - - const whenReady = function(newAppConfig){ - var tempUiConfig = newAppConfig; - - // backwards compat hack to fill in the raw_data_use for programs that don't have it - const default_raw_data_use = { - "en": `to monitor the ${tempUiConfig.intro.program_or_study}, send personalized surveys or provide recommendations to participants`, - "es": `para monitorear el ${tempUiConfig.intro.program_or_study}, enviar encuestas personalizadas o proporcionar recomendaciones a los participantes` - } - Object.entries(tempUiConfig.intro.translated_text).forEach(([lang, val]) => { - val.raw_data_use = val.raw_data_use || default_raw_data_use[lang]; - }); - - // Backwards compat hack to fill in the `app_required` based on the - // old-style "program_or_study" - // remove this at the end of 2023 when all programs have been migrated over - if (tempUiConfig.intro.app_required == undefined) { - tempUiConfig.intro.app_required = tempUiConfig?.intro.program_or_study == 'program'; - } - tempUiConfig.opcode = tempUiConfig.opcode || {}; - if (tempUiConfig.opcode.autogen == undefined) { - tempUiConfig.opcode.autogen = tempUiConfig?.intro.program_or_study == 'study'; - } - - // setTemplateText(tempUiConfig.intro.translated_text); - // console.log("translated text is??", templateText); - setUiConfig(tempUiConfig); - refreshScreen(); - } - - async function refreshCollectSettings() { - console.debug('about to refreshCollectSettings, collectSettings = ', collectSettings); - const newCollectSettings = {}; - - // // refresh collect plugin configuration - const collectionPluginConfig = await getHelperCollectionSettings(); - newCollectSettings.config = collectionPluginConfig; - - const collectionPluginState = await getState(); - newCollectSettings.state = collectionPluginState; - newCollectSettings.trackingOn = collectionPluginState != "local.state.tracking_stopped" - && collectionPluginState != "STATE_TRACKING_STOPPED"; - - const isLowAccuracy = await isMediumAccuracy(); - if (typeof isLowAccuracy != 'undefined') { - newCollectSettings.lowAccuracy = isLowAccuracy; - } - - setCollectSettings(newCollectSettings); + // anything that mutates must go in --- depend on props or state... + const { t } = useTranslation(); + const appConfig = useAppConfig(); + const { colors } = useTheme(); + const { setPermissionsPopupVis } = useContext(AppContext); + + //angular services needed + const CarbonDatasetHelper = getAngularService('CarbonDatasetHelper'); + const EmailHelper = getAngularService('EmailHelper'); + const NotificationScheduler = getAngularService('NotificationScheduler'); + const ControlHelper = getAngularService('ControlHelper'); + + //functions that come directly from an Angular service + const editCollectionConfig = () => setEditCollectionVis(true); + const editSyncConfig = () => setEditSync(true); + + //states and variables used to control/create the settings + const [opCodeVis, setOpCodeVis] = useState(false); + const [nukeSetVis, setNukeVis] = useState(false); + const [carbonDataVis, setCarbonDataVis] = useState(false); + const [forceStateVis, setForceStateVis] = useState(false); + const [logoutVis, setLogoutVis] = useState(false); + const [invalidateSuccessVis, setInvalidateSuccessVis] = useState(false); + const [noConsentVis, setNoConsentVis] = useState(false); + const [noConsentMessageVis, setNoConsentMessageVis] = useState(false); + const [consentVis, setConsentVis] = useState(false); + const [dateDumpVis, setDateDumpVis] = useState(false); + const [privacyVis, setPrivacyVis] = useState(false); + const [uploadVis, setUploadVis] = useState(false); + const [showingSensed, setShowingSensed] = useState(false); + const [showingLog, setShowingLog] = useState(false); + const [editSync, setEditSync] = useState(false); + const [editCollectionVis, setEditCollectionVis] = useState(false); + + // const [collectConfig, setCollectConfig] = useState({}); + const [collectSettings, setCollectSettings] = useState({}); + const [notificationSettings, setNotificationSettings] = useState({}); + const [authSettings, setAuthSettings] = useState({}); + const [syncSettings, setSyncSettings] = useState({}); + const [cacheResult, setCacheResult] = useState(''); + const [connectSettings, setConnectSettings] = useState({}); + const [uiConfig, setUiConfig] = useState({}); + const [consentDoc, setConsentDoc] = useState({}); + const [dumpDate, setDumpDate] = useState(new Date()); + const [uploadReason, setUploadReason] = useState(''); + const appVersion = useRef(); + + let carbonDatasetString = + t('general-settings.carbon-dataset') + ': ' + CarbonDatasetHelper.getCurrentCarbonDatasetCode(); + const carbonOptions = CarbonDatasetHelper.getCarbonDatasetOptions(); + const stateActions = [ + { text: 'Initialize', transition: 'INITIALIZE' }, + { text: 'Start trip', transition: 'EXITED_GEOFENCE' }, + { text: 'End trip', transition: 'STOPPED_MOVING' }, + { text: 'Visit ended', transition: 'VISIT_ENDED' }, + { text: 'Visit started', transition: 'VISIT_STARTED' }, + { text: 'Remote push', transition: 'RECEIVED_SILENT_PUSH' }, + ]; + + useEffect(() => { + //added appConfig.name needed to be defined because appConfig was defined but empty + if (appConfig && appConfig.name) { + whenReady(appConfig); } - - //ensure ui table updated when editor closes - useEffect(() => { - if(editCollectionVis == false) { - setTimeout(function() { - console.log("closed editor, time to refresh collect"); - refreshCollectSettings(); - }, 1000); - } - }, [editCollectionVis]) - - async function refreshNotificationSettings() { - console.debug('about to refreshNotificationSettings, notificationSettings = ', notificationSettings); - const newNotificationSettings ={}; - - if (uiConfig?.reminderSchemes) { - const prefs = await NotificationScheduler.getReminderPrefs(); - const m = moment(prefs.reminder_time_of_day, 'HH:mm'); - newNotificationSettings.prefReminderTimeVal = m.toDate(); - const n = moment(newNotificationSettings.prefReminderTimeVal); - newNotificationSettings.prefReminderTime = n.format('LT'); - newNotificationSettings.prefReminderTimeOnLoad = prefs.reminder_time_of_day; - newNotificationSettings.scheduledNotifs = await NotificationScheduler.getScheduledNotifs(); - updatePrefReminderTime(false); - } - - console.log("notification settings before and after", notificationSettings, newNotificationSettings); - setNotificationSettings(newNotificationSettings); - } - - async function getSyncSettings() { - console.log("getting sync settings"); - var newSyncSettings = {}; - getHelperSyncSettings().then(function(showConfig) { - newSyncSettings.show_config = showConfig; - setSyncSettings(newSyncSettings); - console.log("sync settings are ", syncSettings); - }); + }, [appConfig]); + + const refreshScreen = function () { + refreshCollectSettings(); + refreshNotificationSettings(); + getOPCode(); + getSyncSettings(); + getConnectURL(); + getAppVersion().then((version) => { + appVersion.current = version; + }); + }; + + //previously not loaded on regular refresh, this ensures it stays caught up + useEffect(() => { + refreshNotificationSettings(); + }, [uiConfig]); + + const whenReady = function (newAppConfig) { + var tempUiConfig = newAppConfig; + + // backwards compat hack to fill in the raw_data_use for programs that don't have it + const default_raw_data_use = { + en: `to monitor the ${tempUiConfig.intro.program_or_study}, send personalized surveys or provide recommendations to participants`, + es: `para monitorear el ${tempUiConfig.intro.program_or_study}, enviar encuestas personalizadas o proporcionar recomendaciones a los participantes`, }; - - //update sync settings in the table when close editor - useEffect(() => { - getSyncSettings(); - }, [editSync]); - - async function getConnectURL() { - ControlHelper.getSettings().then(function(response) { - var newConnectSettings ={} - newConnectSettings.url = response.connectUrl; - console.log(response); - setConnectSettings(newConnectSettings); - }, function(error) { - Logger.displayError("While getting connect url", error); - }); + Object.entries(tempUiConfig.intro.translated_text).forEach(([lang, val]) => { + val.raw_data_use = val.raw_data_use || default_raw_data_use[lang]; + }); + + // Backwards compat hack to fill in the `app_required` based on the + // old-style "program_or_study" + // remove this at the end of 2023 when all programs have been migrated over + if (tempUiConfig.intro.app_required == undefined) { + tempUiConfig.intro.app_required = tempUiConfig?.intro.program_or_study == 'program'; } - - async function getOPCode() { - const newAuthSettings = {}; - const opcode = await ControlHelper.getOPCode(); - if(opcode == null){ - newAuthSettings.opcode = "Not logged in"; - } else { - newAuthSettings.opcode = opcode; - } - setAuthSettings(newAuthSettings); - }; - - //methods that control the settings - const uploadLog = function () { - if(uploadReason != "") { - let reason = uploadReason; - uploadFile("loggerDB", reason); - setUploadVis(false); - } + tempUiConfig.opcode = tempUiConfig.opcode || {}; + if (tempUiConfig.opcode.autogen == undefined) { + tempUiConfig.opcode.autogen = tempUiConfig?.intro.program_or_study == 'study'; } - const emailLog = function () { - // Passing true, we want to send logs - EmailHelper.sendEmail("loggerDB") - }; - - async function updatePrefReminderTime(storeNewVal=true, newTime){ - console.log(newTime); - if(storeNewVal){ - const m = moment(newTime); - // store in HH:mm - NotificationScheduler.setReminderPrefs({ reminder_time_of_day: m.format('HH:mm') }).then(() => { - refreshNotificationSettings(); - }); - } + // setTemplateText(tempUiConfig.intro.translated_text); + // console.log("translated text is??", templateText); + setUiConfig(tempUiConfig); + refreshScreen(); + }; + + async function refreshCollectSettings() { + console.debug('about to refreshCollectSettings, collectSettings = ', collectSettings); + const newCollectSettings = {}; + + // // refresh collect plugin configuration + const collectionPluginConfig = await getHelperCollectionSettings(); + newCollectSettings.config = collectionPluginConfig; + + const collectionPluginState = await getState(); + newCollectSettings.state = collectionPluginState; + newCollectSettings.trackingOn = + collectionPluginState != 'local.state.tracking_stopped' && + collectionPluginState != 'STATE_TRACKING_STOPPED'; + + const isLowAccuracy = await isMediumAccuracy(); + if (typeof isLowAccuracy != 'undefined') { + newCollectSettings.lowAccuracy = isLowAccuracy; } - function dummyNotification() { - cordova.plugins.notification.local.addActions('dummy-actions', [ - { id: 'action', title: 'Yes' }, - { id: 'cancel', title: 'No' } - ]); - cordova.plugins.notification.local.schedule({ - id: new Date().getTime(), - title: 'Dummy Title', - text: 'Dummy text', - actions: 'dummy-actions', - trigger: {at: new Date(new Date().getTime() + 5000)}, - }); - } + setCollectSettings(newCollectSettings); + } - async function userStartStopTracking() { - const transitionToForce = collectSettings.trackingOn ? 'STOP_TRACKING' : 'START_TRACKING'; - await forceTransition(transitionToForce); + //ensure ui table updated when editor closes + useEffect(() => { + if (editCollectionVis == false) { + setTimeout(function () { + console.log('closed editor, time to refresh collect'); refreshCollectSettings(); + }, 1000); } + }, [editCollectionVis]); - async function toggleLowAccuracy() { - let toggle = await helperToggleLowAccuracy(); - setTimeout(function() { - refreshCollectSettings(); - }, 1500); - } - - const viewQRCode = function(e) { - setOpCodeVis(true); - } - - const clearNotifications = function() { - window.cordova.plugins.notification.local.clearAll(); - } - - //Platform.OS returns "web" now, but could be used once it's fully a Native app - //for now, use window.cordova.platformId - - const parseState = function(state) { - console.log("state in parse state is", state); - if (state) { - console.log("state in parse state exists", window.cordova.platformId); - if(window.cordova.platformId == 'android') { - console.log("ANDROID state in parse state is", state.substring(12)); - return state.substring(12); - } - else if(window.cordova.platformId == 'ios') { - console.log("IOS state in parse state is", state.substring(6)); - return state.substring(6); - } - } - } - - async function invalidateCache() { - window.cordova.plugins.BEMUserCache.invalidateAllCache().then(function(result) { - console.log("invalidate result", result); - setCacheResult(result); - setInvalidateSuccessVis(true); - }, function(error) { - Logger.displayError("while invalidating cache, error->", error); - }); - } - - //in ProfileSettings in DevZone (above two functions are helpers) - async function checkConsent() { - getConsentDocument().then(function(resultDoc){ - setConsentDoc(resultDoc); - logDebug("In profile settings, consent doc found", resultDoc); - if (resultDoc == null) { - setNoConsentVis(true); - } else { - setConsentVis(true); - } - }, function(error) { - Logger.displayError("Error reading consent document from cache", error) - }); + async function refreshNotificationSettings() { + console.debug( + 'about to refreshNotificationSettings, notificationSettings = ', + notificationSettings, + ); + const newNotificationSettings = {}; + + if (uiConfig?.reminderSchemes) { + const prefs = await NotificationScheduler.getReminderPrefs(); + const m = moment(prefs.reminder_time_of_day, 'HH:mm'); + newNotificationSettings.prefReminderTimeVal = m.toDate(); + const n = moment(newNotificationSettings.prefReminderTimeVal); + newNotificationSettings.prefReminderTime = n.format('LT'); + newNotificationSettings.prefReminderTimeOnLoad = prefs.reminder_time_of_day; + newNotificationSettings.scheduledNotifs = await NotificationScheduler.getScheduledNotifs(); + updatePrefReminderTime(false); } - const onSelectState = function(stateObject) { - forceTransition(stateObject.transition); + console.log( + 'notification settings before and after', + notificationSettings, + newNotificationSettings, + ); + setNotificationSettings(newNotificationSettings); + } + + async function getSyncSettings() { + console.log('getting sync settings'); + var newSyncSettings = {}; + getHelperSyncSettings().then(function (showConfig) { + newSyncSettings.show_config = showConfig; + setSyncSettings(newSyncSettings); + console.log('sync settings are ', syncSettings); + }); + } + + //update sync settings in the table when close editor + useEffect(() => { + getSyncSettings(); + }, [editSync]); + + async function getConnectURL() { + ControlHelper.getSettings().then( + function (response) { + var newConnectSettings = {}; + newConnectSettings.url = response.connectUrl; + console.log(response); + setConnectSettings(newConnectSettings); + }, + function (error) { + Logger.displayError('While getting connect url', error); + }, + ); + } + + async function getOPCode() { + const newAuthSettings = {}; + const opcode = await ControlHelper.getOPCode(); + if (opcode == null) { + newAuthSettings.opcode = 'Not logged in'; + } else { + newAuthSettings.opcode = opcode; } - - const onSelectCarbon = function(carbonObject) { - console.log("changeCarbonDataset(): chose locale " + carbonObject.value); - CarbonDatasetHelper.saveCurrentCarbonDatasetLocale(carbonObject.value); //there's some sort of error here - //Unhandled Promise Rejection: While logging, error -[NSNull UTF8String]: unrecognized selector sent to instance 0x7fff8a625fb0 - carbonDatasetString = i18next.t('general-settings.carbon-dataset') + ": " + CarbonDatasetHelper.getCurrentCarbonDatasetCode(); + setAuthSettings(newAuthSettings); + } + + //methods that control the settings + const uploadLog = function () { + if (uploadReason != '') { + let reason = uploadReason; + uploadFile('loggerDB', reason); + setUploadVis(false); } - - //conditional creation of setting sections - - let logUploadSection; - console.debug("appConfg: support_upload:", appConfig?.profile_controls?.support_upload); - if (appConfig?.profile_controls?.support_upload) { - logUploadSection = setUploadVis(true)}>; + }; + + const emailLog = function () { + // Passing true, we want to send logs + EmailHelper.sendEmail('loggerDB'); + }; + + async function updatePrefReminderTime(storeNewVal = true, newTime) { + console.log(newTime); + if (storeNewVal) { + const m = moment(newTime); + // store in HH:mm + NotificationScheduler.setReminderPrefs({ reminder_time_of_day: m.format('HH:mm') }).then( + () => { + refreshNotificationSettings(); + }, + ); } - - let timePicker; - let notifSchedule; - if (appConfig?.reminderSchemes) - { - timePicker = ; - notifSchedule = <>console.log("")}> - + } + + function dummyNotification() { + cordova.plugins.notification.local.addActions('dummy-actions', [ + { id: 'action', title: 'Yes' }, + { id: 'cancel', title: 'No' }, + ]); + cordova.plugins.notification.local.schedule({ + id: new Date().getTime(), + title: 'Dummy Title', + text: 'Dummy text', + actions: 'dummy-actions', + trigger: { at: new Date(new Date().getTime() + 5000) }, + }); + } + + async function userStartStopTracking() { + const transitionToForce = collectSettings.trackingOn ? 'STOP_TRACKING' : 'START_TRACKING'; + await forceTransition(transitionToForce); + refreshCollectSettings(); + } + + async function toggleLowAccuracy() { + let toggle = await helperToggleLowAccuracy(); + setTimeout(function () { + refreshCollectSettings(); + }, 1500); + } + + const viewQRCode = function (e) { + setOpCodeVis(true); + }; + + const clearNotifications = function () { + window.cordova.plugins.notification.local.clearAll(); + }; + + //Platform.OS returns "web" now, but could be used once it's fully a Native app + //for now, use window.cordova.platformId + + const parseState = function (state) { + console.log('state in parse state is', state); + if (state) { + console.log('state in parse state exists', window.cordova.platformId); + if (window.cordova.platformId == 'android') { + console.log('ANDROID state in parse state is', state.substring(12)); + return state.substring(12); + } else if (window.cordova.platformId == 'ios') { + console.log('IOS state in parse state is', state.substring(6)); + return state.substring(6); + } } - - return ( - <> - - - {t('control.log-out')} - setLogoutVis(true)}> - - - - - - setPrivacyVis(true)}> - {timePicker} - - setPermissionsPopupVis(true)}> - - setCarbonDataVis(true)}> - setDateDumpVis(true)}> - {logUploadSection} - - - - - - - - {notifSchedule} - - setNukeVis(true)}> - setForceStateVis(true)}> - setShowingLog(true)}> - setShowingSensed(true)}> - - - - - - console.log("")} desc={appVersion.current}> - - - {/* menu for "nuke data" */} - setNukeVis(false)} - transparent={true}> - setNukeVis(false)} - style={settingStyles.dialog(colors.elevation.level3)}> - {t('general-settings.clear-data')} - - - - - - - - - - - - {/* menu for "set carbon dataset - only somewhat working" */} - clearNotifications()}> - - {/* force state sheet */} - {}}> - - {/* upload reason input */} - setUploadVis(false)} - transparent={true}> - setUploadVis(false)} - style={settingStyles.dialog(colors.elevation.level3)}> - {t('upload-service.upload-database', {db: "loggerDB"})} - - setUploadReason(uploadReason)} - placeholder={t('upload-service.please-fill-in-what-is-wrong')}> - - - - - - - - - - {/* opcode viewing popup */} - shareQR(authSettings.opcode)}> - - {/* {view privacy} */} - - - {/* logout menu */} - setLogoutVis(false)} transparent={true}> - setLogoutVis(false)} - style={settingStyles.dialog(colors.elevation.level3)}> - {t('general-settings.are-you-sure')} - - {t('general-settings.log-out-warning')} - - - - - - - - - {/* handle no consent */} - setNoConsentVis(false)} transparent={true}> - setNoConsentVis(false)} - style={settingStyles.dialog(colors.elevation.level3)}> - {t('general-settings.consent-not-found')} - - {t('general-settings.no-consent-logout')} - - - - - - - - {/* handle consent */} - setConsentVis(false)} transparent={true}> - setConsentVis(false)} - style={settingStyles.dialog(colors.elevation.level3)}> - {t('general-settings.consented-to', {approval_date: consentDoc.approval_date})} - - - - - - - - - - - - - - - - - - - + }; + + async function invalidateCache() { + window.cordova.plugins.BEMUserCache.invalidateAllCache().then( + function (result) { + console.log('invalidate result', result); + setCacheResult(result); + setInvalidateSuccessVis(true); + }, + function (error) { + Logger.displayError('while invalidating cache, error->', error); + }, ); + } + + //in ProfileSettings in DevZone (above two functions are helpers) + async function checkConsent() { + getConsentDocument().then( + function (resultDoc) { + setConsentDoc(resultDoc); + logDebug('In profile settings, consent doc found', resultDoc); + if (resultDoc == null) { + setNoConsentVis(true); + } else { + setConsentVis(true); + } + }, + function (error) { + Logger.displayError('Error reading consent document from cache', error); + }, + ); + } + + const onSelectState = function (stateObject) { + forceTransition(stateObject.transition); + }; + + const onSelectCarbon = function (carbonObject) { + console.log('changeCarbonDataset(): chose locale ' + carbonObject.value); + CarbonDatasetHelper.saveCurrentCarbonDatasetLocale(carbonObject.value); //there's some sort of error here + //Unhandled Promise Rejection: While logging, error -[NSNull UTF8String]: unrecognized selector sent to instance 0x7fff8a625fb0 + carbonDatasetString = + i18next.t('general-settings.carbon-dataset') + + ': ' + + CarbonDatasetHelper.getCurrentCarbonDatasetCode(); + }; + + //conditional creation of setting sections + + let logUploadSection; + console.debug('appConfg: support_upload:', appConfig?.profile_controls?.support_upload); + if (appConfig?.profile_controls?.support_upload) { + logUploadSection = ( + setUploadVis(true)}> + ); + } + + let timePicker; + let notifSchedule; + if (appConfig?.reminderSchemes) { + timePicker = ( + + ); + notifSchedule = ( + <> + console.log('')}> + + + ); + } + + return ( + <> + + + {t('control.log-out')} + setLogoutVis(true)}> + + + + + + setPrivacyVis(true)}> + {timePicker} + + setPermissionsPopupVis(true)}> + + setCarbonDataVis(true)}> + setDateDumpVis(true)}> + {logUploadSection} + + + + + + + + {notifSchedule} + + setNukeVis(true)}> + setForceStateVis(true)}> + setShowingLog(true)}> + setShowingSensed(true)}> + + + + + + console.log('')} + desc={appVersion.current}> + + + {/* menu for "nuke data" */} + setNukeVis(false)} transparent={true}> + setNukeVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + {t('general-settings.clear-data')} + + + + + + + + + + + + {/* menu for "set carbon dataset - only somewhat working" */} + clearNotifications()}> + + {/* force state sheet */} + {}}> + + {/* upload reason input */} + setUploadVis(false)} transparent={true}> + setUploadVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + {t('upload-service.upload-database', { db: 'loggerDB' })} + + setUploadReason(uploadReason)} + placeholder={t('upload-service.please-fill-in-what-is-wrong')}> + + + + + + + + + {/* opcode viewing popup */} + shareQR(authSettings.opcode)}> + + {/* {view privacy} */} + + + {/* logout menu */} + setLogoutVis(false)} transparent={true}> + setLogoutVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + {t('general-settings.are-you-sure')} + + {t('general-settings.log-out-warning')} + + + + + + + + + {/* handle no consent */} + setNoConsentVis(false)} transparent={true}> + setNoConsentVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + {t('general-settings.consent-not-found')} + + {t('general-settings.no-consent-logout')} + + + + + + + + {/* handle consent */} + setConsentVis(false)} transparent={true}> + setConsentVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + + {t('general-settings.consented-to', { approval_date: consentDoc.approval_date })} + + + + + + + + + + + + + + + + + + + ); }; export const settingStyles = StyleSheet.create({ - dialog: (surfaceColor) => ({ - backgroundColor: surfaceColor, - margin: 5, - marginLeft: 25, - marginRight: 25 - }), - monoDesc: { - fontSize: 12, - fontFamily: "monospace", - } - }); - - export default ProfileSettings; + dialog: (surfaceColor) => ({ + backgroundColor: surfaceColor, + margin: 5, + marginLeft: 25, + marginRight: 25, + }), + monoDesc: { + fontSize: 12, + fontFamily: 'monospace', + }, +}); + +export default ProfileSettings; diff --git a/www/js/control/ReminderTime.tsx b/www/js/control/ReminderTime.tsx index 40e8485ee..b603758b0 100644 --- a/www/js/control/ReminderTime.tsx +++ b/www/js/control/ReminderTime.tsx @@ -1,69 +1,70 @@ -import React, { useState } from "react"; +import React, { useState } from 'react'; import { Modal, StyleSheet } from 'react-native'; import { List, useTheme } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; +import { useTranslation } from 'react-i18next'; import { TimePickerModal } from 'react-native-paper-dates'; import { styles as rowStyles } from './SettingRow'; const TimeSelect = ({ visible, setVisible, defaultTime, updateFunc }) => { + const onDismiss = React.useCallback(() => { + setVisible(false); + }, [setVisible]); - const onDismiss = React.useCallback(() => { - setVisible(false) - }, [setVisible]) + const onConfirm = React.useCallback( + ({ hours, minutes }) => { + setVisible(false); + const d = new Date(); + d.setHours(hours, minutes); + updateFunc(true, d); + }, + [setVisible, updateFunc], + ); - const onConfirm = React.useCallback( - ({ hours, minutes }) => { - setVisible(false); - const d = new Date(); - d.setHours(hours, minutes); - updateFunc(true, d); - }, - [setVisible, updateFunc] - ); - - return ( - setVisible(false)} - transparent={true}> - - - ) -} + return ( + setVisible(false)} transparent={true}> + + + ); +}; const ReminderTime = ({ rowText, timeVar, defaultTime, updateFunc }) => { - const { t } = useTranslation(); - const { colors } = useTheme(); - const [pickTimeVis, setPickTimeVis] = useState(false); + const { t } = useTranslation(); + const { colors } = useTheme(); + const [pickTimeVis, setPickTimeVis] = useState(false); - let rightComponent = ; + let rightComponent = ; - return ( - <> - + setPickTimeVis(true)} right={() => rightComponent} - /> - - + /> - - ); + + + ); }; const styles = StyleSheet.create({ - item: (surfaceColor) => ({ - justifyContent: 'space-between', - alignContent: 'center', - backgroundColor: surfaceColor, - margin: 1, - }), - }); + item: (surfaceColor) => ({ + justifyContent: 'space-between', + alignContent: 'center', + backgroundColor: surfaceColor, + margin: 1, + }), +}); -export default ReminderTime; \ No newline at end of file +export default ReminderTime; diff --git a/www/js/control/SensedPage.tsx b/www/js/control/SensedPage.tsx index b746dfc8d..82fa60581 100644 --- a/www/js/control/SensedPage.tsx +++ b/www/js/control/SensedPage.tsx @@ -1,91 +1,101 @@ -import React, { useState, useEffect } from "react"; -import { View, StyleSheet, SafeAreaView, Modal } from "react-native"; -import { useTheme, Appbar, IconButton, Text } from "react-native-paper"; -import { getAngularService } from "../angular-react-helper"; -import { useTranslation } from "react-i18next"; +import React, { useState, useEffect } from 'react'; +import { View, StyleSheet, SafeAreaView, Modal } from 'react-native'; +import { useTheme, Appbar, IconButton, Text } from 'react-native-paper'; +import { getAngularService } from '../angular-react-helper'; +import { useTranslation } from 'react-i18next'; import { FlashList } from '@shopify/flash-list'; -import moment from "moment"; +import moment from 'moment'; -const SensedPage = ({pageVis, setPageVis}) => { - const { t } = useTranslation(); - const { colors } = useTheme(); - const EmailHelper = getAngularService('EmailHelper'); +const SensedPage = ({ pageVis, setPageVis }) => { + const { t } = useTranslation(); + const { colors } = useTheme(); + const EmailHelper = getAngularService('EmailHelper'); - /* Let's keep a reference to the database for convenience */ - const [ DB, setDB ]= useState(); - const [ entries, setEntries ] = useState([]); + /* Let's keep a reference to the database for convenience */ + const [DB, setDB] = useState(); + const [entries, setEntries] = useState([]); - const emailCache = function() { - EmailHelper.sendEmail("userCacheDB"); - } + const emailCache = function () { + EmailHelper.sendEmail('userCacheDB'); + }; - async function updateEntries() { - //hardcoded function and keys after eliminating bit-rotted options - setDB(window.cordova.plugins.BEMUserCache); - let userCacheFn = DB.getAllMessages; - let userCacheKey = "statemachine/transition"; - try { - let entryList = await userCacheFn(userCacheKey, true); - let tempEntries = []; - entryList.forEach(entry => { - entry.metadata.write_fmt_time = moment.unix(entry.metadata.write_ts) - .tz(entry.metadata.time_zone) - .format("llll"); - entry.data = JSON.stringify(entry.data, null, 2); - tempEntries.push(entry); - }); - setEntries(tempEntries); - } - catch(error) { - window.Logger.log(window.Logger.LEVEL_ERROR, "Error updating entries"+ error); - } + async function updateEntries() { + //hardcoded function and keys after eliminating bit-rotted options + setDB(window.cordova.plugins.BEMUserCache); + let userCacheFn = DB.getAllMessages; + let userCacheKey = 'statemachine/transition'; + try { + let entryList = await userCacheFn(userCacheKey, true); + let tempEntries = []; + entryList.forEach((entry) => { + entry.metadata.write_fmt_time = moment + .unix(entry.metadata.write_ts) + .tz(entry.metadata.time_zone) + .format('llll'); + entry.data = JSON.stringify(entry.data, null, 2); + tempEntries.push(entry); + }); + setEntries(tempEntries); + } catch (error) { + window.Logger.log(window.Logger.LEVEL_ERROR, 'Error updating entries' + error); } + } + + useEffect(() => { + updateEntries(); + }, [pageVis]); - useEffect(() => { - updateEntries(); - }, [pageVis]); + const separator = () => ; + const cacheItem = ({ item: cacheItem }) => ( + + + {cacheItem.metadata.write_fmt_time} + + + {cacheItem.data} + + + ); - const separator = () => - const cacheItem = ({item: cacheItem}) => ( - {cacheItem.metadata.write_fmt_time} - {cacheItem.data} - ); + return ( + setPageVis(false)}> + + + setPageVis(false)} /> + + - return ( - setPageVis(false)}> - - - setPageVis(false)}/> - - + + updateEntries()} /> + emailCache()} /> + - - updateEntries()}/> - emailCache()}/> - - - item.metadata.write_ts} - ItemSeparatorComponent={separator} - /> - - - ); + item.metadata.write_ts} + ItemSeparatorComponent={separator} + /> + + + ); }; const styles = StyleSheet.create({ - date: (surfaceColor) => ({ - backgroundColor: surfaceColor, - }), - details: { - fontFamily: "monospace", - }, - entry: (surfaceColor) => ({ - backgroundColor: surfaceColor, - marginLeft: 5, - }), - }); + date: (surfaceColor) => ({ + backgroundColor: surfaceColor, + }), + details: { + fontFamily: 'monospace', + }, + entry: (surfaceColor) => ({ + backgroundColor: surfaceColor, + marginLeft: 5, + }), +}); export default SensedPage; diff --git a/www/js/control/SettingRow.jsx b/www/js/control/SettingRow.jsx index 473a45d7f..b55b3c804 100644 --- a/www/js/control/SettingRow.jsx +++ b/www/js/control/SettingRow.jsx @@ -1,52 +1,59 @@ -import React from "react"; +import React from 'react'; import { StyleSheet } from 'react-native'; import { List, Switch, useTheme } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; +import { useTranslation } from 'react-i18next'; -const SettingRow = ({textKey, iconName=undefined, action, desc=undefined, switchValue=undefined, descStyle=undefined}) => { - const { t } = useTranslation(); //this accesses the translations - const { colors } = useTheme(); // use this to get the theme colors instead of hardcoded #hex colors +const SettingRow = ({ + textKey, + iconName = undefined, + action, + desc = undefined, + switchValue = undefined, + descStyle = undefined, +}) => { + const { t } = useTranslation(); //this accesses the translations + const { colors } = useTheme(); // use this to get the theme colors instead of hardcoded #hex colors - let rightComponent; - if (iconName) { - rightComponent = ; - } else { - rightComponent = ; - } - let descriptionText; - if(desc) { - descriptionText = {desc}; - } else { - descriptionText = ""; - } + let rightComponent; + if (iconName) { + rightComponent = ; + } else { + rightComponent = ; + } + let descriptionText; + if (desc) { + descriptionText = { desc }; + } else { + descriptionText = ''; + } - return ( - action(e)} - right={() => rightComponent} - /> - ); + return ( + action(e)} + right={() => rightComponent} + /> + ); }; export const styles = StyleSheet.create({ - item: (surfaceColor) => ({ - justifyContent: 'space-between', - alignContent: 'center', - backgroundColor: surfaceColor, - margin: 1, - }), - title: { - fontSize: 14, - marginVertical: 2, - }, - description: { - fontSize: 12, - }, - }); + item: (surfaceColor) => ({ + justifyContent: 'space-between', + alignContent: 'center', + backgroundColor: surfaceColor, + margin: 1, + }), + title: { + fontSize: 14, + marginVertical: 2, + }, + description: { + fontSize: 12, + }, +}); export default SettingRow; diff --git a/www/js/control/emailService.js b/www/js/control/emailService.js index 0374adf5a..8eeaf39bb 100644 --- a/www/js/control/emailService.js +++ b/www/js/control/emailService.js @@ -2,96 +2,113 @@ import angular from 'angular'; -angular.module('emission.services.email', ['emission.plugin.logger']) +angular + .module('emission.services.email', ['emission.plugin.logger']) - .service('EmailHelper', function ($window, $http, Logger) { + .service('EmailHelper', function ($window, $http, Logger) { + const getEmailConfig = function () { + return new Promise(function (resolve, reject) { + window.Logger.log(window.Logger.LEVEL_INFO, 'About to get email config'); + var address = []; + $http + .get('json/emailConfig.json') + .then(function (emailConfig) { + window.Logger.log( + window.Logger.LEVEL_DEBUG, + 'emailConfigString = ' + JSON.stringify(emailConfig.data), + ); + address.push(emailConfig.data.address); + resolve(address); + }) + .catch(function (err) { + $http + .get('json/emailConfig.json.sample') + .then(function (emailConfig) { + window.Logger.log( + window.Logger.LEVEL_DEBUG, + 'default emailConfigString = ' + JSON.stringify(emailConfig.data), + ); + address.push(emailConfig.data.address); + resolve(address); + }) + .catch(function (err) { + window.Logger.log( + window.Logger.LEVEL_ERROR, + 'Error while reading default email config' + err, + ); + reject(err); + }); + }); + }); + }; - const getEmailConfig = function () { - return new Promise(function (resolve, reject) { - window.Logger.log(window.Logger.LEVEL_INFO, "About to get email config"); - var address = []; - $http.get("json/emailConfig.json").then(function (emailConfig) { - window.Logger.log(window.Logger.LEVEL_DEBUG, "emailConfigString = " + JSON.stringify(emailConfig.data)); - address.push(emailConfig.data.address) - resolve(address); - }).catch(function (err) { - $http.get("json/emailConfig.json.sample").then(function (emailConfig) { - window.Logger.log(window.Logger.LEVEL_DEBUG, "default emailConfigString = " + JSON.stringify(emailConfig.data)); - address.push(emailConfig.data.address) - resolve(address); - }).catch(function (err) { - window.Logger.log(window.Logger.LEVEL_ERROR, "Error while reading default email config" + err); - reject(err); - }); - }); - }); - } - - const hasAccount = function() { - return new Promise(function(resolve, reject) { - $window.cordova.plugins.email.hasAccount(function (hasAct) { - resolve(hasAct); - }); - }); - } + const hasAccount = function () { + return new Promise(function (resolve, reject) { + $window.cordova.plugins.email.hasAccount(function (hasAct) { + resolve(hasAct); + }); + }); + }; - this.sendEmail = function (database) { - Promise.all([getEmailConfig(), hasAccount()]).then(function([address, hasAct]) { - var parentDir = "unknown"; + this.sendEmail = function (database) { + Promise.all([getEmailConfig(), hasAccount()]).then(function ([address, hasAct]) { + var parentDir = 'unknown'; - // Check this only for ios, since for android, the check always fails unless - // the user grants the "GET_ACCOUNTS" dynamic permission - // without the permission, we only see the e-mission account which is not valid - // - // https://developer.android.com/reference/android/accounts/AccountManager#getAccounts() - // - // Caller targeting API level below Build.VERSION_CODES.O that - // have not been granted the Manifest.permission.GET_ACCOUNTS - // permission, will only see those accounts managed by - // AbstractAccountAuthenticators whose signature matches the - // client. - // and on android, if the account is not configured, the gmail app will be launched anyway - // on iOS, nothing will happen. So we perform the check only on iOS so that we can - // generate a reasonably relevant error message + // Check this only for ios, since for android, the check always fails unless + // the user grants the "GET_ACCOUNTS" dynamic permission + // without the permission, we only see the e-mission account which is not valid + // + // https://developer.android.com/reference/android/accounts/AccountManager#getAccounts() + // + // Caller targeting API level below Build.VERSION_CODES.O that + // have not been granted the Manifest.permission.GET_ACCOUNTS + // permission, will only see those accounts managed by + // AbstractAccountAuthenticators whose signature matches the + // client. + // and on android, if the account is not configured, the gmail app will be launched anyway + // on iOS, nothing will happen. So we perform the check only on iOS so that we can + // generate a reasonably relevant error message - if (ionic.Platform.isIOS() && !hasAct) { - alert(i18next.t('email-service.email-account-not-configured')); - return; - } + if (ionic.Platform.isIOS() && !hasAct) { + alert(i18next.t('email-service.email-account-not-configured')); + return; + } - if (ionic.Platform.isAndroid()) { - parentDir = "app://databases"; - } - if (ionic.Platform.isIOS()) { - alert(i18next.t('email-service.email-account-mail-app')); - parentDir = cordova.file.dataDirectory + "../LocalDatabase"; - } + if (ionic.Platform.isAndroid()) { + parentDir = 'app://databases'; + } + if (ionic.Platform.isIOS()) { + alert(i18next.t('email-service.email-account-mail-app')); + parentDir = cordova.file.dataDirectory + '../LocalDatabase'; + } - if (parentDir == "unknown") { - alert("parentDir unexpectedly = " + parentDir + "!") - } + if (parentDir == 'unknown') { + alert('parentDir unexpectedly = ' + parentDir + '!'); + } - window.Logger.log(window.Logger.LEVEL_INFO, "Going to email " + database); - parentDir = parentDir + "/" + database; - /* + window.Logger.log(window.Logger.LEVEL_INFO, 'Going to email ' + database); + parentDir = parentDir + '/' + database; + /* window.Logger.log(window.Logger.LEVEL_INFO, "Going to export logs to "+parentDir); */ - alert(i18next.t('email-service.going-to-email', { parentDir: parentDir })); - var email = { - to: address, - attachments: [ - parentDir - ], - subject: i18next.t('email-service.email-log.subject-logs'), - body: i18next.t('email-service.email-log.body-please-fill-in-what-is-wrong') - } - - $window.cordova.plugins.email.open(email, function () { - Logger.log("email app closed while sending, "+JSON.stringify(email)+" not sure if we should do anything"); - // alert(i18next.t('email-service.no-email-address-configured') + err); - return; - }); - }); + alert(i18next.t('email-service.going-to-email', { parentDir: parentDir })); + var email = { + to: address, + attachments: [parentDir], + subject: i18next.t('email-service.email-log.subject-logs'), + body: i18next.t('email-service.email-log.body-please-fill-in-what-is-wrong'), }; -}); + + $window.cordova.plugins.email.open(email, function () { + Logger.log( + 'email app closed while sending, ' + + JSON.stringify(email) + + ' not sure if we should do anything', + ); + // alert(i18next.t('email-service.no-email-address-configured') + err); + return; + }); + }); + }; + }); diff --git a/www/js/control/uploadService.ts b/www/js/control/uploadService.ts index 038bd8efc..2b7520edb 100644 --- a/www/js/control/uploadService.ts +++ b/www/js/control/uploadService.ts @@ -1,129 +1,135 @@ -import { logDebug, logInfo, displayError } from "../plugin/logger"; -import i18next from "i18next"; +import { logDebug, logInfo, displayError } from '../plugin/logger'; +import i18next from 'i18next'; /** * @returns A promise that resolves with an upload URL or rejects with an error */ async function getUploadConfig() { - return new Promise(async function (resolve, reject) { - logInfo( "About to get email config"); - let url = []; - try { - let response = await fetch("json/uploadConfig.json"); - let uploadConfig = await response.json(); - logDebug("uploadConfigString = " + JSON.stringify(uploadConfig['url'])); - url.push(uploadConfig["url"]); - resolve(url); - } catch (err) { - try{ - let response = await fetch("json/uploadConfig.json.sample"); - let uploadConfig = await response.json(); - logDebug("default uploadConfigString = " + JSON.stringify(uploadConfig['url'])); - console.log("default uploadConfigString = " + JSON.stringify(uploadConfig['url'])) - url.push(uploadConfig["url"]); - resolve(url); - } catch (err) { - displayError(err, "Error while reading default upload config"); - reject(err); - } - } - }) + return new Promise(async function (resolve, reject) { + logInfo('About to get email config'); + let url = []; + try { + let response = await fetch('json/uploadConfig.json'); + let uploadConfig = await response.json(); + logDebug('uploadConfigString = ' + JSON.stringify(uploadConfig['url'])); + url.push(uploadConfig['url']); + resolve(url); + } catch (err) { + try { + let response = await fetch('json/uploadConfig.json.sample'); + let uploadConfig = await response.json(); + logDebug('default uploadConfigString = ' + JSON.stringify(uploadConfig['url'])); + console.log('default uploadConfigString = ' + JSON.stringify(uploadConfig['url'])); + url.push(uploadConfig['url']); + resolve(url); + } catch (err) { + displayError(err, 'Error while reading default upload config'); + reject(err); + } + } + }); } function onReadError(err) { - displayError(err, "Error while reading log"); + displayError(err, 'Error while reading log'); } function onUploadError(err) { - displayError(err, "Error while uploading log"); + displayError(err, 'Error while uploading log'); } function readDBFile(parentDir, database, callbackFn) { - return new Promise(function(resolve, reject) { - window['resolveLocalFileSystemURL'](parentDir, function(fs) { - console.log("resolving file system as ", fs); - fs.filesystem.root.getFile(fs.fullPath+database, null, (fileEntry) => { - console.log(fileEntry); - fileEntry.file(function(file) { - console.log(file); - var reader = new FileReader(); + return new Promise(function (resolve, reject) { + window['resolveLocalFileSystemURL'](parentDir, function (fs) { + console.log('resolving file system as ', fs); + fs.filesystem.root.getFile( + fs.fullPath + database, + null, + (fileEntry) => { + console.log(fileEntry); + fileEntry.file(function (file) { + console.log(file); + var reader = new FileReader(); - reader.onprogress = function(report) { - console.log("Current progress is "+JSON.stringify(report)); - if (callbackFn != undefined) { - callbackFn(report.loaded * 100 / report.total); - } - } + reader.onprogress = function (report) { + console.log('Current progress is ' + JSON.stringify(report)); + if (callbackFn != undefined) { + callbackFn((report.loaded * 100) / report.total); + } + }; - reader.onerror = function(error) { - console.log(this.error); - reject({"error": {"message": this.error}}); - } + reader.onerror = function (error) { + console.log(this.error); + reject({ error: { message: this.error } }); + }; - reader.onload = function() { - console.log("Successful file read with " + this.result['byteLength'] +" characters"); - resolve(new DataView(this.result as ArrayBuffer)); - } + reader.onload = function () { + console.log('Successful file read with ' + this.result['byteLength'] + ' characters'); + resolve(new DataView(this.result as ArrayBuffer)); + }; - reader.readAsArrayBuffer(file); - }, reject); - }, reject); - }); + reader.readAsArrayBuffer(file); + }, reject); + }, + reject, + ); }); + }); } const sendToServer = function upload(url, binArray, params) { - //use url encoding to pass additional params in the post - const urlParams = "?reason=" + params.reason + "&tz=" + params.tz; - return fetch(url+urlParams, { - method: 'POST', - headers: {'Content-Type': undefined }, - body: binArray - } ) -} + //use url encoding to pass additional params in the post + const urlParams = '?reason=' + params.reason + '&tz=' + params.tz; + return fetch(url + urlParams, { + method: 'POST', + headers: { 'Content-Type': undefined }, + body: binArray, + }); +}; //only export of this file, used in ProfileSettings and passed the argument (""loggerDB"") export async function uploadFile(database, reason) { - try { - let uploadConfig = await getUploadConfig(); - var parentDir = "unknown"; + try { + let uploadConfig = await getUploadConfig(); + var parentDir = 'unknown'; - if (window['cordova'].platformId.toLowerCase() == "android") { - parentDir = window['cordova'].file.applicationStorageDirectory+"/databases"; - } - else if (window['cordova'].platformId.toLowerCase() == "ios") { - parentDir = window['cordova'].file.dataDirectory + "../LocalDatabase"; - } else { - alert("parentDir unexpectedly = " + parentDir + "!") - } + if (window['cordova'].platformId.toLowerCase() == 'android') { + parentDir = window['cordova'].file.applicationStorageDirectory + '/databases'; + } else if (window['cordova'].platformId.toLowerCase() == 'ios') { + parentDir = window['cordova'].file.dataDirectory + '../LocalDatabase'; + } else { + alert('parentDir unexpectedly = ' + parentDir + '!'); + } - logInfo("Going to upload " + database); - try { - let binString = await readDBFile(parentDir, database, undefined); - console.log("Uploading file of size "+binString['byteLength']); - const params = { - reason: reason, - tz: Intl.DateTimeFormat().resolvedOptions().timeZone - } - uploadConfig.forEach(async (url) => { - //have alert for starting upload, but not progress - window.alert(i18next.t("upload-service.upload-database", {db: database})); + logInfo('Going to upload ' + database); + try { + let binString = await readDBFile(parentDir, database, undefined); + console.log('Uploading file of size ' + binString['byteLength']); + const params = { + reason: reason, + tz: Intl.DateTimeFormat().resolvedOptions().timeZone, + }; + uploadConfig.forEach(async (url) => { + //have alert for starting upload, but not progress + window.alert(i18next.t('upload-service.upload-database', { db: database })); - try { - let response = await sendToServer(url, binString, params); - window.alert(i18next.t("upload-service.upload-details", - {filesizemb: binString['byteLength'] / (1000 * 1000), serverURL: url}) - + i18next.t("upload-service.upload-success")); - return response; - } catch (error) { - onUploadError(error); - } - }); - } - catch (error){ - onReadError(error); + try { + let response = await sendToServer(url, binString, params); + window.alert( + i18next.t('upload-service.upload-details', { + filesizemb: binString['byteLength'] / (1000 * 1000), + serverURL: url, + }) + i18next.t('upload-service.upload-success'), + ); + return response; + } catch (error) { + onUploadError(error); } + }); } catch (error) { - onReadError(error); + onReadError(error); } -}; \ No newline at end of file + } catch (error) { + onReadError(error); + } +} diff --git a/www/js/controllers.js b/www/js/controllers.js index 5a4de0cb4..abf5916c5 100644 --- a/www/js/controllers.js +++ b/www/js/controllers.js @@ -4,85 +4,112 @@ import angular from 'angular'; import { addStatError, addStatReading, statKeys } from './plugin/clientStats'; import { getPendingOnboardingState } from './onboarding/onboardingHelper'; -angular.module('emission.controllers', ['emission.splash.pushnotify', - 'emission.splash.storedevicesettings', - 'emission.splash.localnotify', - 'emission.splash.remotenotify']) +angular + .module('emission.controllers', [ + 'emission.splash.pushnotify', + 'emission.splash.storedevicesettings', + 'emission.splash.localnotify', + 'emission.splash.remotenotify', + ]) -.controller('RootCtrl', function($scope) {}) + .controller('RootCtrl', function ($scope) {}) -.controller('DashCtrl', function($scope) {}) + .controller('DashCtrl', function ($scope) {}) -.controller('SplashCtrl', function($scope, $state, $interval, $rootScope, - PushNotify, StoreDeviceSettings, - LocalNotify, RemoteNotify) { - console.log('SplashCtrl invoked'); - // alert("attach debugger!"); - // PushNotify.startupInit(); + .controller( + 'SplashCtrl', + function ( + $scope, + $state, + $interval, + $rootScope, + PushNotify, + StoreDeviceSettings, + LocalNotify, + RemoteNotify, + ) { + console.log('SplashCtrl invoked'); + // alert("attach debugger!"); + // PushNotify.startupInit(); - $rootScope.$on('$stateChangeSuccess', function(event, toState, toParams, fromState, fromParams){ - console.log("Finished changing state from "+JSON.stringify(fromState) - + " to "+JSON.stringify(toState)); - addStatReading(statKeys.STATE_CHANGED, fromState.name + '-2-' + toState.name); - }); - $rootScope.$on('$stateChangeError', function(event, toState, toParams, fromState, fromParams, error){ - console.log("Error "+error+" while changing state from "+JSON.stringify(fromState) - +" to "+JSON.stringify(toState)); - addStatError(statKeys.STATE_CHANGED, fromState.name + '-2-' + toState.name+ "_" + error); - }); - $rootScope.$on('$stateNotFound', - function(event, unfoundState, fromState, fromParams){ - console.log("unfoundState.to = "+unfoundState.to); // "lazy.state" - console.log("unfoundState.toParams = " + unfoundState.toParams); // {a:1, b:2} - console.log("unfoundState.options = " + unfoundState.options); // {inherit:false} + default options - addStatError(statKeys.STATE_CHANGED, fromState.name + '-2-' + unfoundState.name); - }); - - var isInList = function(element, list) { - return list.indexOf(element) != -1 - } + $rootScope.$on( + '$stateChangeSuccess', + function (event, toState, toParams, fromState, fromParams) { + console.log( + 'Finished changing state from ' + + JSON.stringify(fromState) + + ' to ' + + JSON.stringify(toState), + ); + addStatReading(statKeys.STATE_CHANGED, fromState.name + '-2-' + toState.name); + }, + ); + $rootScope.$on( + '$stateChangeError', + function (event, toState, toParams, fromState, fromParams, error) { + console.log( + 'Error ' + + error + + ' while changing state from ' + + JSON.stringify(fromState) + + ' to ' + + JSON.stringify(toState), + ); + addStatError(statKeys.STATE_CHANGED, fromState.name + '-2-' + toState.name + '_' + error); + }, + ); + $rootScope.$on('$stateNotFound', function (event, unfoundState, fromState, fromParams) { + console.log('unfoundState.to = ' + unfoundState.to); // "lazy.state" + console.log('unfoundState.toParams = ' + unfoundState.toParams); // {a:1, b:2} + console.log('unfoundState.options = ' + unfoundState.options); // {inherit:false} + default options + addStatError(statKeys.STATE_CHANGED, fromState.name + '-2-' + unfoundState.name); + }); - $rootScope.$on('$stateChangeStart', - function(event, toState, toParams, fromState, fromParams, options){ - var personalTabs = ['root.main.common.map', - 'root.main.control', - 'root.main.metrics'] - if (isInList(toState.name, personalTabs)) { - // toState is in the personalTabs list - getPendingOnboardingState().then(function(result) { - if (result != null) { - event.preventDefault(); - $state.go(result); - }; - // else, will do default behavior, which is to go to the tab - }); - } - }) - console.log('SplashCtrl invoke finished'); -}) + var isInList = function (element, list) { + return list.indexOf(element) != -1; + }; + $rootScope.$on( + '$stateChangeStart', + function (event, toState, toParams, fromState, fromParams, options) { + var personalTabs = ['root.main.common.map', 'root.main.control', 'root.main.metrics']; + if (isInList(toState.name, personalTabs)) { + // toState is in the personalTabs list + getPendingOnboardingState().then(function (result) { + if (result != null) { + event.preventDefault(); + $state.go(result); + } + // else, will do default behavior, which is to go to the tab + }); + } + }, + ); + console.log('SplashCtrl invoke finished'); + }, + ) -.controller('ChatsCtrl', function($scope, Chats) { - // With the new view caching in Ionic, Controllers are only called - // when they are recreated or on app start, instead of every page change. - // To listen for when this page is active (for example, to refresh data), - // listen for the $ionicView.enter event: - // - //$scope.$on('$ionicView.enter', function(e) { - //}); + .controller('ChatsCtrl', function ($scope, Chats) { + // With the new view caching in Ionic, Controllers are only called + // when they are recreated or on app start, instead of every page change. + // To listen for when this page is active (for example, to refresh data), + // listen for the $ionicView.enter event: + // + //$scope.$on('$ionicView.enter', function(e) { + //}); - $scope.chats = Chats.all(); - $scope.remove = function(chat) { - Chats.remove(chat); - }; -}) + $scope.chats = Chats.all(); + $scope.remove = function (chat) { + Chats.remove(chat); + }; + }) -.controller('ChatDetailCtrl', function($scope, $stateParams, Chats) { - $scope.chat = Chats.get($stateParams.chatId); -}) + .controller('ChatDetailCtrl', function ($scope, $stateParams, Chats) { + $scope.chat = Chats.get($stateParams.chatId); + }) -.controller('AccountCtrl', function($scope) { - $scope.settings = { - enableFriends: true - }; -}); + .controller('AccountCtrl', function ($scope) { + $scope.settings = { + enableFriends: true, + }; + }); diff --git a/www/js/diary.js b/www/js/diary.js index c0b7bce35..7c8294005 100644 --- a/www/js/diary.js +++ b/www/js/diary.js @@ -1,20 +1,22 @@ import angular from 'angular'; import LabelTab from './diary/LabelTab'; -angular.module('emission.main.diary',['emission.main.diary.services', - 'emission.survey.multilabel.buttons', - 'emission.survey.enketo.add-note-button', - 'emission.survey.enketo.trip.button', - 'emission.plugin.logger']) +angular + .module('emission.main.diary', [ + 'emission.main.diary.services', + 'emission.survey.multilabel.buttons', + 'emission.survey.enketo.add-note-button', + 'emission.survey.enketo.trip.button', + 'emission.plugin.logger', + ]) -.config(function($stateProvider) { - $stateProvider - .state('root.main.inf_scroll', { - url: "/inf_scroll", + .config(function ($stateProvider) { + $stateProvider.state('root.main.inf_scroll', { + url: '/inf_scroll', views: { 'main-inf-scroll': { - template: "", + template: '', }, - } - }) -}); + }, + }); + }); diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index f4677766d..8b6e65d52 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -6,23 +6,28 @@ share the data that has been loaded and interacted with. */ -import React, { useEffect, useState, useRef } from "react"; -import { getAngularService } from "../angular-react-helper"; -import useAppConfig from "../useAppConfig"; -import { useTranslation } from "react-i18next"; -import { invalidateMaps } from "../components/LeafletView"; -import moment from "moment"; -import LabelListScreen from "./list/LabelListScreen"; -import { createStackNavigator } from "@react-navigation/stack"; -import LabelScreenDetails from "./details/LabelDetailsScreen"; -import { NavigationContainer } from "@react-navigation/native"; -import { compositeTrips2TimelineMap, getAllUnprocessedInputs, getLocalUnprocessedInputs, populateCompositeTrips } from "./timelineHelper"; -import { fillLocationNamesOfTrip, resetNominatimLimiter } from "./addressNamesHelper"; -import { SurveyOptions } from "../survey/survey"; -import { getLabelOptions } from "../survey/multilabel/confirmHelper"; -import { displayError } from "../plugin/logger"; -import { useTheme } from "react-native-paper"; -import { getPipelineRangeTs } from "../commHelper"; +import React, { useEffect, useState, useRef } from 'react'; +import { getAngularService } from '../angular-react-helper'; +import useAppConfig from '../useAppConfig'; +import { useTranslation } from 'react-i18next'; +import { invalidateMaps } from '../components/LeafletView'; +import moment from 'moment'; +import LabelListScreen from './list/LabelListScreen'; +import { createStackNavigator } from '@react-navigation/stack'; +import LabelScreenDetails from './details/LabelDetailsScreen'; +import { NavigationContainer } from '@react-navigation/native'; +import { + compositeTrips2TimelineMap, + getAllUnprocessedInputs, + getLocalUnprocessedInputs, + populateCompositeTrips, +} from './timelineHelper'; +import { fillLocationNamesOfTrip, resetNominatimLimiter } from './addressNamesHelper'; +import { SurveyOptions } from '../survey/survey'; +import { getLabelOptions } from '../survey/multilabel/confirmHelper'; +import { displayError } from '../plugin/logger'; +import { useTheme } from 'react-native-paper'; +import { getPipelineRangeTs } from '../commHelper'; let labelPopulateFactory, labelsResultMap, notesResultMap, showPlaces; const ONE_DAY = 24 * 60 * 60; // seconds @@ -42,7 +47,7 @@ const LabelTab = () => { const [timelineMap, setTimelineMap] = useState(null); const [displayedEntries, setDisplayedEntries] = useState(null); const [refreshTime, setRefreshTime] = useState(null); - const [isLoading, setIsLoading] = useState('replace'); + const [isLoading, setIsLoading] = useState('replace'); const $rootScope = getAngularService('$rootScope'); const $state = getAngularService('$state'); @@ -70,7 +75,8 @@ const LabelTab = () => { // initalize filters const tripFilter = surveyOpt.filter; const allFalseFilters = tripFilter.map((f, i) => ({ - ...f, state: (i == 0 ? true : false) // only the first filter will have state true on init + ...f, + state: i == 0 ? true : false, // only the first filter will have state true on init })); setFilterInputs(allFalseFilters); } @@ -86,7 +92,7 @@ const LabelTab = () => { let entriesToDisplay = allEntries; if (activeFilter) { const entriesAfterFilter = allEntries.filter( - t => t.justRepopulated || activeFilter?.filter(t) + (t) => t.justRepopulated || activeFilter?.filter(t), ); /* next, filter out any untracked time if the trips that came before and after it are no longer displayed */ @@ -106,12 +112,20 @@ const LabelTab = () => { async function loadTimelineEntries() { try { const pipelineRange = await getPipelineRangeTs(); - [labelsResultMap, notesResultMap] = await getAllUnprocessedInputs(pipelineRange, labelPopulateFactory, enbs); - Logger.log("After reading unprocessedInputs, labelsResultMap =" + JSON.stringify(labelsResultMap) - + "; notesResultMap = " + JSON.stringify(notesResultMap)); + [labelsResultMap, notesResultMap] = await getAllUnprocessedInputs( + pipelineRange, + labelPopulateFactory, + enbs, + ); + Logger.log( + 'After reading unprocessedInputs, labelsResultMap =' + + JSON.stringify(labelsResultMap) + + '; notesResultMap = ' + + JSON.stringify(notesResultMap), + ); setPipelineRange(pipelineRange); } catch (error) { - Logger.displayError("Error while loading pipeline range", error); + Logger.displayError('Error while loading pipeline range', error); setIsLoading(false); } } @@ -131,34 +145,39 @@ const LabelTab = () => { setRefreshTime(new Date()); } - async function loadAnotherWeek(when: 'past'|'future') { + async function loadAnotherWeek(when: 'past' | 'future') { try { - const reachedPipelineStart = queriedRange?.start_ts && queriedRange.start_ts <= pipelineRange.start_ts; - const reachedPipelineEnd = queriedRange?.end_ts && queriedRange.end_ts >= pipelineRange.end_ts; + const reachedPipelineStart = + queriedRange?.start_ts && queriedRange.start_ts <= pipelineRange.start_ts; + const reachedPipelineEnd = + queriedRange?.end_ts && queriedRange.end_ts >= pipelineRange.end_ts; if (!queriedRange) { // first time loading - if(!isLoading) setIsLoading('replace'); + if (!isLoading) setIsLoading('replace'); const nowTs = new Date().getTime() / 1000; const [ctList, utList] = await fetchTripsInRange(pipelineRange.end_ts - ONE_WEEK, nowTs); handleFetchedTrips(ctList, utList, 'replace'); - setQueriedRange({start_ts: pipelineRange.end_ts - ONE_WEEK, end_ts: nowTs}); + setQueriedRange({ start_ts: pipelineRange.end_ts - ONE_WEEK, end_ts: nowTs }); } else if (when == 'past' && !reachedPipelineStart) { - if(!isLoading) setIsLoading('prepend'); + if (!isLoading) setIsLoading('prepend'); const fetchStartTs = Math.max(queriedRange.start_ts - ONE_WEEK, pipelineRange.start_ts); - const [ctList, utList] = await fetchTripsInRange(queriedRange.start_ts - ONE_WEEK, queriedRange.start_ts - 1); + const [ctList, utList] = await fetchTripsInRange( + queriedRange.start_ts - ONE_WEEK, + queriedRange.start_ts - 1, + ); handleFetchedTrips(ctList, utList, 'prepend'); - setQueriedRange({start_ts: fetchStartTs, end_ts: queriedRange.end_ts}) + setQueriedRange({ start_ts: fetchStartTs, end_ts: queriedRange.end_ts }); } else if (when == 'future' && !reachedPipelineEnd) { - if(!isLoading) setIsLoading('append'); + if (!isLoading) setIsLoading('append'); const fetchEndTs = Math.min(queriedRange.end_ts + ONE_WEEK, pipelineRange.end_ts); const [ctList, utList] = await fetchTripsInRange(queriedRange.end_ts + 1, fetchEndTs); handleFetchedTrips(ctList, utList, 'append'); - setQueriedRange({start_ts: queriedRange.start_ts, end_ts: fetchEndTs}) + setQueriedRange({ start_ts: queriedRange.start_ts, end_ts: fetchEndTs }); } } catch (e) { setIsLoading(false); - displayError(e, t('errors.while-loading-another-week', {when: when})); + displayError(e, t('errors.while-loading-another-week', { when: when })); } } @@ -170,20 +189,30 @@ const LabelTab = () => { const threeDaysAfter = moment(day).add(3, 'days').unix(); const [ctList, utList] = await fetchTripsInRange(threeDaysBefore, threeDaysAfter); handleFetchedTrips(ctList, utList, 'replace'); - setQueriedRange({start_ts: threeDaysBefore, end_ts: threeDaysAfter}); + setQueriedRange({ start_ts: threeDaysBefore, end_ts: threeDaysAfter }); } catch (e) { setIsLoading(false); - displayError(e, t('errors.while-loading-specific-week', {day: day})); + displayError(e, t('errors.while-loading-specific-week', { day: day })); } } function handleFetchedTrips(ctList, utList, mode: 'prepend' | 'append' | 'replace') { const tripsRead = ctList.concat(utList); - populateCompositeTrips(tripsRead, showPlaces, labelPopulateFactory, labelsResultMap, enbs, notesResultMap); + populateCompositeTrips( + tripsRead, + showPlaces, + labelPopulateFactory, + labelsResultMap, + enbs, + notesResultMap, + ); // Fill place names on a reversed copy of the list so we fill from the bottom up - tripsRead.slice().reverse().forEach(function (trip, index) { - fillLocationNamesOfTrip(trip); - }); + tripsRead + .slice() + .reverse() + .forEach(function (trip, index) { + fillLocationNamesOfTrip(trip); + }); const readTimelineMap = compositeTrips2TimelineMap(tripsRead, showPlaces); if (mode == 'append') { setTimelineMap(new Map([...timelineMap, ...readTimelineMap])); @@ -192,13 +221,13 @@ const LabelTab = () => { } else if (mode == 'replace') { setTimelineMap(readTimelineMap); } else { - return console.error("Unknown insertion mode " + mode); + return console.error('Unknown insertion mode ' + mode); } } async function fetchTripsInRange(startTs: number, endTs: number) { if (!pipelineRange.start_ts) { - console.warn("trying to read data too early, early return"); + console.warn('trying to read data too early, early return'); return; } @@ -206,16 +235,22 @@ const LabelTab = () => { let readUnprocessedPromise; if (endTs >= pipelineRange.end_ts) { const nowTs = new Date().getTime() / 1000; - const lastProcessedTrip = timelineMap && [...timelineMap?.values()].reverse().find( - trip => trip.origin_key.includes('confirmed_trip') + const lastProcessedTrip = + timelineMap && + [...timelineMap?.values()] + .reverse() + .find((trip) => trip.origin_key.includes('confirmed_trip')); + readUnprocessedPromise = Timeline.readUnprocessedTrips( + pipelineRange.end_ts, + nowTs, + lastProcessedTrip, ); - readUnprocessedPromise = Timeline.readUnprocessedTrips(pipelineRange.end_ts, nowTs, lastProcessedTrip); } else { readUnprocessedPromise = Promise.resolve([]); } const results = await Promise.all([readCompositePromise, readUnprocessedPromise]); return results; - }; + } useEffect(() => { if (!displayedEntries) return; @@ -225,10 +260,15 @@ const LabelTab = () => { const timelineMapRef = useRef(timelineMap); async function repopulateTimelineEntry(oid: string) { - if (!timelineMap.has(oid)) return console.error("Item with oid: " + oid + " not found in timeline"); - const [newLabels, newNotes] = await getLocalUnprocessedInputs(pipelineRange, labelPopulateFactory, enbs); + if (!timelineMap.has(oid)) + return console.error('Item with oid: ' + oid + ' not found in timeline'); + const [newLabels, newNotes] = await getLocalUnprocessedInputs( + pipelineRange, + labelPopulateFactory, + enbs, + ); const repopTime = new Date().getTime(); - const newEntry = {...timelineMap.get(oid), justRepopulated: repopTime}; + const newEntry = { ...timelineMap.get(oid), justRepopulated: repopTime }; labelPopulateFactory.populateInputsAndInferences(newEntry, newLabels); enbs.populateInputsAndInferences(newEntry, newNotes); const newTimelineMap = new Map(timelineMap).set(oid, newEntry); @@ -239,10 +279,13 @@ const LabelTab = () => { https://legacy.reactjs.org/docs/hooks-faq.html#why-am-i-seeing-stale-props-or-state-inside-my-function */ timelineMapRef.current = newTimelineMap; setTimeout(() => { - const entry = {...timelineMapRef.current.get(oid)}; + const entry = { ...timelineMapRef.current.get(oid) }; if (entry.justRepopulated != repopTime) - return console.log("Entry " + oid + " was repopulated again, skipping"); - const newTimelineMap = new Map(timelineMapRef.current).set(oid, {...entry, justRepopulated: false}); + return console.log('Entry ' + oid + ' was repopulated again, skipping'); + const newTimelineMap = new Map(timelineMapRef.current).set(oid, { + ...entry, + justRepopulated: false, + }); setTimelineMap(newTimelineMap); }, 30000); } @@ -261,24 +304,27 @@ const LabelTab = () => { loadSpecificWeek, refresh, repopulateTimelineEntry, - } + }; const Tab = createStackNavigator(); return ( - + - + options={{ detachPreviousScreen: false }} + /> ); -} +}; export default LabelTab; diff --git a/www/js/diary/addressNamesHelper.ts b/www/js/diary/addressNamesHelper.ts index e7f198fbe..f0e17921a 100644 --- a/www/js/diary/addressNamesHelper.ts +++ b/www/js/diary/addressNamesHelper.ts @@ -29,9 +29,7 @@ export default function createObserver< }, publish: (entryKey: KeyType, event: EventType) => { if (!listeners[entryKey]) listeners[entryKey] = []; - listeners[entryKey].forEach((listener: Listener) => - listener(event), - ); + listeners[entryKey].forEach((listener: Listener) => listener(event)); }, }; } @@ -41,7 +39,6 @@ export const LocalStorageObserver = createObserver(); export const { subscribe, publish } = LocalStorageObserver; export function useLocalStorage(key: string, initialValue: T) { - const [storedValue, setStoredValue] = useState(() => { try { const item = window.localStorage.getItem(key); @@ -63,8 +60,7 @@ export function useLocalStorage(key: string, initialValue: T) { const setValue = (value: T) => { try { - const valueToStore = - value instanceof Function ? value(storedValue) : value; + const valueToStore = value instanceof Function ? value(storedValue) : value; setStoredValue(valueToStore); LocalStorageObserver.publish(key, valueToStore); if (typeof window !== 'undefined') { @@ -77,11 +73,8 @@ export function useLocalStorage(key: string, initialValue: T) { return [storedValue, setValue]; } - - - -import Bottleneck from "bottleneck"; -import { getAngularService } from "../angular-react-helper"; +import Bottleneck from 'bottleneck'; +import { getAngularService } from '../angular-react-helper'; let nominatimLimiter = new Bottleneck({ maxConcurrent: 2, minTime: 500 }); export const resetNominatimLimiter = () => { @@ -93,15 +86,19 @@ export const resetNominatimLimiter = () => { // accepts a nominatim response object and returns an address-like string // e.g. "Main St, San Francisco" function toAddressName(data) { - const address = data?.["address"]; + const address = data?.['address']; if (address) { /* Sometimes, the street name ('road') isn't found and is undefined. If so, fallback to 'pedestrian' or 'suburb' or 'neighbourhood' */ - const placeName = address['road'] || address['pedestrian'] || - address['suburb'] || address['neighbourhood'] || ''; + const placeName = + address['road'] || + address['pedestrian'] || + address['suburb'] || + address['neighbourhood'] || + ''; /* This could be either a city or town. If neither, fallback to 'county' */ const municipalityName = address['city'] || address['town'] || address['county'] || ''; - return `${placeName}, ${municipalityName}` + return `${placeName}, ${municipalityName}`; } return '...'; } @@ -115,31 +112,42 @@ async function fetchNominatimLocName(loc_geojson) { const coordsStr = loc_geojson.coordinates.toString(); const cachedResponse = localStorage.getItem(coordsStr); if (cachedResponse) { - console.log('fetchNominatimLocName: found cached response for ', coordsStr, cachedResponse, 'skipping fetch'); + console.log( + 'fetchNominatimLocName: found cached response for ', + coordsStr, + cachedResponse, + 'skipping fetch', + ); return; } - console.log("Getting location name for ", coordsStr); - const url = "https://nominatim.openstreetmap.org/reverse?format=json&lat=" + loc_geojson.coordinates[1] + "&lon=" + loc_geojson.coordinates[0]; + console.log('Getting location name for ', coordsStr); + const url = + 'https://nominatim.openstreetmap.org/reverse?format=json&lat=' + + loc_geojson.coordinates[1] + + '&lon=' + + loc_geojson.coordinates[0]; try { const response = await fetch(url); const data = await response.json(); - Logger.log(`while reading data from nominatim, status = ${response.status} data = ${JSON.stringify(data)}`); + Logger.log( + `while reading data from nominatim, status = ${response.status} data = ${JSON.stringify( + data, + )}`, + ); localStorage.setItem(coordsStr, JSON.stringify(data)); publish(coordsStr, data); } catch (error) { if (!nominatimError) { nominatimError = error; - Logger.displayError("while reading address data ", error); + Logger.displayError('while reading address data ', error); } } -}; +} // Schedules nominatim fetches for the start and end locations of a trip export function fillLocationNamesOfTrip(trip) { - nominatimLimiter.schedule(() => - fetchNominatimLocName(trip.end_loc)); - nominatimLimiter.schedule(() => - fetchNominatimLocName(trip.start_loc)); + nominatimLimiter.schedule(() => fetchNominatimLocName(trip.end_loc)); + nominatimLimiter.schedule(() => fetchNominatimLocName(trip.start_loc)); } // a React hook that takes a trip or place and returns an array of its address names diff --git a/www/js/diary/cards/DiaryCard.tsx b/www/js/diary/cards/DiaryCard.tsx index f97a38e46..f6e845983 100644 --- a/www/js/diary/cards/DiaryCard.tsx +++ b/www/js/diary/cards/DiaryCard.tsx @@ -7,35 +7,53 @@ (see appTheme.ts for more info on theme flavors) */ -import React from "react"; +import React from 'react'; import { View, useWindowDimensions, StyleSheet } from 'react-native'; import { Card, PaperProvider, useTheme } from 'react-native-paper'; -import TimestampBadge from "./TimestampBadge"; -import useDerivedProperties from "../useDerivedProperties"; +import TimestampBadge from './TimestampBadge'; +import useDerivedProperties from '../useDerivedProperties'; export const DiaryCard = ({ timelineEntry, children, flavoredTheme, ...otherProps }) => { const { width: windowWidth } = useWindowDimensions(); - const { displayStartTime, displayEndTime, - displayStartDateAbbr, displayEndDateAbbr } = useDerivedProperties(timelineEntry); + const { displayStartTime, displayEndTime, displayStartDateAbbr, displayEndDateAbbr } = + useDerivedProperties(timelineEntry); const theme = flavoredTheme || useTheme(); return ( - - - + + + {children} - - + + ); -} +}; // common styles, used for DiaryCard export const cardStyles = StyleSheet.create({ diff --git a/www/js/diary/cards/ModesIndicator.tsx b/www/js/diary/cards/ModesIndicator.tsx index 5211f7ed4..37788a789 100644 --- a/www/js/diary/cards/ModesIndicator.tsx +++ b/www/js/diary/cards/ModesIndicator.tsx @@ -1,6 +1,6 @@ import React, { useContext } from 'react'; import { View, StyleSheet } from 'react-native'; -import color from "color"; +import color from 'color'; import { LabelTabContext } from '../LabelTab'; import { logDebug } from '../../plugin/logger'; import { getBaseModeOfLabeledTrip } from '../diaryHelper'; @@ -8,14 +8,13 @@ import { Icon } from '../../components/Icon'; import { Text, useTheme } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; -const ModesIndicator = ({ trip, detectedModes, }) => { - +const ModesIndicator = ({ trip, detectedModes }) => { const { t } = useTranslation(); const { labelOptions } = useContext(LabelTabContext); const { colors } = useTheme(); - const indicatorBackgroundColor = color(colors.onPrimary).alpha(.8).rgb().string(); - let indicatorBorderColor = color('black').alpha(.5).rgb().string(); + const indicatorBackgroundColor = color(colors.onPrimary).alpha(0.8).rgb().string(); + let indicatorBorderColor = color('black').alpha(0.5).rgb().string(); let modeViews; if (trip.userInput.MODE) { @@ -25,35 +24,56 @@ const ModesIndicator = ({ trip, detectedModes, }) => { modeViews = ( - + {trip.userInput.MODE.text} ); - } else if (detectedModes?.length > 1 || detectedModes?.length == 1 && detectedModes[0].mode != 'UNKNOWN') { + } else if ( + detectedModes?.length > 1 || + (detectedModes?.length == 1 && detectedModes[0].mode != 'UNKNOWN') + ) { // show detected modes if there are more than one, or if there is only one and it's not UNKNOWN - modeViews = (<> - {t('diary.detected')} - {detectedModes?.map?.((pct, i) => ( - - - {/* show percents if there are more than one detected modes */} - {detectedModes?.length > 1 && - {pct.pct}% - } - - ))} - ); + modeViews = ( + <> + {t('diary.detected')} + {detectedModes?.map?.((pct, i) => ( + + + {/* show percents if there are more than one detected modes */} + {detectedModes?.length > 1 && ( + + {pct.pct}% + + )} + + ))} + + ); } - return modeViews && ( - - - {modeViews} + return ( + modeViews && ( + + + {modeViews} + - - ) + ) + ); }; const s = StyleSheet.create({ diff --git a/www/js/diary/cards/PlaceCard.tsx b/www/js/diary/cards/PlaceCard.tsx index cd1d9c10e..a351f696f 100644 --- a/www/js/diary/cards/PlaceCard.tsx +++ b/www/js/diary/cards/PlaceCard.tsx @@ -6,45 +6,52 @@ PlaceCards use the blueish 'place' theme flavor. */ -import React from "react"; +import React from 'react'; import { View, StyleSheet } from 'react-native'; import { Text } from 'react-native-paper'; -import useAppConfig from "../../useAppConfig"; -import AddNoteButton from "../../survey/enketo/AddNoteButton"; -import AddedNotesList from "../../survey/enketo/AddedNotesList"; -import { getTheme } from "../../appTheme"; -import { DiaryCard, cardStyles } from "./DiaryCard"; -import { useAddressNames } from "../addressNamesHelper"; -import useDerivedProperties from "../useDerivedProperties"; -import StartEndLocations from "../components/StartEndLocations"; +import useAppConfig from '../../useAppConfig'; +import AddNoteButton from '../../survey/enketo/AddNoteButton'; +import AddedNotesList from '../../survey/enketo/AddedNotesList'; +import { getTheme } from '../../appTheme'; +import { DiaryCard, cardStyles } from './DiaryCard'; +import { useAddressNames } from '../addressNamesHelper'; +import useDerivedProperties from '../useDerivedProperties'; +import StartEndLocations from '../components/StartEndLocations'; -type Props = { place: {[key: string]: any} }; +type Props = { place: { [key: string]: any } }; const PlaceCard = ({ place }: Props) => { - const appConfig = useAppConfig(); const { displayStartTime, displayEndTime, displayDate } = useDerivedProperties(place); - let [ placeDisplayName ] = useAddressNames(place); + let [placeDisplayName] = useAddressNames(place); const flavoredTheme = getTheme('place'); return ( - - {/* date and distance */} + + + {/* date and distance */} - {displayDate} + + {displayDate} + - {/* place name */} - + + {/* place name */} + - {/* add note button */} + + {/* add note button */} - + storeKey={'manual/place_addition_input'} + /> diff --git a/www/js/diary/cards/TimestampBadge.tsx b/www/js/diary/cards/TimestampBadge.tsx index 0e8903ec5..10a97e6ee 100644 --- a/www/js/diary/cards/TimestampBadge.tsx +++ b/www/js/diary/cards/TimestampBadge.tsx @@ -1,14 +1,14 @@ /* A presentational component that accepts a time (and optional date) and displays them in a badge Used in the label screen, on the trip, place, and/or untracked cards */ -import React from "react"; -import { View, StyleSheet } from "react-native"; -import { Text, useTheme } from "react-native-paper"; +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { Text, useTheme } from 'react-native-paper'; type Props = { - lightBg: boolean, - time: string, - date?: string, + lightBg: boolean; + time: string; + date?: string; }; const TimestampBadge = ({ lightBg, time, date, ...otherProps }: Props) => { const { colors } = useTheme(); @@ -16,14 +16,18 @@ const TimestampBadge = ({ lightBg, time, date, ...otherProps }: Props) => { const textColor = lightBg ? 'black' : 'white'; return ( - - - {time} - - {/* if date is not passed as prop, it will not be shown */ - date && - {`\xa0(${date})` /* date shown in parentheses with space before */} - } + + {time} + { + /* if date is not passed as prop, it will not be shown */ + date && ( + + {`\xa0(${date})` /* date shown in parentheses with space before */} + + ) + } ); }; diff --git a/www/js/diary/cards/TripCard.tsx b/www/js/diary/cards/TripCard.tsx index 08e02bca4..78ef42fe1 100644 --- a/www/js/diary/cards/TripCard.tsx +++ b/www/js/diary/cards/TripCard.tsx @@ -4,35 +4,41 @@ will used the greyish 'draft' theme flavor. */ -import React, { useContext } from "react"; +import React, { useContext } from 'react'; import { View, useWindowDimensions, StyleSheet } from 'react-native'; import { Text, IconButton } from 'react-native-paper'; -import LeafletView from "../../components/LeafletView"; -import { useTranslation } from "react-i18next"; -import MultilabelButtonGroup from "../../survey/multilabel/MultiLabelButtonGroup"; -import UserInputButton from "../../survey/enketo/UserInputButton"; -import useAppConfig from "../../useAppConfig"; -import AddNoteButton from "../../survey/enketo/AddNoteButton"; -import AddedNotesList from "../../survey/enketo/AddedNotesList"; -import { getTheme } from "../../appTheme"; -import { DiaryCard, cardStyles } from "./DiaryCard"; -import { useNavigation } from "@react-navigation/native"; -import { useAddressNames } from "../addressNamesHelper"; -import { LabelTabContext } from "../LabelTab"; -import useDerivedProperties from "../useDerivedProperties"; -import StartEndLocations from "../components/StartEndLocations"; -import ModesIndicator from "./ModesIndicator"; -import { useGeojsonForTrip } from "../timelineHelper"; +import LeafletView from '../../components/LeafletView'; +import { useTranslation } from 'react-i18next'; +import MultilabelButtonGroup from '../../survey/multilabel/MultiLabelButtonGroup'; +import UserInputButton from '../../survey/enketo/UserInputButton'; +import useAppConfig from '../../useAppConfig'; +import AddNoteButton from '../../survey/enketo/AddNoteButton'; +import AddedNotesList from '../../survey/enketo/AddedNotesList'; +import { getTheme } from '../../appTheme'; +import { DiaryCard, cardStyles } from './DiaryCard'; +import { useNavigation } from '@react-navigation/native'; +import { useAddressNames } from '../addressNamesHelper'; +import { LabelTabContext } from '../LabelTab'; +import useDerivedProperties from '../useDerivedProperties'; +import StartEndLocations from '../components/StartEndLocations'; +import ModesIndicator from './ModesIndicator'; +import { useGeojsonForTrip } from '../timelineHelper'; -type Props = { trip: {[key: string]: any}}; +type Props = { trip: { [key: string]: any } }; const TripCard = ({ trip }: Props) => { - const { t } = useTranslation(); const { width: windowWidth } = useWindowDimensions(); const appConfig = useAppConfig(); - const { displayStartTime, displayEndTime, displayDate, formattedDistance, - distanceSuffix, displayTime, detectedModes } = useDerivedProperties(trip); - let [ tripStartDisplayName, tripEndDisplayName ] = useAddressNames(trip); + const { + displayStartTime, + displayEndTime, + displayDate, + formattedDistance, + distanceSuffix, + displayTime, + detectedModes, + } = useDerivedProperties(trip); + let [tripStartDisplayName, tripEndDisplayName] = useAddressNames(trip); const navigation = useNavigation(); const { surveyOpt, labelOptions } = useContext(LabelTabContext); const tripGeojson = useGeojsonForTrip(trip, labelOptions, trip?.userInput?.MODE?.value); @@ -42,7 +48,7 @@ const TripCard = ({ trip }: Props) => { function showDetail() { const tripId = trip._id.$oid; - navigation.navigate("label.details", { tripId, flavoredTheme }); + navigation.navigate('label.details', { tripId, flavoredTheme }); } const mapOpts = { zoomControl: false, dragging: false }; @@ -50,52 +56,82 @@ const TripCard = ({ trip }: Props) => { const mapStyle = showAddNoteButton ? s.shortenedMap : s.fullHeightMap; return ( showDetail()}> - - showDetail()} - style={{position: 'absolute', right: 0, top: 0, height: 16, width: 32, - justifyContent: 'center', margin: 4}} /> - {/* right panel */} - {/* date and distance */} - - {displayDate} + showDetail()} + style={{ + position: 'absolute', + right: 0, + top: 0, + height: 16, + width: 32, + justifyContent: 'center', + margin: 4, + }} + /> + + {/* right panel */} + + {/* date and distance */} + + + {displayDate} + - - {t('diary.distance-in-time', {distance: formattedDistance, distsuffix: distanceSuffix, time: displayTime})} + + {t('diary.distance-in-time', { + distance: formattedDistance, + distsuffix: distanceSuffix, + time: displayTime, + })} - {/* start and end locations */} - + + {/* start and end locations */} + - {/* mode and purpose buttons / survey button */} - {surveyOpt?.elementTag == 'multilabel' && - } - {surveyOpt?.elementTag == 'enketo-trip-button' - && } + + {/* mode and purpose buttons / survey button */} + {surveyOpt?.elementTag == 'multilabel' && } + {surveyOpt?.elementTag == 'enketo-trip-button' && ( + + )} - {/* left panel */} - + {/* left panel */} + + style={[{ minHeight: windowWidth / 2 }, mapStyle]} + /> - {showAddNoteButton && + {showAddNoteButton && ( - + - } + )} - {trip.additionsList?.length != 0 && + {trip.additionsList?.length != 0 && ( - } + )} ); }; diff --git a/www/js/diary/cards/UntrackedTimeCard.tsx b/www/js/diary/cards/UntrackedTimeCard.tsx index 855c50ed4..07b5caf71 100644 --- a/www/js/diary/cards/UntrackedTimeCard.tsx +++ b/www/js/diary/cards/UntrackedTimeCard.tsx @@ -7,42 +7,57 @@ UntrackedTimeCards use the reddish 'untracked' theme flavor. */ -import React from "react"; +import React from 'react'; import { View, StyleSheet } from 'react-native'; import { Text } from 'react-native-paper'; -import { getTheme } from "../../appTheme"; -import { useTranslation } from "react-i18next"; -import { DiaryCard, cardStyles } from "./DiaryCard"; -import { useAddressNames } from "../addressNamesHelper"; -import useDerivedProperties from "../useDerivedProperties"; -import StartEndLocations from "../components/StartEndLocations"; +import { getTheme } from '../../appTheme'; +import { useTranslation } from 'react-i18next'; +import { DiaryCard, cardStyles } from './DiaryCard'; +import { useAddressNames } from '../addressNamesHelper'; +import useDerivedProperties from '../useDerivedProperties'; +import StartEndLocations from '../components/StartEndLocations'; -type Props = { triplike: {[key: string]: any}}; +type Props = { triplike: { [key: string]: any } }; const UntrackedTimeCard = ({ triplike }: Props) => { const { t } = useTranslation(); const { displayStartTime, displayEndTime, displayDate } = useDerivedProperties(triplike); - const [ triplikeStartDisplayName, triplikeEndDisplayName ] = useAddressNames(triplike); + const [triplikeStartDisplayName, triplikeEndDisplayName] = useAddressNames(triplike); const flavoredTheme = getTheme('untracked'); return ( - - {/* date and distance */} + + + {/* date and distance */} - {displayDate} + + {displayDate} + - - + + {t('diary.untracked-time-range', { start: displayStartTime, end: displayEndTime })} - {/* start and end locations */} - + {/* start and end locations */} + + displayEndName={triplikeEndDisplayName} + /> @@ -54,7 +69,7 @@ const s = StyleSheet.create({ borderRadius: 5, paddingVertical: 1, paddingHorizontal: 8, - fontSize: 13 + fontSize: 13, }, locationText: { fontSize: 12, diff --git a/www/js/diary/components/StartEndLocations.tsx b/www/js/diary/components/StartEndLocations.tsx index 8d1096fab..b25facc57 100644 --- a/www/js/diary/components/StartEndLocations.tsx +++ b/www/js/diary/components/StartEndLocations.tsx @@ -4,67 +4,70 @@ import { Icon } from '../../components/Icon'; import { Text, Divider, useTheme } from 'react-native-paper'; type Props = { - displayStartTime?: string, displayStartName: string, - displayEndTime?: string, displayEndName?: string, - centered?: boolean, - fontSize?: number, + displayStartTime?: string; + displayStartName: string; + displayEndTime?: string; + displayEndName?: string; + centered?: boolean; + fontSize?: number; }; const StartEndLocations = (props: Props) => { - const { colors } = useTheme(); const fontSize = props.fontSize || 12; - return (<> - - {props.displayStartTime && - - {props.displayStartTime} - - } - - - - - {props.displayStartName} - - - {(props.displayEndName != undefined) && <> - + return ( + <> - {props.displayEndTime && - - {props.displayEndTime} - - } - - + {props.displayStartTime && ( + {props.displayStartTime} + )} + + - - {props.displayEndName} + + {props.displayStartName} - } - ); -} + {props.displayEndName != undefined && ( + <> + + + {props.displayEndTime && ( + {props.displayEndTime} + )} + + + + + {props.displayEndName} + + + + )} + + ); +}; const s = { - location: (centered) => ({ - flexDirection: 'row', - alignItems: 'center', - justifyContent: centered ? 'center' : 'flex-start', - } as ViewProps), - locationIcon: (colors, iconSize, filled?) => ({ - border: `2px solid ${colors.primary}`, - borderRadius: 50, - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - width: iconSize * 1.5, - height: iconSize * 1.5, - backgroundColor: filled ? colors.primary : colors.onPrimary, - marginRight: 6, - } as ViewProps) -} + location: (centered) => + ({ + flexDirection: 'row', + alignItems: 'center', + justifyContent: centered ? 'center' : 'flex-start', + }) as ViewProps, + locationIcon: (colors, iconSize, filled?) => + ({ + border: `2px solid ${colors.primary}`, + borderRadius: 50, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + width: iconSize * 1.5, + height: iconSize * 1.5, + backgroundColor: filled ? colors.primary : colors.onPrimary, + marginRight: 6, + }) as ViewProps, +}; export default StartEndLocations; diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx index ffed9a300..ed48f89c9 100644 --- a/www/js/diary/details/LabelDetailsScreen.tsx +++ b/www/js/diary/details/LabelDetailsScreen.tsx @@ -2,25 +2,32 @@ listed sections of the trip, and a graph of speed during the trip. Navigated to from the main LabelListScreen by clicking a trip card. */ -import React, { useContext, useState } from "react"; -import { View, Modal, ScrollView, useWindowDimensions } from "react-native"; -import { PaperProvider, Appbar, SegmentedButtons, Button, Surface, Text, useTheme } from "react-native-paper"; -import { LabelTabContext } from "../LabelTab"; -import LeafletView from "../../components/LeafletView"; -import { useTranslation } from "react-i18next"; -import MultilabelButtonGroup from "../../survey/multilabel/MultiLabelButtonGroup"; -import UserInputButton from "../../survey/enketo/UserInputButton"; -import { useAddressNames } from "../addressNamesHelper"; -import { SafeAreaView } from "react-native-safe-area-context"; -import useDerivedProperties from "../useDerivedProperties"; -import StartEndLocations from "../components/StartEndLocations"; -import { useGeojsonForTrip } from "../timelineHelper"; -import TripSectionsDescriptives from "./TripSectionsDescriptives"; -import OverallTripDescriptives from "./OverallTripDescriptives"; -import ToggleSwitch from "../../components/ToggleSwitch"; +import React, { useContext, useState } from 'react'; +import { View, Modal, ScrollView, useWindowDimensions } from 'react-native'; +import { + PaperProvider, + Appbar, + SegmentedButtons, + Button, + Surface, + Text, + useTheme, +} from 'react-native-paper'; +import { LabelTabContext } from '../LabelTab'; +import LeafletView from '../../components/LeafletView'; +import { useTranslation } from 'react-i18next'; +import MultilabelButtonGroup from '../../survey/multilabel/MultiLabelButtonGroup'; +import UserInputButton from '../../survey/enketo/UserInputButton'; +import { useAddressNames } from '../addressNamesHelper'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import useDerivedProperties from '../useDerivedProperties'; +import StartEndLocations from '../components/StartEndLocations'; +import { useGeojsonForTrip } from '../timelineHelper'; +import TripSectionsDescriptives from './TripSectionsDescriptives'; +import OverallTripDescriptives from './OverallTripDescriptives'; +import ToggleSwitch from '../../components/ToggleSwitch'; const LabelScreenDetails = ({ route, navigation }) => { - const { surveyOpt, timelineMap, labelOptions } = useContext(LabelTabContext); const { t } = useTranslation(); const { height: windowHeight } = useWindowDimensions(); @@ -28,58 +35,91 @@ const LabelScreenDetails = ({ route, navigation }) => { const trip = timelineMap.get(tripId); const { colors } = flavoredTheme || useTheme(); const { displayDate, displayStartTime, displayEndTime } = useDerivedProperties(trip); - const [ tripStartDisplayName, tripEndDisplayName ] = useAddressNames(trip); + const [tripStartDisplayName, tripEndDisplayName] = useAddressNames(trip); - const [ modesShown, setModesShown ] = useState<'labeled'|'detected'>('labeled'); - const tripGeojson = useGeojsonForTrip(trip, labelOptions, modesShown=='labeled' && trip?.userInput?.MODE?.value); - const mapOpts = {minZoom: 3, maxZoom: 17}; + const [modesShown, setModesShown] = useState<'labeled' | 'detected'>('labeled'); + const tripGeojson = useGeojsonForTrip( + trip, + labelOptions, + modesShown == 'labeled' && trip?.userInput?.MODE?.value, + ); + const mapOpts = { minZoom: 3, maxZoom: 17 }; const modal = ( - - - { navigation.goBack() }} /> - + + + { + navigation.goBack(); + }} + /> + - - + + - + {/* MultiLabel or UserInput button, inline on one row */} - {surveyOpt?.elementTag == 'multilabel' && - } - {surveyOpt?.elementTag == 'enketo-trip-button' - && } + {surveyOpt?.elementTag == 'multilabel' && ( + + )} + {surveyOpt?.elementTag == 'enketo-trip-button' && ( + + )} {/* Full-size Leaflet map, with zoom controls */} - + {/* If trip is labeled, show a toggle to switch between "Labeled Mode" and "Detected Modes" otherwise, just show "Detected" */} - {trip?.userInput?.MODE?.value ? - setModesShown(v)} value={modesShown} density='medium' - buttons={[{label: t('diary.labeled-mode'), value: 'labeled'}, {label: t('diary.detected-modes'), value: 'detected'}]} /> - : - - } + )} {/* section-by-section breakdown of duration, distance, and mode */} - + {/* Overall trip duration, distance, and modes. Only show this when multiple sections are shown, and we are showing detected modes. If we just showed the labeled mode or a single section, this would be redundant. */} - { modesShown == 'detected' && trip?.sections?.length > 1 && + {modesShown == 'detected' && trip?.sections?.length > 1 && ( - } + )} {/* TODO: show speed graph here */} @@ -87,13 +127,9 @@ const LabelScreenDetails = ({ route, navigation }) => { ); if (route.params.flavoredTheme) { - return ( - - {modal} - - ); + return {modal}; } return modal; -} +}; export default LabelScreenDetails; diff --git a/www/js/diary/details/OverallTripDescriptives.tsx b/www/js/diary/details/OverallTripDescriptives.tsx index 3902c8afe..8030842df 100644 --- a/www/js/diary/details/OverallTripDescriptives.tsx +++ b/www/js/diary/details/OverallTripDescriptives.tsx @@ -1,42 +1,45 @@ import React from 'react'; import { View } from 'react-native'; -import { Text } from 'react-native-paper' +import { Text } from 'react-native-paper'; import useDerivedProperties from '../useDerivedProperties'; import { Icon } from '../../components/Icon'; import { useTranslation } from 'react-i18next'; const OverallTripDescriptives = ({ trip }) => { - const { t } = useTranslation(); - const { displayStartTime, displayEndTime, displayTime, - formattedDistance, distanceSuffix, detectedModes } = useDerivedProperties(trip); + const { + displayStartTime, + displayEndTime, + displayTime, + formattedDistance, + distanceSuffix, + detectedModes, + } = useDerivedProperties(trip); return ( - Overall + + Overall + - {displayTime} - {`${displayStartTime} - ${displayEndTime}`} + {displayTime} + {`${displayStartTime} - ${displayEndTime}`} - - {`${formattedDistance} ${distanceSuffix}`} - + {`${formattedDistance} ${distanceSuffix}`} {detectedModes?.map?.((pct, i) => ( - - {pct.pct}% - + {pct.pct}% ))} ); -} +}; export default OverallTripDescriptives; diff --git a/www/js/diary/details/TripSectionsDescriptives.tsx b/www/js/diary/details/TripSectionsDescriptives.tsx index 6d172fed4..5bd30fdd5 100644 --- a/www/js/diary/details/TripSectionsDescriptives.tsx +++ b/www/js/diary/details/TripSectionsDescriptives.tsx @@ -1,65 +1,82 @@ import React, { useContext } from 'react'; import { View } from 'react-native'; -import { Text, useTheme } from 'react-native-paper' +import { Text, useTheme } from 'react-native-paper'; import { Icon } from '../../components/Icon'; import useDerivedProperties from '../useDerivedProperties'; import { getBaseModeByKey, getBaseModeOfLabeledTrip } from '../diaryHelper'; import { LabelTabContext } from '../LabelTab'; -const TripSectionsDescriptives = ({ trip, showLabeledMode=false }) => { - +const TripSectionsDescriptives = ({ trip, showLabeledMode = false }) => { const { labelOptions } = useContext(LabelTabContext); - const { displayStartTime, displayTime, formattedDistance, - distanceSuffix, formattedSectionProperties } = useDerivedProperties(trip); + const { + displayStartTime, + displayTime, + formattedDistance, + distanceSuffix, + formattedSectionProperties, + } = useDerivedProperties(trip); const { colors } = useTheme(); let sections = formattedSectionProperties; /* if we're only showing the labeled mode, or there are no sections (i.e. unprocessed trip), we treat this as unimodal and use trip-level attributes to construct a single section */ - if (showLabeledMode && trip?.userInput?.MODE || !trip.sections?.length) { + if ((showLabeledMode && trip?.userInput?.MODE) || !trip.sections?.length) { let baseMode; if (showLabeledMode && trip?.userInput?.MODE) { baseMode = getBaseModeOfLabeledTrip(trip, labelOptions); } else { baseMode = getBaseModeByKey('UNPROCESSED'); } - sections = [{ - startTime: displayStartTime, - duration: displayTime, - distance: formattedDistance, - color: baseMode.color, - icon: baseMode.icon, - text: showLabeledMode && trip.userInput?.MODE?.text, // label text only shown for labeled trips - }]; + sections = [ + { + startTime: displayStartTime, + duration: displayTime, + distance: formattedDistance, + color: baseMode.color, + icon: baseMode.icon, + text: showLabeledMode && trip.userInput?.MODE?.text, // label text only shown for labeled trips + }, + ]; } return ( {sections.map((section, i) => ( - + - {section.duration} - {section.startTime} + {section.duration} + {section.startTime} - - {`${section.distance} ${distanceSuffix}`} - + {`${section.distance} ${distanceSuffix}`} - - - {section.text && - + + + {section.text && ( + {section.text} - } + )} ))} ); -} +}; export default TripSectionsDescriptives; diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts index 0b834a485..48f40322d 100644 --- a/www/js/diary/diaryHelper.ts +++ b/www/js/diary/diaryHelper.ts @@ -1,57 +1,67 @@ // here we have some helper functions used throughout the label tab // these functions are being gradually migrated out of services.js -import moment from "moment"; -import { DateTime } from "luxon"; -import { LabelOptions, readableLabelToKey } from "../survey/multilabel/confirmHelper"; +import moment from 'moment'; +import { DateTime } from 'luxon'; +import { LabelOptions, readableLabelToKey } from '../survey/multilabel/confirmHelper'; export const modeColors = { - pink: '#c32e85', // oklch(56% 0.2 350) // e-car - red: '#c21725', // oklch(52% 0.2 25) // car - orange: '#bf5900', // oklch(58% 0.16 50) // air, hsr - green: '#008148', // oklch(53% 0.14 155) // bike, e-bike, moped - blue: '#0074b7', // oklch(54% 0.14 245) // walk - periwinkle: '#6356bf', // oklch(52% 0.16 285) // light rail, train, tram, subway - magenta: '#9240a4', // oklch(52% 0.17 320) // bus - grey: '#555555', // oklch(45% 0 0) // unprocessed / unknown - taupe: '#7d585a', // oklch(50% 0.05 15) // ferry, trolleybus, user-defined modes -} + pink: '#c32e85', // oklch(56% 0.2 350) // e-car + red: '#c21725', // oklch(52% 0.2 25) // car + orange: '#bf5900', // oklch(58% 0.16 50) // air, hsr + green: '#008148', // oklch(53% 0.14 155) // bike, e-bike, moped + blue: '#0074b7', // oklch(54% 0.14 245) // walk + periwinkle: '#6356bf', // oklch(52% 0.16 285) // light rail, train, tram, subway + magenta: '#9240a4', // oklch(52% 0.17 320) // bus + grey: '#555555', // oklch(45% 0 0) // unprocessed / unknown + taupe: '#7d585a', // oklch(50% 0.05 15) // ferry, trolleybus, user-defined modes +}; type BaseMode = { - name: string, - icon: string, - color: string -} + name: string; + icon: string; + color: string; +}; // parallels the server-side MotionTypes enum: https://github.com/e-mission/e-mission-server/blob/94e7478e627fa8c171323662f951c611c0993031/emission/core/wrapper/motionactivity.py#L12 -type MotionTypeKey = 'IN_VEHICLE' | 'BICYCLING' | 'ON_FOOT' | 'STILL' | 'UNKNOWN' | 'TILTING' - | 'WALKING' | 'RUNNING' | 'NONE' | 'STOPPED_WHILE_IN_VEHICLE' | 'AIR_OR_HSR'; - -const BaseModes: {[k: string]: BaseMode} = { +type MotionTypeKey = + | 'IN_VEHICLE' + | 'BICYCLING' + | 'ON_FOOT' + | 'STILL' + | 'UNKNOWN' + | 'TILTING' + | 'WALKING' + | 'RUNNING' + | 'NONE' + | 'STOPPED_WHILE_IN_VEHICLE' + | 'AIR_OR_HSR'; + +const BaseModes: { [k: string]: BaseMode } = { // BEGIN MotionTypes - IN_VEHICLE: { name: "IN_VEHICLE", icon: "speedometer", color: modeColors.red }, - BICYCLING: { name: "BICYCLING", icon: "bike", color: modeColors.green }, - ON_FOOT: { name: "ON_FOOT", icon: "walk", color: modeColors.blue }, - UNKNOWN: { name: "UNKNOWN", icon: "help", color: modeColors.grey }, - WALKING: { name: "WALKING", icon: "walk", color: modeColors.blue }, - AIR_OR_HSR: { name: "AIR_OR_HSR", icon: "airplane", color: modeColors.orange }, + IN_VEHICLE: { name: 'IN_VEHICLE', icon: 'speedometer', color: modeColors.red }, + BICYCLING: { name: 'BICYCLING', icon: 'bike', color: modeColors.green }, + ON_FOOT: { name: 'ON_FOOT', icon: 'walk', color: modeColors.blue }, + UNKNOWN: { name: 'UNKNOWN', icon: 'help', color: modeColors.grey }, + WALKING: { name: 'WALKING', icon: 'walk', color: modeColors.blue }, + AIR_OR_HSR: { name: 'AIR_OR_HSR', icon: 'airplane', color: modeColors.orange }, // END MotionTypes - CAR: { name: "CAR", icon: "car", color: modeColors.red }, - E_CAR: { name: "E_CAR", icon: "car-electric", color: modeColors.pink }, - E_BIKE: { name: "E_BIKE", icon: "bicycle-electric", color: modeColors.green }, - E_SCOOTER: { name: "E_SCOOTER", icon: "scooter-electric", color: modeColors.periwinkle }, - MOPED: { name: "MOPED", icon: "moped", color: modeColors.green }, - TAXI: { name: "TAXI", icon: "taxi", color: modeColors.red }, - BUS: { name: "BUS", icon: "bus-side", color: modeColors.magenta }, - AIR: { name: "AIR", icon: "airplane", color: modeColors.orange }, - LIGHT_RAIL: { name: "LIGHT_RAIL", icon: "train-car-passenger", color: modeColors.periwinkle }, - TRAIN: { name: "TRAIN", icon: "train-car-passenger", color: modeColors.periwinkle }, - TRAM: { name: "TRAM", icon: "fas fa-tram", color: modeColors.periwinkle }, - SUBWAY: { name: "SUBWAY", icon: "subway-variant", color: modeColors.periwinkle }, - FERRY: { name: "FERRY", icon: "ferry", color: modeColors.taupe }, - TROLLEYBUS: { name: "TROLLEYBUS", icon: "bus-side", color: modeColors.taupe }, - UNPROCESSED: { name: "UNPROCESSED", icon: "help", color: modeColors.grey }, - OTHER: { name: "OTHER", icon: "pencil-circle", color: modeColors.taupe }, + CAR: { name: 'CAR', icon: 'car', color: modeColors.red }, + E_CAR: { name: 'E_CAR', icon: 'car-electric', color: modeColors.pink }, + E_BIKE: { name: 'E_BIKE', icon: 'bicycle-electric', color: modeColors.green }, + E_SCOOTER: { name: 'E_SCOOTER', icon: 'scooter-electric', color: modeColors.periwinkle }, + MOPED: { name: 'MOPED', icon: 'moped', color: modeColors.green }, + TAXI: { name: 'TAXI', icon: 'taxi', color: modeColors.red }, + BUS: { name: 'BUS', icon: 'bus-side', color: modeColors.magenta }, + AIR: { name: 'AIR', icon: 'airplane', color: modeColors.orange }, + LIGHT_RAIL: { name: 'LIGHT_RAIL', icon: 'train-car-passenger', color: modeColors.periwinkle }, + TRAIN: { name: 'TRAIN', icon: 'train-car-passenger', color: modeColors.periwinkle }, + TRAM: { name: 'TRAM', icon: 'fas fa-tram', color: modeColors.periwinkle }, + SUBWAY: { name: 'SUBWAY', icon: 'subway-variant', color: modeColors.periwinkle }, + FERRY: { name: 'FERRY', icon: 'ferry', color: modeColors.taupe }, + TROLLEYBUS: { name: 'TROLLEYBUS', icon: 'bus-side', color: modeColors.taupe }, + UNPROCESSED: { name: 'UNPROCESSED', icon: 'help', color: modeColors.grey }, + OTHER: { name: 'OTHER', icon: 'pencil-circle', color: modeColors.taupe }, }; type BaseModeKey = keyof typeof BaseModes; @@ -59,27 +69,29 @@ type BaseModeKey = keyof typeof BaseModes; * @param motionName A string like "WALKING" or "MotionTypes.WALKING" * @returns A BaseMode object containing the name, icon, and color of the motion type */ -export function getBaseModeByKey(motionName: BaseModeKey | MotionTypeKey | `MotionTypes.${MotionTypeKey}`) { +export function getBaseModeByKey( + motionName: BaseModeKey | MotionTypeKey | `MotionTypes.${MotionTypeKey}`, +) { let key = ('' + motionName).toUpperCase(); - key = key.split(".").pop(); // if "MotionTypes.WALKING", then just take "WALKING" + key = key.split('.').pop(); // if "MotionTypes.WALKING", then just take "WALKING" return BaseModes[key] || BaseModes.UNKNOWN; } export function getBaseModeOfLabeledTrip(trip, labelOptions) { const modeKey = trip?.userInput?.MODE?.value; if (!modeKey) return null; // trip has no MODE label - const modeOption = labelOptions?.MODE?.find(opt => opt.value == modeKey); - return getBaseModeByKey(modeOption?.baseMode || "OTHER"); + const modeOption = labelOptions?.MODE?.find((opt) => opt.value == modeKey); + return getBaseModeByKey(modeOption?.baseMode || 'OTHER'); } export function getBaseModeByValue(value, labelOptions: LabelOptions) { - const modeOption = labelOptions?.MODE?.find(opt => opt.value == value); - return getBaseModeByKey(modeOption?.baseMode || "OTHER"); + const modeOption = labelOptions?.MODE?.find((opt) => opt.value == value); + return getBaseModeByKey(modeOption?.baseMode || 'OTHER'); } export function getBaseModeByText(text, labelOptions: LabelOptions) { - const modeOption = labelOptions?.MODE?.find(opt => opt.text == text); - return getBaseModeByKey(modeOption?.baseMode || "OTHER"); + const modeOption = labelOptions?.MODE?.find((opt) => opt.text == text); + return getBaseModeByKey(modeOption?.baseMode || 'OTHER'); } /** @@ -90,7 +102,10 @@ export function getBaseModeByText(text, labelOptions: LabelOptions) { */ export function isMultiDay(beginFmtTime: string, endFmtTime: string) { if (!beginFmtTime || !endFmtTime) return false; - return moment.parseZone(beginFmtTime).format('YYYYMMDD') != moment.parseZone(endFmtTime).format('YYYYMMDD'); + return ( + moment.parseZone(beginFmtTime).format('YYYYMMDD') != + moment.parseZone(endFmtTime).format('YYYYMMDD') + ); } /** @@ -138,11 +153,10 @@ export function getFormattedTimeRange(beginFmtTime: string, endFmtTime: string) const beginMoment = moment.parseZone(beginFmtTime); const endMoment = moment.parseZone(endFmtTime); return endMoment.to(beginMoment, true); -}; +} // Temporary function to avoid repear in getDetectedModes ret val. -const filterRunning = (mode) => - (mode == 'MotionTypes.RUNNING') ? 'MotionTypes.WALKING' : mode; +const filterRunning = (mode) => (mode == 'MotionTypes.RUNNING' ? 'MotionTypes.WALKING' : mode); export function getDetectedModes(trip) { if (!trip.sections?.length) return []; @@ -157,14 +171,16 @@ export function getDetectedModes(trip) { }); // sort modes by the distance traveled (descending) - const sortedKeys = Object.entries(dists).sort((a, b) => b[1] - a[1]).map(e => e[0]); + const sortedKeys = Object.entries(dists) + .sort((a, b) => b[1] - a[1]) + .map((e) => e[0]); let sectionPcts = sortedKeys.map(function (mode) { const fract = dists[mode] / totalDist; return { mode: mode, icon: getBaseModeByKey(mode)?.icon, color: getBaseModeByKey(mode)?.color || 'black', - pct: Math.round(fract * 100) || '<1' // if rounds to 0%, show <1% + pct: Math.round(fract * 100) || '<1', // if rounds to 0%, show <1% }; }); @@ -178,7 +194,7 @@ export function getFormattedSectionProperties(trip, ImperialConfig) { distance: ImperialConfig.getFormattedDistance(s.distance), distanceSuffix: ImperialConfig.distanceSuffix, icon: getBaseModeByKey(s.sensed_mode_str)?.icon, - color: getBaseModeByKey(s.sensed_mode_str)?.color || "#333", + color: getBaseModeByKey(s.sensed_mode_str)?.color || '#333', })); } @@ -186,6 +202,6 @@ export function getLocalTimeString(dt) { if (!dt) return; /* correcting the date of the processed trips knowing that local_dt months are from 1 -> 12 and for the moment function they need to be between 0 -> 11 */ - const mdt = { ...dt, month: dt.month-1 }; - return moment(mdt).format("LT"); + const mdt = { ...dt, month: dt.month - 1 }; + return moment(mdt).format('LT'); } diff --git a/www/js/diary/diaryTypes.ts b/www/js/diary/diaryTypes.ts index bcaeb83ae..5755c91ab 100644 --- a/www/js/diary/diaryTypes.ts +++ b/www/js/diary/diaryTypes.ts @@ -10,63 +10,63 @@ type ConfirmedPlace = any; // TODO /* These are the properties received from the server (basically matches Python code) This should match what Timeline.readAllCompositeTrips returns (an array of these objects) */ export type CompositeTrip = { - _id: {$oid: string}, - additions: any[], // TODO - cleaned_section_summary: any, // TODO - cleaned_trip: {$oid: string}, - confidence_threshold: number, - confirmed_trip: {$oid: string}, - distance: number, - duration: number, - end_confirmed_place: ConfirmedPlace, - end_fmt_time: string, - end_loc: {type: string, coordinates: number[]}, - end_local_dt: any, // TODO - end_place: {$oid: string}, - end_ts: number, - expectation: any, // TODO "{to_label: boolean}" - expected_trip: {$oid: string}, - inferred_labels: any[], // TODO - inferred_section_summary: any, // TODO - inferred_trip: {$oid: string}, - key: string, - locations: any[], // TODO - origin_key: string, - raw_trip: {$oid: string}, - sections: any[], // TODO - source: string, - start_confirmed_place: ConfirmedPlace, - start_fmt_time: string, - start_loc: {type: string, coordinates: number[]}, - start_local_dt: any, // TODO - start_place: {$oid: string}, - start_ts: number, - user_input: any, // TODO -} + _id: { $oid: string }; + additions: any[]; // TODO + cleaned_section_summary: any; // TODO + cleaned_trip: { $oid: string }; + confidence_threshold: number; + confirmed_trip: { $oid: string }; + distance: number; + duration: number; + end_confirmed_place: ConfirmedPlace; + end_fmt_time: string; + end_loc: { type: string; coordinates: number[] }; + end_local_dt: any; // TODO + end_place: { $oid: string }; + end_ts: number; + expectation: any; // TODO "{to_label: boolean}" + expected_trip: { $oid: string }; + inferred_labels: any[]; // TODO + inferred_section_summary: any; // TODO + inferred_trip: { $oid: string }; + key: string; + locations: any[]; // TODO + origin_key: string; + raw_trip: { $oid: string }; + sections: any[]; // TODO + source: string; + start_confirmed_place: ConfirmedPlace; + start_fmt_time: string; + start_loc: { type: string; coordinates: number[] }; + start_local_dt: any; // TODO + start_place: { $oid: string }; + start_ts: number; + user_input: any; // TODO +}; /* These properties aren't received from the server, but are derived from the above properties. They are used in the UI to display trip/place details and are computed by the useDerivedProperties hook. */ export type DerivedProperties = { - displayDate: string, - displayStartTime: string, - displayEndTime: string, - displayTime: string, - displayStartDateAbbr: string, - displayEndDateAbbr: string, - formattedDistance: string, - formattedSectionProperties: any[], // TODO - distanceSuffix: string, - detectedModes: { mode: string, icon: string, color: string, pct: number|string }[], -} + displayDate: string; + displayStartTime: string; + displayEndTime: string; + displayTime: string; + displayStartDateAbbr: string; + displayEndDateAbbr: string; + formattedDistance: string; + formattedSectionProperties: any[]; // TODO + distanceSuffix: string; + detectedModes: { mode: string; icon: string; color: string; pct: number | string }[]; +}; /* These are the properties that are still filled in by some kind of 'populate' mechanism. It would simplify the codebase to just compute them where they're needed (using memoization when apt so performance is not impacted). */ export type PopulatedTrip = CompositeTrip & { - additionsList?: any[], // TODO - finalInference?: any, // TODO - geojson?: any, // TODO - getNextEntry?: () => PopulatedTrip | ConfirmedPlace, - userInput?: any, // TODO - verifiability?: string, -} + additionsList?: any[]; // TODO + finalInference?: any; // TODO + geojson?: any; // TODO + getNextEntry?: () => PopulatedTrip | ConfirmedPlace; + userInput?: any; // TODO + verifiability?: string; +}; diff --git a/www/js/diary/list/DateSelect.tsx b/www/js/diary/list/DateSelect.tsx index 1c28cdc2c..515553851 100644 --- a/www/js/diary/list/DateSelect.tsx +++ b/www/js/diary/list/DateSelect.tsx @@ -6,18 +6,17 @@ and allows the user to select a date. */ -import React, { useEffect, useState, useMemo, useContext } from "react"; -import { StyleSheet } from "react-native"; -import moment from "moment"; -import { LabelTabContext } from "../LabelTab"; -import { DatePickerModal } from "react-native-paper-dates"; -import { Text, Divider, useTheme } from "react-native-paper"; -import i18next from "i18next"; -import { useTranslation } from "react-i18next"; -import NavBarButton from "../../components/NavBarButton"; +import React, { useEffect, useState, useMemo, useContext } from 'react'; +import { StyleSheet } from 'react-native'; +import moment from 'moment'; +import { LabelTabContext } from '../LabelTab'; +import { DatePickerModal } from 'react-native-paper-dates'; +import { Text, Divider, useTheme } from 'react-native-paper'; +import i18next from 'i18next'; +import { useTranslation } from 'react-i18next'; +import NavBarButton from '../../components/NavBarButton'; const DateSelect = ({ tsRange, loadSpecificWeekFn }) => { - const { pipelineRange } = useContext(LabelTabContext); const { t } = useTranslation(); const { colors } = useTheme(); @@ -57,36 +56,48 @@ const DateSelect = ({ tsRange, loadSpecificWeekFn }) => { loadSpecificWeekFn(params.date); setOpen(false); }, - [setOpen, loadSpecificWeekFn] + [setOpen, loadSpecificWeekFn], ); const dateRangeEnd = dateRange[1] || t('diary.today'); - return (<> - setOpen(true)}> - {dateRange[0] && (<> - {dateRange[0]} - - )} - {dateRangeEnd} - - - ); + return ( + <> + setOpen(true)}> + {dateRange[0] && ( + <> + {dateRange[0]} + + + )} + {dateRangeEnd} + + + + ); }; export const s = StyleSheet.create({ divider: { width: 25, marginHorizontal: 'auto', - } + }, }); export default DateSelect; diff --git a/www/js/diary/list/FilterSelect.tsx b/www/js/diary/list/FilterSelect.tsx index d1906f462..0018c1bc5 100644 --- a/www/js/diary/list/FilterSelect.tsx +++ b/www/js/diary/list/FilterSelect.tsx @@ -7,36 +7,36 @@ shows the available filters and allows the user to select one. */ -import React, { useState, useMemo } from "react"; -import { Modal } from "react-native"; -import { useTranslation } from "react-i18next"; -import NavBarButton from "../../components/NavBarButton"; -import { RadioButton, Text, Dialog } from "react-native-paper"; +import React, { useState, useMemo } from 'react'; +import { Modal } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import NavBarButton from '../../components/NavBarButton'; +import { RadioButton, Text, Dialog } from 'react-native-paper'; const FilterSelect = ({ filters, setFilters, numListDisplayed, numListTotal }) => { - const { t } = useTranslation(); const [modalVisible, setModalVisible] = useState(false); - const selectedFilter = useMemo(() => filters?.find(f => f.state)?.key || 'show-all', [filters]); + const selectedFilter = useMemo(() => filters?.find((f) => f.state)?.key || 'show-all', [filters]); const labelDisplayText = useMemo(() => { - if (!filters) - return '...'; - const selectedFilterObj = filters?.find(f => f.state); - if (!selectedFilterObj) return t('diary.show-all') + ` (${numListTotal||0})`; - return selectedFilterObj.text + ` (${numListDisplayed||0}/${numListTotal||0})`; + if (!filters) return '...'; + const selectedFilterObj = filters?.find((f) => f.state); + if (!selectedFilterObj) return t('diary.show-all') + ` (${numListTotal || 0})`; + return selectedFilterObj.text + ` (${numListDisplayed || 0}/${numListTotal || 0})`; }, [filters, numListDisplayed, numListTotal]); function chooseFilter(filterKey) { if (filterKey == 'show-all') { - setFilters(filters.map(f => ({ ...f, state: false }))); + setFilters(filters.map((f) => ({ ...f, state: false }))); } else { - setFilters(filters.map(f => { - if (f.key === filterKey) { - return { ...f, state: true }; - } else { - return { ...f, state: false }; - } - })); + setFilters( + filters.map((f) => { + if (f.key === filterKey) { + return { ...f, state: true }; + } else { + return { ...f, state: false }; + } + }), + ); } /* We must wait to close the modal until this function is done running, else the click event might leak to the content behind the modal */ @@ -44,28 +44,32 @@ const FilterSelect = ({ filters, setFilters, numListDisplayed, numListTotal }) = the next event loop cycle */ } - return (<> - setModalVisible(true)}> - - {labelDisplayText} - - - setModalVisible(false)}> - setModalVisible(false)}> - {/* TODO - add title */} - {/* {t('diary.filter-travel')} */} - - chooseFilter(k)} value={selectedFilter}> - {filters.map(f => ( - - ))} - - - - - - ); + return ( + <> + setModalVisible(true)}> + {labelDisplayText} + + setModalVisible(false)}> + setModalVisible(false)}> + {/* TODO - add title */} + {/* {t('diary.filter-travel')} */} + + chooseFilter(k)} value={selectedFilter}> + {filters.map((f) => ( + + ))} + + + + + + + ); }; export default FilterSelect; diff --git a/www/js/diary/list/LabelListScreen.tsx b/www/js/diary/list/LabelListScreen.tsx index 4fb1702b2..217115938 100644 --- a/www/js/diary/list/LabelListScreen.tsx +++ b/www/js/diary/list/LabelListScreen.tsx @@ -1,38 +1,61 @@ -import React, { useContext } from "react"; -import { View } from "react-native"; -import { Appbar, useTheme } from "react-native-paper"; -import DateSelect from "./DateSelect"; -import FilterSelect from "./FilterSelect"; -import TimelineScrollList from "./TimelineScrollList"; -import { LabelTabContext } from "../LabelTab"; +import React, { useContext } from 'react'; +import { View } from 'react-native'; +import { Appbar, useTheme } from 'react-native-paper'; +import DateSelect from './DateSelect'; +import FilterSelect from './FilterSelect'; +import TimelineScrollList from './TimelineScrollList'; +import { LabelTabContext } from '../LabelTab'; const LabelListScreen = () => { - - const { filterInputs, setFilterInputs, timelineMap, displayedEntries, - queriedRange, loadSpecificWeek, refresh, pipelineRange, - loadAnotherWeek, isLoading } = useContext(LabelTabContext); + const { + filterInputs, + setFilterInputs, + timelineMap, + displayedEntries, + queriedRange, + loadSpecificWeek, + refresh, + pipelineRange, + loadAnotherWeek, + isLoading, + } = useContext(LabelTabContext); const { colors } = useTheme(); - return (<> - - - - refresh()} accessibilityLabel="Refresh" - style={{marginLeft: 'auto'}} /> - - - - - ) -} + return ( + <> + + + + refresh()} + accessibilityLabel="Refresh" + style={{ marginLeft: 'auto' }} + /> + + + + + + ); +}; export default LabelListScreen; diff --git a/www/js/diary/list/LoadMoreButton.tsx b/www/js/diary/list/LoadMoreButton.tsx index f3d6db082..dfc49a9e2 100644 --- a/www/js/diary/list/LoadMoreButton.tsx +++ b/www/js/diary/list/LoadMoreButton.tsx @@ -1,18 +1,24 @@ -import React from "react"; -import { StyleSheet, View } from "react-native"; -import { Button, useTheme } from "react-native-paper"; +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import { Button, useTheme } from 'react-native-paper'; const LoadMoreButton = ({ children, onPressFn, ...otherProps }) => { const { colors } = useTheme(); return ( - ); -} +}; const s = StyleSheet.create({ container: { @@ -21,8 +27,8 @@ const s = StyleSheet.create({ }, btn: { maxHeight: 30, - justifyContent: 'center' - } + justifyContent: 'center', + }, }); export default LoadMoreButton; diff --git a/www/js/diary/list/TimelineScrollList.tsx b/www/js/diary/list/TimelineScrollList.tsx index 6dfd1e736..954a90db9 100644 --- a/www/js/diary/list/TimelineScrollList.tsx +++ b/www/js/diary/list/TimelineScrollList.tsx @@ -11,51 +11,58 @@ import { Icon } from '../../components/Icon'; const renderCard = ({ item: listEntry }) => { if (listEntry.origin_key.includes('trip')) { - return + return ; } else if (listEntry.origin_key.includes('place')) { - return + return ; } else if (listEntry.origin_key.includes('untracked')) { - return + return ; } }; -const separator = () => -const bigSpinner = -const smallSpinner = +const separator = () => ; +const bigSpinner = ; +const smallSpinner = ; type Props = { - listEntries: any[], - queriedRange: any, - pipelineRange: any, - loadMoreFn: (direction: string) => void, - isLoading: boolean | string -} -const TimelineScrollList = ({ listEntries, queriedRange, pipelineRange, loadMoreFn, isLoading }: Props) => { - + listEntries: any[]; + queriedRange: any; + pipelineRange: any; + loadMoreFn: (direction: string) => void; + isLoading: boolean | string; +}; +const TimelineScrollList = ({ + listEntries, + queriedRange, + pipelineRange, + loadMoreFn, + isLoading, +}: Props) => { const { t } = useTranslation(); // The way that FlashList inverts the scroll view means we have to reverse the order of items too const reversedListEntries = listEntries ? [...listEntries].reverse() : []; - const reachedPipelineStart = (queriedRange?.start_ts <= pipelineRange?.start_ts); - const footer = loadMoreFn('past')} - disabled={reachedPipelineStart}> - { reachedPipelineStart ? t('diary.no-more-travel') : t('diary.show-older-travel')} - ; - - const reachedPipelineEnd = (queriedRange?.end_ts >= pipelineRange?.end_ts); - const header = loadMoreFn('future')} - disabled={reachedPipelineEnd}> - { reachedPipelineEnd ? t('diary.no-more-travel') : t('diary.show-more-travel')} - ; + const reachedPipelineStart = queriedRange?.start_ts <= pipelineRange?.start_ts; + const footer = ( + loadMoreFn('past')} disabled={reachedPipelineStart}> + {reachedPipelineStart ? t('diary.no-more-travel') : t('diary.show-older-travel')} + + ); + + const reachedPipelineEnd = queriedRange?.end_ts >= pipelineRange?.end_ts; + const header = ( + loadMoreFn('future')} disabled={reachedPipelineEnd}> + {reachedPipelineEnd ? t('diary.no-more-travel') : t('diary.show-more-travel')} + + ); const noTravelBanner = ( - - }> + }> - {t('diary.no-travel')} - {t('diary.no-travel-hint')} + {t('diary.no-travel')} + {t('diary.no-travel-hint')} ); @@ -64,7 +71,7 @@ const TimelineScrollList = ({ listEntries, queriedRange, pipelineRange, loadMore /* Condition: pipelineRange has been fetched but has no defined end, meaning nothing has been processed for this OPCode yet, and there are no unprocessed trips either. Show 'no travel'. */ return noTravelBanner; - } else if (isLoading=='replace') { + } else if (isLoading == 'replace') { /* Condition: we're loading an entirely new batch of trips, so show a big spinner */ return bigSpinner; } else if (listEntries && listEntries.length == 0) { @@ -73,7 +80,8 @@ const TimelineScrollList = ({ listEntries, queriedRange, pipelineRange, loadMore } else if (listEntries) { /* Condition: we've successfully loaded and set `listEntries`, so show the list */ return ( - console.debug(e.nativeEvent.contentOffset.y)} - ListHeaderComponent={isLoading == 'append' ? smallSpinner : (!reachedPipelineEnd && header)} + ListHeaderComponent={isLoading == 'append' ? smallSpinner : !reachedPipelineEnd && header} ListFooterComponent={isLoading == 'prepend' ? smallSpinner : footer} - ItemSeparatorComponent={separator} /> + ItemSeparatorComponent={separator} + /> ); } -} +}; export default TimelineScrollList; diff --git a/www/js/diary/services.js b/www/js/diary/services.js index 774273fa2..92d322f04 100644 --- a/www/js/diary/services.js +++ b/www/js/diary/services.js @@ -6,47 +6,56 @@ import { SurveyOptions } from '../survey/survey'; import { getConfig } from '../config/dynamicConfig'; import { getRawEntries } from '../commHelper'; -angular.module('emission.main.diary.services', ['emission.plugin.logger', - 'emission.services']) -.factory('Timeline', function($http, $ionicLoading, $ionicPlatform, $window, - $rootScope, UnifiedDataLoader, Logger, $injector) { - var timeline = {}; - // corresponds to the old $scope.data. Contains all state for the current - // day, including the indication of the current day - timeline.data = {}; - timeline.data.unifiedConfirmsResults = null; - timeline.UPDATE_DONE = "TIMELINE_UPDATE_DONE"; - - let manualInputFactory; - $ionicPlatform.ready(function () { - getConfig().then((configObj) => { - const surveyOptKey = configObj.survey_info['trip-labels']; - const surveyOpt = SurveyOptions[surveyOptKey]; - console.log('surveyOpt in services.js is', surveyOpt); - manualInputFactory = $injector.get(surveyOpt.service); +angular + .module('emission.main.diary.services', ['emission.plugin.logger', 'emission.services']) + .factory( + 'Timeline', + function ( + $http, + $ionicLoading, + $ionicPlatform, + $window, + $rootScope, + UnifiedDataLoader, + Logger, + $injector, + ) { + var timeline = {}; + // corresponds to the old $scope.data. Contains all state for the current + // day, including the indication of the current day + timeline.data = {}; + timeline.data.unifiedConfirmsResults = null; + timeline.UPDATE_DONE = 'TIMELINE_UPDATE_DONE'; + + let manualInputFactory; + $ionicPlatform.ready(function () { + getConfig().then((configObj) => { + const surveyOptKey = configObj.survey_info['trip-labels']; + const surveyOpt = SurveyOptions[surveyOptKey]; + console.log('surveyOpt in services.js is', surveyOpt); + manualInputFactory = $injector.get(surveyOpt.service); + }); }); - }); - - // DB entries retrieved from the server have '_id', 'metadata', and 'data' fields. - // This function returns a shallow copy of the obj, which flattens the - // 'data' field into the top level, while also including '_id' and 'metadata.key' - const unpack = (obj) => ({ - ...obj.data, - _id: obj._id, - key: obj.metadata.key, - origin_key: obj.metadata.origin_key || obj.metadata.key, - }); - - timeline.readAllCompositeTrips = function(startTs, endTs) { - $ionicLoading.show({ - template: i18next.t('service.reading-server') + + // DB entries retrieved from the server have '_id', 'metadata', and 'data' fields. + // This function returns a shallow copy of the obj, which flattens the + // 'data' field into the top level, while also including '_id' and 'metadata.key' + const unpack = (obj) => ({ + ...obj.data, + _id: obj._id, + key: obj.metadata.key, + origin_key: obj.metadata.origin_key || obj.metadata.key, }); - const readPromises = [ - getRawEntries(["analysis/composite_trip"], - startTs, endTs, "data.end_ts"), - ]; - return Promise.all(readPromises) - .then(([ctList]) => { + + timeline.readAllCompositeTrips = function (startTs, endTs) { + $ionicLoading.show({ + template: i18next.t('service.reading-server'), + }); + const readPromises = [ + getRawEntries(['analysis/composite_trip'], startTs, endTs, 'data.end_ts'), + ]; + return Promise.all(readPromises) + .then(([ctList]) => { $ionicLoading.hide(); return ctList.phone_data.map((ct) => { const unpackedCt = unpack(ct); @@ -56,191 +65,222 @@ angular.module('emission.main.diary.services', ['emission.plugin.logger', end_confirmed_place: unpack(unpackedCt.end_confirmed_place), locations: unpackedCt.locations?.map(unpack), sections: unpackedCt.sections?.map(unpack), - } + }; }); - }) - .catch((err) => { - Logger.displayError("while reading confirmed trips", err); + }) + .catch((err) => { + Logger.displayError('while reading confirmed trips', err); $ionicLoading.hide(); return []; - }); - }; - - /* - * This is going to be a bit tricky. As we can see from - * https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163, - * when we read local transitions, they have a string for the transition - * (e.g. `T_DATA_PUSHED`), while the remote transitions have an integer - * (e.g. `2`). - * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286338606 - * - * Also, at least on iOS, it is possible for trip end to be detected way - * after the end of the trip, so the trip end transition of a processed - * trip may actually show up as an unprocessed transition. - * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163 - * - * Let's abstract this out into our own minor state machine. - */ - var transitions2Trips = function(transitionList) { + }); + }; + + /* + * This is going to be a bit tricky. As we can see from + * https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163, + * when we read local transitions, they have a string for the transition + * (e.g. `T_DATA_PUSHED`), while the remote transitions have an integer + * (e.g. `2`). + * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286338606 + * + * Also, at least on iOS, it is possible for trip end to be detected way + * after the end of the trip, so the trip end transition of a processed + * trip may actually show up as an unprocessed transition. + * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163 + * + * Let's abstract this out into our own minor state machine. + */ + var transitions2Trips = function (transitionList) { var inTrip = false; - var tripList = [] + var tripList = []; var currStartTransitionIndex = -1; var currEndTransitionIndex = -1; var processedUntil = 0; - - while(processedUntil < transitionList.length) { + + while (processedUntil < transitionList.length) { // Logger.log("searching within list = "+JSON.stringify(transitionList.slice(processedUntil))); - if(inTrip == false) { - var foundStartTransitionIndex = transitionList.slice(processedUntil).findIndex(isStartingTransition); - if (foundStartTransitionIndex == -1) { - Logger.log("No further unprocessed trips started, exiting loop"); - processedUntil = transitionList.length; - } else { - currStartTransitionIndex = processedUntil + foundStartTransitionIndex; - processedUntil = currStartTransitionIndex; - Logger.log("Unprocessed trip started at "+JSON.stringify(transitionList[currStartTransitionIndex])); - inTrip = true; - } + if (inTrip == false) { + var foundStartTransitionIndex = transitionList + .slice(processedUntil) + .findIndex(isStartingTransition); + if (foundStartTransitionIndex == -1) { + Logger.log('No further unprocessed trips started, exiting loop'); + processedUntil = transitionList.length; + } else { + currStartTransitionIndex = processedUntil + foundStartTransitionIndex; + processedUntil = currStartTransitionIndex; + Logger.log( + 'Unprocessed trip started at ' + + JSON.stringify(transitionList[currStartTransitionIndex]), + ); + inTrip = true; + } } else { - // Logger.log("searching within list = "+JSON.stringify(transitionList.slice(processedUntil))); - var foundEndTransitionIndex = transitionList.slice(processedUntil).findIndex(isEndingTransition); - if (foundEndTransitionIndex == -1) { - Logger.log("Can't find end for trip starting at "+JSON.stringify(transitionList[currStartTransitionIndex])+" dropping it"); - processedUntil = transitionList.length; - } else { - currEndTransitionIndex = processedUntil + foundEndTransitionIndex; - processedUntil = currEndTransitionIndex; - Logger.log("currEndTransitionIndex = "+currEndTransitionIndex); - Logger.log("Unprocessed trip starting at "+JSON.stringify(transitionList[currStartTransitionIndex])+" ends at "+JSON.stringify(transitionList[currEndTransitionIndex])); - tripList.push([transitionList[currStartTransitionIndex], - transitionList[currEndTransitionIndex]]) - inTrip = false; - } + // Logger.log("searching within list = "+JSON.stringify(transitionList.slice(processedUntil))); + var foundEndTransitionIndex = transitionList + .slice(processedUntil) + .findIndex(isEndingTransition); + if (foundEndTransitionIndex == -1) { + Logger.log( + "Can't find end for trip starting at " + + JSON.stringify(transitionList[currStartTransitionIndex]) + + ' dropping it', + ); + processedUntil = transitionList.length; + } else { + currEndTransitionIndex = processedUntil + foundEndTransitionIndex; + processedUntil = currEndTransitionIndex; + Logger.log('currEndTransitionIndex = ' + currEndTransitionIndex); + Logger.log( + 'Unprocessed trip starting at ' + + JSON.stringify(transitionList[currStartTransitionIndex]) + + ' ends at ' + + JSON.stringify(transitionList[currEndTransitionIndex]), + ); + tripList.push([ + transitionList[currStartTransitionIndex], + transitionList[currEndTransitionIndex], + ]); + inTrip = false; + } } } return tripList; - } + }; - var isStartingTransition = function(transWrapper) { + var isStartingTransition = function (transWrapper) { // Logger.log("isStartingTransition: transWrapper.data.transition = "+transWrapper.data.transition); - if(transWrapper.data.transition == 'local.transition.exited_geofence' || - transWrapper.data.transition == 'T_EXITED_GEOFENCE' || - transWrapper.data.transition == 1) { - // Logger.log("Returning true"); - return true; + if ( + transWrapper.data.transition == 'local.transition.exited_geofence' || + transWrapper.data.transition == 'T_EXITED_GEOFENCE' || + transWrapper.data.transition == 1 + ) { + // Logger.log("Returning true"); + return true; } // Logger.log("Returning false"); return false; - } + }; - var isEndingTransition = function(transWrapper) { + var isEndingTransition = function (transWrapper) { // Logger.log("isEndingTransition: transWrapper.data.transition = "+transWrapper.data.transition); - if(transWrapper.data.transition == 'T_TRIP_ENDED' || - transWrapper.data.transition == 'local.transition.stopped_moving' || - transWrapper.data.transition == 2) { - // Logger.log("Returning true"); - return true; + if ( + transWrapper.data.transition == 'T_TRIP_ENDED' || + transWrapper.data.transition == 'local.transition.stopped_moving' || + transWrapper.data.transition == 2 + ) { + // Logger.log("Returning true"); + return true; } // Logger.log("Returning false"); return false; - } + }; - /* - * Fill out place geojson after pulling trip location points. - * Place is only partially filled out because we haven't linked the timeline yet - */ + /* + * Fill out place geojson after pulling trip location points. + * Place is only partially filled out because we haven't linked the timeline yet + */ - var moment2localdate = function(currMoment, tz) { + var moment2localdate = function (currMoment, tz) { return { - timezone: tz, - year: currMoment.year(), - //the months of the draft trips match the one format needed for - //moment function however now that is modified we need to also - //modify the months value here - month: currMoment.month() + 1, - day: currMoment.date(), - weekday: currMoment.weekday(), - hour: currMoment.hour(), - minute: currMoment.minute(), - second: currMoment.second() + timezone: tz, + year: currMoment.year(), + //the months of the draft trips match the one format needed for + //moment function however now that is modified we need to also + //modify the months value here + month: currMoment.month() + 1, + day: currMoment.date(), + weekday: currMoment.weekday(), + hour: currMoment.hour(), + minute: currMoment.minute(), + second: currMoment.second(), }; - } - - var points2TripProps = function(locationPoints) { - var startPoint = locationPoints[0]; - var endPoint = locationPoints[locationPoints.length - 1]; - var tripAndSectionId = "unprocessed_"+startPoint.data.ts+"_"+endPoint.data.ts; - var startMoment = moment.unix(startPoint.data.ts).tz(startPoint.metadata.time_zone); - var endMoment = moment.unix(endPoint.data.ts).tz(endPoint.metadata.time_zone); - - const speeds = [], dists = []; - let loc, locLatLng; - locationPoints.forEach((pt) => { - const ptLatLng = L.latLng([pt.data.latitude, pt.data.longitude]); - if (loc) { - const dist = locLatLng.distanceTo(ptLatLng); - const timeDelta = pt.data.ts - loc.data.ts; - dists.push(dist); - speeds.push(dist / timeDelta); - } - loc = pt; - locLatLng = ptLatLng; - }); - - const locations = locationPoints.map((point, i) => ({ + }; + + var points2TripProps = function (locationPoints) { + var startPoint = locationPoints[0]; + var endPoint = locationPoints[locationPoints.length - 1]; + var tripAndSectionId = 'unprocessed_' + startPoint.data.ts + '_' + endPoint.data.ts; + var startMoment = moment.unix(startPoint.data.ts).tz(startPoint.metadata.time_zone); + var endMoment = moment.unix(endPoint.data.ts).tz(endPoint.metadata.time_zone); + + const speeds = [], + dists = []; + let loc, locLatLng; + locationPoints.forEach((pt) => { + const ptLatLng = L.latLng([pt.data.latitude, pt.data.longitude]); + if (loc) { + const dist = locLatLng.distanceTo(ptLatLng); + const timeDelta = pt.data.ts - loc.data.ts; + dists.push(dist); + speeds.push(dist / timeDelta); + } + loc = pt; + locLatLng = ptLatLng; + }); + + const locations = locationPoints.map((point, i) => ({ loc: { - coordinates: [point.data.longitude, point.data.latitude] + coordinates: [point.data.longitude, point.data.latitude], }, ts: point.data.ts, speed: speeds[i], - })); - - return { - _id: {$oid: tripAndSectionId}, - key: "UNPROCESSED_trip", - origin_key: "UNPROCESSED_trip", - additions: [], - confidence_threshold: 0, - distance: dists.reduce((a, b) => a + b, 0), - duration: endPoint.data.ts - startPoint.data.ts, - end_fmt_time: endMoment.format(), - end_local_dt: moment2localdate(endMoment, endPoint.metadata.time_zone), - end_ts: endPoint.data.ts, - expectation: {to_label: true}, - inferred_labels: [], - locations: locations, - source: "unprocessed", - start_fmt_time: startMoment.format(), - start_local_dt: moment2localdate(startMoment, startPoint.metadata.time_zone), - start_ts: startPoint.data.ts, - user_input: {}, - } - } - - var tsEntrySort = function(e1, e2) { - // compare timestamps - return e1.data.ts - e2.data.ts; - } - - var transitionTrip2TripObj = function(trip) { - var tripStartTransition = trip[0]; - var tripEndTransition = trip[1]; - var tq = {key: "write_ts", - startTs: tripStartTransition.data.ts, - endTs: tripEndTransition.data.ts - } - Logger.log("About to pull location data for range " - + moment.unix(tripStartTransition.data.ts).toString() + " -> " - + moment.unix(tripEndTransition.data.ts).toString()); - return UnifiedDataLoader.getUnifiedSensorDataForInterval("background/filtered_location", tq).then(function(locationList) { + })); + + return { + _id: { $oid: tripAndSectionId }, + key: 'UNPROCESSED_trip', + origin_key: 'UNPROCESSED_trip', + additions: [], + confidence_threshold: 0, + distance: dists.reduce((a, b) => a + b, 0), + duration: endPoint.data.ts - startPoint.data.ts, + end_fmt_time: endMoment.format(), + end_local_dt: moment2localdate(endMoment, endPoint.metadata.time_zone), + end_ts: endPoint.data.ts, + expectation: { to_label: true }, + inferred_labels: [], + locations: locations, + source: 'unprocessed', + start_fmt_time: startMoment.format(), + start_local_dt: moment2localdate(startMoment, startPoint.metadata.time_zone), + start_ts: startPoint.data.ts, + user_input: {}, + }; + }; + + var tsEntrySort = function (e1, e2) { + // compare timestamps + return e1.data.ts - e2.data.ts; + }; + + var transitionTrip2TripObj = function (trip) { + var tripStartTransition = trip[0]; + var tripEndTransition = trip[1]; + var tq = { + key: 'write_ts', + startTs: tripStartTransition.data.ts, + endTs: tripEndTransition.data.ts, + }; + Logger.log( + 'About to pull location data for range ' + + moment.unix(tripStartTransition.data.ts).toString() + + ' -> ' + + moment.unix(tripEndTransition.data.ts).toString(), + ); + return UnifiedDataLoader.getUnifiedSensorDataForInterval( + 'background/filtered_location', + tq, + ).then(function (locationList) { if (locationList.length == 0) { return undefined; } var sortedLocationList = locationList.sort(tsEntrySort); - var retainInRange = function(loc) { - return (tripStartTransition.data.ts <= loc.data.ts) && - (loc.data.ts <= tripEndTransition.data.ts) - } + var retainInRange = function (loc) { + return ( + tripStartTransition.data.ts <= loc.data.ts && loc.data.ts <= tripEndTransition.data.ts + ); + }; var filteredLocationList = sortedLocationList.filter(retainInRange); @@ -250,17 +290,26 @@ angular.module('emission.main.diary.services', ['emission.plugin.logger', } var tripStartPoint = filteredLocationList[0]; - var tripEndPoint = filteredLocationList[filteredLocationList.length-1]; - Logger.log("tripStartPoint = "+JSON.stringify(tripStartPoint)+"tripEndPoint = "+JSON.stringify(tripEndPoint)); + var tripEndPoint = filteredLocationList[filteredLocationList.length - 1]; + Logger.log( + 'tripStartPoint = ' + + JSON.stringify(tripStartPoint) + + 'tripEndPoint = ' + + JSON.stringify(tripEndPoint), + ); // if we get a list but our start and end are undefined // let's print out the complete original list to get a clue - // this should help with debugging + // this should help with debugging // https://github.com/e-mission/e-mission-docs/issues/417 // if it ever occurs again if (angular.isUndefined(tripStartPoint) || angular.isUndefined(tripEndPoint)) { - Logger.log("BUG 417 check: locationList = "+JSON.stringify(locationList)); - Logger.log("transitions: start = "+JSON.stringify(tripStartTransition.data) - + " end = "+JSON.stringify(tripEndTransition.data.ts)); + Logger.log('BUG 417 check: locationList = ' + JSON.stringify(locationList)); + Logger.log( + 'transitions: start = ' + + JSON.stringify(tripStartTransition.data) + + ' end = ' + + JSON.stringify(tripEndTransition.data.ts), + ); } const tripProps = points2TripProps(filteredLocationList); @@ -268,121 +317,130 @@ angular.module('emission.main.diary.services', ['emission.plugin.logger', return { ...tripProps, start_loc: { - type: "Point", - coordinates: [tripStartPoint.data.longitude, tripStartPoint.data.latitude] + type: 'Point', + coordinates: [tripStartPoint.data.longitude, tripStartPoint.data.latitude], }, end_loc: { - type: "Point", + type: 'Point', coordinates: [tripEndPoint.data.longitude, tripEndPoint.data.latitude], }, - } + }; }); - } + }; - var linkTrips = function(trip1, trip2) { + var linkTrips = function (trip1, trip2) { // complete trip1 - trip1.starting_trip = {$oid: trip2.id}; + trip1.starting_trip = { $oid: trip2.id }; trip1.exit_fmt_time = trip2.enter_fmt_time; trip1.exit_local_dt = trip2.enter_local_dt; trip1.exit_ts = trip2.enter_ts; // start trip2 - trip2.ending_trip = {$oid: trip1.id}; + trip2.ending_trip = { $oid: trip1.id }; trip2.enter_fmt_time = trip1.exit_fmt_time; trip2.enter_local_dt = trip1.exit_local_dt; trip2.enter_ts = trip1.exit_ts; - } + }; - timeline.readUnprocessedTrips = function(startTs, endTs, lastProcessedTrip) { + timeline.readUnprocessedTrips = function (startTs, endTs, lastProcessedTrip) { $ionicLoading.show({ - template: i18next.t('service.reading-unprocessed-data') + template: i18next.t('service.reading-unprocessed-data'), }); - var tq = {key: "write_ts", - startTs, - endTs - } - Logger.log("about to query for unprocessed trips from " - +moment.unix(tq.startTs).toString()+" -> "+moment.unix(tq.endTs).toString()); - return UnifiedDataLoader.getUnifiedMessagesForInterval("statemachine/transition", tq) - .then(function(transitionList) { - if (transitionList.length == 0) { - Logger.log("No unprocessed trips. yay!"); - $ionicLoading.hide(); - return []; - } else { - Logger.log("Found "+transitionList.length+" transitions. yay!"); - var sortedTransitionList = transitionList.sort(tsEntrySort); - /* + var tq = { key: 'write_ts', startTs, endTs }; + Logger.log( + 'about to query for unprocessed trips from ' + + moment.unix(tq.startTs).toString() + + ' -> ' + + moment.unix(tq.endTs).toString(), + ); + return UnifiedDataLoader.getUnifiedMessagesForInterval('statemachine/transition', tq).then( + function (transitionList) { + if (transitionList.length == 0) { + Logger.log('No unprocessed trips. yay!'); + $ionicLoading.hide(); + return []; + } else { + Logger.log('Found ' + transitionList.length + ' transitions. yay!'); + var sortedTransitionList = transitionList.sort(tsEntrySort); + /* sortedTransitionList.forEach(function(transition) { console.log(moment(transition.data.ts * 1000).format()+":" + JSON.stringify(transition.data)); }); */ - var tripsList = transitions2Trips(transitionList); - Logger.log("Mapped into"+tripsList.length+" trips. yay!"); - tripsList.forEach(function(trip) { + var tripsList = transitions2Trips(transitionList); + Logger.log('Mapped into' + tripsList.length + ' trips. yay!'); + tripsList.forEach(function (trip) { console.log(JSON.stringify(trip)); - }); - var tripFillPromises = tripsList.map(transitionTrip2TripObj); - return Promise.all(tripFillPromises).then(function(raw_trip_gj_list) { + }); + var tripFillPromises = tripsList.map(transitionTrip2TripObj); + return Promise.all(tripFillPromises).then(function (raw_trip_gj_list) { // Now we need to link up the trips. linking unprocessed trips // to one another is fairly simple, but we need to link the // first unprocessed trip to the last processed trip. // This might be challenging if we don't have any processed - // trips for the day. I don't want to go back forever until + // trips for the day. I don't want to go back forever until // I find a trip. So if this is the first trip, we will start a // new chain for now, since this is with unprocessed data // anyway. - Logger.log("mapped trips to trip_gj_list of size "+raw_trip_gj_list.length); + Logger.log('mapped trips to trip_gj_list of size ' + raw_trip_gj_list.length); /* Filtering: we will keep trips that are 1) defined and 2) have a distance >= 100m or duration >= 5 minutes https://github.com/e-mission/e-mission-docs/issues/966#issuecomment-1709112578 */ - const trip_gj_list = raw_trip_gj_list.filter((trip) => - trip && (trip.distance >= 100 || trip.duration >= 300) + const trip_gj_list = raw_trip_gj_list.filter( + (trip) => trip && (trip.distance >= 100 || trip.duration >= 300), + ); + Logger.log( + 'after filtering undefined and distance < 100, trip_gj_list size = ' + + raw_trip_gj_list.length, ); - Logger.log("after filtering undefined and distance < 100, trip_gj_list size = "+raw_trip_gj_list.length); // Link 0th trip to first, first to second, ... - for (var i = 0; i < trip_gj_list.length-1; i++) { - linkTrips(trip_gj_list[i], trip_gj_list[i+1]); + for (var i = 0; i < trip_gj_list.length - 1; i++) { + linkTrips(trip_gj_list[i], trip_gj_list[i + 1]); } - Logger.log("finished linking trips for list of size "+trip_gj_list.length); + Logger.log('finished linking trips for list of size ' + trip_gj_list.length); if (lastProcessedTrip && trip_gj_list.length != 0) { - // Need to link the entire chain above to the processed data - Logger.log("linking unprocessed and processed trip chains"); - linkTrips(lastProcessedTrip, trip_gj_list[0]); + // Need to link the entire chain above to the processed data + Logger.log('linking unprocessed and processed trip chains'); + linkTrips(lastProcessedTrip, trip_gj_list[0]); } $ionicLoading.hide(); - Logger.log("Returning final list of size "+trip_gj_list.length); + Logger.log('Returning final list of size ' + trip_gj_list.length); return trip_gj_list; - }); - } - }); - } + }); + } + }, + ); + }; - var localCacheReadFn = timeline.updateFromDatabase; + var localCacheReadFn = timeline.updateFromDatabase; - timeline.getTrip = function(tripId) { - return angular.isDefined(timeline.data.tripMap)? timeline.data.tripMap[tripId] : undefined; + timeline.getTrip = function (tripId) { + return angular.isDefined(timeline.data.tripMap) ? timeline.data.tripMap[tripId] : undefined; }; - timeline.getTripWrapper = function(tripId) { - return angular.isDefined(timeline.data.tripWrapperMap)? timeline.data.tripWrapperMap[tripId] : undefined; + timeline.getTripWrapper = function (tripId) { + return angular.isDefined(timeline.data.tripWrapperMap) + ? timeline.data.tripWrapperMap[tripId] + : undefined; }; - timeline.getCompositeTrip = function(tripId) { - return angular.isDefined(timeline.data.infScrollCompositeTripMap)? timeline.data.infScrollCompositeTripMap[tripId] : undefined; + timeline.getCompositeTrip = function (tripId) { + return angular.isDefined(timeline.data.infScrollCompositeTripMap) + ? timeline.data.infScrollCompositeTripMap[tripId] + : undefined; }; - timeline.setInfScrollCompositeTripList = function(compositeTripList) { + timeline.setInfScrollCompositeTripList = function (compositeTripList) { timeline.data.infScrollCompositeTripList = compositeTripList; timeline.data.infScrollCompositeTripMap = {}; - timeline.data.infScrollCompositeTripList.forEach(function(trip, index, array) { + timeline.data.infScrollCompositeTripList.forEach(function (trip, index, array) { timeline.data.infScrollCompositeTripMap[trip._id.$oid] = trip; }); - } - - return timeline; - }) + }; + return timeline; + }, + ); diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 579e3ac4f..be6ee1bb3 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -1,8 +1,8 @@ -import moment from "moment"; -import { getAngularService } from "../angular-react-helper"; -import { displayError, logDebug } from "../plugin/logger"; -import { getBaseModeByKey, getBaseModeOfLabeledTrip } from "./diaryHelper"; -import i18next from "i18next"; +import moment from 'moment'; +import { getAngularService } from '../angular-react-helper'; +import { displayError, logDebug } from '../plugin/logger'; +import { getBaseModeByKey, getBaseModeOfLabeledTrip } from './diaryHelper'; +import i18next from 'i18next'; const cachedGeojsons = new Map(); /** @@ -15,29 +15,29 @@ export function useGeojsonForTrip(trip, labelOptions, labeledMode?) { return cachedGeojsons.get(gjKey); } - let trajectoryColor: string|null; + let trajectoryColor: string | null; if (labeledMode) { trajectoryColor = getBaseModeOfLabeledTrip(trip, labelOptions)?.color; } - logDebug("Reading trip's " + trip.locations.length + " location points at " + (new Date())); + logDebug("Reading trip's " + trip.locations.length + ' location points at ' + new Date()); var features = [ - location2GeojsonPoint(trip.start_loc, "start_place"), - location2GeojsonPoint(trip.end_loc, "end_place"), - ...locations2GeojsonTrajectory(trip, trip.locations, trajectoryColor) + location2GeojsonPoint(trip.start_loc, 'start_place'), + location2GeojsonPoint(trip.end_loc, 'end_place'), + ...locations2GeojsonTrajectory(trip, trip.locations, trajectoryColor), ]; const gj = { data: { id: gjKey, - type: "FeatureCollection", + type: 'FeatureCollection', features: features, properties: { start_ts: trip.start_ts, - end_ts: trip.end_ts - } - } - } + end_ts: trip.end_ts, + }, + }, + }; cachedGeojsons.set(gjKey, gj); return gj; } @@ -70,7 +70,14 @@ export function compositeTrips2TimelineMap(ctList: any[], unpackPlaces?: boolean return timelineEntriesMap; } -export function populateCompositeTrips(ctList, showPlaces, labelsFactory, labelsResultMap, notesFactory, notesResultMap) { +export function populateCompositeTrips( + ctList, + showPlaces, + labelsFactory, + labelsResultMap, + notesFactory, + notesResultMap, +) { try { ctList.forEach((ct, i) => { if (showPlaces && ct.start_confirmed_place) { @@ -97,9 +104,9 @@ export function populateCompositeTrips(ctList, showPlaces, labelsFactory, labels } const getUnprocessedInputQuery = (pipelineRange) => ({ - key: "write_ts", + key: 'write_ts', startTs: pipelineRange.end_ts - 10, - endTs: moment().unix() + 10 + endTs: moment().unix() + 10, }); function getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, notesPromises) { @@ -128,10 +135,10 @@ export function getLocalUnprocessedInputs(pipelineRange, labelsFactory, notesFac const BEMUserCache = window['cordova'].plugins.BEMUserCache; const tq = getUnprocessedInputQuery(pipelineRange); const labelsPromises = labelsFactory.MANUAL_KEYS.map((key) => - BEMUserCache.getMessagesForInterval(key, tq, true).then(labelsFactory.extractResult) + BEMUserCache.getMessagesForInterval(key, tq, true).then(labelsFactory.extractResult), ); const notesPromises = notesFactory.MANUAL_KEYS.map((key) => - BEMUserCache.getMessagesForInterval(key, tq, true).then(notesFactory.extractResult) + BEMUserCache.getMessagesForInterval(key, tq, true).then(notesFactory.extractResult), ); return getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, notesPromises); } @@ -150,10 +157,12 @@ export function getAllUnprocessedInputs(pipelineRange, labelsFactory, notesFacto const UnifiedDataLoader = getAngularService('UnifiedDataLoader'); const tq = getUnprocessedInputQuery(pipelineRange); const labelsPromises = labelsFactory.MANUAL_KEYS.map((key) => - UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true).then(labelsFactory.extractResult) + UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true).then( + labelsFactory.extractResult, + ), ); const notesPromises = notesFactory.MANUAL_KEYS.map((key) => - UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true).then(notesFactory.extractResult) + UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true).then(notesFactory.extractResult), ); return getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, notesPromises); } @@ -164,14 +173,14 @@ export function getAllUnprocessedInputs(pipelineRange, labelsFactory, notesFacto * @returns a GeoJSON feature with type "Point", the given location's coordinates and the given feature type */ const location2GeojsonPoint = (locationPoint: any, featureType: string) => ({ - type: "Feature", + type: 'Feature', geometry: { - type: "Point", + type: 'Point', coordinates: locationPoint.coordinates, }, properties: { feature_type: featureType, - } + }, }); /** @@ -188,25 +197,23 @@ const locations2GeojsonTrajectory = (trip, locationList, trajectoryColor?) => { } else { // this is a multimodal trip so we sort the locations into sections by timestamp sectionsPoints = trip.sections.map((s) => - trip.locations.filter((l) => - l.ts >= s.start_ts && l.ts <= s.end_ts - ) + trip.locations.filter((l) => l.ts >= s.start_ts && l.ts <= s.end_ts), ); } return sectionsPoints.map((sectionPoints, i) => { const section = trip.sections?.[i]; return { - type: "Feature", + type: 'Feature', geometry: { - type: "LineString", + type: 'LineString', coordinates: sectionPoints.map((pt) => pt.loc.coordinates), }, style: { /* If a color was passed as arg, use it for the whole trajectory. Otherwise, use the color for the sensed mode of this section, and fall back to dark grey */ - color: trajectoryColor || getBaseModeByKey(section?.sensed_mode_str)?.color || "#333", + color: trajectoryColor || getBaseModeByKey(section?.sensed_mode_str)?.color || '#333', }, - } + }; }); -} +}; diff --git a/www/js/diary/useDerivedProperties.tsx b/www/js/diary/useDerivedProperties.tsx index 604fef227..fe324ee3f 100644 --- a/www/js/diary/useDerivedProperties.tsx +++ b/www/js/diary/useDerivedProperties.tsx @@ -1,9 +1,16 @@ -import { useMemo } from "react"; -import { useImperialConfig } from "../config/useImperialConfig"; -import { getFormattedDate, getFormattedDateAbbr, getFormattedSectionProperties, getFormattedTimeRange, getLocalTimeString, getDetectedModes, isMultiDay } from "./diaryHelper"; +import { useMemo } from 'react'; +import { useImperialConfig } from '../config/useImperialConfig'; +import { + getFormattedDate, + getFormattedDateAbbr, + getFormattedSectionProperties, + getFormattedTimeRange, + getLocalTimeString, + getDetectedModes, + isMultiDay, +} from './diaryHelper'; const useDerivedProperties = (tlEntry) => { - const imperialConfig = useImperialConfig(); return useMemo(() => { @@ -12,7 +19,7 @@ const useDerivedProperties = (tlEntry) => { const beginDt = tlEntry.start_local_dt || tlEntry.enter_local_dt; const endDt = tlEntry.end_local_dt || tlEntry.exit_local_dt; const tlEntryIsMultiDay = isMultiDay(beginFmt, endFmt); - + return { displayDate: getFormattedDate(beginFmt, endFmt), displayStartTime: getLocalTimeString(beginDt), @@ -24,8 +31,8 @@ const useDerivedProperties = (tlEntry) => { formattedSectionProperties: getFormattedSectionProperties(tlEntry, imperialConfig), distanceSuffix: imperialConfig.distanceSuffix, detectedModes: getDetectedModes(tlEntry), - } + }; }, [tlEntry, imperialConfig]); -} +}; export default useDerivedProperties; diff --git a/www/js/i18n-utils.js b/www/js/i18n-utils.js index 45cca7043..bcfb74391 100644 --- a/www/js/i18n-utils.js +++ b/www/js/i18n-utils.js @@ -2,39 +2,48 @@ import angular from 'angular'; -angular.module('emission.i18n.utils', []) -.factory("i18nUtils", function($http, Logger) { +angular.module('emission.i18n.utils', []).factory('i18nUtils', function ($http, Logger) { var iu = {}; // copy-pasted from ngCordova, and updated to promises - iu.checkFile = function(fn) { - return new Promise(function(resolve, reject) { - if ((/^\//.test(fn))) { - reject('directory cannot start with \/'); + iu.checkFile = function (fn) { + return new Promise(function (resolve, reject) { + if (/^\//.test(fn)) { + reject('directory cannot start with /'); } return $http.get(fn); }); - } + }; // The language comes in between the first and second part // the default path should end with a "/" iu.geti18nFileName = function (defaultPath, fpFirstPart, fpSecondPart) { const lang = i18next.resolvedLanguage; - const i18nPath = "i18n/"; + const i18nPath = 'i18n/'; var defaultVal = defaultPath + fpFirstPart + fpSecondPart; if (lang != 'en') { - var url = i18nPath + fpFirstPart + "-" + lang + fpSecondPart; - return $http.get(url).then( function(result){ - Logger.log(window.Logger.LEVEL_DEBUG, - "Successfully found the "+url+", result is " + JSON.stringify(result.data).substring(0,10)); - return url; - }).catch(function (err) { - Logger.log(window.Logger.LEVEL_DEBUG, - url+" file not found, loading english version, error is " + JSON.stringify(err)); - return Promise.resolve(defaultVal); - }); + var url = i18nPath + fpFirstPart + '-' + lang + fpSecondPart; + return $http + .get(url) + .then(function (result) { + Logger.log( + window.Logger.LEVEL_DEBUG, + 'Successfully found the ' + + url + + ', result is ' + + JSON.stringify(result.data).substring(0, 10), + ); + return url; + }) + .catch(function (err) { + Logger.log( + window.Logger.LEVEL_DEBUG, + url + ' file not found, loading english version, error is ' + JSON.stringify(err), + ); + return Promise.resolve(defaultVal); + }); } return Promise.resolve(defaultVal); - } + }; return iu; }); diff --git a/www/js/i18nextInit.ts b/www/js/i18nextInit.ts index a2688d66e..c2093c698 100644 --- a/www/js/i18nextInit.ts +++ b/www/js/i18nextInit.ts @@ -21,7 +21,7 @@ const mergeInTranslations = (lang, fallbackLang) => { console.warn(`Missing translation for key '${key}'`); if (__DEV__) { if (typeof value === 'string') { - lang[key] = `🌐${value}` + lang[key] = `🌐${value}`; } else if (typeof value === 'object' && typeof lang[key] === 'object') { lang[key] = {}; mergeInTranslations(lang[key], value); @@ -30,11 +30,11 @@ const mergeInTranslations = (lang, fallbackLang) => { lang[key] = value; } } else if (typeof value === 'object' && typeof lang[key] === 'object') { - mergeInTranslations(lang[key], fallbackLang[key]) + mergeInTranslations(lang[key], fallbackLang[key]); } }); return lang; -} +}; import enJson from '../i18n/en.json'; import esJson from '../../locales/es/i18n/es.json'; @@ -59,22 +59,24 @@ for (const locale of locales) { } } -i18next.use(initReactI18next) - .init({ - debug: true, - resources: langs, - lng: detectedLang, - fallbackLng: 'en' - }); +i18next.use(initReactI18next).init({ + debug: true, + resources: langs, + lng: detectedLang, + fallbackLng: 'en', +}); export default i18next; // Next, register the translations for react-native-paper-dates import { en, es, fr, it, registerTranslation } from 'react-native-paper-dates'; const rnpDatesLangs = { - en, es, fr, it, + en, + es, + fr, + it, lo: loJson['react-native-paper-dates'] /* Lao translations are not included in the library, - so we register them from 'lo.json' in /locales */ + so we register them from 'lo.json' in /locales */, }; for (const lang of Object.keys(rnpDatesLangs)) { registerTranslation(lang, rnpDatesLangs[lang]); diff --git a/www/js/main.js b/www/js/main.js index 91437a07a..2b351e2c4 100644 --- a/www/js/main.js +++ b/www/js/main.js @@ -2,29 +2,40 @@ import angular from 'angular'; -angular.module('emission.main', ['emission.main.diary', - 'emission.i18n.utils', - 'emission.splash.notifscheduler', - 'emission.main.metrics.factory', - 'emission.main.metrics.mappings', - 'emission.services']) +angular + .module('emission.main', [ + 'emission.main.diary', + 'emission.i18n.utils', + 'emission.splash.notifscheduler', + 'emission.main.metrics.factory', + 'emission.main.metrics.mappings', + 'emission.services', + ]) -.config(function($stateProvider) { - $stateProvider.state('root.main', { - url: '/main', - template: `` - }); -}) + .config(function ($stateProvider) { + $stateProvider.state('root.main', { + url: '/main', + template: ``, + }); + }) -.controller('appCtrl', function($scope, $ionicModal, $timeout) { - $scope.openNativeSettings = function() { - window.Logger.log(window.Logger.LEVEL_DEBUG, "about to open native settings"); - window.cordova.plugins.BEMLaunchNative.launch("NativeSettings", function(result) { - window.Logger.log(window.Logger.LEVEL_DEBUG, - "Successfully opened screen NativeSettings, result is "+result); - }, function(err) { - window.Logger.log(window.Logger.LEVEL_ERROR, - "Unable to open screen NativeSettings because of err "+err); - }); - } -}); + .controller('appCtrl', function ($scope, $ionicModal, $timeout) { + $scope.openNativeSettings = function () { + window.Logger.log(window.Logger.LEVEL_DEBUG, 'about to open native settings'); + window.cordova.plugins.BEMLaunchNative.launch( + 'NativeSettings', + function (result) { + window.Logger.log( + window.Logger.LEVEL_DEBUG, + 'Successfully opened screen NativeSettings, result is ' + result, + ); + }, + function (err) { + window.Logger.log( + window.Logger.LEVEL_ERROR, + 'Unable to open screen NativeSettings because of err ' + err, + ); + }, + ); + }; + }); diff --git a/www/js/metrics-factory.js b/www/js/metrics-factory.js index b5ead9c82..28ef5ae9e 100644 --- a/www/js/metrics-factory.js +++ b/www/js/metrics-factory.js @@ -1,238 +1,271 @@ 'use strict'; import angular from 'angular'; -import { getBaseModeByValue } from './diary/diaryHelper' +import { getBaseModeByValue } from './diary/diaryHelper'; import { labelOptions } from './survey/multilabel/confirmHelper'; import { storageGet, storageRemove, storageSet } from './plugin/storage'; -angular.module('emission.main.metrics.factory', - ['emission.main.metrics.mappings']) +angular + .module('emission.main.metrics.factory', ['emission.main.metrics.mappings']) -.factory('FootprintHelper', function(CarbonDatasetHelper, CustomDatasetHelper) { - var fh = {}; - var highestFootprint = 0; + .factory('FootprintHelper', function (CarbonDatasetHelper, CustomDatasetHelper) { + var fh = {}; + var highestFootprint = 0; - var mtokm = function(v) { - return v / 1000; - } - fh.useCustom = false; + var mtokm = function (v) { + return v / 1000; + }; + fh.useCustom = false; - fh.setUseCustomFootprint = function () { - fh.useCustom = true; - } + fh.setUseCustomFootprint = function () { + fh.useCustom = true; + }; - fh.getFootprint = function() { - if (this.useCustom == true) { + fh.getFootprint = function () { + if (this.useCustom == true) { return CustomDatasetHelper.getCustomFootprint(); - } else { + } else { return CarbonDatasetHelper.getCurrentCarbonDatasetFootprint(); - } - } - - fh.readableFormat = function(v) { - return v > 999? Math.round(v / 1000) + 'k kg CO₂' : Math.round(v) + ' kg CO₂'; - } - fh.getFootprintForMetrics = function(userMetrics, defaultIfMissing=0) { - var footprint = fh.getFootprint(); - var result = 0; - for (var i in userMetrics) { - var mode = userMetrics[i].key; - if (mode == 'ON_FOOT') { - mode = 'WALKING'; } + }; - if (mode in footprint) { - result += footprint[mode] * mtokm(userMetrics[i].values); - } else if (mode == 'IN_VEHICLE') { - result += ((footprint['CAR'] + footprint['BUS'] + footprint["LIGHT_RAIL"] + footprint['TRAIN'] + footprint['TRAM'] + footprint['SUBWAY']) / 6) * mtokm(userMetrics[i].values); - } else { - console.warn('WARNING FootprintHelper.getFootprintFromMetrics() was requested for an unknown mode: ' + mode + " metrics JSON: " + JSON.stringify(userMetrics)); - result += defaultIfMissing * mtokm(userMetrics[i].values); - } - } - return result; - } - fh.getLowestFootprintForDistance = function(distance) { - var footprint = fh.getFootprint(); - var lowestFootprint = Number.MAX_SAFE_INTEGER; - for (var mode in footprint) { - if (mode == 'WALKING' || mode == 'BICYCLING') { - // these modes aren't considered when determining the lowest carbon footprint + fh.readableFormat = function (v) { + return v > 999 ? Math.round(v / 1000) + 'k kg CO₂' : Math.round(v) + ' kg CO₂'; + }; + fh.getFootprintForMetrics = function (userMetrics, defaultIfMissing = 0) { + var footprint = fh.getFootprint(); + var result = 0; + for (var i in userMetrics) { + var mode = userMetrics[i].key; + if (mode == 'ON_FOOT') { + mode = 'WALKING'; + } + + if (mode in footprint) { + result += footprint[mode] * mtokm(userMetrics[i].values); + } else if (mode == 'IN_VEHICLE') { + result += + ((footprint['CAR'] + + footprint['BUS'] + + footprint['LIGHT_RAIL'] + + footprint['TRAIN'] + + footprint['TRAM'] + + footprint['SUBWAY']) / + 6) * + mtokm(userMetrics[i].values); + } else { + console.warn( + 'WARNING FootprintHelper.getFootprintFromMetrics() was requested for an unknown mode: ' + + mode + + ' metrics JSON: ' + + JSON.stringify(userMetrics), + ); + result += defaultIfMissing * mtokm(userMetrics[i].values); + } } - else { - lowestFootprint = Math.min(lowestFootprint, footprint[mode]); + return result; + }; + fh.getLowestFootprintForDistance = function (distance) { + var footprint = fh.getFootprint(); + var lowestFootprint = Number.MAX_SAFE_INTEGER; + for (var mode in footprint) { + if (mode == 'WALKING' || mode == 'BICYCLING') { + // these modes aren't considered when determining the lowest carbon footprint + } else { + lowestFootprint = Math.min(lowestFootprint, footprint[mode]); + } } - } - return lowestFootprint * mtokm(distance); - } + return lowestFootprint * mtokm(distance); + }; - fh.getHighestFootprint = function() { - if (!highestFootprint) { + fh.getHighestFootprint = function () { + if (!highestFootprint) { var footprint = fh.getFootprint(); let footprintList = []; for (var mode in footprint) { - footprintList.push(footprint[mode]); + footprintList.push(footprint[mode]); } highestFootprint = Math.max(...footprintList); - } - return highestFootprint; - } - - fh.getHighestFootprintForDistance = function(distance) { - return fh.getHighestFootprint() * mtokm(distance); - } - - var getLowestMotorizedNonAirFootprint = function(footprint, rlmCO2) { - var lowestFootprint = Number.MAX_SAFE_INTEGER; - for (var mode in footprint) { - if (mode == 'AIR_OR_HSR' || mode == 'air') { - console.log("Air mode, ignoring"); } - else { - if (footprint[mode] == 0 || footprint[mode] <= rlmCO2) { - console.log("Non motorized mode or footprint <= range_limited_motorized", mode, footprint[mode], rlmCO2); + return highestFootprint; + }; + + fh.getHighestFootprintForDistance = function (distance) { + return fh.getHighestFootprint() * mtokm(distance); + }; + + var getLowestMotorizedNonAirFootprint = function (footprint, rlmCO2) { + var lowestFootprint = Number.MAX_SAFE_INTEGER; + for (var mode in footprint) { + if (mode == 'AIR_OR_HSR' || mode == 'air') { + console.log('Air mode, ignoring'); } else { + if (footprint[mode] == 0 || footprint[mode] <= rlmCO2) { + console.log( + 'Non motorized mode or footprint <= range_limited_motorized', + mode, + footprint[mode], + rlmCO2, + ); + } else { lowestFootprint = Math.min(lowestFootprint, footprint[mode]); + } } } - } - return lowestFootprint; - } - - fh.getOptimalDistanceRanges = function() { - const FIVE_KM = 5 * 1000; - const SIX_HUNDRED_KM = 600 * 1000; - if (!fh.useCustom) { + return lowestFootprint; + }; + + fh.getOptimalDistanceRanges = function () { + const FIVE_KM = 5 * 1000; + const SIX_HUNDRED_KM = 600 * 1000; + if (!fh.useCustom) { const defaultFootprint = CarbonDatasetHelper.getCurrentCarbonDatasetFootprint(); const lowestMotorizedNonAir = getLowestMotorizedNonAirFootprint(defaultFootprint); - const airFootprint = defaultFootprint["AIR_OR_HSR"]; + const airFootprint = defaultFootprint['AIR_OR_HSR']; return [ - {low: 0, high: FIVE_KM, optimal: 0}, - {low: FIVE_KM, high: SIX_HUNDRED_KM, optimal: lowestMotorizedNonAir}, - {low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint}]; - } else { + { low: 0, high: FIVE_KM, optimal: 0 }, + { low: FIVE_KM, high: SIX_HUNDRED_KM, optimal: lowestMotorizedNonAir }, + { low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint }, + ]; + } else { // custom footprint, let's get the custom values const customFootprint = CustomDatasetHelper.getCustomFootprint(); - let airFootprint = customFootprint["air"] + let airFootprint = customFootprint['air']; if (!airFootprint) { - // 2341 BTU/PMT from - // https://tedb.ornl.gov/wp-content/uploads/2021/02/TEDB_Ed_39.pdf#page=68 - // 159.25 lb per million BTU from EIA - // https://www.eia.gov/environment/emissions/co2_vol_mass.php - // (2341 * (159.25/1000000))/(1.6*2.2) = 0.09975, rounded up a bit - console.log("No entry for air in ", customFootprint," using default"); - airFootprint = 0.1; + // 2341 BTU/PMT from + // https://tedb.ornl.gov/wp-content/uploads/2021/02/TEDB_Ed_39.pdf#page=68 + // 159.25 lb per million BTU from EIA + // https://www.eia.gov/environment/emissions/co2_vol_mass.php + // (2341 * (159.25/1000000))/(1.6*2.2) = 0.09975, rounded up a bit + console.log('No entry for air in ', customFootprint, ' using default'); + airFootprint = 0.1; } const rlm = CustomDatasetHelper.range_limited_motorized; if (!rlm) { - return [ - {low: 0, high: FIVE_KM, optimal: 0}, - {low: FIVE_KM, high: SIX_HUNDRED_KM, optimal: lowestMotorizedNonAir}, - {low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint}]; + return [ + { low: 0, high: FIVE_KM, optimal: 0 }, + { low: FIVE_KM, high: SIX_HUNDRED_KM, optimal: lowestMotorizedNonAir }, + { low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint }, + ]; } else { - console.log("Found range_limited_motorized mode", rlm); - const lowestMotorizedNonAir = getLowestMotorizedNonAirFootprint(customFootprint, rlm.kgCo2PerKm); - return [ - {low: 0, high: FIVE_KM, optimal: 0}, - {low: FIVE_KM, high: rlm.range_limit_km * 1000, optimal: rlm.kgCo2PerKm}, - {low: rlm.range_limit_km * 1000, high: SIX_HUNDRED_KM, optimal: lowestMotorizedNonAir}, - {low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint}]; + console.log('Found range_limited_motorized mode', rlm); + const lowestMotorizedNonAir = getLowestMotorizedNonAirFootprint( + customFootprint, + rlm.kgCo2PerKm, + ); + return [ + { low: 0, high: FIVE_KM, optimal: 0 }, + { low: FIVE_KM, high: rlm.range_limit_km * 1000, optimal: rlm.kgCo2PerKm }, + { + low: rlm.range_limit_km * 1000, + high: SIX_HUNDRED_KM, + optimal: lowestMotorizedNonAir, + }, + { low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint }, + ]; } - } - } - - return fh; -}) + } + }; -.factory('CalorieCal', function(METDatasetHelper, CustomDatasetHelper) { + return fh; + }) - var cc = {}; - var highestMET = 0; - var USER_DATA_KEY = "user-data"; - cc.useCustom = false; + .factory('CalorieCal', function (METDatasetHelper, CustomDatasetHelper) { + var cc = {}; + var highestMET = 0; + var USER_DATA_KEY = 'user-data'; + cc.useCustom = false; - cc.setUseCustomFootprint = function () { - cc.useCustom = true; - } + cc.setUseCustomFootprint = function () { + cc.useCustom = true; + }; - cc.getMETs = function() { - if (this.useCustom == true) { + cc.getMETs = function () { + if (this.useCustom == true) { return CustomDatasetHelper.getCustomMETs(); - } else { + } else { return METDatasetHelper.getStandardMETs(); - } - } - - cc.set = function(info) { - return storageSet(USER_DATA_KEY, info); - }; - cc.get = function() { - return storageGet(USER_DATA_KEY); - }; - cc.delete = function() { - return storageRemove(USER_DATA_KEY); - }; - Number.prototype.between = function (min, max) { - return this >= min && this <= max; - }; - cc.getHighestMET = function() { - if (!highestMET) { + } + }; + + cc.set = function (info) { + return storageSet(USER_DATA_KEY, info); + }; + cc.get = function () { + return storageGet(USER_DATA_KEY); + }; + cc.delete = function () { + return storageRemove(USER_DATA_KEY); + }; + Number.prototype.between = function (min, max) { + return this >= min && this <= max; + }; + cc.getHighestMET = function () { + if (!highestMET) { var met = cc.getMETs(); let metList = []; for (var mode in met) { - var rangeList = met[mode]; - for (var range in rangeList) { - metList.push(rangeList[range].mets); - } + var rangeList = met[mode]; + for (var range in rangeList) { + metList.push(rangeList[range].mets); + } } highestMET = Math.max(...metList); - } - return highestMET; - } - cc.getMet = function(mode, speed, defaultIfMissing) { - if (mode == 'ON_FOOT') { - console.log("CalorieCal.getMet() converted 'ON_FOOT' to 'WALKING'"); - mode = 'WALKING'; - } - let currentMETs = cc.getMETs(); - if (!currentMETs[mode]) { - console.warn("CalorieCal.getMet() Illegal mode: " + mode); - return defaultIfMissing; //So the calorie sum does not break with wrong return type - } - for (var i in currentMETs[mode]) { - if (mpstomph(speed).between(currentMETs[mode][i].range[0], currentMETs[mode][i].range[1])) { - return currentMETs[mode][i].mets; - } else if (mpstomph(speed) < 0 ) { - console.log("CalorieCal.getMet() Negative speed: " + mpstomph(speed)); - return 0; } - } - } - var mpstomph = function(mps) { - return 2.23694 * mps; - } - var lbtokg = function(lb) { - return lb * 0.453592; - } - var fttocm = function(ft) { - return ft * 30.48; - } - cc.getCorrectedMet = function(met, gender, age, height, heightUnit, weight, weightUnit) { - var height = heightUnit == 0? fttocm(height) : height; - var weight = weightUnit == 0? lbtokg(weight) : weight; - if (gender == 1) { //male - var met = met*3.5/((66.4730+5.0033*height+13.7516*weight-6.7550*age)/ 1440 / 5 / weight * 1000); - return met; - } else if (gender == 0) { //female - var met = met*3.5/((655.0955+1.8496*height+9.5634*weight-4.6756*age)/ 1440 / 5 / weight * 1000); - return met; - } - } - cc.getuserCalories = function(durationInMin, met) { - return 65 * durationInMin * met; - } - cc.getCalories = function(weightInKg, durationInMin, met) { - return weightInKg * durationInMin * met; - } - return cc; -}); + return highestMET; + }; + cc.getMet = function (mode, speed, defaultIfMissing) { + if (mode == 'ON_FOOT') { + console.log("CalorieCal.getMet() converted 'ON_FOOT' to 'WALKING'"); + mode = 'WALKING'; + } + let currentMETs = cc.getMETs(); + if (!currentMETs[mode]) { + console.warn('CalorieCal.getMet() Illegal mode: ' + mode); + return defaultIfMissing; //So the calorie sum does not break with wrong return type + } + for (var i in currentMETs[mode]) { + if (mpstomph(speed).between(currentMETs[mode][i].range[0], currentMETs[mode][i].range[1])) { + return currentMETs[mode][i].mets; + } else if (mpstomph(speed) < 0) { + console.log('CalorieCal.getMet() Negative speed: ' + mpstomph(speed)); + return 0; + } + } + }; + var mpstomph = function (mps) { + return 2.23694 * mps; + }; + var lbtokg = function (lb) { + return lb * 0.453592; + }; + var fttocm = function (ft) { + return ft * 30.48; + }; + cc.getCorrectedMet = function (met, gender, age, height, heightUnit, weight, weightUnit) { + var height = heightUnit == 0 ? fttocm(height) : height; + var weight = weightUnit == 0 ? lbtokg(weight) : weight; + if (gender == 1) { + //male + var met = + (met * 3.5) / + (((66.473 + 5.0033 * height + 13.7516 * weight - 6.755 * age) / 1440 / 5 / weight) * + 1000); + return met; + } else if (gender == 0) { + //female + var met = + (met * 3.5) / + (((655.0955 + 1.8496 * height + 9.5634 * weight - 4.6756 * age) / 1440 / 5 / weight) * + 1000); + return met; + } + }; + cc.getuserCalories = function (durationInMin, met) { + return 65 * durationInMin * met; + }; + cc.getCalories = function (weightInKg, durationInMin, met) { + return weightInKg * durationInMin * met; + }; + return cc; + }); diff --git a/www/js/metrics-mappings.js b/www/js/metrics-mappings.js index 60068711d..38836a3a1 100644 --- a/www/js/metrics-mappings.js +++ b/www/js/metrics-mappings.js @@ -3,399 +3,423 @@ import { getLabelOptions } from './survey/multilabel/confirmHelper'; import { getConfig } from './config/dynamicConfig'; import { storageGet, storageSet } from './plugin/storage'; -angular.module('emission.main.metrics.mappings', ['emission.plugin.logger']) +angular + .module('emission.main.metrics.mappings', ['emission.plugin.logger']) -.service('CarbonDatasetHelper', function() { - var CARBON_DATASET_KEY = 'carbon_dataset_locale'; + .service('CarbonDatasetHelper', function () { + var CARBON_DATASET_KEY = 'carbon_dataset_locale'; - // Values are in Kg/PKm (kilograms per passenger-kilometer) - // Sources for EU values: - // - Tremod: 2017, CO2, CH4 and N2O in CO2-equivalent - // - HBEFA: 2020, CO2 (per country) - // German data uses Tremod. Other EU countries (and Switzerland) use HBEFA for car and bus, - // and Tremod for train and air (because HBEFA doesn't provide these). - // EU data is an average of the Tremod/HBEFA data for the countries listed; - // for this average the HBEFA data was used also in the German set (for car and bus). - var carbonDatasets = { - US: { - regionName: "United States", - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 267/1609, - BUS: 278/1609, - LIGHT_RAIL: 120/1609, - SUBWAY: 74/1609, - TRAM: 90/1609, - TRAIN: 92/1609, - AIR_OR_HSR: 217/1609 - } - }, - EU: { // Plain average of values for the countries below (using HBEFA for car and bus, Tremod for others) - regionName: "European Union", - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.14515, - BUS: 0.04751, - LIGHT_RAIL: 0.064, - SUBWAY: 0.064, - TRAM: 0.064, - TRAIN: 0.048, - AIR_OR_HSR: 0.201 - } - }, - DE: { - regionName: "Germany", - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.139, // Tremod (passenger car) - BUS: 0.0535, // Tremod (average city/coach) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201 // Tremod (DE airplane) - } - }, - FR: { - regionName: "France", - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.13125, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04838, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201 // Tremod (DE airplane) - } - }, - AT: { - regionName: "Austria", - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.14351, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04625, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201 // Tremod (DE airplane) - } - }, - SE: { - regionName: "Sweden", - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.13458, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04557, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201 // Tremod (DE airplane) - } - }, - NO: { - regionName: "Norway", - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.13265, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04185, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201 // Tremod (DE airplane) - } - }, - CH: { - regionName: "Switzerland", - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.17638, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04866, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201 // Tremod (DE airplane) - } - } - }; + // Values are in Kg/PKm (kilograms per passenger-kilometer) + // Sources for EU values: + // - Tremod: 2017, CO2, CH4 and N2O in CO2-equivalent + // - HBEFA: 2020, CO2 (per country) + // German data uses Tremod. Other EU countries (and Switzerland) use HBEFA for car and bus, + // and Tremod for train and air (because HBEFA doesn't provide these). + // EU data is an average of the Tremod/HBEFA data for the countries listed; + // for this average the HBEFA data was used also in the German set (for car and bus). + var carbonDatasets = { + US: { + regionName: 'United States', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 267 / 1609, + BUS: 278 / 1609, + LIGHT_RAIL: 120 / 1609, + SUBWAY: 74 / 1609, + TRAM: 90 / 1609, + TRAIN: 92 / 1609, + AIR_OR_HSR: 217 / 1609, + }, + }, + EU: { + // Plain average of values for the countries below (using HBEFA for car and bus, Tremod for others) + regionName: 'European Union', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 0.14515, + BUS: 0.04751, + LIGHT_RAIL: 0.064, + SUBWAY: 0.064, + TRAM: 0.064, + TRAIN: 0.048, + AIR_OR_HSR: 0.201, + }, + }, + DE: { + regionName: 'Germany', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 0.139, // Tremod (passenger car) + BUS: 0.0535, // Tremod (average city/coach) + LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) + SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) + TRAM: 0.064, // Tremod (DE tram, urban rail and subway) + TRAIN: 0.048, // Tremod (DE average short/long distance) + AIR_OR_HSR: 0.201, // Tremod (DE airplane) + }, + }, + FR: { + regionName: 'France', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 0.13125, // HBEFA (passenger car, considering 1 passenger) + BUS: 0.04838, // HBEFA (average short/long distance, considering 16/25 passengers) + LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) + SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) + TRAM: 0.064, // Tremod (DE tram, urban rail and subway) + TRAIN: 0.048, // Tremod (DE average short/long distance) + AIR_OR_HSR: 0.201, // Tremod (DE airplane) + }, + }, + AT: { + regionName: 'Austria', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 0.14351, // HBEFA (passenger car, considering 1 passenger) + BUS: 0.04625, // HBEFA (average short/long distance, considering 16/25 passengers) + LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) + SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) + TRAM: 0.064, // Tremod (DE tram, urban rail and subway) + TRAIN: 0.048, // Tremod (DE average short/long distance) + AIR_OR_HSR: 0.201, // Tremod (DE airplane) + }, + }, + SE: { + regionName: 'Sweden', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 0.13458, // HBEFA (passenger car, considering 1 passenger) + BUS: 0.04557, // HBEFA (average short/long distance, considering 16/25 passengers) + LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) + SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) + TRAM: 0.064, // Tremod (DE tram, urban rail and subway) + TRAIN: 0.048, // Tremod (DE average short/long distance) + AIR_OR_HSR: 0.201, // Tremod (DE airplane) + }, + }, + NO: { + regionName: 'Norway', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 0.13265, // HBEFA (passenger car, considering 1 passenger) + BUS: 0.04185, // HBEFA (average short/long distance, considering 16/25 passengers) + LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) + SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) + TRAM: 0.064, // Tremod (DE tram, urban rail and subway) + TRAIN: 0.048, // Tremod (DE average short/long distance) + AIR_OR_HSR: 0.201, // Tremod (DE airplane) + }, + }, + CH: { + regionName: 'Switzerland', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 0.17638, // HBEFA (passenger car, considering 1 passenger) + BUS: 0.04866, // HBEFA (average short/long distance, considering 16/25 passengers) + LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) + SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) + TRAM: 0.064, // Tremod (DE tram, urban rail and subway) + TRAIN: 0.048, // Tremod (DE average short/long distance) + AIR_OR_HSR: 0.201, // Tremod (DE airplane) + }, + }, + }; - var defaultCarbonDatasetCode = 'US'; - var currentCarbonDatasetCode = defaultCarbonDatasetCode; + var defaultCarbonDatasetCode = 'US'; + var currentCarbonDatasetCode = defaultCarbonDatasetCode; - // we need to call the method from within a promise in initialize() - // and using this.setCurrentCarbonDatasetLocale doesn't seem to work - var setCurrentCarbonDatasetLocale = function(localeCode) { - for (var code in carbonDatasets) { - if (code == localeCode) { - currentCarbonDatasetCode = localeCode; - break; + // we need to call the method from within a promise in initialize() + // and using this.setCurrentCarbonDatasetLocale doesn't seem to work + var setCurrentCarbonDatasetLocale = function (localeCode) { + for (var code in carbonDatasets) { + if (code == localeCode) { + currentCarbonDatasetCode = localeCode; + break; + } } - } - } + }; - this.loadCarbonDatasetLocale = function() { - return storageGet(CARBON_DATASET_KEY).then(function(localeCode) { - Logger.log("CarbonDatasetHelper.loadCarbonDatasetLocale() obtained value from storage [" + localeCode + "]"); - if (!localeCode) { - localeCode = defaultCarbonDatasetCode; - Logger.log("CarbonDatasetHelper.loadCarbonDatasetLocale() no value in storage, using [" + localeCode + "] instead"); - } - setCurrentCarbonDatasetLocale(localeCode); - }); - } + this.loadCarbonDatasetLocale = function () { + return storageGet(CARBON_DATASET_KEY).then(function (localeCode) { + Logger.log( + 'CarbonDatasetHelper.loadCarbonDatasetLocale() obtained value from storage [' + + localeCode + + ']', + ); + if (!localeCode) { + localeCode = defaultCarbonDatasetCode; + Logger.log( + 'CarbonDatasetHelper.loadCarbonDatasetLocale() no value in storage, using [' + + localeCode + + '] instead', + ); + } + setCurrentCarbonDatasetLocale(localeCode); + }); + }; - this.saveCurrentCarbonDatasetLocale = function (localeCode) { - setCurrentCarbonDatasetLocale(localeCode); - storageSet(CARBON_DATASET_KEY, currentCarbonDatasetCode); - Logger.log("CarbonDatasetHelper.saveCurrentCarbonDatasetLocale() saved value [" + currentCarbonDatasetCode + "] to storage"); - } + this.saveCurrentCarbonDatasetLocale = function (localeCode) { + setCurrentCarbonDatasetLocale(localeCode); + storageSet(CARBON_DATASET_KEY, currentCarbonDatasetCode); + Logger.log( + 'CarbonDatasetHelper.saveCurrentCarbonDatasetLocale() saved value [' + + currentCarbonDatasetCode + + '] to storage', + ); + }; - this.getCarbonDatasetOptions = function() { - var options = []; - for (var code in carbonDatasets) { - options.push({ - text: code, //carbonDatasets[code].regionName, - value: code - }); - } - return options; - }; + this.getCarbonDatasetOptions = function () { + var options = []; + for (var code in carbonDatasets) { + options.push({ + text: code, //carbonDatasets[code].regionName, + value: code, + }); + } + return options; + }; - this.getCurrentCarbonDatasetCode = function () { - return currentCarbonDatasetCode; - }; + this.getCurrentCarbonDatasetCode = function () { + return currentCarbonDatasetCode; + }; - this.getCurrentCarbonDatasetFootprint = function () { - return carbonDatasets[currentCarbonDatasetCode].footprintData; - }; -}) -.service('METDatasetHelper', function() { - var standardMETs = { - "WALKING": { - "VERY_SLOW": { - range: [0, 2.0], - mets: 2.0 - }, - "SLOW": { - range: [2.0, 2.5], - mets: 2.8 - }, - "MODERATE_0": { - range: [2.5, 2.8], - mets: 3.0 - }, - "MODERATE_1": { - range: [2.8, 3.2], - mets: 3.5 - }, - "FAST": { - range: [3.2, 3.5], - mets: 4.3 + this.getCurrentCarbonDatasetFootprint = function () { + return carbonDatasets[currentCarbonDatasetCode].footprintData; + }; + }) + .service('METDatasetHelper', function () { + var standardMETs = { + WALKING: { + VERY_SLOW: { + range: [0, 2.0], + mets: 2.0, + }, + SLOW: { + range: [2.0, 2.5], + mets: 2.8, + }, + MODERATE_0: { + range: [2.5, 2.8], + mets: 3.0, + }, + MODERATE_1: { + range: [2.8, 3.2], + mets: 3.5, + }, + FAST: { + range: [3.2, 3.5], + mets: 4.3, + }, + VERY_FAST_0: { + range: [3.5, 4.0], + mets: 5.0, + }, + 'VERY_FAST_!': { + range: [4.0, 4.5], + mets: 6.0, + }, + VERY_VERY_FAST: { + range: [4.5, 5], + mets: 7.0, + }, + SUPER_FAST: { + range: [5, 6], + mets: 8.3, + }, + RUNNING: { + range: [6, Number.MAX_VALUE], + mets: 9.8, + }, }, - "VERY_FAST_0": { - range: [3.5, 4.0], - mets: 5.0 + BICYCLING: { + VERY_VERY_SLOW: { + range: [0, 5.5], + mets: 3.5, + }, + VERY_SLOW: { + range: [5.5, 10], + mets: 5.8, + }, + SLOW: { + range: [10, 12], + mets: 6.8, + }, + MODERATE: { + range: [12, 14], + mets: 8.0, + }, + FAST: { + range: [14, 16], + mets: 10.0, + }, + VERT_FAST: { + range: [16, 19], + mets: 12.0, + }, + RACING: { + range: [20, Number.MAX_VALUE], + mets: 15.8, + }, }, - "VERY_FAST_!": { - range: [4.0, 4.5], - mets: 6.0 + UNKNOWN: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, }, - "VERY_VERY_FAST": { - range: [4.5, 5], - mets: 7.0 + IN_VEHICLE: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, }, - "SUPER_FAST": { - range: [5, 6], - mets: 8.3 + CAR: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, }, - "RUNNING": { - range: [6, Number.MAX_VALUE], - mets: 9.8 - } - }, - "BICYCLING": { - "VERY_VERY_SLOW": { - range: [0, 5.5], - mets: 3.5 + BUS: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, }, - "VERY_SLOW": { - range: [5.5, 10], - mets: 5.8 + LIGHT_RAIL: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, }, - "SLOW": { - range: [10, 12], - mets: 6.8 + TRAIN: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, }, - "MODERATE": { - range: [12, 14], - mets: 8.0 + TRAM: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, }, - "FAST": { - range: [14, 16], - mets: 10.0 + SUBWAY: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, }, - "VERT_FAST": { - range: [16, 19], - mets: 12.0 + AIR_OR_HSR: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, }, - "RACING": { - range: [20, Number.MAX_VALUE], - mets: 15.8 - } - }, - "UNKNOWN": { - "ALL": { - range: [0, Number.MAX_VALUE], - mets: 0 - } - }, - "IN_VEHICLE": { - "ALL": { - range: [0, Number.MAX_VALUE], - mets: 0 - } - }, - "CAR": { - "ALL": { - range: [0, Number.MAX_VALUE], - mets: 0 - } - }, - "BUS": { - "ALL": { - range: [0, Number.MAX_VALUE], - mets: 0 - } - }, - "LIGHT_RAIL": { - "ALL": { - range: [0, Number.MAX_VALUE], - mets: 0 - } - }, - "TRAIN": { - "ALL": { - range: [0, Number.MAX_VALUE], - mets: 0 - } - }, - "TRAM": { - "ALL": { - range: [0, Number.MAX_VALUE], - mets: 0 - } - }, - "SUBWAY": { - "ALL": { - range: [0, Number.MAX_VALUE], - mets: 0 - } - }, - "AIR_OR_HSR": { - "ALL": { - range: [0, Number.MAX_VALUE], - mets: 0 - } - } - } - this.getStandardMETs = function() { - return standardMETs; - } -}) -.factory('CustomDatasetHelper', function(METDatasetHelper, Logger, $ionicPlatform) { + }; + this.getStandardMETs = function () { + return standardMETs; + }; + }) + .factory('CustomDatasetHelper', function (METDatasetHelper, Logger, $ionicPlatform) { var cdh = {}; - cdh.getCustomMETs = function() { - console.log("Getting custom METs", cdh.customMETs); - return cdh.customMETs; + cdh.getCustomMETs = function () { + console.log('Getting custom METs', cdh.customMETs); + return cdh.customMETs; }; - cdh.getCustomFootprint = function() { - console.log("Getting custom footprint", cdh.customPerKmFootprint); - return cdh.customPerKmFootprint; + cdh.getCustomFootprint = function () { + console.log('Getting custom footprint', cdh.customPerKmFootprint); + return cdh.customPerKmFootprint; }; - cdh.populateCustomMETs = function() { - let standardMETs = METDatasetHelper.getStandardMETs(); - let modeOptions = cdh.inputParams["MODE"]; - let modeMETEntries = modeOptions.map((opt) => { - if (opt.met_equivalent) { - let currMET = standardMETs[opt.met_equivalent]; - return [opt.value, currMET]; - } else { - if (opt.met) { - let currMET = opt.met; - // if the user specifies a custom MET, they can't specify - // Number.MAX_VALUE since it is not valid JSON - // we assume that they specify -1 instead, and we will - // map -1 to Number.MAX_VALUE here by iterating over all the ranges - for (const rangeName in currMET) { - // console.log("Handling range ", rangeName); - currMET[rangeName].range = currMET[rangeName].range.map((i) => i == -1? Number.MAX_VALUE : i); - } - return [opt.value, currMET]; - } else { - console.warn("Did not find either met_equivalent or met for " - +opt.value+" ignoring entry"); - return undefined; - } + cdh.populateCustomMETs = function () { + let standardMETs = METDatasetHelper.getStandardMETs(); + let modeOptions = cdh.inputParams['MODE']; + let modeMETEntries = modeOptions.map((opt) => { + if (opt.met_equivalent) { + let currMET = standardMETs[opt.met_equivalent]; + return [opt.value, currMET]; + } else { + if (opt.met) { + let currMET = opt.met; + // if the user specifies a custom MET, they can't specify + // Number.MAX_VALUE since it is not valid JSON + // we assume that they specify -1 instead, and we will + // map -1 to Number.MAX_VALUE here by iterating over all the ranges + for (const rangeName in currMET) { + // console.log("Handling range ", rangeName); + currMET[rangeName].range = currMET[rangeName].range.map((i) => + i == -1 ? Number.MAX_VALUE : i, + ); } - }); - cdh.customMETs = Object.fromEntries(modeMETEntries.filter((e) => angular.isDefined(e))); - console.log("After populating, custom METs = ", cdh.customMETs); + return [opt.value, currMET]; + } else { + console.warn( + 'Did not find either met_equivalent or met for ' + opt.value + ' ignoring entry', + ); + return undefined; + } + } + }); + cdh.customMETs = Object.fromEntries(modeMETEntries.filter((e) => angular.isDefined(e))); + console.log('After populating, custom METs = ', cdh.customMETs); }; - cdh.populateCustomFootprints = function() { - let modeOptions = cdh.inputParams["MODE"]; - let modeCO2PerKm = modeOptions.map((opt) => { - if (opt.range_limit_km) { - if (cdh.range_limited_motorized) { - Logger.displayError("Found two range limited motorized options", { - first: cdh.range_limited_motorized, second: opt}); - } - cdh.range_limited_motorized = opt; - console.log("Found range limited motorized mode", cdh.range_limited_motorized); + cdh.populateCustomFootprints = function () { + let modeOptions = cdh.inputParams['MODE']; + let modeCO2PerKm = modeOptions + .map((opt) => { + if (opt.range_limit_km) { + if (cdh.range_limited_motorized) { + Logger.displayError('Found two range limited motorized options', { + first: cdh.range_limited_motorized, + second: opt, + }); } - if (angular.isDefined(opt.kgCo2PerKm)) { - return [opt.value, opt.kgCo2PerKm]; - } else { - return undefined; - } - }).filter((modeCO2) => angular.isDefined(modeCO2));; - cdh.customPerKmFootprint = Object.fromEntries(modeCO2PerKm); - console.log("After populating, custom perKmFootprint", cdh.customPerKmFootprint); - } + cdh.range_limited_motorized = opt; + console.log('Found range limited motorized mode', cdh.range_limited_motorized); + } + if (angular.isDefined(opt.kgCo2PerKm)) { + return [opt.value, opt.kgCo2PerKm]; + } else { + return undefined; + } + }) + .filter((modeCO2) => angular.isDefined(modeCO2)); + cdh.customPerKmFootprint = Object.fromEntries(modeCO2PerKm); + console.log('After populating, custom perKmFootprint', cdh.customPerKmFootprint); + }; - cdh.init = function(newConfig) { + cdh.init = function (newConfig) { try { getLabelOptions(newConfig).then((inputParams) => { - console.log("Input params = ", inputParams); + console.log('Input params = ', inputParams); cdh.inputParams = inputParams; cdh.populateCustomMETs(); cdh.populateCustomFootprints(); }); } catch (e) { setTimeout(() => { - Logger.displayError("Error in metrics-mappings while initializing custom dataset helper", e); + Logger.displayError( + 'Error in metrics-mappings while initializing custom dataset helper', + e, + ); }, 1000); } - } + }; - $ionicPlatform.ready().then(function() { + $ionicPlatform.ready().then(function () { getConfig().then((newConfig) => cdh.init(newConfig)); }); return cdh; -}); + }); diff --git a/www/js/metrics/ActiveMinutesTableCard.tsx b/www/js/metrics/ActiveMinutesTableCard.tsx index ea360ce8e..2ed26ccfc 100644 --- a/www/js/metrics/ActiveMinutesTableCard.tsx +++ b/www/js/metrics/ActiveMinutesTableCard.tsx @@ -2,24 +2,26 @@ import React, { useMemo, useState } from 'react'; import { Card, DataTable, useTheme } from 'react-native-paper'; import { MetricsData } from './metricsTypes'; import { cardStyles } from './MetricsTab'; -import { formatDate, formatDateRangeOfDays, secondsToMinutes, segmentDaysByWeeks } from './metricsHelper'; +import { + formatDate, + formatDateRangeOfDays, + secondsToMinutes, + segmentDaysByWeeks, +} from './metricsHelper'; import { useTranslation } from 'react-i18next'; import { ACTIVE_MODES } from './WeeklyActiveMinutesCard'; import { labelKeyToRichMode } from '../survey/multilabel/confirmHelper'; -type Props = { userMetrics: MetricsData } +type Props = { userMetrics: MetricsData }; const ActiveMinutesTableCard = ({ userMetrics }: Props) => { - const { colors } = useTheme(); const { t } = useTranslation(); const cumulativeTotals = useMemo(() => { if (!userMetrics?.duration) return []; const totals = {}; - ACTIVE_MODES.forEach(mode => { - const sum = userMetrics.duration.reduce((acc, day) => ( - acc + (day[`label_${mode}`] || 0) - ), 0); + ACTIVE_MODES.forEach((mode) => { + const sum = userMetrics.duration.reduce((acc, day) => acc + (day[`label_${mode}`] || 0), 0); totals[mode] = secondsToMinutes(sum); }); totals['period'] = formatDateRangeOfDays(userMetrics.duration); @@ -28,30 +30,32 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { const recentWeeksActiveModesTotals = useMemo(() => { if (!userMetrics?.duration) return []; - return segmentDaysByWeeks(userMetrics.duration).reverse().map(week => { - const totals = {}; - ACTIVE_MODES.forEach(mode => { - const sum = week.reduce((acc, day) => ( - acc + (day[`label_${mode}`] || 0) - ), 0); - totals[mode] = secondsToMinutes(sum); - }) - totals['period'] = formatDateRangeOfDays(week); - return totals; - }); + return segmentDaysByWeeks(userMetrics.duration) + .reverse() + .map((week) => { + const totals = {}; + ACTIVE_MODES.forEach((mode) => { + const sum = week.reduce((acc, day) => acc + (day[`label_${mode}`] || 0), 0); + totals[mode] = secondsToMinutes(sum); + }); + totals['period'] = formatDateRangeOfDays(week); + return totals; + }); }, [userMetrics?.duration]); const dailyActiveModesTotals = useMemo(() => { if (!userMetrics?.duration) return []; - return userMetrics.duration.map(day => { - const totals = {}; - ACTIVE_MODES.forEach(mode => { - const sum = day[`label_${mode}`] || 0; - totals[mode] = secondsToMinutes(sum); + return userMetrics.duration + .map((day) => { + const totals = {}; + ACTIVE_MODES.forEach((mode) => { + const sum = day[`label_${mode}`] || 0; + totals[mode] = secondsToMinutes(sum); + }); + totals['period'] = formatDate(day); + return totals; }) - totals['period'] = formatDate(day); - return totals; - }).reverse(); + .reverse(); }, [userMetrics?.duration]); const allTotals = [cumulativeTotals, ...recentWeeksActiveModesTotals, ...dailyActiveModesTotals]; @@ -62,38 +66,46 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { const to = Math.min((page + 1) * itemsPerPage, allTotals.length); return ( - + + style={cardStyles.title(colors)} + /> - {ACTIVE_MODES.map((mode, i) => - {labelKeyToRichMode(mode)} - )} + {ACTIVE_MODES.map((mode, i) => ( + + {labelKeyToRichMode(mode)} + + ))} - {allTotals.slice(from, to).map((total, i) => - + {allTotals.slice(from, to).map((total, i) => ( + {total['period']} - {ACTIVE_MODES.map((mode, j) => - {total[mode]} {t('metrics.minutes')} - )} + {ACTIVE_MODES.map((mode, j) => ( + + {total[mode]} {t('metrics.minutes')} + + ))} - )} - setPage(p)} - numberOfPages={Math.ceil(allTotals.length / 5)} numberOfItemsPerPage={5} - label={`${page * 5 + 1}-${page * 5 + 5} of ${allTotals.length}`} /> + ))} + setPage(p)} + numberOfPages={Math.ceil(allTotals.length / 5)} + numberOfItemsPerPage={5} + label={`${page * 5 + 1}-${page * 5 + 5} of ${allTotals.length}`} + /> - ) -} + ); +}; export default ActiveMinutesTableCard; diff --git a/www/js/metrics/CarbonFootprintCard.tsx b/www/js/metrics/CarbonFootprintCard.tsx index 6012cb61a..7c9bf3891 100644 --- a/www/js/metrics/CarbonFootprintCard.tsx +++ b/www/js/metrics/CarbonFootprintCard.tsx @@ -1,168 +1,240 @@ import React, { useState, useMemo } from 'react'; import { View } from 'react-native'; -import { Card, Text, useTheme} from 'react-native-paper'; +import { Card, Text, useTheme } from 'react-native-paper'; import { MetricsData } from './metricsTypes'; import { cardStyles } from './MetricsTab'; -import { formatDateRangeOfDays, parseDataFromMetrics, generateSummaryFromData, calculatePercentChange, segmentDaysByWeeks, isCustomLabels } from './metricsHelper'; +import { + formatDateRangeOfDays, + parseDataFromMetrics, + generateSummaryFromData, + calculatePercentChange, + segmentDaysByWeeks, + isCustomLabels, +} from './metricsHelper'; import { useTranslation } from 'react-i18next'; import BarChart from '../components/BarChart'; import { getAngularService } from '../angular-react-helper'; import ChangeIndicator from './ChangeIndicator'; -import color from "color"; +import color from 'color'; -type Props = { userMetrics: MetricsData, aggMetrics: MetricsData } +type Props = { userMetrics: MetricsData; aggMetrics: MetricsData }; const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { - const FootprintHelper = getAngularService("FootprintHelper"); - const { colors } = useTheme(); - const { t } = useTranslation(); - - const [emissionsChange, setEmissionsChange] = useState({}); - - const userCarbonRecords = useMemo(() => { - if(userMetrics?.distance?.length > 0) { - //separate data into weeks - const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks(userMetrics?.distance, 2); - - //formatted data from last week, if exists (14 days ago -> 8 days ago) - let userLastWeekModeMap = {}; - let userLastWeekSummaryMap = {}; - if(lastWeekDistance && lastWeekDistance?.length == 7) { - userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user'); - userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance'); - } - - //formatted distance data from this week (7 days ago -> yesterday) - let userThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'user'); - let userThisWeekSummaryMap = generateSummaryFromData(userThisWeekModeMap, 'distance'); - let worstDistance = userThisWeekSummaryMap.reduce((prevDistance, currModeSummary) => prevDistance + currModeSummary.values, 0); - - //setting up data to be displayed - let graphRecords = []; - - //set custon dataset, if the labels are custom - if(isCustomLabels(userThisWeekModeMap)){ - FootprintHelper.setUseCustomFootprint(); - } - - //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago) - let userPrevWeek; - if(userLastWeekSummaryMap[0]) { - userPrevWeek = { - low: FootprintHelper.getFootprintForMetrics(userLastWeekSummaryMap, 0), - high: FootprintHelper.getFootprintForMetrics(userLastWeekSummaryMap, FootprintHelper.getHighestFootprint()) - }; - graphRecords.push({label: t('main-metrics.unlabeled'), x: userPrevWeek.high - userPrevWeek.low, y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`}) - graphRecords.push({label: t('main-metrics.labeled'), x: userPrevWeek.low, y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`}); - } - - //calculate low-high and format range for past week (7 days ago -> yesterday) - let userPastWeek = { - low: FootprintHelper.getFootprintForMetrics(userThisWeekSummaryMap, 0), - high: FootprintHelper.getFootprintForMetrics(userThisWeekSummaryMap, FootprintHelper.getHighestFootprint()), - }; - graphRecords.push({label: t('main-metrics.unlabeled'), x: userPastWeek.high - userPastWeek.low, y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`}) - graphRecords.push({label: t('main-metrics.labeled'), x: userPastWeek.low, y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`}); - if (userPrevWeek) { - let pctChange = calculatePercentChange(userPastWeek, userPrevWeek); - setEmissionsChange(pctChange); - } - - //calculate worst-case carbon footprint - let worstCarbon = FootprintHelper.getHighestFootprintForDistance(worstDistance); - graphRecords.push({label: t('main-metrics.labeled'), x: worstCarbon, y: `${t('main-metrics.worst-case')}`}); - - return graphRecords; + const FootprintHelper = getAngularService('FootprintHelper'); + const { colors } = useTheme(); + const { t } = useTranslation(); + + const [emissionsChange, setEmissionsChange] = useState({}); + + const userCarbonRecords = useMemo(() => { + if (userMetrics?.distance?.length > 0) { + //separate data into weeks + const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks(userMetrics?.distance, 2); + + //formatted data from last week, if exists (14 days ago -> 8 days ago) + let userLastWeekModeMap = {}; + let userLastWeekSummaryMap = {}; + if (lastWeekDistance && lastWeekDistance?.length == 7) { + userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user'); + userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance'); + } + + //formatted distance data from this week (7 days ago -> yesterday) + let userThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'user'); + let userThisWeekSummaryMap = generateSummaryFromData(userThisWeekModeMap, 'distance'); + let worstDistance = userThisWeekSummaryMap.reduce( + (prevDistance, currModeSummary) => prevDistance + currModeSummary.values, + 0, + ); + + //setting up data to be displayed + let graphRecords = []; + + //set custon dataset, if the labels are custom + if (isCustomLabels(userThisWeekModeMap)) { + FootprintHelper.setUseCustomFootprint(); + } + + //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago) + let userPrevWeek; + if (userLastWeekSummaryMap[0]) { + userPrevWeek = { + low: FootprintHelper.getFootprintForMetrics(userLastWeekSummaryMap, 0), + high: FootprintHelper.getFootprintForMetrics( + userLastWeekSummaryMap, + FootprintHelper.getHighestFootprint(), + ), + }; + graphRecords.push({ + label: t('main-metrics.unlabeled'), + x: userPrevWeek.high - userPrevWeek.low, + y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`, + }); + graphRecords.push({ + label: t('main-metrics.labeled'), + x: userPrevWeek.low, + y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`, + }); + } + + //calculate low-high and format range for past week (7 days ago -> yesterday) + let userPastWeek = { + low: FootprintHelper.getFootprintForMetrics(userThisWeekSummaryMap, 0), + high: FootprintHelper.getFootprintForMetrics( + userThisWeekSummaryMap, + FootprintHelper.getHighestFootprint(), + ), + }; + graphRecords.push({ + label: t('main-metrics.unlabeled'), + x: userPastWeek.high - userPastWeek.low, + y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, + }); + graphRecords.push({ + label: t('main-metrics.labeled'), + x: userPastWeek.low, + y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, + }); + if (userPrevWeek) { + let pctChange = calculatePercentChange(userPastWeek, userPrevWeek); + setEmissionsChange(pctChange); + } + + //calculate worst-case carbon footprint + let worstCarbon = FootprintHelper.getHighestFootprintForDistance(worstDistance); + graphRecords.push({ + label: t('main-metrics.labeled'), + x: worstCarbon, + y: `${t('main-metrics.worst-case')}`, + }); + + return graphRecords; + } + }, [userMetrics?.distance]); + + const groupCarbonRecords = useMemo(() => { + if (aggMetrics?.distance?.length > 0) { + //separate data into weeks + const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, 1)[0]; + console.log('testing agg metrics', aggMetrics, thisWeekDistance); + + let aggThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'aggregate'); + let aggThisWeekSummary = generateSummaryFromData(aggThisWeekModeMap, 'distance'); + + // Issue 422: + // https://github.com/e-mission/e-mission-docs/issues/422 + let aggCarbonData = []; + for (var i in aggThisWeekSummary) { + aggCarbonData.push(aggThisWeekSummary[i]); + if (isNaN(aggCarbonData[i].values)) { + console.warn( + 'WARNING in calculating groupCarbonRecords: value is NaN for mode ' + + aggCarbonData[i].key + + ', changing to 0', + ); + aggCarbonData[i].values = 0; } - }, [userMetrics?.distance]) - - const groupCarbonRecords = useMemo(() => { - if(aggMetrics?.distance?.length > 0) - { - //separate data into weeks - const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, 1)[0]; - console.log("testing agg metrics" , aggMetrics, thisWeekDistance); - - let aggThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, "aggregate"); - let aggThisWeekSummary = generateSummaryFromData(aggThisWeekModeMap, "distance"); - - // Issue 422: - // https://github.com/e-mission/e-mission-docs/issues/422 - let aggCarbonData = []; - for (var i in aggThisWeekSummary) { - aggCarbonData.push(aggThisWeekSummary[i]); - if (isNaN(aggCarbonData[i].values)) { - console.warn("WARNING in calculating groupCarbonRecords: value is NaN for mode " + aggCarbonData[i].key + ", changing to 0"); - aggCarbonData[i].values = 0; - } - } - - let groupRecords = []; - - let aggCarbon = { - low: FootprintHelper.getFootprintForMetrics(aggCarbonData, 0), - high: FootprintHelper.getFootprintForMetrics(aggCarbonData, FootprintHelper.getHighestFootprint()), - } - console.log("testing group past week", aggCarbon); - groupRecords.push({label: t('main-metrics.unlabeled'), x: aggCarbon.high - aggCarbon.low, y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`}); - groupRecords.push({label: t('main-metrics.labeled'), x: aggCarbon.low, y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`}); - - return groupRecords; - } - }, [aggMetrics]) - - const chartData = useMemo(() => { - let tempChartData = []; - if(userCarbonRecords?.length) { - tempChartData = tempChartData.concat(userCarbonRecords); - } - if(groupCarbonRecords?.length) { - tempChartData = tempChartData.concat(groupCarbonRecords); - } - tempChartData = tempChartData.reverse(); - console.log("testing chart data", tempChartData); - return tempChartData; - }, [userCarbonRecords, groupCarbonRecords]); - - const cardSubtitleText = useMemo(() => { - const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, 2).reverse().flat(); - const recentEntriesRange = formatDateRangeOfDays(recentEntries); - return `${t('main-metrics.estimated-emissions')}, (${recentEntriesRange})`; - }, [aggMetrics?.distance]); - - //hardcoded here, could be read from config at later customization? - let carbonGoals = [ {label: t('main-metrics.us-2050-goal'), value: 14, color: color(colors.warn).darken(.65).saturate(.5).rgb().toString()}, - {label: t('main-metrics.us-2030-goal'), value: 54, color: color(colors.danger).saturate(.5).rgb().toString()} ]; - let meter = { dash_key: t('main-metrics.unlabeled'), high: 54, middle: 14 }; - - return ( - - } - style={cardStyles.title(colors)} /> - - { chartData?.length > 0 ? - - - - {t('main-metrics.us-goals-footnote')} - - - : - - - {t('metrics.chart-no-data')} - - } - - - ) -} + } + + let groupRecords = []; + + let aggCarbon = { + low: FootprintHelper.getFootprintForMetrics(aggCarbonData, 0), + high: FootprintHelper.getFootprintForMetrics( + aggCarbonData, + FootprintHelper.getHighestFootprint(), + ), + }; + console.log('testing group past week', aggCarbon); + groupRecords.push({ + label: t('main-metrics.unlabeled'), + x: aggCarbon.high - aggCarbon.low, + y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, + }); + groupRecords.push({ + label: t('main-metrics.labeled'), + x: aggCarbon.low, + y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, + }); + + return groupRecords; + } + }, [aggMetrics]); + + const chartData = useMemo(() => { + let tempChartData = []; + if (userCarbonRecords?.length) { + tempChartData = tempChartData.concat(userCarbonRecords); + } + if (groupCarbonRecords?.length) { + tempChartData = tempChartData.concat(groupCarbonRecords); + } + tempChartData = tempChartData.reverse(); + console.log('testing chart data', tempChartData); + return tempChartData; + }, [userCarbonRecords, groupCarbonRecords]); + + const cardSubtitleText = useMemo(() => { + const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, 2) + .reverse() + .flat(); + const recentEntriesRange = formatDateRangeOfDays(recentEntries); + return `${t('main-metrics.estimated-emissions')}, (${recentEntriesRange})`; + }, [aggMetrics?.distance]); + + //hardcoded here, could be read from config at later customization? + let carbonGoals = [ + { + label: t('main-metrics.us-2050-goal'), + value: 14, + color: color(colors.warn).darken(0.65).saturate(0.5).rgb().toString(), + }, + { + label: t('main-metrics.us-2030-goal'), + value: 54, + color: color(colors.danger).saturate(0.5).rgb().toString(), + }, + ]; + let meter = { dash_key: t('main-metrics.unlabeled'), high: 54, middle: 14 }; + + return ( + + } + style={cardStyles.title(colors)} + /> + + {chartData?.length > 0 ? ( + + + + {t('main-metrics.us-goals-footnote')} + + + ) : ( + + + {t('metrics.chart-no-data')} + + + )} + + + ); +}; export default CarbonFootprintCard; diff --git a/www/js/metrics/CarbonTextCard.tsx b/www/js/metrics/CarbonTextCard.tsx index 223ae709f..9f1b4490f 100644 --- a/www/js/metrics/CarbonTextCard.tsx +++ b/www/js/metrics/CarbonTextCard.tsx @@ -1,151 +1,189 @@ import React, { useMemo } from 'react'; import { View } from 'react-native'; -import { Card, Text, useTheme} from 'react-native-paper'; +import { Card, Text, useTheme } from 'react-native-paper'; import { MetricsData } from './metricsTypes'; import { cardStyles } from './MetricsTab'; import { useTranslation } from 'react-i18next'; -import { formatDateRangeOfDays, parseDataFromMetrics, generateSummaryFromData, calculatePercentChange, segmentDaysByWeeks } from './metricsHelper'; +import { + formatDateRangeOfDays, + parseDataFromMetrics, + generateSummaryFromData, + calculatePercentChange, + segmentDaysByWeeks, +} from './metricsHelper'; import { getAngularService } from '../angular-react-helper'; -type Props = { userMetrics: MetricsData, aggMetrics: MetricsData } +type Props = { userMetrics: MetricsData; aggMetrics: MetricsData }; const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { - const { colors } = useTheme(); const { t } = useTranslation(); - const FootprintHelper = getAngularService("FootprintHelper"); + const FootprintHelper = getAngularService('FootprintHelper'); const userText = useMemo(() => { - if(userMetrics?.distance?.length > 0) { - //separate data into weeks - const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks(userMetrics?.distance, 2); - - //formatted data from last week, if exists (14 days ago -> 8 days ago) - let userLastWeekModeMap = {}; - let userLastWeekSummaryMap = {}; - if(lastWeekDistance && lastWeekDistance?.length == 7) { - userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user'); - userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance'); - } - - //formatted distance data from this week (7 days ago -> yesterday) - let userThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'user'); - let userThisWeekSummaryMap = generateSummaryFromData(userThisWeekModeMap, 'distance'); - let worstDistance = userThisWeekSummaryMap.reduce((prevDistance, currModeSummary) => prevDistance + currModeSummary.values, 0); - - //setting up data to be displayed - let textList = []; - - //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago) - if(userLastWeekSummaryMap[0]) { - let userPrevWeek = { - low: FootprintHelper.getFootprintForMetrics(userLastWeekSummaryMap, 0), - high: FootprintHelper.getFootprintForMetrics(userLastWeekSummaryMap, FootprintHelper.getHighestFootprint()) - }; - const label = `${t('main-metrics.prev-week')} (${formatDateRangeOfDays(lastWeekDistance)})`; - if (userPrevWeek.low == userPrevWeek.high) - textList.push({label: label, value: Math.round(userPrevWeek.low)}); - else - textList.push({label: label + '²', value: `${Math.round(userPrevWeek.low)} - ${Math.round(userPrevWeek.high)}`}); - } + if (userMetrics?.distance?.length > 0) { + //separate data into weeks + const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks(userMetrics?.distance, 2); + + //formatted data from last week, if exists (14 days ago -> 8 days ago) + let userLastWeekModeMap = {}; + let userLastWeekSummaryMap = {}; + if (lastWeekDistance && lastWeekDistance?.length == 7) { + userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user'); + userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance'); + } - //calculate low-high and format range for past week (7 days ago -> yesterday) - let userPastWeek = { - low: FootprintHelper.getFootprintForMetrics(userThisWeekSummaryMap, 0), - high: FootprintHelper.getFootprintForMetrics(userThisWeekSummaryMap, FootprintHelper.getHighestFootprint()), + //formatted distance data from this week (7 days ago -> yesterday) + let userThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'user'); + let userThisWeekSummaryMap = generateSummaryFromData(userThisWeekModeMap, 'distance'); + let worstDistance = userThisWeekSummaryMap.reduce( + (prevDistance, currModeSummary) => prevDistance + currModeSummary.values, + 0, + ); + + //setting up data to be displayed + let textList = []; + + //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago) + if (userLastWeekSummaryMap[0]) { + let userPrevWeek = { + low: FootprintHelper.getFootprintForMetrics(userLastWeekSummaryMap, 0), + high: FootprintHelper.getFootprintForMetrics( + userLastWeekSummaryMap, + FootprintHelper.getHighestFootprint(), + ), }; - const label = `${t('main-metrics.past-week')} (${formatDateRangeOfDays(thisWeekDistance)})`; - if (userPastWeek.low == userPastWeek.high) - textList.push({label: label, value: Math.round(userPastWeek.low)}); + const label = `${t('main-metrics.prev-week')} (${formatDateRangeOfDays(lastWeekDistance)})`; + if (userPrevWeek.low == userPrevWeek.high) + textList.push({ label: label, value: Math.round(userPrevWeek.low) }); else - textList.push({label: label + '²', value: `${Math.round(userPastWeek.low)} - ${Math.round(userPastWeek.high)}`}); - - //calculate worst-case carbon footprint - let worstCarbon = FootprintHelper.getHighestFootprintForDistance(worstDistance); - textList.push({label:t('main-metrics.worst-case'), value: Math.round(worstCarbon)}); + textList.push({ + label: label + '²', + value: `${Math.round(userPrevWeek.low)} - ${Math.round(userPrevWeek.high)}`, + }); + } + + //calculate low-high and format range for past week (7 days ago -> yesterday) + let userPastWeek = { + low: FootprintHelper.getFootprintForMetrics(userThisWeekSummaryMap, 0), + high: FootprintHelper.getFootprintForMetrics( + userThisWeekSummaryMap, + FootprintHelper.getHighestFootprint(), + ), + }; + const label = `${t('main-metrics.past-week')} (${formatDateRangeOfDays(thisWeekDistance)})`; + if (userPastWeek.low == userPastWeek.high) + textList.push({ label: label, value: Math.round(userPastWeek.low) }); + else + textList.push({ + label: label + '²', + value: `${Math.round(userPastWeek.low)} - ${Math.round(userPastWeek.high)}`, + }); - return textList; + //calculate worst-case carbon footprint + let worstCarbon = FootprintHelper.getHighestFootprintForDistance(worstDistance); + textList.push({ label: t('main-metrics.worst-case'), value: Math.round(worstCarbon) }); + + return textList; } }, [userMetrics]); const groupText = useMemo(() => { - if(aggMetrics?.distance?.length > 0) - { - //separate data into weeks - const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, 1)[0]; - - let aggThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, "aggregate"); - let aggThisWeekSummary = generateSummaryFromData(aggThisWeekModeMap, "distance"); - - // Issue 422: - // https://github.com/e-mission/e-mission-docs/issues/422 - let aggCarbonData = []; - for (var i in aggThisWeekSummary) { - aggCarbonData.push(aggThisWeekSummary[i]); - if (isNaN(aggCarbonData[i].values)) { - console.warn("WARNING in calculating groupCarbonRecords: value is NaN for mode " + aggCarbonData[i].key + ", changing to 0"); - aggCarbonData[i].values = 0; - } - } + if (aggMetrics?.distance?.length > 0) { + //separate data into weeks + const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, 1)[0]; - let groupText = []; + let aggThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'aggregate'); + let aggThisWeekSummary = generateSummaryFromData(aggThisWeekModeMap, 'distance'); - let aggCarbon = { - low: FootprintHelper.getFootprintForMetrics(aggCarbonData, 0), - high: FootprintHelper.getFootprintForMetrics(aggCarbonData, FootprintHelper.getHighestFootprint()), + // Issue 422: + // https://github.com/e-mission/e-mission-docs/issues/422 + let aggCarbonData = []; + for (var i in aggThisWeekSummary) { + aggCarbonData.push(aggThisWeekSummary[i]); + if (isNaN(aggCarbonData[i].values)) { + console.warn( + 'WARNING in calculating groupCarbonRecords: value is NaN for mode ' + + aggCarbonData[i].key + + ', changing to 0', + ); + aggCarbonData[i].values = 0; } - console.log("testing group past week", aggCarbon); - const label = t('main-metrics.average'); - if (aggCarbon.low == aggCarbon.high) - groupText.push({label: label, value: Math.round(aggCarbon.low)}); - else - groupText.push({label: label + '²', value: `${Math.round(aggCarbon.low)} - ${Math.round(aggCarbon.high)}`}); + } + + let groupText = []; - return groupText; + let aggCarbon = { + low: FootprintHelper.getFootprintForMetrics(aggCarbonData, 0), + high: FootprintHelper.getFootprintForMetrics( + aggCarbonData, + FootprintHelper.getHighestFootprint(), + ), + }; + console.log('testing group past week', aggCarbon); + const label = t('main-metrics.average'); + if (aggCarbon.low == aggCarbon.high) + groupText.push({ label: label, value: Math.round(aggCarbon.low) }); + else + groupText.push({ + label: label + '²', + value: `${Math.round(aggCarbon.low)} - ${Math.round(aggCarbon.high)}`, + }); + + return groupText; } }, [aggMetrics]); const textEntries = useMemo(() => { - let tempText = [] - if(userText?.length){ - tempText = tempText.concat(userText); + let tempText = []; + if (userText?.length) { + tempText = tempText.concat(userText); } - if(groupText?.length) { - tempText = tempText.concat(groupText); + if (groupText?.length) { + tempText = tempText.concat(groupText); } return tempText; }, [userText, groupText]); - + const cardSubtitleText = useMemo(() => { - const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, 2).reverse().flat(); + const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, 2) + .reverse() + .flat(); const recentEntriesRange = formatDateRangeOfDays(recentEntries); return `${t('main-metrics.estimated-emissions')}, (${recentEntriesRange})`; }, [aggMetrics?.distance]); return ( - - + - - { textEntries?.length > 0 && - Object.keys(textEntries).map((i) => - - {textEntries[i].label} - {textEntries[i].value + ' ' + "kg CO₂"} + style={cardStyles.title(colors)} + /> + + {textEntries?.length > 0 && + Object.keys(textEntries).map((i) => ( + + {textEntries[i].label} + {textEntries[i].value + ' ' + 'kg CO₂'} - ) - } - - {t('main-metrics.range-uncertain-footnote')} + ))} + + {t('main-metrics.range-uncertain-footnote')} - + - ) -} + ); +}; export default CarbonTextCard; diff --git a/www/js/metrics/ChangeIndicator.tsx b/www/js/metrics/ChangeIndicator.tsx index eafd3460e..a2373faf3 100644 --- a/www/js/metrics/ChangeIndicator.tsx +++ b/www/js/metrics/ChangeIndicator.tsx @@ -1,79 +1,72 @@ -import React, {useMemo} from 'react'; +import React, { useMemo } from 'react'; import { View } from 'react-native'; -import { useTheme, Text } from "react-native-paper"; +import { useTheme, Text } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; -import colorLib from "color"; +import colorLib from 'color'; type Props = { - change: {low: number, high: number}, -} + change: { low: number; high: number }; +}; const ChangeIndicator = ({ change }) => { - const { colors } = useTheme(); - const { t } = useTranslation(); + const { colors } = useTheme(); + const { t } = useTranslation(); - const changeSign = function(changeNum) { - if(changeNum > 0) { - return "+"; - } else { - return "-"; - } - }; + const changeSign = function (changeNum) { + if (changeNum > 0) { + return '+'; + } else { + return '-'; + } + }; - const changeText = useMemo(() => { - if(change) { - let low = isFinite(change.low) ? Math.round(Math.abs(change.low)): '∞'; - let high = isFinite(change.high) ? Math.round(Math.abs(change.high)) : '∞'; - - if(Math.round(change.low) == Math.round(change.high)) - { - let text = changeSign(change.low) + low + "%"; - return text; - } else if(!(isFinite(change.low) || isFinite(change.high))) { - return ""; //if both are not finite, no information is really conveyed, so don't show - } - else { - let text = `${changeSign(change.low) + low}% / ${changeSign(change.high) + high}%`; - return text; - } - } - },[change]) - - return ( - (changeText != "") ? - 0 ? colors.danger : colors.success)}> - - {changeText + '\n'} - - - {`${t("metrics.this-week")}`} - - - : - <> - ) -} + const changeText = useMemo(() => { + if (change) { + let low = isFinite(change.low) ? Math.round(Math.abs(change.low)) : '∞'; + let high = isFinite(change.high) ? Math.round(Math.abs(change.high)) : '∞'; + + if (Math.round(change.low) == Math.round(change.high)) { + let text = changeSign(change.low) + low + '%'; + return text; + } else if (!(isFinite(change.low) || isFinite(change.high))) { + return ''; //if both are not finite, no information is really conveyed, so don't show + } else { + let text = `${changeSign(change.low) + low}% / ${changeSign(change.high) + high}%`; + return text; + } + } + }, [change]); + + return changeText != '' ? ( + 0 ? colors.danger : colors.success)}> + {changeText + '\n'} + {`${t('metrics.this-week')}`} + + ) : ( + <> + ); +}; const styles: any = { - text: (colors) => ({ - color: colors.onPrimary, - fontWeight: '400', - textAlign: 'center' - }), - importantText: (colors) => ({ - color: colors.onPrimary, - fontWeight: '500', - textAlign: 'center', - fontSize: 16, - }), - view: (color) => ({ - backgroundColor: colorLib(color).alpha(0.85).rgb().toString(), - padding: 2, - borderStyle: 'solid', - borderColor: colorLib(color).darken(0.4).rgb().toString(), - borderWidth: 2.5, - borderRadius: 10, - }), -} - + text: (colors) => ({ + color: colors.onPrimary, + fontWeight: '400', + textAlign: 'center', + }), + importantText: (colors) => ({ + color: colors.onPrimary, + fontWeight: '500', + textAlign: 'center', + fontSize: 16, + }), + view: (color) => ({ + backgroundColor: colorLib(color).alpha(0.85).rgb().toString(), + padding: 2, + borderStyle: 'solid', + borderColor: colorLib(color).darken(0.4).rgb().toString(), + borderWidth: 2.5, + borderRadius: 10, + }), +}; + export default ChangeIndicator; diff --git a/www/js/metrics/DailyActiveMinutesCard.tsx b/www/js/metrics/DailyActiveMinutesCard.tsx index 479a5f5b5..acaf9c1ed 100644 --- a/www/js/metrics/DailyActiveMinutesCard.tsx +++ b/www/js/metrics/DailyActiveMinutesCard.tsx @@ -1,7 +1,6 @@ - import React, { useMemo } from 'react'; import { View } from 'react-native'; -import { Card, Text, useTheme} from 'react-native-paper'; +import { Card, Text, useTheme } from 'react-native-paper'; import { MetricsData } from './metricsTypes'; import { cardStyles } from './MetricsTab'; import { useTranslation } from 'react-i18next'; @@ -10,19 +9,18 @@ import LineChart from '../components/LineChart'; import { getBaseModeByText } from '../diary/diaryHelper'; const ACTIVE_MODES = ['walk', 'bike'] as const; -type ActiveMode = typeof ACTIVE_MODES[number]; +type ActiveMode = (typeof ACTIVE_MODES)[number]; -type Props = { userMetrics: MetricsData } +type Props = { userMetrics: MetricsData }; const DailyActiveMinutesCard = ({ userMetrics }: Props) => { - const { colors } = useTheme(); const { t } = useTranslation(); const dailyActiveMinutesRecords = useMemo(() => { const records = []; const recentDays = userMetrics?.duration?.slice(-14); - recentDays?.forEach(day => { - ACTIVE_MODES.forEach(mode => { + recentDays?.forEach((day) => { + ACTIVE_MODES.forEach((mode) => { const activeSeconds = day[`label_${mode}`]; records.push({ label: labelKeyToRichMode(mode), @@ -31,34 +29,38 @@ const DailyActiveMinutesCard = ({ userMetrics }: Props) => { }); }); }); - return records as {label: ActiveMode, x: string, y: number}[]; + return records as { label: ActiveMode; x: string; y: number }[]; }, [userMetrics?.duration]); return ( - - + + style={cardStyles.title(colors)} + /> - { dailyActiveMinutesRecords.length ? - getBaseModeByText(l, labelOptions).color} /> - : - - + {dailyActiveMinutesRecords.length ? ( + getBaseModeByText(l, labelOptions).color} + /> + ) : ( + + {t('metrics.chart-no-data')} - } + )} ); -} +}; export default DailyActiveMinutesCard; diff --git a/www/js/metrics/MetricsCard.tsx b/www/js/metrics/MetricsCard.tsx index 7a0f8c8bc..1727d6e49 100644 --- a/www/js/metrics/MetricsCard.tsx +++ b/www/js/metrics/MetricsCard.tsx @@ -1,8 +1,7 @@ - import React, { useMemo, useState } from 'react'; import { View } from 'react-native'; import { Card, Checkbox, Text, useTheme } from 'react-native-paper'; -import colorLib from "color"; +import colorLib from 'color'; import BarChart from '../components/BarChart'; import { DayOfMetricData } from './metricsTypes'; import { formatDateRangeOfDays, getLabelsForDay, getUniqueLabelsForDays } from './metricsHelper'; @@ -13,30 +12,36 @@ import { getBaseModeByKey, getBaseModeByText } from '../diary/diaryHelper'; import { useTranslation } from 'react-i18next'; type Props = { - cardTitle: string, - userMetricsDays: DayOfMetricData[], - aggMetricsDays: DayOfMetricData[], - axisUnits: string, - unitFormatFn?: (val: number) => string|number, -} -const MetricsCard = ({cardTitle, userMetricsDays, aggMetricsDays, axisUnits, unitFormatFn}: Props) => { - - const { colors } = useTheme(); + cardTitle: string; + userMetricsDays: DayOfMetricData[]; + aggMetricsDays: DayOfMetricData[]; + axisUnits: string; + unitFormatFn?: (val: number) => string | number; +}; +const MetricsCard = ({ + cardTitle, + userMetricsDays, + aggMetricsDays, + axisUnits, + unitFormatFn, +}: Props) => { + const { colors } = useTheme(); const { t } = useTranslation(); - const [viewMode, setViewMode] = useState<'details'|'graph'>('details'); - const [populationMode, setPopulationMode] = useState<'user'|'aggregate'>('user'); + const [viewMode, setViewMode] = useState<'details' | 'graph'>('details'); + const [populationMode, setPopulationMode] = useState<'user' | 'aggregate'>('user'); const [graphIsStacked, setGraphIsStacked] = useState(true); - const metricDataDays = useMemo(() => ( - populationMode == 'user' ? userMetricsDays : aggMetricsDays - ), [populationMode, userMetricsDays, aggMetricsDays]); + const metricDataDays = useMemo( + () => (populationMode == 'user' ? userMetricsDays : aggMetricsDays), + [populationMode, userMetricsDays, aggMetricsDays], + ); // for each label on each day, create a record for the chart const chartData = useMemo(() => { if (!metricDataDays || viewMode != 'graph') return []; - const records: {label: string, x: string|number, y: string|number}[] = []; - metricDataDays.forEach(day => { + const records: { label: string; x: string | number; y: string | number }[] = []; + metricDataDays.forEach((day) => { const labels = getLabelsForDay(day); - labels.forEach(label => { + labels.forEach((label) => { const rawVal = day[`label_${label}`]; records.push({ label: labelKeyToRichMode(label), @@ -47,7 +52,7 @@ const MetricsCard = ({cardTitle, userMetricsDays, aggMetricsDays, axisUnits, uni }); // sort records (affects the order they appear in the chart legend) records.sort((a, b) => { - if (a.label == 'Unlabeled') return 1; // sort Unlabeled to the end + if (a.label == 'Unlabeled') return 1; // sort Unlabeled to the end if (b.label == 'Unlabeled') return -1; // sort Unlabeled to the end return (a.y as number) - (b.y as number); // otherwise, just sort by time }); @@ -55,8 +60,8 @@ const MetricsCard = ({cardTitle, userMetricsDays, aggMetricsDays, axisUnits, uni }, [metricDataDays, viewMode]); const cardSubtitleText = useMemo(() => { - const groupText = populationMode == 'user' ? t('main-metrics.user-totals') - : t('main-metrics.group-totals'); + const groupText = + populationMode == 'user' ? t('main-metrics.user-totals') : t('main-metrics.group-totals'); return `${groupText} (${formatDateRangeOfDays(metricDataDays)})`; }, [metricDataDays, populationMode]); @@ -67,10 +72,8 @@ const MetricsCard = ({cardTitle, userMetricsDays, aggMetricsDays, axisUnits, uni // for each label, sum up cumulative values across all days const vals = {}; - uniqueLabels.forEach(label => { - const sum = metricDataDays.reduce((acc, day) => ( - acc + (day[`label_${label}`] || 0) - ), 0); + uniqueLabels.forEach((label) => { + const sum = metricDataDays.reduce((acc, day) => acc + (day[`label_${label}`] || 0), 0); vals[label] = unitFormatFn ? unitFormatFn(sum) : sum; }); return vals; @@ -79,55 +82,84 @@ const MetricsCard = ({cardTitle, userMetricsDays, aggMetricsDays, axisUnits, uni // Unlabelled data shows up as 'UNKNOWN' grey and mostly transparent // All other modes are colored according to their base mode const getColorForLabel = (label: string) => { - if (label == "Unlabeled") { + if (label == 'Unlabeled') { const unknownModeColor = getBaseModeByKey('UNKNOWN').color; return colorLib(unknownModeColor).alpha(0.15).rgb().string(); } return getBaseModeByText(label, labelOptions).color; - } + }; return ( - - - setViewMode(v as any)} - buttons={[{ icon: 'abacus', value: 'details' }, { icon: 'chart-bar', value: 'graph' }]} /> - setPopulationMode(p as any)} - buttons={[{ icon: 'account', value: 'user' }, { icon: 'account-group', value: 'aggregate' }]} /> + right={() => ( + + setViewMode(v as any)} + buttons={[ + { icon: 'abacus', value: 'details' }, + { icon: 'chart-bar', value: 'graph' }, + ]} + /> + setPopulationMode(p as any)} + buttons={[ + { icon: 'account', value: 'user' }, + { icon: 'account-group', value: 'aggregate' }, + ]} + /> - } - style={cardStyles.title(colors)} /> + )} + style={cardStyles.title(colors)} + /> - {viewMode=='details' && - - { Object.keys(metricSumValues).map((label, i) => + {viewMode == 'details' && ( + + {Object.keys(metricSumValues).map((label, i) => ( - {labelKeyToRichMode(label)} + {labelKeyToRichMode(label)} {metricSumValues[label] + ' ' + axisUnits} - )} - - } - {viewMode=='graph' && <> - - - Stack bars: - setGraphIsStacked(!graphIsStacked)} /> + ))} - } + )} + {viewMode == 'graph' && ( + <> + + + Stack bars: + setGraphIsStacked(!graphIsStacked)} + /> + + + )} - ) -} + ); +}; export default MetricsCard; diff --git a/www/js/metrics/MetricsDateSelect.tsx b/www/js/metrics/MetricsDateSelect.tsx index c66218453..fa1aaed3e 100644 --- a/www/js/metrics/MetricsDateSelect.tsx +++ b/www/js/metrics/MetricsDateSelect.tsx @@ -6,66 +6,78 @@ and allows the user to select a date. */ -import React, { useState, useCallback, useMemo } from "react"; -import { Text, StyleSheet } from "react-native"; -import { DatePickerModal } from "react-native-paper-dates"; -import { Divider, useTheme } from "react-native-paper"; -import i18next from "i18next"; -import { useTranslation } from "react-i18next"; -import NavBarButton from "../components/NavBarButton"; -import { DateTime } from "luxon"; +import React, { useState, useCallback, useMemo } from 'react'; +import { Text, StyleSheet } from 'react-native'; +import { DatePickerModal } from 'react-native-paper-dates'; +import { Divider, useTheme } from 'react-native-paper'; +import i18next from 'i18next'; +import { useTranslation } from 'react-i18next'; +import NavBarButton from '../components/NavBarButton'; +import { DateTime } from 'luxon'; type Props = { - dateRange: DateTime[], - setDateRange: (dateRange: [DateTime, DateTime]) => void, -} + dateRange: DateTime[]; + setDateRange: (dateRange: [DateTime, DateTime]) => void; +}; const MetricsDateSelect = ({ dateRange, setDateRange }: Props) => { - const { t } = useTranslation(); const { colors } = useTheme(); const [open, setOpen] = useState(false); const todayDate = useMemo(() => new Date(), []); - const dateRangeAsJSDate = useMemo(() => - [ dateRange[0].toJSDate(), dateRange[1].toJSDate() ], - [dateRange]); + const dateRangeAsJSDate = useMemo( + () => [dateRange[0].toJSDate(), dateRange[1].toJSDate()], + [dateRange], + ); const onDismiss = useCallback(() => { setOpen(false); }, [setOpen]); - const onChoose = useCallback(({ startDate, endDate }) => { - setOpen(false); - setDateRange([ - DateTime.fromJSDate(startDate).startOf('day'), - DateTime.fromJSDate(endDate).startOf('day') - ]); - }, [setOpen, setDateRange]); + const onChoose = useCallback( + ({ startDate, endDate }) => { + setOpen(false); + setDateRange([ + DateTime.fromJSDate(startDate).startOf('day'), + DateTime.fromJSDate(endDate).startOf('day'), + ]); + }, + [setOpen, setDateRange], + ); - return (<> - setOpen(true)}> - {dateRange[0] && (<> - {dateRange[0].toLocaleString()} - - )} - {dateRange[1]?.toLocaleString() || t('diary.today')} - - - ); + return ( + <> + setOpen(true)}> + {dateRange[0] && ( + <> + {dateRange[0].toLocaleString()} + + + )} + {dateRange[1]?.toLocaleString() || t('diary.today')} + + + + ); }; export const s = StyleSheet.create({ divider: { width: '3ch', marginHorizontal: 'auto', - } + }, }); export default MetricsDateSelect; diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 450155622..d23cdd454 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -1,36 +1,35 @@ -import React, { useEffect, useState, useMemo } from "react"; -import { getAngularService } from "../angular-react-helper"; -import { View, ScrollView, useWindowDimensions } from "react-native"; -import { Appbar } from "react-native-paper"; -import NavBarButton from "../components/NavBarButton"; -import { useTranslation } from "react-i18next"; -import { DateTime } from "luxon"; -import { MetricsData } from "./metricsTypes"; -import MetricsCard from "./MetricsCard"; -import { formatForDisplay, useImperialConfig } from "../config/useImperialConfig"; -import MetricsDateSelect from "./MetricsDateSelect"; -import WeeklyActiveMinutesCard from "./WeeklyActiveMinutesCard"; -import { secondsToHours, secondsToMinutes } from "./metricsHelper"; -import CarbonFootprintCard from "./CarbonFootprintCard"; -import Carousel from "../components/Carousel"; -import DailyActiveMinutesCard from "./DailyActiveMinutesCard"; -import CarbonTextCard from "./CarbonTextCard"; -import ActiveMinutesTableCard from "./ActiveMinutesTableCard"; -import { getAggregateData, getMetrics } from "../commHelper"; +import React, { useEffect, useState, useMemo } from 'react'; +import { getAngularService } from '../angular-react-helper'; +import { View, ScrollView, useWindowDimensions } from 'react-native'; +import { Appbar } from 'react-native-paper'; +import NavBarButton from '../components/NavBarButton'; +import { useTranslation } from 'react-i18next'; +import { DateTime } from 'luxon'; +import { MetricsData } from './metricsTypes'; +import MetricsCard from './MetricsCard'; +import { formatForDisplay, useImperialConfig } from '../config/useImperialConfig'; +import MetricsDateSelect from './MetricsDateSelect'; +import WeeklyActiveMinutesCard from './WeeklyActiveMinutesCard'; +import { secondsToHours, secondsToMinutes } from './metricsHelper'; +import CarbonFootprintCard from './CarbonFootprintCard'; +import Carousel from '../components/Carousel'; +import DailyActiveMinutesCard from './DailyActiveMinutesCard'; +import CarbonTextCard from './CarbonTextCard'; +import ActiveMinutesTableCard from './ActiveMinutesTableCard'; +import { getAggregateData, getMetrics } from '../commHelper'; export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; -async function fetchMetricsFromServer(type: 'user'|'aggregate', dateRange: DateTime[]) { +async function fetchMetricsFromServer(type: 'user' | 'aggregate', dateRange: DateTime[]) { const query = { freq: 'D', start_time: dateRange[0].toSeconds(), end_time: dateRange[1].toSeconds(), metric_list: METRIC_LIST, - is_return_aggregate: (type == 'aggregate'), - } - if (type == 'user') - return getMetrics('timestamp', query); - return getAggregateData("result/metrics/timestamp", query); + is_return_aggregate: type == 'aggregate', + }; + if (type == 'user') return getMetrics('timestamp', query); + return getAggregateData('result/metrics/timestamp', query); } function getLastTwoWeeksDtRange() { @@ -41,10 +40,9 @@ function getLastTwoWeeksDtRange() { } const MetricsTab = () => { - const { t } = useTranslation(); - const { getFormattedSpeed, speedSuffix, - getFormattedDistance, distanceSuffix } = useImperialConfig(); + const { getFormattedSpeed, speedSuffix, getFormattedDistance, distanceSuffix } = + useImperialConfig(); const [dateRange, setDateRange] = useState(getLastTwoWeeksDtRange); const [aggMetrics, setAggMetrics] = useState(null); @@ -55,11 +53,11 @@ const MetricsTab = () => { loadMetricsForPopulation('aggregate', dateRange); }, [dateRange]); - async function loadMetricsForPopulation(population: 'user'|'aggregate', dateRange: DateTime[]) { + async function loadMetricsForPopulation(population: 'user' | 'aggregate', dateRange: DateTime[]) { const serverResponse = await fetchMetricsFromServer(population, dateRange); - console.debug("Got metrics = ", serverResponse); + console.debug('Got metrics = ', serverResponse); const metrics = {}; - const dataKey = (population == 'user') ? 'user_metrics' : 'aggregate_metrics'; + const dataKey = population == 'user' ? 'user_metrics' : 'aggregate_metrics'; METRIC_LIST.forEach((metricName, i) => { metrics[metricName] = serverResponse[dataKey][i]; }); @@ -75,49 +73,60 @@ const MetricsTab = () => { } const { width: windowWidth } = useWindowDimensions(); - const cardWidth = windowWidth * .88; + const cardWidth = windowWidth * 0.88; - return (<> - - - - - - - - - - - - - - - - - - - - {/* + + + + + + + + + + + + + + + + + + + + {/* */} - - - ); -} + + + + ); +}; export const cardMargin = 10; @@ -134,7 +143,7 @@ export const cardStyles: any = { titleText: (colors) => ({ color: colors.onPrimary, fontWeight: '500', - textAlign: 'center' + textAlign: 'center', }), subtitleText: { fontSize: 13, @@ -146,7 +155,7 @@ export const cardStyles: any = { padding: 8, paddingBottom: 12, flex: 1, - } -} + }, +}; export default MetricsTab; diff --git a/www/js/metrics/WeeklyActiveMinutesCard.tsx b/www/js/metrics/WeeklyActiveMinutesCard.tsx index 99bf9d425..387ebc79d 100644 --- a/www/js/metrics/WeeklyActiveMinutesCard.tsx +++ b/www/js/metrics/WeeklyActiveMinutesCard.tsx @@ -1,7 +1,6 @@ - import React, { useMemo, useState } from 'react'; import { View } from 'react-native'; -import { Card, Text, useTheme} from 'react-native-paper'; +import { Card, Text, useTheme } from 'react-native-paper'; import { MetricsData } from './metricsTypes'; import { cardMargin, cardStyles } from './MetricsTab'; import { formatDateRangeOfDays, segmentDaysByWeeks } from './metricsHelper'; @@ -11,68 +10,70 @@ import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHe import { getBaseModeByText } from '../diary/diaryHelper'; export const ACTIVE_MODES = ['walk', 'bike'] as const; -type ActiveMode = typeof ACTIVE_MODES[number]; +type ActiveMode = (typeof ACTIVE_MODES)[number]; -type Props = { userMetrics: MetricsData } +type Props = { userMetrics: MetricsData }; const WeeklyActiveMinutesCard = ({ userMetrics }: Props) => { - const { colors } = useTheme(); const { t } = useTranslation(); - const weeklyActiveMinutesRecords = useMemo(() => { const records = []; - const [ recentWeek, prevWeek ] = segmentDaysByWeeks(userMetrics?.duration, 2); - ACTIVE_MODES.forEach(mode => { - const prevSum = prevWeek?.reduce((acc, day) => ( - acc + (day[`label_${mode}`] || 0) - ), 0); + const [recentWeek, prevWeek] = segmentDaysByWeeks(userMetrics?.duration, 2); + ACTIVE_MODES.forEach((mode) => { + const prevSum = prevWeek?.reduce((acc, day) => acc + (day[`label_${mode}`] || 0), 0); if (prevSum) { const xLabel = `Previous Week\n(${formatDateRangeOfDays(prevWeek)})`; // TODO: i18n - records.push({label: labelKeyToRichMode(mode), x: xLabel, y: prevSum / 60}); + records.push({ label: labelKeyToRichMode(mode), x: xLabel, y: prevSum / 60 }); } - const recentSum = recentWeek?.reduce((acc, day) => ( - acc + (day[`label_${mode}`] || 0) - ), 0); + const recentSum = recentWeek?.reduce((acc, day) => acc + (day[`label_${mode}`] || 0), 0); if (recentSum) { const xLabel = `Past Week\n(${formatDateRangeOfDays(recentWeek)})`; // TODO: i18n - records.push({label: labelKeyToRichMode(mode), x: xLabel, y: recentSum / 60}); + records.push({ label: labelKeyToRichMode(mode), x: xLabel, y: recentSum / 60 }); } }); - return records as {label: ActiveMode, x: string, y: number}[]; + return records as { label: ActiveMode; x: string; y: number }[]; }, [userMetrics?.duration]); return ( - - + + style={cardStyles.title(colors)} + /> - { weeklyActiveMinutesRecords.length ? - - getBaseModeByText(l, labelOptions).color} /> - + {weeklyActiveMinutesRecords.length ? ( + + getBaseModeByText(l, labelOptions).color} + /> + {t('main-metrics.weekly-goal-footnote')} - : - - + ) : ( + + {t('metrics.chart-no-data')} - } + )} - ) -} + ); +}; export default WeeklyActiveMinutesCard; diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index d1cd435d4..3df71cdc1 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -1,12 +1,12 @@ -import { DateTime } from "luxon"; -import { formatForDisplay } from "../config/useImperialConfig"; -import { DayOfMetricData } from "./metricsTypes"; +import { DateTime } from 'luxon'; +import { formatForDisplay } from '../config/useImperialConfig'; +import { DayOfMetricData } from './metricsTypes'; import moment from 'moment'; export function getUniqueLabelsForDays(metricDataDays: DayOfMetricData[]) { const uniqueLabels: string[] = []; - metricDataDays.forEach(e => { - Object.keys(e).forEach(k => { + metricDataDays.forEach((e) => { + Object.keys(e).forEach((k) => { if (k.startsWith('label_')) { const label = k.substring(6); // remove 'label_' prefix leaving just the mode label if (!uniqueLabels.includes(label)) uniqueLabels.push(label); @@ -16,42 +16,39 @@ export function getUniqueLabelsForDays(metricDataDays: DayOfMetricData[]) { return uniqueLabels; } -export const getLabelsForDay = (metricDataDay: DayOfMetricData) => ( +export const getLabelsForDay = (metricDataDay: DayOfMetricData) => Object.keys(metricDataDay).reduce((acc, k) => { if (k.startsWith('label_')) { acc.push(k.substring(6)); // remove 'label_' prefix leaving just the mode label } return acc; - }, [] as string[]) -); + }, [] as string[]); -export const secondsToMinutes = (seconds: number) => - formatForDisplay(seconds / 60); +export const secondsToMinutes = (seconds: number) => formatForDisplay(seconds / 60); -export const secondsToHours = (seconds: number) => - formatForDisplay(seconds / 3600); +export const secondsToHours = (seconds: number) => formatForDisplay(seconds / 3600); // segments metricsDays into weeks, with the most recent week first -export function segmentDaysByWeeks (days: DayOfMetricData[], nWeeks?: number) { +export function segmentDaysByWeeks(days: DayOfMetricData[], nWeeks?: number) { const weeks: DayOfMetricData[][] = []; for (let i = days?.length - 1; i >= 0; i -= 7) { weeks.push(days.slice(Math.max(i - 6, 0), i + 1)); } if (nWeeks) return weeks.slice(0, nWeeks); return weeks; -}; +} export function formatDate(day: DayOfMetricData) { const dt = DateTime.fromISO(day.fmt_time, { zone: 'utc' }); - return dt.toLocaleString({...DateTime.DATE_SHORT, year: undefined}); + return dt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined }); } export function formatDateRangeOfDays(days: DayOfMetricData[]) { if (!days?.length) return ''; const firstDayDt = DateTime.fromISO(days[0].fmt_time, { zone: 'utc' }); const lastDayDt = DateTime.fromISO(days[days.length - 1].fmt_time, { zone: 'utc' }); - const firstDay = firstDayDt.toLocaleString({...DateTime.DATE_SHORT, year: undefined}); - const lastDay = lastDayDt.toLocaleString({...DateTime.DATE_SHORT, year: undefined}); + const firstDay = firstDayDt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined }); + const lastDay = lastDayDt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined }); return `${firstDay} - ${lastDay}`; } @@ -61,50 +58,49 @@ export function formatDateRangeOfDays(days: DayOfMetricData[]) { const ON_FOOT_MODES = ['WALKING', 'RUNNING', 'ON_FOOT'] as const; /* -* metric2val is a function that takes a metric entry and a field and returns -* the appropriate value. -* for regular data (user-specific), this will return the field value -* for avg data (aggregate), this will return the field value/nUsers -*/ -const metricToValue = function(population:'user'|'aggreagte', metric, field) { - if(population == "user"){ + * metric2val is a function that takes a metric entry and a field and returns + * the appropriate value. + * for regular data (user-specific), this will return the field value + * for avg data (aggregate), this will return the field value/nUsers + */ +const metricToValue = function (population: 'user' | 'aggreagte', metric, field) { + if (population == 'user') { return metric[field]; + } else { + return metric[field] / metric.nUsers; } - else{ - return metric[field]/metric.nUsers; - } -} +}; //testing agains global list of what is "on foot" //returns true | false -const isOnFoot = function(mode: string) { +const isOnFoot = function (mode: string) { for (let ped_mode in ON_FOOT_MODES) { if (mode === ped_mode) { return true; } } return false; -} +}; //from two weeks fo low and high values, calculates low and high change export function calculatePercentChange(pastWeekRange, previousWeekRange) { let greaterLesserPct = { - low: (pastWeekRange.low/previousWeekRange.low) * 100 - 100, - high: (pastWeekRange.high/previousWeekRange.high) * 100 - 100, - } + low: (pastWeekRange.low / previousWeekRange.low) * 100 - 100, + high: (pastWeekRange.high / previousWeekRange.high) * 100 - 100, + }; return greaterLesserPct; } export function parseDataFromMetrics(metrics, population) { - console.log("Called parseDataFromMetrics on ", metrics); + console.log('Called parseDataFromMetrics on ', metrics); let mode_bins = {}; - metrics?.forEach(function(metric) { + metrics?.forEach(function (metric) { let onFootVal = 0; for (let field in metric) { /*For modes inferred from sensor data, we check if the string is all upper case by converting it to upper case and seeing if it is changed*/ - if(field == field.toUpperCase()) { + if (field == field.toUpperCase()) { /*sum all possible on foot modes: see https://github.com/e-mission/e-mission-docs/issues/422 */ if (isOnFoot(field)) { onFootVal += metricToValue(population, metric, field); @@ -114,49 +110,56 @@ export function parseDataFromMetrics(metrics, population) { mode_bins[field] = []; } //for all except onFoot, add to bin - could discover mult onFoot modes - if (field != "ON_FOOT") { - mode_bins[field].push([metric.ts, metricToValue(population, metric, field), metric.fmt_time]); + if (field != 'ON_FOOT') { + mode_bins[field].push([ + metric.ts, + metricToValue(population, metric, field), + metric.fmt_time, + ]); } } //this section handles user lables, assuming 'label_' prefix - if(field.startsWith('label_')) { + if (field.startsWith('label_')) { let actualMode = field.slice(6, field.length); //remove prefix - console.log("Mapped field "+field+" to mode "+actualMode); + console.log('Mapped field ' + field + ' to mode ' + actualMode); if (!(actualMode in mode_bins)) { - mode_bins[actualMode] = []; + mode_bins[actualMode] = []; } - mode_bins[actualMode].push([metric.ts, Math.round(metricToValue(population, metric, field)), moment(metric.fmt_time).format()]); + mode_bins[actualMode].push([ + metric.ts, + Math.round(metricToValue(population, metric, field)), + moment(metric.fmt_time).format(), + ]); } } //handle the ON_FOOT modes once all have been summed - if ("ON_FOOT" in mode_bins) { - mode_bins["ON_FOOT"].push([metric.ts, Math.round(onFootVal), metric.fmt_time]); + if ('ON_FOOT' in mode_bins) { + mode_bins['ON_FOOT'].push([metric.ts, Math.round(onFootVal), metric.fmt_time]); } }); let return_val = []; for (let mode in mode_bins) { - return_val.push({key: mode, values: mode_bins[mode]}); + return_val.push({ key: mode, values: mode_bins[mode] }); } return return_val; } export function generateSummaryFromData(modeMap, metric) { - console.log("Invoked getSummaryDataRaw on ", modeMap, "with", metric); + console.log('Invoked getSummaryDataRaw on ', modeMap, 'with', metric); let summaryMap = []; - for (let i=0; i < modeMap.length; i++){ + for (let i = 0; i < modeMap.length; i++) { let summary = {}; - summary['key'] = modeMap[i].key; + summary['key'] = modeMap[i].key; let sumVals = 0; - for (let j = 0; j < modeMap[i].values.length; j++) - { + for (let j = 0; j < modeMap[i].values.length; j++) { sumVals += modeMap[i].values[j][1]; //2nd item of array is value } - if (metric === 'mean_speed'){ + if (metric === 'mean_speed') { //we care about avg speed, sum for other metrics summary['values'] = Math.round(sumVals / modeMap[i].values.length); } else { @@ -170,13 +173,13 @@ export function generateSummaryFromData(modeMap, metric) { } /* -* We use the results to determine whether these results are from custom -* labels or from the automatically sensed labels. Automatically sensedV -* labels are in all caps, custom labels are prefixed by label, but have had -* the label_prefix stripped out before this. Results should have either all -* sensed labels or all custom labels. -*/ -export const isCustomLabels = function(modeMap) { + * We use the results to determine whether these results are from custom + * labels or from the automatically sensed labels. Automatically sensedV + * labels are in all caps, custom labels are prefixed by label, but have had + * the label_prefix stripped out before this. Results should have either all + * sensed labels or all custom labels. + */ +export const isCustomLabels = function (modeMap) { const isSensed = (mode) => mode == mode.toUpperCase(); const isCustom = (mode) => mode == mode.toLowerCase(); const metricSummaryChecksCustom = []; @@ -185,28 +188,34 @@ export const isCustomLabels = function(modeMap) { const distanceKeys = modeMap.map((e) => e.key); const isSensedKeys = distanceKeys.map(isSensed); const isCustomKeys = distanceKeys.map(isCustom); - console.log("Checking metric keys", distanceKeys, " sensed ", isSensedKeys, - " custom ", isCustomKeys); + console.log( + 'Checking metric keys', + distanceKeys, + ' sensed ', + isSensedKeys, + ' custom ', + isCustomKeys, + ); const isAllCustomForMetric = isAllCustom(isSensedKeys, isCustomKeys); metricSummaryChecksSensed.push(!isAllCustomForMetric); metricSummaryChecksCustom.push(isAllCustomForMetric); - console.log("overall custom/not results for each metric = ", metricSummaryChecksCustom); + console.log('overall custom/not results for each metric = ', metricSummaryChecksCustom); return isAllCustom(metricSummaryChecksSensed, metricSummaryChecksCustom); -} +}; -const isAllCustom = function(isSensedKeys, isCustomKeys) { - const allSensed = isSensedKeys.reduce((a, b) => a && b, true); - const anySensed = isSensedKeys.reduce((a, b) => a || b, false); - const allCustom = isCustomKeys.reduce((a, b) => a && b, true); - const anyCustom = isCustomKeys.reduce((a, b) => a || b, false); - if ((allSensed && !anyCustom)) { - return false; // sensed, not custom - } - if ((!anySensed && allCustom)) { - return true; // custom, not sensed; false implies that the other option is true - } - // Logger.displayError("Mixed entries that combine sensed and custom labels", - // "Please report to your program admin"); - return undefined; -} \ No newline at end of file +const isAllCustom = function (isSensedKeys, isCustomKeys) { + const allSensed = isSensedKeys.reduce((a, b) => a && b, true); + const anySensed = isSensedKeys.reduce((a, b) => a || b, false); + const allCustom = isCustomKeys.reduce((a, b) => a && b, true); + const anyCustom = isCustomKeys.reduce((a, b) => a || b, false); + if (allSensed && !anyCustom) { + return false; // sensed, not custom + } + if (!anySensed && allCustom) { + return true; // custom, not sensed; false implies that the other option is true + } + // Logger.displayError("Mixed entries that combine sensed and custom labels", + // "Please report to your program admin"); + return undefined; +}; diff --git a/www/js/metrics/metricsTypes.ts b/www/js/metrics/metricsTypes.ts index d51c98b3a..cfe4444a3 100644 --- a/www/js/metrics/metricsTypes.ts +++ b/www/js/metrics/metricsTypes.ts @@ -1,14 +1,14 @@ -import { METRIC_LIST } from "./MetricsTab" +import { METRIC_LIST } from './MetricsTab'; -type MetricName = typeof METRIC_LIST[number]; -type LabelProps = {[k in `label_${string}`]?: number}; // label_, where could be anything +type MetricName = (typeof METRIC_LIST)[number]; +type LabelProps = { [k in `label_${string}`]?: number }; // label_, where could be anything export type DayOfMetricData = LabelProps & { - ts: number, - fmt_time: string, - nUsers: number, - local_dt: {[k: string]: any}, // TODO type datetime obj -} + ts: number; + fmt_time: string; + nUsers: number; + local_dt: { [k: string]: any }; // TODO type datetime obj +}; export type MetricsData = { - [key in MetricName]: DayOfMetricData[] -} + [key in MetricName]: DayOfMetricData[]; +}; diff --git a/www/js/ngApp.js b/www/js/ngApp.js index f82c53482..228c2a989 100644 --- a/www/js/ngApp.js +++ b/www/js/ngApp.js @@ -32,51 +32,61 @@ import App from './App'; import { getTheme } from './appTheme'; import { SafeAreaView } from 'react-native-safe-area-context'; -angular.module('emission', ['ionic', 'jm.i18next', - 'emission.controllers','emission.services', 'emission.plugin.logger', - 'emission.splash.referral', 'emission.services.email', - 'emission.main', 'pascalprecht.translate', 'LocalStorageModule']) +angular + .module('emission', [ + 'ionic', + 'jm.i18next', + 'emission.controllers', + 'emission.services', + 'emission.plugin.logger', + 'emission.splash.referral', + 'emission.services.email', + 'emission.main', + 'pascalprecht.translate', + 'LocalStorageModule', + ]) -.run(function($ionicPlatform, $rootScope, $http, Logger, localStorageService) { - console.log("Starting run"); - // ensure that plugin events are delivered after the ionicPlatform is ready - // https://github.com/katzer/cordova-plugin-local-notifications#launch-details - window.skipLocalNotificationReady = true; - // alert("Starting run"); - $ionicPlatform.ready(function() { - // Hide the accessory bar by default (remove this to show the accessory bar above the keyboard - // for form inputs) - Logger.log("ionicPlatform is ready"); + .run(function ($ionicPlatform, $rootScope, $http, Logger, localStorageService) { + console.log('Starting run'); + // ensure that plugin events are delivered after the ionicPlatform is ready + // https://github.com/katzer/cordova-plugin-local-notifications#launch-details + window.skipLocalNotificationReady = true; + // alert("Starting run"); + $ionicPlatform.ready(function () { + // Hide the accessory bar by default (remove this to show the accessory bar above the keyboard + // for form inputs) + Logger.log('ionicPlatform is ready'); - if (window.StatusBar) { - // org.apache.cordova.statusbar required - StatusBar.styleDefault(); - } - cordova.plugin.http.setDataSerializer('json'); - // backwards compat hack to be consistent with - // https://github.com/e-mission/e-mission-data-collection/commit/92f41145e58c49e3145a9222a78d1ccacd16d2a7#diff-962320754eba07107ecd413954411f725c98fd31cddbb5defd4a542d1607e5a3R160 - // remove during migration to react native - localStorageService.remove("OP_GEOFENCE_CFG"); - cordova.plugins.BEMUserCache.removeLocalStorage("OP_GEOFENCE_CFG"); + if (window.StatusBar) { + // org.apache.cordova.statusbar required + StatusBar.styleDefault(); + } + cordova.plugin.http.setDataSerializer('json'); + // backwards compat hack to be consistent with + // https://github.com/e-mission/e-mission-data-collection/commit/92f41145e58c49e3145a9222a78d1ccacd16d2a7#diff-962320754eba07107ecd413954411f725c98fd31cddbb5defd4a542d1607e5a3R160 + // remove during migration to react native + localStorageService.remove('OP_GEOFENCE_CFG'); + cordova.plugins.BEMUserCache.removeLocalStorage('OP_GEOFENCE_CFG'); - const rootEl = document.getElementById('appRoot'); - const reactRoot = createRoot(rootEl); + const rootEl = document.getElementById('appRoot'); + const reactRoot = createRoot(rootEl); - const theme = getTheme(); + const theme = getTheme(); - reactRoot.render( - - - - - - - ); + + + + + , + ); + }); + console.log('Ending run'); }); - console.log("Ending run"); -}); diff --git a/www/js/onboarding/OnboardingStack.tsx b/www/js/onboarding/OnboardingStack.tsx index c547fd074..cfe0b5c6a 100644 --- a/www/js/onboarding/OnboardingStack.tsx +++ b/www/js/onboarding/OnboardingStack.tsx @@ -1,16 +1,15 @@ -import React, { useContext } from "react"; -import { StyleSheet } from "react-native"; -import { AppContext } from "../App"; -import WelcomePage from "./WelcomePage"; -import ProtocolPage from "./ProtocolPage"; -import SurveyPage from "./SurveyPage"; -import SaveQrPage from "./SaveQrPage"; -import SummaryPage from "./SummaryPage"; -import { OnboardingRoute } from "./onboardingHelper"; -import { displayErrorMsg } from "../plugin/logger"; +import React, { useContext } from 'react'; +import { StyleSheet } from 'react-native'; +import { AppContext } from '../App'; +import WelcomePage from './WelcomePage'; +import ProtocolPage from './ProtocolPage'; +import SurveyPage from './SurveyPage'; +import SaveQrPage from './SaveQrPage'; +import SummaryPage from './SummaryPage'; +import { OnboardingRoute } from './onboardingHelper'; +import { displayErrorMsg } from '../plugin/logger'; const OnboardingStack = () => { - const { onboardingState } = useContext(AppContext); console.debug('onboardingState in OnboardingStack', onboardingState); @@ -28,7 +27,7 @@ const OnboardingStack = () => { } else { displayErrorMsg('OnboardingStack: unknown route', onboardingState.route); } -} +}; export const onboardingStyles = StyleSheet.create({ page: { @@ -50,4 +49,4 @@ export const onboardingStyles = StyleSheet.create({ }, }); -export default OnboardingStack +export default OnboardingStack; diff --git a/www/js/onboarding/PrivacyPolicy.tsx b/www/js/onboarding/PrivacyPolicy.tsx index f237e359c..bfd884cac 100644 --- a/www/js/onboarding/PrivacyPolicy.tsx +++ b/www/js/onboarding/PrivacyPolicy.tsx @@ -1,59 +1,73 @@ -import React, { useMemo } from "react"; -import { StyleSheet, Text } from "react-native"; -import { useTranslation } from "react-i18next"; -import useAppConfig from "../useAppConfig"; -import { getTemplateText } from "./StudySummary"; +import React, { useMemo } from 'react'; +import { StyleSheet, Text } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import useAppConfig from '../useAppConfig'; +import { getTemplateText } from './StudySummary'; const PrivacyPolicy = () => { - const { t, i18n } = useTranslation(); - const appConfig = useAppConfig(); + const { t, i18n } = useTranslation(); + const appConfig = useAppConfig(); - let opCodeText; - if(appConfig?.opcode?.autogen) { - opCodeText = {t('consent-text.opcode.autogen')}; - - } else { - opCodeText = {t('consent-text.opcode.not-autogen')}; - } + let opCodeText; + if (appConfig?.opcode?.autogen) { + opCodeText = {t('consent-text.opcode.autogen')}; + } else { + opCodeText = {t('consent-text.opcode.not-autogen')}; + } - let yourRightsText; - if(appConfig?.intro?.app_required) { - yourRightsText = {t('consent-text.rights.app-required', {program_admin_contact: appConfig?.intro?.program_admin_contact})}; + let yourRightsText; + if (appConfig?.intro?.app_required) { + yourRightsText = ( + + {t('consent-text.rights.app-required', { + program_admin_contact: appConfig?.intro?.program_admin_contact, + })} + + ); + } else { + yourRightsText = ( + + {t('consent-text.rights.app-not-required', { + program_or_study: appConfig?.intro?.program_or_study, + })} + + ); + } - } else { - yourRightsText = {t('consent-text.rights.app-not-required', {program_or_study: appConfig?.intro?.program_or_study})}; - } + const templateText = useMemo(() => getTemplateText(appConfig, i18n.language), [appConfig]); - const templateText = useMemo(() => getTemplateText(appConfig, i18n.language), [appConfig]); + return ( + <> + {t('consent-text.title')} + {t('consent-text.introduction.header')} + {templateText?.short_textual_description} + {'\n'} + {t('consent-text.introduction.what-is-openpath')} + {'\n'} + + {t('consent-text.introduction.what-is-NREL', { + program_or_study: appConfig?.intro?.program_or_study, + })} + + {'\n'} + {t('consent-text.introduction.if-disagree')} + {'\n'} - return ( - <> - {t('consent-text.title')} - {t('consent-text.introduction.header')} - {templateText?.short_textual_description} - {'\n'} - {t('consent-text.introduction.what-is-openpath')} - {'\n'} - {t('consent-text.introduction.what-is-NREL', {program_or_study: appConfig?.intro?.program_or_study})} - {'\n'} - {t('consent-text.introduction.if-disagree')} - {'\n'} + {t('consent-text.why.header')} + {templateText?.why_we_collect} + {'\n'} - {t('consent-text.why.header')} - {templateText?.why_we_collect} - {'\n'} - - {t('consent-text.what.header')} - {t('consent-text.what.no-pii')} - {'\n'} - {t('consent-text.what.phone-sensor')} - {'\n'} - {t('consent-text.what.labeling')} - {'\n'} - {t('consent-text.what.demographics')} - {'\n'} - {t('consent-text.what.on-nrel-site')} - {/* Linking is broken, look into enabling after migration + {t('consent-text.what.header')} + {t('consent-text.what.no-pii')} + {'\n'} + {t('consent-text.what.phone-sensor')} + {'\n'} + {t('consent-text.what.labeling')} + {'\n'} + {t('consent-text.what.demographics')} + {'\n'} + {t('consent-text.what.on-nrel-site')} + {/* Linking is broken, look into enabling after migration {t('consent-text.what.open-source-data')} { {' '}https://github.com/e-mission/em-public-dashboard.git{' '} */} - {'\n'} + {'\n'} - {t('consent-text.opcode.header')} - {opCodeText} - {'\n'} + {t('consent-text.opcode.header')} + {opCodeText} + {'\n'} - {t('consent-text.who-sees.header')} - {t('consent-text.who-sees.public-dash')} - {'\n'} - {t('consent-text.who-sees.individual-info')} - {'\n'} - {t('consent-text.who-sees.program-admins', { - deployment_partner_name: appConfig?.intro?.deployment_partner_name, - raw_data_use: templateText?.raw_data_use})} - {t('consent-text.who-sees.nrel-devs')} - {'\n'} - {t('consent-text.who-sees.TSDC-info')} - {/* Linking is broken, look into enabling after migration + {t('consent-text.who-sees.header')} + {t('consent-text.who-sees.public-dash')} + {'\n'} + {t('consent-text.who-sees.individual-info')} + {'\n'} + + {t('consent-text.who-sees.program-admins', { + deployment_partner_name: appConfig?.intro?.deployment_partner_name, + raw_data_use: templateText?.raw_data_use, + })} + + {t('consent-text.who-sees.nrel-devs')} + {'\n'} + + {t('consent-text.who-sees.TSDC-info')} + {/* Linking is broken, look into enabling after migration { @@ -121,15 +139,16 @@ const PrivacyPolicy = () => { }}> {t('consent-text.who-sees.fact-sheet')} */} - {t('consent-text.who-sees.on-nrel-site')} - - {'\n'} + {t('consent-text.who-sees.on-nrel-site')} + + {'\n'} - {t('consent-text.rights.header')} - {yourRightsText} - {'\n'} - {t('consent-text.rights.destroy-data-pt1')} - {/* Linking is broken, look into enabling after migration + {t('consent-text.rights.header')} + {yourRightsText} + {'\n'} + + {t('consent-text.rights.destroy-data-pt1')} + {/* Linking is broken, look into enabling after migration { @@ -137,41 +156,49 @@ const PrivacyPolicy = () => { }}> k.shankari@nrel.gov */} - (k.shankari@nrel.gov) - {t('consent-text.rights.destroy-data-pt2')} - - {'\n'} - - {t('consent-text.questions.header')} - {t('consent-text.questions.for-questions', {program_admin_contact: appConfig?.intro?.program_admin_contact})} - {'\n'} - - {t('consent-text.consent.header')} - {t('consent-text.consent.press-button-to-consent', {program_or_study: appConfig?.intro?.program_or_study})} - - ) -} + (k.shankari@nrel.gov) + {t('consent-text.rights.destroy-data-pt2')} + + {'\n'} + + {t('consent-text.questions.header')} + + {t('consent-text.questions.for-questions', { + program_admin_contact: appConfig?.intro?.program_admin_contact, + })} + + {'\n'} + + {t('consent-text.consent.header')} + + {t('consent-text.consent.press-button-to-consent', { + program_or_study: appConfig?.intro?.program_or_study, + })} + + + ); +}; const styles = StyleSheet.create({ - hyperlinkStyle: (linkColor) => ({ - color: linkColor - }), - text: { - fontSize: 14, - }, - header: { - fontWeight: "bold", - fontSize: 18 - }, - title: { - fontWeight: "bold", - fontSize: 22, - paddingBottom: 10, - textAlign: "center" - }, - divider: { - marginVertical: 10 - } - }); + hyperlinkStyle: (linkColor) => ({ + color: linkColor, + }), + text: { + fontSize: 14, + }, + header: { + fontWeight: 'bold', + fontSize: 18, + }, + title: { + fontWeight: 'bold', + fontSize: 22, + paddingBottom: 10, + textAlign: 'center', + }, + divider: { + marginVertical: 10, + }, +}); export default PrivacyPolicy; diff --git a/www/js/onboarding/ProtocolPage.tsx b/www/js/onboarding/ProtocolPage.tsx index a047a0aae..1f096ecc9 100644 --- a/www/js/onboarding/ProtocolPage.tsx +++ b/www/js/onboarding/ProtocolPage.tsx @@ -10,7 +10,6 @@ import { markConsented } from '../splash/startprefs'; import { setProtocolDone } from './onboardingHelper'; const ProtocolPage = () => { - const { t } = useTranslation(); const context = useContext(AppContext); const { refreshOnboardingState } = context; @@ -18,25 +17,33 @@ const ProtocolPage = () => { /* If the user does not consent, we boot them back out to the join screen */ function disagree() { resetDataAndRefresh(); - }; + } function agree() { - setProtocolDone(true); - refreshOnboardingState(); - }; + setProtocolDone(true); + refreshOnboardingState(); + } // privacy policy and data collection info, followed by accept/reject buttons - return (<> - - - - - - - - - - ); -} + return ( + <> + + + + + + + + + + + ); +}; export default ProtocolPage; diff --git a/www/js/onboarding/SaveQrPage.tsx b/www/js/onboarding/SaveQrPage.tsx index 3bfc93bb4..768fa9101 100644 --- a/www/js/onboarding/SaveQrPage.tsx +++ b/www/js/onboarding/SaveQrPage.tsx @@ -1,22 +1,21 @@ -import React, { useContext, useEffect, useState } from "react"; -import { View, StyleSheet } from "react-native"; -import { ActivityIndicator, Button, Surface, Text } from "react-native-paper"; -import { registerUserDone, setRegisterUserDone, setSaveQrDone } from "./onboardingHelper"; -import { AppContext } from "../App"; -import { getAngularService } from "../angular-react-helper"; -import { displayError, logDebug } from "../plugin/logger"; -import { useTranslation } from "react-i18next"; -import QrCode, { shareQR } from "../components/QrCode"; -import { onboardingStyles } from "./OnboardingStack"; -import { preloadDemoSurveyResponse } from "./SurveyPage"; -import { storageSet } from "../plugin/storage"; -import { registerUser } from "../commHelper"; -import { resetDataAndRefresh } from "../config/dynamicConfig"; -import { markConsented } from "../splash/startprefs"; -import i18next from "i18next"; - -const SaveQrPage = ({ }) => { +import React, { useContext, useEffect, useState } from 'react'; +import { View, StyleSheet } from 'react-native'; +import { ActivityIndicator, Button, Surface, Text } from 'react-native-paper'; +import { registerUserDone, setRegisterUserDone, setSaveQrDone } from './onboardingHelper'; +import { AppContext } from '../App'; +import { getAngularService } from '../angular-react-helper'; +import { displayError, logDebug } from '../plugin/logger'; +import { useTranslation } from 'react-i18next'; +import QrCode, { shareQR } from '../components/QrCode'; +import { onboardingStyles } from './OnboardingStack'; +import { preloadDemoSurveyResponse } from './SurveyPage'; +import { storageSet } from '../plugin/storage'; +import { registerUser } from '../commHelper'; +import { resetDataAndRefresh } from '../config/dynamicConfig'; +import { markConsented } from '../splash/startprefs'; +import i18next from 'i18next'; +const SaveQrPage = ({}) => { const { t } = useTranslation(); const { permissionStatus, onboardingState, refreshOnboardingState } = useContext(AppContext); const { overallStatus } = permissionStatus; @@ -24,36 +23,39 @@ const SaveQrPage = ({ }) => { useEffect(() => { if (overallStatus == true && !registerUserDone) { logDebug('permissions done, going to log in'); - markConsented() - .then(login(onboardingState.opcode) - .then((response) => { - logDebug('login done, refreshing onboarding state'); - setRegisterUserDone(true); - preloadDemoSurveyResponse(); - refreshOnboardingState(); - }) - ); + markConsented().then( + login(onboardingState.opcode).then((response) => { + logDebug('login done, refreshing onboarding state'); + setRegisterUserDone(true); + preloadDemoSurveyResponse(); + refreshOnboardingState(); + }), + ); } else { logDebug('permissions not done, waiting'); } }, [overallStatus]); function login(token) { - const EXPECTED_METHOD = "prompted-auth"; - const dbStorageObject = {"token": token}; - logDebug("about to login with token"); - return storageSet(EXPECTED_METHOD, dbStorageObject).then((r) => { - registerUser().then((r) => { - logDebug("registered user in CommHelper result " + r); - refreshOnboardingState(); - }).catch((e) => { - displayError(e, "User registration error"); - resetDataAndRefresh(); + const EXPECTED_METHOD = 'prompted-auth'; + const dbStorageObject = { token: token }; + logDebug('about to login with token'); + return storageSet(EXPECTED_METHOD, dbStorageObject) + .then((r) => { + registerUser() + .then((r) => { + logDebug('registered user in CommHelper result ' + r); + refreshOnboardingState(); + }) + .catch((e) => { + displayError(e, 'User registration error'); + resetDataAndRefresh(); + }); + }) + .catch((e) => { + displayError(e, 'Sign in error'); }); - }).catch((e) => { - displayError(e, "Sign in error"); - }); - }; + } function onFinish() { setSaveQrDone(true); @@ -63,30 +65,28 @@ const SaveQrPage = ({ }) => { return ( - + {t('login.make-sure-save-your-opcode')} - + {t('login.cannot-retrieve')} - - - - {onboardingState.opcode} - + + + {onboardingState.opcode} - - ); -} +}; const s = StyleSheet.create({ opcodeText: { diff --git a/www/js/onboarding/StudySummary.tsx b/www/js/onboarding/StudySummary.tsx index 3996ba076..9913c6d81 100644 --- a/www/js/onboarding/StudySummary.tsx +++ b/www/js/onboarding/StudySummary.tsx @@ -1,45 +1,48 @@ -import React, { useMemo } from "react"; -import { View, StyleSheet } from "react-native"; -import { Text } from "react-native-paper"; -import { useTranslation } from "react-i18next"; -import useAppConfig from "../useAppConfig"; +import React, { useMemo } from 'react'; +import { View, StyleSheet } from 'react-native'; +import { Text } from 'react-native-paper'; +import { useTranslation } from 'react-i18next'; +import useAppConfig from '../useAppConfig'; export function getTemplateText(configObject, lang) { - if (configObject && (configObject.name)) { + if (configObject && configObject.name) { return configObject.intro.translated_text[lang]; } } const StudySummary = () => { - const { i18n } = useTranslation(); const appConfig = useAppConfig(); const templateText = useMemo(() => getTemplateText(appConfig, i18n.language), [appConfig]); - return (<> - {templateText?.deployment_name} - {appConfig?.intro?.deployment_partner_name + " " + templateText?.deployment_name} - - {"✔️ " + templateText?.summary_line_1} - {"✔️ " + templateText?.summary_line_2} - {"✔️ " + templateText?.summary_line_3} - - ) + return ( + <> + {templateText?.deployment_name} + + {appConfig?.intro?.deployment_partner_name + ' ' + templateText?.deployment_name} + + + {'✔️ ' + templateText?.summary_line_1} + {'✔️ ' + templateText?.summary_line_2} + {'✔️ ' + templateText?.summary_line_3} + + + ); }; const styles = StyleSheet.create({ title: { - fontWeight: "bold", + fontWeight: 'bold', fontSize: 24, paddingBottom: 10, - textAlign: "center" + textAlign: 'center', }, text: { fontSize: 15, }, studyName: { - fontWeight: "bold", + fontWeight: 'bold', fontSize: 17, }, }); diff --git a/www/js/onboarding/SummaryPage.tsx b/www/js/onboarding/SummaryPage.tsx index d15e9f60e..7acd1d1be 100644 --- a/www/js/onboarding/SummaryPage.tsx +++ b/www/js/onboarding/SummaryPage.tsx @@ -8,7 +8,6 @@ import StudySummary from './StudySummary'; import { setSummaryDone } from './onboardingHelper'; const SummaryPage = () => { - const { t } = useTranslation(); const context = useContext(AppContext); const { refreshOnboardingState } = context; @@ -16,21 +15,26 @@ const SummaryPage = () => { function next() { setSummaryDone(true); refreshOnboardingState(); - }; + } // summary of the study, followed by 'next' button - return (<> - - - - - - - - - - - ); -} + return ( + <> + + + + + + + + + + + + ); +}; export default SummaryPage; diff --git a/www/js/onboarding/SurveyPage.tsx b/www/js/onboarding/SurveyPage.tsx index c02439cbf..3ba430e85 100644 --- a/www/js/onboarding/SurveyPage.tsx +++ b/www/js/onboarding/SurveyPage.tsx @@ -1,16 +1,19 @@ -import React, { useState, useEffect, useContext, useMemo } from "react"; -import { View, StyleSheet } from "react-native"; -import { ActivityIndicator, Button, Surface, Text } from "react-native-paper"; -import EnketoModal from "../survey/enketo/EnketoModal"; -import { DEMOGRAPHIC_SURVEY_DATAKEY, DEMOGRAPHIC_SURVEY_NAME } from "../control/DemographicsSettingRow"; -import { loadPreviousResponseForSurvey } from "../survey/enketo/enketoHelper"; -import { AppContext } from "../App"; -import { markIntroDone, registerUserDone } from "./onboardingHelper"; -import { useTranslation } from "react-i18next"; -import { DateTime } from "luxon"; -import { onboardingStyles } from "./OnboardingStack"; -import { displayErrorMsg } from "../plugin/logger"; -import i18next from "i18next"; +import React, { useState, useEffect, useContext, useMemo } from 'react'; +import { View, StyleSheet } from 'react-native'; +import { ActivityIndicator, Button, Surface, Text } from 'react-native-paper'; +import EnketoModal from '../survey/enketo/EnketoModal'; +import { + DEMOGRAPHIC_SURVEY_DATAKEY, + DEMOGRAPHIC_SURVEY_NAME, +} from '../control/DemographicsSettingRow'; +import { loadPreviousResponseForSurvey } from '../survey/enketo/enketoHelper'; +import { AppContext } from '../App'; +import { markIntroDone, registerUserDone } from './onboardingHelper'; +import { useTranslation } from 'react-i18next'; +import { DateTime } from 'luxon'; +import { onboardingStyles } from './OnboardingStack'; +import { displayErrorMsg } from '../plugin/logger'; +import i18next from 'i18next'; let preloadedResponsePromise: Promise = null; export const preloadDemoSurveyResponse = () => { @@ -22,10 +25,9 @@ export const preloadDemoSurveyResponse = () => { preloadedResponsePromise = loadPreviousResponseForSurvey(DEMOGRAPHIC_SURVEY_DATAKEY); } return preloadedResponsePromise; -} +}; const SurveyPage = () => { - const { t } = useTranslation(); const { refreshOnboardingState } = useContext(AppContext); const [surveyModalVisible, setSurveyModalVisible] = useState(false); @@ -33,7 +35,7 @@ const SurveyPage = () => { const prevSurveyResponseDate = useMemo(() => { if (prevSurveyResponse) { const parser = new DOMParser(); - const xmlDoc = parser.parseFromString(prevSurveyResponse, "text/xml"); + const xmlDoc = parser.parseFromString(prevSurveyResponse, 'text/xml'); const surveyEndDt = xmlDoc.querySelector('end')?.textContent; // ISO datetime of survey completion return DateTime.fromISO(surveyEndDt).toLocaleString(DateTime.DATE_FULL); } @@ -60,42 +62,49 @@ const SurveyPage = () => { refreshOnboardingState(); } - return (<> - - {prevSurveyResponse ? - - - {t('survey.prev-survey-found')} - {prevSurveyResponseDate} + return ( + <> + + {prevSurveyResponse ? ( + + + + {' '} + {t('survey.prev-survey-found')}{' '} + + {prevSurveyResponseDate} + + + + + - - - + ) : ( + + + {t('survey.loading-prior-survey')} - - : - - - - {t('survey.loading-prior-survey')} - - - } - - setSurveyModalVisible(false)} - onResponseSaved={onFinish} surveyName={DEMOGRAPHIC_SURVEY_NAME} - opts={{ - /* If there is no prev response, we need an initial response from the user and should + )} + + setSurveyModalVisible(false)} + onResponseSaved={onFinish} + surveyName={DEMOGRAPHIC_SURVEY_NAME} + opts={{ + /* If there is no prev response, we need an initial response from the user and should not allow them to dismiss the modal by the "<- Dismiss" button */ - undismissable: !prevSurveyResponse, - prefilledSurveyResponse: prevSurveyResponse, - dataKey: DEMOGRAPHIC_SURVEY_DATAKEY, - }} /> - ); + undismissable: !prevSurveyResponse, + prefilledSurveyResponse: prevSurveyResponse, + dataKey: DEMOGRAPHIC_SURVEY_DATAKEY, + }} + /> + + ); }; export default SurveyPage; diff --git a/www/js/onboarding/WelcomePage.tsx b/www/js/onboarding/WelcomePage.tsx index cb317c5bc..7c09a21d3 100644 --- a/www/js/onboarding/WelcomePage.tsx +++ b/www/js/onboarding/WelcomePage.tsx @@ -1,16 +1,33 @@ import React, { useContext, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; -import { View, Image, Modal, ScrollView, StyleSheet, ViewStyle, useWindowDimensions } from 'react-native'; -import { Button, Dialog, Divider, IconButton, Surface, Text, TextInput, TouchableRipple, useTheme } from 'react-native-paper'; +import { + View, + Image, + Modal, + ScrollView, + StyleSheet, + ViewStyle, + useWindowDimensions, +} from 'react-native'; +import { + Button, + Dialog, + Divider, + IconButton, + Surface, + Text, + TextInput, + TouchableRipple, + useTheme, +} from 'react-native-paper'; import color from 'color'; import { initByUser } from '../config/dynamicConfig'; import { AppContext } from '../App'; -import { displayError, logDebug } from "../plugin/logger"; +import { displayError, logDebug } from '../plugin/logger'; import { onboardingStyles } from './OnboardingStack'; import { Icon } from '../components/Icon'; const WelcomePage = () => { - const { t } = useTranslation(); const { colors } = useTheme(); const { width: windowWidth } = useWindowDimensions(); @@ -23,128 +40,158 @@ const WelcomePage = () => { const getCode = function (result) { let url = new window.URL(result.text); let notCancelled = result.cancelled == false; - let isQR = result.format == "QR_CODE"; - let hasPrefix = url.protocol == "emission:"; - let hasToken = url.searchParams.has("token"); - let code = url.searchParams.get("token"); + let isQR = result.format == 'QR_CODE'; + let hasPrefix = url.protocol == 'emission:'; + let hasToken = url.searchParams.has('token'); + let code = url.searchParams.get('token'); - logDebug("QR code " + result.text + " checks: cancel, format, prefix, params, code " + notCancelled + isQR + hasPrefix + hasToken + code); + logDebug( + 'QR code ' + + result.text + + ' checks: cancel, format, prefix, params, code ' + + notCancelled + + isQR + + hasPrefix + + hasToken + + code, + ); if (notCancelled && isQR && hasPrefix && hasToken) { return code; } else { return false; } - }; + }; const scanCode = function () { window['cordova'].plugins.barcodeScanner.scan( function (result) { - console.debug("scanned code", result); + console.debug('scanned code', result); let code = getCode(result); if (code != false) { - console.log("found code", code); + console.log('found code', code); loginWithToken(code); } else { - displayError(result.text, "invalid study reference"); + displayError(result.text, 'invalid study reference'); } }, function (error) { - displayError(error, "Scanning failed: "); - }); + displayError(error, 'Scanning failed: '); + }, + ); }; function loginWithToken(token) { - initByUser({token}).then((configUpdated) => { - if (configUpdated) { - setPasteModalVis(false); - refreshOnboardingState(); - } - }).catch(err => { - console.error('Error logging in with token', err); - setExistingToken(''); - }); + initByUser({ token }) + .then((configUpdated) => { + if (configUpdated) { + setPasteModalVis(false); + refreshOnboardingState(); + } + }) + .catch((err) => { + console.error('Error logging in with token', err); + setExistingToken(''); + }); } - return (<> - - - setInfoPopupVis(true)} /> - - + return ( + <> + + + setInfoPopupVis(true)} + /> + + + + + + }} + /> + + + {t('join.to-proceed-further')} + {t('join.code-hint')} + + + + + {t('join.scan-code')} + + {t('join.scan-hint')} + + + + setPasteModalVis(true)} icon="content-paste"> + {t('join.paste-code')} + + {t('join.paste-hint')} + + + - - - }} /> - - - {t('join.to-proceed-further')} - {t('join.code-hint')} - - - - - {t('join.scan-code')} - - {t('join.scan-hint')} - - - - setPasteModalVis(true)} icon='content-paste'> - {t('join.paste-code')} - - {t('join.paste-hint')} - - - - - setPasteModalVis(false)}> - setPasteModalVis(false)}> - - - - - - - - setInfoPopupVis(false)}> - setInfoPopupVis(false)}> - - {t('join.about-app-title', {appName: t('join.app-name')})} - - - - {t('join.about-app-para-1')} - {t('join.about-app-para-2')} - {t('join.about-app-para-3')} - {t('join.tips-title')} - - {t('join.all-green-status')} - - {t('join.dont-force-kill')} - - {t('join.background-restrictions')} - - - - - - - - ); -} + setPasteModalVis(false)}> + setPasteModalVis(false)}> + + + + + + + + setInfoPopupVis(false)}> + setInfoPopupVis(false)}> + {t('join.about-app-title', { appName: t('join.app-name') })} + + + {t('join.about-app-para-1')} + {t('join.about-app-para-2')} + {t('join.about-app-para-3')} + {t('join.tips-title')} + - {t('join.all-green-status')} + - {t('join.dont-force-kill')} + - {t('join.background-restrictions')} + + + + + + + + + ); +}; const s: any = StyleSheet.create({ headerArea: ((windowWidth, colors) => ({ width: windowWidth * 2.5, height: windowWidth, - left: -windowWidth * .75, + left: -windowWidth * 0.75, borderBottomRightRadius: '50%', borderBottomLeftRadius: '50%', position: 'absolute', - top: windowWidth * -2/3, + top: (windowWidth * -2) / 3, backgroundColor: colors.primary, boxShadow: `0 16px ${color(colors.primary).alpha(0.3).rgb().string()}`, })) as ViewStyle, @@ -183,9 +230,7 @@ const s: any = StyleSheet.create({ }, }); - const WelcomePageButton = ({ onPress, icon, children }) => { - const { colors } = useTheme(); const { width: windowWidth } = useWindowDimensions(); @@ -193,13 +238,13 @@ const WelcomePageButton = ({ onPress, icon, children }) => { - + {children} ); -} +}; const welcomeButtonStyles: any = StyleSheet.create({ btn: ((colors): ViewStyle => ({ diff --git a/www/js/onboarding/onboardingHelper.ts b/www/js/onboarding/onboardingHelper.ts index 4110c2394..b776d65bd 100644 --- a/www/js/onboarding/onboardingHelper.ts +++ b/www/js/onboarding/onboardingHelper.ts @@ -1,9 +1,9 @@ -import { DateTime } from "luxon"; -import { getConfig, resetDataAndRefresh } from "../config/dynamicConfig"; -import { storageGet, storageSet } from "../plugin/storage"; -import { logDebug } from "../plugin/logger"; -import { readConsentState, isConsented } from "../splash/startprefs"; -import { getAngularService } from "../angular-react-helper"; +import { DateTime } from 'luxon'; +import { getConfig, resetDataAndRefresh } from '../config/dynamicConfig'; +import { storageGet, storageSet } from '../plugin/storage'; +import { logDebug } from '../plugin/logger'; +import { readConsentState, isConsented } from '../splash/startprefs'; +import { getAngularService } from '../angular-react-helper'; export const INTRO_DONE_KEY = 'intro_done'; @@ -13,53 +13,70 @@ export const INTRO_DONE_KEY = 'intro_done'; // route = SAVE_QR if config present, protocol done, but save qr not done // route = SURVEY if config present, consented and save qr done // route = DONE if onboarding is finished (intro_done marked) -export enum OnboardingRoute { WELCOME, SUMMARY, PROTOCOL, SAVE_QR, SURVEY, DONE }; -export type OnboardingState = { - opcode: string, - route: OnboardingRoute, +export enum OnboardingRoute { + WELCOME, + SUMMARY, + PROTOCOL, + SAVE_QR, + SURVEY, + DONE, } +export type OnboardingState = { + opcode: string; + route: OnboardingRoute; +}; export let summaryDone = false; -export const setSummaryDone = (b) => summaryDone = b; +export const setSummaryDone = (b) => (summaryDone = b); export let protocolDone = false; -export const setProtocolDone = (b) => protocolDone = b; +export const setProtocolDone = (b) => (protocolDone = b); export let saveQrDone = false; -export const setSaveQrDone = (b) => saveQrDone = b; +export const setSaveQrDone = (b) => (saveQrDone = b); export let registerUserDone = false; -export const setRegisterUserDone = (b) => registerUserDone = b; +export const setRegisterUserDone = (b) => (registerUserDone = b); export function getPendingOnboardingState(): Promise { - return Promise.all([getConfig(), readConsented(), readIntroDone()]).then(([config, isConsented, isIntroDone]) => { - let route: OnboardingRoute; + return Promise.all([getConfig(), readConsented(), readIntroDone()]).then( + ([config, isConsented, isIntroDone]) => { + let route: OnboardingRoute; - // backwards compat - prev. versions might have config cleared but still have intro_done set - if (!config && (isIntroDone || isConsented)) { - resetDataAndRefresh(); // if there's no config, we need to reset everything - return null; - } - - if (isIntroDone) { - route = OnboardingRoute.DONE; - } else if (!config) { - route = OnboardingRoute.WELCOME; - } else if (!protocolDone && !summaryDone) { - route = OnboardingRoute.SUMMARY; - } else if (!protocolDone) { - route = OnboardingRoute.PROTOCOL; - } else if (!saveQrDone) { - route = OnboardingRoute.SAVE_QR; - } else { - route = OnboardingRoute.SURVEY; - } + // backwards compat - prev. versions might have config cleared but still have intro_done set + if (!config && (isIntroDone || isConsented)) { + resetDataAndRefresh(); // if there's no config, we need to reset everything + return null; + } - logDebug("pending onboarding state is " + route + " intro, config, consent, qr saved : " + isIntroDone + config + isConsented + saveQrDone); + if (isIntroDone) { + route = OnboardingRoute.DONE; + } else if (!config) { + route = OnboardingRoute.WELCOME; + } else if (!protocolDone && !summaryDone) { + route = OnboardingRoute.SUMMARY; + } else if (!protocolDone) { + route = OnboardingRoute.PROTOCOL; + } else if (!saveQrDone) { + route = OnboardingRoute.SAVE_QR; + } else { + route = OnboardingRoute.SURVEY; + } - return { route, opcode: config?.joined?.opcode }; - }); -}; + logDebug( + 'pending onboarding state is ' + + route + + ' intro, config, consent, qr saved : ' + + isIntroDone + + config + + isConsented + + saveQrDone, + ); + + return { route, opcode: config?.joined?.opcode }; + }, + ); +} async function readConsented() { return readConsentState().then(isConsented) as Promise; @@ -71,13 +88,12 @@ export async function readIntroDone() { export async function markIntroDone() { const currDateTime = DateTime.now().toISO(); - return storageSet(INTRO_DONE_KEY, currDateTime) - .then(() => { - //handle "on intro" events - logDebug("intro done, calling registerPush and storeDeviceSettings"); - const PushNotify = getAngularService("PushNotify"); - const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); - PushNotify.registerPush(); - StoreSeviceSettings.storeDeviceSettings(); - }); + return storageSet(INTRO_DONE_KEY, currDateTime).then(() => { + //handle "on intro" events + logDebug('intro done, calling registerPush and storeDeviceSettings'); + const PushNotify = getAngularService('PushNotify'); + const StoreSeviceSettings = getAngularService('StoreDeviceSettings'); + PushNotify.registerPush(); + StoreSeviceSettings.storeDeviceSettings(); + }); } diff --git a/www/js/plugin/clientStats.ts b/www/js/plugin/clientStats.ts index cefaf8f22..6735ef5ff 100644 --- a/www/js/plugin/clientStats.ts +++ b/www/js/plugin/clientStats.ts @@ -1,24 +1,24 @@ -import { displayErrorMsg } from "./logger"; +import { displayErrorMsg } from './logger'; -const CLIENT_TIME = "stats/client_time"; -const CLIENT_ERROR = "stats/client_error"; -const CLIENT_NAV_EVENT = "stats/client_nav_event"; +const CLIENT_TIME = 'stats/client_time'; +const CLIENT_ERROR = 'stats/client_error'; +const CLIENT_NAV_EVENT = 'stats/client_nav_event'; export const statKeys = { - STATE_CHANGED: "state_changed", - BUTTON_FORCE_SYNC: "button_sync_forced", - CHECKED_DIARY: "checked_diary", - DIARY_TIME: "diary_time", - METRICS_TIME: "metrics_time", - CHECKED_INF_SCROLL: "checked_inf_scroll", - INF_SCROLL_TIME: "inf_scroll_time", - VERIFY_TRIP: "verify_trip", - LABEL_TAB_SWITCH: "label_tab_switch", - SELECT_LABEL: "select_label", - EXPANDED_TRIP: "expanded_trip", - NOTIFICATION_OPEN: "notification_open", - REMINDER_PREFS: "reminder_time_prefs", - MISSING_KEYS: "missing_keys" + STATE_CHANGED: 'state_changed', + BUTTON_FORCE_SYNC: 'button_sync_forced', + CHECKED_DIARY: 'checked_diary', + DIARY_TIME: 'diary_time', + METRICS_TIME: 'metrics_time', + CHECKED_INF_SCROLL: 'checked_inf_scroll', + INF_SCROLL_TIME: 'inf_scroll_time', + VERIFY_TRIP: 'verify_trip', + LABEL_TAB_SWITCH: 'label_tab_switch', + SELECT_LABEL: 'select_label', + EXPANDED_TRIP: 'expanded_trip', + NOTIFICATION_OPEN: 'notification_open', + REMINDER_PREFS: 'reminder_time_prefs', + MISSING_KEYS: 'missing_keys', }; let appVersion; @@ -28,32 +28,32 @@ export const getAppVersion = () => { appVersion = version; return version; }); -} +}; const getStatsEvent = async (name: string, reading: any) => { const ts = Date.now() / 1000; const client_app_version = await getAppVersion(); const client_os_version = window['device'].version; return { name, ts, reading, client_app_version, client_os_version }; -} +}; export const addStatReading = async (name: string, reading: any) => { const db = window['cordova']?.plugins?.BEMUserCache; const event = await getStatsEvent(name, reading); if (db) return db.putMessage(CLIENT_TIME, event); - displayErrorMsg("addStatReading: db is not defined"); -} + displayErrorMsg('addStatReading: db is not defined'); +}; export const addStatEvent = async (name: string) => { const db = window['cordova']?.plugins?.BEMUserCache; const event = await getStatsEvent(name, null); if (db) return db.putMessage(CLIENT_NAV_EVENT, event); - displayErrorMsg("addStatEvent: db is not defined"); -} + displayErrorMsg('addStatEvent: db is not defined'); +}; export const addStatError = async (name: string, errorStr: string) => { const db = window['cordova']?.plugins?.BEMUserCache; const event = await getStatsEvent(name, errorStr); if (db) return db.putMessage(CLIENT_ERROR, event); - displayErrorMsg("addStatError: db is not defined"); -} + displayErrorMsg('addStatError: db is not defined'); +}; diff --git a/www/js/plugin/logger.ts b/www/js/plugin/logger.ts index d127f5549..376c6486b 100644 --- a/www/js/plugin/logger.ts +++ b/www/js/plugin/logger.ts @@ -1,28 +1,33 @@ import angular from 'angular'; -angular.module('emission.plugin.logger', []) +angular + .module('emission.plugin.logger', []) -// explicit annotations needed in .ts files - Babel does not fix them (see webpack.prod.js) -.factory('Logger', ['$window', '$ionicPopup', function($window, $ionicPopup) { - var loggerJs: any = {}; - loggerJs.log = function(message) { + // explicit annotations needed in .ts files - Babel does not fix them (see webpack.prod.js) + .factory('Logger', [ + '$window', + '$ionicPopup', + function ($window, $ionicPopup) { + var loggerJs: any = {}; + loggerJs.log = function (message) { $window.Logger.log($window.Logger.LEVEL_DEBUG, message); - } - loggerJs.displayError = function(title, error) { - var display_msg = error.message + "\n" + error.stack; - if (!angular.isDefined(error.message)) { - display_msg = JSON.stringify(error); - } - // Check for OPcode DNE errors and prepend the title with "Invalid OPcode" - if (error.includes?.("403") || error.message?.includes?.("403")) { - title = "Invalid OPcode: " + title; - } - $ionicPopup.alert({"title": title, "template": display_msg}); - console.log(title + display_msg); - $window.Logger.log($window.Logger.LEVEL_ERROR, title + display_msg); - } - return loggerJs; -}]); + }; + loggerJs.displayError = function (title, error) { + var display_msg = error.message + '\n' + error.stack; + if (!angular.isDefined(error.message)) { + display_msg = JSON.stringify(error); + } + // Check for OPcode DNE errors and prepend the title with "Invalid OPcode" + if (error.includes?.('403') || error.message?.includes?.('403')) { + title = 'Invalid OPcode: ' + title; + } + $ionicPopup.alert({ title: title, template: display_msg }); + console.log(title + display_msg); + $window.Logger.log($window.Logger.LEVEL_ERROR, title + display_msg); + }; + return loggerJs; + }, + ]); export const logDebug = (message: string) => window['Logger'].log(window['Logger'].LEVEL_DEBUG, message); @@ -40,8 +45,8 @@ export function displayError(error: Error, title?: string) { export function displayErrorMsg(errorMsg: string, title?: string) { // Check for OPcode 'Does Not Exist' errors and prepend the title with "Invalid OPcode" - if (errorMsg.includes?.("403")) { - title = "Invalid OPcode: " + (title || ''); + if (errorMsg.includes?.('403')) { + title = 'Invalid OPcode: ' + (title || ''); } const displayMsg = `━━━━\n${title}\n━━━━\n` + errorMsg; window.alert(displayMsg); diff --git a/www/js/plugin/storage.ts b/www/js/plugin/storage.ts index 643e985e1..63604e8c1 100644 --- a/www/js/plugin/storage.ts +++ b/www/js/plugin/storage.ts @@ -1,15 +1,15 @@ -import { getAngularService } from "../angular-react-helper"; -import { addStatReading, statKeys } from "./clientStats"; -import { logDebug, logWarn } from "./logger"; +import { getAngularService } from '../angular-react-helper'; +import { addStatReading, statKeys } from './clientStats'; +import { logDebug, logWarn } from './logger'; const mungeValue = (key, value) => { let store_val = value; - if (typeof value != "object") { + if (typeof value != 'object') { store_val = {}; store_val[key] = value; } return store_val; -} +}; /* * If a non-JSON object was munged for storage, unwrap it. @@ -22,16 +22,16 @@ const unmungeValue = (key, retData) => { // it must have been an object return retData; } -} +}; -const localStorageSet = (key: string, value: {[k: string]: any}) => { +const localStorageSet = (key: string, value: { [k: string]: any }) => { //checking for a value to prevent storing undefined //case where local was null and native was undefined stored "undefined" //see discussion: https://github.com/e-mission/e-mission-phone/pull/1072#discussion_r1373753945 if (value) { localStorage.setItem(key, JSON.stringify(value)); } -} +}; const localStorageGet = (key: string) => { const value = localStorage.getItem(key); @@ -40,7 +40,7 @@ const localStorageGet = (key: string) => { } else { return null; } -} +}; /* We redundantly store data in both local and native storage. This function checks both for a value. If a value is present in only one, it copies it to the other and returns it. @@ -48,47 +48,55 @@ const localStorageGet = (key: string) => { local storage and returns it. */ function getUnifiedValue(key) { const ls_stored_val = localStorageGet(key); - return window['cordova'].plugins.BEMUserCache.getLocalStorage(key, false).then((uc_stored_val) => { - logDebug(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, + return window['cordova'].plugins.BEMUserCache.getLocalStorage(key, false).then( + (uc_stored_val) => { + logDebug(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, ls_stored_val = ${JSON.stringify(ls_stored_val)}.`); - /* compare stored values by stringified JSON equality, not by == or ===. + /* compare stored values by stringified JSON equality, not by == or ===. for objects, == or === only compares the references, not the contents of the objects */ - if (JSON.stringify(ls_stored_val) == JSON.stringify(uc_stored_val)) { - logDebug("local and native values match, already synced"); - return uc_stored_val; - } else { - // the values are different - if (ls_stored_val == null) { - // local value is missing, fill it in from native - console.assert(uc_stored_val != null, "uc_stored_val should be non-null"); - logWarn(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, + if (JSON.stringify(ls_stored_val) == JSON.stringify(uc_stored_val)) { + logDebug('local and native values match, already synced'); + return uc_stored_val; + } else { + // the values are different + if (ls_stored_val == null) { + // local value is missing, fill it in from native + console.assert(uc_stored_val != null, 'uc_stored_val should be non-null'); + logWarn(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, ls_stored_val = ${JSON.stringify(ls_stored_val)}. copying native ${key} to local...`); - localStorageSet(key, uc_stored_val); - return uc_stored_val; - } else if (uc_stored_val == null) { - // native value is missing, fill it in from local - console.assert(ls_stored_val != null); - logWarn(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, + localStorageSet(key, uc_stored_val); + return uc_stored_val; + } else if (uc_stored_val == null) { + // native value is missing, fill it in from local + console.assert(ls_stored_val != null); + logWarn(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, ls_stored_val = ${JSON.stringify(ls_stored_val)}. copying local ${key} to native...`); - return window['cordova'].plugins.BEMUserCache.putLocalStorage(key, ls_stored_val).then(() => { - // we only return the value after we have finished writing - return ls_stored_val; - }); - } - // both values are present, but they are different - console.assert(ls_stored_val != null && uc_stored_val != null, - "ls_stored_val =" + JSON.stringify(ls_stored_val) + - "uc_stored_val =" + JSON.stringify(uc_stored_val)); - logWarn(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, + return window['cordova'].plugins.BEMUserCache.putLocalStorage(key, ls_stored_val).then( + () => { + // we only return the value after we have finished writing + return ls_stored_val; + }, + ); + } + // both values are present, but they are different + console.assert( + ls_stored_val != null && uc_stored_val != null, + 'ls_stored_val =' + + JSON.stringify(ls_stored_val) + + 'uc_stored_val =' + + JSON.stringify(uc_stored_val), + ); + logWarn(`for key ${key}, uc_stored_val = ${JSON.stringify(uc_stored_val)}, ls_stored_val = ${JSON.stringify(ls_stored_val)}. copying native ${key} to local...`); - localStorageSet(key, uc_stored_val); - return uc_stored_val; - } - }); + localStorageSet(key, uc_stored_val); + return uc_stored_val; + } + }, + ); } export function storageSet(key: string, value: any) { @@ -112,7 +120,7 @@ export function storageRemove(key: string) { return window['cordova'].plugins.BEMUserCache.removeLocalStorage(key); } -export function storageClear({ local, native }: { local?: boolean, native?: boolean }) { +export function storageClear({ local, native }: { local?: boolean; native?: boolean }) { if (local) localStorage.clear(); if (native) return window['cordova'].plugins.BEMUserCache.clearAll(); return Promise.resolve(); @@ -138,42 +146,51 @@ function findMissing(fromKeys, toKeys) { } export function storageSyncLocalAndNative() { - console.log("STORAGE_PLUGIN: Called syncAllWebAndNativeValues "); - const syncKeys = window['cordova'].plugins.BEMUserCache.listAllLocalStorageKeys().then((nativeKeys) => { - console.log("STORAGE_PLUGIN: native plugin returned"); - const webKeys = Object.keys(localStorage); - // I thought about iterating through the lists and copying over - // only missing values, etc but `getUnifiedValue` already does - // that, and we don't need to copy it - // so let's just find all the missing values and read them - logDebug("STORAGE_PLUGIN: Comparing web keys " + webKeys + " with " + nativeKeys); - let [foundNative, missingNative] = findMissing(webKeys, nativeKeys); - let [foundWeb, missingWeb] = findMissing(nativeKeys, webKeys); - logDebug("STORAGE_PLUGIN: Found native keys " + foundNative + " missing native keys " + missingNative); - logDebug("STORAGE_PLUGIN: Found web keys " + foundWeb + " missing web keys " + missingWeb); - const allMissing = missingNative.concat(missingWeb); - logDebug("STORAGE_PLUGIN: Syncing all missing keys " + allMissing); - allMissing.forEach(getUnifiedValue); - if (allMissing.length != 0) { - addStatReading(statKeys.MISSING_KEYS, { - "type": "local_storage_mismatch", - "allMissingLength": allMissing.length, - "missingWebLength": missingWeb.length, - "missingNativeLength": missingNative.length, - "foundWebLength": foundWeb.length, - "foundNativeLength": foundNative.length, - "allMissing": allMissing, - }).then(logDebug("Logged missing keys to client stats")); - } - }); - const listAllKeys = window['cordova'].plugins.BEMUserCache.listAllUniqueKeys().then((nativeKeys) => { - logDebug("STORAGE_PLUGIN: For the record, all unique native keys are " + nativeKeys); - if (nativeKeys.length == 0) { - addStatReading(statKeys.MISSING_KEYS, { - "type": "all_native", - }).then(logDebug("Logged all missing native keys to client stats")); - } - }); + console.log('STORAGE_PLUGIN: Called syncAllWebAndNativeValues '); + const syncKeys = window['cordova'].plugins.BEMUserCache.listAllLocalStorageKeys().then( + (nativeKeys) => { + console.log('STORAGE_PLUGIN: native plugin returned'); + const webKeys = Object.keys(localStorage); + // I thought about iterating through the lists and copying over + // only missing values, etc but `getUnifiedValue` already does + // that, and we don't need to copy it + // so let's just find all the missing values and read them + logDebug('STORAGE_PLUGIN: Comparing web keys ' + webKeys + ' with ' + nativeKeys); + let [foundNative, missingNative] = findMissing(webKeys, nativeKeys); + let [foundWeb, missingWeb] = findMissing(nativeKeys, webKeys); + logDebug( + 'STORAGE_PLUGIN: Found native keys ' + + foundNative + + ' missing native keys ' + + missingNative, + ); + logDebug('STORAGE_PLUGIN: Found web keys ' + foundWeb + ' missing web keys ' + missingWeb); + const allMissing = missingNative.concat(missingWeb); + logDebug('STORAGE_PLUGIN: Syncing all missing keys ' + allMissing); + allMissing.forEach(getUnifiedValue); + if (allMissing.length != 0) { + addStatReading(statKeys.MISSING_KEYS, { + type: 'local_storage_mismatch', + allMissingLength: allMissing.length, + missingWebLength: missingWeb.length, + missingNativeLength: missingNative.length, + foundWebLength: foundWeb.length, + foundNativeLength: foundNative.length, + allMissing: allMissing, + }).then(logDebug('Logged missing keys to client stats')); + } + }, + ); + const listAllKeys = window['cordova'].plugins.BEMUserCache.listAllUniqueKeys().then( + (nativeKeys) => { + logDebug('STORAGE_PLUGIN: For the record, all unique native keys are ' + nativeKeys); + if (nativeKeys.length == 0) { + addStatReading(statKeys.MISSING_KEYS, { + type: 'all_native', + }).then(logDebug('Logged all missing native keys to client stats')); + } + }, + ); return Promise.all([syncKeys, listAllKeys]); } diff --git a/www/js/services.js b/www/js/services.js index 0c9c6e2ac..444ff94b7 100644 --- a/www/js/services.js +++ b/www/js/services.js @@ -3,30 +3,39 @@ import angular from 'angular'; import { getRawEntries } from './commHelper'; -angular.module('emission.services', ['emission.plugin.logger']) +angular + .module('emission.services', ['emission.plugin.logger']) -.service('ReferHelper', function($http) { - - this.habiticaRegister = function(groupid, successCallback, errorCallback) { - window.cordova.plugins.BEMServerComm.getUserPersonalData("/join.group/"+groupid, successCallback, errorCallback); + .service('ReferHelper', function ($http) { + this.habiticaRegister = function (groupid, successCallback, errorCallback) { + window.cordova.plugins.BEMServerComm.getUserPersonalData( + '/join.group/' + groupid, + successCallback, + errorCallback, + ); }; - this.joinGroup = function(groupid, userid) { - - // TODO: - return new Promise(function(resolve, reject) { - window.cordova.plugins.BEMServerComm.postUserPersonalData("/join.group/"+groupid, "inviter", userid, resolve, reject); - }) + this.joinGroup = function (groupid, userid) { + // TODO: + return new Promise(function (resolve, reject) { + window.cordova.plugins.BEMServerComm.postUserPersonalData( + '/join.group/' + groupid, + 'inviter', + userid, + resolve, + reject, + ); + }); - //function firstUpperCase(string) { - // return string[0].toUpperCase() + string.slice(1); - //}*/ - } -}) -.service('UnifiedDataLoader', function($window, Logger) { - var combineWithDedup = function(list1, list2) { + //function firstUpperCase(string) { + // return string[0].toUpperCase() + string.slice(1); + //}*/ + }; + }) + .service('UnifiedDataLoader', function ($window, Logger) { + var combineWithDedup = function (list1, list2) { var combinedList = list1.concat(list2); - return combinedList.filter(function(value, i, array) { - var firstIndexOfValue = array.findIndex(function(element, index, array) { + return combinedList.filter(function (value, i, array) { + var firstIndexOfValue = array.findIndex(function (element, index, array) { return element.metadata.write_ts == value.metadata.write_ts; }); return firstIndexOfValue == i; @@ -34,259 +43,296 @@ angular.module('emission.services', ['emission.plugin.logger']) }; // TODO: generalize to iterable of promises - var combinedPromise = function(localPromise, remotePromise, combiner) { - return new Promise(function(resolve, reject) { - var localResult = []; - var localError = null; + var combinedPromise = function (localPromise, remotePromise, combiner) { + return new Promise(function (resolve, reject) { + var localResult = []; + var localError = null; - var remoteResult = []; - var remoteError = null; + var remoteResult = []; + var remoteError = null; - var localPromiseDone = false; - var remotePromiseDone = false; + var localPromiseDone = false; + var remotePromiseDone = false; - var checkAndResolve = function() { - if (localPromiseDone && remotePromiseDone) { - // time to return from this promise - if (localError && remoteError) { - reject([localError, remoteError]); - } else { - Logger.log("About to dedup localResult = "+localResult.length - +"remoteResult = "+remoteResult.length); - var dedupedList = combiner(localResult, remoteResult); - Logger.log("Deduped list = "+dedupedList.length); - resolve(dedupedList); - } + var checkAndResolve = function () { + if (localPromiseDone && remotePromiseDone) { + // time to return from this promise + if (localError && remoteError) { + reject([localError, remoteError]); + } else { + Logger.log( + 'About to dedup localResult = ' + + localResult.length + + 'remoteResult = ' + + remoteResult.length, + ); + var dedupedList = combiner(localResult, remoteResult); + Logger.log('Deduped list = ' + dedupedList.length); + resolve(dedupedList); } - }; + } + }; - localPromise.then(function(currentLocalResult) { - localResult = currentLocalResult; - localPromiseDone = true; - }, function(error) { - localResult = []; - localError = error; - localPromiseDone = true; - }).then(checkAndResolve); + localPromise + .then( + function (currentLocalResult) { + localResult = currentLocalResult; + localPromiseDone = true; + }, + function (error) { + localResult = []; + localError = error; + localPromiseDone = true; + }, + ) + .then(checkAndResolve); - remotePromise.then(function(currentRemoteResult) { - remoteResult = currentRemoteResult; - remotePromiseDone = true; - }, function(error) { - remoteResult = []; - remoteError = error; - remotePromiseDone = true; - }).then(checkAndResolve); - }) - } + remotePromise + .then( + function (currentRemoteResult) { + remoteResult = currentRemoteResult; + remotePromiseDone = true; + }, + function (error) { + remoteResult = []; + remoteError = error; + remotePromiseDone = true; + }, + ) + .then(checkAndResolve); + }); + }; // TODO: Generalize this to work for both sensor data and messages // Do we even need to separate the two kinds of data? // Alternatively, we can maintain another mapping between key -> type // Probably in www/json... - this.getUnifiedSensorDataForInterval = function(key, tq) { - var localPromise = $window.cordova.plugins.BEMUserCache.getSensorDataForInterval(key, tq, true); - var remotePromise = getRawEntries([key], tq.startTs, tq.endTs) - .then(function(serverResponse) { - return serverResponse.phone_data; - }); - return combinedPromise(localPromise, remotePromise, combineWithDedup); + this.getUnifiedSensorDataForInterval = function (key, tq) { + var localPromise = $window.cordova.plugins.BEMUserCache.getSensorDataForInterval( + key, + tq, + true, + ); + var remotePromise = getRawEntries([key], tq.startTs, tq.endTs).then( + function (serverResponse) { + return serverResponse.phone_data; + }, + ); + return combinedPromise(localPromise, remotePromise, combineWithDedup); }; - this.getUnifiedMessagesForInterval = function(key, tq, withMetadata) { + this.getUnifiedMessagesForInterval = function (key, tq, withMetadata) { var localPromise = $window.cordova.plugins.BEMUserCache.getMessagesForInterval(key, tq, true); - var remotePromise = getRawEntries([key], tq.startTs, tq.endTs) - .then(function(serverResponse) { - return serverResponse.phone_data; - }); + var remotePromise = getRawEntries([key], tq.startTs, tq.endTs).then( + function (serverResponse) { + return serverResponse.phone_data; + }, + ); return combinedPromise(localPromise, remotePromise, combineWithDedup); - } -}) -.service('ControlHelper', function($window, - $ionicPopup, - Logger) { - - this.writeFile = function(fileEntry, resultList) { + }; + }) + .service('ControlHelper', function ($window, $ionicPopup, Logger) { + this.writeFile = function (fileEntry, resultList) { // Create a FileWriter object for our FileEntry (log.txt). - } + }; - this.getMyData = function(startTs) { - var fmt = "YYYY-MM-DD"; - // We are only retrieving data for a single day to avoid - // running out of memory on the phone - var startMoment = moment(startTs); - var endMoment = moment(startTs).endOf("day"); - var dumpFile = startMoment.format(fmt) + "." - + endMoment.format(fmt) - + ".timeline"; - alert("Going to retrieve data to "+dumpFile); + this.getMyData = function (startTs) { + var fmt = 'YYYY-MM-DD'; + // We are only retrieving data for a single day to avoid + // running out of memory on the phone + var startMoment = moment(startTs); + var endMoment = moment(startTs).endOf('day'); + var dumpFile = startMoment.format(fmt) + '.' + endMoment.format(fmt) + '.timeline'; + alert('Going to retrieve data to ' + dumpFile); - var writeDumpFile = function(result) { - return new Promise(function(resolve, reject) { - var resultList = result.phone_data; - window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { - console.log('file system open: ' + fs.name); - fs.root.getFile(dumpFile, { create: true, exclusive: false }, function (fileEntry) { - console.log("fileEntry "+fileEntry.nativeURL+" is file?" + fileEntry.isFile.toString()); - fileEntry.createWriter(function (fileWriter) { - fileWriter.onwriteend = function() { - console.log("Successful file write..."); - resolve(); - // readFile(fileEntry); - }; + var writeDumpFile = function (result) { + return new Promise(function (resolve, reject) { + var resultList = result.phone_data; + window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function (fs) { + console.log('file system open: ' + fs.name); + fs.root.getFile(dumpFile, { create: true, exclusive: false }, function (fileEntry) { + console.log( + 'fileEntry ' + fileEntry.nativeURL + ' is file?' + fileEntry.isFile.toString(), + ); + fileEntry.createWriter(function (fileWriter) { + fileWriter.onwriteend = function () { + console.log('Successful file write...'); + resolve(); + // readFile(fileEntry); + }; - fileWriter.onerror = function (e) { - console.log("Failed file write: " + e.toString()); - reject(); - }; + fileWriter.onerror = function (e) { + console.log('Failed file write: ' + e.toString()); + reject(); + }; - // If data object is not passed in, - // create a new Blob instead. - var dataObj = new Blob([JSON.stringify(resultList, null, 2)], - { type: 'application/json' }); - fileWriter.write(dataObj); - }); - // this.writeFile(fileEntry, resultList); + // If data object is not passed in, + // create a new Blob instead. + var dataObj = new Blob([JSON.stringify(resultList, null, 2)], { + type: 'application/json', }); + fileWriter.write(dataObj); }); + // this.writeFile(fileEntry, resultList); }); - } - + }); + }); + }; - var emailData = function(result) { - return new Promise(function(resolve, reject) { - window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { - console.log("During email, file system open: "+fs.name); - fs.root.getFile(dumpFile, null, function(fileEntry) { - console.log("fileEntry "+fileEntry.nativeURL+" is file?"+fileEntry.isFile.toString()); - fileEntry.file(function (file) { - var reader = new FileReader(); + var emailData = function (result) { + return new Promise(function (resolve, reject) { + window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function (fs) { + console.log('During email, file system open: ' + fs.name); + fs.root.getFile(dumpFile, null, function (fileEntry) { + console.log( + 'fileEntry ' + fileEntry.nativeURL + ' is file?' + fileEntry.isFile.toString(), + ); + fileEntry.file( + function (file) { + var reader = new FileReader(); - reader.onloadend = function() { - console.log("Successful file read with " + this.result.length +" characters"); - var dataArray = JSON.parse(this.result); - console.log("Successfully read resultList of size "+dataArray.length); - // displayFileData(fileEntry.fullPath + ": " + this.result); - var attachFile = fileEntry.nativeURL; - if (ionic.Platform.isAndroid()) { - // At least on nexus, getting a temporary file puts it into - // the cache, so I can hardcode that for now - attachFile = "app://cache/"+dumpFile; - } - if (ionic.Platform.isIOS()) { - alert(i18next.t('email-service.email-account-mail-app')); - } - var email = { - attachments: [ - attachFile - ], - subject: i18next.t('email-service.email-data.subject-data-dump-from-to', {start: startMoment.format(fmt),end: endMoment.format(fmt)}), - body: i18next.t('email-service.email-data.body-data-consists-of-list-of-entries') - } - $window.cordova.plugins.email.open(email).then(resolve()); + reader.onloadend = function () { + console.log('Successful file read with ' + this.result.length + ' characters'); + var dataArray = JSON.parse(this.result); + console.log('Successfully read resultList of size ' + dataArray.length); + // displayFileData(fileEntry.fullPath + ": " + this.result); + var attachFile = fileEntry.nativeURL; + if (ionic.Platform.isAndroid()) { + // At least on nexus, getting a temporary file puts it into + // the cache, so I can hardcode that for now + attachFile = 'app://cache/' + dumpFile; + } + if (ionic.Platform.isIOS()) { + alert(i18next.t('email-service.email-account-mail-app')); } - reader.readAsText(file); - }, function(error) { - $ionicPopup.alert({title: "Error while downloading JSON dump", - template: error}); - reject(error); + var email = { + attachments: [attachFile], + subject: i18next.t('email-service.email-data.subject-data-dump-from-to', { + start: startMoment.format(fmt), + end: endMoment.format(fmt), + }), + body: i18next.t( + 'email-service.email-data.body-data-consists-of-list-of-entries', + ), + }; + $window.cordova.plugins.email.open(email).then(resolve()); + }; + reader.readAsText(file); + }, + function (error) { + $ionicPopup.alert({ + title: 'Error while downloading JSON dump', + template: error, }); - }); - }); + reject(error); + }, + ); }); - }; + }); + }); + }; - getRawEntries(null, startMoment.unix(), endMoment.unix()) - .then(writeDumpFile) - .then(emailData) - .then(function() { - Logger.log("Email queued successfully"); - }) - .catch(function(error) { - Logger.displayError("Error emailing JSON dump", error); - }) + getRawEntries(null, startMoment.unix(), endMoment.unix()) + .then(writeDumpFile) + .then(emailData) + .then(function () { + Logger.log('Email queued successfully'); + }) + .catch(function (error) { + Logger.displayError('Error emailing JSON dump', error); + }); }; - this.getOPCode = function() { + this.getOPCode = function () { return window.cordova.plugins.OPCodeAuth.getOPCode(); }; - this.getSettings = function() { + this.getSettings = function () { return window.cordova.plugins.BEMConnectionSettings.getSettings(); }; + }) -}) - -.factory('Chats', function() { - // Might use a resource here that returns a JSON array + .factory('Chats', function () { + // Might use a resource here that returns a JSON array - // Some fake testing data - var chats = [{ - id: 0, - name: 'Ben Sparrow', - lastText: 'You on your way?', - face: 'img/ben.png' - }, { - id: 1, - name: 'Max Lynx', - lastText: 'Hey, it\'s me', - face: 'img/max.png' - }, { - id: 2, - name: 'Adam Bradleyson', - lastText: 'I should buy a boat', - face: 'img/adam.jpg' - }, { - id: 3, - name: 'Perry Governor', - lastText: 'Look at my mukluks!', - face: 'img/perry.png' - }, { - id: 4, - name: 'Mike Harrington', - lastText: 'This is wicked good ice cream.', - face: 'img/mike.png' - }, { - id: 5, - name: 'Ben Sparrow', - lastText: 'You on your way again?', - face: 'img/ben.png' - }, { - id: 6, - name: 'Max Lynx', - lastText: 'Hey, it\'s me again', - face: 'img/max.png' - }, { - id: 7, - name: 'Adam Bradleyson', - lastText: 'I should buy a boat again', - face: 'img/adam.jpg' - }, { - id: 8, - name: 'Perry Governor', - lastText: 'Look at my mukluks again!', - face: 'img/perry.png' - }, { - id: 9, - name: 'Mike Harrington', - lastText: 'This is wicked good ice cream again.', - face: 'img/mike.png' - }]; + // Some fake testing data + var chats = [ + { + id: 0, + name: 'Ben Sparrow', + lastText: 'You on your way?', + face: 'img/ben.png', + }, + { + id: 1, + name: 'Max Lynx', + lastText: "Hey, it's me", + face: 'img/max.png', + }, + { + id: 2, + name: 'Adam Bradleyson', + lastText: 'I should buy a boat', + face: 'img/adam.jpg', + }, + { + id: 3, + name: 'Perry Governor', + lastText: 'Look at my mukluks!', + face: 'img/perry.png', + }, + { + id: 4, + name: 'Mike Harrington', + lastText: 'This is wicked good ice cream.', + face: 'img/mike.png', + }, + { + id: 5, + name: 'Ben Sparrow', + lastText: 'You on your way again?', + face: 'img/ben.png', + }, + { + id: 6, + name: 'Max Lynx', + lastText: "Hey, it's me again", + face: 'img/max.png', + }, + { + id: 7, + name: 'Adam Bradleyson', + lastText: 'I should buy a boat again', + face: 'img/adam.jpg', + }, + { + id: 8, + name: 'Perry Governor', + lastText: 'Look at my mukluks again!', + face: 'img/perry.png', + }, + { + id: 9, + name: 'Mike Harrington', + lastText: 'This is wicked good ice cream again.', + face: 'img/mike.png', + }, + ]; - return { - all: function() { - return chats; - }, - remove: function(chat) { - chats.splice(chats.indexOf(chat), 1); - }, - get: function(chatId) { - for (var i = 0; i < chats.length; i++) { - if (chats[i].id === parseInt(chatId)) { - return chats[i]; + return { + all: function () { + return chats; + }, + remove: function (chat) { + chats.splice(chats.indexOf(chat), 1); + }, + get: function (chatId) { + for (var i = 0; i < chats.length; i++) { + if (chats[i].id === parseInt(chatId)) { + return chats[i]; + } } - } - return null; - } - }; -}); + return null; + }, + }; + }); diff --git a/www/js/splash/customURL.ts b/www/js/splash/customURL.ts index bc3d93f3e..d351fcc0b 100644 --- a/www/js/splash/customURL.ts +++ b/www/js/splash/customURL.ts @@ -1,22 +1,24 @@ type UrlComponents = { - [key : string] : string -} - -type OnLaunchCustomURL = (rawUrl: string, callback: (url: string, urlComponents: UrlComponents) => void ) => void; + [key: string]: string; +}; +type OnLaunchCustomURL = ( + rawUrl: string, + callback: (url: string, urlComponents: UrlComponents) => void, +) => void; export const onLaunchCustomURL: OnLaunchCustomURL = (rawUrl, handler) => { - try { - const url = rawUrl.split('//')[1]; - const [ route, paramString ] = url.split('?'); - const paramsList = paramString.split('&'); - const urlComponents: UrlComponents = { route : route }; - for (let i = 0; i < paramsList.length; i++) { - const [key, value] = paramsList[i].split('='); - urlComponents[key] = value; - } - handler(url, urlComponents); - }catch { - console.log('not a valid url'); + try { + const url = rawUrl.split('//')[1]; + const [route, paramString] = url.split('?'); + const paramsList = paramString.split('&'); + const urlComponents: UrlComponents = { route: route }; + for (let i = 0; i < paramsList.length; i++) { + const [key, value] = paramsList[i].split('='); + urlComponents[key] = value; } -}; \ No newline at end of file + handler(url, urlComponents); + } catch { + console.log('not a valid url'); + } +}; diff --git a/www/js/splash/localnotify.js b/www/js/splash/localnotify.js index c96ba827d..9f3db3ab3 100644 --- a/www/js/splash/localnotify.js +++ b/www/js/splash/localnotify.js @@ -7,102 +7,132 @@ import angular from 'angular'; -angular.module('emission.splash.localnotify', ['emission.plugin.logger', - 'ionic-toast']) -.factory('LocalNotify', function($window, $ionicPlatform, $ionicPopup, - $state, $rootScope, ionicToast, Logger) { - var localNotify = {}; +angular + .module('emission.splash.localnotify', ['emission.plugin.logger', 'ionic-toast']) + .factory( + 'LocalNotify', + function ($window, $ionicPlatform, $ionicPopup, $state, $rootScope, ionicToast, Logger) { + var localNotify = {}; - /* - * Return the state to redirect to, undefined otherwise - */ - localNotify.getRedirectState = function(data) { - // TODO: Think whether this should be in data or in category - if (angular.isDefined(data)) { - return [data.redirectTo, data.redirectParams]; - } - return undefined; - } + /* + * Return the state to redirect to, undefined otherwise + */ + localNotify.getRedirectState = function (data) { + // TODO: Think whether this should be in data or in category + if (angular.isDefined(data)) { + return [data.redirectTo, data.redirectParams]; + } + return undefined; + }; - localNotify.handleLaunch = function(targetState, targetParams) { - $rootScope.redirectTo = targetState; - $rootScope.redirectParams = targetParams; - $state.go(targetState, targetParams, { reload : true }); - } + localNotify.handleLaunch = function (targetState, targetParams) { + $rootScope.redirectTo = targetState; + $rootScope.redirectParams = targetParams; + $state.go(targetState, targetParams, { reload: true }); + }; - localNotify.handlePrompt = function(notification, targetState, targetParams) { - Logger.log("Prompting for notification "+notification.title+" and text "+notification.text); - var promptPromise = $ionicPopup.show({title: notification.title, - template: notification.text, - buttons: [{ - text: 'Handle', - type: 'button-positive', - onTap: function(e) { - // e.preventDefault() will stop the popup from closing when tapped. - return true; + localNotify.handlePrompt = function (notification, targetState, targetParams) { + Logger.log( + 'Prompting for notification ' + notification.title + ' and text ' + notification.text, + ); + var promptPromise = $ionicPopup.show({ + title: notification.title, + template: notification.text, + buttons: [ + { + text: 'Handle', + type: 'button-positive', + onTap: function (e) { + // e.preventDefault() will stop the popup from closing when tapped. + return true; + }, + }, + { + text: 'Ignore', + type: 'button-positive', + onTap: function (e) { + return false; + }, + }, + ], + }); + promptPromise.then(function (handle) { + if (handle == true) { + localNotify.handleLaunch(targetState, targetParams); + } else { + Logger.log( + 'Ignoring notification ' + notification.title + ' and text ' + notification.text, + ); } - }, { - text: 'Ignore', - type: 'button-positive', - onTap: function(e) { - return false; - } - }] - }); - promptPromise.then(function(handle) { - if (handle == true) { - localNotify.handleLaunch(targetState, targetParams); - } else { - Logger.log("Ignoring notification "+notification.title+" and text "+notification.text); - } - }); - } + }); + }; - localNotify.handleNotification = function(notification,state,data) { - // Comment this out for ease of testing. But in the real world, we do in fact want to - // cancel the notification to avoid "hey! I just fixed this, why is the notification still around!" - // issues - // $window.cordova.plugins.notification.local.cancel(notification.id); - let redirectData = notification; - if (state.event == 'action') { - redirectData = notification.data.action; - } - var [targetState, targetParams] = localNotify.getRedirectState(redirectData); - Logger.log("targetState = "+targetState); - if (angular.isDefined(targetState)) { - if (state.foreground == true) { - localNotify.handlePrompt(notification, targetState, targetParams); - } else { - localNotify.handleLaunch(targetState, targetParams); - } - } - } + localNotify.handleNotification = function (notification, state, data) { + // Comment this out for ease of testing. But in the real world, we do in fact want to + // cancel the notification to avoid "hey! I just fixed this, why is the notification still around!" + // issues + // $window.cordova.plugins.notification.local.cancel(notification.id); + let redirectData = notification; + if (state.event == 'action') { + redirectData = notification.data.action; + } + var [targetState, targetParams] = localNotify.getRedirectState(redirectData); + Logger.log('targetState = ' + targetState); + if (angular.isDefined(targetState)) { + if (state.foreground == true) { + localNotify.handlePrompt(notification, targetState, targetParams); + } else { + localNotify.handleLaunch(targetState, targetParams); + } + } + }; - localNotify.registerRedirectHandler = function() { - Logger.log( "registerUserResponse received!" ); - $window.cordova.plugins.notification.local.on('action', function (notification, state, data) { - localNotify.handleNotification(notification, state, data); - }); - $window.cordova.plugins.notification.local.on('clear', function (notification, state, data) { - // alert("notification cleared, no report"); - }); - $window.cordova.plugins.notification.local.on('cancel', function (notification, state, data) { - // alert("notification cancelled, no report"); - }); - $window.cordova.plugins.notification.local.on('trigger', function (notification, state, data) { - ionicToast.show(`Notification: ${notification.title}\n${notification.text}`, 'bottom', false, 250000); - localNotify.handleNotification(notification, state, data); - }); - $window.cordova.plugins.notification.local.on('click', function (notification, state, data) { - localNotify.handleNotification(notification, state, data); - }); - } + localNotify.registerRedirectHandler = function () { + Logger.log('registerUserResponse received!'); + $window.cordova.plugins.notification.local.on( + 'action', + function (notification, state, data) { + localNotify.handleNotification(notification, state, data); + }, + ); + $window.cordova.plugins.notification.local.on( + 'clear', + function (notification, state, data) { + // alert("notification cleared, no report"); + }, + ); + $window.cordova.plugins.notification.local.on( + 'cancel', + function (notification, state, data) { + // alert("notification cancelled, no report"); + }, + ); + $window.cordova.plugins.notification.local.on( + 'trigger', + function (notification, state, data) { + ionicToast.show( + `Notification: ${notification.title}\n${notification.text}`, + 'bottom', + false, + 250000, + ); + localNotify.handleNotification(notification, state, data); + }, + ); + $window.cordova.plugins.notification.local.on( + 'click', + function (notification, state, data) { + localNotify.handleNotification(notification, state, data); + }, + ); + }; - $ionicPlatform.ready().then(function() { - localNotify.registerRedirectHandler(); - Logger.log("finished registering handlers, about to fire queued events"); - $window.cordova.plugins.notification.local.fireQueuedEvents(); - }); + $ionicPlatform.ready().then(function () { + localNotify.registerRedirectHandler(); + Logger.log('finished registering handlers, about to fire queued events'); + $window.cordova.plugins.notification.local.fireQueuedEvents(); + }); - return localNotify; -}); + return localNotify; + }, + ); diff --git a/www/js/splash/notifScheduler.js b/www/js/splash/notifScheduler.js index 22f8407ee..9ceb0a23e 100644 --- a/www/js/splash/notifScheduler.js +++ b/www/js/splash/notifScheduler.js @@ -5,12 +5,10 @@ import { getConfig } from '../config/dynamicConfig'; import { addStatReading, statKeys } from '../plugin/clientStats'; import { getUser, updateUser } from '../commHelper'; -angular.module('emission.splash.notifscheduler', - ['emission.services', - 'emission.plugin.logger']) - -.factory('NotificationScheduler', function($http, $window, $ionicPlatform, Logger) { +angular + .module('emission.splash.notifscheduler', ['emission.services', 'emission.plugin.logger']) + .factory('NotificationScheduler', function ($http, $window, $ionicPlatform, Logger) { const scheduler = {}; let _config; let scheduledPromise = new Promise((rs) => rs()); @@ -18,36 +16,36 @@ angular.module('emission.splash.notifscheduler', // like python range() function range(start, stop, step) { - let a = [start], b = start; - while (b < stop) - a.push(b += step || 1); - return a; + let a = [start], + b = start; + while (b < stop) a.push((b += step || 1)); + return a; } // returns an array of moment objects, for all times that notifications should be sent const calcNotifTimes = (scheme, dayZeroDate, timeOfDay) => { - const notifTimes = []; - for (const s of scheme.schedule) { - // the days to send notifications, as integers, relative to day zero - const notifDays = range(s.start, s.end, s.intervalInDays); - for (const d of notifDays) { - const date = moment(dayZeroDate).add(d, 'days').format('YYYY-MM-DD') - const notifTime = moment(date+' '+timeOfDay, 'YYYY-MM-DD HH:mm'); - notifTimes.push(notifTime); - } + const notifTimes = []; + for (const s of scheme.schedule) { + // the days to send notifications, as integers, relative to day zero + const notifDays = range(s.start, s.end, s.intervalInDays); + for (const d of notifDays) { + const date = moment(dayZeroDate).add(d, 'days').format('YYYY-MM-DD'); + const notifTime = moment(date + ' ' + timeOfDay, 'YYYY-MM-DD HH:mm'); + notifTimes.push(notifTime); } - return notifTimes; - } + } + return notifTimes; + }; // returns true if all expected times are already scheduled const areAlreadyScheduled = (notifs, expectedTimes) => { - for (const t of expectedTimes) { - if (!notifs.some((n) => moment(n.at).isSame(t))) { - return false; - } + for (const t of expectedTimes) { + if (!notifs.some((n) => moment(n.at).isSame(t))) { + return false; } - return true; - } + } + return true; + }; /* remove notif actions as they do not work, can restore post routing migration */ // const setUpActions = () => { @@ -62,155 +60,155 @@ angular.module('emission.splash.notifscheduler', // } function debugGetScheduled(prefix) { - cordova.plugins.notification.local.getScheduled((notifs) => { - if (!notifs?.length) - return Logger.log(`${prefix}, there are no scheduled notifications`); - const time = moment(notifs?.[0].trigger.at).format('HH:mm'); - //was in plugin, changed to scheduler - scheduler.scheduledNotifs = notifs.map((n) => { - const time = moment(n.trigger.at).format('LT'); - const date = moment(n.trigger.at).format('LL'); - return { - key: date, - val: time - } - }); - //have the list of scheduled show up in this log - Logger.log(`${prefix}, there are ${notifs.length} scheduled notifications at ${time} first is ${scheduler.scheduledNotifs[0].key} at ${scheduler.scheduledNotifs[0].val}`); + cordova.plugins.notification.local.getScheduled((notifs) => { + if (!notifs?.length) return Logger.log(`${prefix}, there are no scheduled notifications`); + const time = moment(notifs?.[0].trigger.at).format('HH:mm'); + //was in plugin, changed to scheduler + scheduler.scheduledNotifs = notifs.map((n) => { + const time = moment(n.trigger.at).format('LT'); + const date = moment(n.trigger.at).format('LL'); + return { + key: date, + val: time, + }; }); + //have the list of scheduled show up in this log + Logger.log( + `${prefix}, there are ${notifs.length} scheduled notifications at ${time} first is ${scheduler.scheduledNotifs[0].key} at ${scheduler.scheduledNotifs[0].val}`, + ); + }); } //new method to fetch notifications - scheduler.getScheduledNotifs = function() { - return new Promise((resolve, reject) => { - /* if the notifications are still in active scheduling it causes problems + scheduler.getScheduledNotifs = function () { + return new Promise((resolve, reject) => { + /* if the notifications are still in active scheduling it causes problems anywhere from 0-n of the scheduled notifs are displayed if actively scheduling, wait for the scheduledPromise to resolve before fetching prevents such errors */ - if(isScheduling) - { - console.log("requesting fetch while still actively scheduling, waiting on scheduledPromise"); - scheduledPromise.then(() => { - getNotifs().then((notifs) => { - console.log("done scheduling notifs", notifs); - resolve(notifs); - }) - }) - } - else{ - getNotifs().then((notifs) => { - resolve(notifs); - }) - } - }) - } + if (isScheduling) { + console.log( + 'requesting fetch while still actively scheduling, waiting on scheduledPromise', + ); + scheduledPromise.then(() => { + getNotifs().then((notifs) => { + console.log('done scheduling notifs', notifs); + resolve(notifs); + }); + }); + } else { + getNotifs().then((notifs) => { + resolve(notifs); + }); + } + }); + }; //get scheduled notifications from cordova plugin and format them - const getNotifs = function() { - return new Promise((resolve, reject) => { - cordova.plugins.notification.local.getScheduled((notifs) => { - if (!notifs?.length){ - console.log("there are no notifications"); - resolve([]); //if none, return empty array - } - - const notifSubset = notifs.slice(0, 5); //prevent near-infinite listing - let scheduledNotifs = []; - scheduledNotifs = notifSubset.map((n) => { - const time = moment(n.trigger.at).format('LT'); - const date = moment(n.trigger.at).format('LL'); - return { - key: date, - val: time - } - }); - resolve(scheduledNotifs); - }); - }) - } + const getNotifs = function () { + return new Promise((resolve, reject) => { + cordova.plugins.notification.local.getScheduled((notifs) => { + if (!notifs?.length) { + console.log('there are no notifications'); + resolve([]); //if none, return empty array + } + + const notifSubset = notifs.slice(0, 5); //prevent near-infinite listing + let scheduledNotifs = []; + scheduledNotifs = notifSubset.map((n) => { + const time = moment(n.trigger.at).format('LT'); + const date = moment(n.trigger.at).format('LL'); + return { + key: date, + val: time, + }; + }); + resolve(scheduledNotifs); + }); + }); + }; // schedules the notifications using the cordova plugin const scheduleNotifs = (scheme, notifTimes) => { - return new Promise((rs) => { - isScheduling = true; - const localeCode = i18next.resolvedLanguage; - const nots = notifTimes.map((n) => { - const nDate = n.toDate(); - const seconds = nDate.getTime() / 1000; - return { - id: seconds, - title: scheme.title[localeCode], - text: scheme.text[localeCode], - trigger: {at: nDate}, - // actions: 'reminder-actions', - // data: { - // action: { - // redirectTo: 'root.main.control', - // redirectParams: { - // openTimeOfDayPicker: true - // } - // } - // } - } - }); - cordova.plugins.notification.local.cancelAll(() => { - debugGetScheduled("After cancelling"); - cordova.plugins.notification.local.schedule(nots, () => { - debugGetScheduled("After scheduling"); - isScheduling = false; - rs(); //scheduling promise resolved here - }); - }); + return new Promise((rs) => { + isScheduling = true; + const localeCode = i18next.resolvedLanguage; + const nots = notifTimes.map((n) => { + const nDate = n.toDate(); + const seconds = nDate.getTime() / 1000; + return { + id: seconds, + title: scheme.title[localeCode], + text: scheme.text[localeCode], + trigger: { at: nDate }, + // actions: 'reminder-actions', + // data: { + // action: { + // redirectTo: 'root.main.control', + // redirectParams: { + // openTimeOfDayPicker: true + // } + // } + // } + }; }); - } + cordova.plugins.notification.local.cancelAll(() => { + debugGetScheduled('After cancelling'); + cordova.plugins.notification.local.schedule(nots, () => { + debugGetScheduled('After scheduling'); + isScheduling = false; + rs(); //scheduling promise resolved here + }); + }); + }); + }; // determines when notifications are needed, and schedules them if not already scheduled const update = async () => { - const { reminder_assignment, - reminder_join_date, - reminder_time_of_day} = await scheduler.getReminderPrefs(); - const scheme = _config.reminderSchemes[reminder_assignment]; - const notifTimes = calcNotifTimes(scheme, reminder_join_date, reminder_time_of_day); + const { reminder_assignment, reminder_join_date, reminder_time_of_day } = + await scheduler.getReminderPrefs(); + const scheme = _config.reminderSchemes[reminder_assignment]; + const notifTimes = calcNotifTimes(scheme, reminder_join_date, reminder_time_of_day); - return new Promise((resolve, reject) => { - cordova.plugins.notification.local.getScheduled((notifs) => { - if (areAlreadyScheduled(notifs, notifTimes)) { - Logger.log("Already scheduled, not scheduling again"); - } else { - // to ensure we don't overlap with the last scheduling() request, - // we'll wait for the previous one to finish before scheduling again - scheduledPromise.then(() => { - if (isScheduling) { - console.log("ERROR: Already scheduling notifications, not scheduling again") - } else { - scheduledPromise = scheduleNotifs(scheme, notifTimes); - //enforcing end of scheduling to conisder update through - scheduledPromise.then(() => { - resolve(); - }) - } - }); - } + return new Promise((resolve, reject) => { + cordova.plugins.notification.local.getScheduled((notifs) => { + if (areAlreadyScheduled(notifs, notifTimes)) { + Logger.log('Already scheduled, not scheduling again'); + } else { + // to ensure we don't overlap with the last scheduling() request, + // we'll wait for the previous one to finish before scheduling again + scheduledPromise.then(() => { + if (isScheduling) { + console.log('ERROR: Already scheduling notifications, not scheduling again'); + } else { + scheduledPromise = scheduleNotifs(scheme, notifTimes); + //enforcing end of scheduling to conisder update through + scheduledPromise.then(() => { + resolve(); + }); + } }); + } }); - } + }); + }; /* Randomly assign a scheme, set the join date to today, and use the default time of day from config (or noon if not specified) This is only called once when the user first joins the study */ const initReminderPrefs = () => { - // randomly assign from the schemes listed in config - const schemes = Object.keys(_config.reminderSchemes); - const randAssignment = schemes[Math.floor(Math.random() * schemes.length)]; - const todayDate = moment().format('YYYY-MM-DD'); - const defaultTime = _config.reminderSchemes[randAssignment]?.defaultTime || '12:00'; - return { - reminder_assignment: randAssignment, - reminder_join_date: todayDate, - reminder_time_of_day: defaultTime, - }; - } + // randomly assign from the schemes listed in config + const schemes = Object.keys(_config.reminderSchemes); + const randAssignment = schemes[Math.floor(Math.random() * schemes.length)]; + const todayDate = moment().format('YYYY-MM-DD'); + const defaultTime = _config.reminderSchemes[randAssignment]?.defaultTime || '12:00'; + return { + reminder_assignment: randAssignment, + reminder_join_date: todayDate, + reminder_time_of_day: defaultTime, + }; + }; /* EXAMPLE VALUES - present in user profile object reminder_assignment: 'passive', @@ -219,53 +217,49 @@ angular.module('emission.splash.notifscheduler', */ scheduler.getReminderPrefs = async () => { - const user = await getUser(); - if (user?.reminder_assignment && - user?.reminder_join_date && - user?.reminder_time_of_day) { - return user; - } - // if no prefs, user just joined, so initialize them - const initPrefs = initReminderPrefs(); - await scheduler.setReminderPrefs(initPrefs); - return { ...user, ...initPrefs }; // user profile + the new prefs - } + const user = await getUser(); + if (user?.reminder_assignment && user?.reminder_join_date && user?.reminder_time_of_day) { + return user; + } + // if no prefs, user just joined, so initialize them + const initPrefs = initReminderPrefs(); + await scheduler.setReminderPrefs(initPrefs); + return { ...user, ...initPrefs }; // user profile + the new prefs + }; scheduler.setReminderPrefs = async (newPrefs) => { - await updateUser(newPrefs) - const updatePromise = new Promise((resolve, reject) => { - //enforcing update before moving on - update().then(() => { - resolve(); - }); + await updateUser(newPrefs); + const updatePromise = new Promise((resolve, reject) => { + //enforcing update before moving on + update().then(() => { + resolve(); }); + }); - // record the new prefs in client stats - scheduler.getReminderPrefs().then((prefs) => { - // extract only the relevant fields from the prefs, - // and add as a reading to client stats - const { reminder_assignment, - reminder_join_date, - reminder_time_of_day} = prefs; - addStatReading(statKeys.REMINDER_PREFS, { - reminder_assignment, - reminder_join_date, - reminder_time_of_day - }).then(Logger.log("Added reminder prefs to client stats")); - }); + // record the new prefs in client stats + scheduler.getReminderPrefs().then((prefs) => { + // extract only the relevant fields from the prefs, + // and add as a reading to client stats + const { reminder_assignment, reminder_join_date, reminder_time_of_day } = prefs; + addStatReading(statKeys.REMINDER_PREFS, { + reminder_assignment, + reminder_join_date, + reminder_time_of_day, + }).then(Logger.log('Added reminder prefs to client stats')); + }); - return updatePromise; - } + return updatePromise; + }; $ionicPlatform.ready().then(async () => { - _config = await getConfig(); - if (!_config.reminderSchemes) { - Logger.log("No reminder schemes found in config, not scheduling notifications"); - return; - } - //setUpActions(); - update(); + _config = await getConfig(); + if (!_config.reminderSchemes) { + Logger.log('No reminder schemes found in config, not scheduling notifications'); + return; + } + //setUpActions(); + update(); }); return scheduler; -}); + }); diff --git a/www/js/splash/pushnotify.js b/www/js/splash/pushnotify.js index 775ddc4bd..28e37aaa1 100644 --- a/www/js/splash/pushnotify.js +++ b/www/js/splash/pushnotify.js @@ -1,7 +1,6 @@ //naming of this file can be a little confusing - "pushnotifysettings" for rewritten file //https://github.com/e-mission/e-mission-phone/pull/1072#discussion_r1375360832 - /* * This module deals with the interaction with the push plugin, the redirection * of silent push notifications and the re-parsing of iOS pushes. It then @@ -21,160 +20,174 @@ import angular from 'angular'; import { updateUser } from '../commHelper'; import { readConsentState, isConsented } from './startprefs'; -angular.module('emission.splash.pushnotify', ['emission.plugin.logger', - 'emission.services']) -.factory('PushNotify', function($window, $state, $rootScope, $ionicPlatform, - $ionicPopup, Logger) { - - var pushnotify = {}; - var push = null; - pushnotify.CLOUD_NOTIFICATION_EVENT = 'cloud:push:notification'; +angular + .module('emission.splash.pushnotify', ['emission.plugin.logger', 'emission.services']) + .factory( + 'PushNotify', + function ($window, $state, $rootScope, $ionicPlatform, $ionicPopup, Logger) { + var pushnotify = {}; + var push = null; + pushnotify.CLOUD_NOTIFICATION_EVENT = 'cloud:push:notification'; - pushnotify.startupInit = function() { - push = $window.PushNotification.init({ - "ios": { - "badge": true, - "sound": true, - "vibration": true, - "clearBadge": true - }, - "android": { - "iconColor": "#008acf", - "icon": "ic_mood_question", - "clearNotifications": true - } - }); - push.on('notification', function(data) { - if ($ionicPlatform.is('ios')) { + pushnotify.startupInit = function () { + push = $window.PushNotification.init({ + ios: { + badge: true, + sound: true, + vibration: true, + clearBadge: true, + }, + android: { + iconColor: '#008acf', + icon: 'ic_mood_question', + clearNotifications: true, + }, + }); + push.on('notification', function (data) { + if ($ionicPlatform.is('ios')) { // Parse the iOS values that are returned as strings - if(angular.isDefined(data) && - angular.isDefined(data.additionalData)) { - if(angular.isDefined(data.additionalData.payload)) { - data.additionalData.payload = JSON.parse(data.additionalData.payload); - } - if(angular.isDefined(data.additionalData.data) && typeof(data.additionalData.data) == "string") { - data.additionalData.data = JSON.parse(data.additionalData.data); - } else { - console.log("additionalData is already an object, no need to parse it"); - } + if (angular.isDefined(data) && angular.isDefined(data.additionalData)) { + if (angular.isDefined(data.additionalData.payload)) { + data.additionalData.payload = JSON.parse(data.additionalData.payload); + } + if ( + angular.isDefined(data.additionalData.data) && + typeof data.additionalData.data == 'string' + ) { + data.additionalData.data = JSON.parse(data.additionalData.data); + } else { + console.log('additionalData is already an object, no need to parse it'); + } } else { - Logger.log("No additional data defined, nothing to parse"); + Logger.log('No additional data defined, nothing to parse'); } - } - $rootScope.$emit(pushnotify.CLOUD_NOTIFICATION_EVENT, data); - }); - } + } + $rootScope.$emit(pushnotify.CLOUD_NOTIFICATION_EVENT, data); + }); + }; - pushnotify.registerPromise = function() { - return new Promise(function(resolve, reject) { - pushnotify.startupInit(); - push.on("registration", function(data) { - console.log("Got registration " + data); - resolve({token: data.registrationId, - type: data.registrationType}); - }); - push.on("error", function(error) { - console.log("Got push error " + error); - reject(error); - }); - console.log("push notify = "+push); + pushnotify.registerPromise = function () { + return new Promise(function (resolve, reject) { + pushnotify.startupInit(); + push.on('registration', function (data) { + console.log('Got registration ' + data); + resolve({ token: data.registrationId, type: data.registrationType }); + }); + push.on('error', function (error) { + console.log('Got push error ' + error); + reject(error); + }); + console.log('push notify = ' + push); }); - } + }; - pushnotify.registerPush = function() { - pushnotify.registerPromise().then(function(t) { - // alert("Token = "+JSON.stringify(t)); - Logger.log("Token = "+JSON.stringify(t)); - return $window.cordova.plugins.BEMServerSync.getConfig().then(function(config) { - return config.sync_interval; - }, function(error) { - console.log("Got error "+error+" while reading config, returning default = 3600"); - return 3600; - }).then(function(sync_interval) { - updateUser({ - device_token: t.token, - curr_platform: ionic.Platform.platform(), - curr_sync_interval: sync_interval - }); - return t; - }); - }).then(function(t) { - // alert("Finished saving token = "+JSON.stringify(t.token)); - Logger.log("Finished saving token = "+JSON.stringify(t.token)); - }).catch(function(error) { - Logger.displayError("Error in registering push notifications", error); - }); - } + pushnotify.registerPush = function () { + pushnotify + .registerPromise() + .then(function (t) { + // alert("Token = "+JSON.stringify(t)); + Logger.log('Token = ' + JSON.stringify(t)); + return $window.cordova.plugins.BEMServerSync.getConfig() + .then( + function (config) { + return config.sync_interval; + }, + function (error) { + console.log( + 'Got error ' + error + ' while reading config, returning default = 3600', + ); + return 3600; + }, + ) + .then(function (sync_interval) { + updateUser({ + device_token: t.token, + curr_platform: ionic.Platform.platform(), + curr_sync_interval: sync_interval, + }); + return t; + }); + }) + .then(function (t) { + // alert("Finished saving token = "+JSON.stringify(t.token)); + Logger.log('Finished saving token = ' + JSON.stringify(t.token)); + }) + .catch(function (error) { + Logger.displayError('Error in registering push notifications', error); + }); + }; - var redirectSilentPush = function(event, data) { - Logger.log("Found silent push notification, for platform "+ionic.Platform.platform()); + var redirectSilentPush = function (event, data) { + Logger.log('Found silent push notification, for platform ' + ionic.Platform.platform()); if (!$ionicPlatform.is('ios')) { - Logger.log("Platform is not ios, handleSilentPush is not implemented or needed"); + Logger.log('Platform is not ios, handleSilentPush is not implemented or needed'); // doesn't matter if we finish or not because platforms other than ios don't care return; } - Logger.log("Platform is ios, calling handleSilentPush on DataCollection"); + Logger.log('Platform is ios, calling handleSilentPush on DataCollection'); var notId = data.additionalData.payload.notId; - var finishErrFn = function(error) { - Logger.log("in push.finish, error = "+error); + var finishErrFn = function (error) { + Logger.log('in push.finish, error = ' + error); }; - pushnotify.datacollect.getConfig().then(function(config) { - if(config.ios_use_remote_push_for_sync) { - pushnotify.datacollect.handleSilentPush() - .then(function() { - Logger.log("silent push finished successfully, calling push.finish"); - showDebugLocalNotification("silent push finished, calling push.finish"); - push.finish(function(){}, finishErrFn, notId); - }) - } else { - Logger.log("Using background fetch for sync, no need to redirect push"); - push.finish(function(){}, finishErrFn, notId); - }; - }) - .catch(function(error) { - push.finish(function(){}, finishErrFn, notId); - Logger.displayError("Error while redirecting silent push", error); - }); - } - - var showDebugLocalNotification = function(message) { - pushnotify.datacollect.getConfig().then(function(config) { - if(config.simulate_user_interaction) { - cordova.plugins.notification.local.schedule({ - id: 1, - title: "Debug javascript notification", - text: message, - actions: [], - category: 'SIGN_IN_TO_CLASS' + pushnotify.datacollect + .getConfig() + .then(function (config) { + if (config.ios_use_remote_push_for_sync) { + pushnotify.datacollect.handleSilentPush().then(function () { + Logger.log('silent push finished successfully, calling push.finish'); + showDebugLocalNotification('silent push finished, calling push.finish'); + push.finish(function () {}, finishErrFn, notId); }); + } else { + Logger.log('Using background fetch for sync, no need to redirect push'); + push.finish(function () {}, finishErrFn, notId); } + }) + .catch(function (error) { + push.finish(function () {}, finishErrFn, notId); + Logger.displayError('Error while redirecting silent push', error); + }); + }; + + var showDebugLocalNotification = function (message) { + pushnotify.datacollect.getConfig().then(function (config) { + if (config.simulate_user_interaction) { + cordova.plugins.notification.local.schedule({ + id: 1, + title: 'Debug javascript notification', + text: message, + actions: [], + category: 'SIGN_IN_TO_CLASS', + }); + } }); - } + }; - pushnotify.registerNotificationHandler = function() { - $rootScope.$on(pushnotify.CLOUD_NOTIFICATION_EVENT, function(event, data) { - Logger.log("data = "+JSON.stringify(data)); - if (data.additionalData["content-available"] == 1) { - redirectSilentPush(event, data); - }; // else no need to call finish - }); - }; + pushnotify.registerNotificationHandler = function () { + $rootScope.$on(pushnotify.CLOUD_NOTIFICATION_EVENT, function (event, data) { + Logger.log('data = ' + JSON.stringify(data)); + if (data.additionalData['content-available'] == 1) { + redirectSilentPush(event, data); + } // else no need to call finish + }); + }; - $ionicPlatform.ready().then(function() { - pushnotify.datacollect = $window.cordova.plugins.BEMDataCollection; - readConsentState() - .then(isConsented) - .then(function(consentState) { - if (consentState == true) { + $ionicPlatform.ready().then(function () { + pushnotify.datacollect = $window.cordova.plugins.BEMDataCollection; + readConsentState() + .then(isConsented) + .then(function (consentState) { + if (consentState == true) { pushnotify.registerPush(); - } else { - Logger.log("no consent yet, waiting to sign up for remote push"); - } - }); - pushnotify.registerNotificationHandler(); - Logger.log("pushnotify startup done"); - }); + } else { + Logger.log('no consent yet, waiting to sign up for remote push'); + } + }); + pushnotify.registerNotificationHandler(); + Logger.log('pushnotify startup done'); + }); - return pushnotify; -}); + return pushnotify; + }, + ); diff --git a/www/js/splash/referral.js b/www/js/splash/referral.js index 849de847a..334fd0ebe 100644 --- a/www/js/splash/referral.js +++ b/www/js/splash/referral.js @@ -1,9 +1,10 @@ import angular from 'angular'; import { storageGetDirect, storageRemove, storageSet } from '../plugin/storage'; -angular.module('emission.splash.referral', []) +angular + .module('emission.splash.referral', []) -.factory('ReferralHandler', function($window) { + .factory('ReferralHandler', function ($window) { var referralHandler = {}; var REFERRAL_NAVIGATION_KEY = 'referral_navigation'; @@ -11,34 +12,33 @@ angular.module('emission.splash.referral', []) var REFERRED_GROUP_ID = 'referred_group_id'; var REFERRED_USER_ID = 'referred_user_id'; - referralHandler.getReferralNavigation = function() { + referralHandler.getReferralNavigation = function () { const toReturn = storageGetDirect(REFERRAL_NAVIGATION_KEY); storageRemove(REFERRAL_NAVIGATION_KEY); return toReturn; - } - - referralHandler.setupGroupReferral = function(kvList) { - storageSet(REFERRED_KEY, true); - storageSet(REFERRED_GROUP_ID, kvList['groupid']); - storageSet(REFERRED_USER_ID, kvList['userid']); - storageSet(REFERRAL_NAVIGATION_KEY, 'goals'); - }; - - referralHandler.clearGroupReferral = function(kvList) { - storageRemove(REFERRED_KEY); - storageRemove(REFERRED_GROUP_ID); - storageRemove(REFERRED_USER_ID); - storageRemove(REFERRAL_NAVIGATION_KEY); - }; - - referralHandler.getReferralParams = function(kvList) { - return [storageGetDirect(REFERRED_GROUP_ID), - storageGetDirect(REFERRED_USER_ID)]; - } - - referralHandler.hasPendingRegistration = function() { - return storageGetDirect(REFERRED_KEY) - }; - - return referralHandler; -}); + }; + + referralHandler.setupGroupReferral = function (kvList) { + storageSet(REFERRED_KEY, true); + storageSet(REFERRED_GROUP_ID, kvList['groupid']); + storageSet(REFERRED_USER_ID, kvList['userid']); + storageSet(REFERRAL_NAVIGATION_KEY, 'goals'); + }; + + referralHandler.clearGroupReferral = function (kvList) { + storageRemove(REFERRED_KEY); + storageRemove(REFERRED_GROUP_ID); + storageRemove(REFERRED_USER_ID); + storageRemove(REFERRAL_NAVIGATION_KEY); + }; + + referralHandler.getReferralParams = function (kvList) { + return [storageGetDirect(REFERRED_GROUP_ID), storageGetDirect(REFERRED_USER_ID)]; + }; + + referralHandler.hasPendingRegistration = function () { + return storageGetDirect(REFERRED_KEY); + }; + + return referralHandler; + }); diff --git a/www/js/splash/remotenotify.js b/www/js/splash/remotenotify.js index f67cb9d87..a59fdf376 100644 --- a/www/js/splash/remotenotify.js +++ b/www/js/splash/remotenotify.js @@ -18,62 +18,74 @@ import angular from 'angular'; import { addStatEvent, statKeys } from '../plugin/clientStats'; -angular.module('emission.splash.remotenotify', ['emission.plugin.logger']) - -.factory('RemoteNotify', function($http, $window, $ionicPopup, $rootScope, Logger) { +angular + .module('emission.splash.remotenotify', ['emission.plugin.logger']) + .factory('RemoteNotify', function ($http, $window, $ionicPopup, $rootScope, Logger) { var remoteNotify = {}; - remoteNotify.options = "location=yes,clearcache=no,toolbar=yes,hideurlbar=yes"; + remoteNotify.options = 'location=yes,clearcache=no,toolbar=yes,hideurlbar=yes'; /* TODO: Potentially unify with the survey URL loading */ - remoteNotify.launchWebpage = function(url) { + remoteNotify.launchWebpage = function (url) { // THIS LINE FOR inAppBrowser let iab = $window.cordova.InAppBrowser.open(url, '_blank', remoteNotify.options); - } + }; - remoteNotify.launchPopup = function(title, text) { + remoteNotify.launchPopup = function (title, text) { // THIS LINE FOR inAppBrowser let alertPopup = $ionicPopup.alert({ title: title, - template: text + template: text, }); - } + }; - remoteNotify.init = function() { - $rootScope.$on('cloud:push:notification', function(event, data) { + remoteNotify.init = function () { + $rootScope.$on('cloud:push:notification', function (event, data) { addStatEvent(statKeys.NOTIFICATION_OPEN).then(() => { - console.log("Added "+statKeys.NOTIFICATION_OPEN+" event. Data = " + JSON.stringify(data)); + console.log( + 'Added ' + statKeys.NOTIFICATION_OPEN + ' event. Data = ' + JSON.stringify(data), + ); }); - Logger.log("data = "+JSON.stringify(data)); - if (angular.isDefined(data.additionalData) && - angular.isDefined(data.additionalData.payload) && - angular.isDefined(data.additionalData.payload.alert_type)) { - if(data.additionalData.payload.alert_type == "website") { - var webpage_spec = data.additionalData.payload.spec; - if (angular.isDefined(webpage_spec) && - angular.isDefined(webpage_spec.url) && - webpage_spec.url.startsWith("https://")) { - remoteNotify.launchWebpage(webpage_spec.url); - } else { - $ionicPopup.alert("webpage was not specified correctly. spec is "+JSON.stringify(webpage_spec)); - } + Logger.log('data = ' + JSON.stringify(data)); + if ( + angular.isDefined(data.additionalData) && + angular.isDefined(data.additionalData.payload) && + angular.isDefined(data.additionalData.payload.alert_type) + ) { + if (data.additionalData.payload.alert_type == 'website') { + var webpage_spec = data.additionalData.payload.spec; + if ( + angular.isDefined(webpage_spec) && + angular.isDefined(webpage_spec.url) && + webpage_spec.url.startsWith('https://') + ) { + remoteNotify.launchWebpage(webpage_spec.url); + } else { + $ionicPopup.alert( + 'webpage was not specified correctly. spec is ' + JSON.stringify(webpage_spec), + ); } - if(data.additionalData.payload.alert_type == "popup") { - var popup_spec = data.additionalData.payload.spec; - if (angular.isDefined(popup_spec) && - angular.isDefined(popup_spec.title) && - angular.isDefined(popup_spec.text)) { - remoteNotify.launchPopup(popup_spec.title, popup_spec.text); - } else { - $ionicPopup.alert("webpage was not specified correctly. spec is "+JSON.stringify(popup_spec)); - } + } + if (data.additionalData.payload.alert_type == 'popup') { + var popup_spec = data.additionalData.payload.spec; + if ( + angular.isDefined(popup_spec) && + angular.isDefined(popup_spec.title) && + angular.isDefined(popup_spec.text) + ) { + remoteNotify.launchPopup(popup_spec.title, popup_spec.text); + } else { + $ionicPopup.alert( + 'webpage was not specified correctly. spec is ' + JSON.stringify(popup_spec), + ); } + } } }); - } + }; remoteNotify.init(); return remoteNotify; -}); + }); diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index 43f29c692..75282bfd3 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -1,7 +1,7 @@ -import { getAngularService } from "../angular-react-helper"; +import { getAngularService } from '../angular-react-helper'; import { storageGet, storageSet } from '../plugin/storage'; import { logInfo, logDebug, displayErrorMsg } from '../plugin/logger'; -import { readIntroDone } from "../onboarding/onboardingHelper"; +import { readIntroDone } from '../onboarding/onboardingHelper'; // data collection consented protocol: string, represents the date on // which the consented protocol was approved by the IRB @@ -13,59 +13,64 @@ let _curr_consented; /** * @function writes the consent document to native storage * @returns Promise to execute the write to storage -*/ + */ function writeConsentToNative() { - //note that this calls to the notification API, + //note that this calls to the notification API, //so should not be called until we have notification permissions //see https://github.com/e-mission/e-mission-docs/issues/1006 return window['cordova'].plugins.BEMDataCollection.markConsented(_req_consent); -}; +} /** * @function marks consent in native storage, local storage, and local var * @returns Promise for marking the consent in native and local storage */ export function markConsented() { - logInfo("changing consent from " + - _curr_consented + " -> " + JSON.stringify(_req_consent)); + logInfo('changing consent from ' + _curr_consented + ' -> ' + JSON.stringify(_req_consent)); // mark in native storage - return readConsentState() - .then(writeConsentToNative) - .then(function (response) { - // mark in local storage - storageSet(DATA_COLLECTION_CONSENTED_PROTOCOL, - _req_consent); - // mark in local variable as well - _curr_consented = { ..._req_consent }; - }) - //check for reconsent - .then(readIntroDone) - .then((isIntroDone) => { - if (isIntroDone) { - logDebug("reconsent scenario - marked consent after intro done - registering pushnoify and storing device settings") - const PushNotify = getAngularService("PushNotify"); - const StoreSeviceSettings = getAngularService("StoreDeviceSettings"); - PushNotify.registerPush(); - StoreSeviceSettings.storeDeviceSettings(); - } - }) - .catch((error) => { - displayErrorMsg(error, "Error while while wrting consent to storage"); - }); -}; + return ( + readConsentState() + .then(writeConsentToNative) + .then(function (response) { + // mark in local storage + storageSet(DATA_COLLECTION_CONSENTED_PROTOCOL, _req_consent); + // mark in local variable as well + _curr_consented = { ..._req_consent }; + }) + //check for reconsent + .then(readIntroDone) + .then((isIntroDone) => { + if (isIntroDone) { + logDebug( + 'reconsent scenario - marked consent after intro done - registering pushnoify and storing device settings', + ); + const PushNotify = getAngularService('PushNotify'); + const StoreSeviceSettings = getAngularService('StoreDeviceSettings'); + PushNotify.registerPush(); + StoreSeviceSettings.storeDeviceSettings(); + } + }) + .catch((error) => { + displayErrorMsg(error, 'Error while while wrting consent to storage'); + }) + ); +} /** * @function checking for consent locally * @returns {boolean} if the consent is marked in the local var */ export function isConsented() { - logDebug("curr consented is" + JSON.stringify(_curr_consented)); - if (_curr_consented == null || _curr_consented == "" || - _curr_consented.approval_date != _req_consent.approval_date) { - logDebug("Not consented in local storage, need to show consent"); + logDebug('curr consented is' + JSON.stringify(_curr_consented)); + if ( + _curr_consented == null || + _curr_consented == '' || + _curr_consented.approval_date != _req_consent.approval_date + ) { + logDebug('Not consented in local storage, need to show consent'); return false; } else { - logDebug("Consented in local storage, no need to show consent"); + logDebug('Consented in local storage, no need to show consent'); return true; } } @@ -75,16 +80,21 @@ export function isConsented() { * @returns nothing, just reads into local variables */ export function readConsentState() { - return fetch("json/startupConfig.json") - .then(response => response.json()) + return fetch('json/startupConfig.json') + .then((response) => response.json()) .then(function (startupConfigResult) { console.log(startupConfigResult); _req_consent = startupConfigResult.emSensorDataCollectionProtocol; - logDebug("required consent version = " + JSON.stringify(_req_consent)); + logDebug('required consent version = ' + JSON.stringify(_req_consent)); return storageGet(DATA_COLLECTION_CONSENTED_PROTOCOL); - }).then(function (kv_store_consent) { + }) + .then(function (kv_store_consent) { _curr_consented = kv_store_consent; - console.assert(((_req_consent != undefined) && (_req_consent != null)), "in readConsentState $rootScope.req_consent", JSON.stringify(_req_consent)); + console.assert( + _req_consent != undefined && _req_consent != null, + 'in readConsentState $rootScope.req_consent', + JSON.stringify(_req_consent), + ); // we can just launch this, we don't need to wait for it checkNativeConsent(); }); @@ -96,15 +106,16 @@ export function readConsentState() { */ //used in ProfileSettings export function getConsentDocument() { - return window['cordova'].plugins.BEMUserCache.getDocument("config/consent", false) - .then(function (resultDoc) { + return window['cordova'].plugins.BEMUserCache.getDocument('config/consent', false).then( + function (resultDoc) { if (window['cordova'].plugins.BEMUserCache.isEmptyDoc(resultDoc)) { return null; } else { return resultDoc; } - }); -}; + }, + ); +} /** * @function checks the consent doc in native storage @@ -114,11 +125,11 @@ function checkNativeConsent() { getConsentDocument().then(function (resultDoc) { if (resultDoc == null) { if (isConsented()) { - logDebug("Local consent found, native consent missing, writing consent to native"); - displayErrorMsg("Local consent found, native consent missing, writing consent to native"); + logDebug('Local consent found, native consent missing, writing consent to native'); + displayErrorMsg('Local consent found, native consent missing, writing consent to native'); return writeConsentToNative(); } else { - logDebug("Both local and native consent not found, nothing to sync"); + logDebug('Both local and native consent not found, nothing to sync'); } } }); diff --git a/www/js/splash/storedevicesettings.js b/www/js/splash/storedevicesettings.js index 31543bc6c..ab28cde2c 100644 --- a/www/js/splash/storedevicesettings.js +++ b/www/js/splash/storedevicesettings.js @@ -1,48 +1,53 @@ import angular from 'angular'; import { updateUser } from '../commHelper'; -import { isConsented, readConsentState } from "./startprefs"; +import { isConsented, readConsentState } from './startprefs'; -angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', - 'emission.services']) -.factory('StoreDeviceSettings', function($window, $state, $rootScope, $ionicPlatform, - $ionicPopup, Logger ) { +angular + .module('emission.splash.storedevicesettings', ['emission.plugin.logger', 'emission.services']) + .factory( + 'StoreDeviceSettings', + function ($window, $state, $rootScope, $ionicPlatform, $ionicPopup, Logger) { + var storedevicesettings = {}; - var storedevicesettings = {}; + storedevicesettings.storeDeviceSettings = function () { + var lang = i18next.resolvedLanguage; + var manufacturer = $window.device.manufacturer; + var osver = $window.device.version; + return $window.cordova.getAppVersion + .getVersionNumber() + .then(function (appver) { + var updateJSON = { + phone_lang: lang, + curr_platform: ionic.Platform.platform(), + manufacturer: manufacturer, + client_os_version: osver, + client_app_version: appver, + }; + Logger.log('About to update profile with settings = ' + JSON.stringify(updateJSON)); + return updateUser(updateJSON); + }) + .then(function (updateJSON) { + // alert("Finished saving token = "+JSON.stringify(t.token)); + }) + .catch(function (error) { + Logger.displayError('Error in updating profile to store device settings', error); + }); + }; - storedevicesettings.storeDeviceSettings = function() { - var lang = i18next.resolvedLanguage; - var manufacturer = $window.device.manufacturer; - var osver = $window.device.version; - return $window.cordova.getAppVersion.getVersionNumber().then(function(appver) { - var updateJSON = { - phone_lang: lang, - curr_platform: ionic.Platform.platform(), - manufacturer: manufacturer, - client_os_version: osver, - client_app_version: appver - }; - Logger.log("About to update profile with settings = "+JSON.stringify(updateJSON)); - return updateUser(updateJSON); - }).then(function(updateJSON) { - // alert("Finished saving token = "+JSON.stringify(t.token)); - }).catch(function(error) { - Logger.displayError("Error in updating profile to store device settings", error); - }); - } - - $ionicPlatform.ready().then(function() { - storedevicesettings.datacollect = $window.cordova.plugins.BEMDataCollection; - readConsentState() - .then(isConsented) - .then(function(consentState) { - if (consentState == true) { + $ionicPlatform.ready().then(function () { + storedevicesettings.datacollect = $window.cordova.plugins.BEMDataCollection; + readConsentState() + .then(isConsented) + .then(function (consentState) { + if (consentState == true) { storedevicesettings.storeDeviceSettings(); - } else { - Logger.log("no consent yet, waiting to store device settings in profile"); - } - }); - Logger.log("storedevicesettings startup done"); - }); + } else { + Logger.log('no consent yet, waiting to store device settings in profile'); + } + }); + Logger.log('storedevicesettings startup done'); + }); - return storedevicesettings; -}); + return storedevicesettings; + }, + ); diff --git a/www/js/survey/enketo/AddNoteButton.tsx b/www/js/survey/enketo/AddNoteButton.tsx index 1b85c728e..fb35951ee 100644 --- a/www/js/survey/enketo/AddNoteButton.tsx +++ b/www/js/survey/enketo/AddNoteButton.tsx @@ -7,23 +7,23 @@ The start and end times of the addition are determined by the survey response. */ -import React, { useEffect, useState, useContext } from "react"; -import DiaryButton from "../../components/DiaryButton"; -import { useTranslation } from "react-i18next"; -import moment from "moment"; -import { LabelTabContext } from "../../diary/LabelTab"; -import EnketoModal from "./EnketoModal"; -import { displayErrorMsg, logDebug } from "../../plugin/logger"; +import React, { useEffect, useState, useContext } from 'react'; +import DiaryButton from '../../components/DiaryButton'; +import { useTranslation } from 'react-i18next'; +import moment from 'moment'; +import { LabelTabContext } from '../../diary/LabelTab'; +import EnketoModal from './EnketoModal'; +import { displayErrorMsg, logDebug } from '../../plugin/logger'; type Props = { - timelineEntry: any, - notesConfig: any, - storeKey: string, -} + timelineEntry: any; + notesConfig: any; + storeKey: string; +}; const AddNoteButton = ({ timelineEntry, notesConfig, storeKey }: Props) => { const { t, i18n } = useTranslation(); const [displayLabel, setDisplayLabel] = useState(''); - const { repopulateTimelineEntry } = useContext(LabelTabContext) + const { repopulateTimelineEntry } = useContext(LabelTabContext); useEffect(() => { let newLabel: string; @@ -39,20 +39,19 @@ const AddNoteButton = ({ timelineEntry, notesConfig, storeKey }: Props) => { // return a dictionary of fields we want to prefill, using start/enter and end/exit times function getPrefillTimes() { - let begin = timelineEntry.start_ts || timelineEntry.enter_ts; let stop = timelineEntry.end_ts || timelineEntry.exit_ts; // if addition(s) already present on this timeline entry, `begin` where the last one left off - timelineEntry.additionsList.forEach(a => { - if (a.data.end_ts > (begin || 0) && a.data.end_ts != stop) - begin = a.data.end_ts; + timelineEntry.additionsList.forEach((a) => { + if (a.data.end_ts > (begin || 0) && a.data.end_ts != stop) begin = a.data.end_ts; }); - - const timezone = timelineEntry.start_local_dt?.timezone - || timelineEntry.enter_local_dt?.timezone - || timelineEntry.end_local_dt?.timezone - || timelineEntry.exit_local_dt?.timezone; + + const timezone = + timelineEntry.start_local_dt?.timezone || + timelineEntry.enter_local_dt?.timezone || + timelineEntry.end_local_dt?.timezone || + timelineEntry.exit_local_dt?.timezone; const momentBegin = begin ? moment(begin * 1000).tz(timezone) : null; const momentStop = stop ? moment(stop * 1000).tz(timezone) : null; @@ -80,11 +79,14 @@ const AddNoteButton = ({ timelineEntry, notesConfig, storeKey }: Props) => { console.log('About to launch survey ', surveyName); setPrefillTimes(getPrefillTimes()); setModalVisible(true); - }; + } function onResponseSaved(result) { if (result) { - logDebug('AddNoteButton: response was saved, about to repopulateTimelineEntry; result=' + JSON.stringify(result)); + logDebug( + 'AddNoteButton: response was saved, about to repopulateTimelineEntry; result=' + + JSON.stringify(result), + ); repopulateTimelineEntry(timelineEntry._id.$oid); } else { displayErrorMsg('AddNoteButton: response was not saved, result=', result); @@ -94,19 +96,20 @@ const AddNoteButton = ({ timelineEntry, notesConfig, storeKey }: Props) => { const [prefillTimes, setPrefillTimes] = useState(null); const [modalVisible, setModalVisible] = useState(false); - return (<> - launchAddNoteSurvey()}> - {displayLabel} - - setModalVisible(false)} - onResponseSaved={onResponseSaved} - surveyName={notesConfig?.surveyName} - opts={{ timelineEntry, - dataKey: storeKey, - prefillFields: prefillTimes - }} /> - ); + return ( + <> + launchAddNoteSurvey()}> + {displayLabel} + + setModalVisible(false)} + onResponseSaved={onResponseSaved} + surveyName={notesConfig?.surveyName} + opts={{ timelineEntry, dataKey: storeKey, prefillFields: prefillTimes }} + /> + + ); }; export default AddNoteButton; diff --git a/www/js/survey/enketo/AddedNotesList.tsx b/www/js/survey/enketo/AddedNotesList.tsx index e29278cca..f1563c4a9 100644 --- a/www/js/survey/enketo/AddedNotesList.tsx +++ b/www/js/survey/enketo/AddedNotesList.tsx @@ -2,22 +2,21 @@ Notes are added from the AddNoteButton and are derived from survey responses. */ -import React, { useContext, useState } from "react"; -import moment from "moment"; -import { Modal } from "react-native" -import { Text, Button, DataTable, Dialog } from "react-native-paper"; -import { LabelTabContext } from "../../diary/LabelTab"; -import { getFormattedDateAbbr, isMultiDay } from "../../diary/diaryHelper"; -import { Icon } from "../../components/Icon"; -import EnketoModal from "./EnketoModal"; -import { useTranslation } from "react-i18next"; +import React, { useContext, useState } from 'react'; +import moment from 'moment'; +import { Modal } from 'react-native'; +import { Text, Button, DataTable, Dialog } from 'react-native-paper'; +import { LabelTabContext } from '../../diary/LabelTab'; +import { getFormattedDateAbbr, isMultiDay } from '../../diary/diaryHelper'; +import { Icon } from '../../components/Icon'; +import EnketoModal from './EnketoModal'; +import { useTranslation } from 'react-i18next'; type Props = { - timelineEntry: any, - additionEntries: any[], -} + timelineEntry: any; + additionEntries: any[]; +}; const AddedNotesList = ({ timelineEntry, additionEntries }: Props) => { - const { t } = useTranslation(); const { repopulateTimelineEntry } = useContext(LabelTabContext); const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] = useState(false); @@ -25,41 +24,46 @@ const AddedNotesList = ({ timelineEntry, additionEntries }: Props) => { const [editingEntry, setEditingEntry] = useState(null); function setDisplayDt(entry) { - const timezone = timelineEntry.start_local_dt?.timezone - || timelineEntry.enter_local_dt?.timezone - || timelineEntry.end_local_dt?.timezone - || timelineEntry.exit_local_dt?.timezone; + const timezone = + timelineEntry.start_local_dt?.timezone || + timelineEntry.enter_local_dt?.timezone || + timelineEntry.end_local_dt?.timezone || + timelineEntry.exit_local_dt?.timezone; const beginTs = entry.data.start_ts || entry.data.enter_ts; const stopTs = entry.data.end_ts || entry.data.exit_ts; let d; if (isMultiDay(beginTs, stopTs)) { - const beginTsZoned = moment.parseZone(beginTs*1000).tz(timezone); - const stopTsZoned = moment.parseZone(stopTs*1000).tz(timezone); + const beginTsZoned = moment.parseZone(beginTs * 1000).tz(timezone); + const stopTsZoned = moment.parseZone(stopTs * 1000).tz(timezone); d = getFormattedDateAbbr(beginTsZoned.toISOString(), stopTsZoned.toISOString()); } - const begin = moment.parseZone(beginTs*1000).tz(timezone).format('LT'); - const stop = moment.parseZone(stopTs*1000).tz(timezone).format('LT'); - return entry.displayDt = { + const begin = moment + .parseZone(beginTs * 1000) + .tz(timezone) + .format('LT'); + const stop = moment + .parseZone(stopTs * 1000) + .tz(timezone) + .format('LT'); + return (entry.displayDt = { date: d, - time: begin + " - " + stop - } + time: begin + ' - ' + stop, + }); } function deleteEntry(entry) { - console.log("Deleting entry", entry); + console.log('Deleting entry', entry); const dataKey = entry.key || entry.metadata.key; const data = entry.data; const index = additionEntries.indexOf(entry); data.status = 'DELETED'; - return window['cordova'].plugins.BEMUserCache - .putMessage(dataKey, data) - .then(() => { - additionEntries.splice(index, 1); - setConfirmDeleteModalVisible(false); - setEditingEntry(null); - }); + return window['cordova'].plugins.BEMUserCache.putMessage(dataKey, data).then(() => { + additionEntries.splice(index, 1); + setConfirmDeleteModalVisible(false); + setEditingEntry(null); + }); } function confirmDeleteEntry(entry) { @@ -90,66 +94,80 @@ const AddedNotesList = ({ timelineEntry, additionEntries }: Props) => { } const sortedEntries = additionEntries?.sort((a, b) => a.data.start_ts - b.data.start_ts); - return (<> - - {sortedEntries?.map((entry, index) => { - const isLastRow = (index == additionEntries.length - 1); - return ( - - editEntry(entry)} - style={[styles.cell, {flex: 5, pointerEvents: 'auto'}]} - textStyle={{fontSize: 12, fontWeight: 'bold'}}> - {entry.data.label} - - editEntry(entry)} - style={[styles.cell, {flex: 4}]} - textStyle={{fontSize: 12, lineHeight: 12}}> - {entry.displayDt?.date} - {entry.displayDt?.time || setDisplayDt(entry)} - - confirmDeleteEntry(entry)} - style={[styles.cell, {flex: 1}]}> - - - - ) - })} - - - - - { t('diary.delete-entry-confirm') } - - {editingEntry?.data?.label} - {editingEntry?.displayDt?.date} - {editingEntry?.displayDt?.time} - - - - - - - - ); + return ( + <> + + {sortedEntries?.map((entry, index) => { + const isLastRow = index == additionEntries.length - 1; + return ( + + editEntry(entry)} + style={[styles.cell, { flex: 5, pointerEvents: 'auto' }]} + textStyle={{ fontSize: 12, fontWeight: 'bold' }}> + {entry.data.label} + + editEntry(entry)} + style={[styles.cell, { flex: 4 }]} + textStyle={{ fontSize: 12, lineHeight: 12 }}> + {entry.displayDt?.date} + + {entry.displayDt?.time || setDisplayDt(entry)} + + + confirmDeleteEntry(entry)} + style={[styles.cell, { flex: 1 }]}> + + + + ); + })} + + + + + {t('diary.delete-entry-confirm')} + + {editingEntry?.data?.label} + {editingEntry?.displayDt?.date} + {editingEntry?.displayDt?.time} + + + + + + + + + ); }; -const styles:any = { +const styles: any = { row: (isLastRow) => ({ minHeight: 36, height: 36, - borderBottomWidth: (isLastRow ? 0 : 1), + borderBottomWidth: isLastRow ? 0 : 1, borderBottomColor: 'rgba(0,0,0,0.1)', pointerEvents: 'all', }), cell: { pointerEvents: 'auto', }, -} +}; export default AddedNotesList; diff --git a/www/js/survey/enketo/EnketoModal.tsx b/www/js/survey/enketo/EnketoModal.tsx index 8b80b6dfe..de1f505f3 100644 --- a/www/js/survey/enketo/EnketoModal.tsx +++ b/www/js/survey/enketo/EnketoModal.tsx @@ -10,13 +10,12 @@ import { displayError, displayErrorMsg } from '../../plugin/logger'; // import { transform } from 'enketo-transformer/web'; type Props = Omit & { - surveyName: string, - onResponseSaved: (response: any) => void, - opts?: SurveyOptions, -} - -const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest } : Props) => { + surveyName: string; + onResponseSaved: (response: any) => void; + opts?: SurveyOptions; +}; +const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest }: Props) => { const { t, i18n } = useTranslation(); const headerEl = useRef(null); const surveyJson = useRef(null); @@ -27,9 +26,11 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest } : Props) => const responseText = await fetchUrlCached(url); try { return JSON.parse(responseText); - } catch ({name, message}) { + } catch ({ name, message }) { // not JSON, so it must be XML - return Promise.reject('downloaded survey was not JSON; enketo-transformer is not available yet'); + return Promise.reject( + 'downloaded survey was not JSON; enketo-transformer is not available yet', + ); /* uncomment once enketo-transformer is available */ // if `response` is not JSON, it is an XML string and needs transformation to JSON // const xmlText = await res.text(); @@ -41,18 +42,21 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest } : Props) => const valid = await enketoForm.current.validate(); if (!valid) return false; const result = await saveResponse(surveyName, enketoForm.current, appConfig, opts); - if (!result) { // validation failed + if (!result) { + // validation failed displayErrorMsg(t('survey.enketo-form-errors')); - } else if (result instanceof Error) { // error thrown in saveResponse + } else if (result instanceof Error) { + // error thrown in saveResponse displayError(result); - } else { // success + } else { + // success rest.onDismiss(); onResponseSaved(result); return; } } - // init logic: retrieve form -> inject into DOM -> initialize Enketo -> show modal + // init logic: retrieve form -> inject into DOM -> initialize Enketo -> show modal function initSurvey() { console.debug('Loading survey', surveyName); const formPath = appConfig.survey_info?.surveys?.[surveyName]?.formPath; @@ -89,14 +93,18 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest } : Props) => Just make sure to keep a .form-language-selector element into which the form language selector ( @@ -111,16 +119,44 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest } : Props) =>
    @@ -129,19 +165,17 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest } : Props) => ); return ( - - - - -
    - {enketoContent} -
    + + + + +
    {enketoContent}
    ); -} +}; const s = StyleSheet.create({ dismissBtn: { @@ -152,7 +186,7 @@ const s = StyleSheet.create({ display: 'flex', alignItems: 'center', padding: 0, - } + }, }); export default EnketoModal; diff --git a/www/js/survey/enketo/UserInputButton.tsx b/www/js/survey/enketo/UserInputButton.tsx index 68d0ae944..fa2412b73 100644 --- a/www/js/survey/enketo/UserInputButton.tsx +++ b/www/js/survey/enketo/UserInputButton.tsx @@ -8,18 +8,18 @@ The start and end times of the addition are the same as the trip or place. */ -import React, { useContext, useMemo, useState } from "react"; -import { getAngularService } from "../../angular-react-helper"; -import DiaryButton from "../../components/DiaryButton"; -import { useTranslation } from "react-i18next"; -import { useTheme } from "react-native-paper"; -import { displayErrorMsg, logDebug } from "../../plugin/logger"; -import EnketoModal from "./EnketoModal"; -import { LabelTabContext } from "../../diary/LabelTab"; +import React, { useContext, useMemo, useState } from 'react'; +import { getAngularService } from '../../angular-react-helper'; +import DiaryButton from '../../components/DiaryButton'; +import { useTranslation } from 'react-i18next'; +import { useTheme } from 'react-native-paper'; +import { displayErrorMsg, logDebug } from '../../plugin/logger'; +import EnketoModal from './EnketoModal'; +import { LabelTabContext } from '../../diary/LabelTab'; type Props = { - timelineEntry: any, -} + timelineEntry: any; +}; const UserInputButton = ({ timelineEntry }: Props) => { const { colors } = useTheme(); const { t, i18n } = useTranslation(); @@ -28,13 +28,14 @@ const UserInputButton = ({ timelineEntry }: Props) => { const [modalVisible, setModalVisible] = useState(false); const { repopulateTimelineEntry } = useContext(LabelTabContext); - const EnketoTripButtonService = getAngularService("EnketoTripButtonService"); + const EnketoTripButtonService = getAngularService('EnketoTripButtonService'); const etbsSingleKey = EnketoTripButtonService.SINGLE_KEY; // the label resolved from the survey response, or null if there is no response yet - const responseLabel = useMemo(() => ( - timelineEntry.userInput?.[etbsSingleKey]?.data?.label || null - ), [timelineEntry]); + const responseLabel = useMemo( + () => timelineEntry.userInput?.[etbsSingleKey]?.data?.label || null, + [timelineEntry], + ); function launchUserInputSurvey() { logDebug('UserInputButton: About to launch survey'); @@ -45,31 +46,37 @@ const UserInputButton = ({ timelineEntry }: Props) => { function onResponseSaved(result) { if (result) { - logDebug('UserInputButton: response was saved, about to repopulateTimelineEntry; result=' + JSON.stringify(result)); + logDebug( + 'UserInputButton: response was saved, about to repopulateTimelineEntry; result=' + + JSON.stringify(result), + ); repopulateTimelineEntry(timelineEntry._id.$oid); } else { displayErrorMsg('UserInputButton: response was not saved, result=', result); } } - return (<> - launchUserInputSurvey()}> - {/* if no response yet, show the default label */} - {responseLabel || t('diary.choose-survey')} - + return ( + <> + launchUserInputSurvey()}> + {/* if no response yet, show the default label */} + {responseLabel || t('diary.choose-survey')} + - setModalVisible(false)} - onResponseSaved={onResponseSaved} - surveyName={'TripConfirmSurvey'} /* As of now, the survey name is hardcoded. + setModalVisible(false)} + onResponseSaved={onResponseSaved} + surveyName={'TripConfirmSurvey'} /* As of now, the survey name is hardcoded. In the future, if we ever implement something like a "Place Details" survey, we may want to make this configurable. */ - opts={{ timelineEntry, - prefilledSurveyResponse: prevSurveyResponse - }} /> - ); + opts={{ timelineEntry, prefilledSurveyResponse: prevSurveyResponse }} + /> + + ); }; export default UserInputButton; diff --git a/www/js/survey/enketo/answer.js b/www/js/survey/enketo/answer.js index e6077c479..cb5745037 100644 --- a/www/js/survey/enketo/answer.js +++ b/www/js/survey/enketo/answer.js @@ -2,192 +2,191 @@ import angular from 'angular'; import MessageFormat from 'messageformat'; import { getConfig } from '../../config/dynamicConfig'; -angular.module('emission.survey.enketo.answer', ['ionic']) -.factory('EnketoSurveyAnswer', function($http) { - /** - * @typedef EnketoAnswerData - * @type {object} - * @property {string} label - display label (this value is use for displaying on the button) - * @property {string} ts - the timestamp at which the survey was filled out (in seconds) - * @property {string} fmt_time - the formatted timestamp at which the survey was filled out - * @property {string} name - survey name - * @property {string} version - survey version - * @property {string} xmlResponse - survey answer XML string - * @property {string} jsonDocResponse - survey answer JSON object - */ - - /** - * @typedef EnketoAnswer - * @type {object} - * @property {EnketoAnswerData} data - answer data - * @property {{[labelField:string]: string}} [labels] - virtual labels (populated by populateLabels method) - */ - - /** - * @typedef EnketoSurveyConfig - * @type {{ - * [surveyName:string]: { - * formPath: string; - * labelFields: string[]; - * version: number; - * compatibleWith: number; - * } - * }} - */ - - const LABEL_FUNCTIONS = { - UseLabelTemplate: (xmlDoc, name) => { - - return _lazyLoadConfig().then(configSurveys => { - - const config = configSurveys[name]; // config for this survey - const lang = i18next.resolvedLanguage; - const labelTemplate = config.labelTemplate?.[lang]; - - if (!labelTemplate) return "Answered"; // no template given in config - if (!config.labelVars) return labelTemplate; // if no vars given, nothing to interpolate, - // so we return the unaltered template - - // gather vars that will be interpolated into the template according to the survey config - const labelVars = {} - for (let lblVar in config.labelVars) { - const fieldName = config.labelVars[lblVar].key; - let fieldStr = _getAnswerByTagName(xmlDoc, fieldName); - if (fieldStr == '') fieldStr = null; - if (config.labelVars[lblVar].type == 'length') { - const fieldMatches = fieldStr?.split(' '); - labelVars[lblVar] = fieldMatches?.length || 0; - } else { - throw new Error(`labelVar type ${config.labelVars[lblVar].type } is not supported!`) +angular + .module('emission.survey.enketo.answer', ['ionic']) + .factory('EnketoSurveyAnswer', function ($http) { + /** + * @typedef EnketoAnswerData + * @type {object} + * @property {string} label - display label (this value is use for displaying on the button) + * @property {string} ts - the timestamp at which the survey was filled out (in seconds) + * @property {string} fmt_time - the formatted timestamp at which the survey was filled out + * @property {string} name - survey name + * @property {string} version - survey version + * @property {string} xmlResponse - survey answer XML string + * @property {string} jsonDocResponse - survey answer JSON object + */ + + /** + * @typedef EnketoAnswer + * @type {object} + * @property {EnketoAnswerData} data - answer data + * @property {{[labelField:string]: string}} [labels] - virtual labels (populated by populateLabels method) + */ + + /** + * @typedef EnketoSurveyConfig + * @type {{ + * [surveyName:string]: { + * formPath: string; + * labelFields: string[]; + * version: number; + * compatibleWith: number; + * } + * }} + */ + + const LABEL_FUNCTIONS = { + UseLabelTemplate: (xmlDoc, name) => { + return _lazyLoadConfig().then((configSurveys) => { + const config = configSurveys[name]; // config for this survey + const lang = i18next.resolvedLanguage; + const labelTemplate = config.labelTemplate?.[lang]; + + if (!labelTemplate) return 'Answered'; // no template given in config + if (!config.labelVars) return labelTemplate; // if no vars given, nothing to interpolate, + // so we return the unaltered template + + // gather vars that will be interpolated into the template according to the survey config + const labelVars = {}; + for (let lblVar in config.labelVars) { + const fieldName = config.labelVars[lblVar].key; + let fieldStr = _getAnswerByTagName(xmlDoc, fieldName); + if (fieldStr == '') fieldStr = null; + if (config.labelVars[lblVar].type == 'length') { + const fieldMatches = fieldStr?.split(' '); + labelVars[lblVar] = fieldMatches?.length || 0; + } else { + throw new Error(`labelVar type ${config.labelVars[lblVar].type} is not supported!`); + } } - } - // use MessageFormat interpolate the label template with the label vars - const mf = new MessageFormat(lang); - const label = mf.compile(labelTemplate)(labelVars); - return label.replace(/^[ ,]+|[ ,]+$/g, ''); // trim leading and trailing spaces and commas - }) + // use MessageFormat interpolate the label template with the label vars + const mf = new MessageFormat(lang); + const label = mf.compile(labelTemplate)(labelVars); + return label.replace(/^[ ,]+|[ ,]+$/g, ''); // trim leading and trailing spaces and commas + }); + }, + }; + + /** @type {EnketoSurveyConfig} _config */ + let _config; + + /** + * _getAnswerByTagName lookup for the survey answer by tag name form the given XML document. + * @param {XMLDocument} xmlDoc survey answer object + * @param {string} tagName tag name + * @returns {string} answer string. If not found, return "\" + */ + function _getAnswerByTagName(xmlDoc, tagName) { + const vals = xmlDoc.getElementsByTagName(tagName); + const val = vals.length ? vals[0].innerHTML : null; + if (!val) return ''; + return val; } - }; - - /** @type {EnketoSurveyConfig} _config */ - let _config; - - /** - * _getAnswerByTagName lookup for the survey answer by tag name form the given XML document. - * @param {XMLDocument} xmlDoc survey answer object - * @param {string} tagName tag name - * @returns {string} answer string. If not found, return "\" - */ - function _getAnswerByTagName(xmlDoc, tagName) { - const vals = xmlDoc.getElementsByTagName(tagName); - const val = vals.length ? vals[0].innerHTML : null; - if (!val) return ''; - return val; - } - - /** - * _lazyLoadConfig load enketo survey config. If already loaded, return the cached config - * @returns {Promise} enketo survey config - */ - function _lazyLoadConfig() { - if (_config !== undefined) { - return Promise.resolve(_config); + + /** + * _lazyLoadConfig load enketo survey config. If already loaded, return the cached config + * @returns {Promise} enketo survey config + */ + function _lazyLoadConfig() { + if (_config !== undefined) { + return Promise.resolve(_config); + } + return getConfig().then((newConfig) => { + Logger.log('Resolved UI_CONFIG_READY promise in answer.js, filling in templates'); + _config = newConfig.survey_info.surveys; + return _config; + }); + } + + /** + * filterByNameAndVersion filter the survey answers by survey name and their version. + * The version for filtering is specified in enketo survey `compatibleWith` config. + * The stored survey answer version must be greater than or equal to `compatibleWith` to be included. + * @param {string} name survey name (defined in enketo survey config) + * @param {EnketoAnswer[]} answers survey answers + * (usually retrieved by calling UnifiedDataLoader.getUnifiedMessagesForInterval('manual/survey_response', tq)) method. + * @return {Promise} filtered survey answers + */ + function filterByNameAndVersion(name, answers) { + return _lazyLoadConfig().then((config) => + answers.filter( + (answer) => + answer.data.name === name && answer.data.version >= config[name].compatibleWith, + ), + ); } - return getConfig().then((newConfig) => { - Logger.log("Resolved UI_CONFIG_READY promise in answer.js, filling in templates"); - _config = newConfig.survey_info.surveys; - return _config; - }) - } - - /** - * filterByNameAndVersion filter the survey answers by survey name and their version. - * The version for filtering is specified in enketo survey `compatibleWith` config. - * The stored survey answer version must be greater than or equal to `compatibleWith` to be included. - * @param {string} name survey name (defined in enketo survey config) - * @param {EnketoAnswer[]} answers survey answers - * (usually retrieved by calling UnifiedDataLoader.getUnifiedMessagesForInterval('manual/survey_response', tq)) method. - * @return {Promise} filtered survey answers - */ - function filterByNameAndVersion(name, answers) { - return _lazyLoadConfig().then(config => - answers.filter(answer => - answer.data.name === name && - answer.data.version >= config[name].compatibleWith - ) - ); - } - - /** - * resolve answer label for the survey - * @param {string} name survey name - * @param {XMLDocument} xmlDoc survey answer object - * @returns {Promise} label string Promise - */ - function resolveLabel(name, xmlDoc) { - // Some studies may want a custom label function for their survey. - // Those can be added in LABEL_FUNCTIONS with the survey name as the key. - // Otherwise, UseLabelTemplate will create a label using the template in the config - if (LABEL_FUNCTIONS[name]) - return LABEL_FUNCTIONS[name](xmlDoc); - return LABEL_FUNCTIONS.UseLabelTemplate(xmlDoc, name); - } - - /** - * resolve timestamps label from the survey response - * @param {XMLDocument} xmlDoc survey answer object - * @param {object} trip trip object - * @returns {object} object with `start_ts` and `end_ts` - * - null if no timestamps are resolved - * - undefined if the timestamps are invalid - */ - function resolveTimestamps(xmlDoc, timelineEntry) { - // check for Date and Time fields - const startDate = xmlDoc.getElementsByTagName('Start_date')?.[0]?.innerHTML; - let startTime = xmlDoc.getElementsByTagName('Start_time')?.[0]?.innerHTML; - const endDate = xmlDoc.getElementsByTagName('End_date')?.[0]?.innerHTML; - let endTime = xmlDoc.getElementsByTagName('End_time')?.[0]?.innerHTML; - - // if any of the fields are missing, return null - if (!startDate || !startTime || !endDate || !endTime) return null; - - const timezone = timelineEntry.start_local_dt?.timezone - || timelineEntry.enter_local_dt?.timezone - || timelineEntry.end_local_dt?.timezone - || timelineEntry.exit_local_dt?.timezone; - // split by + or - to get time without offset - startTime = startTime.split(/\-|\+/)[0]; - endTime = endTime.split(/\-|\+/)[0]; - - let additionStartTs = moment.tz(startDate+'T'+startTime, timezone).unix(); - let additionEndTs = moment.tz(endDate+'T'+endTime, timezone).unix(); - - if (additionStartTs > additionEndTs) { - return undefined; // if the start time is after the end time, this is an invalid response + + /** + * resolve answer label for the survey + * @param {string} name survey name + * @param {XMLDocument} xmlDoc survey answer object + * @returns {Promise} label string Promise + */ + function resolveLabel(name, xmlDoc) { + // Some studies may want a custom label function for their survey. + // Those can be added in LABEL_FUNCTIONS with the survey name as the key. + // Otherwise, UseLabelTemplate will create a label using the template in the config + if (LABEL_FUNCTIONS[name]) return LABEL_FUNCTIONS[name](xmlDoc); + return LABEL_FUNCTIONS.UseLabelTemplate(xmlDoc, name); } - /* Enketo survey time inputs are only precise to the minute, while trips/places are precise to + /** + * resolve timestamps label from the survey response + * @param {XMLDocument} xmlDoc survey answer object + * @param {object} trip trip object + * @returns {object} object with `start_ts` and `end_ts` + * - null if no timestamps are resolved + * - undefined if the timestamps are invalid + */ + function resolveTimestamps(xmlDoc, timelineEntry) { + // check for Date and Time fields + const startDate = xmlDoc.getElementsByTagName('Start_date')?.[0]?.innerHTML; + let startTime = xmlDoc.getElementsByTagName('Start_time')?.[0]?.innerHTML; + const endDate = xmlDoc.getElementsByTagName('End_date')?.[0]?.innerHTML; + let endTime = xmlDoc.getElementsByTagName('End_time')?.[0]?.innerHTML; + + // if any of the fields are missing, return null + if (!startDate || !startTime || !endDate || !endTime) return null; + + const timezone = + timelineEntry.start_local_dt?.timezone || + timelineEntry.enter_local_dt?.timezone || + timelineEntry.end_local_dt?.timezone || + timelineEntry.exit_local_dt?.timezone; + // split by + or - to get time without offset + startTime = startTime.split(/\-|\+/)[0]; + endTime = endTime.split(/\-|\+/)[0]; + + let additionStartTs = moment.tz(startDate + 'T' + startTime, timezone).unix(); + let additionEndTs = moment.tz(endDate + 'T' + endTime, timezone).unix(); + + if (additionStartTs > additionEndTs) { + return undefined; // if the start time is after the end time, this is an invalid response + } + + /* Enketo survey time inputs are only precise to the minute, while trips/places are precise to the millisecond. To avoid precision issues, we will check if the start/end timestamps from the survey response are within the same minute as the start/end or enter/exit timestamps. If so, we will use the exact trip/place timestamps */ - const entryStartTs = timelineEntry.start_ts || timelineEntry.enter_ts; - const entryEndTs = timelineEntry.end_ts || timelineEntry.exit_ts; - if (additionStartTs - (additionStartTs % 60) == entryStartTs - (entryStartTs % 60)) - additionStartTs = entryStartTs; - if (additionEndTs - (additionEndTs % 60) == entryEndTs - (entryEndTs % 60)) - additionEndTs = entryEndTs; - - // return unix timestamps in seconds + const entryStartTs = timelineEntry.start_ts || timelineEntry.enter_ts; + const entryEndTs = timelineEntry.end_ts || timelineEntry.exit_ts; + if (additionStartTs - (additionStartTs % 60) == entryStartTs - (entryStartTs % 60)) + additionStartTs = entryStartTs; + if (additionEndTs - (additionEndTs % 60) == entryEndTs - (entryEndTs % 60)) + additionEndTs = entryEndTs; + + // return unix timestamps in seconds + return { + start_ts: additionStartTs, + end_ts: additionEndTs, + }; + } + return { - start_ts: additionStartTs, - end_ts: additionEndTs - }; - } - - return { - filterByNameAndVersion, - resolveLabel, - resolveTimestamps, - }; -}); + filterByNameAndVersion, + resolveLabel, + resolveTimestamps, + }; + }); diff --git a/www/js/survey/enketo/enketo-add-note-button.js b/www/js/survey/enketo/enketo-add-note-button.js index 49f7747f6..a5bb7edd2 100644 --- a/www/js/survey/enketo/enketo-add-note-button.js +++ b/www/js/survey/enketo/enketo-add-note-button.js @@ -4,102 +4,130 @@ import angular from 'angular'; -angular.module('emission.survey.enketo.add-note-button', - ['emission.services', - 'emission.survey.enketo.answer', - 'emission.survey.inputmatcher']) -.factory("EnketoNotesButtonService", function(InputMatcher, EnketoSurveyAnswer, Logger, $timeout) { - var enbs = {}; - console.log("Creating EnketoNotesButtonService"); - enbs.SINGLE_KEY="NOTES"; - enbs.MANUAL_KEYS = []; +angular + .module('emission.survey.enketo.add-note-button', [ + 'emission.services', + 'emission.survey.enketo.answer', + 'emission.survey.inputmatcher', + ]) + .factory( + 'EnketoNotesButtonService', + function (InputMatcher, EnketoSurveyAnswer, Logger, $timeout) { + var enbs = {}; + console.log('Creating EnketoNotesButtonService'); + enbs.SINGLE_KEY = 'NOTES'; + enbs.MANUAL_KEYS = []; - /** - * Set the keys for trip and/or place additions whichever will be enabled, - * and sets the name of the surveys they will use. - */ - enbs.initConfig = function(tripSurveyName, placeSurveyName) { - enbs.tripSurveyName = tripSurveyName; - if (tripSurveyName) { - enbs.MANUAL_KEYS.push("manual/trip_addition_input") - } - enbs.placeSurveyName = placeSurveyName; - if (placeSurveyName) { - enbs.MANUAL_KEYS.push("manual/place_addition_input") - } - } + /** + * Set the keys for trip and/or place additions whichever will be enabled, + * and sets the name of the surveys they will use. + */ + enbs.initConfig = function (tripSurveyName, placeSurveyName) { + enbs.tripSurveyName = tripSurveyName; + if (tripSurveyName) { + enbs.MANUAL_KEYS.push('manual/trip_addition_input'); + } + enbs.placeSurveyName = placeSurveyName; + if (placeSurveyName) { + enbs.MANUAL_KEYS.push('manual/place_addition_input'); + } + }; - /** - * Embed 'inputType' to the timelineEntry. - */ - enbs.extractResult = function(results) { - const resultsPromises = [EnketoSurveyAnswer.filterByNameAndVersion(enbs.timelineEntrySurveyName, results)]; - if (enbs.timelineEntrySurveyName != enbs.placeSurveyName) { - resultsPromises.push(EnketoSurveyAnswer.filterByNameAndVersion(enbs.placeSurveyName, results)); - } - return Promise.all(resultsPromises); - }; + /** + * Embed 'inputType' to the timelineEntry. + */ + enbs.extractResult = function (results) { + const resultsPromises = [ + EnketoSurveyAnswer.filterByNameAndVersion(enbs.timelineEntrySurveyName, results), + ]; + if (enbs.timelineEntrySurveyName != enbs.placeSurveyName) { + resultsPromises.push( + EnketoSurveyAnswer.filterByNameAndVersion(enbs.placeSurveyName, results), + ); + } + return Promise.all(resultsPromises); + }; - enbs.processManualInputs = function(manualResults, resultMap) { - console.log("ENKETO: processManualInputs with ", manualResults, " and ", resultMap); - const surveyResults = manualResults.flat(2); - resultMap[enbs.SINGLE_KEY] = surveyResults; - } + enbs.processManualInputs = function (manualResults, resultMap) { + console.log('ENKETO: processManualInputs with ', manualResults, ' and ', resultMap); + const surveyResults = manualResults.flat(2); + resultMap[enbs.SINGLE_KEY] = surveyResults; + }; - enbs.populateInputsAndInferences = function(timelineEntry, manualResultMap) { - console.log("ENKETO: populating timelineEntry,", timelineEntry, " with result map", manualResultMap); - if (angular.isDefined(timelineEntry)) { - // initialize additions array as empty if it doesn't already exist - timelineEntry.additionsList ||= []; - enbs.populateManualInputs(timelineEntry, enbs.SINGLE_KEY, manualResultMap[enbs.SINGLE_KEY]); - } else { - console.log("timelineEntry information not yet bound, skipping fill"); - } - } + enbs.populateInputsAndInferences = function (timelineEntry, manualResultMap) { + console.log( + 'ENKETO: populating timelineEntry,', + timelineEntry, + ' with result map', + manualResultMap, + ); + if (angular.isDefined(timelineEntry)) { + // initialize additions array as empty if it doesn't already exist + timelineEntry.additionsList ||= []; + enbs.populateManualInputs( + timelineEntry, + enbs.SINGLE_KEY, + manualResultMap[enbs.SINGLE_KEY], + ); + } else { + console.log('timelineEntry information not yet bound, skipping fill'); + } + }; - /** - * Embed 'inputType' to the timelineEntry - * This is the version that is called from the list, which focuses only on - * manual inputs. It also sets some additional values - */ - enbs.populateManualInputs = function (timelineEntry, inputType, inputList) { - // there is not necessarily just one addition per timeline entry, - // so unlike user inputs, we don't want to replace the server entry with - // the unprocessed entry - // but we also don't want to blindly append the unprocessed entry; what - // if it was a deletion. - // what we really want to do is to merge the unprocessed and processed entries - // taking deletion into account - // one option for that is to just combine the processed and unprocessed entries - // into a single list - // note that this is not necessarily the most performant approach, since we will - // be re-matching entries that have already been matched on the server - // but the number of matched entries is likely to be small, so we can live - // with the performance for now - const unprocessedAdditions = InputMatcher.getAdditionsForTimelineEntry(timelineEntry, inputList); - const combinedPotentialAdditionList = timelineEntry.additions.concat(unprocessedAdditions); - const dedupedList = InputMatcher.getUniqueEntries(combinedPotentialAdditionList); - Logger.log("After combining unprocessed ("+unprocessedAdditions.length+ - ") with server ("+timelineEntry.additions.length+ - ") for a combined ("+combinedPotentialAdditionList.length+ - "), deduped entries are ("+dedupedList.length+")"); + /** + * Embed 'inputType' to the timelineEntry + * This is the version that is called from the list, which focuses only on + * manual inputs. It also sets some additional values + */ + enbs.populateManualInputs = function (timelineEntry, inputType, inputList) { + // there is not necessarily just one addition per timeline entry, + // so unlike user inputs, we don't want to replace the server entry with + // the unprocessed entry + // but we also don't want to blindly append the unprocessed entry; what + // if it was a deletion. + // what we really want to do is to merge the unprocessed and processed entries + // taking deletion into account + // one option for that is to just combine the processed and unprocessed entries + // into a single list + // note that this is not necessarily the most performant approach, since we will + // be re-matching entries that have already been matched on the server + // but the number of matched entries is likely to be small, so we can live + // with the performance for now + const unprocessedAdditions = InputMatcher.getAdditionsForTimelineEntry( + timelineEntry, + inputList, + ); + const combinedPotentialAdditionList = timelineEntry.additions.concat(unprocessedAdditions); + const dedupedList = InputMatcher.getUniqueEntries(combinedPotentialAdditionList); + Logger.log( + 'After combining unprocessed (' + + unprocessedAdditions.length + + ') with server (' + + timelineEntry.additions.length + + ') for a combined (' + + combinedPotentialAdditionList.length + + '), deduped entries are (' + + dedupedList.length + + ')', + ); - enbs.populateInput(timelineEntry.additionsList, inputType, dedupedList); - // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); - enbs.editingTrip = angular.undefined; - } + enbs.populateInput(timelineEntry.additionsList, inputType, dedupedList); + // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); + enbs.editingTrip = angular.undefined; + }; - /** - * Insert the given userInputLabel into the given inputType's slot in inputField - */ - enbs.populateInput = function(timelineEntryField, inputType, userInputEntry) { - if (angular.isDefined(userInputEntry)) { - timelineEntryField.length = 0; - userInputEntry.forEach(ta => { + /** + * Insert the given userInputLabel into the given inputType's slot in inputField + */ + enbs.populateInput = function (timelineEntryField, inputType, userInputEntry) { + if (angular.isDefined(userInputEntry)) { + timelineEntryField.length = 0; + userInputEntry.forEach((ta) => { timelineEntryField.push(ta); }); - } - } + } + }; - return enbs; -}); + return enbs; + }, + ); diff --git a/www/js/survey/enketo/enketo-trip-button.js b/www/js/survey/enketo/enketo-trip-button.js index 6e710435f..66cf82cd7 100644 --- a/www/js/survey/enketo/enketo-trip-button.js +++ b/www/js/survey/enketo/enketo-trip-button.js @@ -13,99 +13,111 @@ import angular from 'angular'; -angular.module('emission.survey.enketo.trip.button', - ['emission.survey.enketo.answer', - 'emission.survey.inputmatcher']) -.factory("EnketoTripButtonService", function(InputMatcher, EnketoSurveyAnswer, Logger, $timeout) { - var etbs = {}; - console.log("Creating EnketoTripButtonService"); - etbs.key = "manual/trip_user_input"; - etbs.SINGLE_KEY="SURVEY"; - etbs.MANUAL_KEYS = [etbs.key]; +angular + .module('emission.survey.enketo.trip.button', [ + 'emission.survey.enketo.answer', + 'emission.survey.inputmatcher', + ]) + .factory( + 'EnketoTripButtonService', + function (InputMatcher, EnketoSurveyAnswer, Logger, $timeout) { + var etbs = {}; + console.log('Creating EnketoTripButtonService'); + etbs.key = 'manual/trip_user_input'; + etbs.SINGLE_KEY = 'SURVEY'; + etbs.MANUAL_KEYS = [etbs.key]; - /** - * Embed 'inputType' to the trip. - */ - etbs.extractResult = (results) => EnketoSurveyAnswer.filterByNameAndVersion('TripConfirmSurvey', results); + /** + * Embed 'inputType' to the trip. + */ + etbs.extractResult = (results) => + EnketoSurveyAnswer.filterByNameAndVersion('TripConfirmSurvey', results); - etbs.processManualInputs = function(manualResults, resultMap) { - if (manualResults.length > 1) { - Logger.displayError("Found "+manualResults.length+" results expected 1", manualResults); - } else { - console.log("ENKETO: processManualInputs with ", manualResults, " and ", resultMap); - const surveyResult = manualResults[0]; - resultMap[etbs.SINGLE_KEY] = surveyResult; - } - } + etbs.processManualInputs = function (manualResults, resultMap) { + if (manualResults.length > 1) { + Logger.displayError( + 'Found ' + manualResults.length + ' results expected 1', + manualResults, + ); + } else { + console.log('ENKETO: processManualInputs with ', manualResults, ' and ', resultMap); + const surveyResult = manualResults[0]; + resultMap[etbs.SINGLE_KEY] = surveyResult; + } + }; - etbs.populateInputsAndInferences = function(trip, manualResultMap) { - console.log("ENKETO: populating trip,", trip, " with result map", manualResultMap); - if (angular.isDefined(trip)) { - // console.log("Expectation: "+JSON.stringify(trip.expectation)); - // console.log("Inferred labels from server: "+JSON.stringify(trip.inferred_labels)); - trip.userInput = {}; - etbs.populateManualInputs(trip, trip.getNextEntry(), etbs.SINGLE_KEY, - manualResultMap[etbs.SINGLE_KEY]); - trip.finalInference = {}; - etbs.inferFinalLabels(trip); - etbs.updateVerifiability(trip); - } else { - console.log("Trip information not yet bound, skipping fill"); - } - } + etbs.populateInputsAndInferences = function (trip, manualResultMap) { + console.log('ENKETO: populating trip,', trip, ' with result map', manualResultMap); + if (angular.isDefined(trip)) { + // console.log("Expectation: "+JSON.stringify(trip.expectation)); + // console.log("Inferred labels from server: "+JSON.stringify(trip.inferred_labels)); + trip.userInput = {}; + etbs.populateManualInputs( + trip, + trip.getNextEntry(), + etbs.SINGLE_KEY, + manualResultMap[etbs.SINGLE_KEY], + ); + trip.finalInference = {}; + etbs.inferFinalLabels(trip); + etbs.updateVerifiability(trip); + } else { + console.log('Trip information not yet bound, skipping fill'); + } + }; - /** - * Embed 'inputType' to the trip - * This is the version that is called from the list, which focuses only on - * manual inputs. It also sets some additional values - */ - etbs.populateManualInputs = function (trip, nextTrip, inputType, inputList) { - // Check unprocessed labels first since they are more recent - const unprocessedLabelEntry = InputMatcher.getUserInputForTrip(trip, nextTrip, - inputList); - var userInputEntry = unprocessedLabelEntry; - if (!angular.isDefined(userInputEntry)) { + /** + * Embed 'inputType' to the trip + * This is the version that is called from the list, which focuses only on + * manual inputs. It also sets some additional values + */ + etbs.populateManualInputs = function (trip, nextTrip, inputType, inputList) { + // Check unprocessed labels first since they are more recent + const unprocessedLabelEntry = InputMatcher.getUserInputForTrip(trip, nextTrip, inputList); + var userInputEntry = unprocessedLabelEntry; + if (!angular.isDefined(userInputEntry)) { userInputEntry = trip.user_input?.[etbs.inputType2retKey(inputType)]; - } - etbs.populateInput(trip.userInput, inputType, userInputEntry); - // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); - etbs.editingTrip = angular.undefined; - } + } + etbs.populateInput(trip.userInput, inputType, userInputEntry); + // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); + etbs.editingTrip = angular.undefined; + }; - /** - * Insert the given userInputLabel into the given inputType's slot in inputField - */ - etbs.populateInput = function(tripField, inputType, userInputEntry) { - if (angular.isDefined(userInputEntry)) { - tripField[inputType] = userInputEntry; - } - } + /** + * Insert the given userInputLabel into the given inputType's slot in inputField + */ + etbs.populateInput = function (tripField, inputType, userInputEntry) { + if (angular.isDefined(userInputEntry)) { + tripField[inputType] = userInputEntry; + } + }; - /** - * Given the list of possible label tuples we've been sent and what the user has already input for the trip, choose the best labels to actually present to the user. - * The algorithm below operationalizes these principles: - * - Never consider label tuples that contradict a green label - * - Obey "conservation of uncertainty": the sum of probabilities after filtering by green labels must equal the sum of probabilities before - * - After filtering, predict the most likely choices at the level of individual labels, not label tuples - * - Never show user yellow labels that have a lower probability of being correct than confidenceThreshold - */ - etbs.inferFinalLabels = function(trip) { - // currently a NOP since we don't have any other trip properties - return; - } + /** + * Given the list of possible label tuples we've been sent and what the user has already input for the trip, choose the best labels to actually present to the user. + * The algorithm below operationalizes these principles: + * - Never consider label tuples that contradict a green label + * - Obey "conservation of uncertainty": the sum of probabilities after filtering by green labels must equal the sum of probabilities before + * - After filtering, predict the most likely choices at the level of individual labels, not label tuples + * - Never show user yellow labels that have a lower probability of being correct than confidenceThreshold + */ + etbs.inferFinalLabels = function (trip) { + // currently a NOP since we don't have any other trip properties + return; + }; - /** - * MODE (becomes manual/mode_confirm) becomes mode_confirm - */ - etbs.inputType2retKey = function(inputType) { - return etbs.key.split("/")[1]; - } + /** + * MODE (becomes manual/mode_confirm) becomes mode_confirm + */ + etbs.inputType2retKey = function (inputType) { + return etbs.key.split('/')[1]; + }; - etbs.updateVerifiability = function(trip) { - // currently a NOP since we don't have any other trip properties - trip.verifiability = "cannot-verify"; - return; - } + etbs.updateVerifiability = function (trip) { + // currently a NOP since we don't have any other trip properties + trip.verifiability = 'cannot-verify'; + return; + }; - return etbs; -}); + return etbs; + }, + ); diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index 6e9147cf8..b1e228540 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -1,10 +1,10 @@ -import { getAngularService } from "../../angular-react-helper"; +import { getAngularService } from '../../angular-react-helper'; import { Form } from 'enketo-core'; import { XMLParser } from 'fast-xml-parser'; import i18next from 'i18next'; -import { logDebug } from "../../plugin/logger"; +import { logDebug } from '../../plugin/logger'; -export type PrefillFields = {[key: string]: string}; +export type PrefillFields = { [key: string]: string }; export type SurveyOptions = { undismissable?: boolean; @@ -37,12 +37,10 @@ function getXmlWithPrefills(xmlModel: string, prefillFields: PrefillFields) { * @param opts object with options like 'prefilledSurveyResponse' or 'prefillFields' * @returns XML string of an existing or prefilled model response, or null if no response is available */ -export function getInstanceStr(xmlModel: string, opts: SurveyOptions): string|null { +export function getInstanceStr(xmlModel: string, opts: SurveyOptions): string | null { if (!xmlModel) return null; - if (opts.prefilledSurveyResponse) - return opts.prefilledSurveyResponse; - if (opts.prefillFields) - return getXmlWithPrefills(xmlModel, opts.prefillFields); + if (opts.prefilledSurveyResponse) return opts.prefilledSurveyResponse; + if (opts.prefillFields) return getXmlWithPrefills(xmlModel, opts.prefillFields); return null; } @@ -58,58 +56,59 @@ export function saveResponse(surveyName: string, enketoForm: Form, appConfig, op const xmlParser = new window.DOMParser(); const xmlResponse = enketoForm.getDataStr(); const xmlDoc = xmlParser.parseFromString(xmlResponse, 'text/xml'); - const xml2js = new XMLParser({ignoreAttributes: false, attributeNamePrefix: 'attr'}); + const xml2js = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: 'attr' }); const jsonDocResponse = xml2js.parse(xmlResponse); - return EnketoSurveyAnswer.resolveLabel(surveyName, xmlDoc).then(rsLabel => { - const data: any = { - label: rsLabel, - name: surveyName, - version: appConfig.survey_info.surveys[surveyName].version, - xmlResponse, - jsonDocResponse, - }; - if (opts.timelineEntry) { - let timestamps = EnketoSurveyAnswer.resolveTimestamps(xmlDoc, opts.timelineEntry); - if (timestamps === undefined) { - // timestamps were resolved, but they are invalid - return new Error(i18next.t('survey.enketo-timestamps-invalid')); //"Timestamps are invalid. Please ensure that the start time is before the end time."); + return EnketoSurveyAnswer.resolveLabel(surveyName, xmlDoc) + .then((rsLabel) => { + const data: any = { + label: rsLabel, + name: surveyName, + version: appConfig.survey_info.surveys[surveyName].version, + xmlResponse, + jsonDocResponse, + }; + if (opts.timelineEntry) { + let timestamps = EnketoSurveyAnswer.resolveTimestamps(xmlDoc, opts.timelineEntry); + if (timestamps === undefined) { + // timestamps were resolved, but they are invalid + return new Error(i18next.t('survey.enketo-timestamps-invalid')); //"Timestamps are invalid. Please ensure that the start time is before the end time."); + } + // if timestamps were not resolved from the survey, we will use the trip or place timestamps + timestamps ||= opts.timelineEntry; + data.start_ts = timestamps.start_ts || timestamps.enter_ts; + data.end_ts = timestamps.end_ts || timestamps.exit_ts; + // UUID generated using this method https://stackoverflow.com/a/66332305 + data.match_id = URL.createObjectURL(new Blob([])).slice(-36); + } else { + const now = Date.now(); + data.ts = now / 1000; // convert to seconds to be consistent with the server + data.fmt_time = new Date(now); } - // if timestamps were not resolved from the survey, we will use the trip or place timestamps - timestamps ||= opts.timelineEntry; - data.start_ts = timestamps.start_ts || timestamps.enter_ts; - data.end_ts = timestamps.end_ts || timestamps.exit_ts; - // UUID generated using this method https://stackoverflow.com/a/66332305 - data.match_id = URL.createObjectURL(new Blob([])).slice(-36); - } else { - const now = Date.now(); - data.ts = now/1000; // convert to seconds to be consistent with the server - data.fmt_time = new Date(now); - } - // use dataKey passed into opts if available, otherwise get it from the config - const dataKey = opts.dataKey || appConfig.survey_info.surveys[surveyName].dataKey; - return window['cordova'].plugins.BEMUserCache - .putMessage(dataKey, data) - .then(() => data); - }).then(data => data); + // use dataKey passed into opts if available, otherwise get it from the config + const dataKey = opts.dataKey || appConfig.survey_info.surveys[surveyName].dataKey; + return window['cordova'].plugins.BEMUserCache.putMessage(dataKey, data).then(() => data); + }) + .then((data) => data); } const _getMostRecent = (answers) => { answers.sort((a, b) => a.metadata.write_ts < b.metadata.write_ts); - console.log("first answer is ", answers[0], " last answer is ", answers[answers.length-1]); + console.log('first answer is ', answers[0], ' last answer is ', answers[answers.length - 1]); return answers[0]; -} +}; /* - * We retrieve all the records every time instead of caching because of the - * usage pattern. We assume that the demographic survey is edited fairly - * rarely, so loading it every time will likely do a bunch of unnecessary work. - * Loading it on demand seems like the way to go. If we choose to experiment - * with incremental updates, we may want to revisit this. -*/ + * We retrieve all the records every time instead of caching because of the + * usage pattern. We assume that the demographic survey is edited fairly + * rarely, so loading it every time will likely do a bunch of unnecessary work. + * Loading it on demand seems like the way to go. If we choose to experiment + * with incremental updates, we may want to revisit this. + */ export function loadPreviousResponseForSurvey(dataKey: string) { const UnifiedDataLoader = getAngularService('UnifiedDataLoader'); const tq = window['cordova'].plugins.BEMUserCache.getAllTimeQuery(); - logDebug("loadPreviousResponseForSurvey: dataKey = " + dataKey + "; tq = " + tq); - return UnifiedDataLoader.getUnifiedMessagesForInterval(dataKey, tq) - .then(answers => _getMostRecent(answers)) + logDebug('loadPreviousResponseForSurvey: dataKey = ' + dataKey + '; tq = ' + tq); + return UnifiedDataLoader.getUnifiedMessagesForInterval(dataKey, tq).then((answers) => + _getMostRecent(answers), + ); } diff --git a/www/js/survey/enketo/infinite_scroll_filters.ts b/www/js/survey/enketo/infinite_scroll_filters.ts index 98eba65db..bc43591e0 100644 --- a/www/js/survey/enketo/infinite_scroll_filters.ts +++ b/www/js/survey/enketo/infinite_scroll_filters.ts @@ -6,33 +6,29 @@ * All UI elements should only use $scope variables. */ -import i18next from "i18next"; -import { getAngularService } from "../../angular-react-helper"; +import i18next from 'i18next'; +import { getAngularService } from '../../angular-react-helper'; const unlabeledCheck = (t) => { - try { - const EnketoTripButtonService = getAngularService("EnketoTripButtonService"); - const etbsSingleKey = EnketoTripButtonService.SINGLE_KEY; - return typeof t.userInput[etbsSingleKey] === 'undefined'; - } - catch (e) { - console.log("Error in retrieving EnketoTripButtonService: ", e); - } -} + try { + const EnketoTripButtonService = getAngularService('EnketoTripButtonService'); + const etbsSingleKey = EnketoTripButtonService.SINGLE_KEY; + return typeof t.userInput[etbsSingleKey] === 'undefined'; + } catch (e) { + console.log('Error in retrieving EnketoTripButtonService: ', e); + } +}; const UNLABELED = { - key: "unlabeled", - text: i18next.t("diary.unlabeled"), - filter: unlabeledCheck -} + key: 'unlabeled', + text: i18next.t('diary.unlabeled'), + filter: unlabeledCheck, +}; const TO_LABEL = { - key: "to_label", - text: i18next.t("diary.to-label"), - filter: unlabeledCheck -} + key: 'to_label', + text: i18next.t('diary.to-label'), + filter: unlabeledCheck, +}; -export const configuredFilters = [ - TO_LABEL, - UNLABELED -]; \ No newline at end of file +export const configuredFilters = [TO_LABEL, UNLABELED]; diff --git a/www/js/survey/input-matcher.js b/www/js/survey/input-matcher.js index 2e3d5b908..6fc3178df 100644 --- a/www/js/survey/input-matcher.js +++ b/www/js/survey/input-matcher.js @@ -2,23 +2,37 @@ import angular from 'angular'; -angular.module('emission.survey.inputmatcher', ['emission.plugin.logger']) -.factory('InputMatcher', function(Logger){ - var im = {}; - - const EPOCH_MAXIMUM = 2**31 - 1; - const fmtTs = function(ts_in_secs, tz) { - return moment(ts_in_secs * 1000).tz(tz).format(); - } - - var printUserInput = function(ui) { - return fmtTs(ui.data.start_ts, ui.metadata.time_zone) + "("+ui.data.start_ts + ") -> "+ - fmtTs(ui.data.end_ts, ui.metadata.time_zone) + "("+ui.data.end_ts + ")"+ - " " + ui.data.label + " logged at "+ ui.metadata.write_ts; - } - - im.validUserInputForDraftTrip = function(trip, userInput, logsEnabled) { - if (logsEnabled) { +angular + .module('emission.survey.inputmatcher', ['emission.plugin.logger']) + .factory('InputMatcher', function (Logger) { + var im = {}; + + const EPOCH_MAXIMUM = 2 ** 31 - 1; + const fmtTs = function (ts_in_secs, tz) { + return moment(ts_in_secs * 1000) + .tz(tz) + .format(); + }; + + var printUserInput = function (ui) { + return ( + fmtTs(ui.data.start_ts, ui.metadata.time_zone) + + '(' + + ui.data.start_ts + + ') -> ' + + fmtTs(ui.data.end_ts, ui.metadata.time_zone) + + '(' + + ui.data.end_ts + + ')' + + ' ' + + ui.data.label + + ' logged at ' + + ui.metadata.write_ts + ); + }; + + im.validUserInputForDraftTrip = function (trip, userInput, logsEnabled) { + if (logsEnabled) { Logger.log(`Draft trip: comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} -> ${fmtTs(userInput.data.end_ts, userInput.metadata.time_zone)} @@ -29,40 +43,40 @@ angular.module('emission.survey.inputmatcher', ['emission.plugin.logger']) || ${-(userInput.data.start_ts - trip.start_ts) <= 15 * 60}) && ${userInput.data.end_ts <= trip.end_ts} `); - } - return (userInput.data.start_ts >= trip.start_ts - && userInput.data.start_ts < trip.end_ts - || -(userInput.data.start_ts - trip.start_ts) <= 15 * 60) - && userInput.data.end_ts <= trip.end_ts; - } - - im.validUserInputForTimelineEntry = function(tlEntry, userInput, logsEnabled) { - if (!tlEntry.origin_key) return false; - if (tlEntry.origin_key.includes('UNPROCESSED') == true) + } + return ( + ((userInput.data.start_ts >= trip.start_ts && userInput.data.start_ts < trip.end_ts) || + -(userInput.data.start_ts - trip.start_ts) <= 15 * 60) && + userInput.data.end_ts <= trip.end_ts + ); + }; + + im.validUserInputForTimelineEntry = function (tlEntry, userInput, logsEnabled) { + if (!tlEntry.origin_key) return false; + if (tlEntry.origin_key.includes('UNPROCESSED') == true) return im.validUserInputForDraftTrip(tlEntry, userInput, logsEnabled); - /* Place-level inputs always have a key starting with 'manual/place', and + /* Place-level inputs always have a key starting with 'manual/place', and trip-level inputs never have a key starting with 'manual/place' So if these don't match, we can immediately return false */ - const entryIsPlace = tlEntry.origin_key == 'analysis/confirmed_place'; - const isPlaceInput = (userInput.key || userInput.metadata.key).startsWith('manual/place'); - if (entryIsPlace != isPlaceInput) - return false; - - let entryStart = tlEntry.start_ts || tlEntry.enter_ts; - let entryEnd = tlEntry.end_ts || tlEntry.exit_ts; - if (!entryStart && entryEnd) { - // if a place has no enter time, this is the first start_place of the first composite trip object - // so we will set the start time to the start of the day of the end time for the purpose of comparison - entryStart = moment.unix(entryEnd).startOf('day').unix(); - } - if (!entryEnd) { + const entryIsPlace = tlEntry.origin_key == 'analysis/confirmed_place'; + const isPlaceInput = (userInput.key || userInput.metadata.key).startsWith('manual/place'); + if (entryIsPlace != isPlaceInput) return false; + + let entryStart = tlEntry.start_ts || tlEntry.enter_ts; + let entryEnd = tlEntry.end_ts || tlEntry.exit_ts; + if (!entryStart && entryEnd) { + // if a place has no enter time, this is the first start_place of the first composite trip object + // so we will set the start time to the start of the day of the end time for the purpose of comparison + entryStart = moment.unix(entryEnd).startOf('day').unix(); + } + if (!entryEnd) { // if a place has no exit time, the user hasn't left there yet // so we will set the end time as high as possible for the purpose of comparison entryEnd = EPOCH_MAXIMUM; - } - - if (logsEnabled) { + } + + if (logsEnabled) { Logger.log(`Cleaned trip: comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} -> ${fmtTs(userInput.data.end_ts, userInput.metadata.time_zone)} @@ -73,141 +87,187 @@ angular.module('emission.survey.inputmatcher', ['emission.plugin.logger']) end checks are ${userInput.data.end_ts <= entryEnd} || ${userInput.data.end_ts - entryEnd <= 15 * 60}) `); - } + } - /* For this input to match, it must begin after the start of the timelineEntry (inclusive) + /* For this input to match, it must begin after the start of the timelineEntry (inclusive) but before the end of the timelineEntry (exclusive) */ - const startChecks = userInput.data.start_ts >= entryStart && - userInput.data.start_ts < entryEnd; - /* A matching user input must also finish before the end of the timelineEntry, + const startChecks = + userInput.data.start_ts >= entryStart && userInput.data.start_ts < entryEnd; + /* A matching user input must also finish before the end of the timelineEntry, or within 15 minutes. */ - var endChecks = (userInput.data.end_ts <= entryEnd || - (userInput.data.end_ts - entryEnd) <= 15 * 60); - if (startChecks && !endChecks) { + var endChecks = + userInput.data.end_ts <= entryEnd || userInput.data.end_ts - entryEnd <= 15 * 60; + if (startChecks && !endChecks) { const nextEntryObj = tlEntry.getNextEntry(); if (nextEntryObj) { - const nextEntryEnd = nextEntryObj.end_ts || nextEntryObj.exit_ts; - if (!nextEntryEnd) { // the last place will not have an exit_ts - endChecks = true; // so we will just skip the end check - } else { - endChecks = userInput.data.end_ts <= nextEntryEnd; - Logger.log("Second level of end checks when the next trip is defined("+userInput.data.end_ts+" <= "+ nextEntryEnd+") = "+endChecks); - } + const nextEntryEnd = nextEntryObj.end_ts || nextEntryObj.exit_ts; + if (!nextEntryEnd) { + // the last place will not have an exit_ts + endChecks = true; // so we will just skip the end check + } else { + endChecks = userInput.data.end_ts <= nextEntryEnd; + Logger.log( + 'Second level of end checks when the next trip is defined(' + + userInput.data.end_ts + + ' <= ' + + nextEntryEnd + + ') = ' + + endChecks, + ); + } } else { - // next trip is not defined, last trip - endChecks = (userInput.data.end_local_dt.day == userInput.data.start_local_dt.day) - Logger.log("Second level of end checks for the last trip of the day"); - Logger.log("compare "+userInput.data.end_local_dt.day + " with " + userInput.data.start_local_dt.day + " = " + endChecks); + // next trip is not defined, last trip + endChecks = userInput.data.end_local_dt.day == userInput.data.start_local_dt.day; + Logger.log('Second level of end checks for the last trip of the day'); + Logger.log( + 'compare ' + + userInput.data.end_local_dt.day + + ' with ' + + userInput.data.start_local_dt.day + + ' = ' + + endChecks, + ); } if (endChecks) { - // If we have flipped the values, check to see that there - // is sufficient overlap - const overlapDuration = Math.min(userInput.data.end_ts, entryEnd) - Math.max(userInput.data.start_ts, entryStart) - Logger.log("Flipped endCheck, overlap("+overlapDuration+ - ")/trip("+tlEntry.duration+") = "+ (overlapDuration / tlEntry.duration)); - endChecks = (overlapDuration/tlEntry.duration) > 0.5; + // If we have flipped the values, check to see that there + // is sufficient overlap + const overlapDuration = + Math.min(userInput.data.end_ts, entryEnd) - + Math.max(userInput.data.start_ts, entryStart); + Logger.log( + 'Flipped endCheck, overlap(' + + overlapDuration + + ')/trip(' + + tlEntry.duration + + ') = ' + + overlapDuration / tlEntry.duration, + ); + endChecks = overlapDuration / tlEntry.duration > 0.5; } - } - return startChecks && endChecks; - } - - // parallels get_not_deleted_candidates() in trip_queries.py - const getNotDeletedCandidates = function(candidates) { - console.log('getNotDeletedCandidates called with ' + candidates.length + ' candidates'); - // We want to retain all ACTIVE entries that have not been DELETED - const allActiveList = candidates.filter(c => !c.data.status || c.data.status == 'ACTIVE'); - const allDeletedIds = candidates.filter(c => c.data.status && c.data.status == 'DELETED').map(c => c.data['match_id']); - const notDeletedActive = allActiveList.filter(c => !allDeletedIds.includes(c.data['match_id'])); - console.log(`Found ${allActiveList.length} active entries, + } + return startChecks && endChecks; + }; + + // parallels get_not_deleted_candidates() in trip_queries.py + const getNotDeletedCandidates = function (candidates) { + console.log('getNotDeletedCandidates called with ' + candidates.length + ' candidates'); + // We want to retain all ACTIVE entries that have not been DELETED + const allActiveList = candidates.filter((c) => !c.data.status || c.data.status == 'ACTIVE'); + const allDeletedIds = candidates + .filter((c) => c.data.status && c.data.status == 'DELETED') + .map((c) => c.data['match_id']); + const notDeletedActive = allActiveList.filter( + (c) => !allDeletedIds.includes(c.data['match_id']), + ); + console.log(`Found ${allActiveList.length} active entries, ${allDeletedIds.length} deleted entries -> ${notDeletedActive.length} non deleted active entries`); - return notDeletedActive; - } + return notDeletedActive; + }; - im.getUserInputForTrip = function(trip, nextTrip, userInputList) { - const logsEnabled = userInputList.length < 20; + im.getUserInputForTrip = function (trip, nextTrip, userInputList) { + const logsEnabled = userInputList.length < 20; - if (userInputList === undefined) { - Logger.log("In getUserInputForTrip, no user input, returning undefined"); + if (userInputList === undefined) { + Logger.log('In getUserInputForTrip, no user input, returning undefined'); return undefined; - } - - if (logsEnabled) { - console.log("Input list = "+userInputList.map(printUserInput)); - } - // undefined != true, so this covers the label view case as well - var potentialCandidates = userInputList.filter((ui) => im.validUserInputForTimelineEntry(trip, ui, logsEnabled)); - if (potentialCandidates.length === 0) { + } + + if (logsEnabled) { + console.log('Input list = ' + userInputList.map(printUserInput)); + } + // undefined != true, so this covers the label view case as well + var potentialCandidates = userInputList.filter((ui) => + im.validUserInputForTimelineEntry(trip, ui, logsEnabled), + ); + if (potentialCandidates.length === 0) { if (logsEnabled) { - Logger.log("In getUserInputForTripStartEnd, no potential candidates, returning []"); + Logger.log('In getUserInputForTripStartEnd, no potential candidates, returning []'); } return undefined; - } + } - if (potentialCandidates.length === 1) { - Logger.log("In getUserInputForTripStartEnd, one potential candidate, returning "+ printUserInput(potentialCandidates[0])); + if (potentialCandidates.length === 1) { + Logger.log( + 'In getUserInputForTripStartEnd, one potential candidate, returning ' + + printUserInput(potentialCandidates[0]), + ); return potentialCandidates[0]; - } + } - Logger.log("potentialCandidates are "+potentialCandidates.map(printUserInput)); - var sortedPC = potentialCandidates.sort(function(pc1, pc2) { + Logger.log('potentialCandidates are ' + potentialCandidates.map(printUserInput)); + var sortedPC = potentialCandidates.sort(function (pc1, pc2) { return pc2.metadata.write_ts - pc1.metadata.write_ts; - }); - var mostRecentEntry = sortedPC[0]; - Logger.log("Returning mostRecentEntry "+printUserInput(mostRecentEntry)); - return mostRecentEntry; - } - - // return array of matching additions for a trip or place - im.getAdditionsForTimelineEntry = function(entry, additionsList) { - const logsEnabled = additionsList.length < 20; - - if (additionsList === undefined) { - Logger.log("In getAdditionsForTimelineEntry, no addition input, returning []"); - return []; - } - - // get additions that have not been deleted - // and filter out additions that do not start within the bounds of the timeline entry - const notDeleted = getNotDeletedCandidates(additionsList); - const matchingAdditions = notDeleted.filter((ui) => im.validUserInputForTimelineEntry(entry, ui, logsEnabled)); - - if (logsEnabled) { - console.log("Matching Addition list = "+matchingAdditions.map(printUserInput)); - } - return matchingAdditions; - } - - im.getUniqueEntries = function(combinedList) { - // we should not get any non-ACTIVE entries here - // since we have run filtering algorithms on both the phone and the server - const allDeleted = combinedList.filter(c => c.data.status && c.data.status == 'DELETED'); - if (allDeleted.length > 0) { - Logger.displayError("Found "+allDeletedEntries.length - +" non-ACTIVE addition entries while trying to dedup entries", - allDeletedEntries); - } - const uniqueMap = new Map(); - combinedList.forEach((e) => { + }); + var mostRecentEntry = sortedPC[0]; + Logger.log('Returning mostRecentEntry ' + printUserInput(mostRecentEntry)); + return mostRecentEntry; + }; + + // return array of matching additions for a trip or place + im.getAdditionsForTimelineEntry = function (entry, additionsList) { + const logsEnabled = additionsList.length < 20; + + if (additionsList === undefined) { + Logger.log('In getAdditionsForTimelineEntry, no addition input, returning []'); + return []; + } + + // get additions that have not been deleted + // and filter out additions that do not start within the bounds of the timeline entry + const notDeleted = getNotDeletedCandidates(additionsList); + const matchingAdditions = notDeleted.filter((ui) => + im.validUserInputForTimelineEntry(entry, ui, logsEnabled), + ); + + if (logsEnabled) { + console.log('Matching Addition list = ' + matchingAdditions.map(printUserInput)); + } + return matchingAdditions; + }; + + im.getUniqueEntries = function (combinedList) { + // we should not get any non-ACTIVE entries here + // since we have run filtering algorithms on both the phone and the server + const allDeleted = combinedList.filter((c) => c.data.status && c.data.status == 'DELETED'); + if (allDeleted.length > 0) { + Logger.displayError( + 'Found ' + + allDeletedEntries.length + + ' non-ACTIVE addition entries while trying to dedup entries', + allDeletedEntries, + ); + } + const uniqueMap = new Map(); + combinedList.forEach((e) => { const existingVal = uniqueMap.get(e.data.match_id); // if the existing entry and the input entry don't match // and they are both active, we have an error // let's notify the user for now if (existingVal) { - if ((existingVal.data.start_ts != e.data.start_ts) || - (existingVal.data.end_ts != e.data.end_ts) || - (existingVal.data.write_ts != e.data.write_ts)) { - Logger.displayError("Found two ACTIVE entries with the same match ID but different timestamps "+existingVal.data.match_id, - JSON.stringify(existingVal) + " vs. "+ JSON.stringify(e)); - } else { - console.log("Found two entries with match_id "+existingVal.data.match_id+" but they are identical"); - } + if ( + existingVal.data.start_ts != e.data.start_ts || + existingVal.data.end_ts != e.data.end_ts || + existingVal.data.write_ts != e.data.write_ts + ) { + Logger.displayError( + 'Found two ACTIVE entries with the same match ID but different timestamps ' + + existingVal.data.match_id, + JSON.stringify(existingVal) + ' vs. ' + JSON.stringify(e), + ); + } else { + console.log( + 'Found two entries with match_id ' + + existingVal.data.match_id + + ' but they are identical', + ); + } } else { - uniqueMap.set(e.data.match_id, e); + uniqueMap.set(e.data.match_id, e); } - }); - return Array.from(uniqueMap.values()); - } + }); + return Array.from(uniqueMap.values()); + }; - return im; -}); + return im; + }); diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index ca71721a7..36a350bd3 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -2,28 +2,38 @@ In the default configuration, these are the "Mode" and "Purpose" buttons. Next to the buttons is a small checkmark icon, which marks inferrel labels as confirmed */ -import React, { useContext, useEffect, useState, useMemo } from "react"; -import { getAngularService } from "../../angular-react-helper"; -import { View, Modal, ScrollView, Pressable, useWindowDimensions } from "react-native"; -import { IconButton, Text, Dialog, useTheme, RadioButton, Button, TextInput } from "react-native-paper"; -import DiaryButton from "../../components/DiaryButton"; -import { useTranslation } from "react-i18next"; -import { LabelTabContext } from "../../diary/LabelTab"; -import { displayErrorMsg, logDebug } from "../../plugin/logger"; -import { getLabelInputDetails, getLabelInputs, readableLabelToKey } from "./confirmHelper"; +import React, { useContext, useEffect, useState, useMemo } from 'react'; +import { getAngularService } from '../../angular-react-helper'; +import { View, Modal, ScrollView, Pressable, useWindowDimensions } from 'react-native'; +import { + IconButton, + Text, + Dialog, + useTheme, + RadioButton, + Button, + TextInput, +} from 'react-native-paper'; +import DiaryButton from '../../components/DiaryButton'; +import { useTranslation } from 'react-i18next'; +import { LabelTabContext } from '../../diary/LabelTab'; +import { displayErrorMsg, logDebug } from '../../plugin/logger'; +import { getLabelInputDetails, getLabelInputs, readableLabelToKey } from './confirmHelper'; -const MultilabelButtonGroup = ({ trip, buttonsInline=false }) => { +const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { const { colors } = useTheme(); const { t } = useTranslation(); const { repopulateTimelineEntry, labelOptions } = useContext(LabelTabContext); const { height: windowHeight } = useWindowDimensions(); // modal visible for which input type? (mode or purpose or replaced_mode, null if not visible) - const [ modalVisibleFor, setModalVisibleFor ] = useState<'MODE'|'PURPOSE'|'REPLACED_MODE'|null>(null); - const [otherLabel, setOtherLabel] = useState(null); + const [modalVisibleFor, setModalVisibleFor] = useState< + 'MODE' | 'PURPOSE' | 'REPLACED_MODE' | null + >(null); + const [otherLabel, setOtherLabel] = useState(null); const chosenLabel = useMemo(() => { if (otherLabel != null) return 'other'; - return trip.userInput[modalVisibleFor]?.value + return trip.userInput[modalVisibleFor]?.value; }, [modalVisibleFor, otherLabel]); // to mark 'inferred' labels as 'confirmed'; turn yellow labels blue @@ -51,94 +61,116 @@ const MultilabelButtonGroup = ({ trip, buttonsInline=false }) => { } function store(inputType, chosenLabel, isOther) { - if (!chosenLabel) return displayErrorMsg("Label is empty"); + if (!chosenLabel) return displayErrorMsg('Label is empty'); if (isOther) { /* Let's make the value for user entered inputs look consistent with our other values (i.e. lowercase, and with underscores instead of spaces) */ chosenLabel = readableLabelToKey(chosenLabel); } const inputDataToStore = { - "start_ts": trip.start_ts, - "end_ts": trip.end_ts, - "label": chosenLabel, + start_ts: trip.start_ts, + end_ts: trip.end_ts, + label: chosenLabel, }; const storageKey = getLabelInputDetails()[inputType].key; window['cordova'].plugins.BEMUserCache.putMessage(storageKey, inputDataToStore).then(() => { dismiss(); repopulateTimelineEntry(trip._id.$oid); - logDebug("Successfully stored input data "+JSON.stringify(inputDataToStore)); + logDebug('Successfully stored input data ' + JSON.stringify(inputDataToStore)); }); } const inputKeys = Object.keys(trip.inputDetails); - return (<> - - - {inputKeys.map((key, i) => { - const input = trip.inputDetails[key]; - const inputIsConfirmed = trip.userInput[input.name]; - const inputIsInferred = trip.finalInference[input.name]; - let fillColor, textColor, borderColor; - if (inputIsConfirmed) { - fillColor = colors.primary; - } else if (inputIsInferred) { - fillColor = colors.secondaryContainer; - borderColor = colors.secondary; - textColor = colors.onSecondaryContainer; - } - const btnText = inputIsConfirmed?.text || inputIsInferred?.text || input.choosetext; + return ( + <> + + + {inputKeys.map((key, i) => { + const input = trip.inputDetails[key]; + const inputIsConfirmed = trip.userInput[input.name]; + const inputIsInferred = trip.finalInference[input.name]; + let fillColor, textColor, borderColor; + if (inputIsConfirmed) { + fillColor = colors.primary; + } else if (inputIsInferred) { + fillColor = colors.secondaryContainer; + borderColor = colors.secondary; + textColor = colors.onSecondaryContainer; + } + const btnText = inputIsConfirmed?.text || inputIsInferred?.text || input.choosetext; - return ( - - {t(input.labeltext)} - setModalVisibleFor(input.name)}> - { t(btnText) } - - - ) - })} - - {trip.verifiability === 'can-verify' && ( - - + return ( + + {t(input.labeltext)} + setModalVisibleFor(input.name)}> + {t(btnText)} + + + ); + })} - )} - - dismiss()}> - dismiss()}> - - - {(modalVisibleFor == 'MODE') && t('diary.select-mode-scroll') || - (modalVisibleFor == 'PURPOSE') && t('diary.select-purpose-scroll') || - (modalVisibleFor == 'REPLACED_MODE') && t('diary.select-replaced-mode-scroll')} - - - - onChooseLabel(val)} value={chosenLabel}> - {labelOptions?.[modalVisibleFor]?.map((o, i) => ( - // @ts-ignore - - ))} - - - - {otherLabel != null && <> - setOtherLabel(t)} /> - - - - } - - - - ); + {trip.verifiability === 'can-verify' && ( + + + + )} + + dismiss()}> + dismiss()}> + + + {(modalVisibleFor == 'MODE' && t('diary.select-mode-scroll')) || + (modalVisibleFor == 'PURPOSE' && t('diary.select-purpose-scroll')) || + (modalVisibleFor == 'REPLACED_MODE' && t('diary.select-replaced-mode-scroll'))} + + + + onChooseLabel(val)} value={chosenLabel}> + {labelOptions?.[modalVisibleFor]?.map((o, i) => ( + // @ts-ignore + + ))} + + + + {otherLabel != null && ( + <> + setOtherLabel(t)} + /> + + + + + )} + + + + + ); }; export default MultilabelButtonGroup; diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index b668669bf..a8972709b 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -1,34 +1,36 @@ // may refactor this into a React hook once it's no longer used by any Angular screens -import { getAngularService } from "../../angular-react-helper"; -import { fetchUrlCached } from "../../commHelper"; -import i18next from "i18next"; -import { logDebug } from "../../plugin/logger"; +import { getAngularService } from '../../angular-react-helper'; +import { fetchUrlCached } from '../../commHelper'; +import i18next from 'i18next'; +import { logDebug } from '../../plugin/logger'; type InputDetails = { [k in T]?: { - name: string, - labeltext: string, - choosetext: string, - key: string, - } + name: string; + labeltext: string; + choosetext: string; + key: string; + }; }; -export type LabelOptions = { +export type LabelOptions = { [k in T]: { - value: string, - baseMode: string, - met?: {range: any[], mets: number} - met_equivalent?: string, - kgCo2PerKm: number, - text?: string, - }[] -} & { translations: { - [lang: string]: { [translationKey: string]: string } -}}; + value: string; + baseMode: string; + met?: { range: any[]; mets: number }; + met_equivalent?: string; + kgCo2PerKm: number; + text?: string; + }[]; +} & { + translations: { + [lang: string]: { [translationKey: string]: string }; + }; +}; let appConfig; -export let labelOptions: LabelOptions<'MODE'|'PURPOSE'|'REPLACED_MODE'>; -export let inputDetails: InputDetails<'MODE'|'PURPOSE'|'REPLACED_MODE'>; +export let labelOptions: LabelOptions<'MODE' | 'PURPOSE' | 'REPLACED_MODE'>; +export let inputDetails: InputDetails<'MODE' | 'PURPOSE' | 'REPLACED_MODE'>; export async function getLabelOptions(appConfigParam?) { if (appConfigParam) appConfig = appConfigParam; @@ -36,11 +38,15 @@ export async function getLabelOptions(appConfigParam?) { if (appConfig.label_options) { const labelOptionsJson = await fetchUrlCached(appConfig.label_options); - logDebug("label_options found in config, using dynamic label options at " + appConfig.label_options); + logDebug( + 'label_options found in config, using dynamic label options at ' + appConfig.label_options, + ); labelOptions = JSON.parse(labelOptionsJson) as LabelOptions; } else { const defaultLabelOptionsURL = 'json/label-options.json.sample'; - logDebug("No label_options found in config, using default label options at " + defaultLabelOptionsURL); + logDebug( + 'No label_options found in config, using default label options at ' + defaultLabelOptionsURL, + ); const defaultLabelOptionsJson = await fetchUrlCached(defaultLabelOptionsURL); labelOptions = JSON.parse(defaultLabelOptionsJson) as LabelOptions; } @@ -51,7 +57,10 @@ export async function getLabelOptions(appConfigParam?) { labelOptions[opt]?.forEach?.((o, i) => { const translationKey = o.value; // If translation exists in labelOptions, use that. Otherwise, use the one in the i18next. If there is not "translations" field in labelOptions, defaultly use the one in the i18next. - const translation = labelOptions.translations ? labelOptions.translations[lang][translationKey] || i18next.t(`multilabel.${translationKey}`) : i18next.t(`multilabel.${translationKey}`); + const translation = labelOptions.translations + ? labelOptions.translations[lang][translationKey] || + i18next.t(`multilabel.${translationKey}`) + : i18next.t(`multilabel.${translationKey}`); labelOptions[opt][i].text = translation; }); } @@ -60,18 +69,18 @@ export async function getLabelOptions(appConfigParam?) { export const baseLabelInputDetails = { MODE: { - name: "MODE", - labeltext: "diary.mode", - choosetext: "diary.choose-mode", - key: "manual/mode_confirm", + name: 'MODE', + labeltext: 'diary.mode', + choosetext: 'diary.choose-mode', + key: 'manual/mode_confirm', }, PURPOSE: { - name: "PURPOSE", - labeltext: "diary.purpose", - choosetext: "diary.choose-purpose", - key: "manual/purpose_confirm", + name: 'PURPOSE', + labeltext: 'diary.purpose', + choosetext: 'diary.choose-purpose', + key: 'manual/purpose_confirm', }, -} +}; export function getLabelInputDetails(appConfigParam?) { if (appConfigParam) appConfig = appConfigParam; @@ -83,13 +92,14 @@ export function getLabelInputDetails(appConfigParam?) { return baseLabelInputDetails; } // else this is a program, so add the REPLACED_MODE - inputDetails = { ...baseLabelInputDetails, + inputDetails = { + ...baseLabelInputDetails, REPLACED_MODE: { - name: "REPLACED_MODE", - labeltext: "diary.replaces", - choosetext: "diary.choose-replaced-mode", - key: "manual/replaced_mode", - } + name: 'REPLACED_MODE', + labeltext: 'diary.replaces', + choosetext: 'diary.choose-replaced-mode', + key: 'manual/replaced_mode', + }, }; return inputDetails; } @@ -99,16 +109,14 @@ export const getBaseLabelInputs = () => Object.keys(baseLabelInputDetails); /** @description replace all underscores with spaces, and capitalizes the first letter of each word */ export const labelKeyToReadable = (otherValue: string) => { - const words = otherValue.replace(/_/g, " ").trim().split(" "); - if (words.length == 0) return ""; - return words.map((word) => - word[0].toUpperCase() + word.slice(1) - ).join(" "); -} + const words = otherValue.replace(/_/g, ' ').trim().split(' '); + if (words.length == 0) return ''; + return words.map((word) => word[0].toUpperCase() + word.slice(1)).join(' '); +}; /** @description replaces all spaces with underscores, and lowercases the string */ export const readableLabelToKey = (otherText: string) => - otherText.trim().replace(/ /g, "_").toLowerCase(); + otherText.trim().replace(/ /g, '_').toLowerCase(); export const getFakeEntry = (otherValue) => ({ text: labelKeyToReadable(otherValue), @@ -116,4 +124,4 @@ export const getFakeEntry = (otherValue) => ({ }); export const labelKeyToRichMode = (labelKey: string) => - labelOptions?.MODE?.find(m => m.value == labelKey)?.text || labelKeyToReadable(labelKey); + labelOptions?.MODE?.find((m) => m.value == labelKey)?.text || labelKeyToReadable(labelKey); diff --git a/www/js/survey/multilabel/infinite_scroll_filters.ts b/www/js/survey/multilabel/infinite_scroll_filters.ts index 8d71266d9..28d91d48d 100644 --- a/www/js/survey/multilabel/infinite_scroll_filters.ts +++ b/www/js/survey/multilabel/infinite_scroll_filters.ts @@ -6,53 +6,52 @@ * All UI elements should only use $scope variables. */ -import i18next from "i18next"; +import i18next from 'i18next'; const unlabeledCheck = (t) => { - return t.INPUTS - .map((inputType, index) => !t.userInput[inputType]) - .reduce((acc, val) => acc || val, false); -} + return t.INPUTS.map((inputType, index) => !t.userInput[inputType]).reduce( + (acc, val) => acc || val, + false, + ); +}; const invalidCheck = (t) => { - const retVal = - (t.userInput['MODE'] && t.userInput['MODE'].value === 'pilot_ebike') && - (!t.userInput['REPLACED_MODE'] || - t.userInput['REPLACED_MODE'].value === 'pilot_ebike' || - t.userInput['REPLACED_MODE'].value === 'same_mode'); - return retVal; -} + const retVal = + t.userInput['MODE'] && + t.userInput['MODE'].value === 'pilot_ebike' && + (!t.userInput['REPLACED_MODE'] || + t.userInput['REPLACED_MODE'].value === 'pilot_ebike' || + t.userInput['REPLACED_MODE'].value === 'same_mode'); + return retVal; +}; const toLabelCheck = (trip) => { - if (trip.expectation) { - console.log(trip.expectation.to_label) - return trip.expectation.to_label && unlabeledCheck(trip); - } else { - return true; - } -} + if (trip.expectation) { + console.log(trip.expectation.to_label); + return trip.expectation.to_label && unlabeledCheck(trip); + } else { + return true; + } +}; const UNLABELED = { - key: "unlabeled", - text: i18next.t("diary.unlabeled"), - filter: unlabeledCheck, - width: "col-50" -} + key: 'unlabeled', + text: i18next.t('diary.unlabeled'), + filter: unlabeledCheck, + width: 'col-50', +}; const INVALID_EBIKE = { - key: "invalid_ebike", - text: i18next.t("diary.invalid-ebike"), - filter: invalidCheck -} + key: 'invalid_ebike', + text: i18next.t('diary.invalid-ebike'), + filter: invalidCheck, +}; const TO_LABEL = { - key: "to_label", - text: i18next.t("diary.to-label"), - filter: toLabelCheck, - width: "col-50" -} - -export const configuredFilters = [ - TO_LABEL, - UNLABELED -]; \ No newline at end of file + key: 'to_label', + text: i18next.t('diary.to-label'), + filter: toLabelCheck, + width: 'col-50', +}; + +export const configuredFilters = [TO_LABEL, UNLABELED]; diff --git a/www/js/survey/multilabel/multi-label-ui.js b/www/js/survey/multilabel/multi-label-ui.js index 7d1bc4007..c4a8c732c 100644 --- a/www/js/survey/multilabel/multi-label-ui.js +++ b/www/js/survey/multilabel/multi-label-ui.js @@ -1,205 +1,240 @@ import angular from 'angular'; -import { baseLabelInputDetails, getBaseLabelInputs, getFakeEntry, getLabelInputDetails, getLabelInputs, getLabelOptions } from './confirmHelper'; +import { + baseLabelInputDetails, + getBaseLabelInputs, + getFakeEntry, + getLabelInputDetails, + getLabelInputs, + getLabelOptions, +} from './confirmHelper'; import { getConfig } from '../../config/dynamicConfig'; -angular.module('emission.survey.multilabel.buttons', - ['emission.survey.inputmatcher']) - -.factory("MultiLabelService", function($rootScope, InputMatcher, $timeout, $ionicPlatform, Logger) { - var mls = {}; - console.log("Creating MultiLabelService"); - mls.init = function(config) { - Logger.log("About to initialize the MultiLabelService"); - mls.ui_config = config; - getLabelOptions(config).then((inputParams) => mls.inputParams = inputParams); - mls.MANUAL_KEYS = Object.values(getLabelInputDetails(config)).map((inp) => inp.key); - Logger.log("finished initializing the MultiLabelService"); - }; - - $ionicPlatform.ready().then(function() { - Logger.log("UI_CONFIG: about to call configReady function in MultiLabelService"); - getConfig().then((newConfig) => { - mls.init(newConfig); - }).catch((err) => Logger.displayError("Error while handling config in MultiLabelService", err)); - }); - - /** - * Embed 'inputType' to the trip. - */ - - mls.extractResult = (results) => results; - - mls.processManualInputs = function(manualResults, resultMap) { - var mrString = 'unprocessed manual inputs ' - + manualResults.map(function(item, index) { - return ` ${item.length} ${getLabelInputs()[index]}`; - }); - console.log(mrString); - manualResults.forEach(function(mr, index) { - resultMap[getLabelInputs()[index]] = mr; +angular + .module('emission.survey.multilabel.buttons', ['emission.survey.inputmatcher']) + + .factory( + 'MultiLabelService', + function ($rootScope, InputMatcher, $timeout, $ionicPlatform, Logger) { + var mls = {}; + console.log('Creating MultiLabelService'); + mls.init = function (config) { + Logger.log('About to initialize the MultiLabelService'); + mls.ui_config = config; + getLabelOptions(config).then((inputParams) => (mls.inputParams = inputParams)); + mls.MANUAL_KEYS = Object.values(getLabelInputDetails(config)).map((inp) => inp.key); + Logger.log('finished initializing the MultiLabelService'); + }; + + $ionicPlatform.ready().then(function () { + Logger.log('UI_CONFIG: about to call configReady function in MultiLabelService'); + getConfig() + .then((newConfig) => { + mls.init(newConfig); + }) + .catch((err) => + Logger.displayError('Error while handling config in MultiLabelService', err), + ); }); - } - - mls.populateInputsAndInferences = function(trip, manualResultMap) { - if (angular.isDefined(trip)) { - // console.log("Expectation: "+JSON.stringify(trip.expectation)); - // console.log("Inferred labels from server: "+JSON.stringify(trip.inferred_labels)); - trip.userInput = {}; - getLabelInputs().forEach(function(item, index) { - mls.populateManualInputs(trip, trip.nextTrip, item, - manualResultMap[item]); + + /** + * Embed 'inputType' to the trip. + */ + + mls.extractResult = (results) => results; + + mls.processManualInputs = function (manualResults, resultMap) { + var mrString = + 'unprocessed manual inputs ' + + manualResults.map(function (item, index) { + return ` ${item.length} ${getLabelInputs()[index]}`; + }); + console.log(mrString); + manualResults.forEach(function (mr, index) { + resultMap[getLabelInputs()[index]] = mr; }); - trip.finalInference = {}; - mls.inferFinalLabels(trip); - mls.expandInputsIfNecessary(trip); - mls.updateVerifiability(trip); - } else { - console.log("Trip information not yet bound, skipping fill"); - } - } - - /** - * Embed 'inputType' to the trip - * This is the version that is called from the list, which focuses only on - * manual inputs. It also sets some additional values - */ - mls.populateManualInputs = function (trip, nextTrip, inputType, inputList) { - // Check unprocessed labels first since they are more recent - const unprocessedLabelEntry = InputMatcher.getUserInputForTrip(trip, nextTrip, - inputList); - var userInputLabel = unprocessedLabelEntry? unprocessedLabelEntry.data.label : undefined; - if (!angular.isDefined(userInputLabel)) { + }; + + mls.populateInputsAndInferences = function (trip, manualResultMap) { + if (angular.isDefined(trip)) { + // console.log("Expectation: "+JSON.stringify(trip.expectation)); + // console.log("Inferred labels from server: "+JSON.stringify(trip.inferred_labels)); + trip.userInput = {}; + getLabelInputs().forEach(function (item, index) { + mls.populateManualInputs(trip, trip.nextTrip, item, manualResultMap[item]); + }); + trip.finalInference = {}; + mls.inferFinalLabels(trip); + mls.expandInputsIfNecessary(trip); + mls.updateVerifiability(trip); + } else { + console.log('Trip information not yet bound, skipping fill'); + } + }; + + /** + * Embed 'inputType' to the trip + * This is the version that is called from the list, which focuses only on + * manual inputs. It also sets some additional values + */ + mls.populateManualInputs = function (trip, nextTrip, inputType, inputList) { + // Check unprocessed labels first since they are more recent + const unprocessedLabelEntry = InputMatcher.getUserInputForTrip(trip, nextTrip, inputList); + var userInputLabel = unprocessedLabelEntry ? unprocessedLabelEntry.data.label : undefined; + if (!angular.isDefined(userInputLabel)) { userInputLabel = trip.user_input?.[mls.inputType2retKey(inputType)]; - } - mls.populateInput(trip.userInput, inputType, userInputLabel); - // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); - mls.editingTrip = angular.undefined; - } - - /** - * Insert the given userInputLabel into the given inputType's slot in inputField - */ - mls.populateInput = function(tripField, inputType, userInputLabel) { - if (angular.isDefined(userInputLabel)) { - console.log("populateInput: looking in map of "+inputType+" for userInputLabel"+userInputLabel); - var userInputEntry = mls.inputParams[inputType].find(o => o.value == userInputLabel); - if (!angular.isDefined(userInputEntry)) { - userInputEntry = getFakeEntry(userInputLabel); - mls.inputParams[inputType].push(userInputEntry); } - console.log("Mapped label "+userInputLabel+" to entry "+JSON.stringify(userInputEntry)); - tripField[inputType] = userInputEntry; - } - } - - /** - * Given the list of possible label tuples we've been sent and what the user has already input for the trip, choose the best labels to actually present to the user. - * The algorithm below operationalizes these principles: - * - Never consider label tuples that contradict a green label - * - Obey "conservation of uncertainty": the sum of probabilities after filtering by green labels must equal the sum of probabilities before - * - After filtering, predict the most likely choices at the level of individual labels, not label tuples - * - Never show user yellow labels that have a lower probability of being correct than confidenceThreshold - */ - mls.inferFinalLabels = function(trip) { - // Deep copy the possibility tuples - let labelsList = []; - if (angular.isDefined(trip.inferred_labels)) { - labelsList = JSON.parse(JSON.stringify(trip.inferred_labels)); - } - - // Capture the level of certainty so we can reconstruct it later - const totalCertainty = labelsList.map(item => item.p).reduce(((item, rest) => item + rest), 0); - - // Filter out the tuples that are inconsistent with existing green labels - for (const inputType of getLabelInputs()) { - const userInput = trip.userInput[inputType]; - if (userInput) { - const retKey = mls.inputType2retKey(inputType); - labelsList = labelsList.filter(item => item.labels[retKey] == userInput.value); - } - } - - // Red labels if we have no possibilities left - if (labelsList.length == 0) { - for (const inputType of getLabelInputs()) mls.populateInput(trip.finalInference, inputType, undefined); - } - else { - // Normalize probabilities to previous level of certainty - const certaintyScalar = totalCertainty/labelsList.map(item => item.p).reduce((item, rest) => item + rest); - labelsList.forEach(item => item.p*=certaintyScalar); - - for (const inputType of getLabelInputs()) { - // For each label type, find the most probable value by binning by label value and summing - const retKey = mls.inputType2retKey(inputType); - let valueProbs = new Map(); - for (const tuple of labelsList) { - const labelValue = tuple.labels[retKey]; - if (!valueProbs.has(labelValue)) valueProbs.set(labelValue, 0); - valueProbs.set(labelValue, valueProbs.get(labelValue) + tuple.p); + mls.populateInput(trip.userInput, inputType, userInputLabel); + // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); + mls.editingTrip = angular.undefined; + }; + + /** + * Insert the given userInputLabel into the given inputType's slot in inputField + */ + mls.populateInput = function (tripField, inputType, userInputLabel) { + if (angular.isDefined(userInputLabel)) { + console.log( + 'populateInput: looking in map of ' + + inputType + + ' for userInputLabel' + + userInputLabel, + ); + var userInputEntry = mls.inputParams[inputType].find((o) => o.value == userInputLabel); + if (!angular.isDefined(userInputEntry)) { + userInputEntry = getFakeEntry(userInputLabel); + mls.inputParams[inputType].push(userInputEntry); + } + console.log( + 'Mapped label ' + userInputLabel + ' to entry ' + JSON.stringify(userInputEntry), + ); + tripField[inputType] = userInputEntry; } - let max = {p: 0, labelValue: undefined}; - for (const [thisLabelValue, thisP] of valueProbs) { - // In the case of a tie, keep the label with earlier first appearance in the labelsList (we used a Map to preserve this order) - if (thisP > max.p) max = {p: thisP, labelValue: thisLabelValue}; + }; + + /** + * Given the list of possible label tuples we've been sent and what the user has already input for the trip, choose the best labels to actually present to the user. + * The algorithm below operationalizes these principles: + * - Never consider label tuples that contradict a green label + * - Obey "conservation of uncertainty": the sum of probabilities after filtering by green labels must equal the sum of probabilities before + * - After filtering, predict the most likely choices at the level of individual labels, not label tuples + * - Never show user yellow labels that have a lower probability of being correct than confidenceThreshold + */ + mls.inferFinalLabels = function (trip) { + // Deep copy the possibility tuples + let labelsList = []; + if (angular.isDefined(trip.inferred_labels)) { + labelsList = JSON.parse(JSON.stringify(trip.inferred_labels)); } - // Display a label as red if its most probable inferred value has a probability less than or equal to the trip's confidence_threshold - // Fails safe if confidence_threshold doesn't exist - if (max.p <= trip.confidence_threshold) max.labelValue = undefined; - - mls.populateInput(trip.finalInference, inputType, max.labelValue); - } - } - } - - /* - * Uses either 2 or 3 labels depending on the type of install (program vs. study) - * and the primary mode. - * This used to be in the controller, where it really should be, but we had - * to move it to the service because we need to invoke it from the list view - * as part of filtering "To Label" entries. - * - * TODO: Move it back later after the diary vs. label unification - */ - mls.expandInputsIfNecessary = function(trip) { - console.log("Reading expanding inputs for ", trip); - const inputValue = trip.userInput["MODE"]? trip.userInput["MODE"].value : undefined; - console.log("Experimenting with expanding inputs for mode "+inputValue); - if (mls.ui_config.intro.mode_studied) { - if (inputValue == mls.ui_config.intro.mode_studied) { - Logger.log("Found "+mls.ui_config.intro.mode_studied+" mode in a program, displaying full details"); + // Capture the level of certainty so we can reconstruct it later + const totalCertainty = labelsList + .map((item) => item.p) + .reduce((item, rest) => item + rest, 0); + + // Filter out the tuples that are inconsistent with existing green labels + for (const inputType of getLabelInputs()) { + const userInput = trip.userInput[inputType]; + if (userInput) { + const retKey = mls.inputType2retKey(inputType); + labelsList = labelsList.filter((item) => item.labels[retKey] == userInput.value); + } + } + + // Red labels if we have no possibilities left + if (labelsList.length == 0) { + for (const inputType of getLabelInputs()) + mls.populateInput(trip.finalInference, inputType, undefined); + } else { + // Normalize probabilities to previous level of certainty + const certaintyScalar = + totalCertainty / labelsList.map((item) => item.p).reduce((item, rest) => item + rest); + labelsList.forEach((item) => (item.p *= certaintyScalar)); + + for (const inputType of getLabelInputs()) { + // For each label type, find the most probable value by binning by label value and summing + const retKey = mls.inputType2retKey(inputType); + let valueProbs = new Map(); + for (const tuple of labelsList) { + const labelValue = tuple.labels[retKey]; + if (!valueProbs.has(labelValue)) valueProbs.set(labelValue, 0); + valueProbs.set(labelValue, valueProbs.get(labelValue) + tuple.p); + } + let max = { p: 0, labelValue: undefined }; + for (const [thisLabelValue, thisP] of valueProbs) { + // In the case of a tie, keep the label with earlier first appearance in the labelsList (we used a Map to preserve this order) + if (thisP > max.p) max = { p: thisP, labelValue: thisLabelValue }; + } + + // Display a label as red if its most probable inferred value has a probability less than or equal to the trip's confidence_threshold + // Fails safe if confidence_threshold doesn't exist + if (max.p <= trip.confidence_threshold) max.labelValue = undefined; + + mls.populateInput(trip.finalInference, inputType, max.labelValue); + } + } + }; + + /* + * Uses either 2 or 3 labels depending on the type of install (program vs. study) + * and the primary mode. + * This used to be in the controller, where it really should be, but we had + * to move it to the service because we need to invoke it from the list view + * as part of filtering "To Label" entries. + * + * TODO: Move it back later after the diary vs. label unification + */ + mls.expandInputsIfNecessary = function (trip) { + console.log('Reading expanding inputs for ', trip); + const inputValue = trip.userInput['MODE'] ? trip.userInput['MODE'].value : undefined; + console.log('Experimenting with expanding inputs for mode ' + inputValue); + if (mls.ui_config.intro.mode_studied) { + if (inputValue == mls.ui_config.intro.mode_studied) { + Logger.log( + 'Found ' + + mls.ui_config.intro.mode_studied + + ' mode in a program, displaying full details', + ); trip.inputDetails = getLabelInputDetails(); trip.INPUTS = getLabelInputs(); - } else { - Logger.log("Found non "+mls.ui_config.intro.mode_studied+" mode in a program, displaying base details"); + } else { + Logger.log( + 'Found non ' + + mls.ui_config.intro.mode_studied + + ' mode in a program, displaying base details', + ); trip.inputDetails = baseLabelInputDetails; trip.INPUTS = getBaseLabelInputs(); + } + } else { + Logger.log('study, not program, displaying full details'); + trip.INPUTS = getLabelInputs(); + trip.inputDetails = getLabelInputDetails(); + } + }; + + /** + * MODE (becomes manual/mode_confirm) becomes mode_confirm + */ + mls.inputType2retKey = function (inputType) { + return getLabelInputDetails()[inputType].key.split('/')[1]; + }; + + mls.updateVerifiability = function (trip) { + var allGreen = true; + var someYellow = false; + for (const inputType of trip.INPUTS) { + const green = trip.userInput[inputType]; + const yellow = trip.finalInference[inputType] && !green; + if (yellow) someYellow = true; + if (!green) allGreen = false; } - } else { - Logger.log("study, not program, displaying full details"); - trip.INPUTS = getLabelInputs(); - trip.inputDetails = getLabelInputDetails(); - } - } - - /** - * MODE (becomes manual/mode_confirm) becomes mode_confirm - */ - mls.inputType2retKey = function(inputType) { - return getLabelInputDetails()[inputType].key.split("/")[1]; - } - - mls.updateVerifiability = function(trip) { - var allGreen = true; - var someYellow = false; - for (const inputType of trip.INPUTS) { - const green = trip.userInput[inputType]; - const yellow = trip.finalInference[inputType] && !green; - if (yellow) someYellow = true; - if (!green) allGreen = false; - } - trip.verifiability = someYellow ? "can-verify" : (allGreen ? "already-verified" : "cannot-verify"); - } - - return mls; -}); + trip.verifiability = someYellow + ? 'can-verify' + : allGreen + ? 'already-verified' + : 'cannot-verify'; + }; + + return mls; + }, + ); diff --git a/www/js/survey/survey.ts b/www/js/survey/survey.ts index 66f662082..a12e65713 100644 --- a/www/js/survey/survey.ts +++ b/www/js/survey/survey.ts @@ -1,16 +1,16 @@ -import { configuredFilters as multilabelConfiguredFilters } from "./multilabel/infinite_scroll_filters"; -import { configuredFilters as enketoConfiguredFilters } from "./enketo/infinite_scroll_filters"; +import { configuredFilters as multilabelConfiguredFilters } from './multilabel/infinite_scroll_filters'; +import { configuredFilters as enketoConfiguredFilters } from './enketo/infinite_scroll_filters'; -type SurveyOption = { filter: Array, service: string, elementTag: string } -export const SurveyOptions: {[key: string]: SurveyOption} = { +type SurveyOption = { filter: Array; service: string; elementTag: string }; +export const SurveyOptions: { [key: string]: SurveyOption } = { MULTILABEL: { filter: multilabelConfiguredFilters, - service: "MultiLabelService", - elementTag: "multilabel" + service: 'MultiLabelService', + elementTag: 'multilabel', }, ENKETO: { filter: enketoConfiguredFilters, - service: "EnketoTripButtonService", - elementTag: "enketo-trip-button" - } -} + service: 'EnketoTripButtonService', + elementTag: 'enketo-trip-button', + }, +}; diff --git a/www/js/useAppConfig.ts b/www/js/useAppConfig.ts index 633069326..96d1a56cb 100644 --- a/www/js/useAppConfig.ts +++ b/www/js/useAppConfig.ts @@ -1,10 +1,9 @@ -import { useEffect, useState } from "react"; -import { getAngularService } from "./angular-react-helper" -import { configChanged, getConfig, setConfigChanged } from "./config/dynamicConfig"; -import { logDebug } from "./plugin/logger"; +import { useEffect, useState } from 'react'; +import { getAngularService } from './angular-react-helper'; +import { configChanged, getConfig, setConfigChanged } from './config/dynamicConfig'; +import { logDebug } from './plugin/logger'; const useAppConfig = () => { - const [appConfig, setAppConfig] = useState(null); const $ionicPlatform = getAngularService('$ionicPlatform'); @@ -27,6 +26,6 @@ const useAppConfig = () => { updateConfig().then(() => setConfigChanged(false)); } return appConfig; -} +}; export default useAppConfig; diff --git a/www/js/useAppStateChange.ts b/www/js/useAppStateChange.ts index 8b9c6497c..470eb67a6 100644 --- a/www/js/useAppStateChange.ts +++ b/www/js/useAppStateChange.ts @@ -7,23 +7,20 @@ import { useEffect, useRef } from 'react'; import { AppState } from 'react-native'; const useAppStateChange = (onResume) => { + const appState = useRef(AppState.currentState); - const appState = useRef(AppState.currentState); - - useEffect(() => { - const subscription = AppState.addEventListener('change', nextAppState => { - if ( appState.current != 'active' && nextAppState === 'active') { - onResume(); - } - - appState.current = nextAppState; - console.log('AppState', appState.current); - }); - - }, []); - - return {}; - } - - export default useAppStateChange; - \ No newline at end of file + useEffect(() => { + const subscription = AppState.addEventListener('change', (nextAppState) => { + if (appState.current != 'active' && nextAppState === 'active') { + onResume(); + } + + appState.current = nextAppState; + console.log('AppState', appState.current); + }); + }, []); + + return {}; +}; + +export default useAppStateChange; diff --git a/www/js/usePermissionStatus.ts b/www/js/usePermissionStatus.ts index 035ba6b16..1bef38c44 100644 --- a/www/js/usePermissionStatus.ts +++ b/www/js/usePermissionStatus.ts @@ -1,352 +1,434 @@ import { useEffect, useState, useMemo } from 'react'; -import useAppStateChange from "./useAppStateChange"; -import useAppConfig from "./useAppConfig"; +import useAppStateChange from './useAppStateChange'; +import useAppConfig from './useAppConfig'; import { useTheme } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; +import { useTranslation } from 'react-i18next'; //refreshing checks with the plugins to update the check's statusState export function refreshAllChecks(checkList) { - //refresh each check - checkList.forEach((lc) => { - lc.refresh(); - }); - console.log("setting checks are", checkList); + //refresh each check + checkList.forEach((lc) => { + lc.refresh(); + }); + console.log('setting checks are', checkList); } const usePermissionStatus = () => { + const { t } = useTranslation(); + const { colors } = useTheme(); + const appConfig = useAppConfig(); + + const [error, setError] = useState(''); + const [errorVis, setErrorVis] = useState(false); - const { t } = useTranslation(); - const { colors } = useTheme(); - const appConfig = useAppConfig(); + const [checkList, setCheckList] = useState([]); + const [explanationList, setExplanationList] = useState>([]); + const [haveSetText, setHaveSetText] = useState(false); - const [error, setError] = useState(""); - const [errorVis, setErrorVis] = useState(false); + let iconMap = (statusState) => (statusState ? 'check-circle-outline' : 'alpha-x-circle-outline'); + let colorMap = (statusState) => (statusState ? colors.success : colors.danger); - const [checkList, setCheckList] = useState([]); - const [explanationList, setExplanationList] = useState>([]); - const [haveSetText, setHaveSetText] = useState(false); + const overallStatus = useMemo(() => { + let status = true; + if (!checkList?.length) return undefined; // if checks not loaded yet, status is undetermined + checkList.forEach((lc) => { + console.debug('check in permission status for ' + lc.name + ':', lc.statusState); + if (lc.statusState === false) { + status = false; + } + }); + return status; + }, [checkList]); - let iconMap = (statusState) => statusState ? "check-circle-outline" : "alpha-x-circle-outline"; - let colorMap = (statusState) => statusState ? colors.success : colors.danger; + //using this function to update checks rather than mutate + //this cues React to update UI + function updateCheck(newObject) { + var tempList = [...checkList]; //make a copy rather than mutate + //update the visiblility pieces here, rather than mutating + newObject.statusIcon = iconMap(newObject.statusState); + newObject.statusColor = colorMap(newObject.statusState); + //"find and replace" the check + tempList.forEach((item, i) => { + if (item.name == newObject.name) { + tempList[i] = newObject; + } + }); + setCheckList(tempList); + } - const overallStatus = useMemo(() => { - let status = true; - if (!checkList?.length) return undefined; // if checks not loaded yet, status is undetermined - checkList.forEach((lc) => { - console.debug('check in permission status for ' + lc.name + ':', lc.statusState); - if (lc.statusState === false) { - status = false; - } - }) + async function checkOrFix(checkObj, nativeFn, showError = true) { + console.log('checking object', checkObj.name, checkObj); + let newCheck = checkObj; + return nativeFn() + .then((status) => { + console.log('availability ', status); + newCheck.statusState = true; + updateCheck(newCheck); + console.log('after checking object', checkObj.name, checkList); return status; - }, [checkList]) + }) + .catch((error) => { + console.log('Error', error); + if (showError) { + console.log('please fix again'); + setError(error); + setErrorVis(true); + } + newCheck.statusState = false; + updateCheck(newCheck); + console.log('after checking object', checkObj.name, checkList); + return error; + }); + } - //using this function to update checks rather than mutate - //this cues React to update UI - function updateCheck(newObject) { - var tempList = [...checkList]; //make a copy rather than mutate - //update the visiblility pieces here, rather than mutating - newObject.statusIcon = iconMap(newObject.statusState); - newObject.statusColor = colorMap(newObject.statusState); - //"find and replace" the check - tempList.forEach((item, i) => { - if(item.name == newObject.name){ - tempList[i] = newObject; - } - }); - setCheckList(tempList); + function setupAndroidLocChecks() { + let fixSettings = function () { + console.log('Fix and refresh location settings'); + return checkOrFix( + locSettingsCheck, + window['cordova'].plugins.BEMDataCollection.fixLocationSettings, + true, + ); + }; + let checkSettings = function () { + console.log('Refresh location settings'); + return checkOrFix( + locSettingsCheck, + window['cordova'].plugins.BEMDataCollection.isValidLocationSettings, + false, + ); + }; + let fixPerms = function () { + console.log('fix and refresh location permissions'); + return checkOrFix( + locPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.fixLocationPermissions, + true, + ).then((error) => { + if (error) { + locPermissionsCheck.desc = error; + } + }); + }; + let checkPerms = function () { + console.log('fix and refresh location permissions'); + return checkOrFix( + locPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.isValidLocationPermissions, + false, + ); + }; + var androidSettingsDescTag = 'intro.appstatus.locsettings.description.android-gte-9'; + if (window['device'].version.split('.')[0] < 9) { + androidSettingsDescTag = 'intro.appstatus.locsettings.description.android-lt-9'; } - - async function checkOrFix(checkObj, nativeFn, showError=true) { - console.log("checking object", checkObj.name, checkObj); - let newCheck = checkObj; - return nativeFn() - .then((status) => { - console.log("availability ", status) - newCheck.statusState = true; - updateCheck(newCheck); - console.log("after checking object", checkObj.name, checkList); - return status; - }).catch((error) => { - console.log("Error", error) - if (showError) { - console.log("please fix again"); - setError(error); - setErrorVis(true); - }; - newCheck.statusState = false; - updateCheck(newCheck); - console.log("after checking object", checkObj.name, checkList); - return error; - }); + var androidPermDescTag = 'intro.appstatus.locperms.description.android-gte-12'; + if (window['device'].version.split('.')[0] < 6) { + androidPermDescTag = 'intro.appstatus.locperms.description.android-lt-6'; + } else if (window['device'].version.split('.')[0] < 10) { + androidPermDescTag = 'intro.appstatus.locperms.description.android-6-9'; + } else if (window['device'].version.split('.')[0] < 11) { + androidPermDescTag = 'intro.appstatus.locperms.description.android-10'; + } else if (window['device'].version.split('.')[0] < 12) { + androidPermDescTag = 'intro.appstatus.locperms.description.android-11'; } + console.log('description tags are ' + androidSettingsDescTag + ' ' + androidPermDescTag); + // location settings + let locSettingsCheck = { + name: t('intro.appstatus.locsettings.name'), + desc: t(androidSettingsDescTag), + fix: fixSettings, + refresh: checkSettings, + }; + let locPermissionsCheck = { + name: t('intro.appstatus.locperms.name'), + desc: t(androidPermDescTag), + fix: fixPerms, + refresh: checkPerms, + }; + let tempChecks = checkList; + tempChecks.push(locSettingsCheck, locPermissionsCheck); + setCheckList(tempChecks); + } - function setupAndroidLocChecks() { - let fixSettings = function() { - console.log("Fix and refresh location settings"); - return checkOrFix(locSettingsCheck, window['cordova'].plugins.BEMDataCollection.fixLocationSettings, true); - }; - let checkSettings = function() { - console.log("Refresh location settings"); - return checkOrFix(locSettingsCheck, window['cordova'].plugins.BEMDataCollection.isValidLocationSettings, false); - }; - let fixPerms = function() { - console.log("fix and refresh location permissions"); - return checkOrFix(locPermissionsCheck, window['cordova'].plugins.BEMDataCollection.fixLocationPermissions, - true).then((error) => {if(error){locPermissionsCheck.desc = error}}); - }; - let checkPerms = function() { - console.log("fix and refresh location permissions"); - return checkOrFix(locPermissionsCheck, window['cordova'].plugins.BEMDataCollection.isValidLocationPermissions, false); - }; - var androidSettingsDescTag = "intro.appstatus.locsettings.description.android-gte-9"; - if (window['device'].version.split(".")[0] < 9) { - androidSettingsDescTag = "intro.appstatus.locsettings.description.android-lt-9"; - } - var androidPermDescTag = "intro.appstatus.locperms.description.android-gte-12"; - if(window['device'].version.split(".")[0] < 6) { - androidPermDescTag = 'intro.appstatus.locperms.description.android-lt-6'; - } else if (window['device'].version.split(".")[0] < 10) { - androidPermDescTag = "intro.appstatus.locperms.description.android-6-9"; - } else if (window['device'].version.split(".")[0] < 11) { - androidPermDescTag= "intro.appstatus.locperms.description.android-10"; - } else if (window['device'].version.split(".")[0] < 12) { - androidPermDescTag= "intro.appstatus.locperms.description.android-11"; - } - console.log("description tags are "+androidSettingsDescTag+" "+androidPermDescTag); - // location settings - let locSettingsCheck = { - name: t("intro.appstatus.locsettings.name"), - desc: t(androidSettingsDescTag), - fix: fixSettings, - refresh: checkSettings + function setupIOSLocChecks() { + let fixSettings = function () { + console.log('Fix and refresh location settings'); + return checkOrFix( + locSettingsCheck, + window['cordova'].plugins.BEMDataCollection.fixLocationSettings, + true, + ); + }; + let checkSettings = function () { + console.log('Refresh location settings'); + return checkOrFix( + locSettingsCheck, + window['cordova'].plugins.BEMDataCollection.isValidLocationSettings, + false, + ); + }; + let fixPerms = function () { + console.log('fix and refresh location permissions'); + return checkOrFix( + locPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.fixLocationPermissions, + true, + ).then((error) => { + if (error) { + locPermissionsCheck.desc = error; } - let locPermissionsCheck = { - name: t("intro.appstatus.locperms.name"), - desc: t(androidPermDescTag), - fix: fixPerms, - refresh: checkPerms - } - let tempChecks = checkList; - tempChecks.push(locSettingsCheck, locPermissionsCheck); - setCheckList(tempChecks); + }); + }; + let checkPerms = function () { + console.log('fix and refresh location permissions'); + return checkOrFix( + locPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.isValidLocationPermissions, + false, + ); + }; + var iOSSettingsDescTag = 'intro.appstatus.locsettings.description.ios'; + var iOSPermDescTag = 'intro.appstatus.locperms.description.ios-gte-13'; + if (window['device'].version.split('.')[0] < 13) { + iOSPermDescTag = 'intro.appstatus.locperms.description.ios-lt-13'; } + console.log('description tags are ' + iOSSettingsDescTag + ' ' + iOSPermDescTag); - function setupIOSLocChecks() { - let fixSettings = function() { - console.log("Fix and refresh location settings"); - return checkOrFix(locSettingsCheck, window['cordova'].plugins.BEMDataCollection.fixLocationSettings, - true); - }; - let checkSettings = function() { - console.log("Refresh location settings"); - return checkOrFix(locSettingsCheck, window['cordova'].plugins.BEMDataCollection.isValidLocationSettings, - false); - }; - let fixPerms = function() { - console.log("fix and refresh location permissions"); - return checkOrFix(locPermissionsCheck, window['cordova'].plugins.BEMDataCollection.fixLocationPermissions, - true).then((error) => {if(error){locPermissionsCheck.desc = error}}); - }; - let checkPerms = function() { - console.log("fix and refresh location permissions"); - return checkOrFix(locPermissionsCheck, window['cordova'].plugins.BEMDataCollection.isValidLocationPermissions, - false); - }; - var iOSSettingsDescTag = "intro.appstatus.locsettings.description.ios"; - var iOSPermDescTag = "intro.appstatus.locperms.description.ios-gte-13"; - if(window['device'].version.split(".")[0] < 13) { - iOSPermDescTag = 'intro.appstatus.locperms.description.ios-lt-13'; - } - console.log("description tags are "+iOSSettingsDescTag+" "+iOSPermDescTag); + const locSettingsCheck = { + name: t('intro.appstatus.locsettings.name'), + desc: t(iOSSettingsDescTag), + fix: fixSettings, + refresh: checkSettings, + }; + const locPermissionsCheck = { + name: t('intro.appstatus.locperms.name'), + desc: t(iOSPermDescTag), + fix: fixPerms, + refresh: checkPerms, + }; + let tempChecks = checkList; + tempChecks.push(locSettingsCheck, locPermissionsCheck); + setCheckList(tempChecks); + } - const locSettingsCheck = { - name: t("intro.appstatus.locsettings.name"), - desc: t(iOSSettingsDescTag), - fix: fixSettings, - refresh: checkSettings - }; - const locPermissionsCheck = { - name: t("intro.appstatus.locperms.name"), - desc: t(iOSPermDescTag), - fix: fixPerms, - refresh: checkPerms - }; - let tempChecks = checkList; - tempChecks.push(locSettingsCheck, locPermissionsCheck); - setCheckList(tempChecks); - } + function setupAndroidFitnessChecks() { + if (window['device'].version.split('.')[0] >= 10) { + let fixPerms = function () { + console.log('fix and refresh fitness permissions'); + return checkOrFix( + fitnessPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.fixFitnessPermissions, + true, + ).then((error) => { + if (error) { + fitnessPermissionsCheck.desc = error; + } + }); + }; + let checkPerms = function () { + console.log('fix and refresh fitness permissions'); + return checkOrFix( + fitnessPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.isValidFitnessPermissions, + false, + ); + }; - function setupAndroidFitnessChecks() { - if(window['device'].version.split(".")[0] >= 10){ - let fixPerms = function() { - console.log("fix and refresh fitness permissions"); - return checkOrFix(fitnessPermissionsCheck, window['cordova'].plugins.BEMDataCollection.fixFitnessPermissions, - true).then((error) => {if(error){fitnessPermissionsCheck.desc = error}}); - }; - let checkPerms = function() { - console.log("fix and refresh fitness permissions"); - return checkOrFix(fitnessPermissionsCheck, window['cordova'].plugins.BEMDataCollection.isValidFitnessPermissions, - false); - }; - - let fitnessPermissionsCheck = { - name: t("intro.appstatus.fitnessperms.name"), - desc: t("intro.appstatus.fitnessperms.description.android"), - fix: fixPerms, - refresh: checkPerms - } - let tempChecks = checkList; - tempChecks.push(fitnessPermissionsCheck); - setCheckList(tempChecks); - } + let fitnessPermissionsCheck = { + name: t('intro.appstatus.fitnessperms.name'), + desc: t('intro.appstatus.fitnessperms.description.android'), + fix: fixPerms, + refresh: checkPerms, + }; + let tempChecks = checkList; + tempChecks.push(fitnessPermissionsCheck); + setCheckList(tempChecks); } + } - function setupIOSFitnessChecks() { - let fixPerms = function() { - console.log("fix and refresh fitness permissions"); - return checkOrFix(fitnessPermissionsCheck, window['cordova'].plugins.BEMDataCollection.fixFitnessPermissions, - true).then((error) => {if(error){fitnessPermissionsCheck.desc = error}}); - }; - let checkPerms = function() { - console.log("fix and refresh fitness permissions"); - return checkOrFix(fitnessPermissionsCheck, window['cordova'].plugins.BEMDataCollection.isValidFitnessPermissions, - false); - }; - - let fitnessPermissionsCheck = { - name: t("intro.appstatus.fitnessperms.name"), - desc: t("intro.appstatus.fitnessperms.description.ios"), - fix: fixPerms, - refresh: checkPerms + function setupIOSFitnessChecks() { + let fixPerms = function () { + console.log('fix and refresh fitness permissions'); + return checkOrFix( + fitnessPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.fixFitnessPermissions, + true, + ).then((error) => { + if (error) { + fitnessPermissionsCheck.desc = error; } - let tempChecks = checkList; - tempChecks.push(fitnessPermissionsCheck); - setCheckList(tempChecks); - } + }); + }; + let checkPerms = function () { + console.log('fix and refresh fitness permissions'); + return checkOrFix( + fitnessPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.isValidFitnessPermissions, + false, + ); + }; - function setupAndroidNotificationChecks() { - let fixPerms = function() { - console.log("fix and refresh notification permissions"); - return checkOrFix(appAndChannelNotificationsCheck, window['cordova'].plugins.BEMDataCollection.fixShowNotifications, - true); - }; - let checkPerms = function() { - console.log("fix and refresh notification permissions"); - return checkOrFix(appAndChannelNotificationsCheck, window['cordova'].plugins.BEMDataCollection.isValidShowNotifications, - false); - }; - let appAndChannelNotificationsCheck = { - name: t("intro.appstatus.notificationperms.app-enabled-name"), - desc: t("intro.appstatus.notificationperms.description.android-enable"), - fix: fixPerms, - refresh: checkPerms - } - let tempChecks = checkList; - tempChecks.push(appAndChannelNotificationsCheck); - setCheckList(tempChecks); + let fitnessPermissionsCheck = { + name: t('intro.appstatus.fitnessperms.name'), + desc: t('intro.appstatus.fitnessperms.description.ios'), + fix: fixPerms, + refresh: checkPerms, + }; + let tempChecks = checkList; + tempChecks.push(fitnessPermissionsCheck); + setCheckList(tempChecks); + } + + function setupAndroidNotificationChecks() { + let fixPerms = function () { + console.log('fix and refresh notification permissions'); + return checkOrFix( + appAndChannelNotificationsCheck, + window['cordova'].plugins.BEMDataCollection.fixShowNotifications, + true, + ); + }; + let checkPerms = function () { + console.log('fix and refresh notification permissions'); + return checkOrFix( + appAndChannelNotificationsCheck, + window['cordova'].plugins.BEMDataCollection.isValidShowNotifications, + false, + ); + }; + let appAndChannelNotificationsCheck = { + name: t('intro.appstatus.notificationperms.app-enabled-name'), + desc: t('intro.appstatus.notificationperms.description.android-enable'), + fix: fixPerms, + refresh: checkPerms, + }; + let tempChecks = checkList; + tempChecks.push(appAndChannelNotificationsCheck); + setCheckList(tempChecks); + } + + function setupAndroidBackgroundRestrictionChecks() { + let fixPerms = function () { + console.log('fix and refresh backgroundRestriction permissions'); + return checkOrFix( + unusedAppsUnrestrictedCheck, + window['cordova'].plugins.BEMDataCollection.fixUnusedAppRestrictions, + true, + ); + }; + let checkPerms = function () { + console.log('fix and refresh backgroundRestriction permissions'); + return checkOrFix( + unusedAppsUnrestrictedCheck, + window['cordova'].plugins.BEMDataCollection.isUnusedAppUnrestricted, + false, + ); + }; + let fixBatteryOpt = function () { + console.log('fix and refresh battery optimization permissions'); + return checkOrFix( + ignoreBatteryOptCheck, + window['cordova'].plugins.BEMDataCollection.fixIgnoreBatteryOptimizations, + true, + ); + }; + let checkBatteryOpt = function () { + console.log('fix and refresh battery optimization permissions'); + return checkOrFix( + ignoreBatteryOptCheck, + window['cordova'].plugins.BEMDataCollection.isIgnoreBatteryOptimizations, + false, + ); + }; + var androidUnusedDescTag = + 'intro.appstatus.unusedapprestrict.description.android-disable-gte-13'; + if (window['device'].version.split('.')[0] == 12) { + androidUnusedDescTag = 'intro.appstatus.unusedapprestrict.description.android-disable-12'; + } else if (window['device'].version.split('.')[0] < 12) { + androidUnusedDescTag = 'intro.appstatus.unusedapprestrict.description.android-disable-lt-12'; } + let unusedAppsUnrestrictedCheck = { + name: t('intro.appstatus.unusedapprestrict.name'), + desc: t(androidUnusedDescTag), + fix: fixPerms, + refresh: checkPerms, + }; + let ignoreBatteryOptCheck = { + name: t('intro.appstatus.ignorebatteryopt.name'), + desc: t('intro.appstatus.ignorebatteryopt.description'), + fix: fixBatteryOpt, + refresh: checkBatteryOpt, + }; + let tempChecks = checkList; + tempChecks.push(unusedAppsUnrestrictedCheck, ignoreBatteryOptCheck); + setCheckList(tempChecks); + } - function setupAndroidBackgroundRestrictionChecks() { - let fixPerms = function() { - console.log("fix and refresh backgroundRestriction permissions"); - return checkOrFix(unusedAppsUnrestrictedCheck, window['cordova'].plugins.BEMDataCollection.fixUnusedAppRestrictions, - true); - }; - let checkPerms = function() { - console.log("fix and refresh backgroundRestriction permissions"); - return checkOrFix(unusedAppsUnrestrictedCheck, window['cordova'].plugins.BEMDataCollection.isUnusedAppUnrestricted, - false); - }; - let fixBatteryOpt = function() { - console.log("fix and refresh battery optimization permissions"); - return checkOrFix(ignoreBatteryOptCheck, window['cordova'].plugins.BEMDataCollection.fixIgnoreBatteryOptimizations, - true); - }; - let checkBatteryOpt = function() { - console.log("fix and refresh battery optimization permissions"); - return checkOrFix(ignoreBatteryOptCheck, window['cordova'].plugins.BEMDataCollection.isIgnoreBatteryOptimizations, - false); - }; - var androidUnusedDescTag = "intro.appstatus.unusedapprestrict.description.android-disable-gte-13"; - if (window['device'].version.split(".")[0] == 12) { - androidUnusedDescTag= "intro.appstatus.unusedapprestrict.description.android-disable-12"; - } - else if (window['device'].version.split(".")[0] < 12) { - androidUnusedDescTag= "intro.appstatus.unusedapprestrict.description.android-disable-lt-12"; - } - let unusedAppsUnrestrictedCheck = { - name: t("intro.appstatus.unusedapprestrict.name"), - desc: t(androidUnusedDescTag), - fix: fixPerms, - refresh: checkPerms - } - let ignoreBatteryOptCheck = { - name: t("intro.appstatus.ignorebatteryopt.name"), - desc: t("intro.appstatus.ignorebatteryopt.description"), - fix: fixBatteryOpt, - refresh: checkBatteryOpt - } - let tempChecks = checkList; - tempChecks.push(unusedAppsUnrestrictedCheck, ignoreBatteryOptCheck); - setCheckList(tempChecks); + function setupPermissionText() { + let tempExplanations = explanationList; + + let overallFitnessName = t('intro.appstatus.overall-fitness-name-android'); + let locExplanation = t('intro.appstatus.overall-loc-description'); + if (window['device'].platform.toLowerCase() == 'ios') { + overallFitnessName = t('intro.appstatus.overall-fitness-name-ios'); } + tempExplanations.push({ name: t('intro.appstatus.overall-loc-name'), desc: locExplanation }); + tempExplanations.push({ + name: overallFitnessName, + desc: t('intro.appstatus.overall-fitness-description'), + }); + tempExplanations.push({ + name: t('intro.appstatus.overall-notification-name'), + desc: t('intro.appstatus.overall-notification-description'), + }); + tempExplanations.push({ + name: t('intro.appstatus.overall-background-restrictions-name'), + desc: t('intro.appstatus.overall-background-restrictions-description'), + }); - function setupPermissionText() { - let tempExplanations = explanationList; + setExplanationList(tempExplanations); - let overallFitnessName = t('intro.appstatus.overall-fitness-name-android'); - let locExplanation = t('intro.appstatus.overall-loc-description'); - if(window['device'].platform.toLowerCase() == "ios") { - overallFitnessName = t('intro.appstatus.overall-fitness-name-ios'); - } - tempExplanations.push({name: t('intro.appstatus.overall-loc-name'), desc: locExplanation}); - tempExplanations.push({name: overallFitnessName, desc: t('intro.appstatus.overall-fitness-description')}); - tempExplanations.push({name: t('intro.appstatus.overall-notification-name'), desc: t('intro.appstatus.overall-notification-description')}); - tempExplanations.push({name: t('intro.appstatus.overall-background-restrictions-name'), desc: t('intro.appstatus.overall-background-restrictions-description')}); + //TODO - update samsung handling based on feedback - setExplanationList(tempExplanations); - - //TODO - update samsung handling based on feedback + console.log('Explanation = ' + explanationList); + } - console.log("Explanation = "+explanationList); + function createChecklist() { + if (window['device'].platform.toLowerCase() == 'android') { + setupAndroidLocChecks(); + setupAndroidFitnessChecks(); + setupAndroidNotificationChecks(); + setupAndroidBackgroundRestrictionChecks(); + } else if (window['device'].platform.toLowerCase() == 'ios') { + setupIOSLocChecks(); + setupIOSFitnessChecks(); + setupAndroidNotificationChecks(); + } else { + setError('Alert! unknownplatform, no tracking'); + setErrorVis(true); + console.log('Alert! unknownplatform, no tracking'); //need an alert, can use AlertBar? } - function createChecklist(){ - if(window['device'].platform.toLowerCase() == "android") { - setupAndroidLocChecks(); - setupAndroidFitnessChecks(); - setupAndroidNotificationChecks(); - setupAndroidBackgroundRestrictionChecks(); - } else if (window['device'].platform.toLowerCase() == "ios") { - setupIOSLocChecks(); - setupIOSFitnessChecks(); - setupAndroidNotificationChecks(); - } else { - setError("Alert! unknownplatform, no tracking"); - setErrorVis(true); - console.log("Alert! unknownplatform, no tracking"); //need an alert, can use AlertBar? - } - - refreshAllChecks(checkList); + refreshAllChecks(checkList); + } + + useAppStateChange(function () { + console.log('PERMISSION CHECK: app has resumed, should refresh'); + refreshAllChecks(checkList); + }); + + //load when ready + useEffect(() => { + if (appConfig && window['device']?.platform) { + setupPermissionText(); + setHaveSetText(true); + console.log('setting up permissions'); + createChecklist(); } + }, [appConfig]); - useAppStateChange( function() { - console.log("PERMISSION CHECK: app has resumed, should refresh"); - refreshAllChecks(checkList); - }); + return { checkList, overallStatus, error, errorVis, setErrorVis, explanationList }; +}; - //load when ready - useEffect(() => { - if (appConfig && window['device']?.platform) { - setupPermissionText(); - setHaveSetText(true); - console.log("setting up permissions"); - createChecklist(); - } - }, [appConfig]); - - return {checkList, overallStatus, error, errorVis, setErrorVis, explanationList}; - } - - export default usePermissionStatus; +export default usePermissionStatus; From 3e6d2b0170f63a3e0ccd74656a9353abd156b3bf Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 2 Nov 2023 13:23:44 -0700 Subject: [PATCH 287/850] Add prettier to workflows --- .github/workflows/prettier.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/workflows/prettier.yml diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml new file mode 100644 index 000000000..05a8c551d --- /dev/null +++ b/.github/workflows/prettier.yml @@ -0,0 +1,11 @@ +name: prettier +on: + pull_request: + +jobs: + run-prettier: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - run: npx prettier --check www + From 72a76fa16a520664b9f3fc8a66f8c6c238cda660 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 2 Nov 2023 14:24:25 -0600 Subject: [PATCH 288/850] add server comm mock this is needed for the updateUser/getUser in storeDeviceSettings and corresponding tests --- www/__mocks__/cordovaMocks.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 9b8980c37..99b9cf3b4 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -143,3 +143,22 @@ export const mockBEMDataCollection = () => { window['cordova'] ||= {}; window['cordova'].plugins.BEMDataCollection = mockBEMDataCollection; } + +export const mockBEMServerCom = () => { + const mockBEMServerCom = { + postUserPersonalData: (actionString, typeString, updateDoc, rs, rj) => { + setTimeout(() => { + console.log("set in mock", updateDoc); + _storage["user_data"] = updateDoc; + rs(); + }, 100) + }, + + getUserPersonalData: (actionString, rs, rj) => { + setTimeout(() => { + rs( _storage["user_data"] ); + }, 100) + } + } + window['cordova'].plugins.BEMServerComm = mockBEMServerCom; +} From 188d478915930d245d2654259a164668d6ba9321 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 2 Nov 2023 14:25:03 -0600 Subject: [PATCH 289/850] start tests needed to await the calls to storeDeviceSettings since it stores a promise needed to clean out the doc in storage --- www/__mocks__/cordovaMocks.ts | 1 + www/__tests__/storeDeviceSettings.test.ts | 65 +++++++++++++++++++++++ www/js/splash/storeDeviceSettings.ts | 20 ++++--- 3 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 www/__tests__/storeDeviceSettings.test.ts diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 99b9cf3b4..1c13cc144 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -63,6 +63,7 @@ export const mockBEMUserCache = () => { return new Promise((rs, rj) => setTimeout(() => { for (let p in _cache) delete _cache[p]; + for (let doc in _storage) delete _storage[doc]; rs(); }, 100) ); diff --git a/www/__tests__/storeDeviceSettings.test.ts b/www/__tests__/storeDeviceSettings.test.ts new file mode 100644 index 000000000..ceaf93bda --- /dev/null +++ b/www/__tests__/storeDeviceSettings.test.ts @@ -0,0 +1,65 @@ + +import { readConsentState, markConsented } from '../js/splash/startprefs'; +import { storageClear } from '../js/plugin/storage'; +import { getUser } from '../js/commHelper'; +import { initStoreDeviceSettings, teardownDeviceSettings } from '../js/splash/storeDeviceSettings'; +import { mockBEMServerCom, mockBEMUserCache, mockCordova, mockDevice, mockGetAppVersion } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; +import { EVENT_NAMES, publish, unsubscribe } from '../js/customEventHandler'; + +mockBEMUserCache(); +mockDevice(); +mockCordova(); +mockLogger(); +mockGetAppVersion(); +mockBEMServerCom(); + +global.fetch = (url: string) => new Promise((rs, rj) => { + setTimeout(() => rs({ + json: () => new Promise((rs, rj) => { + let myJSON = { "emSensorDataCollectionProtocol": { "protocol_id": "2014-04-6267", "approval_date": "2016-07-14" } }; + setTimeout(() => rs(myJSON), 100); + }) + })); +}) as any; + +afterEach(async () => { + await storageClear({ local: true, native: true }); + teardownDeviceSettings(); +}); + +it('stores device settings when intialized after consent', async () => { + await storageClear({ local: true, native: true }); + await readConsentState(); + await markConsented(); + await new Promise((r) => setTimeout(r, 500)); + initStoreDeviceSettings(); + await new Promise((r) => setTimeout(r, 500)); + let user = await getUser(); + expect(user).toMatchObject({ + client_os_version: '14.0.0', + client_app_version: '1.2.3' + }) +}); + +it('does not store if not subscribed', async () => { + publish(EVENT_NAMES.INTRO_DONE_EVENT, "test data"); + publish(EVENT_NAMES.CONSENTED_EVENT, "test data"); + await new Promise((r) => setTimeout(r, 500)); //time to carry out event handling + let user = await getUser(); + expect(user).toBeUndefined(); +}); + + +it('stores device settings after intro done', async () => { + initStoreDeviceSettings(); + await new Promise((r) => setTimeout(r, 500)); //time to check consent and subscribe + publish(EVENT_NAMES.INTRO_DONE_EVENT, "test data"); + await new Promise((r) => setTimeout(r, 500)); //time to carry out event handling + let user = await getUser(); + expect(user).toMatchObject({ + client_os_version: '14.0.0', + client_app_version: '1.2.3' + }) +}); + diff --git a/www/js/splash/storeDeviceSettings.ts b/www/js/splash/storeDeviceSettings.ts index f4269355e..2c86ace49 100644 --- a/www/js/splash/storeDeviceSettings.ts +++ b/www/js/splash/storeDeviceSettings.ts @@ -4,7 +4,7 @@ import { isConsented, readConsentState } from "./startprefs"; import i18next from 'i18next'; import { displayError, logDebug } from '../plugin/logger'; import { readIntroDone } from '../onboarding/onboardingHelper'; -import { subscribe, EVENT_NAMES } from '../customEventHandler'; +import { subscribe, EVENT_NAMES, unsubscribe } from '../customEventHandler'; /** * @function Gathers information about the user's device and stores it @@ -40,10 +40,10 @@ const storeDeviceSettings = function () { console.log("got consented event " + JSON.stringify(event['name']) + " with data " + JSON.stringify(data)); readIntroDone() - .then((isIntroDone) => { + .then(async (isIntroDone) => { if (isIntroDone) { logDebug("intro is done -> reconsent situation, we already have a token -> store device settings"); - storeDeviceSettings(); + await storeDeviceSettings(); } }); } @@ -53,9 +53,9 @@ const storeDeviceSettings = function () { * @param event that called this function * @param data from the event */ -const onIntroEvent = function (event, data) { +const onIntroEvent = async function (event, data) { logDebug("intro is done -> original consent situation, we should have a token by now -> store device settings"); - storeDeviceSettings(); + await storeDeviceSettings(); } /** @@ -65,9 +65,10 @@ const onIntroEvent = function (event, data) { export const initStoreDeviceSettings = function () { readConsentState() .then(isConsented) - .then(function (consentState) { + .then(async function (consentState) { + console.log("found consent", consentState); if (consentState == true) { - storeDeviceSettings(); + await storeDeviceSettings(); } else { logDebug("no consent yet, waiting to store device settings in profile"); } @@ -76,3 +77,8 @@ export const initStoreDeviceSettings = function () { }); logDebug("storedevicesettings startup done"); } + +export const teardownDeviceSettings = function() { + unsubscribe(EVENT_NAMES.CONSENTED_EVENT, (event) => onConsentEvent(event, event.detail)); + unsubscribe(EVENT_NAMES.INTRO_DONE_EVENT, (event) => onIntroEvent(event, event.detail)); +} From 5e0453693de47d302ba94e223aa17d6ea589938b Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 2 Nov 2023 14:39:55 -0600 Subject: [PATCH 290/850] re-prettify merge conflicted files when I was resolving merge conflicts, keeping some of my changes meant that I kept the old formatting. I enabled prettier locally and re-saved those files, resolving this formatting --- www/__mocks__/cordovaMocks.ts | 30 +++++++++++++-------------- www/js/controllers.js | 14 ++----------- www/js/onboarding/onboardingHelper.ts | 14 ++++++------- www/js/splash/startprefs.ts | 27 ++++++++++++------------ 4 files changed, 37 insertions(+), 48 deletions(-) diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index f27dc1c1b..a031c8444 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -128,42 +128,42 @@ export const mockBEMDataCollection = () => { markConsented: (consentDoc) => { setTimeout(() => { _storage['config/consent'] = consentDoc; - }, 100) + }, 100); }, getConfig: () => { return new Promise((rs, rj) => { setTimeout(() => { - rs({ 'ios_use_remote_push_for_sync': true }); - }, 100) + rs({ ios_use_remote_push_for_sync: true }); + }, 100); }); }, handleSilentPush: () => { return new Promise((rs, rj) => setTimeout(() => { rs(); - }, 100) + }, 100), ); - } - } + }, + }; window['cordova'] ||= {}; window['cordova'].plugins.BEMDataCollection = mockBEMDataCollection; -} +}; export const mockBEMServerCom = () => { const mockBEMServerCom = { postUserPersonalData: (actionString, typeString, updateDoc, rs, rj) => { setTimeout(() => { - console.log("set in mock", updateDoc); - _storage["user_data"] = updateDoc; + console.log('set in mock', updateDoc); + _storage['user_data'] = updateDoc; rs(); - }, 100) + }, 100); }, getUserPersonalData: (actionString, rs, rj) => { setTimeout(() => { - rs( _storage["user_data"] ); - }, 100) - } - } + rs(_storage['user_data']); + }, 100); + }, + }; window['cordova'].plugins.BEMServerComm = mockBEMServerCom; -} +}; diff --git a/www/js/controllers.js b/www/js/controllers.js index a973ad16d..e502dda2e 100644 --- a/www/js/controllers.js +++ b/www/js/controllers.js @@ -5,10 +5,7 @@ import { addStatError, addStatReading, statKeys } from './plugin/clientStats'; import { getPendingOnboardingState } from './onboarding/onboardingHelper'; angular - .module('emission.controllers', [ - 'emission.splash.localnotify', - 'emission.splash.remotenotify', - ]) + .module('emission.controllers', ['emission.splash.localnotify', 'emission.splash.remotenotify']) .controller('RootCtrl', function ($scope) {}) @@ -16,14 +13,7 @@ angular .controller( 'SplashCtrl', - function ( - $scope, - $state, - $interval, - $rootScope, - LocalNotify, - RemoteNotify, - ) { + function ($scope, $state, $interval, $rootScope, LocalNotify, RemoteNotify) { console.log('SplashCtrl invoked'); // alert("attach debugger!"); // PushNotify.startupInit(); diff --git a/www/js/onboarding/onboardingHelper.ts b/www/js/onboarding/onboardingHelper.ts index adaf58ad8..fb65c1648 100644 --- a/www/js/onboarding/onboardingHelper.ts +++ b/www/js/onboarding/onboardingHelper.ts @@ -1,9 +1,9 @@ -import { DateTime } from "luxon"; -import { getConfig, resetDataAndRefresh } from "../config/dynamicConfig"; -import { storageGet, storageSet } from "../plugin/storage"; -import { logDebug } from "../plugin/logger"; -import { EVENT_NAMES, publish } from "../customEventHandler"; -import { readConsentState, isConsented } from "../splash/startprefs"; +import { DateTime } from 'luxon'; +import { getConfig, resetDataAndRefresh } from '../config/dynamicConfig'; +import { storageGet, storageSet } from '../plugin/storage'; +import { logDebug } from '../plugin/logger'; +import { EVENT_NAMES, publish } from '../customEventHandler'; +import { readConsentState, isConsented } from '../splash/startprefs'; export const INTRO_DONE_KEY = 'intro_done'; @@ -90,7 +90,7 @@ export async function markIntroDone() { const currDateTime = DateTime.now().toISO(); return storageSet(INTRO_DONE_KEY, currDateTime).then(() => { //handle "on intro" events - logDebug("intro done, publishing event"); + logDebug('intro done, publishing event'); publish(EVENT_NAMES.INTRO_DONE_EVENT, currDateTime); }); } diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index c896a2b2e..22ff4acaa 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -1,6 +1,6 @@ import { storageGet, storageSet } from '../plugin/storage'; import { logInfo, logDebug, displayErrorMsg } from '../plugin/logger'; -import { EVENT_NAMES, publish } from "../customEventHandler"; +import { EVENT_NAMES, publish } from '../customEventHandler'; // data collection consented protocol: string, represents the date on // which the consented protocol was approved by the IRB @@ -31,17 +31,16 @@ export function markConsented() { .then(writeConsentToNative) .then(function (response) { // mark in local storage - storageSet(DATA_COLLECTION_CONSENTED_PROTOCOL, - _req_consent); + storageSet(DATA_COLLECTION_CONSENTED_PROTOCOL, _req_consent); // mark in local variable as well _curr_consented = { ..._req_consent }; // publish event publish(EVENT_NAMES.CONSENTED_EVENT, _req_consent); }) .catch((error) => { - displayErrorMsg(error, "Error while while wrting consent to storage"); + displayErrorMsg(error, 'Error while while wrting consent to storage'); }); -}; +} /** * @function checking for consent locally @@ -93,15 +92,15 @@ export function readConsentState() { */ //used in ProfileSettings export function getConsentDocument() { - return window['cordova'].plugins.BEMUserCache.getDocument('config/consent', false).then( - function (resultDoc) { - if (window['cordova'].plugins.BEMUserCache.isEmptyDoc(resultDoc)) { - return null; - } else { - return resultDoc; - } - }, - ); + return window['cordova'].plugins.BEMUserCache.getDocument('config/consent', false).then(function ( + resultDoc, + ) { + if (window['cordova'].plugins.BEMUserCache.isEmptyDoc(resultDoc)) { + return null; + } else { + return resultDoc; + } + }); } /** From 51108457028fa6c15006b50eb88eec8f98e8139d Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 2 Nov 2023 16:54:36 -0400 Subject: [PATCH 291/850] apply prettier to PR #1086 --- locales | 1 + www/__tests__/diaryHelper.test.ts | 2 +- www/__tests__/inputMatcher.test.ts | 465 +++++++++--------- www/i18n/en.json | 2 +- www/js/diary.js | 9 +- www/js/diary/LabelTab.tsx | 60 ++- www/js/diary/LabelTabContext.ts | 28 +- www/js/diary/cards/ModesIndicator.tsx | 12 +- www/js/diary/cards/PlaceCard.tsx | 18 +- .../details/TripSectionsDescriptives.tsx | 32 +- www/js/diary/diaryHelper.ts | 24 +- www/js/diary/list/DateSelect.tsx | 16 +- www/js/diary/list/LabelListScreen.tsx | 12 +- www/js/diary/timelineHelper.ts | 33 +- www/js/survey/enketo/AddNoteButton.tsx | 21 +- www/js/survey/enketo/AddedNotesList.tsx | 18 +- www/js/survey/enketo/UserInputButton.tsx | 23 +- .../survey/enketo/infinite_scroll_filters.ts | 14 +- www/js/survey/inputMatcher.ts | 342 +++++++------ www/js/survey/multilabel/confirmHelper.ts | 46 +- .../multilabel/infinite_scroll_filters.ts | 29 +- www/js/types/diaryTypes.ts | 148 +++--- 22 files changed, 733 insertions(+), 622 deletions(-) create mode 160000 locales diff --git a/locales b/locales new file mode 160000 index 000000000..7a62b7866 --- /dev/null +++ b/locales @@ -0,0 +1 @@ +Subproject commit 7a62b7866e549fad40217f2229f80e500bc61494 diff --git a/www/__tests__/diaryHelper.test.ts b/www/__tests__/diaryHelper.test.ts index ebcd7e5cf..26ed03a8f 100644 --- a/www/__tests__/diaryHelper.test.ts +++ b/www/__tests__/diaryHelper.test.ts @@ -94,4 +94,4 @@ it('returns the detected modes, with percentages, for a trip', () => { expect(getDetectedModes(myFakeTrip)).toEqual(myFakeDetectedModes); expect(getDetectedModes(myFakeTrip2)).toEqual(myFakeDetectedModes2); expect(getDetectedModes({} as any)).toEqual([]); // empty trip, no sections, no modes -}) +}); diff --git a/www/__tests__/inputMatcher.test.ts b/www/__tests__/inputMatcher.test.ts index 1550686eb..b4082fb33 100644 --- a/www/__tests__/inputMatcher.test.ts +++ b/www/__tests__/inputMatcher.test.ts @@ -1,261 +1,268 @@ -import { - fmtTs, - printUserInput, - validUserInputForDraftTrip, - validUserInputForTimelineEntry, - getNotDeletedCandidates, - getUserInputForTimelineEntry, - getAdditionsForTimelineEntry, - getUniqueEntries +import { + fmtTs, + printUserInput, + validUserInputForDraftTrip, + validUserInputForTimelineEntry, + getNotDeletedCandidates, + getUserInputForTimelineEntry, + getAdditionsForTimelineEntry, + getUniqueEntries, } from '../js/survey/inputMatcher'; import { CompositeTrip, TimelineEntry, UserInputEntry } from '../js/types/diaryTypes'; describe('input-matcher', () => { - let userTrip: UserInputEntry; - let trip: TimelineEntry; - let nextTrip: TimelineEntry; + let userTrip: UserInputEntry; + let trip: TimelineEntry; + let nextTrip: TimelineEntry; - beforeEach(() => { - /* + beforeEach(() => { + /* Create a valid userTrip and trip object before each test case. The trip data is from the 'real_examples' data (shankari_2015-07-22) on the server. For some test cases, I need to generate fake data, such as labels, keys, and origin_keys. In such cases, I referred to 'TestUserInputFakeData.py' on the server. */ - userTrip = { - data: { - end_ts: 1437604764, - start_ts: 1437601247, - label: 'FOO', - status: 'ACTIVE' - }, - metadata: { - time_zone: 'America/Los_Angeles', - plugin: 'none', - write_ts: 1695921991, - platform: 'ios', - read_ts: 0, - key: 'manual/mode_confirm' - }, - key: 'manual/place' - }, - trip = { - key: 'FOO', - origin_key: 'FOO', - start_ts: 1437601000, - end_ts: 1437605000, - enter_ts: 1437605000, - exit_ts: 1437605000, - duration: 100, - }, - nextTrip = { - key: 'BAR', - origin_key: 'BAR', - start_ts: 1437606000, - end_ts: 1437607000, - enter_ts: 1437607000, - exit_ts: 1437607000, - duration: 100, - }, + (userTrip = { + data: { + end_ts: 1437604764, + start_ts: 1437601247, + label: 'FOO', + status: 'ACTIVE', + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695921991, + platform: 'ios', + read_ts: 0, + key: 'manual/mode_confirm', + }, + key: 'manual/place', + }), + (trip = { + key: 'FOO', + origin_key: 'FOO', + start_ts: 1437601000, + end_ts: 1437605000, + enter_ts: 1437605000, + exit_ts: 1437605000, + duration: 100, + }), + (nextTrip = { + key: 'BAR', + origin_key: 'BAR', + start_ts: 1437606000, + end_ts: 1437607000, + enter_ts: 1437607000, + exit_ts: 1437607000, + duration: 100, + }), + // mock Logger + (window['Logger'] = { log: console.log }); + }); - // mock Logger - window['Logger'] = { log: console.log }; - }); + it('tests fmtTs with valid input', () => { + const pstTime = fmtTs(1437601247.8459613, 'America/Los_Angeles'); + const estTime = fmtTs(1437601247.8459613, 'America/New_York'); - it('tests fmtTs with valid input', () => { - const pstTime = fmtTs(1437601247.8459613, 'America/Los_Angeles'); - const estTime = fmtTs(1437601247.8459613, 'America/New_York'); - - // Check if it contains correct year-mm-dd hr:mm - expect(pstTime).toContain('2015-07-22T14:40'); - expect(estTime).toContain('2015-07-22T17:40'); - }); + // Check if it contains correct year-mm-dd hr:mm + expect(pstTime).toContain('2015-07-22T14:40'); + expect(estTime).toContain('2015-07-22T17:40'); + }); - it('tests fmtTs with invalid input', () => { - const formattedTime = fmtTs(0, ''); - expect(formattedTime).toBeFalsy(); - }); + it('tests fmtTs with invalid input', () => { + const formattedTime = fmtTs(0, ''); + expect(formattedTime).toBeFalsy(); + }); - it('tests printUserInput prints the trip log correctly', () => { - const userTripLog = printUserInput(userTrip); - expect(userTripLog).toContain('1437604764'); - expect(userTripLog).toContain('1437601247'); - expect(userTripLog).toContain('FOO'); - }); + it('tests printUserInput prints the trip log correctly', () => { + const userTripLog = printUserInput(userTrip); + expect(userTripLog).toContain('1437604764'); + expect(userTripLog).toContain('1437601247'); + expect(userTripLog).toContain('FOO'); + }); - it('tests validUserInputForDraftTrip with valid trip input', () => { - const validTrp = { - end_ts: 1437604764, - start_ts: 1437601247 - } as CompositeTrip; - const validUserInput = validUserInputForDraftTrip(validTrp, userTrip, false); - expect(validUserInput).toBeTruthy(); - }); + it('tests validUserInputForDraftTrip with valid trip input', () => { + const validTrp = { + end_ts: 1437604764, + start_ts: 1437601247, + } as CompositeTrip; + const validUserInput = validUserInputForDraftTrip(validTrp, userTrip, false); + expect(validUserInput).toBeTruthy(); + }); - it('tests validUserInputForDraftTrip with invalid trip input', () => { - const invalidTrip = { - end_ts: 0, - start_ts: 0 - } as CompositeTrip; - const invalidUserInput = validUserInputForDraftTrip(invalidTrip, userTrip, false); - expect(invalidUserInput).toBeFalsy(); - }); + it('tests validUserInputForDraftTrip with invalid trip input', () => { + const invalidTrip = { + end_ts: 0, + start_ts: 0, + } as CompositeTrip; + const invalidUserInput = validUserInputForDraftTrip(invalidTrip, userTrip, false); + expect(invalidUserInput).toBeFalsy(); + }); - it('tests validUserInputForTimelineEntry with valid trip object', () => { - // we need valid key and origin_key for validUserInputForTimelineEntry test - trip['key'] = 'analysis/confirmed_place'; - trip['origin_key'] = 'analysis/confirmed_place'; - const validTimelineEntry = validUserInputForTimelineEntry(trip, nextTrip, userTrip, false); - expect(validTimelineEntry).toBeTruthy(); - }); + it('tests validUserInputForTimelineEntry with valid trip object', () => { + // we need valid key and origin_key for validUserInputForTimelineEntry test + trip['key'] = 'analysis/confirmed_place'; + trip['origin_key'] = 'analysis/confirmed_place'; + const validTimelineEntry = validUserInputForTimelineEntry(trip, nextTrip, userTrip, false); + expect(validTimelineEntry).toBeTruthy(); + }); - it('tests validUserInputForTimelineEntry with tlEntry with invalid key and origin_key', () => { - const invalidTlEntry = trip; - const invalidTimelineEntry = validUserInputForTimelineEntry(invalidTlEntry, null, userTrip, false); - expect(invalidTimelineEntry).toBeFalsy(); - }); + it('tests validUserInputForTimelineEntry with tlEntry with invalid key and origin_key', () => { + const invalidTlEntry = trip; + const invalidTimelineEntry = validUserInputForTimelineEntry( + invalidTlEntry, + null, + userTrip, + false, + ); + expect(invalidTimelineEntry).toBeFalsy(); + }); - it('tests validUserInputForTimelineEntry with tlEntry with invalie start & end time', () => { - const invalidTlEntry: TimelineEntry = { - key: 'analysis/confirmed_place', - origin_key: 'analysis/confirmed_place', - start_ts: 1, - end_ts: 1, - enter_ts: 1, - exit_ts: 1, - duration: 1, - } - const invalidTimelineEntry = validUserInputForTimelineEntry(invalidTlEntry, null, userTrip, false); - expect(invalidTimelineEntry).toBeFalsy(); - }); + it('tests validUserInputForTimelineEntry with tlEntry with invalie start & end time', () => { + const invalidTlEntry: TimelineEntry = { + key: 'analysis/confirmed_place', + origin_key: 'analysis/confirmed_place', + start_ts: 1, + end_ts: 1, + enter_ts: 1, + exit_ts: 1, + duration: 1, + }; + const invalidTimelineEntry = validUserInputForTimelineEntry( + invalidTlEntry, + null, + userTrip, + false, + ); + expect(invalidTimelineEntry).toBeFalsy(); + }); - it('tests getNotDeletedCandidates called with 0 candidates', () => { - jest.spyOn(console, 'log'); - const candidates = getNotDeletedCandidates([]); - - // check if the log printed collectly with - expect(console.log).toHaveBeenCalledWith('getNotDeletedCandidates called with 0 candidates'); - expect(candidates).toStrictEqual([]); + it('tests getNotDeletedCandidates called with 0 candidates', () => { + jest.spyOn(console, 'log'); + const candidates = getNotDeletedCandidates([]); - }); + // check if the log printed collectly with + expect(console.log).toHaveBeenCalledWith('getNotDeletedCandidates called with 0 candidates'); + expect(candidates).toStrictEqual([]); + }); - it('tests getNotDeletedCandidates called with multiple candidates', () => { - const activeTrip = userTrip; - const deletedTrip = { - data: { - end_ts: 1437604764, - start_ts: 1437601247, - label: 'FOO', - status: 'DELETED', - match_id: 'FOO' - }, - metadata: { - time_zone: 'America/Los_Angeles', - plugin: 'none', - write_ts: 1695921991, - platform: 'ios', - read_ts: 0, - key: 'manual/mode_confirm' - }, - key: 'manual/place' - } - const candidates = [ activeTrip, deletedTrip ]; - const validCandidates = getNotDeletedCandidates(candidates); + it('tests getNotDeletedCandidates called with multiple candidates', () => { + const activeTrip = userTrip; + const deletedTrip = { + data: { + end_ts: 1437604764, + start_ts: 1437601247, + label: 'FOO', + status: 'DELETED', + match_id: 'FOO', + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695921991, + platform: 'ios', + read_ts: 0, + key: 'manual/mode_confirm', + }, + key: 'manual/place', + }; + const candidates = [activeTrip, deletedTrip]; + const validCandidates = getNotDeletedCandidates(candidates); - // check if the result has only 'ACTIVE' data - expect(validCandidates).toHaveLength(1); - expect(validCandidates[0]).toMatchObject(userTrip); + // check if the result has only 'ACTIVE' data + expect(validCandidates).toHaveLength(1); + expect(validCandidates[0]).toMatchObject(userTrip); + }); - }); + it('tests getUserInputForTrip with valid userInputList', () => { + const userInputWriteFirst = { + data: { + end_ts: 1437607732, + label: 'bus', + start_ts: 1437606026, + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695830232, + platform: 'ios', + read_ts: 0, + key: 'manual/mode_confirm', + type: 'message', + }, + }; + const userInputWriteSecond = { + data: { + end_ts: 1437598393, + label: 'e-bike', + start_ts: 1437596745, + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695838268, + platform: 'ios', + read_ts: 0, + key: 'manual/mode_confirm', + type: 'message', + }, + }; + const userInputWriteThird = { + data: { + end_ts: 1437604764, + label: 'e-bike', + start_ts: 1437601247, + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695921991, + platform: 'ios', + read_ts: 0, + key: 'manual/mode_confirm', + type: 'message', + }, + }; - it('tests getUserInputForTrip with valid userInputList', () => { - const userInputWriteFirst = { - data: { - end_ts: 1437607732, - label: 'bus', - start_ts: 1437606026 - }, - metadata: { - time_zone: 'America/Los_Angeles', - plugin: 'none', - write_ts: 1695830232, - platform: 'ios', - read_ts: 0, - key:'manual/mode_confirm', - type:'message' - } - } - const userInputWriteSecond = { - data: { - end_ts: 1437598393, - label: 'e-bike', - start_ts: 1437596745 - }, - metadata: { - time_zone: 'America/Los_Angeles', - plugin: 'none', - write_ts: 1695838268, - platform: 'ios', - read_ts: 0, - key:'manual/mode_confirm', - type:'message' - } - } - const userInputWriteThird = { - data: { - end_ts: 1437604764, - label: 'e-bike', - start_ts: 1437601247 - }, - metadata: { - time_zone: 'America/Los_Angeles', - plugin: 'none', - write_ts: 1695921991, - platform: 'ios', - read_ts: 0, - key:'manual/mode_confirm', - type:'message' - } - } + // make the linst unsorted and then check if userInputWriteThird(latest one) is return output + const userInputList = [userInputWriteSecond, userInputWriteThird, userInputWriteFirst]; + const mostRecentEntry = getUserInputForTimelineEntry(trip, nextTrip, userInputList); + expect(mostRecentEntry).toMatchObject(userInputWriteThird); + }); - // make the linst unsorted and then check if userInputWriteThird(latest one) is return output - const userInputList = [userInputWriteSecond, userInputWriteThird, userInputWriteFirst]; - const mostRecentEntry = getUserInputForTimelineEntry(trip, nextTrip, userInputList); - expect(mostRecentEntry).toMatchObject(userInputWriteThird); - }); + it('tests getUserInputForTrip with invalid userInputList', () => { + const userInputList = undefined; + const mostRecentEntry = getUserInputForTimelineEntry(trip, nextTrip, userInputList); + expect(mostRecentEntry).toBe(undefined); + }); - it('tests getUserInputForTrip with invalid userInputList', () => { - const userInputList = undefined; - const mostRecentEntry = getUserInputForTimelineEntry(trip, nextTrip, userInputList); - expect(mostRecentEntry).toBe(undefined); - }); + it('tests getAdditionsForTimelineEntry with valid additionsList', () => { + const additionsList = new Array(5).fill(userTrip); + trip['key'] = 'analysis/confirmed_place'; + trip['origin_key'] = 'analysis/confirmed_place'; - it('tests getAdditionsForTimelineEntry with valid additionsList', () => { - const additionsList = new Array(5).fill(userTrip); - trip['key'] = 'analysis/confirmed_place'; - trip['origin_key'] = 'analysis/confirmed_place'; + // check if the result keep the all valid userTrip items + const matchingAdditions = getAdditionsForTimelineEntry(trip, nextTrip, additionsList); + expect(matchingAdditions).toHaveLength(5); + }); - // check if the result keep the all valid userTrip items - const matchingAdditions = getAdditionsForTimelineEntry(trip, nextTrip, additionsList); - expect(matchingAdditions).toHaveLength(5); - }); + it('tests getAdditionsForTimelineEntry with invalid additionsList', () => { + const additionsList = undefined; + const matchingAdditions = getAdditionsForTimelineEntry(trip, nextTrip, additionsList); + expect(matchingAdditions).toMatchObject([]); + }); - it('tests getAdditionsForTimelineEntry with invalid additionsList', () => { - const additionsList = undefined; - const matchingAdditions = getAdditionsForTimelineEntry(trip, nextTrip, additionsList); - expect(matchingAdditions).toMatchObject([]); - }); + it('tests getUniqueEntries with valid combinedList', () => { + const combinedList = new Array(5).fill(userTrip); - it('tests getUniqueEntries with valid combinedList', () => { - const combinedList = new Array(5).fill(userTrip); + // check if the result keeps only unique userTrip items + const uniqueEntires = getUniqueEntries(combinedList); + expect(uniqueEntires).toHaveLength(1); + }); - // check if the result keeps only unique userTrip items - const uniqueEntires = getUniqueEntries(combinedList); - expect(uniqueEntires).toHaveLength(1); - }); - - it('tests getUniqueEntries with empty combinedList', () => { - const uniqueEntires = getUniqueEntries([]); - expect(uniqueEntires).toMatchObject([]); - }); -}) + it('tests getUniqueEntries with empty combinedList', () => { + const uniqueEntires = getUniqueEntries([]); + expect(uniqueEntires).toMatchObject([]); + }); +}); diff --git a/www/i18n/en.json b/www/i18n/en.json index 3278340f6..5238021dc 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -460,7 +460,7 @@ "while-loading-another-week": "Error while loading travel of {{when}} week", "while-loading-specific-week": "Error while loading travel for the week of {{day}}", "while-log-messages": "While getting messages from the log ", - "while-max-index" : "While getting max index " + "while-max-index": "While getting max index " }, "consent": { "header": "Consent", diff --git a/www/js/diary.js b/www/js/diary.js index c1fd1963c..c580ad8f2 100644 --- a/www/js/diary.js +++ b/www/js/diary.js @@ -1,9 +1,12 @@ import angular from 'angular'; import LabelTab from './diary/LabelTab'; -angular.module('emission.main.diary',['emission.main.diary.services', - 'emission.plugin.logger', - 'emission.survey.enketo.answer']) +angular + .module('emission.main.diary', [ + 'emission.main.diary.services', + 'emission.plugin.logger', + 'emission.survey.enketo.answer', + ]) .config(function ($stateProvider) { $stateProvider.state('root.main.inf_scroll', { diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index a9fd0d921..882a708b2 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -6,26 +6,36 @@ share the data that has been loaded and interacted with. */ -import React, { useEffect, useState, useRef } from "react"; -import { getAngularService } from "../angular-react-helper"; -import useAppConfig from "../useAppConfig"; -import { useTranslation } from "react-i18next"; -import { invalidateMaps } from "../components/LeafletView"; -import moment from "moment"; -import LabelListScreen from "./list/LabelListScreen"; -import { createStackNavigator } from "@react-navigation/stack"; -import LabelScreenDetails from "./details/LabelDetailsScreen"; -import { NavigationContainer } from "@react-navigation/native"; -import { compositeTrips2TimelineMap, updateAllUnprocessedInputs, updateLocalUnprocessedInputs, unprocessedLabels, unprocessedNotes } from "./timelineHelper"; -import { fillLocationNamesOfTrip, resetNominatimLimiter } from "./addressNamesHelper"; -import { getLabelOptions } from "../survey/multilabel/confirmHelper"; -import { displayError, logDebug } from "../plugin/logger"; -import { useTheme } from "react-native-paper"; -import { getPipelineRangeTs } from "../commHelper"; -import { mapInputsToTimelineEntries } from "../survey/inputMatcher"; -import { configuredFilters as multilabelConfiguredFilters } from "../survey/multilabel/infinite_scroll_filters"; -import { configuredFilters as enketoConfiguredFilters } from "../survey/enketo/infinite_scroll_filters"; -import LabelTabContext, { TimelineLabelMap, TimelineMap, TimelineNotesMap } from "./LabelTabContext"; +import React, { useEffect, useState, useRef } from 'react'; +import { getAngularService } from '../angular-react-helper'; +import useAppConfig from '../useAppConfig'; +import { useTranslation } from 'react-i18next'; +import { invalidateMaps } from '../components/LeafletView'; +import moment from 'moment'; +import LabelListScreen from './list/LabelListScreen'; +import { createStackNavigator } from '@react-navigation/stack'; +import LabelScreenDetails from './details/LabelDetailsScreen'; +import { NavigationContainer } from '@react-navigation/native'; +import { + compositeTrips2TimelineMap, + updateAllUnprocessedInputs, + updateLocalUnprocessedInputs, + unprocessedLabels, + unprocessedNotes, +} from './timelineHelper'; +import { fillLocationNamesOfTrip, resetNominatimLimiter } from './addressNamesHelper'; +import { getLabelOptions } from '../survey/multilabel/confirmHelper'; +import { displayError, logDebug } from '../plugin/logger'; +import { useTheme } from 'react-native-paper'; +import { getPipelineRangeTs } from '../commHelper'; +import { mapInputsToTimelineEntries } from '../survey/inputMatcher'; +import { configuredFilters as multilabelConfiguredFilters } from '../survey/multilabel/infinite_scroll_filters'; +import { configuredFilters as enketoConfiguredFilters } from '../survey/enketo/infinite_scroll_filters'; +import LabelTabContext, { + TimelineLabelMap, + TimelineMap, + TimelineNotesMap, +} from './LabelTabContext'; let showPlaces; const ONE_DAY = 24 * 60 * 60; // seconds @@ -64,7 +74,8 @@ const LabelTab = () => { ? enketoConfiguredFilters : multilabelConfiguredFilters; const allFalseFilters = tripFilters.map((f, i) => ({ - ...f, state: (i == 0 ? true : false) // only the first filter will have state true on init + ...f, + state: i == 0 ? true : false, // only the first filter will have state true on init })); setFilterInputs(allFalseFilters); } @@ -88,7 +99,7 @@ const LabelTab = () => { let entriesToDisplay = allEntries; if (activeFilter) { const entriesAfterFilter = allEntries.filter( - t => t.justRepopulated || activeFilter?.filter(t, newTimelineLabelMap[t._id.$oid]) + (t) => t.justRepopulated || activeFilter?.filter(t, newTimelineLabelMap[t._id.$oid]), ); /* next, filter out any untracked time if the trips that came before and after it are no longer displayed */ @@ -244,10 +255,11 @@ const LabelTab = () => { const timelineMapRef = useRef(timelineMap); async function repopulateTimelineEntry(oid: string) { - if (!timelineMap.has(oid)) return console.error("Item with oid: " + oid + " not found in timeline"); + if (!timelineMap.has(oid)) + return console.error('Item with oid: ' + oid + ' not found in timeline'); await updateLocalUnprocessedInputs(pipelineRange, appConfig); const repopTime = new Date().getTime(); - const newEntry = {...timelineMap.get(oid), justRepopulated: repopTime}; + const newEntry = { ...timelineMap.get(oid), justRepopulated: repopTime }; const newTimelineMap = new Map(timelineMap).set(oid, newEntry); setTimelineMap(newTimelineMap); diff --git a/www/js/diary/LabelTabContext.ts b/www/js/diary/LabelTabContext.ts index 944ba19df..24d7ade41 100644 --- a/www/js/diary/LabelTabContext.ts +++ b/www/js/diary/LabelTabContext.ts @@ -20,20 +20,20 @@ export type TimelineNotesMap = { }; type ContextProps = { - labelOptions: any, - timelineMap: TimelineMap, - timelineLabelMap: TimelineLabelMap, - timelineNotesMap: TimelineNotesMap, - displayedEntries: TimelineEntry[], - filterInputs: any, // TODO - setFilterInputs: any, // TODO - queriedRange: any, // TODO - pipelineRange: any, // TODO - isLoading: string|false, - loadAnotherWeek: any, // TODO - loadSpecificWeek: any, // TODO - refresh: any, // TODO - repopulateTimelineEntry: any, // TODO + labelOptions: any; + timelineMap: TimelineMap; + timelineLabelMap: TimelineLabelMap; + timelineNotesMap: TimelineNotesMap; + displayedEntries: TimelineEntry[]; + filterInputs: any; // TODO + setFilterInputs: any; // TODO + queriedRange: any; // TODO + pipelineRange: any; // TODO + isLoading: string | false; + loadAnotherWeek: any; // TODO + loadSpecificWeek: any; // TODO + refresh: any; // TODO + repopulateTimelineEntry: any; // TODO }; export default createContext(null); diff --git a/www/js/diary/cards/ModesIndicator.tsx b/www/js/diary/cards/ModesIndicator.tsx index 15e300175..89366630d 100644 --- a/www/js/diary/cards/ModesIndicator.tsx +++ b/www/js/diary/cards/ModesIndicator.tsx @@ -1,6 +1,6 @@ import React, { useContext } from 'react'; import { View, StyleSheet } from 'react-native'; -import color from "color"; +import color from 'color'; import LabelTabContext from '../LabelTabContext'; import { logDebug } from '../../plugin/logger'; import { getBaseModeByValue } from '../diaryHelper'; @@ -25,8 +25,14 @@ const ModesIndicator = ({ trip, detectedModes }) => { modeViews = ( - + {timelineLabelMap[trip._id.$oid]?.MODE.text} diff --git a/www/js/diary/cards/PlaceCard.tsx b/www/js/diary/cards/PlaceCard.tsx index e5db2644c..52ad37c44 100644 --- a/www/js/diary/cards/PlaceCard.tsx +++ b/www/js/diary/cards/PlaceCard.tsx @@ -6,17 +6,17 @@ PlaceCards use the blueish 'place' theme flavor. */ -import React, { useContext } from "react"; +import React, { useContext } from 'react'; import { View, StyleSheet } from 'react-native'; import { Text } from 'react-native-paper'; -import useAppConfig from "../../useAppConfig"; -import AddNoteButton from "../../survey/enketo/AddNoteButton"; -import AddedNotesList from "../../survey/enketo/AddedNotesList"; -import { getTheme } from "../../appTheme"; -import { DiaryCard, cardStyles } from "./DiaryCard"; -import { useAddressNames } from "../addressNamesHelper"; -import useDerivedProperties from "../useDerivedProperties"; -import StartEndLocations from "../components/StartEndLocations"; +import useAppConfig from '../../useAppConfig'; +import AddNoteButton from '../../survey/enketo/AddNoteButton'; +import AddedNotesList from '../../survey/enketo/AddedNotesList'; +import { getTheme } from '../../appTheme'; +import { DiaryCard, cardStyles } from './DiaryCard'; +import { useAddressNames } from '../addressNamesHelper'; +import useDerivedProperties from '../useDerivedProperties'; +import StartEndLocations from '../components/StartEndLocations'; import LabelTabContext from '../LabelTabContext'; type Props = { place: { [key: string]: any } }; diff --git a/www/js/diary/details/TripSectionsDescriptives.tsx b/www/js/diary/details/TripSectionsDescriptives.tsx index 579e684dc..53aad9d34 100644 --- a/www/js/diary/details/TripSectionsDescriptives.tsx +++ b/www/js/diary/details/TripSectionsDescriptives.tsx @@ -6,11 +6,15 @@ import useDerivedProperties from '../useDerivedProperties'; import { getBaseModeByKey, getBaseModeByValue } from '../diaryHelper'; import LabelTabContext from '../LabelTabContext'; -const TripSectionsDescriptives = ({ trip, showLabeledMode=false }) => { - +const TripSectionsDescriptives = ({ trip, showLabeledMode = false }) => { const { labelOptions, timelineLabelMap } = useContext(LabelTabContext); - const { displayStartTime, displayTime, formattedDistance, - distanceSuffix, formattedSectionProperties } = useDerivedProperties(trip); + const { + displayStartTime, + displayTime, + formattedDistance, + distanceSuffix, + formattedSectionProperties, + } = useDerivedProperties(trip); const { colors } = useTheme(); @@ -18,21 +22,23 @@ const TripSectionsDescriptives = ({ trip, showLabeledMode=false }) => { let sections = formattedSectionProperties; /* if we're only showing the labeled mode, or there are no sections (i.e. unprocessed trip), we treat this as unimodal and use trip-level attributes to construct a single section */ - if (showLabeledMode && labeledModeForTrip || !trip.sections?.length) { + if ((showLabeledMode && labeledModeForTrip) || !trip.sections?.length) { let baseMode; if (showLabeledMode && labeledModeForTrip) { baseMode = getBaseModeByValue(labeledModeForTrip.value, labelOptions); } else { baseMode = getBaseModeByKey('UNPROCESSED'); } - sections = [{ - startTime: displayStartTime, - duration: displayTime, - distance: formattedDistance, - color: baseMode.color, - icon: baseMode.icon, - text: showLabeledMode && labeledModeForTrip?.text, // label text only shown for labeled trips - }]; + sections = [ + { + startTime: displayStartTime, + duration: displayTime, + distance: formattedDistance, + color: baseMode.color, + icon: baseMode.icon, + text: showLabeledMode && labeledModeForTrip?.text, // label text only shown for labeled trips + }, + ]; } return ( diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts index 4d83f4281..616974b7b 100644 --- a/www/js/diary/diaryHelper.ts +++ b/www/js/diary/diaryHelper.ts @@ -1,9 +1,9 @@ // here we have some helper functions used throughout the label tab // these functions are being gradually migrated out of services.js -import moment from "moment"; -import { DateTime } from "luxon"; -import { LabelOptions, readableLabelToKey } from "../survey/multilabel/confirmHelper"; +import moment from 'moment'; +import { DateTime } from 'luxon'; +import { LabelOptions, readableLabelToKey } from '../survey/multilabel/confirmHelper'; import { CompositeTrip } from '../types/diaryTypes'; export const modeColors = { @@ -25,10 +25,20 @@ type BaseMode = { }; // parallels the server-side MotionTypes enum: https://github.com/e-mission/e-mission-server/blob/94e7478e627fa8c171323662f951c611c0993031/emission/core/wrapper/motionactivity.py#L12 -export type MotionTypeKey = 'IN_VEHICLE' | 'BICYCLING' | 'ON_FOOT' | 'STILL' | 'UNKNOWN' | 'TILTING' - | 'WALKING' | 'RUNNING' | 'NONE' | 'STOPPED_WHILE_IN_VEHICLE' | 'AIR_OR_HSR'; - -const BaseModes: {[k: string]: BaseMode} = { +export type MotionTypeKey = + | 'IN_VEHICLE' + | 'BICYCLING' + | 'ON_FOOT' + | 'STILL' + | 'UNKNOWN' + | 'TILTING' + | 'WALKING' + | 'RUNNING' + | 'NONE' + | 'STOPPED_WHILE_IN_VEHICLE' + | 'AIR_OR_HSR'; + +const BaseModes: { [k: string]: BaseMode } = { // BEGIN MotionTypes IN_VEHICLE: { name: 'IN_VEHICLE', icon: 'speedometer', color: modeColors.red }, BICYCLING: { name: 'BICYCLING', icon: 'bike', color: modeColors.green }, diff --git a/www/js/diary/list/DateSelect.tsx b/www/js/diary/list/DateSelect.tsx index 0aa79f05e..91f2f4fb5 100644 --- a/www/js/diary/list/DateSelect.tsx +++ b/www/js/diary/list/DateSelect.tsx @@ -6,15 +6,15 @@ and allows the user to select a date. */ -import React, { useEffect, useState, useMemo, useContext } from "react"; -import { StyleSheet } from "react-native"; -import moment from "moment"; +import React, { useEffect, useState, useMemo, useContext } from 'react'; +import { StyleSheet } from 'react-native'; +import moment from 'moment'; import LabelTabContext from '../LabelTabContext'; -import { DatePickerModal } from "react-native-paper-dates"; -import { Text, Divider, useTheme } from "react-native-paper"; -import i18next from "i18next"; -import { useTranslation } from "react-i18next"; -import NavBarButton from "../../components/NavBarButton"; +import { DatePickerModal } from 'react-native-paper-dates'; +import { Text, Divider, useTheme } from 'react-native-paper'; +import i18next from 'i18next'; +import { useTranslation } from 'react-i18next'; +import NavBarButton from '../../components/NavBarButton'; const DateSelect = ({ tsRange, loadSpecificWeekFn }) => { const { pipelineRange } = useContext(LabelTabContext); diff --git a/www/js/diary/list/LabelListScreen.tsx b/www/js/diary/list/LabelListScreen.tsx index 897cfbfc1..eb50c05a0 100644 --- a/www/js/diary/list/LabelListScreen.tsx +++ b/www/js/diary/list/LabelListScreen.tsx @@ -1,9 +1,9 @@ -import React, { useContext } from "react"; -import { View } from "react-native"; -import { Appbar, useTheme } from "react-native-paper"; -import DateSelect from "./DateSelect"; -import FilterSelect from "./FilterSelect"; -import TimelineScrollList from "./TimelineScrollList"; +import React, { useContext } from 'react'; +import { View } from 'react-native'; +import { Appbar, useTheme } from 'react-native-paper'; +import DateSelect from './DateSelect'; +import FilterSelect from './FilterSelect'; +import TimelineScrollList from './TimelineScrollList'; import LabelTabContext from '../LabelTabContext'; const LabelListScreen = () => { diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 5ad5dc103..d6e36c397 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -1,11 +1,11 @@ -import moment from "moment"; -import { getAngularService } from "../angular-react-helper"; -import { displayError, logDebug } from "../plugin/logger"; -import { getBaseModeByKey, getBaseModeByValue } from "./diaryHelper"; -import i18next from "i18next"; -import { UserInputEntry } from "../types/diaryTypes"; -import { getLabelInputDetails, getLabelInputs } from "../survey/multilabel/confirmHelper"; -import { getNotDeletedCandidates, getUniqueEntries } from "../survey/inputMatcher"; +import moment from 'moment'; +import { getAngularService } from '../angular-react-helper'; +import { displayError, logDebug } from '../plugin/logger'; +import { getBaseModeByKey, getBaseModeByValue } from './diaryHelper'; +import i18next from 'i18next'; +import { UserInputEntry } from '../types/diaryTypes'; +import { getLabelInputDetails, getLabelInputs } from '../survey/multilabel/confirmHelper'; +import { getNotDeletedCandidates, getUniqueEntries } from '../survey/inputMatcher'; const cachedGeojsons = new Map(); /** @@ -99,8 +99,8 @@ function updateUnprocessedInputs(labelsPromises, notesPromises, appConfig) { }); // merge the notes we just read into the existing unprocessedNotes, removing duplicates const combinedNotes = [...unprocessedNotes, ...notesResults]; - unprocessedNotes = combinedNotes.filter((note, i, self) => - self.findIndex(n => n.metadata.write_ts == note.metadata.write_ts) == i + unprocessedNotes = combinedNotes.filter( + (note, i, self) => self.findIndex((n) => n.metadata.write_ts == note.metadata.write_ts) == i, ); }); } @@ -110,17 +110,17 @@ function updateUnprocessedInputs(labelsPromises, notesPromises, appConfig) { * pipeline range and have not yet been pushed to the server. * @param pipelineRange an object with start_ts and end_ts representing the range of time * for which travel data has been processed through the pipeline on the server -* @param appConfig the app configuration + * @param appConfig the app configuration * @returns Promise an array with 1) results for labels and 2) results for notes */ export async function updateLocalUnprocessedInputs(pipelineRange, appConfig) { const BEMUserCache = window['cordova'].plugins.BEMUserCache; const tq = getUnprocessedInputQuery(pipelineRange); const labelsPromises = keysForLabelInputs(appConfig).map((key) => - BEMUserCache.getMessagesForInterval(key, tq, true) + BEMUserCache.getMessagesForInterval(key, tq, true), ); const notesPromises = keysForNotesInputs(appConfig).map((key) => - BEMUserCache.getMessagesForInterval(key, tq, true) + BEMUserCache.getMessagesForInterval(key, tq, true), ); await updateUnprocessedInputs(labelsPromises, notesPromises, appConfig); } @@ -137,10 +137,10 @@ export async function updateAllUnprocessedInputs(pipelineRange, appConfig) { const UnifiedDataLoader = getAngularService('UnifiedDataLoader'); const tq = getUnprocessedInputQuery(pipelineRange); const labelsPromises = keysForLabelInputs(appConfig).map((key) => - UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true) + UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true), ); const notesPromises = keysForNotesInputs(appConfig).map((key) => - UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true) + UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true), ); await updateUnprocessedInputs(labelsPromises, notesPromises, appConfig); } @@ -155,8 +155,7 @@ export function keysForLabelInputs(appConfig) { function keysForNotesInputs(appConfig) { const notesKeys = []; - if (appConfig.survey_info?.buttons?.['trip-notes']) - notesKeys.push('manual/trip_addition_input'); + if (appConfig.survey_info?.buttons?.['trip-notes']) notesKeys.push('manual/trip_addition_input'); if (appConfig.survey_info?.buttons?.['place-notes']) notesKeys.push('manual/place_addition_input'); return notesKeys; diff --git a/www/js/survey/enketo/AddNoteButton.tsx b/www/js/survey/enketo/AddNoteButton.tsx index 57f85d062..c4bbcdade 100644 --- a/www/js/survey/enketo/AddNoteButton.tsx +++ b/www/js/survey/enketo/AddNoteButton.tsx @@ -7,13 +7,13 @@ The start and end times of the addition are determined by the survey response. */ -import React, { useEffect, useState, useContext } from "react"; -import DiaryButton from "../../components/DiaryButton"; -import { useTranslation } from "react-i18next"; -import moment from "moment"; -import LabelTabContext from "../../diary/LabelTabContext"; -import EnketoModal from "./EnketoModal"; -import { displayErrorMsg, logDebug } from "../../plugin/logger"; +import React, { useEffect, useState, useContext } from 'react'; +import DiaryButton from '../../components/DiaryButton'; +import { useTranslation } from 'react-i18next'; +import moment from 'moment'; +import LabelTabContext from '../../diary/LabelTabContext'; +import EnketoModal from './EnketoModal'; +import { displayErrorMsg, logDebug } from '../../plugin/logger'; type Props = { timelineEntry: any; @@ -23,7 +23,7 @@ type Props = { const AddNoteButton = ({ timelineEntry, notesConfig, storeKey }: Props) => { const { t, i18n } = useTranslation(); const [displayLabel, setDisplayLabel] = useState(''); - const { repopulateTimelineEntry, timelineNotesMap } = useContext(LabelTabContext) + const { repopulateTimelineEntry, timelineNotesMap } = useContext(LabelTabContext); useEffect(() => { let newLabel: string; @@ -43,9 +43,8 @@ const AddNoteButton = ({ timelineEntry, notesConfig, storeKey }: Props) => { let stop = timelineEntry.end_ts || timelineEntry.exit_ts; // if addition(s) already present on this timeline entry, `begin` where the last one left off - timelineNotesMap[timelineEntry._id.$oid]?.forEach(a => { - if (a.data.end_ts > (begin || 0) && a.data.end_ts != stop) - begin = a.data.end_ts; + timelineNotesMap[timelineEntry._id.$oid]?.forEach((a) => { + if (a.data.end_ts > (begin || 0) && a.data.end_ts != stop) begin = a.data.end_ts; }); const timezone = diff --git a/www/js/survey/enketo/AddedNotesList.tsx b/www/js/survey/enketo/AddedNotesList.tsx index cab23d4a7..7cc161779 100644 --- a/www/js/survey/enketo/AddedNotesList.tsx +++ b/www/js/survey/enketo/AddedNotesList.tsx @@ -2,15 +2,15 @@ Notes are added from the AddNoteButton and are derived from survey responses. */ -import React, { useContext, useState } from "react"; -import moment from "moment"; -import { Modal } from "react-native" -import { Text, Button, DataTable, Dialog } from "react-native-paper"; -import LabelTabContext from "../../diary/LabelTabContext"; -import { getFormattedDateAbbr, isMultiDay } from "../../diary/diaryHelper"; -import { Icon } from "../../components/Icon"; -import EnketoModal from "./EnketoModal"; -import { useTranslation } from "react-i18next"; +import React, { useContext, useState } from 'react'; +import moment from 'moment'; +import { Modal } from 'react-native'; +import { Text, Button, DataTable, Dialog } from 'react-native-paper'; +import LabelTabContext from '../../diary/LabelTabContext'; +import { getFormattedDateAbbr, isMultiDay } from '../../diary/diaryHelper'; +import { Icon } from '../../components/Icon'; +import EnketoModal from './EnketoModal'; +import { useTranslation } from 'react-i18next'; type Props = { timelineEntry: any; diff --git a/www/js/survey/enketo/UserInputButton.tsx b/www/js/survey/enketo/UserInputButton.tsx index 39f75384b..f2ed4c6e7 100644 --- a/www/js/survey/enketo/UserInputButton.tsx +++ b/www/js/survey/enketo/UserInputButton.tsx @@ -8,14 +8,14 @@ The start and end times of the addition are the same as the trip or place. */ -import React, { useContext, useMemo, useState } from "react"; -import { getAngularService } from "../../angular-react-helper"; -import DiaryButton from "../../components/DiaryButton"; -import { useTranslation } from "react-i18next"; -import { useTheme } from "react-native-paper"; -import { displayErrorMsg, logDebug } from "../../plugin/logger"; -import EnketoModal from "./EnketoModal"; -import LabelTabContext from "../../diary/LabelTabContext"; +import React, { useContext, useMemo, useState } from 'react'; +import { getAngularService } from '../../angular-react-helper'; +import DiaryButton from '../../components/DiaryButton'; +import { useTranslation } from 'react-i18next'; +import { useTheme } from 'react-native-paper'; +import { displayErrorMsg, logDebug } from '../../plugin/logger'; +import EnketoModal from './EnketoModal'; +import LabelTabContext from '../../diary/LabelTabContext'; type Props = { timelineEntry: any; @@ -29,9 +29,10 @@ const UserInputButton = ({ timelineEntry }: Props) => { const { repopulateTimelineEntry, timelineLabelMap } = useContext(LabelTabContext); // the label resolved from the survey response, or null if there is no response yet - const responseLabel = useMemo(() => ( - timelineLabelMap[timelineEntry._id.$oid]?.['SURVEY']?.data?.label || null - ), [timelineEntry]); + const responseLabel = useMemo( + () => timelineLabelMap[timelineEntry._id.$oid]?.['SURVEY']?.data?.label || null, + [timelineEntry], + ); function launchUserInputSurvey() { logDebug('UserInputButton: About to launch survey'); diff --git a/www/js/survey/enketo/infinite_scroll_filters.ts b/www/js/survey/enketo/infinite_scroll_filters.ts index 363cfaa85..5d17b600e 100644 --- a/www/js/survey/enketo/infinite_scroll_filters.ts +++ b/www/js/survey/enketo/infinite_scroll_filters.ts @@ -6,18 +6,16 @@ * All UI elements should only use $scope variables. */ -import i18next from "i18next"; +import i18next from 'i18next'; const unlabeledCheck = (trip, userInputForTrip) => { return !userInputForTrip?.['SURVEY']; -} +}; const TO_LABEL = { - key: "to_label", - text: i18next.t("diary.to-label"), + key: 'to_label', + text: i18next.t('diary.to-label'), filter: unlabeledCheck, -} +}; -export const configuredFilters = [ - TO_LABEL, -]; +export const configuredFilters = [TO_LABEL]; diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index 929fb269a..0fc7bd89a 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -1,20 +1,39 @@ -import { logDebug, displayErrorMsg } from "../plugin/logger" -import { DateTime } from "luxon"; -import { CompositeTrip, TimelineEntry, UserInputEntry } from "../types/diaryTypes"; -import { keysForLabelInputs, unprocessedLabels, unprocessedNotes } from "../diary/timelineHelper"; -import { LabelOption, MultilabelKey, getLabelInputDetails, getLabelInputs, getLabelOptions, inputType2retKey, labelKeyToRichMode, labelOptions } from "./multilabel/confirmHelper"; -import { TimelineLabelMap, TimelineNotesMap } from "../diary/LabelTabContext"; +import { logDebug, displayErrorMsg } from '../plugin/logger'; +import { DateTime } from 'luxon'; +import { CompositeTrip, TimelineEntry, UserInputEntry } from '../types/diaryTypes'; +import { keysForLabelInputs, unprocessedLabels, unprocessedNotes } from '../diary/timelineHelper'; +import { + LabelOption, + MultilabelKey, + getLabelInputDetails, + getLabelInputs, + getLabelOptions, + inputType2retKey, + labelKeyToRichMode, + labelOptions, +} from './multilabel/confirmHelper'; +import { TimelineLabelMap, TimelineNotesMap } from '../diary/LabelTabContext'; -const EPOCH_MAXIMUM = 2**31 - 1; +const EPOCH_MAXIMUM = 2 ** 31 - 1; -export const fmtTs = (ts_in_secs: number, tz: string): string | null => DateTime.fromSeconds(ts_in_secs, {zone : tz}).toISO(); +export const fmtTs = (ts_in_secs: number, tz: string): string | null => + DateTime.fromSeconds(ts_in_secs, { zone: tz }).toISO(); -export const printUserInput = (ui: UserInputEntry): string => `${fmtTs(ui.data.start_ts, ui.metadata.time_zone)} (${ui.data.start_ts}) -> -${fmtTs(ui.data.end_ts, ui.metadata.time_zone)} (${ui.data.end_ts}) ${ui.data.label} logged at ${ui.metadata.write_ts}`; +export const printUserInput = (ui: UserInputEntry): string => `${fmtTs( + ui.data.start_ts, + ui.metadata.time_zone, +)} (${ui.data.start_ts}) -> +${fmtTs(ui.data.end_ts, ui.metadata.time_zone)} (${ui.data.end_ts}) ${ui.data.label} logged at ${ + ui.metadata.write_ts +}`; -export const validUserInputForDraftTrip = (trip: CompositeTrip, userInput: UserInputEntry, logsEnabled: boolean): boolean => { - if(logsEnabled) { - logDebug(`Draft trip: +export const validUserInputForDraftTrip = ( + trip: CompositeTrip, + userInput: UserInputEntry, + logsEnabled: boolean, +): boolean => { + if (logsEnabled) { + logDebug(`Draft trip: comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} -> ${fmtTs(userInput.data.end_ts, userInput.metadata.time_zone)} trip = ${fmtTs(trip.start_ts, userInput.metadata.time_zone)} @@ -24,11 +43,14 @@ export const validUserInputForDraftTrip = (trip: CompositeTrip, userInput: UserI || ${-(userInput.data.start_ts - trip.start_ts) <= 15 * 60}) && ${userInput.data.end_ts <= trip.end_ts} `); - } + } - return (userInput.data.start_ts >= trip.start_ts && userInput.data.start_ts < trip.end_ts - || -(userInput.data.start_ts - trip.start_ts) <= 15 * 60) && userInput.data.end_ts <= trip.end_ts; -} + return ( + ((userInput.data.start_ts >= trip.start_ts && userInput.data.start_ts < trip.end_ts) || + -(userInput.data.start_ts - trip.start_ts) <= 15 * 60) && + userInput.data.end_ts <= trip.end_ts + ); +}; export const validUserInputForTimelineEntry = ( tlEntry: TimelineEntry, @@ -36,34 +58,35 @@ export const validUserInputForTimelineEntry = ( userInput: UserInputEntry, logsEnabled: boolean, ): boolean => { - if (!tlEntry.origin_key) return false; - if (tlEntry.origin_key.includes('UNPROCESSED')) return validUserInputForDraftTrip(tlEntry, userInput, logsEnabled); + if (!tlEntry.origin_key) return false; + if (tlEntry.origin_key.includes('UNPROCESSED')) + return validUserInputForDraftTrip(tlEntry, userInput, logsEnabled); - /* Place-level inputs always have a key starting with 'manual/place', and + /* Place-level inputs always have a key starting with 'manual/place', and trip-level inputs never have a key starting with 'manual/place' So if these don't match, we can immediately return false */ - const entryIsPlace = tlEntry.origin_key === 'analysis/confirmed_place'; - const isPlaceInput = (userInput.key || userInput.metadata.key).startsWith('manual/place'); - - if (entryIsPlace !== isPlaceInput) return false; - - let entryStart = tlEntry.start_ts || tlEntry.enter_ts; - let entryEnd = tlEntry.end_ts || tlEntry.exit_ts; - - if (!entryStart && entryEnd) { - /* if a place has no enter time, this is the first start_place of the first composite trip object + const entryIsPlace = tlEntry.origin_key === 'analysis/confirmed_place'; + const isPlaceInput = (userInput.key || userInput.metadata.key).startsWith('manual/place'); + + if (entryIsPlace !== isPlaceInput) return false; + + let entryStart = tlEntry.start_ts || tlEntry.enter_ts; + let entryEnd = tlEntry.end_ts || tlEntry.exit_ts; + + if (!entryStart && entryEnd) { + /* if a place has no enter time, this is the first start_place of the first composite trip object so we will set the start time to the start of the day of the end time for the purpose of comparison */ - entryStart = DateTime.fromSeconds(entryEnd).startOf('day').toUnixInteger(); - } + entryStart = DateTime.fromSeconds(entryEnd).startOf('day').toUnixInteger(); + } - if (!entryEnd) { - /* if a place has no exit time, the user hasn't left there yet + if (!entryEnd) { + /* if a place has no exit time, the user hasn't left there yet so we will set the end time as high as possible for the purpose of comparison */ - entryEnd = EPOCH_MAXIMUM; - } - - if (logsEnabled) { - logDebug(`Cleaned trip: + entryEnd = EPOCH_MAXIMUM; + } + + if (logsEnabled) { + logDebug(`Cleaned trip: comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} -> ${fmtTs(userInput.data.end_ts, userInput.metadata.time_zone)} trip = ${fmtTs(entryStart, userInput.metadata.time_zone)} @@ -73,91 +96,108 @@ export const validUserInputForTimelineEntry = ( end checks are ${userInput.data.end_ts <= entryEnd} || ${userInput.data.end_ts - entryEnd <= 15 * 60}) `); - } + } - /* For this input to match, it must begin after the start of the timelineEntry (inclusive) + /* For this input to match, it must begin after the start of the timelineEntry (inclusive) but before the end of the timelineEntry (exclusive) */ - const startChecks = userInput.data.start_ts >= entryStart && userInput.data.start_ts < entryEnd; - /* A matching user input must also finish before the end of the timelineEntry, + const startChecks = userInput.data.start_ts >= entryStart && userInput.data.start_ts < entryEnd; + /* A matching user input must also finish before the end of the timelineEntry, or within 15 minutes. */ - let endChecks = (userInput.data.end_ts <= entryEnd || (userInput.data.end_ts - entryEnd) <= 15 * 60); + let endChecks = userInput.data.end_ts <= entryEnd || userInput.data.end_ts - entryEnd <= 15 * 60; - if (startChecks && !endChecks) { - if (nextEntry) { - const nextEntryEnd = nextEntry.end_ts || nextEntry.exit_ts; - if (!nextEntryEnd) { // the last place will not have an exit_ts - endChecks = true; // so we will just skip the end check - } else { - endChecks = userInput.data.end_ts <= nextEntryEnd; - logDebug(`Second level of end checks when the next trip is defined(${userInput.data.end_ts} <= ${nextEntryEnd}) ${endChecks}`); - } + if (startChecks && !endChecks) { + if (nextEntry) { + const nextEntryEnd = nextEntry.end_ts || nextEntry.exit_ts; + if (!nextEntryEnd) { + // the last place will not have an exit_ts + endChecks = true; // so we will just skip the end check + } else { + endChecks = userInput.data.end_ts <= nextEntryEnd; + logDebug( + `Second level of end checks when the next trip is defined(${userInput.data.end_ts} <= ${nextEntryEnd}) ${endChecks}`, + ); + } } else { - // next trip is not defined, last trip - endChecks = (userInput.data.end_local_dt.day == userInput.data.start_local_dt.day) - logDebug("Second level of end checks for the last trip of the day"); - logDebug(`compare ${userInput.data.end_local_dt.day} with ${userInput.data.start_local_dt.day} ${endChecks}`); + // next trip is not defined, last trip + endChecks = userInput.data.end_local_dt.day == userInput.data.start_local_dt.day; + logDebug('Second level of end checks for the last trip of the day'); + logDebug( + `compare ${userInput.data.end_local_dt.day} with ${userInput.data.start_local_dt.day} ${endChecks}`, + ); } if (endChecks) { - // If we have flipped the values, check to see that there is sufficient overlap - const overlapDuration = Math.min(userInput.data.end_ts, entryEnd) - Math.max(userInput.data.start_ts, entryStart) - logDebug(`Flipped endCheck, overlap(${overlapDuration})/trip(${tlEntry.duration} (${overlapDuration} / ${tlEntry.duration})`); - endChecks = (overlapDuration/tlEntry.duration) > 0.5; + // If we have flipped the values, check to see that there is sufficient overlap + const overlapDuration = + Math.min(userInput.data.end_ts, entryEnd) - Math.max(userInput.data.start_ts, entryStart); + logDebug( + `Flipped endCheck, overlap(${overlapDuration})/trip(${tlEntry.duration} (${overlapDuration} / ${tlEntry.duration})`, + ); + endChecks = overlapDuration / tlEntry.duration > 0.5; } } return startChecks && endChecks; -} +}; // parallels get_not_deleted_candidates() in trip_queries.py export const getNotDeletedCandidates = (candidates: UserInputEntry[]): UserInputEntry[] => { - console.log('getNotDeletedCandidates called with ' + candidates.length + ' candidates'); - - // We want to retain all ACTIVE entries that have not been DELETED - const allActiveList = candidates.filter(c => !c.data.status || c.data.status == 'ACTIVE'); - const allDeletedIds = candidates.filter(c => c.data.status && c.data.status == 'DELETED').map(c => c.data['match_id']); - const notDeletedActive = allActiveList.filter(c => !allDeletedIds.includes(c.data['match_id'])); - - console.log(`Found ${allActiveList.length} active entries, ${allDeletedIds.length} deleted entries -> + console.log('getNotDeletedCandidates called with ' + candidates.length + ' candidates'); + + // We want to retain all ACTIVE entries that have not been DELETED + const allActiveList = candidates.filter((c) => !c.data.status || c.data.status == 'ACTIVE'); + const allDeletedIds = candidates + .filter((c) => c.data.status && c.data.status == 'DELETED') + .map((c) => c.data['match_id']); + const notDeletedActive = allActiveList.filter((c) => !allDeletedIds.includes(c.data['match_id'])); + + console.log(`Found ${allActiveList.length} active entries, ${allDeletedIds.length} deleted entries -> ${notDeletedActive.length} non deleted active entries`); - - return notDeletedActive; -} + + return notDeletedActive; +}; export const getUserInputForTimelineEntry = ( entry: TimelineEntry, nextEntry: TimelineEntry | null, userInputList: UserInputEntry[], ): undefined | UserInputEntry => { - const logsEnabled = userInputList?.length < 20; - if (userInputList === undefined) { - logDebug("In getUserInputForTimelineEntry, no user input, returning undefined"); - return undefined; - } + const logsEnabled = userInputList?.length < 20; + if (userInputList === undefined) { + logDebug('In getUserInputForTimelineEntry, no user input, returning undefined'); + return undefined; + } - if (logsEnabled) console.log(`Input list = ${userInputList.map(printUserInput)}`); + if (logsEnabled) console.log(`Input list = ${userInputList.map(printUserInput)}`); - // undefined !== true, so this covers the label view case as well - const potentialCandidates = userInputList.filter((ui) => - validUserInputForTimelineEntry(entry, nextEntry, ui, logsEnabled), + // undefined !== true, so this covers the label view case as well + const potentialCandidates = userInputList.filter((ui) => + validUserInputForTimelineEntry(entry, nextEntry, ui, logsEnabled), + ); + + if (potentialCandidates.length === 0) { + if (logsEnabled) + logDebug('In getUserInputForTimelineEntry, no potential candidates, returning []'); + return undefined; + } + + if (potentialCandidates.length === 1) { + logDebug( + `In getUserInputForTimelineEntry, one potential candidate, returning ${printUserInput( + potentialCandidates[0], + )}`, ); - - if (potentialCandidates.length === 0) { - if (logsEnabled) logDebug("In getUserInputForTimelineEntry, no potential candidates, returning []"); - return undefined; - } + return potentialCandidates[0]; + } - if (potentialCandidates.length === 1) { - logDebug(`In getUserInputForTimelineEntry, one potential candidate, returning ${printUserInput(potentialCandidates[0])}`); - return potentialCandidates[0]; - } + logDebug(`potentialCandidates are ${potentialCandidates.map(printUserInput)}`); - logDebug(`potentialCandidates are ${potentialCandidates.map(printUserInput)}`); + const sortedPC = potentialCandidates.sort( + (pc1, pc2) => pc2.metadata.write_ts - pc1.metadata.write_ts, + ); + const mostRecentEntry = sortedPC[0]; + logDebug('Returning mostRecentEntry ' + printUserInput(mostRecentEntry)); - const sortedPC = potentialCandidates.sort((pc1, pc2) => pc2.metadata.write_ts - pc1.metadata.write_ts); - const mostRecentEntry = sortedPC[0]; - logDebug("Returning mostRecentEntry "+printUserInput(mostRecentEntry)); - - return mostRecentEntry; -} + return mostRecentEntry; +}; // return array of matching additions for a trip or place export const getAdditionsForTimelineEntry = ( @@ -165,60 +205,72 @@ export const getAdditionsForTimelineEntry = ( nextEntry: TimelineEntry | null, additionsList: UserInputEntry[], ): UserInputEntry[] => { - const logsEnabled = additionsList?.length < 20; + const logsEnabled = additionsList?.length < 20; - if (additionsList === undefined) { - logDebug("In getAdditionsForTimelineEntry, no addition input, returning []"); - return []; - } + if (additionsList === undefined) { + logDebug('In getAdditionsForTimelineEntry, no addition input, returning []'); + return []; + } - // get additions that have not been deleted and filter out additions that do not start within the bounds of the timeline entry - const notDeleted = getNotDeletedCandidates(additionsList); - const matchingAdditions = notDeleted.filter((ui) => - validUserInputForTimelineEntry(entry, nextEntry, ui, logsEnabled), - ); + // get additions that have not been deleted and filter out additions that do not start within the bounds of the timeline entry + const notDeleted = getNotDeletedCandidates(additionsList); + const matchingAdditions = notDeleted.filter((ui) => + validUserInputForTimelineEntry(entry, nextEntry, ui, logsEnabled), + ); - if (logsEnabled) console.log(`Matching Addition list ${matchingAdditions.map(printUserInput)}`); + if (logsEnabled) console.log(`Matching Addition list ${matchingAdditions.map(printUserInput)}`); - return matchingAdditions; -} + return matchingAdditions; +}; export const getUniqueEntries = (combinedList) => { - /* we should not get any non-ACTIVE entries here + /* we should not get any non-ACTIVE entries here since we have run filtering algorithms on both the phone and the server */ - const allDeleted = combinedList.filter(c => c.data.status && c.data.status == 'DELETED'); - - if (allDeleted.length > 0) { - displayErrorMsg("Found "+allDeleted.length +" non-ACTIVE addition entries while trying to dedup entries", JSON.stringify(allDeleted)); - } - - const uniqueMap = new Map(); - combinedList.forEach((e) => { - const existingVal = uniqueMap.get(e.data.match_id); - /* if the existing entry and the input entry don't match and they are both active, we have an error + const allDeleted = combinedList.filter((c) => c.data.status && c.data.status == 'DELETED'); + + if (allDeleted.length > 0) { + displayErrorMsg( + 'Found ' + allDeleted.length + ' non-ACTIVE addition entries while trying to dedup entries', + JSON.stringify(allDeleted), + ); + } + + const uniqueMap = new Map(); + combinedList.forEach((e) => { + const existingVal = uniqueMap.get(e.data.match_id); + /* if the existing entry and the input entry don't match and they are both active, we have an error let's notify the user for now */ - if (existingVal) { - if ((existingVal.data.start_ts != e.data.start_ts) || - (existingVal.data.end_ts != e.data.end_ts) || - (existingVal.data.write_ts != e.data.write_ts)) { - displayErrorMsg(`Found two ACTIVE entries with the same match ID but different timestamps ${existingVal.data.match_id}` - , `${JSON.stringify(existingVal)} vs ${JSON.stringify(e)}`); - } else { - console.log(`Found two entries with match_id ${existingVal.data.match_id} but they are identical`); - } - } else { - uniqueMap.set(e.data.match_id, e); - } - }); - return Array.from(uniqueMap.values()); -} + if (existingVal) { + if ( + existingVal.data.start_ts != e.data.start_ts || + existingVal.data.end_ts != e.data.end_ts || + existingVal.data.write_ts != e.data.write_ts + ) { + displayErrorMsg( + `Found two ACTIVE entries with the same match ID but different timestamps ${existingVal.data.match_id}`, + `${JSON.stringify(existingVal)} vs ${JSON.stringify(e)}`, + ); + } else { + console.log( + `Found two entries with match_id ${existingVal.data.match_id} but they are identical`, + ); + } + } else { + uniqueMap.set(e.data.match_id, e); + } + }); + return Array.from(uniqueMap.values()); +}; /** * @param allEntries the array of timeline entries to map inputs to * @returns an array containing: (i) an object mapping timeline entry IDs to label inputs, * and (ii) an object mapping timeline entry IDs to note inputs */ -export function mapInputsToTimelineEntries(allEntries: TimelineEntry[], appConfig): [TimelineLabelMap, TimelineNotesMap] { +export function mapInputsToTimelineEntries( + allEntries: TimelineEntry[], + appConfig, +): [TimelineLabelMap, TimelineNotesMap] { const timelineLabelMap: TimelineLabelMap = {}; const timelineNotesMap: TimelineNotesMap = {}; @@ -255,10 +307,14 @@ export function mapInputsToTimelineEntries(allEntries: TimelineEntry[], appConfi unprocessedLabels[label], ); if (userInputForTrip) { - labelsForTrip[label] = labelOptions[label].find((opt: LabelOption) => opt.value == userInputForTrip.data.label); + labelsForTrip[label] = labelOptions[label].find( + (opt: LabelOption) => opt.value == userInputForTrip.data.label, + ); } else { const processedLabelValue = tlEntry.user_input?.[inputType2retKey(label)]; - labelsForTrip[label] = labelOptions[label].find((opt: LabelOption) => opt.value == processedLabelValue); + labelsForTrip[label] = labelOptions[label].find( + (opt: LabelOption) => opt.value == processedLabelValue, + ); } }); if (Object.keys(labelsForTrip).length) { @@ -277,7 +333,11 @@ export function mapInputsToTimelineEntries(allEntries: TimelineEntry[], appConfi So, we will read both the processed additions and unprocessed additions and merge them together, removing duplicates. */ const nextEntry = i + 1 < allEntries.length ? allEntries[i + 1] : null; - const unprocessedAdditions = getAdditionsForTimelineEntry(tlEntry, nextEntry, unprocessedNotes); + const unprocessedAdditions = getAdditionsForTimelineEntry( + tlEntry, + nextEntry, + unprocessedNotes, + ); const processedAdditions = tlEntry.additions || []; const mergedAdditions = getUniqueEntries( diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index 26b2d4fbd..fa339323d 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -14,19 +14,21 @@ type InputDetails = { }; }; export type LabelOption = { - value: string, - baseMode: string, - met?: {range: any[], mets: number} - met_equivalent?: string, - kgCo2PerKm: number, - text?: string, + value: string; + baseMode: string; + met?: { range: any[]; mets: number }; + met_equivalent?: string; + kgCo2PerKm: number; + text?: string; }; -export type MultilabelKey = 'MODE'|'PURPOSE'|'REPLACED_MODE'; +export type MultilabelKey = 'MODE' | 'PURPOSE' | 'REPLACED_MODE'; export type LabelOptions = { - [k in T]: LabelOption[] -} & { translations: { - [lang: string]: { [translationKey: string]: string } -}}; + [k in T]: LabelOption[]; +} & { + translations: { + [lang: string]: { [translationKey: string]: string }; + }; +}; let appConfig; export let labelOptions: LabelOptions; @@ -108,14 +110,22 @@ export function labelInputDetailsForTrip(userInputForTrip, appConfigParam?) { if (appConfigParam) appConfig = appConfigParam; if (appConfig.intro.mode_studied) { if (userInputForTrip?.['MODE']?.value == appConfig.intro.mode_studied) { - logDebug("Found trip labeled with mode of study "+appConfig.intro.mode_studied+". Needs REPLACED_MODE"); + logDebug( + 'Found trip labeled with mode of study ' + + appConfig.intro.mode_studied + + '. Needs REPLACED_MODE', + ); return getLabelInputDetails(); } else { - logDebug("Found trip not labeled with mode of study "+appConfig.intro.mode_studied+". Doesn't need REPLACED_MODE"); + logDebug( + 'Found trip not labeled with mode of study ' + + appConfig.intro.mode_studied + + ". Doesn't need REPLACED_MODE", + ); return baseLabelInputDetails; } } else { - logDebug("No mode of study, so there is no REPLACED_MODE label option"); + logDebug('No mode of study, so there is no REPLACED_MODE label option'); return getLabelInputDetails(); } } @@ -140,7 +150,7 @@ export const getFakeEntry = (otherValue) => ({ }); export const labelKeyToRichMode = (labelKey: string) => - labelOptions?.MODE?.find(m => m.value == labelKey)?.text || labelKeyToReadable(labelKey); + labelOptions?.MODE?.find((m) => m.value == labelKey)?.text || labelKeyToReadable(labelKey); /* manual/mode_confirm becomes mode_confirm */ export const inputType2retKey = (inputType) => getLabelInputDetails()[inputType].key.split('/')[1]; @@ -157,7 +167,7 @@ export function verifiabilityForTrip(trip, userInputForTrip) { } return someInferred ? 'can-verify' : allConfirmed ? 'already-verified' : 'cannot-verify'; } - + export function inferFinalLabels(trip, userInputForTrip) { // Deep copy the possibility tuples let labelsList = []; @@ -210,7 +220,9 @@ export function inferFinalLabels(trip, userInputForTrip) { // Fails safe if confidence_threshold doesn't exist if (max.p <= trip.confidence_threshold) max.labelValue = undefined; - finalInference[inputType] = labelOptions[inputType].find((opt) => opt.value == max.labelValue); + finalInference[inputType] = labelOptions[inputType].find( + (opt) => opt.value == max.labelValue, + ); } return finalInference; } diff --git a/www/js/survey/multilabel/infinite_scroll_filters.ts b/www/js/survey/multilabel/infinite_scroll_filters.ts index 62fe2cd20..a13d0e48d 100644 --- a/www/js/survey/multilabel/infinite_scroll_filters.ts +++ b/www/js/survey/multilabel/infinite_scroll_filters.ts @@ -6,36 +6,33 @@ * All UI elements should only use $scope variables. */ -import i18next from "i18next"; -import { labelInputDetailsForTrip } from "./confirmHelper"; -import { logDebug } from "../../plugin/logger"; +import i18next from 'i18next'; +import { labelInputDetailsForTrip } from './confirmHelper'; +import { logDebug } from '../../plugin/logger'; const unlabeledCheck = (trip, userInputForTrip) => { const tripInputDetails = labelInputDetailsForTrip(userInputForTrip); return Object.keys(tripInputDetails) .map((inputType) => !userInputForTrip?.[inputType]) .reduce((acc, val) => acc || val, false); -} +}; const toLabelCheck = (trip, userInputForTrip) => { - logDebug('Expectation: '+trip.expectation); + logDebug('Expectation: ' + trip.expectation); if (!trip.expectation) return true; return trip.expectation.to_label && unlabeledCheck(trip, userInputForTrip); -} +}; const UNLABELED = { - key: "unlabeled", - text: i18next.t("diary.unlabeled"), + key: 'unlabeled', + text: i18next.t('diary.unlabeled'), filter: unlabeledCheck, -} +}; const TO_LABEL = { - key: "to_label", - text: i18next.t("diary.to-label"), + key: 'to_label', + text: i18next.t('diary.to-label'), filter: toLabelCheck, -} +}; -export const configuredFilters = [ - TO_LABEL, - UNLABELED, -]; +export const configuredFilters = [TO_LABEL, UNLABELED]; diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index cd3f8cd8a..743d75b15 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -2,7 +2,7 @@ and user input objects. As much as possible, these types parallel the types used in the server code. */ -import { BaseModeKey, MotionTypeKey } from "../diary/diaryHelper"; +import { BaseModeKey, MotionTypeKey } from '../diary/diaryHelper'; type ObjectId = { $oid: string }; type ConfirmedPlace = { @@ -31,37 +31,37 @@ type ConfirmedPlace = { /* These are the properties received from the server (basically matches Python code) This should match what Timeline.readAllCompositeTrips returns (an array of these objects) */ export type CompositeTrip = { - _id: ObjectId, - additions: UserInputEntry[], - cleaned_section_summary: SectionSummary, - cleaned_trip: ObjectId, - confidence_threshold: number, - confirmed_trip: ObjectId, - distance: number, - duration: number, - end_confirmed_place: ConfirmedPlace, - end_fmt_time: string, - end_loc: {type: string, coordinates: number[]}, - end_local_dt: LocalDt, - end_place: ObjectId, - end_ts: number, - expectation: any, // TODO "{to_label: boolean}" - expected_trip: ObjectId, - inferred_labels: any[], // TODO - inferred_section_summary: SectionSummary, - inferred_trip: ObjectId, - key: string, - locations: any[], // TODO - origin_key: string, - raw_trip: ObjectId, - sections: any[], // TODO - source: string, - start_confirmed_place: ConfirmedPlace, - start_fmt_time: string, - start_loc: {type: string, coordinates: number[]}, - start_local_dt: LocalDt, - start_place: ObjectId, - start_ts: number, + _id: ObjectId; + additions: UserInputEntry[]; + cleaned_section_summary: SectionSummary; + cleaned_trip: ObjectId; + confidence_threshold: number; + confirmed_trip: ObjectId; + distance: number; + duration: number; + end_confirmed_place: ConfirmedPlace; + end_fmt_time: string; + end_loc: { type: string; coordinates: number[] }; + end_local_dt: LocalDt; + end_place: ObjectId; + end_ts: number; + expectation: any; // TODO "{to_label: boolean}" + expected_trip: ObjectId; + inferred_labels: any[]; // TODO + inferred_section_summary: SectionSummary; + inferred_trip: ObjectId; + key: string; + locations: any[]; // TODO + origin_key: string; + raw_trip: ObjectId; + sections: any[]; // TODO + source: string; + start_confirmed_place: ConfirmedPlace; + start_fmt_time: string; + start_loc: { type: string; coordinates: number[] }; + start_local_dt: LocalDt; + start_place: ObjectId; + start_ts: number; user_input: { /* for keys ending in 'user_input' (e.g. 'trip_user_input'), the server gives us the raw user input object with 'data' and 'metadata' */ @@ -70,7 +70,7 @@ export type CompositeTrip = { as a string (e.g. 'walk', 'drove_alone') */ [k: `${string}confirm`]: string; }; -} +}; /* The 'timeline' for a user is a list of their trips and places, so a 'timeline entry' is either a trip or a place. */ @@ -79,52 +79,52 @@ export type TimelineEntry = ConfirmedPlace | CompositeTrip; /* These properties aren't received from the server, but are derived from the above properties. They are used in the UI to display trip/place details and are computed by the useDerivedProperties hook. */ export type DerivedProperties = { - displayDate: string, - displayStartTime: string, - displayEndTime: string, - displayTime: string, - displayStartDateAbbr: string, - displayEndDateAbbr: string, - formattedDistance: string, - formattedSectionProperties: any[], // TODO - distanceSuffix: string, - detectedModes: { mode: string, icon: string, color: string, pct: number|string }[], -} + displayDate: string; + displayStartTime: string; + displayEndTime: string; + displayTime: string; + displayStartDateAbbr: string; + displayEndDateAbbr: string; + formattedDistance: string; + formattedSectionProperties: any[]; // TODO + distanceSuffix: string; + detectedModes: { mode: string; icon: string; color: string; pct: number | string }[]; +}; export type SectionSummary = { - count: {[k: MotionTypeKey | BaseModeKey]: number}, - distance: {[k: MotionTypeKey | BaseModeKey]: number}, - duration: {[k: MotionTypeKey | BaseModeKey]: number}, -} + count: { [k: MotionTypeKey | BaseModeKey]: number }; + distance: { [k: MotionTypeKey | BaseModeKey]: number }; + duration: { [k: MotionTypeKey | BaseModeKey]: number }; +}; export type UserInputEntry = { data: { - end_ts: number, - start_ts: number - label: string, - start_local_dt?: LocalDt - end_local_dt?: LocalDt - status?: string, - match_id?: string, - }, + end_ts: number; + start_ts: number; + label: string; + start_local_dt?: LocalDt; + end_local_dt?: LocalDt; + status?: string; + match_id?: string; + }; metadata: { - time_zone: string, - plugin: string, - write_ts: number, - platform: string, - read_ts: number, - key: string, - }, - key?: string -} + time_zone: string; + plugin: string; + write_ts: number; + platform: string; + read_ts: number; + key: string; + }; + key?: string; +}; export type LocalDt = { - minute: number, - hour: number, - second: number, - day: number, - weekday: number, - month: number, - year: number, - timezone: string, -} + minute: number; + hour: number; + second: number; + day: number; + weekday: number; + month: number; + year: number; + timezone: string; +}; From b4bc7ab568b6bf2cfb4f3fb039e351b6583fc72f Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Thu, 2 Nov 2023 13:57:17 -0700 Subject: [PATCH 292/850] Run prettier --- www/__tests__/inputMatcher.test.ts | 484 +++++++++--------- .../survey/enketo/enketo-add-note-button.js | 180 +++---- www/js/survey/enketo/enketo-trip-button.js | 178 ++++--- www/js/survey/inputMatcher.ts | 357 +++++++------ www/js/survey/multilabel/multi-label-ui.js | 385 +++++++------- www/js/types/diaryTypes.ts | 156 +++--- 6 files changed, 895 insertions(+), 845 deletions(-) diff --git a/www/__tests__/inputMatcher.test.ts b/www/__tests__/inputMatcher.test.ts index ac14a506b..6033df444 100644 --- a/www/__tests__/inputMatcher.test.ts +++ b/www/__tests__/inputMatcher.test.ts @@ -1,253 +1,251 @@ -import { - fmtTs, - printUserInput, - validUserInputForDraftTrip, - validUserInputForTimelineEntry, - getNotDeletedCandidates, - getUserInputForTrip, - getAdditionsForTimelineEntry, - getUniqueEntries +import { + fmtTs, + printUserInput, + validUserInputForDraftTrip, + validUserInputForTimelineEntry, + getNotDeletedCandidates, + getUserInputForTrip, + getAdditionsForTimelineEntry, + getUniqueEntries, } from '../js/survey/inputMatcher'; import { TlEntry, UserInput } from '../js/types/diaryTypes'; describe('input-matcher', () => { - let userTrip: UserInput; - let trip: TlEntry; + let userTrip: UserInput; + let trip: TlEntry; - beforeEach(() => { - /* + beforeEach(() => { + /* Create a valid userTrip and trip object before each test case. The trip data is from the 'real_examples' data (shankari_2015-07-22) on the server. For some test cases, I need to generate fake data, such as labels, keys, and origin_keys. In such cases, I referred to 'TestUserInputFakeData.py' on the server. */ - userTrip = { - data: { - end_ts: 1437604764, - start_ts: 1437601247, - label: 'FOO', - status: 'ACTIVE' - }, - metadata: { - time_zone: 'America/Los_Angeles', - plugin: 'none', - write_ts: 1695921991, - platform: 'ios', - read_ts: 0, - key: 'manual/mode_confirm' - }, - key: 'manual/place' - } - trip = { - key: 'FOO', - origin_key: 'FOO', - start_ts: 1437601000, - end_ts: 1437605000, - enter_ts: 1437605000, - exit_ts: 1437605000, - duration: 100, - getNextEntry: jest.fn() - } - - // mock Logger - window['Logger'] = { log: console.log }; - }); - - it('tests fmtTs with valid input', () => { - const pstTime = fmtTs(1437601247.8459613, 'America/Los_Angeles'); - const estTime = fmtTs(1437601247.8459613, 'America/New_York'); - - // Check if it contains correct year-mm-dd hr:mm - expect(pstTime).toContain('2015-07-22T14:40'); - expect(estTime).toContain('2015-07-22T17:40'); - }); - - it('tests fmtTs with invalid input', () => { - const formattedTime = fmtTs(0, ''); - expect(formattedTime).toBeFalsy(); - }); - - it('tests printUserInput prints the trip log correctly', () => { - const userTripLog = printUserInput(userTrip); - expect(userTripLog).toContain('1437604764'); - expect(userTripLog).toContain('1437601247'); - expect(userTripLog).toContain('FOO'); - }); - - it('tests validUserInputForDraftTrip with valid trip input', () => { - const validTrp = { - end_ts: 1437604764, - start_ts: 1437601247 - } - const validUserInput = validUserInputForDraftTrip(validTrp, userTrip, false); - expect(validUserInput).toBeTruthy(); - }); - - it('tests validUserInputForDraftTrip with invalid trip input', () => { - const invalidTrip = { - end_ts: 0, - start_ts: 0 - } - const invalidUserInput = validUserInputForDraftTrip(invalidTrip, userTrip, false); - expect(invalidUserInput).toBeFalsy(); - }); - - it('tests validUserInputForTimelineEntry with valid trip object', () => { - // we need valid key and origin_key for validUserInputForTimelineEntry test - trip['key'] = 'analysis/confirmed_place'; - trip['origin_key'] = 'analysis/confirmed_place'; - const validTimelineEntry = validUserInputForTimelineEntry(trip, userTrip, false); - expect(validTimelineEntry).toBeTruthy(); - }); - - it('tests validUserInputForTimelineEntry with tlEntry with invalid key and origin_key', () => { - const invalidTlEntry = trip; - const invalidTimelineEntry = validUserInputForTimelineEntry(invalidTlEntry, userTrip, false); - expect(invalidTimelineEntry).toBeFalsy(); - }); - - it('tests validUserInputForTimelineEntry with tlEntry with invalie start & end time', () => { - const invalidTlEntry: TlEntry = { - key: 'analysis/confirmed_place', - origin_key: 'analysis/confirmed_place', - start_ts: 1, - end_ts: 1, - enter_ts: 1, - exit_ts: 1, - duration: 1, - getNextEntry: jest.fn() - } - const invalidTimelineEntry = validUserInputForTimelineEntry(invalidTlEntry, userTrip, false); - expect(invalidTimelineEntry).toBeFalsy(); - }); - - it('tests getNotDeletedCandidates called with 0 candidates', () => { - jest.spyOn(console, 'log'); - const candidates = getNotDeletedCandidates([]); - - // check if the log printed collectly with - expect(console.log).toHaveBeenCalledWith('getNotDeletedCandidates called with 0 candidates'); - expect(candidates).toStrictEqual([]); - - }); - - it('tests getNotDeletedCandidates called with multiple candidates', () => { - const activeTrip = userTrip; - const deletedTrip = { - data: { - end_ts: 1437604764, - start_ts: 1437601247, - label: 'FOO', - status: 'DELETED', - match_id: 'FOO' - }, - metadata: { - time_zone: 'America/Los_Angeles', - plugin: 'none', - write_ts: 1695921991, - platform: 'ios', - read_ts: 0, - key: 'manual/mode_confirm' - }, - key: 'manual/place' - } - const candidates = [ activeTrip, deletedTrip ]; - const validCandidates = getNotDeletedCandidates(candidates); - - // check if the result has only 'ACTIVE' data - expect(validCandidates).toHaveLength(1); - expect(validCandidates[0]).toMatchObject(userTrip); - - }); - - it('tests getUserInputForTrip with valid userInputList', () => { - const userInputWriteFirst = { - data: { - end_ts: 1437607732, - label: 'bus', - start_ts: 1437606026 - }, - metadata: { - time_zone: 'America/Los_Angeles', - plugin: 'none', - write_ts: 1695830232, - platform: 'ios', - read_ts: 0, - key:'manual/mode_confirm', - type:'message' - } - } - const userInputWriteSecond = { - data: { - end_ts: 1437598393, - label: 'e-bike', - start_ts: 1437596745 - }, - metadata: { - time_zone: 'America/Los_Angeles', - plugin: 'none', - write_ts: 1695838268, - platform: 'ios', - read_ts: 0, - key:'manual/mode_confirm', - type:'message' - } - } - const userInputWriteThird = { - data: { - end_ts: 1437604764, - label: 'e-bike', - start_ts: 1437601247 - }, - metadata: { - time_zone: 'America/Los_Angeles', - plugin: 'none', - write_ts: 1695921991, - platform: 'ios', - read_ts: 0, - key:'manual/mode_confirm', - type:'message' - } - } - - // make the linst unsorted and then check if userInputWriteThird(latest one) is return output - const userInputList = [userInputWriteSecond, userInputWriteThird, userInputWriteFirst]; - const mostRecentEntry = getUserInputForTrip(trip, {}, userInputList); - expect(mostRecentEntry).toMatchObject(userInputWriteThird); - }); - - it('tests getUserInputForTrip with invalid userInputList', () => { - const userInputList = undefined; - const mostRecentEntry = getUserInputForTrip(trip, {}, userInputList); - expect(mostRecentEntry).toBe(undefined); - }); - - it('tests getAdditionsForTimelineEntry with valid additionsList', () => { - const additionsList = new Array(5).fill(userTrip); - trip['key'] = 'analysis/confirmed_place'; - trip['origin_key'] = 'analysis/confirmed_place'; - - // check if the result keep the all valid userTrip items - const matchingAdditions = getAdditionsForTimelineEntry(trip, additionsList); - expect(matchingAdditions).toHaveLength(5); - }); - - it('tests getAdditionsForTimelineEntry with invalid additionsList', () => { - const additionsList = undefined; - const matchingAdditions = getAdditionsForTimelineEntry(trip, additionsList); - expect(matchingAdditions).toMatchObject([]); - }); - - it('tests getUniqueEntries with valid combinedList', () => { - const combinedList = new Array(5).fill(userTrip); - - // check if the result keeps only unique userTrip items - const uniqueEntires = getUniqueEntries(combinedList); - expect(uniqueEntires).toHaveLength(1); - }); - - it('tests getUniqueEntries with empty combinedList', () => { - const uniqueEntires = getUniqueEntries([]); - expect(uniqueEntires).toMatchObject([]); - }); -}) + userTrip = { + data: { + end_ts: 1437604764, + start_ts: 1437601247, + label: 'FOO', + status: 'ACTIVE', + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695921991, + platform: 'ios', + read_ts: 0, + key: 'manual/mode_confirm', + }, + key: 'manual/place', + }; + trip = { + key: 'FOO', + origin_key: 'FOO', + start_ts: 1437601000, + end_ts: 1437605000, + enter_ts: 1437605000, + exit_ts: 1437605000, + duration: 100, + getNextEntry: jest.fn(), + }; + + // mock Logger + window['Logger'] = { log: console.log }; + }); + + it('tests fmtTs with valid input', () => { + const pstTime = fmtTs(1437601247.8459613, 'America/Los_Angeles'); + const estTime = fmtTs(1437601247.8459613, 'America/New_York'); + + // Check if it contains correct year-mm-dd hr:mm + expect(pstTime).toContain('2015-07-22T14:40'); + expect(estTime).toContain('2015-07-22T17:40'); + }); + + it('tests fmtTs with invalid input', () => { + const formattedTime = fmtTs(0, ''); + expect(formattedTime).toBeFalsy(); + }); + + it('tests printUserInput prints the trip log correctly', () => { + const userTripLog = printUserInput(userTrip); + expect(userTripLog).toContain('1437604764'); + expect(userTripLog).toContain('1437601247'); + expect(userTripLog).toContain('FOO'); + }); + + it('tests validUserInputForDraftTrip with valid trip input', () => { + const validTrp = { + end_ts: 1437604764, + start_ts: 1437601247, + }; + const validUserInput = validUserInputForDraftTrip(validTrp, userTrip, false); + expect(validUserInput).toBeTruthy(); + }); + + it('tests validUserInputForDraftTrip with invalid trip input', () => { + const invalidTrip = { + end_ts: 0, + start_ts: 0, + }; + const invalidUserInput = validUserInputForDraftTrip(invalidTrip, userTrip, false); + expect(invalidUserInput).toBeFalsy(); + }); + + it('tests validUserInputForTimelineEntry with valid trip object', () => { + // we need valid key and origin_key for validUserInputForTimelineEntry test + trip['key'] = 'analysis/confirmed_place'; + trip['origin_key'] = 'analysis/confirmed_place'; + const validTimelineEntry = validUserInputForTimelineEntry(trip, userTrip, false); + expect(validTimelineEntry).toBeTruthy(); + }); + + it('tests validUserInputForTimelineEntry with tlEntry with invalid key and origin_key', () => { + const invalidTlEntry = trip; + const invalidTimelineEntry = validUserInputForTimelineEntry(invalidTlEntry, userTrip, false); + expect(invalidTimelineEntry).toBeFalsy(); + }); + + it('tests validUserInputForTimelineEntry with tlEntry with invalie start & end time', () => { + const invalidTlEntry: TlEntry = { + key: 'analysis/confirmed_place', + origin_key: 'analysis/confirmed_place', + start_ts: 1, + end_ts: 1, + enter_ts: 1, + exit_ts: 1, + duration: 1, + getNextEntry: jest.fn(), + }; + const invalidTimelineEntry = validUserInputForTimelineEntry(invalidTlEntry, userTrip, false); + expect(invalidTimelineEntry).toBeFalsy(); + }); + + it('tests getNotDeletedCandidates called with 0 candidates', () => { + jest.spyOn(console, 'log'); + const candidates = getNotDeletedCandidates([]); + + // check if the log printed collectly with + expect(console.log).toHaveBeenCalledWith('getNotDeletedCandidates called with 0 candidates'); + expect(candidates).toStrictEqual([]); + }); + + it('tests getNotDeletedCandidates called with multiple candidates', () => { + const activeTrip = userTrip; + const deletedTrip = { + data: { + end_ts: 1437604764, + start_ts: 1437601247, + label: 'FOO', + status: 'DELETED', + match_id: 'FOO', + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695921991, + platform: 'ios', + read_ts: 0, + key: 'manual/mode_confirm', + }, + key: 'manual/place', + }; + const candidates = [activeTrip, deletedTrip]; + const validCandidates = getNotDeletedCandidates(candidates); + + // check if the result has only 'ACTIVE' data + expect(validCandidates).toHaveLength(1); + expect(validCandidates[0]).toMatchObject(userTrip); + }); + + it('tests getUserInputForTrip with valid userInputList', () => { + const userInputWriteFirst = { + data: { + end_ts: 1437607732, + label: 'bus', + start_ts: 1437606026, + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695830232, + platform: 'ios', + read_ts: 0, + key: 'manual/mode_confirm', + type: 'message', + }, + }; + const userInputWriteSecond = { + data: { + end_ts: 1437598393, + label: 'e-bike', + start_ts: 1437596745, + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695838268, + platform: 'ios', + read_ts: 0, + key: 'manual/mode_confirm', + type: 'message', + }, + }; + const userInputWriteThird = { + data: { + end_ts: 1437604764, + label: 'e-bike', + start_ts: 1437601247, + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695921991, + platform: 'ios', + read_ts: 0, + key: 'manual/mode_confirm', + type: 'message', + }, + }; + + // make the linst unsorted and then check if userInputWriteThird(latest one) is return output + const userInputList = [userInputWriteSecond, userInputWriteThird, userInputWriteFirst]; + const mostRecentEntry = getUserInputForTrip(trip, {}, userInputList); + expect(mostRecentEntry).toMatchObject(userInputWriteThird); + }); + + it('tests getUserInputForTrip with invalid userInputList', () => { + const userInputList = undefined; + const mostRecentEntry = getUserInputForTrip(trip, {}, userInputList); + expect(mostRecentEntry).toBe(undefined); + }); + + it('tests getAdditionsForTimelineEntry with valid additionsList', () => { + const additionsList = new Array(5).fill(userTrip); + trip['key'] = 'analysis/confirmed_place'; + trip['origin_key'] = 'analysis/confirmed_place'; + + // check if the result keep the all valid userTrip items + const matchingAdditions = getAdditionsForTimelineEntry(trip, additionsList); + expect(matchingAdditions).toHaveLength(5); + }); + + it('tests getAdditionsForTimelineEntry with invalid additionsList', () => { + const additionsList = undefined; + const matchingAdditions = getAdditionsForTimelineEntry(trip, additionsList); + expect(matchingAdditions).toMatchObject([]); + }); + + it('tests getUniqueEntries with valid combinedList', () => { + const combinedList = new Array(5).fill(userTrip); + + // check if the result keeps only unique userTrip items + const uniqueEntires = getUniqueEntries(combinedList); + expect(uniqueEntires).toHaveLength(1); + }); + + it('tests getUniqueEntries with empty combinedList', () => { + const uniqueEntires = getUniqueEntries([]); + expect(uniqueEntires).toMatchObject([]); + }); +}); diff --git a/www/js/survey/enketo/enketo-add-note-button.js b/www/js/survey/enketo/enketo-add-note-button.js index 720427f2c..56a41cb04 100644 --- a/www/js/survey/enketo/enketo-add-note-button.js +++ b/www/js/survey/enketo/enketo-add-note-button.js @@ -5,77 +5,75 @@ import angular from 'angular'; import { getAdditionsForTimelineEntry, getUniqueEntries } from '../inputMatcher'; -angular.module('emission.survey.enketo.add-note-button', - ['emission.services', - 'emission.survey.enketo.answer']) -.factory("EnketoNotesButtonService", function(EnketoSurveyAnswer, Logger, $timeout) { - var enbs = {}; - console.log("Creating EnketoNotesButtonService"); - enbs.SINGLE_KEY="NOTES"; - enbs.MANUAL_KEYS = []; +angular + .module('emission.survey.enketo.add-note-button', [ + 'emission.services', + 'emission.survey.enketo.answer', + ]) + .factory('EnketoNotesButtonService', function (EnketoSurveyAnswer, Logger, $timeout) { + var enbs = {}; + console.log('Creating EnketoNotesButtonService'); + enbs.SINGLE_KEY = 'NOTES'; + enbs.MANUAL_KEYS = []; - /** - * Set the keys for trip and/or place additions whichever will be enabled, - * and sets the name of the surveys they will use. - */ - enbs.initConfig = function (tripSurveyName, placeSurveyName) { - enbs.tripSurveyName = tripSurveyName; - if (tripSurveyName) { - enbs.MANUAL_KEYS.push('manual/trip_addition_input'); - } - enbs.placeSurveyName = placeSurveyName; - if (placeSurveyName) { - enbs.MANUAL_KEYS.push('manual/place_addition_input'); - } - }; + /** + * Set the keys for trip and/or place additions whichever will be enabled, + * and sets the name of the surveys they will use. + */ + enbs.initConfig = function (tripSurveyName, placeSurveyName) { + enbs.tripSurveyName = tripSurveyName; + if (tripSurveyName) { + enbs.MANUAL_KEYS.push('manual/trip_addition_input'); + } + enbs.placeSurveyName = placeSurveyName; + if (placeSurveyName) { + enbs.MANUAL_KEYS.push('manual/place_addition_input'); + } + }; - /** - * Embed 'inputType' to the timelineEntry. - */ - enbs.extractResult = function (results) { - const resultsPromises = [ - EnketoSurveyAnswer.filterByNameAndVersion(enbs.timelineEntrySurveyName, results), - ]; - if (enbs.timelineEntrySurveyName != enbs.placeSurveyName) { - resultsPromises.push( - EnketoSurveyAnswer.filterByNameAndVersion(enbs.placeSurveyName, results), - ); - } - return Promise.all(resultsPromises); - }; + /** + * Embed 'inputType' to the timelineEntry. + */ + enbs.extractResult = function (results) { + const resultsPromises = [ + EnketoSurveyAnswer.filterByNameAndVersion(enbs.timelineEntrySurveyName, results), + ]; + if (enbs.timelineEntrySurveyName != enbs.placeSurveyName) { + resultsPromises.push( + EnketoSurveyAnswer.filterByNameAndVersion(enbs.placeSurveyName, results), + ); + } + return Promise.all(resultsPromises); + }; - enbs.processManualInputs = function (manualResults, resultMap) { - console.log('ENKETO: processManualInputs with ', manualResults, ' and ', resultMap); - const surveyResults = manualResults.flat(2); - resultMap[enbs.SINGLE_KEY] = surveyResults; - }; + enbs.processManualInputs = function (manualResults, resultMap) { + console.log('ENKETO: processManualInputs with ', manualResults, ' and ', resultMap); + const surveyResults = manualResults.flat(2); + resultMap[enbs.SINGLE_KEY] = surveyResults; + }; - enbs.populateInputsAndInferences = function (timelineEntry, manualResultMap) { - console.log( - 'ENKETO: populating timelineEntry,', - timelineEntry, - ' with result map', - manualResultMap, - ); - if (angular.isDefined(timelineEntry)) { - // initialize additions array as empty if it doesn't already exist - timelineEntry.additionsList ||= []; - enbs.populateManualInputs( - timelineEntry, - enbs.SINGLE_KEY, - manualResultMap[enbs.SINGLE_KEY], - ); - } else { - console.log('timelineEntry information not yet bound, skipping fill'); - } - }; + enbs.populateInputsAndInferences = function (timelineEntry, manualResultMap) { + console.log( + 'ENKETO: populating timelineEntry,', + timelineEntry, + ' with result map', + manualResultMap, + ); + if (angular.isDefined(timelineEntry)) { + // initialize additions array as empty if it doesn't already exist + timelineEntry.additionsList ||= []; + enbs.populateManualInputs(timelineEntry, enbs.SINGLE_KEY, manualResultMap[enbs.SINGLE_KEY]); + } else { + console.log('timelineEntry information not yet bound, skipping fill'); + } + }; - /** - * Embed 'inputType' to the timelineEntry - * This is the version that is called from the list, which focuses only on - * manual inputs. It also sets some additional values - */ - enbs.populateManualInputs = function (timelineEntry, inputType, inputList) { + /** + * Embed 'inputType' to the timelineEntry + * This is the version that is called from the list, which focuses only on + * manual inputs. It also sets some additional values + */ + enbs.populateManualInputs = function (timelineEntry, inputType, inputList) { // there is not necessarily just one addition per timeline entry, // so unlike user inputs, we don't want to replace the server entry with // the unprocessed entry @@ -92,28 +90,34 @@ angular.module('emission.survey.enketo.add-note-button', const unprocessedAdditions = getAdditionsForTimelineEntry(timelineEntry, inputList); const combinedPotentialAdditionList = timelineEntry.additions.concat(unprocessedAdditions); const dedupedList = getUniqueEntries(combinedPotentialAdditionList); - Logger.log("After combining unprocessed ("+unprocessedAdditions.length+ - ") with server ("+timelineEntry.additions.length+ - ") for a combined ("+combinedPotentialAdditionList.length+ - "), deduped entries are ("+dedupedList.length+")"); + Logger.log( + 'After combining unprocessed (' + + unprocessedAdditions.length + + ') with server (' + + timelineEntry.additions.length + + ') for a combined (' + + combinedPotentialAdditionList.length + + '), deduped entries are (' + + dedupedList.length + + ')', + ); - enbs.populateInput(timelineEntry.additionsList, inputType, dedupedList); - // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); - enbs.editingTrip = angular.undefined; - }; + enbs.populateInput(timelineEntry.additionsList, inputType, dedupedList); + // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); + enbs.editingTrip = angular.undefined; + }; - /** - * Insert the given userInputLabel into the given inputType's slot in inputField - */ - enbs.populateInput = function (timelineEntryField, inputType, userInputEntry) { - if (angular.isDefined(userInputEntry)) { - timelineEntryField.length = 0; - userInputEntry.forEach((ta) => { - timelineEntryField.push(ta); - }); - } - }; + /** + * Insert the given userInputLabel into the given inputType's slot in inputField + */ + enbs.populateInput = function (timelineEntryField, inputType, userInputEntry) { + if (angular.isDefined(userInputEntry)) { + timelineEntryField.length = 0; + userInputEntry.forEach((ta) => { + timelineEntryField.push(ta); + }); + } + }; - return enbs; - }, - ); + return enbs; + }); diff --git a/www/js/survey/enketo/enketo-trip-button.js b/www/js/survey/enketo/enketo-trip-button.js index 01761ab9d..89ae9dc29 100644 --- a/www/js/survey/enketo/enketo-trip-button.js +++ b/www/js/survey/enketo/enketo-trip-button.js @@ -14,106 +14,102 @@ import angular from 'angular'; import { getUserInputForTrip } from '../inputMatcher'; -angular.module('emission.survey.enketo.trip.button', - ['emission.survey.enketo.answer']) -.factory("EnketoTripButtonService", function(EnketoSurveyAnswer, Logger, $timeout) { - var etbs = {}; - console.log("Creating EnketoTripButtonService"); - etbs.key = "manual/trip_user_input"; - etbs.SINGLE_KEY="SURVEY"; - etbs.MANUAL_KEYS = [etbs.key]; +angular + .module('emission.survey.enketo.trip.button', ['emission.survey.enketo.answer']) + .factory('EnketoTripButtonService', function (EnketoSurveyAnswer, Logger, $timeout) { + var etbs = {}; + console.log('Creating EnketoTripButtonService'); + etbs.key = 'manual/trip_user_input'; + etbs.SINGLE_KEY = 'SURVEY'; + etbs.MANUAL_KEYS = [etbs.key]; - /** - * Embed 'inputType' to the trip. - */ - etbs.extractResult = (results) => - EnketoSurveyAnswer.filterByNameAndVersion('TripConfirmSurvey', results); + /** + * Embed 'inputType' to the trip. + */ + etbs.extractResult = (results) => + EnketoSurveyAnswer.filterByNameAndVersion('TripConfirmSurvey', results); - etbs.processManualInputs = function (manualResults, resultMap) { - if (manualResults.length > 1) { - Logger.displayError( - 'Found ' + manualResults.length + ' results expected 1', - manualResults, - ); - } else { - console.log('ENKETO: processManualInputs with ', manualResults, ' and ', resultMap); - const surveyResult = manualResults[0]; - resultMap[etbs.SINGLE_KEY] = surveyResult; - } - }; + etbs.processManualInputs = function (manualResults, resultMap) { + if (manualResults.length > 1) { + Logger.displayError('Found ' + manualResults.length + ' results expected 1', manualResults); + } else { + console.log('ENKETO: processManualInputs with ', manualResults, ' and ', resultMap); + const surveyResult = manualResults[0]; + resultMap[etbs.SINGLE_KEY] = surveyResult; + } + }; - etbs.populateInputsAndInferences = function (trip, manualResultMap) { - console.log('ENKETO: populating trip,', trip, ' with result map', manualResultMap); - if (angular.isDefined(trip)) { - // console.log("Expectation: "+JSON.stringify(trip.expectation)); - // console.log("Inferred labels from server: "+JSON.stringify(trip.inferred_labels)); - trip.userInput = {}; - etbs.populateManualInputs( - trip, - trip.getNextEntry(), - etbs.SINGLE_KEY, - manualResultMap[etbs.SINGLE_KEY], - ); - trip.finalInference = {}; - etbs.inferFinalLabels(trip); - etbs.updateVerifiability(trip); - } else { - console.log('Trip information not yet bound, skipping fill'); - } - }; + etbs.populateInputsAndInferences = function (trip, manualResultMap) { + console.log('ENKETO: populating trip,', trip, ' with result map', manualResultMap); + if (angular.isDefined(trip)) { + // console.log("Expectation: "+JSON.stringify(trip.expectation)); + // console.log("Inferred labels from server: "+JSON.stringify(trip.inferred_labels)); + trip.userInput = {}; + etbs.populateManualInputs( + trip, + trip.getNextEntry(), + etbs.SINGLE_KEY, + manualResultMap[etbs.SINGLE_KEY], + ); + trip.finalInference = {}; + etbs.inferFinalLabels(trip); + etbs.updateVerifiability(trip); + } else { + console.log('Trip information not yet bound, skipping fill'); + } + }; - /** - * Embed 'inputType' to the trip - * This is the version that is called from the list, which focuses only on - * manual inputs. It also sets some additional values - */ - etbs.populateManualInputs = function (trip, nextTrip, inputType, inputList) { + /** + * Embed 'inputType' to the trip + * This is the version that is called from the list, which focuses only on + * manual inputs. It also sets some additional values + */ + etbs.populateManualInputs = function (trip, nextTrip, inputType, inputList) { // Check unprocessed labels first since they are more recent - const unprocessedLabelEntry = getUserInputForTrip(trip, nextTrip,inputList); + const unprocessedLabelEntry = getUserInputForTrip(trip, nextTrip, inputList); var userInputEntry = unprocessedLabelEntry; if (!angular.isDefined(userInputEntry)) { - userInputEntry = trip.user_input?.[etbs.inputType2retKey(inputType)]; - } - etbs.populateInput(trip.userInput, inputType, userInputEntry); - // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); - etbs.editingTrip = angular.undefined; - }; + userInputEntry = trip.user_input?.[etbs.inputType2retKey(inputType)]; + } + etbs.populateInput(trip.userInput, inputType, userInputEntry); + // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); + etbs.editingTrip = angular.undefined; + }; - /** - * Insert the given userInputLabel into the given inputType's slot in inputField - */ - etbs.populateInput = function (tripField, inputType, userInputEntry) { - if (angular.isDefined(userInputEntry)) { - tripField[inputType] = userInputEntry; - } - }; + /** + * Insert the given userInputLabel into the given inputType's slot in inputField + */ + etbs.populateInput = function (tripField, inputType, userInputEntry) { + if (angular.isDefined(userInputEntry)) { + tripField[inputType] = userInputEntry; + } + }; - /** - * Given the list of possible label tuples we've been sent and what the user has already input for the trip, choose the best labels to actually present to the user. - * The algorithm below operationalizes these principles: - * - Never consider label tuples that contradict a green label - * - Obey "conservation of uncertainty": the sum of probabilities after filtering by green labels must equal the sum of probabilities before - * - After filtering, predict the most likely choices at the level of individual labels, not label tuples - * - Never show user yellow labels that have a lower probability of being correct than confidenceThreshold - */ - etbs.inferFinalLabels = function (trip) { - // currently a NOP since we don't have any other trip properties - return; - }; + /** + * Given the list of possible label tuples we've been sent and what the user has already input for the trip, choose the best labels to actually present to the user. + * The algorithm below operationalizes these principles: + * - Never consider label tuples that contradict a green label + * - Obey "conservation of uncertainty": the sum of probabilities after filtering by green labels must equal the sum of probabilities before + * - After filtering, predict the most likely choices at the level of individual labels, not label tuples + * - Never show user yellow labels that have a lower probability of being correct than confidenceThreshold + */ + etbs.inferFinalLabels = function (trip) { + // currently a NOP since we don't have any other trip properties + return; + }; - /** - * MODE (becomes manual/mode_confirm) becomes mode_confirm - */ - etbs.inputType2retKey = function (inputType) { - return etbs.key.split('/')[1]; - }; + /** + * MODE (becomes manual/mode_confirm) becomes mode_confirm + */ + etbs.inputType2retKey = function (inputType) { + return etbs.key.split('/')[1]; + }; - etbs.updateVerifiability = function (trip) { - // currently a NOP since we don't have any other trip properties - trip.verifiability = 'cannot-verify'; - return; - }; + etbs.updateVerifiability = function (trip) { + // currently a NOP since we don't have any other trip properties + trip.verifiability = 'cannot-verify'; + return; + }; - return etbs; - }, - ); + return etbs; + }); diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index c6c8ed61c..6203b5f27 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -1,17 +1,27 @@ -import { logDebug, displayErrorMsg } from "../plugin/logger" -import { DateTime } from "luxon"; -import { UserInput, Trip, TlEntry } from "../types/diaryTypes"; - -const EPOCH_MAXIMUM = 2**31 - 1; - -export const fmtTs = (ts_in_secs: number, tz: string): string | null => DateTime.fromSeconds(ts_in_secs, {zone : tz}).toISO(); - -export const printUserInput = (ui: UserInput): string => `${fmtTs(ui.data.start_ts, ui.metadata.time_zone)} (${ui.data.start_ts}) -> -${fmtTs(ui.data.end_ts, ui.metadata.time_zone)} (${ui.data.end_ts}) ${ui.data.label} logged at ${ui.metadata.write_ts}`; - -export const validUserInputForDraftTrip = (trip: Trip, userInput: UserInput, logsEnabled: boolean): boolean => { - if(logsEnabled) { - logDebug(`Draft trip: +import { logDebug, displayErrorMsg } from '../plugin/logger'; +import { DateTime } from 'luxon'; +import { UserInput, Trip, TlEntry } from '../types/diaryTypes'; + +const EPOCH_MAXIMUM = 2 ** 31 - 1; + +export const fmtTs = (ts_in_secs: number, tz: string): string | null => + DateTime.fromSeconds(ts_in_secs, { zone: tz }).toISO(); + +export const printUserInput = (ui: UserInput): string => `${fmtTs( + ui.data.start_ts, + ui.metadata.time_zone, +)} (${ui.data.start_ts}) -> +${fmtTs(ui.data.end_ts, ui.metadata.time_zone)} (${ui.data.end_ts}) ${ui.data.label} logged at ${ + ui.metadata.write_ts +}`; + +export const validUserInputForDraftTrip = ( + trip: Trip, + userInput: UserInput, + logsEnabled: boolean, +): boolean => { + if (logsEnabled) { + logDebug(`Draft trip: comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} -> ${fmtTs(userInput.data.end_ts, userInput.metadata.time_zone)} trip = ${fmtTs(trip.start_ts, userInput.metadata.time_zone)} @@ -21,41 +31,49 @@ export const validUserInputForDraftTrip = (trip: Trip, userInput: UserInput, log || ${-(userInput.data.start_ts - trip.start_ts) <= 15 * 60}) && ${userInput.data.end_ts <= trip.end_ts} `); - } + } + + return ( + ((userInput.data.start_ts >= trip.start_ts && userInput.data.start_ts < trip.end_ts) || + -(userInput.data.start_ts - trip.start_ts) <= 15 * 60) && + userInput.data.end_ts <= trip.end_ts + ); +}; + +export const validUserInputForTimelineEntry = ( + tlEntry: TlEntry, + userInput: UserInput, + logsEnabled: boolean, +): boolean => { + if (!tlEntry.origin_key) return false; + if (tlEntry.origin_key.includes('UNPROCESSED')) + return validUserInputForDraftTrip(tlEntry, userInput, logsEnabled); + + /* Place-level inputs always have a key starting with 'manual/place', and + trip-level inputs never have a key starting with 'manual/place' + So if these don't match, we can immediately return false */ + const entryIsPlace = tlEntry.origin_key === 'analysis/confirmed_place'; + const isPlaceInput = (userInput.key || userInput.metadata.key).startsWith('manual/place'); - return (userInput.data.start_ts >= trip.start_ts && userInput.data.start_ts < trip.end_ts - || -(userInput.data.start_ts - trip.start_ts) <= 15 * 60) && userInput.data.end_ts <= trip.end_ts; -} + if (entryIsPlace !== isPlaceInput) return false; -export const validUserInputForTimelineEntry = (tlEntry: TlEntry, userInput: UserInput, logsEnabled: boolean): boolean => { - if (!tlEntry.origin_key) return false; - if (tlEntry.origin_key.includes('UNPROCESSED')) return validUserInputForDraftTrip(tlEntry, userInput, logsEnabled); + let entryStart = tlEntry.start_ts || tlEntry.enter_ts; + let entryEnd = tlEntry.end_ts || tlEntry.exit_ts; - /* Place-level inputs always have a key starting with 'manual/place', and - trip-level inputs never have a key starting with 'manual/place' - So if these don't match, we can immediately return false */ - const entryIsPlace = tlEntry.origin_key === 'analysis/confirmed_place'; - const isPlaceInput = (userInput.key || userInput.metadata.key).startsWith('manual/place'); - - if (entryIsPlace !== isPlaceInput) return false; - - let entryStart = tlEntry.start_ts || tlEntry.enter_ts; - let entryEnd = tlEntry.end_ts || tlEntry.exit_ts; - - if (!entryStart && entryEnd) { - /* if a place has no enter time, this is the first start_place of the first composite trip object + if (!entryStart && entryEnd) { + /* if a place has no enter time, this is the first start_place of the first composite trip object so we will set the start time to the start of the day of the end time for the purpose of comparison */ - entryStart = DateTime.fromSeconds(entryEnd).startOf('day').toUnixInteger(); - } + entryStart = DateTime.fromSeconds(entryEnd).startOf('day').toUnixInteger(); + } - if (!entryEnd) { - /* if a place has no exit time, the user hasn't left there yet + if (!entryEnd) { + /* if a place has no exit time, the user hasn't left there yet so we will set the end time as high as possible for the purpose of comparison */ - entryEnd = EPOCH_MAXIMUM; - } - - if (logsEnabled) { - logDebug(`Cleaned trip: + entryEnd = EPOCH_MAXIMUM; + } + + if (logsEnabled) { + logDebug(`Cleaned trip: comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} -> ${fmtTs(userInput.data.end_ts, userInput.metadata.time_zone)} trip = ${fmtTs(entryStart, userInput.metadata.time_zone)} @@ -65,131 +83,168 @@ export const validUserInputForTimelineEntry = (tlEntry: TlEntry, userInput: User end checks are ${userInput.data.end_ts <= entryEnd} || ${userInput.data.end_ts - entryEnd <= 15 * 60}) `); - } + } - /* For this input to match, it must begin after the start of the timelineEntry (inclusive) + /* For this input to match, it must begin after the start of the timelineEntry (inclusive) but before the end of the timelineEntry (exclusive) */ - const startChecks = userInput.data.start_ts >= entryStart && userInput.data.start_ts < entryEnd; - /* A matching user input must also finish before the end of the timelineEntry, + const startChecks = userInput.data.start_ts >= entryStart && userInput.data.start_ts < entryEnd; + /* A matching user input must also finish before the end of the timelineEntry, or within 15 minutes. */ - let endChecks = (userInput.data.end_ts <= entryEnd || (userInput.data.end_ts - entryEnd) <= 15 * 60); - - if (startChecks && !endChecks) { - const nextEntryObj = tlEntry.getNextEntry(); - if (nextEntryObj) { - const nextEntryEnd = nextEntryObj.end_ts || nextEntryObj.exit_ts; - if (!nextEntryEnd) { // the last place will not have an exit_ts - endChecks = true; // so we will just skip the end check - } else { - endChecks = userInput.data.end_ts <= nextEntryEnd; - logDebug(`Second level of end checks when the next trip is defined(${userInput.data.end_ts} <= ${nextEntryEnd}) ${endChecks}`); - } - } else { - // next trip is not defined, last trip - endChecks = (userInput.data.end_local_dt.day == userInput.data.start_local_dt.day) - logDebug("Second level of end checks for the last trip of the day"); - logDebug(`compare ${userInput.data.end_local_dt.day} with ${userInput.data.start_local_dt.day} ${endChecks}`); - } - if (endChecks) { - // If we have flipped the values, check to see that there is sufficient overlap - const overlapDuration = Math.min(userInput.data.end_ts, entryEnd) - Math.max(userInput.data.start_ts, entryStart) - logDebug(`Flipped endCheck, overlap(${overlapDuration})/trip(${tlEntry.duration} (${overlapDuration} / ${tlEntry.duration})`); - endChecks = (overlapDuration/tlEntry.duration) > 0.5; - } + let endChecks = userInput.data.end_ts <= entryEnd || userInput.data.end_ts - entryEnd <= 15 * 60; + + if (startChecks && !endChecks) { + const nextEntryObj = tlEntry.getNextEntry(); + if (nextEntryObj) { + const nextEntryEnd = nextEntryObj.end_ts || nextEntryObj.exit_ts; + if (!nextEntryEnd) { + // the last place will not have an exit_ts + endChecks = true; // so we will just skip the end check + } else { + endChecks = userInput.data.end_ts <= nextEntryEnd; + logDebug( + `Second level of end checks when the next trip is defined(${userInput.data.end_ts} <= ${nextEntryEnd}) ${endChecks}`, + ); + } + } else { + // next trip is not defined, last trip + endChecks = userInput.data.end_local_dt.day == userInput.data.start_local_dt.day; + logDebug('Second level of end checks for the last trip of the day'); + logDebug( + `compare ${userInput.data.end_local_dt.day} with ${userInput.data.start_local_dt.day} ${endChecks}`, + ); + } + if (endChecks) { + // If we have flipped the values, check to see that there is sufficient overlap + const overlapDuration = + Math.min(userInput.data.end_ts, entryEnd) - Math.max(userInput.data.start_ts, entryStart); + logDebug( + `Flipped endCheck, overlap(${overlapDuration})/trip(${tlEntry.duration} (${overlapDuration} / ${tlEntry.duration})`, + ); + endChecks = overlapDuration / tlEntry.duration > 0.5; } - return startChecks && endChecks; -} + } + return startChecks && endChecks; +}; // parallels get_not_deleted_candidates() in trip_queries.py export const getNotDeletedCandidates = (candidates: UserInput[]): UserInput[] => { - console.log('getNotDeletedCandidates called with ' + candidates.length + ' candidates'); - - // We want to retain all ACTIVE entries that have not been DELETED - const allActiveList = candidates.filter(c => !c.data.status || c.data.status == 'ACTIVE'); - const allDeletedIds = candidates.filter(c => c.data.status && c.data.status == 'DELETED').map(c => c.data['match_id']); - const notDeletedActive = allActiveList.filter(c => !allDeletedIds.includes(c.data['match_id'])); - - console.log(`Found ${allActiveList.length} active entries, ${allDeletedIds.length} deleted entries -> - ${notDeletedActive.length} non deleted active entries`); - - return notDeletedActive; -} - -export const getUserInputForTrip = (trip: TlEntry, nextTrip: any, userInputList: UserInput[]): undefined | UserInput => { - const logsEnabled = userInputList?.length < 20; - if (userInputList === undefined) { - logDebug("In getUserInputForTrip, no user input, returning undefined"); - return undefined; - } - - if (logsEnabled) console.log(`Input list = ${userInputList.map(printUserInput)}`); - - // undefined !== true, so this covers the label view case as well - const potentialCandidates = userInputList.filter((ui) => validUserInputForTimelineEntry(trip, ui, logsEnabled)); - - if (potentialCandidates.length === 0) { - if (logsEnabled) logDebug("In getUserInputForTripStartEnd, no potential candidates, returning []"); - return undefined; - } + console.log('getNotDeletedCandidates called with ' + candidates.length + ' candidates'); - if (potentialCandidates.length === 1) { - logDebug(`In getUserInputForTripStartEnd, one potential candidate, returning ${printUserInput(potentialCandidates[0])}`); - return potentialCandidates[0]; - } + // We want to retain all ACTIVE entries that have not been DELETED + const allActiveList = candidates.filter((c) => !c.data.status || c.data.status == 'ACTIVE'); + const allDeletedIds = candidates + .filter((c) => c.data.status && c.data.status == 'DELETED') + .map((c) => c.data['match_id']); + const notDeletedActive = allActiveList.filter((c) => !allDeletedIds.includes(c.data['match_id'])); - logDebug(`potentialCandidates are ${potentialCandidates.map(printUserInput)}`); + console.log(`Found ${allActiveList.length} active entries, ${allDeletedIds.length} deleted entries -> + ${notDeletedActive.length} non deleted active entries`); - const sortedPC = potentialCandidates.sort((pc1, pc2) => pc2.metadata.write_ts - pc1.metadata.write_ts); - const mostRecentEntry = sortedPC[0]; - logDebug("Returning mostRecentEntry "+printUserInput(mostRecentEntry)); - - return mostRecentEntry; -} + return notDeletedActive; +}; + +export const getUserInputForTrip = ( + trip: TlEntry, + nextTrip: any, + userInputList: UserInput[], +): undefined | UserInput => { + const logsEnabled = userInputList?.length < 20; + if (userInputList === undefined) { + logDebug('In getUserInputForTrip, no user input, returning undefined'); + return undefined; + } + + if (logsEnabled) console.log(`Input list = ${userInputList.map(printUserInput)}`); + + // undefined !== true, so this covers the label view case as well + const potentialCandidates = userInputList.filter((ui) => + validUserInputForTimelineEntry(trip, ui, logsEnabled), + ); + + if (potentialCandidates.length === 0) { + if (logsEnabled) + logDebug('In getUserInputForTripStartEnd, no potential candidates, returning []'); + return undefined; + } + + if (potentialCandidates.length === 1) { + logDebug( + `In getUserInputForTripStartEnd, one potential candidate, returning ${printUserInput( + potentialCandidates[0], + )}`, + ); + return potentialCandidates[0]; + } + + logDebug(`potentialCandidates are ${potentialCandidates.map(printUserInput)}`); + + const sortedPC = potentialCandidates.sort( + (pc1, pc2) => pc2.metadata.write_ts - pc1.metadata.write_ts, + ); + const mostRecentEntry = sortedPC[0]; + logDebug('Returning mostRecentEntry ' + printUserInput(mostRecentEntry)); + + return mostRecentEntry; +}; // return array of matching additions for a trip or place -export const getAdditionsForTimelineEntry = (entry: TlEntry, additionsList: UserInput[]): UserInput[] => { - const logsEnabled = additionsList?.length < 20; +export const getAdditionsForTimelineEntry = ( + entry: TlEntry, + additionsList: UserInput[], +): UserInput[] => { + const logsEnabled = additionsList?.length < 20; - if (additionsList === undefined) { - logDebug("In getAdditionsForTimelineEntry, no addition input, returning []"); - return []; - } + if (additionsList === undefined) { + logDebug('In getAdditionsForTimelineEntry, no addition input, returning []'); + return []; + } - // get additions that have not been deleted and filter out additions that do not start within the bounds of the timeline entry - const notDeleted = getNotDeletedCandidates(additionsList); - const matchingAdditions = notDeleted.filter((ui) => validUserInputForTimelineEntry(entry, ui, logsEnabled)); + // get additions that have not been deleted and filter out additions that do not start within the bounds of the timeline entry + const notDeleted = getNotDeletedCandidates(additionsList); + const matchingAdditions = notDeleted.filter((ui) => + validUserInputForTimelineEntry(entry, ui, logsEnabled), + ); - if (logsEnabled) console.log(`Matching Addition list ${matchingAdditions.map(printUserInput)}`); + if (logsEnabled) console.log(`Matching Addition list ${matchingAdditions.map(printUserInput)}`); - return matchingAdditions; -} + return matchingAdditions; +}; export const getUniqueEntries = (combinedList) => { - /* we should not get any non-ACTIVE entries here + /* we should not get any non-ACTIVE entries here since we have run filtering algorithms on both the phone and the server */ - const allDeleted = combinedList.filter(c => c.data.status && c.data.status == 'DELETED'); - - if (allDeleted.length > 0) { - displayErrorMsg("Found "+allDeleted.length +" non-ACTIVE addition entries while trying to dedup entries", JSON.stringify(allDeleted)); - } - - const uniqueMap = new Map(); - combinedList.forEach((e) => { - const existingVal = uniqueMap.get(e.data.match_id); - /* if the existing entry and the input entry don't match and they are both active, we have an error + const allDeleted = combinedList.filter((c) => c.data.status && c.data.status == 'DELETED'); + + if (allDeleted.length > 0) { + displayErrorMsg( + 'Found ' + allDeleted.length + ' non-ACTIVE addition entries while trying to dedup entries', + JSON.stringify(allDeleted), + ); + } + + const uniqueMap = new Map(); + combinedList.forEach((e) => { + const existingVal = uniqueMap.get(e.data.match_id); + /* if the existing entry and the input entry don't match and they are both active, we have an error let's notify the user for now */ - if (existingVal) { - if ((existingVal.data.start_ts != e.data.start_ts) || - (existingVal.data.end_ts != e.data.end_ts) || - (existingVal.data.write_ts != e.data.write_ts)) { - displayErrorMsg(`Found two ACTIVE entries with the same match ID but different timestamps ${existingVal.data.match_id}` - , `${JSON.stringify(existingVal)} vs ${JSON.stringify(e)}`); - } else { - console.log(`Found two entries with match_id ${existingVal.data.match_id} but they are identical`); - } - } else { - uniqueMap.set(e.data.match_id, e); - } - }); - return Array.from(uniqueMap.values()); -} + if (existingVal) { + if ( + existingVal.data.start_ts != e.data.start_ts || + existingVal.data.end_ts != e.data.end_ts || + existingVal.data.write_ts != e.data.write_ts + ) { + displayErrorMsg( + `Found two ACTIVE entries with the same match ID but different timestamps ${existingVal.data.match_id}`, + `${JSON.stringify(existingVal)} vs ${JSON.stringify(e)}`, + ); + } else { + console.log( + `Found two entries with match_id ${existingVal.data.match_id} but they are identical`, + ); + } + } else { + uniqueMap.set(e.data.match_id, e); + } + }); + return Array.from(uniqueMap.values()); +}; diff --git a/www/js/survey/multilabel/multi-label-ui.js b/www/js/survey/multilabel/multi-label-ui.js index 904a77b68..8123272e4 100644 --- a/www/js/survey/multilabel/multi-label-ui.js +++ b/www/js/survey/multilabel/multi-label-ui.js @@ -10,212 +10,209 @@ import { import { getConfig } from '../../config/dynamicConfig'; import { getUserInputForTrip } from '../inputMatcher'; -angular.module('emission.survey.multilabel.buttons', []) - -.factory("MultiLabelService", function($rootScope, $timeout, $ionicPlatform, Logger) { - var mls = {}; - console.log("Creating MultiLabelService"); - mls.init = function(config) { - Logger.log("About to initialize the MultiLabelService"); +angular + .module('emission.survey.multilabel.buttons', []) + + .factory('MultiLabelService', function ($rootScope, $timeout, $ionicPlatform, Logger) { + var mls = {}; + console.log('Creating MultiLabelService'); + mls.init = function (config) { + Logger.log('About to initialize the MultiLabelService'); mls.ui_config = config; - getLabelOptions(config).then((inputParams) => mls.inputParams = inputParams); + getLabelOptions(config).then((inputParams) => (mls.inputParams = inputParams)); mls.MANUAL_KEYS = Object.values(getLabelInputDetails(config)).map((inp) => inp.key); - Logger.log("finished initializing the MultiLabelService"); - }; - - $ionicPlatform.ready().then(function () { - Logger.log('UI_CONFIG: about to call configReady function in MultiLabelService'); - getConfig() - .then((newConfig) => { - mls.init(newConfig); - }) - .catch((err) => - Logger.displayError('Error while handling config in MultiLabelService', err), - ); - }); - - /** - * Embed 'inputType' to the trip. - */ - - mls.extractResult = (results) => results; - - mls.processManualInputs = function (manualResults, resultMap) { - var mrString = - 'unprocessed manual inputs ' + - manualResults.map(function (item, index) { - return ` ${item.length} ${getLabelInputs()[index]}`; - }); - console.log(mrString); - manualResults.forEach(function (mr, index) { - resultMap[getLabelInputs()[index]] = mr; + Logger.log('finished initializing the MultiLabelService'); + }; + + $ionicPlatform.ready().then(function () { + Logger.log('UI_CONFIG: about to call configReady function in MultiLabelService'); + getConfig() + .then((newConfig) => { + mls.init(newConfig); + }) + .catch((err) => + Logger.displayError('Error while handling config in MultiLabelService', err), + ); + }); + + /** + * Embed 'inputType' to the trip. + */ + + mls.extractResult = (results) => results; + + mls.processManualInputs = function (manualResults, resultMap) { + var mrString = + 'unprocessed manual inputs ' + + manualResults.map(function (item, index) { + return ` ${item.length} ${getLabelInputs()[index]}`; }); - }; - - /** - * Embed 'inputType' to the trip - * This is the version that is called from the list, which focuses only on - * manual inputs. It also sets some additional values - */ - mls.populateManualInputs = function (trip, nextTrip, inputType, inputList) { + console.log(mrString); + manualResults.forEach(function (mr, index) { + resultMap[getLabelInputs()[index]] = mr; + }); + }; + + /** + * Embed 'inputType' to the trip + * This is the version that is called from the list, which focuses only on + * manual inputs. It also sets some additional values + */ + mls.populateManualInputs = function (trip, nextTrip, inputType, inputList) { // Check unprocessed labels first since they are more recent const unprocessedLabelEntry = getUserInputForTrip(trip, nextTrip, inputList); - var userInputLabel = unprocessedLabelEntry? unprocessedLabelEntry.data.label : undefined; + var userInputLabel = unprocessedLabelEntry ? unprocessedLabelEntry.data.label : undefined; if (!angular.isDefined(userInputLabel)) { - userInputLabel = trip.user_input?.[mls.inputType2retKey(inputType)]; - } - mls.populateInput(trip.userInput, inputType, userInputLabel); - // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); - mls.editingTrip = angular.undefined; - }; - - /** - * Insert the given userInputLabel into the given inputType's slot in inputField - */ - mls.populateInput = function (tripField, inputType, userInputLabel) { - if (angular.isDefined(userInputLabel)) { - console.log( - 'populateInput: looking in map of ' + - inputType + - ' for userInputLabel' + - userInputLabel, - ); - var userInputEntry = mls.inputParams[inputType].find((o) => o.value == userInputLabel); - if (!angular.isDefined(userInputEntry)) { - userInputEntry = getFakeEntry(userInputLabel); - mls.inputParams[inputType].push(userInputEntry); - } - console.log( - 'Mapped label ' + userInputLabel + ' to entry ' + JSON.stringify(userInputEntry), - ); - tripField[inputType] = userInputEntry; + userInputLabel = trip.user_input?.[mls.inputType2retKey(inputType)]; + } + mls.populateInput(trip.userInput, inputType, userInputLabel); + // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); + mls.editingTrip = angular.undefined; + }; + + /** + * Insert the given userInputLabel into the given inputType's slot in inputField + */ + mls.populateInput = function (tripField, inputType, userInputLabel) { + if (angular.isDefined(userInputLabel)) { + console.log( + 'populateInput: looking in map of ' + inputType + ' for userInputLabel' + userInputLabel, + ); + var userInputEntry = mls.inputParams[inputType].find((o) => o.value == userInputLabel); + if (!angular.isDefined(userInputEntry)) { + userInputEntry = getFakeEntry(userInputLabel); + mls.inputParams[inputType].push(userInputEntry); } - }; - - /** - * Given the list of possible label tuples we've been sent and what the user has already input for the trip, choose the best labels to actually present to the user. - * The algorithm below operationalizes these principles: - * - Never consider label tuples that contradict a green label - * - Obey "conservation of uncertainty": the sum of probabilities after filtering by green labels must equal the sum of probabilities before - * - After filtering, predict the most likely choices at the level of individual labels, not label tuples - * - Never show user yellow labels that have a lower probability of being correct than confidenceThreshold - */ - mls.inferFinalLabels = function (trip) { - // Deep copy the possibility tuples - let labelsList = []; - if (angular.isDefined(trip.inferred_labels)) { - labelsList = JSON.parse(JSON.stringify(trip.inferred_labels)); + console.log( + 'Mapped label ' + userInputLabel + ' to entry ' + JSON.stringify(userInputEntry), + ); + tripField[inputType] = userInputEntry; + } + }; + + /** + * Given the list of possible label tuples we've been sent and what the user has already input for the trip, choose the best labels to actually present to the user. + * The algorithm below operationalizes these principles: + * - Never consider label tuples that contradict a green label + * - Obey "conservation of uncertainty": the sum of probabilities after filtering by green labels must equal the sum of probabilities before + * - After filtering, predict the most likely choices at the level of individual labels, not label tuples + * - Never show user yellow labels that have a lower probability of being correct than confidenceThreshold + */ + mls.inferFinalLabels = function (trip) { + // Deep copy the possibility tuples + let labelsList = []; + if (angular.isDefined(trip.inferred_labels)) { + labelsList = JSON.parse(JSON.stringify(trip.inferred_labels)); + } + + // Capture the level of certainty so we can reconstruct it later + const totalCertainty = labelsList + .map((item) => item.p) + .reduce((item, rest) => item + rest, 0); + + // Filter out the tuples that are inconsistent with existing green labels + for (const inputType of getLabelInputs()) { + const userInput = trip.userInput[inputType]; + if (userInput) { + const retKey = mls.inputType2retKey(inputType); + labelsList = labelsList.filter((item) => item.labels[retKey] == userInput.value); } + } + + // Red labels if we have no possibilities left + if (labelsList.length == 0) { + for (const inputType of getLabelInputs()) + mls.populateInput(trip.finalInference, inputType, undefined); + } else { + // Normalize probabilities to previous level of certainty + const certaintyScalar = + totalCertainty / labelsList.map((item) => item.p).reduce((item, rest) => item + rest); + labelsList.forEach((item) => (item.p *= certaintyScalar)); - // Capture the level of certainty so we can reconstruct it later - const totalCertainty = labelsList - .map((item) => item.p) - .reduce((item, rest) => item + rest, 0); - - // Filter out the tuples that are inconsistent with existing green labels for (const inputType of getLabelInputs()) { - const userInput = trip.userInput[inputType]; - if (userInput) { - const retKey = mls.inputType2retKey(inputType); - labelsList = labelsList.filter((item) => item.labels[retKey] == userInput.value); + // For each label type, find the most probable value by binning by label value and summing + const retKey = mls.inputType2retKey(inputType); + let valueProbs = new Map(); + for (const tuple of labelsList) { + const labelValue = tuple.labels[retKey]; + if (!valueProbs.has(labelValue)) valueProbs.set(labelValue, 0); + valueProbs.set(labelValue, valueProbs.get(labelValue) + tuple.p); } - } - - // Red labels if we have no possibilities left - if (labelsList.length == 0) { - for (const inputType of getLabelInputs()) - mls.populateInput(trip.finalInference, inputType, undefined); - } else { - // Normalize probabilities to previous level of certainty - const certaintyScalar = - totalCertainty / labelsList.map((item) => item.p).reduce((item, rest) => item + rest); - labelsList.forEach((item) => (item.p *= certaintyScalar)); - - for (const inputType of getLabelInputs()) { - // For each label type, find the most probable value by binning by label value and summing - const retKey = mls.inputType2retKey(inputType); - let valueProbs = new Map(); - for (const tuple of labelsList) { - const labelValue = tuple.labels[retKey]; - if (!valueProbs.has(labelValue)) valueProbs.set(labelValue, 0); - valueProbs.set(labelValue, valueProbs.get(labelValue) + tuple.p); - } - let max = { p: 0, labelValue: undefined }; - for (const [thisLabelValue, thisP] of valueProbs) { - // In the case of a tie, keep the label with earlier first appearance in the labelsList (we used a Map to preserve this order) - if (thisP > max.p) max = { p: thisP, labelValue: thisLabelValue }; - } - - // Display a label as red if its most probable inferred value has a probability less than or equal to the trip's confidence_threshold - // Fails safe if confidence_threshold doesn't exist - if (max.p <= trip.confidence_threshold) max.labelValue = undefined; - - mls.populateInput(trip.finalInference, inputType, max.labelValue); + let max = { p: 0, labelValue: undefined }; + for (const [thisLabelValue, thisP] of valueProbs) { + // In the case of a tie, keep the label with earlier first appearance in the labelsList (we used a Map to preserve this order) + if (thisP > max.p) max = { p: thisP, labelValue: thisLabelValue }; } + + // Display a label as red if its most probable inferred value has a probability less than or equal to the trip's confidence_threshold + // Fails safe if confidence_threshold doesn't exist + if (max.p <= trip.confidence_threshold) max.labelValue = undefined; + + mls.populateInput(trip.finalInference, inputType, max.labelValue); } - }; - - /* - * Uses either 2 or 3 labels depending on the type of install (program vs. study) - * and the primary mode. - * This used to be in the controller, where it really should be, but we had - * to move it to the service because we need to invoke it from the list view - * as part of filtering "To Label" entries. - * - * TODO: Move it back later after the diary vs. label unification - */ - mls.expandInputsIfNecessary = function (trip) { - console.log('Reading expanding inputs for ', trip); - const inputValue = trip.userInput['MODE'] ? trip.userInput['MODE'].value : undefined; - console.log('Experimenting with expanding inputs for mode ' + inputValue); - if (mls.ui_config.intro.mode_studied) { - if (inputValue == mls.ui_config.intro.mode_studied) { - Logger.log( - 'Found ' + - mls.ui_config.intro.mode_studied + - ' mode in a program, displaying full details', - ); - trip.inputDetails = getLabelInputDetails(); - trip.INPUTS = getLabelInputs(); - } else { - Logger.log( - 'Found non ' + - mls.ui_config.intro.mode_studied + - ' mode in a program, displaying base details', - ); - trip.inputDetails = baseLabelInputDetails; - trip.INPUTS = getBaseLabelInputs(); - } - } else { - Logger.log('study, not program, displaying full details'); - trip.INPUTS = getLabelInputs(); + } + }; + + /* + * Uses either 2 or 3 labels depending on the type of install (program vs. study) + * and the primary mode. + * This used to be in the controller, where it really should be, but we had + * to move it to the service because we need to invoke it from the list view + * as part of filtering "To Label" entries. + * + * TODO: Move it back later after the diary vs. label unification + */ + mls.expandInputsIfNecessary = function (trip) { + console.log('Reading expanding inputs for ', trip); + const inputValue = trip.userInput['MODE'] ? trip.userInput['MODE'].value : undefined; + console.log('Experimenting with expanding inputs for mode ' + inputValue); + if (mls.ui_config.intro.mode_studied) { + if (inputValue == mls.ui_config.intro.mode_studied) { + Logger.log( + 'Found ' + + mls.ui_config.intro.mode_studied + + ' mode in a program, displaying full details', + ); trip.inputDetails = getLabelInputDetails(); + trip.INPUTS = getLabelInputs(); + } else { + Logger.log( + 'Found non ' + + mls.ui_config.intro.mode_studied + + ' mode in a program, displaying base details', + ); + trip.inputDetails = baseLabelInputDetails; + trip.INPUTS = getBaseLabelInputs(); } - }; - - /** - * MODE (becomes manual/mode_confirm) becomes mode_confirm - */ - mls.inputType2retKey = function (inputType) { - return getLabelInputDetails()[inputType].key.split('/')[1]; - }; - - mls.updateVerifiability = function (trip) { - var allGreen = true; - var someYellow = false; - for (const inputType of trip.INPUTS) { - const green = trip.userInput[inputType]; - const yellow = trip.finalInference[inputType] && !green; - if (yellow) someYellow = true; - if (!green) allGreen = false; - } - trip.verifiability = someYellow - ? 'can-verify' - : allGreen - ? 'already-verified' - : 'cannot-verify'; - }; - - return mls; - }, - ); + } else { + Logger.log('study, not program, displaying full details'); + trip.INPUTS = getLabelInputs(); + trip.inputDetails = getLabelInputDetails(); + } + }; + + /** + * MODE (becomes manual/mode_confirm) becomes mode_confirm + */ + mls.inputType2retKey = function (inputType) { + return getLabelInputDetails()[inputType].key.split('/')[1]; + }; + + mls.updateVerifiability = function (trip) { + var allGreen = true; + var someYellow = false; + for (const inputType of trip.INPUTS) { + const green = trip.userInput[inputType]; + const yellow = trip.finalInference[inputType] && !green; + if (yellow) someYellow = true; + if (!green) allGreen = false; + } + trip.verifiability = someYellow + ? 'can-verify' + : allGreen + ? 'already-verified' + : 'cannot-verify'; + }; + + return mls; + }); diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 6c0417d2c..1e71d1cd9 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -10,39 +10,39 @@ type ConfirmedPlace = any; // TODO /* These are the properties received from the server (basically matches Python code) This should match what Timeline.readAllCompositeTrips returns (an array of these objects) */ export type CompositeTrip = { - _id: {$oid: string}, - additions: any[], // TODO - cleaned_section_summary: any, // TODO - cleaned_trip: {$oid: string}, - confidence_threshold: number, - confirmed_trip: {$oid: string}, - distance: number, - duration: number, - end_confirmed_place: ConfirmedPlace, - end_fmt_time: string, - end_loc: {type: string, coordinates: number[]}, - end_local_dt: LocalDt, - end_place: {$oid: string}, - end_ts: number, - expectation: any, // TODO "{to_label: boolean}" - expected_trip: {$oid: string}, - inferred_labels: any[], // TODO - inferred_section_summary: any, // TODO - inferred_trip: {$oid: string}, - key: string, - locations: any[], // TODO - origin_key: string, - raw_trip: {$oid: string}, - sections: any[], // TODO - source: string, - start_confirmed_place: ConfirmedPlace, - start_fmt_time: string, - start_loc: {type: string, coordinates: number[]}, - start_local_dt: LocalDt, - start_place: {$oid: string}, - start_ts: number, - user_input: UserInput, -} + _id: { $oid: string }; + additions: any[]; // TODO + cleaned_section_summary: any; // TODO + cleaned_trip: { $oid: string }; + confidence_threshold: number; + confirmed_trip: { $oid: string }; + distance: number; + duration: number; + end_confirmed_place: ConfirmedPlace; + end_fmt_time: string; + end_loc: { type: string; coordinates: number[] }; + end_local_dt: LocalDt; + end_place: { $oid: string }; + end_ts: number; + expectation: any; // TODO "{to_label: boolean}" + expected_trip: { $oid: string }; + inferred_labels: any[]; // TODO + inferred_section_summary: any; // TODO + inferred_trip: { $oid: string }; + key: string; + locations: any[]; // TODO + origin_key: string; + raw_trip: { $oid: string }; + sections: any[]; // TODO + source: string; + start_confirmed_place: ConfirmedPlace; + start_fmt_time: string; + start_loc: { type: string; coordinates: number[] }; + start_local_dt: LocalDt; + start_place: { $oid: string }; + start_ts: number; + user_input: UserInput; +}; /* These properties aren't received from the server, but are derived from the above properties. They are used in the UI to display trip/place details and are computed by the useDerivedProperties hook. */ @@ -63,58 +63,58 @@ export type DerivedProperties = { It would simplify the codebase to just compute them where they're needed (using memoization when apt so performance is not impacted). */ export type PopulatedTrip = CompositeTrip & { - additionsList?: any[], // TODO - finalInference?: any, // TODO - geojson?: any, // TODO - getNextEntry?: () => PopulatedTrip | ConfirmedPlace, - userInput?: UserInput, - verifiability?: string, -} + additionsList?: any[]; // TODO + finalInference?: any; // TODO + geojson?: any; // TODO + getNextEntry?: () => PopulatedTrip | ConfirmedPlace; + userInput?: UserInput; + verifiability?: string; +}; export type UserInput = { data: { - end_ts: number, - start_ts: number - label: string, - start_local_dt?: LocalDt - end_local_dt?: LocalDt - status?: string, - match_id?: string, - }, + end_ts: number; + start_ts: number; + label: string; + start_local_dt?: LocalDt; + end_local_dt?: LocalDt; + status?: string; + match_id?: string; + }; metadata: { - time_zone: string, - plugin: string, - write_ts: number, - platform: string, - read_ts: number, - key: string, - }, - key?: string -} + time_zone: string; + plugin: string; + write_ts: number; + platform: string; + read_ts: number; + key: string; + }; + key?: string; +}; export type LocalDt = { - minute: number, - hour: number, - second: number, - day: number, - weekday: number, - month: number, - year: number, - timezone: string, -} + minute: number; + hour: number; + second: number; + day: number; + weekday: number; + month: number; + year: number; + timezone: string; +}; export type Trip = { - end_ts: number, - start_ts: number, -} + end_ts: number; + start_ts: number; +}; export type TlEntry = { - key: string, - origin_key: string, - start_ts: number, - end_ts: number, - enter_ts: number, - exit_ts: number, - duration: number, - getNextEntry?: () => PopulatedTrip | ConfirmedPlace, -} + key: string; + origin_key: string; + start_ts: number; + end_ts: number; + enter_ts: number; + exit_ts: number; + duration: number; + getNextEntry?: () => PopulatedTrip | ConfirmedPlace; +}; From 9cf629e17365a221db9dc63b3b054296dbc2cecd Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 2 Nov 2023 13:59:36 -0700 Subject: [PATCH 293/850] Updated type appearances with prettier --- www/js/types/diaryTypes.ts | 124 ++++++++++++++++----------------- www/js/types/fileShareTypes.ts | 12 ++-- www/js/types/serverData.ts | 45 ++++++------ 3 files changed, 90 insertions(+), 91 deletions(-) diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index b51725977..14d8acc07 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -1,75 +1,75 @@ -import { LocalDt, ServerData } from './serverData' +import { LocalDt, ServerData } from './serverData'; -export type UserInput = ServerData +export type UserInput = ServerData; export type UserInputData = { - end_ts: number, - start_ts: number - label: string, - start_local_dt?: LocalDt - end_local_dt?: LocalDt - status?: string, - match_id?: string, -} + end_ts: number; + start_ts: number; + label: string; + start_local_dt?: LocalDt; + end_local_dt?: LocalDt; + status?: string; + match_id?: string; +}; type ConfirmedPlace = any; // TODO export type CompositeTrip = { - _id: {$oid: string}, - additions: any[], // TODO - cleaned_section_summary: any, // TODO - cleaned_trip: {$oid: string}, - confidence_threshold: number, - confirmed_trip: {$oid: string}, - distance: number, - duration: number, - end_confirmed_place: ConfirmedPlace, - end_fmt_time: string, - end_loc: {type: string, coordinates: number[]}, - end_local_dt: LocalDt, - end_place: {$oid: string}, - end_ts: number, - expectation: any, // TODO "{to_label: boolean}" - expected_trip: {$oid: string}, - inferred_labels: any[], // TODO - inferred_section_summary: any, // TODO - inferred_trip: {$oid: string}, - key: string, - locations: any[], // TODO - origin_key: string, - raw_trip: {$oid: string}, - sections: any[], // TODO - source: string, - start_confirmed_place: ConfirmedPlace, - start_fmt_time: string, - start_loc: {type: string, coordinates: number[]}, - start_local_dt: LocalDt, - start_place: {$oid: string}, - start_ts: number, - user_input: UserInput, -} + _id: { $oid: string }; + additions: any[]; // TODO + cleaned_section_summary: any; // TODO + cleaned_trip: { $oid: string }; + confidence_threshold: number; + confirmed_trip: { $oid: string }; + distance: number; + duration: number; + end_confirmed_place: ConfirmedPlace; + end_fmt_time: string; + end_loc: { type: string; coordinates: number[] }; + end_local_dt: LocalDt; + end_place: { $oid: string }; + end_ts: number; + expectation: any; // TODO "{to_label: boolean}" + expected_trip: { $oid: string }; + inferred_labels: any[]; // TODO + inferred_section_summary: any; // TODO + inferred_trip: { $oid: string }; + key: string; + locations: any[]; // TODO + origin_key: string; + raw_trip: { $oid: string }; + sections: any[]; // TODO + source: string; + start_confirmed_place: ConfirmedPlace; + start_fmt_time: string; + start_loc: { type: string; coordinates: number[] }; + start_local_dt: LocalDt; + start_place: { $oid: string }; + start_ts: number; + user_input: UserInput; +}; export type PopulatedTrip = CompositeTrip & { - additionsList?: any[], // TODO - finalInference?: any, // TODO - geojson?: any, // TODO - getNextEntry?: () => PopulatedTrip | ConfirmedPlace, - userInput?: UserInput, - verifiability?: string, -} + additionsList?: any[]; // TODO + finalInference?: any; // TODO + geojson?: any; // TODO + getNextEntry?: () => PopulatedTrip | ConfirmedPlace; + userInput?: UserInput; + verifiability?: string; +}; export type Trip = { - end_ts: number, - start_ts: number, -} + end_ts: number; + start_ts: number; +}; export type TlEntry = { - key: string, - origin_key: string, - start_ts: number, - end_ts: number, - enter_ts: number, - exit_ts: number, - duration: number, -getNextEntry?: () => PopulatedTrip | ConfirmedPlace, -} \ No newline at end of file + key: string; + origin_key: string; + start_ts: number; + end_ts: number; + enter_ts: number; + exit_ts: number; + duration: number; + getNextEntry?: () => PopulatedTrip | ConfirmedPlace; +}; diff --git a/www/js/types/fileShareTypes.ts b/www/js/types/fileShareTypes.ts index 89481624d..03b41a161 100644 --- a/www/js/types/fileShareTypes.ts +++ b/www/js/types/fileShareTypes.ts @@ -1,11 +1,11 @@ -import { ServerData } from './serverData'; +import { ServerData } from './serverData'; export type TimeStampData = ServerData; export type RawTimelineData = { - name: string, - ts: number, - reading: number, + name: string; + ts: number; + reading: number; }; export interface FsWindow extends Window { @@ -13,10 +13,10 @@ export interface FsWindow extends Window { type: number, size: number, successCallback: (fs: any) => void, - errorCallback?: (error: any) => void + errorCallback?: (error: any) => void, ) => void; LocalFileSystem: { TEMPORARY: number; PERSISTENT: number; }; -}; +} diff --git a/www/js/types/serverData.ts b/www/js/types/serverData.ts index 35bd283bb..76142b603 100644 --- a/www/js/types/serverData.ts +++ b/www/js/types/serverData.ts @@ -1,32 +1,31 @@ export type ServerResponse = { - phone_data: Array>, -} + phone_data: Array>; +}; export type ServerData = { - data: Type, - metadata: MetaData, - key?: string, - user_id?: { $uuid: string, }, - _id?: { $oid: string, }, + data: Type; + metadata: MetaData; + key?: string; + user_id?: { $uuid: string }; + _id?: { $oid: string }; }; export type MetaData = { - key: string, - platform: string, - write_ts: number, - time_zone: string, - write_fmt_time: string, - write_local_dt: LocalDt, + key: string; + platform: string; + write_ts: number; + time_zone: string; + write_fmt_time: string; + write_local_dt: LocalDt; }; - + export type LocalDt = { - minute: number, - hour: number, - second: number, - day: number, - weekday: number, - month: number, - year: number, - timezone: string, + minute: number; + hour: number; + second: number; + day: number; + weekday: number; + month: number; + year: number; + timezone: string; }; - \ No newline at end of file From 8a5ce8bf4e8c01b8de3910c669932229846e04d8 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 2 Nov 2023 14:03:02 -0700 Subject: [PATCH 294/850] updated files with prettier --- www/js/diary/timelineHelper.ts | 20 ++++++++++---------- www/js/services/commHelper.ts | 4 ++-- www/js/survey/enketo/enketoHelper.ts | 11 ++++++----- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 23fda30c4..238495cd6 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -1,8 +1,8 @@ -import moment from "moment"; -import { displayError, logDebug } from "../plugin/logger"; -import { getBaseModeByKey, getBaseModeOfLabeledTrip } from "./diaryHelper"; -import { getUnifiedDataForInterval } from "../services/unifiedDataLoader"; -import i18next from "i18next"; +import moment from 'moment'; +import { displayError, logDebug } from '../plugin/logger'; +import { getBaseModeByKey, getBaseModeOfLabeledTrip } from './diaryHelper'; +import { getUnifiedDataForInterval } from '../services/unifiedDataLoader'; +import i18next from 'i18next'; const cachedGeojsons = new Map(); /** @@ -156,12 +156,12 @@ export function getLocalUnprocessedInputs(pipelineRange, labelsFactory, notesFac export function getAllUnprocessedInputs(pipelineRange, labelsFactory, notesFactory) { const tq = getUnprocessedInputQuery(pipelineRange); const getMethod = window['cordova'].plugins.BEMUserCache.getMessagesForInterval; - - const labelsPromises = labelsFactory.MANUAL_KEYS.map((key) => - getUnifiedDataForInterval(key, tq, getMethod).then(labelsFactory.extractResult) + + const labelsPromises = labelsFactory.MANUAL_KEYS.map((key) => + getUnifiedDataForInterval(key, tq, getMethod).then(labelsFactory.extractResult), ); - const notesPromises = notesFactory.MANUAL_KEYS.map((key) => - getUnifiedDataForInterval(key, tq, getMethod).then(notesFactory.extractResult) + const notesPromises = notesFactory.MANUAL_KEYS.map((key) => + getUnifiedDataForInterval(key, tq, getMethod).then(notesFactory.extractResult), ); return getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, notesPromises); } diff --git a/www/js/services/commHelper.ts b/www/js/services/commHelper.ts index bda3a7f3b..6dc71160a 100644 --- a/www/js/services/commHelper.ts +++ b/www/js/services/commHelper.ts @@ -1,5 +1,5 @@ -import { DateTime } from "luxon"; -import { logDebug } from "../plugin/logger"; +import { DateTime } from 'luxon'; +import { logDebug } from '../plugin/logger'; /** * @param url URL endpoint for the request diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index 567091c6c..424e364d2 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -2,8 +2,8 @@ import { getAngularService } from '../../angular-react-helper'; import { Form } from 'enketo-core'; import { XMLParser } from 'fast-xml-parser'; import i18next from 'i18next'; -import { logDebug } from "../../plugin/logger"; -import { getUnifiedDataForInterval} from "../../services/unifiedDataLoader"; +import { logDebug } from '../../plugin/logger'; +import { getUnifiedDataForInterval } from '../../services/unifiedDataLoader'; export type PrefillFields = { [key: string]: string }; @@ -107,8 +107,9 @@ const _getMostRecent = (answers) => { */ export function loadPreviousResponseForSurvey(dataKey: string) { const tq = window['cordova'].plugins.BEMUserCache.getAllTimeQuery(); - logDebug("loadPreviousResponseForSurvey: dataKey = " + dataKey + "; tq = " + tq); + logDebug('loadPreviousResponseForSurvey: dataKey = ' + dataKey + '; tq = ' + tq); const getMethod = window['cordova'].plugins.BEMUserCache.getSensorDataForInterval; - return getUnifiedDataForInterval(dataKey, tq, getMethod) - .then(answers => _getMostRecent(answers)); + return getUnifiedDataForInterval(dataKey, tq, getMethod).then((answers) => + _getMostRecent(answers), + ); } From 58a341155200d21b5275df7a50f26ee248873178 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 2 Nov 2023 14:26:49 -0700 Subject: [PATCH 295/850] Ran prettier on controlHelper --- www/js/services/controlHelper.ts | 210 +++++++++++++++++-------------- 1 file changed, 115 insertions(+), 95 deletions(-) diff --git a/www/js/services/controlHelper.ts b/www/js/services/controlHelper.ts index 58d6fe738..f2741b0dd 100644 --- a/www/js/services/controlHelper.ts +++ b/www/js/services/controlHelper.ts @@ -1,139 +1,159 @@ -import { DateTime } from "luxon"; +import { DateTime } from 'luxon'; -import { getRawEntries } from "./commHelper"; -import { logInfo, displayError, logDebug, logWarn } from "../plugin/logger"; -import { FsWindow } from "../types/fileShareTypes" -import { ServerResponse} from "../types/serverData"; -import i18next from "../i18nextInit" ; +import { getRawEntries } from './commHelper'; +import { logInfo, displayError, logDebug, logWarn } from '../plugin/logger'; +import { FsWindow } from '../types/fileShareTypes'; +import { ServerResponse } from '../types/serverData'; +import i18next from '../i18nextInit'; declare let window: FsWindow; -export const getMyDataHelpers = function(fileName: string, startTimeString: string, endTimeString: string) { +export const getMyDataHelpers = function ( + fileName: string, + startTimeString: string, + endTimeString: string, +) { const localWriteFile = function (result: ServerResponse) { const resultList = result.phone_data; - return new Promise(function(resolve, reject) { - window['resolveLocalFileSystemURL'](window['cordova'].file.tempDirectory, function(fs) { - fs.filesystem.root.getFile(fileName, { create: true, exclusive: false }, function (fileEntry) { - logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`) + return new Promise(function (resolve, reject) { + window['resolveLocalFileSystemURL'](window['cordova'].file.tempDirectory, function (fs) { + fs.filesystem.root.getFile( + fileName, + { create: true, exclusive: false }, + function (fileEntry) { + logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`); fileEntry.createWriter(function (fileWriter) { - fileWriter.onwriteend = function() { - logDebug("Successful file write..."); + fileWriter.onwriteend = function () { + logDebug('Successful file write...'); resolve(); - } - fileWriter.onerror = function(e) { + }; + fileWriter.onerror = function (e) { logDebug(`Failed file write: ${e.toString()}`); reject(); - } - logDebug(`fileWriter is: ${JSON.stringify(fileWriter.onwriteend, null, 2)}`) + }; + logDebug(`fileWriter is: ${JSON.stringify(fileWriter.onwriteend, null, 2)}`); // if data object is not passed in, create a new blob instead. - const dataObj = new Blob([JSON.stringify(resultList, null, 2)], - { type: "application/json" }); + const dataObj = new Blob([JSON.stringify(resultList, null, 2)], { + type: 'application/json', + }); fileWriter.write(dataObj); - }) - }); - }); + }); + }, + ); + }); }); }; const localShareData = function () { - return new Promise(function(resolve, reject) { - window['resolveLocalFileSystemURL'](window['cordova'].file.tempDirectory, function(fs) { - fs.filesystem.root.getFile(fileName, null, function(fileEntry) { - logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`); - fileEntry.file(function(file) { - const reader = new FileReader(); + return new Promise(function (resolve, reject) { + window['resolveLocalFileSystemURL'](window['cordova'].file.tempDirectory, function (fs) { + fs.filesystem.root.getFile(fileName, null, function (fileEntry) { + logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`); + fileEntry.file( + function (file) { + const reader = new FileReader(); - reader.onloadend = function() { - const readResult = this.result as string; - logDebug(`Successfull file read with ${readResult.length} characters`); - const dataArray = JSON.parse(readResult); - logDebug(`Successfully read resultList of size ${dataArray.length}`); - let attachFile = fileEntry.nativeURL; - const shareObj = { - 'files': [attachFile], - 'message': i18next.t("email-service.email-data.body-data-consists-of-list-of-entries"), - 'subject': i18next.t("email-service.email-data.subject-data-dump-from-to", {start: startTimeString, end: endTimeString}), - } - window['plugins'].socialsharing.shareWithOptions(shareObj, function (result) { - logDebug(`Share Completed? ${result.completed}`); // On Android, most likely returns false - logDebug(`Shared to app: ${result.app}`); - resolve(); - }, function (msg) { - logDebug(`Sharing failed with message ${msg}`); - }); - } - reader.readAsText(file); - }, function(error) { - displayError(error, "Error while downloading JSON dump"); - reject(error); + reader.onloadend = function () { + const readResult = this.result as string; + logDebug(`Successfull file read with ${readResult.length} characters`); + const dataArray = JSON.parse(readResult); + logDebug(`Successfully read resultList of size ${dataArray.length}`); + let attachFile = fileEntry.nativeURL; + const shareObj = { + files: [attachFile], + message: i18next.t( + 'email-service.email-data.body-data-consists-of-list-of-entries', + ), + subject: i18next.t('email-service.email-data.subject-data-dump-from-to', { + start: startTimeString, + end: endTimeString, + }), + }; + window['plugins'].socialsharing.shareWithOptions( + shareObj, + function (result) { + logDebug(`Share Completed? ${result.completed}`); // On Android, most likely returns false + logDebug(`Shared to app: ${result.app}`); + resolve(); + }, + function (msg) { + logDebug(`Sharing failed with message ${msg}`); + }, + ); + }; + reader.readAsText(file); + }, + function (error) { + displayError(error, 'Error while downloading JSON dump'); + reject(error); + }, + ); }); - }); }); - }) }; // window['cordova'].file.TempDirectory is not guaranteed to free up memory, // so it's good practice to remove the file right after it's used! - const localClearData = function() { - return new Promise(function(resolve, reject) { - window['resolveLocalFileSystemURL'](window['cordova'].file.tempDirectory, function(fs) { - fs.filesystem.root.getFile(fileName, null, function(fileEntry) { - fileEntry.remove(() => { - logDebug(`Successfully cleaned up file ${fileName}`); - resolve(); - }, - (err) => { - logWarn(`Error deleting ${fileName} : ${err}`); - reject(err); - }); + const localClearData = function () { + return new Promise(function (resolve, reject) { + window['resolveLocalFileSystemURL'](window['cordova'].file.tempDirectory, function (fs) { + fs.filesystem.root.getFile(fileName, null, function (fileEntry) { + fileEntry.remove( + () => { + logDebug(`Successfully cleaned up file ${fileName}`); + resolve(); + }, + (err) => { + logWarn(`Error deleting ${fileName} : ${err}`); + reject(err); + }, + ); }); }); }); - } + }; return { writeFile: localWriteFile, shareData: localShareData, clearData: localClearData, }; -} +}; /** * getMyData fetches timeline data for a given day, and then gives the user a prompt to share the data * @param timeStamp initial timestamp of the timeline to be fetched. */ -export const getMyData = function(timeStamp: Date) { - // We are only retrieving data for a single day to avoid - // running out of memory on the phone - const endTime = DateTime.fromJSDate(timeStamp); - const startTime = endTime.startOf('day'); - const startTimeString = startTime.toFormat("yyyy'-'MM'-'dd"); - const endTimeString = endTime.toFormat("yyyy'-'MM'-'dd"); +export const getMyData = function (timeStamp: Date) { + // We are only retrieving data for a single day to avoid + // running out of memory on the phone + const endTime = DateTime.fromJSDate(timeStamp); + const startTime = endTime.startOf('day'); + const startTimeString = startTime.toFormat("yyyy'-'MM'-'dd"); + const endTimeString = endTime.toFormat("yyyy'-'MM'-'dd"); - const dumpFile = startTimeString + "." - + endTimeString - + ".timeline"; - alert(`Going to retrieve data to ${dumpFile}`); + const dumpFile = startTimeString + '.' + endTimeString + '.timeline'; + alert(`Going to retrieve data to ${dumpFile}`); - const getDataMethods = getMyDataHelpers(dumpFile, startTimeString, endTimeString); + const getDataMethods = getMyDataHelpers(dumpFile, startTimeString, endTimeString); - getRawEntries(null, startTime.toUnixInteger(), endTime.toUnixInteger()) - .then(getDataMethods.writeFile) - .then(getDataMethods.shareData) - .then(getDataMethods.clearData) - .then(function() { - logInfo("Share queued successfully"); - }) - .catch(function(error) { - displayError(error, "Error sharing JSON dump"); - }) + getRawEntries(null, startTime.toUnixInteger(), endTime.toUnixInteger()) + .then(getDataMethods.writeFile) + .then(getDataMethods.shareData) + .then(getDataMethods.clearData) + .then(function () { + logInfo('Share queued successfully'); + }) + .catch(function (error) { + displayError(error, 'Error sharing JSON dump'); + }); }; -export const fetchOPCode = (() => { - return window["cordova"].plugins.OPCodeAuth.getOPCode(); - }); +export const fetchOPCode = () => { + return window['cordova'].plugins.OPCodeAuth.getOPCode(); +}; -export const getSettings = (() => { - return window["cordova"].plugins.BEMConnectionSettings.getSettings(); -}); +export const getSettings = () => { + return window['cordova'].plugins.BEMConnectionSettings.getSettings(); +}; From 7c7f4d8ad9a0a9d7b76c227a59a6170563ea26a0 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 2 Nov 2023 15:06:33 -0700 Subject: [PATCH 296/850] Fixed error in prettier merge --- www/js/diary/LabelTab.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 0c9c20f7d..f58277bea 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -21,6 +21,8 @@ import { getAllUnprocessedInputs, getLocalUnprocessedInputs, populateCompositeTrips, + readAllCompositeTrips, + readUnprocessedTrips, } from './timelineHelper'; import { fillLocationNamesOfTrip, resetNominatimLimiter } from './addressNamesHelper'; import { SurveyOptions } from '../survey/survey'; @@ -239,7 +241,7 @@ const LabelTab = () => { [...timelineMap?.values()] .reverse() .find((trip) => trip.origin_key.includes('confirmed_trip')); - readUnprocessedPromise = Timeline.readUnprocessedTrips( + readUnprocessedPromise = readUnprocessedTrips( pipelineRange.end_ts, nowTs, lastProcessedTrip, @@ -261,7 +263,7 @@ const LabelTab = () => { const timelineMapRef = useRef(timelineMap); async function repopulateTimelineEntry(oid: string) { if (!timelineMap.has(oid)) - return console.error('Item with oid: ' + oid + ' not found in timeline'); + return console.error(`Item with oid: ${oid} not found in timeline`); const [newLabels, newNotes] = await getLocalUnprocessedInputs( pipelineRange, labelPopulateFactory, From 131eca5f18c93e55b1904d4221fd1466a22352a8 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Thu, 2 Nov 2023 16:20:55 -0600 Subject: [PATCH 297/850] Format notifScheduler.ts Following the Prettier changes in @jiji14 commit bb73676c7245b68b9a8784204b68d71ba0af5267 --- www/js/splash/notifScheduler.ts | 418 ++++++++++++++++---------------- 1 file changed, 210 insertions(+), 208 deletions(-) diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts index 0474fca5d..cd3385ff8 100644 --- a/www/js/splash/notifScheduler.ts +++ b/www/js/splash/notifScheduler.ts @@ -1,11 +1,11 @@ import angular from 'angular'; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState } from 'react'; import { getConfig } from '../config/dynamicConfig'; -import useAppConfig from "../useAppConfig"; +import useAppConfig from '../useAppConfig'; import { addStatReading, statKeys } from '../plugin/clientStats'; import { getUser, updateUser } from '../commHelper'; -import { logDebug } from "../plugin/logger"; -import { DateTime } from "luxon"; +import { logDebug } from '../plugin/logger'; +import { DateTime } from 'luxon'; import i18next from 'i18next'; let scheduledPromise = new Promise((rs) => rs()); @@ -14,36 +14,38 @@ let isScheduling = false; // like python range() function range(start, stop, step) { - let a = [start], b = start; - while (b < stop) - a.push(b += step || 1); - return a; + let a = [start], + b = start; + while (b < stop) a.push((b += step || 1)); + return a; } // returns an array of moment objects, for all times that notifications should be sent const calcNotifTimes = (scheme, dayZeroDate, timeOfDay) => { - const notifTimes = []; - for (const s of scheme.schedule) { - // the days to send notifications, as integers, relative to day zero - const notifDays = range(s.start, s.end, s.intervalInDays); - for (const d of notifDays) { - const date = DateTime.fromFormat(dayZeroDate, 'yyyy-MM-dd').plus({ days: d}).toFormat('yyyy-MM-dd') - const notifTime = DateTime.fromFormat(date+' '+timeOfDay, 'yyyy-MM-dd HH:mm'); - notifTimes.push(notifTime); - } + const notifTimes = []; + for (const s of scheme.schedule) { + // the days to send notifications, as integers, relative to day zero + const notifDays = range(s.start, s.end, s.intervalInDays); + for (const d of notifDays) { + const date = DateTime.fromFormat(dayZeroDate, 'yyyy-MM-dd') + .plus({ days: d }) + .toFormat('yyyy-MM-dd'); + const notifTime = DateTime.fromFormat(date + ' ' + timeOfDay, 'yyyy-MM-dd HH:mm'); + notifTimes.push(notifTime); } - return notifTimes; -} + } + return notifTimes; +}; // returns true if all expected times are already scheduled const areAlreadyScheduled = (notifs, expectedTimes) => { - for (const t of expectedTimes) { - if (!notifs.some((n) => DateTime.fromMillis(n.trigger.at).equals(t))) { - return false; - } + for (const t of expectedTimes) { + if (!notifs.some((n) => DateTime.fromMillis(n.trigger.at).equals(t))) { + return false; } - return true; -} + } + return true; +}; /* remove notif actions as they do not work, can restore post routing migration */ // const setUpActions = () => { @@ -57,161 +59,165 @@ const areAlreadyScheduled = (notifs, expectedTimes) => { // }); // } function debugGetScheduled(prefix) { - window['cordova'].plugins.notification.local.getScheduled((notifs) => { - if (!notifs?.length) - return logDebug(`${prefix}, there are no scheduled notifications`); - const time = DateTime.fromMillis(notifs?.[0].trigger.at).toFormat('HH:mm'); - //was in plugin, changed to scheduler - scheduledNotifs = notifs.map((n) => { - const time = DateTime.fromMillis(n.trigger.at).toFormat('t'); - const date = DateTime.fromMillis(n.trigger.at).toFormat('DDD'); - return { - key: date, - val: time - } - }); - //have the list of scheduled show up in this log - logDebug(`${prefix}, there are ${notifs.length} scheduled notifications at ${time} first is ${scheduledNotifs[0].key} at ${scheduledNotifs[0].val}`); + window['cordova'].plugins.notification.local.getScheduled((notifs) => { + if (!notifs?.length) return logDebug(`${prefix}, there are no scheduled notifications`); + const time = DateTime.fromMillis(notifs?.[0].trigger.at).toFormat('HH:mm'); + //was in plugin, changed to scheduler + scheduledNotifs = notifs.map((n) => { + const time = DateTime.fromMillis(n.trigger.at).toFormat('t'); + const date = DateTime.fromMillis(n.trigger.at).toFormat('DDD'); + return { + key: date, + val: time, + }; }); + //have the list of scheduled show up in this log + logDebug( + `${prefix}, there are ${notifs.length} scheduled notifications at ${time} first is ${scheduledNotifs[0].key} at ${scheduledNotifs[0].val}`, + ); + }); } //new method to fetch notifications -const getScheduledNotifs = function() { - return new Promise((resolve, reject) => { - /* if the notifications are still in active scheduling it causes problems +const getScheduledNotifs = function () { + return new Promise((resolve, reject) => { + /* if the notifications are still in active scheduling it causes problems anywhere from 0-n of the scheduled notifs are displayed if actively scheduling, wait for the scheduledPromise to resolve before fetching prevents such errors */ - if(isScheduling) - { - console.log("requesting fetch while still actively scheduling, waiting on scheduledPromise"); - scheduledPromise.then(() => { - getNotifs().then((notifs) => { - console.log("done scheduling notifs", notifs); - resolve(notifs); - }) - }) - } - else{ - getNotifs().then((notifs) => { - resolve(notifs); - }) - } - }) -} + if (isScheduling) { + console.log('requesting fetch while still actively scheduling, waiting on scheduledPromise'); + scheduledPromise.then(() => { + getNotifs().then((notifs) => { + console.log('done scheduling notifs', notifs); + resolve(notifs); + }); + }); + } else { + getNotifs().then((notifs) => { + resolve(notifs); + }); + } + }); +}; //get scheduled notifications from cordova plugin and format them -const getNotifs = function() { - return new Promise((resolve, reject) => { - window['cordova'].plugins.notification.local.getScheduled((notifs) => { - if (!notifs?.length){ - console.log("there are no notifications"); - resolve([]); //if none, return empty array - } - - const notifSubset = notifs.slice(0, 5); //prevent near-infinite listing - let scheduledNotifs = []; - scheduledNotifs = notifSubset.map((n) => { - const time = DateTime.fromMillis(n.trigger.at).toFormat('t'); - const date = DateTime.fromMillis(n.trigger.at).toFormat('DDD'); - return { - key: date, - val: time - } - }); - resolve(scheduledNotifs); - }); - }) -} +const getNotifs = function () { + return new Promise((resolve, reject) => { + window['cordova'].plugins.notification.local.getScheduled((notifs) => { + if (!notifs?.length) { + console.log('there are no notifications'); + resolve([]); //if none, return empty array + } + + const notifSubset = notifs.slice(0, 5); //prevent near-infinite listing + let scheduledNotifs = []; + scheduledNotifs = notifSubset.map((n) => { + const time = DateTime.fromMillis(n.trigger.at).toFormat('t'); + const date = DateTime.fromMillis(n.trigger.at).toFormat('DDD'); + return { + key: date, + val: time, + }; + }); + resolve(scheduledNotifs); + }); + }); +}; // schedules the notifications using the cordova plugin const scheduleNotifs = (scheme, notifTimes) => { - return new Promise((rs) => { - isScheduling = true; - const localeCode = i18next.resolvedLanguage; - console.error("notifTimes: ", notifTimes, " - type: ", typeof(notifTimes)); - const nots = notifTimes.map((n) => { - console.error("n: ", n, " - type: ", typeof(n)); - const nDate = n.toDate(); - const seconds = nDate.getTime() / 1000; - return { - id: seconds, - title: scheme.title[localeCode], - text: scheme.text[localeCode], - trigger: {at: nDate}, - // actions: 'reminder-actions', - // data: { - // action: { - // redirectTo: 'root.main.control', - // redirectParams: { - // openTimeOfDayPicker: true - // } - // } - // } - } - }); - window['cordova'].plugins.notification.local.cancelAll(() => { - debugGetScheduled("After cancelling"); - window['cordova'].plugins.notification.local.schedule(nots, () => { - debugGetScheduled("After scheduling"); - isScheduling = false; - rs(); //scheduling promise resolved here - }); - }); + return new Promise((rs) => { + isScheduling = true; + const localeCode = i18next.resolvedLanguage; + console.error('notifTimes: ', notifTimes, ' - type: ', typeof notifTimes); + const nots = notifTimes.map((n) => { + console.error('n: ', n, ' - type: ', typeof n); + const nDate = n.toDate(); + const seconds = nDate.getTime() / 1000; + return { + id: seconds, + title: scheme.title[localeCode], + text: scheme.text[localeCode], + trigger: { at: nDate }, + // actions: 'reminder-actions', + // data: { + // action: { + // redirectTo: 'root.main.control', + // redirectParams: { + // openTimeOfDayPicker: true + // } + // } + // } + }; }); -} + window['cordova'].plugins.notification.local.cancelAll(() => { + debugGetScheduled('After cancelling'); + window['cordova'].plugins.notification.local.schedule(nots, () => { + debugGetScheduled('After scheduling'); + isScheduling = false; + rs(); //scheduling promise resolved here + }); + }); + }); +}; // determines when notifications are needed, and schedules them if not already scheduled const update = async (reminderSchemes) => { - const { reminder_assignment, - reminder_join_date, - reminder_time_of_day} = await getReminderPrefs(reminderSchemes); - var scheme = {}; - try { - scheme = reminderSchemes[reminder_assignment]; - } catch (e) { - console.log("ERROR: Could not find reminder scheme for assignment " + reminderSchemes + " - " + reminder_assignment); - } - const notifTimes = calcNotifTimes(scheme, reminder_join_date, reminder_time_of_day); - return new Promise((resolve, reject) => { - window['cordova'].plugins.notification.local.getScheduled((notifs) => { - if (areAlreadyScheduled(notifs, notifTimes)) { - logDebug("Already scheduled, not scheduling again"); - } else { - // to ensure we don't overlap with the last scheduling() request, - // we'll wait for the previous one to finish before scheduling again - scheduledPromise.then(() => { - if (isScheduling) { - console.log("ERROR: Already scheduling notifications, not scheduling again") - } else { - scheduledPromise = scheduleNotifs(scheme, notifTimes); - //enforcing end of scheduling to conisder update through - scheduledPromise.then(() => { - resolve(); - }) - } - }); - } + const { reminder_assignment, reminder_join_date, reminder_time_of_day } = await getReminderPrefs( + reminderSchemes, + ); + var scheme = {}; + try { + scheme = reminderSchemes[reminder_assignment]; + } catch (e) { + console.log( + 'ERROR: Could not find reminder scheme for assignment ' + + reminderSchemes + + ' - ' + + reminder_assignment, + ); + } + const notifTimes = calcNotifTimes(scheme, reminder_join_date, reminder_time_of_day); + return new Promise((resolve, reject) => { + window['cordova'].plugins.notification.local.getScheduled((notifs) => { + if (areAlreadyScheduled(notifs, notifTimes)) { + logDebug('Already scheduled, not scheduling again'); + } else { + // to ensure we don't overlap with the last scheduling() request, + // we'll wait for the previous one to finish before scheduling again + scheduledPromise.then(() => { + if (isScheduling) { + console.log('ERROR: Already scheduling notifications, not scheduling again'); + } else { + scheduledPromise = scheduleNotifs(scheme, notifTimes); + //enforcing end of scheduling to conisder update through + scheduledPromise.then(() => { + resolve(); + }); + } }); + } }); -} + }); +}; /* Randomly assign a scheme, set the join date to today, and use the default time of day from config (or noon if not specified) This is only called once when the user first joins the study */ const initReminderPrefs = (reminderSchemes) => { - // randomly assign from the schemes listed in config - const schemes = Object.keys(reminderSchemes); - const randAssignment = schemes[Math.floor(Math.random() * schemes.length)]; - const todayDate = DateTime.local().toFormat('yyyy-MM-dd'); - const defaultTime = reminderSchemes[randAssignment]?.defaultTime || '12:00'; - return { - reminder_assignment: randAssignment, - reminder_join_date: todayDate, - reminder_time_of_day: defaultTime, - }; -} + // randomly assign from the schemes listed in config + const schemes = Object.keys(reminderSchemes); + const randAssignment = schemes[Math.floor(Math.random() * schemes.length)]; + const todayDate = DateTime.local().toFormat('yyyy-MM-dd'); + const defaultTime = reminderSchemes[randAssignment]?.defaultTime || '12:00'; + return { + reminder_assignment: randAssignment, + reminder_join_date: todayDate, + reminder_time_of_day: defaultTime, + }; +}; /* EXAMPLE VALUES - present in user profile object reminder_assignment: 'passive', @@ -225,62 +231,58 @@ const initReminderPrefs = (reminderSchemes) => { // } const getReminderPrefs = async (reminderSchemes): Promise => { - const user = await getUser(); - if (user?.reminder_assignment && - user?.reminder_join_date && - user?.reminder_time_of_day) { - console.log("User already has reminder prefs, returning them", user) - return user; - } - // if no prefs, user just joined, so initialize them - console.log("User just joined, Initializing reminder prefs") - const initPrefs = initReminderPrefs(reminderSchemes); - console.log("Initialized reminder prefs: ", initPrefs); - await setReminderPrefs(initPrefs, reminderSchemes); - return { ...user, ...initPrefs }; // user profile + the new prefs -} + const user = await getUser(); + if (user?.reminder_assignment && user?.reminder_join_date && user?.reminder_time_of_day) { + console.log('User already has reminder prefs, returning them', user); + return user; + } + // if no prefs, user just joined, so initialize them + console.log('User just joined, Initializing reminder prefs'); + const initPrefs = initReminderPrefs(reminderSchemes); + console.log('Initialized reminder prefs: ', initPrefs); + await setReminderPrefs(initPrefs, reminderSchemes); + return { ...user, ...initPrefs }; // user profile + the new prefs +}; const setReminderPrefs = async (newPrefs, reminderSchemes) => { - await updateUser(newPrefs) - const updatePromise = new Promise((resolve, reject) => { - //enforcing update before moving on - update(reminderSchemes).then(() => { - resolve(); - }); - }); - // record the new prefs in client stats - getReminderPrefs(reminderSchemes).then((prefs) => { - // extract only the relevant fields from the prefs, - // and add as a reading to client stats - const { reminder_assignment, - reminder_join_date, - reminder_time_of_day} = prefs; - addStatReading(statKeys.REMINDER_PREFS, { - reminder_assignment, - reminder_join_date, - reminder_time_of_day - }).then(logDebug("Added reminder prefs to client stats")); + await updateUser(newPrefs); + const updatePromise = new Promise((resolve, reject) => { + //enforcing update before moving on + update(reminderSchemes).then(() => { + resolve(); }); - return updatePromise; -} + }); + // record the new prefs in client stats + getReminderPrefs(reminderSchemes).then((prefs) => { + // extract only the relevant fields from the prefs, + // and add as a reading to client stats + const { reminder_assignment, reminder_join_date, reminder_time_of_day } = prefs; + addStatReading(statKeys.REMINDER_PREFS, { + reminder_assignment, + reminder_join_date, + reminder_time_of_day, + }).then(logDebug('Added reminder prefs to client stats')); + }); + return updatePromise; +}; export function useSchedulerHelper() { - const appConfig = useAppConfig(); - const [reminderSchemes, setReminderSchemes] = useState(); + const appConfig = useAppConfig(); + const [reminderSchemes, setReminderSchemes] = useState(); - useEffect(() => { - if (!appConfig) { - logDebug("No reminder schemes found in config, not scheduling notifications"); - return; - } - setReminderSchemes(appConfig.reminderSchemes); - }, [appConfig]); + useEffect(() => { + if (!appConfig) { + logDebug('No reminder schemes found in config, not scheduling notifications'); + return; + } + setReminderSchemes(appConfig.reminderSchemes); + }, [appConfig]); - //setUpActions(); - update(reminderSchemes); + //setUpActions(); + update(reminderSchemes); - return { - setReminderPrefs: (newPrefs) => setReminderPrefs(newPrefs, reminderSchemes), - getReminderPrefs: () => getReminderPrefs(reminderSchemes), - getScheduledNotifs: () => getScheduledNotifs(), - } -} \ No newline at end of file + return { + setReminderPrefs: (newPrefs) => setReminderPrefs(newPrefs, reminderSchemes), + getReminderPrefs: () => getReminderPrefs(reminderSchemes), + getScheduledNotifs: () => getScheduledNotifs(), + }; +} From 4393362ae2b1e2174c927ca27f13d740b092c24e Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 2 Nov 2023 16:57:15 -0600 Subject: [PATCH 298/850] prettify recent changes a lot of the changes on my branch had not passed through the prettier workflow yet, this should hopefully be the last prettifying change on this branch, as I will keep up with the formatting as I edit from here forward --- www/__mocks__/pushNotificationMocks.ts | 18 +- www/__tests__/customEventHandler.test.ts | 24 +-- www/__tests__/pushNotifySettings.test.ts | 90 ++++++---- www/__tests__/storeDeviceSettings.test.ts | 54 +++--- www/js/customEventHandler.ts | 24 +-- www/js/splash/pushNotifySettings.ts | 194 ++++++++++++---------- www/js/splash/startprefs.ts | 18 +- www/js/splash/storeDeviceSettings.ts | 81 ++++----- 8 files changed, 278 insertions(+), 225 deletions(-) diff --git a/www/__mocks__/pushNotificationMocks.ts b/www/__mocks__/pushNotificationMocks.ts index 47b7bd940..0e98fa064 100644 --- a/www/__mocks__/pushNotificationMocks.ts +++ b/www/__mocks__/pushNotificationMocks.ts @@ -1,5 +1,5 @@ let notifSettings; -let onList : any = {}; +let onList: any = {}; let called = null; export const mockPushNotification = () => { @@ -12,27 +12,27 @@ export const mockPushNotification = () => { }, finish: (content: any, errorFcn: Function, notID: any) => { called = notID; - } + }, }; }, }; -} +}; export const clearNotifMock = function () { notifSettings = {}; onList = {}; called = null; -} +}; export const getOnList = function () { return onList; -} +}; -export const getCalled = function() { +export const getCalled = function () { return called; -} +}; -export const fakeEvent = function (eventName : string) { +export const fakeEvent = function (eventName: string) { //fake the event by executing whatever we have stored for it onList[eventName](); -} \ No newline at end of file +}; diff --git a/www/__tests__/customEventHandler.test.ts b/www/__tests__/customEventHandler.test.ts index d45dba01b..7a5c09539 100644 --- a/www/__tests__/customEventHandler.test.ts +++ b/www/__tests__/customEventHandler.test.ts @@ -1,24 +1,24 @@ -import { publish, subscribe, unsubscribe } from "../js/customEventHandler"; -import { mockLogger } from "../__mocks__/globalMocks"; +import { publish, subscribe, unsubscribe } from '../js/customEventHandler'; +import { mockLogger } from '../__mocks__/globalMocks'; mockLogger(); it('subscribes and publishes to an event', () => { const listener = jest.fn(); - subscribe("test", listener); - publish("test", "test data"); + subscribe('test', listener); + publish('test', 'test data'); expect(listener).toHaveBeenCalledWith( expect.objectContaining({ - type: "test", - detail: "test data", - }) + type: 'test', + detail: 'test data', + }), ); -}) +}); it('can unsubscribe', () => { const listener = jest.fn(); - subscribe("test", listener); - unsubscribe("test", listener); - publish("test", "test data"); + subscribe('test', listener); + unsubscribe('test', listener); + publish('test', 'test data'); expect(listener).not.toHaveBeenCalled(); -}) \ No newline at end of file +}); diff --git a/www/__tests__/pushNotifySettings.test.ts b/www/__tests__/pushNotifySettings.test.ts index 58c6bb48e..bf2ce4343 100644 --- a/www/__tests__/pushNotifySettings.test.ts +++ b/www/__tests__/pushNotifySettings.test.ts @@ -5,7 +5,12 @@ import { storageSet } from '../js/plugin/storage'; import { initPushNotify } from '../js/splash/pushNotifySettings'; import { mockCordova, mockBEMUserCache, mockBEMDataCollection } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; -import { clearNotifMock, getOnList, mockPushNotification, getCalled } from '../__mocks__/pushNotificationMocks'; +import { + clearNotifMock, + getOnList, + mockPushNotification, + getCalled, +} from '../__mocks__/pushNotificationMocks'; mockCordova(); mockLogger(); @@ -13,14 +18,23 @@ mockPushNotification(); mockBEMUserCache(); mockBEMDataCollection(); -global.fetch = (url: string) => new Promise((rs, rj) => { - setTimeout(() => rs({ - json: () => new Promise((rs, rj) => { - let myJSON = { "emSensorDataCollectionProtocol": { "protocol_id": "2014-04-6267", "approval_date": "2016-07-14" }, }; - setTimeout(() => rs(myJSON), 100); - }) - })); -}) as any; +global.fetch = (url: string) => + new Promise((rs, rj) => { + setTimeout(() => + rs({ + json: () => + new Promise((rs, rj) => { + let myJSON = { + emSensorDataCollectionProtocol: { + protocol_id: '2014-04-6267', + approval_date: '2016-07-14', + }, + }; + setTimeout(() => rs(myJSON), 100); + }), + }), + ); + }) as any; afterEach(() => { clearNotifMock(); @@ -28,42 +42,48 @@ afterEach(() => { it('intro done does nothing if not registered', () => { expect(getOnList()).toStrictEqual({}); - publish(EVENT_NAMES.INTRO_DONE_EVENT, "test data"); + publish(EVENT_NAMES.INTRO_DONE_EVENT, 'test data'); expect(getOnList()).toStrictEqual({}); -}) +}); it('intro done initializes the push notifications', () => { expect(getOnList()).toStrictEqual({}); initPushNotify(); - publish(EVENT_NAMES.INTRO_DONE_EVENT, "test data"); - expect(getOnList()).toStrictEqual(expect.objectContaining({ - notification: expect.any(Function), - error: expect.any(Function), - registration: expect.any(Function) - })); -}) + publish(EVENT_NAMES.INTRO_DONE_EVENT, 'test data'); + expect(getOnList()).toStrictEqual( + expect.objectContaining({ + notification: expect.any(Function), + error: expect.any(Function), + registration: expect.any(Function), + }), + ); +}); it('cloud event does nothing if not registered', () => { expect(window['cordova'].platformId).toEqual('ios'); - publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, {additionalData: {'content-available': 1, 'payload' : {'notId' : 3}}}); + publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, { + additionalData: { 'content-available': 1, payload: { notId: 3 } }, + }); expect(getCalled()).toBeNull(); -}) +}); it('cloud event handles notification if registered', async () => { expect(window['cordova'].platformId).toEqual('ios'); initPushNotify(); - publish(EVENT_NAMES.INTRO_DONE_EVENT, "intro done"); - publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, {additionalData: {'content-available': 1, 'payload' : {'notId' : 3}}}); + publish(EVENT_NAMES.INTRO_DONE_EVENT, 'intro done'); + publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, { + additionalData: { 'content-available': 1, payload: { notId: 3 } }, + }); await new Promise((r) => setTimeout(r, 1000)); expect(getCalled()).toEqual(3); -}) +}); it('consent event does nothing if not registered', () => { expect(getOnList()).toStrictEqual({}); - publish(EVENT_NAMES.CONSENTED_EVENT, "test data"); + publish(EVENT_NAMES.CONSENTED_EVENT, 'test data'); expect(getOnList()).toStrictEqual({}); -}) +}); it('consent event registers if intro done', async () => { //make sure the mock is clear @@ -79,19 +99,21 @@ it('consent event registers if intro done', async () => { expect(introDone).toBeTruthy(); //publish consent event and check results - publish(EVENT_NAMES.CONSENTED_EVENT, "test data"); + publish(EVENT_NAMES.CONSENTED_EVENT, 'test data'); //have to wait a beat since event response is async await new Promise((r) => setTimeout(r, 1000)); - expect(getOnList()).toStrictEqual(expect.objectContaining({ - notification: expect.any(Function), - error: expect.any(Function), - registration: expect.any(Function) - })); -}) + expect(getOnList()).toStrictEqual( + expect.objectContaining({ + notification: expect.any(Function), + error: expect.any(Function), + registration: expect.any(Function), + }), + ); +}); it('consent event does not register if intro not done', () => { expect(getOnList()).toStrictEqual({}); initPushNotify(); - publish(EVENT_NAMES.CONSENTED_EVENT, "test data"); + publish(EVENT_NAMES.CONSENTED_EVENT, 'test data'); expect(getOnList()).toStrictEqual({}); //nothing, intro not done -}) \ No newline at end of file +}); diff --git a/www/__tests__/storeDeviceSettings.test.ts b/www/__tests__/storeDeviceSettings.test.ts index ceaf93bda..9026e7621 100644 --- a/www/__tests__/storeDeviceSettings.test.ts +++ b/www/__tests__/storeDeviceSettings.test.ts @@ -1,9 +1,14 @@ - import { readConsentState, markConsented } from '../js/splash/startprefs'; import { storageClear } from '../js/plugin/storage'; import { getUser } from '../js/commHelper'; import { initStoreDeviceSettings, teardownDeviceSettings } from '../js/splash/storeDeviceSettings'; -import { mockBEMServerCom, mockBEMUserCache, mockCordova, mockDevice, mockGetAppVersion } from '../__mocks__/cordovaMocks'; +import { + mockBEMServerCom, + mockBEMUserCache, + mockCordova, + mockDevice, + mockGetAppVersion, +} from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; import { EVENT_NAMES, publish, unsubscribe } from '../js/customEventHandler'; @@ -14,14 +19,23 @@ mockLogger(); mockGetAppVersion(); mockBEMServerCom(); -global.fetch = (url: string) => new Promise((rs, rj) => { - setTimeout(() => rs({ - json: () => new Promise((rs, rj) => { - let myJSON = { "emSensorDataCollectionProtocol": { "protocol_id": "2014-04-6267", "approval_date": "2016-07-14" } }; - setTimeout(() => rs(myJSON), 100); - }) - })); -}) as any; +global.fetch = (url: string) => + new Promise((rs, rj) => { + setTimeout(() => + rs({ + json: () => + new Promise((rs, rj) => { + let myJSON = { + emSensorDataCollectionProtocol: { + protocol_id: '2014-04-6267', + approval_date: '2016-07-14', + }, + }; + setTimeout(() => rs(myJSON), 100); + }), + }), + ); + }) as any; afterEach(async () => { await storageClear({ local: true, native: true }); @@ -37,29 +51,27 @@ it('stores device settings when intialized after consent', async () => { await new Promise((r) => setTimeout(r, 500)); let user = await getUser(); expect(user).toMatchObject({ - client_os_version: '14.0.0', - client_app_version: '1.2.3' - }) + client_os_version: '14.0.0', + client_app_version: '1.2.3', + }); }); it('does not store if not subscribed', async () => { - publish(EVENT_NAMES.INTRO_DONE_EVENT, "test data"); - publish(EVENT_NAMES.CONSENTED_EVENT, "test data"); + publish(EVENT_NAMES.INTRO_DONE_EVENT, 'test data'); + publish(EVENT_NAMES.CONSENTED_EVENT, 'test data'); await new Promise((r) => setTimeout(r, 500)); //time to carry out event handling let user = await getUser(); expect(user).toBeUndefined(); }); - it('stores device settings after intro done', async () => { initStoreDeviceSettings(); await new Promise((r) => setTimeout(r, 500)); //time to check consent and subscribe - publish(EVENT_NAMES.INTRO_DONE_EVENT, "test data"); + publish(EVENT_NAMES.INTRO_DONE_EVENT, 'test data'); await new Promise((r) => setTimeout(r, 500)); //time to carry out event handling let user = await getUser(); expect(user).toMatchObject({ - client_os_version: '14.0.0', - client_app_version: '1.2.3' - }) + client_os_version: '14.0.0', + client_app_version: '1.2.3', + }); }); - diff --git a/www/js/customEventHandler.ts b/www/js/customEventHandler.ts index f6606cf8f..600847223 100644 --- a/www/js/customEventHandler.ts +++ b/www/js/customEventHandler.ts @@ -3,11 +3,11 @@ * having the ability to broadcast and emit events prevents files from being tightly coupled * if we want something else to happen when an event is emitted, we can just listen for it * instead of having to change the code at the point the event is emitted - * + * * looser coupling = point of broadcast doesn't 'know' what is triggered by that event * leads to more extensible code * consistent event names help us know what happens when - * + * * code based on: https://blog.logrocket.com/using-custom-events-react/ */ @@ -18,27 +18,27 @@ import { logDebug } from './plugin/logger'; */ export const EVENT_NAMES = { CLOUD_NOTIFICATION_EVENT: 'cloud:push:notification', - CONSENTED_EVENT: "data_collection_consented", - INTRO_DONE_EVENT: "intro_done", -} + CONSENTED_EVENT: 'data_collection_consented', + INTRO_DONE_EVENT: 'intro_done', +}; /** * @function starts listening to an event - * @param eventName {string} the name of the event + * @param eventName {string} the name of the event * @param listener event listener, function to execute on event */ export function subscribe(eventName: string, listener) { - logDebug("adding " + eventName + " listener"); + logDebug('adding ' + eventName + ' listener'); document.addEventListener(eventName, listener); } /** * @function stops listening to an event - * @param eventName {string} the name of the event + * @param eventName {string} the name of the event * @param listener event listener, function to execute on event */ -export function unsubscribe(eventName: string, listener){ - logDebug("removing " + eventName + " listener"); +export function unsubscribe(eventName: string, listener) { + logDebug('removing ' + eventName + ' listener'); document.removeEventListener(eventName, listener); } @@ -49,7 +49,7 @@ export function unsubscribe(eventName: string, listener){ * @param data any additional data to be added to event */ export function publish(eventName: string, data) { - logDebug("publishing " + eventName + " with data " + JSON.stringify(data)); + logDebug('publishing ' + eventName + ' with data ' + JSON.stringify(data)); const event = new CustomEvent(eventName, { detail: data }); document.dispatchEvent(event); -} \ No newline at end of file +} diff --git a/www/js/splash/pushNotifySettings.ts b/www/js/splash/pushNotifySettings.ts index b252508db..eefcbcdf8 100644 --- a/www/js/splash/pushNotifySettings.ts +++ b/www/js/splash/pushNotifySettings.ts @@ -28,91 +28,101 @@ let push = null; */ const startupInit = function () { push = window['PushNotification'].init({ - "ios": { - "badge": true, - "sound": true, - "vibration": true, - "clearBadge": true + ios: { + badge: true, + sound: true, + vibration: true, + clearBadge: true, + }, + android: { + iconColor: '#008acf', + icon: 'ic_mood_question', + clearNotifications: true, }, - "android": { - "iconColor": "#008acf", - "icon": "ic_mood_question", - "clearNotifications": true - } }); push.on('notification', function (data) { if (window['cordova'].platformId == 'ios') { // Parse the iOS values that are returned as strings - if (angular.isDefined(data) && - angular.isDefined(data.additionalData)) { + if (angular.isDefined(data) && angular.isDefined(data.additionalData)) { if (angular.isDefined(data.additionalData.payload)) { data.additionalData.payload = JSON.parse(data.additionalData.payload); } - if (angular.isDefined(data.additionalData.data) && typeof (data.additionalData.data) == "string") { + if ( + angular.isDefined(data.additionalData.data) && + typeof data.additionalData.data == 'string' + ) { data.additionalData.data = JSON.parse(data.additionalData.data); } else { - console.log("additionalData is already an object, no need to parse it"); + console.log('additionalData is already an object, no need to parse it'); } } else { - logDebug("No additional data defined, nothing to parse"); + logDebug('No additional data defined, nothing to parse'); } } publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, data); }); -} +}; /** * @function registers notifications and handles result - * @returns Promise for initialization logic, - * resolves on registration with token + * @returns Promise for initialization logic, + * resolves on registration with token * rejects on error with error */ const registerPromise = function () { return new Promise(function (resolve, reject) { startupInit(); - push.on("registration", function (data) { - console.log("Got registration " + data); + push.on('registration', function (data) { + console.log('Got registration ' + data); resolve({ token: data.registrationId, - type: data.registrationType + type: data.registrationType, }); }); - push.on("error", function (error) { - console.log("Got push error " + error); + push.on('error', function (error) { + console.log('Got push error ' + error); reject(error); }); - console.log("push notify = " + push); + console.log('push notify = ' + push); }); -} +}; /** * @function registers for notifications and updates user * currently called on reconsent and on intro done */ const registerPush = function () { - registerPromise().then(function (t) { - // alert("Token = "+JSON.stringify(t)); - logDebug("Token = " + JSON.stringify(t)); - return window['cordova'].plugins.BEMServerSync.getConfig().then(function (config) { - return config.sync_interval; - }, function (error) { - console.log("Got error " + error + " while reading config, returning default = 3600"); - return 3600; - }).then(function (sync_interval) { - updateUser({ - device_token: t['token'], - curr_platform: window['cordova'].platformId, - curr_sync_interval: sync_interval - }); - return t; + registerPromise() + .then(function (t) { + // alert("Token = "+JSON.stringify(t)); + logDebug('Token = ' + JSON.stringify(t)); + return window['cordova'].plugins.BEMServerSync.getConfig() + .then( + function (config) { + return config.sync_interval; + }, + function (error) { + console.log('Got error ' + error + ' while reading config, returning default = 3600'); + return 3600; + }, + ) + .then(function (sync_interval) { + updateUser({ + device_token: t['token'], + curr_platform: window['cordova'].platformId, + curr_sync_interval: sync_interval, + }); + return t; + }); + }) + .then(function (t) { + // alert("Finished saving token = "+JSON.stringify(t.token)); + logDebug('Finished saving token = ' + JSON.stringify(t.token)); + }) + .catch(function (error) { + displayError(error, 'Error in registering push notifications'); }); - }).then(function (t) { - // alert("Finished saving token = "+JSON.stringify(t.token)); - logDebug("Finished saving token = " + JSON.stringify(t.token)); - }).catch(function (error) { - displayError(error, "Error in registering push notifications"); - }); -} +}; /** * @function handles silent push notifications @@ -121,36 +131,36 @@ const registerPush = function () { * @returns early if platform is not ios */ const redirectSilentPush = function (event, data) { - logDebug("Found silent push notification, for platform " + window['cordova'].platformId); + logDebug('Found silent push notification, for platform ' + window['cordova'].platformId); if (window['cordova'].platformId != 'ios') { - logDebug("Platform is not ios, handleSilentPush is not implemented or needed"); + logDebug('Platform is not ios, handleSilentPush is not implemented or needed'); // doesn't matter if we finish or not because platforms other than ios don't care return; } - logDebug("Platform is ios, calling handleSilentPush on DataCollection"); + logDebug('Platform is ios, calling handleSilentPush on DataCollection'); var notId = data.additionalData.payload.notId; var finishErrFn = function (error) { - logDebug("in push.finish, error = " + error); + logDebug('in push.finish, error = ' + error); }; - window['cordova'].plugins.BEMDataCollection.getConfig().then(function (config) { - if (config.ios_use_remote_push_for_sync) { - window['cordova'].plugins.BEMDataCollection.handleSilentPush() - .then(function () { - logDebug("silent push finished successfully, calling push.finish"); - showDebugLocalNotification("silent push finished, calling push.finish"); - push.finish(function () { }, finishErrFn, notId); - }) - } else { - logDebug("Using background fetch for sync, no need to redirect push"); - push.finish(function () { }, finishErrFn, notId); - }; - }) + window['cordova'].plugins.BEMDataCollection.getConfig() + .then(function (config) { + if (config.ios_use_remote_push_for_sync) { + window['cordova'].plugins.BEMDataCollection.handleSilentPush().then(function () { + logDebug('silent push finished successfully, calling push.finish'); + showDebugLocalNotification('silent push finished, calling push.finish'); + push.finish(function () {}, finishErrFn, notId); + }); + } else { + logDebug('Using background fetch for sync, no need to redirect push'); + push.finish(function () {}, finishErrFn, notId); + } + }) .catch(function (error) { - push.finish(function () { }, finishErrFn, notId); - displayError(error, "Error while redirecting silent push"); + push.finish(function () {}, finishErrFn, notId); + displayError(error, 'Error while redirecting silent push'); }); -} +}; /** * @function shows debug notifications if simulating user interaction @@ -161,14 +171,14 @@ var showDebugLocalNotification = function (message) { if (config.simulate_user_interaction) { window['cordova'].plugins.notification.local.schedule({ id: 1, - title: "Debug javascript notification", + title: 'Debug javascript notification', text: message, actions: [], - category: 'SIGN_IN_TO_CLASS' + category: 'SIGN_IN_TO_CLASS', }); } }); -} +}; /** * @function handles pushNotification intitially @@ -176,11 +186,11 @@ var showDebugLocalNotification = function (message) { * @param data from the notification */ const onCloudEvent = function (event, data) { - logDebug("data = " + JSON.stringify(data)); - if (data.additionalData["content-available"] == 1) { + logDebug('data = ' + JSON.stringify(data)); + if (data.additionalData['content-available'] == 1) { redirectSilentPush(event, data); - }; // else no need to call finish -} + } // else no need to call finish +}; /** * @function registers push on reconsent @@ -188,16 +198,16 @@ const onCloudEvent = function (event, data) { * @param data data from the conesnt event */ const onConsentEvent = function (event, data) { - console.log("got consented event " + JSON.stringify(event['name']) - + " with data " + JSON.stringify(data)); - readIntroDone() - .then((isIntroDone) => { - if (isIntroDone) { - console.log("intro is done -> reconsent situation, we already have a token -> register"); - registerPush(); - } - }); -} + console.log( + 'got consented event ' + JSON.stringify(event['name']) + ' with data ' + JSON.stringify(data), + ); + readIntroDone().then((isIntroDone) => { + if (isIntroDone) { + console.log('intro is done -> reconsent situation, we already have a token -> register'); + registerPush(); + } + }); +}; /** * @function registers push after intro received @@ -205,12 +215,14 @@ const onConsentEvent = function (event, data) { * @param data from the event */ const onIntroEvent = function (event, data) { - console.log("intro is done -> original consent situation, we should have a token by now -> register"); + console.log( + 'intro is done -> original consent situation, we should have a token by now -> register', + ); registerPush(); -} +}; /** - * startup code - + * startup code - * @function registers push if consented, subscribes event listeners for local handline */ export const initPushNotify = function () { @@ -218,10 +230,10 @@ export const initPushNotify = function () { .then(isConsented) .then(function (consentState) { if (consentState == true) { - logDebug("already consented, signing up for remote push"); + logDebug('already consented, signing up for remote push'); registerPush(); } else { - logDebug("no consent yet, waiting to sign up for remote push"); + logDebug('no consent yet, waiting to sign up for remote push'); } }); @@ -229,5 +241,5 @@ export const initPushNotify = function () { subscribe(EVENT_NAMES.CONSENTED_EVENT, (event) => onConsentEvent(event, event.detail)); subscribe(EVENT_NAMES.INTRO_DONE_EVENT, (event) => onIntroEvent(event, event.detail)); - logDebug("pushnotify startup done"); -} + logDebug('pushnotify startup done'); +}; diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index 22ff4acaa..7c75bf3b3 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -92,15 +92,15 @@ export function readConsentState() { */ //used in ProfileSettings export function getConsentDocument() { - return window['cordova'].plugins.BEMUserCache.getDocument('config/consent', false).then(function ( - resultDoc, - ) { - if (window['cordova'].plugins.BEMUserCache.isEmptyDoc(resultDoc)) { - return null; - } else { - return resultDoc; - } - }); + return window['cordova'].plugins.BEMUserCache.getDocument('config/consent', false).then( + function (resultDoc) { + if (window['cordova'].plugins.BEMUserCache.isEmptyDoc(resultDoc)) { + return null; + } else { + return resultDoc; + } + }, + ); } /** diff --git a/www/js/splash/storeDeviceSettings.ts b/www/js/splash/storeDeviceSettings.ts index 2c86ace49..f54ce8d53 100644 --- a/www/js/splash/storeDeviceSettings.ts +++ b/www/js/splash/storeDeviceSettings.ts @@ -1,6 +1,5 @@ - import { updateUser } from '../commHelper'; -import { isConsented, readConsentState } from "./startprefs"; +import { isConsented, readConsentState } from './startprefs'; import i18next from 'i18next'; import { displayError, logDebug } from '../plugin/logger'; import { readIntroDone } from '../onboarding/onboardingHelper'; @@ -14,39 +13,45 @@ const storeDeviceSettings = function () { var lang = i18next.resolvedLanguage; var manufacturer = window['device'].manufacturer; var osver = window['device'].version; - return window['cordova'].getAppVersion.getVersionNumber().then(function (appver) { - var updateJSON = { - phone_lang: lang, - curr_platform: window['cordova'].platformId, - manufacturer: manufacturer, - client_os_version: osver, - client_app_version: appver - }; - logDebug("About to update profile with settings = " + JSON.stringify(updateJSON)); - return updateUser(updateJSON); - }).then(function (updateJSON) { - // alert("Finished saving token = "+JSON.stringify(t.token)); - }).catch(function (error) { - displayError(error, "Error in updating profile to store device settings"); - }); -} + return window['cordova'].getAppVersion + .getVersionNumber() + .then(function (appver) { + var updateJSON = { + phone_lang: lang, + curr_platform: window['cordova'].platformId, + manufacturer: manufacturer, + client_os_version: osver, + client_app_version: appver, + }; + logDebug('About to update profile with settings = ' + JSON.stringify(updateJSON)); + return updateUser(updateJSON); + }) + .then(function (updateJSON) { + // alert("Finished saving token = "+JSON.stringify(t.token)); + }) + .catch(function (error) { + displayError(error, 'Error in updating profile to store device settings'); + }); +}; /** * @function stores device settings on reconsent * @param event that called this function * @param data data from the conesnt event */ - const onConsentEvent = function (event, data) { - console.log("got consented event " + JSON.stringify(event['name']) - + " with data " + JSON.stringify(data)); - readIntroDone() - .then(async (isIntroDone) => { - if (isIntroDone) { - logDebug("intro is done -> reconsent situation, we already have a token -> store device settings"); - await storeDeviceSettings(); - } - }); -} +const onConsentEvent = function (event, data) { + console.log( + 'got consented event ' + JSON.stringify(event['name']) + ' with data ' + JSON.stringify(data), + ); + readIntroDone().then(async (isIntroDone) => { + if (isIntroDone) { + logDebug( + 'intro is done -> reconsent situation, we already have a token -> store device settings', + ); + await storeDeviceSettings(); + } + }); +}; /** * @function stores device settings after intro received @@ -54,9 +59,11 @@ const storeDeviceSettings = function () { * @param data from the event */ const onIntroEvent = async function (event, data) { - logDebug("intro is done -> original consent situation, we should have a token by now -> store device settings"); + logDebug( + 'intro is done -> original consent situation, we should have a token by now -> store device settings', + ); await storeDeviceSettings(); -} +}; /** * @function initializes store device: subscribes to events @@ -66,19 +73,19 @@ export const initStoreDeviceSettings = function () { readConsentState() .then(isConsented) .then(async function (consentState) { - console.log("found consent", consentState); + console.log('found consent', consentState); if (consentState == true) { await storeDeviceSettings(); } else { - logDebug("no consent yet, waiting to store device settings in profile"); + logDebug('no consent yet, waiting to store device settings in profile'); } subscribe(EVENT_NAMES.CONSENTED_EVENT, (event) => onConsentEvent(event, event.detail)); subscribe(EVENT_NAMES.INTRO_DONE_EVENT, (event) => onIntroEvent(event, event.detail)); }); - logDebug("storedevicesettings startup done"); -} + logDebug('storedevicesettings startup done'); +}; -export const teardownDeviceSettings = function() { +export const teardownDeviceSettings = function () { unsubscribe(EVENT_NAMES.CONSENTED_EVENT, (event) => onConsentEvent(event, event.detail)); unsubscribe(EVENT_NAMES.INTRO_DONE_EVENT, (event) => onIntroEvent(event, event.detail)); -} +}; From 39986a35f3430077b3ddf3aa3455cf9af49c9777 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Thu, 2 Nov 2023 17:02:23 -0600 Subject: [PATCH 299/850] Temporary any type, handled purely in notifScheduler getReminderPrefs In order to not mess with commHelper.ts as it is outside of the scope of this issue - related to https://github.com/e-mission/e-mission-phone/pull/1092#discussion_r1379114750 --- www/js/commHelper.ts | 2 +- www/js/splash/notifScheduler.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/www/js/commHelper.ts b/www/js/commHelper.ts index e39bed841..5f144888b 100644 --- a/www/js/commHelper.ts +++ b/www/js/commHelper.ts @@ -196,7 +196,7 @@ export function updateUser(updateDoc) { }); } -export function getUser(): any { +export function getUser() { return new Promise((rs, rj) => { window['cordova'].plugins.BEMServerComm.getUserPersonalData('/profile/get', rs, rj); }).catch((error) => { diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts index cd3385ff8..6b054b7af 100644 --- a/www/js/splash/notifScheduler.ts +++ b/www/js/splash/notifScheduler.ts @@ -231,7 +231,7 @@ const initReminderPrefs = (reminderSchemes) => { // } const getReminderPrefs = async (reminderSchemes): Promise => { - const user = await getUser(); + const user = (await getUser()) as any; if (user?.reminder_assignment && user?.reminder_join_date && user?.reminder_time_of_day) { console.log('User already has reminder prefs, returning them', user); return user; From 58539cad0c34ab8568bc0b3d5002be0ee334a071 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Thu, 2 Nov 2023 17:06:18 -0600 Subject: [PATCH 300/850] Undoing imports getting duplicated during prettier changes During the prettier changes, a bunch of the imports got merged. Undoing in this commit: https://github.com/e-mission/e-mission-phone/pull/1092/commits/1bfb113605f161c16dcdf28b73233b18c76f3cb0 --- www/js/control/ProfileSettings.jsx | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index b57111f51..b081e642a 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -33,20 +33,6 @@ import { storageClear } from '../plugin/storage'; import { getAppVersion } from '../plugin/clientStats'; import { getConsentDocument } from '../splash/startprefs'; import { logDebug } from '../plugin/logger'; -import {uploadFile} from "./uploadService"; -import ActionMenu from "../components/ActionMenu"; -import SensedPage from "./SensedPage" -import LogPage from "./LogPage"; -import ControlSyncHelper, {ForceSyncRow, getHelperSyncSettings} from "./ControlSyncHelper"; -import ControlCollectionHelper, {getHelperCollectionSettings, getState, isMediumAccuracy, helperToggleLowAccuracy, forceTransition} from "./ControlCollectionHelper"; -import { resetDataAndRefresh } from "../config/dynamicConfig"; -import { AppContext } from "../App"; -import { shareQR } from "../components/QrCode"; -import { storageClear } from "../plugin/storage"; -import { getAppVersion } from "../plugin/clientStats"; -import { useSchedulerHelper } from "../splash/notifScheduler"; -import { getConsentDocument } from "../splash/startprefs"; -import { logDebug } from "../plugin/logger"; //any pure functions can go outside const ProfileSettings = () => { From 384ffe8e6d053575bf3c90a766c0da630ebcb95b Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Thu, 2 Nov 2023 17:08:47 -0600 Subject: [PATCH 301/850] Replace console.log with logger.ts's displayErrorMsg Per https://github.com/e-mission/e-mission-phone/pull/1092#discussion_r1379143510 --- www/js/splash/notifScheduler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts index 6b054b7af..020a864d7 100644 --- a/www/js/splash/notifScheduler.ts +++ b/www/js/splash/notifScheduler.ts @@ -4,7 +4,7 @@ import { getConfig } from '../config/dynamicConfig'; import useAppConfig from '../useAppConfig'; import { addStatReading, statKeys } from '../plugin/clientStats'; import { getUser, updateUser } from '../commHelper'; -import { logDebug } from '../plugin/logger'; +import { displayErrorMsg, logDebug } from '../plugin/logger'; import { DateTime } from 'luxon'; import i18next from 'i18next'; @@ -171,7 +171,7 @@ const update = async (reminderSchemes) => { try { scheme = reminderSchemes[reminder_assignment]; } catch (e) { - console.log( + displayErrorMsg( 'ERROR: Could not find reminder scheme for assignment ' + reminderSchemes + ' - ' + From 428f191b0274b8ae530fae4335063cad68e98e05 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 2 Nov 2023 16:17:45 -0700 Subject: [PATCH 302/850] Fixed merge conflict artifact --- www/js/services.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/www/js/services.js b/www/js/services.js index dfd0ac778..e1bf1d1c5 100644 --- a/www/js/services.js +++ b/www/js/services.js @@ -119,12 +119,6 @@ angular }, ); return combinedPromise(localPromise, remotePromise, combineWithDedup); -<<<<<<< HEAD - } -}) -.factory('Chats', function() { - // Might use a resource here that returns a JSON array -======= }; this.getUnifiedMessagesForInterval = function (key, tq, withMetadata) { From 1f952f75626fd1d75687614c0aa37d880c2b93f9 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 3 Nov 2023 02:33:17 -0400 Subject: [PATCH 303/850] remove 'locales' submodule This was committed by mistake in 51108457028fa6c15006b50eb88eec8f98e8139d --- locales | 1 - 1 file changed, 1 deletion(-) delete mode 160000 locales diff --git a/locales b/locales deleted file mode 160000 index 7a62b7866..000000000 --- a/locales +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7a62b7866e549fad40217f2229f80e500bc61494 From 1b0169208dccdf913ad2f2bee0aa72a89aa09f8d Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 3 Nov 2023 02:47:21 -0400 Subject: [PATCH 304/850] fix syntax errors and apply prettier Prettier was not running on these files because they had syntax errors from bad merge conflicts. Fixes syntax on these and runs Prettier to clean up --- www/js/diary/cards/TripCard.tsx | 41 ++++--- www/js/diary/details/LabelDetailsScreen.tsx | 76 +++++++----- www/js/diary/services.js | 53 +++++---- .../multilabel/MultiLabelButtonGroup.tsx | 112 +++++++++++------- 4 files changed, 171 insertions(+), 111 deletions(-) diff --git a/www/js/diary/cards/TripCard.tsx b/www/js/diary/cards/TripCard.tsx index 7bcda9c36..5c598f886 100644 --- a/www/js/diary/cards/TripCard.tsx +++ b/www/js/diary/cards/TripCard.tsx @@ -7,22 +7,22 @@ import React, { useContext } from 'react'; import { View, useWindowDimensions, StyleSheet } from 'react-native'; import { Text, IconButton } from 'react-native-paper'; -import LeafletView from "../../components/LeafletView"; -import { useTranslation } from "react-i18next"; -import MultilabelButtonGroup from "../../survey/multilabel/MultiLabelButtonGroup"; -import UserInputButton from "../../survey/enketo/UserInputButton"; -import useAppConfig from "../../useAppConfig"; -import AddNoteButton from "../../survey/enketo/AddNoteButton"; -import AddedNotesList from "../../survey/enketo/AddedNotesList"; -import { getTheme } from "../../appTheme"; -import { DiaryCard, cardStyles } from "./DiaryCard"; -import { useNavigation } from "@react-navigation/native"; -import { useAddressNames } from "../addressNamesHelper"; +import LeafletView from '../../components/LeafletView'; +import { useTranslation } from 'react-i18next'; +import MultilabelButtonGroup from '../../survey/multilabel/MultiLabelButtonGroup'; +import UserInputButton from '../../survey/enketo/UserInputButton'; +import useAppConfig from '../../useAppConfig'; +import AddNoteButton from '../../survey/enketo/AddNoteButton'; +import AddedNotesList from '../../survey/enketo/AddedNotesList'; +import { getTheme } from '../../appTheme'; +import { DiaryCard, cardStyles } from './DiaryCard'; +import { useNavigation } from '@react-navigation/native'; +import { useAddressNames } from '../addressNamesHelper'; import LabelTabContext from '../LabelTabContext'; -import useDerivedProperties from "../useDerivedProperties"; -import StartEndLocations from "../components/StartEndLocations"; -import ModesIndicator from "./ModesIndicator"; -import { useGeojsonForTrip } from "../timelineHelper"; +import useDerivedProperties from '../useDerivedProperties'; +import StartEndLocations from '../components/StartEndLocations'; +import ModesIndicator from './ModesIndicator'; +import { useGeojsonForTrip } from '../timelineHelper'; type Props = { trip: { [key: string]: any } }; const TripCard = ({ trip }: Props) => { @@ -41,7 +41,11 @@ const TripCard = ({ trip }: Props) => { let [tripStartDisplayName, tripEndDisplayName] = useAddressNames(trip); const navigation = useNavigation(); const { labelOptions, timelineLabelMap, timelineNotesMap } = useContext(LabelTabContext); - const tripGeojson = useGeojsonForTrip(trip, labelOptions, timelineLabelMap[trip._id.$oid]?.MODE?.value); + const tripGeojson = useGeojsonForTrip( + trip, + labelOptions, + timelineLabelMap[trip._id.$oid]?.MODE?.value, + ); const isDraft = trip.key.includes('UNPROCESSED'); const flavoredTheme = getTheme(isDraft ? 'draft' : undefined); @@ -98,7 +102,8 @@ const TripCard = ({ trip }: Props) => { displayEndName={tripEndDisplayName} /> - {/* mode and purpose buttons / survey button */} + + {/* mode and purpose buttons / survey button */} {appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL' && ( )} @@ -128,7 +133,7 @@ const TripCard = ({ trip }: Props) => { )} - {timelineNotesMap[trip._id.$oid]?.length && + {timelineNotesMap[trip._id.$oid]?.length && ( diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx index 0ad9852d0..89ff822d4 100644 --- a/www/js/diary/details/LabelDetailsScreen.tsx +++ b/www/js/diary/details/LabelDetailsScreen.tsx @@ -2,26 +2,33 @@ listed sections of the trip, and a graph of speed during the trip. Navigated to from the main LabelListScreen by clicking a trip card. */ -import React, { useContext, useState } from "react"; -import { View, Modal, ScrollView, useWindowDimensions } from "react-native"; -import { PaperProvider, Appbar, SegmentedButtons, Button, Surface, Text, useTheme } from "react-native-paper"; +import React, { useContext, useState } from 'react'; +import { View, Modal, ScrollView, useWindowDimensions } from 'react-native'; +import { + PaperProvider, + Appbar, + SegmentedButtons, + Button, + Surface, + Text, + useTheme, +} from 'react-native-paper'; import LabelTabContext from '../LabelTabContext'; -import LeafletView from "../../components/LeafletView"; -import { useTranslation } from "react-i18next"; -import MultilabelButtonGroup from "../../survey/multilabel/MultiLabelButtonGroup"; -import UserInputButton from "../../survey/enketo/UserInputButton"; -import { useAddressNames } from "../addressNamesHelper"; -import { SafeAreaView } from "react-native-safe-area-context"; -import useDerivedProperties from "../useDerivedProperties"; -import StartEndLocations from "../components/StartEndLocations"; -import { useGeojsonForTrip } from "../timelineHelper"; -import TripSectionsDescriptives from "./TripSectionsDescriptives"; -import OverallTripDescriptives from "./OverallTripDescriptives"; -import ToggleSwitch from "../../components/ToggleSwitch"; -import useAppConfig from "../../useAppConfig"; +import LeafletView from '../../components/LeafletView'; +import { useTranslation } from 'react-i18next'; +import MultilabelButtonGroup from '../../survey/multilabel/MultiLabelButtonGroup'; +import UserInputButton from '../../survey/enketo/UserInputButton'; +import { useAddressNames } from '../addressNamesHelper'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import useDerivedProperties from '../useDerivedProperties'; +import StartEndLocations from '../components/StartEndLocations'; +import { useGeojsonForTrip } from '../timelineHelper'; +import TripSectionsDescriptives from './TripSectionsDescriptives'; +import OverallTripDescriptives from './OverallTripDescriptives'; +import ToggleSwitch from '../../components/ToggleSwitch'; +import useAppConfig from '../../useAppConfig'; const LabelScreenDetails = ({ route, navigation }) => { - const { timelineMap, labelOptions, timelineLabelMap } = useContext(LabelTabContext); const { t } = useTranslation(); const { height: windowHeight } = useWindowDimensions(); @@ -32,9 +39,13 @@ const LabelScreenDetails = ({ route, navigation }) => { const { displayDate, displayStartTime, displayEndTime } = useDerivedProperties(trip); const [tripStartDisplayName, tripEndDisplayName] = useAddressNames(trip); - const [ modesShown, setModesShown ] = useState<'labeled'|'detected'>('labeled'); - const tripGeojson = useGeojsonForTrip(trip, labelOptions, modesShown=='labeled' && timelineLabelMap[trip._id.$oid]?.MODE?.value); - const mapOpts = {minZoom: 3, maxZoom: 17}; + const [modesShown, setModesShown] = useState<'labeled' | 'detected'>('labeled'); + const tripGeojson = useGeojsonForTrip( + trip, + labelOptions, + modesShown == 'labeled' && timelineLabelMap[trip._id.$oid]?.MODE?.value, + ); + const mapOpts = { minZoom: 3, maxZoom: 17 }; const modal = ( @@ -82,13 +93,24 @@ const LabelScreenDetails = ({ route, navigation }) => { {/* If trip is labeled, show a toggle to switch between "Labeled Mode" and "Detected Modes" otherwise, just show "Detected" */} - {timelineLabelMap[trip._id.$oid]?.MODE?.value ? - setModesShown(v)} value={modesShown} density='medium' - buttons={[{label: t('diary.labeled-mode'), value: 'labeled'}, {label: t('diary.detected-modes'), value: 'detected'}]} /> - : - )} diff --git a/www/js/diary/services.js b/www/js/diary/services.js index 197125b52..a1b238ef8 100644 --- a/www/js/diary/services.js +++ b/www/js/diary/services.js @@ -4,30 +4,35 @@ import angular from 'angular'; import { getConfig } from '../config/dynamicConfig'; import { getRawEntries } from '../commHelper'; -angular.module('emission.main.diary.services', ['emission.plugin.logger', - 'emission.services']) -.factory('Timeline', function($http, $ionicLoading, $ionicPlatform, $window, - $rootScope, UnifiedDataLoader, Logger, $injector) { - var timeline = {}; - // corresponds to the old $scope.data. Contains all state for the current - // day, including the indication of the current day - timeline.data = {}; - timeline.data.unifiedConfirmsResults = null; - timeline.UPDATE_DONE = "TIMELINE_UPDATE_DONE"; - - // DB entries retrieved from the server have '_id', 'metadata', and 'data' fields. - // This function returns a shallow copy of the obj, which flattens the - // 'data' field into the top level, while also including '_id' and 'metadata.key' - const unpack = (obj) => ({ - ...obj.data, - _id: obj._id, - key: obj.metadata.key, - origin_key: obj.metadata.origin_key || obj.metadata.key, - }); - - timeline.readAllCompositeTrips = function(startTs, endTs) { - $ionicLoading.show({ - template: i18next.t('service.reading-server') +angular + .module('emission.main.diary.services', ['emission.plugin.logger', 'emission.services']) + .factory( + 'Timeline', + function ( + $http, + $ionicLoading, + $ionicPlatform, + $window, + $rootScope, + UnifiedDataLoader, + Logger, + $injector, + ) { + var timeline = {}; + // corresponds to the old $scope.data. Contains all state for the current + // day, including the indication of the current day + timeline.data = {}; + timeline.data.unifiedConfirmsResults = null; + timeline.UPDATE_DONE = 'TIMELINE_UPDATE_DONE'; + + // DB entries retrieved from the server have '_id', 'metadata', and 'data' fields. + // This function returns a shallow copy of the obj, which flattens the + // 'data' field into the top level, while also including '_id' and 'metadata.key' + const unpack = (obj) => ({ + ...obj.data, + _id: obj._id, + key: obj.metadata.key, + origin_key: obj.metadata.origin_key || obj.metadata.key, }); timeline.readAllCompositeTrips = function (startTs, endTs) { diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index 548ce6caa..797961843 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -2,16 +2,32 @@ In the default configuration, these are the "Mode" and "Purpose" buttons. Next to the buttons is a small checkmark icon, which marks inferrel labels as confirmed */ -import React, { useContext, useEffect, useState, useMemo } from "react"; -import { getAngularService } from "../../angular-react-helper"; -import { View, Modal, ScrollView, Pressable, useWindowDimensions } from "react-native"; -import { IconButton, Text, Dialog, useTheme, RadioButton, Button, TextInput } from "react-native-paper"; -import DiaryButton from "../../components/DiaryButton"; -import { useTranslation } from "react-i18next"; -import LabelTabContext from "../../diary/LabelTabContext"; -import { displayErrorMsg, logDebug } from "../../plugin/logger"; -import { getLabelInputDetails, getLabelInputs, inferFinalLabels, labelInputDetailsForTrip, labelKeyToRichMode, readableLabelToKey, verifiabilityForTrip } from "./confirmHelper"; -import useAppConfig from "../../useAppConfig"; +import React, { useContext, useEffect, useState, useMemo } from 'react'; +import { getAngularService } from '../../angular-react-helper'; +import { View, Modal, ScrollView, Pressable, useWindowDimensions } from 'react-native'; +import { + IconButton, + Text, + Dialog, + useTheme, + RadioButton, + Button, + TextInput, +} from 'react-native-paper'; +import DiaryButton from '../../components/DiaryButton'; +import { useTranslation } from 'react-i18next'; +import LabelTabContext from '../../diary/LabelTabContext'; +import { displayErrorMsg, logDebug } from '../../plugin/logger'; +import { + getLabelInputDetails, + getLabelInputs, + inferFinalLabels, + labelInputDetailsForTrip, + labelKeyToRichMode, + readableLabelToKey, + verifiabilityForTrip, +} from './confirmHelper'; +import useAppConfig from '../../useAppConfig'; const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { const { colors } = useTheme(); @@ -78,40 +94,52 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { } const tripInputDetails = labelInputDetailsForTrip(timelineLabelMap[trip._id.$oid], appConfig); - return (<> - - - {Object.keys(tripInputDetails).map((key, i) => { - const input = tripInputDetails[key]; - const inputIsConfirmed = timelineLabelMap[trip._id.$oid]?.[input.name]; - const inputIsInferred = inferFinalLabels(trip, timelineLabelMap[trip._id.$oid])[input.name]; - let fillColor, textColor, borderColor; - if (inputIsConfirmed) { - fillColor = colors.primary; - } else if (inputIsInferred) { - fillColor = colors.secondaryContainer; - borderColor = colors.secondary; - textColor = colors.onSecondaryContainer; - } - const btnText = inputIsConfirmed?.text || inputIsInferred?.text || input.choosetext; + return ( + <> + + + {Object.keys(tripInputDetails).map((key, i) => { + const input = tripInputDetails[key]; + const inputIsConfirmed = timelineLabelMap[trip._id.$oid]?.[input.name]; + const inputIsInferred = inferFinalLabels(trip, timelineLabelMap[trip._id.$oid])[ + input.name + ]; + let fillColor, textColor, borderColor; + if (inputIsConfirmed) { + fillColor = colors.primary; + } else if (inputIsInferred) { + fillColor = colors.secondaryContainer; + borderColor = colors.secondary; + textColor = colors.onSecondaryContainer; + } + const btnText = inputIsConfirmed?.text || inputIsInferred?.text || input.choosetext; - return ( - - {t(input.labeltext)} - setModalVisibleFor(input.name)}> - { t(btnText) } - - - ) - })} - - {verifiabilityForTrip(trip, timelineLabelMap[trip._id.$oid]) == 'can-verify' && ( - - + return ( + + {t(input.labeltext)} + setModalVisibleFor(input.name)}> + {t(btnText)} + + + ); + })} + {verifiabilityForTrip(trip, timelineLabelMap[trip._id.$oid]) == 'can-verify' && ( + + + + )} {trip.verifiability === 'can-verify' && ( Date: Fri, 3 Nov 2023 10:58:47 -0600 Subject: [PATCH 305/850] update tests so they pass I found two problems to fix in my testing - first was that I was not properly removing the event listeners and learned more: https://macarthur.me/posts/options-for-removing-event-listeners Second was that I was missing a mock, which meant some of the storage operations were failing due to that function not exisiting --- www/__tests__/storeDeviceSettings.test.ts | 22 +++++++++++++++++----- www/js/splash/storeDeviceSettings.ts | 19 ++++++++++--------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/www/__tests__/storeDeviceSettings.test.ts b/www/__tests__/storeDeviceSettings.test.ts index 9026e7621..1676f6a86 100644 --- a/www/__tests__/storeDeviceSettings.test.ts +++ b/www/__tests__/storeDeviceSettings.test.ts @@ -3,6 +3,7 @@ import { storageClear } from '../js/plugin/storage'; import { getUser } from '../js/commHelper'; import { initStoreDeviceSettings, teardownDeviceSettings } from '../js/splash/storeDeviceSettings'; import { + mockBEMDataCollection, mockBEMServerCom, mockBEMUserCache, mockCordova, @@ -10,7 +11,7 @@ import { mockGetAppVersion, } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; -import { EVENT_NAMES, publish, unsubscribe } from '../js/customEventHandler'; +import { EVENT_NAMES, publish } from '../js/customEventHandler'; mockBEMUserCache(); mockDevice(); @@ -18,6 +19,7 @@ mockCordova(); mockLogger(); mockGetAppVersion(); mockBEMServerCom(); +mockBEMDataCollection(); global.fetch = (url: string) => new Promise((rs, rj) => { @@ -37,15 +39,16 @@ global.fetch = (url: string) => ); }) as any; -afterEach(async () => { - await storageClear({ local: true, native: true }); +beforeEach(async () => { teardownDeviceSettings(); + await storageClear({ local: true, native: true }); + let user = await getUser(); + expect(user).toBeUndefined(); }); it('stores device settings when intialized after consent', async () => { - await storageClear({ local: true, native: true }); await readConsentState(); - await markConsented(); + let marked = await markConsented(); await new Promise((r) => setTimeout(r, 500)); initStoreDeviceSettings(); await new Promise((r) => setTimeout(r, 500)); @@ -56,6 +59,15 @@ it('stores device settings when intialized after consent', async () => { }); }); +it('verifies my subscrition clearing', async () => { + initStoreDeviceSettings(); + await new Promise((r) => setTimeout(r, 500)); + teardownDeviceSettings(); + publish(EVENT_NAMES.INTRO_DONE_EVENT, 'test data'); + let user = await getUser(); + expect(user).toBeUndefined(); +}); + it('does not store if not subscribed', async () => { publish(EVENT_NAMES.INTRO_DONE_EVENT, 'test data'); publish(EVENT_NAMES.CONSENTED_EVENT, 'test data'); diff --git a/www/js/splash/storeDeviceSettings.ts b/www/js/splash/storeDeviceSettings.ts index f54ce8d53..d9604b3b5 100644 --- a/www/js/splash/storeDeviceSettings.ts +++ b/www/js/splash/storeDeviceSettings.ts @@ -37,11 +37,13 @@ const storeDeviceSettings = function () { /** * @function stores device settings on reconsent * @param event that called this function - * @param data data from the conesnt event */ -const onConsentEvent = function (event, data) { +const onConsentEvent = (event) => { console.log( - 'got consented event ' + JSON.stringify(event['name']) + ' with data ' + JSON.stringify(data), + 'got consented event ' + + JSON.stringify(event['name']) + + ' with data ' + + JSON.stringify(event.detail), ); readIntroDone().then(async (isIntroDone) => { if (isIntroDone) { @@ -56,9 +58,8 @@ const onConsentEvent = function (event, data) { /** * @function stores device settings after intro received * @param event that called this function - * @param data from the event */ -const onIntroEvent = async function (event, data) { +const onIntroEvent = async (event) => { logDebug( 'intro is done -> original consent situation, we should have a token by now -> store device settings', ); @@ -79,13 +80,13 @@ export const initStoreDeviceSettings = function () { } else { logDebug('no consent yet, waiting to store device settings in profile'); } - subscribe(EVENT_NAMES.CONSENTED_EVENT, (event) => onConsentEvent(event, event.detail)); - subscribe(EVENT_NAMES.INTRO_DONE_EVENT, (event) => onIntroEvent(event, event.detail)); + subscribe(EVENT_NAMES.CONSENTED_EVENT, onConsentEvent); + subscribe(EVENT_NAMES.INTRO_DONE_EVENT, onIntroEvent); }); logDebug('storedevicesettings startup done'); }; export const teardownDeviceSettings = function () { - unsubscribe(EVENT_NAMES.CONSENTED_EVENT, (event) => onConsentEvent(event, event.detail)); - unsubscribe(EVENT_NAMES.INTRO_DONE_EVENT, (event) => onIntroEvent(event, event.detail)); + unsubscribe(EVENT_NAMES.CONSENTED_EVENT, onConsentEvent); + unsubscribe(EVENT_NAMES.INTRO_DONE_EVENT, onIntroEvent); }; From 1aceb94ec79e6c0cd824a1fce83508f1496402b2 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 3 Nov 2023 11:11:06 -0600 Subject: [PATCH 306/850] complete tests now testing for settings stored on initialization, on consent, and on intro done also testing for not done if not consented on initialization, and if intro not done on consent --- www/__tests__/storeDeviceSettings.test.ts | 31 +++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/www/__tests__/storeDeviceSettings.test.ts b/www/__tests__/storeDeviceSettings.test.ts index 1676f6a86..ae046216a 100644 --- a/www/__tests__/storeDeviceSettings.test.ts +++ b/www/__tests__/storeDeviceSettings.test.ts @@ -12,6 +12,7 @@ import { } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; import { EVENT_NAMES, publish } from '../js/customEventHandler'; +import { markIntroDone } from '../js/onboarding/onboardingHelper'; mockBEMUserCache(); mockDevice(); @@ -59,6 +60,13 @@ it('stores device settings when intialized after consent', async () => { }); }); +it('does not stores device settings when intialized before consent', async () => { + initStoreDeviceSettings(); + await new Promise((r) => setTimeout(r, 500)); + let user = await getUser(); + expect(user).toBeUndefined(); +}); + it('verifies my subscrition clearing', async () => { initStoreDeviceSettings(); await new Promise((r) => setTimeout(r, 500)); @@ -87,3 +95,26 @@ it('stores device settings after intro done', async () => { client_app_version: '1.2.3', }); }); + +it('stores device settings after consent if intro done', async () => { + initStoreDeviceSettings(); + await new Promise((r) => setTimeout(r, 500)); //time to check consent and subscribe + markIntroDone(); + await new Promise((r) => setTimeout(r, 500)); + publish(EVENT_NAMES.CONSENTED_EVENT, 'test data'); + await new Promise((r) => setTimeout(r, 500)); //time to carry out event handling + let user = await getUser(); + expect(user).toMatchObject({ + client_os_version: '14.0.0', + client_app_version: '1.2.3', + }); +}); + +it('does not store device settings after consent if intro not done', async () => { + initStoreDeviceSettings(); + await new Promise((r) => setTimeout(r, 500)); //time to check consent and subscribe + publish(EVENT_NAMES.CONSENTED_EVENT, 'test data'); + await new Promise((r) => setTimeout(r, 500)); //time to carry out event handling + let user = await getUser(); + expect(user).toBeUndefined(); +}); From 9581ca2e6abc71ac59c422c36ad5d33a8c0cef8e Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 3 Nov 2023 11:44:02 -0600 Subject: [PATCH 307/850] remove unused mock code this is leftover code and I never used it in testing, removing --- www/__mocks__/pushNotificationMocks.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/www/__mocks__/pushNotificationMocks.ts b/www/__mocks__/pushNotificationMocks.ts index 0e98fa064..53a81e16c 100644 --- a/www/__mocks__/pushNotificationMocks.ts +++ b/www/__mocks__/pushNotificationMocks.ts @@ -31,8 +31,3 @@ export const getOnList = function () { export const getCalled = function () { return called; }; - -export const fakeEvent = function (eventName: string) { - //fake the event by executing whatever we have stored for it - onList[eventName](); -}; From 9aa89d588ec6832369ff8d487dd5e95d53fd7b8a Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 3 Nov 2023 11:44:46 -0600 Subject: [PATCH 308/850] rename remotenotify file hoping to preserve some commit history here remotenotify uses the cloud notif event, so rewritting alongside the rest of the event-driven code --- www/js/splash/{remotenotify.js => remoteNotifyHandler.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename www/js/splash/{remotenotify.js => remoteNotifyHandler.ts} (100%) diff --git a/www/js/splash/remotenotify.js b/www/js/splash/remoteNotifyHandler.ts similarity index 100% rename from www/js/splash/remotenotify.js rename to www/js/splash/remoteNotifyHandler.ts From 16c87f0dd789e55e2d03f482cfefe1884effa2fe Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 3 Nov 2023 12:00:29 -0600 Subject: [PATCH 309/850] typescript migration migrate the file into typescript, including adding the event handling remove references (all unused) add init call with others in App.tsx --- www/index.js | 1 - www/js/App.tsx | 2 + www/js/controllers.js | 8 +- www/js/splash/remoteNotifyHandler.ts | 120 ++++++++++++--------------- 4 files changed, 60 insertions(+), 71 deletions(-) diff --git a/www/index.js b/www/index.js index ca1bbcfbb..3aa5be4ed 100644 --- a/www/index.js +++ b/www/index.js @@ -6,7 +6,6 @@ import 'leaflet/dist/leaflet.css'; import './js/ngApp.js'; import './js/splash/referral.js'; import './js/splash/localnotify.js'; -import './js/splash/remotenotify.js'; import './js/splash/notifScheduler.js'; import './js/controllers.js'; import './js/services.js'; diff --git a/www/js/App.tsx b/www/js/App.tsx index dbbef406c..e6b09b3ee 100644 --- a/www/js/App.tsx +++ b/www/js/App.tsx @@ -17,6 +17,7 @@ import AppStatusModal from './control/AppStatusModal'; import usePermissionStatus from './usePermissionStatus'; import { initPushNotify } from './splash/pushNotifySettings'; import { initStoreDeviceSettings } from './splash/storeDeviceSettings'; +import { initRemoteNotifyHandler } from './splash/remoteNotifyHandler'; const defaultRoutes = (t) => [ { @@ -74,6 +75,7 @@ const App = () => { }); initPushNotify(); initStoreDeviceSettings(); + initRemoteNotifyHandler(); }, [appConfig]); const appContextValue = { diff --git a/www/js/controllers.js b/www/js/controllers.js index e502dda2e..ee29af103 100644 --- a/www/js/controllers.js +++ b/www/js/controllers.js @@ -5,18 +5,16 @@ import { addStatError, addStatReading, statKeys } from './plugin/clientStats'; import { getPendingOnboardingState } from './onboarding/onboardingHelper'; angular - .module('emission.controllers', ['emission.splash.localnotify', 'emission.splash.remotenotify']) + .module('emission.controllers', ['emission.splash.localnotify']) .controller('RootCtrl', function ($scope) {}) .controller('DashCtrl', function ($scope) {}) - .controller( - 'SplashCtrl', - function ($scope, $state, $interval, $rootScope, LocalNotify, RemoteNotify) { - console.log('SplashCtrl invoked'); // alert("attach debugger!"); // PushNotify.startupInit(); + .controller('SplashCtrl', function ($scope, $state, $interval, $rootScope, LocalNotify) { + console.log('SplashCtrl invoked'); $rootScope.$on( '$stateChangeSuccess', diff --git a/www/js/splash/remoteNotifyHandler.ts b/www/js/splash/remoteNotifyHandler.ts index a59fdf376..0be6ca201 100644 --- a/www/js/splash/remoteNotifyHandler.ts +++ b/www/js/splash/remoteNotifyHandler.ts @@ -16,76 +16,66 @@ 'use strict'; import angular from 'angular'; +import { EVENT_NAMES, subscribe } from '../customEventHandler'; import { addStatEvent, statKeys } from '../plugin/clientStats'; +import { displayErrorMsg, logDebug } from '../plugin/logger'; -angular - .module('emission.splash.remotenotify', ['emission.plugin.logger']) +const options = 'location=yes,clearcache=no,toolbar=yes,hideurlbar=yes'; - .factory('RemoteNotify', function ($http, $window, $ionicPopup, $rootScope, Logger) { - var remoteNotify = {}; - remoteNotify.options = 'location=yes,clearcache=no,toolbar=yes,hideurlbar=yes'; - - /* +/* TODO: Potentially unify with the survey URL loading */ - remoteNotify.launchWebpage = function (url) { - // THIS LINE FOR inAppBrowser - let iab = $window.cordova.InAppBrowser.open(url, '_blank', remoteNotify.options); - }; +const launchWebpage = function (url) { + // THIS LINE FOR inAppBrowser + let iab = window['cordova'].InAppBrowser.open(url, '_blank', options); +}; - remoteNotify.launchPopup = function (title, text) { - // THIS LINE FOR inAppBrowser - let alertPopup = $ionicPopup.alert({ - title: title, - template: text, - }); - }; +const launchPopup = function (title, text) { + // THIS LINE FOR inAppBrowser + displayErrorMsg(text, title); +}; - remoteNotify.init = function () { - $rootScope.$on('cloud:push:notification', function (event, data) { - addStatEvent(statKeys.NOTIFICATION_OPEN).then(() => { - console.log( - 'Added ' + statKeys.NOTIFICATION_OPEN + ' event. Data = ' + JSON.stringify(data), - ); - }); - Logger.log('data = ' + JSON.stringify(data)); - if ( - angular.isDefined(data.additionalData) && - angular.isDefined(data.additionalData.payload) && - angular.isDefined(data.additionalData.payload.alert_type) - ) { - if (data.additionalData.payload.alert_type == 'website') { - var webpage_spec = data.additionalData.payload.spec; - if ( - angular.isDefined(webpage_spec) && - angular.isDefined(webpage_spec.url) && - webpage_spec.url.startsWith('https://') - ) { - remoteNotify.launchWebpage(webpage_spec.url); - } else { - $ionicPopup.alert( - 'webpage was not specified correctly. spec is ' + JSON.stringify(webpage_spec), - ); - } - } - if (data.additionalData.payload.alert_type == 'popup') { - var popup_spec = data.additionalData.payload.spec; - if ( - angular.isDefined(popup_spec) && - angular.isDefined(popup_spec.title) && - angular.isDefined(popup_spec.text) - ) { - remoteNotify.launchPopup(popup_spec.title, popup_spec.text); - } else { - $ionicPopup.alert( - 'webpage was not specified correctly. spec is ' + JSON.stringify(popup_spec), - ); - } - } - } - }); - }; - - remoteNotify.init(); - return remoteNotify; +const onCloudNotifEvent = (event) => { + const data = event.detail; + addStatEvent(statKeys.NOTIFICATION_OPEN).then(() => { + console.log('Added ' + statKeys.NOTIFICATION_OPEN + ' event. Data = ' + JSON.stringify(data)); }); + logDebug('data = ' + JSON.stringify(data)); + if ( + angular.isDefined(data.additionalData) && + angular.isDefined(data.additionalData.payload) && + angular.isDefined(data.additionalData.payload.alert_type) + ) { + if (data.additionalData.payload.alert_type == 'website') { + var webpage_spec = data.additionalData.payload.spec; + if ( + angular.isDefined(webpage_spec) && + angular.isDefined(webpage_spec.url) && + webpage_spec.url.startsWith('https://') + ) { + launchWebpage(webpage_spec.url); + } else { + displayErrorMsg( + JSON.stringify(webpage_spec), + 'webpage was not specified correctly. spec is ', + ); + } + } + if (data.additionalData.payload.alert_type == 'popup') { + var popup_spec = data.additionalData.payload.spec; + if ( + angular.isDefined(popup_spec) && + angular.isDefined(popup_spec.title) && + angular.isDefined(popup_spec.text) + ) { + launchPopup(popup_spec.title, popup_spec.text); + } else { + displayErrorMsg(JSON.stringify(popup_spec), 'popup was not specified correctly. spec is '); + } + } + } +}; + +export const initRemoteNotifyHandler = function () { + subscribe(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, onCloudNotifEvent); +}; From 68e0701dad82dcc796cbadc7d7b5b0321f1a9843 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 3 Nov 2023 12:03:38 -0600 Subject: [PATCH 310/850] formatting changes to controller.js prettier wanted to make a bunch of changes to code that I didn't edit, so putting it in a dedicated commit --- www/js/controllers.js | 111 +++++++++++++++++++++--------------------- 1 file changed, 55 insertions(+), 56 deletions(-) diff --git a/www/js/controllers.js b/www/js/controllers.js index ee29af103..16f962c8d 100644 --- a/www/js/controllers.js +++ b/www/js/controllers.js @@ -11,67 +11,66 @@ angular .controller('DashCtrl', function ($scope) {}) - // alert("attach debugger!"); - // PushNotify.startupInit(); + // alert("attach debugger!"); + // PushNotify.startupInit(); .controller('SplashCtrl', function ($scope, $state, $interval, $rootScope, LocalNotify) { console.log('SplashCtrl invoked'); - $rootScope.$on( - '$stateChangeSuccess', - function (event, toState, toParams, fromState, fromParams) { - console.log( - 'Finished changing state from ' + - JSON.stringify(fromState) + - ' to ' + - JSON.stringify(toState), - ); - addStatReading(statKeys.STATE_CHANGED, fromState.name + '-2-' + toState.name); - }, - ); - $rootScope.$on( - '$stateChangeError', - function (event, toState, toParams, fromState, fromParams, error) { - console.log( - 'Error ' + - error + - ' while changing state from ' + - JSON.stringify(fromState) + - ' to ' + - JSON.stringify(toState), - ); - addStatError(statKeys.STATE_CHANGED, fromState.name + '-2-' + toState.name + '_' + error); - }, - ); - $rootScope.$on('$stateNotFound', function (event, unfoundState, fromState, fromParams) { - console.log('unfoundState.to = ' + unfoundState.to); // "lazy.state" - console.log('unfoundState.toParams = ' + unfoundState.toParams); // {a:1, b:2} - console.log('unfoundState.options = ' + unfoundState.options); // {inherit:false} + default options - addStatError(statKeys.STATE_CHANGED, fromState.name + '-2-' + unfoundState.name); - }); + $rootScope.$on( + '$stateChangeSuccess', + function (event, toState, toParams, fromState, fromParams) { + console.log( + 'Finished changing state from ' + + JSON.stringify(fromState) + + ' to ' + + JSON.stringify(toState), + ); + addStatReading(statKeys.STATE_CHANGED, fromState.name + '-2-' + toState.name); + }, + ); + $rootScope.$on( + '$stateChangeError', + function (event, toState, toParams, fromState, fromParams, error) { + console.log( + 'Error ' + + error + + ' while changing state from ' + + JSON.stringify(fromState) + + ' to ' + + JSON.stringify(toState), + ); + addStatError(statKeys.STATE_CHANGED, fromState.name + '-2-' + toState.name + '_' + error); + }, + ); + $rootScope.$on('$stateNotFound', function (event, unfoundState, fromState, fromParams) { + console.log('unfoundState.to = ' + unfoundState.to); // "lazy.state" + console.log('unfoundState.toParams = ' + unfoundState.toParams); // {a:1, b:2} + console.log('unfoundState.options = ' + unfoundState.options); // {inherit:false} + default options + addStatError(statKeys.STATE_CHANGED, fromState.name + '-2-' + unfoundState.name); + }); - var isInList = function (element, list) { - return list.indexOf(element) != -1; - }; + var isInList = function (element, list) { + return list.indexOf(element) != -1; + }; - $rootScope.$on( - '$stateChangeStart', - function (event, toState, toParams, fromState, fromParams, options) { - var personalTabs = ['root.main.common.map', 'root.main.control', 'root.main.metrics']; - if (isInList(toState.name, personalTabs)) { - // toState is in the personalTabs list - getPendingOnboardingState().then(function (result) { - if (result != null) { - event.preventDefault(); - $state.go(result); - } - // else, will do default behavior, which is to go to the tab - }); - } - }, - ); - console.log('SplashCtrl invoke finished'); - }, - ) + $rootScope.$on( + '$stateChangeStart', + function (event, toState, toParams, fromState, fromParams, options) { + var personalTabs = ['root.main.common.map', 'root.main.control', 'root.main.metrics']; + if (isInList(toState.name, personalTabs)) { + // toState is in the personalTabs list + getPendingOnboardingState().then(function (result) { + if (result != null) { + event.preventDefault(); + $state.go(result); + } + // else, will do default behavior, which is to go to the tab + }); + } + }, + ); + console.log('SplashCtrl invoke finished'); + }) .controller('ChatsCtrl', function ($scope, Chats) { // With the new view caching in Ionic, Controllers are only called From 84064bb79d7830f870275d9a5668668f11006080 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 3 Nov 2023 12:10:10 -0600 Subject: [PATCH 311/850] remove angular.isDefined calls since our eventual goal is to remove angular, removing these calls since it will evaluate false if undefined anyways --- www/js/splash/pushNotifySettings.ts | 10 +++------- www/js/splash/remoteNotifyHandler.ts | 19 +++++-------------- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/www/js/splash/pushNotifySettings.ts b/www/js/splash/pushNotifySettings.ts index eefcbcdf8..38f3a4931 100644 --- a/www/js/splash/pushNotifySettings.ts +++ b/www/js/splash/pushNotifySettings.ts @@ -13,7 +13,6 @@ * notification handling gets more complex, we should consider decoupling it as well. */ -import angular from 'angular'; import { updateUser } from '../commHelper'; import { logDebug, displayError } from '../plugin/logger'; import { publish, subscribe, EVENT_NAMES } from '../customEventHandler'; @@ -43,14 +42,11 @@ const startupInit = function () { push.on('notification', function (data) { if (window['cordova'].platformId == 'ios') { // Parse the iOS values that are returned as strings - if (angular.isDefined(data) && angular.isDefined(data.additionalData)) { - if (angular.isDefined(data.additionalData.payload)) { + if (data && data.additionalData) { + if (data.additionalData.payload) { data.additionalData.payload = JSON.parse(data.additionalData.payload); } - if ( - angular.isDefined(data.additionalData.data) && - typeof data.additionalData.data == 'string' - ) { + if (data.additionalData.data && typeof data.additionalData.data == 'string') { data.additionalData.data = JSON.parse(data.additionalData.data); } else { console.log('additionalData is already an object, no need to parse it'); diff --git a/www/js/splash/remoteNotifyHandler.ts b/www/js/splash/remoteNotifyHandler.ts index 0be6ca201..8252d4169 100644 --- a/www/js/splash/remoteNotifyHandler.ts +++ b/www/js/splash/remoteNotifyHandler.ts @@ -15,7 +15,6 @@ */ 'use strict'; -import angular from 'angular'; import { EVENT_NAMES, subscribe } from '../customEventHandler'; import { addStatEvent, statKeys } from '../plugin/clientStats'; import { displayErrorMsg, logDebug } from '../plugin/logger'; @@ -42,17 +41,13 @@ const onCloudNotifEvent = (event) => { }); logDebug('data = ' + JSON.stringify(data)); if ( - angular.isDefined(data.additionalData) && - angular.isDefined(data.additionalData.payload) && - angular.isDefined(data.additionalData.payload.alert_type) + data.additionalData && + data.additionalData.payload && + data.additionalData.payload.alert_type ) { if (data.additionalData.payload.alert_type == 'website') { var webpage_spec = data.additionalData.payload.spec; - if ( - angular.isDefined(webpage_spec) && - angular.isDefined(webpage_spec.url) && - webpage_spec.url.startsWith('https://') - ) { + if (webpage_spec && webpage_spec.url && webpage_spec.url.startsWith('https://')) { launchWebpage(webpage_spec.url); } else { displayErrorMsg( @@ -63,11 +58,7 @@ const onCloudNotifEvent = (event) => { } if (data.additionalData.payload.alert_type == 'popup') { var popup_spec = data.additionalData.payload.spec; - if ( - angular.isDefined(popup_spec) && - angular.isDefined(popup_spec.title) && - angular.isDefined(popup_spec.text) - ) { + if (popup_spec && popup_spec.title && popup_spec.text) { launchPopup(popup_spec.title, popup_spec.text); } else { displayErrorMsg(JSON.stringify(popup_spec), 'popup was not specified correctly. spec is '); From 24b34ee922323c495d68880dfce79133d82eb34f Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 3 Nov 2023 15:11:41 -0400 Subject: [PATCH 312/850] clean up confirmHelper -add labelOptionByValue function to make it easier to lookup a labelOption by a label value; use it in other functions that need to perform this lookup -type getFakeEntry, return undefined if falsy input -verifiabilityForTrip should only consider 'inferred' true if it has any truthy values; not if all values are undefined -inferFinalLabels: if no usable inferences, return empty object rather than object with undefined values --- www/js/survey/inputMatcher.ts | 13 ++------- www/js/survey/multilabel/confirmHelper.ts | 34 +++++++++++++---------- 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index 0fc7bd89a..8f05f9639 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -6,11 +6,8 @@ import { LabelOption, MultilabelKey, getLabelInputDetails, - getLabelInputs, - getLabelOptions, inputType2retKey, - labelKeyToRichMode, - labelOptions, + labelOptionByValue, } from './multilabel/confirmHelper'; import { TimelineLabelMap, TimelineNotesMap } from '../diary/LabelTabContext'; @@ -307,14 +304,10 @@ export function mapInputsToTimelineEntries( unprocessedLabels[label], ); if (userInputForTrip) { - labelsForTrip[label] = labelOptions[label].find( - (opt: LabelOption) => opt.value == userInputForTrip.data.label, - ); + labelsForTrip[label] = labelOptionByValue(userInputForTrip.data.label, label); } else { const processedLabelValue = tlEntry.user_input?.[inputType2retKey(label)]; - labelsForTrip[label] = labelOptions[label].find( - (opt: LabelOption) => opt.value == processedLabelValue, - ); + labelsForTrip[label] = labelOptionByValue(processedLabelValue, label); } }); if (Object.keys(labelsForTrip).length) { diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index fa339323d..632023313 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -69,6 +69,9 @@ export async function getLabelOptions(appConfigParam?) { return labelOptions; } +export const labelOptionByValue = (value: string, labelType: string): LabelOption | undefined => + labelOptions[labelType]?.find((o) => o.value == value) || getFakeEntry(value); + export const baseLabelInputDetails = { MODE: { name: 'MODE', @@ -144,13 +147,16 @@ export const labelKeyToReadable = (otherValue: string) => { export const readableLabelToKey = (otherText: string) => otherText.trim().replace(/ /g, '_').toLowerCase(); -export const getFakeEntry = (otherValue) => ({ - text: labelKeyToReadable(otherValue), - value: otherValue, -}); +export const getFakeEntry = (otherValue): Partial => { + if (!otherValue) return undefined; + return { + text: labelKeyToReadable(otherValue), + value: otherValue, + }; +}; export const labelKeyToRichMode = (labelKey: string) => - labelOptions?.MODE?.find((m) => m.value == labelKey)?.text || labelKeyToReadable(labelKey); + labelOptionByValue(labelKey, 'MODE')?.text || labelKeyToReadable(labelKey); /* manual/mode_confirm becomes mode_confirm */ export const inputType2retKey = (inputType) => getLabelInputDetails()[inputType].key.split('/')[1]; @@ -160,9 +166,10 @@ export function verifiabilityForTrip(trip, userInputForTrip) { let someInferred = false; const inputsForTrip = Object.keys(labelInputDetailsForTrip(userInputForTrip)); for (const inputType of inputsForTrip) { + const finalInference = inferFinalLabels(trip, userInputForTrip)[inputType]; const confirmed = userInputForTrip[inputType]; - const inferred = inferFinalLabels(trip, userInputForTrip)[inputType] && !confirmed; - if (inferred) someInferred = true; + const inferred = finalInference && Object.values(finalInference).some((o) => o); + if (inferred && !confirmed) someInferred = true; if (!confirmed) allConfirmed = false; } return someInferred ? 'can-verify' : allConfirmed ? 'already-verified' : 'cannot-verify'; @@ -187,13 +194,10 @@ export function inferFinalLabels(trip, userInputForTrip) { } } - const finalInference = {}; + const finalInference: { [k in MultilabelKey]?: LabelOption } = {}; - // Red labels if we have no possibilities left + // Return early with (empty obj) if there are no possibilities left if (labelsList.length == 0) { - for (const inputType of getLabelInputs()) { - finalInference[inputType] = undefined; - } return finalInference; } else { // Normalize probabilities to previous level of certainty @@ -220,9 +224,9 @@ export function inferFinalLabels(trip, userInputForTrip) { // Fails safe if confidence_threshold doesn't exist if (max.p <= trip.confidence_threshold) max.labelValue = undefined; - finalInference[inputType] = labelOptions[inputType].find( - (opt) => opt.value == max.labelValue, - ); + if (max.labelValue) { + finalInference[inputType] = labelOptionByValue(max.labelValue, inputType); + } } return finalInference; } From 55c313d6d4e1b7649c9ab3ac6bc1134265faceff Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 3 Nov 2023 15:11:52 -0400 Subject: [PATCH 313/850] add tests for confirmHelper --- www/__tests__/confirmHelper.test.ts | 170 ++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 www/__tests__/confirmHelper.test.ts diff --git a/www/__tests__/confirmHelper.test.ts b/www/__tests__/confirmHelper.test.ts new file mode 100644 index 000000000..17c23a972 --- /dev/null +++ b/www/__tests__/confirmHelper.test.ts @@ -0,0 +1,170 @@ +import { mockLogger } from '../__mocks__/globalMocks'; +import * as CommHelper from '../js/commHelper'; +import { + baseLabelInputDetails, + getLabelInputDetails, + getLabelOptions, + inferFinalLabels, + labelInputDetailsForTrip, + labelKeyToReadable, + labelKeyToRichMode, + labelOptionByValue, + readableLabelToKey, + verifiabilityForTrip, +} from '../js/survey/multilabel/confirmHelper'; + +import initializedI18next from '../js/i18nextInit'; +window['i18next'] = initializedI18next; +mockLogger(); + +const fakeAppConfig = { + label_options: 'json/label-options.json.sample', +}; +const fakeAppConfigWithModeOfStudy = { + ...fakeAppConfig, + intro: { + mode_studied: 'walk', + }, +}; +const fakeDefaultLabelOptions = { + MODE: [ + { value: 'walk', baseMode: 'WALKING', met_equivalent: 'WALKING', kgCo2PerKm: 0 }, + { value: 'bike', baseMode: 'BICYCLING', met_equivalent: 'BICYCLING', kgCo2PerKm: 0 }, + ], + PURPOSE: [{ value: 'home' }, { value: 'work' }], + REPLACED_MODE: [{ value: 'no_travel' }, { value: 'walk' }, { value: 'bike' }], + translations: { + en: { + walk: 'Walk', + bike: 'Regular Bike', + no_travel: 'No travel', + home: 'Home', + work: 'To Work', + }, + }, +}; + +CommHelper.fetchUrlCached = jest + .fn() + .mockImplementation(() => JSON.stringify(fakeDefaultLabelOptions)); + +describe('confirmHelper', () => { + it('returns labelOptions given an appConfig', async () => { + const labelOptions = await getLabelOptions(fakeAppConfig); + expect(labelOptions).toBeTruthy(); + expect(labelOptions.MODE[0].text).toEqual('Walk'); // translation is filled in + }); + + it('returns base labelInputDetails for a labelUserInput which does not have mode of study', () => { + const fakeLabelUserInput = { + MODE: fakeDefaultLabelOptions.MODE[1], + PURPOSE: fakeDefaultLabelOptions.PURPOSE[0], + }; + const labelInputDetails = labelInputDetailsForTrip( + fakeLabelUserInput, + fakeAppConfigWithModeOfStudy, + ); + expect(labelInputDetails).toEqual(baseLabelInputDetails); + }); + + it('returns full labelInputDetails for a labelUserInput which has the mode of study', () => { + const fakeLabelUserInput = { + MODE: fakeDefaultLabelOptions.MODE[0], // 'walk' is mode of study + PURPOSE: fakeDefaultLabelOptions.PURPOSE[0], + }; + const labelInputDetails = labelInputDetailsForTrip( + fakeLabelUserInput, + fakeAppConfigWithModeOfStudy, + ); + const fullLabelInputDetails = getLabelInputDetails(fakeAppConfigWithModeOfStudy); + expect(labelInputDetails).toEqual(fullLabelInputDetails); + }); + + it(`converts 'other' text to a label key`, () => { + const mode1 = readableLabelToKey(`Scooby Doo Mystery Machine `); + expect(mode1).toEqual('scooby_doo_mystery_machine'); // trailing space is trimmed + const mode2 = readableLabelToKey(`My niece's tricycle . `); + expect(mode2).toEqual(`my_niece's_tricycle_.`); // apostrophe and period are preserved + const purpose1 = readableLabelToKey(`Going to the store to buy 12 eggs.`); + expect(purpose1).toEqual('going_to_the_store_to_buy_12_eggs.'); // numbers are preserved + }); + + it(`converts keys to readable labels`, () => { + const mode1 = labelKeyToReadable(`scooby_doo_mystery_machine`); + expect(mode1).toEqual(`Scooby Doo Mystery Machine`); + const mode2 = labelKeyToReadable(`my_niece's_tricycle_.`); + expect(mode2).toEqual(`My Niece's Tricycle .`); + const purpose1 = labelKeyToReadable(`going_to_the_store_to_buy_12_eggs.`); + expect(purpose1).toEqual(`Going To The Store To Buy 12 Eggs.`); + }); + + it('looks up a rich mode from a label key, or humanizes the label key if there is no rich mode', () => { + const key = 'walk'; + const richMode = labelKeyToRichMode(key); + expect(richMode).toEqual('Walk'); + const key2 = 'scooby_doo_mystery_machine'; + const readableMode = labelKeyToRichMode(key2); + expect(readableMode).toEqual('Scooby Doo Mystery Machine'); + }); + + /* BEGIN: tests for inferences, which are loosely based on the server-side tests from + e-mission-server -> emission/tests/storageTests/TestTripQueries.py -> testExpandFinalLabels() */ + + it('has no final label for a trip with no user labels or inferred labels', () => { + const fakeTrip = {}; + const fakeUserInput = {}; + expect(inferFinalLabels(fakeTrip, fakeUserInput)).toEqual({}); + expect(verifiabilityForTrip(fakeTrip, fakeUserInput)).toEqual('cannot-verify'); + }); + + it('returns a final inference for a trip no user labels and all high-confidence inferred labels', () => { + const fakeTrip = { + inferred_labels: [{ labels: { mode_confirm: 'walk', purpose_confirm: 'exercise' }, p: 0.9 }], + }; + const fakeUserInput = {}; + const final = inferFinalLabels(fakeTrip, fakeUserInput); + expect(final.MODE.value).toEqual('walk'); + expect(final.PURPOSE.value).toEqual('exercise'); + expect(verifiabilityForTrip(fakeTrip, fakeUserInput)).toEqual('can-verify'); + }); + + it('gives no final inference when there are user labels and no inferred labels', () => { + const fakeTrip = {}; + const fakeUserInput = { + MODE: labelOptionByValue('bike', 'MODE'), + PURPOSE: labelOptionByValue('shopping', 'PURPOSE'), + }; + const final = inferFinalLabels(fakeTrip, fakeUserInput); + expect(final.MODE?.value).toBeUndefined(); + expect(final.PURPOSE?.value).toBeUndefined(); + expect(verifiabilityForTrip(fakeTrip, fakeUserInput)).toEqual('already-verified'); + }); + + it('still gives no final inference when there are user labels and high-confidence inferred labels', () => { + const fakeTrip = { + inferred_labels: [{ labels: { mode_confirm: 'walk', purpose_confirm: 'exercise' }, p: 0.9 }], + }; + const fakeUserInput = { + MODE: labelOptionByValue('bike', 'MODE'), + PURPOSE: labelOptionByValue('shopping', 'PURPOSE'), + }; + const final = inferFinalLabels(fakeTrip, fakeUserInput); + expect(final.MODE?.value).toBeUndefined(); + expect(final.PURPOSE?.value).toBeUndefined(); + expect(verifiabilityForTrip(fakeTrip, fakeUserInput)).toEqual('already-verified'); + }); + + it('mixes user input labels with mixed-confidence inferred labels', () => { + const fakeTrip = { + inferred_labels: [ + { labels: { mode_confirm: 'bike', purpose_confirm: 'shopping' }, p: 0.1 }, + { labels: { mode_confirm: 'walk', purpose_confirm: 'exercise' }, p: 0.9 }, + ], + }; + const fakeUserInput = { MODE: labelOptionByValue('bike', 'MODE') }; + const final = inferFinalLabels(fakeTrip, fakeUserInput); + expect(final.MODE.value).toEqual('bike'); + expect(final.PURPOSE.value).toEqual('shopping'); + expect(verifiabilityForTrip(fakeTrip, fakeUserInput)).toEqual('can-verify'); + }); +}); From b58ee5ca1f394e5f278893a8e9e62ee06a9d5d69 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 3 Nov 2023 13:26:36 -0600 Subject: [PATCH 314/850] clean up comments and docstrings --- www/js/splash/remoteNotifyHandler.ts | 30 +++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/www/js/splash/remoteNotifyHandler.ts b/www/js/splash/remoteNotifyHandler.ts index 8252d4169..54a6b32d8 100644 --- a/www/js/splash/remoteNotifyHandler.ts +++ b/www/js/splash/remoteNotifyHandler.ts @@ -1,6 +1,3 @@ -//naming of this module can be confusing "remotenotifyhandler" for rewritten file -//https://github.com/e-mission/e-mission-phone/pull/1072#discussion_r1375360832 - /* * This module deals with handling specific push messages that open web pages * or popups. It does not interface with the push plugin directly. Instead, it @@ -13,8 +10,6 @@ * it only supports redirection to a specific app page. If the local * notification handling gets more complex, we should consider decoupling it as well. */ -'use strict'; - import { EVENT_NAMES, subscribe } from '../customEventHandler'; import { addStatEvent, statKeys } from '../plugin/clientStats'; import { displayErrorMsg, logDebug } from '../plugin/logger'; @@ -22,18 +17,35 @@ import { displayErrorMsg, logDebug } from '../plugin/logger'; const options = 'location=yes,clearcache=no,toolbar=yes,hideurlbar=yes'; /* - TODO: Potentially unify with the survey URL loading - */ +TODO: Potentially unify with the survey URL loading +*/ +/** + * @function launches a webpage + * @param url to open in the browser + */ const launchWebpage = function (url) { // THIS LINE FOR inAppBrowser let iab = window['cordova'].InAppBrowser.open(url, '_blank', options); }; +/* +TODO: replace popup with something with better UI +*/ + +/** + * @function launches popup + * @param title string text for popup title + * @param text string text for popup bode + */ const launchPopup = function (title, text) { // THIS LINE FOR inAppBrowser displayErrorMsg(text, title); }; +/** + * @callback for cloud notification event + * @param event that triggered this call + */ const onCloudNotifEvent = (event) => { const data = event.detail; addStatEvent(statKeys.NOTIFICATION_OPEN).then(() => { @@ -67,6 +79,10 @@ const onCloudNotifEvent = (event) => { } }; +/** + * @function initializes the remote notification handling + * subscribes to cloud notification event + */ export const initRemoteNotifyHandler = function () { subscribe(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, onCloudNotifEvent); }; From 3a5a0f664de320af821b61f9c3037aa227357b7c Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Fri, 3 Nov 2023 13:05:58 -0700 Subject: [PATCH 315/850] Add html files in prettierignore --- .prettierignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.prettierignore b/.prettierignore index be7b1726d..988aead62 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,6 +3,9 @@ www/dist www/manual_lib www/json +# Ignore all HTML files: +**/*.html + # This is the pattern to check only www directory # Ignore all /* From 87de6c208b39e8be27d6055de993d56870ff28d3 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 3 Nov 2023 14:08:20 -0600 Subject: [PATCH 316/850] first two tests function checks for no action if not subscribed and action if subscribed --- www/__tests__/remoteNotifyHandler.test.ts | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 www/__tests__/remoteNotifyHandler.test.ts diff --git a/www/__tests__/remoteNotifyHandler.test.ts b/www/__tests__/remoteNotifyHandler.test.ts new file mode 100644 index 000000000..c6d7feab6 --- /dev/null +++ b/www/__tests__/remoteNotifyHandler.test.ts @@ -0,0 +1,33 @@ +import { EVENT_NAMES, publish } from '../js/customEventHandler'; +import { initRemoteNotifyHandler } from '../js/splash/remoteNotifyHandler'; +import { mockBEMUserCache, mockDevice, mockGetAppVersion } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; + +mockLogger(); +mockDevice(); +mockBEMUserCache(); +mockGetAppVersion(); + +const db = window['cordova']?.plugins?.BEMUserCache; + +it('does not adds a statEvent if not subscribed', async () => { + publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, 'test data'); + const storedMessages = await db.getAllMessages('stats/client_nav_event', false); + expect(storedMessages).toEqual([]); +}); + +it('adds a statEvent if subscribed', async () => { + initRemoteNotifyHandler(); + await new Promise((r) => setTimeout(r, 500)); //wait for subscription + publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, 'test data'); + await new Promise((r) => setTimeout(r, 500)); //wait for event handling + const storedMessages = await db.getAllMessages('stats/client_nav_event', false); + expect(storedMessages).toContainEqual({ + name: 'notification_open', + ts: expect.any(Number), + reading: null, + client_app_version: '1.2.3', + client_os_version: '14.0.0', + }); +}); + From b8cb34a892cbb3e48d6bb29899a5d78214343db0 Mon Sep 17 00:00:00 2001 From: Jijeong Lee Date: Fri, 3 Nov 2023 13:10:39 -0700 Subject: [PATCH 317/850] Revert html file before changing formatting --- www/index.html | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/www/index.html b/www/index.html index b46904cca..72c75eb01 100644 --- a/www/index.html +++ b/www/index.html @@ -1,22 +1,18 @@ - - - + + + - + - + -
    +
    From 0e2448c62f2c782c40179d4310eb03638d29eae3 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Fri, 3 Nov 2023 13:15:45 -0700 Subject: [PATCH 318/850] Minor fix to index.html, changed by prettier - `console.log` issue caused by prettier formatting html incorrectly, should be patched in another PR shortly! --- www/index.html | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/www/index.html b/www/index.html index b46904cca..72c75eb01 100644 --- a/www/index.html +++ b/www/index.html @@ -1,22 +1,18 @@ - - - + + + - + - + -
    +
    From d9285d3b0128363556f008c9389e30fdebc25199 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Fri, 3 Nov 2023 13:35:43 -0700 Subject: [PATCH 319/850] Ran `prettier` on remaining files --- www/js/config/dynamicConfig.ts | 9 +- www/js/control/ControlSyncHelper.tsx | 18 +- www/js/controllers.js | 2 +- www/js/diary/services.js | 2 +- www/js/metrics/MetricsTab.tsx | 38 ++--- www/js/services.js | 197 ++++++++++++---------- www/js/survey/multilabel/confirmHelper.ts | 8 +- 7 files changed, 149 insertions(+), 125 deletions(-) diff --git a/www/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts index 7fa8960a0..eb709c16c 100644 --- a/www/js/config/dynamicConfig.ts +++ b/www/js/config/dynamicConfig.ts @@ -1,8 +1,7 @@ -import i18next from "i18next"; -import { displayError, logDebug, logWarn } from "../plugin/logger"; -import { getAngularService } from "../angular-react-helper"; -import { fetchUrlCached } from "../services/commHelper"; -import { storageClear, storageGet, storageSet } from "../plugin/storage"; +import i18next from 'i18next'; +import { displayError, logDebug, logWarn } from '../plugin/logger'; +import { fetchUrlCached } from '../services/commHelper'; +import { storageClear, storageGet, storageSet } from '../plugin/storage'; export const CONFIG_PHONE_UI = 'config/app_ui_config'; export const CONFIG_PHONE_UI_KVSTORE = 'CONFIG_PHONE_UI'; diff --git a/www/js/control/ControlSyncHelper.tsx b/www/js/control/ControlSyncHelper.tsx index 7124d453d..b26d5e85a 100644 --- a/www/js/control/ControlSyncHelper.tsx +++ b/www/js/control/ControlSyncHelper.tsx @@ -1,15 +1,15 @@ import React, { useEffect, useState } from 'react'; import { Modal, View } from 'react-native'; import { Dialog, Button, Switch, Text, useTheme } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; -import { settingStyles } from "./ProfileSettings"; -import { getAngularService } from "../angular-react-helper"; -import ActionMenu from "../components/ActionMenu"; -import SettingRow from "./SettingRow"; -import AlertBar from "./AlertBar"; -import moment from "moment"; -import { addStatEvent, statKeys } from "../plugin/clientStats"; -import { updateUser } from "../services/commHelper"; +import { useTranslation } from 'react-i18next'; +import { settingStyles } from './ProfileSettings'; +import { getAngularService } from '../angular-react-helper'; +import ActionMenu from '../components/ActionMenu'; +import SettingRow from './SettingRow'; +import AlertBar from './AlertBar'; +import moment from 'moment'; +import { addStatEvent, statKeys } from '../plugin/clientStats'; +import { updateUser } from '../services/commHelper'; /* * BEGIN: Simple read/write wrappers diff --git a/www/js/controllers.js b/www/js/controllers.js index 1f3e64312..17835f3f4 100644 --- a/www/js/controllers.js +++ b/www/js/controllers.js @@ -87,4 +87,4 @@ angular ); console.log('SplashCtrl invoke finished'); }, - ) + ); diff --git a/www/js/diary/services.js b/www/js/diary/services.js index b45f42554..d9ec7fbe3 100644 --- a/www/js/diary/services.js +++ b/www/js/diary/services.js @@ -4,7 +4,7 @@ import angular from 'angular'; import { SurveyOptions } from '../survey/survey'; import { getConfig } from '../config/dynamicConfig'; import { getRawEntries } from '../services/commHelper'; -import { getUnifiedDataForInterval } from '../services/unifiedDataLoader' +import { getUnifiedDataForInterval } from '../services/unifiedDataLoader'; angular .module('emission.main.diary.services', ['emission.plugin.logger', 'emission.services']) diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index d84d3fa23..bbb15a7c7 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -1,22 +1,22 @@ -import React, { useEffect, useState, useMemo } from "react"; -import { getAngularService } from "../angular-react-helper"; -import { View, ScrollView, useWindowDimensions } from "react-native"; -import { Appbar } from "react-native-paper"; -import NavBarButton from "../components/NavBarButton"; -import { useTranslation } from "react-i18next"; -import { DateTime } from "luxon"; -import { MetricsData } from "./metricsTypes"; -import MetricsCard from "./MetricsCard"; -import { formatForDisplay, useImperialConfig } from "../config/useImperialConfig"; -import MetricsDateSelect from "./MetricsDateSelect"; -import WeeklyActiveMinutesCard from "./WeeklyActiveMinutesCard"; -import { secondsToHours, secondsToMinutes } from "./metricsHelper"; -import CarbonFootprintCard from "./CarbonFootprintCard"; -import Carousel from "../components/Carousel"; -import DailyActiveMinutesCard from "./DailyActiveMinutesCard"; -import CarbonTextCard from "./CarbonTextCard"; -import ActiveMinutesTableCard from "./ActiveMinutesTableCard"; -import { getAggregateData, getMetrics } from "../services/commHelper"; +import React, { useEffect, useState, useMemo } from 'react'; +import { getAngularService } from '../angular-react-helper'; +import { View, ScrollView, useWindowDimensions } from 'react-native'; +import { Appbar } from 'react-native-paper'; +import NavBarButton from '../components/NavBarButton'; +import { useTranslation } from 'react-i18next'; +import { DateTime } from 'luxon'; +import { MetricsData } from './metricsTypes'; +import MetricsCard from './MetricsCard'; +import { formatForDisplay, useImperialConfig } from '../config/useImperialConfig'; +import MetricsDateSelect from './MetricsDateSelect'; +import WeeklyActiveMinutesCard from './WeeklyActiveMinutesCard'; +import { secondsToHours, secondsToMinutes } from './metricsHelper'; +import CarbonFootprintCard from './CarbonFootprintCard'; +import Carousel from '../components/Carousel'; +import DailyActiveMinutesCard from './DailyActiveMinutesCard'; +import CarbonTextCard from './CarbonTextCard'; +import ActiveMinutesTableCard from './ActiveMinutesTableCard'; +import { getAggregateData, getMetrics } from '../services/commHelper'; export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; diff --git a/www/js/services.js b/www/js/services.js index ad9f21b46..189115bc2 100644 --- a/www/js/services.js +++ b/www/js/services.js @@ -3,30 +3,39 @@ import angular from 'angular'; import { getRawEntries } from './services/commHelper'; -angular.module('emission.services', ['emission.plugin.logger']) - -.service('ReferHelper', function($http) { +angular + .module('emission.services', ['emission.plugin.logger']) + + .service('ReferHelper', function ($http) { + this.habiticaRegister = function (groupid, successCallback, errorCallback) { + window.cordova.plugins.BEMServerComm.getUserPersonalData( + '/join.group/' + groupid, + successCallback, + errorCallback, + ); + }; + this.joinGroup = function (groupid, userid) { + // TODO: + return new Promise(function (resolve, reject) { + window.cordova.plugins.BEMServerComm.postUserPersonalData( + '/join.group/' + groupid, + 'inviter', + userid, + resolve, + reject, + ); + }); - this.habiticaRegister = function(groupid, successCallback, errorCallback) { - window.cordova.plugins.BEMServerComm.getUserPersonalData("/join.group/"+groupid, successCallback, errorCallback); + //function firstUpperCase(string) { + // return string[0].toUpperCase() + string.slice(1); + //}*/ }; - this.joinGroup = function(groupid, userid) { - - // TODO: - return new Promise(function(resolve, reject) { - window.cordova.plugins.BEMServerComm.postUserPersonalData("/join.group/"+groupid, "inviter", userid, resolve, reject); - }) - - //function firstUpperCase(string) { - // return string[0].toUpperCase() + string.slice(1); - //}*/ - } -}) -.service('UnifiedDataLoader', function($window, Logger) { - var combineWithDedup = function(list1, list2) { + }) + .service('UnifiedDataLoader', function ($window, Logger) { + var combineWithDedup = function (list1, list2) { var combinedList = list1.concat(list2); - return combinedList.filter(function(value, i, array) { - var firstIndexOfValue = array.findIndex(function(element, index, array) { + return combinedList.filter(function (value, i, array) { + var firstIndexOfValue = array.findIndex(function (element, index, array) { return element.metadata.write_ts == value.metadata.write_ts; }); return firstIndexOfValue == i; @@ -34,79 +43,96 @@ angular.module('emission.services', ['emission.plugin.logger']) }; // TODO: generalize to iterable of promises - var combinedPromise = function(localPromise, remotePromise, combiner) { - return new Promise(function(resolve, reject) { - var localResult = []; - var localError = null; - - var remoteResult = []; - var remoteError = null; - - var localPromiseDone = false; - var remotePromiseDone = false; - - var checkAndResolve = function() { - if (localPromiseDone && remotePromiseDone) { - // time to return from this promise - if (localError && remoteError) { - reject([localError, remoteError]); - } else { - Logger.log("About to dedup localResult = "+localResult.length - +"remoteResult = "+remoteResult.length); - var dedupedList = combiner(localResult, remoteResult); - Logger.log("Deduped list = "+dedupedList.length); - resolve(dedupedList); - } + var combinedPromise = function (localPromise, remotePromise, combiner) { + return new Promise(function (resolve, reject) { + var localResult = []; + var localError = null; + + var remoteResult = []; + var remoteError = null; + + var localPromiseDone = false; + var remotePromiseDone = false; + + var checkAndResolve = function () { + if (localPromiseDone && remotePromiseDone) { + // time to return from this promise + if (localError && remoteError) { + reject([localError, remoteError]); + } else { + Logger.log( + 'About to dedup localResult = ' + + localResult.length + + 'remoteResult = ' + + remoteResult.length, + ); + var dedupedList = combiner(localResult, remoteResult); + Logger.log('Deduped list = ' + dedupedList.length); + resolve(dedupedList); } - }; - - localPromise.then(function(currentLocalResult) { - localResult = currentLocalResult; - localPromiseDone = true; - }, function(error) { - localResult = []; - localError = error; - localPromiseDone = true; - }).then(checkAndResolve); - - remotePromise.then(function(currentRemoteResult) { - remoteResult = currentRemoteResult; - remotePromiseDone = true; - }, function(error) { - remoteResult = []; - remoteError = error; - remotePromiseDone = true; - }).then(checkAndResolve); - }) - } + } + }; + + localPromise + .then( + function (currentLocalResult) { + localResult = currentLocalResult; + localPromiseDone = true; + }, + function (error) { + localResult = []; + localError = error; + localPromiseDone = true; + }, + ) + .then(checkAndResolve); + + remotePromise + .then( + function (currentRemoteResult) { + remoteResult = currentRemoteResult; + remotePromiseDone = true; + }, + function (error) { + remoteResult = []; + remoteError = error; + remotePromiseDone = true; + }, + ) + .then(checkAndResolve); + }); + }; // TODO: Generalize this to work for both sensor data and messages // Do we even need to separate the two kinds of data? // Alternatively, we can maintain another mapping between key -> type // Probably in www/json... - this.getUnifiedSensorDataForInterval = function(key, tq) { - var localPromise = $window.cordova.plugins.BEMUserCache.getSensorDataForInterval(key, tq, true); - var remotePromise = getRawEntries([key], tq.startTs, tq.endTs) - .then(function(serverResponse) { - return serverResponse.phone_data; - }); - return combinedPromise(localPromise, remotePromise, combineWithDedup); + this.getUnifiedSensorDataForInterval = function (key, tq) { + var localPromise = $window.cordova.plugins.BEMUserCache.getSensorDataForInterval( + key, + tq, + true, + ); + var remotePromise = getRawEntries([key], tq.startTs, tq.endTs).then( + function (serverResponse) { + return serverResponse.phone_data; + }, + ); + return combinedPromise(localPromise, remotePromise, combineWithDedup); }; - this.getUnifiedMessagesForInterval = function(key, tq, withMetadata) { + this.getUnifiedMessagesForInterval = function (key, tq, withMetadata) { var localPromise = $window.cordova.plugins.BEMUserCache.getMessagesForInterval(key, tq, true); - var remotePromise = getRawEntries([key], tq.startTs, tq.endTs) - .then(function(serverResponse) { - return serverResponse.phone_data; - }); + var remotePromise = getRawEntries([key], tq.startTs, tq.endTs).then( + function (serverResponse) { + return serverResponse.phone_data; + }, + ); return combinedPromise(localPromise, remotePromise, combineWithDedup); - } -}) -.service('ControlHelper', function($window, - $ionicPopup, - Logger) { - - this.writeFile = function(fileEntry, resultList) { + }; + }) + .service('ControlHelper', function ($window, $ionicPopup, Logger) { + this.writeFile = function (fileEntry, resultList) { // Create a FileWriter object for our FileEntry (log.txt). }; @@ -224,5 +250,4 @@ angular.module('emission.services', ['emission.plugin.logger']) this.getSettings = function () { return window.cordova.plugins.BEMConnectionSettings.getSettings(); }; - -}); \ No newline at end of file + }); diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index 9a1e1bd92..ae837e50d 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -1,9 +1,9 @@ // may refactor this into a React hook once it's no longer used by any Angular screens -import { getAngularService } from "../../angular-react-helper"; -import { fetchUrlCached } from "../../services/commHelper"; -import i18next from "i18next"; -import { logDebug } from "../../plugin/logger"; +import { getAngularService } from '../../angular-react-helper'; +import { fetchUrlCached } from '../../services/commHelper'; +import i18next from 'i18next'; +import { logDebug } from '../../plugin/logger'; type InputDetails = { [k in T]?: { From 356dfcf200dc69d147f439e4fc9cae3a44380c7f Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Fri, 3 Nov 2023 14:00:40 -0700 Subject: [PATCH 320/850] Ran prettier on remaining files --- www/js/controllers.js | 2 +- www/js/diary/LabelTab.tsx | 9 +- www/js/services.js | 13 +-- www/js/types/diaryTypes.ts | 124 ++++++++++++------------ www/js/types/fileShareTypes.ts | 12 +-- www/js/types/serverData.ts | 50 +++++----- www/js/unifiedDataLoader.ts | 167 +++++++++++++++++++-------------- 7 files changed, 195 insertions(+), 182 deletions(-) diff --git a/www/js/controllers.js b/www/js/controllers.js index 1f3e64312..17835f3f4 100644 --- a/www/js/controllers.js +++ b/www/js/controllers.js @@ -87,4 +87,4 @@ angular ); console.log('SplashCtrl invoke finished'); }, - ) + ); diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index f58277bea..bbd86d64a 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -241,11 +241,7 @@ const LabelTab = () => { [...timelineMap?.values()] .reverse() .find((trip) => trip.origin_key.includes('confirmed_trip')); - readUnprocessedPromise = readUnprocessedTrips( - pipelineRange.end_ts, - nowTs, - lastProcessedTrip, - ); + readUnprocessedPromise = readUnprocessedTrips(pipelineRange.end_ts, nowTs, lastProcessedTrip); readUnprocessedPromise = readUnprocessedTrips(pipelineRange.end_ts, nowTs, lastProcessedTrip); } else { readUnprocessedPromise = Promise.resolve([]); @@ -262,8 +258,7 @@ const LabelTab = () => { const timelineMapRef = useRef(timelineMap); async function repopulateTimelineEntry(oid: string) { - if (!timelineMap.has(oid)) - return console.error(`Item with oid: ${oid} not found in timeline`); + if (!timelineMap.has(oid)) return console.error(`Item with oid: ${oid} not found in timeline`); const [newLabels, newNotes] = await getLocalUnprocessedInputs( pipelineRange, labelPopulateFactory, diff --git a/www/js/services.js b/www/js/services.js index 891b47eae..aa2958d47 100644 --- a/www/js/services.js +++ b/www/js/services.js @@ -3,12 +3,10 @@ import angular from 'angular'; import { getRawEntries } from './commHelper'; -angular.module('emission.services', ['emission.plugin.logger']) -.service('ControlHelper', function($window, - $ionicPopup, - Logger) { - - this.writeFile = function(fileEntry, resultList) { +angular + .module('emission.services', ['emission.plugin.logger']) + .service('ControlHelper', function ($window, $ionicPopup, Logger) { + this.writeFile = function (fileEntry, resultList) { // Create a FileWriter object for our FileEntry (log.txt). }; @@ -126,5 +124,4 @@ angular.module('emission.services', ['emission.plugin.logger']) this.getSettings = function () { return window.cordova.plugins.BEMConnectionSettings.getSettings(); }; - -}); \ No newline at end of file + }); diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index b51725977..14d8acc07 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -1,75 +1,75 @@ -import { LocalDt, ServerData } from './serverData' +import { LocalDt, ServerData } from './serverData'; -export type UserInput = ServerData +export type UserInput = ServerData; export type UserInputData = { - end_ts: number, - start_ts: number - label: string, - start_local_dt?: LocalDt - end_local_dt?: LocalDt - status?: string, - match_id?: string, -} + end_ts: number; + start_ts: number; + label: string; + start_local_dt?: LocalDt; + end_local_dt?: LocalDt; + status?: string; + match_id?: string; +}; type ConfirmedPlace = any; // TODO export type CompositeTrip = { - _id: {$oid: string}, - additions: any[], // TODO - cleaned_section_summary: any, // TODO - cleaned_trip: {$oid: string}, - confidence_threshold: number, - confirmed_trip: {$oid: string}, - distance: number, - duration: number, - end_confirmed_place: ConfirmedPlace, - end_fmt_time: string, - end_loc: {type: string, coordinates: number[]}, - end_local_dt: LocalDt, - end_place: {$oid: string}, - end_ts: number, - expectation: any, // TODO "{to_label: boolean}" - expected_trip: {$oid: string}, - inferred_labels: any[], // TODO - inferred_section_summary: any, // TODO - inferred_trip: {$oid: string}, - key: string, - locations: any[], // TODO - origin_key: string, - raw_trip: {$oid: string}, - sections: any[], // TODO - source: string, - start_confirmed_place: ConfirmedPlace, - start_fmt_time: string, - start_loc: {type: string, coordinates: number[]}, - start_local_dt: LocalDt, - start_place: {$oid: string}, - start_ts: number, - user_input: UserInput, -} + _id: { $oid: string }; + additions: any[]; // TODO + cleaned_section_summary: any; // TODO + cleaned_trip: { $oid: string }; + confidence_threshold: number; + confirmed_trip: { $oid: string }; + distance: number; + duration: number; + end_confirmed_place: ConfirmedPlace; + end_fmt_time: string; + end_loc: { type: string; coordinates: number[] }; + end_local_dt: LocalDt; + end_place: { $oid: string }; + end_ts: number; + expectation: any; // TODO "{to_label: boolean}" + expected_trip: { $oid: string }; + inferred_labels: any[]; // TODO + inferred_section_summary: any; // TODO + inferred_trip: { $oid: string }; + key: string; + locations: any[]; // TODO + origin_key: string; + raw_trip: { $oid: string }; + sections: any[]; // TODO + source: string; + start_confirmed_place: ConfirmedPlace; + start_fmt_time: string; + start_loc: { type: string; coordinates: number[] }; + start_local_dt: LocalDt; + start_place: { $oid: string }; + start_ts: number; + user_input: UserInput; +}; export type PopulatedTrip = CompositeTrip & { - additionsList?: any[], // TODO - finalInference?: any, // TODO - geojson?: any, // TODO - getNextEntry?: () => PopulatedTrip | ConfirmedPlace, - userInput?: UserInput, - verifiability?: string, -} + additionsList?: any[]; // TODO + finalInference?: any; // TODO + geojson?: any; // TODO + getNextEntry?: () => PopulatedTrip | ConfirmedPlace; + userInput?: UserInput; + verifiability?: string; +}; export type Trip = { - end_ts: number, - start_ts: number, -} + end_ts: number; + start_ts: number; +}; export type TlEntry = { - key: string, - origin_key: string, - start_ts: number, - end_ts: number, - enter_ts: number, - exit_ts: number, - duration: number, -getNextEntry?: () => PopulatedTrip | ConfirmedPlace, -} \ No newline at end of file + key: string; + origin_key: string; + start_ts: number; + end_ts: number; + enter_ts: number; + exit_ts: number; + duration: number; + getNextEntry?: () => PopulatedTrip | ConfirmedPlace; +}; diff --git a/www/js/types/fileShareTypes.ts b/www/js/types/fileShareTypes.ts index 89481624d..03b41a161 100644 --- a/www/js/types/fileShareTypes.ts +++ b/www/js/types/fileShareTypes.ts @@ -1,11 +1,11 @@ -import { ServerData } from './serverData'; +import { ServerData } from './serverData'; export type TimeStampData = ServerData; export type RawTimelineData = { - name: string, - ts: number, - reading: number, + name: string; + ts: number; + reading: number; }; export interface FsWindow extends Window { @@ -13,10 +13,10 @@ export interface FsWindow extends Window { type: number, size: number, successCallback: (fs: any) => void, - errorCallback?: (error: any) => void + errorCallback?: (error: any) => void, ) => void; LocalFileSystem: { TEMPORARY: number; PERSISTENT: number; }; -}; +} diff --git a/www/js/types/serverData.ts b/www/js/types/serverData.ts index 8b14b79df..9a15ff996 100644 --- a/www/js/types/serverData.ts +++ b/www/js/types/serverData.ts @@ -1,39 +1,39 @@ export type ServerResponse = { - phone_data: Array>, -} + phone_data: Array>; +}; export type ServerData = { - data: Type, - metadata: MetaData, - key?: string, - user_id?: { $uuid: string, }, - _id?: { $oid: string, }, + data: Type; + metadata: MetaData; + key?: string; + user_id?: { $uuid: string }; + _id?: { $oid: string }; }; export type MetaData = { - key: string, - platform: string, - write_ts: number, - time_zone: string, - write_fmt_time: string, - write_local_dt: LocalDt, - origin_key?: string, - read_ts?: number, + key: string; + platform: string; + write_ts: number; + time_zone: string; + write_fmt_time: string; + write_local_dt: LocalDt; + origin_key?: string; + read_ts?: number; }; - + export type LocalDt = { - minute: number, - hour: number, - second: number, - day: number, - weekday: number, - month: number, - year: number, - timezone: string, + minute: number; + hour: number; + second: number; + day: number; + weekday: number; + month: number; + year: number; + timezone: string; }; export type TimeQuery = { key: string; startTs: number; endTs: number; -} \ No newline at end of file +}; diff --git a/www/js/unifiedDataLoader.ts b/www/js/unifiedDataLoader.ts index 15bcd341d..dc0866be3 100644 --- a/www/js/unifiedDataLoader.ts +++ b/www/js/unifiedDataLoader.ts @@ -1,103 +1,124 @@ -import { logDebug } from './plugin/logger' +import { logDebug } from './plugin/logger'; import { getRawEntries } from './commHelper'; import { ServerResponse, ServerData, TimeQuery } from './types/serverData'; /** - * combineWithDedup is a helper function for combinedPromises + * combineWithDedup is a helper function for combinedPromises * @param list1 values evaluated from a BEMUserCache promise - * @param list2 same as list1 + * @param list2 same as list1 * @returns a dedup array generated from the input lists */ -export const combineWithDedup = function(list1: Array>, list2: Array) { - const combinedList = list1.concat(list2); - return combinedList.filter(function(value, i, array) { - const firstIndexOfValue = array.findIndex(function(element) { - return element.metadata.write_ts == value.metadata.write_ts; - }); - return firstIndexOfValue == i; +export const combineWithDedup = function (list1: Array>, list2: Array) { + const combinedList = list1.concat(list2); + return combinedList.filter(function (value, i, array) { + const firstIndexOfValue = array.findIndex(function (element) { + return element.metadata.write_ts == value.metadata.write_ts; }); + return firstIndexOfValue == i; + }); }; /** - * combinedPromises is a recursive function that joins multiple promises - * @param promiseList 1 or more promises + * combinedPromises is a recursive function that joins multiple promises + * @param promiseList 1 or more promises * @param combiner a function that takes two arrays and joins them * @returns A promise which evaluates to a combined list of values or errors */ -export const combinedPromises = function(promiseList: Array>, - combiner: (list1: Array, list2: Array) => Array ) { - if (promiseList.length === 0) { - throw new RangeError('combinedPromises needs input array.length >= 1'); - } - return new Promise(function(resolve, reject) { - var firstResult = []; - var firstError = null; +export const combinedPromises = function ( + promiseList: Array>, + combiner: (list1: Array, list2: Array) => Array, +) { + if (promiseList.length === 0) { + throw new RangeError('combinedPromises needs input array.length >= 1'); + } + return new Promise(function (resolve, reject) { + var firstResult = []; + var firstError = null; - var nextResult = []; - var nextError = null; + var nextResult = []; + var nextError = null; - var firstPromiseDone = false; - var nextPromiseDone = false; + var firstPromiseDone = false; + var nextPromiseDone = false; - const checkAndResolve = function() { - if (firstPromiseDone && nextPromiseDone) { - if (firstError && nextError) { - reject([firstError, nextError]); - } else { - logDebug(`About to dedup firstResult = ${firstResult.length}` + - ` nextResult = ${nextResult.length}`); - const dedupedList = combiner(firstResult, nextResult); - logDebug(`Deduped list = ${dedupedList.length}`); - resolve(dedupedList); - } + const checkAndResolve = function () { + if (firstPromiseDone && nextPromiseDone) { + if (firstError && nextError) { + reject([firstError, nextError]); + } else { + logDebug( + `About to dedup firstResult = ${firstResult.length}` + + ` nextResult = ${nextResult.length}`, + ); + const dedupedList = combiner(firstResult, nextResult); + logDebug(`Deduped list = ${dedupedList.length}`); + resolve(dedupedList); } - }; - - if (promiseList.length === 1) { - return promiseList[0].then(function(result: Array) { + } + }; + + if (promiseList.length === 1) { + return promiseList[0].then( + function (result: Array) { resolve(result); - }, function (err) { + }, + function (err) { reject([err]); - }); - } + }, + ); + } - const firstPromise = promiseList[0]; - const nextPromise = combinedPromises(promiseList.slice(1), combiner); - - firstPromise.then(function(currentFirstResult: Array) { - firstResult = currentFirstResult; - firstPromiseDone = true; - }, function(error) { - firstResult = []; - firstError = error; - nextPromiseDone = true; - }).then(checkAndResolve); + const firstPromise = promiseList[0]; + const nextPromise = combinedPromises(promiseList.slice(1), combiner); - nextPromise.then(function(currentNextResult: Array) { - nextResult = currentNextResult; - nextPromiseDone = true; - }, function(error) { - nextResult = []; - nextError = error; - }).then(checkAndResolve); - }); + firstPromise + .then( + function (currentFirstResult: Array) { + firstResult = currentFirstResult; + firstPromiseDone = true; + }, + function (error) { + firstResult = []; + firstError = error; + nextPromiseDone = true; + }, + ) + .then(checkAndResolve); + + nextPromise + .then( + function (currentNextResult: Array) { + nextResult = currentNextResult; + nextPromiseDone = true; + }, + function (error) { + nextResult = []; + nextError = error; + }, + ) + .then(checkAndResolve); + }); }; /** - * getUnifiedDataForInterval is a generalized method to fetch data by its timestamps + * getUnifiedDataForInterval is a generalized method to fetch data by its timestamps * @param key string corresponding to a data entry * @param tq an object that contains interval start and end times * @param getMethod a BEMUserCache method that fetches certain data via a promise * @returns A promise that evaluates to the all values found within the queried data */ -export const getUnifiedDataForInterval = function(key: string, tq: TimeQuery, - getMethod: (key: string, tq: TimeQuery, flag: boolean) => Promise) { - const test = true; - const getPromise = getMethod(key, tq, test); - const remotePromise = getRawEntries([key], tq.startTs, tq.endTs) - .then(function(serverResponse: ServerResponse) { - return serverResponse.phone_data; - }); - var promiseList = [getPromise, remotePromise] - return combinedPromises(promiseList, combineWithDedup); -}; \ No newline at end of file +export const getUnifiedDataForInterval = function ( + key: string, + tq: TimeQuery, + getMethod: (key: string, tq: TimeQuery, flag: boolean) => Promise, +) { + const test = true; + const getPromise = getMethod(key, tq, test); + const remotePromise = getRawEntries([key], tq.startTs, tq.endTs).then(function ( + serverResponse: ServerResponse, + ) { + return serverResponse.phone_data; + }); + var promiseList = [getPromise, remotePromise]; + return combinedPromises(promiseList, combineWithDedup); +}; From dcf35facfbaf595867659336286fb44e4ff6e9c4 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 3 Nov 2023 15:31:15 -0600 Subject: [PATCH 321/850] complete writing testing adding more tests - leveraged mocks to ensure that calls to open url or create popup were happening at the appropriate times --- www/__mocks__/cordovaMocks.ts | 19 +++++++++ www/__mocks__/globalMocks.ts | 16 ++++++++ www/__tests__/remoteNotifyHandler.test.ts | 47 ++++++++++++++++++++++- 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index a031c8444..92ac23a6b 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -167,3 +167,22 @@ export const mockBEMServerCom = () => { }; window['cordova'].plugins.BEMServerComm = mockBEMServerCom; }; + +let _url_stash = ''; + +export const mockInAppBrowser = () => { + const mockInAppBrowser = { + open: (url: string, mode: string, options: {}) => { + _url_stash = url; + }, + }; + window['cordova'].InAppBrowser = mockInAppBrowser; +}; + +export const getURL = () => { + return _url_stash; +}; + +export const clearURL = () => { + _url_stash = ''; +}; diff --git a/www/__mocks__/globalMocks.ts b/www/__mocks__/globalMocks.ts index f13cb274b..62ea1b935 100644 --- a/www/__mocks__/globalMocks.ts +++ b/www/__mocks__/globalMocks.ts @@ -1,3 +1,19 @@ export const mockLogger = () => { window['Logger'] = { log: console.log }; }; + +let alerts = []; + +export const mockAlert = () => { + window['alert'] = (message) => { + alerts.push(message); + }; +}; + +export const clearAlerts = () => { + alerts = []; +}; + +export const getAlerts = () => { + return alerts; +}; diff --git a/www/__tests__/remoteNotifyHandler.test.ts b/www/__tests__/remoteNotifyHandler.test.ts index c6d7feab6..1d0220da5 100644 --- a/www/__tests__/remoteNotifyHandler.test.ts +++ b/www/__tests__/remoteNotifyHandler.test.ts @@ -1,15 +1,29 @@ import { EVENT_NAMES, publish } from '../js/customEventHandler'; import { initRemoteNotifyHandler } from '../js/splash/remoteNotifyHandler'; -import { mockBEMUserCache, mockDevice, mockGetAppVersion } from '../__mocks__/cordovaMocks'; -import { mockLogger } from '../__mocks__/globalMocks'; +import { + clearURL, + getURL, + mockBEMUserCache, + mockDevice, + mockGetAppVersion, + mockInAppBrowser, +} from '../__mocks__/cordovaMocks'; +import { clearAlerts, getAlerts, mockAlert, mockLogger } from '../__mocks__/globalMocks'; mockLogger(); mockDevice(); mockBEMUserCache(); mockGetAppVersion(); +mockInAppBrowser(); +mockAlert(); const db = window['cordova']?.plugins?.BEMUserCache; +beforeEach(() => { + clearURL(); + clearAlerts(); +}); + it('does not adds a statEvent if not subscribed', async () => { publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, 'test data'); const storedMessages = await db.getAllMessages('stats/client_nav_event', false); @@ -31,3 +45,32 @@ it('adds a statEvent if subscribed', async () => { }); }); +it('handles the url if subscribed', () => { + initRemoteNotifyHandler(); + publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, { + additionalData: { + payload: { alert_type: 'website', spec: { url: 'https://this_is_a_test.com' } }, + }, + }); + expect(getURL()).toBe('https://this_is_a_test.com'); +}); + +it('handles the popup if subscribed', () => { + initRemoteNotifyHandler(); + publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, { + additionalData: { + payload: { + alert_type: 'popup', + spec: { title: 'Hello', text: 'World' }, + }, + }, + }); + expect(getAlerts()).toEqual(expect.arrayContaining(['━━━━\nHello\n━━━━\nWorld'])); +}); + +it('does nothing if subscribed and no data', () => { + initRemoteNotifyHandler(); + publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, {}); + expect(getURL()).toEqual(''); + expect(getAlerts()).toEqual([]); +}); From 9d3c718f3e2e1aa375837c291b480b77681b789a Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Fri, 3 Nov 2023 15:59:07 -0600 Subject: [PATCH 322/850] Move scheduledNotifs into the only function that uses it --- www/js/control/ProfileSettings.jsx | 18 +++++++++++++----- www/js/splash/notifScheduler.ts | 2 +- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index b081e642a..695201a33 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -32,7 +32,8 @@ import { shareQR } from '../components/QrCode'; import { storageClear } from '../plugin/storage'; import { getAppVersion } from '../plugin/clientStats'; import { getConsentDocument } from '../splash/startprefs'; -import { logDebug } from '../plugin/logger'; +import { displayErrorMsg, logDebug } from '../plugin/logger'; +import { updateScheduledNotifs, getScheduledNotifs, getReminderPrefs, setReminderPrefs } from "../splash/notifScheduler"; //any pure functions can go outside const ProfileSettings = () => { @@ -45,7 +46,6 @@ const ProfileSettings = () => { //angular services needed const CarbonDatasetHelper = getAngularService('CarbonDatasetHelper'); const EmailHelper = getAngularService('EmailHelper'); - const NotificationScheduler = getAngularService('NotificationScheduler'); const ControlHelper = getAngularService('ControlHelper'); //functions that come directly from an Angular service @@ -141,6 +141,14 @@ const ProfileSettings = () => { tempUiConfig.opcode.autogen = tempUiConfig?.intro.program_or_study == 'study'; } + // Update the scheduled notifs + updateScheduledNotifs(tempUiConfig.reminderSchemes).then(() => { + logDebug("updated scheduled notifs"); + }) + .catch((err) => { + displayErrorMsg("Error while updating scheduled notifs", err); + }); + // setTemplateText(tempUiConfig.intro.translated_text); // console.log("translated text is??", templateText); setUiConfig(tempUiConfig); @@ -187,13 +195,13 @@ const ProfileSettings = () => { const newNotificationSettings = {}; if (uiConfig?.reminderSchemes) { - const prefs = await NotificationScheduler.getReminderPrefs(); + const prefs = await getReminderPrefs(); const m = moment(prefs.reminder_time_of_day, 'HH:mm'); newNotificationSettings.prefReminderTimeVal = m.toDate(); const n = moment(newNotificationSettings.prefReminderTimeVal); newNotificationSettings.prefReminderTime = n.format('LT'); newNotificationSettings.prefReminderTimeOnLoad = prefs.reminder_time_of_day; - newNotificationSettings.scheduledNotifs = await NotificationScheduler.getScheduledNotifs(); + newNotificationSettings.scheduledNotifs = await getScheduledNotifs(); updatePrefReminderTime(false); } @@ -264,7 +272,7 @@ const ProfileSettings = () => { if (storeNewVal) { const m = moment(newTime); // store in HH:mm - NotificationScheduler.setReminderPrefs({ reminder_time_of_day: m.format('HH:mm') }).then( + setReminderPrefs({ reminder_time_of_day: m.format('HH:mm') }, uiConfig.reminderSchemes).then( () => { refreshNotificationSettings(); }, diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts index 020a864d7..f4358014a 100644 --- a/www/js/splash/notifScheduler.ts +++ b/www/js/splash/notifScheduler.ts @@ -9,7 +9,6 @@ import { DateTime } from 'luxon'; import i18next from 'i18next'; let scheduledPromise = new Promise((rs) => rs()); -let scheduledNotifs = []; let isScheduling = false; // like python range() @@ -63,6 +62,7 @@ function debugGetScheduled(prefix) { if (!notifs?.length) return logDebug(`${prefix}, there are no scheduled notifications`); const time = DateTime.fromMillis(notifs?.[0].trigger.at).toFormat('HH:mm'); //was in plugin, changed to scheduler + let scheduledNotifs = []; scheduledNotifs = notifs.map((n) => { const time = DateTime.fromMillis(n.trigger.at).toFormat('t'); const date = DateTime.fromMillis(n.trigger.at).toFormat('DDD'); From a1e035fd1d21c8d5df269faa1a1a850d9e73ac17 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Fri, 3 Nov 2023 15:59:31 -0600 Subject: [PATCH 323/850] Remove console.errors that I was using for debugging --- www/js/splash/notifScheduler.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts index f4358014a..56deab160 100644 --- a/www/js/splash/notifScheduler.ts +++ b/www/js/splash/notifScheduler.ts @@ -130,9 +130,7 @@ const scheduleNotifs = (scheme, notifTimes) => { return new Promise((rs) => { isScheduling = true; const localeCode = i18next.resolvedLanguage; - console.error('notifTimes: ', notifTimes, ' - type: ', typeof notifTimes); const nots = notifTimes.map((n) => { - console.error('n: ', n, ' - type: ', typeof n); const nDate = n.toDate(); const seconds = nDate.getTime() / 1000; return { From 8b09cb331ebdb4135405f06ac219e406b2e8e840 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Fri, 3 Nov 2023 15:59:53 -0600 Subject: [PATCH 324/850] Adding Luxon functions --- www/js/splash/notifScheduler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts index 56deab160..723b949ca 100644 --- a/www/js/splash/notifScheduler.ts +++ b/www/js/splash/notifScheduler.ts @@ -131,8 +131,8 @@ const scheduleNotifs = (scheme, notifTimes) => { isScheduling = true; const localeCode = i18next.resolvedLanguage; const nots = notifTimes.map((n) => { - const nDate = n.toDate(); - const seconds = nDate.getTime() / 1000; + const nDate = n.toISO(); + const seconds = nDate.ts / 1000; return { id: seconds, title: scheme.title[localeCode], From 96d1d50bae35157f9d61d36384a45e3fef725482 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Fri, 3 Nov 2023 16:01:50 -0600 Subject: [PATCH 325/850] Update notifscheduler to export functions instead of using hooks --- www/js/splash/notifScheduler.ts | 38 ++++++--------------------------- 1 file changed, 7 insertions(+), 31 deletions(-) diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts index 723b949ca..e41430529 100644 --- a/www/js/splash/notifScheduler.ts +++ b/www/js/splash/notifScheduler.ts @@ -1,6 +1,5 @@ import angular from 'angular'; import React, { useEffect, useState } from 'react'; -import { getConfig } from '../config/dynamicConfig'; import useAppConfig from '../useAppConfig'; import { addStatReading, statKeys } from '../plugin/clientStats'; import { getUser, updateUser } from '../commHelper'; @@ -79,7 +78,7 @@ function debugGetScheduled(prefix) { } //new method to fetch notifications -const getScheduledNotifs = function () { +export const getScheduledNotifs = function () { return new Promise((resolve, reject) => { /* if the notifications are still in active scheduling it causes problems anywhere from 0-n of the scheduled notifs are displayed @@ -161,10 +160,9 @@ const scheduleNotifs = (scheme, notifTimes) => { }; // determines when notifications are needed, and schedules them if not already scheduled -const update = async (reminderSchemes) => { - const { reminder_assignment, reminder_join_date, reminder_time_of_day } = await getReminderPrefs( - reminderSchemes, - ); +export const updateScheduledNotifs = async (reminderSchemes): Promise => { + const { reminder_assignment, reminder_join_date, reminder_time_of_day } = + await getReminderPrefs(reminderSchemes); var scheme = {}; try { scheme = reminderSchemes[reminder_assignment]; @@ -228,7 +226,7 @@ const initReminderPrefs = (reminderSchemes) => { // reminder_time_of_day: string; // } -const getReminderPrefs = async (reminderSchemes): Promise => { +export const getReminderPrefs = async (reminderSchemes): Promise => { const user = (await getUser()) as any; if (user?.reminder_assignment && user?.reminder_join_date && user?.reminder_time_of_day) { console.log('User already has reminder prefs, returning them', user); @@ -241,11 +239,11 @@ const getReminderPrefs = async (reminderSchemes): Promise => { await setReminderPrefs(initPrefs, reminderSchemes); return { ...user, ...initPrefs }; // user profile + the new prefs }; -const setReminderPrefs = async (newPrefs, reminderSchemes) => { +export const setReminderPrefs = async (newPrefs, reminderSchemes) => { await updateUser(newPrefs); const updatePromise = new Promise((resolve, reject) => { //enforcing update before moving on - update(reminderSchemes).then(() => { + updateScheduledNotifs(reminderSchemes).then(() => { resolve(); }); }); @@ -262,25 +260,3 @@ const setReminderPrefs = async (newPrefs, reminderSchemes) => { }); return updatePromise; }; - -export function useSchedulerHelper() { - const appConfig = useAppConfig(); - const [reminderSchemes, setReminderSchemes] = useState(); - - useEffect(() => { - if (!appConfig) { - logDebug('No reminder schemes found in config, not scheduling notifications'); - return; - } - setReminderSchemes(appConfig.reminderSchemes); - }, [appConfig]); - - //setUpActions(); - update(reminderSchemes); - - return { - setReminderPrefs: (newPrefs) => setReminderPrefs(newPrefs, reminderSchemes), - getReminderPrefs: () => getReminderPrefs(reminderSchemes), - getScheduledNotifs: () => getScheduledNotifs(), - }; -} From a6d99e736906727cb989e7478d8fccff3fc0ace1 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Fri, 3 Nov 2023 16:03:10 -0600 Subject: [PATCH 326/850] Run prettier on non-pretty code --- www/js/control/ProfileSettings.jsx | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 695201a33..a056470a4 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -33,7 +33,12 @@ import { storageClear } from '../plugin/storage'; import { getAppVersion } from '../plugin/clientStats'; import { getConsentDocument } from '../splash/startprefs'; import { displayErrorMsg, logDebug } from '../plugin/logger'; -import { updateScheduledNotifs, getScheduledNotifs, getReminderPrefs, setReminderPrefs } from "../splash/notifScheduler"; +import { + updateScheduledNotifs, + getScheduledNotifs, + getReminderPrefs, + setReminderPrefs, +} from '../splash/notifScheduler'; //any pure functions can go outside const ProfileSettings = () => { @@ -142,12 +147,13 @@ const ProfileSettings = () => { } // Update the scheduled notifs - updateScheduledNotifs(tempUiConfig.reminderSchemes).then(() => { - logDebug("updated scheduled notifs"); - }) - .catch((err) => { - displayErrorMsg("Error while updating scheduled notifs", err); - }); + updateScheduledNotifs(tempUiConfig.reminderSchemes) + .then(() => { + logDebug('updated scheduled notifs'); + }) + .catch((err) => { + displayErrorMsg('Error while updating scheduled notifs', err); + }); // setTemplateText(tempUiConfig.intro.translated_text); // console.log("translated text is??", templateText); From 4afd9ec3d1dba0bec61f225d4f1d13b07a720462 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Sat, 4 Nov 2023 01:00:36 -0400 Subject: [PATCH 327/850] remove js/diary/diaryTypes.ts not needed since we are now keeping diaryTypes as js/types/diaryTypes.ts --- www/js/diary/diaryTypes.ts | 72 -------------------------------------- 1 file changed, 72 deletions(-) delete mode 100644 www/js/diary/diaryTypes.ts diff --git a/www/js/diary/diaryTypes.ts b/www/js/diary/diaryTypes.ts deleted file mode 100644 index 5755c91ab..000000000 --- a/www/js/diary/diaryTypes.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* These type definitions are a work in progress. The goal is to have a single source of truth for - the types of the trip / place / untracked objects and all properties they contain. - Since we are using TypeScript now, we should strive to enforce type safety and also benefit from - IntelliSense and other IDE features. */ - -// Since it is WIP, these types are not used anywhere yet. - -type ConfirmedPlace = any; // TODO - -/* These are the properties received from the server (basically matches Python code) - This should match what Timeline.readAllCompositeTrips returns (an array of these objects) */ -export type CompositeTrip = { - _id: { $oid: string }; - additions: any[]; // TODO - cleaned_section_summary: any; // TODO - cleaned_trip: { $oid: string }; - confidence_threshold: number; - confirmed_trip: { $oid: string }; - distance: number; - duration: number; - end_confirmed_place: ConfirmedPlace; - end_fmt_time: string; - end_loc: { type: string; coordinates: number[] }; - end_local_dt: any; // TODO - end_place: { $oid: string }; - end_ts: number; - expectation: any; // TODO "{to_label: boolean}" - expected_trip: { $oid: string }; - inferred_labels: any[]; // TODO - inferred_section_summary: any; // TODO - inferred_trip: { $oid: string }; - key: string; - locations: any[]; // TODO - origin_key: string; - raw_trip: { $oid: string }; - sections: any[]; // TODO - source: string; - start_confirmed_place: ConfirmedPlace; - start_fmt_time: string; - start_loc: { type: string; coordinates: number[] }; - start_local_dt: any; // TODO - start_place: { $oid: string }; - start_ts: number; - user_input: any; // TODO -}; - -/* These properties aren't received from the server, but are derived from the above properties. - They are used in the UI to display trip/place details and are computed by the useDerivedProperties hook. */ -export type DerivedProperties = { - displayDate: string; - displayStartTime: string; - displayEndTime: string; - displayTime: string; - displayStartDateAbbr: string; - displayEndDateAbbr: string; - formattedDistance: string; - formattedSectionProperties: any[]; // TODO - distanceSuffix: string; - detectedModes: { mode: string; icon: string; color: string; pct: number | string }[]; -}; - -/* These are the properties that are still filled in by some kind of 'populate' mechanism. - It would simplify the codebase to just compute them where they're needed - (using memoization when apt so performance is not impacted). */ -export type PopulatedTrip = CompositeTrip & { - additionsList?: any[]; // TODO - finalInference?: any; // TODO - geojson?: any; // TODO - getNextEntry?: () => PopulatedTrip | ConfirmedPlace; - userInput?: any; // TODO - verifiability?: string; -}; From 8cb369173abab4d4638f74a4a03cb3b9c499340a Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Sat, 4 Nov 2023 01:02:14 -0400 Subject: [PATCH 328/850] expand diaryTypes d96ff8d0d7c785f6914b2d080885df9030f6d033 --- www/js/diary/diaryHelper.ts | 4 +- www/js/types/diaryTypes.ts | 131 ++++++++++++++++++++++++------------ 2 files changed, 90 insertions(+), 45 deletions(-) diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts index 48f40322d..5b060a236 100644 --- a/www/js/diary/diaryHelper.ts +++ b/www/js/diary/diaryHelper.ts @@ -24,7 +24,7 @@ type BaseMode = { }; // parallels the server-side MotionTypes enum: https://github.com/e-mission/e-mission-server/blob/94e7478e627fa8c171323662f951c611c0993031/emission/core/wrapper/motionactivity.py#L12 -type MotionTypeKey = +export type MotionTypeKey = | 'IN_VEHICLE' | 'BICYCLING' | 'ON_FOOT' @@ -64,7 +64,7 @@ const BaseModes: { [k: string]: BaseMode } = { OTHER: { name: 'OTHER', icon: 'pencil-circle', color: modeColors.taupe }, }; -type BaseModeKey = keyof typeof BaseModes; +export type BaseModeKey = keyof typeof BaseModes; /** * @param motionName A string like "WALKING" or "MotionTypes.WALKING" * @returns A BaseMode object containing the name, icon, and color of the motion type diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 14d8acc07..2c901df00 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -1,75 +1,120 @@ -import { LocalDt, ServerData } from './serverData'; +/* This file provides typings for use in '/diary', including timeline objects (trips and places) + and user input objects. + As much as possible, these types parallel the types used in the server code. */ -export type UserInput = ServerData; +import { BaseModeKey, MotionTypeKey } from '../diary/diaryHelper'; +import { LocalDt } from './serverData'; -export type UserInputData = { - end_ts: number; - start_ts: number; - label: string; - start_local_dt?: LocalDt; - end_local_dt?: LocalDt; - status?: string; - match_id?: string; +type ObjectId = { $oid: string }; +type ConfirmedPlace = { + _id: ObjectId; + additions: UserInputEntry[]; + cleaned_place: ObjectId; + ending_trip: ObjectId; + enter_fmt_time: string; // ISO string 2023-10-31T12:00:00.000-04:00 + enter_local_dt: LocalDt; + enter_ts: number; // Unix timestamp + key: string; + location: { type: string; coordinates: number[] }; + origin_key: string; + raw_places: ObjectId[]; + source: string; + user_input: { + /* for keys ending in 'user_input' (e.g. 'trip_user_input'), the server gives us the raw user + input object with 'data' and 'metadata' */ + [k: `${string}user_input`]: UserInputEntry; + /* for keys ending in 'confirm' (e.g. 'mode_confirm'), the server just gives us the user input value + as a string (e.g. 'walk', 'drove_alone') */ + [k: `${string}confirm`]: string; + }; }; -type ConfirmedPlace = any; // TODO - +/* These are the properties received from the server (basically matches Python code) + This should match what Timeline.readAllCompositeTrips returns (an array of these objects) */ export type CompositeTrip = { - _id: { $oid: string }; - additions: any[]; // TODO - cleaned_section_summary: any; // TODO - cleaned_trip: { $oid: string }; + _id: ObjectId; + additions: UserInputEntry[]; + cleaned_section_summary: SectionSummary; + cleaned_trip: ObjectId; confidence_threshold: number; - confirmed_trip: { $oid: string }; + confirmed_trip: ObjectId; distance: number; duration: number; end_confirmed_place: ConfirmedPlace; end_fmt_time: string; end_loc: { type: string; coordinates: number[] }; end_local_dt: LocalDt; - end_place: { $oid: string }; + end_place: ObjectId; end_ts: number; expectation: any; // TODO "{to_label: boolean}" - expected_trip: { $oid: string }; + expected_trip: ObjectId; inferred_labels: any[]; // TODO - inferred_section_summary: any; // TODO - inferred_trip: { $oid: string }; + inferred_section_summary: SectionSummary; + inferred_trip: ObjectId; key: string; locations: any[]; // TODO origin_key: string; - raw_trip: { $oid: string }; + raw_trip: ObjectId; sections: any[]; // TODO source: string; start_confirmed_place: ConfirmedPlace; start_fmt_time: string; start_loc: { type: string; coordinates: number[] }; start_local_dt: LocalDt; - start_place: { $oid: string }; + start_place: ObjectId; start_ts: number; - user_input: UserInput; + user_input: { + /* for keys ending in 'user_input' (e.g. 'trip_user_input'), the server gives us the raw user + input object with 'data' and 'metadata' */ + [k: `${string}user_input`]: UserInputEntry; + /* for keys ending in 'confirm' (e.g. 'mode_confirm'), the server just gives us the user input value + as a string (e.g. 'walk', 'drove_alone') */ + [k: `${string}confirm`]: string; + }; }; -export type PopulatedTrip = CompositeTrip & { - additionsList?: any[]; // TODO - finalInference?: any; // TODO - geojson?: any; // TODO - getNextEntry?: () => PopulatedTrip | ConfirmedPlace; - userInput?: UserInput; - verifiability?: string; +/* The 'timeline' for a user is a list of their trips and places, + so a 'timeline entry' is either a trip or a place. */ +export type TimelineEntry = ConfirmedPlace | CompositeTrip; + +/* These properties aren't received from the server, but are derived from the above properties. + They are used in the UI to display trip/place details and are computed by the useDerivedProperties hook. */ +export type DerivedProperties = { + displayDate: string; + displayStartTime: string; + displayEndTime: string; + displayTime: string; + displayStartDateAbbr: string; + displayEndDateAbbr: string; + formattedDistance: string; + formattedSectionProperties: any[]; // TODO + distanceSuffix: string; + detectedModes: { mode: string; icon: string; color: string; pct: number | string }[]; }; -export type Trip = { - end_ts: number; - start_ts: number; +export type SectionSummary = { + count: { [k: MotionTypeKey | BaseModeKey]: number }; + distance: { [k: MotionTypeKey | BaseModeKey]: number }; + duration: { [k: MotionTypeKey | BaseModeKey]: number }; }; -export type TlEntry = { - key: string; - origin_key: string; - start_ts: number; - end_ts: number; - enter_ts: number; - exit_ts: number; - duration: number; - getNextEntry?: () => PopulatedTrip | ConfirmedPlace; +export type UserInputEntry = { + data: { + end_ts: number; + start_ts: number; + label: string; + start_local_dt?: LocalDt; + end_local_dt?: LocalDt; + status?: string; + match_id?: string; + }; + metadata: { + time_zone: string; + plugin: string; + write_ts: number; + platform: string; + read_ts: number; + key: string; + }; + key?: string; }; From 59503e6d1e3e4f21ec881178a5653e48a3dacb85 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Mon, 6 Nov 2023 08:38:02 -0800 Subject: [PATCH 329/850] Update www/js/services/unifiedDataLoader.ts Co-authored-by: Jack Greenlee --- www/js/services/unifiedDataLoader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/services/unifiedDataLoader.ts b/www/js/services/unifiedDataLoader.ts index 4d644b998..d4211b4ce 100644 --- a/www/js/services/unifiedDataLoader.ts +++ b/www/js/services/unifiedDataLoader.ts @@ -120,6 +120,6 @@ export const getUnifiedDataForInterval = function ( ) { return serverResponse.phone_data; }); - var promiseList = [getPromise, remotePromise]; + const promiseList = [getPromise, remotePromise]; return combinedPromises(promiseList, combineWithDedup); }; From 9a9cd9283a72bfa814be8d181ef73979a2384b3e Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Mon, 6 Nov 2023 09:50:06 -0800 Subject: [PATCH 330/850] Removed old UnifiedDataLoader --- www/js/services.js | 100 --------------------------------------------- 1 file changed, 100 deletions(-) diff --git a/www/js/services.js b/www/js/services.js index 189115bc2..6ed060ed9 100644 --- a/www/js/services.js +++ b/www/js/services.js @@ -31,106 +31,6 @@ angular //}*/ }; }) - .service('UnifiedDataLoader', function ($window, Logger) { - var combineWithDedup = function (list1, list2) { - var combinedList = list1.concat(list2); - return combinedList.filter(function (value, i, array) { - var firstIndexOfValue = array.findIndex(function (element, index, array) { - return element.metadata.write_ts == value.metadata.write_ts; - }); - return firstIndexOfValue == i; - }); - }; - - // TODO: generalize to iterable of promises - var combinedPromise = function (localPromise, remotePromise, combiner) { - return new Promise(function (resolve, reject) { - var localResult = []; - var localError = null; - - var remoteResult = []; - var remoteError = null; - - var localPromiseDone = false; - var remotePromiseDone = false; - - var checkAndResolve = function () { - if (localPromiseDone && remotePromiseDone) { - // time to return from this promise - if (localError && remoteError) { - reject([localError, remoteError]); - } else { - Logger.log( - 'About to dedup localResult = ' + - localResult.length + - 'remoteResult = ' + - remoteResult.length, - ); - var dedupedList = combiner(localResult, remoteResult); - Logger.log('Deduped list = ' + dedupedList.length); - resolve(dedupedList); - } - } - }; - - localPromise - .then( - function (currentLocalResult) { - localResult = currentLocalResult; - localPromiseDone = true; - }, - function (error) { - localResult = []; - localError = error; - localPromiseDone = true; - }, - ) - .then(checkAndResolve); - - remotePromise - .then( - function (currentRemoteResult) { - remoteResult = currentRemoteResult; - remotePromiseDone = true; - }, - function (error) { - remoteResult = []; - remoteError = error; - remotePromiseDone = true; - }, - ) - .then(checkAndResolve); - }); - }; - - // TODO: Generalize this to work for both sensor data and messages - // Do we even need to separate the two kinds of data? - // Alternatively, we can maintain another mapping between key -> type - // Probably in www/json... - this.getUnifiedSensorDataForInterval = function (key, tq) { - var localPromise = $window.cordova.plugins.BEMUserCache.getSensorDataForInterval( - key, - tq, - true, - ); - var remotePromise = getRawEntries([key], tq.startTs, tq.endTs).then( - function (serverResponse) { - return serverResponse.phone_data; - }, - ); - return combinedPromise(localPromise, remotePromise, combineWithDedup); - }; - - this.getUnifiedMessagesForInterval = function (key, tq, withMetadata) { - var localPromise = $window.cordova.plugins.BEMUserCache.getMessagesForInterval(key, tq, true); - var remotePromise = getRawEntries([key], tq.startTs, tq.endTs).then( - function (serverResponse) { - return serverResponse.phone_data; - }, - ); - return combinedPromise(localPromise, remotePromise, combineWithDedup); - }; - }) .service('ControlHelper', function ($window, $ionicPopup, Logger) { this.writeFile = function (fileEntry, resultList) { // Create a FileWriter object for our FileEntry (log.txt). From 9b1665a972a8b1b595bf2c2804bfbef22545b402 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 6 Nov 2023 11:59:45 -0700 Subject: [PATCH 331/850] simplify event name storage --- www/__tests__/pushNotifySettings.test.ts | 18 +++++++++--------- www/__tests__/remoteNotifyHandler.test.ts | 12 ++++++------ www/__tests__/storeDeviceSettings.test.ts | 14 +++++++------- www/js/customEventHandler.ts | 2 +- www/js/onboarding/onboardingHelper.ts | 4 ++-- www/js/splash/pushNotifySettings.ts | 10 +++++----- www/js/splash/remoteNotifyHandler.ts | 4 ++-- www/js/splash/startprefs.ts | 4 ++-- www/js/splash/storeDeviceSettings.ts | 10 +++++----- 9 files changed, 39 insertions(+), 39 deletions(-) diff --git a/www/__tests__/pushNotifySettings.test.ts b/www/__tests__/pushNotifySettings.test.ts index bf2ce4343..d452aa819 100644 --- a/www/__tests__/pushNotifySettings.test.ts +++ b/www/__tests__/pushNotifySettings.test.ts @@ -1,5 +1,5 @@ import { DateTime } from 'luxon'; -import { EVENT_NAMES, publish } from '../js/customEventHandler'; +import { EVENTS, publish } from '../js/customEventHandler'; import { INTRO_DONE_KEY, readIntroDone } from '../js/onboarding/onboardingHelper'; import { storageSet } from '../js/plugin/storage'; import { initPushNotify } from '../js/splash/pushNotifySettings'; @@ -42,7 +42,7 @@ afterEach(() => { it('intro done does nothing if not registered', () => { expect(getOnList()).toStrictEqual({}); - publish(EVENT_NAMES.INTRO_DONE_EVENT, 'test data'); + publish(EVENTS.INTRO_DONE_EVENT, 'test data'); expect(getOnList()).toStrictEqual({}); }); @@ -50,7 +50,7 @@ it('intro done initializes the push notifications', () => { expect(getOnList()).toStrictEqual({}); initPushNotify(); - publish(EVENT_NAMES.INTRO_DONE_EVENT, 'test data'); + publish(EVENTS.INTRO_DONE_EVENT, 'test data'); expect(getOnList()).toStrictEqual( expect.objectContaining({ notification: expect.any(Function), @@ -62,7 +62,7 @@ it('intro done initializes the push notifications', () => { it('cloud event does nothing if not registered', () => { expect(window['cordova'].platformId).toEqual('ios'); - publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, { + publish(EVENTS.CLOUD_NOTIFICATION_EVENT, { additionalData: { 'content-available': 1, payload: { notId: 3 } }, }); expect(getCalled()).toBeNull(); @@ -71,8 +71,8 @@ it('cloud event does nothing if not registered', () => { it('cloud event handles notification if registered', async () => { expect(window['cordova'].platformId).toEqual('ios'); initPushNotify(); - publish(EVENT_NAMES.INTRO_DONE_EVENT, 'intro done'); - publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, { + publish(EVENTS.INTRO_DONE_EVENT, 'intro done'); + publish(EVENTS.CLOUD_NOTIFICATION_EVENT, { additionalData: { 'content-available': 1, payload: { notId: 3 } }, }); await new Promise((r) => setTimeout(r, 1000)); @@ -81,7 +81,7 @@ it('cloud event handles notification if registered', async () => { it('consent event does nothing if not registered', () => { expect(getOnList()).toStrictEqual({}); - publish(EVENT_NAMES.CONSENTED_EVENT, 'test data'); + publish(EVENTS.CONSENTED_EVENT, 'test data'); expect(getOnList()).toStrictEqual({}); }); @@ -99,7 +99,7 @@ it('consent event registers if intro done', async () => { expect(introDone).toBeTruthy(); //publish consent event and check results - publish(EVENT_NAMES.CONSENTED_EVENT, 'test data'); + publish(EVENTS.CONSENTED_EVENT, 'test data'); //have to wait a beat since event response is async await new Promise((r) => setTimeout(r, 1000)); expect(getOnList()).toStrictEqual( @@ -114,6 +114,6 @@ it('consent event registers if intro done', async () => { it('consent event does not register if intro not done', () => { expect(getOnList()).toStrictEqual({}); initPushNotify(); - publish(EVENT_NAMES.CONSENTED_EVENT, 'test data'); + publish(EVENTS.CONSENTED_EVENT, 'test data'); expect(getOnList()).toStrictEqual({}); //nothing, intro not done }); diff --git a/www/__tests__/remoteNotifyHandler.test.ts b/www/__tests__/remoteNotifyHandler.test.ts index 1d0220da5..e1965b80b 100644 --- a/www/__tests__/remoteNotifyHandler.test.ts +++ b/www/__tests__/remoteNotifyHandler.test.ts @@ -1,4 +1,4 @@ -import { EVENT_NAMES, publish } from '../js/customEventHandler'; +import { EVENTS, publish } from '../js/customEventHandler'; import { initRemoteNotifyHandler } from '../js/splash/remoteNotifyHandler'; import { clearURL, @@ -25,7 +25,7 @@ beforeEach(() => { }); it('does not adds a statEvent if not subscribed', async () => { - publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, 'test data'); + publish(EVENTS.CLOUD_NOTIFICATION_EVENT, 'test data'); const storedMessages = await db.getAllMessages('stats/client_nav_event', false); expect(storedMessages).toEqual([]); }); @@ -33,7 +33,7 @@ it('does not adds a statEvent if not subscribed', async () => { it('adds a statEvent if subscribed', async () => { initRemoteNotifyHandler(); await new Promise((r) => setTimeout(r, 500)); //wait for subscription - publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, 'test data'); + publish(EVENTS.CLOUD_NOTIFICATION_EVENT, 'test data'); await new Promise((r) => setTimeout(r, 500)); //wait for event handling const storedMessages = await db.getAllMessages('stats/client_nav_event', false); expect(storedMessages).toContainEqual({ @@ -47,7 +47,7 @@ it('adds a statEvent if subscribed', async () => { it('handles the url if subscribed', () => { initRemoteNotifyHandler(); - publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, { + publish(EVENTS.CLOUD_NOTIFICATION_EVENT, { additionalData: { payload: { alert_type: 'website', spec: { url: 'https://this_is_a_test.com' } }, }, @@ -57,7 +57,7 @@ it('handles the url if subscribed', () => { it('handles the popup if subscribed', () => { initRemoteNotifyHandler(); - publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, { + publish(EVENTS.CLOUD_NOTIFICATION_EVENT, { additionalData: { payload: { alert_type: 'popup', @@ -70,7 +70,7 @@ it('handles the popup if subscribed', () => { it('does nothing if subscribed and no data', () => { initRemoteNotifyHandler(); - publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, {}); + publish(EVENTS.CLOUD_NOTIFICATION_EVENT, {}); expect(getURL()).toEqual(''); expect(getAlerts()).toEqual([]); }); diff --git a/www/__tests__/storeDeviceSettings.test.ts b/www/__tests__/storeDeviceSettings.test.ts index ae046216a..3cd6f8319 100644 --- a/www/__tests__/storeDeviceSettings.test.ts +++ b/www/__tests__/storeDeviceSettings.test.ts @@ -11,7 +11,7 @@ import { mockGetAppVersion, } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; -import { EVENT_NAMES, publish } from '../js/customEventHandler'; +import { EVENTS, publish } from '../js/customEventHandler'; import { markIntroDone } from '../js/onboarding/onboardingHelper'; mockBEMUserCache(); @@ -71,14 +71,14 @@ it('verifies my subscrition clearing', async () => { initStoreDeviceSettings(); await new Promise((r) => setTimeout(r, 500)); teardownDeviceSettings(); - publish(EVENT_NAMES.INTRO_DONE_EVENT, 'test data'); + publish(EVENTS.INTRO_DONE_EVENT, 'test data'); let user = await getUser(); expect(user).toBeUndefined(); }); it('does not store if not subscribed', async () => { - publish(EVENT_NAMES.INTRO_DONE_EVENT, 'test data'); - publish(EVENT_NAMES.CONSENTED_EVENT, 'test data'); + publish(EVENTS.INTRO_DONE_EVENT, 'test data'); + publish(EVENTS.CONSENTED_EVENT, 'test data'); await new Promise((r) => setTimeout(r, 500)); //time to carry out event handling let user = await getUser(); expect(user).toBeUndefined(); @@ -87,7 +87,7 @@ it('does not store if not subscribed', async () => { it('stores device settings after intro done', async () => { initStoreDeviceSettings(); await new Promise((r) => setTimeout(r, 500)); //time to check consent and subscribe - publish(EVENT_NAMES.INTRO_DONE_EVENT, 'test data'); + publish(EVENTS.INTRO_DONE_EVENT, 'test data'); await new Promise((r) => setTimeout(r, 500)); //time to carry out event handling let user = await getUser(); expect(user).toMatchObject({ @@ -101,7 +101,7 @@ it('stores device settings after consent if intro done', async () => { await new Promise((r) => setTimeout(r, 500)); //time to check consent and subscribe markIntroDone(); await new Promise((r) => setTimeout(r, 500)); - publish(EVENT_NAMES.CONSENTED_EVENT, 'test data'); + publish(EVENTS.CONSENTED_EVENT, 'test data'); await new Promise((r) => setTimeout(r, 500)); //time to carry out event handling let user = await getUser(); expect(user).toMatchObject({ @@ -113,7 +113,7 @@ it('stores device settings after consent if intro done', async () => { it('does not store device settings after consent if intro not done', async () => { initStoreDeviceSettings(); await new Promise((r) => setTimeout(r, 500)); //time to check consent and subscribe - publish(EVENT_NAMES.CONSENTED_EVENT, 'test data'); + publish(EVENTS.CONSENTED_EVENT, 'test data'); await new Promise((r) => setTimeout(r, 500)); //time to carry out event handling let user = await getUser(); expect(user).toBeUndefined(); diff --git a/www/js/customEventHandler.ts b/www/js/customEventHandler.ts index 600847223..a30a41349 100644 --- a/www/js/customEventHandler.ts +++ b/www/js/customEventHandler.ts @@ -16,7 +16,7 @@ import { logDebug } from './plugin/logger'; /** * central source for event names */ -export const EVENT_NAMES = { +export const EVENTS = { CLOUD_NOTIFICATION_EVENT: 'cloud:push:notification', CONSENTED_EVENT: 'data_collection_consented', INTRO_DONE_EVENT: 'intro_done', diff --git a/www/js/onboarding/onboardingHelper.ts b/www/js/onboarding/onboardingHelper.ts index fb65c1648..d9292547e 100644 --- a/www/js/onboarding/onboardingHelper.ts +++ b/www/js/onboarding/onboardingHelper.ts @@ -2,7 +2,7 @@ import { DateTime } from 'luxon'; import { getConfig, resetDataAndRefresh } from '../config/dynamicConfig'; import { storageGet, storageSet } from '../plugin/storage'; import { logDebug } from '../plugin/logger'; -import { EVENT_NAMES, publish } from '../customEventHandler'; +import { EVENTS, publish } from '../customEventHandler'; import { readConsentState, isConsented } from '../splash/startprefs'; export const INTRO_DONE_KEY = 'intro_done'; @@ -91,6 +91,6 @@ export async function markIntroDone() { return storageSet(INTRO_DONE_KEY, currDateTime).then(() => { //handle "on intro" events logDebug('intro done, publishing event'); - publish(EVENT_NAMES.INTRO_DONE_EVENT, currDateTime); + publish(EVENTS.INTRO_DONE_EVENT, currDateTime); }); } diff --git a/www/js/splash/pushNotifySettings.ts b/www/js/splash/pushNotifySettings.ts index 38f3a4931..a755f7bfe 100644 --- a/www/js/splash/pushNotifySettings.ts +++ b/www/js/splash/pushNotifySettings.ts @@ -15,7 +15,7 @@ import { updateUser } from '../commHelper'; import { logDebug, displayError } from '../plugin/logger'; -import { publish, subscribe, EVENT_NAMES } from '../customEventHandler'; +import { publish, subscribe, EVENTS } from '../customEventHandler'; import { isConsented, readConsentState } from './startprefs'; import { readIntroDone } from '../onboarding/onboardingHelper'; @@ -55,7 +55,7 @@ const startupInit = function () { logDebug('No additional data defined, nothing to parse'); } } - publish(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, data); + publish(EVENTS.CLOUD_NOTIFICATION_EVENT, data); }); }; @@ -233,9 +233,9 @@ export const initPushNotify = function () { } }); - subscribe(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, (event) => onCloudEvent(event, event.detail)); - subscribe(EVENT_NAMES.CONSENTED_EVENT, (event) => onConsentEvent(event, event.detail)); - subscribe(EVENT_NAMES.INTRO_DONE_EVENT, (event) => onIntroEvent(event, event.detail)); + subscribe(EVENTS.CLOUD_NOTIFICATION_EVENT, (event) => onCloudEvent(event, event.detail)); + subscribe(EVENTS.CONSENTED_EVENT, (event) => onConsentEvent(event, event.detail)); + subscribe(EVENTS.INTRO_DONE_EVENT, (event) => onIntroEvent(event, event.detail)); logDebug('pushnotify startup done'); }; diff --git a/www/js/splash/remoteNotifyHandler.ts b/www/js/splash/remoteNotifyHandler.ts index 54a6b32d8..3eec02900 100644 --- a/www/js/splash/remoteNotifyHandler.ts +++ b/www/js/splash/remoteNotifyHandler.ts @@ -10,7 +10,7 @@ * it only supports redirection to a specific app page. If the local * notification handling gets more complex, we should consider decoupling it as well. */ -import { EVENT_NAMES, subscribe } from '../customEventHandler'; +import { EVENTS, subscribe } from '../customEventHandler'; import { addStatEvent, statKeys } from '../plugin/clientStats'; import { displayErrorMsg, logDebug } from '../plugin/logger'; @@ -84,5 +84,5 @@ const onCloudNotifEvent = (event) => { * subscribes to cloud notification event */ export const initRemoteNotifyHandler = function () { - subscribe(EVENT_NAMES.CLOUD_NOTIFICATION_EVENT, onCloudNotifEvent); + subscribe(EVENTS.CLOUD_NOTIFICATION_EVENT, onCloudNotifEvent); }; diff --git a/www/js/splash/startprefs.ts b/www/js/splash/startprefs.ts index 7c75bf3b3..6d5761aae 100644 --- a/www/js/splash/startprefs.ts +++ b/www/js/splash/startprefs.ts @@ -1,6 +1,6 @@ import { storageGet, storageSet } from '../plugin/storage'; import { logInfo, logDebug, displayErrorMsg } from '../plugin/logger'; -import { EVENT_NAMES, publish } from '../customEventHandler'; +import { EVENTS, publish } from '../customEventHandler'; // data collection consented protocol: string, represents the date on // which the consented protocol was approved by the IRB @@ -35,7 +35,7 @@ export function markConsented() { // mark in local variable as well _curr_consented = { ..._req_consent }; // publish event - publish(EVENT_NAMES.CONSENTED_EVENT, _req_consent); + publish(EVENTS.CONSENTED_EVENT, _req_consent); }) .catch((error) => { displayErrorMsg(error, 'Error while while wrting consent to storage'); diff --git a/www/js/splash/storeDeviceSettings.ts b/www/js/splash/storeDeviceSettings.ts index d9604b3b5..79a34f930 100644 --- a/www/js/splash/storeDeviceSettings.ts +++ b/www/js/splash/storeDeviceSettings.ts @@ -3,7 +3,7 @@ import { isConsented, readConsentState } from './startprefs'; import i18next from 'i18next'; import { displayError, logDebug } from '../plugin/logger'; import { readIntroDone } from '../onboarding/onboardingHelper'; -import { subscribe, EVENT_NAMES, unsubscribe } from '../customEventHandler'; +import { subscribe, EVENTS, unsubscribe } from '../customEventHandler'; /** * @function Gathers information about the user's device and stores it @@ -80,13 +80,13 @@ export const initStoreDeviceSettings = function () { } else { logDebug('no consent yet, waiting to store device settings in profile'); } - subscribe(EVENT_NAMES.CONSENTED_EVENT, onConsentEvent); - subscribe(EVENT_NAMES.INTRO_DONE_EVENT, onIntroEvent); + subscribe(EVENTS.CONSENTED_EVENT, onConsentEvent); + subscribe(EVENTS.INTRO_DONE_EVENT, onIntroEvent); }); logDebug('storedevicesettings startup done'); }; export const teardownDeviceSettings = function () { - unsubscribe(EVENT_NAMES.CONSENTED_EVENT, onConsentEvent); - unsubscribe(EVENT_NAMES.INTRO_DONE_EVENT, onIntroEvent); + unsubscribe(EVENTS.CONSENTED_EVENT, onConsentEvent); + unsubscribe(EVENTS.INTRO_DONE_EVENT, onIntroEvent); }; From c25ebb6f71f20d24f14b135186da66013b6806f8 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 6 Nov 2023 12:07:10 -0700 Subject: [PATCH 332/850] directly use alert, for now https://github.com/e-mission/e-mission-phone/pull/1093#discussion_r1383603427 --- www/js/splash/remoteNotifyHandler.ts | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/www/js/splash/remoteNotifyHandler.ts b/www/js/splash/remoteNotifyHandler.ts index 3eec02900..7725138d1 100644 --- a/www/js/splash/remoteNotifyHandler.ts +++ b/www/js/splash/remoteNotifyHandler.ts @@ -28,20 +28,6 @@ const launchWebpage = function (url) { let iab = window['cordova'].InAppBrowser.open(url, '_blank', options); }; -/* -TODO: replace popup with something with better UI -*/ - -/** - * @function launches popup - * @param title string text for popup title - * @param text string text for popup bode - */ -const launchPopup = function (title, text) { - // THIS LINE FOR inAppBrowser - displayErrorMsg(text, title); -}; - /** * @callback for cloud notification event * @param event that triggered this call @@ -71,7 +57,8 @@ const onCloudNotifEvent = (event) => { if (data.additionalData.payload.alert_type == 'popup') { var popup_spec = data.additionalData.payload.spec; if (popup_spec && popup_spec.title && popup_spec.text) { - launchPopup(popup_spec.title, popup_spec.text); + /* TODO: replace popup with something with better UI */ + window.alert(popup_spec.title + popup_spec.text); } else { displayErrorMsg(JSON.stringify(popup_spec), 'popup was not specified correctly. spec is '); } From ad68e1bb0e3a3387850ccc45912a3ce468184a37 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 6 Nov 2023 12:16:57 -0700 Subject: [PATCH 333/850] remove old alerts we still have the information through debug statements --- www/js/splash/pushNotifySettings.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/www/js/splash/pushNotifySettings.ts b/www/js/splash/pushNotifySettings.ts index a755f7bfe..e39849f7a 100644 --- a/www/js/splash/pushNotifySettings.ts +++ b/www/js/splash/pushNotifySettings.ts @@ -90,7 +90,6 @@ const registerPromise = function () { const registerPush = function () { registerPromise() .then(function (t) { - // alert("Token = "+JSON.stringify(t)); logDebug('Token = ' + JSON.stringify(t)); return window['cordova'].plugins.BEMServerSync.getConfig() .then( @@ -112,7 +111,6 @@ const registerPush = function () { }); }) .then(function (t) { - // alert("Finished saving token = "+JSON.stringify(t.token)); logDebug('Finished saving token = ' + JSON.stringify(t.token)); }) .catch(function (error) { From 85d2884128aecc351ccaafd12a7ab553f8385e83 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 6 Nov 2023 12:17:45 -0700 Subject: [PATCH 334/850] swap var for const https://github.com/e-mission/e-mission-phone/pull/1093#discussion_r1383635228 kept to remain consistent within file --- www/js/splash/pushNotifySettings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/splash/pushNotifySettings.ts b/www/js/splash/pushNotifySettings.ts index e39849f7a..f3eb2c029 100644 --- a/www/js/splash/pushNotifySettings.ts +++ b/www/js/splash/pushNotifySettings.ts @@ -160,7 +160,7 @@ const redirectSilentPush = function (event, data) { * @function shows debug notifications if simulating user interaction * @param message string to display in the degug notif */ -var showDebugLocalNotification = function (message) { +const showDebugLocalNotification = function (message) { window['cordova'].plugins.BEMDataCollection.getConfig().then(function (config) { if (config.simulate_user_interaction) { window['cordova'].plugins.notification.local.schedule({ From 29f44e765e764d7a0a5d17b573048b5560146446 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 6 Nov 2023 12:29:28 -0700 Subject: [PATCH 335/850] update test when I moved from the formatted alert to a plain alert, the text that ends up in the alert changed, but I did not update my test also added a space between the title and the text --- www/__tests__/remoteNotifyHandler.test.ts | 2 +- www/js/splash/remoteNotifyHandler.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/www/__tests__/remoteNotifyHandler.test.ts b/www/__tests__/remoteNotifyHandler.test.ts index e1965b80b..320877c6b 100644 --- a/www/__tests__/remoteNotifyHandler.test.ts +++ b/www/__tests__/remoteNotifyHandler.test.ts @@ -65,7 +65,7 @@ it('handles the popup if subscribed', () => { }, }, }); - expect(getAlerts()).toEqual(expect.arrayContaining(['━━━━\nHello\n━━━━\nWorld'])); + expect(getAlerts()).toEqual(expect.arrayContaining(['Hello World'])); }); it('does nothing if subscribed and no data', () => { diff --git a/www/js/splash/remoteNotifyHandler.ts b/www/js/splash/remoteNotifyHandler.ts index 7725138d1..cbc920ced 100644 --- a/www/js/splash/remoteNotifyHandler.ts +++ b/www/js/splash/remoteNotifyHandler.ts @@ -58,7 +58,7 @@ const onCloudNotifEvent = (event) => { var popup_spec = data.additionalData.payload.spec; if (popup_spec && popup_spec.title && popup_spec.text) { /* TODO: replace popup with something with better UI */ - window.alert(popup_spec.title + popup_spec.text); + window.alert(popup_spec.title + ' ' + popup_spec.text); } else { displayErrorMsg(JSON.stringify(popup_spec), 'popup was not specified correctly. spec is '); } From 000f08d26ea43bca1d89945fb6c5422385e2c092 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Mon, 6 Nov 2023 14:21:33 -0800 Subject: [PATCH 336/850] Refactored combinedPromises - combinedPromises now uses `Promise.allSettled()` - rewrote tests to reflect change --- www/__tests__/unifiedDataLoader.test.ts | 73 +++++++--------- www/js/diary/services.js | 109 ++++++++++++------------ www/js/services/unifiedDataLoader.ts | 109 ++++++------------------ 3 files changed, 114 insertions(+), 177 deletions(-) diff --git a/www/__tests__/unifiedDataLoader.test.ts b/www/__tests__/unifiedDataLoader.test.ts index 285971f0a..c31172d0c 100644 --- a/www/__tests__/unifiedDataLoader.test.ts +++ b/www/__tests__/unifiedDataLoader.test.ts @@ -1,5 +1,5 @@ import { mockLogger } from '../__mocks__/globalMocks'; -import { combineWithDedup, combinedPromises } from '../js/services/unifiedDataLoader'; +import { removeDup, combinedPromises } from '../js/services/unifiedDataLoader'; import { ServerData } from '../js/types/serverData'; mockLogger(); @@ -9,7 +9,7 @@ const testOne: ServerData = { metadata: { key: '', platform: '', - write_ts: 1, // the only value checked by combineWithDedup + write_ts: 1, // the only value checked by removeDup time_zone: '', write_fmt_time: '', write_local_dt: null, @@ -23,25 +23,21 @@ testThree.metadata.write_ts = 3; const testFour = JSON.parse(JSON.stringify(testOne)); testFour.metadata.write_ts = 4; -describe('combineWithDedup can', () => { - it('work with empty arrays', () => { - expect(combineWithDedup([], [])).toEqual([]); - expect(combineWithDedup([], [testOne])).toEqual([testOne]); - expect(combineWithDedup([testOne, testTwo], [])).toEqual([testOne, testTwo]); +describe('removeDup can', () => { + it('work with an empty array', () => { + expect(removeDup([])).toEqual([]); }); - it('work with arrays of len 1', () => { - expect(combineWithDedup([testOne], [testOne])).toEqual([testOne]); - expect(combineWithDedup([testOne], [testTwo])).toEqual([testOne, testTwo]); + + it('work with an array of len 1', () => { + expect(removeDup([testOne])).toEqual([testOne]); }); - it('work with arrays of len > 1', () => { - expect(combineWithDedup([testOne], [testOne, testTwo])).toEqual([testOne, testTwo]); - expect(combineWithDedup([testOne], [testTwo, testTwo])).toEqual([testOne, testTwo]); - expect(combineWithDedup([testOne, testTwo], [testTwo, testTwo])).toEqual([testOne, testTwo]); - expect(combineWithDedup([testOne, testTwo, testThree], [testOne, testTwo])).toEqual([ - testOne, - testTwo, - testThree, - ]); + + it('work with an array of len >=1', () => { + expect(removeDup([testOne, testTwo])).toEqual([testOne, testTwo]); + expect(removeDup([testOne, testOne])).toEqual([testOne]); + expect(removeDup([testOne, testTwo, testThree])).toEqual([testOne, testTwo, testThree]); + expect(removeDup([testOne, testOne, testThree])).toEqual([testOne, testThree]); + expect(removeDup([testOne, testOne, testOne])).toEqual([testOne]); }); }); @@ -55,38 +51,35 @@ const badPromiseGenerator = (input: string) => { it('throws an error on an empty input', async () => { expect(() => { - combinedPromises([], combineWithDedup); + combinedPromises([], removeDup); }).toThrow(); }); it('catches when all promises fails', async () => { - expect(combinedPromises([badPromiseGenerator('')], combineWithDedup)).rejects.toEqual(['']); + expect(combinedPromises([badPromiseGenerator('')], removeDup)).rejects.toEqual(['']); expect( - combinedPromises( - [badPromiseGenerator('bad'), badPromiseGenerator('promise')], - combineWithDedup, - ), + combinedPromises([badPromiseGenerator('bad'), badPromiseGenerator('promise')], removeDup), ).rejects.toEqual(['bad', 'promise']); expect( combinedPromises( [badPromiseGenerator('very'), badPromiseGenerator('bad'), badPromiseGenerator('promise')], - combineWithDedup, + removeDup, ), ).rejects.toEqual(['very', 'bad', 'promise']); expect( - combinedPromises([badPromiseGenerator('bad'), promiseGenerator([testOne])], combineWithDedup), + combinedPromises([badPromiseGenerator('bad'), promiseGenerator([testOne])], removeDup), ).resolves.toEqual([testOne]); expect( - combinedPromises([promiseGenerator([testOne]), badPromiseGenerator('bad')], combineWithDedup), + combinedPromises([promiseGenerator([testOne]), badPromiseGenerator('bad')], removeDup), ).resolves.toEqual([testOne]); }); it('work with arrays of len 1', async () => { const promiseArrayOne = [promiseGenerator([testOne])]; const promiseArrayTwo = [promiseGenerator([testOne, testTwo])]; - const testResultOne = await combinedPromises(promiseArrayOne, combineWithDedup); - const testResultTwo = await combinedPromises(promiseArrayTwo, combineWithDedup); + const testResultOne = await combinedPromises(promiseArrayOne, removeDup); + const testResultTwo = await combinedPromises(promiseArrayTwo, removeDup); expect(testResultOne).toEqual([testOne]); expect(testResultTwo).toEqual([testOne, testTwo]); @@ -105,11 +98,11 @@ it('works with arrays of len 2', async () => { promiseGenerator([testTwo, testThree]), ]; - const testResultOne = await combinedPromises(promiseArrayOne, combineWithDedup); - const testResultTwo = await combinedPromises(promiseArrayTwo, combineWithDedup); - const testResultThree = await combinedPromises(promiseArrayThree, combineWithDedup); - const testResultFour = await combinedPromises(promiseArrayFour, combineWithDedup); - const testResultFive = await combinedPromises(promiseArrayFive, combineWithDedup); + const testResultOne = await combinedPromises(promiseArrayOne, removeDup); + const testResultTwo = await combinedPromises(promiseArrayTwo, removeDup); + const testResultThree = await combinedPromises(promiseArrayThree, removeDup); + const testResultFour = await combinedPromises(promiseArrayFour, removeDup); + const testResultFive = await combinedPromises(promiseArrayFive, removeDup); expect(testResultOne).toEqual([testOne, testTwo]); expect(testResultTwo).toEqual([testOne, testTwo, testThree]); @@ -140,10 +133,10 @@ it('works with arrays of len >= 2', async () => { promiseGenerator([testFour]), ]; - const testResultOne = await combinedPromises(promiseArrayOne, combineWithDedup); - const testResultTwo = await combinedPromises(promiseArrayTwo, combineWithDedup); - const testResultThree = await combinedPromises(promiseArrayThree, combineWithDedup); - const testResultFour = await combinedPromises(promiseArrayFour, combineWithDedup); + const testResultOne = await combinedPromises(promiseArrayOne, removeDup); + const testResultTwo = await combinedPromises(promiseArrayTwo, removeDup); + const testResultThree = await combinedPromises(promiseArrayThree, removeDup); + const testResultFour = await combinedPromises(promiseArrayFour, removeDup); expect(testResultOne).toEqual([testOne, testTwo, testThree]); expect(testResultTwo).toEqual([testOne, testTwo]); @@ -154,4 +147,4 @@ it('works with arrays of len >= 2', async () => { /* TO-DO: Once getRawEnteries can be tested via end-to-end testing, we will be able to test getUnifiedDataForInterval as well. -*/ +*/ \ No newline at end of file diff --git a/www/js/diary/services.js b/www/js/diary/services.js index d9ec7fbe3..7d746fc94 100644 --- a/www/js/diary/services.js +++ b/www/js/diary/services.js @@ -16,7 +16,6 @@ angular $ionicPlatform, $window, $rootScope, - UnifiedDataLoader, Logger, $injector, ) { @@ -268,64 +267,65 @@ angular ' -> ' + moment.unix(tripEndTransition.data.ts).toString(), ); - return UnifiedDataLoader.getUnifiedSensorDataForInterval( - 'background/filtered_location', - tq, - ).then(function (locationList) { - if (locationList.length == 0) { - return undefined; - } - var sortedLocationList = locationList.sort(tsEntrySort); - var retainInRange = function (loc) { - return ( - tripStartTransition.data.ts <= loc.data.ts && loc.data.ts <= tripEndTransition.data.ts - ); - }; + const getMethod = window['cordova'].plugins.BEMUserCache.getSensorDataForInterval; + return getUnifiedDataForInterval('background/filtered_location', tq, getMethod).then( + function (locationList) { + if (locationList.length == 0) { + return undefined; + } + var sortedLocationList = locationList.sort(tsEntrySort); + var retainInRange = function (loc) { + return ( + tripStartTransition.data.ts <= loc.data.ts && + loc.data.ts <= tripEndTransition.data.ts + ); + }; - var filteredLocationList = sortedLocationList.filter(retainInRange); + var filteredLocationList = sortedLocationList.filter(retainInRange); - // Fix for https://github.com/e-mission/e-mission-docs/issues/417 - if (filteredLocationList.length == 0) { - return undefined; - } + // Fix for https://github.com/e-mission/e-mission-docs/issues/417 + if (filteredLocationList.length == 0) { + return undefined; + } - var tripStartPoint = filteredLocationList[0]; - var tripEndPoint = filteredLocationList[filteredLocationList.length - 1]; - Logger.log( - 'tripStartPoint = ' + - JSON.stringify(tripStartPoint) + - 'tripEndPoint = ' + - JSON.stringify(tripEndPoint), - ); - // if we get a list but our start and end are undefined - // let's print out the complete original list to get a clue - // this should help with debugging - // https://github.com/e-mission/e-mission-docs/issues/417 - // if it ever occurs again - if (angular.isUndefined(tripStartPoint) || angular.isUndefined(tripEndPoint)) { - Logger.log('BUG 417 check: locationList = ' + JSON.stringify(locationList)); + var tripStartPoint = filteredLocationList[0]; + var tripEndPoint = filteredLocationList[filteredLocationList.length - 1]; Logger.log( - 'transitions: start = ' + - JSON.stringify(tripStartTransition.data) + - ' end = ' + - JSON.stringify(tripEndTransition.data.ts), + 'tripStartPoint = ' + + JSON.stringify(tripStartPoint) + + 'tripEndPoint = ' + + JSON.stringify(tripEndPoint), ); - } + // if we get a list but our start and end are undefined + // let's print out the complete original list to get a clue + // this should help with debugging + // https://github.com/e-mission/e-mission-docs/issues/417 + // if it ever occurs again + if (angular.isUndefined(tripStartPoint) || angular.isUndefined(tripEndPoint)) { + Logger.log('BUG 417 check: locationList = ' + JSON.stringify(locationList)); + Logger.log( + 'transitions: start = ' + + JSON.stringify(tripStartTransition.data) + + ' end = ' + + JSON.stringify(tripEndTransition.data.ts), + ); + } - const tripProps = points2TripProps(filteredLocationList); - - return { - ...tripProps, - start_loc: { - type: 'Point', - coordinates: [tripStartPoint.data.longitude, tripStartPoint.data.latitude], - }, - end_loc: { - type: 'Point', - coordinates: [tripEndPoint.data.longitude, tripEndPoint.data.latitude], - }, - }; - }); + const tripProps = points2TripProps(filteredLocationList); + + return { + ...tripProps, + start_loc: { + type: 'Point', + coordinates: [tripStartPoint.data.longitude, tripStartPoint.data.latitude], + }, + end_loc: { + type: 'Point', + coordinates: [tripEndPoint.data.longitude, tripEndPoint.data.latitude], + }, + }; + }, + ); }; var linkTrips = function (trip1, trip2) { @@ -354,7 +354,8 @@ angular ' -> ' + moment.unix(tq.endTs).toString(), ); - return UnifiedDataLoader.getUnifiedMessagesForInterval('statemachine/transition', tq).then( + const getMethod = window['cordova'].plugins.BEMUserCache.getMessagesForInterval; + return getUnifiedDataForInterval('statemachine/transition', tq, getMethod).then( function (transitionList) { if (transitionList.length == 0) { Logger.log('No unprocessed trips. yay!'); diff --git a/www/js/services/unifiedDataLoader.ts b/www/js/services/unifiedDataLoader.ts index d4211b4ce..8a91d3587 100644 --- a/www/js/services/unifiedDataLoader.ts +++ b/www/js/services/unifiedDataLoader.ts @@ -1,16 +1,13 @@ -import { logDebug } from '../plugin/logger'; import { getRawEntries } from './commHelper'; import { ServerResponse, ServerData, TimeQuery } from '../types/serverData'; /** - * combineWithDedup is a helper function for combinedPromises - * @param list1 values evaluated from a BEMUserCache promise - * @param list2 same as list1 - * @returns a dedup array generated from the input lists + * removeDup is a helper function for combinedPromises + * @param list An array of values from a BEMUserCache promise + * @returns an array with duplicate values removed */ -export const combineWithDedup = function (list1: Array>, list2: Array) { - const combinedList = list1.concat(list2); - return combinedList.filter(function (value, i, array) { +export const removeDup = function (list: Array>) { + return list.filter(function (value, i, array) { const firstIndexOfValue = array.findIndex(function (element) { return element.metadata.write_ts == value.metadata.write_ts; }); @@ -18,86 +15,32 @@ export const combineWithDedup = function (list1: Array>, list2: }); }; -/** - * combinedPromises is a recursive function that joins multiple promises - * @param promiseList 1 or more promises - * @param combiner a function that takes two arrays and joins them - * @returns A promise which evaluates to a combined list of values or errors - */ export const combinedPromises = function ( promiseList: Array>, - combiner: (list1: Array, list2: Array) => Array, + filter: (list: Array) => Array, ) { if (promiseList.length === 0) { throw new RangeError('combinedPromises needs input array.length >= 1'); } return new Promise(function (resolve, reject) { - var firstResult = []; - var firstError = null; - - var nextResult = []; - var nextError = null; - - var firstPromiseDone = false; - var nextPromiseDone = false; - - const checkAndResolve = function () { - if (firstPromiseDone && nextPromiseDone) { - if (firstError && nextError) { - reject([firstError].concat(nextError)); - } else { - logDebug( - `About to dedup firstResult = ${firstResult.length}` + - ` nextResult = ${nextResult.length}`, - ); - const dedupedList = combiner(firstResult, nextResult); - logDebug(`Deduped list = ${dedupedList.length}`); - resolve(dedupedList); - } - } - }; - - if (promiseList.length === 1) { - return promiseList[0].then( - function (result: Array) { - resolve(result); - }, - function (err) { - reject([err]); - }, - ); - } - - const firstPromise = promiseList[0]; - const nextPromise = combinedPromises(promiseList.slice(1), combiner); - - firstPromise - .then( - function (currentFirstResult: Array) { - firstResult = currentFirstResult; - firstPromiseDone = true; - }, - function (error) { - firstResult = []; - firstError = error; - firstPromiseDone = true; - }, - ) - .then(checkAndResolve); - - nextPromise - .then( - function (currentNextResult: Array) { - nextResult = currentNextResult; - nextPromiseDone = true; - }, - function (error) { - nextResult = []; - nextError = error; - nextPromiseDone = true; - }, - ) - .then(checkAndResolve); + Promise.allSettled(promiseList).then( + (results) => { + var allRej = true; + var values = []; + var rejections = []; + results.forEach((item) => { + if (item.status === 'fulfilled') { + if (allRej) allRej = false; + if (item.value.length != 0) values.push(item.value); + } else rejections.push(item.reason); + }); + if (allRej) reject(rejections); + else resolve(filter(values.flat(1))); + }, + (err) => { + reject(err); + }, + ); }); }; @@ -121,5 +64,5 @@ export const getUnifiedDataForInterval = function ( return serverResponse.phone_data; }); const promiseList = [getPromise, remotePromise]; - return combinedPromises(promiseList, combineWithDedup); -}; + return combinedPromises(promiseList, removeDup); +}; \ No newline at end of file From 71bee6f58681426ec87fb8dd2a5595451490177a Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Mon, 6 Nov 2023 15:03:14 -0800 Subject: [PATCH 337/850] Prettier cleanup --- www/__tests__/unifiedDataLoader.test.ts | 2 +- www/js/diary/services.js | 10 +--------- www/js/services/unifiedDataLoader.ts | 2 +- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/www/__tests__/unifiedDataLoader.test.ts b/www/__tests__/unifiedDataLoader.test.ts index c31172d0c..57b1023da 100644 --- a/www/__tests__/unifiedDataLoader.test.ts +++ b/www/__tests__/unifiedDataLoader.test.ts @@ -147,4 +147,4 @@ it('works with arrays of len >= 2', async () => { /* TO-DO: Once getRawEnteries can be tested via end-to-end testing, we will be able to test getUnifiedDataForInterval as well. -*/ \ No newline at end of file +*/ diff --git a/www/js/diary/services.js b/www/js/diary/services.js index 7d746fc94..1c63bf18d 100644 --- a/www/js/diary/services.js +++ b/www/js/diary/services.js @@ -10,15 +10,7 @@ angular .module('emission.main.diary.services', ['emission.plugin.logger', 'emission.services']) .factory( 'Timeline', - function ( - $http, - $ionicLoading, - $ionicPlatform, - $window, - $rootScope, - Logger, - $injector, - ) { + function ($http, $ionicLoading, $ionicPlatform, $window, $rootScope, Logger, $injector) { var timeline = {}; // corresponds to the old $scope.data. Contains all state for the current // day, including the indication of the current day diff --git a/www/js/services/unifiedDataLoader.ts b/www/js/services/unifiedDataLoader.ts index 8a91d3587..8ab4ad2f9 100644 --- a/www/js/services/unifiedDataLoader.ts +++ b/www/js/services/unifiedDataLoader.ts @@ -65,4 +65,4 @@ export const getUnifiedDataForInterval = function ( }); const promiseList = [getPromise, remotePromise]; return combinedPromises(promiseList, removeDup); -}; \ No newline at end of file +}; From 6dcf1abb6722931b69bf8cef807c42df4e62ede7 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Mon, 6 Nov 2023 15:46:39 -0800 Subject: [PATCH 338/850] Removed $ionicLoading - Behavior remains unchanged when removing this component - labelTab and other parent components handle loading, so the ionic service is not needed. --- www/js/diary/diaryTypes.ts | 72 ---------------------------------- www/js/diary/timelineHelper.ts | 29 +++----------- www/js/types/labelTypes.ts | 24 ++++++++++++ 3 files changed, 30 insertions(+), 95 deletions(-) delete mode 100644 www/js/diary/diaryTypes.ts create mode 100644 www/js/types/labelTypes.ts diff --git a/www/js/diary/diaryTypes.ts b/www/js/diary/diaryTypes.ts deleted file mode 100644 index 5755c91ab..000000000 --- a/www/js/diary/diaryTypes.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* These type definitions are a work in progress. The goal is to have a single source of truth for - the types of the trip / place / untracked objects and all properties they contain. - Since we are using TypeScript now, we should strive to enforce type safety and also benefit from - IntelliSense and other IDE features. */ - -// Since it is WIP, these types are not used anywhere yet. - -type ConfirmedPlace = any; // TODO - -/* These are the properties received from the server (basically matches Python code) - This should match what Timeline.readAllCompositeTrips returns (an array of these objects) */ -export type CompositeTrip = { - _id: { $oid: string }; - additions: any[]; // TODO - cleaned_section_summary: any; // TODO - cleaned_trip: { $oid: string }; - confidence_threshold: number; - confirmed_trip: { $oid: string }; - distance: number; - duration: number; - end_confirmed_place: ConfirmedPlace; - end_fmt_time: string; - end_loc: { type: string; coordinates: number[] }; - end_local_dt: any; // TODO - end_place: { $oid: string }; - end_ts: number; - expectation: any; // TODO "{to_label: boolean}" - expected_trip: { $oid: string }; - inferred_labels: any[]; // TODO - inferred_section_summary: any; // TODO - inferred_trip: { $oid: string }; - key: string; - locations: any[]; // TODO - origin_key: string; - raw_trip: { $oid: string }; - sections: any[]; // TODO - source: string; - start_confirmed_place: ConfirmedPlace; - start_fmt_time: string; - start_loc: { type: string; coordinates: number[] }; - start_local_dt: any; // TODO - start_place: { $oid: string }; - start_ts: number; - user_input: any; // TODO -}; - -/* These properties aren't received from the server, but are derived from the above properties. - They are used in the UI to display trip/place details and are computed by the useDerivedProperties hook. */ -export type DerivedProperties = { - displayDate: string; - displayStartTime: string; - displayEndTime: string; - displayTime: string; - displayStartDateAbbr: string; - displayEndDateAbbr: string; - formattedDistance: string; - formattedSectionProperties: any[]; // TODO - distanceSuffix: string; - detectedModes: { mode: string; icon: string; color: string; pct: number | string }[]; -}; - -/* These are the properties that are still filled in by some kind of 'populate' mechanism. - It would simplify the codebase to just compute them where they're needed - (using memoization when apt so performance is not impacted). */ -export type PopulatedTrip = CompositeTrip & { - additionsList?: any[]; // TODO - finalInference?: any; // TODO - geojson?: any; // TODO - getNextEntry?: () => PopulatedTrip | ConfirmedPlace; - userInput?: any; // TODO - verifiability?: string; -}; diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 72917f00b..07cdb6dc8 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -7,12 +7,15 @@ import { ServerResponse, ServerData } from '../types/serverData'; import L from 'leaflet'; import i18next from 'i18next'; import { DateTime } from 'luxon'; +import { CompositeTrip } from '../types/diaryTypes'; +import { LabelOptions } from '../types/labelTypes'; const cachedGeojsons = new Map(); + /** * @description Gets a formatted GeoJSON object for a trip, including the start and end places and the trajectory. */ -export function useGeojsonForTrip(trip, labelOptions, labeledMode?) { +export function useGeojsonForTrip(trip: CompositeTrip, labelOptions: LabelOptions, labeledMode?) { if (!trip) return; const gjKey = `trip-${trip._id.$oid}-${labeledMode || 'detected'}`; if (cachedGeojsons.has(gjKey)) { @@ -230,14 +233,9 @@ const unpackServerData = (obj: ServerData) => ({ }); export const readAllCompositeTrips = function (startTs: number, endTs: number) { - const $ionicLoading = getAngularService('$ionicLoading'); - $ionicLoading.show({ - template: i18next.t('service.reading-server'), - }); const readPromises = [getRawEntries(['analysis/composite_trip'], startTs, endTs, 'data.end_ts')]; return Promise.all(readPromises) .then(([ctList]: [ServerResponse]) => { - $ionicLoading.hide(); return ctList.phone_data.map((ct) => { const unpackedCt = unpackServerData(ct); return { @@ -251,7 +249,6 @@ export const readAllCompositeTrips = function (startTs: number, endTs: number) { }) .catch((err) => { displayError(err, 'while reading confirmed trips'); - $ionicLoading.hide(); return []; }); }; @@ -271,14 +268,7 @@ const dateTime2localdate = function (currtime: DateTime, tz: string) { second: currtime.get('second'), }; }; -/* locationPoints are of form: - * ServerData - * Point = { - * currentState: string, - * transition: string, - * ts: number, // 1698433683.712 - * } - */ + const points2TripProps = function (locationPoints) { const startPoint = locationPoints[0]; const endPoint = locationPoints[locationPoints.length - 1]; @@ -520,11 +510,6 @@ const linkTrips = function (trip1, trip2) { }; export const readUnprocessedTrips = function (startTs, endTs, lastProcessedTrip) { - const $ionicLoading = getAngularService('$ionicLoading'); - $ionicLoading.show({ - template: i18next.t('service.reading-unprocessed-data'), - }); - var tq = { key: 'write_ts', startTs, endTs }; logDebug( 'about to query for unprocessed trips from ' + @@ -538,14 +523,13 @@ export const readUnprocessedTrips = function (startTs, endTs, lastProcessedTrip) ) { if (transitionList.length == 0) { logDebug('No unprocessed trips. yay!'); - $ionicLoading.hide(); return []; } else { logDebug(`Found ${transitionList.length} transitions. yay!`); const tripsList = transitions2Trips(transitionList); logDebug(`Mapped into ${tripsList.length} trips. yay!`); tripsList.forEach(function (trip) { - console.log(JSON.stringify(trip)); + logDebug(JSON.stringify(trip)); }); var tripFillPromises = tripsList.map(transitionTrip2TripObj); return Promise.all(tripFillPromises).then(function (raw_trip_gj_list) { @@ -577,7 +561,6 @@ export const readUnprocessedTrips = function (startTs, endTs, lastProcessedTrip) logDebug('linking unprocessed and processed trip chains'); linkTrips(lastProcessedTrip, trip_gj_list[0]); } - $ionicLoading.hide(); logDebug(`Returning final list of size ${trip_gj_list.length}`); return trip_gj_list; }); diff --git a/www/js/types/labelTypes.ts b/www/js/types/labelTypes.ts new file mode 100644 index 000000000..9719531a8 --- /dev/null +++ b/www/js/types/labelTypes.ts @@ -0,0 +1,24 @@ +export type LabelOptions = { + MODE: Array; + PURPOSE: Array; + REPLACE_MODE: Array; +}; + +export type ModeLabel = { + value: string; + baseMode: string; + met_equivalent?: string; + met?: { + ALL: { + range: Array; + mets: number; + }; + }; + kgCo2PerKm: number; + test: string; +}; + +export type PurposeReplaceLabel = { + value: string; + test: string; +}; From b0da6e90f47c55ba4e1be5e8657397d8c8b568b4 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Tue, 7 Nov 2023 09:07:18 -0700 Subject: [PATCH 339/850] Remove old Angular notifScheduler.js --- www/index.js | 1 - www/js/main.js | 1 - www/js/splash/notifScheduler.js | 265 -------------------------------- 3 files changed, 267 deletions(-) delete mode 100644 www/js/splash/notifScheduler.js diff --git a/www/index.js b/www/index.js index 78d29cf7a..6adc62186 100644 --- a/www/index.js +++ b/www/index.js @@ -9,7 +9,6 @@ import './js/splash/pushnotify.js'; import './js/splash/storedevicesettings.js'; import './js/splash/localnotify.js'; import './js/splash/remotenotify.js'; -import './js/splash/notifScheduler.js'; import './js/controllers.js'; import './js/services.js'; import './js/i18n-utils.js'; diff --git a/www/js/main.js b/www/js/main.js index 2b351e2c4..ce245cf98 100644 --- a/www/js/main.js +++ b/www/js/main.js @@ -6,7 +6,6 @@ angular .module('emission.main', [ 'emission.main.diary', 'emission.i18n.utils', - 'emission.splash.notifscheduler', 'emission.main.metrics.factory', 'emission.main.metrics.mappings', 'emission.services', diff --git a/www/js/splash/notifScheduler.js b/www/js/splash/notifScheduler.js deleted file mode 100644 index 9ceb0a23e..000000000 --- a/www/js/splash/notifScheduler.js +++ /dev/null @@ -1,265 +0,0 @@ -'use strict'; - -import angular from 'angular'; -import { getConfig } from '../config/dynamicConfig'; -import { addStatReading, statKeys } from '../plugin/clientStats'; -import { getUser, updateUser } from '../commHelper'; - -angular - .module('emission.splash.notifscheduler', ['emission.services', 'emission.plugin.logger']) - - .factory('NotificationScheduler', function ($http, $window, $ionicPlatform, Logger) { - const scheduler = {}; - let _config; - let scheduledPromise = new Promise((rs) => rs()); - let isScheduling = false; - - // like python range() - function range(start, stop, step) { - let a = [start], - b = start; - while (b < stop) a.push((b += step || 1)); - return a; - } - - // returns an array of moment objects, for all times that notifications should be sent - const calcNotifTimes = (scheme, dayZeroDate, timeOfDay) => { - const notifTimes = []; - for (const s of scheme.schedule) { - // the days to send notifications, as integers, relative to day zero - const notifDays = range(s.start, s.end, s.intervalInDays); - for (const d of notifDays) { - const date = moment(dayZeroDate).add(d, 'days').format('YYYY-MM-DD'); - const notifTime = moment(date + ' ' + timeOfDay, 'YYYY-MM-DD HH:mm'); - notifTimes.push(notifTime); - } - } - return notifTimes; - }; - - // returns true if all expected times are already scheduled - const areAlreadyScheduled = (notifs, expectedTimes) => { - for (const t of expectedTimes) { - if (!notifs.some((n) => moment(n.at).isSame(t))) { - return false; - } - } - return true; - }; - - /* remove notif actions as they do not work, can restore post routing migration */ - // const setUpActions = () => { - // const action = { - // id: 'action', - // title: 'Change Time', - // launch: true - // }; - // return new Promise((rs) => { - // cordova.plugins.notification.local.addActions('reminder-actions', [action], rs); - // }); - // } - - function debugGetScheduled(prefix) { - cordova.plugins.notification.local.getScheduled((notifs) => { - if (!notifs?.length) return Logger.log(`${prefix}, there are no scheduled notifications`); - const time = moment(notifs?.[0].trigger.at).format('HH:mm'); - //was in plugin, changed to scheduler - scheduler.scheduledNotifs = notifs.map((n) => { - const time = moment(n.trigger.at).format('LT'); - const date = moment(n.trigger.at).format('LL'); - return { - key: date, - val: time, - }; - }); - //have the list of scheduled show up in this log - Logger.log( - `${prefix}, there are ${notifs.length} scheduled notifications at ${time} first is ${scheduler.scheduledNotifs[0].key} at ${scheduler.scheduledNotifs[0].val}`, - ); - }); - } - - //new method to fetch notifications - scheduler.getScheduledNotifs = function () { - return new Promise((resolve, reject) => { - /* if the notifications are still in active scheduling it causes problems - anywhere from 0-n of the scheduled notifs are displayed - if actively scheduling, wait for the scheduledPromise to resolve before fetching prevents such errors - */ - if (isScheduling) { - console.log( - 'requesting fetch while still actively scheduling, waiting on scheduledPromise', - ); - scheduledPromise.then(() => { - getNotifs().then((notifs) => { - console.log('done scheduling notifs', notifs); - resolve(notifs); - }); - }); - } else { - getNotifs().then((notifs) => { - resolve(notifs); - }); - } - }); - }; - - //get scheduled notifications from cordova plugin and format them - const getNotifs = function () { - return new Promise((resolve, reject) => { - cordova.plugins.notification.local.getScheduled((notifs) => { - if (!notifs?.length) { - console.log('there are no notifications'); - resolve([]); //if none, return empty array - } - - const notifSubset = notifs.slice(0, 5); //prevent near-infinite listing - let scheduledNotifs = []; - scheduledNotifs = notifSubset.map((n) => { - const time = moment(n.trigger.at).format('LT'); - const date = moment(n.trigger.at).format('LL'); - return { - key: date, - val: time, - }; - }); - resolve(scheduledNotifs); - }); - }); - }; - - // schedules the notifications using the cordova plugin - const scheduleNotifs = (scheme, notifTimes) => { - return new Promise((rs) => { - isScheduling = true; - const localeCode = i18next.resolvedLanguage; - const nots = notifTimes.map((n) => { - const nDate = n.toDate(); - const seconds = nDate.getTime() / 1000; - return { - id: seconds, - title: scheme.title[localeCode], - text: scheme.text[localeCode], - trigger: { at: nDate }, - // actions: 'reminder-actions', - // data: { - // action: { - // redirectTo: 'root.main.control', - // redirectParams: { - // openTimeOfDayPicker: true - // } - // } - // } - }; - }); - cordova.plugins.notification.local.cancelAll(() => { - debugGetScheduled('After cancelling'); - cordova.plugins.notification.local.schedule(nots, () => { - debugGetScheduled('After scheduling'); - isScheduling = false; - rs(); //scheduling promise resolved here - }); - }); - }); - }; - - // determines when notifications are needed, and schedules them if not already scheduled - const update = async () => { - const { reminder_assignment, reminder_join_date, reminder_time_of_day } = - await scheduler.getReminderPrefs(); - const scheme = _config.reminderSchemes[reminder_assignment]; - const notifTimes = calcNotifTimes(scheme, reminder_join_date, reminder_time_of_day); - - return new Promise((resolve, reject) => { - cordova.plugins.notification.local.getScheduled((notifs) => { - if (areAlreadyScheduled(notifs, notifTimes)) { - Logger.log('Already scheduled, not scheduling again'); - } else { - // to ensure we don't overlap with the last scheduling() request, - // we'll wait for the previous one to finish before scheduling again - scheduledPromise.then(() => { - if (isScheduling) { - console.log('ERROR: Already scheduling notifications, not scheduling again'); - } else { - scheduledPromise = scheduleNotifs(scheme, notifTimes); - //enforcing end of scheduling to conisder update through - scheduledPromise.then(() => { - resolve(); - }); - } - }); - } - }); - }); - }; - - /* Randomly assign a scheme, set the join date to today, - and use the default time of day from config (or noon if not specified) - This is only called once when the user first joins the study - */ - const initReminderPrefs = () => { - // randomly assign from the schemes listed in config - const schemes = Object.keys(_config.reminderSchemes); - const randAssignment = schemes[Math.floor(Math.random() * schemes.length)]; - const todayDate = moment().format('YYYY-MM-DD'); - const defaultTime = _config.reminderSchemes[randAssignment]?.defaultTime || '12:00'; - return { - reminder_assignment: randAssignment, - reminder_join_date: todayDate, - reminder_time_of_day: defaultTime, - }; - }; - - /* EXAMPLE VALUES - present in user profile object - reminder_assignment: 'passive', - reminder_join_date: '2023-05-09', - reminder_time_of_day: '21:00', - */ - - scheduler.getReminderPrefs = async () => { - const user = await getUser(); - if (user?.reminder_assignment && user?.reminder_join_date && user?.reminder_time_of_day) { - return user; - } - // if no prefs, user just joined, so initialize them - const initPrefs = initReminderPrefs(); - await scheduler.setReminderPrefs(initPrefs); - return { ...user, ...initPrefs }; // user profile + the new prefs - }; - - scheduler.setReminderPrefs = async (newPrefs) => { - await updateUser(newPrefs); - const updatePromise = new Promise((resolve, reject) => { - //enforcing update before moving on - update().then(() => { - resolve(); - }); - }); - - // record the new prefs in client stats - scheduler.getReminderPrefs().then((prefs) => { - // extract only the relevant fields from the prefs, - // and add as a reading to client stats - const { reminder_assignment, reminder_join_date, reminder_time_of_day } = prefs; - addStatReading(statKeys.REMINDER_PREFS, { - reminder_assignment, - reminder_join_date, - reminder_time_of_day, - }).then(Logger.log('Added reminder prefs to client stats')); - }); - - return updatePromise; - }; - - $ionicPlatform.ready().then(async () => { - _config = await getConfig(); - if (!_config.reminderSchemes) { - Logger.log('No reminder schemes found in config, not scheduling notifications'); - return; - } - //setUpActions(); - update(); - }); - - return scheduler; - }); From 6b9d222fa0b11e6dd06711c8913cbd997b829c21 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Tue, 7 Nov 2023 09:07:48 -0700 Subject: [PATCH 340/850] Add the reminderSchemes as an argument to called getReminderPrefs --- www/js/control/ProfileSettings.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index a056470a4..1885c5a9e 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -201,7 +201,7 @@ const ProfileSettings = () => { const newNotificationSettings = {}; if (uiConfig?.reminderSchemes) { - const prefs = await getReminderPrefs(); + const prefs = await getReminderPrefs(uiConfig.reminderSchemes); const m = moment(prefs.reminder_time_of_day, 'HH:mm'); newNotificationSettings.prefReminderTimeVal = m.toDate(); const n = moment(newNotificationSettings.prefReminderTimeVal); From bafdb47ffe97592888e6c0bc13ddf24fb76e8ea1 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Tue, 7 Nov 2023 08:31:24 -0800 Subject: [PATCH 341/850] Update www/js/services/unifiedDataLoader.ts Co-authored-by: Jack Greenlee --- www/js/services/unifiedDataLoader.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/www/js/services/unifiedDataLoader.ts b/www/js/services/unifiedDataLoader.ts index 8ab4ad2f9..00f6e3027 100644 --- a/www/js/services/unifiedDataLoader.ts +++ b/www/js/services/unifiedDataLoader.ts @@ -25,9 +25,9 @@ export const combinedPromises = function ( return new Promise(function (resolve, reject) { Promise.allSettled(promiseList).then( (results) => { - var allRej = true; - var values = []; - var rejections = []; + let allRej = true; + const values = []; + const rejections = []; results.forEach((item) => { if (item.status === 'fulfilled') { if (allRej) allRej = false; From f2976914e0dea5cb72766ec3154b7b8e792b0542 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan Date: Tue, 7 Nov 2023 13:16:35 -0700 Subject: [PATCH 342/850] changes related to emailService --- www/index.js | 2 +- www/js/control/LogPage.tsx | 2 +- www/js/control/SensedPage.tsx | 3 +- www/js/control/emailService.js | 114 --------------------------------- www/js/ngApp.js | 1 - 5 files changed, 4 insertions(+), 118 deletions(-) delete mode 100644 www/js/control/emailService.js diff --git a/www/index.js b/www/index.js index 78d29cf7a..d6a0a6e7a 100644 --- a/www/index.js +++ b/www/index.js @@ -21,7 +21,7 @@ import './js/diary/services.js'; import './js/survey/enketo/answer.js'; import './js/survey/enketo/enketo-trip-button.js'; import './js/survey/enketo/enketo-add-note-button.js'; -import './js/control/emailService.js'; +// import './js/control/emailService.js'; import './js/metrics-factory.js'; import './js/metrics-mappings.js'; import './js/plugin/logger.ts'; diff --git a/www/js/control/LogPage.tsx b/www/js/control/LogPage.tsx index ad369fbff..3fcce72ac 100644 --- a/www/js/control/LogPage.tsx +++ b/www/js/control/LogPage.tsx @@ -12,7 +12,7 @@ type loadStats = { currentStart: number; gotMaxIndex: boolean; reachedEnd: boole const LogPage = ({ pageVis, setPageVis }) => { const { t } = useTranslation(); const { colors } = useTheme(); - const EmailHelper = getAngularService('EmailHelper'); + //const EmailHelper = getAngularService('EmailHelper'); const [loadStats, setLoadStats] = useState(); const [entries, setEntries] = useState([]); diff --git a/www/js/control/SensedPage.tsx b/www/js/control/SensedPage.tsx index 82fa60581..4d51b5308 100644 --- a/www/js/control/SensedPage.tsx +++ b/www/js/control/SensedPage.tsx @@ -5,11 +5,12 @@ import { getAngularService } from '../angular-react-helper'; import { useTranslation } from 'react-i18next'; import { FlashList } from '@shopify/flash-list'; import moment from 'moment'; +import EmailHelper from './emailService'; const SensedPage = ({ pageVis, setPageVis }) => { const { t } = useTranslation(); const { colors } = useTheme(); - const EmailHelper = getAngularService('EmailHelper'); + //const EmailHelper = getAngularService('EmailHelper'); /* Let's keep a reference to the database for convenience */ const [DB, setDB] = useState(); diff --git a/www/js/control/emailService.js b/www/js/control/emailService.js deleted file mode 100644 index 8eeaf39bb..000000000 --- a/www/js/control/emailService.js +++ /dev/null @@ -1,114 +0,0 @@ -'use strict'; - -import angular from 'angular'; - -angular - .module('emission.services.email', ['emission.plugin.logger']) - - .service('EmailHelper', function ($window, $http, Logger) { - const getEmailConfig = function () { - return new Promise(function (resolve, reject) { - window.Logger.log(window.Logger.LEVEL_INFO, 'About to get email config'); - var address = []; - $http - .get('json/emailConfig.json') - .then(function (emailConfig) { - window.Logger.log( - window.Logger.LEVEL_DEBUG, - 'emailConfigString = ' + JSON.stringify(emailConfig.data), - ); - address.push(emailConfig.data.address); - resolve(address); - }) - .catch(function (err) { - $http - .get('json/emailConfig.json.sample') - .then(function (emailConfig) { - window.Logger.log( - window.Logger.LEVEL_DEBUG, - 'default emailConfigString = ' + JSON.stringify(emailConfig.data), - ); - address.push(emailConfig.data.address); - resolve(address); - }) - .catch(function (err) { - window.Logger.log( - window.Logger.LEVEL_ERROR, - 'Error while reading default email config' + err, - ); - reject(err); - }); - }); - }); - }; - - const hasAccount = function () { - return new Promise(function (resolve, reject) { - $window.cordova.plugins.email.hasAccount(function (hasAct) { - resolve(hasAct); - }); - }); - }; - - this.sendEmail = function (database) { - Promise.all([getEmailConfig(), hasAccount()]).then(function ([address, hasAct]) { - var parentDir = 'unknown'; - - // Check this only for ios, since for android, the check always fails unless - // the user grants the "GET_ACCOUNTS" dynamic permission - // without the permission, we only see the e-mission account which is not valid - // - // https://developer.android.com/reference/android/accounts/AccountManager#getAccounts() - // - // Caller targeting API level below Build.VERSION_CODES.O that - // have not been granted the Manifest.permission.GET_ACCOUNTS - // permission, will only see those accounts managed by - // AbstractAccountAuthenticators whose signature matches the - // client. - // and on android, if the account is not configured, the gmail app will be launched anyway - // on iOS, nothing will happen. So we perform the check only on iOS so that we can - // generate a reasonably relevant error message - - if (ionic.Platform.isIOS() && !hasAct) { - alert(i18next.t('email-service.email-account-not-configured')); - return; - } - - if (ionic.Platform.isAndroid()) { - parentDir = 'app://databases'; - } - if (ionic.Platform.isIOS()) { - alert(i18next.t('email-service.email-account-mail-app')); - parentDir = cordova.file.dataDirectory + '../LocalDatabase'; - } - - if (parentDir == 'unknown') { - alert('parentDir unexpectedly = ' + parentDir + '!'); - } - - window.Logger.log(window.Logger.LEVEL_INFO, 'Going to email ' + database); - parentDir = parentDir + '/' + database; - /* - window.Logger.log(window.Logger.LEVEL_INFO, - "Going to export logs to "+parentDir); - */ - alert(i18next.t('email-service.going-to-email', { parentDir: parentDir })); - var email = { - to: address, - attachments: [parentDir], - subject: i18next.t('email-service.email-log.subject-logs'), - body: i18next.t('email-service.email-log.body-please-fill-in-what-is-wrong'), - }; - - $window.cordova.plugins.email.open(email, function () { - Logger.log( - 'email app closed while sending, ' + - JSON.stringify(email) + - ' not sure if we should do anything', - ); - // alert(i18next.t('email-service.no-email-address-configured') + err); - return; - }); - }); - }; - }); diff --git a/www/js/ngApp.js b/www/js/ngApp.js index 228c2a989..84b9972c4 100644 --- a/www/js/ngApp.js +++ b/www/js/ngApp.js @@ -40,7 +40,6 @@ angular 'emission.services', 'emission.plugin.logger', 'emission.splash.referral', - 'emission.services.email', 'emission.main', 'pascalprecht.translate', 'LocalStorageModule', From 97571eeb8573edf56ea61f0a17ca49a71a80ef31 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Tue, 7 Nov 2023 13:28:26 -0700 Subject: [PATCH 343/850] Added typing for User object in getReminderPrefs notifScheduler.ts - Added a User interface that acts as a structure instead of using any for the type --- www/js/splash/notifScheduler.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts index e41430529..906d0712b 100644 --- a/www/js/splash/notifScheduler.ts +++ b/www/js/splash/notifScheduler.ts @@ -226,8 +226,15 @@ const initReminderPrefs = (reminderSchemes) => { // reminder_time_of_day: string; // } -export const getReminderPrefs = async (reminderSchemes): Promise => { - const user = (await getUser()) as any; +interface User { + reminder_assignment: string; + reminder_join_date: string; + reminder_time_of_day: string; +} + +export const getReminderPrefs = async (reminderSchemes): Promise => { + const userPromise = getUser(); + const user = (await userPromise) as User; if (user?.reminder_assignment && user?.reminder_join_date && user?.reminder_time_of_day) { console.log('User already has reminder prefs, returning them', user); return user; From a29705d891ecff154c9cf5a87a31b84fd3fbdef0 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Tue, 7 Nov 2023 13:30:43 -0700 Subject: [PATCH 344/850] Restructure promise format and replaced moment with Luxon ProfileSettings.tsx - 2 locations that used moment I replaced with Luxon because the notifScheduler is returning DateTime objects which don't mesh with moment - Restructured the if(uiConfig?.reminderSchemes) in refreshNotificationSettings to execute the promises at first, and then set the data - Some Prettier formatting took over here automatically too --- www/js/control/ProfileSettings.jsx | 33 +++++++++++++++++++----------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 1885c5a9e..492337de0 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -39,6 +39,7 @@ import { getReminderPrefs, setReminderPrefs, } from '../splash/notifScheduler'; +import { DateTime } from 'luxon'; //any pure functions can go outside const ProfileSettings = () => { @@ -201,13 +202,20 @@ const ProfileSettings = () => { const newNotificationSettings = {}; if (uiConfig?.reminderSchemes) { - const prefs = await getReminderPrefs(uiConfig.reminderSchemes); - const m = moment(prefs.reminder_time_of_day, 'HH:mm'); - newNotificationSettings.prefReminderTimeVal = m.toDate(); - const n = moment(newNotificationSettings.prefReminderTimeVal); - newNotificationSettings.prefReminderTime = n.format('LT'); + let promiseList = []; + promiseList.push(getReminderPrefs(uiConfig.reminderSchemes)); + promiseList.push(getScheduledNotifs()); + let resultList = await Promise.all(promiseList); + const prefs = resultList[0]; + const scheduledNotifs = resultList[1]; + console.log('prefs and scheduled notifs', resultList[0], resultList[1]); + + const m = DateTime.fromFormat(prefs.reminder_time_of_day, 'HH:mm'); + newNotificationSettings.prefReminderTimeVal = m.toJSDate(); + newNotificationSettings.prefReminderTime = m.toFormat('t'); newNotificationSettings.prefReminderTimeOnLoad = prefs.reminder_time_of_day; - newNotificationSettings.scheduledNotifs = await getScheduledNotifs(); + newNotificationSettings.scheduledNotifs = scheduledNotifs; + updatePrefReminderTime(false); } @@ -276,13 +284,14 @@ const ProfileSettings = () => { async function updatePrefReminderTime(storeNewVal = true, newTime) { console.log(newTime); if (storeNewVal) { - const m = moment(newTime); + const m = DateTime.fromISO(newTime); // store in HH:mm - setReminderPrefs({ reminder_time_of_day: m.format('HH:mm') }, uiConfig.reminderSchemes).then( - () => { - refreshNotificationSettings(); - }, - ); + setReminderPrefs( + { reminder_time_of_day: m.toFormat('HH:mm') }, + uiConfig.reminderSchemes, + ).then(() => { + refreshNotificationSettings(); + }); } } From 12e80295e2600439d714cb0131cae98e252bb811 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Tue, 7 Nov 2023 14:55:44 -0800 Subject: [PATCH 345/850] Expanded diaryTypes - these types are used by timelineHelper --- www/js/types/diaryTypes.ts | 85 ++++++++++++++++++++++++++++++++++---- 1 file changed, 76 insertions(+), 9 deletions(-) diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 14d8acc07..4f10fb50f 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -12,39 +12,71 @@ export type UserInputData = { match_id?: string; }; -type ConfirmedPlace = any; // TODO +export type TripTransition = { + currstate: string; + transition: string; + ts: number; +}; + +export type ObjId = { + $oid: string; // objIds have len 24 +}; + +export type LocationCoord = { + type: string; // e.x., "Point" + coordinates: [number, number]; +}; + +type ConfirmedPlace = { + cleaned_place: { + string; + }; + additions: Array; // Todo + ender_local_dt: LocalDt; + starting_trip: ObjId; + exit_fmt_time: string; // ISO + exit_local_dt: LocalDt; + enter_ts: number; + source: string; + enter_fmt_time: string; + raw_places: Array; + location: LocationCoord; + exit_ts: number; + ending_trip: ObjId; + user_input: {}; //todo +}; export type CompositeTrip = { - _id: { $oid: string }; + _id: ObjId; additions: any[]; // TODO cleaned_section_summary: any; // TODO - cleaned_trip: { $oid: string }; + cleaned_trip: ObjId; confidence_threshold: number; - confirmed_trip: { $oid: string }; + confirmed_trip: ObjId; distance: number; duration: number; end_confirmed_place: ConfirmedPlace; end_fmt_time: string; end_loc: { type: string; coordinates: number[] }; end_local_dt: LocalDt; - end_place: { $oid: string }; + end_place: ObjId; end_ts: number; expectation: any; // TODO "{to_label: boolean}" - expected_trip: { $oid: string }; + expected_trip: ObjId; inferred_labels: any[]; // TODO inferred_section_summary: any; // TODO - inferred_trip: { $oid: string }; + inferred_trip: ObjId; key: string; locations: any[]; // TODO origin_key: string; - raw_trip: { $oid: string }; + raw_trip: ObjId; sections: any[]; // TODO source: string; start_confirmed_place: ConfirmedPlace; start_fmt_time: string; start_loc: { type: string; coordinates: number[] }; start_local_dt: LocalDt; - start_place: { $oid: string }; + start_place: ObjId; start_ts: number; user_input: UserInput; }; @@ -73,3 +105,38 @@ export type TlEntry = { duration: number; getNextEntry?: () => PopulatedTrip | ConfirmedPlace; }; + +export type Location = { + speed: number; + heading: number; + local_dt: LocalDt; + idx: number; + section: ObjId; + longitude: number; + latitude: number; + fmt_time: string; // ISO + mode: number; + loc: LocationCoord; + ts: number; // Unix + altitude: number; + distance: number; +}; + +// used in readAllCompositeTrips +export type SectionData = { + end_ts: number; // Unix time, e.x. 1696352498.804 + end_loc: LocationCoord; + start_fmt_time: string; // ISO time + end_fmt_time: string; + trip_id: ObjId; + sensed_mode: number; + source: string; // e.x., "SmoothedHighConfidenceMotion" + start_ts: number; // Unix + start_loc: LocationCoord; + cleaned_section: ObjId; + start_local_dt: LocalDt; + end_local_dt: LocalDt; + sensed_mode_str: string; //e.x., "CAR" + duration: number; + distance: number; +}; From 159d665295f67ad3f0d59df5bc94967e72c83fe8 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 8 Nov 2023 13:29:37 -0500 Subject: [PATCH 346/850] support client-side transformation for enketo XMLs With this change, the `formPath` specifying the URL of an Enketo survey can be either JSON or XML. If it's JSON, we'll be able parse and use it directly. If it cannot be parsed as JSON, we'll perform XML -> JSON transformation with enketo-transformer/web. --- package.cordovabuild.json | 1 + package.serve.json | 1 + webpack.config.js | 7 ++++++- www/js/survey/enketo/EnketoModal.tsx | 16 +++++----------- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/package.cordovabuild.json b/package.cordovabuild.json index c61fa72c5..2e78f7363 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -142,6 +142,7 @@ "cordova-plugin-x-socialsharing": "6.0.4", "core-js": "^2.5.7", "enketo-core": "^6.1.7", + "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", "fs-extra": "^9.0.1", "i18next": "^22.5.0", diff --git a/package.serve.json b/package.serve.json index 59a803085..c66c32b1e 100644 --- a/package.serve.json +++ b/package.serve.json @@ -73,6 +73,7 @@ "chartjs-plugin-annotation": "^3.0.1", "core-js": "^2.5.7", "enketo-core": "^6.1.7", + "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", "fs-extra": "^9.0.1", "i18next": "^22.5.0", diff --git a/webpack.config.js b/webpack.config.js index 1e504ac5f..3e7e6d368 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -79,7 +79,12 @@ module.exports = { /* Enketo expects its per-app configuration to be available as 'enketo-config', so we have to alias it here. https://github.com/enketo/enketo-core#global-configuration */ - 'enketo/config': path.resolve(__dirname, 'www/js/config/enketo-config') + 'enketo/config': path.resolve(__dirname, 'www/js/config/enketo-config'), + /* enketo-transformer has 'libxslt' as an optional peer dependency. + We don't need it since we are only doing client-side transformations via + enketo-transformer/web (https://github.com/enketo/enketo-transformer#web). + So, we can tell webpack it's ok to ignore libxslt by aliasing it to false. */ + 'libxslt': false, }, extensions: ['.web.js', '.jsx', '.tsx', '.ts', '.js'], }, diff --git a/www/js/survey/enketo/EnketoModal.tsx b/www/js/survey/enketo/EnketoModal.tsx index de1f505f3..a0dc667c5 100644 --- a/www/js/survey/enketo/EnketoModal.tsx +++ b/www/js/survey/enketo/EnketoModal.tsx @@ -6,8 +6,8 @@ import useAppConfig from '../../useAppConfig'; import { useTranslation } from 'react-i18next'; import { SurveyOptions, getInstanceStr, saveResponse } from './enketoHelper'; import { fetchUrlCached } from '../../commHelper'; -import { displayError, displayErrorMsg } from '../../plugin/logger'; -// import { transform } from 'enketo-transformer/web'; +import { displayError, displayErrorMsg, logDebug } from '../../plugin/logger'; +import { transform } from 'enketo-transformer/web'; type Props = Omit & { surveyName: string; @@ -26,15 +26,9 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest }: Props) => { const responseText = await fetchUrlCached(url); try { return JSON.parse(responseText); - } catch ({ name, message }) { - // not JSON, so it must be XML - return Promise.reject( - 'downloaded survey was not JSON; enketo-transformer is not available yet', - ); - /* uncomment once enketo-transformer is available */ - // if `response` is not JSON, it is an XML string and needs transformation to JSON - // const xmlText = await res.text(); - // return await transform({xform: xmlText}); + } catch (e) { + logDebug(`${e.name}: Survey was not in JSON format. Attempting to transform XML -> JSON...`); + return await transform({ xform: responseText }); } } From 2b72850ce16de27531185bb8e3f0aa39a613ce39 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 8 Nov 2023 14:41:32 -0500 Subject: [PATCH 347/850] rename + move fetchSurvey to enketoHelper.ts --- www/js/survey/enketo/EnketoModal.tsx | 16 ++-------------- www/js/survey/enketo/enketoHelper.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/www/js/survey/enketo/EnketoModal.tsx b/www/js/survey/enketo/EnketoModal.tsx index a0dc667c5..9267b9808 100644 --- a/www/js/survey/enketo/EnketoModal.tsx +++ b/www/js/survey/enketo/EnketoModal.tsx @@ -4,10 +4,8 @@ import { StyleSheet, Modal, ScrollView, SafeAreaView, Pressable } from 'react-na import { ModalProps } from 'react-native-paper'; import useAppConfig from '../../useAppConfig'; import { useTranslation } from 'react-i18next'; -import { SurveyOptions, getInstanceStr, saveResponse } from './enketoHelper'; -import { fetchUrlCached } from '../../commHelper'; +import { SurveyOptions, fetchSurvey, getInstanceStr, saveResponse } from './enketoHelper'; import { displayError, displayErrorMsg, logDebug } from '../../plugin/logger'; -import { transform } from 'enketo-transformer/web'; type Props = Omit & { surveyName: string; @@ -22,16 +20,6 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest }: Props) => { const enketoForm = useRef(null); const appConfig = useAppConfig(); - async function fetchSurveyJson(url) { - const responseText = await fetchUrlCached(url); - try { - return JSON.parse(responseText); - } catch (e) { - logDebug(`${e.name}: Survey was not in JSON format. Attempting to transform XML -> JSON...`); - return await transform({ xform: responseText }); - } - } - async function validateAndSave() { const valid = await enketoForm.current.validate(); if (!valid) return false; @@ -56,7 +44,7 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest }: Props) => { const formPath = appConfig.survey_info?.surveys?.[surveyName]?.formPath; if (!formPath) return console.error('No form path found for survey', surveyName); - fetchSurveyJson(formPath).then(({ form, model }) => { + fetchSurvey(formPath).then(({ form, model }) => { surveyJson.current = { form, model }; headerEl?.current.insertAdjacentHTML('afterend', form); // inject form into DOM const formEl = document.querySelector('form.or'); diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index 9b96c8463..e4a1fcf45 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -1,11 +1,13 @@ import { getAngularService } from '../../angular-react-helper'; import { Form } from 'enketo-core'; +import { transform } from 'enketo-transformer/web'; import { XMLParser } from 'fast-xml-parser'; import i18next from 'i18next'; import MessageFormat from '@messageformat/core'; import { logDebug, logInfo } from '../../plugin/logger'; import { getConfig } from '../../config/dynamicConfig'; import { DateTime } from 'luxon'; +import { fetchUrlCached } from '../../commHelper'; export type PrefillFields = { [key: string]: string }; @@ -287,3 +289,13 @@ export function loadPreviousResponseForSurvey(dataKey: string) { _getMostRecent(answers), ); } + +export async function fetchSurvey(url: string) { + const responseText = await fetchUrlCached(url); + try { + return JSON.parse(responseText); + } catch (e) { + logDebug(`${e.name}: Survey was not in JSON format. Attempting to transform XML -> JSON...`); + return await transform({ xform: responseText }); + } +} From de49924a7d42febde2109e999eb4d38ebbef7e25 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 8 Nov 2023 14:24:44 -0700 Subject: [PATCH 348/850] convert metrics services angular -> ts migration for metrics-mappings and metrics-factory The datasets have their own files, there is a customhelper, a methelper, and a footprint helper Tested in the emulator and dashboard still behaves as expected --- www/index.js | 2 - www/js/control/ProfileSettings.jsx | 16 +- www/js/metrics-factory.js | 271 ---------------- www/js/metrics-mappings.js | 425 ------------------------- www/js/metrics/CarbonDatasets.ts | 123 +++++++ www/js/metrics/CarbonFootprintCard.tsx | 33 +- www/js/metrics/CarbonTextCard.tsx | 29 +- www/js/metrics/CustomMetricsHelper.ts | 148 +++++++++ www/js/metrics/METDataset.ts | 128 ++++++++ www/js/metrics/footprintHelper.ts | 161 ++++++++++ www/js/metrics/metHelper.ts | 109 +++++++ 11 files changed, 704 insertions(+), 741 deletions(-) delete mode 100644 www/js/metrics-factory.js delete mode 100644 www/js/metrics-mappings.js create mode 100644 www/js/metrics/CarbonDatasets.ts create mode 100644 www/js/metrics/CustomMetricsHelper.ts create mode 100644 www/js/metrics/METDataset.ts create mode 100644 www/js/metrics/footprintHelper.ts create mode 100644 www/js/metrics/metHelper.ts diff --git a/www/index.js b/www/index.js index 78d29cf7a..06802d14f 100644 --- a/www/index.js +++ b/www/index.js @@ -22,6 +22,4 @@ import './js/survey/enketo/answer.js'; import './js/survey/enketo/enketo-trip-button.js'; import './js/survey/enketo/enketo-add-note-button.js'; import './js/control/emailService.js'; -import './js/metrics-factory.js'; -import './js/metrics-mappings.js'; import './js/plugin/logger.ts'; diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index b081e642a..b8943a81c 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -26,6 +26,11 @@ import ControlCollectionHelper, { helperToggleLowAccuracy, forceTransition, } from './ControlCollectionHelper'; +import { + getCarbonDatasetOptions, + getCurrentCarbonDatasetCode, + saveCurrentCarbonDatasetLocale, +} from '../metrics/customMetricsHelper'; import { resetDataAndRefresh } from '../config/dynamicConfig'; import { AppContext } from '../App'; import { shareQR } from '../components/QrCode'; @@ -43,7 +48,6 @@ const ProfileSettings = () => { const { setPermissionsPopupVis } = useContext(AppContext); //angular services needed - const CarbonDatasetHelper = getAngularService('CarbonDatasetHelper'); const EmailHelper = getAngularService('EmailHelper'); const NotificationScheduler = getAngularService('NotificationScheduler'); const ControlHelper = getAngularService('ControlHelper'); @@ -84,8 +88,8 @@ const ProfileSettings = () => { const appVersion = useRef(); let carbonDatasetString = - t('general-settings.carbon-dataset') + ': ' + CarbonDatasetHelper.getCurrentCarbonDatasetCode(); - const carbonOptions = CarbonDatasetHelper.getCarbonDatasetOptions(); + t('general-settings.carbon-dataset') + ': ' + getCurrentCarbonDatasetCode(); + const carbonOptions = getCarbonDatasetOptions(); const stateActions = [ { text: 'Initialize', transition: 'INITIALIZE' }, { text: 'Start trip', transition: 'EXITED_GEOFENCE' }, @@ -361,12 +365,10 @@ const ProfileSettings = () => { const onSelectCarbon = function (carbonObject) { console.log('changeCarbonDataset(): chose locale ' + carbonObject.value); - CarbonDatasetHelper.saveCurrentCarbonDatasetLocale(carbonObject.value); //there's some sort of error here + saveCurrentCarbonDatasetLocale(carbonObject.value); //there's some sort of error here //Unhandled Promise Rejection: While logging, error -[NSNull UTF8String]: unrecognized selector sent to instance 0x7fff8a625fb0 carbonDatasetString = - i18next.t('general-settings.carbon-dataset') + - ': ' + - CarbonDatasetHelper.getCurrentCarbonDatasetCode(); + i18next.t('general-settings.carbon-dataset') + ': ' + getCurrentCarbonDatasetCode(); }; //conditional creation of setting sections diff --git a/www/js/metrics-factory.js b/www/js/metrics-factory.js deleted file mode 100644 index 28ef5ae9e..000000000 --- a/www/js/metrics-factory.js +++ /dev/null @@ -1,271 +0,0 @@ -'use strict'; - -import angular from 'angular'; -import { getBaseModeByValue } from './diary/diaryHelper'; -import { labelOptions } from './survey/multilabel/confirmHelper'; -import { storageGet, storageRemove, storageSet } from './plugin/storage'; - -angular - .module('emission.main.metrics.factory', ['emission.main.metrics.mappings']) - - .factory('FootprintHelper', function (CarbonDatasetHelper, CustomDatasetHelper) { - var fh = {}; - var highestFootprint = 0; - - var mtokm = function (v) { - return v / 1000; - }; - fh.useCustom = false; - - fh.setUseCustomFootprint = function () { - fh.useCustom = true; - }; - - fh.getFootprint = function () { - if (this.useCustom == true) { - return CustomDatasetHelper.getCustomFootprint(); - } else { - return CarbonDatasetHelper.getCurrentCarbonDatasetFootprint(); - } - }; - - fh.readableFormat = function (v) { - return v > 999 ? Math.round(v / 1000) + 'k kg CO₂' : Math.round(v) + ' kg CO₂'; - }; - fh.getFootprintForMetrics = function (userMetrics, defaultIfMissing = 0) { - var footprint = fh.getFootprint(); - var result = 0; - for (var i in userMetrics) { - var mode = userMetrics[i].key; - if (mode == 'ON_FOOT') { - mode = 'WALKING'; - } - - if (mode in footprint) { - result += footprint[mode] * mtokm(userMetrics[i].values); - } else if (mode == 'IN_VEHICLE') { - result += - ((footprint['CAR'] + - footprint['BUS'] + - footprint['LIGHT_RAIL'] + - footprint['TRAIN'] + - footprint['TRAM'] + - footprint['SUBWAY']) / - 6) * - mtokm(userMetrics[i].values); - } else { - console.warn( - 'WARNING FootprintHelper.getFootprintFromMetrics() was requested for an unknown mode: ' + - mode + - ' metrics JSON: ' + - JSON.stringify(userMetrics), - ); - result += defaultIfMissing * mtokm(userMetrics[i].values); - } - } - return result; - }; - fh.getLowestFootprintForDistance = function (distance) { - var footprint = fh.getFootprint(); - var lowestFootprint = Number.MAX_SAFE_INTEGER; - for (var mode in footprint) { - if (mode == 'WALKING' || mode == 'BICYCLING') { - // these modes aren't considered when determining the lowest carbon footprint - } else { - lowestFootprint = Math.min(lowestFootprint, footprint[mode]); - } - } - return lowestFootprint * mtokm(distance); - }; - - fh.getHighestFootprint = function () { - if (!highestFootprint) { - var footprint = fh.getFootprint(); - let footprintList = []; - for (var mode in footprint) { - footprintList.push(footprint[mode]); - } - highestFootprint = Math.max(...footprintList); - } - return highestFootprint; - }; - - fh.getHighestFootprintForDistance = function (distance) { - return fh.getHighestFootprint() * mtokm(distance); - }; - - var getLowestMotorizedNonAirFootprint = function (footprint, rlmCO2) { - var lowestFootprint = Number.MAX_SAFE_INTEGER; - for (var mode in footprint) { - if (mode == 'AIR_OR_HSR' || mode == 'air') { - console.log('Air mode, ignoring'); - } else { - if (footprint[mode] == 0 || footprint[mode] <= rlmCO2) { - console.log( - 'Non motorized mode or footprint <= range_limited_motorized', - mode, - footprint[mode], - rlmCO2, - ); - } else { - lowestFootprint = Math.min(lowestFootprint, footprint[mode]); - } - } - } - return lowestFootprint; - }; - - fh.getOptimalDistanceRanges = function () { - const FIVE_KM = 5 * 1000; - const SIX_HUNDRED_KM = 600 * 1000; - if (!fh.useCustom) { - const defaultFootprint = CarbonDatasetHelper.getCurrentCarbonDatasetFootprint(); - const lowestMotorizedNonAir = getLowestMotorizedNonAirFootprint(defaultFootprint); - const airFootprint = defaultFootprint['AIR_OR_HSR']; - return [ - { low: 0, high: FIVE_KM, optimal: 0 }, - { low: FIVE_KM, high: SIX_HUNDRED_KM, optimal: lowestMotorizedNonAir }, - { low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint }, - ]; - } else { - // custom footprint, let's get the custom values - const customFootprint = CustomDatasetHelper.getCustomFootprint(); - let airFootprint = customFootprint['air']; - if (!airFootprint) { - // 2341 BTU/PMT from - // https://tedb.ornl.gov/wp-content/uploads/2021/02/TEDB_Ed_39.pdf#page=68 - // 159.25 lb per million BTU from EIA - // https://www.eia.gov/environment/emissions/co2_vol_mass.php - // (2341 * (159.25/1000000))/(1.6*2.2) = 0.09975, rounded up a bit - console.log('No entry for air in ', customFootprint, ' using default'); - airFootprint = 0.1; - } - const rlm = CustomDatasetHelper.range_limited_motorized; - if (!rlm) { - return [ - { low: 0, high: FIVE_KM, optimal: 0 }, - { low: FIVE_KM, high: SIX_HUNDRED_KM, optimal: lowestMotorizedNonAir }, - { low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint }, - ]; - } else { - console.log('Found range_limited_motorized mode', rlm); - const lowestMotorizedNonAir = getLowestMotorizedNonAirFootprint( - customFootprint, - rlm.kgCo2PerKm, - ); - return [ - { low: 0, high: FIVE_KM, optimal: 0 }, - { low: FIVE_KM, high: rlm.range_limit_km * 1000, optimal: rlm.kgCo2PerKm }, - { - low: rlm.range_limit_km * 1000, - high: SIX_HUNDRED_KM, - optimal: lowestMotorizedNonAir, - }, - { low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint }, - ]; - } - } - }; - - return fh; - }) - - .factory('CalorieCal', function (METDatasetHelper, CustomDatasetHelper) { - var cc = {}; - var highestMET = 0; - var USER_DATA_KEY = 'user-data'; - cc.useCustom = false; - - cc.setUseCustomFootprint = function () { - cc.useCustom = true; - }; - - cc.getMETs = function () { - if (this.useCustom == true) { - return CustomDatasetHelper.getCustomMETs(); - } else { - return METDatasetHelper.getStandardMETs(); - } - }; - - cc.set = function (info) { - return storageSet(USER_DATA_KEY, info); - }; - cc.get = function () { - return storageGet(USER_DATA_KEY); - }; - cc.delete = function () { - return storageRemove(USER_DATA_KEY); - }; - Number.prototype.between = function (min, max) { - return this >= min && this <= max; - }; - cc.getHighestMET = function () { - if (!highestMET) { - var met = cc.getMETs(); - let metList = []; - for (var mode in met) { - var rangeList = met[mode]; - for (var range in rangeList) { - metList.push(rangeList[range].mets); - } - } - highestMET = Math.max(...metList); - } - return highestMET; - }; - cc.getMet = function (mode, speed, defaultIfMissing) { - if (mode == 'ON_FOOT') { - console.log("CalorieCal.getMet() converted 'ON_FOOT' to 'WALKING'"); - mode = 'WALKING'; - } - let currentMETs = cc.getMETs(); - if (!currentMETs[mode]) { - console.warn('CalorieCal.getMet() Illegal mode: ' + mode); - return defaultIfMissing; //So the calorie sum does not break with wrong return type - } - for (var i in currentMETs[mode]) { - if (mpstomph(speed).between(currentMETs[mode][i].range[0], currentMETs[mode][i].range[1])) { - return currentMETs[mode][i].mets; - } else if (mpstomph(speed) < 0) { - console.log('CalorieCal.getMet() Negative speed: ' + mpstomph(speed)); - return 0; - } - } - }; - var mpstomph = function (mps) { - return 2.23694 * mps; - }; - var lbtokg = function (lb) { - return lb * 0.453592; - }; - var fttocm = function (ft) { - return ft * 30.48; - }; - cc.getCorrectedMet = function (met, gender, age, height, heightUnit, weight, weightUnit) { - var height = heightUnit == 0 ? fttocm(height) : height; - var weight = weightUnit == 0 ? lbtokg(weight) : weight; - if (gender == 1) { - //male - var met = - (met * 3.5) / - (((66.473 + 5.0033 * height + 13.7516 * weight - 6.755 * age) / 1440 / 5 / weight) * - 1000); - return met; - } else if (gender == 0) { - //female - var met = - (met * 3.5) / - (((655.0955 + 1.8496 * height + 9.5634 * weight - 4.6756 * age) / 1440 / 5 / weight) * - 1000); - return met; - } - }; - cc.getuserCalories = function (durationInMin, met) { - return 65 * durationInMin * met; - }; - cc.getCalories = function (weightInKg, durationInMin, met) { - return weightInKg * durationInMin * met; - }; - return cc; - }); diff --git a/www/js/metrics-mappings.js b/www/js/metrics-mappings.js deleted file mode 100644 index 38836a3a1..000000000 --- a/www/js/metrics-mappings.js +++ /dev/null @@ -1,425 +0,0 @@ -import angular from 'angular'; -import { getLabelOptions } from './survey/multilabel/confirmHelper'; -import { getConfig } from './config/dynamicConfig'; -import { storageGet, storageSet } from './plugin/storage'; - -angular - .module('emission.main.metrics.mappings', ['emission.plugin.logger']) - - .service('CarbonDatasetHelper', function () { - var CARBON_DATASET_KEY = 'carbon_dataset_locale'; - - // Values are in Kg/PKm (kilograms per passenger-kilometer) - // Sources for EU values: - // - Tremod: 2017, CO2, CH4 and N2O in CO2-equivalent - // - HBEFA: 2020, CO2 (per country) - // German data uses Tremod. Other EU countries (and Switzerland) use HBEFA for car and bus, - // and Tremod for train and air (because HBEFA doesn't provide these). - // EU data is an average of the Tremod/HBEFA data for the countries listed; - // for this average the HBEFA data was used also in the German set (for car and bus). - var carbonDatasets = { - US: { - regionName: 'United States', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 267 / 1609, - BUS: 278 / 1609, - LIGHT_RAIL: 120 / 1609, - SUBWAY: 74 / 1609, - TRAM: 90 / 1609, - TRAIN: 92 / 1609, - AIR_OR_HSR: 217 / 1609, - }, - }, - EU: { - // Plain average of values for the countries below (using HBEFA for car and bus, Tremod for others) - regionName: 'European Union', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.14515, - BUS: 0.04751, - LIGHT_RAIL: 0.064, - SUBWAY: 0.064, - TRAM: 0.064, - TRAIN: 0.048, - AIR_OR_HSR: 0.201, - }, - }, - DE: { - regionName: 'Germany', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.139, // Tremod (passenger car) - BUS: 0.0535, // Tremod (average city/coach) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201, // Tremod (DE airplane) - }, - }, - FR: { - regionName: 'France', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.13125, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04838, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201, // Tremod (DE airplane) - }, - }, - AT: { - regionName: 'Austria', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.14351, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04625, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201, // Tremod (DE airplane) - }, - }, - SE: { - regionName: 'Sweden', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.13458, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04557, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201, // Tremod (DE airplane) - }, - }, - NO: { - regionName: 'Norway', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.13265, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04185, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201, // Tremod (DE airplane) - }, - }, - CH: { - regionName: 'Switzerland', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.17638, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04866, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201, // Tremod (DE airplane) - }, - }, - }; - - var defaultCarbonDatasetCode = 'US'; - var currentCarbonDatasetCode = defaultCarbonDatasetCode; - - // we need to call the method from within a promise in initialize() - // and using this.setCurrentCarbonDatasetLocale doesn't seem to work - var setCurrentCarbonDatasetLocale = function (localeCode) { - for (var code in carbonDatasets) { - if (code == localeCode) { - currentCarbonDatasetCode = localeCode; - break; - } - } - }; - - this.loadCarbonDatasetLocale = function () { - return storageGet(CARBON_DATASET_KEY).then(function (localeCode) { - Logger.log( - 'CarbonDatasetHelper.loadCarbonDatasetLocale() obtained value from storage [' + - localeCode + - ']', - ); - if (!localeCode) { - localeCode = defaultCarbonDatasetCode; - Logger.log( - 'CarbonDatasetHelper.loadCarbonDatasetLocale() no value in storage, using [' + - localeCode + - '] instead', - ); - } - setCurrentCarbonDatasetLocale(localeCode); - }); - }; - - this.saveCurrentCarbonDatasetLocale = function (localeCode) { - setCurrentCarbonDatasetLocale(localeCode); - storageSet(CARBON_DATASET_KEY, currentCarbonDatasetCode); - Logger.log( - 'CarbonDatasetHelper.saveCurrentCarbonDatasetLocale() saved value [' + - currentCarbonDatasetCode + - '] to storage', - ); - }; - - this.getCarbonDatasetOptions = function () { - var options = []; - for (var code in carbonDatasets) { - options.push({ - text: code, //carbonDatasets[code].regionName, - value: code, - }); - } - return options; - }; - - this.getCurrentCarbonDatasetCode = function () { - return currentCarbonDatasetCode; - }; - - this.getCurrentCarbonDatasetFootprint = function () { - return carbonDatasets[currentCarbonDatasetCode].footprintData; - }; - }) - .service('METDatasetHelper', function () { - var standardMETs = { - WALKING: { - VERY_SLOW: { - range: [0, 2.0], - mets: 2.0, - }, - SLOW: { - range: [2.0, 2.5], - mets: 2.8, - }, - MODERATE_0: { - range: [2.5, 2.8], - mets: 3.0, - }, - MODERATE_1: { - range: [2.8, 3.2], - mets: 3.5, - }, - FAST: { - range: [3.2, 3.5], - mets: 4.3, - }, - VERY_FAST_0: { - range: [3.5, 4.0], - mets: 5.0, - }, - 'VERY_FAST_!': { - range: [4.0, 4.5], - mets: 6.0, - }, - VERY_VERY_FAST: { - range: [4.5, 5], - mets: 7.0, - }, - SUPER_FAST: { - range: [5, 6], - mets: 8.3, - }, - RUNNING: { - range: [6, Number.MAX_VALUE], - mets: 9.8, - }, - }, - BICYCLING: { - VERY_VERY_SLOW: { - range: [0, 5.5], - mets: 3.5, - }, - VERY_SLOW: { - range: [5.5, 10], - mets: 5.8, - }, - SLOW: { - range: [10, 12], - mets: 6.8, - }, - MODERATE: { - range: [12, 14], - mets: 8.0, - }, - FAST: { - range: [14, 16], - mets: 10.0, - }, - VERT_FAST: { - range: [16, 19], - mets: 12.0, - }, - RACING: { - range: [20, Number.MAX_VALUE], - mets: 15.8, - }, - }, - UNKNOWN: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - IN_VEHICLE: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - CAR: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - BUS: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - LIGHT_RAIL: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - TRAIN: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - TRAM: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - SUBWAY: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - AIR_OR_HSR: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - }; - this.getStandardMETs = function () { - return standardMETs; - }; - }) - .factory('CustomDatasetHelper', function (METDatasetHelper, Logger, $ionicPlatform) { - var cdh = {}; - - cdh.getCustomMETs = function () { - console.log('Getting custom METs', cdh.customMETs); - return cdh.customMETs; - }; - - cdh.getCustomFootprint = function () { - console.log('Getting custom footprint', cdh.customPerKmFootprint); - return cdh.customPerKmFootprint; - }; - - cdh.populateCustomMETs = function () { - let standardMETs = METDatasetHelper.getStandardMETs(); - let modeOptions = cdh.inputParams['MODE']; - let modeMETEntries = modeOptions.map((opt) => { - if (opt.met_equivalent) { - let currMET = standardMETs[opt.met_equivalent]; - return [opt.value, currMET]; - } else { - if (opt.met) { - let currMET = opt.met; - // if the user specifies a custom MET, they can't specify - // Number.MAX_VALUE since it is not valid JSON - // we assume that they specify -1 instead, and we will - // map -1 to Number.MAX_VALUE here by iterating over all the ranges - for (const rangeName in currMET) { - // console.log("Handling range ", rangeName); - currMET[rangeName].range = currMET[rangeName].range.map((i) => - i == -1 ? Number.MAX_VALUE : i, - ); - } - return [opt.value, currMET]; - } else { - console.warn( - 'Did not find either met_equivalent or met for ' + opt.value + ' ignoring entry', - ); - return undefined; - } - } - }); - cdh.customMETs = Object.fromEntries(modeMETEntries.filter((e) => angular.isDefined(e))); - console.log('After populating, custom METs = ', cdh.customMETs); - }; - - cdh.populateCustomFootprints = function () { - let modeOptions = cdh.inputParams['MODE']; - let modeCO2PerKm = modeOptions - .map((opt) => { - if (opt.range_limit_km) { - if (cdh.range_limited_motorized) { - Logger.displayError('Found two range limited motorized options', { - first: cdh.range_limited_motorized, - second: opt, - }); - } - cdh.range_limited_motorized = opt; - console.log('Found range limited motorized mode', cdh.range_limited_motorized); - } - if (angular.isDefined(opt.kgCo2PerKm)) { - return [opt.value, opt.kgCo2PerKm]; - } else { - return undefined; - } - }) - .filter((modeCO2) => angular.isDefined(modeCO2)); - cdh.customPerKmFootprint = Object.fromEntries(modeCO2PerKm); - console.log('After populating, custom perKmFootprint', cdh.customPerKmFootprint); - }; - - cdh.init = function (newConfig) { - try { - getLabelOptions(newConfig).then((inputParams) => { - console.log('Input params = ', inputParams); - cdh.inputParams = inputParams; - cdh.populateCustomMETs(); - cdh.populateCustomFootprints(); - }); - } catch (e) { - setTimeout(() => { - Logger.displayError( - 'Error in metrics-mappings while initializing custom dataset helper', - e, - ); - }, 1000); - } - }; - - $ionicPlatform.ready().then(function () { - getConfig().then((newConfig) => cdh.init(newConfig)); - }); - - return cdh; - }); diff --git a/www/js/metrics/CarbonDatasets.ts b/www/js/metrics/CarbonDatasets.ts new file mode 100644 index 000000000..b4815eead --- /dev/null +++ b/www/js/metrics/CarbonDatasets.ts @@ -0,0 +1,123 @@ +// Values are in Kg/PKm (kilograms per passenger-kilometer) +// Sources for EU values: +// - Tremod: 2017, CO2, CH4 and N2O in CO2-equivalent +// - HBEFA: 2020, CO2 (per country) +// German data uses Tremod. Other EU countries (and Switzerland) use HBEFA for car and bus, +// and Tremod for train and air (because HBEFA doesn't provide these). +// EU data is an average of the Tremod/HBEFA data for the countries listed; +// for this average the HBEFA data was used also in the German set (for car and bus). +export const carbonDatasets = { + US: { + regionName: 'United States', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 267 / 1609, + BUS: 278 / 1609, + LIGHT_RAIL: 120 / 1609, + SUBWAY: 74 / 1609, + TRAM: 90 / 1609, + TRAIN: 92 / 1609, + AIR_OR_HSR: 217 / 1609, + }, + }, + EU: { + // Plain average of values for the countries below (using HBEFA for car and bus, Tremod for others) + regionName: 'European Union', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 0.14515, + BUS: 0.04751, + LIGHT_RAIL: 0.064, + SUBWAY: 0.064, + TRAM: 0.064, + TRAIN: 0.048, + AIR_OR_HSR: 0.201, + }, + }, + DE: { + regionName: 'Germany', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 0.139, // Tremod (passenger car) + BUS: 0.0535, // Tremod (average city/coach) + LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) + SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) + TRAM: 0.064, // Tremod (DE tram, urban rail and subway) + TRAIN: 0.048, // Tremod (DE average short/long distance) + AIR_OR_HSR: 0.201, // Tremod (DE airplane) + }, + }, + FR: { + regionName: 'France', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 0.13125, // HBEFA (passenger car, considering 1 passenger) + BUS: 0.04838, // HBEFA (average short/long distance, considering 16/25 passengers) + LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) + SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) + TRAM: 0.064, // Tremod (DE tram, urban rail and subway) + TRAIN: 0.048, // Tremod (DE average short/long distance) + AIR_OR_HSR: 0.201, // Tremod (DE airplane) + }, + }, + AT: { + regionName: 'Austria', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 0.14351, // HBEFA (passenger car, considering 1 passenger) + BUS: 0.04625, // HBEFA (average short/long distance, considering 16/25 passengers) + LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) + SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) + TRAM: 0.064, // Tremod (DE tram, urban rail and subway) + TRAIN: 0.048, // Tremod (DE average short/long distance) + AIR_OR_HSR: 0.201, // Tremod (DE airplane) + }, + }, + SE: { + regionName: 'Sweden', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 0.13458, // HBEFA (passenger car, considering 1 passenger) + BUS: 0.04557, // HBEFA (average short/long distance, considering 16/25 passengers) + LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) + SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) + TRAM: 0.064, // Tremod (DE tram, urban rail and subway) + TRAIN: 0.048, // Tremod (DE average short/long distance) + AIR_OR_HSR: 0.201, // Tremod (DE airplane) + }, + }, + NO: { + regionName: 'Norway', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 0.13265, // HBEFA (passenger car, considering 1 passenger) + BUS: 0.04185, // HBEFA (average short/long distance, considering 16/25 passengers) + LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) + SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) + TRAM: 0.064, // Tremod (DE tram, urban rail and subway) + TRAIN: 0.048, // Tremod (DE average short/long distance) + AIR_OR_HSR: 0.201, // Tremod (DE airplane) + }, + }, + CH: { + regionName: 'Switzerland', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 0.17638, // HBEFA (passenger car, considering 1 passenger) + BUS: 0.04866, // HBEFA (average short/long distance, considering 16/25 passengers) + LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) + SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) + TRAM: 0.064, // Tremod (DE tram, urban rail and subway) + TRAIN: 0.048, // Tremod (DE average short/long distance) + AIR_OR_HSR: 0.201, // Tremod (DE airplane) + }, + }, +}; diff --git a/www/js/metrics/CarbonFootprintCard.tsx b/www/js/metrics/CarbonFootprintCard.tsx index 7c9bf3891..f2ac1cc76 100644 --- a/www/js/metrics/CarbonFootprintCard.tsx +++ b/www/js/metrics/CarbonFootprintCard.tsx @@ -3,6 +3,12 @@ import { View } from 'react-native'; import { Card, Text, useTheme } from 'react-native-paper'; import { MetricsData } from './metricsTypes'; import { cardStyles } from './MetricsTab'; +import { + getFootprintForMetrics, + setUseCustomFootprint, + getHighestFootprint, + getHighestFootprintForDistance, +} from './footprintHelper'; import { formatDateRangeOfDays, parseDataFromMetrics, @@ -13,13 +19,11 @@ import { } from './metricsHelper'; import { useTranslation } from 'react-i18next'; import BarChart from '../components/BarChart'; -import { getAngularService } from '../angular-react-helper'; import ChangeIndicator from './ChangeIndicator'; import color from 'color'; type Props = { userMetrics: MetricsData; aggMetrics: MetricsData }; const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { - const FootprintHelper = getAngularService('FootprintHelper'); const { colors } = useTheme(); const { t } = useTranslation(); @@ -51,18 +55,15 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { //set custon dataset, if the labels are custom if (isCustomLabels(userThisWeekModeMap)) { - FootprintHelper.setUseCustomFootprint(); + setUseCustomFootprint(); } //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago) let userPrevWeek; if (userLastWeekSummaryMap[0]) { userPrevWeek = { - low: FootprintHelper.getFootprintForMetrics(userLastWeekSummaryMap, 0), - high: FootprintHelper.getFootprintForMetrics( - userLastWeekSummaryMap, - FootprintHelper.getHighestFootprint(), - ), + low: getFootprintForMetrics(userLastWeekSummaryMap, 0), + high: getFootprintForMetrics(userLastWeekSummaryMap, getHighestFootprint()), }; graphRecords.push({ label: t('main-metrics.unlabeled'), @@ -78,11 +79,8 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { //calculate low-high and format range for past week (7 days ago -> yesterday) let userPastWeek = { - low: FootprintHelper.getFootprintForMetrics(userThisWeekSummaryMap, 0), - high: FootprintHelper.getFootprintForMetrics( - userThisWeekSummaryMap, - FootprintHelper.getHighestFootprint(), - ), + low: getFootprintForMetrics(userThisWeekSummaryMap, 0), + high: getFootprintForMetrics(userThisWeekSummaryMap, getHighestFootprint()), }; graphRecords.push({ label: t('main-metrics.unlabeled'), @@ -100,7 +98,7 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { } //calculate worst-case carbon footprint - let worstCarbon = FootprintHelper.getHighestFootprintForDistance(worstDistance); + let worstCarbon = getHighestFootprintForDistance(worstDistance); graphRecords.push({ label: t('main-metrics.labeled'), x: worstCarbon, @@ -138,11 +136,8 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { let groupRecords = []; let aggCarbon = { - low: FootprintHelper.getFootprintForMetrics(aggCarbonData, 0), - high: FootprintHelper.getFootprintForMetrics( - aggCarbonData, - FootprintHelper.getHighestFootprint(), - ), + low: getFootprintForMetrics(aggCarbonData, 0), + high: getFootprintForMetrics(aggCarbonData, getHighestFootprint()), }; console.log('testing group past week', aggCarbon); groupRecords.push({ diff --git a/www/js/metrics/CarbonTextCard.tsx b/www/js/metrics/CarbonTextCard.tsx index 9f1b4490f..bf40c4a61 100644 --- a/www/js/metrics/CarbonTextCard.tsx +++ b/www/js/metrics/CarbonTextCard.tsx @@ -4,6 +4,11 @@ import { Card, Text, useTheme } from 'react-native-paper'; import { MetricsData } from './metricsTypes'; import { cardStyles } from './MetricsTab'; import { useTranslation } from 'react-i18next'; +import { + getFootprintForMetrics, + getHighestFootprint, + getHighestFootprintForDistance, +} from './footprintHelper'; import { formatDateRangeOfDays, parseDataFromMetrics, @@ -17,7 +22,6 @@ type Props = { userMetrics: MetricsData; aggMetrics: MetricsData }; const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { const { colors } = useTheme(); const { t } = useTranslation(); - const FootprintHelper = getAngularService('FootprintHelper'); const userText = useMemo(() => { if (userMetrics?.distance?.length > 0) { @@ -46,11 +50,8 @@ const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago) if (userLastWeekSummaryMap[0]) { let userPrevWeek = { - low: FootprintHelper.getFootprintForMetrics(userLastWeekSummaryMap, 0), - high: FootprintHelper.getFootprintForMetrics( - userLastWeekSummaryMap, - FootprintHelper.getHighestFootprint(), - ), + low: getFootprintForMetrics(userLastWeekSummaryMap, 0), + high: getFootprintForMetrics(userLastWeekSummaryMap, getHighestFootprint()), }; const label = `${t('main-metrics.prev-week')} (${formatDateRangeOfDays(lastWeekDistance)})`; if (userPrevWeek.low == userPrevWeek.high) @@ -64,11 +65,8 @@ const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { //calculate low-high and format range for past week (7 days ago -> yesterday) let userPastWeek = { - low: FootprintHelper.getFootprintForMetrics(userThisWeekSummaryMap, 0), - high: FootprintHelper.getFootprintForMetrics( - userThisWeekSummaryMap, - FootprintHelper.getHighestFootprint(), - ), + low: getFootprintForMetrics(userThisWeekSummaryMap, 0), + high: getFootprintForMetrics(userThisWeekSummaryMap, getHighestFootprint()), }; const label = `${t('main-metrics.past-week')} (${formatDateRangeOfDays(thisWeekDistance)})`; if (userPastWeek.low == userPastWeek.high) @@ -80,7 +78,7 @@ const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { }); //calculate worst-case carbon footprint - let worstCarbon = FootprintHelper.getHighestFootprintForDistance(worstDistance); + let worstCarbon = getHighestFootprintForDistance(worstDistance); textList.push({ label: t('main-metrics.worst-case'), value: Math.round(worstCarbon) }); return textList; @@ -113,11 +111,8 @@ const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { let groupText = []; let aggCarbon = { - low: FootprintHelper.getFootprintForMetrics(aggCarbonData, 0), - high: FootprintHelper.getFootprintForMetrics( - aggCarbonData, - FootprintHelper.getHighestFootprint(), - ), + low: getFootprintForMetrics(aggCarbonData, 0), + high: getFootprintForMetrics(aggCarbonData, getHighestFootprint()), }; console.log('testing group past week', aggCarbon); const label = t('main-metrics.average'); diff --git a/www/js/metrics/CustomMetricsHelper.ts b/www/js/metrics/CustomMetricsHelper.ts new file mode 100644 index 000000000..062f6a2ca --- /dev/null +++ b/www/js/metrics/CustomMetricsHelper.ts @@ -0,0 +1,148 @@ +import angular from 'angular'; +import { getLabelOptions } from '../survey/multilabel/confirmHelper'; +import { getConfig } from '../config/dynamicConfig'; +import { storageGet, storageSet } from '../plugin/storage'; +import { displayError, logDebug } from '../plugin/logger'; +import { standardMETs } from './metDataset'; +import { carbonDatasets } from './carbonDatasets'; + +const CARBON_DATASET_KEY = 'carbon_dataset_locale'; +const defaultCarbonDatasetCode = 'US'; + +let _customMETs; +let _customPerKmFootprint; +let _range_limited_motorized; +let _inputParams; +let _currentCarbonDatasetCode = defaultCarbonDatasetCode; + +// we need to call the method from within a promise in initialize() +// and using this.setCurrentCarbonDatasetLocale doesn't seem to work +const setCurrentCarbonDatasetLocale = function (localeCode) { + for (var code in carbonDatasets) { + if (code == localeCode) { + _currentCarbonDatasetCode = localeCode; + break; + } + } +}; + +const loadCarbonDatasetLocale = function () { + return storageGet(CARBON_DATASET_KEY).then(function (localeCode) { + logDebug('loadCarbonDatasetLocale() obtained value from storage [' + localeCode + ']'); + if (!localeCode) { + localeCode = defaultCarbonDatasetCode; + logDebug('loadCarbonDatasetLocale() no value in storage, using [' + localeCode + '] instead'); + } + setCurrentCarbonDatasetLocale(localeCode); + }); +}; + +export const saveCurrentCarbonDatasetLocale = function (localeCode) { + setCurrentCarbonDatasetLocale(localeCode); + storageSet(CARBON_DATASET_KEY, _currentCarbonDatasetCode); + logDebug( + 'saveCurrentCarbonDatasetLocale() saved value [' + _currentCarbonDatasetCode + '] to storage', + ); +}; + +export const getCarbonDatasetOptions = function () { + var options = []; + for (var code in carbonDatasets) { + options.push({ + text: code, //carbonDatasets[code].regionName, + value: code, + }); + } + return options; +}; + +export const getCurrentCarbonDatasetCode = function () { + return _currentCarbonDatasetCode; +}; + +export const getCurrentCarbonDatasetFootprint = function () { + return carbonDatasets[_currentCarbonDatasetCode].footprintData; +}; + +export const getCustomMETs = function () { + console.log('Getting custom METs', _customMETs); + return _customMETs; +}; + +export const getCustomFootprint = function () { + console.log('Getting custom footprint', _customPerKmFootprint); + return _customPerKmFootprint; +}; + +const populateCustomMETs = function () { + let modeOptions = _inputParams['MODE']; + let modeMETEntries = modeOptions.map((opt) => { + if (opt.met_equivalent) { + let currMET = standardMETs[opt.met_equivalent]; + return [opt.value, currMET]; + } else { + if (opt.met) { + let currMET = opt.met; + // if the user specifies a custom MET, they can't specify + // Number.MAX_VALUE since it is not valid JSON + // we assume that they specify -1 instead, and we will + // map -1 to Number.MAX_VALUE here by iterating over all the ranges + for (const rangeName in currMET) { + // console.log("Handling range ", rangeName); + currMET[rangeName].range = currMET[rangeName].range.map((i) => + i == -1 ? Number.MAX_VALUE : i, + ); + } + return [opt.value, currMET]; + } else { + console.warn( + 'Did not find either met_equivalent or met for ' + opt.value + ' ignoring entry', + ); + return undefined; + } + } + }); + _customMETs = Object.fromEntries(modeMETEntries.filter((e) => angular.isDefined(e))); + console.log('After populating, custom METs = ', _customMETs); +}; + +const populateCustomFootprints = function () { + let modeOptions = _inputParams['MODE']; + let modeCO2PerKm = modeOptions + .map((opt) => { + if (opt.range_limit_km) { + if (_range_limited_motorized) { + displayError( + { first: _range_limited_motorized, second: opt }, + 'Found two range limited motorized options', + ); + } + _range_limited_motorized = opt; + console.log('Found range limited motorized mode', _range_limited_motorized); + } + if (angular.isDefined(opt.kgCo2PerKm)) { + return [opt.value, opt.kgCo2PerKm]; + } else { + return undefined; + } + }) + .filter((modeCO2) => angular.isDefined(modeCO2)); + _customPerKmFootprint = Object.fromEntries(modeCO2PerKm); + console.log('After populating, custom perKmFootprint', _customPerKmFootprint); +}; + +const initCustomDatasetHelper = async function (newConfig) { + newConfig = await getConfig(); + try { + getLabelOptions(newConfig).then((inputParams) => { + console.log('Input params = ', inputParams); + _inputParams = inputParams; + populateCustomMETs(); + populateCustomFootprints(); + }); + } catch (e) { + setTimeout(() => { + displayError(e, 'Error while initializing custom dataset helper'); + }, 1000); + } +}; diff --git a/www/js/metrics/METDataset.ts b/www/js/metrics/METDataset.ts new file mode 100644 index 000000000..901c17ae6 --- /dev/null +++ b/www/js/metrics/METDataset.ts @@ -0,0 +1,128 @@ +export const standardMETs = { + WALKING: { + VERY_SLOW: { + range: [0, 2.0], + mets: 2.0, + }, + SLOW: { + range: [2.0, 2.5], + mets: 2.8, + }, + MODERATE_0: { + range: [2.5, 2.8], + mets: 3.0, + }, + MODERATE_1: { + range: [2.8, 3.2], + mets: 3.5, + }, + FAST: { + range: [3.2, 3.5], + mets: 4.3, + }, + VERY_FAST_0: { + range: [3.5, 4.0], + mets: 5.0, + }, + 'VERY_FAST_!': { + range: [4.0, 4.5], + mets: 6.0, + }, + VERY_VERY_FAST: { + range: [4.5, 5], + mets: 7.0, + }, + SUPER_FAST: { + range: [5, 6], + mets: 8.3, + }, + RUNNING: { + range: [6, Number.MAX_VALUE], + mets: 9.8, + }, + }, + BICYCLING: { + VERY_VERY_SLOW: { + range: [0, 5.5], + mets: 3.5, + }, + VERY_SLOW: { + range: [5.5, 10], + mets: 5.8, + }, + SLOW: { + range: [10, 12], + mets: 6.8, + }, + MODERATE: { + range: [12, 14], + mets: 8.0, + }, + FAST: { + range: [14, 16], + mets: 10.0, + }, + VERT_FAST: { + range: [16, 19], + mets: 12.0, + }, + RACING: { + range: [20, Number.MAX_VALUE], + mets: 15.8, + }, + }, + UNKNOWN: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, + }, + IN_VEHICLE: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, + }, + CAR: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, + }, + BUS: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, + }, + LIGHT_RAIL: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, + }, + TRAIN: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, + }, + TRAM: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, + }, + SUBWAY: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, + }, + AIR_OR_HSR: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, + }, +}; diff --git a/www/js/metrics/footprintHelper.ts b/www/js/metrics/footprintHelper.ts new file mode 100644 index 000000000..763e067a2 --- /dev/null +++ b/www/js/metrics/footprintHelper.ts @@ -0,0 +1,161 @@ +import { getCustomFootprint } from './CustomMetricsHelper'; +import { getCurrentCarbonDatasetFootprint } from './CustomMetricsHelper'; + +var highestFootprint = 0; + +var mtokm = function (v) { + return v / 1000; +}; +let useCustom = false; + +export const setUseCustomFootprint = function () { + useCustom = true; +}; + +const getFootprint = function () { + if (useCustom == true) { + return getCustomFootprint(); + } else { + return getCurrentCarbonDatasetFootprint(); + } +}; + +const readableFormat = function (v) { + return v > 999 ? Math.round(v / 1000) + 'k kg CO₂' : Math.round(v) + ' kg CO₂'; +}; + +export const getFootprintForMetrics = function (userMetrics, defaultIfMissing = 0) { + var footprint = getFootprint(); + var result = 0; + for (var i in userMetrics) { + var mode = userMetrics[i].key; + if (mode == 'ON_FOOT') { + mode = 'WALKING'; + } + + if (mode in footprint) { + result += footprint[mode] * mtokm(userMetrics[i].values); + } else if (mode == 'IN_VEHICLE') { + result += + ((footprint['CAR'] + + footprint['BUS'] + + footprint['LIGHT_RAIL'] + + footprint['TRAIN'] + + footprint['TRAM'] + + footprint['SUBWAY']) / + 6) * + mtokm(userMetrics[i].values); + } else { + console.warn( + 'WARNING getFootprintFromMetrics() was requested for an unknown mode: ' + + mode + + ' metrics JSON: ' + + JSON.stringify(userMetrics), + ); + result += defaultIfMissing * mtokm(userMetrics[i].values); + } + } + return result; +}; + +const getLowestFootprintForDistance = function (distance) { + var footprint = getFootprint(); + var lowestFootprint = Number.MAX_SAFE_INTEGER; + for (var mode in footprint) { + if (mode == 'WALKING' || mode == 'BICYCLING') { + // these modes aren't considered when determining the lowest carbon footprint + } else { + lowestFootprint = Math.min(lowestFootprint, footprint[mode]); + } + } + return lowestFootprint * mtokm(distance); +}; + +export const getHighestFootprint = function () { + if (!highestFootprint) { + var footprint = getFootprint(); + let footprintList = []; + for (var mode in footprint) { + footprintList.push(footprint[mode]); + } + highestFootprint = Math.max(...footprintList); + } + return highestFootprint; +}; + +export const getHighestFootprintForDistance = function (distance) { + return getHighestFootprint() * mtokm(distance); +}; + +const getLowestMotorizedNonAirFootprint = function (footprint, rlmCO2) { + var lowestFootprint = Number.MAX_SAFE_INTEGER; + for (var mode in footprint) { + if (mode == 'AIR_OR_HSR' || mode == 'air') { + console.log('Air mode, ignoring'); + } else { + if (footprint[mode] == 0 || footprint[mode] <= rlmCO2) { + console.log( + 'Non motorized mode or footprint <= range_limited_motorized', + mode, + footprint[mode], + rlmCO2, + ); + } else { + lowestFootprint = Math.min(lowestFootprint, footprint[mode]); + } + } + } + return lowestFootprint; +}; + +const getOptimalDistanceRanges = function () { + const FIVE_KM = 5 * 1000; + const SIX_HUNDRED_KM = 600 * 1000; + if (!useCustom) { + const defaultFootprint = getCurrentCarbonDatasetFootprint(); + const lowestMotorizedNonAir = getLowestMotorizedNonAirFootprint(defaultFootprint); + const airFootprint = defaultFootprint['AIR_OR_HSR']; + return [ + { low: 0, high: FIVE_KM, optimal: 0 }, + { low: FIVE_KM, high: SIX_HUNDRED_KM, optimal: lowestMotorizedNonAir }, + { low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint }, + ]; + } else { + // custom footprint, let's get the custom values + const customFootprint = getCustomFootprint(); + let airFootprint = customFootprint['air']; + if (!airFootprint) { + // 2341 BTU/PMT from + // https://tedb.ornl.gov/wp-content/uploads/2021/02/TEDB_Ed_39.pdf#page=68 + // 159.25 lb per million BTU from EIA + // https://www.eia.gov/environment/emissions/co2_vol_mass.php + // (2341 * (159.25/1000000))/(1.6*2.2) = 0.09975, rounded up a bit + console.log('No entry for air in ', customFootprint, ' using default'); + airFootprint = 0.1; + } + const rlm = CustomDatasetHelper.range_limited_motorized; + if (!rlm) { + return [ + { low: 0, high: FIVE_KM, optimal: 0 }, + { low: FIVE_KM, high: SIX_HUNDRED_KM, optimal: lowestMotorizedNonAir }, + { low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint }, + ]; + } else { + console.log('Found range_limited_motorized mode', rlm); + const lowestMotorizedNonAir = getLowestMotorizedNonAirFootprint( + customFootprint, + rlm.kgCo2PerKm, + ); + return [ + { low: 0, high: FIVE_KM, optimal: 0 }, + { low: FIVE_KM, high: rlm.range_limit_km * 1000, optimal: rlm.kgCo2PerKm }, + { + low: rlm.range_limit_km * 1000, + high: SIX_HUNDRED_KM, + optimal: lowestMotorizedNonAir, + }, + { low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint }, + ]; + } + } +}; diff --git a/www/js/metrics/metHelper.ts b/www/js/metrics/metHelper.ts new file mode 100644 index 000000000..8f8b4df85 --- /dev/null +++ b/www/js/metrics/metHelper.ts @@ -0,0 +1,109 @@ +import { getCustomMETs } from './customMetricsHelper'; +import { standardMETs } from './metDataset'; +import { storageGet, storageSet, storageRemove } from '../plugin/storage'; + +var highestMET = 0; +var USER_DATA_KEY = 'user-data'; +let useCustom = false; + +const setUseCustomFootprint = function () { + useCustom = true; +}; + +const getMETs = function () { + if (useCustom == true) { + return getCustomMETs(); + } else { + return standardMETs; + } +}; + +const set = function (info) { + return storageSet(USER_DATA_KEY, info); +}; + +const get = function () { + return storageGet(USER_DATA_KEY); +}; + +const remove = function () { + return storageRemove(USER_DATA_KEY); +}; + +const between = function (num, min, max) { + return num >= min && num <= max; +}; + +const getHighestMET = function () { + if (!highestMET) { + var met = getMETs(); + let metList = []; + for (var mode in met) { + var rangeList = met[mode]; + for (var range in rangeList) { + metList.push(rangeList[range].mets); + } + } + highestMET = Math.max(...metList); + } + return highestMET; +}; + +const getMet = function (mode, speed, defaultIfMissing) { + if (mode == 'ON_FOOT') { + console.log("getMet() converted 'ON_FOOT' to 'WALKING'"); + mode = 'WALKING'; + } + let currentMETs = getMETs(); + if (!currentMETs[mode]) { + console.warn('getMet() Illegal mode: ' + mode); + return defaultIfMissing; //So the calorie sum does not break with wrong return type + } + for (var i in currentMETs[mode]) { + if (between(mpstomph(speed), currentMETs[mode][i].range[0], currentMETs[mode][i].range[1])) { + return currentMETs[mode][i].mets; + } else if (mpstomph(speed) < 0) { + console.log('getMet() Negative speed: ' + mpstomph(speed)); + return 0; + } + } +}; + +const mpstomph = function (mps) { + return 2.23694 * mps; +}; + +const lbtokg = function (lb) { + return lb * 0.453592; +}; + +const fttocm = function (ft) { + return ft * 30.48; +}; + +const getCorrectedMet = function (met, gender, age, height, heightUnit, weight, weightUnit) { + var height = heightUnit == 0 ? fttocm(height) : height; + var weight = weightUnit == 0 ? lbtokg(weight) : weight; + let calcMet; + if (gender == 1) { + //male + calcMet = + (met * 3.5) / + (((66.473 + 5.0033 * height + 13.7516 * weight - 6.755 * age) / 1440 / 5 / weight) * 1000); + return met; + } else if (gender == 0) { + //female + let met = + (calcMet * 3.5) / + (((655.0955 + 1.8496 * height + 9.5634 * weight - 4.6756 * age) / 1440 / 5 / weight) * 1000); + return calcMet; + } +}; + +const getuserCalories = function (durationInMin, met) { + return 65 * durationInMin * met; +}; + +const getCalories = function (weightInKg, durationInMin, met) { + return weightInKg * durationInMin * met; +}; From 70f7238b232006c3e1ae5a40d4d30a195691ae23 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 8 Nov 2023 15:55:29 -0700 Subject: [PATCH 349/850] display message instead of error I was using the wrong function when I first converted --- www/js/metrics/CustomMetricsHelper.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/www/js/metrics/CustomMetricsHelper.ts b/www/js/metrics/CustomMetricsHelper.ts index 062f6a2ca..7d87f0344 100644 --- a/www/js/metrics/CustomMetricsHelper.ts +++ b/www/js/metrics/CustomMetricsHelper.ts @@ -2,7 +2,7 @@ import angular from 'angular'; import { getLabelOptions } from '../survey/multilabel/confirmHelper'; import { getConfig } from '../config/dynamicConfig'; import { storageGet, storageSet } from '../plugin/storage'; -import { displayError, logDebug } from '../plugin/logger'; +import { displayError, displayErrorMsg, logDebug } from '../plugin/logger'; import { standardMETs } from './metDataset'; import { carbonDatasets } from './carbonDatasets'; @@ -112,8 +112,8 @@ const populateCustomFootprints = function () { .map((opt) => { if (opt.range_limit_km) { if (_range_limited_motorized) { - displayError( - { first: _range_limited_motorized, second: opt }, + displayErrorMsg( + JSON.stringify({ first: _range_limited_motorized, second: opt }), 'Found two range limited motorized options', ); } From 5108352e77ad04bdb0f563c2a8f74a63cb611096 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 8 Nov 2023 15:56:12 -0700 Subject: [PATCH 350/850] reduce naming confusion there is a similar function for the footprint, lets make sure they have different names --- www/js/metrics/metHelper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/metrics/metHelper.ts b/www/js/metrics/metHelper.ts index 8f8b4df85..6c052b0be 100644 --- a/www/js/metrics/metHelper.ts +++ b/www/js/metrics/metHelper.ts @@ -6,7 +6,7 @@ var highestMET = 0; var USER_DATA_KEY = 'user-data'; let useCustom = false; -const setUseCustomFootprint = function () { +const setUseCustomMET = function () { useCustom = true; }; From 2bcdac80931927378fd58b246c120d3d29cfe1ed Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Wed, 8 Nov 2023 15:22:14 -0800 Subject: [PATCH 351/850] Added basic timelineHelper tests - Added test for each diaryService rewrite --- www/__mocks__/cordovaMocks.ts | 4 +- www/__tests__/timelineHelper.test.ts | 121 +++++++++++++++++++++++++++ www/js/diary/timelineHelper.ts | 13 +-- www/js/types/diaryTypes.ts | 4 +- 4 files changed, 133 insertions(+), 9 deletions(-) create mode 100644 www/__tests__/timelineHelper.test.ts diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 62aa9be1a..f8d7ae74f 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -1,5 +1,4 @@ import packageJsonBuild from '../../package.cordovabuild.json'; - export const mockCordova = () => { window['cordova'] ||= {}; window['cordova'].platformId ||= 'ios'; @@ -116,6 +115,9 @@ export const mockBEMUserCache = () => { return false; } }, + getMessagesForInterval: () => { + // Used for getUnifiedDataForInterval + }, }; window['cordova'] ||= {}; window['cordova'].plugins ||= {}; diff --git a/www/__tests__/timelineHelper.test.ts b/www/__tests__/timelineHelper.test.ts new file mode 100644 index 000000000..c6a4bcd67 --- /dev/null +++ b/www/__tests__/timelineHelper.test.ts @@ -0,0 +1,121 @@ +import { mockLogger } from '../__mocks__/globalMocks'; +import { readAllCompositeTrips, readUnprocessedTrips } from '../js/diary/timelineHelper'; +import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; + +import { MetaData, ServerResponse } from '../js/types/serverData'; +import { CompositeTrip } from '../js/types/diaryTypes'; + +mockLogger(); +mockBEMUserCache(); +const mockMetaData: MetaData = { + write_ts: -13885091, + key: 'test/value', + platform: 'test', + time_zone: 'America/Los_Angeles', + write_fmt_time: '1969-07-16T07:01:49.000Z', + write_local_dt: null, + origin_key: '12345', +}; +const mockData: ServerResponse = { + phone_data: [ + { + data: { + _id: null, + additions: [], + cleaned_section_summary: null, // TODO + cleaned_trip: null, //ObjId; + confidence_threshold: -1, + confirmed_trip: null, //ObjId; + distance: 777, + duration: 777, + end_confirmed_place: { + data: null, + metadata: JSON.parse(JSON.stringify(mockMetaData)), + }, + end_fmt_time: '2023-11-01T17:55:20.999397-07:00', + end_loc: { + type: 'Point', + coordinates: [-1, -1], + }, + end_local_dt: null, //LocalDt; + end_place: null, //ObjId; + end_ts: -1, + expectation: null, // TODO "{to_label: boolean}" + expected_trip: null, //ObjId; + inferred_labels: [], // TODO + inferred_section_summary: { + count: { + CAR: 1, + WALKING: 1, + }, + distance: { + CAR: 222, + WALKING: 222, + }, + duration: { + CAR: 333, + WALKING: 333, + }, + }, + inferred_trip: null, + key: '12345', + locations: [ + { + metadata: JSON.parse(JSON.stringify(mockMetaData)), + data: null, + }, + ], // LocationType + origin_key: '', + raw_trip: null, + sections: [ + { + metadata: JSON.parse(JSON.stringify(mockMetaData)), + data: null, + }, + ], // TODO + source: 'DwellSegmentationDistFilter', + start_confirmed_place: { + data: null, + metadata: JSON.parse(JSON.stringify(mockMetaData)), + }, + start_fmt_time: '2023-11-01T17:55:20.999397-07:00', + start_loc: { + type: 'Point', + coordinates: [-1, -1], + }, + start_local_dt: null, + start_place: null, + start_ts: null, + user_input: null, + }, + metadata: JSON.parse(JSON.stringify(mockMetaData)), + }, + ], +}; + +const testStart = -14576291; +const testEnd = -13885091; + +jest.mock('../js/commHelper', () => ({ + getRawEntries: jest.fn(() => mockData), +})); + +it('fetches a composite trip object and collapses it', async () => { + // When we have End-to-End testing, we can properly test with getRawEnteries + console.log(JSON.stringify(mockData, null, 2)); + expect(readAllCompositeTrips(testStart, testEnd)).resolves.not.toThrow(); +}); + +jest.mock('../js/unifiedDataLoader', () => ({ + getUnifiedDataForInterval: jest.fn(() => { + return Promise.resolve([]); + }), +})); + +it('works when there are no unprocessed trips...', async () => { + expect(readUnprocessedTrips(testStart, testEnd, null)).resolves.not.toThrow(); +}); + +it('works when there are no unprocessed trips...', async () => { + expect(readUnprocessedTrips(testStart, testEnd, null)).resolves.not.toThrow(); +}); diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 07cdb6dc8..83d9cbf1c 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -1,4 +1,3 @@ -import { getAngularService } from '../angular-react-helper'; import { displayError, logDebug } from '../plugin/logger'; import { getBaseModeByKey, getBaseModeOfLabeledTrip } from './diaryHelper'; import { getUnifiedDataForInterval } from '../unifiedDataLoader'; @@ -7,7 +6,7 @@ import { ServerResponse, ServerData } from '../types/serverData'; import L from 'leaflet'; import i18next from 'i18next'; import { DateTime } from 'luxon'; -import { CompositeTrip } from '../types/diaryTypes'; +import { CompositeTrip, TripTransition, SectionData, Trip } from '../types/diaryTypes'; import { LabelOptions } from '../types/labelTypes'; const cachedGeojsons = new Map(); @@ -235,7 +234,7 @@ const unpackServerData = (obj: ServerData) => ({ export const readAllCompositeTrips = function (startTs: number, endTs: number) { const readPromises = [getRawEntries(['analysis/composite_trip'], startTs, endTs, 'data.end_ts')]; return Promise.all(readPromises) - .then(([ctList]: [ServerResponse]) => { + .then(([ctList]: [ServerResponse]) => { return ctList.phone_data.map((ct) => { const unpackedCt = unpackServerData(ct); return { @@ -439,7 +438,7 @@ const isEndingTransition = function (transWrapper) { * * Let's abstract this out into our own minor state machine. */ -const transitions2Trips = function (transitionList) { +const transitions2Trips = function (transitionList: Array) { var inTrip = false; var tripList = []; var currStartTransitionIndex = -1; @@ -517,9 +516,11 @@ export const readUnprocessedTrips = function (startTs, endTs, lastProcessedTrip) DateTime.fromSeconds(tq.endTs).toLocaleString(DateTime.DATETIME_MED), ); + console.log('Testing...'); const getMessageMethod = window['cordova'].plugins.BEMUserCache.getMessagesForInterval; + console.log('Entering...'); return getUnifiedDataForInterval('statemachine/transition', tq, getMessageMethod).then(function ( - transitionList: Array, + transitionList: Array, ) { if (transitionList.length == 0) { logDebug('No unprocessed trips. yay!'); @@ -529,7 +530,7 @@ export const readUnprocessedTrips = function (startTs, endTs, lastProcessedTrip) const tripsList = transitions2Trips(transitionList); logDebug(`Mapped into ${tripsList.length} trips. yay!`); tripsList.forEach(function (trip) { - logDebug(JSON.stringify(trip)); + logDebug(JSON.stringify(trip, null, 2)); }); var tripFillPromises = tripsList.map(transitionTrip2TripObj); return Promise.all(tripFillPromises).then(function (raw_trip_gj_list) { diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 4f10fb50f..933e2db68 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -55,7 +55,7 @@ export type CompositeTrip = { confirmed_trip: ObjId; distance: number; duration: number; - end_confirmed_place: ConfirmedPlace; + end_confirmed_place: ServerData; end_fmt_time: string; end_loc: { type: string; coordinates: number[] }; end_local_dt: LocalDt; @@ -72,7 +72,7 @@ export type CompositeTrip = { raw_trip: ObjId; sections: any[]; // TODO source: string; - start_confirmed_place: ConfirmedPlace; + start_confirmed_place: ServerData; start_fmt_time: string; start_loc: { type: string; coordinates: number[] }; start_local_dt: LocalDt; From ae5ae954fa1d5afe86f5618a8a80840f3e41b1cf Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Wed, 8 Nov 2023 15:22:51 -0800 Subject: [PATCH 352/850] Updated issue with index.html --- www/index.html | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/www/index.html b/www/index.html index b46904cca..72c75eb01 100644 --- a/www/index.html +++ b/www/index.html @@ -1,22 +1,18 @@ - - - + + + - + - + -
    +
    From 9118133e23ae2298779d23315020c388c8be7d84 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 8 Nov 2023 19:13:30 -0700 Subject: [PATCH 353/850] write footprinthelper tests first pass of tests over the footprintHelper getFootprintForMetrics is one of the main functions, used to calculate displayed values for the dashboard screen --- www/__mocks__/cordovaMocks.ts | 22 ++- www/__mocks__/fakeConfig.json | 88 +++++++++ www/__mocks__/fakeLabels.json | 211 ++++++++++++++++++++++ www/__tests__/footprintHelper.test.ts | 64 +++++++ www/js/metrics/CustomMetricsHelper.ts | 2 +- www/js/metrics/footprintHelper.ts | 4 +- www/js/survey/multilabel/confirmHelper.ts | 3 +- 7 files changed, 385 insertions(+), 9 deletions(-) create mode 100644 www/__mocks__/fakeConfig.json create mode 100644 www/__mocks__/fakeLabels.json create mode 100644 www/__tests__/footprintHelper.test.ts diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 62aa9be1a..ad69b52fe 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -1,4 +1,5 @@ import packageJsonBuild from '../../package.cordovabuild.json'; +import fakeConfig from './fakeConfig.json'; export const mockCordova = () => { window['cordova'] ||= {}; @@ -99,11 +100,22 @@ export const mockBEMUserCache = () => { ); }, getDocument: (key: string, withMetadata?: boolean) => { - return new Promise((rs, rj) => - setTimeout(() => { - rs(_storage[key]); - }, 100), - ); + // this was mocked specifically for enketoHelper's use, could be expanded if needed + const fakeSurveyConfig = fakeConfig; + + if (key == 'config/app_ui_config') { + return new Promise((rs, rj) => + setTimeout(() => { + rs(fakeSurveyConfig); + }, 100), + ); + } else { + return new Promise((rs, rj) => + setTimeout(() => { + rs(_storage[key]); + }, 100), + ); + } }, isEmptyDoc: (doc) => { if (doc == undefined) { diff --git a/www/__mocks__/fakeConfig.json b/www/__mocks__/fakeConfig.json new file mode 100644 index 000000000..dabec6cd9 --- /dev/null +++ b/www/__mocks__/fakeConfig.json @@ -0,0 +1,88 @@ +{ + "version": 1, + "ts": 1655143472, + "server": { + "connectUrl": "https://openpath-test.nrel.gov/api/", + "aggregate_call_auth": "user_only" + }, + "intro": { + "program_or_study": "study", + "start_month": "10", + "start_year": "2023", + "program_admin_contact": "K. Shankari", + "deployment_partner_name": "NREL", + "translated_text": { + "en": { + "deployment_partner_name": "NREL", + "deployment_name": "Testing environment for Jest testing", + "summary_line_1": "", + "summary_line_2": "", + "summary_line_3": "", + "short_textual_description": "", + "why_we_collect": "", + "research_questions": ["", ""] + }, + "es": { + "deployment_partner_name": "NREL", + "deployment_name": "Ambiente prueba para las pruebas de Jest", + "summary_line_1": "", + "summary_line_2": "", + "summary_line_3": "", + "short_textual_description": "", + "why_we_collect": "", + "research_questions": ["", ""] + } + } + }, + "survey_info": { + "surveys": { + "TimeUseSurvey": { + "compatibleWith": 1, + "formPath": "https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json", + "labelTemplate": { + "en": "{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }", + "es": "{ erea, plural, =0 {} other {# Empleo/Educación, } }{ da, plural, =0 {} other {# Actividades domesticas, }}" + }, + "labelVars": { + "da": { + "key": "Domestic_activities", + "type": "length" + }, + "erea": { + "key": "Employment_related_a_Education_activities", + "type": "length" + } + }, + "version": 9 + } + }, + "trip-labels": "ENKETO" + }, + "display_config": { + "use_imperial": false + }, + "profile_controls": { + "support_upload": true, + "trip_end_notification": false + }, + "admin_dashboard": { + "overview_users": true, + "overview_active_users": true, + "overview_trips": true, + "overview_signup_trends": true, + "overview_trips_trend": true, + "data_uuids": true, + "data_trips": true, + "data_trips_columns_exclude": [], + "additional_trip_columns": [], + "data_uuids_columns_exclude": [], + "token_generate": true, + "token_prefix": "nrelop", + "map_heatmap": true, + "map_bubble": true, + "map_trip_lines": true, + "push_send": true, + "options_uuids": true, + "options_emails": true + } +} diff --git a/www/__mocks__/fakeLabels.json b/www/__mocks__/fakeLabels.json new file mode 100644 index 000000000..2b1f3e824 --- /dev/null +++ b/www/__mocks__/fakeLabels.json @@ -0,0 +1,211 @@ +{ + "MODE": [ + { + "value": "walk", + "baseMode": "WALKING", + "met_equivalent": "WALKING", + "kgCo2PerKm": 0 + }, + { + "value": "e-bike", + "baseMode": "E_BIKE", + "met": { + "ALL": { + "range": [ + 0, + -1 + ], + "mets": 4.9 + } + }, + "kgCo2PerKm": 0.00728 + }, + { + "value": "bike", + "baseMode": "BICYCLING", + "met_equivalent": "BICYCLING", + "kgCo2PerKm": 0 + }, + { + "value": "bikeshare", + "baseMode": "BICYCLING", + "met_equivalent": "BICYCLING", + "kgCo2PerKm": 0 + }, + { + "value": "scootershare", + "baseMode": "E_SCOOTER", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.00894 + }, + { + "value": "drove_alone", + "baseMode": "CAR", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.22031 + }, + { + "value": "shared_ride", + "baseMode": "CAR", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.11015 + }, + { + "value": "hybrid_drove_alone", + "baseMode": "CAR", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.127 + }, + { + "value": "hybrid_shared_ride", + "baseMode": "CAR", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.0635 + }, + { + "value": "e_car_drove_alone", + "baseMode": "E_CAR", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.08216 + }, + { + "value": "e_car_shared_ride", + "baseMode": "E_CAR", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.04108 + }, + { + "value": "taxi", + "baseMode": "TAXI", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.30741 + }, + { + "value": "bus", + "baseMode": "BUS", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.20727 + }, + { + "value": "train", + "baseMode": "TRAIN", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.12256 + }, + { + "value": "free_shuttle", + "baseMode": "BUS", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.20727 + }, + { + "value": "air", + "baseMode": "AIR", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.09975 + }, + { + "value": "not_a_trip", + "baseMode": "UNKNOWN", + "met_equivalent": "UNKNOWN", + "kgCo2PerKm": 0 + }, + { + "value": "other", + "baseMode": "OTHER", + "met_equivalent": "UNKNOWN", + "kgCo2PerKm": 0 + } + ], + "PURPOSE": [ + { + "value": "home" + }, + { + "value": "work" + }, + { + "value": "at_work" + }, + { + "value": "school" + }, + { + "value": "transit_transfer" + }, + { + "value": "shopping" + }, + { + "value": "meal" + }, + { + "value": "pick_drop_person" + }, + { + "value": "pick_drop_item" + }, + { + "value": "personal_med" + }, + { + "value": "access_recreation" + }, + { + "value": "exercise" + }, + { + "value": "entertainment" + }, + { + "value": "religious" + }, + { + "value": "other" + } + ], + "REPLACED_MODE": [ + { + "value": "no_travel" + }, + { + "value": "walk" + }, + { + "value": "bike" + }, + { + "value": "bikeshare" + }, + { + "value": "scootershare" + }, + { + "value": "drove_alone" + }, + { + "value": "shared_ride" + }, + { + "value": "e_car_drove_alone" + }, + { + "value": "e_car_shared_ride" + }, + { + "value": "taxi" + }, + { + "value": "bus" + }, + { + "value": "train" + }, + { + "value": "free_shuttle" + }, + { + "value": "other" + } + ] +} diff --git a/www/__tests__/footprintHelper.test.ts b/www/__tests__/footprintHelper.test.ts new file mode 100644 index 000000000..336e46c1d --- /dev/null +++ b/www/__tests__/footprintHelper.test.ts @@ -0,0 +1,64 @@ +import { initCustomDatasetHelper } from '../js/metrics/CustomMetricsHelper'; +import { + getFootprintForMetrics, + mtokm, + setUseCustomFootprint, +} from '../js/metrics/footprintHelper'; +import { getConfig } from '../js/config/dynamicConfig'; +import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; +import fakeLabels from '../__mocks__/fakeLabels.json'; + +mockBEMUserCache(); +mockLogger(); + +global.fetch = (url: string) => + new Promise((rs, rj) => { + setTimeout(() => + rs({ + text: () => + new Promise((rs, rj) => { + let myJSON = JSON.stringify(fakeLabels); + setTimeout(() => rs(myJSON), 100); + }), + }), + ); + }) as any; + +const metrics = [ + { key: 'WALKING', values: 3000 }, + { key: 'BICYCLING', values: 6500 }, + { key: 'CAR', values: 50000 }, + { key: 'LIGHT_RAIL', values: 30000 }, + { key: 'Unicycle', values: 5000 }, +]; + +it('gets footprint for metrics (not custom, fallback 0', () => { + expect(getFootprintForMetrics(metrics, 0)).toBe(10.534493474207583); +}); + +it('gets footprint for metrics (not custom, fallback 0.1', () => { + expect(getFootprintForMetrics(metrics, 0.1)).toBe(10.534493474207583 + 0.5); +}); + +const custom_metrics = [ + { key: 'walk', values: 3000 }, + { key: 'bike', values: 6500 }, + { key: 'drove_alone', values: 10000 }, + { key: 'scootershare', values: 25000 }, + { key: 'unicycle', values: 5000 }, +]; + +it('gets footprint for metrics (custom, fallback 0', async () => { + initCustomDatasetHelper(getConfig()); + setUseCustomFootprint(); + await new Promise((r) => setTimeout(r, 500)); + expect(getFootprintForMetrics(custom_metrics, 0)).toBe(2.4266); +}); + +it('gets footprint for metrics (custom, fallback 0.1', async () => { + initCustomDatasetHelper(getConfig()); + setUseCustomFootprint(); + await new Promise((r) => setTimeout(r, 500)); + expect(getFootprintForMetrics(custom_metrics, 0.1)).toBe(2.4266 + 0.5); +}); diff --git a/www/js/metrics/CustomMetricsHelper.ts b/www/js/metrics/CustomMetricsHelper.ts index 7d87f0344..3d7eaf507 100644 --- a/www/js/metrics/CustomMetricsHelper.ts +++ b/www/js/metrics/CustomMetricsHelper.ts @@ -131,7 +131,7 @@ const populateCustomFootprints = function () { console.log('After populating, custom perKmFootprint', _customPerKmFootprint); }; -const initCustomDatasetHelper = async function (newConfig) { +export const initCustomDatasetHelper = async function (newConfig) { newConfig = await getConfig(); try { getLabelOptions(newConfig).then((inputParams) => { diff --git a/www/js/metrics/footprintHelper.ts b/www/js/metrics/footprintHelper.ts index 763e067a2..4725aa1c7 100644 --- a/www/js/metrics/footprintHelper.ts +++ b/www/js/metrics/footprintHelper.ts @@ -2,11 +2,11 @@ import { getCustomFootprint } from './CustomMetricsHelper'; import { getCurrentCarbonDatasetFootprint } from './CustomMetricsHelper'; var highestFootprint = 0; +let useCustom = false; -var mtokm = function (v) { +const mtokm = function (v) { return v / 1000; }; -let useCustom = false; export const setUseCustomFootprint = function () { useCustom = true; diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index a8972709b..6bcd85a50 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -35,7 +35,7 @@ export let inputDetails: InputDetails<'MODE' | 'PURPOSE' | 'REPLACED_MODE'>; export async function getLabelOptions(appConfigParam?) { if (appConfigParam) appConfig = appConfigParam; if (labelOptions) return labelOptions; - + console.log('in get label options', appConfig); if (appConfig.label_options) { const labelOptionsJson = await fetchUrlCached(appConfig.label_options); logDebug( @@ -48,6 +48,7 @@ export async function getLabelOptions(appConfigParam?) { 'No label_options found in config, using default label options at ' + defaultLabelOptionsURL, ); const defaultLabelOptionsJson = await fetchUrlCached(defaultLabelOptionsURL); + console.log('label options', defaultLabelOptionsJson); labelOptions = JSON.parse(defaultLabelOptionsJson) as LabelOptions; } /* fill in the translations to the 'text' fields of the labelOptions, From 2cdd212c778320fb76cfb151545389b2012c8e09 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 8 Nov 2023 19:15:29 -0700 Subject: [PATCH 354/850] fix formatting --- www/__mocks__/fakeLabels.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/www/__mocks__/fakeLabels.json b/www/__mocks__/fakeLabels.json index 2b1f3e824..676dc97b6 100644 --- a/www/__mocks__/fakeLabels.json +++ b/www/__mocks__/fakeLabels.json @@ -11,10 +11,7 @@ "baseMode": "E_BIKE", "met": { "ALL": { - "range": [ - 0, - -1 - ], + "range": [0, -1], "mets": 4.9 } }, From 1a734f766a887a13006f6c1d67281a46d60fd441 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 8 Nov 2023 19:15:44 -0700 Subject: [PATCH 355/850] remove unused import --- www/__tests__/footprintHelper.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/www/__tests__/footprintHelper.test.ts b/www/__tests__/footprintHelper.test.ts index 336e46c1d..07c0a1264 100644 --- a/www/__tests__/footprintHelper.test.ts +++ b/www/__tests__/footprintHelper.test.ts @@ -1,9 +1,5 @@ import { initCustomDatasetHelper } from '../js/metrics/CustomMetricsHelper'; -import { - getFootprintForMetrics, - mtokm, - setUseCustomFootprint, -} from '../js/metrics/footprintHelper'; +import { getFootprintForMetrics, setUseCustomFootprint } from '../js/metrics/footprintHelper'; import { getConfig } from '../js/config/dynamicConfig'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; From 1d7bf5b2bc42c6e55556bdcfd8aa00f84586ef7a Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 9 Nov 2023 09:13:35 -0700 Subject: [PATCH 356/850] remove country-specific since we've changed the way fallback labels are handled, everything should be custom now. This means that we're not using the carbon values from the set country, but rather from the labels --- www/__tests__/footprintHelper.test.ts | 4 +- www/js/metrics/CarbonDatasets.ts | 123 ------------------------ www/js/metrics/CustomMetricsHelper.ts | 74 +++----------- www/js/metrics/carbonDatasetFallback.ts | 14 +++ www/js/metrics/footprintHelper.ts | 12 +-- 5 files changed, 36 insertions(+), 191 deletions(-) delete mode 100644 www/js/metrics/CarbonDatasets.ts create mode 100644 www/js/metrics/carbonDatasetFallback.ts diff --git a/www/__tests__/footprintHelper.test.ts b/www/__tests__/footprintHelper.test.ts index 07c0a1264..5267bc858 100644 --- a/www/__tests__/footprintHelper.test.ts +++ b/www/__tests__/footprintHelper.test.ts @@ -29,11 +29,11 @@ const metrics = [ { key: 'Unicycle', values: 5000 }, ]; -it('gets footprint for metrics (not custom, fallback 0', () => { +it('gets footprint for metrics (not custom, fallback 0)', () => { expect(getFootprintForMetrics(metrics, 0)).toBe(10.534493474207583); }); -it('gets footprint for metrics (not custom, fallback 0.1', () => { +it('gets footprint for metrics (not custom, fallback 0.1)', () => { expect(getFootprintForMetrics(metrics, 0.1)).toBe(10.534493474207583 + 0.5); }); diff --git a/www/js/metrics/CarbonDatasets.ts b/www/js/metrics/CarbonDatasets.ts deleted file mode 100644 index b4815eead..000000000 --- a/www/js/metrics/CarbonDatasets.ts +++ /dev/null @@ -1,123 +0,0 @@ -// Values are in Kg/PKm (kilograms per passenger-kilometer) -// Sources for EU values: -// - Tremod: 2017, CO2, CH4 and N2O in CO2-equivalent -// - HBEFA: 2020, CO2 (per country) -// German data uses Tremod. Other EU countries (and Switzerland) use HBEFA for car and bus, -// and Tremod for train and air (because HBEFA doesn't provide these). -// EU data is an average of the Tremod/HBEFA data for the countries listed; -// for this average the HBEFA data was used also in the German set (for car and bus). -export const carbonDatasets = { - US: { - regionName: 'United States', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 267 / 1609, - BUS: 278 / 1609, - LIGHT_RAIL: 120 / 1609, - SUBWAY: 74 / 1609, - TRAM: 90 / 1609, - TRAIN: 92 / 1609, - AIR_OR_HSR: 217 / 1609, - }, - }, - EU: { - // Plain average of values for the countries below (using HBEFA for car and bus, Tremod for others) - regionName: 'European Union', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.14515, - BUS: 0.04751, - LIGHT_RAIL: 0.064, - SUBWAY: 0.064, - TRAM: 0.064, - TRAIN: 0.048, - AIR_OR_HSR: 0.201, - }, - }, - DE: { - regionName: 'Germany', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.139, // Tremod (passenger car) - BUS: 0.0535, // Tremod (average city/coach) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201, // Tremod (DE airplane) - }, - }, - FR: { - regionName: 'France', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.13125, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04838, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201, // Tremod (DE airplane) - }, - }, - AT: { - regionName: 'Austria', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.14351, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04625, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201, // Tremod (DE airplane) - }, - }, - SE: { - regionName: 'Sweden', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.13458, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04557, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201, // Tremod (DE airplane) - }, - }, - NO: { - regionName: 'Norway', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.13265, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04185, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201, // Tremod (DE airplane) - }, - }, - CH: { - regionName: 'Switzerland', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.17638, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04866, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201, // Tremod (DE airplane) - }, - }, -}; diff --git a/www/js/metrics/CustomMetricsHelper.ts b/www/js/metrics/CustomMetricsHelper.ts index 3d7eaf507..11493cb95 100644 --- a/www/js/metrics/CustomMetricsHelper.ts +++ b/www/js/metrics/CustomMetricsHelper.ts @@ -1,79 +1,35 @@ import angular from 'angular'; import { getLabelOptions } from '../survey/multilabel/confirmHelper'; import { getConfig } from '../config/dynamicConfig'; -import { storageGet, storageSet } from '../plugin/storage'; import { displayError, displayErrorMsg, logDebug } from '../plugin/logger'; import { standardMETs } from './metDataset'; -import { carbonDatasets } from './carbonDatasets'; - -const CARBON_DATASET_KEY = 'carbon_dataset_locale'; -const defaultCarbonDatasetCode = 'US'; +import { fallbackCarbon } from './carbonDatasetFallback'; let _customMETs; let _customPerKmFootprint; let _range_limited_motorized; let _inputParams; -let _currentCarbonDatasetCode = defaultCarbonDatasetCode; - -// we need to call the method from within a promise in initialize() -// and using this.setCurrentCarbonDatasetLocale doesn't seem to work -const setCurrentCarbonDatasetLocale = function (localeCode) { - for (var code in carbonDatasets) { - if (code == localeCode) { - _currentCarbonDatasetCode = localeCode; - break; - } - } -}; - -const loadCarbonDatasetLocale = function () { - return storageGet(CARBON_DATASET_KEY).then(function (localeCode) { - logDebug('loadCarbonDatasetLocale() obtained value from storage [' + localeCode + ']'); - if (!localeCode) { - localeCode = defaultCarbonDatasetCode; - logDebug('loadCarbonDatasetLocale() no value in storage, using [' + localeCode + '] instead'); - } - setCurrentCarbonDatasetLocale(localeCode); - }); -}; - -export const saveCurrentCarbonDatasetLocale = function (localeCode) { - setCurrentCarbonDatasetLocale(localeCode); - storageSet(CARBON_DATASET_KEY, _currentCarbonDatasetCode); - logDebug( - 'saveCurrentCarbonDatasetLocale() saved value [' + _currentCarbonDatasetCode + '] to storage', - ); -}; - -export const getCarbonDatasetOptions = function () { - var options = []; - for (var code in carbonDatasets) { - options.push({ - text: code, //carbonDatasets[code].regionName, - value: code, - }); - } - return options; -}; - -export const getCurrentCarbonDatasetCode = function () { - return _currentCarbonDatasetCode; -}; - -export const getCurrentCarbonDatasetFootprint = function () { - return carbonDatasets[_currentCarbonDatasetCode].footprintData; -}; export const getCustomMETs = function () { - console.log('Getting custom METs', _customMETs); + logDebug('Getting custom METs' + JSON.stringify(_customMETs)); return _customMETs; }; export const getCustomFootprint = function () { - console.log('Getting custom footprint', _customPerKmFootprint); + logDebug('Getting custom footprint' + JSON.stringify(_customPerKmFootprint)); return _customPerKmFootprint; }; +export const getRangeLimitedMotorixe = function () { + logDebug('Getting range limited motorized' + JSON.stringify(_range_limited_motorized)); + return _range_limited_motorized; +}; + +export const getFallbackFootprint = function () { + console.log('getting fallback carbon'); + return fallbackCarbon.footprintData; +}; + const populateCustomMETs = function () { let modeOptions = _inputParams['MODE']; let modeMETEntries = modeOptions.map((opt) => { @@ -103,7 +59,7 @@ const populateCustomMETs = function () { } }); _customMETs = Object.fromEntries(modeMETEntries.filter((e) => angular.isDefined(e))); - console.log('After populating, custom METs = ', _customMETs); + logDebug('After populating, custom METs = ' + JSON.stringify(_customMETs)); }; const populateCustomFootprints = function () { @@ -128,7 +84,7 @@ const populateCustomFootprints = function () { }) .filter((modeCO2) => angular.isDefined(modeCO2)); _customPerKmFootprint = Object.fromEntries(modeCO2PerKm); - console.log('After populating, custom perKmFootprint', _customPerKmFootprint); + logDebug('After populating, custom perKmFootprint' + JSON.stringify(_customPerKmFootprint)); }; export const initCustomDatasetHelper = async function (newConfig) { diff --git a/www/js/metrics/carbonDatasetFallback.ts b/www/js/metrics/carbonDatasetFallback.ts new file mode 100644 index 000000000..4561d078a --- /dev/null +++ b/www/js/metrics/carbonDatasetFallback.ts @@ -0,0 +1,14 @@ +export const fallbackCarbon = { + regionName: 'United States', + footprintData: { + WALKING: 0, + BICYCLING: 0, + CAR: 267 / 1609, + BUS: 278 / 1609, + LIGHT_RAIL: 120 / 1609, + SUBWAY: 74 / 1609, + TRAM: 90 / 1609, + TRAIN: 92 / 1609, + AIR_OR_HSR: 217 / 1609, + }, +}; diff --git a/www/js/metrics/footprintHelper.ts b/www/js/metrics/footprintHelper.ts index 4725aa1c7..2162e87bf 100644 --- a/www/js/metrics/footprintHelper.ts +++ b/www/js/metrics/footprintHelper.ts @@ -1,5 +1,5 @@ -import { getCustomFootprint } from './CustomMetricsHelper'; -import { getCurrentCarbonDatasetFootprint } from './CustomMetricsHelper'; +import { displayErrorMsg } from '../plugin/logger'; +import { getCustomFootprint, getFallbackFootprint } from './CustomMetricsHelper'; var highestFootprint = 0; let useCustom = false; @@ -16,14 +16,12 @@ const getFootprint = function () { if (useCustom == true) { return getCustomFootprint(); } else { - return getCurrentCarbonDatasetFootprint(); + //TODO: check through configs and ensure they all have custom lables + displayErrorMsg('Error in Footprint Calculatons', 'issue with data or default labels'); + return getFallbackFootprint(); } }; -const readableFormat = function (v) { - return v > 999 ? Math.round(v / 1000) + 'k kg CO₂' : Math.round(v) + ' kg CO₂'; -}; - export const getFootprintForMetrics = function (userMetrics, defaultIfMissing = 0) { var footprint = getFootprint(); var result = 0; From 4c2bbf0d32736b80b935ed86dc3f4cc4bc78d593 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 9 Nov 2023 11:16:21 -0500 Subject: [PATCH 357/850] add ErrorBoundary to each tab --- www/js/App.tsx | 7 ++++--- www/js/plugin/ErrorBoundary.tsx | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 www/js/plugin/ErrorBoundary.tsx diff --git a/www/js/App.tsx b/www/js/App.tsx index ab4caebf7..54b677add 100644 --- a/www/js/App.tsx +++ b/www/js/App.tsx @@ -15,6 +15,7 @@ import { import { setServerConnSettings } from './config/serverConn'; import AppStatusModal from './control/AppStatusModal'; import usePermissionStatus from './usePermissionStatus'; +import { withErrorBoundary } from './plugin/ErrorBoundary'; const defaultRoutes = (t) => [ { @@ -55,9 +56,9 @@ const App = () => { }, [appConfig, t]); const renderScene = BottomNavigation.SceneMap({ - label: LabelTab, - metrics: MetricsTab, - control: ProfileSettings, + label: withErrorBoundary(LabelTab), + metrics: withErrorBoundary(MetricsTab), + control: withErrorBoundary(ProfileSettings), }); const refreshOnboardingState = () => getPendingOnboardingState().then(setOnboardingState); diff --git a/www/js/plugin/ErrorBoundary.tsx b/www/js/plugin/ErrorBoundary.tsx new file mode 100644 index 000000000..61fdf023e --- /dev/null +++ b/www/js/plugin/ErrorBoundary.tsx @@ -0,0 +1,36 @@ +// based on https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary + +import React from 'react'; +import { displayError } from './logger'; +import { Icon } from '../components/Icon'; + +class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean }> { + constructor(props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error) { + return { hasError: true }; + } + + componentDidCatch(error, info) { + displayError(error, info.componentStack); + } + + render() { + if (this.state.hasError) { + return ; + } + + return this.props.children; + } +} + +export const withErrorBoundary = (Component) => (props) => ( + + + +); + +export default ErrorBoundary; From ba239e63a7b267020813a6223704c892bbff25eb Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Thu, 9 Nov 2023 09:38:38 -0700 Subject: [PATCH 358/850] Resolve the app crashing errors From https://github.com/e-mission/e-mission-phone/pull/1092#issuecomment-1800021840 notifScheduler.ts - Replaced toISO for the date object with toJSDate, per @JGreenlee suggestion: https://github.com/e-mission/e-mission-phone/pull/1092#discussion_r1387470795 --- www/js/splash/notifScheduler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts index 906d0712b..0bc8bd747 100644 --- a/www/js/splash/notifScheduler.ts +++ b/www/js/splash/notifScheduler.ts @@ -130,8 +130,8 @@ const scheduleNotifs = (scheme, notifTimes) => { isScheduling = true; const localeCode = i18next.resolvedLanguage; const nots = notifTimes.map((n) => { - const nDate = n.toISO(); - const seconds = nDate.ts / 1000; + const nDate = n.toJSDate(); + const seconds = nDate.getTime() / 1000; return { id: seconds, title: scheme.title[localeCode], From 1eec588f8e3c231830d0c10402f66049972523d8 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 9 Nov 2023 09:39:49 -0700 Subject: [PATCH 359/850] remove stale references after we take out the angular modules, we need to take out all the references to them as well --- www/js/main.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/www/js/main.js b/www/js/main.js index 2b351e2c4..2c789891a 100644 --- a/www/js/main.js +++ b/www/js/main.js @@ -7,8 +7,6 @@ angular 'emission.main.diary', 'emission.i18n.utils', 'emission.splash.notifscheduler', - 'emission.main.metrics.factory', - 'emission.main.metrics.mappings', 'emission.services', ]) From 6102d62e43ef646f150ed995ad05145de570b96b Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 9 Nov 2023 09:43:19 -0700 Subject: [PATCH 360/850] remove dataset setting since we no longer rely on a set country for carbon calculations, but rather custom or default label configurations, we can take out the option to set it from the profile --- www/js/control/ProfileSettings.jsx | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index b8943a81c..894a47bee 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -26,11 +26,6 @@ import ControlCollectionHelper, { helperToggleLowAccuracy, forceTransition, } from './ControlCollectionHelper'; -import { - getCarbonDatasetOptions, - getCurrentCarbonDatasetCode, - saveCurrentCarbonDatasetLocale, -} from '../metrics/customMetricsHelper'; import { resetDataAndRefresh } from '../config/dynamicConfig'; import { AppContext } from '../App'; import { shareQR } from '../components/QrCode'; @@ -59,7 +54,6 @@ const ProfileSettings = () => { //states and variables used to control/create the settings const [opCodeVis, setOpCodeVis] = useState(false); const [nukeSetVis, setNukeVis] = useState(false); - const [carbonDataVis, setCarbonDataVis] = useState(false); const [forceStateVis, setForceStateVis] = useState(false); const [logoutVis, setLogoutVis] = useState(false); const [invalidateSuccessVis, setInvalidateSuccessVis] = useState(false); @@ -87,9 +81,6 @@ const ProfileSettings = () => { const [uploadReason, setUploadReason] = useState(''); const appVersion = useRef(); - let carbonDatasetString = - t('general-settings.carbon-dataset') + ': ' + getCurrentCarbonDatasetCode(); - const carbonOptions = getCarbonDatasetOptions(); const stateActions = [ { text: 'Initialize', transition: 'INITIALIZE' }, { text: 'Start trip', transition: 'EXITED_GEOFENCE' }, @@ -363,14 +354,6 @@ const ProfileSettings = () => { forceTransition(stateObject.transition); }; - const onSelectCarbon = function (carbonObject) { - console.log('changeCarbonDataset(): chose locale ' + carbonObject.value); - saveCurrentCarbonDatasetLocale(carbonObject.value); //there's some sort of error here - //Unhandled Promise Rejection: While logging, error -[NSNull UTF8String]: unrecognized selector sent to instance 0x7fff8a625fb0 - carbonDatasetString = - i18next.t('general-settings.carbon-dataset') + ': ' + getCurrentCarbonDatasetCode(); - }; - //conditional creation of setting sections let logUploadSection; @@ -441,10 +424,6 @@ const ProfileSettings = () => { textKey="control.medium-accuracy" action={toggleLowAccuracy} switchValue={collectSettings.lowAccuracy}> - setCarbonDataVis(true)}> {
    - {/* menu for "set carbon dataset - only somewhat working" */} - clearNotifications()}> - {/* force state sheet */} Date: Thu, 9 Nov 2023 09:51:06 -0700 Subject: [PATCH 361/850] increase message readability --- www/js/metrics/CustomMetricsHelper.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/www/js/metrics/CustomMetricsHelper.ts b/www/js/metrics/CustomMetricsHelper.ts index 11493cb95..088ec8831 100644 --- a/www/js/metrics/CustomMetricsHelper.ts +++ b/www/js/metrics/CustomMetricsHelper.ts @@ -11,17 +11,17 @@ let _range_limited_motorized; let _inputParams; export const getCustomMETs = function () { - logDebug('Getting custom METs' + JSON.stringify(_customMETs)); + logDebug('Getting custom METs ' + JSON.stringify(_customMETs)); return _customMETs; }; export const getCustomFootprint = function () { - logDebug('Getting custom footprint' + JSON.stringify(_customPerKmFootprint)); + logDebug('Getting custom footprint ' + JSON.stringify(_customPerKmFootprint)); return _customPerKmFootprint; }; export const getRangeLimitedMotorixe = function () { - logDebug('Getting range limited motorized' + JSON.stringify(_range_limited_motorized)); + logDebug('Getting range limited motorized ' + JSON.stringify(_range_limited_motorized)); return _range_limited_motorized; }; From ec4abff2a35ea478c78d0750fe9b262ed8f3458a Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 9 Nov 2023 09:52:03 -0700 Subject: [PATCH 362/850] ensure initialization of footprints we need to initialize the footprints before we can call them, accomplish this by calling the initialization function from the metrics tab once the app config is loaded --- www/js/metrics/MetricsTab.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index d23cdd454..6385e2fd3 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -17,6 +17,8 @@ import DailyActiveMinutesCard from './DailyActiveMinutesCard'; import CarbonTextCard from './CarbonTextCard'; import ActiveMinutesTableCard from './ActiveMinutesTableCard'; import { getAggregateData, getMetrics } from '../commHelper'; +import useAppConfig from '../useAppConfig'; +import { initCustomDatasetHelper } from './CustomMetricsHelper'; export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; @@ -41,6 +43,7 @@ function getLastTwoWeeksDtRange() { const MetricsTab = () => { const { t } = useTranslation(); + const appConfig = useAppConfig(); const { getFormattedSpeed, speedSuffix, getFormattedDistance, distanceSuffix } = useImperialConfig(); @@ -53,6 +56,12 @@ const MetricsTab = () => { loadMetricsForPopulation('aggregate', dateRange); }, [dateRange]); + //initialize once config is populated + useEffect(() => { + if (!appConfig) return; + initCustomDatasetHelper(appConfig); + }, [appConfig]); + async function loadMetricsForPopulation(population: 'user' | 'aggregate', dateRange: DateTime[]) { const serverResponse = await fetchMetricsFromServer(population, dateRange); console.debug('Got metrics = ', serverResponse); From 7820646de8433ab19c2eadbce5389095ac0a559a Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 9 Nov 2023 10:13:09 -0700 Subject: [PATCH 363/850] remove old MET code we no longer store user's height/weight/etc because we are not calculating calories burned, for that reason the code related to storing user's data or calculating calories is not needed we will keep MET handling so we can bin activity into high/med/low intensity later not currently using the "highestMET" calculation code, so commenting that out for now --- www/js/metrics/metHelper.ts | 87 ++++++++----------------------------- 1 file changed, 19 insertions(+), 68 deletions(-) diff --git a/www/js/metrics/metHelper.ts b/www/js/metrics/metHelper.ts index 6c052b0be..b696c5790 100644 --- a/www/js/metrics/metHelper.ts +++ b/www/js/metrics/metHelper.ts @@ -1,12 +1,9 @@ import { getCustomMETs } from './customMetricsHelper'; import { standardMETs } from './metDataset'; -import { storageGet, storageSet, storageRemove } from '../plugin/storage'; -var highestMET = 0; -var USER_DATA_KEY = 'user-data'; let useCustom = false; -const setUseCustomMET = function () { +export const setUseCustomMET = function () { useCustom = true; }; @@ -18,38 +15,15 @@ const getMETs = function () { } }; -const set = function (info) { - return storageSet(USER_DATA_KEY, info); -}; - -const get = function () { - return storageGet(USER_DATA_KEY); -}; - -const remove = function () { - return storageRemove(USER_DATA_KEY); -}; - const between = function (num, min, max) { return num >= min && num <= max; }; -const getHighestMET = function () { - if (!highestMET) { - var met = getMETs(); - let metList = []; - for (var mode in met) { - var rangeList = met[mode]; - for (var range in rangeList) { - metList.push(rangeList[range].mets); - } - } - highestMET = Math.max(...metList); - } - return highestMET; +const mpstomph = function (mps) { + return 2.23694 * mps; }; -const getMet = function (mode, speed, defaultIfMissing) { +export const getMet = function (mode, speed, defaultIfMissing) { if (mode == 'ON_FOOT') { console.log("getMet() converted 'ON_FOOT' to 'WALKING'"); mode = 'WALKING'; @@ -69,41 +43,18 @@ const getMet = function (mode, speed, defaultIfMissing) { } }; -const mpstomph = function (mps) { - return 2.23694 * mps; -}; - -const lbtokg = function (lb) { - return lb * 0.453592; -}; - -const fttocm = function (ft) { - return ft * 30.48; -}; - -const getCorrectedMet = function (met, gender, age, height, heightUnit, weight, weightUnit) { - var height = heightUnit == 0 ? fttocm(height) : height; - var weight = weightUnit == 0 ? lbtokg(weight) : weight; - let calcMet; - if (gender == 1) { - //male - calcMet = - (met * 3.5) / - (((66.473 + 5.0033 * height + 13.7516 * weight - 6.755 * age) / 1440 / 5 / weight) * 1000); - return met; - } else if (gender == 0) { - //female - let met = - (calcMet * 3.5) / - (((655.0955 + 1.8496 * height + 9.5634 * weight - 4.6756 * age) / 1440 / 5 / weight) * 1000); - return calcMet; - } -}; - -const getuserCalories = function (durationInMin, met) { - return 65 * durationInMin * met; -}; - -const getCalories = function (weightInKg, durationInMin, met) { - return weightInKg * durationInMin * met; -}; +// var highestMET = 0; +// const getHighestMET = function () { +// if (!highestMET) { +// var met = getMETs(); +// let metList = []; +// for (var mode in met) { +// var rangeList = met[mode]; +// for (var range in rangeList) { +// metList.push(rangeList[range].mets); +// } +// } +// highestMET = Math.max(...metList); +// } +// return highestMET; +// }; From efa96e0aacef2f1d8b1839fd45db8d92a5143983 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 9 Nov 2023 09:18:55 -0800 Subject: [PATCH 364/850] Improved mocks, added unit tests --- www/__tests__/timelineHelper.test.ts | 40 ++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/www/__tests__/timelineHelper.test.ts b/www/__tests__/timelineHelper.test.ts index c6a4bcd67..15f63cdc3 100644 --- a/www/__tests__/timelineHelper.test.ts +++ b/www/__tests__/timelineHelper.test.ts @@ -7,6 +7,11 @@ import { CompositeTrip } from '../js/types/diaryTypes'; mockLogger(); mockBEMUserCache(); + +afterAll(() => { + jest.restoreAllMocks(); +}); + const mockMetaData: MetaData = { write_ts: -13885091, key: 'test/value', @@ -16,6 +21,7 @@ const mockMetaData: MetaData = { write_local_dt: null, origin_key: '12345', }; + const mockData: ServerResponse = { phone_data: [ { @@ -93,17 +99,35 @@ const mockData: ServerResponse = { ], }; -const testStart = -14576291; -const testEnd = -13885091; +// When called by mocks, pair 1 returns 1 value, Pair two 2, pair 3 returns none. +const fakeStartTsOne = -14576291; +const fakeEndTsOne = -13885091; +const fakeStartTsTwo = 1092844665; +const fakeEndTsTwo = 1277049465; +// Once we have end-to-end testing, we could utilize getRawEnteries. jest.mock('../js/commHelper', () => ({ - getRawEntries: jest.fn(() => mockData), + getRawEntries: jest.fn((val, startTs, endTs, valTwo) => { + if (startTs === fakeStartTsOne && endTs === fakeEndTsOne) return mockData; + if (startTs == fakeStartTsTwo && endTs === fakeEndTsTwo) { + console.log('Twoy!'); + let dataCopy = JSON.parse(JSON.stringify(mockData)); + let temp = [dataCopy.phone_data[0], dataCopy.phone_data[0]]; + dataCopy.phone_data = temp; + console.log(`This is phoneData: ${JSON.stringify(dataCopy.phone_data.length)}`); + return dataCopy; + } + return {}; + }), })); +it('works when there are no composite trip objects fetched', async () => { + expect(readAllCompositeTrips(-1, -1)).resolves.not.toThrow(); +}); + it('fetches a composite trip object and collapses it', async () => { - // When we have End-to-End testing, we can properly test with getRawEnteries - console.log(JSON.stringify(mockData, null, 2)); - expect(readAllCompositeTrips(testStart, testEnd)).resolves.not.toThrow(); + expect(readAllCompositeTrips(fakeStartTsOne, fakeEndTsOne)).resolves.not.toThrow(); + expect(readAllCompositeTrips(fakeStartTsTwo, fakeEndTsTwo)).resolves.not.toThrow(); }); jest.mock('../js/unifiedDataLoader', () => ({ @@ -113,9 +137,9 @@ jest.mock('../js/unifiedDataLoader', () => ({ })); it('works when there are no unprocessed trips...', async () => { - expect(readUnprocessedTrips(testStart, testEnd, null)).resolves.not.toThrow(); + expect(readUnprocessedTrips(fakeStartTsOne, fakeEndTsOne, null)).resolves.not.toThrow(); }); it('works when there are no unprocessed trips...', async () => { - expect(readUnprocessedTrips(testStart, testEnd, null)).resolves.not.toThrow(); + expect(readUnprocessedTrips(fakeStartTsOne, fakeEndTsOne, null)).resolves.not.toThrow(); }); From 7ebcc94fd68ce97dceaa31a96f815fbc1cf61a5a Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 9 Nov 2023 10:51:35 -0700 Subject: [PATCH 365/850] add tests for highest footprint needed to include methods for clearing out the local variables, so added parameter to be able to set useCuston to false, and to clearHighest --- www/__tests__/footprintHelper.test.ts | 43 +++++++++++++++++++++++--- www/js/metrics/CarbonFootprintCard.tsx | 2 +- www/js/metrics/footprintHelper.ts | 8 +++-- 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/www/__tests__/footprintHelper.test.ts b/www/__tests__/footprintHelper.test.ts index 5267bc858..fa0b4574d 100644 --- a/www/__tests__/footprintHelper.test.ts +++ b/www/__tests__/footprintHelper.test.ts @@ -1,5 +1,11 @@ import { initCustomDatasetHelper } from '../js/metrics/CustomMetricsHelper'; -import { getFootprintForMetrics, setUseCustomFootprint } from '../js/metrics/footprintHelper'; +import { + clearHighestFootprint, + getFootprintForMetrics, + getHighestFootprint, + getHighestFootprintForDistance, + setUseCustomFootprint, +} from '../js/metrics/footprintHelper'; import { getConfig } from '../js/config/dynamicConfig'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; @@ -21,6 +27,11 @@ global.fetch = (url: string) => ); }) as any; +beforeEach(() => { + setUseCustomFootprint(false); + clearHighestFootprint(); +}); + const metrics = [ { key: 'WALKING', values: 3000 }, { key: 'BICYCLING', values: 6500 }, @@ -37,6 +48,14 @@ it('gets footprint for metrics (not custom, fallback 0.1)', () => { expect(getFootprintForMetrics(metrics, 0.1)).toBe(10.534493474207583 + 0.5); }); +it('gets the highest footprint from the dataset, not custom', () => { + expect(getHighestFootprint()).toBe(278 / 1609); +}); + +it('gets the highest footprint for distance, not custom', () => { + expect(getHighestFootprintForDistance(12345)).toBe((278 / 1609) * (12345 / 1000)); +}); + const custom_metrics = [ { key: 'walk', values: 3000 }, { key: 'bike', values: 6500 }, @@ -45,16 +64,30 @@ const custom_metrics = [ { key: 'unicycle', values: 5000 }, ]; -it('gets footprint for metrics (custom, fallback 0', async () => { +it('gets footprint for metrics (custom, fallback 0)', async () => { initCustomDatasetHelper(getConfig()); - setUseCustomFootprint(); + setUseCustomFootprint(true); await new Promise((r) => setTimeout(r, 500)); expect(getFootprintForMetrics(custom_metrics, 0)).toBe(2.4266); }); -it('gets footprint for metrics (custom, fallback 0.1', async () => { +it('gets footprint for metrics (custom, fallback 0.1)', async () => { initCustomDatasetHelper(getConfig()); - setUseCustomFootprint(); + setUseCustomFootprint(true); await new Promise((r) => setTimeout(r, 500)); expect(getFootprintForMetrics(custom_metrics, 0.1)).toBe(2.4266 + 0.5); }); + +it('gets the highest footprint from the dataset, custom', async () => { + initCustomDatasetHelper(getConfig()); + setUseCustomFootprint(true); + await new Promise((r) => setTimeout(r, 500)); + expect(getHighestFootprint()).toBe(0.30741); +}); + +it('gets the highest footprint for distance, custom', async () => { + initCustomDatasetHelper(getConfig()); + setUseCustomFootprint(true); + await new Promise((r) => setTimeout(r, 500)); + expect(getHighestFootprintForDistance(12345)).toBe(0.30741 * (12345 / 1000)); +}); diff --git a/www/js/metrics/CarbonFootprintCard.tsx b/www/js/metrics/CarbonFootprintCard.tsx index f2ac1cc76..30c265bfc 100644 --- a/www/js/metrics/CarbonFootprintCard.tsx +++ b/www/js/metrics/CarbonFootprintCard.tsx @@ -55,7 +55,7 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { //set custon dataset, if the labels are custom if (isCustomLabels(userThisWeekModeMap)) { - setUseCustomFootprint(); + setUseCustomFootprint(true); } //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago) diff --git a/www/js/metrics/footprintHelper.ts b/www/js/metrics/footprintHelper.ts index 2162e87bf..1c950e690 100644 --- a/www/js/metrics/footprintHelper.ts +++ b/www/js/metrics/footprintHelper.ts @@ -7,9 +7,13 @@ let useCustom = false; const mtokm = function (v) { return v / 1000; }; +export const setUseCustomFootprint = function (val: boolean) { + useCustom = val; +}; -export const setUseCustomFootprint = function () { - useCustom = true; +export const clearHighestFootprint = function () { + //need to clear for testing + highestFootprint = undefined; }; const getFootprint = function () { From 0be22344380c41dffd1ea45de0a08e20d1ff6b32 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 9 Nov 2023 13:35:05 -0500 Subject: [PATCH 366/850] add logDebug statements in Chart.tsx This may help with debugging https://github.com/e-mission/e-mission-docs/issues/986#issuecomment-1799229701 --- www/js/components/Chart.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/www/js/components/Chart.tsx b/www/js/components/Chart.tsx index d7687e424..4ebf49c24 100644 --- a/www/js/components/Chart.tsx +++ b/www/js/components/Chart.tsx @@ -5,6 +5,7 @@ import { Chart as ChartJS, registerables } from 'chart.js'; import { Chart as ChartJSChart } from 'react-chartjs-2'; import Annotation, { AnnotationOptions, LabelPosition } from 'chartjs-plugin-annotation'; import { dedupColors, getChartHeight, darkenOrLighten } from './charting'; +import { logDebug } from '../plugin/logger'; ChartJS.register(...registerables, Annotation); @@ -134,6 +135,9 @@ const Chart = ({ ? {} : { callback: (value, i) => { + logDebug(`Horizontal axis callback: i = ${i}; + chartDatasets = ${JSON.stringify(chartDatasets)}; + chartDatasets[0].data = ${JSON.stringify(chartDatasets[0].data)}`); const label = chartDatasets[0].data[i].y; if (typeof label == 'string' && label.includes('\n')) return label.split('\n'); @@ -168,7 +172,9 @@ const Chart = ({ ? {} : { callback: (value, i) => { - console.log('testing vertical', chartData, i); + logDebug(`Vertical axis callback: i = ${i}; + chartDatasets = ${JSON.stringify(chartDatasets)}; + chartDatasets[0].data = ${JSON.stringify(chartDatasets[0].data)}`); const label = chartDatasets[0].data[i].x; if (typeof label == 'string' && label.includes('\n')) return label.split('\n'); From 4a96b9d146e549549ace56156ac9829af16d5c98 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 9 Nov 2023 10:37:05 -0800 Subject: [PATCH 367/850] Minor adjustments to diaryTypes --- www/js/types/diaryTypes.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index cfde363d6..938b06851 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -3,7 +3,7 @@ As much as possible, these types parallel the types used in the server code. */ import { BaseModeKey, MotionTypeKey } from '../diary/diaryHelper'; -import { LocalDt } from './serverData'; +import { ServerData, LocalDt } from './serverData'; type ObjectId = { $oid: string }; type ConfirmedPlace = { @@ -51,7 +51,7 @@ export type CompositeTrip = { confirmed_trip: ObjectId; distance: number; duration: number; - end_confirmed_place: ConfirmedPlace; + end_confirmed_place: ServerData; end_fmt_time: string; end_loc: { type: string; coordinates: number[] }; end_local_dt: LocalDt; @@ -68,7 +68,7 @@ export type CompositeTrip = { raw_trip: ObjectId; sections: any[]; // TODO source: string; - start_confirmed_place: ConfirmedPlace; + start_confirmed_place: ServerData; start_fmt_time: string; start_loc: { type: string; coordinates: number[] }; start_local_dt: LocalDt; From 1388eee2efdfbefa8dd14399665e74d02047ebcb Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 9 Nov 2023 13:40:34 -0500 Subject: [PATCH 368/850] add more logging in LabelTab -added several try/catches to cover all top-level calls -added logDebug statements throughout, using `` for multi-line so they are not ugly -replaced any uses of the old Logger service --- www/js/diary/LabelTab.tsx | 201 ++++++++++++++++++++++---------------- 1 file changed, 115 insertions(+), 86 deletions(-) diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 8b6e65d52..d5fa8a7c5 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -25,7 +25,7 @@ import { import { fillLocationNamesOfTrip, resetNominatimLimiter } from './addressNamesHelper'; import { SurveyOptions } from '../survey/survey'; import { getLabelOptions } from '../survey/multilabel/confirmHelper'; -import { displayError } from '../plugin/logger'; +import { displayError, displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; import { useTheme } from 'react-native-paper'; import { getPipelineRangeTs } from '../commHelper'; @@ -58,55 +58,66 @@ const LabelTab = () => { // initialization, once the appConfig is loaded useEffect(() => { - if (!appConfig) return; - const surveyOptKey = appConfig.survey_info['trip-labels']; - const surveyOpt = SurveyOptions[surveyOptKey]; - setSurveyOpt(surveyOpt); - showPlaces = appConfig.survey_info?.buttons?.['place-notes']; - getLabelOptions(appConfig).then((labelOptions) => setLabelOptions(labelOptions)); - labelPopulateFactory = getAngularService(surveyOpt.service); - const tripSurveyName = appConfig.survey_info?.buttons?.['trip-notes']?.surveyName; - const placeSurveyName = appConfig.survey_info?.buttons?.['place-notes']?.surveyName; - enbs.initConfig(tripSurveyName, placeSurveyName); + try { + if (!appConfig) return; + const surveyOptKey = appConfig.survey_info['trip-labels']; + const surveyOpt = SurveyOptions[surveyOptKey]; + setSurveyOpt(surveyOpt); + showPlaces = appConfig.survey_info?.buttons?.['place-notes']; + getLabelOptions(appConfig).then((labelOptions) => setLabelOptions(labelOptions)); + labelPopulateFactory = getAngularService(surveyOpt.service); + const tripSurveyName = appConfig.survey_info?.buttons?.['trip-notes']?.surveyName; + const placeSurveyName = appConfig.survey_info?.buttons?.['place-notes']?.surveyName; + enbs.initConfig(tripSurveyName, placeSurveyName); - // we will show filters if 'additions' are not configured - // https://github.com/e-mission/e-mission-docs/issues/894 - if (appConfig.survey_info?.buttons == undefined) { - // initalize filters - const tripFilter = surveyOpt.filter; - const allFalseFilters = tripFilter.map((f, i) => ({ - ...f, - state: i == 0 ? true : false, // only the first filter will have state true on init - })); - setFilterInputs(allFalseFilters); + // we will show filters if 'additions' are not configured + // https://github.com/e-mission/e-mission-docs/issues/894 + if (appConfig.survey_info?.buttons == undefined) { + // initalize filters + const tripFilter = surveyOpt.filter; + const allFalseFilters = tripFilter.map((f, i) => ({ + ...f, + state: i == 0 ? true : false, // only the first filter will have state true on init + })); + setFilterInputs(allFalseFilters); + } + loadTimelineEntries(); + } catch (e) { + displayError(e, t('errors.while-initializing-label')); } - loadTimelineEntries(); }, [appConfig, refreshTime]); // whenever timelineMap is updated, update the displayedEntries // according to the active filter useEffect(() => { - if (!timelineMap) return setDisplayedEntries(null); - const allEntries = Array.from(timelineMap.values()); - const activeFilter = filterInputs?.find((f) => f.state == true); - let entriesToDisplay = allEntries; - if (activeFilter) { - const entriesAfterFilter = allEntries.filter( - (t) => t.justRepopulated || activeFilter?.filter(t), - ); - /* next, filter out any untracked time if the trips that came before and + try { + if (!timelineMap) return setDisplayedEntries(null); + const allEntries = Array.from(timelineMap.values()); + const activeFilter = filterInputs?.find((f) => f.state == true); + let entriesToDisplay = allEntries; + if (activeFilter) { + const entriesAfterFilter = allEntries.filter( + (t) => t.justRepopulated || activeFilter?.filter(t), + ); + /* next, filter out any untracked time if the trips that came before and after it are no longer displayed */ - entriesToDisplay = entriesAfterFilter.filter((tlEntry) => { - if (!tlEntry.origin_key.includes('untracked')) return true; - const prevTrip = allEntries[allEntries.indexOf(tlEntry) - 1]; - const nextTrip = allEntries[allEntries.indexOf(tlEntry) + 1]; - const prevTripDisplayed = entriesAfterFilter.includes(prevTrip); - const nextTripDisplayed = entriesAfterFilter.includes(nextTrip); - // if either the trip before or after is displayed, then keep the untracked time - return prevTripDisplayed || nextTripDisplayed; - }); + entriesToDisplay = entriesAfterFilter.filter((tlEntry) => { + if (!tlEntry.origin_key.includes('untracked')) return true; + const prevTrip = allEntries[allEntries.indexOf(tlEntry) - 1]; + const nextTrip = allEntries[allEntries.indexOf(tlEntry) + 1]; + const prevTripDisplayed = entriesAfterFilter.includes(prevTrip); + const nextTripDisplayed = entriesAfterFilter.includes(nextTrip); + // if either the trip before or after is displayed, then keep the untracked time + return prevTripDisplayed || nextTripDisplayed; + }); + logDebug('After filtering, entriesToDisplay = ' + JSON.stringify(entriesToDisplay)); + } else { + logDebug('No active filter, displaying all entries'); + } + setDisplayedEntries(entriesToDisplay); + } catch (e) { + displayError(e, t('errors.while-updating-timeline')); } - setDisplayedEntries(entriesToDisplay); }, [timelineMap, filterInputs]); async function loadTimelineEntries() { @@ -117,15 +128,12 @@ const LabelTab = () => { labelPopulateFactory, enbs, ); - Logger.log( - 'After reading unprocessedInputs, labelsResultMap =' + - JSON.stringify(labelsResultMap) + - '; notesResultMap = ' + - JSON.stringify(notesResultMap), - ); + logDebug(`LabelTab: After reading unprocessedInputs, + labelsResultMap = ${JSON.stringify(labelsResultMap)}; + notesResultMap = ${JSON.stringify(notesResultMap)}`); setPipelineRange(pipelineRange); - } catch (error) { - Logger.displayError('Error while loading pipeline range', error); + } catch (e) { + displayError(e, 'Error while loading pipeline range'); setIsLoading(false); } } @@ -138,15 +146,22 @@ const LabelTab = () => { }, [pipelineRange]); function refresh() { - setIsLoading('replace'); - resetNominatimLimiter(); - setQueriedRange(null); - setTimelineMap(null); - setRefreshTime(new Date()); + try { + logDebug('Refreshing LabelTab'); + setIsLoading('replace'); + resetNominatimLimiter(); + setQueriedRange(null); + setTimelineMap(null); + setRefreshTime(new Date()); + } catch (e) { + displayError(e, t('errors.while-refreshing-label')); + } } async function loadAnotherWeek(when: 'past' | 'future') { try { + logDebug('LabelTab: loadAnotherWeek into the ' + when); + const reachedPipelineStart = queriedRange?.start_ts && queriedRange.start_ts <= pipelineRange.start_ts; const reachedPipelineEnd = @@ -183,6 +198,7 @@ const LabelTab = () => { async function loadSpecificWeek(day: string) { try { + logDebug('LabelTab: loadSpecificWeek for day ' + day); if (!isLoading) setIsLoading('replace'); resetNominatimLimiter(); const threeDaysBefore = moment(day).subtract(3, 'days').unix(); @@ -197,6 +213,11 @@ const LabelTab = () => { } function handleFetchedTrips(ctList, utList, mode: 'prepend' | 'append' | 'replace') { + logDebug(`LabelTab: handleFetchedTrips with + mode = ${mode}; + ctList = ${JSON.stringify(ctList)}; + utList = ${JSON.stringify(utList)}`); + const tripsRead = ctList.concat(utList); populateCompositeTrips( tripsRead, @@ -214,6 +235,8 @@ const LabelTab = () => { fillLocationNamesOfTrip(trip); }); const readTimelineMap = compositeTrips2TimelineMap(tripsRead, showPlaces); + logDebug(`LabelTab: after composite trips converted, + readTimelineMap = ${JSON.stringify(readTimelineMap)}`); if (mode == 'append') { setTimelineMap(new Map([...timelineMap, ...readTimelineMap])); } else if (mode == 'prepend') { @@ -221,16 +244,13 @@ const LabelTab = () => { } else if (mode == 'replace') { setTimelineMap(readTimelineMap); } else { - return console.error('Unknown insertion mode ' + mode); + return displayErrorMsg('Unknown insertion mode ' + mode); } } async function fetchTripsInRange(startTs: number, endTs: number) { - if (!pipelineRange.start_ts) { - console.warn('trying to read data too early, early return'); - return; - } - + if (!pipelineRange.start_ts) return logWarn('No pipelineRange yet - early return'); + logDebug('LabelTab: fetchTripsInRange from ' + startTs + ' to ' + endTs); const readCompositePromise = Timeline.readAllCompositeTrips(startTs, endTs); let readUnprocessedPromise; if (endTs >= pipelineRange.end_ts) { @@ -249,6 +269,8 @@ const LabelTab = () => { readUnprocessedPromise = Promise.resolve([]); } const results = await Promise.all([readCompositePromise, readUnprocessedPromise]); + logDebug(`LabelTab: readCompositePromise resolved as: ${JSON.stringify(results[0])}; + readUnprocessedPromise resolved as: ${JSON.stringify(results[1])}`); return results; } @@ -260,34 +282,41 @@ const LabelTab = () => { const timelineMapRef = useRef(timelineMap); async function repopulateTimelineEntry(oid: string) { - if (!timelineMap.has(oid)) - return console.error('Item with oid: ' + oid + ' not found in timeline'); - const [newLabels, newNotes] = await getLocalUnprocessedInputs( - pipelineRange, - labelPopulateFactory, - enbs, - ); - const repopTime = new Date().getTime(); - const newEntry = { ...timelineMap.get(oid), justRepopulated: repopTime }; - labelPopulateFactory.populateInputsAndInferences(newEntry, newLabels); - enbs.populateInputsAndInferences(newEntry, newNotes); - const newTimelineMap = new Map(timelineMap).set(oid, newEntry); - setTimelineMap(newTimelineMap); + try { + logDebug('LabelTab: Repopulating timeline entry with oid ' + oid); + if (!timelineMap.has(oid)) + return displayErrorMsg('Item with oid: ' + oid + ' not found in timeline'); + const [newLabels, newNotes] = await getLocalUnprocessedInputs( + pipelineRange, + labelPopulateFactory, + enbs, + ); + const repopTime = new Date().getTime(); + logDebug('LabelTab: creating new entry for oid ' + oid + ' with repopTime ' + repopTime); + const newEntry = { ...timelineMap.get(oid), justRepopulated: repopTime }; + labelPopulateFactory.populateInputsAndInferences(newEntry, newLabels); + enbs.populateInputsAndInferences(newEntry, newNotes); + logDebug('LabelTab: after repopulating, newEntry = ' + JSON.stringify(newEntry)); + const newTimelineMap = new Map(timelineMap).set(oid, newEntry); + setTimelineMap(newTimelineMap); - // after 30 seconds, remove the justRepopulated flag unless it was repopulated again since then - /* ref is needed to avoid stale closure: + // after 30 seconds, remove the justRepopulated flag unless it was repopulated again since then + /* ref is needed to avoid stale closure: https://legacy.reactjs.org/docs/hooks-faq.html#why-am-i-seeing-stale-props-or-state-inside-my-function */ - timelineMapRef.current = newTimelineMap; - setTimeout(() => { - const entry = { ...timelineMapRef.current.get(oid) }; - if (entry.justRepopulated != repopTime) - return console.log('Entry ' + oid + ' was repopulated again, skipping'); - const newTimelineMap = new Map(timelineMapRef.current).set(oid, { - ...entry, - justRepopulated: false, - }); - setTimelineMap(newTimelineMap); - }, 30000); + timelineMapRef.current = newTimelineMap; + setTimeout(() => { + const entry = { ...timelineMapRef.current.get(oid) }; + if (entry.justRepopulated != repopTime) + return logDebug('Entry ' + oid + ' was repopulated again, skipping'); + const newTimelineMap = new Map(timelineMapRef.current).set(oid, { + ...entry, + justRepopulated: false, + }); + setTimelineMap(newTimelineMap); + }, 30000); + } catch (e) { + displayError(e, t('errors.while-repopulating-entry')); + } } const contextVals = { From 94d893337c39a297b446d15939f6d92a9d6d01ac Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 9 Nov 2023 13:41:35 -0500 Subject: [PATCH 369/850] add logs to MetricsTab When metrics are fetched and stored as state, we can have more log statements and a try/catch. --- www/js/metrics/MetricsTab.tsx | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index d23cdd454..392a0ef3b 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -17,6 +17,7 @@ import DailyActiveMinutesCard from './DailyActiveMinutesCard'; import CarbonTextCard from './CarbonTextCard'; import ActiveMinutesTableCard from './ActiveMinutesTableCard'; import { getAggregateData, getMetrics } from '../commHelper'; +import { displayError, logDebug } from '../plugin/logger'; export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; @@ -54,17 +55,24 @@ const MetricsTab = () => { }, [dateRange]); async function loadMetricsForPopulation(population: 'user' | 'aggregate', dateRange: DateTime[]) { - const serverResponse = await fetchMetricsFromServer(population, dateRange); - console.debug('Got metrics = ', serverResponse); - const metrics = {}; - const dataKey = population == 'user' ? 'user_metrics' : 'aggregate_metrics'; - METRIC_LIST.forEach((metricName, i) => { - metrics[metricName] = serverResponse[dataKey][i]; - }); - if (population == 'user') { - setUserMetrics(metrics as MetricsData); - } else { - setAggMetrics(metrics as MetricsData); + try { + logDebug(`MetricsTab: fetching metrics for population ${population}' + in date range ${JSON.stringify(dateRange)}`); + const serverResponse = await fetchMetricsFromServer(population, dateRange); + logDebug('MetricsTab: received metrics: ' + JSON.stringify(serverResponse)); + const metrics = {}; + const dataKey = population == 'user' ? 'user_metrics' : 'aggregate_metrics'; + METRIC_LIST.forEach((metricName, i) => { + metrics[metricName] = serverResponse[dataKey][i]; + }); + logDebug('MetricsTab: parsed metrics: ' + JSON.stringify(metrics)); + if (population == 'user') { + setUserMetrics(metrics as MetricsData); + } else { + setAggMetrics(metrics as MetricsData); + } + } catch (e) { + displayError(e, t('errors.while-loading-metrics')); } } From 98a9db64d0088dc7301d314e14d2fb8c2c2fdc25 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 9 Nov 2023 13:41:53 -0500 Subject: [PATCH 370/850] translations for error msgs just added --- www/i18n/en.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/www/i18n/en.json b/www/i18n/en.json index 7f3798f16..af549d05e 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -398,9 +398,14 @@ "errors": { "registration-check-token": "User registration error. Please check your token and try again.", "not-registered-cant-contact": "User is not registered, so the server cannot be contacted.", + "while-initializing-label": "While initializing Label tab: ", "while-populating-composite": "Error while populating composite trips", "while-loading-another-week": "Error while loading travel of {{when}} week", "while-loading-specific-week": "Error while loading travel for the week of {{day}}", + "while-updating-timeline": "While updating timeline: ", + "while-refreshing-label": "While refreshing Label tab: ", + "while-repopulating-entry": "While repopulating timeline entry: ", + "while-loading-metrics": "While loading metrics: ", "while-log-messages": "While getting messages from the log ", "while-max-index": "While getting max index " }, From 13206e948a85dc0397f40eea7a70aebef077c91f Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 9 Nov 2023 10:50:30 -0800 Subject: [PATCH 371/850] Updated mocks, added tests --- www/__tests__/timelineHelper.test.ts | 42 ++++++++++++++++++---------- www/js/diary/timelineHelper.ts | 6 ++-- www/js/types/diaryTypes.ts | 2 +- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/www/__tests__/timelineHelper.test.ts b/www/__tests__/timelineHelper.test.ts index 15f63cdc3..0ae65d34c 100644 --- a/www/__tests__/timelineHelper.test.ts +++ b/www/__tests__/timelineHelper.test.ts @@ -2,8 +2,8 @@ import { mockLogger } from '../__mocks__/globalMocks'; import { readAllCompositeTrips, readUnprocessedTrips } from '../js/diary/timelineHelper'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; -import { MetaData, ServerResponse } from '../js/types/serverData'; -import { CompositeTrip } from '../js/types/diaryTypes'; +import { MetaData, ServerData, ServerResponse } from '../js/types/serverData'; +import { CompositeTrip, TripTransition } from '../js/types/diaryTypes'; mockLogger(); mockBEMUserCache(); @@ -99,6 +99,22 @@ const mockData: ServerResponse = { ], }; +let mockDataTwo = mockData; +mockDataTwo.phone_data = [mockData.phone_data[0], mockData.phone_data[0]]; + +const mockTransition: Array> = [ + { + data: { + currstate: 'STATE_WAITING_FOR_TRIP_TO_START', + transition: 'T_NOP', + ts: 12345.6789, + }, + metadata: mockMetaData, + }, +]; + +const mockTransitionTwo = mockTransition.push(mockTransition[0]); + // When called by mocks, pair 1 returns 1 value, Pair two 2, pair 3 returns none. const fakeStartTsOne = -14576291; const fakeEndTsOne = -13885091; @@ -107,16 +123,9 @@ const fakeEndTsTwo = 1277049465; // Once we have end-to-end testing, we could utilize getRawEnteries. jest.mock('../js/commHelper', () => ({ - getRawEntries: jest.fn((val, startTs, endTs, valTwo) => { - if (startTs === fakeStartTsOne && endTs === fakeEndTsOne) return mockData; - if (startTs == fakeStartTsTwo && endTs === fakeEndTsTwo) { - console.log('Twoy!'); - let dataCopy = JSON.parse(JSON.stringify(mockData)); - let temp = [dataCopy.phone_data[0], dataCopy.phone_data[0]]; - dataCopy.phone_data = temp; - console.log(`This is phoneData: ${JSON.stringify(dataCopy.phone_data.length)}`); - return dataCopy; - } + getRawEntries: jest.fn((key, startTs, endTs, valTwo) => { + if (startTs === fakeStartTsOne) return mockData; + if (startTs == fakeStartTsTwo) return mockDataTwo; return {}; }), })); @@ -131,15 +140,18 @@ it('fetches a composite trip object and collapses it', async () => { }); jest.mock('../js/unifiedDataLoader', () => ({ - getUnifiedDataForInterval: jest.fn(() => { + getUnifiedDataForInterval: jest.fn((key, tq, combiner) => { + if (tq.startTs === fakeStartTsOne) return Promise.resolve(mockTransition); + if (tq.startTs === fakeStartTsTwo) return Promise.resolve(mockTransitionTwo); return Promise.resolve([]); }), })); it('works when there are no unprocessed trips...', async () => { - expect(readUnprocessedTrips(fakeStartTsOne, fakeEndTsOne, null)).resolves.not.toThrow(); + expect(readUnprocessedTrips(-1, -1, null)).resolves.not.toThrow(); }); -it('works when there are no unprocessed trips...', async () => { +it('works when there are one or more unprocessed trips...', async () => { expect(readUnprocessedTrips(fakeStartTsOne, fakeEndTsOne, null)).resolves.not.toThrow(); + expect(readUnprocessedTrips(fakeStartTsTwo, fakeEndTsTwo, null)).resolves.not.toThrow(); }); diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 83d9cbf1c..47f17e05e 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -6,7 +6,7 @@ import { ServerResponse, ServerData } from '../types/serverData'; import L from 'leaflet'; import i18next from 'i18next'; import { DateTime } from 'luxon'; -import { CompositeTrip, TripTransition, SectionData, Trip } from '../types/diaryTypes'; +import { CompositeTrip, TripTransition, SectionData } from '../types/diaryTypes'; import { LabelOptions } from '../types/labelTypes'; const cachedGeojsons = new Map(); @@ -438,7 +438,7 @@ const isEndingTransition = function (transWrapper) { * * Let's abstract this out into our own minor state machine. */ -const transitions2Trips = function (transitionList: Array) { +const transitions2Trips = function (transitionList: Array>) { var inTrip = false; var tripList = []; var currStartTransitionIndex = -1; @@ -520,7 +520,7 @@ export const readUnprocessedTrips = function (startTs, endTs, lastProcessedTrip) const getMessageMethod = window['cordova'].plugins.BEMUserCache.getMessagesForInterval; console.log('Entering...'); return getUnifiedDataForInterval('statemachine/transition', tq, getMessageMethod).then(function ( - transitionList: Array, + transitionList: Array>, ) { if (transitionList.length == 0) { logDebug('No unprocessed trips. yay!'); diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 6088eb64b..938b06851 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -3,7 +3,7 @@ As much as possible, these types parallel the types used in the server code. */ import { BaseModeKey, MotionTypeKey } from '../diary/diaryHelper'; -import { LocalDt } from './serverData'; +import { ServerData, LocalDt } from './serverData'; type ObjectId = { $oid: string }; type ConfirmedPlace = { From 3943053782924e449e8befc9d0d8080812c5c7de Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 9 Nov 2023 12:13:55 -0700 Subject: [PATCH 372/850] write tests for metHelper --- www/__tests__/metHelper.test.ts | 45 +++++++++++++++++++++++++++++++++ www/js/metrics/metHelper.ts | 4 +-- 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 www/__tests__/metHelper.test.ts diff --git a/www/__tests__/metHelper.test.ts b/www/__tests__/metHelper.test.ts new file mode 100644 index 000000000..ee4fbd70d --- /dev/null +++ b/www/__tests__/metHelper.test.ts @@ -0,0 +1,45 @@ +import { getMet, setUseCustomMET } from '../js/metrics/metHelper'; +import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; +import fakeLabels from '../__mocks__/fakeLabels.json'; +import { getConfig } from '../js/config/dynamicConfig'; +import { initCustomDatasetHelper } from '../js/metrics/CustomMetricsHelper'; + +mockBEMUserCache(); +mockLogger(); + +global.fetch = (url: string) => + new Promise((rs, rj) => { + setTimeout(() => + rs({ + text: () => + new Promise((rs, rj) => { + let myJSON = JSON.stringify(fakeLabels); + setTimeout(() => rs(myJSON), 100); + }), + }), + ); + }) as any; + +beforeEach(() => { + setUseCustomMET(false); +}); + +it('gets met for mode and speed', () => { + expect(getMet('WALKING', 1.47523, 0)).toBe(4.3); + expect(getMet('BICYCLING', 4.5, 0)).toBe(6.8); + expect(getMet('UNICYCLE', 100, 0)).toBe(0); + expect(getMet('CAR', 25, 1)).toBe(0); +}); + +it('gets custom met for mode and speed', async () => { + initCustomDatasetHelper(getConfig()); + setUseCustomMET(true); + await new Promise((r) => setTimeout(r, 500)); + expect(getMet('walk', 1.47523, 0)).toBe(4.3); + expect(getMet('bike', 4.5, 0)).toBe(6.8); + expect(getMet('unicycle', 100, 0)).toBe(0); + expect(getMet('drove_alone', 25, 1)).toBe(0); + expect(getMet('e-bike', 6, 1)).toBe(4.9); + expect(getMet('e-bike', 12, 1)).toBe(4.9); +}); diff --git a/www/js/metrics/metHelper.ts b/www/js/metrics/metHelper.ts index b696c5790..dc1f7d296 100644 --- a/www/js/metrics/metHelper.ts +++ b/www/js/metrics/metHelper.ts @@ -3,8 +3,8 @@ import { standardMETs } from './metDataset'; let useCustom = false; -export const setUseCustomMET = function () { - useCustom = true; +export const setUseCustomMET = function (val: boolean) { + useCustom = val; }; const getMETs = function () { From f031d331fb27fc60cfe6e12c735b54aa44653b32 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 9 Nov 2023 11:40:52 -0800 Subject: [PATCH 373/850] Moved mockData to separate file for legibility --- www/__mocks__/timelineHelperMocks.ts | 111 ++++++++++++++++++++++ www/__tests__/timelineHelper.test.ts | 136 ++++----------------------- 2 files changed, 128 insertions(+), 119 deletions(-) create mode 100644 www/__mocks__/timelineHelperMocks.ts diff --git a/www/__mocks__/timelineHelperMocks.ts b/www/__mocks__/timelineHelperMocks.ts new file mode 100644 index 000000000..0b951174d --- /dev/null +++ b/www/__mocks__/timelineHelperMocks.ts @@ -0,0 +1,111 @@ +import { MetaData, ServerData, ServerResponse } from '../js/types/serverData'; +import { CompositeTrip, TripTransition } from '../js/types/diaryTypes'; + +const mockMetaData: MetaData = { + write_ts: -13885091, + key: 'test/value', + platform: 'test', + time_zone: 'America/Los_Angeles', + write_fmt_time: '1969-07-16T07:01:49.000Z', + write_local_dt: null, + origin_key: '12345', +}; + +export const mockData: ServerResponse = { + phone_data: [ + { + data: { + _id: null, + additions: [], + cleaned_section_summary: null, + cleaned_trip: null, + confidence_threshold: -1, + confirmed_trip: null, + distance: 777, + duration: 777, + end_confirmed_place: { + data: null, + metadata: JSON.parse(JSON.stringify(mockMetaData)), + }, + end_fmt_time: '2023-11-01T17:55:20.999397-07:00', + end_loc: { + type: 'Point', + coordinates: [-1, -1], + }, + end_local_dt: null, + end_place: null, + end_ts: -1, + expectation: null, + expected_trip: null, + inferred_labels: [], + inferred_section_summary: { + count: { + CAR: 1, + WALKING: 1, + }, + distance: { + CAR: 222, + WALKING: 222, + }, + duration: { + CAR: 333, + WALKING: 333, + }, + }, + inferred_trip: null, + key: '12345', + locations: [ + { + metadata: JSON.parse(JSON.stringify(mockMetaData)), + data: null, + }, + ], + origin_key: '', + raw_trip: null, + sections: [ + { + metadata: JSON.parse(JSON.stringify(mockMetaData)), + data: null, + }, + ], + source: 'DwellSegmentationDistFilter', + start_confirmed_place: { + data: null, + metadata: JSON.parse(JSON.stringify(mockMetaData)), + }, + start_fmt_time: '2023-11-01T17:55:20.999397-07:00', + start_loc: { + type: 'Point', + coordinates: [-1, -1], + }, + start_local_dt: null, + start_place: null, + start_ts: null, + user_input: null, + }, + metadata: JSON.parse(JSON.stringify(mockMetaData)), + }, + ], +}; +export const mockDataTwo = { + phone_data: [mockData.phone_data[0], mockData.phone_data[0]], +}; + +export const mockTransition: Array> = [ + { + data: { + currstate: 'STATE_WAITING_FOR_TRIP_TO_START', + transition: 'T_NOP', + ts: 12345.6789, + }, + metadata: mockMetaData, + }, +]; + +export const mockTransitionTwo = mockTransition.push(mockTransition[0]); + +// When called by mocks, pair 1 returns 1 value, Pair two 2, pair 3 returns none. +export const fakeStartTsOne = -14576291; +export const fakeEndTsOne = -13885091; +export const fakeStartTsTwo = 1092844665; +export const fakeEndTsTwo = 1277049465; diff --git a/www/__tests__/timelineHelper.test.ts b/www/__tests__/timelineHelper.test.ts index 0ae65d34c..0faac2c2e 100644 --- a/www/__tests__/timelineHelper.test.ts +++ b/www/__tests__/timelineHelper.test.ts @@ -2,8 +2,7 @@ import { mockLogger } from '../__mocks__/globalMocks'; import { readAllCompositeTrips, readUnprocessedTrips } from '../js/diary/timelineHelper'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; -import { MetaData, ServerData, ServerResponse } from '../js/types/serverData'; -import { CompositeTrip, TripTransition } from '../js/types/diaryTypes'; +import * as mockTLH from '../__mocks__/timelineHelperMocks'; mockLogger(); mockBEMUserCache(); @@ -12,120 +11,11 @@ afterAll(() => { jest.restoreAllMocks(); }); -const mockMetaData: MetaData = { - write_ts: -13885091, - key: 'test/value', - platform: 'test', - time_zone: 'America/Los_Angeles', - write_fmt_time: '1969-07-16T07:01:49.000Z', - write_local_dt: null, - origin_key: '12345', -}; - -const mockData: ServerResponse = { - phone_data: [ - { - data: { - _id: null, - additions: [], - cleaned_section_summary: null, // TODO - cleaned_trip: null, //ObjId; - confidence_threshold: -1, - confirmed_trip: null, //ObjId; - distance: 777, - duration: 777, - end_confirmed_place: { - data: null, - metadata: JSON.parse(JSON.stringify(mockMetaData)), - }, - end_fmt_time: '2023-11-01T17:55:20.999397-07:00', - end_loc: { - type: 'Point', - coordinates: [-1, -1], - }, - end_local_dt: null, //LocalDt; - end_place: null, //ObjId; - end_ts: -1, - expectation: null, // TODO "{to_label: boolean}" - expected_trip: null, //ObjId; - inferred_labels: [], // TODO - inferred_section_summary: { - count: { - CAR: 1, - WALKING: 1, - }, - distance: { - CAR: 222, - WALKING: 222, - }, - duration: { - CAR: 333, - WALKING: 333, - }, - }, - inferred_trip: null, - key: '12345', - locations: [ - { - metadata: JSON.parse(JSON.stringify(mockMetaData)), - data: null, - }, - ], // LocationType - origin_key: '', - raw_trip: null, - sections: [ - { - metadata: JSON.parse(JSON.stringify(mockMetaData)), - data: null, - }, - ], // TODO - source: 'DwellSegmentationDistFilter', - start_confirmed_place: { - data: null, - metadata: JSON.parse(JSON.stringify(mockMetaData)), - }, - start_fmt_time: '2023-11-01T17:55:20.999397-07:00', - start_loc: { - type: 'Point', - coordinates: [-1, -1], - }, - start_local_dt: null, - start_place: null, - start_ts: null, - user_input: null, - }, - metadata: JSON.parse(JSON.stringify(mockMetaData)), - }, - ], -}; - -let mockDataTwo = mockData; -mockDataTwo.phone_data = [mockData.phone_data[0], mockData.phone_data[0]]; - -const mockTransition: Array> = [ - { - data: { - currstate: 'STATE_WAITING_FOR_TRIP_TO_START', - transition: 'T_NOP', - ts: 12345.6789, - }, - metadata: mockMetaData, - }, -]; - -const mockTransitionTwo = mockTransition.push(mockTransition[0]); - -// When called by mocks, pair 1 returns 1 value, Pair two 2, pair 3 returns none. -const fakeStartTsOne = -14576291; -const fakeEndTsOne = -13885091; -const fakeStartTsTwo = 1092844665; -const fakeEndTsTwo = 1277049465; - // Once we have end-to-end testing, we could utilize getRawEnteries. jest.mock('../js/commHelper', () => ({ getRawEntries: jest.fn((key, startTs, endTs, valTwo) => { - if (startTs === fakeStartTsOne) return mockData; - if (startTs == fakeStartTsTwo) return mockDataTwo; + if (startTs === mockTLH.fakeStartTsOne) return mockTLH.mockData; + if (startTs == mockTLH.fakeStartTsTwo) return mockTLH.mockDataTwo; return {}; }), })); @@ -135,14 +25,18 @@ it('works when there are no composite trip objects fetched', async () => { }); it('fetches a composite trip object and collapses it', async () => { - expect(readAllCompositeTrips(fakeStartTsOne, fakeEndTsOne)).resolves.not.toThrow(); - expect(readAllCompositeTrips(fakeStartTsTwo, fakeEndTsTwo)).resolves.not.toThrow(); + expect( + readAllCompositeTrips(mockTLH.fakeStartTsOne, mockTLH.fakeEndTsOne), + ).resolves.not.toThrow(); + expect( + readAllCompositeTrips(mockTLH.fakeStartTsTwo, mockTLH.fakeEndTsTwo), + ).resolves.not.toThrow(); }); jest.mock('../js/unifiedDataLoader', () => ({ getUnifiedDataForInterval: jest.fn((key, tq, combiner) => { - if (tq.startTs === fakeStartTsOne) return Promise.resolve(mockTransition); - if (tq.startTs === fakeStartTsTwo) return Promise.resolve(mockTransitionTwo); + if (tq.startTs === mockTLH.fakeStartTsOne) return Promise.resolve(mockTLH.mockTransition); + if (tq.startTs === mockTLH.fakeStartTsTwo) return Promise.resolve(mockTLH.mockTransitionTwo); return Promise.resolve([]); }), })); @@ -152,6 +46,10 @@ it('works when there are no unprocessed trips...', async () => { }); it('works when there are one or more unprocessed trips...', async () => { - expect(readUnprocessedTrips(fakeStartTsOne, fakeEndTsOne, null)).resolves.not.toThrow(); - expect(readUnprocessedTrips(fakeStartTsTwo, fakeEndTsTwo, null)).resolves.not.toThrow(); + expect( + readUnprocessedTrips(mockTLH.fakeStartTsOne, mockTLH.fakeEndTsOne, null), + ).resolves.not.toThrow(); + expect( + readUnprocessedTrips(mockTLH.fakeStartTsTwo, mockTLH.fakeEndTsTwo, null), + ).resolves.not.toThrow(); }); From 2e1a402d9d474f8bb5aba88d00781c5aedf16ade Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 9 Nov 2023 11:47:32 -0800 Subject: [PATCH 374/850] Deepcopy wasn't necessary, removed Parse/stringify --- www/__mocks__/timelineHelperMocks.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/www/__mocks__/timelineHelperMocks.ts b/www/__mocks__/timelineHelperMocks.ts index 0b951174d..eb9bc834c 100644 --- a/www/__mocks__/timelineHelperMocks.ts +++ b/www/__mocks__/timelineHelperMocks.ts @@ -25,7 +25,7 @@ export const mockData: ServerResponse = { duration: 777, end_confirmed_place: { data: null, - metadata: JSON.parse(JSON.stringify(mockMetaData)), + metadata: mockMetaData, }, end_fmt_time: '2023-11-01T17:55:20.999397-07:00', end_loc: { @@ -56,7 +56,7 @@ export const mockData: ServerResponse = { key: '12345', locations: [ { - metadata: JSON.parse(JSON.stringify(mockMetaData)), + metadata: mockMetaData, data: null, }, ], @@ -64,14 +64,14 @@ export const mockData: ServerResponse = { raw_trip: null, sections: [ { - metadata: JSON.parse(JSON.stringify(mockMetaData)), + metadata: mockMetaData, data: null, }, ], source: 'DwellSegmentationDistFilter', start_confirmed_place: { data: null, - metadata: JSON.parse(JSON.stringify(mockMetaData)), + metadata: mockMetaData, }, start_fmt_time: '2023-11-01T17:55:20.999397-07:00', start_loc: { @@ -83,7 +83,7 @@ export const mockData: ServerResponse = { start_ts: null, user_input: null, }, - metadata: JSON.parse(JSON.stringify(mockMetaData)), + metadata: mockMetaData, }, ], }; From adab677ab1ac07fb9cf215f92a8e8660773ce8dd Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 9 Nov 2023 13:17:46 -0800 Subject: [PATCH 375/850] Added clarifying comment, adjusted DateTime format --- www/js/diary/timelineHelper.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 47f17e05e..b6bcb1b3b 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -223,7 +223,9 @@ const locations2GeojsonTrajectory = (trip, locationList, trajectoryColor?) => { }); }; -// Remaining functions from /diary/services.js +// DB entries retrieved from the server have '_id', 'metadata', and 'data' fields. +// This function returns a shallow copy of the obj, which flattens the +// 'data' field into the top level, while also including '_id' and 'metadata.key' const unpackServerData = (obj: ServerData) => ({ ...obj.data, _id: obj._id, @@ -251,7 +253,6 @@ export const readAllCompositeTrips = function (startTs: number, endTs: number) { return []; }); }; - const dateTime2localdate = function (currtime: DateTime, tz: string) { return { timezone: tz, @@ -298,8 +299,6 @@ const points2TripProps = function (locationPoints) { speed: speeds[i], })); - // used to mimic old momentJS moment.format() - const formatString = "yyyy-MM-dd'T'HH:mm:ssZZ"; return { _id: { $oid: tripAndSectionId }, key: 'UNPROCESSED_trip', @@ -308,14 +307,14 @@ const points2TripProps = function (locationPoints) { confidence_threshold: 0, distance: dists.reduce((a, b) => a + b, 0), duration: endPoint.data.ts - startPoint.data.ts, - end_fmt_time: endTime.toFormat(formatString), + end_fmt_time: endTime.toISO(), end_local_dt: dateTime2localdate(endTime, endPoint.metadata.time_zone), end_ts: endPoint.data.ts, expectation: { to_label: true }, inferred_labels: [], locations: locations, source: 'unprocessed', - start_fmt_time: startTime.toFormat(formatString), + start_fmt_time: startTime.toISO, start_local_dt: dateTime2localdate(startTime, startPoint.metadata.time_zone), start_ts: startPoint.data.ts, user_input: {}, From 7436502a0d56d790e58b72e5e4152cb41fe67a44 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 9 Nov 2023 14:45:12 -0700 Subject: [PATCH 376/850] custom dataset helper tests --- www/__tests__/customMetricsHelper.test.ts | 68 +++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 www/__tests__/customMetricsHelper.test.ts diff --git a/www/__tests__/customMetricsHelper.test.ts b/www/__tests__/customMetricsHelper.test.ts new file mode 100644 index 000000000..ec4b9d709 --- /dev/null +++ b/www/__tests__/customMetricsHelper.test.ts @@ -0,0 +1,68 @@ +import { getConfig } from '../js/config/dynamicConfig'; +import { + getCustomFootprint, + getCustomMETs, + getFallbackFootprint, + initCustomDatasetHelper, +} from '../js/metrics/CustomMetricsHelper'; +import { setUseCustomMET } from '../js/metrics/metHelper'; +import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; +import fakeLabels from '../__mocks__/fakeLabels.json'; +import { setUseCustomFootprint } from '../js/metrics/footprintHelper'; +import { number } from 'prop-types'; + +mockBEMUserCache(); +mockLogger(); + +global.fetch = (url: string) => + new Promise((rs, rj) => { + setTimeout(() => + rs({ + text: () => + new Promise((rs, rj) => { + let myJSON = JSON.stringify(fakeLabels); + setTimeout(() => rs(myJSON), 100); + }), + }), + ); + }) as any; + +it('gets the fallback carbon', async () => { + expect(getFallbackFootprint()).toEqual( + expect.objectContaining({ + WALKING: 0, + BICYCLING: 0, + CAR: 267 / 1609, + TRAIN: 92 / 1609, + }), + ); +}); + +it('gets the custom mets', async () => { + initCustomDatasetHelper(getConfig()); + setUseCustomMET(true); + await new Promise((r) => setTimeout(r, 800)); + expect(getCustomMETs()).toMatchObject({ + walk: {}, + bike: {}, + bikeshare: {}, + 'e-bike': {}, + scootershare: {}, + drove_alone: {}, + }); +}); + +it('gets the custom footprint', async () => { + initCustomDatasetHelper(getConfig()); + setUseCustomFootprint(true); + await new Promise((r) => setTimeout(r, 800)); + expect(getCustomFootprint()).toMatchObject({ + walk: {}, + bike: {}, + bikeshare: {}, + 'e-bike': {}, + scootershare: {}, + drove_alone: {}, + }); +}); From 2d0d321676144633f4334a44b4fb88f0714566aa Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 9 Nov 2023 14:01:25 -0800 Subject: [PATCH 377/850] Expanded mocks, sured up tests - borrowed the windowAlert mocks from PR #1093, to allow error checking - added mockCheck, updated some tests to use to `toEqual()` --- www/__mocks__/globalMocks.ts | 16 ++++++ www/__mocks__/timelineHelperMocks.ts | 77 ++++++++++++++++++++++++++++ www/__tests__/timelineHelper.test.ts | 15 ++++-- 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/www/__mocks__/globalMocks.ts b/www/__mocks__/globalMocks.ts index f13cb274b..62ea1b935 100644 --- a/www/__mocks__/globalMocks.ts +++ b/www/__mocks__/globalMocks.ts @@ -1,3 +1,19 @@ export const mockLogger = () => { window['Logger'] = { log: console.log }; }; + +let alerts = []; + +export const mockAlert = () => { + window['alert'] = (message) => { + alerts.push(message); + }; +}; + +export const clearAlerts = () => { + alerts = []; +}; + +export const getAlerts = () => { + return alerts; +}; diff --git a/www/__mocks__/timelineHelperMocks.ts b/www/__mocks__/timelineHelperMocks.ts index eb9bc834c..53d687968 100644 --- a/www/__mocks__/timelineHelperMocks.ts +++ b/www/__mocks__/timelineHelperMocks.ts @@ -109,3 +109,80 @@ export const fakeStartTsOne = -14576291; export const fakeEndTsOne = -13885091; export const fakeStartTsTwo = 1092844665; export const fakeEndTsTwo = 1277049465; + +export const readAllCheck = [ + { + ...mockData.phone_data[0].data, + }, +]; + +export const readAllCompositeCheck = [ + { + additions: [], + cleaned_section_summary: null, + cleaned_trip: null, + confidence_threshold: -1, + confirmed_trip: null, + distance: 777, + duration: 777, + end_confirmed_place: { + key: 'test/value', + origin_key: '12345', + }, + end_fmt_time: '2023-11-01T17:55:20.999397-07:00', + end_loc: { + type: 'Point', + coordinates: [-1, -1], + }, + end_local_dt: null, + end_place: null, + end_ts: -1, + expectation: null, + expected_trip: null, + inferred_labels: [], + inferred_section_summary: { + count: { + CAR: 1, + WALKING: 1, + }, + distance: { + CAR: 222, + WALKING: 222, + }, + duration: { + CAR: 333, + WALKING: 333, + }, + }, + inferred_trip: null, + key: 'test/value', + locations: [ + { + key: 'test/value', + origin_key: '12345', + }, + ], + origin_key: '12345', + raw_trip: null, + sections: [ + { + key: 'test/value', + origin_key: '12345', + }, + ], + source: 'DwellSegmentationDistFilter', + start_confirmed_place: { + key: 'test/value', + origin_key: '12345', + }, + start_fmt_time: '2023-11-01T17:55:20.999397-07:00', + start_loc: { + type: 'Point', + coordinates: [-1, -1], + }, + start_local_dt: null, + start_place: null, + start_ts: null, + user_input: null, + }, +]; diff --git a/www/__tests__/timelineHelper.test.ts b/www/__tests__/timelineHelper.test.ts index 0faac2c2e..e0f424fcf 100644 --- a/www/__tests__/timelineHelper.test.ts +++ b/www/__tests__/timelineHelper.test.ts @@ -1,12 +1,17 @@ -import { mockLogger } from '../__mocks__/globalMocks'; +import { clearAlerts, mockAlert, mockLogger } from '../__mocks__/globalMocks'; import { readAllCompositeTrips, readUnprocessedTrips } from '../js/diary/timelineHelper'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import * as mockTLH from '../__mocks__/timelineHelperMocks'; mockLogger(); +mockAlert(); mockBEMUserCache(); +beforeEach(() => { + clearAlerts(); +}); + afterAll(() => { jest.restoreAllMocks(); }); @@ -21,13 +26,13 @@ jest.mock('../js/commHelper', () => ({ })); it('works when there are no composite trip objects fetched', async () => { - expect(readAllCompositeTrips(-1, -1)).resolves.not.toThrow(); + expect(readAllCompositeTrips(-1, -1)).resolves.toEqual([]); }); it('fetches a composite trip object and collapses it', async () => { - expect( - readAllCompositeTrips(mockTLH.fakeStartTsOne, mockTLH.fakeEndTsOne), - ).resolves.not.toThrow(); + expect(readAllCompositeTrips(mockTLH.fakeStartTsOne, mockTLH.fakeEndTsOne)).resolves.toEqual( + mockTLH.readAllCompositeCheck, + ); expect( readAllCompositeTrips(mockTLH.fakeStartTsTwo, mockTLH.fakeEndTsTwo), ).resolves.not.toThrow(); From 974ac3ad124038dc46a11edc3b3e73bdc8284816 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 9 Nov 2023 15:30:27 -0700 Subject: [PATCH 378/850] remove carbon fallback This code should never be used at this point -- labels will default to those in the sample file, which means that they are "walk, bike, ect" -- custom labels rather than the default "WALKING, BICYCLING, etc" Since the labels are always some version of custom (even the fallback) those values will always be used over the country-specific fallback values --- www/__tests__/customMetricsHelper.test.ts | 13 ------------ www/__tests__/footprintHelper.test.ts | 24 ----------------------- www/js/metrics/CustomMetricsHelper.ts | 8 +------- www/js/metrics/carbonDatasetFallback.ts | 14 ------------- www/js/metrics/footprintHelper.ts | 4 ++-- 5 files changed, 3 insertions(+), 60 deletions(-) delete mode 100644 www/js/metrics/carbonDatasetFallback.ts diff --git a/www/__tests__/customMetricsHelper.test.ts b/www/__tests__/customMetricsHelper.test.ts index ec4b9d709..0f221e12c 100644 --- a/www/__tests__/customMetricsHelper.test.ts +++ b/www/__tests__/customMetricsHelper.test.ts @@ -2,7 +2,6 @@ import { getConfig } from '../js/config/dynamicConfig'; import { getCustomFootprint, getCustomMETs, - getFallbackFootprint, initCustomDatasetHelper, } from '../js/metrics/CustomMetricsHelper'; import { setUseCustomMET } from '../js/metrics/metHelper'; @@ -10,7 +9,6 @@ import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; import fakeLabels from '../__mocks__/fakeLabels.json'; import { setUseCustomFootprint } from '../js/metrics/footprintHelper'; -import { number } from 'prop-types'; mockBEMUserCache(); mockLogger(); @@ -28,17 +26,6 @@ global.fetch = (url: string) => ); }) as any; -it('gets the fallback carbon', async () => { - expect(getFallbackFootprint()).toEqual( - expect.objectContaining({ - WALKING: 0, - BICYCLING: 0, - CAR: 267 / 1609, - TRAIN: 92 / 1609, - }), - ); -}); - it('gets the custom mets', async () => { initCustomDatasetHelper(getConfig()); setUseCustomMET(true); diff --git a/www/__tests__/footprintHelper.test.ts b/www/__tests__/footprintHelper.test.ts index fa0b4574d..1de4fd701 100644 --- a/www/__tests__/footprintHelper.test.ts +++ b/www/__tests__/footprintHelper.test.ts @@ -32,30 +32,6 @@ beforeEach(() => { clearHighestFootprint(); }); -const metrics = [ - { key: 'WALKING', values: 3000 }, - { key: 'BICYCLING', values: 6500 }, - { key: 'CAR', values: 50000 }, - { key: 'LIGHT_RAIL', values: 30000 }, - { key: 'Unicycle', values: 5000 }, -]; - -it('gets footprint for metrics (not custom, fallback 0)', () => { - expect(getFootprintForMetrics(metrics, 0)).toBe(10.534493474207583); -}); - -it('gets footprint for metrics (not custom, fallback 0.1)', () => { - expect(getFootprintForMetrics(metrics, 0.1)).toBe(10.534493474207583 + 0.5); -}); - -it('gets the highest footprint from the dataset, not custom', () => { - expect(getHighestFootprint()).toBe(278 / 1609); -}); - -it('gets the highest footprint for distance, not custom', () => { - expect(getHighestFootprintForDistance(12345)).toBe((278 / 1609) * (12345 / 1000)); -}); - const custom_metrics = [ { key: 'walk', values: 3000 }, { key: 'bike', values: 6500 }, diff --git a/www/js/metrics/CustomMetricsHelper.ts b/www/js/metrics/CustomMetricsHelper.ts index 088ec8831..0c25b7d41 100644 --- a/www/js/metrics/CustomMetricsHelper.ts +++ b/www/js/metrics/CustomMetricsHelper.ts @@ -3,7 +3,6 @@ import { getLabelOptions } from '../survey/multilabel/confirmHelper'; import { getConfig } from '../config/dynamicConfig'; import { displayError, displayErrorMsg, logDebug } from '../plugin/logger'; import { standardMETs } from './metDataset'; -import { fallbackCarbon } from './carbonDatasetFallback'; let _customMETs; let _customPerKmFootprint; @@ -20,16 +19,11 @@ export const getCustomFootprint = function () { return _customPerKmFootprint; }; -export const getRangeLimitedMotorixe = function () { +export const getRangeLimitedMotorized = function () { logDebug('Getting range limited motorized ' + JSON.stringify(_range_limited_motorized)); return _range_limited_motorized; }; -export const getFallbackFootprint = function () { - console.log('getting fallback carbon'); - return fallbackCarbon.footprintData; -}; - const populateCustomMETs = function () { let modeOptions = _inputParams['MODE']; let modeMETEntries = modeOptions.map((opt) => { diff --git a/www/js/metrics/carbonDatasetFallback.ts b/www/js/metrics/carbonDatasetFallback.ts deleted file mode 100644 index 4561d078a..000000000 --- a/www/js/metrics/carbonDatasetFallback.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const fallbackCarbon = { - regionName: 'United States', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 267 / 1609, - BUS: 278 / 1609, - LIGHT_RAIL: 120 / 1609, - SUBWAY: 74 / 1609, - TRAM: 90 / 1609, - TRAIN: 92 / 1609, - AIR_OR_HSR: 217 / 1609, - }, -}; diff --git a/www/js/metrics/footprintHelper.ts b/www/js/metrics/footprintHelper.ts index 1c950e690..aef7249b3 100644 --- a/www/js/metrics/footprintHelper.ts +++ b/www/js/metrics/footprintHelper.ts @@ -1,5 +1,5 @@ import { displayErrorMsg } from '../plugin/logger'; -import { getCustomFootprint, getFallbackFootprint } from './CustomMetricsHelper'; +import { getCustomFootprint } from './CustomMetricsHelper'; var highestFootprint = 0; let useCustom = false; @@ -22,7 +22,7 @@ const getFootprint = function () { } else { //TODO: check through configs and ensure they all have custom lables displayErrorMsg('Error in Footprint Calculatons', 'issue with data or default labels'); - return getFallbackFootprint(); + return; } }; From bf913012c559a513719f2a88f8dade2f23a76cc2 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 9 Nov 2023 14:39:12 -0800 Subject: [PATCH 379/850] Minor adjustment to tests --- www/__mocks__/timelineHelperMocks.ts | 8 +------- www/__tests__/timelineHelper.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/www/__mocks__/timelineHelperMocks.ts b/www/__mocks__/timelineHelperMocks.ts index 53d687968..eb0eb672e 100644 --- a/www/__mocks__/timelineHelperMocks.ts +++ b/www/__mocks__/timelineHelperMocks.ts @@ -110,13 +110,7 @@ export const fakeEndTsOne = -13885091; export const fakeStartTsTwo = 1092844665; export const fakeEndTsTwo = 1277049465; -export const readAllCheck = [ - { - ...mockData.phone_data[0].data, - }, -]; - -export const readAllCompositeCheck = [ +export const readAllCheckOne = [ { additions: [], cleaned_section_summary: null, diff --git a/www/__tests__/timelineHelper.test.ts b/www/__tests__/timelineHelper.test.ts index e0f424fcf..e5e37ca2d 100644 --- a/www/__tests__/timelineHelper.test.ts +++ b/www/__tests__/timelineHelper.test.ts @@ -31,7 +31,7 @@ it('works when there are no composite trip objects fetched', async () => { it('fetches a composite trip object and collapses it', async () => { expect(readAllCompositeTrips(mockTLH.fakeStartTsOne, mockTLH.fakeEndTsOne)).resolves.toEqual( - mockTLH.readAllCompositeCheck, + mockTLH.readAllCheckOne, ); expect( readAllCompositeTrips(mockTLH.fakeStartTsTwo, mockTLH.fakeEndTsTwo), @@ -47,7 +47,7 @@ jest.mock('../js/unifiedDataLoader', () => ({ })); it('works when there are no unprocessed trips...', async () => { - expect(readUnprocessedTrips(-1, -1, null)).resolves.not.toThrow(); + expect(readUnprocessedTrips(-1, -1, null)).resolves.toEqual([]); }); it('works when there are one or more unprocessed trips...', async () => { From 86a4433b0226db9e74f84127f2ca784e92fcea7c Mon Sep 17 00:00:00 2001 From: louisg1337 Date: Thu, 9 Nov 2023 17:48:14 -0500 Subject: [PATCH 380/850] Bumped up usercache version for new bounding fix --- package.cordovabuild.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.cordovabuild.json b/package.cordovabuild.json index 12da8b81a..048f8f81d 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -131,7 +131,7 @@ "cordova-plugin-em-serversync": "git+https://github.com/e-mission/cordova-server-sync.git#v1.3.2", "cordova-plugin-em-settings": "git+https://github.com/e-mission/cordova-connection-settings.git#v1.2.3", "cordova-plugin-em-unifiedlogger": "git+https://github.com/e-mission/cordova-unified-logger.git#v1.3.6", - "cordova-plugin-em-usercache": "git+https://github.com/e-mission/cordova-usercache.git#v1.1.6", + "cordova-plugin-em-usercache": "git+https://github.com/e-mission/cordova-usercache.git#v1.1.7", "cordova-plugin-email-composer": "git+https://github.com/katzer/cordova-plugin-email-composer.git#0.10.1", "cordova-plugin-file": "8.0.0", "cordova-plugin-inappbrowser": "5.0.0", From f4b0e2d7134fc37df6cfc1ea5cee82653e27735d Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 9 Nov 2023 15:14:46 -0800 Subject: [PATCH 381/850] Ran prettier on LabelTab merge --- www/js/diary/LabelTab.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 19f070bc5..7dde387a5 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -250,7 +250,6 @@ const LabelTab = () => { } async function fetchTripsInRange(startTs: number, endTs: number) { - if (!pipelineRange.start_ts) return logWarn('No pipelineRange yet - early return'); logDebug('LabelTab: fetchTripsInRange from ' + startTs + ' to ' + endTs); const readCompositePromise = Timeline.readAllCompositeTrips(startTs, endTs); From 8c28737a73f3e078f0a606b78f79114bf06aff9a Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 9 Nov 2023 16:47:38 -0700 Subject: [PATCH 382/850] remove unused code --- www/js/metrics/footprintHelper.ts | 86 ------------------------------- www/js/metrics/metHelper.ts | 16 ------ 2 files changed, 102 deletions(-) diff --git a/www/js/metrics/footprintHelper.ts b/www/js/metrics/footprintHelper.ts index aef7249b3..840647e75 100644 --- a/www/js/metrics/footprintHelper.ts +++ b/www/js/metrics/footprintHelper.ts @@ -60,19 +60,6 @@ export const getFootprintForMetrics = function (userMetrics, defaultIfMissing = return result; }; -const getLowestFootprintForDistance = function (distance) { - var footprint = getFootprint(); - var lowestFootprint = Number.MAX_SAFE_INTEGER; - for (var mode in footprint) { - if (mode == 'WALKING' || mode == 'BICYCLING') { - // these modes aren't considered when determining the lowest carbon footprint - } else { - lowestFootprint = Math.min(lowestFootprint, footprint[mode]); - } - } - return lowestFootprint * mtokm(distance); -}; - export const getHighestFootprint = function () { if (!highestFootprint) { var footprint = getFootprint(); @@ -88,76 +75,3 @@ export const getHighestFootprint = function () { export const getHighestFootprintForDistance = function (distance) { return getHighestFootprint() * mtokm(distance); }; - -const getLowestMotorizedNonAirFootprint = function (footprint, rlmCO2) { - var lowestFootprint = Number.MAX_SAFE_INTEGER; - for (var mode in footprint) { - if (mode == 'AIR_OR_HSR' || mode == 'air') { - console.log('Air mode, ignoring'); - } else { - if (footprint[mode] == 0 || footprint[mode] <= rlmCO2) { - console.log( - 'Non motorized mode or footprint <= range_limited_motorized', - mode, - footprint[mode], - rlmCO2, - ); - } else { - lowestFootprint = Math.min(lowestFootprint, footprint[mode]); - } - } - } - return lowestFootprint; -}; - -const getOptimalDistanceRanges = function () { - const FIVE_KM = 5 * 1000; - const SIX_HUNDRED_KM = 600 * 1000; - if (!useCustom) { - const defaultFootprint = getCurrentCarbonDatasetFootprint(); - const lowestMotorizedNonAir = getLowestMotorizedNonAirFootprint(defaultFootprint); - const airFootprint = defaultFootprint['AIR_OR_HSR']; - return [ - { low: 0, high: FIVE_KM, optimal: 0 }, - { low: FIVE_KM, high: SIX_HUNDRED_KM, optimal: lowestMotorizedNonAir }, - { low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint }, - ]; - } else { - // custom footprint, let's get the custom values - const customFootprint = getCustomFootprint(); - let airFootprint = customFootprint['air']; - if (!airFootprint) { - // 2341 BTU/PMT from - // https://tedb.ornl.gov/wp-content/uploads/2021/02/TEDB_Ed_39.pdf#page=68 - // 159.25 lb per million BTU from EIA - // https://www.eia.gov/environment/emissions/co2_vol_mass.php - // (2341 * (159.25/1000000))/(1.6*2.2) = 0.09975, rounded up a bit - console.log('No entry for air in ', customFootprint, ' using default'); - airFootprint = 0.1; - } - const rlm = CustomDatasetHelper.range_limited_motorized; - if (!rlm) { - return [ - { low: 0, high: FIVE_KM, optimal: 0 }, - { low: FIVE_KM, high: SIX_HUNDRED_KM, optimal: lowestMotorizedNonAir }, - { low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint }, - ]; - } else { - console.log('Found range_limited_motorized mode', rlm); - const lowestMotorizedNonAir = getLowestMotorizedNonAirFootprint( - customFootprint, - rlm.kgCo2PerKm, - ); - return [ - { low: 0, high: FIVE_KM, optimal: 0 }, - { low: FIVE_KM, high: rlm.range_limit_km * 1000, optimal: rlm.kgCo2PerKm }, - { - low: rlm.range_limit_km * 1000, - high: SIX_HUNDRED_KM, - optimal: lowestMotorizedNonAir, - }, - { low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint }, - ]; - } - } -}; diff --git a/www/js/metrics/metHelper.ts b/www/js/metrics/metHelper.ts index dc1f7d296..e1d1ce28b 100644 --- a/www/js/metrics/metHelper.ts +++ b/www/js/metrics/metHelper.ts @@ -42,19 +42,3 @@ export const getMet = function (mode, speed, defaultIfMissing) { } } }; - -// var highestMET = 0; -// const getHighestMET = function () { -// if (!highestMET) { -// var met = getMETs(); -// let metList = []; -// for (var mode in met) { -// var rangeList = met[mode]; -// for (var range in rangeList) { -// metList.push(rangeList[range].mets); -// } -// } -// highestMET = Math.max(...metList); -// } -// return highestMET; -// }; From deb7104362459ad108ea9022a2ec16421384e4f3 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Thu, 9 Nov 2023 18:09:20 -0700 Subject: [PATCH 383/850] add docstrings --- www/js/metrics/CustomMetricsHelper.ts | 27 +++++++++++++---- www/js/metrics/footprintHelper.ts | 43 ++++++++++++++++++++++++--- www/js/metrics/metHelper.ts | 27 +++++++++++++++++ 3 files changed, 88 insertions(+), 9 deletions(-) diff --git a/www/js/metrics/CustomMetricsHelper.ts b/www/js/metrics/CustomMetricsHelper.ts index 0c25b7d41..eab094062 100644 --- a/www/js/metrics/CustomMetricsHelper.ts +++ b/www/js/metrics/CustomMetricsHelper.ts @@ -4,26 +4,34 @@ import { getConfig } from '../config/dynamicConfig'; import { displayError, displayErrorMsg, logDebug } from '../plugin/logger'; import { standardMETs } from './metDataset'; +//variables to store values locally let _customMETs; let _customPerKmFootprint; let _range_limited_motorized; let _inputParams; +/** + * @function gets custom mets, must be initialized + * @returns the custom mets stored locally + */ export const getCustomMETs = function () { logDebug('Getting custom METs ' + JSON.stringify(_customMETs)); return _customMETs; }; +/** + * @function gets the custom footprint, must be initialized + * @returns custom footprint + */ export const getCustomFootprint = function () { logDebug('Getting custom footprint ' + JSON.stringify(_customPerKmFootprint)); return _customPerKmFootprint; }; -export const getRangeLimitedMotorized = function () { - logDebug('Getting range limited motorized ' + JSON.stringify(_range_limited_motorized)); - return _range_limited_motorized; -}; - +/** + * @function stores custom mets in local var + * needs _inputParams, label options stored after gotten from config + */ const populateCustomMETs = function () { let modeOptions = _inputParams['MODE']; let modeMETEntries = modeOptions.map((opt) => { @@ -56,6 +64,10 @@ const populateCustomMETs = function () { logDebug('After populating, custom METs = ' + JSON.stringify(_customMETs)); }; +/** + * @function stores custom footprint in local var + * needs _inputParams which is stored after gotten from config + */ const populateCustomFootprints = function () { let modeOptions = _inputParams['MODE']; let modeCO2PerKm = modeOptions @@ -81,6 +93,11 @@ const populateCustomFootprints = function () { logDebug('After populating, custom perKmFootprint' + JSON.stringify(_customPerKmFootprint)); }; +/** + * @function initializes the datasets based on configured label options + * calls popuplateCustomMETs and populateCustomFootprint + * @param newConfig the app config file + */ export const initCustomDatasetHelper = async function (newConfig) { newConfig = await getConfig(); try { diff --git a/www/js/metrics/footprintHelper.ts b/www/js/metrics/footprintHelper.ts index 840647e75..a7b7f74ce 100644 --- a/www/js/metrics/footprintHelper.ts +++ b/www/js/metrics/footprintHelper.ts @@ -1,31 +1,57 @@ import { displayErrorMsg } from '../plugin/logger'; import { getCustomFootprint } from './CustomMetricsHelper'; -var highestFootprint = 0; +//variables for the highest footprint in the set and if using custom +let highestFootprint = 0; let useCustom = false; +/** + * @function converts meters to kilometers + * @param {number} v value in meters to be converted + * @returns {number} converted value in km + */ const mtokm = function (v) { return v / 1000; }; + +/** + * @function sets the value of useCustom + * @param {boolean} val if using custom footprint + */ export const setUseCustomFootprint = function (val: boolean) { useCustom = val; }; +/** + * @function clears the stored highest footprint + */ export const clearHighestFootprint = function () { //need to clear for testing highestFootprint = undefined; }; +/** + * @function gets the footprint + * currently will only be custom, as all labels are "custom" + * fallback is json/label-options.json.sample, with MET and kgCO2 defined + * @returns the footprint or undefined + */ const getFootprint = function () { if (useCustom == true) { return getCustomFootprint(); } else { - //TODO: check through configs and ensure they all have custom lables - displayErrorMsg('Error in Footprint Calculatons', 'issue with data or default labels'); - return; + displayErrorMsg('failed to use custom labels', 'Error in Footprint Calculatons'); + return undefined; } }; +/** + * @function calculates footprint for given metrics + * @param {Array} userMetrics string mode + number distance in meters pairs + * ex: const custom_metrics = [ { key: 'walk', values: 3000 }, { key: 'bike', values: 6500 }, ]; + * @param {number} defaultIfMissing optional, carbon intensity if mode not in footprint + * @returns {number} the sum of carbon emissions for userMetrics given + */ export const getFootprintForMetrics = function (userMetrics, defaultIfMissing = 0) { var footprint = getFootprint(); var result = 0; @@ -60,6 +86,10 @@ export const getFootprintForMetrics = function (userMetrics, defaultIfMissing = return result; }; +/** + * @function gets highest co2 intensity in the footprint + * @returns {number} the highest co2 intensity in the footprint + */ export const getHighestFootprint = function () { if (!highestFootprint) { var footprint = getFootprint(); @@ -72,6 +102,11 @@ export const getHighestFootprint = function () { return highestFootprint; }; +/** + * @function gets highest theoretical footprint for given distance + * @param {number} distance in meters to calculate max footprint + * @returns max footprint for given distance + */ export const getHighestFootprintForDistance = function (distance) { return getHighestFootprint() * mtokm(distance); }; diff --git a/www/js/metrics/metHelper.ts b/www/js/metrics/metHelper.ts index e1d1ce28b..c5ea7554e 100644 --- a/www/js/metrics/metHelper.ts +++ b/www/js/metrics/metHelper.ts @@ -3,10 +3,18 @@ import { standardMETs } from './metDataset'; let useCustom = false; +/** + * @function sets boolean to use custom mets + * @param {boolean} val + */ export const setUseCustomMET = function (val: boolean) { useCustom = val; }; +/** + * @function gets the METs object + * @returns {object} mets either custom or standard + */ const getMETs = function () { if (useCustom == true) { return getCustomMETs(); @@ -15,14 +23,33 @@ const getMETs = function () { } }; +/** + * @function checks number agains bounds + * @param num the number to check + * @param min lower bound + * @param max upper bound + * @returns {boolean} if number is within given bounds + */ const between = function (num, min, max) { return num >= min && num <= max; }; +/** + * @function converts meters per second to miles per hour + * @param mps meters per second speed + * @returns speed in miles per hour + */ const mpstomph = function (mps) { return 2.23694 * mps; }; +/** + * @function gets met for a given mode and speed + * @param {string} mode of travel + * @param {number} speed of travel in meters per second + * @param {number} defaultIfMissing default MET if mode not in METs + * @returns + */ export const getMet = function (mode, speed, defaultIfMissing) { if (mode == 'ON_FOOT') { console.log("getMet() converted 'ON_FOOT' to 'WALKING'"); From d3e6af2f7bd999afd19f7790b3d90b1c5893ed1c Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 10 Nov 2023 00:38:07 -0500 Subject: [PATCH 384/850] fix en.json -> "questions" Something got jumbled when pulling in upstream changes + resolving conflicts --- www/i18n/en.json | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/www/i18n/en.json b/www/i18n/en.json index 4fa7447f3..aa41988f3 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -458,15 +458,9 @@ "destroy-data-pt1": "If you would like to have your data destroyed, please contact K. Shankari ", "destroy-data-pt2": " requesting deletion. You must include your token in the request for deletion. Because we do not connect your identity with your token, we cannot delete your information without obtaining the token as part of the deletion request. We will then destroy all data associated with that deletion request, both in the online and archived datasets." }, - "errors": { - "registration-check-token": "User registration error. Please check your token and try again.", - "not-registered-cant-contact": "User is not registered, so the server cannot be contacted.", - "while-loading-pipeline-range": "Error while loading pipeline range", - "while-populating-composite": "Error while populating composite trips", - "while-loading-another-week": "Error while loading travel of {{when}} week", - "while-loading-specific-week": "Error while loading travel for the week of {{day}}", - "while-log-messages": "While getting messages from the log ", - "while-max-index": "While getting max index " + "questions": { + "header": "Questions", + "for-questions": "If you have any questions about the data collection goals and results, please contact the primary point of contact for the study, {{program_admin_contact}}. If you have any technical questions about app operation, please contact NREL’s K. Shankari (k.shankari@nrel.gov)." }, "consent": { "header": "Consent", From 69b18cb557c1d37e409bfe817c7b8a57825dfe61 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 10 Nov 2023 00:53:48 -0500 Subject: [PATCH 385/850] remove duplicate 'verifiability' check This was also likely due to bad merging from upstream changes --- www/js/survey/multilabel/MultiLabelButtonGroup.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index 797961843..a6023f1f4 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -140,18 +140,6 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { />
    )} - {trip.verifiability === 'can-verify' && ( - - - - )} dismiss()}> dismiss()}> From 9340297c6d94d4edaf7cba89aac9a8ef0a9ed23b Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 10 Nov 2023 09:15:55 -0700 Subject: [PATCH 386/850] working to debug dashboard --- www/js/metrics/CustomMetricsHelper.ts | 2 +- www/js/metrics/footprintHelper.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/www/js/metrics/CustomMetricsHelper.ts b/www/js/metrics/CustomMetricsHelper.ts index eab094062..5a70933f8 100644 --- a/www/js/metrics/CustomMetricsHelper.ts +++ b/www/js/metrics/CustomMetricsHelper.ts @@ -99,8 +99,8 @@ const populateCustomFootprints = function () { * @param newConfig the app config file */ export const initCustomDatasetHelper = async function (newConfig) { - newConfig = await getConfig(); try { + logDebug('initializing custom datasets with config' + newConfig); getLabelOptions(newConfig).then((inputParams) => { console.log('Input params = ', inputParams); _inputParams = inputParams; diff --git a/www/js/metrics/footprintHelper.ts b/www/js/metrics/footprintHelper.ts index a7b7f74ce..fd4ef8122 100644 --- a/www/js/metrics/footprintHelper.ts +++ b/www/js/metrics/footprintHelper.ts @@ -1,4 +1,4 @@ -import { displayErrorMsg } from '../plugin/logger'; +import { displayErrorMsg, logDebug } from '../plugin/logger'; import { getCustomFootprint } from './CustomMetricsHelper'; //variables for the highest footprint in the set and if using custom @@ -54,6 +54,7 @@ const getFootprint = function () { */ export const getFootprintForMetrics = function (userMetrics, defaultIfMissing = 0) { var footprint = getFootprint(); + logDebug('getting footprint for ' + userMetrics + ' with ' + footprint); var result = 0; for (var i in userMetrics) { var mode = userMetrics[i].key; From 25f7adf8aeb4b5cf0caed01adc1cadf2ab1de0fa Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Fri, 10 Nov 2023 08:33:55 -0800 Subject: [PATCH 387/850] Ran Prettier after merge --- www/js/controllers.js | 1 - www/js/types/diaryTypes.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/www/js/controllers.js b/www/js/controllers.js index 57b6eef8f..e5ab2749e 100644 --- a/www/js/controllers.js +++ b/www/js/controllers.js @@ -71,4 +71,3 @@ angular ); console.log('SplashCtrl invoke finished'); }); - diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 6260c21ea..41b92e0af 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -1,4 +1,3 @@ - import { LocalDt, ServerData } from './serverData'; export type UserInput = ServerData; From acc80223cb1115ab60235b8a22d1ecef0800fe85 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 10 Nov 2023 09:41:23 -0700 Subject: [PATCH 388/850] lift dataset initialization dataset was not initialized in time from metricsTab, lifting the call up to App fixed this issue --- www/js/App.tsx | 2 ++ www/js/metrics/MetricsTab.tsx | 10 ---------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/www/js/App.tsx b/www/js/App.tsx index a955b032d..77bf42463 100644 --- a/www/js/App.tsx +++ b/www/js/App.tsx @@ -19,6 +19,7 @@ import { initPushNotify } from './splash/pushNotifySettings'; import { initStoreDeviceSettings } from './splash/storeDeviceSettings'; import { initRemoteNotifyHandler } from './splash/remoteNotifyHandler'; import { withErrorBoundary } from './plugin/ErrorBoundary'; +import { initCustomDatasetHelper } from './metrics/CustomMetricsHelper'; const defaultRoutes = (t) => [ { @@ -77,6 +78,7 @@ const App = () => { initPushNotify(); initStoreDeviceSettings(); initRemoteNotifyHandler(); + initCustomDatasetHelper(appConfig); }, [appConfig]); const appContextValue = { diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 67670bf0c..03c6737a9 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useState, useMemo } from 'react'; -import { getAngularService } from '../angular-react-helper'; import { View, ScrollView, useWindowDimensions } from 'react-native'; import { Appbar } from 'react-native-paper'; import NavBarButton from '../components/NavBarButton'; @@ -17,8 +16,6 @@ import DailyActiveMinutesCard from './DailyActiveMinutesCard'; import CarbonTextCard from './CarbonTextCard'; import ActiveMinutesTableCard from './ActiveMinutesTableCard'; import { getAggregateData, getMetrics } from '../commHelper'; -import useAppConfig from '../useAppConfig'; -import { initCustomDatasetHelper } from './CustomMetricsHelper'; import { displayError, logDebug } from '../plugin/logger'; export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; @@ -44,7 +41,6 @@ function getLastTwoWeeksDtRange() { const MetricsTab = () => { const { t } = useTranslation(); - const appConfig = useAppConfig(); const { getFormattedSpeed, speedSuffix, getFormattedDistance, distanceSuffix } = useImperialConfig(); @@ -57,12 +53,6 @@ const MetricsTab = () => { loadMetricsForPopulation('aggregate', dateRange); }, [dateRange]); - //initialize once config is populated - useEffect(() => { - if (!appConfig) return; - initCustomDatasetHelper(appConfig); - }, [appConfig]); - async function loadMetricsForPopulation(population: 'user' | 'aggregate', dateRange: DateTime[]) { try { logDebug(`MetricsTab: fetching metrics for population ${population}' From e230420fa5c816a4eb7da359946312a93fc82a3c Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 10 Nov 2023 10:41:11 -0700 Subject: [PATCH 389/850] prettify merge conflicted file --- www/js/survey/enketo/enketo-add-note-button.js | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/www/js/survey/enketo/enketo-add-note-button.js b/www/js/survey/enketo/enketo-add-note-button.js index 2d8730f6f..8dc2e26e4 100644 --- a/www/js/survey/enketo/enketo-add-note-button.js +++ b/www/js/survey/enketo/enketo-add-note-button.js @@ -7,8 +7,8 @@ import { filterByNameAndVersion } from './enketoHelper'; import { getAdditionsForTimelineEntry, getUniqueEntries } from '../inputMatcher'; angular - .module('emission.survey.enketo.add-note-button', ['emission.services',]) - .factory('EnketoNotesButtonService', function ( Logger, $timeout) { + .module('emission.survey.enketo.add-note-button', ['emission.services']) + .factory('EnketoNotesButtonService', function (Logger, $timeout) { var enbs = {}; console.log('Creating EnketoNotesButtonService'); enbs.SINGLE_KEY = 'NOTES'; @@ -33,13 +33,9 @@ angular * Embed 'inputType' to the timelineEntry. */ enbs.extractResult = function (results) { - const resultsPromises = [ - filterByNameAndVersion(enbs.timelineEntrySurveyName, results), - ]; + const resultsPromises = [filterByNameAndVersion(enbs.timelineEntrySurveyName, results)]; if (enbs.timelineEntrySurveyName != enbs.placeSurveyName) { - resultsPromises.push( - filterByNameAndVersion(enbs.placeSurveyName, results), - ); + resultsPromises.push(filterByNameAndVersion(enbs.placeSurveyName, results)); } return Promise.all(resultsPromises); }; From fa95f975e8808398bff0a57d84f46a9963f00f05 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Fri, 10 Nov 2023 09:41:56 -0800 Subject: [PATCH 390/850] Fixed imports --- www/__tests__/storeDeviceSettings.test.ts | 2 +- www/js/splash/pushNotifySettings.ts | 2 +- www/js/splash/storeDeviceSettings.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/www/__tests__/storeDeviceSettings.test.ts b/www/__tests__/storeDeviceSettings.test.ts index 3cd6f8319..4bccbc0af 100644 --- a/www/__tests__/storeDeviceSettings.test.ts +++ b/www/__tests__/storeDeviceSettings.test.ts @@ -1,6 +1,6 @@ import { readConsentState, markConsented } from '../js/splash/startprefs'; import { storageClear } from '../js/plugin/storage'; -import { getUser } from '../js/commHelper'; +import { getUser } from '../js/services/commHelper'; import { initStoreDeviceSettings, teardownDeviceSettings } from '../js/splash/storeDeviceSettings'; import { mockBEMDataCollection, diff --git a/www/js/splash/pushNotifySettings.ts b/www/js/splash/pushNotifySettings.ts index f3eb2c029..42fe05c8f 100644 --- a/www/js/splash/pushNotifySettings.ts +++ b/www/js/splash/pushNotifySettings.ts @@ -13,7 +13,7 @@ * notification handling gets more complex, we should consider decoupling it as well. */ -import { updateUser } from '../commHelper'; +import { updateUser } from '../services/commHelper'; import { logDebug, displayError } from '../plugin/logger'; import { publish, subscribe, EVENTS } from '../customEventHandler'; import { isConsented, readConsentState } from './startprefs'; diff --git a/www/js/splash/storeDeviceSettings.ts b/www/js/splash/storeDeviceSettings.ts index 79a34f930..2a4a646b9 100644 --- a/www/js/splash/storeDeviceSettings.ts +++ b/www/js/splash/storeDeviceSettings.ts @@ -1,4 +1,4 @@ -import { updateUser } from '../commHelper'; +import { updateUser } from '../services/commHelper'; import { isConsented, readConsentState } from './startprefs'; import i18next from 'i18next'; import { displayError, logDebug } from '../plugin/logger'; From da4947d7c303f6dfeef865410f901ae524e0aa75 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 10 Nov 2023 10:57:47 -0700 Subject: [PATCH 391/850] add exception to transform the enketoHelper tests were failing because one of the files in the added directory was not transpiled ,so it was picking up as bad syntax --- jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jest.config.js b/jest.config.js index 743ab5a00..ef3503294 100644 --- a/jest.config.js +++ b/jest.config.js @@ -12,7 +12,7 @@ module.exports = { "^.+\\.(ts|tsx|js|jsx)$": "babel-jest" }, transformIgnorePatterns: [ - "node_modules/(?!((jest-)?react-native(-.*)?|@react-native(-community)?)/)" + "node_modules/(?!((enketo-transformer/dist/enketo-transformer/web)|(jest-)?react-native(-.*)?|@react-native(-community)?)/)", ], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], moduleDirectories: ["node_modules", "src"], From 2f75de9f4d044c999afc53a7146b72172fc2c96b Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Fri, 10 Nov 2023 11:20:39 -0700 Subject: [PATCH 392/850] Remove unnecessary imports notifScheduler.ts - Removed angular (no longer used), - React useState and useEffect (no hooks used), - and useAppConfig (pulled in as arguments in the exported functions) --- www/js/splash/notifScheduler.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts index 0bc8bd747..326e9fe86 100644 --- a/www/js/splash/notifScheduler.ts +++ b/www/js/splash/notifScheduler.ts @@ -1,6 +1,3 @@ -import angular from 'angular'; -import React, { useEffect, useState } from 'react'; -import useAppConfig from '../useAppConfig'; import { addStatReading, statKeys } from '../plugin/clientStats'; import { getUser, updateUser } from '../commHelper'; import { displayErrorMsg, logDebug } from '../plugin/logger'; From 05b287a08e66e00ee4336ad1d577dc7f62d68ab0 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Fri, 10 Nov 2023 11:11:50 -0800 Subject: [PATCH 393/850] index.html fix --- www/index.html | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/www/index.html b/www/index.html index b46904cca..44fcb5bbf 100644 --- a/www/index.html +++ b/www/index.html @@ -1,22 +1,18 @@ - - - + + + - + - + -
    +
    - + \ No newline at end of file From 245982159acb2ee4f6410d027b8acfe2a73ff9c2 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 10 Nov 2023 14:19:56 -0700 Subject: [PATCH 394/850] lingering merge conflict this file does not exist in this branch! --- www/js/diary.js | 1 - 1 file changed, 1 deletion(-) diff --git a/www/js/diary.js b/www/js/diary.js index c580ad8f2..7e64e555f 100644 --- a/www/js/diary.js +++ b/www/js/diary.js @@ -5,7 +5,6 @@ angular .module('emission.main.diary', [ 'emission.main.diary.services', 'emission.plugin.logger', - 'emission.survey.enketo.answer', ]) .config(function ($stateProvider) { From 0890e23f6d82e8fd878fef9d74633889f5650fc0 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 10 Nov 2023 14:25:41 -0700 Subject: [PATCH 395/850] re-run prettier after merging --- www/js/diary.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/www/js/diary.js b/www/js/diary.js index 7e64e555f..08909886d 100644 --- a/www/js/diary.js +++ b/www/js/diary.js @@ -2,10 +2,7 @@ import angular from 'angular'; import LabelTab from './diary/LabelTab'; angular - .module('emission.main.diary', [ - 'emission.main.diary.services', - 'emission.plugin.logger', - ]) + .module('emission.main.diary', ['emission.main.diary.services', 'emission.plugin.logger']) .config(function ($stateProvider) { $stateProvider.state('root.main.inf_scroll', { From fa22ee4b7621430ef5b4399aafd3578fa9ce0733 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 10 Nov 2023 17:45:19 -0500 Subject: [PATCH 396/850] remove use of `rootScope` in getAggregateData This function used the Angular `rootScope` to get 2 of the server conn setting: connectUrl and aggregateAuth. These are both derived from dynamic config file in the 'server' section. So we can useAppConfig() in MetricsTab and pass 'appConfig.server' in as a parameter 'serverConnConfig'. Also, - cleaned up some syntax of getAggregateData to make it more readable - added type definitions for the server conn config One thing to note is that I don't think any active study or program uses "no_auth". But it still looks like it is an option so I am including it. --- www/js/commHelper.ts | 29 +++++++++++------------------ www/js/metrics/MetricsTab.tsx | 16 ++++++++++++---- www/js/types/appConfigTypes.ts | 12 ++++++++++++ 3 files changed, 35 insertions(+), 22 deletions(-) create mode 100644 www/js/types/appConfigTypes.ts diff --git a/www/js/commHelper.ts b/www/js/commHelper.ts index 5f144888b..f69f42331 100644 --- a/www/js/commHelper.ts +++ b/www/js/commHelper.ts @@ -1,5 +1,6 @@ import { DateTime } from 'luxon'; import { logDebug } from './plugin/logger'; +import { ServerConnConfig } from './types/appConfigTypes'; /** * @param url URL endpoint for the request @@ -129,20 +130,17 @@ export function getMetrics(timeType: 'timestamp' | 'local_date', metricsQuery) { }); } -export function getAggregateData(path: string, data: any) { +export function getAggregateData(path: string, query, serverConnConfig: ServerConnConfig) { return new Promise((rs, rj) => { - const fullUrl = `${window['$rootScope'].connectUrl}/${path}`; - data['aggregate'] = true; + const fullUrl = `${serverConnConfig.connectUrl}/${path}`; + query['aggregate'] = true; - if (window['$rootScope'].aggregateAuth === 'no_auth') { - logDebug( - `getting aggregate data without user authentication from ${fullUrl} with arguments ${JSON.stringify( - data, - )}`, - ); + if (serverConnConfig.aggregate_call_auth == 'no_auth') { + logDebug(`getting aggregate data without user authentication from ${fullUrl} + with arguments ${JSON.stringify(query)}`); const options = { method: 'post', - data: data, + data: query, responseType: 'json', }; window['cordova'].plugin.http.sendRequest( @@ -156,14 +154,9 @@ export function getAggregateData(path: string, data: any) { }, ); } else { - logDebug( - `getting aggregate data with user authentication from ${fullUrl} with arguments ${JSON.stringify( - data, - )}`, - ); - const msgFiller = (message) => { - return Object.assign(message, data); - }; + logDebug(`getting aggregate data with user authentication from ${fullUrl} + with arguments ${JSON.stringify(query)}`); + const msgFiller = (message) => Object.assign(message, query); window['cordova'].plugins.BEMServerComm.pushGetJSON(`/${path}`, msgFiller, rs, rj); } }).catch((error) => { diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 03c6737a9..7a1636b61 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -17,10 +17,16 @@ import CarbonTextCard from './CarbonTextCard'; import ActiveMinutesTableCard from './ActiveMinutesTableCard'; import { getAggregateData, getMetrics } from '../commHelper'; import { displayError, logDebug } from '../plugin/logger'; +import useAppConfig from '../useAppConfig'; +import { ServerConnConfig } from '../types/appConfigTypes'; export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; -async function fetchMetricsFromServer(type: 'user' | 'aggregate', dateRange: DateTime[]) { +async function fetchMetricsFromServer( + type: 'user' | 'aggregate', + dateRange: DateTime[], + serverConnConfig: ServerConnConfig, +) { const query = { freq: 'D', start_time: dateRange[0].toSeconds(), @@ -29,7 +35,7 @@ async function fetchMetricsFromServer(type: 'user' | 'aggregate', dateRange: Dat is_return_aggregate: type == 'aggregate', }; if (type == 'user') return getMetrics('timestamp', query); - return getAggregateData('result/metrics/timestamp', query); + return getAggregateData('result/metrics/timestamp', query, serverConnConfig); } function getLastTwoWeeksDtRange() { @@ -40,6 +46,7 @@ function getLastTwoWeeksDtRange() { } const MetricsTab = () => { + const appConfig = useAppConfig(); const { t } = useTranslation(); const { getFormattedSpeed, speedSuffix, getFormattedDistance, distanceSuffix } = useImperialConfig(); @@ -49,15 +56,16 @@ const MetricsTab = () => { const [userMetrics, setUserMetrics] = useState(null); useEffect(() => { + if (!appConfig?.server) return; loadMetricsForPopulation('user', dateRange); loadMetricsForPopulation('aggregate', dateRange); - }, [dateRange]); + }, [dateRange, appConfig?.server]); async function loadMetricsForPopulation(population: 'user' | 'aggregate', dateRange: DateTime[]) { try { logDebug(`MetricsTab: fetching metrics for population ${population}' in date range ${JSON.stringify(dateRange)}`); - const serverResponse = await fetchMetricsFromServer(population, dateRange); + const serverResponse = await fetchMetricsFromServer(population, dateRange, appConfig.server); logDebug('MetricsTab: received metrics: ' + JSON.stringify(serverResponse)); const metrics = {}; const dataKey = population == 'user' ? 'user_metrics' : 'aggregate_metrics'; diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts new file mode 100644 index 000000000..3d62f51cf --- /dev/null +++ b/www/js/types/appConfigTypes.ts @@ -0,0 +1,12 @@ +// WIP: type definitions for the 'dynamic config' spec +// examples of configs: https://github.com/e-mission/nrel-openpath-deploy-configs/tree/main/configs + +export type AppConfig = { + server: ServerConnConfig; + [k: string]: any; // TODO fill in all the other fields +}; + +export type ServerConnConfig = { + connectUrl: `https://${string}`; + aggregate_call_auth: 'no_auth' | 'user_only' | 'never'; +}; From 7e8f07bb06daa86a6238a33d6da0cfaa05ce1945 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Fri, 10 Nov 2023 16:29:42 -0800 Subject: [PATCH 397/850] Merge cleanup - Fixed imports --- www/__tests__/confirmHelper.test.ts | 2 +- www/js/metrics/MetricsTab.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/www/__tests__/confirmHelper.test.ts b/www/__tests__/confirmHelper.test.ts index 17c23a972..6dd94b808 100644 --- a/www/__tests__/confirmHelper.test.ts +++ b/www/__tests__/confirmHelper.test.ts @@ -1,5 +1,5 @@ import { mockLogger } from '../__mocks__/globalMocks'; -import * as CommHelper from '../js/commHelper'; +import * as CommHelper from '../js/services/commHelper'; import { baseLabelInputDetails, getLabelInputDetails, diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 2ac62daba..bdc426e62 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -16,8 +16,8 @@ import Carousel from '../components/Carousel'; import DailyActiveMinutesCard from './DailyActiveMinutesCard'; import CarbonTextCard from './CarbonTextCard'; import ActiveMinutesTableCard from './ActiveMinutesTableCard'; -import { getAggregateData, getMetrics } from '../service/commHelper'; -import { displayError, logDebug } from '../plugin/logger'; +import { getAggregateData, getMetrics } from '../services/commHelper'; +import { displayError, logDebug, logWarn } from '../plugin/logger'; export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; @@ -72,7 +72,7 @@ const MetricsTab = () => { setAggMetrics(metrics as MetricsData); } } catch (e) { - displayError(e, t('errors.while-loading-metrics')); + logWarn(e + t('errors.while-loading-metrics')); // replace with displayErr } } From 7df44e9c3f6892c572635f386ce32c7e87246703 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Sun, 12 Nov 2023 22:19:04 -0800 Subject: [PATCH 398/850] cleanup enketo after merge - Restore the use of 'filterByNameAndVersion' for trip confim surveys (not currently used for anything else). If a survey response is no longer compatible according to the appConfig, it should be filtered out here. - Make the terminology unambiguous such that "answer" means how the user answered a particular question within the survey, and "response" means the overall survey response - Clean up / clarify comments - Remove unused import statements --- www/__tests__/enketoHelper.test.ts | 32 ++++++++-------- www/js/diary/timelineHelper.ts | 7 ++-- www/js/survey/enketo/EnketoModal.tsx | 2 +- www/js/survey/enketo/enketoHelper.ts | 55 ++++++++++++++-------------- 4 files changed, 47 insertions(+), 49 deletions(-) diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index a2e5514b1..113e7f995 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -186,15 +186,15 @@ it('loads the previous response to a given survey', () => { }); /** - * filterByNameAndVersion filter the survey answers by survey name and their version. + * filterByNameAndVersion filter the survey responses by survey name and their version. * The version for filtering is specified in enketo survey `compatibleWith` config. - * The stored survey answer version must be greater than or equal to `compatibleWith` to be included. + * The stored survey response version must be greater than or equal to `compatibleWith` to be included. */ -it('filters the survey answers by their name and version', () => { - //no answers -> no filtered answers +it('filters the survey responses by their name and version', () => { + //no response -> no filtered responses expect(filterByNameAndVersion('TimeUseSurvey', [])).resolves.toStrictEqual([]); - const answer = [ + const response = [ { data: { label: 'Activity', //display label (this value is use for displaying on the button) @@ -202,17 +202,17 @@ it('filters the survey answers by their name and version', () => { fmt_time: '12:36', //the formatted timestamp at which the survey was filled out name: 'TimeUseSurvey', //survey name version: '1', //survey version - xmlResponse: '', //survey answer XML string - jsonDocResponse: 'this is my json object', //survey answer JSON object + xmlResponse: '', //survey response XML string + jsonDocResponse: 'this is my json object', //survey response JSON object }, metadata: {}, }, ]; - //one answer -> that answer - expect(filterByNameAndVersion('TimeUseSurvey', answer)).resolves.toStrictEqual(answer); + //one response -> that response + expect(filterByNameAndVersion('TimeUseSurvey', response)).resolves.toStrictEqual(response); - const answers = [ + const responses = [ { data: { label: 'Activity', //display label (this value is use for displaying on the button) @@ -220,8 +220,8 @@ it('filters the survey answers by their name and version', () => { fmt_time: '12:36', //the formatted timestamp at which the survey was filled out name: 'TimeUseSurvey', //survey name version: '1', //survey version - xmlResponse: '', //survey answer XML string - jsonDocResponse: 'this is my json object', //survey answer JSON object + xmlResponse: '', //survey response XML string + jsonDocResponse: 'this is my json object', //survey response JSON object }, metadata: {}, }, @@ -232,13 +232,13 @@ it('filters the survey answers by their name and version', () => { fmt_time: '12:36', //the formatted timestamp at which the survey was filled out name: 'OtherSurvey', //survey name version: '1', //survey version - xmlResponse: '', //survey answer XML string - jsonDocResponse: 'this is my json object', //survey answer JSON object + xmlResponse: '', //survey response XML string + jsonDocResponse: 'this is my json object', //survey response JSON object }, metadata: {}, }, ]; - //several answers -> only the one that has a name match - expect(filterByNameAndVersion('TimeUseSurvey', answers)).resolves.toStrictEqual(answer); + //several responses -> only the one that has a name match + expect(filterByNameAndVersion('TimeUseSurvey', responses)).resolves.toStrictEqual(response); }); diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 174974a9d..19c885cc1 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -1,11 +1,10 @@ import moment from 'moment'; -import { displayError, logDebug } from '../plugin/logger'; +import { logDebug } from '../plugin/logger'; import { getBaseModeByKey, getBaseModeByValue } from './diaryHelper'; import { getUnifiedDataForInterval } from '../services/unifiedDataLoader'; -import i18next from 'i18next'; import { UserInputEntry } from '../types/diaryTypes'; import { getLabelInputDetails, getLabelInputs } from '../survey/multilabel/confirmHelper'; -import { getNotDeletedCandidates, getUniqueEntries } from '../survey/inputMatcher'; +import { filterByNameAndVersion } from '../survey/enketo/enketoHelper'; const cachedGeojsons = new Map(); /** @@ -92,7 +91,7 @@ function updateUnprocessedInputs(labelsPromises, notesPromises, appConfig) { // fill in the unprocessedLabels object with the labels we just read labelResults.forEach((r, i) => { if (appConfig.survey_info?.['trip-labels'] == 'ENKETO') { - unprocessedLabels['SURVEY'] = r; + unprocessedLabels['SURVEY'] = filterByNameAndVersion('TripConfirmSurvey', r); } else { unprocessedLabels[getLabelInputs()[i]] = r; } diff --git a/www/js/survey/enketo/EnketoModal.tsx b/www/js/survey/enketo/EnketoModal.tsx index 9267b9808..1d169ee9b 100644 --- a/www/js/survey/enketo/EnketoModal.tsx +++ b/www/js/survey/enketo/EnketoModal.tsx @@ -5,7 +5,7 @@ import { ModalProps } from 'react-native-paper'; import useAppConfig from '../../useAppConfig'; import { useTranslation } from 'react-i18next'; import { SurveyOptions, fetchSurvey, getInstanceStr, saveResponse } from './enketoHelper'; -import { displayError, displayErrorMsg, logDebug } from '../../plugin/logger'; +import { displayError, displayErrorMsg } from '../../plugin/logger'; type Props = Omit & { surveyName: string; diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index 9defa77f5..379120373 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -1,4 +1,3 @@ -import { getAngularService } from '../../angular-react-helper'; import { Form } from 'enketo-core'; import { transform } from 'enketo-transformer/web'; import { XMLParser } from 'fast-xml-parser'; @@ -7,7 +6,7 @@ import MessageFormat from '@messageformat/core'; import { logDebug, logInfo } from '../../plugin/logger'; import { getConfig } from '../../config/dynamicConfig'; import { DateTime } from 'luxon'; -import { fetchUrlCached } from '../../commHelper'; +import { fetchUrlCached } from '../../services/commHelper'; import { getUnifiedDataForInterval } from '../../services/unifiedDataLoader'; export type PrefillFields = { [key: string]: string }; @@ -20,18 +19,18 @@ export type SurveyOptions = { dataKey?: string; }; -type EnketoAnswerData = { +type EnketoResponseData = { label: string; //display label (this value is use for displaying on the button) ts: string; //the timestamp at which the survey was filled out (in seconds) fmt_time: string; //the formatted timestamp at which the survey was filled out name: string; //survey name version: string; //survey version - xmlResponse: string; //survey answer XML string - jsonDocResponse: string; //survey answer JSON object + xmlResponse: string; //survey response as XML string + jsonDocResponse: string; //survey response as JSON object }; -type EnketoAnswer = { - data: EnketoAnswerData; //answer data +type EnketoResponse = { + data: EnketoResponseData; //survey response data metadata: any; }; @@ -79,9 +78,10 @@ const LABEL_FUNCTIONS = { }; /** - * _getAnswerByTagName lookup for the survey answer by tag name form the given XML document. - * @param {XMLDocument} xmlDoc survey answer object - * @param {string} tagName tag name + * _getAnswerByTagName look up how a question was answered, given the survey response + * and the tag name of the question + * @param {XMLDocument} xmlDoc survey response as XML object + * @param {string} tagName tag name of the question * @returns {string} answer string. If not found, return "\" */ function _getAnswerByTagName(xmlDoc: XMLDocument, tagName: string) { @@ -110,26 +110,24 @@ export function _lazyLoadConfig() { } /** - * filterByNameAndVersion filter the survey answers by survey name and their version. + * filterByNameAndVersion filter the survey responses by survey name and their version. * The version for filtering is specified in enketo survey `compatibleWith` config. - * The stored survey answer version must be greater than or equal to `compatibleWith` to be included. + * The survey version of the response must be greater than or equal to `compatibleWith` to be included. * @param {string} name survey name (defined in enketo survey config) - * @param {EnketoAnswer[]} answers survey answers - * (usually retrieved by calling UnifiedDataLoader.getUnifiedMessagesForInterval('manual/survey_response', tq)) method. - * @return {Promise} filtered survey answers + * @param {EnketoResponse[]} responses An array of previously recorded responses to Enketo surveys + * (presumably having been retrieved from unifiedDataLoader) + * @return {Promise} filtered survey responses */ -export function filterByNameAndVersion(name: string, answers: EnketoAnswer[]) { +export function filterByNameAndVersion(name: string, responses: EnketoResponse[]) { return _lazyLoadConfig().then((config) => - answers.filter( - (answer) => answer.data.name === name && answer.data.version >= config[name].compatibleWith, - ), + responses.filter((r) => r.data.name === name && r.data.version >= config[name].compatibleWith), ); } /** - * resolve answer label for the survey + * resolve a label for the survey response * @param {string} name survey name - * @param {XMLDocument} xmlDoc survey answer object + * @param {XMLDocument} xmlDoc survey response as XML object * @returns {Promise} label string Promise */ export async function resolveLabel(name: string, xmlDoc: XMLDocument) { @@ -172,7 +170,7 @@ export function getInstanceStr(xmlModel: string, opts: SurveyOptions): string | /** * resolve timestamps label from the survey response - * @param {XMLDocument} xmlDoc survey answer object + * @param {XMLDocument} xmlDoc survey response as XML object * @param {object} trip trip object * @returns {object} object with `start_ts` and `end_ts` * - null if no timestamps are resolved @@ -269,10 +267,11 @@ export function saveResponse(surveyName: string, enketoForm: Form, appConfig, op .then((data) => data); } -const _getMostRecent = (answers) => { - answers.sort((a, b) => a.metadata.write_ts < b.metadata.write_ts); - console.log('first answer is ', answers[0], ' last answer is ', answers[answers.length - 1]); - return answers[0]; +const _getMostRecent = (responses) => { + responses.sort((a, b) => a.metadata.write_ts < b.metadata.write_ts); + logDebug(`_getMostRecent: first response is ${responses[0]}; + last response is ${responses.slice(-1)[0]}`); + return responses[0]; }; /* @@ -286,8 +285,8 @@ export function loadPreviousResponseForSurvey(dataKey: string) { const tq = window['cordova'].plugins.BEMUserCache.getAllTimeQuery(); logDebug('loadPreviousResponseForSurvey: dataKey = ' + dataKey + '; tq = ' + tq); const getMethod = window['cordova'].plugins.BEMUserCache.getSensorDataForInterval; - return getUnifiedDataForInterval(dataKey, tq, getMethod).then((answers) => - _getMostRecent(answers), + return getUnifiedDataForInterval(dataKey, tq, getMethod).then((responses) => + _getMostRecent(responses), ); } From c39b3684008de94882886c218a1a5e9852119462 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan Date: Mon, 13 Nov 2023 05:14:06 -0700 Subject: [PATCH 399/850] changes made to emailService and other files as needed --- www/js/control/LogPage.tsx | 3 +- www/js/control/ProfileSettings.jsx | 6 +- www/js/control/SensedPage.tsx | 4 +- www/js/control/emailService.ts | 105 +++++++++++++++++++++++++++++ www/json/emailConfig.json.sample | 3 - 5 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 www/js/control/emailService.ts delete mode 100644 www/json/emailConfig.json.sample diff --git a/www/js/control/LogPage.tsx b/www/js/control/LogPage.tsx index 3fcce72ac..fba7a72d5 100644 --- a/www/js/control/LogPage.tsx +++ b/www/js/control/LogPage.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import { FlashList } from '@shopify/flash-list'; import moment from 'moment'; import AlertBar from './AlertBar'; +import { sendEmail } from './emailService'; type loadStats = { currentStart: number; gotMaxIndex: boolean; reachedEnd: boolean }; @@ -96,7 +97,7 @@ const LogPage = ({ pageVis, setPageVis }) => { }; const emailLog = function () { - EmailHelper.sendEmail('loggerDB'); + sendEmail('loggerDB'); }; const separator = () => ; diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index b081e642a..9296b74c5 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -13,7 +13,7 @@ import useAppConfig from '../useAppConfig'; import AlertBar from './AlertBar'; import DataDatePicker from './DataDatePicker'; import PrivacyPolicyModal from './PrivacyPolicyModal'; - +import { sendEmail } from './emailService'; import { uploadFile } from './uploadService'; import ActionMenu from '../components/ActionMenu'; import SensedPage from './SensedPage'; @@ -44,7 +44,7 @@ const ProfileSettings = () => { //angular services needed const CarbonDatasetHelper = getAngularService('CarbonDatasetHelper'); - const EmailHelper = getAngularService('EmailHelper'); + //const EmailHelper = getAngularService('EmailHelper'); const NotificationScheduler = getAngularService('NotificationScheduler'); const ControlHelper = getAngularService('ControlHelper'); @@ -256,7 +256,7 @@ const ProfileSettings = () => { const emailLog = function () { // Passing true, we want to send logs - EmailHelper.sendEmail('loggerDB'); + sendEmail('loggerDB'); }; async function updatePrefReminderTime(storeNewVal = true, newTime) { diff --git a/www/js/control/SensedPage.tsx b/www/js/control/SensedPage.tsx index 4d51b5308..a55188469 100644 --- a/www/js/control/SensedPage.tsx +++ b/www/js/control/SensedPage.tsx @@ -5,7 +5,7 @@ import { getAngularService } from '../angular-react-helper'; import { useTranslation } from 'react-i18next'; import { FlashList } from '@shopify/flash-list'; import moment from 'moment'; -import EmailHelper from './emailService'; +import { sendEmail } from './emailService'; const SensedPage = ({ pageVis, setPageVis }) => { const { t } = useTranslation(); @@ -17,7 +17,7 @@ const SensedPage = ({ pageVis, setPageVis }) => { const [entries, setEntries] = useState([]); const emailCache = function () { - EmailHelper.sendEmail('userCacheDB'); + sendEmail('userCacheDB'); }; async function updateEntries() { diff --git a/www/js/control/emailService.ts b/www/js/control/emailService.ts new file mode 100644 index 000000000..72ddfaf82 --- /dev/null +++ b/www/js/control/emailService.ts @@ -0,0 +1,105 @@ +import React, { useEffect, useState } from 'react'; +import i18next from "i18next"; +import { logInfo, logDebug, displayError } from "../plugin/logger"; +//import 'cordova-plugin-email-composer'; + + +// Separated functions here + +/* +function getEmailConfig() { + return new Promise(async (resolve, reject) => { + try { + logInfo("About to get email config"); + let url = "json/emailConfig.json"; + let response = await fetch(url); + let emailConfigData = await response.json(); + logDebug("emailConfigString = " + JSON.stringify(emailConfigData.address)); + resolve(emailConfigData.address); + } catch (err) { + try { + let url = "json/emailConfig.json.sample"; + let response = await fetch(url); + let emailConfigData = await response.json(); + logDebug("default emailConfigString = " + JSON.stringify(emailConfigData.address)); + resolve(emailConfigData.address); + } catch (err) { + displayError(err, "Error while reading default email config"); + reject(err); + } + } + }); +} +*/ + +async function hasAccount(): Promise { + return new Promise((resolve, reject) => { + window['cordova'].plugins['email'].hasAccount(hasAct => { + resolve(hasAct); + }); + }); +} + +export async function sendEmail(database: string) { + let parentDir = "unknown"; + + if (window['ionic'].Platform.isIOS() && !(await hasAccount())) { //check in iOS for configuration of email thingy + alert(i18next.t('email-service.email-account-not-configured')); + return; + } + + if (window['ionic'].Platform.isAndroid()) { + parentDir = "app://databases"; + } + + if (window['ionic'].Platform.isIOS()) { + alert(i18next.t('email-service.email-account-mail-app')); + console.log(window['cordova'].file.dataDirectory); + parentDir = window['cordova'].file.dataDirectory + "../LocalDatabase"; + } + + if (parentDir === 'unknown') { + alert('parentDir unexpectedly = ' + parentDir + '!'); + } + + logInfo('Going to email ' + database); + parentDir = parentDir + '/' + database; + + alert(i18next.t('email-service.going-to-email', { parentDir: parentDir })); + + let emailConfig = `nseptank@nrel.gov`; //remember to change it to Shankari's + + let emailData = { + to: emailConfig, + attachments: [parentDir], + subject: i18next.t('email-service.email-log.subject-logs'), + body: i18next.t('email-service.email-log.body-please-fill-in-what-is-wrong') + }; + + window['cordova'].plugins['email'].open(emailData, () => { + logInfo('Email app closed while sending, ' + JSON.stringify(emailData) + ' not sure if we should do anything'); + }); +} + +/* +function EmailHelper() { + const [emailConfig, setEmailConfig]; + + useEffect(() => { + }, [emailConfig]); + + + +// My export component here + return ( +
    + +
    + ); + +} + +export default EmailHelper; //maybe this is a good option qmark - I think so? +*/ \ No newline at end of file diff --git a/www/json/emailConfig.json.sample b/www/json/emailConfig.json.sample deleted file mode 100644 index b1e28e63b..000000000 --- a/www/json/emailConfig.json.sample +++ /dev/null @@ -1,3 +0,0 @@ -{ - "address": "shankari@eecs.berkeley.edu" -} \ No newline at end of file From 473893f6e05641a2078b86915dbdb84cc85a15da Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Mon, 13 Nov 2023 09:32:13 -0700 Subject: [PATCH 400/850] Update emailService.ts - Changed email address to Shankari's --- www/js/control/emailService.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/www/js/control/emailService.ts b/www/js/control/emailService.ts index 72ddfaf82..e8dec1a9c 100644 --- a/www/js/control/emailService.ts +++ b/www/js/control/emailService.ts @@ -43,7 +43,7 @@ async function hasAccount(): Promise { export async function sendEmail(database: string) { let parentDir = "unknown"; - if (window['ionic'].Platform.isIOS() && !(await hasAccount())) { //check in iOS for configuration of email thingy + if (window['ionic'].Platform.isIOS() && !(await hasAccount())) { alert(i18next.t('email-service.email-account-not-configured')); return; } @@ -67,7 +67,7 @@ export async function sendEmail(database: string) { alert(i18next.t('email-service.going-to-email', { parentDir: parentDir })); - let emailConfig = `nseptank@nrel.gov`; //remember to change it to Shankari's + let emailConfig = `k.shankari@nrel.gov`; let emailData = { to: emailConfig, @@ -102,4 +102,4 @@ function EmailHelper() { } export default EmailHelper; //maybe this is a good option qmark - I think so? -*/ \ No newline at end of file +*/ From ab65825a380d1c892b0c378739d94d79fca75ef8 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Mon, 13 Nov 2023 09:39:51 -0700 Subject: [PATCH 401/850] Update emailService.ts Removed comments --- www/js/control/emailService.ts | 53 ---------------------------------- 1 file changed, 53 deletions(-) diff --git a/www/js/control/emailService.ts b/www/js/control/emailService.ts index e8dec1a9c..ea7f976b2 100644 --- a/www/js/control/emailService.ts +++ b/www/js/control/emailService.ts @@ -1,36 +1,6 @@ import React, { useEffect, useState } from 'react'; import i18next from "i18next"; import { logInfo, logDebug, displayError } from "../plugin/logger"; -//import 'cordova-plugin-email-composer'; - - -// Separated functions here - -/* -function getEmailConfig() { - return new Promise(async (resolve, reject) => { - try { - logInfo("About to get email config"); - let url = "json/emailConfig.json"; - let response = await fetch(url); - let emailConfigData = await response.json(); - logDebug("emailConfigString = " + JSON.stringify(emailConfigData.address)); - resolve(emailConfigData.address); - } catch (err) { - try { - let url = "json/emailConfig.json.sample"; - let response = await fetch(url); - let emailConfigData = await response.json(); - logDebug("default emailConfigString = " + JSON.stringify(emailConfigData.address)); - resolve(emailConfigData.address); - } catch (err) { - displayError(err, "Error while reading default email config"); - reject(err); - } - } - }); -} -*/ async function hasAccount(): Promise { return new Promise((resolve, reject) => { @@ -80,26 +50,3 @@ export async function sendEmail(database: string) { logInfo('Email app closed while sending, ' + JSON.stringify(emailData) + ' not sure if we should do anything'); }); } - -/* -function EmailHelper() { - const [emailConfig, setEmailConfig]; - - useEffect(() => { - }, [emailConfig]); - - - -// My export component here - return ( -
    - -
    - ); - -} - -export default EmailHelper; //maybe this is a good option qmark - I think so? -*/ From 695196e3377a0000c5df5a806de99e82fbe7611a Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Mon, 13 Nov 2023 09:40:36 -0700 Subject: [PATCH 402/850] Update SensedPage.tsx Removed comments --- www/js/control/SensedPage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/www/js/control/SensedPage.tsx b/www/js/control/SensedPage.tsx index a55188469..db1d43535 100644 --- a/www/js/control/SensedPage.tsx +++ b/www/js/control/SensedPage.tsx @@ -10,7 +10,6 @@ import { sendEmail } from './emailService'; const SensedPage = ({ pageVis, setPageVis }) => { const { t } = useTranslation(); const { colors } = useTheme(); - //const EmailHelper = getAngularService('EmailHelper'); /* Let's keep a reference to the database for convenience */ const [DB, setDB] = useState(); From 35dbdcf426f4c803cc371ee9bc71d8947f5fed64 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Mon, 13 Nov 2023 09:41:13 -0700 Subject: [PATCH 403/850] Update LogPage.tsx Removed comments --- www/js/control/LogPage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/www/js/control/LogPage.tsx b/www/js/control/LogPage.tsx index fba7a72d5..6d603f19e 100644 --- a/www/js/control/LogPage.tsx +++ b/www/js/control/LogPage.tsx @@ -13,7 +13,6 @@ type loadStats = { currentStart: number; gotMaxIndex: boolean; reachedEnd: boole const LogPage = ({ pageVis, setPageVis }) => { const { t } = useTranslation(); const { colors } = useTheme(); - //const EmailHelper = getAngularService('EmailHelper'); const [loadStats, setLoadStats] = useState(); const [entries, setEntries] = useState([]); From d04d87aa4781175f131381fcc2859534bfc5b30e Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan <69108657+niccolopaganini@users.noreply.github.com> Date: Mon, 13 Nov 2023 09:41:41 -0700 Subject: [PATCH 404/850] Update ProfileSettings.jsx Removed comments --- www/js/control/ProfileSettings.jsx | 1 - 1 file changed, 1 deletion(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 9296b74c5..f239b8b29 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -44,7 +44,6 @@ const ProfileSettings = () => { //angular services needed const CarbonDatasetHelper = getAngularService('CarbonDatasetHelper'); - //const EmailHelper = getAngularService('EmailHelper'); const NotificationScheduler = getAngularService('NotificationScheduler'); const ControlHelper = getAngularService('ControlHelper'); From 7d66e57a10d868d17d461d37f80fbeb176fdc291 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan Date: Mon, 13 Nov 2023 10:45:44 -0700 Subject: [PATCH 405/850] ran prettier --- www/js/control/emailService.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/www/js/control/emailService.ts b/www/js/control/emailService.ts index ea7f976b2..0efca1518 100644 --- a/www/js/control/emailService.ts +++ b/www/js/control/emailService.ts @@ -1,31 +1,31 @@ import React, { useEffect, useState } from 'react'; -import i18next from "i18next"; -import { logInfo, logDebug, displayError } from "../plugin/logger"; +import i18next from 'i18next'; +import { logInfo, logDebug, displayError } from '../plugin/logger'; async function hasAccount(): Promise { return new Promise((resolve, reject) => { - window['cordova'].plugins['email'].hasAccount(hasAct => { + window['cordova'].plugins['email'].hasAccount((hasAct) => { resolve(hasAct); }); }); } export async function sendEmail(database: string) { - let parentDir = "unknown"; + let parentDir = 'unknown'; - if (window['ionic'].Platform.isIOS() && !(await hasAccount())) { + if (window['ionic'].Platform.isIOS() && !(await hasAccount())) { alert(i18next.t('email-service.email-account-not-configured')); return; } if (window['ionic'].Platform.isAndroid()) { - parentDir = "app://databases"; + parentDir = 'app://databases'; } if (window['ionic'].Platform.isIOS()) { alert(i18next.t('email-service.email-account-mail-app')); console.log(window['cordova'].file.dataDirectory); - parentDir = window['cordova'].file.dataDirectory + "../LocalDatabase"; + parentDir = window['cordova'].file.dataDirectory + '../LocalDatabase'; } if (parentDir === 'unknown') { @@ -43,10 +43,14 @@ export async function sendEmail(database: string) { to: emailConfig, attachments: [parentDir], subject: i18next.t('email-service.email-log.subject-logs'), - body: i18next.t('email-service.email-log.body-please-fill-in-what-is-wrong') + body: i18next.t('email-service.email-log.body-please-fill-in-what-is-wrong'), }; window['cordova'].plugins['email'].open(emailData, () => { - logInfo('Email app closed while sending, ' + JSON.stringify(emailData) + ' not sure if we should do anything'); + logInfo( + 'Email app closed while sending, ' + + JSON.stringify(emailData) + + ' not sure if we should do anything', + ); }); } From 4499e24097e42bac9d096a690437cca7f90256da Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 13 Nov 2023 10:58:18 -0700 Subject: [PATCH 406/850] test remaining function now that Unified Data Loader has been merged, we can test this function, may benefit from more end-to-end testing later, as it relies on the usercache plugin heavily --- www/__mocks__/cordovaMocks.ts | 10 ++++++++++ www/__tests__/enketoHelper.test.ts | 4 +--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 0684fa06d..a74df6b18 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -129,6 +129,16 @@ export const mockBEMUserCache = () => { return false; } }, + getAllTimeQuery: () => { + return {key: "write_ts", startTs: 0, endTs: Date.now()/1000}; + }, + getSensorDataForInterval: (key, tq, withMetadata) => { + return new Promise((rs, rj) => + setTimeout(() => { + rs({metadata: {write_ts: "1699897723"}, data: "completed", time: "01/01/2001"}); + }, 100), + ); + }, }; window['cordova'] ||= {}; window['cordova'].plugins ||= {}; diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index 113e7f995..ae43b7bb4 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -179,10 +179,8 @@ it('gets the saved result or throws an error', () => { * Loading it on demand seems like the way to go. If we choose to experiment * with incremental updates, we may want to revisit this. */ -// export function loadPreviousResponseForSurvey(dataKey: string) { it('loads the previous response to a given survey', () => { - //not really sure if I can test this yet given that it relies on an angular service... - // loadPreviousResponseForSurvey("manual/demographic_survey"); + expect(loadPreviousResponseForSurvey("manual/demographic_survey")).resolves.toMatchObject({data: "completed", time: "01/01/2001"}); }); /** From 4a000fc4e82be569afe27e87066f5aaac172b4a2 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 13 Nov 2023 11:01:54 -0700 Subject: [PATCH 407/850] re-run prettier --- www/__mocks__/cordovaMocks.ts | 10 +++++----- www/__tests__/enketoHelper.test.ts | 5 ++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index a74df6b18..f08293a85 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -130,14 +130,14 @@ export const mockBEMUserCache = () => { } }, getAllTimeQuery: () => { - return {key: "write_ts", startTs: 0, endTs: Date.now()/1000}; + return { key: 'write_ts', startTs: 0, endTs: Date.now() / 1000 }; }, getSensorDataForInterval: (key, tq, withMetadata) => { return new Promise((rs, rj) => - setTimeout(() => { - rs({metadata: {write_ts: "1699897723"}, data: "completed", time: "01/01/2001"}); - }, 100), - ); + setTimeout(() => { + rs({ metadata: { write_ts: '1699897723' }, data: 'completed', time: '01/01/2001' }); + }, 100), + ); }, }; window['cordova'] ||= {}; diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index ae43b7bb4..a8f49b29c 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -180,7 +180,10 @@ it('gets the saved result or throws an error', () => { * with incremental updates, we may want to revisit this. */ it('loads the previous response to a given survey', () => { - expect(loadPreviousResponseForSurvey("manual/demographic_survey")).resolves.toMatchObject({data: "completed", time: "01/01/2001"}); + expect(loadPreviousResponseForSurvey('manual/demographic_survey')).resolves.toMatchObject({ + data: 'completed', + time: '01/01/2001', + }); }); /** From ebaa1e6e5cd19b76ef7b6f23c5509f99b0c845f2 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Mon, 13 Nov 2023 15:41:41 -0700 Subject: [PATCH 408/850] fix issue with filter The root of the issue I was having is that filterByNameAndVersion returns a promise, but it was being assigned to the unprocessedLabels var before the promise was fulfilled, adding the fiter.then() flow resolved this --- www/js/diary/timelineHelper.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 19c885cc1..1a2c87462 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -91,7 +91,9 @@ function updateUnprocessedInputs(labelsPromises, notesPromises, appConfig) { // fill in the unprocessedLabels object with the labels we just read labelResults.forEach((r, i) => { if (appConfig.survey_info?.['trip-labels'] == 'ENKETO') { - unprocessedLabels['SURVEY'] = filterByNameAndVersion('TripConfirmSurvey', r); + filterByNameAndVersion('TripConfirmSurvey', r).then((filtered) => { + unprocessedLabels['SURVEY'] = filtered; + }); } else { unprocessedLabels[getLabelInputs()[i]] = r; } From 5e572d4b4701ce58a0ef844b2329c9e5336013da Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Tue, 14 Nov 2023 10:33:23 -0700 Subject: [PATCH 409/850] notif-scheduling-state variables kept in ProfileSettings.jsx and handed as parameters ProfileSettings.jsx - Created scheduledPromise and isScheduling - Hand these variables as arguments to notifScheduler functions that require it notifScheduler - Removed scheduledPromise and isScheduling as they aren't doing anything - Added isScheduling and scheduledPromise as parameters to functions that require it - Some prettier formatting came in --- www/js/control/ProfileSettings.jsx | 12 ++++++--- www/js/splash/notifScheduler.ts | 43 +++++++++++++++++++----------- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 492337de0..4cab17024 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -101,6 +101,10 @@ const ProfileSettings = () => { { text: 'Remote push', transition: 'RECEIVED_SILENT_PUSH' }, ]; + // used for scheduling notifs + let scheduledPromise = new Promise((rs) => rs()); + let isScheduling = false; + useEffect(() => { //added appConfig.name needed to be defined because appConfig was defined but empty if (appConfig && appConfig.name) { @@ -148,7 +152,7 @@ const ProfileSettings = () => { } // Update the scheduled notifs - updateScheduledNotifs(tempUiConfig.reminderSchemes) + updateScheduledNotifs(tempUiConfig.reminderSchemes, isScheduling, scheduledPromise) .then(() => { logDebug('updated scheduled notifs'); }) @@ -203,8 +207,8 @@ const ProfileSettings = () => { if (uiConfig?.reminderSchemes) { let promiseList = []; - promiseList.push(getReminderPrefs(uiConfig.reminderSchemes)); - promiseList.push(getScheduledNotifs()); + promiseList.push(getReminderPrefs(uiConfig.reminderSchemes, isScheduling, scheduledPromise)); + promiseList.push(getScheduledNotifs(isScheduling, scheduledPromise)); let resultList = await Promise.all(promiseList); const prefs = resultList[0]; const scheduledNotifs = resultList[1]; @@ -289,6 +293,8 @@ const ProfileSettings = () => { setReminderPrefs( { reminder_time_of_day: m.toFormat('HH:mm') }, uiConfig.reminderSchemes, + isScheduling, + scheduledPromise, ).then(() => { refreshNotificationSettings(); }); diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts index 326e9fe86..e21f61500 100644 --- a/www/js/splash/notifScheduler.ts +++ b/www/js/splash/notifScheduler.ts @@ -4,9 +4,6 @@ import { displayErrorMsg, logDebug } from '../plugin/logger'; import { DateTime } from 'luxon'; import i18next from 'i18next'; -let scheduledPromise = new Promise((rs) => rs()); -let isScheduling = false; - // like python range() function range(start, stop, step) { let a = [start], @@ -75,7 +72,7 @@ function debugGetScheduled(prefix) { } //new method to fetch notifications -export const getScheduledNotifs = function () { +export const getScheduledNotifs = function (isScheduling: boolean, scheduledPromise: Promise) { return new Promise((resolve, reject) => { /* if the notifications are still in active scheduling it causes problems anywhere from 0-n of the scheduled notifs are displayed @@ -122,7 +119,7 @@ const getNotifs = function () { }; // schedules the notifications using the cordova plugin -const scheduleNotifs = (scheme, notifTimes) => { +const scheduleNotifs = (scheme, notifTimes: [DateTime], isScheduling: boolean) => { return new Promise((rs) => { isScheduling = true; const localeCode = i18next.resolvedLanguage; @@ -157,9 +154,16 @@ const scheduleNotifs = (scheme, notifTimes) => { }; // determines when notifications are needed, and schedules them if not already scheduled -export const updateScheduledNotifs = async (reminderSchemes): Promise => { - const { reminder_assignment, reminder_join_date, reminder_time_of_day } = - await getReminderPrefs(reminderSchemes); +export const updateScheduledNotifs = async ( + reminderSchemes, + isScheduling: boolean, + scheduledPromise: Promise, +): Promise => { + const { reminder_assignment, reminder_join_date, reminder_time_of_day } = await getReminderPrefs( + reminderSchemes, + isScheduling, + scheduledPromise, + ); var scheme = {}; try { scheme = reminderSchemes[reminder_assignment]; @@ -171,7 +175,7 @@ export const updateScheduledNotifs = async (reminderSchemes): Promise => { reminder_assignment, ); } - const notifTimes = calcNotifTimes(scheme, reminder_join_date, reminder_time_of_day); + const notifTimes = calcNotifTimes(scheme, reminder_join_date, reminder_time_of_day) as [DateTime]; return new Promise((resolve, reject) => { window['cordova'].plugins.notification.local.getScheduled((notifs) => { if (areAlreadyScheduled(notifs, notifTimes)) { @@ -183,7 +187,7 @@ export const updateScheduledNotifs = async (reminderSchemes): Promise => { if (isScheduling) { console.log('ERROR: Already scheduling notifications, not scheduling again'); } else { - scheduledPromise = scheduleNotifs(scheme, notifTimes); + scheduledPromise = scheduleNotifs(scheme, notifTimes, isScheduling); //enforcing end of scheduling to conisder update through scheduledPromise.then(() => { resolve(); @@ -229,7 +233,11 @@ interface User { reminder_time_of_day: string; } -export const getReminderPrefs = async (reminderSchemes): Promise => { +export const getReminderPrefs = async ( + reminderSchemes, + isScheduling: boolean, + scheduledPromise: Promise, +): Promise => { const userPromise = getUser(); const user = (await userPromise) as User; if (user?.reminder_assignment && user?.reminder_join_date && user?.reminder_time_of_day) { @@ -240,19 +248,24 @@ export const getReminderPrefs = async (reminderSchemes): Promise => { console.log('User just joined, Initializing reminder prefs'); const initPrefs = initReminderPrefs(reminderSchemes); console.log('Initialized reminder prefs: ', initPrefs); - await setReminderPrefs(initPrefs, reminderSchemes); + await setReminderPrefs(initPrefs, reminderSchemes, isScheduling, scheduledPromise); return { ...user, ...initPrefs }; // user profile + the new prefs }; -export const setReminderPrefs = async (newPrefs, reminderSchemes) => { +export const setReminderPrefs = async ( + newPrefs, + reminderSchemes, + isScheduling: boolean, + scheduledPromise: Promise, +) => { await updateUser(newPrefs); const updatePromise = new Promise((resolve, reject) => { //enforcing update before moving on - updateScheduledNotifs(reminderSchemes).then(() => { + updateScheduledNotifs(reminderSchemes, isScheduling, scheduledPromise).then(() => { resolve(); }); }); // record the new prefs in client stats - getReminderPrefs(reminderSchemes).then((prefs) => { + getReminderPrefs(reminderSchemes, isScheduling, scheduledPromise).then((prefs) => { // extract only the relevant fields from the prefs, // and add as a reading to client stats const { reminder_assignment, reminder_join_date, reminder_time_of_day } = prefs; From db91c8cdae16c4cd1b1ae24d4eda1276092b89b3 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Tue, 14 Nov 2023 11:09:57 -0700 Subject: [PATCH 410/850] Remove empty objects from notifs, fixing the "n.trigger.at does not exist" error notifScheduler.ts - Added some typing - Created removeEmptyObjects to remove any empty objects from notifs coming from the cordova plugin --- www/js/splash/notifScheduler.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts index e21f61500..9f1f5fd8f 100644 --- a/www/js/splash/notifScheduler.ts +++ b/www/js/splash/notifScheduler.ts @@ -12,9 +12,9 @@ function range(start, stop, step) { return a; } -// returns an array of moment objects, for all times that notifications should be sent -const calcNotifTimes = (scheme, dayZeroDate, timeOfDay) => { - const notifTimes = []; +// returns an array of DateTime objects, for all times that notifications should be sent +const calcNotifTimes = (scheme, dayZeroDate, timeOfDay): DateTime[] => { + const notifTimes: DateTime[] = []; for (const s of scheme.schedule) { // the days to send notifications, as integers, relative to day zero const notifDays = range(s.start, s.end, s.intervalInDays); @@ -119,7 +119,7 @@ const getNotifs = function () { }; // schedules the notifications using the cordova plugin -const scheduleNotifs = (scheme, notifTimes: [DateTime], isScheduling: boolean) => { +const scheduleNotifs = (scheme, notifTimes: DateTime[], isScheduling: boolean) => { return new Promise((rs) => { isScheduling = true; const localeCode = i18next.resolvedLanguage; @@ -153,6 +153,10 @@ const scheduleNotifs = (scheme, notifTimes: [DateTime], isScheduling: boolean) = }); }; +const removeEmptyObjects = (list: any[]): any[] => { + return list.filter((n) => Object.keys(n).length !== 0); +}; + // determines when notifications are needed, and schedules them if not already scheduled export const updateScheduledNotifs = async ( reminderSchemes, @@ -175,9 +179,11 @@ export const updateScheduledNotifs = async ( reminder_assignment, ); } - const notifTimes = calcNotifTimes(scheme, reminder_join_date, reminder_time_of_day) as [DateTime]; + const notifTimes = calcNotifTimes(scheme, reminder_join_date, reminder_time_of_day) as DateTime[]; return new Promise((resolve, reject) => { window['cordova'].plugins.notification.local.getScheduled((notifs) => { + // some empty objects slip through, remove them from notifs + notifs = removeEmptyObjects(notifs); if (areAlreadyScheduled(notifs, notifTimes)) { logDebug('Already scheduled, not scheduling again'); } else { From e4817da1aa01aeadb14122d219c309e7e7a63cd1 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Tue, 14 Nov 2023 11:28:06 -0700 Subject: [PATCH 411/850] Added removeEmptyObjects check to the other cordova plugin getScheduled notifs call --- www/js/splash/notifScheduler.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts index 9f1f5fd8f..20f1daa66 100644 --- a/www/js/splash/notifScheduler.ts +++ b/www/js/splash/notifScheduler.ts @@ -101,6 +101,9 @@ const getNotifs = function () { if (!notifs?.length) { console.log('there are no notifications'); resolve([]); //if none, return empty array + } else { + // some empty objects slip through, remove them from notifs + notifs = removeEmptyObjects(notifs); } const notifSubset = notifs.slice(0, 5); //prevent near-infinite listing From 42c924c352b3850ab78090dd39c32ff86a11e089 Mon Sep 17 00:00:00 2001 From: Jiji14 Date: Tue, 14 Nov 2023 12:04:53 -0800 Subject: [PATCH 412/850] Change timeout for the test from 5s to 15s --- www/__tests__/LoadMoreButton.test.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/www/__tests__/LoadMoreButton.test.tsx b/www/__tests__/LoadMoreButton.test.tsx index 100cf19fc..b3c9cc956 100644 --- a/www/__tests__/LoadMoreButton.test.tsx +++ b/www/__tests__/LoadMoreButton.test.tsx @@ -11,13 +11,15 @@ describe('LoadMoreButton', () => { await waitFor(() => { expect(screen.getByTestId('load-button')).toBeTruthy(); }); - }); + }, 15000); - it('calls onPressFn when clicked', () => { + it('calls onPressFn when clicked', async () => { const mockFn = jest.fn(); const { getByTestId } = render({}); const loadButton = getByTestId('load-button'); fireEvent.press(loadButton); - expect(mockFn).toHaveBeenCalled(); - }); + await waitFor(() => { + expect(mockFn).toHaveBeenCalled(); + }); + }, 15000); }); From a2426c4a8b2c0fe2fb525cf6e74d07e2eea88387 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Wed, 15 Nov 2023 09:07:10 -0800 Subject: [PATCH 413/850] Fixed service imports, removed Angular import --- www/__tests__/timelineHelper.test.ts | 3 +-- www/js/diary.js | 1 - www/js/survey/enketo/enketoHelper.ts | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/www/__tests__/timelineHelper.test.ts b/www/__tests__/timelineHelper.test.ts index e5e37ca2d..40e6e65f4 100644 --- a/www/__tests__/timelineHelper.test.ts +++ b/www/__tests__/timelineHelper.test.ts @@ -1,7 +1,6 @@ import { clearAlerts, mockAlert, mockLogger } from '../__mocks__/globalMocks'; import { readAllCompositeTrips, readUnprocessedTrips } from '../js/diary/timelineHelper'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; - import * as mockTLH from '../__mocks__/timelineHelperMocks'; mockLogger(); @@ -38,7 +37,7 @@ it('fetches a composite trip object and collapses it', async () => { ).resolves.not.toThrow(); }); -jest.mock('../js/unifiedDataLoader', () => ({ +jest.mock('../js/services/unifiedDataLoader', () => ({ getUnifiedDataForInterval: jest.fn((key, tq, combiner) => { if (tq.startTs === mockTLH.fakeStartTsOne) return Promise.resolve(mockTLH.mockTransition); if (tq.startTs === mockTLH.fakeStartTsTwo) return Promise.resolve(mockTLH.mockTransitionTwo); diff --git a/www/js/diary.js b/www/js/diary.js index c580ad8f2..bdf417e41 100644 --- a/www/js/diary.js +++ b/www/js/diary.js @@ -3,7 +3,6 @@ import LabelTab from './diary/LabelTab'; angular .module('emission.main.diary', [ - 'emission.main.diary.services', 'emission.plugin.logger', 'emission.survey.enketo.answer', ]) diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index fc5ea503e..424e364d2 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -3,7 +3,7 @@ import { Form } from 'enketo-core'; import { XMLParser } from 'fast-xml-parser'; import i18next from 'i18next'; import { logDebug } from '../../plugin/logger'; -import { getUnifiedDataForInterval } from '../../unifiedDataLoader'; +import { getUnifiedDataForInterval } from '../../services/unifiedDataLoader'; export type PrefillFields = { [key: string]: string }; From 08c10f2ba4e4ea502abf4fbcea2287f8f694a445 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Wed, 15 Nov 2023 10:23:42 -0800 Subject: [PATCH 414/850] Rewrote first tests, mocks, ran prettier --- www/__mocks__/timelineHelperMocks.ts | 136 ++++++++++++--------------- www/__tests__/timelineHelper.test.ts | 40 ++++++-- www/index.html | 2 +- www/js/diary.js | 5 +- www/js/diary/timelineHelper.ts | 1 - www/js/types/diaryTypes.ts | 9 +- 6 files changed, 98 insertions(+), 95 deletions(-) diff --git a/www/__mocks__/timelineHelperMocks.ts b/www/__mocks__/timelineHelperMocks.ts index eb0eb672e..b535e4e48 100644 --- a/www/__mocks__/timelineHelperMocks.ts +++ b/www/__mocks__/timelineHelperMocks.ts @@ -1,16 +1,59 @@ import { MetaData, ServerData, ServerResponse } from '../js/types/serverData'; -import { CompositeTrip, TripTransition } from '../js/types/diaryTypes'; +import { CompositeTrip, ConfirmedPlace, TripTransition } from '../js/types/diaryTypes'; const mockMetaData: MetaData = { - write_ts: -13885091, - key: 'test/value', + write_ts: 1, + key: 'test/value/one', platform: 'test', time_zone: 'America/Los_Angeles', write_fmt_time: '1969-07-16T07:01:49.000Z', write_local_dt: null, - origin_key: '12345', + origin_key: '1', }; +const mockObjId = { + $oid: 'objID', +}; + +const mockConfirmedPlaceData: ConfirmedPlace = { + source: 'DwellSegmentationTimeFilter', + location: { + type: 'Point', + coordinates: [-122.0876886, 37.3887767], + }, + cleaned_place: { + $oid: '6553c3a0f27f16fbf9d1def1', + }, + additions: [], + user_input: {}, + enter_fmt_time: '2015-07-22T08:14:53.881000-07:00', + exit_fmt_time: '2015-07-22T08:14:53.881000-07:00', + starting_trip: { + $oid: '6553c3a1f27f16fbf9d1df15', + }, + ending_trip: { + $oid: '6553c3a1f27f16fbf9d1df15', + }, + enter_local_dt: null, + exit_local_dt: null, + raw_places: [ + { + $oid: '6553c39df27f16fbf9d1dcef', + }, + { + $oid: '6553c39df27f16fbf9d1dcef', + }, + ], + enter_ts: 1437578093.881, + exit_ts: 1437578093.881, +}; + +// using parse/stringify to deep copy & populate data +let tempMetaData = JSON.parse(JSON.stringify(mockMetaData)); +tempMetaData.write_ts = 2; +tempMetaData.origin_key = '2'; +export const mockMetaDataTwo = tempMetaData; + export const mockData: ServerResponse = { phone_data: [ { @@ -24,8 +67,9 @@ export const mockData: ServerResponse = { distance: 777, duration: 777, end_confirmed_place: { - data: null, + data: mockConfirmedPlaceData, metadata: mockMetaData, + _id: { $oid: 'endConfirmedPlace' }, }, end_fmt_time: '2023-11-01T17:55:20.999397-07:00', end_loc: { @@ -70,8 +114,9 @@ export const mockData: ServerResponse = { ], source: 'DwellSegmentationDistFilter', start_confirmed_place: { - data: null, + data: mockConfirmedPlaceData, metadata: mockMetaData, + _id: { $oid: 'startConfirmedPlace' }, }, start_fmt_time: '2023-11-01T17:55:20.999397-07:00', start_loc: { @@ -87,8 +132,14 @@ export const mockData: ServerResponse = { }, ], }; + +let newPhoneData = JSON.parse(JSON.stringify(mockData.phone_data[0])); +newPhoneData.metadata = mockMetaDataTwo; +newPhoneData.data.start_confirmed_place.metadata = mockMetaDataTwo; +newPhoneData.data.end_confirmed_place.metadata = mockMetaDataTwo; + export const mockDataTwo = { - phone_data: [mockData.phone_data[0], mockData.phone_data[0]], + phone_data: [mockData.phone_data[0], newPhoneData], }; export const mockTransition: Array> = [ @@ -109,74 +160,3 @@ export const fakeStartTsOne = -14576291; export const fakeEndTsOne = -13885091; export const fakeStartTsTwo = 1092844665; export const fakeEndTsTwo = 1277049465; - -export const readAllCheckOne = [ - { - additions: [], - cleaned_section_summary: null, - cleaned_trip: null, - confidence_threshold: -1, - confirmed_trip: null, - distance: 777, - duration: 777, - end_confirmed_place: { - key: 'test/value', - origin_key: '12345', - }, - end_fmt_time: '2023-11-01T17:55:20.999397-07:00', - end_loc: { - type: 'Point', - coordinates: [-1, -1], - }, - end_local_dt: null, - end_place: null, - end_ts: -1, - expectation: null, - expected_trip: null, - inferred_labels: [], - inferred_section_summary: { - count: { - CAR: 1, - WALKING: 1, - }, - distance: { - CAR: 222, - WALKING: 222, - }, - duration: { - CAR: 333, - WALKING: 333, - }, - }, - inferred_trip: null, - key: 'test/value', - locations: [ - { - key: 'test/value', - origin_key: '12345', - }, - ], - origin_key: '12345', - raw_trip: null, - sections: [ - { - key: 'test/value', - origin_key: '12345', - }, - ], - source: 'DwellSegmentationDistFilter', - start_confirmed_place: { - key: 'test/value', - origin_key: '12345', - }, - start_fmt_time: '2023-11-01T17:55:20.999397-07:00', - start_loc: { - type: 'Point', - coordinates: [-1, -1], - }, - start_local_dt: null, - start_place: null, - start_ts: null, - user_input: null, - }, -]; diff --git a/www/__tests__/timelineHelper.test.ts b/www/__tests__/timelineHelper.test.ts index 40e6e65f4..74afe03ec 100644 --- a/www/__tests__/timelineHelper.test.ts +++ b/www/__tests__/timelineHelper.test.ts @@ -16,7 +16,7 @@ afterAll(() => { }); // Once we have end-to-end testing, we could utilize getRawEnteries. -jest.mock('../js/commHelper', () => ({ +jest.mock('../js/services/commHelper', () => ({ getRawEntries: jest.fn((key, startTs, endTs, valTwo) => { if (startTs === mockTLH.fakeStartTsOne) return mockTLH.mockData; if (startTs == mockTLH.fakeStartTsTwo) return mockTLH.mockDataTwo; @@ -28,15 +28,40 @@ it('works when there are no composite trip objects fetched', async () => { expect(readAllCompositeTrips(-1, -1)).resolves.toEqual([]); }); -it('fetches a composite trip object and collapses it', async () => { - expect(readAllCompositeTrips(mockTLH.fakeStartTsOne, mockTLH.fakeEndTsOne)).resolves.toEqual( - mockTLH.readAllCheckOne, +// Checks that `readAllCOmpositeTrips` properly unpacks & flattens the confirmedPlaces +const checkTripIsUnpacked = (obj) => { + expect(obj.metadata).toBeUndefined(); + expect(obj).toEqual( + expect.objectContaining({ + key: expect.any(String), + origin_key: expect.any(String), + start_confirmed_place: expect.objectContaining({ + origin_key: expect.any(String), + }), + end_confirmed_place: expect.objectContaining({ + origin_key: expect.any(String), + }), + locations: expect.any(Array), + sections: expect.any(Array), + }), ); - expect( - readAllCompositeTrips(mockTLH.fakeStartTsTwo, mockTLH.fakeEndTsTwo), - ).resolves.not.toThrow(); +}; + +it('fetches a composite trip object and collapses it', async () => { + const testValue = await readAllCompositeTrips(mockTLH.fakeStartTsOne, mockTLH.fakeEndTsOne); + expect(testValue.length).toEqual(1); + checkTripIsUnpacked(testValue[0]); }); +it('Works with multiple trips', async () => { + const testValue = await readAllCompositeTrips(mockTLH.fakeStartTsTwo, mockTLH.fakeEndTsTwo); + expect(testValue.length).toEqual(2); + checkTripIsUnpacked(testValue[0]); + checkTripIsUnpacked(testValue[1]); + expect(testValue[0].origin_key).toBe('1'); + expect(testValue[1].origin_key).toBe('2'); +}); +/* jest.mock('../js/services/unifiedDataLoader', () => ({ getUnifiedDataForInterval: jest.fn((key, tq, combiner) => { if (tq.startTs === mockTLH.fakeStartTsOne) return Promise.resolve(mockTLH.mockTransition); @@ -57,3 +82,4 @@ it('works when there are one or more unprocessed trips...', async () => { readUnprocessedTrips(mockTLH.fakeStartTsTwo, mockTLH.fakeEndTsTwo, null), ).resolves.not.toThrow(); }); +*/ diff --git a/www/index.html b/www/index.html index 72c75eb01..44fcb5bbf 100644 --- a/www/index.html +++ b/www/index.html @@ -15,4 +15,4 @@
    - + \ No newline at end of file diff --git a/www/js/diary.js b/www/js/diary.js index bdf417e41..93fed24d4 100644 --- a/www/js/diary.js +++ b/www/js/diary.js @@ -2,10 +2,7 @@ import angular from 'angular'; import LabelTab from './diary/LabelTab'; angular - .module('emission.main.diary', [ - 'emission.plugin.logger', - 'emission.survey.enketo.answer', - ]) + .module('emission.main.diary', ['emission.plugin.logger', 'emission.survey.enketo.answer']) .config(function ($stateProvider) { $stateProvider.state('root.main.inf_scroll', { diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 45ea4312c..e32657903 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -4,7 +4,6 @@ import { getUnifiedDataForInterval } from '../services/unifiedDataLoader'; import { getRawEntries } from '../services/commHelper'; import { ServerResponse, ServerData } from '../types/serverData'; import L from 'leaflet'; -import i18next from 'i18next'; import { DateTime } from 'luxon'; import { UserInputEntry, CompositeTrip, TripTransition, SectionData } from '../types/diaryTypes'; import { getLabelInputDetails, getLabelInputs } from '../survey/multilabel/confirmHelper'; diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 938b06851..08065c2a2 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -6,17 +6,14 @@ import { BaseModeKey, MotionTypeKey } from '../diary/diaryHelper'; import { ServerData, LocalDt } from './serverData'; type ObjectId = { $oid: string }; -type ConfirmedPlace = { - _id: ObjectId; +export type ConfirmedPlace = { additions: UserInputEntry[]; cleaned_place: ObjectId; ending_trip: ObjectId; enter_fmt_time: string; // ISO string 2023-10-31T12:00:00.000-04:00 enter_local_dt: LocalDt; enter_ts: number; // Unix timestamp - key: string; location: { type: string; coordinates: number[] }; - origin_key: string; raw_places: ObjectId[]; source: string; user_input: { @@ -27,6 +24,10 @@ type ConfirmedPlace = { as a string (e.g. 'walk', 'drove_alone') */ [k: `${string}confirm`]: string; }; + exit_fmt_time: string; + exit_ts: number; + exit_local_dt: LocalDt; + starting_trip: ObjectId; }; export type TripTransition = { From 941907aa21159e408c537e78d422efb8642053b5 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan Date: Wed, 15 Nov 2023 11:34:18 -0700 Subject: [PATCH 415/850] Fixed buttons --- www/css/style.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/www/css/style.css b/www/css/style.css index a2ac29368..9838ddd63 100644 --- a/www/css/style.css +++ b/www/css/style.css @@ -6,8 +6,10 @@ .question.non-select { display: inline-block; } - .question input[name*='_date'], - .question input[name*='_time'] { + .question input[name$="Start_date"], + .question input[name$="Start_time"], + .question input[name$="End_date"], + .question input[name$="End_time"] { width: calc(40vw - 10px); margin-right: 5px; display: flex; From 6cf3a802bcdaa4e786193854fc0562f63ac4f318 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 15 Nov 2023 13:42:31 -0700 Subject: [PATCH 416/850] patch for WSOD on dashboard the reason I was seeing WSOD with some of my data was that I had spans of weeks where one week I only biked and the other I only walked, so each dataset (walk and bike) only had one datapoint each. I was able to fix this by acesssing the 0th entry in the ith dataset instead of the ith entry in the 0th dataset, since the latter does not always exist in the active minutes chart --- www/js/components/Chart.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/js/components/Chart.tsx b/www/js/components/Chart.tsx index 4ebf49c24..5374946f5 100644 --- a/www/js/components/Chart.tsx +++ b/www/js/components/Chart.tsx @@ -174,8 +174,8 @@ const Chart = ({ callback: (value, i) => { logDebug(`Vertical axis callback: i = ${i}; chartDatasets = ${JSON.stringify(chartDatasets)}; - chartDatasets[0].data = ${JSON.stringify(chartDatasets[0].data)}`); - const label = chartDatasets[0].data[i].x; + chartDatasets[i].data = ${JSON.stringify(chartDatasets[i].data)}`); + const label = chartDatasets[i].data[0].x; if (typeof label == 'string' && label.includes('\n')) return label.split('\n'); return label; From 2a3fb1360ace53764f6afd7bb0f93087ffa8904b Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan Date: Wed, 15 Nov 2023 14:09:42 -0700 Subject: [PATCH 417/850] Run through prettier --- www/css/style.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/www/css/style.css b/www/css/style.css index 9838ddd63..2bc7c6fee 100644 --- a/www/css/style.css +++ b/www/css/style.css @@ -6,10 +6,10 @@ .question.non-select { display: inline-block; } - .question input[name$="Start_date"], - .question input[name$="Start_time"], - .question input[name$="End_date"], - .question input[name$="End_time"] { + .question input[name$='Start_date'], + .question input[name$='Start_time'], + .question input[name$='End_date'], + .question input[name$='End_time'] { width: calc(40vw - 10px); margin-right: 5px; display: flex; From 1693409d3a19d6ccbefb55ade32e06c1d6a7659e Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Wed, 15 Nov 2023 14:11:28 -0700 Subject: [PATCH 418/850] tweak to account for same mode both weeks as an add on to my previous patch, we also need to account for if the same mode is both weeks (like the span in July where I just walked both weeks) This if statement accounts for both data cases. --- www/js/components/Chart.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/www/js/components/Chart.tsx b/www/js/components/Chart.tsx index 5374946f5..8d9154713 100644 --- a/www/js/components/Chart.tsx +++ b/www/js/components/Chart.tsx @@ -174,8 +174,14 @@ const Chart = ({ callback: (value, i) => { logDebug(`Vertical axis callback: i = ${i}; chartDatasets = ${JSON.stringify(chartDatasets)}; - chartDatasets[i].data = ${JSON.stringify(chartDatasets[i].data)}`); - const label = chartDatasets[i].data[0].x; + chartDatasets[0].data = ${JSON.stringify(chartDatasets[0].data)}`); + //account for different data possiblities - one mode per weeek, one mode both weeks, mixed weeks + let label; + if (chartDatasets[0].data[i]) { + label = chartDatasets[0].data[i].x; + } else { + label = chartDatasets[i].data[0].x; + } if (typeof label == 'string' && label.includes('\n')) return label.split('\n'); return label; From e6a21ca6e466972c824d9a0c7f5759a4a14ab634 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Wed, 15 Nov 2023 13:13:33 -0800 Subject: [PATCH 419/850] Updated second diaryServices test --- www/__tests__/timelineHelper.test.ts | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/www/__tests__/timelineHelper.test.ts b/www/__tests__/timelineHelper.test.ts index 74afe03ec..7172345e3 100644 --- a/www/__tests__/timelineHelper.test.ts +++ b/www/__tests__/timelineHelper.test.ts @@ -1,5 +1,9 @@ import { clearAlerts, mockAlert, mockLogger } from '../__mocks__/globalMocks'; -import { readAllCompositeTrips, readUnprocessedTrips } from '../js/diary/timelineHelper'; +import { + useGeojsonForTrip, + readAllCompositeTrips, + readUnprocessedTrips, +} from '../js/diary/timelineHelper'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import * as mockTLH from '../__mocks__/timelineHelperMocks'; @@ -15,6 +19,7 @@ afterAll(() => { jest.restoreAllMocks(); }); +// Tests for readAllCompositeTrips // Once we have end-to-end testing, we could utilize getRawEnteries. jest.mock('../js/services/commHelper', () => ({ getRawEntries: jest.fn((key, startTs, endTs, valTwo) => { @@ -28,7 +33,7 @@ it('works when there are no composite trip objects fetched', async () => { expect(readAllCompositeTrips(-1, -1)).resolves.toEqual([]); }); -// Checks that `readAllCOmpositeTrips` properly unpacks & flattens the confirmedPlaces +// Checks that `readAllCompositeTrips` properly unpacks & flattens the confirmedPlaces const checkTripIsUnpacked = (obj) => { expect(obj.metadata).toBeUndefined(); expect(obj).toEqual( @@ -61,7 +66,8 @@ it('Works with multiple trips', async () => { expect(testValue[0].origin_key).toBe('1'); expect(testValue[1].origin_key).toBe('2'); }); -/* + +// Tests for `readUnprocessedTrips` jest.mock('../js/services/unifiedDataLoader', () => ({ getUnifiedDataForInterval: jest.fn((key, tq, combiner) => { if (tq.startTs === mockTLH.fakeStartTsOne) return Promise.resolve(mockTLH.mockTransition); @@ -74,12 +80,13 @@ it('works when there are no unprocessed trips...', async () => { expect(readUnprocessedTrips(-1, -1, null)).resolves.toEqual([]); }); +// In manual testing, it seems that `trip_gj_list` always returns +// as an empty array - should find data where this is different... it('works when there are one or more unprocessed trips...', async () => { - expect( - readUnprocessedTrips(mockTLH.fakeStartTsOne, mockTLH.fakeEndTsOne, null), - ).resolves.not.toThrow(); - expect( - readUnprocessedTrips(mockTLH.fakeStartTsTwo, mockTLH.fakeEndTsTwo, null), - ).resolves.not.toThrow(); + const testValueOne = await readUnprocessedTrips( + mockTLH.fakeStartTsOne, + mockTLH.fakeEndTsOne, + null, + ); + expect(testValueOne).toEqual([]); }); -*/ From 361cc280f0e51c8737c8f38741154480cfb0992e Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Wed, 15 Nov 2023 13:44:39 -0800 Subject: [PATCH 420/850] Separated `labelTypes` into separate file --- www/js/diary/LabelTabContext.ts | 2 +- www/js/diary/diaryHelper.ts | 3 ++- www/js/survey/multilabel/confirmHelper.ts | 26 +---------------------- www/js/types/labelTypes.ts | 24 +++++++++++++++++++++ 4 files changed, 28 insertions(+), 27 deletions(-) create mode 100644 www/js/types/labelTypes.ts diff --git a/www/js/diary/LabelTabContext.ts b/www/js/diary/LabelTabContext.ts index 24d7ade41..717a4980d 100644 --- a/www/js/diary/LabelTabContext.ts +++ b/www/js/diary/LabelTabContext.ts @@ -1,6 +1,6 @@ import { createContext } from 'react'; import { TimelineEntry, UserInputEntry } from '../types/diaryTypes'; -import { LabelOption } from '../survey/multilabel/confirmHelper'; +import { LabelOption } from '../types/labelTypes'; export type TimelineMap = Map; export type TimelineLabelMap = { diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts index 616974b7b..2fee7eccd 100644 --- a/www/js/diary/diaryHelper.ts +++ b/www/js/diary/diaryHelper.ts @@ -3,8 +3,9 @@ import moment from 'moment'; import { DateTime } from 'luxon'; -import { LabelOptions, readableLabelToKey } from '../survey/multilabel/confirmHelper'; +import { readableLabelToKey } from '../survey/multilabel/confirmHelper'; import { CompositeTrip } from '../types/diaryTypes'; +import { LabelOptions } from '../types/labelTypes'; export const modeColors = { pink: '#c32e85', // oklch(56% 0.2 350) // e-car diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index 51674b0c3..4c2be1012 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -4,31 +4,7 @@ import { getAngularService } from '../../angular-react-helper'; import { fetchUrlCached } from '../../services/commHelper'; import i18next from 'i18next'; import { logDebug } from '../../plugin/logger'; - -type InputDetails = { - [k in T]?: { - name: string; - labeltext: string; - choosetext: string; - key: string; - }; -}; -export type LabelOption = { - value: string; - baseMode: string; - met?: { range: any[]; mets: number }; - met_equivalent?: string; - kgCo2PerKm: number; - text?: string; -}; -export type MultilabelKey = 'MODE' | 'PURPOSE' | 'REPLACED_MODE'; -export type LabelOptions = { - [k in T]: LabelOption[]; -} & { - translations: { - [lang: string]: { [translationKey: string]: string }; - }; -}; +import { LabelOption, LabelOptions, MultilabelKey, InputDetails } from '../../types/labelTypes'; let appConfig; export let labelOptions: LabelOptions; diff --git a/www/js/types/labelTypes.ts b/www/js/types/labelTypes.ts new file mode 100644 index 000000000..8ac720adc --- /dev/null +++ b/www/js/types/labelTypes.ts @@ -0,0 +1,24 @@ +export type InputDetails = { + [k in T]?: { + name: string; + labeltext: string; + choosetext: string; + key: string; + }; +}; +export type LabelOption = { + value: string; + baseMode: string; + met?: { range: any[]; mets: number }; + met_equivalent?: string; + kgCo2PerKm: number; + text?: string; +}; +export type MultilabelKey = 'MODE' | 'PURPOSE' | 'REPLACED_MODE'; +export type LabelOptions = { + [k in T]: LabelOption[]; +} & { + translations: { + [lang: string]: { [translationKey: string]: string }; + }; +}; From 691aeb54200940d3f796117a001c8622492e81ec Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Wed, 15 Nov 2023 15:59:15 -0800 Subject: [PATCH 421/850] Added test for useGeojsonForTrip --- www/__mocks__/timelineHelperMocks.ts | 12 ++++++++---- www/__tests__/timelineHelper.test.ts | 27 +++++++++++++++++++++++++++ www/js/diary/LabelTab.tsx | 1 - www/js/diary/LabelTabContext.ts | 2 +- www/js/diary/timelineHelper.ts | 10 +++++----- www/js/types/diaryTypes.ts | 21 ++++++++++++++++++++- 6 files changed, 61 insertions(+), 12 deletions(-) diff --git a/www/__mocks__/timelineHelperMocks.ts b/www/__mocks__/timelineHelperMocks.ts index b535e4e48..9a16938f7 100644 --- a/www/__mocks__/timelineHelperMocks.ts +++ b/www/__mocks__/timelineHelperMocks.ts @@ -1,5 +1,6 @@ import { MetaData, ServerData, ServerResponse } from '../js/types/serverData'; import { CompositeTrip, ConfirmedPlace, TripTransition } from '../js/types/diaryTypes'; +import { LabelOptions } from '../js/types/labelTypes'; const mockMetaData: MetaData = { write_ts: 1, @@ -11,8 +12,11 @@ const mockMetaData: MetaData = { origin_key: '1', }; -const mockObjId = { - $oid: 'objID', +export const mockLabelOptions: LabelOptions = { + MODE: null, + PURPOSE: null, + REPLACED_MODE: null, + translations: null, }; const mockConfirmedPlaceData: ConfirmedPlace = { @@ -58,7 +62,7 @@ export const mockData: ServerResponse = { phone_data: [ { data: { - _id: null, + _id: { $oid: 'mockDataOne' }, additions: [], cleaned_section_summary: null, cleaned_trip: null, @@ -125,7 +129,7 @@ export const mockData: ServerResponse = { }, start_local_dt: null, start_place: null, - start_ts: null, + start_ts: 1, user_input: null, }, metadata: mockMetaData, diff --git a/www/__tests__/timelineHelper.test.ts b/www/__tests__/timelineHelper.test.ts index 7172345e3..19a87255c 100644 --- a/www/__tests__/timelineHelper.test.ts +++ b/www/__tests__/timelineHelper.test.ts @@ -6,6 +6,7 @@ import { } from '../js/diary/timelineHelper'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import * as mockTLH from '../__mocks__/timelineHelperMocks'; +import { GeoJSON, GjFeature } from '../js/types/diaryTypes'; mockLogger(); mockAlert(); @@ -19,6 +20,32 @@ afterAll(() => { jest.restoreAllMocks(); }); +describe('useGeojsonForTrip', () => { + it('work with an empty input', () => { + const testVal = useGeojsonForTrip(null, null, null); + expect(testVal).toBeFalsy; + }); + + const checkGeojson = (geoObj: GeoJSON) => { + expect(geoObj.data).toEqual( + expect.objectContaining({ + id: expect.any(String), + type: 'FeatureCollection', + features: expect.any(Array), + }), + ); + }; + + it('works without labelMode flag', () => { + const testValue = useGeojsonForTrip( + mockTLH.mockDataTwo.phone_data[1].data, + mockTLH.mockLabelOptions, + ); + checkGeojson(testValue); + expect(testValue.data.features.length).toBe(3); + }); +}); + // Tests for readAllCompositeTrips // Once we have end-to-end testing, we could utilize getRawEnteries. jest.mock('../js/services/commHelper', () => ({ diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 5c753b2b0..bcad5e5a3 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -255,7 +255,6 @@ const LabelTab = () => { .reverse() .find((trip) => trip.origin_key.includes('confirmed_trip')); readUnprocessedPromise = readUnprocessedTrips(pipelineRange.end_ts, nowTs, lastProcessedTrip); - readUnprocessedPromise = readUnprocessedTrips(pipelineRange.end_ts, nowTs, lastProcessedTrip); } else { readUnprocessedPromise = Promise.resolve([]); } diff --git a/www/js/diary/LabelTabContext.ts b/www/js/diary/LabelTabContext.ts index 717a4980d..18e157234 100644 --- a/www/js/diary/LabelTabContext.ts +++ b/www/js/diary/LabelTabContext.ts @@ -2,7 +2,7 @@ import { createContext } from 'react'; import { TimelineEntry, UserInputEntry } from '../types/diaryTypes'; import { LabelOption } from '../types/labelTypes'; -export type TimelineMap = Map; +export type TimelineMap = Map; // Todo: update to reflect unpacked trips (origin_Key, etc) export type TimelineLabelMap = { [k: string]: { /* if the key here is 'SURVEY', we are in the ENKETO configuration, meaning the user input diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index e32657903..a1beb6ff1 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -5,17 +5,17 @@ import { getRawEntries } from '../services/commHelper'; import { ServerResponse, ServerData } from '../types/serverData'; import L from 'leaflet'; import { DateTime } from 'luxon'; -import { UserInputEntry, CompositeTrip, TripTransition, SectionData } from '../types/diaryTypes'; +import { UserInputEntry, TripTransition, TimelineEntry, GeoJSON } from '../types/diaryTypes'; import { getLabelInputDetails, getLabelInputs } from '../survey/multilabel/confirmHelper'; -import { LabelOptions } from '../survey/multilabel/confirmHelper'; +import { LabelOptions } from '../types/labelTypes'; import { getNotDeletedCandidates, getUniqueEntries } from '../survey/inputMatcher'; -const cachedGeojsons = new Map(); +const cachedGeojsons: Map = new Map(); /** * @description Gets a formatted GeoJSON object for a trip, including the start and end places and the trajectory. */ -export function useGeojsonForTrip(trip: CompositeTrip, labelOptions: LabelOptions, labeledMode?) { +export function useGeojsonForTrip(trip, labelOptions: LabelOptions, labeledMode?: Boolean) { if (!trip) return; const gjKey = `trip-${trip._id.$oid}-${labeledMode || 'detected'}`; if (cachedGeojsons.has(gjKey)) { @@ -229,7 +229,7 @@ const unpackServerData = (obj: ServerData) => ({ export const readAllCompositeTrips = function (startTs: number, endTs: number) { const readPromises = [getRawEntries(['analysis/composite_trip'], startTs, endTs, 'data.end_ts')]; return Promise.all(readPromises) - .then(([ctList]: [ServerResponse]) => { + .then(([ctList]: [ServerResponse]) => { return ctList.phone_data.map((ct) => { const unpackedCt = unpackServerData(ct); return { diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 08065c2a2..99fa2b817 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -38,7 +38,7 @@ export type TripTransition = { export type LocationCoord = { type: string; // e.x., "Point" - coordinates: [number, number]; + coordinates: [number, number] | Array<[number, number]>; }; /* These are the properties received from the server (basically matches Python code) @@ -165,3 +165,22 @@ export type SectionData = { duration: number; distance: number; }; + +export type GjFeature = { + type: string; + geometry: LocationCoord; + properties?: { featureType: string }; // if geometry.coordinates.length == 1, property + style?: { color: string }; // otherwise, style (which is a hexcode) +}; + +export type GeoJSON = { + data: { + id: string; + type: string; + features: GjFeature[]; + properties: { + start_ts: number; + end_ts: number; + }; + }; +}; From 0ee468a2cd65a531c1c38f10070a79acc67f1514 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Thu, 16 Nov 2023 09:39:09 -0700 Subject: [PATCH 422/850] Added sorting to list of notifications right before they get scheduled notifScheduler.ts - Added a sort function to the list of notifications before they get scheduled - I chose to go with id since it's an convenient way to sort, since the id is the number of epoch seconds. the alternative is .trigger.at, but that is a full on JSDate in Luxon which is a string ("Date Thu Nov 16 2023 09:36:17 GMT-0700 (Mountain Standard Time)") and harder to sort --- www/js/splash/notifScheduler.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts index 20f1daa66..a539ea52a 100644 --- a/www/js/splash/notifScheduler.ts +++ b/www/js/splash/notifScheduler.ts @@ -128,7 +128,7 @@ const scheduleNotifs = (scheme, notifTimes: DateTime[], isScheduling: boolean) = const localeCode = i18next.resolvedLanguage; const nots = notifTimes.map((n) => { const nDate = n.toJSDate(); - const seconds = nDate.getTime() / 1000; + const seconds = nDate.getTime() / 1000; // the id must be in seconds, otherwise the sorting won't work return { id: seconds, title: scheme.title[localeCode], @@ -145,6 +145,7 @@ const scheduleNotifs = (scheme, notifTimes: DateTime[], isScheduling: boolean) = // } }; }); + nots.sort((a, b) => b.id - a.id); // sort notifications by id (time) window['cordova'].plugins.notification.local.cancelAll(() => { debugGetScheduled('After cancelling'); window['cordova'].plugins.notification.local.schedule(nots, () => { From 84d675a46f6a059cb3ac9d4ed71b53cf67422ece Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 16 Nov 2023 10:40:11 -0800 Subject: [PATCH 423/850] Added test for compositeTrips2TimelineMap --- www/__mocks__/timelineHelperMocks.ts | 4 +++ www/__tests__/timelineHelper.test.ts | 44 ++++++++++++++++++++++++++++ www/js/diary/LabelTab.tsx | 2 +- 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/www/__mocks__/timelineHelperMocks.ts b/www/__mocks__/timelineHelperMocks.ts index 9a16938f7..4b7c9a96b 100644 --- a/www/__mocks__/timelineHelperMocks.ts +++ b/www/__mocks__/timelineHelperMocks.ts @@ -137,10 +137,14 @@ export const mockData: ServerResponse = { ], }; +// Setup for second mockData let newPhoneData = JSON.parse(JSON.stringify(mockData.phone_data[0])); +newPhoneData.data._id.$oid = 'mockDataTwo'; newPhoneData.metadata = mockMetaDataTwo; newPhoneData.data.start_confirmed_place.metadata = mockMetaDataTwo; +newPhoneData.data.start_confirmed_place._id.$oid = 'startConfirmedPlaceTwo'; newPhoneData.data.end_confirmed_place.metadata = mockMetaDataTwo; +newPhoneData.data.end_confirmed_place._id.$oid = 'endConfirmedPlaceTwo'; export const mockDataTwo = { phone_data: [mockData.phone_data[0], newPhoneData], diff --git a/www/__tests__/timelineHelper.test.ts b/www/__tests__/timelineHelper.test.ts index 19a87255c..a9a059813 100644 --- a/www/__tests__/timelineHelper.test.ts +++ b/www/__tests__/timelineHelper.test.ts @@ -3,6 +3,7 @@ import { useGeojsonForTrip, readAllCompositeTrips, readUnprocessedTrips, + compositeTrips2TimelineMap, } from '../js/diary/timelineHelper'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import * as mockTLH from '../__mocks__/timelineHelperMocks'; @@ -46,6 +47,49 @@ describe('useGeojsonForTrip', () => { }); }); +describe('compositeTrips2TimelineMap', () => { + const firstTripList = [mockTLH.mockData.phone_data[0].data]; + const secondTripList = [ + mockTLH.mockDataTwo.phone_data[0].data, + mockTLH.mockDataTwo.phone_data[1].data, + ]; + const firstKey = mockTLH.mockData.phone_data[0].data._id.$oid; + const secondKey = mockTLH.mockDataTwo.phone_data[1].data._id.$oid; + const thirdKey = mockTLH.mockData.phone_data[0].data._id.$oid; + let testValue; + + it('Works with an empty list', () => { + expect(Object.keys(compositeTrips2TimelineMap([])).length).toBe(0); + }); + + it('Works with a list of len = 1, no flag', () => { + testValue = compositeTrips2TimelineMap(firstTripList); + expect(testValue.size).toBe(1); + expect(testValue.get(firstKey)).toEqual(firstTripList[0]); + }); + + it('Works with a list of len = 1, with flag', () => { + testValue = compositeTrips2TimelineMap(firstTripList, true); + expect(testValue.size).toBe(3); + expect(testValue.get(firstKey)).toEqual(firstTripList[0]); + expect(testValue.get('startConfirmedPlace')).toEqual(firstTripList[0].start_confirmed_place); + expect(testValue.get('endConfirmedPlace')).toEqual(firstTripList[0].end_confirmed_place); + }); + + it('Works with a list of len >= 1, no flag', () => { + testValue = compositeTrips2TimelineMap(secondTripList); + expect(testValue.size).toBe(2); + expect(testValue.get(secondKey)).toEqual(secondTripList[1]); + expect(testValue.get(thirdKey)).toEqual(secondTripList[0]); + }); + + it('Works with a list of len >= 1, with flag', () => { + testValue = compositeTrips2TimelineMap(secondTripList, true); + console.log(`Len: ${testValue.size}`); + expect(testValue.size).toBe(6); + }); +}); + // Tests for readAllCompositeTrips // Once we have end-to-end testing, we could utilize getRawEnteries. jest.mock('../js/services/commHelper', () => ({ diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index bcad5e5a3..2e181859c 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -230,7 +230,7 @@ const LabelTab = () => { }); const readTimelineMap = compositeTrips2TimelineMap(tripsRead, showPlaces); logDebug(`LabelTab: after composite trips converted, - readTimelineMap = ${JSON.stringify(readTimelineMap)}`); + readTimelineMap = ${[...readTimelineMap.entries()]}`); if (mode == 'append') { setTimelineMap(new Map([...timelineMap, ...readTimelineMap])); } else if (mode == 'prepend') { From e209f1016947d189330bcd7427930049e7b7f26f Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Thu, 16 Nov 2023 13:41:03 -0700 Subject: [PATCH 424/850] Change static variable isScheduling to a useState I was not confident that isScheduling would be set properly if it were set as a static variable being passed as an arg, so I used useState and passed the value and set function --- www/js/control/ProfileSettings.jsx | 14 ++++++-- www/js/splash/notifScheduler.ts | 56 +++++++++++++++++++----------- 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 4cab17024..193e30b8f 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -103,7 +103,7 @@ const ProfileSettings = () => { // used for scheduling notifs let scheduledPromise = new Promise((rs) => rs()); - let isScheduling = false; + const [isScheduling, setIsScheduling] = useState(false); useEffect(() => { //added appConfig.name needed to be defined because appConfig was defined but empty @@ -152,7 +152,12 @@ const ProfileSettings = () => { } // Update the scheduled notifs - updateScheduledNotifs(tempUiConfig.reminderSchemes, isScheduling, scheduledPromise) + updateScheduledNotifs( + tempUiConfig.reminderSchemes, + isScheduling, + setIsScheduling, + scheduledPromise, + ) .then(() => { logDebug('updated scheduled notifs'); }) @@ -207,7 +212,9 @@ const ProfileSettings = () => { if (uiConfig?.reminderSchemes) { let promiseList = []; - promiseList.push(getReminderPrefs(uiConfig.reminderSchemes, isScheduling, scheduledPromise)); + promiseList.push( + getReminderPrefs(uiConfig.reminderSchemes, isScheduling, setIsScheduling, scheduledPromise), + ); promiseList.push(getScheduledNotifs(isScheduling, scheduledPromise)); let resultList = await Promise.all(promiseList); const prefs = resultList[0]; @@ -294,6 +301,7 @@ const ProfileSettings = () => { { reminder_time_of_day: m.toFormat('HH:mm') }, uiConfig.reminderSchemes, isScheduling, + setIsScheduling, scheduledPromise, ).then(() => { refreshNotificationSettings(); diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts index a539ea52a..06a869876 100644 --- a/www/js/splash/notifScheduler.ts +++ b/www/js/splash/notifScheduler.ts @@ -122,9 +122,9 @@ const getNotifs = function () { }; // schedules the notifications using the cordova plugin -const scheduleNotifs = (scheme, notifTimes: DateTime[], isScheduling: boolean) => { +const scheduleNotifs = (scheme, notifTimes: DateTime[], setIsScheduling: Function) => { return new Promise((rs) => { - isScheduling = true; + setIsScheduling(true); const localeCode = i18next.resolvedLanguage; const nots = notifTimes.map((n) => { const nDate = n.toJSDate(); @@ -150,7 +150,7 @@ const scheduleNotifs = (scheme, notifTimes: DateTime[], isScheduling: boolean) = debugGetScheduled('After cancelling'); window['cordova'].plugins.notification.local.schedule(nots, () => { debugGetScheduled('After scheduling'); - isScheduling = false; + setIsScheduling(false); rs(); //scheduling promise resolved here }); }); @@ -163,13 +163,15 @@ const removeEmptyObjects = (list: any[]): any[] => { // determines when notifications are needed, and schedules them if not already scheduled export const updateScheduledNotifs = async ( - reminderSchemes, + reminderSchemes: object, isScheduling: boolean, + setIsScheduling: Function, scheduledPromise: Promise, ): Promise => { const { reminder_assignment, reminder_join_date, reminder_time_of_day } = await getReminderPrefs( reminderSchemes, isScheduling, + setIsScheduling, scheduledPromise, ); var scheme = {}; @@ -197,7 +199,7 @@ export const updateScheduledNotifs = async ( if (isScheduling) { console.log('ERROR: Already scheduling notifications, not scheduling again'); } else { - scheduledPromise = scheduleNotifs(scheme, notifTimes, isScheduling); + scheduledPromise = scheduleNotifs(scheme, notifTimes, setIsScheduling); //enforcing end of scheduling to conisder update through scheduledPromise.then(() => { resolve(); @@ -246,6 +248,7 @@ interface User { export const getReminderPrefs = async ( reminderSchemes, isScheduling: boolean, + setIsScheduling: Function, scheduledPromise: Promise, ): Promise => { const userPromise = getUser(); @@ -258,32 +261,43 @@ export const getReminderPrefs = async ( console.log('User just joined, Initializing reminder prefs'); const initPrefs = initReminderPrefs(reminderSchemes); console.log('Initialized reminder prefs: ', initPrefs); - await setReminderPrefs(initPrefs, reminderSchemes, isScheduling, scheduledPromise); + await setReminderPrefs( + initPrefs, + reminderSchemes, + isScheduling, + setIsScheduling, + scheduledPromise, + ); return { ...user, ...initPrefs }; // user profile + the new prefs }; export const setReminderPrefs = async ( - newPrefs, - reminderSchemes, + newPrefs: object, + reminderSchemes: object, isScheduling: boolean, + setIsScheduling: Function, scheduledPromise: Promise, ) => { await updateUser(newPrefs); const updatePromise = new Promise((resolve, reject) => { //enforcing update before moving on - updateScheduledNotifs(reminderSchemes, isScheduling, scheduledPromise).then(() => { - resolve(); - }); + updateScheduledNotifs(reminderSchemes, isScheduling, setIsScheduling, scheduledPromise).then( + () => { + resolve(); + }, + ); }); // record the new prefs in client stats - getReminderPrefs(reminderSchemes, isScheduling, scheduledPromise).then((prefs) => { - // extract only the relevant fields from the prefs, - // and add as a reading to client stats - const { reminder_assignment, reminder_join_date, reminder_time_of_day } = prefs; - addStatReading(statKeys.REMINDER_PREFS, { - reminder_assignment, - reminder_join_date, - reminder_time_of_day, - }).then(logDebug('Added reminder prefs to client stats')); - }); + getReminderPrefs(reminderSchemes, isScheduling, setIsScheduling, scheduledPromise).then( + (prefs) => { + // extract only the relevant fields from the prefs, + // and add as a reading to client stats + const { reminder_assignment, reminder_join_date, reminder_time_of_day } = prefs; + addStatReading(statKeys.REMINDER_PREFS, { + reminder_assignment, + reminder_join_date, + reminder_time_of_day, + }).then(logDebug('Added reminder prefs to client stats')); + }, + ); return updatePromise; }; From b8bf1cf131d62b81c637795feb10a3311318f7fd Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Thu, 16 Nov 2023 13:42:37 -0700 Subject: [PATCH 425/850] Replace console.logs with logDebugs We want to standardize on using logDebug, so I replaced all important console.logs with logDebugs that use JSON.stringify for object printouts --- www/js/control/ProfileSettings.jsx | 22 ++++++++++++++-------- www/js/splash/notifScheduler.ts | 18 ++++++------------ 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 193e30b8f..00b73bdf8 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -204,9 +204,9 @@ const ProfileSettings = () => { }, [editCollectionVis]); async function refreshNotificationSettings() { - console.debug( - 'about to refreshNotificationSettings, notificationSettings = ', - notificationSettings, + logDebug( + 'about to refreshNotificationSettings, notificationSettings = ' + + JSON.stringify(notificationSettings), ); const newNotificationSettings = {}; @@ -219,7 +219,12 @@ const ProfileSettings = () => { let resultList = await Promise.all(promiseList); const prefs = resultList[0]; const scheduledNotifs = resultList[1]; - console.log('prefs and scheduled notifs', resultList[0], resultList[1]); + logDebug( + 'prefs and scheduled notifs\n' + + JSON.stringify(prefs) + + '\n-\n' + + JSON.stringify(scheduledNotifs), + ); const m = DateTime.fromFormat(prefs.reminder_time_of_day, 'HH:mm'); newNotificationSettings.prefReminderTimeVal = m.toJSDate(); @@ -230,10 +235,11 @@ const ProfileSettings = () => { updatePrefReminderTime(false); } - console.log( - 'notification settings before and after', - notificationSettings, - newNotificationSettings, + logDebug( + 'notification settings before and after\n' + + JSON.stringify(notificationSettings) + + '\n-\n' + + JSON.stringify(newNotificationSettings), ); setNotificationSettings(newNotificationSettings); } diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts index 06a869876..79f81b3a5 100644 --- a/www/js/splash/notifScheduler.ts +++ b/www/js/splash/notifScheduler.ts @@ -79,10 +79,9 @@ export const getScheduledNotifs = function (isScheduling: boolean, scheduledProm if actively scheduling, wait for the scheduledPromise to resolve before fetching prevents such errors */ if (isScheduling) { - console.log('requesting fetch while still actively scheduling, waiting on scheduledPromise'); + logDebug('requesting fetch while still actively scheduling, waiting on scheduledPromise'); scheduledPromise.then(() => { getNotifs().then((notifs) => { - console.log('done scheduling notifs', notifs); resolve(notifs); }); }); @@ -99,7 +98,7 @@ const getNotifs = function () { return new Promise((resolve, reject) => { window['cordova'].plugins.notification.local.getScheduled((notifs) => { if (!notifs?.length) { - console.log('there are no notifications'); + logDebug('there are no notifications'); resolve([]); //if none, return empty array } else { // some empty objects slip through, remove them from notifs @@ -197,7 +196,7 @@ export const updateScheduledNotifs = async ( // we'll wait for the previous one to finish before scheduling again scheduledPromise.then(() => { if (isScheduling) { - console.log('ERROR: Already scheduling notifications, not scheduling again'); + logDebug('ERROR: Already scheduling notifications, not scheduling again'); } else { scheduledPromise = scheduleNotifs(scheme, notifTimes, setIsScheduling); //enforcing end of scheduling to conisder update through @@ -233,11 +232,6 @@ const initReminderPrefs = (reminderSchemes) => { reminder_join_date: '2023-05-09', reminder_time_of_day: '21:00', */ -// interface ReminderPrefs { -// reminder_assignment: string; -// reminder_join_date: string; -// reminder_time_of_day: string; -// } interface User { reminder_assignment: string; @@ -254,13 +248,13 @@ export const getReminderPrefs = async ( const userPromise = getUser(); const user = (await userPromise) as User; if (user?.reminder_assignment && user?.reminder_join_date && user?.reminder_time_of_day) { - console.log('User already has reminder prefs, returning them', user); + logDebug('User already has reminder prefs, returning them: ' + JSON.stringify(user)); return user; } // if no prefs, user just joined, so initialize them - console.log('User just joined, Initializing reminder prefs'); + logDebug('User just joined, Initializing reminder prefs'); const initPrefs = initReminderPrefs(reminderSchemes); - console.log('Initialized reminder prefs: ', initPrefs); + logDebug('Initialized reminder prefs: ' + JSON.stringify(initPrefs)); await setReminderPrefs( initPrefs, reminderSchemes, From b27ae9accba2fd42b12d2f5f92355dfdc31c62f5 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Thu, 16 Nov 2023 13:46:07 -0700 Subject: [PATCH 426/850] Add in more type declarations notifScheduler.ts - notifs uses any[], because n.trigger.at is not recognized if we use object[] - TODO: We may want to create a typing file for these types, such as User, which is currently declared as an interface within this file - TODO: We will want to add a type for notifs coming from cordova, and for ReminderScheme from the config, but I'm not sure how since ReminderScheme is an object with a variety of inner objects whose keys may or may not be named the same (weekly, week-quarterly, passive, etc.) --- www/js/splash/notifScheduler.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts index 79f81b3a5..a92ac51c3 100644 --- a/www/js/splash/notifScheduler.ts +++ b/www/js/splash/notifScheduler.ts @@ -30,7 +30,7 @@ const calcNotifTimes = (scheme, dayZeroDate, timeOfDay): DateTime[] => { }; // returns true if all expected times are already scheduled -const areAlreadyScheduled = (notifs, expectedTimes) => { +const areAlreadyScheduled = (notifs: any[], expectedTimes: DateTime[]) => { for (const t of expectedTimes) { if (!notifs.some((n) => DateTime.fromMillis(n.trigger.at).equals(t))) { return false; @@ -81,12 +81,12 @@ export const getScheduledNotifs = function (isScheduling: boolean, scheduledProm if (isScheduling) { logDebug('requesting fetch while still actively scheduling, waiting on scheduledPromise'); scheduledPromise.then(() => { - getNotifs().then((notifs) => { + getNotifs().then((notifs: object[]) => { resolve(notifs); }); }); } else { - getNotifs().then((notifs) => { + getNotifs().then((notifs: object[]) => { resolve(notifs); }); } @@ -96,7 +96,7 @@ export const getScheduledNotifs = function (isScheduling: boolean, scheduledProm //get scheduled notifications from cordova plugin and format them const getNotifs = function () { return new Promise((resolve, reject) => { - window['cordova'].plugins.notification.local.getScheduled((notifs) => { + window['cordova'].plugins.notification.local.getScheduled((notifs: any[]) => { if (!notifs?.length) { logDebug('there are no notifications'); resolve([]); //if none, return empty array @@ -108,8 +108,8 @@ const getNotifs = function () { const notifSubset = notifs.slice(0, 5); //prevent near-infinite listing let scheduledNotifs = []; scheduledNotifs = notifSubset.map((n) => { - const time = DateTime.fromMillis(n.trigger.at).toFormat('t'); - const date = DateTime.fromMillis(n.trigger.at).toFormat('DDD'); + const time: string = DateTime.fromMillis(n.trigger.at).toFormat('t'); + const date: string = DateTime.fromMillis(n.trigger.at).toFormat('DDD'); return { key: date, val: time, @@ -184,9 +184,9 @@ export const updateScheduledNotifs = async ( reminder_assignment, ); } - const notifTimes = calcNotifTimes(scheme, reminder_join_date, reminder_time_of_day) as DateTime[]; + const notifTimes = calcNotifTimes(scheme, reminder_join_date, reminder_time_of_day); return new Promise((resolve, reject) => { - window['cordova'].plugins.notification.local.getScheduled((notifs) => { + window['cordova'].plugins.notification.local.getScheduled((notifs: any[]) => { // some empty objects slip through, remove them from notifs notifs = removeEmptyObjects(notifs); if (areAlreadyScheduled(notifs, notifTimes)) { @@ -214,12 +214,12 @@ export const updateScheduledNotifs = async ( and use the default time of day from config (or noon if not specified) This is only called once when the user first joins the study */ -const initReminderPrefs = (reminderSchemes) => { +const initReminderPrefs = (reminderSchemes: object): object => { // randomly assign from the schemes listed in config const schemes = Object.keys(reminderSchemes); - const randAssignment = schemes[Math.floor(Math.random() * schemes.length)]; - const todayDate = DateTime.local().toFormat('yyyy-MM-dd'); - const defaultTime = reminderSchemes[randAssignment]?.defaultTime || '12:00'; + const randAssignment: string = schemes[Math.floor(Math.random() * schemes.length)]; + const todayDate: string = DateTime.local().toFormat('yyyy-MM-dd'); + const defaultTime: string = reminderSchemes[randAssignment]?.defaultTime || '12:00'; return { reminder_assignment: randAssignment, reminder_join_date: todayDate, @@ -240,7 +240,7 @@ interface User { } export const getReminderPrefs = async ( - reminderSchemes, + reminderSchemes: object, isScheduling: boolean, setIsScheduling: Function, scheduledPromise: Promise, @@ -270,7 +270,7 @@ export const setReminderPrefs = async ( isScheduling: boolean, setIsScheduling: Function, scheduledPromise: Promise, -) => { +): Promise => { await updateUser(newPrefs); const updatePromise = new Promise((resolve, reject) => { //enforcing update before moving on From a2b5da53b8fbf2528387660e67a8dcc7c62bf1c5 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Thu, 16 Nov 2023 14:08:11 -0800 Subject: [PATCH 427/850] Added tests for keysForNotesInputs --- www/__tests__/timelineHelper.test.ts | 51 ++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/www/__tests__/timelineHelper.test.ts b/www/__tests__/timelineHelper.test.ts index a9a059813..2cf590e3c 100644 --- a/www/__tests__/timelineHelper.test.ts +++ b/www/__tests__/timelineHelper.test.ts @@ -4,6 +4,7 @@ import { readAllCompositeTrips, readUnprocessedTrips, compositeTrips2TimelineMap, + keysForLabelInputs, } from '../js/diary/timelineHelper'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import * as mockTLH from '../__mocks__/timelineHelperMocks'; @@ -48,14 +49,14 @@ describe('useGeojsonForTrip', () => { }); describe('compositeTrips2TimelineMap', () => { - const firstTripList = [mockTLH.mockData.phone_data[0].data]; - const secondTripList = [ + const tripListOne = [mockTLH.mockData.phone_data[0].data]; + const tripListTwo = [ mockTLH.mockDataTwo.phone_data[0].data, mockTLH.mockDataTwo.phone_data[1].data, ]; - const firstKey = mockTLH.mockData.phone_data[0].data._id.$oid; - const secondKey = mockTLH.mockDataTwo.phone_data[1].data._id.$oid; - const thirdKey = mockTLH.mockData.phone_data[0].data._id.$oid; + const keyOne = mockTLH.mockData.phone_data[0].data._id.$oid; + const keyTwo = mockTLH.mockDataTwo.phone_data[1].data._id.$oid; + const keyThree = mockTLH.mockData.phone_data[0].data._id.$oid; let testValue; it('Works with an empty list', () => { @@ -63,33 +64,53 @@ describe('compositeTrips2TimelineMap', () => { }); it('Works with a list of len = 1, no flag', () => { - testValue = compositeTrips2TimelineMap(firstTripList); + testValue = compositeTrips2TimelineMap(tripListOne); expect(testValue.size).toBe(1); - expect(testValue.get(firstKey)).toEqual(firstTripList[0]); + expect(testValue.get(keyOne)).toEqual(tripListOne[0]); }); it('Works with a list of len = 1, with flag', () => { - testValue = compositeTrips2TimelineMap(firstTripList, true); + testValue = compositeTrips2TimelineMap(tripListOne, true); expect(testValue.size).toBe(3); - expect(testValue.get(firstKey)).toEqual(firstTripList[0]); - expect(testValue.get('startConfirmedPlace')).toEqual(firstTripList[0].start_confirmed_place); - expect(testValue.get('endConfirmedPlace')).toEqual(firstTripList[0].end_confirmed_place); + expect(testValue.get(keyOne)).toEqual(tripListOne[0]); + expect(testValue.get('startConfirmedPlace')).toEqual(tripListOne[0].start_confirmed_place); + expect(testValue.get('endConfirmedPlace')).toEqual(tripListOne[0].end_confirmed_place); }); it('Works with a list of len >= 1, no flag', () => { - testValue = compositeTrips2TimelineMap(secondTripList); + testValue = compositeTrips2TimelineMap(tripListTwo); expect(testValue.size).toBe(2); - expect(testValue.get(secondKey)).toEqual(secondTripList[1]); - expect(testValue.get(thirdKey)).toEqual(secondTripList[0]); + expect(testValue.get(keyTwo)).toEqual(tripListTwo[1]); + expect(testValue.get(keyThree)).toEqual(tripListTwo[0]); }); it('Works with a list of len >= 1, with flag', () => { - testValue = compositeTrips2TimelineMap(secondTripList, true); + testValue = compositeTrips2TimelineMap(tripListTwo, true); console.log(`Len: ${testValue.size}`); expect(testValue.size).toBe(6); }); }); +// updateAllUnprocessedinputs tests +it('can use an appConfig to get labelInputKeys', () => { + const mockAppConfigOne = { + survey_info: { + 'trip-labels': 'ENKETO', + }, + }; + const mockAppConfigTwo = { + survey_info: { + 'trip-labels': 'Other', + }, + intro: { + mode_studied: 'sample', + }, + }; + expect(keysForLabelInputs(mockAppConfigOne)).rejects; + expect(keysForLabelInputs(mockAppConfigOne)).toEqual(['manual/trip_user_input']); + expect(keysForLabelInputs(mockAppConfigTwo).length).toEqual(3); +}); + // Tests for readAllCompositeTrips // Once we have end-to-end testing, we could utilize getRawEnteries. jest.mock('../js/services/commHelper', () => ({ From abbe618cb816bf6f5d3d4fdaaadd7536281e04c2 Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Thu, 16 Nov 2023 15:43:09 -0700 Subject: [PATCH 428/850] Add Jest Testing - getScheduledNotifs cordovaMocks.ts - Added a reminder mock for the cordova plugin for reminders notifScheduler.test.ts - Mock the cordova plugin to return the active reminders - Test the getScheduledNotifs function --- www/__mocks__/cordovaMocks.ts | 8 ++ www/__tests__/notifScheduler.test.ts | 115 +++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 www/__tests__/notifScheduler.test.ts diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 62aa9be1a..8b7322204 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -7,6 +7,14 @@ export const mockCordova = () => { window['cordova'].plugins ||= {}; }; +export const mockReminders = () => { + window['cordova'] ||= {}; + window['cordova'].plugins ||= {}; + window['cordova'].plugins.notification ||= {}; + window['cordova'].plugins.notification.local ||= {}; + window['cordova'].plugins.notification.local.getScheduled ||= () => []; +}; + export const mockDevice = () => { window['device'] ||= {}; window['device'].platform ||= 'ios'; diff --git a/www/__tests__/notifScheduler.test.ts b/www/__tests__/notifScheduler.test.ts new file mode 100644 index 000000000..de4ce27d7 --- /dev/null +++ b/www/__tests__/notifScheduler.test.ts @@ -0,0 +1,115 @@ +import { mockReminders } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; +import { DateTime } from 'luxon'; +import { + getScheduledNotifs, + updateScheduledNotifs, + getReminderPrefs, + setReminderPrefs, +} from '../js/splash/notifScheduler'; + +mockLogger(); +mockReminders(); + +jest.mock('../js/splash/notifScheduler', () => ({ + ...jest.requireActual('../js/splash/notifScheduler'), + getNotifs: jest.fn(), +})); + +describe('getScheduledNotifs', () => { + it('should resolve with notifications while not actively scheduling', async () => { + const isScheduling = false; + const scheduledPromise = Promise.resolve(); + const mockNotifications = [{ trigger: { at: DateTime.now().toMillis() } }]; + const expectedResult = [ + { + key: DateTime.fromMillis(mockNotifications[0].trigger.at).toFormat('DDD'), + val: DateTime.fromMillis(mockNotifications[0].trigger.at).toFormat('t'), + }, + ]; + + jest + .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') + .mockImplementation((callback) => callback(mockNotifications)); + const scheduledNotifs = await getScheduledNotifs(isScheduling, Promise.resolve()); + + expect(scheduledNotifs).toEqual(expectedResult); + }); + + it('should resolve with notifications if actively scheduling', async () => { + const isScheduling = true; + const scheduledPromise = Promise.resolve(); + const mockNotifications = [{ trigger: { at: DateTime.now().toMillis() } }]; + const expectedResult = [ + { + key: DateTime.fromMillis(mockNotifications[0].trigger.at).toFormat('DDD'), + val: DateTime.fromMillis(mockNotifications[0].trigger.at).toFormat('t'), + }, + ]; + + jest + .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') + .mockImplementation((callback) => callback(mockNotifications)); + const scheduledNotifs = await getScheduledNotifs(isScheduling, scheduledPromise); + + expect(scheduledNotifs).toEqual(expectedResult); + }); + + it('should handle case where no notifications are present', async () => { + const isScheduling = false; + const scheduledPromise = Promise.resolve(); + const mockNotifications = []; + const expectedResult = []; + + jest + .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') + .mockImplementation((callback) => callback(mockNotifications)); + const scheduledNotifs = await getScheduledNotifs(isScheduling, Promise.resolve()); + + expect(scheduledNotifs).toEqual(expectedResult); + }); + + it('should handle the case where greater than 5 notifications are present', async () => { + const isScheduling = false; + const scheduledPromise = Promise.resolve(); + const mockNotifications = [ + { trigger: { at: DateTime.now().toMillis() } }, + { trigger: { at: DateTime.now().plus({ weeks: 1 }).toMillis() } }, + { trigger: { at: DateTime.now().plus({ weeks: 2 }).toMillis() } }, + { trigger: { at: DateTime.now().plus({ weeks: 3 }).toMillis() } }, + { trigger: { at: DateTime.now().plus({ weeks: 4 }).toMillis() } }, + { trigger: { at: DateTime.now().plus({ weeks: 5 }).toMillis() } }, + { trigger: { at: DateTime.now().plus({ weeks: 6 }).toMillis() } }, + { trigger: { at: DateTime.now().plus({ weeks: 7 }).toMillis() } }, + ]; + const expectedResult = [ + { + key: DateTime.fromMillis(mockNotifications[0].trigger.at).toFormat('DDD'), + val: DateTime.fromMillis(mockNotifications[0].trigger.at).toFormat('t'), + }, + { + key: DateTime.fromMillis(mockNotifications[1].trigger.at).toFormat('DDD'), + val: DateTime.fromMillis(mockNotifications[1].trigger.at).toFormat('t'), + }, + { + key: DateTime.fromMillis(mockNotifications[2].trigger.at).toFormat('DDD'), + val: DateTime.fromMillis(mockNotifications[2].trigger.at).toFormat('t'), + }, + { + key: DateTime.fromMillis(mockNotifications[3].trigger.at).toFormat('DDD'), + val: DateTime.fromMillis(mockNotifications[3].trigger.at).toFormat('t'), + }, + { + key: DateTime.fromMillis(mockNotifications[4].trigger.at).toFormat('DDD'), + val: DateTime.fromMillis(mockNotifications[4].trigger.at).toFormat('t'), + }, + ]; + + jest + .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') + .mockImplementation((callback) => callback(mockNotifications)); + const scheduledNotifs = await getScheduledNotifs(isScheduling, Promise.resolve()); + + expect(scheduledNotifs).toEqual(expectedResult); + }); +}); From fbd1b626413ffc60082490c4bb09a5fda660adbd Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Thu, 16 Nov 2023 17:04:10 -0700 Subject: [PATCH 429/850] Add first updateScheduledNotifs test A lot of mocking was required to do this: globalMocks.ts - Added log mock for console.alert - Added log mock for console.error - Both of these are called in displayErrorMsg, which is why we have to mock them notifScheduler.ts - Added a resolve in the if statement for the case of "reminders are already scheduled", so that we don't keep running the test and possibly time-out notifScheduler.test.ts - added new mocks for clientStats, logger, commHelper, and notifScheduler - Added comments for clarity in tests - Renamed any instances of mockNotifications to mockNotifs for more clarity and to mirror the variable name notifs in the actual file - Created the new test for updateScheduledNotifs that currently only tests to see if it sees that notifications have already been scheduled and then ends --- www/__mocks__/globalMocks.ts | 6 + www/__tests__/notifScheduler.test.ts | 177 +++++++++++++++++++++++---- www/js/splash/notifScheduler.ts | 1 + 3 files changed, 162 insertions(+), 22 deletions(-) diff --git a/www/__mocks__/globalMocks.ts b/www/__mocks__/globalMocks.ts index f13cb274b..0e518897c 100644 --- a/www/__mocks__/globalMocks.ts +++ b/www/__mocks__/globalMocks.ts @@ -1,3 +1,9 @@ export const mockLogger = () => { window['Logger'] = { log: console.log }; + window.alert = (msg) => { + console.log(msg); + }; + console.error = (msg) => { + console.log(msg); + }; }; diff --git a/www/__tests__/notifScheduler.test.ts b/www/__tests__/notifScheduler.test.ts index de4ce27d7..00a925678 100644 --- a/www/__tests__/notifScheduler.test.ts +++ b/www/__tests__/notifScheduler.test.ts @@ -1,5 +1,6 @@ import { mockReminders } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; +import { logDebug } from '../js/plugin/logger'; import { DateTime } from 'luxon'; import { getScheduledNotifs, @@ -8,71 +9,169 @@ import { setReminderPrefs, } from '../js/splash/notifScheduler'; +const exampleReminderSchemes = { + weekly: { + title: { + en: 'Please take a moment to label your trips', + es: 'Por favor, tómese un momento para etiquetar sus viajes', + }, + text: { + en: 'Click to open the app and view unlabeled trips', + es: 'Haga clic para abrir la aplicación y ver los viajes sin etiquetar', + }, + schedule: [ + { start: 0, end: 1, intervalInDays: 1 }, + { start: 3, end: 5, intervalInDays: 2 }, + ], + defaultTime: '21:00', + }, + 'week-quarterly': { + title: { + en: 'Please take a moment to label your trips', + es: 'Por favor, tómese un momento para etiquetar sus viajes', + }, + text: { + en: 'Click to open the app and view unlabeled trips', + es: 'Haga clic para abrir la aplicación y ver los viajes sin etiquetar', + }, + schedule: [ + { start: 0, end: 1, intervalInDays: 1 }, + { start: 3, end: 5, intervalInDays: 2 }, + ], + defaultTime: '22:00', + }, + passive: { + title: { + en: 'Please take a moment to label your trips', + es: 'Por favor, tómese un momento para etiquetar sus viajes', + }, + text: { + en: 'Click to open the app and view unlabeled trips', + es: 'Haga clic para abrir la aplicación y ver los viajes sin etiquetar', + }, + schedule: [ + { start: 0, end: 1, intervalInDays: 1 }, + { start: 3, end: 5, intervalInDays: 2 }, + ], + defaultTime: '23:00', + }, +}; + mockLogger(); mockReminders(); +jest.mock('../js/commHelper', () => ({ + ...jest.requireActual('../js/commHelper'), + getUser: jest.fn(() => + Promise.resolve({ + // These values are **important**... + // reminder_assignment: must match a key from the reminder scheme above, + // reminder_join_date: must match the first day of the mocked notifs below in the tests, + // reminder_time_of_day: must match the defaultTime from the chosen reminder_assignment in the reminder scheme above + reminder_assignment: 'weekly', + reminder_join_date: '2023-11-14', + reminder_time_of_day: '21:00', + }), + ), + updateUser: jest.fn(() => Promise.resolve()), +})); + +jest.mock('../js/plugin/clientStats', () => ({ + ...jest.requireActual('../js/plugin/clientStats'), + addStatReading: jest.fn(), +})); + +jest.mock('../js/plugin/logger', () => ({ + ...jest.requireActual('../js/plugin/logger'), + logDebug: jest.fn(), +})); + jest.mock('../js/splash/notifScheduler', () => ({ ...jest.requireActual('../js/splash/notifScheduler'), + // for getScheduledNotifs getNotifs: jest.fn(), + // for updateScheduledNotifs + getReminderPrefs: jest.fn(), + calcNotifTimes: jest.fn(), + removeEmptyObjects: jest.fn(), + areAlreadyScheduled: jest.fn(), + scheduleNotifs: jest.fn(), })); describe('getScheduledNotifs', () => { it('should resolve with notifications while not actively scheduling', async () => { + // getScheduledNotifs arguments const isScheduling = false; const scheduledPromise = Promise.resolve(); - const mockNotifications = [{ trigger: { at: DateTime.now().toMillis() } }]; + // create the mock notifs from cordova plugin + const mockNotifs = [{ trigger: { at: DateTime.now().toMillis() } }]; + // create the expected result const expectedResult = [ { - key: DateTime.fromMillis(mockNotifications[0].trigger.at).toFormat('DDD'), - val: DateTime.fromMillis(mockNotifications[0].trigger.at).toFormat('t'), + key: DateTime.fromMillis(mockNotifs[0].trigger.at).toFormat('DDD'), + val: DateTime.fromMillis(mockNotifs[0].trigger.at).toFormat('t'), }, ]; + // mock the cordova plugin jest .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') - .mockImplementation((callback) => callback(mockNotifications)); + .mockImplementation((callback) => callback(mockNotifs)); + // call the function const scheduledNotifs = await getScheduledNotifs(isScheduling, Promise.resolve()); expect(scheduledNotifs).toEqual(expectedResult); }); it('should resolve with notifications if actively scheduling', async () => { + // getScheduledNotifs arguments const isScheduling = true; const scheduledPromise = Promise.resolve(); - const mockNotifications = [{ trigger: { at: DateTime.now().toMillis() } }]; + // create the mock notifs from cordova plugin + const mockNotifs = [{ trigger: { at: DateTime.now().toMillis() } }]; + // create the expected result const expectedResult = [ { - key: DateTime.fromMillis(mockNotifications[0].trigger.at).toFormat('DDD'), - val: DateTime.fromMillis(mockNotifications[0].trigger.at).toFormat('t'), + key: DateTime.fromMillis(mockNotifs[0].trigger.at).toFormat('DDD'), + val: DateTime.fromMillis(mockNotifs[0].trigger.at).toFormat('t'), }, ]; + // mock the cordova plugin jest .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') - .mockImplementation((callback) => callback(mockNotifications)); + .mockImplementation((callback) => callback(mockNotifs)); + // call the funciton const scheduledNotifs = await getScheduledNotifs(isScheduling, scheduledPromise); expect(scheduledNotifs).toEqual(expectedResult); }); it('should handle case where no notifications are present', async () => { + // getScheduledNotifs arguments const isScheduling = false; const scheduledPromise = Promise.resolve(); - const mockNotifications = []; + // create the mock notifs from cordova plugin + const mockNotifs = []; + // create the expected result const expectedResult = []; + // mock the cordova plugin jest .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') - .mockImplementation((callback) => callback(mockNotifications)); + .mockImplementation((callback) => callback(mockNotifs)); + // call the funciton const scheduledNotifs = await getScheduledNotifs(isScheduling, Promise.resolve()); expect(scheduledNotifs).toEqual(expectedResult); }); it('should handle the case where greater than 5 notifications are present', async () => { + // getScheduledNotifs arguments const isScheduling = false; const scheduledPromise = Promise.resolve(); - const mockNotifications = [ + // create the mock notifs from cordova plugin (greater than 5 notifications) + const mockNotifs = [ { trigger: { at: DateTime.now().toMillis() } }, { trigger: { at: DateTime.now().plus({ weeks: 1 }).toMillis() } }, { trigger: { at: DateTime.now().plus({ weeks: 2 }).toMillis() } }, @@ -82,34 +181,68 @@ describe('getScheduledNotifs', () => { { trigger: { at: DateTime.now().plus({ weeks: 6 }).toMillis() } }, { trigger: { at: DateTime.now().plus({ weeks: 7 }).toMillis() } }, ]; + // create the expected result (only the first 5 notifications) const expectedResult = [ { - key: DateTime.fromMillis(mockNotifications[0].trigger.at).toFormat('DDD'), - val: DateTime.fromMillis(mockNotifications[0].trigger.at).toFormat('t'), + key: DateTime.fromMillis(mockNotifs[0].trigger.at).toFormat('DDD'), + val: DateTime.fromMillis(mockNotifs[0].trigger.at).toFormat('t'), }, { - key: DateTime.fromMillis(mockNotifications[1].trigger.at).toFormat('DDD'), - val: DateTime.fromMillis(mockNotifications[1].trigger.at).toFormat('t'), + key: DateTime.fromMillis(mockNotifs[1].trigger.at).toFormat('DDD'), + val: DateTime.fromMillis(mockNotifs[1].trigger.at).toFormat('t'), }, { - key: DateTime.fromMillis(mockNotifications[2].trigger.at).toFormat('DDD'), - val: DateTime.fromMillis(mockNotifications[2].trigger.at).toFormat('t'), + key: DateTime.fromMillis(mockNotifs[2].trigger.at).toFormat('DDD'), + val: DateTime.fromMillis(mockNotifs[2].trigger.at).toFormat('t'), }, { - key: DateTime.fromMillis(mockNotifications[3].trigger.at).toFormat('DDD'), - val: DateTime.fromMillis(mockNotifications[3].trigger.at).toFormat('t'), + key: DateTime.fromMillis(mockNotifs[3].trigger.at).toFormat('DDD'), + val: DateTime.fromMillis(mockNotifs[3].trigger.at).toFormat('t'), }, { - key: DateTime.fromMillis(mockNotifications[4].trigger.at).toFormat('DDD'), - val: DateTime.fromMillis(mockNotifications[4].trigger.at).toFormat('t'), + key: DateTime.fromMillis(mockNotifs[4].trigger.at).toFormat('DDD'), + val: DateTime.fromMillis(mockNotifs[4].trigger.at).toFormat('t'), }, ]; + // mock the cordova plugin jest .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') - .mockImplementation((callback) => callback(mockNotifications)); + .mockImplementation((callback) => callback(mockNotifs)); + // call the funciton const scheduledNotifs = await getScheduledNotifs(isScheduling, Promise.resolve()); expect(scheduledNotifs).toEqual(expectedResult); }); }); + +describe('updateScheduledNotifs', () => { + afterEach(() => { + jest.restoreAllMocks(); // Restore mocked functions after each test + }); + + it('should resolve without scheduling if notifications are already scheduled', async () => { + // updateScheduleNotifs arguments + const reminderSchemes: any = exampleReminderSchemes; + let isScheduling: boolean = false; + const setIsScheduling: Function = jest.fn((val: boolean) => (isScheduling = val)); + const scheduledPromise: Promise = Promise.resolve(); + // create the mock notifs from cordova plugin (must match the notifs that will generate from the reminder scheme above... + // in this case: exampleReminderSchemes.weekly, because getUser is mocked to return reminder_assignment: 'weekly') + const mockNotifs = [ + { trigger: { at: DateTime.fromFormat('2023-11-14 21:00', 'yyyy-MM-dd HH:mm').toMillis() } }, + { trigger: { at: DateTime.fromFormat('2023-11-15 21:00', 'yyyy-MM-dd HH:mm').toMillis() } }, + { trigger: { at: DateTime.fromFormat('2023-11-17 21:00', 'yyyy-MM-dd HH:mm').toMillis() } }, + { trigger: { at: DateTime.fromFormat('2023-11-19 21:00', 'yyyy-MM-dd HH:mm').toMillis() } }, + ]; + + // mock the cordova plugin + jest + .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') + .mockImplementationOnce((callback) => callback(mockNotifs)); + // call the function + await updateScheduledNotifs(reminderSchemes, isScheduling, setIsScheduling, scheduledPromise); + + expect(logDebug).toHaveBeenCalledWith('Already scheduled, not scheduling again'); + }); +}); diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts index a92ac51c3..55c4b3cdb 100644 --- a/www/js/splash/notifScheduler.ts +++ b/www/js/splash/notifScheduler.ts @@ -191,6 +191,7 @@ export const updateScheduledNotifs = async ( notifs = removeEmptyObjects(notifs); if (areAlreadyScheduled(notifs, notifTimes)) { logDebug('Already scheduled, not scheduling again'); + resolve(); } else { // to ensure we don't overlap with the last scheduling() request, // we'll wait for the previous one to finish before scheduling again From 2c02e37a07d00e0a5c638b664e7408fae27abc9b Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Fri, 17 Nov 2023 11:31:44 -0800 Subject: [PATCH 430/850] Ran prettier, removed broken tests Cleaned up branch for review --- www/__mocks__/fileSystemMocks.ts | 10 -- www/__tests__/controlHelper.test.ts | 80 ------------ www/js/control/ProfileSettings.jsx | 2 +- www/js/services.js | 182 +++++++++++++++------------- 4 files changed, 97 insertions(+), 177 deletions(-) delete mode 100644 www/__tests__/controlHelper.test.ts diff --git a/www/__mocks__/fileSystemMocks.ts b/www/__mocks__/fileSystemMocks.ts index 9ed351f02..1648c2f4b 100644 --- a/www/__mocks__/fileSystemMocks.ts +++ b/www/__mocks__/fileSystemMocks.ts @@ -16,16 +16,6 @@ export const mockFileSystem = () => { }, nativeURL: 'file:///Users/Jest/test/URL/', isFile: true, - createWriter: (handleWriter) => { - var mockFileWriter: MockFileWriter = { - onreadend: null, - onerror: null, - write: (obj) => { - console.log(`Mock this: ${obj}`); - }, - }; - handleWriter(mockFileWriter); - }, }; onSuccess(fileEntry); }, diff --git a/www/__tests__/controlHelper.test.ts b/www/__tests__/controlHelper.test.ts deleted file mode 100644 index c62267d64..000000000 --- a/www/__tests__/controlHelper.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { mockLogger } from "../__mocks__/globalMocks"; -import { mockFileSystem } from "../__mocks__/fileSystemMocks"; -import { mockDevice, mockCordova, mockFile } from "../__mocks__/cordovaMocks"; - -import { getMyDataHelpers } from "../js/services/controlHelper"; -import { FsWindow } from "../js/types/fileShareTypes" -import { ServerData, ServerResponse} from "../js/types/serverData" - -mockDevice(); -mockCordova(); -mockFile(); -mockFileSystem(); -mockLogger(); -declare let window: FsWindow; - -// createWriteFile does not require these objects specifically, but it -// is better to test with similar data - using real data would take -// up too much code space, and we cannot use getRawEnteries() in testing -const generateFakeValues = (arraySize: number) => { - if (arraySize <= 0) - return Promise.reject('reject'); - - const sampleDataObj : ServerData= { - data: { - name: 'testValue #', - ts: 1234567890.9876543, - reading: 0.1234567891011121, - }, - metadata: { - key: 'MyKey/test', - platform: 'dev_testing', - time_zone: 'America/Los_Angeles', - write_fmt_time: '2023-04-14T00:09:10.80023-07:00', - write_local_dt: { - minute: 1, - hour: 2, - second: 3, - day: 4, - weekday: 5, - month: 6, - year: 7, - timezone: 'America/Los_Angeles', - }, - write_ts: 12345.6789, - }, - user_id: { - $uuid: '41t0l8e00s914tval1234567u9658699', - }, - _id: { - $oid: '12341x123afe3fbf541524d8', - } - }; - - // The parse/stringify lets us "deep copy" the objects, to quickly populate/change test data - let values = Array.from({length: arraySize}, e => JSON.parse(JSON.stringify(sampleDataObj))); - values.forEach((element, index) => { - values[index].data.name = element.data.name + index.toString() - }); - return Promise.resolve({ phone_data: values }); -}; - -// Test constants: -const fileName = 'testOne' -const startTime = '1969-06-16' -const endTime = '1969-06-24' -const getDataMethodsOne = getMyDataHelpers(fileName, startTime, endTime); -const writeFile = getDataMethodsOne.writeFile; - -const testPromiseOne = generateFakeValues(1); -const testPromiseTwo = generateFakeValues(2222); -const badPromise = generateFakeValues(0); - -it('writes a file for an array of objects', async () => { - expect(testPromiseOne.then(writeFile)).resolves.not.toThrow(); - expect(testPromiseTwo.then(writeFile)).resolves.not.toThrow(); -}); - -it('rejects an empty input', async () => { - expect(badPromise.then(writeFile)).rejects.toEqual('reject'); -}); \ No newline at end of file diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 2beeb2fde..110b24bfc 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -33,7 +33,7 @@ import { storageClear } from '../plugin/storage'; import { getAppVersion } from '../plugin/clientStats'; import { getConsentDocument } from '../splash/startprefs'; import { logDebug } from '../plugin/logger'; -import { fetchOPCode, getSettings } from "../services/controlHelper"; +import { fetchOPCode, getSettings } from '../services/controlHelper'; //any pure functions can go outside const ProfileSettings = () => { diff --git a/www/js/services.js b/www/js/services.js index d0d2b059a..6ed060ed9 100644 --- a/www/js/services.js +++ b/www/js/services.js @@ -34,110 +34,120 @@ angular .service('ControlHelper', function ($window, $ionicPopup, Logger) { this.writeFile = function (fileEntry, resultList) { // Create a FileWriter object for our FileEntry (log.txt). - } + }; - this.getMyData = function(startTs) { - var fmt = "YYYY-MM-DD"; - // We are only retrieving data for a single day to avoid - // running out of memory on the phone - var startMoment = moment(startTs); - var endMoment = moment(startTs).endOf("day"); - var dumpFile = startMoment.format(fmt) + "." - + endMoment.format(fmt) - + ".timeline"; - alert("Going to retrieve data to "+dumpFile); + this.getMyData = function (startTs) { + var fmt = 'YYYY-MM-DD'; + // We are only retrieving data for a single day to avoid + // running out of memory on the phone + var startMoment = moment(startTs); + var endMoment = moment(startTs).endOf('day'); + var dumpFile = startMoment.format(fmt) + '.' + endMoment.format(fmt) + '.timeline'; + alert('Going to retrieve data to ' + dumpFile); - var writeDumpFile = function(result) { - return new Promise(function(resolve, reject) { - var resultList = result.phone_data; - window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { - console.log('file system open: ' + fs.name); - fs.root.getFile(dumpFile, { create: true, exclusive: false }, function (fileEntry) { - console.log("fileEntry "+fileEntry.nativeURL+" is file?" + fileEntry.isFile.toString()); - fileEntry.createWriter(function (fileWriter) { - fileWriter.onwriteend = function() { - console.log("Successful file write..."); - resolve(); - // readFile(fileEntry); - }; + var writeDumpFile = function (result) { + return new Promise(function (resolve, reject) { + var resultList = result.phone_data; + window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function (fs) { + console.log('file system open: ' + fs.name); + fs.root.getFile(dumpFile, { create: true, exclusive: false }, function (fileEntry) { + console.log( + 'fileEntry ' + fileEntry.nativeURL + ' is file?' + fileEntry.isFile.toString(), + ); + fileEntry.createWriter(function (fileWriter) { + fileWriter.onwriteend = function () { + console.log('Successful file write...'); + resolve(); + // readFile(fileEntry); + }; - fileWriter.onerror = function (e) { - console.log("Failed file write: " + e.toString()); - reject(); - }; + fileWriter.onerror = function (e) { + console.log('Failed file write: ' + e.toString()); + reject(); + }; - // If data object is not passed in, - // create a new Blob instead. - var dataObj = new Blob([JSON.stringify(resultList, null, 2)], - { type: 'application/json' }); - fileWriter.write(dataObj); - }); - // this.writeFile(fileEntry, resultList); + // If data object is not passed in, + // create a new Blob instead. + var dataObj = new Blob([JSON.stringify(resultList, null, 2)], { + type: 'application/json', }); + fileWriter.write(dataObj); }); + // this.writeFile(fileEntry, resultList); }); - } - + }); + }); + }; - var emailData = function(result) { - return new Promise(function(resolve, reject) { - window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function(fs) { - console.log("During email, file system open: "+fs.name); - fs.root.getFile(dumpFile, null, function(fileEntry) { - console.log("fileEntry "+fileEntry.nativeURL+" is file?"+fileEntry.isFile.toString()); - fileEntry.file(function (file) { - var reader = new FileReader(); + var emailData = function (result) { + return new Promise(function (resolve, reject) { + window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function (fs) { + console.log('During email, file system open: ' + fs.name); + fs.root.getFile(dumpFile, null, function (fileEntry) { + console.log( + 'fileEntry ' + fileEntry.nativeURL + ' is file?' + fileEntry.isFile.toString(), + ); + fileEntry.file( + function (file) { + var reader = new FileReader(); - reader.onloadend = function() { - console.log("Successful file read with " + this.result.length +" characters"); - var dataArray = JSON.parse(this.result); - console.log("Successfully read resultList of size "+dataArray.length); - // displayFileData(fileEntry.fullPath + ": " + this.result); - var attachFile = fileEntry.nativeURL; - if (ionic.Platform.isAndroid()) { - // At least on nexus, getting a temporary file puts it into - // the cache, so I can hardcode that for now - attachFile = "app://cache/"+dumpFile; - } - if (ionic.Platform.isIOS()) { - alert(i18next.t('email-service.email-account-mail-app')); - } - var email = { - attachments: [ - attachFile - ], - subject: i18next.t('email-service.email-data.subject-data-dump-from-to', {start: startMoment.format(fmt),end: endMoment.format(fmt)}), - body: i18next.t('email-service.email-data.body-data-consists-of-list-of-entries') - } - $window.cordova.plugins.email.open(email).then(resolve()); + reader.onloadend = function () { + console.log('Successful file read with ' + this.result.length + ' characters'); + var dataArray = JSON.parse(this.result); + console.log('Successfully read resultList of size ' + dataArray.length); + // displayFileData(fileEntry.fullPath + ": " + this.result); + var attachFile = fileEntry.nativeURL; + if (ionic.Platform.isAndroid()) { + // At least on nexus, getting a temporary file puts it into + // the cache, so I can hardcode that for now + attachFile = 'app://cache/' + dumpFile; } - reader.readAsText(file); - }, function(error) { - $ionicPopup.alert({title: "Error while downloading JSON dump", - template: error}); - reject(error); + if (ionic.Platform.isIOS()) { + alert(i18next.t('email-service.email-account-mail-app')); + } + var email = { + attachments: [attachFile], + subject: i18next.t('email-service.email-data.subject-data-dump-from-to', { + start: startMoment.format(fmt), + end: endMoment.format(fmt), + }), + body: i18next.t( + 'email-service.email-data.body-data-consists-of-list-of-entries', + ), + }; + $window.cordova.plugins.email.open(email).then(resolve()); + }; + reader.readAsText(file); + }, + function (error) { + $ionicPopup.alert({ + title: 'Error while downloading JSON dump', + template: error, }); - }); - }); + reject(error); + }, + ); }); - }; + }); + }); + }; - getRawEntries(null, startMoment.unix(), endMoment.unix()) - .then(writeDumpFile) - .then(emailData) - .then(function() { - Logger.log("Email queued successfully"); - }) - .catch(function(error) { - Logger.displayError("Error emailing JSON dump", error); - }) + getRawEntries(null, startMoment.unix(), endMoment.unix()) + .then(writeDumpFile) + .then(emailData) + .then(function () { + Logger.log('Email queued successfully'); + }) + .catch(function (error) { + Logger.displayError('Error emailing JSON dump', error); + }); }; - this.getOPCode = function() { + this.getOPCode = function () { return window.cordova.plugins.OPCodeAuth.getOPCode(); }; - this.getSettings = function() { + this.getSettings = function () { return window.cordova.plugins.BEMConnectionSettings.getSettings(); }; }); From 1f495e21b298918b7310b9d750183821187f39e3 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 17 Nov 2023 13:56:31 -0700 Subject: [PATCH 431/850] clean up bugfix fixed typo, made single line, and added to horizontal chart as well https://github.com/e-mission/e-mission-phone/pull/1098#discussion_r1397765721 --- www/js/components/Chart.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/www/js/components/Chart.tsx b/www/js/components/Chart.tsx index 8d9154713..372d7e6c8 100644 --- a/www/js/components/Chart.tsx +++ b/www/js/components/Chart.tsx @@ -138,7 +138,8 @@ const Chart = ({ logDebug(`Horizontal axis callback: i = ${i}; chartDatasets = ${JSON.stringify(chartDatasets)}; chartDatasets[0].data = ${JSON.stringify(chartDatasets[0].data)}`); - const label = chartDatasets[0].data[i].y; + //account for different data possiblities + const label = chartDatasets[0].data[i]?.y || chartDatasets[i].data[0]?.y if (typeof label == 'string' && label.includes('\n')) return label.split('\n'); return label; @@ -175,13 +176,8 @@ const Chart = ({ logDebug(`Vertical axis callback: i = ${i}; chartDatasets = ${JSON.stringify(chartDatasets)}; chartDatasets[0].data = ${JSON.stringify(chartDatasets[0].data)}`); - //account for different data possiblities - one mode per weeek, one mode both weeks, mixed weeks - let label; - if (chartDatasets[0].data[i]) { - label = chartDatasets[0].data[i].x; - } else { - label = chartDatasets[i].data[0].x; - } + //account for different data possiblities - one mode per week, one mode both weeks, mixed weeks + const label = chartDatasets[0].data[i]?.x || chartDatasets[i].data[0]?.x if (typeof label == 'string' && label.includes('\n')) return label.split('\n'); return label; From ef3bd59e2d1b6ad76371ade69855485e5c2c9b51 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 17 Nov 2023 13:59:42 -0700 Subject: [PATCH 432/850] renamed file CustomMetricsHelper -> customMetricsHelper --- www/__tests__/customMetricsHelper.test.ts | 2 +- www/__tests__/footprintHelper.test.ts | 2 +- www/__tests__/metHelper.test.ts | 2 +- www/js/App.tsx | 2 +- www/js/metrics/footprintHelper.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/www/__tests__/customMetricsHelper.test.ts b/www/__tests__/customMetricsHelper.test.ts index 0f221e12c..0a45f739a 100644 --- a/www/__tests__/customMetricsHelper.test.ts +++ b/www/__tests__/customMetricsHelper.test.ts @@ -3,7 +3,7 @@ import { getCustomFootprint, getCustomMETs, initCustomDatasetHelper, -} from '../js/metrics/CustomMetricsHelper'; +} from '../js/metrics/customMetricsHelper'; import { setUseCustomMET } from '../js/metrics/metHelper'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; diff --git a/www/__tests__/footprintHelper.test.ts b/www/__tests__/footprintHelper.test.ts index 1de4fd701..5360b7a39 100644 --- a/www/__tests__/footprintHelper.test.ts +++ b/www/__tests__/footprintHelper.test.ts @@ -1,4 +1,4 @@ -import { initCustomDatasetHelper } from '../js/metrics/CustomMetricsHelper'; +import { initCustomDatasetHelper } from '../js/metrics/customMetricsHelper'; import { clearHighestFootprint, getFootprintForMetrics, diff --git a/www/__tests__/metHelper.test.ts b/www/__tests__/metHelper.test.ts index ee4fbd70d..ea36ec87f 100644 --- a/www/__tests__/metHelper.test.ts +++ b/www/__tests__/metHelper.test.ts @@ -3,7 +3,7 @@ import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; import fakeLabels from '../__mocks__/fakeLabels.json'; import { getConfig } from '../js/config/dynamicConfig'; -import { initCustomDatasetHelper } from '../js/metrics/CustomMetricsHelper'; +import { initCustomDatasetHelper } from '../js/metrics/customMetricsHelper'; mockBEMUserCache(); mockLogger(); diff --git a/www/js/App.tsx b/www/js/App.tsx index 77bf42463..2eece7f55 100644 --- a/www/js/App.tsx +++ b/www/js/App.tsx @@ -19,7 +19,7 @@ import { initPushNotify } from './splash/pushNotifySettings'; import { initStoreDeviceSettings } from './splash/storeDeviceSettings'; import { initRemoteNotifyHandler } from './splash/remoteNotifyHandler'; import { withErrorBoundary } from './plugin/ErrorBoundary'; -import { initCustomDatasetHelper } from './metrics/CustomMetricsHelper'; +import { initCustomDatasetHelper } from './metrics/customMetricsHelper'; const defaultRoutes = (t) => [ { diff --git a/www/js/metrics/footprintHelper.ts b/www/js/metrics/footprintHelper.ts index fd4ef8122..e5a615c4a 100644 --- a/www/js/metrics/footprintHelper.ts +++ b/www/js/metrics/footprintHelper.ts @@ -1,5 +1,5 @@ import { displayErrorMsg, logDebug } from '../plugin/logger'; -import { getCustomFootprint } from './CustomMetricsHelper'; +import { getCustomFootprint } from './customMetricsHelper'; //variables for the highest footprint in the set and if using custom let highestFootprint = 0; From df8ec72287d95872aedaa05fdb80b4de60a465d4 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 17 Nov 2023 14:07:34 -0700 Subject: [PATCH 433/850] input Params -> labelOptions --- www/js/metrics/CustomMetricsHelper.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/www/js/metrics/CustomMetricsHelper.ts b/www/js/metrics/CustomMetricsHelper.ts index 5a70933f8..3b479f8a2 100644 --- a/www/js/metrics/CustomMetricsHelper.ts +++ b/www/js/metrics/CustomMetricsHelper.ts @@ -1,6 +1,5 @@ import angular from 'angular'; import { getLabelOptions } from '../survey/multilabel/confirmHelper'; -import { getConfig } from '../config/dynamicConfig'; import { displayError, displayErrorMsg, logDebug } from '../plugin/logger'; import { standardMETs } from './metDataset'; @@ -8,7 +7,7 @@ import { standardMETs } from './metDataset'; let _customMETs; let _customPerKmFootprint; let _range_limited_motorized; -let _inputParams; +let _labelOptions; /** * @function gets custom mets, must be initialized @@ -30,10 +29,10 @@ export const getCustomFootprint = function () { /** * @function stores custom mets in local var - * needs _inputParams, label options stored after gotten from config + * needs _labelOptions, stored after gotten from config */ const populateCustomMETs = function () { - let modeOptions = _inputParams['MODE']; + let modeOptions = _labelOptions['MODE']; let modeMETEntries = modeOptions.map((opt) => { if (opt.met_equivalent) { let currMET = standardMETs[opt.met_equivalent]; @@ -69,7 +68,7 @@ const populateCustomMETs = function () { * needs _inputParams which is stored after gotten from config */ const populateCustomFootprints = function () { - let modeOptions = _inputParams['MODE']; + let modeOptions = _labelOptions['MODE']; let modeCO2PerKm = modeOptions .map((opt) => { if (opt.range_limit_km) { @@ -103,7 +102,7 @@ export const initCustomDatasetHelper = async function (newConfig) { logDebug('initializing custom datasets with config' + newConfig); getLabelOptions(newConfig).then((inputParams) => { console.log('Input params = ', inputParams); - _inputParams = inputParams; + _labelOptions = inputParams; populateCustomMETs(); populateCustomFootprints(); }); From c1607887fafa37c576369bdecb1e3d36de52193a Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 17 Nov 2023 14:16:09 -0700 Subject: [PATCH 434/850] footprint will always be custom removing the "is custom" variable for the footprint, and all references checking that footprint gotten is defined before returning, if not defined will throw an error --- www/__tests__/customMetricsHelper.test.ts | 2 -- www/__tests__/footprintHelper.test.ts | 6 ------ www/js/metrics/CarbonFootprintCard.tsx | 6 ------ www/js/metrics/footprintHelper.ts | 14 +++----------- 4 files changed, 3 insertions(+), 25 deletions(-) diff --git a/www/__tests__/customMetricsHelper.test.ts b/www/__tests__/customMetricsHelper.test.ts index 0a45f739a..251c1d977 100644 --- a/www/__tests__/customMetricsHelper.test.ts +++ b/www/__tests__/customMetricsHelper.test.ts @@ -8,7 +8,6 @@ import { setUseCustomMET } from '../js/metrics/metHelper'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; import fakeLabels from '../__mocks__/fakeLabels.json'; -import { setUseCustomFootprint } from '../js/metrics/footprintHelper'; mockBEMUserCache(); mockLogger(); @@ -42,7 +41,6 @@ it('gets the custom mets', async () => { it('gets the custom footprint', async () => { initCustomDatasetHelper(getConfig()); - setUseCustomFootprint(true); await new Promise((r) => setTimeout(r, 800)); expect(getCustomFootprint()).toMatchObject({ walk: {}, diff --git a/www/__tests__/footprintHelper.test.ts b/www/__tests__/footprintHelper.test.ts index 5360b7a39..3f6883bbe 100644 --- a/www/__tests__/footprintHelper.test.ts +++ b/www/__tests__/footprintHelper.test.ts @@ -4,7 +4,6 @@ import { getFootprintForMetrics, getHighestFootprint, getHighestFootprintForDistance, - setUseCustomFootprint, } from '../js/metrics/footprintHelper'; import { getConfig } from '../js/config/dynamicConfig'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; @@ -28,7 +27,6 @@ global.fetch = (url: string) => }) as any; beforeEach(() => { - setUseCustomFootprint(false); clearHighestFootprint(); }); @@ -42,28 +40,24 @@ const custom_metrics = [ it('gets footprint for metrics (custom, fallback 0)', async () => { initCustomDatasetHelper(getConfig()); - setUseCustomFootprint(true); await new Promise((r) => setTimeout(r, 500)); expect(getFootprintForMetrics(custom_metrics, 0)).toBe(2.4266); }); it('gets footprint for metrics (custom, fallback 0.1)', async () => { initCustomDatasetHelper(getConfig()); - setUseCustomFootprint(true); await new Promise((r) => setTimeout(r, 500)); expect(getFootprintForMetrics(custom_metrics, 0.1)).toBe(2.4266 + 0.5); }); it('gets the highest footprint from the dataset, custom', async () => { initCustomDatasetHelper(getConfig()); - setUseCustomFootprint(true); await new Promise((r) => setTimeout(r, 500)); expect(getHighestFootprint()).toBe(0.30741); }); it('gets the highest footprint for distance, custom', async () => { initCustomDatasetHelper(getConfig()); - setUseCustomFootprint(true); await new Promise((r) => setTimeout(r, 500)); expect(getHighestFootprintForDistance(12345)).toBe(0.30741 * (12345 / 1000)); }); diff --git a/www/js/metrics/CarbonFootprintCard.tsx b/www/js/metrics/CarbonFootprintCard.tsx index 30c265bfc..835f20a22 100644 --- a/www/js/metrics/CarbonFootprintCard.tsx +++ b/www/js/metrics/CarbonFootprintCard.tsx @@ -5,7 +5,6 @@ import { MetricsData } from './metricsTypes'; import { cardStyles } from './MetricsTab'; import { getFootprintForMetrics, - setUseCustomFootprint, getHighestFootprint, getHighestFootprintForDistance, } from './footprintHelper'; @@ -53,11 +52,6 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { //setting up data to be displayed let graphRecords = []; - //set custon dataset, if the labels are custom - if (isCustomLabels(userThisWeekModeMap)) { - setUseCustomFootprint(true); - } - //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago) let userPrevWeek; if (userLastWeekSummaryMap[0]) { diff --git a/www/js/metrics/footprintHelper.ts b/www/js/metrics/footprintHelper.ts index e5a615c4a..8b3a5de9e 100644 --- a/www/js/metrics/footprintHelper.ts +++ b/www/js/metrics/footprintHelper.ts @@ -3,7 +3,6 @@ import { getCustomFootprint } from './customMetricsHelper'; //variables for the highest footprint in the set and if using custom let highestFootprint = 0; -let useCustom = false; /** * @function converts meters to kilometers @@ -14,14 +13,6 @@ const mtokm = function (v) { return v / 1000; }; -/** - * @function sets the value of useCustom - * @param {boolean} val if using custom footprint - */ -export const setUseCustomFootprint = function (val: boolean) { - useCustom = val; -}; - /** * @function clears the stored highest footprint */ @@ -37,8 +28,9 @@ export const clearHighestFootprint = function () { * @returns the footprint or undefined */ const getFootprint = function () { - if (useCustom == true) { - return getCustomFootprint(); + let footprint = getCustomFootprint(); + if (footprint) { + return footprint; } else { displayErrorMsg('failed to use custom labels', 'Error in Footprint Calculatons'); return undefined; From 4c1a4a6a3d9a00d0c90e0f554fb29807cb8bdd28 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 17 Nov 2023 14:19:02 -0700 Subject: [PATCH 435/850] rename the metDatasets file even though its an acronym, it is less confusing to have the name be lowercase since it is not a component --- www/js/metrics/{METDataset.ts => metDatasets.ts} | 0 www/js/metrics/metHelper.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename www/js/metrics/{METDataset.ts => metDatasets.ts} (100%) diff --git a/www/js/metrics/METDataset.ts b/www/js/metrics/metDatasets.ts similarity index 100% rename from www/js/metrics/METDataset.ts rename to www/js/metrics/metDatasets.ts diff --git a/www/js/metrics/metHelper.ts b/www/js/metrics/metHelper.ts index c5ea7554e..d3522e152 100644 --- a/www/js/metrics/metHelper.ts +++ b/www/js/metrics/metHelper.ts @@ -1,5 +1,5 @@ import { getCustomMETs } from './customMetricsHelper'; -import { standardMETs } from './metDataset'; +import { standardMETs } from './metDatasets'; let useCustom = false; From b655f4a17ea4d2630ade33af0375ce5672082896 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 17 Nov 2023 14:23:53 -0700 Subject: [PATCH 436/850] name updates, just one dataset --- www/js/metrics/{metDatasets.ts => metDataset.ts} | 0 www/js/metrics/metHelper.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename www/js/metrics/{metDatasets.ts => metDataset.ts} (100%) diff --git a/www/js/metrics/metDatasets.ts b/www/js/metrics/metDataset.ts similarity index 100% rename from www/js/metrics/metDatasets.ts rename to www/js/metrics/metDataset.ts diff --git a/www/js/metrics/metHelper.ts b/www/js/metrics/metHelper.ts index d3522e152..c5ea7554e 100644 --- a/www/js/metrics/metHelper.ts +++ b/www/js/metrics/metHelper.ts @@ -1,5 +1,5 @@ import { getCustomMETs } from './customMetricsHelper'; -import { standardMETs } from './metDatasets'; +import { standardMETs } from './metDataset'; let useCustom = false; From dec322fc7262408d7814059a0b77cbd6d8ccc6e9 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 17 Nov 2023 14:24:06 -0700 Subject: [PATCH 437/850] prettier in Chart.tsx --- www/js/components/Chart.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/www/js/components/Chart.tsx b/www/js/components/Chart.tsx index 372d7e6c8..257eb3cf6 100644 --- a/www/js/components/Chart.tsx +++ b/www/js/components/Chart.tsx @@ -139,7 +139,8 @@ const Chart = ({ chartDatasets = ${JSON.stringify(chartDatasets)}; chartDatasets[0].data = ${JSON.stringify(chartDatasets[0].data)}`); //account for different data possiblities - const label = chartDatasets[0].data[i]?.y || chartDatasets[i].data[0]?.y + const label = + chartDatasets[0].data[i]?.y || chartDatasets[i].data[0]?.y; if (typeof label == 'string' && label.includes('\n')) return label.split('\n'); return label; @@ -177,7 +178,8 @@ const Chart = ({ chartDatasets = ${JSON.stringify(chartDatasets)}; chartDatasets[0].data = ${JSON.stringify(chartDatasets[0].data)}`); //account for different data possiblities - one mode per week, one mode both weeks, mixed weeks - const label = chartDatasets[0].data[i]?.x || chartDatasets[i].data[0]?.x + const label = + chartDatasets[0].data[i]?.x || chartDatasets[i].data[0]?.x; if (typeof label == 'string' && label.includes('\n')) return label.split('\n'); return label; From a29957459dcb34224c0124998f0818350d2ef72c Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 17 Nov 2023 14:40:33 -0700 Subject: [PATCH 438/850] remove debugging log statements --- www/js/survey/multilabel/confirmHelper.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index 7fffa54cb..f032f2f5a 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -37,7 +37,6 @@ export let inputDetails: InputDetails; export async function getLabelOptions(appConfigParam?) { if (appConfigParam) appConfig = appConfigParam; if (labelOptions) return labelOptions; - console.log('in get label options', appConfig); if (appConfig.label_options) { const labelOptionsJson = await fetchUrlCached(appConfig.label_options); logDebug( @@ -50,7 +49,6 @@ export async function getLabelOptions(appConfigParam?) { 'No label_options found in config, using default label options at ' + defaultLabelOptionsURL, ); const defaultLabelOptionsJson = await fetchUrlCached(defaultLabelOptionsURL); - console.log('label options', defaultLabelOptionsJson); labelOptions = JSON.parse(defaultLabelOptionsJson) as LabelOptions; } /* fill in the translations to the 'text' fields of the labelOptions, From 6272fb0d11d6e9e1558798f3cc2a7e865b6c17ff Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 17 Nov 2023 14:44:13 -0700 Subject: [PATCH 439/850] logDebug statements logDebug/logWarn instead of the console equivalents --- www/js/metrics/metHelper.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/www/js/metrics/metHelper.ts b/www/js/metrics/metHelper.ts index c5ea7554e..377352a9b 100644 --- a/www/js/metrics/metHelper.ts +++ b/www/js/metrics/metHelper.ts @@ -1,3 +1,4 @@ +import { logDebug, logWarn } from '../plugin/logger'; import { getCustomMETs } from './customMetricsHelper'; import { standardMETs } from './metDataset'; @@ -52,19 +53,19 @@ const mpstomph = function (mps) { */ export const getMet = function (mode, speed, defaultIfMissing) { if (mode == 'ON_FOOT') { - console.log("getMet() converted 'ON_FOOT' to 'WALKING'"); + logDebug("getMet() converted 'ON_FOOT' to 'WALKING'"); mode = 'WALKING'; } let currentMETs = getMETs(); if (!currentMETs[mode]) { - console.warn('getMet() Illegal mode: ' + mode); + logWarn('getMet() Illegal mode: ' + mode); return defaultIfMissing; //So the calorie sum does not break with wrong return type } for (var i in currentMETs[mode]) { if (between(mpstomph(speed), currentMETs[mode][i].range[0], currentMETs[mode][i].range[1])) { return currentMETs[mode][i].mets; } else if (mpstomph(speed) < 0) { - console.log('getMet() Negative speed: ' + mpstomph(speed)); + logWarn('getMet() Negative speed: ' + mpstomph(speed)); return 0; } } From 6233af5b96a356d0cc50d00dff491bb67c8e781a Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 17 Nov 2023 14:50:00 -0700 Subject: [PATCH 440/850] logWarn instead of console.warn logWarn in footprintHelper --- www/js/metrics/footprintHelper.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/www/js/metrics/footprintHelper.ts b/www/js/metrics/footprintHelper.ts index 8b3a5de9e..b42f5364a 100644 --- a/www/js/metrics/footprintHelper.ts +++ b/www/js/metrics/footprintHelper.ts @@ -1,4 +1,4 @@ -import { displayErrorMsg, logDebug } from '../plugin/logger'; +import { displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; import { getCustomFootprint } from './customMetricsHelper'; //variables for the highest footprint in the set and if using custom @@ -67,11 +67,10 @@ export const getFootprintForMetrics = function (userMetrics, defaultIfMissing = 6) * mtokm(userMetrics[i].values); } else { - console.warn( - 'WARNING getFootprintFromMetrics() was requested for an unknown mode: ' + - mode + - ' metrics JSON: ' + - JSON.stringify(userMetrics), + logWarn( + `WARNING getFootprintFromMetrics() was requested for an unknown mode: ${mode} metrics JSON: ${JSON.stringify( + userMetrics, + )}`, ); result += defaultIfMissing * mtokm(userMetrics[i].values); } From 5918b6be7ac3ab8497706b1e2e1135fb74efb542 Mon Sep 17 00:00:00 2001 From: Abby Wheelis <54848919+Abby-Wheelis@users.noreply.github.com> Date: Fri, 17 Nov 2023 14:55:47 -0700 Subject: [PATCH 441/850] Rename CustomMetricsHelper.ts to customMetricsHelper.ts --- www/js/metrics/{CustomMetricsHelper.ts => customMetricsHelper.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename www/js/metrics/{CustomMetricsHelper.ts => customMetricsHelper.ts} (100%) diff --git a/www/js/metrics/CustomMetricsHelper.ts b/www/js/metrics/customMetricsHelper.ts similarity index 100% rename from www/js/metrics/CustomMetricsHelper.ts rename to www/js/metrics/customMetricsHelper.ts From dbe76d11d0011d1e349020bdd918beda6a154df6 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Fri, 17 Nov 2023 14:01:08 -0800 Subject: [PATCH 442/850] Switched to old fileIO, fixes issue with android - Android was having issues with the new fileIO - since we are not mocking these functions, we can switch back for now. --- www/js/services/controlHelper.ts | 69 ++++++++++---------------------- 1 file changed, 21 insertions(+), 48 deletions(-) diff --git a/www/js/services/controlHelper.ts b/www/js/services/controlHelper.ts index f2741b0dd..f3f93013c 100644 --- a/www/js/services/controlHelper.ts +++ b/www/js/services/controlHelper.ts @@ -16,38 +16,34 @@ export const getMyDataHelpers = function ( const localWriteFile = function (result: ServerResponse) { const resultList = result.phone_data; return new Promise(function (resolve, reject) { - window['resolveLocalFileSystemURL'](window['cordova'].file.tempDirectory, function (fs) { - fs.filesystem.root.getFile( - fileName, - { create: true, exclusive: false }, - function (fileEntry) { - logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`); - fileEntry.createWriter(function (fileWriter) { - fileWriter.onwriteend = function () { - logDebug('Successful file write...'); - resolve(); - }; - fileWriter.onerror = function (e) { - logDebug(`Failed file write: ${e.toString()}`); - reject(); - }; - logDebug(`fileWriter is: ${JSON.stringify(fileWriter.onwriteend, null, 2)}`); - // if data object is not passed in, create a new blob instead. - const dataObj = new Blob([JSON.stringify(resultList, null, 2)], { - type: 'application/json', - }); - fileWriter.write(dataObj); + window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function (fs) { + fs.root.getFile(fileName, { create: true, exclusive: false }, function (fileEntry) { + logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`); + fileEntry.createWriter(function (fileWriter) { + fileWriter.onwriteend = function () { + logDebug('Successful file write...'); + resolve(); + }; + fileWriter.onerror = function (e) { + logDebug(`Failed file write: ${e.toString()}`); + reject(); + }; + logDebug(`fileWriter is: ${JSON.stringify(fileWriter.onwriteend, null, 2)}`); + // if data object is not passed in, create a new blob instead. + const dataObj = new Blob([JSON.stringify(resultList, null, 2)], { + type: 'application/json', }); - }, - ); + fileWriter.write(dataObj); + }); + }); }); }); }; const localShareData = function () { return new Promise(function (resolve, reject) { - window['resolveLocalFileSystemURL'](window['cordova'].file.tempDirectory, function (fs) { - fs.filesystem.root.getFile(fileName, null, function (fileEntry) { + window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function (fs) { + fs.root.getFile(fileName, null, function (fileEntry) { logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`); fileEntry.file( function (file) { @@ -93,31 +89,9 @@ export const getMyDataHelpers = function ( }); }; - // window['cordova'].file.TempDirectory is not guaranteed to free up memory, - // so it's good practice to remove the file right after it's used! - const localClearData = function () { - return new Promise(function (resolve, reject) { - window['resolveLocalFileSystemURL'](window['cordova'].file.tempDirectory, function (fs) { - fs.filesystem.root.getFile(fileName, null, function (fileEntry) { - fileEntry.remove( - () => { - logDebug(`Successfully cleaned up file ${fileName}`); - resolve(); - }, - (err) => { - logWarn(`Error deleting ${fileName} : ${err}`); - reject(err); - }, - ); - }); - }); - }); - }; - return { writeFile: localWriteFile, shareData: localShareData, - clearData: localClearData, }; }; @@ -141,7 +115,6 @@ export const getMyData = function (timeStamp: Date) { getRawEntries(null, startTime.toUnixInteger(), endTime.toUnixInteger()) .then(getDataMethods.writeFile) .then(getDataMethods.shareData) - .then(getDataMethods.clearData) .then(function () { logInfo('Share queued successfully'); }) From 125faffa88d166f6123599c0774a803cf1b85a13 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 17 Nov 2023 15:06:57 -0700 Subject: [PATCH 443/850] cleaning up customMetrics making sure the log statements here are thoughtful, renaming the input params to label options in the initialization code --- www/js/metrics/customMetricsHelper.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/www/js/metrics/customMetricsHelper.ts b/www/js/metrics/customMetricsHelper.ts index 3b479f8a2..3a2f551ca 100644 --- a/www/js/metrics/customMetricsHelper.ts +++ b/www/js/metrics/customMetricsHelper.ts @@ -1,6 +1,6 @@ import angular from 'angular'; import { getLabelOptions } from '../survey/multilabel/confirmHelper'; -import { displayError, displayErrorMsg, logDebug } from '../plugin/logger'; +import { displayError, displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; import { standardMETs } from './metDataset'; //variables to store values locally @@ -52,9 +52,7 @@ const populateCustomMETs = function () { } return [opt.value, currMET]; } else { - console.warn( - 'Did not find either met_equivalent or met for ' + opt.value + ' ignoring entry', - ); + logWarn(`Did not find either met_equivalent or met for ${opt.value} ignoring entry`); return undefined; } } @@ -79,7 +77,7 @@ const populateCustomFootprints = function () { ); } _range_limited_motorized = opt; - console.log('Found range limited motorized mode', _range_limited_motorized); + logDebug(`Found range limited motorized mode - ${_range_limited_motorized}`); } if (angular.isDefined(opt.kgCo2PerKm)) { return [opt.value, opt.kgCo2PerKm]; @@ -100,9 +98,9 @@ const populateCustomFootprints = function () { export const initCustomDatasetHelper = async function (newConfig) { try { logDebug('initializing custom datasets with config' + newConfig); - getLabelOptions(newConfig).then((inputParams) => { - console.log('Input params = ', inputParams); - _labelOptions = inputParams; + getLabelOptions(newConfig).then((labelOptions) => { + console.log('In custom metrics, label options: ', labelOptions); + _labelOptions = labelOptions; populateCustomMETs(); populateCustomFootprints(); }); From e626f6e36c5b5cdadfb16968f03204cf024b2066 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 17 Nov 2023 15:42:02 -0700 Subject: [PATCH 444/850] clarify "in vehicle" calculations The divisor being on it's own line was confusing, so now we are adding the vehicle modes and then dividing this should make the fact that it's an average more clear, and is one line shorter! --- www/js/metrics/footprintHelper.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/www/js/metrics/footprintHelper.ts b/www/js/metrics/footprintHelper.ts index b42f5364a..6e4bd46d9 100644 --- a/www/js/metrics/footprintHelper.ts +++ b/www/js/metrics/footprintHelper.ts @@ -57,15 +57,14 @@ export const getFootprintForMetrics = function (userMetrics, defaultIfMissing = if (mode in footprint) { result += footprint[mode] * mtokm(userMetrics[i].values); } else if (mode == 'IN_VEHICLE') { - result += - ((footprint['CAR'] + - footprint['BUS'] + - footprint['LIGHT_RAIL'] + - footprint['TRAIN'] + - footprint['TRAM'] + - footprint['SUBWAY']) / - 6) * - mtokm(userMetrics[i].values); + const sum = + footprint['CAR'] + + footprint['BUS'] + + footprint['LIGHT_RAIL'] + + footprint['TRAIN'] + + footprint['TRAM'] + + footprint['SUBWAY']; + result += (sum / 6) * mtokm(userMetrics[i].values); } else { logWarn( `WARNING getFootprintFromMetrics() was requested for an unknown mode: ${mode} metrics JSON: ${JSON.stringify( From b040691e4db8270c1a2ad7a45104fefa0a4630ab Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 17 Nov 2023 15:54:27 -0700 Subject: [PATCH 445/850] all mets are custom similar to the footprint, we will always use the custom METs, since the labels are custom (either deployer specified or our sample set) The custom mets are set up in the initialization of the custom dataset helper if the custom mets return undefined, will fall back to the standard mets --- www/__tests__/customMetricsHelper.test.ts | 2 -- www/__tests__/metHelper.test.ts | 7 +------ www/js/metrics/metHelper.ts | 15 +++------------ 3 files changed, 4 insertions(+), 20 deletions(-) diff --git a/www/__tests__/customMetricsHelper.test.ts b/www/__tests__/customMetricsHelper.test.ts index 251c1d977..173fbc97f 100644 --- a/www/__tests__/customMetricsHelper.test.ts +++ b/www/__tests__/customMetricsHelper.test.ts @@ -4,7 +4,6 @@ import { getCustomMETs, initCustomDatasetHelper, } from '../js/metrics/customMetricsHelper'; -import { setUseCustomMET } from '../js/metrics/metHelper'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; import fakeLabels from '../__mocks__/fakeLabels.json'; @@ -27,7 +26,6 @@ global.fetch = (url: string) => it('gets the custom mets', async () => { initCustomDatasetHelper(getConfig()); - setUseCustomMET(true); await new Promise((r) => setTimeout(r, 800)); expect(getCustomMETs()).toMatchObject({ walk: {}, diff --git a/www/__tests__/metHelper.test.ts b/www/__tests__/metHelper.test.ts index ea36ec87f..a06034a61 100644 --- a/www/__tests__/metHelper.test.ts +++ b/www/__tests__/metHelper.test.ts @@ -1,4 +1,4 @@ -import { getMet, setUseCustomMET } from '../js/metrics/metHelper'; +import { getMet } from '../js/metrics/metHelper'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; import fakeLabels from '../__mocks__/fakeLabels.json'; @@ -21,10 +21,6 @@ global.fetch = (url: string) => ); }) as any; -beforeEach(() => { - setUseCustomMET(false); -}); - it('gets met for mode and speed', () => { expect(getMet('WALKING', 1.47523, 0)).toBe(4.3); expect(getMet('BICYCLING', 4.5, 0)).toBe(6.8); @@ -34,7 +30,6 @@ it('gets met for mode and speed', () => { it('gets custom met for mode and speed', async () => { initCustomDatasetHelper(getConfig()); - setUseCustomMET(true); await new Promise((r) => setTimeout(r, 500)); expect(getMet('walk', 1.47523, 0)).toBe(4.3); expect(getMet('bike', 4.5, 0)).toBe(6.8); diff --git a/www/js/metrics/metHelper.ts b/www/js/metrics/metHelper.ts index 377352a9b..28765d193 100644 --- a/www/js/metrics/metHelper.ts +++ b/www/js/metrics/metHelper.ts @@ -2,23 +2,14 @@ import { logDebug, logWarn } from '../plugin/logger'; import { getCustomMETs } from './customMetricsHelper'; import { standardMETs } from './metDataset'; -let useCustom = false; - -/** - * @function sets boolean to use custom mets - * @param {boolean} val - */ -export const setUseCustomMET = function (val: boolean) { - useCustom = val; -}; - /** * @function gets the METs object * @returns {object} mets either custom or standard */ const getMETs = function () { - if (useCustom == true) { - return getCustomMETs(); + let custom_mets = getCustomMETs(); + if (custom_mets) { + return custom_mets; } else { return standardMETs; } From 8f7c6748ed161e248e247cc04514dacc46908317 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 17 Nov 2023 16:45:29 -0700 Subject: [PATCH 446/850] add a test case we also want to test that the timestamps follow the "minute" accuracy convention we implemented, so that if the survey and timelineEntry match within the minute, we use the timelineEntry timestamps, else use the timestamps from the survey https://github.com/e-mission/e-mission-phone/pull/1063#discussion_r1397710929 --- www/__tests__/enketoHelper.test.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index a8f49b29c..f4cfeb8ee 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -86,14 +86,22 @@ it('resolves the timestamps', () => { ' 2016-08-28 2016-07-25 17:32:32.928-06:00 17:30:31.000-06:00 '; const badTimeDoc = xmlParser.parseFromString(badTimes, 'text/xml'); expect(resolveTimestamps(badTimeDoc, timelineEntry)).toBeUndefined(); - //good info returns unix start and end timestamps -- TODO : address precise vs less precise? - const timeSurvey = + //if within a minute, timelineEntry timestamps + const timeEntry = ' 2016-07-25 2016-07-25 17:24:32.928-06:00 17:30:31.000-06:00 '; - const xmlDoc = xmlParser.parseFromString(timeSurvey, 'text/xml'); - expect(resolveTimestamps(xmlDoc, timelineEntry)).toMatchObject({ + const xmlDoc1 = xmlParser.parseFromString(timeEntry, 'text/xml'); + expect(resolveTimestamps(xmlDoc1, timelineEntry)).toMatchObject({ start_ts: 1469492672.928242, end_ts: 1469493031, }); + // else survey timestamps + const timeSurvey = + ' 2016-07-25 2016-07-25 17:22:33.928-06:00 17:33:33.000-06:00 '; + const xmlDoc2 = xmlParser.parseFromString(timeSurvey, 'text/xml'); + expect(resolveTimestamps(xmlDoc2, timelineEntry)).toMatchObject({ + start_ts: 1469492553.928, + end_ts: 1469493213, + }); }); //resolve label From 7ec575e41701725723609c9eb40654ef8d05385b Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 17 Nov 2023 17:02:39 -0700 Subject: [PATCH 447/850] note the issue this form is invalide because of the start and end times mismatching --- www/__tests__/enketoHelper.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index f4cfeb8ee..e06dc94a6 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -136,6 +136,7 @@ it('gets the saved result or throws an error', () => { return '2023-10-13T15:05:48.890-06:002023-10-13T15:05:48.892-06:002016-07-2517:24:32.928-06:002016-07-2517:30:31.000-06:00personal_care_activitiesdoing_sportuuid:dc16c287-08b2-4435-95aa-e4d7838b4225'; }, }; + //the start time listed is after the end time listed const badForm = { getDataStr: () => { return '2023-10-13T15:05:48.890-06:002023-10-13T15:05:48.892-06:002016-08-2517:24:32.928-06:002016-07-2517:30:31.000-06:00personal_care_activitiesdoing_sportuuid:dc16c287-08b2-4435-95aa-e4d7838b4225'; From 8b3ab76327d93011e2a31cfa6409bcb23f710e1f Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 17 Nov 2023 17:07:22 -0700 Subject: [PATCH 448/850] check for key before resolving with this information --- www/__mocks__/cordovaMocks.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index f08293a85..4911b3ebe 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -133,11 +133,15 @@ export const mockBEMUserCache = () => { return { key: 'write_ts', startTs: 0, endTs: Date.now() / 1000 }; }, getSensorDataForInterval: (key, tq, withMetadata) => { - return new Promise((rs, rj) => - setTimeout(() => { - rs({ metadata: { write_ts: '1699897723' }, data: 'completed', time: '01/01/2001' }); - }, 100), - ); + if (key == `manual/demographic_survey`) { + return new Promise((rs, rj) => + setTimeout(() => { + rs({ metadata: { write_ts: '1699897723' }, data: 'completed', time: '01/01/2001' }); + }, 100), + ); + } else { + return undefined; + } }, }; window['cordova'] ||= {}; From 20f544115a2682aaa7413513858e5dd960540b15 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Fri, 17 Nov 2023 16:21:56 -0800 Subject: [PATCH 449/850] Minor review changes, updated GeoJSON Typing --- www/__tests__/timelineHelper.test.ts | 6 ++--- www/js/diary/timelineHelper.ts | 24 ++++++++---------- www/js/types/diaryTypes.ts | 37 ++++++++-------------------- 3 files changed, 23 insertions(+), 44 deletions(-) diff --git a/www/__tests__/timelineHelper.test.ts b/www/__tests__/timelineHelper.test.ts index 2cf590e3c..115bf2d43 100644 --- a/www/__tests__/timelineHelper.test.ts +++ b/www/__tests__/timelineHelper.test.ts @@ -8,7 +8,7 @@ import { } from '../js/diary/timelineHelper'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import * as mockTLH from '../__mocks__/timelineHelperMocks'; -import { GeoJSON, GjFeature } from '../js/types/diaryTypes'; +import { GeoJSONData, GeoJSONStyledFeature } from '../js/types/diaryTypes'; mockLogger(); mockAlert(); @@ -28,12 +28,12 @@ describe('useGeojsonForTrip', () => { expect(testVal).toBeFalsy; }); - const checkGeojson = (geoObj: GeoJSON) => { + const checkGeojson = (geoObj: GeoJSONData) => { expect(geoObj.data).toEqual( expect.objectContaining({ id: expect.any(String), type: 'FeatureCollection', - features: expect.any(Array), + features: expect.any(Array), }), ); }; diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index a1beb6ff1..707a126ad 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -5,12 +5,11 @@ import { getRawEntries } from '../services/commHelper'; import { ServerResponse, ServerData } from '../types/serverData'; import L from 'leaflet'; import { DateTime } from 'luxon'; -import { UserInputEntry, TripTransition, TimelineEntry, GeoJSON } from '../types/diaryTypes'; +import { UserInputEntry, TripTransition, TimelineEntry, GeoJSONData } from '../types/diaryTypes'; import { getLabelInputDetails, getLabelInputs } from '../survey/multilabel/confirmHelper'; import { LabelOptions } from '../types/labelTypes'; -import { getNotDeletedCandidates, getUniqueEntries } from '../survey/inputMatcher'; -const cachedGeojsons: Map = new Map(); +const cachedGeojsons: Map = new Map(); /** * @description Gets a formatted GeoJSON object for a trip, including the start and end places and the trajectory. @@ -34,7 +33,7 @@ export function useGeojsonForTrip(trip, labelOptions: LabelOptions, labeledMode? ...locations2GeojsonTrajectory(trip, trip.locations, trajectoryColor), ]; - const gj = { + const gj: GeoJSONData = { data: { id: gjKey, type: 'FeatureCollection', @@ -249,16 +248,16 @@ export const readAllCompositeTrips = function (startTs: number, endTs: number) { const dateTime2localdate = function (currtime: DateTime, tz: string) { return { timezone: tz, - year: currtime.get('year'), + year: currtime.year, //the months of the draft trips match the one format needed for //moment function however now that is modified we need to also //modify the months value here - month: currtime.get('month') + 1, - day: currtime.get('day'), - weekday: currtime.get('weekday'), - hour: currtime.get('hour'), - minute: currtime.get('minute'), - second: currtime.get('second'), + month: currtime.month, + day: currtime.day, + weekday: currtime.weekday, + hour: currtime.hour, + minute: currtime.minute, + second: currtime.second, }; }; @@ -507,10 +506,7 @@ export const readUnprocessedTrips = function (startTs, endTs, lastProcessedTrip) DateTime.fromSeconds(tq.startTs).toLocaleString(DateTime.DATETIME_MED) + DateTime.fromSeconds(tq.endTs).toLocaleString(DateTime.DATETIME_MED), ); - - console.log('Testing...'); const getMessageMethod = window['cordova'].plugins.BEMUserCache.getMessagesForInterval; - console.log('Entering...'); return getUnifiedDataForInterval('statemachine/transition', tq, getMessageMethod).then(function ( transitionList: Array>, ) { diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 99fa2b817..fd7f03b38 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -4,6 +4,7 @@ import { BaseModeKey, MotionTypeKey } from '../diary/diaryHelper'; import { ServerData, LocalDt } from './serverData'; +import { FeatureCollection, Feature, Geometry } from 'geojson'; type ObjectId = { $oid: string }; export type ConfirmedPlace = { @@ -13,7 +14,7 @@ export type ConfirmedPlace = { enter_fmt_time: string; // ISO string 2023-10-31T12:00:00.000-04:00 enter_local_dt: LocalDt; enter_ts: number; // Unix timestamp - location: { type: string; coordinates: number[] }; + location: Geometry; raw_places: ObjectId[]; source: string; user_input: { @@ -36,11 +37,6 @@ export type TripTransition = { ts: number; }; -export type LocationCoord = { - type: string; // e.x., "Point" - coordinates: [number, number] | Array<[number, number]>; -}; - /* These are the properties received from the server (basically matches Python code) This should match what Timeline.readAllCompositeTrips returns (an array of these objects) */ export type CompositeTrip = { @@ -54,7 +50,7 @@ export type CompositeTrip = { duration: number; end_confirmed_place: ServerData; end_fmt_time: string; - end_loc: { type: string; coordinates: number[] }; + end_loc: Geometry; end_local_dt: LocalDt; end_place: ObjectId; end_ts: number; @@ -71,7 +67,7 @@ export type CompositeTrip = { source: string; start_confirmed_place: ServerData; start_fmt_time: string; - start_loc: { type: string; coordinates: number[] }; + start_loc: Geometry; start_local_dt: LocalDt; start_place: ObjectId; start_ts: number; @@ -141,7 +137,7 @@ export type Location = { latitude: number; fmt_time: string; // ISO mode: number; - loc: LocationCoord; + loc: Geometry; ts: number; // Unix altitude: number; distance: number; @@ -150,14 +146,14 @@ export type Location = { // used in readAllCompositeTrips export type SectionData = { end_ts: number; // Unix time, e.x. 1696352498.804 - end_loc: LocationCoord; + end_loc: Geometry; start_fmt_time: string; // ISO time end_fmt_time: string; trip_id: ObjectId; sensed_mode: number; source: string; // e.x., "SmoothedHighConfidenceMotion" start_ts: number; // Unix - start_loc: LocationCoord; + start_loc: Geometry; cleaned_section: ObjectId; start_local_dt: LocalDt; end_local_dt: LocalDt; @@ -166,21 +162,8 @@ export type SectionData = { distance: number; }; -export type GjFeature = { - type: string; - geometry: LocationCoord; - properties?: { featureType: string }; // if geometry.coordinates.length == 1, property - style?: { color: string }; // otherwise, style (which is a hexcode) -}; +export type GeoJSONStyledFeature = Feature & { style?: { color: string } }; -export type GeoJSON = { - data: { - id: string; - type: string; - features: GjFeature[]; - properties: { - start_ts: number; - end_ts: number; - }; - }; +export type GeoJSONData = { + data: FeatureCollection & { id: string; properties: { start_ts: number; end_ts: number } }; }; From d123b4f23f90537671e13e59ff672f7156e655c2 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 17 Nov 2023 17:43:34 -0700 Subject: [PATCH 450/850] remove _lazyLoadConfig, move types the function was not all that necessary, so I removed it Also moved my survey config type into the appropriate place https://github.com/e-mission/e-mission-phone/pull/1063#discussion_r1397747847 --- www/__tests__/enketoHelper.test.ts | 6 ++--- www/js/survey/enketo/enketoHelper.ts | 36 +++++++--------------------- www/js/types/appConfigTypes.ts | 22 +++++++++++++++++ 3 files changed, 33 insertions(+), 31 deletions(-) create mode 100644 www/js/types/appConfigTypes.ts diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index e06dc94a6..0f2982318 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -3,12 +3,12 @@ import { filterByNameAndVersion, resolveTimestamps, resolveLabel, - _lazyLoadConfig, loadPreviousResponseForSurvey, saveResponse, } from '../js/survey/enketo/enketoHelper'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; +import { getConfig } from '../../www/js/config/dynamicConfig'; import initializedI18next from '../js/i18nextInit'; window['i18next'] = initializedI18next; @@ -22,7 +22,7 @@ global.Blob = require('node:buffer').Blob; it('gets the survey config', async () => { //this is aimed at testing my mock of the config //mocked getDocument for the case of getting the config - let config = await _lazyLoadConfig(); + let config = await getConfig(); let mockSurveys = { TimeUseSurvey: { compatibleWith: 1, @@ -39,7 +39,7 @@ it('gets the survey config', async () => { version: 9, }, }; - expect(config).toMatchObject(mockSurveys); + expect(config.survey_info.surveys).toMatchObject(mockSurveys); }); it('gets the model response, if avaliable, or returns null', () => { diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index 379120373..cb5e0bac0 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -8,6 +8,7 @@ import { getConfig } from '../../config/dynamicConfig'; import { DateTime } from 'luxon'; import { fetchUrlCached } from '../../services/commHelper'; import { getUnifiedDataForInterval } from '../../services/unifiedDataLoader'; +import { EnketoSurveyConfig } from '../../types/appConfigTypes'; export type PrefillFields = { [key: string]: string }; @@ -34,19 +35,10 @@ type EnketoResponse = { metadata: any; }; -type EnketoSurveyConfig = { - [surveyName: string]: { - formPath: string; - labelTemplate: { [lang: string]: string }; - labelVars: { [activity: string]: { [key: string]: string; type: string } }; - version: number; - compatibleWith: number; - }; -}; - const LABEL_FUNCTIONS = { UseLabelTemplate: async (xmlDoc: XMLDocument, name: string) => { - let configSurveys = await _lazyLoadConfig(); + let appConfig = await getConfig(); + const configSurveys = appConfig.survey_info.surveys; const config = configSurveys[name]; // config for this survey const lang = i18next.resolvedLanguage; @@ -94,21 +86,6 @@ function _getAnswerByTagName(xmlDoc: XMLDocument, tagName: string) { /** @type {EnketoSurveyConfig} _config */ let _config: EnketoSurveyConfig; -/** - * _lazyLoadConfig load enketo survey config. If already loaded, return the cached config - * @returns {Promise} enketo survey config - */ -export function _lazyLoadConfig() { - if (_config !== undefined) { - return Promise.resolve(_config); - } - return getConfig().then((newConfig) => { - logInfo('Resolved UI_CONFIG_READY promise in enketoHelper, filling in templates'); - _config = newConfig.survey_info.surveys; - return _config; - }); -} - /** * filterByNameAndVersion filter the survey responses by survey name and their version. * The version for filtering is specified in enketo survey `compatibleWith` config. @@ -119,8 +96,11 @@ export function _lazyLoadConfig() { * @return {Promise} filtered survey responses */ export function filterByNameAndVersion(name: string, responses: EnketoResponse[]) { - return _lazyLoadConfig().then((config) => - responses.filter((r) => r.data.name === name && r.data.version >= config[name].compatibleWith), + return getConfig().then((config) => + responses.filter( + (r) => + r.data.name === name && r.data.version >= config.survey_info.surveys[name].compatibleWith, + ), ); } diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts new file mode 100644 index 000000000..f55b27bc0 --- /dev/null +++ b/www/js/types/appConfigTypes.ts @@ -0,0 +1,22 @@ +// WIP: type definitions for the 'dynamic config' spec +// examples of configs: https://github.com/e-mission/nrel-openpath-deploy-configs/tree/main/configs + +export type AppConfig = { + server: ServerConnConfig; + [k: string]: any; // TODO fill in all the other fields +}; + +export type ServerConnConfig = { + connectUrl: `https://${string}`; + aggregate_call_auth: 'no_auth' | 'user_only' | 'never'; +}; + +export type EnketoSurveyConfig = { + [surveyName: string]: { + formPath: string; + labelTemplate: { [lang: string]: string }; + labelVars: { [activity: string]: { [key: string]: string; type: string } }; + version: number; + compatibleWith: number; + }; +}; From 100eefb76dd7bd118be1f78b8dd003a11c924ac2 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 17 Nov 2023 17:54:37 -0700 Subject: [PATCH 451/850] test the version adding a response that should get filtered out because the version is too low https://github.com/e-mission/e-mission-phone/pull/1063#discussion_r1397717837 --- www/__tests__/enketoHelper.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index 0f2982318..e81e1e15b 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -247,6 +247,18 @@ it('filters the survey responses by their name and version', () => { }, metadata: {}, }, + { + data: { + label: 'Activity', //display label (this value is use for displaying on the button) + ts: '100000000', //the timestamp at which the survey was filled out (in seconds) + fmt_time: '12:39', //the formatted timestamp at which the survey was filled out + name: 'TimeUseSurvey', //survey name + version: '0.5', //survey version + xmlResponse: '', //survey response XML string + jsonDocResponse: 'this is my json object', //survey response JSON object + }, + metadata: {}, + }, ]; //several responses -> only the one that has a name match From 22decbfd0b58776450737ba536dc9bf42e375aa5 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 17 Nov 2023 18:15:34 -0700 Subject: [PATCH 452/850] make config a parameter to mockBEMUserCache --- www/__mocks__/cordovaMocks.ts | 8 +++----- www/__tests__/clientStats.test.ts | 3 ++- www/__tests__/enketoHelper.test.ts | 3 ++- www/__tests__/pushNotifySettings.test.ts | 3 ++- www/__tests__/remoteNotifyHandler.test.ts | 3 ++- www/__tests__/startprefs.test.ts | 3 ++- www/__tests__/storage.test.ts | 3 ++- www/__tests__/storeDeviceSettings.test.ts | 3 ++- 8 files changed, 17 insertions(+), 12 deletions(-) diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 4911b3ebe..e8b680965 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -35,7 +35,7 @@ export const mockFile = () => { //for consent document const _storage = {}; -export const mockBEMUserCache = () => { +export const mockBEMUserCache = (config) => { const _cache = {}; const messages = []; const mockBEMUserCache = { @@ -101,13 +101,11 @@ export const mockBEMUserCache = () => { ); }, getDocument: (key: string, withMetadata?: boolean) => { - // this was mocked specifically for enketoHelper's use, could be expanded if needed - const fakeSurveyConfig = fakeConfig; - + //returns the config provided as a paramenter to this mock! if (key == 'config/app_ui_config') { return new Promise((rs, rj) => setTimeout(() => { - rs(fakeSurveyConfig); + rs(config); }, 100), ); } else { diff --git a/www/__tests__/clientStats.test.ts b/www/__tests__/clientStats.test.ts index a3a953582..a508550e5 100644 --- a/www/__tests__/clientStats.test.ts +++ b/www/__tests__/clientStats.test.ts @@ -6,12 +6,13 @@ import { getAppVersion, statKeys, } from '../js/plugin/clientStats'; +import fakeConfig from '../__mocks__/fakeConfig.json'; mockDevice(); // this mocks cordova-plugin-app-version, generating a "Mock App", version "1.2.3" mockGetAppVersion(); // clientStats.ts uses BEMUserCache to store the stats, so we need to mock that too -mockBEMUserCache(); +mockBEMUserCache(fakeConfig); const db = window['cordova']?.plugins?.BEMUserCache; it('gets the app version', async () => { diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index e81e1e15b..3b5a95f3d 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -9,11 +9,12 @@ import { import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; import { getConfig } from '../../www/js/config/dynamicConfig'; +import fakeConfig from '../__mocks__/fakeConfig.json'; import initializedI18next from '../js/i18nextInit'; window['i18next'] = initializedI18next; -mockBEMUserCache(); +mockBEMUserCache(fakeConfig); mockLogger(); global.URL = require('url').URL; diff --git a/www/__tests__/pushNotifySettings.test.ts b/www/__tests__/pushNotifySettings.test.ts index d452aa819..9e6e25bb5 100644 --- a/www/__tests__/pushNotifySettings.test.ts +++ b/www/__tests__/pushNotifySettings.test.ts @@ -11,11 +11,12 @@ import { mockPushNotification, getCalled, } from '../__mocks__/pushNotificationMocks'; +import fakeConfig from '../__mocks__/fakeConfig.json'; mockCordova(); mockLogger(); mockPushNotification(); -mockBEMUserCache(); +mockBEMUserCache(fakeConfig); mockBEMDataCollection(); global.fetch = (url: string) => diff --git a/www/__tests__/remoteNotifyHandler.test.ts b/www/__tests__/remoteNotifyHandler.test.ts index 320877c6b..6fe0a73fe 100644 --- a/www/__tests__/remoteNotifyHandler.test.ts +++ b/www/__tests__/remoteNotifyHandler.test.ts @@ -9,10 +9,11 @@ import { mockInAppBrowser, } from '../__mocks__/cordovaMocks'; import { clearAlerts, getAlerts, mockAlert, mockLogger } from '../__mocks__/globalMocks'; +import fakeConfig from '../__mocks__/fakeConfig.json'; mockLogger(); mockDevice(); -mockBEMUserCache(); +mockBEMUserCache(fakeConfig); mockGetAppVersion(); mockInAppBrowser(); mockAlert(); diff --git a/www/__tests__/startprefs.test.ts b/www/__tests__/startprefs.test.ts index 75ed707dc..17b44a4be 100644 --- a/www/__tests__/startprefs.test.ts +++ b/www/__tests__/startprefs.test.ts @@ -7,8 +7,9 @@ import { import { mockBEMUserCache, mockBEMDataCollection } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; +import fakeConfig from '../__mocks__/fakeConfig.json'; -mockBEMUserCache(); +mockBEMUserCache(fakeConfig); mockBEMDataCollection(); mockLogger(); diff --git a/www/__tests__/storage.test.ts b/www/__tests__/storage.test.ts index ca6d71dec..bbfa9c410 100644 --- a/www/__tests__/storage.test.ts +++ b/www/__tests__/storage.test.ts @@ -1,11 +1,12 @@ import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; import { storageClear, storageGet, storageRemove, storageSet } from '../js/plugin/storage'; +import fakeConfig from '../__mocks__/fakeConfig.json'; // mocks used - storage.ts uses BEMUserCache and logging. // localStorage is already mocked for us by Jest :) mockLogger(); -mockBEMUserCache(); +mockBEMUserCache(fakeConfig); it('stores a value and retrieves it back', async () => { await storageSet('test1', 'test value 1'); diff --git a/www/__tests__/storeDeviceSettings.test.ts b/www/__tests__/storeDeviceSettings.test.ts index 4bccbc0af..41c36eb16 100644 --- a/www/__tests__/storeDeviceSettings.test.ts +++ b/www/__tests__/storeDeviceSettings.test.ts @@ -13,8 +13,9 @@ import { import { mockLogger } from '../__mocks__/globalMocks'; import { EVENTS, publish } from '../js/customEventHandler'; import { markIntroDone } from '../js/onboarding/onboardingHelper'; +import fakeConfig from '../__mocks__/fakeConfig.json'; -mockBEMUserCache(); +mockBEMUserCache(fakeConfig); mockDevice(); mockCordova(); mockLogger(); From 80aa5ea18081849819236d65dc5d07ea583aa8e8 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Fri, 17 Nov 2023 18:42:33 -0700 Subject: [PATCH 453/850] more test cases!! Because of the new parameter, I was able to add more test cases to resolve Labels I needed to also clear the locally stored config out of dynamicConfig.ts --- www/__tests__/enketoHelper.test.ts | 81 ++++++++++++++++++++++++++++-- www/js/config/dynamicConfig.ts | 5 ++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index 3b5a95f3d..c4fda7dc4 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -8,7 +8,7 @@ import { } from '../js/survey/enketo/enketoHelper'; import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; -import { getConfig } from '../../www/js/config/dynamicConfig'; +import { getConfig, resetStoredConfig } from '../../www/js/config/dynamicConfig'; import fakeConfig from '../__mocks__/fakeConfig.json'; import initializedI18next from '../js/i18nextInit'; @@ -20,6 +20,10 @@ mockLogger(); global.URL = require('url').URL; global.Blob = require('node:buffer').Blob; +beforeEach(() => { + resetStoredConfig(); +}); + it('gets the survey config', async () => { //this is aimed at testing my mock of the config //mocked getDocument for the case of getting the config @@ -106,7 +110,7 @@ it('resolves the timestamps', () => { }); //resolve label -it('resolves the label', async () => { +it('resolves the label, normal case', async () => { const xmlParser = new window.DOMParser(); const xmlString = ' option_1 '; const xmlDoc = xmlParser.parseFromString(xmlString, 'text/html'); @@ -114,14 +118,83 @@ it('resolves the label', async () => { ' option_1 option_3 '; const xmlDoc2 = xmlParser.parseFromString(xmlString2, 'text/xml'); - //if no template, returns "Answered" TODO: find a way to engineer this case - //if no labelVars, returns template TODO: find a way to engineer this case //have a custom survey label function TODO: we currently don't have custome label functions, but should test when we do //no custom function, fallback to UseLabelTemplate (standard case) + mockBEMUserCache(fakeConfig); expect(await resolveLabel('TimeUseSurvey', xmlDoc)).toBe('3 Domestic'); expect(await resolveLabel('TimeUseSurvey', xmlDoc2)).toBe('3 Employment/Education, 3 Domestic'); }); +it('resolves the label, if no template, returns "Answered"', async () => { + const xmlParser = new window.DOMParser(); + const xmlString = ' option_1 '; + const xmlDoc = xmlParser.parseFromString(xmlString, 'text/html'); + const xmlString2 = + ' option_1 option_3 '; + const xmlDoc2 = xmlParser.parseFromString(xmlString2, 'text/xml'); + + const noTemplate = { + survey_info: { + surveys: { + TimeUseSurvey: { + compatibleWith: 1, + formPath: + 'https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json', + labelVars: { + da: { + key: 'Domestic_activities', + type: 'length', + }, + erea: { + key: 'Employment_related_a_Education_activities', + type: 'length', + }, + }, + version: 9, + }, + }, + 'trip-labels': 'ENKETO', + }, + }; + mockBEMUserCache(noTemplate); + expect(await resolveLabel('TimeUseSurvey', xmlDoc)).toBe('Answered'); + expect(await resolveLabel('TimeUseSurvey', xmlDoc2)).toBe('Answered'); +}); + +it('resolves the label, if no labelVars, returns template', async () => { + const xmlParser = new window.DOMParser(); + const xmlString = ' option_1 '; + const xmlDoc = xmlParser.parseFromString(xmlString, 'text/html'); + const xmlString2 = + ' option_1 option_3 '; + const xmlDoc2 = xmlParser.parseFromString(xmlString2, 'text/xml'); + + const noLabels = { + survey_info: { + surveys: { + TimeUseSurvey: { + compatibleWith: 1, + formPath: + 'https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json', + labelTemplate: { + en: '{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }', + es: '{ erea, plural, =0 {} other {# Empleo/Educación, } }{ da, plural, =0 {} other {# Actividades domesticas, }}', + }, + version: 9, + }, + }, + 'trip-labels': 'ENKETO', + }, + }; + mockBEMUserCache(noLabels); + expect(await resolveLabel('TimeUseSurvey', xmlDoc)).toBe( + '{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }', + ); + expect(await resolveLabel('TimeUseSurvey', xmlDoc2)).toBe( + '{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }', + ); +}); + /** * @param surveyName the name of the survey (e.g. "TimeUseSurvey") * @param enketoForm the Form object from enketo-core that contains this survey diff --git a/www/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts index eb709c16c..801e24b07 100644 --- a/www/js/config/dynamicConfig.ts +++ b/www/js/config/dynamicConfig.ts @@ -10,6 +10,11 @@ export let storedConfig = null; export let configChanged = false; export const setConfigChanged = (b) => (configChanged = b); +//used test multiple configs, not used outside of test +export const resetStoredConfig = function () { + storedConfig = null; +}; + const _getStudyName = function (connectUrl) { const orig_host = new URL(connectUrl).hostname; const first_domain = orig_host.split('.')[0]; From 0ef1db81e574d44f9fe09858a9c24ffc6175904a Mon Sep 17 00:00:00 2001 From: Abby Wheelis <54848919+Abby-Wheelis@users.noreply.github.com> Date: Sun, 19 Nov 2023 08:33:47 -0700 Subject: [PATCH 454/850] add survey_info to the AppConfig Type from @JGreenlee's suggestion in review Co-authored-by: Jack Greenlee --- www/js/types/appConfigTypes.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts index f55b27bc0..07e8ccb5f 100644 --- a/www/js/types/appConfigTypes.ts +++ b/www/js/types/appConfigTypes.ts @@ -3,6 +3,10 @@ export type AppConfig = { server: ServerConnConfig; + survey_info: { + 'trip-labels': 'MULTILABEL' | 'ENKETO'; + surveys: EnketoSurveyConfig; + } [k: string]: any; // TODO fill in all the other fields }; From 10b954669a277210ac129892aa1ac61682aa8fa4 Mon Sep 17 00:00:00 2001 From: Abby Wheelis <54848919+Abby-Wheelis@users.noreply.github.com> Date: Sun, 19 Nov 2023 08:36:24 -0700 Subject: [PATCH 455/850] make config optional param not all (in fact many) of the tests don't need this config at all, so the parameter should be optional to clean things up Co-authored-by: Jack Greenlee --- www/__mocks__/cordovaMocks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index e8b680965..08293e73f 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -35,7 +35,7 @@ export const mockFile = () => { //for consent document const _storage = {}; -export const mockBEMUserCache = (config) => { +export const mockBEMUserCache = (config?) => { const _cache = {}; const messages = []; const mockBEMUserCache = { From fd0103427627dab2a38d7cbb46ddaaabc8df313c Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Sun, 19 Nov 2023 08:43:44 -0700 Subject: [PATCH 456/850] fallback to fakeConfig follow-on to making the config an optional parameter to mockBEMUserCache Instead of passing it in EVERY TIME, it is now a fallback, and the config only needs to be specified and re-specified in the enketoHelper tests added fallback and removed specification from tests that didn't need it --- www/__mocks__/cordovaMocks.ts | 2 +- www/__tests__/clientStats.test.ts | 3 +-- www/__tests__/pushNotifySettings.test.ts | 3 +-- www/__tests__/remoteNotifyHandler.test.ts | 3 +-- www/__tests__/startprefs.test.ts | 3 +-- www/__tests__/storage.test.ts | 3 +-- www/__tests__/storeDeviceSettings.test.ts | 3 +-- 7 files changed, 7 insertions(+), 13 deletions(-) diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 08293e73f..1d3934ea4 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -105,7 +105,7 @@ export const mockBEMUserCache = (config?) => { if (key == 'config/app_ui_config') { return new Promise((rs, rj) => setTimeout(() => { - rs(config); + rs(config || fakeConfig); }, 100), ); } else { diff --git a/www/__tests__/clientStats.test.ts b/www/__tests__/clientStats.test.ts index a508550e5..a3a953582 100644 --- a/www/__tests__/clientStats.test.ts +++ b/www/__tests__/clientStats.test.ts @@ -6,13 +6,12 @@ import { getAppVersion, statKeys, } from '../js/plugin/clientStats'; -import fakeConfig from '../__mocks__/fakeConfig.json'; mockDevice(); // this mocks cordova-plugin-app-version, generating a "Mock App", version "1.2.3" mockGetAppVersion(); // clientStats.ts uses BEMUserCache to store the stats, so we need to mock that too -mockBEMUserCache(fakeConfig); +mockBEMUserCache(); const db = window['cordova']?.plugins?.BEMUserCache; it('gets the app version', async () => { diff --git a/www/__tests__/pushNotifySettings.test.ts b/www/__tests__/pushNotifySettings.test.ts index 9e6e25bb5..d452aa819 100644 --- a/www/__tests__/pushNotifySettings.test.ts +++ b/www/__tests__/pushNotifySettings.test.ts @@ -11,12 +11,11 @@ import { mockPushNotification, getCalled, } from '../__mocks__/pushNotificationMocks'; -import fakeConfig from '../__mocks__/fakeConfig.json'; mockCordova(); mockLogger(); mockPushNotification(); -mockBEMUserCache(fakeConfig); +mockBEMUserCache(); mockBEMDataCollection(); global.fetch = (url: string) => diff --git a/www/__tests__/remoteNotifyHandler.test.ts b/www/__tests__/remoteNotifyHandler.test.ts index 6fe0a73fe..320877c6b 100644 --- a/www/__tests__/remoteNotifyHandler.test.ts +++ b/www/__tests__/remoteNotifyHandler.test.ts @@ -9,11 +9,10 @@ import { mockInAppBrowser, } from '../__mocks__/cordovaMocks'; import { clearAlerts, getAlerts, mockAlert, mockLogger } from '../__mocks__/globalMocks'; -import fakeConfig from '../__mocks__/fakeConfig.json'; mockLogger(); mockDevice(); -mockBEMUserCache(fakeConfig); +mockBEMUserCache(); mockGetAppVersion(); mockInAppBrowser(); mockAlert(); diff --git a/www/__tests__/startprefs.test.ts b/www/__tests__/startprefs.test.ts index 17b44a4be..75ed707dc 100644 --- a/www/__tests__/startprefs.test.ts +++ b/www/__tests__/startprefs.test.ts @@ -7,9 +7,8 @@ import { import { mockBEMUserCache, mockBEMDataCollection } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; -import fakeConfig from '../__mocks__/fakeConfig.json'; -mockBEMUserCache(fakeConfig); +mockBEMUserCache(); mockBEMDataCollection(); mockLogger(); diff --git a/www/__tests__/storage.test.ts b/www/__tests__/storage.test.ts index bbfa9c410..ca6d71dec 100644 --- a/www/__tests__/storage.test.ts +++ b/www/__tests__/storage.test.ts @@ -1,12 +1,11 @@ import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; import { storageClear, storageGet, storageRemove, storageSet } from '../js/plugin/storage'; -import fakeConfig from '../__mocks__/fakeConfig.json'; // mocks used - storage.ts uses BEMUserCache and logging. // localStorage is already mocked for us by Jest :) mockLogger(); -mockBEMUserCache(fakeConfig); +mockBEMUserCache(); it('stores a value and retrieves it back', async () => { await storageSet('test1', 'test value 1'); diff --git a/www/__tests__/storeDeviceSettings.test.ts b/www/__tests__/storeDeviceSettings.test.ts index 41c36eb16..4bccbc0af 100644 --- a/www/__tests__/storeDeviceSettings.test.ts +++ b/www/__tests__/storeDeviceSettings.test.ts @@ -13,9 +13,8 @@ import { import { mockLogger } from '../__mocks__/globalMocks'; import { EVENTS, publish } from '../js/customEventHandler'; import { markIntroDone } from '../js/onboarding/onboardingHelper'; -import fakeConfig from '../__mocks__/fakeConfig.json'; -mockBEMUserCache(fakeConfig); +mockBEMUserCache(); mockDevice(); mockCordova(); mockLogger(); From 83a773ec79096cd90fce36e18d5bb1a8f5e8610d Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Sun, 19 Nov 2023 08:45:25 -0700 Subject: [PATCH 457/850] prettier types --- www/js/types/appConfigTypes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts index 07e8ccb5f..aa2e3f312 100644 --- a/www/js/types/appConfigTypes.ts +++ b/www/js/types/appConfigTypes.ts @@ -6,7 +6,7 @@ export type AppConfig = { survey_info: { 'trip-labels': 'MULTILABEL' | 'ENKETO'; surveys: EnketoSurveyConfig; - } + }; [k: string]: any; // TODO fill in all the other fields }; From 51f6ece32cf73c110ce9b17477224ea2c52f95b6 Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Sun, 19 Nov 2023 09:22:39 -0700 Subject: [PATCH 458/850] await instead of timeout in testing the initCustomDatasets function, I was calling it and then setting a timeout to wait for it to run, now I await getting the config, then await initialization double checked the Jest tests and running in the emulator - still going smoothly! --- www/__tests__/customMetricsHelper.test.ts | 8 ++++---- www/__tests__/footprintHelper.test.ts | 16 ++++++++-------- www/__tests__/metHelper.test.ts | 4 ++-- www/js/metrics/customMetricsHelper.ts | 11 +++++------ 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/www/__tests__/customMetricsHelper.test.ts b/www/__tests__/customMetricsHelper.test.ts index 173fbc97f..572fc0c27 100644 --- a/www/__tests__/customMetricsHelper.test.ts +++ b/www/__tests__/customMetricsHelper.test.ts @@ -25,8 +25,8 @@ global.fetch = (url: string) => }) as any; it('gets the custom mets', async () => { - initCustomDatasetHelper(getConfig()); - await new Promise((r) => setTimeout(r, 800)); + const appConfig = await getConfig(); + await initCustomDatasetHelper(appConfig); expect(getCustomMETs()).toMatchObject({ walk: {}, bike: {}, @@ -38,8 +38,8 @@ it('gets the custom mets', async () => { }); it('gets the custom footprint', async () => { - initCustomDatasetHelper(getConfig()); - await new Promise((r) => setTimeout(r, 800)); + const appConfig = await getConfig(); + await initCustomDatasetHelper(appConfig); expect(getCustomFootprint()).toMatchObject({ walk: {}, bike: {}, diff --git a/www/__tests__/footprintHelper.test.ts b/www/__tests__/footprintHelper.test.ts index 3f6883bbe..842442153 100644 --- a/www/__tests__/footprintHelper.test.ts +++ b/www/__tests__/footprintHelper.test.ts @@ -39,25 +39,25 @@ const custom_metrics = [ ]; it('gets footprint for metrics (custom, fallback 0)', async () => { - initCustomDatasetHelper(getConfig()); - await new Promise((r) => setTimeout(r, 500)); + const appConfig = await getConfig(); + await initCustomDatasetHelper(appConfig); expect(getFootprintForMetrics(custom_metrics, 0)).toBe(2.4266); }); it('gets footprint for metrics (custom, fallback 0.1)', async () => { - initCustomDatasetHelper(getConfig()); - await new Promise((r) => setTimeout(r, 500)); + const appConfig = await getConfig(); + await initCustomDatasetHelper(appConfig); expect(getFootprintForMetrics(custom_metrics, 0.1)).toBe(2.4266 + 0.5); }); it('gets the highest footprint from the dataset, custom', async () => { - initCustomDatasetHelper(getConfig()); - await new Promise((r) => setTimeout(r, 500)); + const appConfig = await getConfig(); + await initCustomDatasetHelper(appConfig); expect(getHighestFootprint()).toBe(0.30741); }); it('gets the highest footprint for distance, custom', async () => { - initCustomDatasetHelper(getConfig()); - await new Promise((r) => setTimeout(r, 500)); + const appConfig = await getConfig(); + await initCustomDatasetHelper(appConfig); expect(getHighestFootprintForDistance(12345)).toBe(0.30741 * (12345 / 1000)); }); diff --git a/www/__tests__/metHelper.test.ts b/www/__tests__/metHelper.test.ts index a06034a61..bc477daa0 100644 --- a/www/__tests__/metHelper.test.ts +++ b/www/__tests__/metHelper.test.ts @@ -29,8 +29,8 @@ it('gets met for mode and speed', () => { }); it('gets custom met for mode and speed', async () => { - initCustomDatasetHelper(getConfig()); - await new Promise((r) => setTimeout(r, 500)); + const appConfig = await getConfig(); + await initCustomDatasetHelper(appConfig); expect(getMet('walk', 1.47523, 0)).toBe(4.3); expect(getMet('bike', 4.5, 0)).toBe(6.8); expect(getMet('unicycle', 100, 0)).toBe(0); diff --git a/www/js/metrics/customMetricsHelper.ts b/www/js/metrics/customMetricsHelper.ts index 3a2f551ca..ac345e39c 100644 --- a/www/js/metrics/customMetricsHelper.ts +++ b/www/js/metrics/customMetricsHelper.ts @@ -98,12 +98,11 @@ const populateCustomFootprints = function () { export const initCustomDatasetHelper = async function (newConfig) { try { logDebug('initializing custom datasets with config' + newConfig); - getLabelOptions(newConfig).then((labelOptions) => { - console.log('In custom metrics, label options: ', labelOptions); - _labelOptions = labelOptions; - populateCustomMETs(); - populateCustomFootprints(); - }); + const labelOptions = await getLabelOptions(newConfig); + console.log('In custom metrics, label options: ', labelOptions); + _labelOptions = labelOptions; + populateCustomMETs(); + populateCustomFootprints(); } catch (e) { setTimeout(() => { displayError(e, 'Error while initializing custom dataset helper'); From 3dee26f69f6a54f5265d938ac0897e713bb385cc Mon Sep 17 00:00:00 2001 From: Abby Wheelis Date: Sun, 19 Nov 2023 09:29:11 -0700 Subject: [PATCH 459/850] Use expect.any(Type) for stricter testing https://github.com/e-mission/e-mission-phone/pull/1098#discussion_r1398327203 --- www/__tests__/customMetricsHelper.test.ts | 24 +++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/www/__tests__/customMetricsHelper.test.ts b/www/__tests__/customMetricsHelper.test.ts index 572fc0c27..0ae025bff 100644 --- a/www/__tests__/customMetricsHelper.test.ts +++ b/www/__tests__/customMetricsHelper.test.ts @@ -28,12 +28,12 @@ it('gets the custom mets', async () => { const appConfig = await getConfig(); await initCustomDatasetHelper(appConfig); expect(getCustomMETs()).toMatchObject({ - walk: {}, - bike: {}, - bikeshare: {}, - 'e-bike': {}, - scootershare: {}, - drove_alone: {}, + walk: expect.any(Object), + bike: expect.any(Object), + bikeshare: expect.any(Object), + 'e-bike': expect.any(Object), + scootershare: expect.any(Object), + drove_alone: expect.any(Object), }); }); @@ -41,11 +41,11 @@ it('gets the custom footprint', async () => { const appConfig = await getConfig(); await initCustomDatasetHelper(appConfig); expect(getCustomFootprint()).toMatchObject({ - walk: {}, - bike: {}, - bikeshare: {}, - 'e-bike': {}, - scootershare: {}, - drove_alone: {}, + walk: expect.any(Number), + bike: expect.any(Number), + bikeshare: expect.any(Number), + 'e-bike': expect.any(Number), + scootershare: expect.any(Number), + drove_alone: expect.any(Number), }); }); From becc4af0d724e676bedde73f3fb145fcdc211485 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 20 Nov 2023 10:41:35 -0500 Subject: [PATCH 460/850] enketoHelper: expand typings for resolveTimestamps -add types for the parameters of resolveTimestamps -update doc of this function -cast 'timelineEntry' to trip or place where needed -add a few fields that were missing from type defs of ConfirmedPlace and EnketoSurveyConfig --- www/js/survey/enketo/enketoHelper.ts | 28 ++++++++++++++++++---------- www/js/types/appConfigTypes.ts | 1 + www/js/types/diaryTypes.ts | 12 ++++++++++-- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index cb5e0bac0..933ca3aed 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -8,7 +8,8 @@ import { getConfig } from '../../config/dynamicConfig'; import { DateTime } from 'luxon'; import { fetchUrlCached } from '../../services/commHelper'; import { getUnifiedDataForInterval } from '../../services/unifiedDataLoader'; -import { EnketoSurveyConfig } from '../../types/appConfigTypes'; +import { AppConfig, EnketoSurveyConfig } from '../../types/appConfigTypes'; +import { CompositeTrip, ConfirmedPlace, TimelineEntry } from '../../types/diaryTypes'; export type PrefillFields = { [key: string]: string }; @@ -151,12 +152,12 @@ export function getInstanceStr(xmlModel: string, opts: SurveyOptions): string | /** * resolve timestamps label from the survey response * @param {XMLDocument} xmlDoc survey response as XML object - * @param {object} trip trip object + * @param {object} timelineEntry trip or place object * @returns {object} object with `start_ts` and `end_ts` * - null if no timestamps are resolved * - undefined if the timestamps are invalid */ -export function resolveTimestamps(xmlDoc, timelineEntry) { +export function resolveTimestamps(xmlDoc: XMLDocument, timelineEntry: TimelineEntry) { // check for Date and Time fields const startDate = xmlDoc.getElementsByTagName('Start_date')?.[0]?.innerHTML; let startTime = xmlDoc.getElementsByTagName('Start_time')?.[0]?.innerHTML; @@ -167,10 +168,10 @@ export function resolveTimestamps(xmlDoc, timelineEntry) { if (!startDate || !startTime || !endDate || !endTime) return null; const timezone = - timelineEntry.start_local_dt?.timezone || - timelineEntry.enter_local_dt?.timezone || - timelineEntry.end_local_dt?.timezone || - timelineEntry.exit_local_dt?.timezone; + (timelineEntry as CompositeTrip).start_local_dt?.timezone || + (timelineEntry as ConfirmedPlace).enter_local_dt?.timezone || + (timelineEntry as CompositeTrip).end_local_dt?.timezone || + (timelineEntry as ConfirmedPlace).exit_local_dt?.timezone; // split by + or - to get time without offset startTime = startTime.split(/\-|\+/)[0]; endTime = endTime.split(/\-|\+/)[0]; @@ -188,8 +189,10 @@ export function resolveTimestamps(xmlDoc, timelineEntry) { the millisecond. To avoid precision issues, we will check if the start/end timestamps from the survey response are within the same minute as the start/end or enter/exit timestamps. If so, we will use the exact trip/place timestamps */ - const entryStartTs = timelineEntry.start_ts || timelineEntry.enter_ts; - const entryEndTs = timelineEntry.end_ts || timelineEntry.exit_ts; + const entryStartTs = + (timelineEntry as CompositeTrip).start_ts || (timelineEntry as ConfirmedPlace).enter_ts; + const entryEndTs = + (timelineEntry as CompositeTrip).end_ts || (timelineEntry as ConfirmedPlace).exit_ts; if (additionStartTs - (additionStartTs % 60) == entryStartTs - (entryStartTs % 60)) additionStartTs = entryStartTs; if (additionEndTs - (additionEndTs % 60) == entryEndTs - (entryEndTs % 60)) @@ -209,7 +212,12 @@ export function resolveTimestamps(xmlDoc, timelineEntry) { * @param opts object with SurveyOptions like 'timelineEntry' or 'dataKey' * @returns Promise of the saved result, or an Error if there was a problem */ -export function saveResponse(surveyName: string, enketoForm: Form, appConfig, opts: SurveyOptions) { +export function saveResponse( + surveyName: string, + enketoForm: Form, + appConfig: AppConfig, + opts: SurveyOptions, +) { const xmlParser = new window.DOMParser(); const xmlResponse = enketoForm.getDataStr(); const xmlDoc = xmlParser.parseFromString(xmlResponse, 'text/xml'); diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts index aa2e3f312..1a2e50722 100644 --- a/www/js/types/appConfigTypes.ts +++ b/www/js/types/appConfigTypes.ts @@ -22,5 +22,6 @@ export type EnketoSurveyConfig = { labelVars: { [activity: string]: { [key: string]: string; type: string } }; version: number; compatibleWith: number; + dataKey?: string; }; }; diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 743d75b15..7cce67923 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -5,14 +5,17 @@ import { BaseModeKey, MotionTypeKey } from '../diary/diaryHelper'; type ObjectId = { $oid: string }; -type ConfirmedPlace = { +export type ConfirmedPlace = { _id: ObjectId; additions: UserInputEntry[]; cleaned_place: ObjectId; ending_trip: ObjectId; - enter_fmt_time: string; // ISO string 2023-10-31T12:00:00.000-04:00 + enter_fmt_time: string; // ISO string e.g. 2023-10-31T12:00:00.000-04:00 enter_local_dt: LocalDt; enter_ts: number; // Unix timestamp + exit_fmt_time: string; // ISO string e.g. 2023-10-31T12:00:00.000-04:00 + exit_local_dt: LocalDt; + exit_ts: number; // Unix timestamp key: string; location: { type: string; coordinates: number[] }; origin_key: string; @@ -76,6 +79,11 @@ export type CompositeTrip = { so a 'timeline entry' is either a trip or a place. */ export type TimelineEntry = ConfirmedPlace | CompositeTrip; +/* Type guard to disambiguate timeline entries as either trips or places + If it has a 'start_ts' and 'end_ts', it's a trip. Else, it's a place. */ +export const isTrip = (entry: TimelineEntry): entry is CompositeTrip => + entry.hasOwnProperty('start_ts') && entry.hasOwnProperty('end_ts'); + /* These properties aren't received from the server, but are derived from the above properties. They are used in the UI to display trip/place details and are computed by the useDerivedProperties hook. */ export type DerivedProperties = { From 9a5753bf2e95e74494c248672b8aa2068aebc5d4 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 20 Nov 2023 11:01:42 -0500 Subject: [PATCH 461/850] code style cleanup There is no right or wrong here, but it's a bit cleaner and more consistent with the rest of the codebase if we generally follow this: i) for short one-liners, use const and declare an arrow function ii) for longer functions, use the traditional 'function' declaration also replaced some uses of 'var' with 'const' or 'let' added AppConfig type to initCustomDatasetHelper and replaced a console statement with logDebug --- www/js/metrics/customMetricsHelper.ts | 23 +++++++++-------- www/js/metrics/footprintHelper.ts | 36 ++++++++++++--------------- www/js/metrics/metHelper.ts | 18 ++++++-------- 3 files changed, 35 insertions(+), 42 deletions(-) diff --git a/www/js/metrics/customMetricsHelper.ts b/www/js/metrics/customMetricsHelper.ts index ac345e39c..317113327 100644 --- a/www/js/metrics/customMetricsHelper.ts +++ b/www/js/metrics/customMetricsHelper.ts @@ -2,6 +2,7 @@ import angular from 'angular'; import { getLabelOptions } from '../survey/multilabel/confirmHelper'; import { displayError, displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; import { standardMETs } from './metDataset'; +import { AppConfig } from '../types/appConfigTypes'; //variables to store values locally let _customMETs; @@ -13,25 +14,25 @@ let _labelOptions; * @function gets custom mets, must be initialized * @returns the custom mets stored locally */ -export const getCustomMETs = function () { +export function getCustomMETs() { logDebug('Getting custom METs ' + JSON.stringify(_customMETs)); return _customMETs; -}; +} /** * @function gets the custom footprint, must be initialized * @returns custom footprint */ -export const getCustomFootprint = function () { +export function getCustomFootprint() { logDebug('Getting custom footprint ' + JSON.stringify(_customPerKmFootprint)); return _customPerKmFootprint; -}; +} /** * @function stores custom mets in local var * needs _labelOptions, stored after gotten from config */ -const populateCustomMETs = function () { +function populateCustomMETs() { let modeOptions = _labelOptions['MODE']; let modeMETEntries = modeOptions.map((opt) => { if (opt.met_equivalent) { @@ -59,13 +60,13 @@ const populateCustomMETs = function () { }); _customMETs = Object.fromEntries(modeMETEntries.filter((e) => angular.isDefined(e))); logDebug('After populating, custom METs = ' + JSON.stringify(_customMETs)); -}; +} /** * @function stores custom footprint in local var * needs _inputParams which is stored after gotten from config */ -const populateCustomFootprints = function () { +function populateCustomFootprints() { let modeOptions = _labelOptions['MODE']; let modeCO2PerKm = modeOptions .map((opt) => { @@ -88,18 +89,18 @@ const populateCustomFootprints = function () { .filter((modeCO2) => angular.isDefined(modeCO2)); _customPerKmFootprint = Object.fromEntries(modeCO2PerKm); logDebug('After populating, custom perKmFootprint' + JSON.stringify(_customPerKmFootprint)); -}; +} /** * @function initializes the datasets based on configured label options * calls popuplateCustomMETs and populateCustomFootprint * @param newConfig the app config file */ -export const initCustomDatasetHelper = async function (newConfig) { +export async function initCustomDatasetHelper(newConfig: AppConfig) { try { logDebug('initializing custom datasets with config' + newConfig); const labelOptions = await getLabelOptions(newConfig); - console.log('In custom metrics, label options: ', labelOptions); + logDebug('In custom metrics, label options = ' + JSON.stringify(labelOptions)); _labelOptions = labelOptions; populateCustomMETs(); populateCustomFootprints(); @@ -108,4 +109,4 @@ export const initCustomDatasetHelper = async function (newConfig) { displayError(e, 'Error while initializing custom dataset helper'); }, 1000); } -}; +} diff --git a/www/js/metrics/footprintHelper.ts b/www/js/metrics/footprintHelper.ts index 6e4bd46d9..24677feaf 100644 --- a/www/js/metrics/footprintHelper.ts +++ b/www/js/metrics/footprintHelper.ts @@ -9,17 +9,15 @@ let highestFootprint = 0; * @param {number} v value in meters to be converted * @returns {number} converted value in km */ -const mtokm = function (v) { - return v / 1000; -}; +const mtokm = (v) => v / 1000; /** * @function clears the stored highest footprint */ -export const clearHighestFootprint = function () { +export function clearHighestFootprint() { //need to clear for testing highestFootprint = undefined; -}; +} /** * @function gets the footprint @@ -27,7 +25,7 @@ export const clearHighestFootprint = function () { * fallback is json/label-options.json.sample, with MET and kgCO2 defined * @returns the footprint or undefined */ -const getFootprint = function () { +function getFootprint() { let footprint = getCustomFootprint(); if (footprint) { return footprint; @@ -35,7 +33,7 @@ const getFootprint = function () { displayErrorMsg('failed to use custom labels', 'Error in Footprint Calculatons'); return undefined; } -}; +} /** * @function calculates footprint for given metrics @@ -44,12 +42,12 @@ const getFootprint = function () { * @param {number} defaultIfMissing optional, carbon intensity if mode not in footprint * @returns {number} the sum of carbon emissions for userMetrics given */ -export const getFootprintForMetrics = function (userMetrics, defaultIfMissing = 0) { - var footprint = getFootprint(); +export function getFootprintForMetrics(userMetrics, defaultIfMissing = 0) { + const footprint = getFootprint(); logDebug('getting footprint for ' + userMetrics + ' with ' + footprint); - var result = 0; - for (var i in userMetrics) { - var mode = userMetrics[i].key; + let result = 0; + for (let i in userMetrics) { + let mode = userMetrics[i].key; if (mode == 'ON_FOOT') { mode = 'WALKING'; } @@ -75,29 +73,27 @@ export const getFootprintForMetrics = function (userMetrics, defaultIfMissing = } } return result; -}; +} /** * @function gets highest co2 intensity in the footprint * @returns {number} the highest co2 intensity in the footprint */ -export const getHighestFootprint = function () { +export function getHighestFootprint() { if (!highestFootprint) { - var footprint = getFootprint(); + const footprint = getFootprint(); let footprintList = []; - for (var mode in footprint) { + for (let mode in footprint) { footprintList.push(footprint[mode]); } highestFootprint = Math.max(...footprintList); } return highestFootprint; -}; +} /** * @function gets highest theoretical footprint for given distance * @param {number} distance in meters to calculate max footprint * @returns max footprint for given distance */ -export const getHighestFootprintForDistance = function (distance) { - return getHighestFootprint() * mtokm(distance); -}; +export const getHighestFootprintForDistance = (distance) => getHighestFootprint() * mtokm(distance); diff --git a/www/js/metrics/metHelper.ts b/www/js/metrics/metHelper.ts index 28765d193..25bcc2e7e 100644 --- a/www/js/metrics/metHelper.ts +++ b/www/js/metrics/metHelper.ts @@ -6,14 +6,14 @@ import { standardMETs } from './metDataset'; * @function gets the METs object * @returns {object} mets either custom or standard */ -const getMETs = function () { +function getMETs() { let custom_mets = getCustomMETs(); if (custom_mets) { return custom_mets; } else { return standardMETs; } -}; +} /** * @function checks number agains bounds @@ -22,18 +22,14 @@ const getMETs = function () { * @param max upper bound * @returns {boolean} if number is within given bounds */ -const between = function (num, min, max) { - return num >= min && num <= max; -}; +const between = (num, min, max) => num >= min && num <= max; /** * @function converts meters per second to miles per hour * @param mps meters per second speed * @returns speed in miles per hour */ -const mpstomph = function (mps) { - return 2.23694 * mps; -}; +const mpstomph = (mps) => 2.23694 * mps; /** * @function gets met for a given mode and speed @@ -42,7 +38,7 @@ const mpstomph = function (mps) { * @param {number} defaultIfMissing default MET if mode not in METs * @returns */ -export const getMet = function (mode, speed, defaultIfMissing) { +export function getMet(mode, speed, defaultIfMissing) { if (mode == 'ON_FOOT') { logDebug("getMet() converted 'ON_FOOT' to 'WALKING'"); mode = 'WALKING'; @@ -52,7 +48,7 @@ export const getMet = function (mode, speed, defaultIfMissing) { logWarn('getMet() Illegal mode: ' + mode); return defaultIfMissing; //So the calorie sum does not break with wrong return type } - for (var i in currentMETs[mode]) { + for (let i in currentMETs[mode]) { if (between(mpstomph(speed), currentMETs[mode][i].range[0], currentMETs[mode][i].range[1])) { return currentMETs[mode][i].mets; } else if (mpstomph(speed) < 0) { @@ -60,4 +56,4 @@ export const getMet = function (mode, speed, defaultIfMissing) { return 0; } } -}; +} From 25a064d20c26e3b0bf4e47011ba49a216e07a95b Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Mon, 20 Nov 2023 09:30:47 -0800 Subject: [PATCH 462/850] Fixes missing timezone conversions --- www/js/diary/diaryHelper.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts index 79918207a..ed592427f 100644 --- a/www/js/diary/diaryHelper.ts +++ b/www/js/diary/diaryHelper.ts @@ -97,8 +97,8 @@ export function getBaseModeByText(text, labelOptions: LabelOptions) { export function isMultiDay(beginFmtTime: string, endFmtTime: string) { if (!beginFmtTime || !endFmtTime) return false; return ( - DateTime.fromISO(beginFmtTime).toFormat('YYYYMMDD') != - DateTime.fromISO(endFmtTime).toFormat('YYYYMMDD') + DateTime.fromISO(beginFmtTime, { setZone: true }).toFormat('YYYYMMDD') != + DateTime.fromISO(endFmtTime, { setZone: true }).toFormat('YYYYMMDD') ); } @@ -114,7 +114,7 @@ export function getFormattedDate(beginFmtTime: string, endFmtTime?: string) { return `${getFormattedDate(beginFmtTime)} - ${getFormattedDate(endFmtTime)}`; } // only one day given, or both are the same day - const t = DateTime.fromISO(beginFmtTime || endFmtTime); + const t = DateTime.fromISO(beginFmtTime || endFmtTime, { setZone: true }); // We use toLocale to get Wed May 3, 2023 or equivalent, const tConversion = t.toLocaleString({ weekday: 'short', @@ -148,8 +148,8 @@ export function getFormattedDateAbbr(beginFmtTime: string, endFmtTime?: string) */ export function getFormattedTimeRange(beginFmtTime: string, endFmtTime: string) { if (!beginFmtTime || !endFmtTime) return; - const beginTime = DateTime.fromISO(beginFmtTime); - const endTime = DateTime.fromISO(endFmtTime); + const beginTime = DateTime.fromISO(beginFmtTime, { setZone: true }); + const endTime = DateTime.fromISO(endFmtTime, { setZone: true }); const range = endTime.diff(beginTime, ['hours']); const roundedHours = Math.round(range.as('hours')); // Round up or down to nearest hour const formattedRange = `${roundedHours} hour${roundedHours !== 1 ? 's' : ''}`; From 7480b926d6586658defa7b1bf5d2f014a643b5c4 Mon Sep 17 00:00:00 2001 From: Nitish Ramakrishnan Date: Mon, 20 Nov 2023 10:30:21 -0800 Subject: [PATCH 463/850] Removed unnecessary imports --- www/index.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/www/index.js b/www/index.js index cabf5ef04..865cc9a32 100644 --- a/www/index.js +++ b/www/index.js @@ -14,9 +14,8 @@ import './js/main.js'; import './js/diary.js'; import './js/diary/services.js'; import './js/survey/enketo/answer.js'; -import './js/survey/enketo/enketo-trip-button.js'; -import './js/survey/enketo/enketo-add-note-button.js'; -import './js/control/emailService.js'; +//import './js/survey/enketo/enketo-trip-button.js'; +//import './js/survey/enketo/enketo-add-note-button.js'; import './js/metrics-factory.js'; import './js/metrics-mappings.js'; import './js/plugin/logger.ts'; From fd0c1ab5de27b381be218aac0ad0ae38a89efd86 Mon Sep 17 00:00:00 2001 From: Katie Rischpater <98350084+the-bay-kay@users.noreply.github.com> Date: Mon, 20 Nov 2023 15:04:29 -0800 Subject: [PATCH 464/850] Added npm package, fixed HTML formatting - NPM package for GeoJSON types --- package.cordovabuild.json | 1 + package.serve.json | 1 + www/index.html | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/package.cordovabuild.json b/package.cordovabuild.json index 048f8f81d..9413912c7 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -105,6 +105,7 @@ "@react-navigation/native": "^6.1.7", "@react-navigation/stack": "^6.3.17", "@shopify/flash-list": "^1.3.1", + "@types/leaflet": "^1.9.4", "angular": "1.6.7", "angular-animate": "1.6.7", "angular-local-storage": "^0.7.1", diff --git a/package.serve.json b/package.serve.json index 6315b6a46..2ee9480dc 100644 --- a/package.serve.json +++ b/package.serve.json @@ -57,6 +57,7 @@ "@react-navigation/stack": "^6.3.17", "@shopify/flash-list": "^1.3.1", "@types/jest": "^29.5.5", + "@types/leaflet": "^1.9.4", "angular": "1.6.7", "angular-animate": "1.6.7", "angular-local-storage": "^0.7.1", diff --git a/www/index.html b/www/index.html index 44fcb5bbf..72c75eb01 100644 --- a/www/index.html +++ b/www/index.html @@ -15,4 +15,4 @@
    - \ No newline at end of file + From dd39c9284137ad55fcf99fa49269fd06935ae4b3 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 21 Nov 2023 00:24:41 -0500 Subject: [PATCH 465/850] fix missing import in timelineHelper --- www/js/diary/timelineHelper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index abaa969c1..f92dfae62 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -1,5 +1,5 @@ import moment from 'moment'; -import { logDebug } from '../plugin/logger'; +import { logDebug, displayError } from '../plugin/logger'; import { getBaseModeByKey, getBaseModeByValue } from './diaryHelper'; import { getUnifiedDataForInterval } from '../services/unifiedDataLoader'; import { getRawEntries } from '../services/commHelper'; From c0170de9f0add9d828cd747fc73468fa1b9218eb Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 21 Nov 2023 00:25:07 -0500 Subject: [PATCH 466/850] fix bad merge conflict resolution in diaryTypes --- www/js/types/diaryTypes.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index fcb144328..f77860c84 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -42,10 +42,6 @@ export type LocationCoord = { coordinates: [number, number]; }; -========= -}; - ->>>>>>>>> Temporary merge branch 2 /* These are the properties received from the server (basically matches Python code) This should match what Timeline.readAllCompositeTrips returns (an array of these objects) */ export type CompositeTrip = { From 3760d25d47e90a54e861e60ff6616259ffcd9b8b Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 21 Nov 2023 01:20:01 -0500 Subject: [PATCH 467/850] remove all remaining uses of getAngularService() -In useAppConfig.ts, we can't rely on $ionicPlatform anymore since we won't be using Angular moving forward. So we need an alternate way to ensure Cordova plugins are ready before we try to do stuff with them. I found that Cordova fires the 'deviceready' event once plugins are loaded - so we can just listen for that, wrap it in a promise, and use it in the place of $ionicPlatform.ready(...) -There were a few uses where getAngularService() was still used to access the old Logger, which were easily substituted to instead use the functions exported from logger.ts (logDebug, logWarn, etc) -There were several unused imports left over in files where getAngularService was formerly used and is no longer --- www/js/App.tsx | 1 - www/js/angular-react-helper.tsx | 25 ------------------- www/js/control/ControlCollectionHelper.tsx | 8 +++--- www/js/control/ControlSyncHelper.tsx | 24 ++++++------------ www/js/control/LogPage.tsx | 1 - www/js/control/ProfileSettings.jsx | 1 - www/js/control/SensedPage.tsx | 1 - www/js/diary/LabelTab.tsx | 1 - www/js/diary/addressNamesHelper.ts | 14 ++++------- www/js/metrics/CarbonTextCard.tsx | 1 - www/js/plugin/storage.ts | 1 - www/js/splash/notifScheduler.ts | 2 +- www/js/survey/enketo/UserInputButton.tsx | 1 - .../multilabel/MultiLabelButtonGroup.tsx | 1 - www/js/survey/multilabel/confirmHelper.ts | 1 - www/js/useAppConfig.ts | 12 ++++++--- 16 files changed, 26 insertions(+), 69 deletions(-) delete mode 100644 www/js/angular-react-helper.tsx diff --git a/www/js/App.tsx b/www/js/App.tsx index 2eece7f55..a013bea8c 100644 --- a/www/js/App.tsx +++ b/www/js/App.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useState, createContext, useMemo } from 'react'; -import { getAngularService } from './angular-react-helper'; import { ActivityIndicator, BottomNavigation, useTheme } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; import LabelTab from './diary/LabelTab'; diff --git a/www/js/angular-react-helper.tsx b/www/js/angular-react-helper.tsx deleted file mode 100644 index 3cf891666..000000000 --- a/www/js/angular-react-helper.tsx +++ /dev/null @@ -1,25 +0,0 @@ -// ---- angular-react-helper.jsx ---- -// Adapted from https://dev.to/kaplona/angularjs-to-react-migration-184g -// Modified to use React 18 and wrap elements with the React Native Paper Provider - -import angular from 'angular'; - -export function getAngularService(name: string) { - const injector = angular.element(document.body).injector(); - if (!injector || !injector.get) { - throw new Error(`Couldn't find angular injector to get "${name}" service`); - } - - const service = injector.get(name); - if (!service) { - throw new Error(`Couldn't find "${name}" angular service`); - } - - return service as any; // casting to 'any' because not all Angular services are typed -} - -export function createScopeWithVars(vars) { - const scope = getAngularService('$rootScope').$new(); - Object.assign(scope, vars); - return scope; -} diff --git a/www/js/control/ControlCollectionHelper.tsx b/www/js/control/ControlCollectionHelper.tsx index cc3efa8c1..b1066c48c 100644 --- a/www/js/control/ControlCollectionHelper.tsx +++ b/www/js/control/ControlCollectionHelper.tsx @@ -4,7 +4,7 @@ import { Dialog, Button, Switch, Text, useTheme, TextInput } from 'react-native- import { useTranslation } from 'react-i18next'; import ActionMenu from '../components/ActionMenu'; import { settingStyles } from './ProfileSettings'; -import { getAngularService } from '../angular-react-helper'; +import { displayError } from '../plugin/logger'; type collectionConfig = { is_duty_cycling: boolean; @@ -62,7 +62,6 @@ export async function isMediumAccuracy() { } export async function helperToggleLowAccuracy() { - const Logger = getAngularService('Logger'); let tempConfig = await getConfig(); let accuracyOptions = await getAccuracyOptions(); let medium = await isMediumAccuracy(); @@ -83,7 +82,7 @@ export async function helperToggleLowAccuracy() { let set = await setConfig(tempConfig); console.log('setConfig Sucess'); } catch (err) { - Logger.displayError('Error while setting collection config', err); + displayError(err, 'Error while setting collection config'); } } @@ -138,7 +137,6 @@ const formatConfigForDisplay = function (config, accuracyOptions) { const ControlCollectionHelper = ({ editVis, setEditVis }) => { const { colors } = useTheme(); - const Logger = getAngularService('Logger'); const [localConfig, setLocalConfig] = useState(); const [accuracyActions, setAccuracyActions] = useState([]); @@ -178,7 +176,7 @@ const ControlCollectionHelper = ({ editVis, setEditVis }) => { let set = await setConfig(localConfig); setEditVis(false); } catch (err) { - Logger.displayError('Error while setting collection config', err); + displayError(err, 'Error while setting collection config'); } } diff --git a/www/js/control/ControlSyncHelper.tsx b/www/js/control/ControlSyncHelper.tsx index 5225cf6c6..9ef31d3b8 100644 --- a/www/js/control/ControlSyncHelper.tsx +++ b/www/js/control/ControlSyncHelper.tsx @@ -3,13 +3,13 @@ import { Modal, View } from 'react-native'; import { Dialog, Button, Switch, Text, useTheme } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; import { settingStyles } from './ProfileSettings'; -import { getAngularService } from '../angular-react-helper'; import ActionMenu from '../components/ActionMenu'; import SettingRow from './SettingRow'; import AlertBar from './AlertBar'; import moment from 'moment'; import { addStatEvent, statKeys } from '../plugin/clientStats'; import { updateUser } from '../services/commHelper'; +import { displayError, logDebug, logWarn } from '../plugin/logger'; /* * BEGIN: Simple read/write wrappers @@ -45,7 +45,6 @@ type syncConfig = { sync_interval: number; ios_use_remote_push: boolean }; export const ForceSyncRow = ({ getState }) => { const { t } = useTranslation(); const { colors } = useTheme(); - const Logger = getAngularService('Logger'); const [dataPendingVis, setDataPendingVis] = useState(false); const [dataPushedVis, setDataPushedVis] = useState(false); @@ -74,24 +73,18 @@ export const ForceSyncRow = ({ getState }) => { }; let syncLaunchedCalls = sensorDataList.filter(isTripEnd); let syncPending = syncLaunchedCalls.length > 0; - Logger.log( - 'sensorDataList.length = ' + - sensorDataList.length + - ', syncLaunchedCalls.length = ' + - syncLaunchedCalls.length + - ', syncPending? = ' + - syncPending, - ); - Logger.log('sync launched = ' + syncPending); + logDebug(`sensorDataList.length = ${sensorDataList.length}, + syncLaunchedCalls.length = ${syncLaunchedCalls.length}, + syncPending? = ${syncPending}`); if (syncPending) { - Logger.log(Logger.log('data is pending, showing confirm dialog')); + logDebug('data is pending, showing confirm dialog'); setDataPendingVis(true); //consent handling in modal } else { setDataPushedVis(true); } } catch (error) { - Logger.displayError('Error while forcing sync', error); + displayError(error, 'Error while forcing sync'); } } @@ -161,7 +154,7 @@ export const ForceSyncRow = ({ getState }) => { @@ -188,7 +181,6 @@ export const ForceSyncRow = ({ getState }) => { const ControlSyncHelper = ({ editVis, setEditVis }) => { const { t } = useTranslation(); const { colors } = useTheme(); - const Logger = getAngularService('Logger'); const [localConfig, setLocalConfig] = useState(); const [intervalVis, setIntervalVis] = useState(false); @@ -231,7 +223,7 @@ const ControlSyncHelper = ({ editVis, setEditVis }) => { }); } catch (err) { console.log('error with setting sync config', err); - Logger.displayError('Error while setting sync config', err); + displayError(err, 'Error while setting sync config'); } } const onChooseInterval = function (interval) { diff --git a/www/js/control/LogPage.tsx b/www/js/control/LogPage.tsx index 6d603f19e..b95baf715 100644 --- a/www/js/control/LogPage.tsx +++ b/www/js/control/LogPage.tsx @@ -1,7 +1,6 @@ import React, { useState, useMemo, useEffect } from 'react'; import { View, StyleSheet, SafeAreaView, Modal } from 'react-native'; import { useTheme, Text, Appbar, IconButton } from 'react-native-paper'; -import { getAngularService } from '../angular-react-helper'; import { useTranslation } from 'react-i18next'; import { FlashList } from '@shopify/flash-list'; import moment from 'moment'; diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index d14911114..1799c2c2c 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -1,7 +1,6 @@ import React, { useState, useEffect, useContext, useRef } from 'react'; import { Modal, StyleSheet, ScrollView } from 'react-native'; import { Dialog, Button, useTheme, Text, Appbar, IconButton, TextInput } from 'react-native-paper'; -import { getAngularService } from '../angular-react-helper'; import { useTranslation } from 'react-i18next'; import ExpansionSection from './ExpandMenu'; import SettingRow from './SettingRow'; diff --git a/www/js/control/SensedPage.tsx b/www/js/control/SensedPage.tsx index db1d43535..0c6e60b17 100644 --- a/www/js/control/SensedPage.tsx +++ b/www/js/control/SensedPage.tsx @@ -1,7 +1,6 @@ import React, { useState, useEffect } from 'react'; import { View, StyleSheet, SafeAreaView, Modal } from 'react-native'; import { useTheme, Appbar, IconButton, Text } from 'react-native-paper'; -import { getAngularService } from '../angular-react-helper'; import { useTranslation } from 'react-i18next'; import { FlashList } from '@shopify/flash-list'; import moment from 'moment'; diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 2e181859c..abdfd0be6 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -7,7 +7,6 @@ */ import React, { useEffect, useState, useRef } from 'react'; -import { getAngularService } from '../angular-react-helper'; import useAppConfig from '../useAppConfig'; import { useTranslation } from 'react-i18next'; import { invalidateMaps } from '../components/LeafletView'; diff --git a/www/js/diary/addressNamesHelper.ts b/www/js/diary/addressNamesHelper.ts index f0e17921a..cf3049492 100644 --- a/www/js/diary/addressNamesHelper.ts +++ b/www/js/diary/addressNamesHelper.ts @@ -74,7 +74,7 @@ export function useLocalStorage(key: string, initialValue: T) { } import Bottleneck from 'bottleneck'; -import { getAngularService } from '../angular-react-helper'; +import { displayError, logDebug } from '../plugin/logger'; let nominatimLimiter = new Bottleneck({ maxConcurrent: 2, minTime: 500 }); export const resetNominatimLimiter = () => { @@ -104,11 +104,9 @@ function toAddressName(data) { } let nominatimError: Error; -let Logger; // fetches nominatim data for a given location and stores it using the coordinates as the key // if the address name is already cached, it skips the fetch async function fetchNominatimLocName(loc_geojson) { - Logger = Logger || getAngularService('Logger'); const coordsStr = loc_geojson.coordinates.toString(); const cachedResponse = localStorage.getItem(coordsStr); if (cachedResponse) { @@ -129,17 +127,15 @@ async function fetchNominatimLocName(loc_geojson) { try { const response = await fetch(url); const data = await response.json(); - Logger.log( - `while reading data from nominatim, status = ${response.status} data = ${JSON.stringify( - data, - )}`, - ); + logDebug(`while reading data from nominatim, + status = ${response.status}; + data = ${JSON.stringify(data)}`); localStorage.setItem(coordsStr, JSON.stringify(data)); publish(coordsStr, data); } catch (error) { if (!nominatimError) { nominatimError = error; - Logger.displayError('while reading address data ', error); + displayError(error, 'while reading address data'); } } } diff --git a/www/js/metrics/CarbonTextCard.tsx b/www/js/metrics/CarbonTextCard.tsx index bf40c4a61..81495a099 100644 --- a/www/js/metrics/CarbonTextCard.tsx +++ b/www/js/metrics/CarbonTextCard.tsx @@ -16,7 +16,6 @@ import { calculatePercentChange, segmentDaysByWeeks, } from './metricsHelper'; -import { getAngularService } from '../angular-react-helper'; type Props = { userMetrics: MetricsData; aggMetrics: MetricsData }; const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { diff --git a/www/js/plugin/storage.ts b/www/js/plugin/storage.ts index 63604e8c1..51589ad17 100644 --- a/www/js/plugin/storage.ts +++ b/www/js/plugin/storage.ts @@ -1,4 +1,3 @@ -import { getAngularService } from '../angular-react-helper'; import { addStatReading, statKeys } from './clientStats'; import { logDebug, logWarn } from './logger'; diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts index 55c4b3cdb..ad767d136 100644 --- a/www/js/splash/notifScheduler.ts +++ b/www/js/splash/notifScheduler.ts @@ -1,5 +1,5 @@ import { addStatReading, statKeys } from '../plugin/clientStats'; -import { getUser, updateUser } from '../commHelper'; +import { getUser, updateUser } from '../services/commHelper'; import { displayErrorMsg, logDebug } from '../plugin/logger'; import { DateTime } from 'luxon'; import i18next from 'i18next'; diff --git a/www/js/survey/enketo/UserInputButton.tsx b/www/js/survey/enketo/UserInputButton.tsx index f2ed4c6e7..5673cc687 100644 --- a/www/js/survey/enketo/UserInputButton.tsx +++ b/www/js/survey/enketo/UserInputButton.tsx @@ -9,7 +9,6 @@ */ import React, { useContext, useMemo, useState } from 'react'; -import { getAngularService } from '../../angular-react-helper'; import DiaryButton from '../../components/DiaryButton'; import { useTranslation } from 'react-i18next'; import { useTheme } from 'react-native-paper'; diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index a6023f1f4..41c3de560 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -3,7 +3,6 @@ Next to the buttons is a small checkmark icon, which marks inferrel labels as confirmed */ import React, { useContext, useEffect, useState, useMemo } from 'react'; -import { getAngularService } from '../../angular-react-helper'; import { View, Modal, ScrollView, Pressable, useWindowDimensions } from 'react-native'; import { IconButton, diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index 6a91095ac..f02d57a6b 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -1,6 +1,5 @@ // may refactor this into a React hook once it's no longer used by any Angular screens -import { getAngularService } from '../../angular-react-helper'; import { fetchUrlCached } from '../../services/commHelper'; import i18next from 'i18next'; import { logDebug } from '../../plugin/logger'; diff --git a/www/js/useAppConfig.ts b/www/js/useAppConfig.ts index 96d1a56cb..0c57b7b2c 100644 --- a/www/js/useAppConfig.ts +++ b/www/js/useAppConfig.ts @@ -1,14 +1,20 @@ import { useEffect, useState } from 'react'; -import { getAngularService } from './angular-react-helper'; import { configChanged, getConfig, setConfigChanged } from './config/dynamicConfig'; import { logDebug } from './plugin/logger'; +/* For Cordova, 'deviceready' means that Cordova plugins are loaded and ready to access. + https://cordova.apache.org/docs/en/5.0.0/cordova/events/events.deviceready.html + We wrap this event in a promise and await it before attempting to update the config, + since loading the config requires accessing native storage through plugins. */ +const deviceReady = new Promise((resolve) => { + document.addEventListener('deviceready', resolve); +}); + const useAppConfig = () => { const [appConfig, setAppConfig] = useState(null); - const $ionicPlatform = getAngularService('$ionicPlatform'); useEffect(() => { - $ionicPlatform.ready().then(updateConfig); + deviceReady.then(updateConfig); }, []); function updateConfig() { From 2a0daf90c85118563903705c96af91e0e8a314a8 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 21 Nov 2023 01:33:59 -0500 Subject: [PATCH 468/850] remove unused files + services --- www/index.js | 8 -- www/js/controllers.js | 73 ------------------ www/js/diary.js | 16 ---- www/js/i18n-utils.js | 49 ------------- www/js/main.js | 34 --------- www/js/ngApp.js | 17 +---- www/js/plugin/logger.ts | 31 -------- www/js/services.js | 127 -------------------------------- www/js/splash/localnotify.js | 138 ----------------------------------- www/js/splash/referral.js | 44 ----------- 10 files changed, 4 insertions(+), 533 deletions(-) delete mode 100644 www/js/controllers.js delete mode 100644 www/js/diary.js delete mode 100644 www/js/i18n-utils.js delete mode 100644 www/js/main.js delete mode 100644 www/js/services.js delete mode 100644 www/js/splash/localnotify.js delete mode 100644 www/js/splash/referral.js diff --git a/www/index.js b/www/index.js index 14ed7fd23..3677f2ca2 100644 --- a/www/index.js +++ b/www/index.js @@ -4,11 +4,3 @@ import './css/main.diary.css'; import 'leaflet/dist/leaflet.css'; import './js/ngApp.js'; -import './js/splash/referral.js'; -import './js/splash/localnotify.js'; -import './js/controllers.js'; -import './js/services.js'; -import './js/i18n-utils.js'; -import './js/main.js'; -import './js/diary.js'; -import './js/plugin/logger.ts'; diff --git a/www/js/controllers.js b/www/js/controllers.js deleted file mode 100644 index e5ab2749e..000000000 --- a/www/js/controllers.js +++ /dev/null @@ -1,73 +0,0 @@ -'use strict'; - -import angular from 'angular'; -import { addStatError, addStatReading, statKeys } from './plugin/clientStats'; -import { getPendingOnboardingState } from './onboarding/onboardingHelper'; - -angular - .module('emission.controllers', ['emission.splash.localnotify']) - - .controller('RootCtrl', function ($scope) {}) - - .controller('DashCtrl', function ($scope) {}) - - // alert("attach debugger!"); - // PushNotify.startupInit(); - .controller('SplashCtrl', function ($scope, $state, $interval, $rootScope, LocalNotify) { - console.log('SplashCtrl invoked'); - - $rootScope.$on( - '$stateChangeSuccess', - function (event, toState, toParams, fromState, fromParams) { - console.log( - 'Finished changing state from ' + - JSON.stringify(fromState) + - ' to ' + - JSON.stringify(toState), - ); - addStatReading(statKeys.STATE_CHANGED, fromState.name + '-2-' + toState.name); - }, - ); - $rootScope.$on( - '$stateChangeError', - function (event, toState, toParams, fromState, fromParams, error) { - console.log( - 'Error ' + - error + - ' while changing state from ' + - JSON.stringify(fromState) + - ' to ' + - JSON.stringify(toState), - ); - addStatError(statKeys.STATE_CHANGED, fromState.name + '-2-' + toState.name + '_' + error); - }, - ); - $rootScope.$on('$stateNotFound', function (event, unfoundState, fromState, fromParams) { - console.log('unfoundState.to = ' + unfoundState.to); // "lazy.state" - console.log('unfoundState.toParams = ' + unfoundState.toParams); // {a:1, b:2} - console.log('unfoundState.options = ' + unfoundState.options); // {inherit:false} + default options - addStatError(statKeys.STATE_CHANGED, fromState.name + '-2-' + unfoundState.name); - }); - - var isInList = function (element, list) { - return list.indexOf(element) != -1; - }; - - $rootScope.$on( - '$stateChangeStart', - function (event, toState, toParams, fromState, fromParams, options) { - var personalTabs = ['root.main.common.map', 'root.main.control', 'root.main.metrics']; - if (isInList(toState.name, personalTabs)) { - // toState is in the personalTabs list - getPendingOnboardingState().then(function (result) { - if (result != null) { - event.preventDefault(); - $state.go(result); - } - // else, will do default behavior, which is to go to the tab - }); - } - }, - ); - console.log('SplashCtrl invoke finished'); - }); diff --git a/www/js/diary.js b/www/js/diary.js deleted file mode 100644 index 729aa807c..000000000 --- a/www/js/diary.js +++ /dev/null @@ -1,16 +0,0 @@ -import angular from 'angular'; -import LabelTab from './diary/LabelTab'; - -angular - .module('emission.main.diary', ['emission.plugin.logger']) - - .config(function ($stateProvider) { - $stateProvider.state('root.main.inf_scroll', { - url: '/inf_scroll', - views: { - 'main-inf-scroll': { - template: '', - }, - }, - }); - }); diff --git a/www/js/i18n-utils.js b/www/js/i18n-utils.js deleted file mode 100644 index bcfb74391..000000000 --- a/www/js/i18n-utils.js +++ /dev/null @@ -1,49 +0,0 @@ -'use strict'; - -import angular from 'angular'; - -angular.module('emission.i18n.utils', []).factory('i18nUtils', function ($http, Logger) { - var iu = {}; - // copy-pasted from ngCordova, and updated to promises - iu.checkFile = function (fn) { - return new Promise(function (resolve, reject) { - if (/^\//.test(fn)) { - reject('directory cannot start with /'); - } - - return $http.get(fn); - }); - }; - - // The language comes in between the first and second part - // the default path should end with a "/" - iu.geti18nFileName = function (defaultPath, fpFirstPart, fpSecondPart) { - const lang = i18next.resolvedLanguage; - const i18nPath = 'i18n/'; - var defaultVal = defaultPath + fpFirstPart + fpSecondPart; - if (lang != 'en') { - var url = i18nPath + fpFirstPart + '-' + lang + fpSecondPart; - return $http - .get(url) - .then(function (result) { - Logger.log( - window.Logger.LEVEL_DEBUG, - 'Successfully found the ' + - url + - ', result is ' + - JSON.stringify(result.data).substring(0, 10), - ); - return url; - }) - .catch(function (err) { - Logger.log( - window.Logger.LEVEL_DEBUG, - url + ' file not found, loading english version, error is ' + JSON.stringify(err), - ); - return Promise.resolve(defaultVal); - }); - } - return Promise.resolve(defaultVal); - }; - return iu; -}); diff --git a/www/js/main.js b/www/js/main.js deleted file mode 100644 index a343f1d7a..000000000 --- a/www/js/main.js +++ /dev/null @@ -1,34 +0,0 @@ -'use strict'; - -import angular from 'angular'; - -angular - .module('emission.main', ['emission.main.diary', 'emission.i18n.utils', 'emission.services']) - - .config(function ($stateProvider) { - $stateProvider.state('root.main', { - url: '/main', - template: ``, - }); - }) - - .controller('appCtrl', function ($scope, $ionicModal, $timeout) { - $scope.openNativeSettings = function () { - window.Logger.log(window.Logger.LEVEL_DEBUG, 'about to open native settings'); - window.cordova.plugins.BEMLaunchNative.launch( - 'NativeSettings', - function (result) { - window.Logger.log( - window.Logger.LEVEL_DEBUG, - 'Successfully opened screen NativeSettings, result is ' + result, - ); - }, - function (err) { - window.Logger.log( - window.Logger.LEVEL_ERROR, - 'Unable to open screen NativeSettings because of err ' + err, - ); - }, - ); - }; - }); diff --git a/www/js/ngApp.js b/www/js/ngApp.js index 84b9972c4..695f37a4a 100644 --- a/www/js/ngApp.js +++ b/www/js/ngApp.js @@ -31,21 +31,12 @@ import { Provider as PaperProvider } from 'react-native-paper'; import App from './App'; import { getTheme } from './appTheme'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { logDebug } from './plugin/logger'; angular - .module('emission', [ - 'ionic', - 'jm.i18next', - 'emission.controllers', - 'emission.services', - 'emission.plugin.logger', - 'emission.splash.referral', - 'emission.main', - 'pascalprecht.translate', - 'LocalStorageModule', - ]) + .module('emission', ['ionic', 'jm.i18next', 'pascalprecht.translate', 'LocalStorageModule']) - .run(function ($ionicPlatform, $rootScope, $http, Logger, localStorageService) { + .run(function ($ionicPlatform, $rootScope, $http, localStorageService) { console.log('Starting run'); // ensure that plugin events are delivered after the ionicPlatform is ready // https://github.com/katzer/cordova-plugin-local-notifications#launch-details @@ -54,7 +45,7 @@ angular $ionicPlatform.ready(function () { // Hide the accessory bar by default (remove this to show the accessory bar above the keyboard // for form inputs) - Logger.log('ionicPlatform is ready'); + logDebug('ionicPlatform is ready'); if (window.StatusBar) { // org.apache.cordova.statusbar required diff --git a/www/js/plugin/logger.ts b/www/js/plugin/logger.ts index 376c6486b..c2e678d40 100644 --- a/www/js/plugin/logger.ts +++ b/www/js/plugin/logger.ts @@ -1,34 +1,3 @@ -import angular from 'angular'; - -angular - .module('emission.plugin.logger', []) - - // explicit annotations needed in .ts files - Babel does not fix them (see webpack.prod.js) - .factory('Logger', [ - '$window', - '$ionicPopup', - function ($window, $ionicPopup) { - var loggerJs: any = {}; - loggerJs.log = function (message) { - $window.Logger.log($window.Logger.LEVEL_DEBUG, message); - }; - loggerJs.displayError = function (title, error) { - var display_msg = error.message + '\n' + error.stack; - if (!angular.isDefined(error.message)) { - display_msg = JSON.stringify(error); - } - // Check for OPcode DNE errors and prepend the title with "Invalid OPcode" - if (error.includes?.('403') || error.message?.includes?.('403')) { - title = 'Invalid OPcode: ' + title; - } - $ionicPopup.alert({ title: title, template: display_msg }); - console.log(title + display_msg); - $window.Logger.log($window.Logger.LEVEL_ERROR, title + display_msg); - }; - return loggerJs; - }, - ]); - export const logDebug = (message: string) => window['Logger'].log(window['Logger'].LEVEL_DEBUG, message); diff --git a/www/js/services.js b/www/js/services.js deleted file mode 100644 index 59eb56810..000000000 --- a/www/js/services.js +++ /dev/null @@ -1,127 +0,0 @@ -'use strict'; - -import angular from 'angular'; -import { getRawEntries } from './services/commHelper'; - -angular - .module('emission.services', ['emission.plugin.logger']) - .service('ControlHelper', function ($window, $ionicPopup, Logger) { - this.writeFile = function (fileEntry, resultList) { - // Create a FileWriter object for our FileEntry (log.txt). - }; - - this.getMyData = function (startTs) { - var fmt = 'YYYY-MM-DD'; - // We are only retrieving data for a single day to avoid - // running out of memory on the phone - var startMoment = moment(startTs); - var endMoment = moment(startTs).endOf('day'); - var dumpFile = startMoment.format(fmt) + '.' + endMoment.format(fmt) + '.timeline'; - alert('Going to retrieve data to ' + dumpFile); - - var writeDumpFile = function (result) { - return new Promise(function (resolve, reject) { - var resultList = result.phone_data; - window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function (fs) { - console.log('file system open: ' + fs.name); - fs.root.getFile(dumpFile, { create: true, exclusive: false }, function (fileEntry) { - console.log( - 'fileEntry ' + fileEntry.nativeURL + ' is file?' + fileEntry.isFile.toString(), - ); - fileEntry.createWriter(function (fileWriter) { - fileWriter.onwriteend = function () { - console.log('Successful file write...'); - resolve(); - // readFile(fileEntry); - }; - - fileWriter.onerror = function (e) { - console.log('Failed file write: ' + e.toString()); - reject(); - }; - - // If data object is not passed in, - // create a new Blob instead. - var dataObj = new Blob([JSON.stringify(resultList, null, 2)], { - type: 'application/json', - }); - fileWriter.write(dataObj); - }); - // this.writeFile(fileEntry, resultList); - }); - }); - }); - }; - - var emailData = function (result) { - return new Promise(function (resolve, reject) { - window.requestFileSystem(window.LocalFileSystem.TEMPORARY, 0, function (fs) { - console.log('During email, file system open: ' + fs.name); - fs.root.getFile(dumpFile, null, function (fileEntry) { - console.log( - 'fileEntry ' + fileEntry.nativeURL + ' is file?' + fileEntry.isFile.toString(), - ); - fileEntry.file( - function (file) { - var reader = new FileReader(); - - reader.onloadend = function () { - console.log('Successful file read with ' + this.result.length + ' characters'); - var dataArray = JSON.parse(this.result); - console.log('Successfully read resultList of size ' + dataArray.length); - // displayFileData(fileEntry.fullPath + ": " + this.result); - var attachFile = fileEntry.nativeURL; - if (ionic.Platform.isAndroid()) { - // At least on nexus, getting a temporary file puts it into - // the cache, so I can hardcode that for now - attachFile = 'app://cache/' + dumpFile; - } - if (ionic.Platform.isIOS()) { - alert(i18next.t('email-service.email-account-mail-app')); - } - var email = { - attachments: [attachFile], - subject: i18next.t('email-service.email-data.subject-data-dump-from-to', { - start: startMoment.format(fmt), - end: endMoment.format(fmt), - }), - body: i18next.t( - 'email-service.email-data.body-data-consists-of-list-of-entries', - ), - }; - $window.cordova.plugins.email.open(email).then(resolve()); - }; - reader.readAsText(file); - }, - function (error) { - $ionicPopup.alert({ - title: 'Error while downloading JSON dump', - template: error, - }); - reject(error); - }, - ); - }); - }); - }); - }; - - getRawEntries(null, startMoment.unix(), endMoment.unix()) - .then(writeDumpFile) - .then(emailData) - .then(function () { - Logger.log('Email queued successfully'); - }) - .catch(function (error) { - Logger.displayError('Error emailing JSON dump', error); - }); - }; - - this.getOPCode = function () { - return window.cordova.plugins.OPCodeAuth.getOPCode(); - }; - - this.getSettings = function () { - return window.cordova.plugins.BEMConnectionSettings.getSettings(); - }; - }); diff --git a/www/js/splash/localnotify.js b/www/js/splash/localnotify.js deleted file mode 100644 index 9f3db3ab3..000000000 --- a/www/js/splash/localnotify.js +++ /dev/null @@ -1,138 +0,0 @@ -/* - * We think that a common pattern is to generate a prompt to notify the user - * about something and then to re-route them to the appropriate tab. An - * existing example is the notification prompt. So let's write a standard - * factory to make that easier. - */ - -import angular from 'angular'; - -angular - .module('emission.splash.localnotify', ['emission.plugin.logger', 'ionic-toast']) - .factory( - 'LocalNotify', - function ($window, $ionicPlatform, $ionicPopup, $state, $rootScope, ionicToast, Logger) { - var localNotify = {}; - - /* - * Return the state to redirect to, undefined otherwise - */ - localNotify.getRedirectState = function (data) { - // TODO: Think whether this should be in data or in category - if (angular.isDefined(data)) { - return [data.redirectTo, data.redirectParams]; - } - return undefined; - }; - - localNotify.handleLaunch = function (targetState, targetParams) { - $rootScope.redirectTo = targetState; - $rootScope.redirectParams = targetParams; - $state.go(targetState, targetParams, { reload: true }); - }; - - localNotify.handlePrompt = function (notification, targetState, targetParams) { - Logger.log( - 'Prompting for notification ' + notification.title + ' and text ' + notification.text, - ); - var promptPromise = $ionicPopup.show({ - title: notification.title, - template: notification.text, - buttons: [ - { - text: 'Handle', - type: 'button-positive', - onTap: function (e) { - // e.preventDefault() will stop the popup from closing when tapped. - return true; - }, - }, - { - text: 'Ignore', - type: 'button-positive', - onTap: function (e) { - return false; - }, - }, - ], - }); - promptPromise.then(function (handle) { - if (handle == true) { - localNotify.handleLaunch(targetState, targetParams); - } else { - Logger.log( - 'Ignoring notification ' + notification.title + ' and text ' + notification.text, - ); - } - }); - }; - - localNotify.handleNotification = function (notification, state, data) { - // Comment this out for ease of testing. But in the real world, we do in fact want to - // cancel the notification to avoid "hey! I just fixed this, why is the notification still around!" - // issues - // $window.cordova.plugins.notification.local.cancel(notification.id); - let redirectData = notification; - if (state.event == 'action') { - redirectData = notification.data.action; - } - var [targetState, targetParams] = localNotify.getRedirectState(redirectData); - Logger.log('targetState = ' + targetState); - if (angular.isDefined(targetState)) { - if (state.foreground == true) { - localNotify.handlePrompt(notification, targetState, targetParams); - } else { - localNotify.handleLaunch(targetState, targetParams); - } - } - }; - - localNotify.registerRedirectHandler = function () { - Logger.log('registerUserResponse received!'); - $window.cordova.plugins.notification.local.on( - 'action', - function (notification, state, data) { - localNotify.handleNotification(notification, state, data); - }, - ); - $window.cordova.plugins.notification.local.on( - 'clear', - function (notification, state, data) { - // alert("notification cleared, no report"); - }, - ); - $window.cordova.plugins.notification.local.on( - 'cancel', - function (notification, state, data) { - // alert("notification cancelled, no report"); - }, - ); - $window.cordova.plugins.notification.local.on( - 'trigger', - function (notification, state, data) { - ionicToast.show( - `Notification: ${notification.title}\n${notification.text}`, - 'bottom', - false, - 250000, - ); - localNotify.handleNotification(notification, state, data); - }, - ); - $window.cordova.plugins.notification.local.on( - 'click', - function (notification, state, data) { - localNotify.handleNotification(notification, state, data); - }, - ); - }; - - $ionicPlatform.ready().then(function () { - localNotify.registerRedirectHandler(); - Logger.log('finished registering handlers, about to fire queued events'); - $window.cordova.plugins.notification.local.fireQueuedEvents(); - }); - - return localNotify; - }, - ); diff --git a/www/js/splash/referral.js b/www/js/splash/referral.js deleted file mode 100644 index 334fd0ebe..000000000 --- a/www/js/splash/referral.js +++ /dev/null @@ -1,44 +0,0 @@ -import angular from 'angular'; -import { storageGetDirect, storageRemove, storageSet } from '../plugin/storage'; - -angular - .module('emission.splash.referral', []) - - .factory('ReferralHandler', function ($window) { - var referralHandler = {}; - - var REFERRAL_NAVIGATION_KEY = 'referral_navigation'; - var REFERRED_KEY = 'referred'; - var REFERRED_GROUP_ID = 'referred_group_id'; - var REFERRED_USER_ID = 'referred_user_id'; - - referralHandler.getReferralNavigation = function () { - const toReturn = storageGetDirect(REFERRAL_NAVIGATION_KEY); - storageRemove(REFERRAL_NAVIGATION_KEY); - return toReturn; - }; - - referralHandler.setupGroupReferral = function (kvList) { - storageSet(REFERRED_KEY, true); - storageSet(REFERRED_GROUP_ID, kvList['groupid']); - storageSet(REFERRED_USER_ID, kvList['userid']); - storageSet(REFERRAL_NAVIGATION_KEY, 'goals'); - }; - - referralHandler.clearGroupReferral = function (kvList) { - storageRemove(REFERRED_KEY); - storageRemove(REFERRED_GROUP_ID); - storageRemove(REFERRED_USER_ID); - storageRemove(REFERRAL_NAVIGATION_KEY); - }; - - referralHandler.getReferralParams = function (kvList) { - return [storageGetDirect(REFERRED_GROUP_ID), storageGetDirect(REFERRED_USER_ID)]; - }; - - referralHandler.hasPendingRegistration = function () { - return storageGetDirect(REFERRED_KEY); - }; - - return referralHandler; - }); From 87fdae505b80ac0d0535ebeddecbb8b5ffd4a48f Mon Sep 17 00:00:00 2001 From: Sebastian Barry Date: Tue, 21 Nov 2023 09:33:48 -0600 Subject: [PATCH 469/850] Add reminder scheme missing test for updateScheduledNotifs I also had to adjust the error coming from notifScheduler because it wasn't helping much with the way it was defined before --- www/__tests__/notifScheduler.test.ts | 15 +++++++++++++++ www/js/splash/notifScheduler.ts | 14 ++++---------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/www/__tests__/notifScheduler.test.ts b/www/__tests__/notifScheduler.test.ts index 00a925678..f49cd84bc 100644 --- a/www/__tests__/notifScheduler.test.ts +++ b/www/__tests__/notifScheduler.test.ts @@ -245,4 +245,19 @@ describe('updateScheduledNotifs', () => { expect(logDebug).toHaveBeenCalledWith('Already scheduled, not scheduling again'); }); + + it('should log an error message if the reminder scheme is missing', async () => { + // updateScheduleNotifs arguments + let reminderSchemes: any = exampleReminderSchemes; + delete reminderSchemes.weekly; // delete the weekly reminder scheme, to create a missing reminder scheme error + let isScheduling: boolean = false; + const setIsScheduling: Function = jest.fn((val: boolean) => (isScheduling = val)); + const scheduledPromise: Promise = Promise.resolve(); + + await updateScheduledNotifs(reminderSchemes, isScheduling, setIsScheduling, scheduledPromise); + + // Your assertions here + expect(logDebug).toHaveBeenCalledWith('Error: Reminder scheme not found'); + // Add more assertions as needed + }); }); diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts index 55c4b3cdb..114d5d9b9 100644 --- a/www/js/splash/notifScheduler.ts +++ b/www/js/splash/notifScheduler.ts @@ -173,16 +173,10 @@ export const updateScheduledNotifs = async ( setIsScheduling, scheduledPromise, ); - var scheme = {}; - try { - scheme = reminderSchemes[reminder_assignment]; - } catch (e) { - displayErrorMsg( - 'ERROR: Could not find reminder scheme for assignment ' + - reminderSchemes + - ' - ' + - reminder_assignment, - ); + const scheme = reminderSchemes[reminder_assignment]; + if (scheme === undefined) { + logDebug('Error: Reminder scheme not found'); + return; } const notifTimes = calcNotifTimes(scheme, reminder_join_date, reminder_time_of_day); return new Promise((resolve, reject) => { From 28714026c8f83499c775abec64fe75b8b5931a01 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 21 Nov 2023 10:39:12 -0500 Subject: [PATCH 470/850] remove angular from customMetricsHelper --- www/js/metrics/customMetricsHelper.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/www/js/metrics/customMetricsHelper.ts b/www/js/metrics/customMetricsHelper.ts index 317113327..888e794ad 100644 --- a/www/js/metrics/customMetricsHelper.ts +++ b/www/js/metrics/customMetricsHelper.ts @@ -1,4 +1,3 @@ -import angular from 'angular'; import { getLabelOptions } from '../survey/multilabel/confirmHelper'; import { displayError, displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; import { standardMETs } from './metDataset'; @@ -58,7 +57,7 @@ function populateCustomMETs() { } } }); - _customMETs = Object.fromEntries(modeMETEntries.filter((e) => angular.isDefined(e))); + _customMETs = Object.fromEntries(modeMETEntries.filter((e) => typeof e !== 'undefined')); logDebug('After populating, custom METs = ' + JSON.stringify(_customMETs)); } @@ -80,13 +79,13 @@ function populateCustomFootprints() { _range_limited_motorized = opt; logDebug(`Found range limited motorized mode - ${_range_limited_motorized}`); } - if (angular.isDefined(opt.kgCo2PerKm)) { + if (typeof opt.kgCo2PerKm !== 'undefined') { return [opt.value, opt.kgCo2PerKm]; } else { return undefined; } }) - .filter((modeCO2) => angular.isDefined(modeCO2)); + .filter((modeCO2) => typeof modeCO2 !== 'undefined'); _customPerKmFootprint = Object.fromEntries(modeCO2PerKm); logDebug('After populating, custom perKmFootprint' + JSON.stringify(_customPerKmFootprint)); } From 4df43c21d477faeadf197aa157bb752a69775f10 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 21 Nov 2023 10:54:44 -0500 Subject: [PATCH 471/850] don't use angular in ngApp.js The startup logic will now happen not in an Angular context, but with an event listener for 'deviceready' (which is what Cordova uses) And the React initialization will happen from there. --- www/js/ngApp.js | 77 +++++++++++++++++++------------------------------ 1 file changed, 30 insertions(+), 47 deletions(-) diff --git a/www/js/ngApp.js b/www/js/ngApp.js index 695f37a4a..7cd66fba7 100644 --- a/www/js/ngApp.js +++ b/www/js/ngApp.js @@ -1,7 +1,3 @@ -// Ionic E-Mission App - -'use strict'; - import angular from 'angular'; import React from 'react'; import { createRoot } from 'react-dom/client'; @@ -33,50 +29,37 @@ import { getTheme } from './appTheme'; import { SafeAreaView } from 'react-native-safe-area-context'; import { logDebug } from './plugin/logger'; -angular - .module('emission', ['ionic', 'jm.i18next', 'pascalprecht.translate', 'LocalStorageModule']) - - .run(function ($ionicPlatform, $rootScope, $http, localStorageService) { - console.log('Starting run'); - // ensure that plugin events are delivered after the ionicPlatform is ready - // https://github.com/katzer/cordova-plugin-local-notifications#launch-details - window.skipLocalNotificationReady = true; - // alert("Starting run"); - $ionicPlatform.ready(function () { - // Hide the accessory bar by default (remove this to show the accessory bar above the keyboard - // for form inputs) - logDebug('ionicPlatform is ready'); +export const deviceReady = new Promise((resolve) => { + document.addEventListener('deviceready', resolve); +}); - if (window.StatusBar) { - // org.apache.cordova.statusbar required - StatusBar.styleDefault(); - } - cordova.plugin.http.setDataSerializer('json'); - // backwards compat hack to be consistent with - // https://github.com/e-mission/e-mission-data-collection/commit/92f41145e58c49e3145a9222a78d1ccacd16d2a7#diff-962320754eba07107ecd413954411f725c98fd31cddbb5defd4a542d1607e5a3R160 - // remove during migration to react native - localStorageService.remove('OP_GEOFENCE_CFG'); - cordova.plugins.BEMUserCache.removeLocalStorage('OP_GEOFENCE_CFG'); +/* ensure that plugin events are not delivered before Cordova is ready: + https://github.com/katzer/cordova-plugin-local-notifications#launch-details */ +window.skipLocalNotificationReady = true; - const rootEl = document.getElementById('appRoot'); - const reactRoot = createRoot(rootEl); +deviceReady.then(() => { + logDebug('deviceReady'); + /* give status bar dark text because we have a light background + https://cordova.apache.org/docs/en/10.x/reference/cordova-plugin-statusbar/#statusbarstyledefault */ + if (window['StatusBar']) window['StatusBar'].styleDefault(); + cordova.plugin.http.setDataSerializer('json'); + const rootEl = document.getElementById('appRoot'); + const reactRoot = createRoot(rootEl); - const theme = getTheme(); + const theme = getTheme(); - reactRoot.render( - - - - - - , - ); - }); - console.log('Ending run'); - }); + reactRoot.render( + + + + + + , + ); +}); From b81493c138647e08d013f461091cc10349a400a8 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 21 Nov 2023 11:24:03 -0500 Subject: [PATCH 472/850] Clean up this file - remove unused imports - use logDebug, logInfo, logWarn, format nicely - replace ionic.Platform.isIOS / isAndroid with cordova.platformId --- www/index.js | 2 -- www/js/control/emailService.ts | 18 +++++++----------- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/www/index.js b/www/index.js index 865cc9a32..0ae3896a2 100644 --- a/www/index.js +++ b/www/index.js @@ -14,8 +14,6 @@ import './js/main.js'; import './js/diary.js'; import './js/diary/services.js'; import './js/survey/enketo/answer.js'; -//import './js/survey/enketo/enketo-trip-button.js'; -//import './js/survey/enketo/enketo-add-note-button.js'; import './js/metrics-factory.js'; import './js/metrics-mappings.js'; import './js/plugin/logger.ts'; diff --git a/www/js/control/emailService.ts b/www/js/control/emailService.ts index 0efca1518..601413546 100644 --- a/www/js/control/emailService.ts +++ b/www/js/control/emailService.ts @@ -1,6 +1,5 @@ -import React, { useEffect, useState } from 'react'; import i18next from 'i18next'; -import { logInfo, logDebug, displayError } from '../plugin/logger'; +import { logDebug, logInfo, logWarn } from '../plugin/logger'; async function hasAccount(): Promise { return new Promise((resolve, reject) => { @@ -13,18 +12,18 @@ async function hasAccount(): Promise { export async function sendEmail(database: string) { let parentDir = 'unknown'; - if (window['ionic'].Platform.isIOS() && !(await hasAccount())) { + if (window['cordova'].platformId == 'ios' && !(await hasAccount())) { alert(i18next.t('email-service.email-account-not-configured')); return; } - if (window['ionic'].Platform.isAndroid()) { + if (window['cordova'].platformId) == 'android') { parentDir = 'app://databases'; } - if (window['ionic'].Platform.isIOS()) { + if (window['cordova'].platformId) == 'ios') { alert(i18next.t('email-service.email-account-mail-app')); - console.log(window['cordova'].file.dataDirectory); + logDebug(window['cordova'].file.dataDirectory); parentDir = window['cordova'].file.dataDirectory + '../LocalDatabase'; } @@ -47,10 +46,7 @@ export async function sendEmail(database: string) { }; window['cordova'].plugins['email'].open(emailData, () => { - logInfo( - 'Email app closed while sending, ' + - JSON.stringify(emailData) + - ' not sure if we should do anything', - ); + logWarn(`Email app closed while sending, + emailData = ${JSON.stringify(emailData)}`); }); } From 810c31911ba6c0477c8abdf7da80b75e597cee9e Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 21 Nov 2023 11:27:50 -0500 Subject: [PATCH 473/850] fix bad parentheses --- www/js/control/emailService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/js/control/emailService.ts b/www/js/control/emailService.ts index 601413546..3a6e8a5c5 100644 --- a/www/js/control/emailService.ts +++ b/www/js/control/emailService.ts @@ -17,11 +17,11 @@ export async function sendEmail(database: string) { return; } - if (window['cordova'].platformId) == 'android') { + if (window['cordova'].platformId == 'android') { parentDir = 'app://databases'; } - if (window['cordova'].platformId) == 'ios') { + if (window['cordova'].platformId == 'ios') { alert(i18next.t('email-service.email-account-mail-app')); logDebug(window['cordova'].file.dataDirectory); parentDir = window['cordova'].file.dataDirectory + '../LocalDatabase'; From 324269743f79dac66387d38b0210edce3cf90f09 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 21 Nov 2023 11:30:14 -0500 Subject: [PATCH 474/850] remove angular and ionic packages --- .prettierignore | 3 +- jest.config.js | 1 - package.cordovabuild.json | 11 - package.serve.json | 11 - tsconfig.json | 2 +- webpack.config.js | 1 - webpack.prod.js | 17 - www/index.js | 1 - www/js/ngApp.js | 14 - .../angular-ui-router/angular-ui-router.js | 4232 - www/manual_lib/ionic/.bower.json | 46 - www/manual_lib/ionic/README.md | 15 - www/manual_lib/ionic/bower.json | 37 - www/manual_lib/ionic/css/ionic.css | 9813 --- www/manual_lib/ionic/css/ionic.min.css | 23 - www/manual_lib/ionic/fonts/ionicons.eot | Bin 120724 -> 0 bytes www/manual_lib/ionic/fonts/ionicons.svg | 2230 - www/manual_lib/ionic/fonts/ionicons.ttf | Bin 188508 -> 0 bytes www/manual_lib/ionic/fonts/ionicons.woff | Bin 67904 -> 0 bytes www/manual_lib/ionic/js/ionic-angular.js | 14399 ---- www/manual_lib/ionic/js/ionic-angular.min.js | 18 - www/manual_lib/ionic/js/ionic.bundle.js | 67582 ---------------- www/manual_lib/ionic/js/ionic.bundle.min.js | 472 - www/manual_lib/ionic/js/ionic.js | 13361 --- www/manual_lib/ionic/js/ionic.min.js | 20 - 25 files changed, 2 insertions(+), 112307 deletions(-) delete mode 100644 www/manual_lib/angular-ui-router/angular-ui-router.js delete mode 100644 www/manual_lib/ionic/.bower.json delete mode 100644 www/manual_lib/ionic/README.md delete mode 100644 www/manual_lib/ionic/bower.json delete mode 100644 www/manual_lib/ionic/css/ionic.css delete mode 100644 www/manual_lib/ionic/css/ionic.min.css delete mode 100644 www/manual_lib/ionic/fonts/ionicons.eot delete mode 100644 www/manual_lib/ionic/fonts/ionicons.svg delete mode 100644 www/manual_lib/ionic/fonts/ionicons.ttf delete mode 100644 www/manual_lib/ionic/fonts/ionicons.woff delete mode 100644 www/manual_lib/ionic/js/ionic-angular.js delete mode 100644 www/manual_lib/ionic/js/ionic-angular.min.js delete mode 100644 www/manual_lib/ionic/js/ionic.bundle.js delete mode 100644 www/manual_lib/ionic/js/ionic.bundle.min.js delete mode 100644 www/manual_lib/ionic/js/ionic.js delete mode 100644 www/manual_lib/ionic/js/ionic.min.js diff --git a/.prettierignore b/.prettierignore index 988aead62..090654d94 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,5 @@ -# Ignore www/dist, manual_lib, json +# Ignore www/dist, json www/dist -www/manual_lib www/json # Ignore all HTML files: diff --git a/jest.config.js b/jest.config.js index ef3503294..854a503e4 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,7 +5,6 @@ module.exports = { "/platforms/", "/plugins/", "/lib/", - "/manual_lib/" ], preset: 'react-native', transform: { diff --git a/package.cordovabuild.json b/package.cordovabuild.json index 5a512fa41..63b278bf6 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -28,7 +28,6 @@ "@types/luxon": "^3.3.0", "@types/react": "^18.2.20", "babel-loader": "^9.1.2", - "babel-plugin-angularjs-annotate": "^0.10.0", "babel-plugin-optional-require": "^0.3.1", "concurrently": "^8.0.1", "cordova": "^11.1.0", @@ -107,14 +106,6 @@ "@react-navigation/stack": "^6.3.17", "@shopify/flash-list": "^1.3.1", "@types/leaflet": "^1.9.4", - "angular": "1.6.7", - "angular-animate": "1.6.7", - "angular-local-storage": "^0.7.1", - "angular-sanitize": "1.6.7", - "angular-simple-logger": "^0.1.7", - "angular-translate": "^2.18.1", - "angular-translate-loader-static-files": "^2.18.1", - "angular-ui-router": "0.2.13", "animate.css": "^3.5.2", "bottleneck": "^2.19.5", "chart.js": "^4.3.0", @@ -148,8 +139,6 @@ "fs-extra": "^9.0.1", "i18next": "^22.5.0", "install": "^0.13.0", - "ionic-datepicker": "1.2.1", - "ionic-toast": "^0.4.1", "jquery": "^3.1.0", "klaw-sync": "^6.0.0", "leaflet": "^1.9.4", diff --git a/package.serve.json b/package.serve.json index 2744b9f28..83ff95724 100644 --- a/package.serve.json +++ b/package.serve.json @@ -29,7 +29,6 @@ "@types/react": "^18.2.20", "babel-jest": "^29.7.0", "babel-loader": "^9.1.2", - "babel-plugin-angularjs-annotate": "^0.10.0", "babel-plugin-optional-require": "^0.3.1", "concurrently": "^8.0.1", "cordova": "^11.1.0", @@ -59,14 +58,6 @@ "@shopify/flash-list": "^1.3.1", "@types/jest": "^29.5.5", "@types/leaflet": "^1.9.4", - "angular": "1.6.7", - "angular-animate": "1.6.7", - "angular-local-storage": "^0.7.1", - "angular-sanitize": "1.6.7", - "angular-simple-logger": "^0.1.7", - "angular-translate": "^2.18.1", - "angular-translate-loader-static-files": "^2.18.1", - "angular-ui-router": "0.2.13", "animate.css": "^3.5.2", "bottleneck": "^2.19.5", "chart.js": "^4.3.0", @@ -79,8 +70,6 @@ "fs-extra": "^9.0.1", "i18next": "^22.5.0", "install": "^0.13.0", - "ionic-datepicker": "1.2.1", - "ionic-toast": "^0.4.1", "jquery": "^3.1.0", "klaw-sync": "^6.0.0", "leaflet": "^1.9.4", diff --git a/tsconfig.json b/tsconfig.json index 29384751e..6d39726f6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,5 +12,5 @@ "moduleResolution": "node" }, "include": ["www/**/*"], - "exclude": ["**/www/manual_lib/*", "**/node_modules/*", "**/dist/*"], + "exclude": ["**/node_modules/*", "**/dist/*"], } diff --git a/webpack.config.js b/webpack.config.js index 3e7e6d368..400f8b16d 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -13,7 +13,6 @@ module.exports = { { test: /\.(scss|css)$/, include: [path.resolve(__dirname, 'www/css'), - path.resolve(__dirname, 'www/manual_lib'), path.resolve(__dirname, 'node_modules/enketo-core'), path.resolve(__dirname, 'node_modules/leaflet')], use: ['style-loader', 'css-loader', 'sass-loader'], diff --git a/webpack.prod.js b/webpack.prod.js index c08fc140c..231209d36 100644 --- a/webpack.prod.js +++ b/webpack.prod.js @@ -7,23 +7,6 @@ module.exports = merge(common, { devtool: 'source-map', module: { rules: [ - /* In production, Webpack minifies JS files and randomizes variable names. - This causes problems with AngularJS unless you use explicit annotations, - which we don't. - https://docs.angularjs.org/error/$injector/strictdi - (The syntax we use is like the 'bad' example: implicit annotations) - So rather than change every file in our codebase, I'm adding this - babel plugin which basically preprocesses our 'bad' code into 'good' code. - Only needed on production because minification doesn't happen on dev. */ - { - test: /\.(js)$/, - include: path.resolve(__dirname, 'www'), - loader: 'babel-loader', - options: { - presets: ['@babel/preset-env'], - plugins: ["angularjs-annotate"], - }, - }, { test: /\.(js|jsx|ts|tsx)$/, loader: 'babel-loader', diff --git a/www/index.js b/www/index.js index 3677f2ca2..6f3126c04 100644 --- a/www/index.js +++ b/www/index.js @@ -1,4 +1,3 @@ -import './manual_lib/ionic/css/ionic.css'; import './css/style.css'; import './css/main.diary.css'; import 'leaflet/dist/leaflet.css'; diff --git a/www/js/ngApp.js b/www/js/ngApp.js index 7cd66fba7..61cf6f456 100644 --- a/www/js/ngApp.js +++ b/www/js/ngApp.js @@ -1,24 +1,10 @@ -import angular from 'angular'; import React from 'react'; import { createRoot } from 'react-dom/client'; -import 'angular-animate'; -import 'angular-sanitize'; -import 'angular-translate'; -import '../manual_lib/angular-ui-router/angular-ui-router.js'; -import 'angular-local-storage'; -import 'angular-translate-loader-static-files'; import 'moment'; import 'moment-timezone'; import 'chartjs-adapter-luxon'; -import 'ionic-toast'; -import 'ionic-datepicker'; -import 'angular-simple-logger'; - -import '../manual_lib/ionic/js/ionic.js'; -import '../manual_lib/ionic/js/ionic-angular.js'; - import initializedI18next from './i18nextInit'; window.i18next = initializedI18next; import 'ng-i18next'; diff --git a/www/manual_lib/angular-ui-router/angular-ui-router.js b/www/manual_lib/angular-ui-router/angular-ui-router.js deleted file mode 100644 index ddeb2f950..000000000 --- a/www/manual_lib/angular-ui-router/angular-ui-router.js +++ /dev/null @@ -1,4232 +0,0 @@ -/** - * State-based routing for AngularJS - * @version v0.2.13 - * @link http://angular-ui.github.com/ - * @license MIT License, http://www.opensource.org/licenses/MIT - */ - -/* commonjs package manager support (eg componentjs) */ -if (typeof module !== "undefined" && typeof exports !== "undefined" && module.exports === exports){ - module.exports = 'ui.router'; - } - - (function (window, angular, undefined) { - /*jshint globalstrict:true*/ - /*global angular:false*/ - 'use strict'; - - var isDefined = angular.isDefined, - isFunction = angular.isFunction, - isString = angular.isString, - isObject = angular.isObject, - isArray = angular.isArray, - forEach = angular.forEach, - extend = angular.extend, - copy = angular.copy; - - function inherit(parent, extra) { - return extend(new (extend(function() {}, { prototype: parent }))(), extra); - } - - function merge(dst) { - forEach(arguments, function(obj) { - if (obj !== dst) { - forEach(obj, function(value, key) { - if (!dst.hasOwnProperty(key)) dst[key] = value; - }); - } - }); - return dst; - } - - /** - * Finds the common ancestor path between two states. - * - * @param {Object} first The first state. - * @param {Object} second The second state. - * @return {Array} Returns an array of state names in descending order, not including the root. - */ - function ancestors(first, second) { - var path = []; - - for (var n in first.path) { - if (first.path[n] !== second.path[n]) break; - path.push(first.path[n]); - } - return path; - } - - /** - * IE8-safe wrapper for `Object.keys()`. - * - * @param {Object} object A JavaScript object. - * @return {Array} Returns the keys of the object as an array. - */ - function objectKeys(object) { - if (Object.keys) { - return Object.keys(object); - } - var result = []; - - angular.forEach(object, function(val, key) { - result.push(key); - }); - return result; - } - - /** - * IE8-safe wrapper for `Array.prototype.indexOf()`. - * - * @param {Array} array A JavaScript array. - * @param {*} value A value to search the array for. - * @return {Number} Returns the array index value of `value`, or `-1` if not present. - */ - function indexOf(array, value) { - if (Array.prototype.indexOf) { - return array.indexOf(value, Number(arguments[2]) || 0); - } - var len = array.length >>> 0, from = Number(arguments[2]) || 0; - from = (from < 0) ? Math.ceil(from) : Math.floor(from); - - if (from < 0) from += len; - - for (; from < len; from++) { - if (from in array && array[from] === value) return from; - } - return -1; - } - - /** - * Merges a set of parameters with all parameters inherited between the common parents of the - * current state and a given destination state. - * - * @param {Object} currentParams The value of the current state parameters ($stateParams). - * @param {Object} newParams The set of parameters which will be composited with inherited params. - * @param {Object} $current Internal definition of object representing the current state. - * @param {Object} $to Internal definition of object representing state to transition to. - */ - function inheritParams(currentParams, newParams, $current, $to) { - var parents = ancestors($current, $to), parentParams, inherited = {}, inheritList = []; - - for (var i in parents) { - if (!parents[i].params) continue; - parentParams = objectKeys(parents[i].params); - if (!parentParams.length) continue; - - for (var j in parentParams) { - if (indexOf(inheritList, parentParams[j]) >= 0) continue; - inheritList.push(parentParams[j]); - inherited[parentParams[j]] = currentParams[parentParams[j]]; - } - } - return extend({}, inherited, newParams); - } - - /** - * Performs a non-strict comparison of the subset of two objects, defined by a list of keys. - * - * @param {Object} a The first object. - * @param {Object} b The second object. - * @param {Array} keys The list of keys within each object to compare. If the list is empty or not specified, - * it defaults to the list of keys in `a`. - * @return {Boolean} Returns `true` if the keys match, otherwise `false`. - */ - function equalForKeys(a, b, keys) { - if (!keys) { - keys = []; - for (var n in a) keys.push(n); // Used instead of Object.keys() for IE8 compatibility - } - - for (var i=0; i - * - * - * - * - * - * - * - * - * - * - * - * - */ - angular.module('ui.router', ['ui.router.state']); - - angular.module('ui.router.compat', ['ui.router']); - - /** - * @ngdoc object - * @name ui.router.util.$resolve - * - * @requires $q - * @requires $injector - * - * @description - * Manages resolution of (acyclic) graphs of promises. - */ - $Resolve.$inject = ['$q', '$injector']; - function $Resolve( $q, $injector) { - - var VISIT_IN_PROGRESS = 1, - VISIT_DONE = 2, - NOTHING = {}, - NO_DEPENDENCIES = [], - NO_LOCALS = NOTHING, - NO_PARENT = extend($q.when(NOTHING), { $$promises: NOTHING, $$values: NOTHING }); - - - /** - * @ngdoc function - * @name ui.router.util.$resolve#study - * @methodOf ui.router.util.$resolve - * - * @description - * Studies a set of invocables that are likely to be used multiple times. - *
    -     * $resolve.study(invocables)(locals, parent, self)
    -     * 
    - * is equivalent to - *
    -     * $resolve.resolve(invocables, locals, parent, self)
    -     * 
    - * but the former is more efficient (in fact `resolve` just calls `study` - * internally). - * - * @param {object} invocables Invocable objects - * @return {function} a function to pass in locals, parent and self - */ - this.study = function (invocables) { - if (!isObject(invocables)) throw new Error("'invocables' must be an object"); - var invocableKeys = objectKeys(invocables || {}); - - // Perform a topological sort of invocables to build an ordered plan - var plan = [], cycle = [], visited = {}; - function visit(value, key) { - if (visited[key] === VISIT_DONE) return; - - cycle.push(key); - if (visited[key] === VISIT_IN_PROGRESS) { - cycle.splice(0, indexOf(cycle, key)); - throw new Error("Cyclic dependency: " + cycle.join(" -> ")); - } - visited[key] = VISIT_IN_PROGRESS; - - if (isString(value)) { - plan.push(key, [ function() { return $injector.get(value); }], NO_DEPENDENCIES); - } else { - var params = $injector.annotate(value); - forEach(params, function (param) { - if (param !== key && invocables.hasOwnProperty(param)) visit(invocables[param], param); - }); - plan.push(key, value, params); - } - - cycle.pop(); - visited[key] = VISIT_DONE; - } - forEach(invocables, visit); - invocables = cycle = visited = null; // plan is all that's required - - function isResolve(value) { - return isObject(value) && value.then && value.$$promises; - } - - return function (locals, parent, self) { - if (isResolve(locals) && self === undefined) { - self = parent; parent = locals; locals = null; - } - if (!locals) locals = NO_LOCALS; - else if (!isObject(locals)) { - throw new Error("'locals' must be an object"); - } - if (!parent) parent = NO_PARENT; - else if (!isResolve(parent)) { - throw new Error("'parent' must be a promise returned by $resolve.resolve()"); - } - - // To complete the overall resolution, we have to wait for the parent - // promise and for the promise for each invokable in our plan. - var resolution = $q.defer(), - result = resolution.promise, - promises = result.$$promises = {}, - values = extend({}, locals), - wait = 1 + plan.length/3, - merged = false; - - function done() { - // Merge parent values we haven't got yet and publish our own $$values - if (!--wait) { - if (!merged) merge(values, parent.$$values); - result.$$values = values; - result.$$promises = result.$$promises || true; // keep for isResolve() - delete result.$$inheritedValues; - resolution.resolve(values); - } - } - - function fail(reason) { - result.$$failure = reason; - resolution.reject(reason); - } - - // Short-circuit if parent has already failed - if (isDefined(parent.$$failure)) { - fail(parent.$$failure); - return result; - } - - if (parent.$$inheritedValues) { - merge(values, omit(parent.$$inheritedValues, invocableKeys)); - } - - // Merge parent values if the parent has already resolved, or merge - // parent promises and wait if the parent resolve is still in progress. - extend(promises, parent.$$promises); - if (parent.$$values) { - merged = merge(values, omit(parent.$$values, invocableKeys)); - result.$$inheritedValues = omit(parent.$$values, invocableKeys); - done(); - } else { - if (parent.$$inheritedValues) { - result.$$inheritedValues = omit(parent.$$inheritedValues, invocableKeys); - } - parent.then(done, fail); - } - - // Process each invocable in the plan, but ignore any where a local of the same name exists. - for (var i=0, ii=plan.length; i} The template html as a string, or a promise - * for that string. - */ - this.fromUrl = function (url, params) { - if (isFunction(url)) url = url(params); - if (url == null) return null; - else return $http - .get(url, { cache: $templateCache, headers: { Accept: 'text/html' }}) - .then(function(response) { return response.data; }); - }; - - /** - * @ngdoc function - * @name ui.router.util.$templateFactory#fromProvider - * @methodOf ui.router.util.$templateFactory - * - * @description - * Creates a template by invoking an injectable provider function. - * - * @param {Function} provider Function to invoke via `$injector.invoke` - * @param {Object} params Parameters for the template. - * @param {Object} locals Locals to pass to `invoke`. Defaults to - * `{ params: params }`. - * @return {string|Promise.} The template html as a string, or a promise - * for that string. - */ - this.fromProvider = function (provider, params, locals) { - return $injector.invoke(provider, null, locals || { params: params }); - }; - } - - angular.module('ui.router.util').service('$templateFactory', $TemplateFactory); - - var $$UMFP; // reference to $UrlMatcherFactoryProvider - - /** - * @ngdoc object - * @name ui.router.util.type:UrlMatcher - * - * @description - * Matches URLs against patterns and extracts named parameters from the path or the search - * part of the URL. A URL pattern consists of a path pattern, optionally followed by '?' and a list - * of search parameters. Multiple search parameter names are separated by '&'. Search parameters - * do not influence whether or not a URL is matched, but their values are passed through into - * the matched parameters returned by {@link ui.router.util.type:UrlMatcher#methods_exec exec}. - * - * Path parameter placeholders can be specified using simple colon/catch-all syntax or curly brace - * syntax, which optionally allows a regular expression for the parameter to be specified: - * - * * `':'` name - colon placeholder - * * `'*'` name - catch-all placeholder - * * `'{' name '}'` - curly placeholder - * * `'{' name ':' regexp|type '}'` - curly placeholder with regexp or type name. Should the - * regexp itself contain curly braces, they must be in matched pairs or escaped with a backslash. - * - * Parameter names may contain only word characters (latin letters, digits, and underscore) and - * must be unique within the pattern (across both path and search parameters). For colon - * placeholders or curly placeholders without an explicit regexp, a path parameter matches any - * number of characters other than '/'. For catch-all placeholders the path parameter matches - * any number of characters. - * - * Examples: - * - * * `'/hello/'` - Matches only if the path is exactly '/hello/'. There is no special treatment for - * trailing slashes, and patterns have to match the entire path, not just a prefix. - * * `'/user/:id'` - Matches '/user/bob' or '/user/1234!!!' or even '/user/' but not '/user' or - * '/user/bob/details'. The second path segment will be captured as the parameter 'id'. - * * `'/user/{id}'` - Same as the previous example, but using curly brace syntax. - * * `'/user/{id:[^/]*}'` - Same as the previous example. - * * `'/user/{id:[0-9a-fA-F]{1,8}}'` - Similar to the previous example, but only matches if the id - * parameter consists of 1 to 8 hex digits. - * * `'/files/{path:.*}'` - Matches any URL starting with '/files/' and captures the rest of the - * path into the parameter 'path'. - * * `'/files/*path'` - ditto. - * * `'/calendar/{start:date}'` - Matches "/calendar/2014-11-12" (because the pattern defined - * in the built-in `date` Type matches `2014-11-12`) and provides a Date object in $stateParams.start - * - * @param {string} pattern The pattern to compile into a matcher. - * @param {Object} config A configuration object hash: - * @param {Object=} parentMatcher Used to concatenate the pattern/config onto - * an existing UrlMatcher - * - * * `caseInsensitive` - `true` if URL matching should be case insensitive, otherwise `false`, the default value (for backward compatibility) is `false`. - * * `strict` - `false` if matching against a URL with a trailing slash should be treated as equivalent to a URL without a trailing slash, the default value is `true`. - * - * @property {string} prefix A static prefix of this pattern. The matcher guarantees that any - * URL matching this matcher (i.e. any string for which {@link ui.router.util.type:UrlMatcher#methods_exec exec()} returns - * non-null) will start with this prefix. - * - * @property {string} source The pattern that was passed into the constructor - * - * @property {string} sourcePath The path portion of the source property - * - * @property {string} sourceSearch The search portion of the source property - * - * @property {string} regex The constructed regex that will be used to match against the url when - * it is time to determine which url will match. - * - * @returns {Object} New `UrlMatcher` object - */ - function UrlMatcher(pattern, config, parentMatcher) { - config = extend({ params: {} }, isObject(config) ? config : {}); - - // Find all placeholders and create a compiled pattern, using either classic or curly syntax: - // '*' name - // ':' name - // '{' name '}' - // '{' name ':' regexp '}' - // The regular expression is somewhat complicated due to the need to allow curly braces - // inside the regular expression. The placeholder regexp breaks down as follows: - // ([:*])([\w\[\]]+) - classic placeholder ($1 / $2) (search version has - for snake-case) - // \{([\w\[\]]+)(?:\:( ... ))?\} - curly brace placeholder ($3) with optional regexp/type ... ($4) (search version has - for snake-case - // (?: ... | ... | ... )+ - the regexp consists of any number of atoms, an atom being either - // [^{}\\]+ - anything other than curly braces or backslash - // \\. - a backslash escape - // \{(?:[^{}\\]+|\\.)*\} - a matched set of curly braces containing other atoms - var placeholder = /([:*])([\w\[\]]+)|\{([\w\[\]]+)(?:\:((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g, - searchPlaceholder = /([:]?)([\w\[\]-]+)|\{([\w\[\]-]+)(?:\:((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g, - compiled = '^', last = 0, m, - segments = this.segments = [], - parentParams = parentMatcher ? parentMatcher.params : {}, - params = this.params = parentMatcher ? parentMatcher.params.$$new() : new $$UMFP.ParamSet(), - paramNames = []; - - function addParameter(id, type, config, location) { - paramNames.push(id); - if (parentParams[id]) return parentParams[id]; - if (!/^\w+(-+\w+)*(?:\[\])?$/.test(id)) throw new Error("Invalid parameter name '" + id + "' in pattern '" + pattern + "'"); - if (params[id]) throw new Error("Duplicate parameter name '" + id + "' in pattern '" + pattern + "'"); - params[id] = new $$UMFP.Param(id, type, config, location); - return params[id]; - } - - function quoteRegExp(string, pattern, squash) { - var surroundPattern = ['',''], result = string.replace(/[\\\[\]\^$*+?.()|{}]/g, "\\$&"); - if (!pattern) return result; - switch(squash) { - case false: surroundPattern = ['(', ')']; break; - case true: surroundPattern = ['?(', ')?']; break; - default: surroundPattern = ['(' + squash + "|", ')?']; break; - } - return result + surroundPattern[0] + pattern + surroundPattern[1]; - } - - this.source = pattern; - - // Split into static segments separated by path parameter placeholders. - // The number of segments is always 1 more than the number of parameters. - function matchDetails(m, isSearch) { - var id, regexp, segment, type, cfg, arrayMode; - id = m[2] || m[3]; // IE[78] returns '' for unmatched groups instead of null - cfg = config.params[id]; - segment = pattern.substring(last, m.index); - regexp = isSearch ? m[4] : m[4] || (m[1] == '*' ? '.*' : null); - type = $$UMFP.type(regexp || "string") || inherit($$UMFP.type("string"), { pattern: new RegExp(regexp) }); - return { - id: id, regexp: regexp, segment: segment, type: type, cfg: cfg - }; - } - - var p, param, segment; - while ((m = placeholder.exec(pattern))) { - p = matchDetails(m, false); - if (p.segment.indexOf('?') >= 0) break; // we're into the search part - - param = addParameter(p.id, p.type, p.cfg, "path"); - compiled += quoteRegExp(p.segment, param.type.pattern.source, param.squash); - segments.push(p.segment); - last = placeholder.lastIndex; - } - segment = pattern.substring(last); - - // Find any search parameter names and remove them from the last segment - var i = segment.indexOf('?'); - - if (i >= 0) { - var search = this.sourceSearch = segment.substring(i); - segment = segment.substring(0, i); - this.sourcePath = pattern.substring(0, last + i); - - if (search.length > 0) { - last = 0; - while ((m = searchPlaceholder.exec(search))) { - p = matchDetails(m, true); - param = addParameter(p.id, p.type, p.cfg, "search"); - last = placeholder.lastIndex; - // check if ?& - } - } - } else { - this.sourcePath = pattern; - this.sourceSearch = ''; - } - - compiled += quoteRegExp(segment) + (config.strict === false ? '\/?' : '') + '$'; - segments.push(segment); - - this.regexp = new RegExp(compiled, config.caseInsensitive ? 'i' : undefined); - this.prefix = segments[0]; - this.$$paramNames = paramNames; - } - - /** - * @ngdoc function - * @name ui.router.util.type:UrlMatcher#concat - * @methodOf ui.router.util.type:UrlMatcher - * - * @description - * Returns a new matcher for a pattern constructed by appending the path part and adding the - * search parameters of the specified pattern to this pattern. The current pattern is not - * modified. This can be understood as creating a pattern for URLs that are relative to (or - * suffixes of) the current pattern. - * - * @example - * The following two matchers are equivalent: - *
    -   * new UrlMatcher('/user/{id}?q').concat('/details?date');
    -   * new UrlMatcher('/user/{id}/details?q&date');
    -   * 
    - * - * @param {string} pattern The pattern to append. - * @param {Object} config An object hash of the configuration for the matcher. - * @returns {UrlMatcher} A matcher for the concatenated pattern. - */ - UrlMatcher.prototype.concat = function (pattern, config) { - // Because order of search parameters is irrelevant, we can add our own search - // parameters to the end of the new pattern. Parse the new pattern by itself - // and then join the bits together, but it's much easier to do this on a string level. - var defaultConfig = { - caseInsensitive: $$UMFP.caseInsensitive(), - strict: $$UMFP.strictMode(), - squash: $$UMFP.defaultSquashPolicy() - }; - return new UrlMatcher(this.sourcePath + pattern + this.sourceSearch, extend(defaultConfig, config), this); - }; - - UrlMatcher.prototype.toString = function () { - return this.source; - }; - - /** - * @ngdoc function - * @name ui.router.util.type:UrlMatcher#exec - * @methodOf ui.router.util.type:UrlMatcher - * - * @description - * Tests the specified path against this matcher, and returns an object containing the captured - * parameter values, or null if the path does not match. The returned object contains the values - * of any search parameters that are mentioned in the pattern, but their value may be null if - * they are not present in `searchParams`. This means that search parameters are always treated - * as optional. - * - * @example - *
    -   * new UrlMatcher('/user/{id}?q&r').exec('/user/bob', {
    -   *   x: '1', q: 'hello'
    -   * });
    -   * // returns { id: 'bob', q: 'hello', r: null }
    -   * 
    - * - * @param {string} path The URL path to match, e.g. `$location.path()`. - * @param {Object} searchParams URL search parameters, e.g. `$location.search()`. - * @returns {Object} The captured parameter values. - */ - UrlMatcher.prototype.exec = function (path, searchParams) { - var m = this.regexp.exec(path); - if (!m) return null; - searchParams = searchParams || {}; - - var paramNames = this.parameters(), nTotal = paramNames.length, - nPath = this.segments.length - 1, - values = {}, i, j, cfg, paramName; - - if (nPath !== m.length - 1) throw new Error("Unbalanced capture group in route '" + this.source + "'"); - - function decodePathArray(string) { - function reverseString(str) { return str.split("").reverse().join(""); } - function unquoteDashes(str) { return str.replace(/\\-/, "-"); } - - var split = reverseString(string).split(/-(?!\\)/); - var allReversed = map(split, reverseString); - return map(allReversed, unquoteDashes).reverse(); - } - - for (i = 0; i < nPath; i++) { - paramName = paramNames[i]; - var param = this.params[paramName]; - var paramVal = m[i+1]; - // if the param value matches a pre-replace pair, replace the value before decoding. - for (j = 0; j < param.replace; j++) { - if (param.replace[j].from === paramVal) paramVal = param.replace[j].to; - } - if (paramVal && param.array === true) paramVal = decodePathArray(paramVal); - values[paramName] = param.value(paramVal); - } - for (/**/; i < nTotal; i++) { - paramName = paramNames[i]; - values[paramName] = this.params[paramName].value(searchParams[paramName]); - } - - return values; - }; - - /** - * @ngdoc function - * @name ui.router.util.type:UrlMatcher#parameters - * @methodOf ui.router.util.type:UrlMatcher - * - * @description - * Returns the names of all path and search parameters of this pattern in an unspecified order. - * - * @returns {Array.} An array of parameter names. Must be treated as read-only. If the - * pattern has no parameters, an empty array is returned. - */ - UrlMatcher.prototype.parameters = function (param) { - if (!isDefined(param)) return this.$$paramNames; - return this.params[param] || null; - }; - - /** - * @ngdoc function - * @name ui.router.util.type:UrlMatcher#validate - * @methodOf ui.router.util.type:UrlMatcher - * - * @description - * Checks an object hash of parameters to validate their correctness according to the parameter - * types of this `UrlMatcher`. - * - * @param {Object} params The object hash of parameters to validate. - * @returns {boolean} Returns `true` if `params` validates, otherwise `false`. - */ - UrlMatcher.prototype.validates = function (params) { - return this.params.$$validates(params); - }; - - /** - * @ngdoc function - * @name ui.router.util.type:UrlMatcher#format - * @methodOf ui.router.util.type:UrlMatcher - * - * @description - * Creates a URL that matches this pattern by substituting the specified values - * for the path and search parameters. Null values for path parameters are - * treated as empty strings. - * - * @example - *
    -   * new UrlMatcher('/user/{id}?q').format({ id:'bob', q:'yes' });
    -   * // returns '/user/bob?q=yes'
    -   * 
    - * - * @param {Object} values the values to substitute for the parameters in this pattern. - * @returns {string} the formatted URL (path and optionally search part). - */ - UrlMatcher.prototype.format = function (values) { - values = values || {}; - var segments = this.segments, params = this.parameters(), paramset = this.params; - if (!this.validates(values)) return null; - - var i, search = false, nPath = segments.length - 1, nTotal = params.length, result = segments[0]; - - function encodeDashes(str) { // Replace dashes with encoded "\-" - return encodeURIComponent(str).replace(/-/g, function(c) { return '%5C%' + c.charCodeAt(0).toString(16).toUpperCase(); }); - } - - for (i = 0; i < nTotal; i++) { - var isPathParam = i < nPath; - var name = params[i], param = paramset[name], value = param.value(values[name]); - var isDefaultValue = param.isOptional && param.type.equals(param.value(), value); - var squash = isDefaultValue ? param.squash : false; - var encoded = param.type.encode(value); - - if (isPathParam) { - var nextSegment = segments[i + 1]; - if (squash === false) { - if (encoded != null) { - if (isArray(encoded)) { - result += map(encoded, encodeDashes).join("-"); - } else { - result += encodeURIComponent(encoded); - } - } - result += nextSegment; - } else if (squash === true) { - var capture = result.match(/\/$/) ? /\/?(.*)/ : /(.*)/; - result += nextSegment.match(capture)[1]; - } else if (isString(squash)) { - result += squash + nextSegment; - } - } else { - if (encoded == null || (isDefaultValue && squash !== false)) continue; - if (!isArray(encoded)) encoded = [ encoded ]; - encoded = map(encoded, encodeURIComponent).join('&' + name + '='); - result += (search ? '&' : '?') + (name + '=' + encoded); - search = true; - } - } - - return result; - }; - - /** - * @ngdoc object - * @name ui.router.util.type:Type - * - * @description - * Implements an interface to define custom parameter types that can be decoded from and encoded to - * string parameters matched in a URL. Used by {@link ui.router.util.type:UrlMatcher `UrlMatcher`} - * objects when matching or formatting URLs, or comparing or validating parameter values. - * - * See {@link ui.router.util.$urlMatcherFactory#methods_type `$urlMatcherFactory#type()`} for more - * information on registering custom types. - * - * @param {Object} config A configuration object which contains the custom type definition. The object's - * properties will override the default methods and/or pattern in `Type`'s public interface. - * @example - *
    -   * {
    -   *   decode: function(val) { return parseInt(val, 10); },
    -   *   encode: function(val) { return val && val.toString(); },
    -   *   equals: function(a, b) { return this.is(a) && a === b; },
    -   *   is: function(val) { return angular.isNumber(val) isFinite(val) && val % 1 === 0; },
    -   *   pattern: /\d+/
    -   * }
    -   * 
    - * - * @property {RegExp} pattern The regular expression pattern used to match values of this type when - * coming from a substring of a URL. - * - * @returns {Object} Returns a new `Type` object. - */ - function Type(config) { - extend(this, config); - } - - /** - * @ngdoc function - * @name ui.router.util.type:Type#is - * @methodOf ui.router.util.type:Type - * - * @description - * Detects whether a value is of a particular type. Accepts a native (decoded) value - * and determines whether it matches the current `Type` object. - * - * @param {*} val The value to check. - * @param {string} key Optional. If the type check is happening in the context of a specific - * {@link ui.router.util.type:UrlMatcher `UrlMatcher`} object, this is the name of the - * parameter in which `val` is stored. Can be used for meta-programming of `Type` objects. - * @returns {Boolean} Returns `true` if the value matches the type, otherwise `false`. - */ - Type.prototype.is = function(val, key) { - return true; - }; - - /** - * @ngdoc function - * @name ui.router.util.type:Type#encode - * @methodOf ui.router.util.type:Type - * - * @description - * Encodes a custom/native type value to a string that can be embedded in a URL. Note that the - * return value does *not* need to be URL-safe (i.e. passed through `encodeURIComponent()`), it - * only needs to be a representation of `val` that has been coerced to a string. - * - * @param {*} val The value to encode. - * @param {string} key The name of the parameter in which `val` is stored. Can be used for - * meta-programming of `Type` objects. - * @returns {string} Returns a string representation of `val` that can be encoded in a URL. - */ - Type.prototype.encode = function(val, key) { - return val; - }; - - /** - * @ngdoc function - * @name ui.router.util.type:Type#decode - * @methodOf ui.router.util.type:Type - * - * @description - * Converts a parameter value (from URL string or transition param) to a custom/native value. - * - * @param {string} val The URL parameter value to decode. - * @param {string} key The name of the parameter in which `val` is stored. Can be used for - * meta-programming of `Type` objects. - * @returns {*} Returns a custom representation of the URL parameter value. - */ - Type.prototype.decode = function(val, key) { - return val; - }; - - /** - * @ngdoc function - * @name ui.router.util.type:Type#equals - * @methodOf ui.router.util.type:Type - * - * @description - * Determines whether two decoded values are equivalent. - * - * @param {*} a A value to compare against. - * @param {*} b A value to compare against. - * @returns {Boolean} Returns `true` if the values are equivalent/equal, otherwise `false`. - */ - Type.prototype.equals = function(a, b) { - return a == b; - }; - - Type.prototype.$subPattern = function() { - var sub = this.pattern.toString(); - return sub.substr(1, sub.length - 2); - }; - - Type.prototype.pattern = /.*/; - - Type.prototype.toString = function() { return "{Type:" + this.name + "}"; }; - - /* - * Wraps an existing custom Type as an array of Type, depending on 'mode'. - * e.g.: - * - urlmatcher pattern "/path?{queryParam[]:int}" - * - url: "/path?queryParam=1&queryParam=2 - * - $stateParams.queryParam will be [1, 2] - * if `mode` is "auto", then - * - url: "/path?queryParam=1 will create $stateParams.queryParam: 1 - * - url: "/path?queryParam=1&queryParam=2 will create $stateParams.queryParam: [1, 2] - */ - Type.prototype.$asArray = function(mode, isSearch) { - if (!mode) return this; - if (mode === "auto" && !isSearch) throw new Error("'auto' array mode is for query parameters only"); - return new ArrayType(this, mode); - - function ArrayType(type, mode) { - function bindTo(type, callbackName) { - return function() { - return type[callbackName].apply(type, arguments); - }; - } - - // Wrap non-array value as array - function arrayWrap(val) { return isArray(val) ? val : (isDefined(val) ? [ val ] : []); } - // Unwrap array value for "auto" mode. Return undefined for empty array. - function arrayUnwrap(val) { - switch(val.length) { - case 0: return undefined; - case 1: return mode === "auto" ? val[0] : val; - default: return val; - } - } - function falsey(val) { return !val; } - - // Wraps type (.is/.encode/.decode) functions to operate on each value of an array - function arrayHandler(callback, allTruthyMode) { - return function handleArray(val) { - val = arrayWrap(val); - var result = map(val, callback); - if (allTruthyMode === true) - return filter(result, falsey).length === 0; - return arrayUnwrap(result); - }; - } - - // Wraps type (.equals) functions to operate on each value of an array - function arrayEqualsHandler(callback) { - return function handleArray(val1, val2) { - var left = arrayWrap(val1), right = arrayWrap(val2); - if (left.length !== right.length) return false; - for (var i = 0; i < left.length; i++) { - if (!callback(left[i], right[i])) return false; - } - return true; - }; - } - - this.encode = arrayHandler(bindTo(type, 'encode')); - this.decode = arrayHandler(bindTo(type, 'decode')); - this.is = arrayHandler(bindTo(type, 'is'), true); - this.equals = arrayEqualsHandler(bindTo(type, 'equals')); - this.pattern = type.pattern; - this.$arrayMode = mode; - } - }; - - - - /** - * @ngdoc object - * @name ui.router.util.$urlMatcherFactory - * - * @description - * Factory for {@link ui.router.util.type:UrlMatcher `UrlMatcher`} instances. The factory - * is also available to providers under the name `$urlMatcherFactoryProvider`. - */ - function $UrlMatcherFactory() { - $$UMFP = this; - - var isCaseInsensitive = false, isStrictMode = true, defaultSquashPolicy = false; - - function valToString(val) { return val != null ? val.toString().replace(/\//g, "%2F") : val; } - function valFromString(val) { return val != null ? val.toString().replace(/%2F/g, "/") : val; } - // TODO: in 1.0, make string .is() return false if value is undefined by default. - // function regexpMatches(val) { /*jshint validthis:true */ return isDefined(val) && this.pattern.test(val); } - function regexpMatches(val) { /*jshint validthis:true */ return this.pattern.test(val); } - - var $types = {}, enqueue = true, typeQueue = [], injector, defaultTypes = { - string: { - encode: valToString, - decode: valFromString, - is: regexpMatches, - pattern: /[^/]*/ - }, - int: { - encode: valToString, - decode: function(val) { return parseInt(val, 10); }, - is: function(val) { return isDefined(val) && this.decode(val.toString()) === val; }, - pattern: /\d+/ - }, - bool: { - encode: function(val) { return val ? 1 : 0; }, - decode: function(val) { return parseInt(val, 10) !== 0; }, - is: function(val) { return val === true || val === false; }, - pattern: /0|1/ - }, - date: { - encode: function (val) { - if (!this.is(val)) - return undefined; - return [ val.getFullYear(), - ('0' + (val.getMonth() + 1)).slice(-2), - ('0' + val.getDate()).slice(-2) - ].join("-"); - }, - decode: function (val) { - if (this.is(val)) return val; - var match = this.capture.exec(val); - return match ? new Date(match[1], match[2] - 1, match[3]) : undefined; - }, - is: function(val) { return val instanceof Date && !isNaN(val.valueOf()); }, - equals: function (a, b) { return this.is(a) && this.is(b) && a.toISOString() === b.toISOString(); }, - pattern: /[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[1-2][0-9]|3[0-1])/, - capture: /([0-9]{4})-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])/ - }, - json: { - encode: angular.toJson, - decode: angular.fromJson, - is: angular.isObject, - equals: angular.equals, - pattern: /[^/]*/ - }, - any: { // does not encode/decode - encode: angular.identity, - decode: angular.identity, - is: angular.identity, - equals: angular.equals, - pattern: /.*/ - } - }; - - function getDefaultConfig() { - return { - strict: isStrictMode, - caseInsensitive: isCaseInsensitive - }; - } - - function isInjectable(value) { - return (isFunction(value) || (isArray(value) && isFunction(value[value.length - 1]))); - } - - /** - * [Internal] Get the default value of a parameter, which may be an injectable function. - */ - $UrlMatcherFactory.$$getDefaultValue = function(config) { - if (!isInjectable(config.value)) return config.value; - if (!injector) throw new Error("Injectable functions cannot be called at configuration time"); - return injector.invoke(config.value); - }; - - /** - * @ngdoc function - * @name ui.router.util.$urlMatcherFactory#caseInsensitive - * @methodOf ui.router.util.$urlMatcherFactory - * - * @description - * Defines whether URL matching should be case sensitive (the default behavior), or not. - * - * @param {boolean} value `false` to match URL in a case sensitive manner; otherwise `true`; - * @returns {boolean} the current value of caseInsensitive - */ - this.caseInsensitive = function(value) { - if (isDefined(value)) - isCaseInsensitive = value; - return isCaseInsensitive; - }; - - /** - * @ngdoc function - * @name ui.router.util.$urlMatcherFactory#strictMode - * @methodOf ui.router.util.$urlMatcherFactory - * - * @description - * Defines whether URLs should match trailing slashes, or not (the default behavior). - * - * @param {boolean=} value `false` to match trailing slashes in URLs, otherwise `true`. - * @returns {boolean} the current value of strictMode - */ - this.strictMode = function(value) { - if (isDefined(value)) - isStrictMode = value; - return isStrictMode; - }; - - /** - * @ngdoc function - * @name ui.router.util.$urlMatcherFactory#defaultSquashPolicy - * @methodOf ui.router.util.$urlMatcherFactory - * - * @description - * Sets the default behavior when generating or matching URLs with default parameter values. - * - * @param {string} value A string that defines the default parameter URL squashing behavior. - * `nosquash`: When generating an href with a default parameter value, do not squash the parameter value from the URL - * `slash`: When generating an href with a default parameter value, squash (remove) the parameter value, and, if the - * parameter is surrounded by slashes, squash (remove) one slash from the URL - * any other string, e.g. "~": When generating an href with a default parameter value, squash (remove) - * the parameter value from the URL and replace it with this string. - */ - this.defaultSquashPolicy = function(value) { - if (!isDefined(value)) return defaultSquashPolicy; - if (value !== true && value !== false && !isString(value)) - throw new Error("Invalid squash policy: " + value + ". Valid policies: false, true, arbitrary-string"); - defaultSquashPolicy = value; - return value; - }; - - /** - * @ngdoc function - * @name ui.router.util.$urlMatcherFactory#compile - * @methodOf ui.router.util.$urlMatcherFactory - * - * @description - * Creates a {@link ui.router.util.type:UrlMatcher `UrlMatcher`} for the specified pattern. - * - * @param {string} pattern The URL pattern. - * @param {Object} config The config object hash. - * @returns {UrlMatcher} The UrlMatcher. - */ - this.compile = function (pattern, config) { - return new UrlMatcher(pattern, extend(getDefaultConfig(), config)); - }; - - /** - * @ngdoc function - * @name ui.router.util.$urlMatcherFactory#isMatcher - * @methodOf ui.router.util.$urlMatcherFactory - * - * @description - * Returns true if the specified object is a `UrlMatcher`, or false otherwise. - * - * @param {Object} object The object to perform the type check against. - * @returns {Boolean} Returns `true` if the object matches the `UrlMatcher` interface, by - * implementing all the same methods. - */ - this.isMatcher = function (o) { - if (!isObject(o)) return false; - var result = true; - - forEach(UrlMatcher.prototype, function(val, name) { - if (isFunction(val)) { - result = result && (isDefined(o[name]) && isFunction(o[name])); - } - }); - return result; - }; - - /** - * @ngdoc function - * @name ui.router.util.$urlMatcherFactory#type - * @methodOf ui.router.util.$urlMatcherFactory - * - * @description - * Registers a custom {@link ui.router.util.type:Type `Type`} object that can be used to - * generate URLs with typed parameters. - * - * @param {string} name The type name. - * @param {Object|Function} definition The type definition. See - * {@link ui.router.util.type:Type `Type`} for information on the values accepted. - * @param {Object|Function} definitionFn (optional) A function that is injected before the app - * runtime starts. The result of this function is merged into the existing `definition`. - * See {@link ui.router.util.type:Type `Type`} for information on the values accepted. - * - * @returns {Object} Returns `$urlMatcherFactoryProvider`. - * - * @example - * This is a simple example of a custom type that encodes and decodes items from an - * array, using the array index as the URL-encoded value: - * - *
    -     * var list = ['John', 'Paul', 'George', 'Ringo'];
    -     *
    -     * $urlMatcherFactoryProvider.type('listItem', {
    -     *   encode: function(item) {
    -     *     // Represent the list item in the URL using its corresponding index
    -     *     return list.indexOf(item);
    -     *   },
    -     *   decode: function(item) {
    -     *     // Look up the list item by index
    -     *     return list[parseInt(item, 10)];
    -     *   },
    -     *   is: function(item) {
    -     *     // Ensure the item is valid by checking to see that it appears
    -     *     // in the list
    -     *     return list.indexOf(item) > -1;
    -     *   }
    -     * });
    -     *
    -     * $stateProvider.state('list', {
    -     *   url: "/list/{item:listItem}",
    -     *   controller: function($scope, $stateParams) {
    -     *     console.log($stateParams.item);
    -     *   }
    -     * });
    -     *
    -     * // ...
    -     *
    -     * // Changes URL to '/list/3', logs "Ringo" to the console
    -     * $state.go('list', { item: "Ringo" });
    -     * 
    - * - * This is a more complex example of a type that relies on dependency injection to - * interact with services, and uses the parameter name from the URL to infer how to - * handle encoding and decoding parameter values: - * - *
    -     * // Defines a custom type that gets a value from a service,
    -     * // where each service gets different types of values from
    -     * // a backend API:
    -     * $urlMatcherFactoryProvider.type('dbObject', {}, function(Users, Posts) {
    -     *
    -     *   // Matches up services to URL parameter names
    -     *   var services = {
    -     *     user: Users,
    -     *     post: Posts
    -     *   };
    -     *
    -     *   return {
    -     *     encode: function(object) {
    -     *       // Represent the object in the URL using its unique ID
    -     *       return object.id;
    -     *     },
    -     *     decode: function(value, key) {
    -     *       // Look up the object by ID, using the parameter
    -     *       // name (key) to call the correct service
    -     *       return services[key].findById(value);
    -     *     },
    -     *     is: function(object, key) {
    -     *       // Check that object is a valid dbObject
    -     *       return angular.isObject(object) && object.id && services[key];
    -     *     }
    -     *     equals: function(a, b) {
    -     *       // Check the equality of decoded objects by comparing
    -     *       // their unique IDs
    -     *       return a.id === b.id;
    -     *     }
    -     *   };
    -     * });
    -     *
    -     * // In a config() block, you can then attach URLs with
    -     * // type-annotated parameters:
    -     * $stateProvider.state('users', {
    -     *   url: "/users",
    -     *   // ...
    -     * }).state('users.item', {
    -     *   url: "/{user:dbObject}",
    -     *   controller: function($scope, $stateParams) {
    -     *     // $stateParams.user will now be an object returned from
    -     *     // the Users service
    -     *   },
    -     *   // ...
    -     * });
    -     * 
    - */ - this.type = function (name, definition, definitionFn) { - if (!isDefined(definition)) return $types[name]; - if ($types.hasOwnProperty(name)) throw new Error("A type named '" + name + "' has already been defined."); - - $types[name] = new Type(extend({ name: name }, definition)); - if (definitionFn) { - typeQueue.push({ name: name, def: definitionFn }); - if (!enqueue) flushTypeQueue(); - } - return this; - }; - - // `flushTypeQueue()` waits until `$urlMatcherFactory` is injected before invoking the queued `definitionFn`s - function flushTypeQueue() { - while(typeQueue.length) { - var type = typeQueue.shift(); - if (type.pattern) throw new Error("You cannot override a type's .pattern at runtime."); - angular.extend($types[type.name], injector.invoke(type.def)); - } - } - - // Register default types. Store them in the prototype of $types. - forEach(defaultTypes, function(type, name) { $types[name] = new Type(extend({name: name}, type)); }); - $types = inherit($types, {}); - - /* No need to document $get, since it returns this */ - this.$get = ['$injector', function ($injector) { - injector = $injector; - enqueue = false; - flushTypeQueue(); - - forEach(defaultTypes, function(type, name) { - if (!$types[name]) $types[name] = new Type(type); - }); - return this; - }]; - - this.Param = function Param(id, type, config, location) { - var self = this; - config = unwrapShorthand(config); - type = getType(config, type, location); - var arrayMode = getArrayMode(); - type = arrayMode ? type.$asArray(arrayMode, location === "search") : type; - if (type.name === "string" && !arrayMode && location === "path" && config.value === undefined) - config.value = ""; // for 0.2.x; in 0.3.0+ do not automatically default to "" - var isOptional = config.value !== undefined; - var squash = getSquashPolicy(config, isOptional); - var replace = getReplace(config, arrayMode, isOptional, squash); - - function unwrapShorthand(config) { - var keys = isObject(config) ? objectKeys(config) : []; - var isShorthand = indexOf(keys, "value") === -1 && indexOf(keys, "type") === -1 && - indexOf(keys, "squash") === -1 && indexOf(keys, "array") === -1; - if (isShorthand) config = { value: config }; - config.$$fn = isInjectable(config.value) ? config.value : function () { return config.value; }; - return config; - } - - function getType(config, urlType, location) { - if (config.type && urlType) throw new Error("Param '"+id+"' has two type configurations."); - if (urlType) return urlType; - if (!config.type) return (location === "config" ? $types.any : $types.string); - return config.type instanceof Type ? config.type : new Type(config.type); - } - - // array config: param name (param[]) overrides default settings. explicit config overrides param name. - function getArrayMode() { - var arrayDefaults = { array: (location === "search" ? "auto" : false) }; - var arrayParamNomenclature = id.match(/\[\]$/) ? { array: true } : {}; - return extend(arrayDefaults, arrayParamNomenclature, config).array; - } - - /** - * returns false, true, or the squash value to indicate the "default parameter url squash policy". - */ - function getSquashPolicy(config, isOptional) { - var squash = config.squash; - if (!isOptional || squash === false) return false; - if (!isDefined(squash) || squash == null) return defaultSquashPolicy; - if (squash === true || isString(squash)) return squash; - throw new Error("Invalid squash policy: '" + squash + "'. Valid policies: false, true, or arbitrary string"); - } - - function getReplace(config, arrayMode, isOptional, squash) { - var replace, configuredKeys, defaultPolicy = [ - { from: "", to: (isOptional || arrayMode ? undefined : "") }, - { from: null, to: (isOptional || arrayMode ? undefined : "") } - ]; - replace = isArray(config.replace) ? config.replace : []; - if (isString(squash)) - replace.push({ from: squash, to: undefined }); - configuredKeys = map(replace, function(item) { return item.from; } ); - return filter(defaultPolicy, function(item) { return indexOf(configuredKeys, item.from) === -1; }).concat(replace); - } - - /** - * [Internal] Get the default value of a parameter, which may be an injectable function. - */ - function $$getDefaultValue() { - if (!injector) throw new Error("Injectable functions cannot be called at configuration time"); - return injector.invoke(config.$$fn); - } - - /** - * [Internal] Gets the decoded representation of a value if the value is defined, otherwise, returns the - * default value, which may be the result of an injectable function. - */ - function $value(value) { - function hasReplaceVal(val) { return function(obj) { return obj.from === val; }; } - function $replace(value) { - var replacement = map(filter(self.replace, hasReplaceVal(value)), function(obj) { return obj.to; }); - return replacement.length ? replacement[0] : value; - } - value = $replace(value); - return isDefined(value) ? self.type.decode(value) : $$getDefaultValue(); - } - - function toString() { return "{Param:" + id + " " + type + " squash: '" + squash + "' optional: " + isOptional + "}"; } - - extend(this, { - id: id, - type: type, - location: location, - array: arrayMode, - squash: squash, - replace: replace, - isOptional: isOptional, - value: $value, - dynamic: undefined, - config: config, - toString: toString - }); - }; - - function ParamSet(params) { - extend(this, params || {}); - } - - ParamSet.prototype = { - $$new: function() { - return inherit(this, extend(new ParamSet(), { $$parent: this})); - }, - $$keys: function () { - var keys = [], chain = [], parent = this, - ignore = objectKeys(ParamSet.prototype); - while (parent) { chain.push(parent); parent = parent.$$parent; } - chain.reverse(); - forEach(chain, function(paramset) { - forEach(objectKeys(paramset), function(key) { - if (indexOf(keys, key) === -1 && indexOf(ignore, key) === -1) keys.push(key); - }); - }); - return keys; - }, - $$values: function(paramValues) { - var values = {}, self = this; - forEach(self.$$keys(), function(key) { - values[key] = self[key].value(paramValues && paramValues[key]); - }); - return values; - }, - $$equals: function(paramValues1, paramValues2) { - var equal = true, self = this; - forEach(self.$$keys(), function(key) { - var left = paramValues1 && paramValues1[key], right = paramValues2 && paramValues2[key]; - if (!self[key].type.equals(left, right)) equal = false; - }); - return equal; - }, - $$validates: function $$validate(paramValues) { - var result = true, isOptional, val, param, self = this; - - forEach(this.$$keys(), function(key) { - param = self[key]; - val = paramValues[key]; - isOptional = !val && param.isOptional; - result = result && (isOptional || !!param.type.is(val)); - }); - return result; - }, - $$parent: undefined - }; - - this.ParamSet = ParamSet; - } - - // Register as a provider so it's available to other providers - angular.module('ui.router.util').provider('$urlMatcherFactory', $UrlMatcherFactory); - angular.module('ui.router.util').run(['$urlMatcherFactory', function($urlMatcherFactory) { }]); - - /** - * @ngdoc object - * @name ui.router.router.$urlRouterProvider - * - * @requires ui.router.util.$urlMatcherFactoryProvider - * @requires $locationProvider - * - * @description - * `$urlRouterProvider` has the responsibility of watching `$location`. - * When `$location` changes it runs through a list of rules one by one until a - * match is found. `$urlRouterProvider` is used behind the scenes anytime you specify - * a url in a state configuration. All urls are compiled into a UrlMatcher object. - * - * There are several methods on `$urlRouterProvider` that make it useful to use directly - * in your module config. - */ - $UrlRouterProvider.$inject = ['$locationProvider', '$urlMatcherFactoryProvider']; - function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { - var rules = [], otherwise = null, interceptDeferred = false, listener; - - // Returns a string that is a prefix of all strings matching the RegExp - function regExpPrefix(re) { - var prefix = /^\^((?:\\[^a-zA-Z0-9]|[^\\\[\]\^$*+?.()|{}]+)*)/.exec(re.source); - return (prefix != null) ? prefix[1].replace(/\\(.)/g, "$1") : ''; - } - - // Interpolates matched values into a String.replace()-style pattern - function interpolate(pattern, match) { - return pattern.replace(/\$(\$|\d{1,2})/, function (m, what) { - return match[what === '$' ? 0 : Number(what)]; - }); - } - - /** - * @ngdoc function - * @name ui.router.router.$urlRouterProvider#rule - * @methodOf ui.router.router.$urlRouterProvider - * - * @description - * Defines rules that are used by `$urlRouterProvider` to find matches for - * specific URLs. - * - * @example - *
    -     * var app = angular.module('app', ['ui.router.router']);
    -     *
    -     * app.config(function ($urlRouterProvider) {
    -     *   // Here's an example of how you might allow case insensitive urls
    -     *   $urlRouterProvider.rule(function ($injector, $location) {
    -     *     var path = $location.path(),
    -     *         normalized = path.toLowerCase();
    -     *
    -     *     if (path !== normalized) {
    -     *       return normalized;
    -     *     }
    -     *   });
    -     * });
    -     * 
    - * - * @param {object} rule Handler function that takes `$injector` and `$location` - * services as arguments. You can use them to return a valid path as a string. - * - * @return {object} `$urlRouterProvider` - `$urlRouterProvider` instance - */ - this.rule = function (rule) { - if (!isFunction(rule)) throw new Error("'rule' must be a function"); - rules.push(rule); - return this; - }; - - /** - * @ngdoc object - * @name ui.router.router.$urlRouterProvider#otherwise - * @methodOf ui.router.router.$urlRouterProvider - * - * @description - * Defines a path that is used when an invalid route is requested. - * - * @example - *
    -     * var app = angular.module('app', ['ui.router.router']);
    -     *
    -     * app.config(function ($urlRouterProvider) {
    -     *   // if the path doesn't match any of the urls you configured
    -     *   // otherwise will take care of routing the user to the
    -     *   // specified url
    -     *   $urlRouterProvider.otherwise('/index');
    -     *
    -     *   // Example of using function rule as param
    -     *   $urlRouterProvider.otherwise(function ($injector, $location) {
    -     *     return '/a/valid/url';
    -     *   });
    -     * });
    -     * 
    - * - * @param {string|object} rule The url path you want to redirect to or a function - * rule that returns the url path. The function version is passed two params: - * `$injector` and `$location` services, and must return a url string. - * - * @return {object} `$urlRouterProvider` - `$urlRouterProvider` instance - */ - this.otherwise = function (rule) { - if (isString(rule)) { - var redirect = rule; - rule = function () { return redirect; }; - } - else if (!isFunction(rule)) throw new Error("'rule' must be a function"); - otherwise = rule; - return this; - }; - - - function handleIfMatch($injector, handler, match) { - if (!match) return false; - var result = $injector.invoke(handler, handler, { $match: match }); - return isDefined(result) ? result : true; - } - - /** - * @ngdoc function - * @name ui.router.router.$urlRouterProvider#when - * @methodOf ui.router.router.$urlRouterProvider - * - * @description - * Registers a handler for a given url matching. if handle is a string, it is - * treated as a redirect, and is interpolated according to the syntax of match - * (i.e. like `String.replace()` for `RegExp`, or like a `UrlMatcher` pattern otherwise). - * - * If the handler is a function, it is injectable. It gets invoked if `$location` - * matches. You have the option of inject the match object as `$match`. - * - * The handler can return - * - * - **falsy** to indicate that the rule didn't match after all, then `$urlRouter` - * will continue trying to find another one that matches. - * - **string** which is treated as a redirect and passed to `$location.url()` - * - **void** or any **truthy** value tells `$urlRouter` that the url was handled. - * - * @example - *
    -     * var app = angular.module('app', ['ui.router.router']);
    -     *
    -     * app.config(function ($urlRouterProvider) {
    -     *   $urlRouterProvider.when($state.url, function ($match, $stateParams) {
    -     *     if ($state.$current.navigable !== state ||
    -     *         !equalForKeys($match, $stateParams) {
    -     *      $state.transitionTo(state, $match, false);
    -     *     }
    -     *   });
    -     * });
    -     * 
    - * - * @param {string|object} what The incoming path that you want to redirect. - * @param {string|object} handler The path you want to redirect your user to. - */ - this.when = function (what, handler) { - var redirect, handlerIsString = isString(handler); - if (isString(what)) what = $urlMatcherFactory.compile(what); - - if (!handlerIsString && !isFunction(handler) && !isArray(handler)) - throw new Error("invalid 'handler' in when()"); - - var strategies = { - matcher: function (what, handler) { - if (handlerIsString) { - redirect = $urlMatcherFactory.compile(handler); - handler = ['$match', function ($match) { return redirect.format($match); }]; - } - return extend(function ($injector, $location) { - return handleIfMatch($injector, handler, what.exec($location.path(), $location.search())); - }, { - prefix: isString(what.prefix) ? what.prefix : '' - }); - }, - regex: function (what, handler) { - if (what.global || what.sticky) throw new Error("when() RegExp must not be global or sticky"); - - if (handlerIsString) { - redirect = handler; - handler = ['$match', function ($match) { return interpolate(redirect, $match); }]; - } - return extend(function ($injector, $location) { - return handleIfMatch($injector, handler, what.exec($location.path())); - }, { - prefix: regExpPrefix(what) - }); - } - }; - - var check = { matcher: $urlMatcherFactory.isMatcher(what), regex: what instanceof RegExp }; - - for (var n in check) { - if (check[n]) return this.rule(strategies[n](what, handler)); - } - - throw new Error("invalid 'what' in when()"); - }; - - /** - * @ngdoc function - * @name ui.router.router.$urlRouterProvider#deferIntercept - * @methodOf ui.router.router.$urlRouterProvider - * - * @description - * Disables (or enables) deferring location change interception. - * - * If you wish to customize the behavior of syncing the URL (for example, if you wish to - * defer a transition but maintain the current URL), call this method at configuration time. - * Then, at run time, call `$urlRouter.listen()` after you have configured your own - * `$locationChangeSuccess` event handler. - * - * @example - *
    -     * var app = angular.module('app', ['ui.router.router']);
    -     *
    -     * app.config(function ($urlRouterProvider) {
    -     *
    -     *   // Prevent $urlRouter from automatically intercepting URL changes;
    -     *   // this allows you to configure custom behavior in between
    -     *   // location changes and route synchronization:
    -     *   $urlRouterProvider.deferIntercept();
    -     *
    -     * }).run(function ($rootScope, $urlRouter, UserService) {
    -     *
    -     *   $rootScope.$on('$locationChangeSuccess', function(e) {
    -     *     // UserService is an example service for managing user state
    -     *     if (UserService.isLoggedIn()) return;
    -     *
    -     *     // Prevent $urlRouter's default handler from firing
    -     *     e.preventDefault();
    -     *
    -     *     UserService.handleLogin().then(function() {
    -     *       // Once the user has logged in, sync the current URL
    -     *       // to the router:
    -     *       $urlRouter.sync();
    -     *     });
    -     *   });
    -     *
    -     *   // Configures $urlRouter's listener *after* your custom listener
    -     *   $urlRouter.listen();
    -     * });
    -     * 
    - * - * @param {boolean} defer Indicates whether to defer location change interception. Passing - no parameter is equivalent to `true`. - */ - this.deferIntercept = function (defer) { - if (defer === undefined) defer = true; - interceptDeferred = defer; - }; - - /** - * @ngdoc object - * @name ui.router.router.$urlRouter - * - * @requires $location - * @requires $rootScope - * @requires $injector - * @requires $browser - * - * @description - * - */ - this.$get = $get; - $get.$inject = ['$location', '$rootScope', '$injector', '$browser']; - function $get( $location, $rootScope, $injector, $browser) { - - var baseHref = $browser.baseHref(), location = $location.url(), lastPushedUrl; - - function appendBasePath(url, isHtml5, absolute) { - if (baseHref === '/') return url; - if (isHtml5) return baseHref.slice(0, -1) + url; - if (absolute) return baseHref.slice(1) + url; - return url; - } - - // TODO: Optimize groups of rules with non-empty prefix into some sort of decision tree - function update(evt) { - if (evt && evt.defaultPrevented) return; - var ignoreUpdate = lastPushedUrl && $location.url() === lastPushedUrl; - lastPushedUrl = undefined; - if (ignoreUpdate) return true; - - function check(rule) { - var handled = rule($injector, $location); - - if (!handled) return false; - if (isString(handled)) $location.replace().url(handled); - return true; - } - var n = rules.length, i; - - for (i = 0; i < n; i++) { - if (check(rules[i])) return; - } - // always check otherwise last to allow dynamic updates to the set of rules - if (otherwise) check(otherwise); - } - - function listen() { - listener = listener || $rootScope.$on('$locationChangeSuccess', update); - return listener; - } - - if (!interceptDeferred) listen(); - - return { - /** - * @ngdoc function - * @name ui.router.router.$urlRouter#sync - * @methodOf ui.router.router.$urlRouter - * - * @description - * Triggers an update; the same update that happens when the address bar url changes, aka `$locationChangeSuccess`. - * This method is useful when you need to use `preventDefault()` on the `$locationChangeSuccess` event, - * perform some custom logic (route protection, auth, config, redirection, etc) and then finally proceed - * with the transition by calling `$urlRouter.sync()`. - * - * @example - *
    -         * angular.module('app', ['ui.router'])
    -         *   .run(function($rootScope, $urlRouter) {
    -         *     $rootScope.$on('$locationChangeSuccess', function(evt) {
    -         *       // Halt state change from even starting
    -         *       evt.preventDefault();
    -         *       // Perform custom logic
    -         *       var meetsRequirement = ...
    -         *       // Continue with the update and state transition if logic allows
    -         *       if (meetsRequirement) $urlRouter.sync();
    -         *     });
    -         * });
    -         * 
    - */ - sync: function() { - update(); - }, - - listen: function() { - return listen(); - }, - - update: function(read) { - if (read) { - location = $location.url(); - return; - } - if ($location.url() === location) return; - - $location.url(location); - $location.replace(); - }, - - push: function(urlMatcher, params, options) { - $location.url(urlMatcher.format(params || {})); - lastPushedUrl = options && options.$$avoidResync ? $location.url() : undefined; - if (options && options.replace) $location.replace(); - }, - - /** - * @ngdoc function - * @name ui.router.router.$urlRouter#href - * @methodOf ui.router.router.$urlRouter - * - * @description - * A URL generation method that returns the compiled URL for a given - * {@link ui.router.util.type:UrlMatcher `UrlMatcher`}, populated with the provided parameters. - * - * @example - *
    -         * $bob = $urlRouter.href(new UrlMatcher("/about/:person"), {
    -         *   person: "bob"
    -         * });
    -         * // $bob == "/about/bob";
    -         * 
    - * - * @param {UrlMatcher} urlMatcher The `UrlMatcher` object which is used as the template of the URL to generate. - * @param {object=} params An object of parameter values to fill the matcher's required parameters. - * @param {object=} options Options object. The options are: - * - * - **`absolute`** - {boolean=false}, If true will generate an absolute url, e.g. "http://www.example.com/fullurl". - * - * @returns {string} Returns the fully compiled URL, or `null` if `params` fail validation against `urlMatcher` - */ - href: function(urlMatcher, params, options) { - if (!urlMatcher.validates(params)) return null; - - var isHtml5 = $locationProvider.html5Mode(); - if (angular.isObject(isHtml5)) { - isHtml5 = isHtml5.enabled; - } - - var url = urlMatcher.format(params); - options = options || {}; - - if (!isHtml5 && url !== null) { - url = "#" + $locationProvider.hashPrefix() + url; - } - url = appendBasePath(url, isHtml5, options.absolute); - - if (!options.absolute || !url) { - return url; - } - - var slash = (!isHtml5 && url ? '/' : ''), port = $location.port(); - port = (port === 80 || port === 443 ? '' : ':' + port); - - return [$location.protocol(), '://', $location.host(), port, slash, url].join(''); - } - }; - } - } - - angular.module('ui.router.router').provider('$urlRouter', $UrlRouterProvider); - - /** - * @ngdoc object - * @name ui.router.state.$stateProvider - * - * @requires ui.router.router.$urlRouterProvider - * @requires ui.router.util.$urlMatcherFactoryProvider - * - * @description - * The new `$stateProvider` works similar to Angular's v1 router, but it focuses purely - * on state. - * - * A state corresponds to a "place" in the application in terms of the overall UI and - * navigation. A state describes (via the controller / template / view properties) what - * the UI looks like and does at that place. - * - * States often have things in common, and the primary way of factoring out these - * commonalities in this model is via the state hierarchy, i.e. parent/child states aka - * nested states. - * - * The `$stateProvider` provides interfaces to declare these states for your app. - */ - $StateProvider.$inject = ['$urlRouterProvider', '$urlMatcherFactoryProvider']; - function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { - - var root, states = {}, $state, queue = {}, abstractKey = 'abstract'; - - // Builds state properties from definition passed to registerState() - var stateBuilder = { - - // Derive parent state from a hierarchical name only if 'parent' is not explicitly defined. - // state.children = []; - // if (parent) parent.children.push(state); - parent: function(state) { - if (isDefined(state.parent) && state.parent) return findState(state.parent); - // regex matches any valid composite state name - // would match "contact.list" but not "contacts" - var compositeName = /^(.+)\.[^.]+$/.exec(state.name); - return compositeName ? findState(compositeName[1]) : root; - }, - - // inherit 'data' from parent and override by own values (if any) - data: function(state) { - if (state.parent && state.parent.data) { - state.data = state.self.data = extend({}, state.parent.data, state.data); - } - return state.data; - }, - - // Build a URLMatcher if necessary, either via a relative or absolute URL - url: function(state) { - var url = state.url, config = { params: state.params || {} }; - - if (isString(url)) { - if (url.charAt(0) == '^') return $urlMatcherFactory.compile(url.substring(1), config); - return (state.parent.navigable || root).url.concat(url, config); - } - - if (!url || $urlMatcherFactory.isMatcher(url)) return url; - throw new Error("Invalid url '" + url + "' in state '" + state + "'"); - }, - - // Keep track of the closest ancestor state that has a URL (i.e. is navigable) - navigable: function(state) { - return state.url ? state : (state.parent ? state.parent.navigable : null); - }, - - // Own parameters for this state. state.url.params is already built at this point. Create and add non-url params - ownParams: function(state) { - var params = state.url && state.url.params || new $$UMFP.ParamSet(); - forEach(state.params || {}, function(config, id) { - if (!params[id]) params[id] = new $$UMFP.Param(id, null, config, "config"); - }); - return params; - }, - - // Derive parameters for this state and ensure they're a super-set of parent's parameters - params: function(state) { - return state.parent && state.parent.params ? extend(state.parent.params.$$new(), state.ownParams) : new $$UMFP.ParamSet(); - }, - - // If there is no explicit multi-view configuration, make one up so we don't have - // to handle both cases in the view directive later. Note that having an explicit - // 'views' property will mean the default unnamed view properties are ignored. This - // is also a good time to resolve view names to absolute names, so everything is a - // straight lookup at link time. - views: function(state) { - var views = {}; - - forEach(isDefined(state.views) ? state.views : { '': state }, function (view, name) { - if (name.indexOf('@') < 0) name += '@' + state.parent.name; - views[name] = view; - }); - return views; - }, - - // Keep a full path from the root down to this state as this is needed for state activation. - path: function(state) { - return state.parent ? state.parent.path.concat(state) : []; // exclude root from path - }, - - // Speed up $state.contains() as it's used a lot - includes: function(state) { - var includes = state.parent ? extend({}, state.parent.includes) : {}; - includes[state.name] = true; - return includes; - }, - - $delegates: {} - }; - - function isRelative(stateName) { - return stateName.indexOf(".") === 0 || stateName.indexOf("^") === 0; - } - - function findState(stateOrName, base) { - if (!stateOrName) return undefined; - - var isStr = isString(stateOrName), - name = isStr ? stateOrName : stateOrName.name, - path = isRelative(name); - - if (path) { - if (!base) throw new Error("No reference point given for path '" + name + "'"); - base = findState(base); - - var rel = name.split("."), i = 0, pathLength = rel.length, current = base; - - for (; i < pathLength; i++) { - if (rel[i] === "" && i === 0) { - current = base; - continue; - } - if (rel[i] === "^") { - if (!current.parent) throw new Error("Path '" + name + "' not valid for state '" + base.name + "'"); - current = current.parent; - continue; - } - break; - } - rel = rel.slice(i).join("."); - name = current.name + (current.name && rel ? "." : "") + rel; - } - var state = states[name]; - - if (state && (isStr || (!isStr && (state === stateOrName || state.self === stateOrName)))) { - return state; - } - return undefined; - } - - function queueState(parentName, state) { - if (!queue[parentName]) { - queue[parentName] = []; - } - queue[parentName].push(state); - } - - function flushQueuedChildren(parentName) { - var queued = queue[parentName] || []; - while(queued.length) { - registerState(queued.shift()); - } - } - - function registerState(state) { - // Wrap a new object around the state so we can store our private details easily. - state = inherit(state, { - self: state, - resolve: state.resolve || {}, - toString: function() { return this.name; } - }); - - var name = state.name; - if (!isString(name) || name.indexOf('@') >= 0) throw new Error("State must have a valid name"); - if (states.hasOwnProperty(name)) throw new Error("State '" + name + "'' is already defined"); - - // Get parent name - var parentName = (name.indexOf('.') !== -1) ? name.substring(0, name.lastIndexOf('.')) - : (isString(state.parent)) ? state.parent - : (isObject(state.parent) && isString(state.parent.name)) ? state.parent.name - : ''; - - // If parent is not registered yet, add state to queue and register later - if (parentName && !states[parentName]) { - return queueState(parentName, state.self); - } - - for (var key in stateBuilder) { - if (isFunction(stateBuilder[key])) state[key] = stateBuilder[key](state, stateBuilder.$delegates[key]); - } - states[name] = state; - - // Register the state in the global state list and with $urlRouter if necessary. - if (!state[abstractKey] && state.url) { - $urlRouterProvider.when(state.url, ['$match', '$stateParams', function ($match, $stateParams) { - if ($state.$current.navigable != state || !equalForKeys($match, $stateParams)) { - $state.transitionTo(state, $match, { inherit: true, location: false }); - } - }]); - } - - // Register any queued children - flushQueuedChildren(name); - - return state; - } - - // Checks text to see if it looks like a glob. - function isGlob (text) { - return text.indexOf('*') > -1; - } - - // Returns true if glob matches current $state name. - function doesStateMatchGlob (glob) { - var globSegments = glob.split('.'), - segments = $state.$current.name.split('.'); - - //match greedy starts - if (globSegments[0] === '**') { - segments = segments.slice(indexOf(segments, globSegments[1])); - segments.unshift('**'); - } - //match greedy ends - if (globSegments[globSegments.length - 1] === '**') { - segments.splice(indexOf(segments, globSegments[globSegments.length - 2]) + 1, Number.MAX_VALUE); - segments.push('**'); - } - - if (globSegments.length != segments.length) { - return false; - } - - //match single stars - for (var i = 0, l = globSegments.length; i < l; i++) { - if (globSegments[i] === '*') { - segments[i] = '*'; - } - } - - return segments.join('') === globSegments.join(''); - } - - - // Implicit root state that is always active - root = registerState({ - name: '', - url: '^', - views: null, - 'abstract': true - }); - root.navigable = null; - - - /** - * @ngdoc function - * @name ui.router.state.$stateProvider#decorator - * @methodOf ui.router.state.$stateProvider - * - * @description - * Allows you to extend (carefully) or override (at your own peril) the - * `stateBuilder` object used internally by `$stateProvider`. This can be used - * to add custom functionality to ui-router, for example inferring templateUrl - * based on the state name. - * - * When passing only a name, it returns the current (original or decorated) builder - * function that matches `name`. - * - * The builder functions that can be decorated are listed below. Though not all - * necessarily have a good use case for decoration, that is up to you to decide. - * - * In addition, users can attach custom decorators, which will generate new - * properties within the state's internal definition. There is currently no clear - * use-case for this beyond accessing internal states (i.e. $state.$current), - * however, expect this to become increasingly relevant as we introduce additional - * meta-programming features. - * - * **Warning**: Decorators should not be interdependent because the order of - * execution of the builder functions in non-deterministic. Builder functions - * should only be dependent on the state definition object and super function. - * - * - * Existing builder functions and current return values: - * - * - **parent** `{object}` - returns the parent state object. - * - **data** `{object}` - returns state data, including any inherited data that is not - * overridden by own values (if any). - * - **url** `{object}` - returns a {@link ui.router.util.type:UrlMatcher UrlMatcher} - * or `null`. - * - **navigable** `{object}` - returns closest ancestor state that has a URL (aka is - * navigable). - * - **params** `{object}` - returns an array of state params that are ensured to - * be a super-set of parent's params. - * - **views** `{object}` - returns a views object where each key is an absolute view - * name (i.e. "viewName@stateName") and each value is the config object - * (template, controller) for the view. Even when you don't use the views object - * explicitly on a state config, one is still created for you internally. - * So by decorating this builder function you have access to decorating template - * and controller properties. - * - **ownParams** `{object}` - returns an array of params that belong to the state, - * not including any params defined by ancestor states. - * - **path** `{string}` - returns the full path from the root down to this state. - * Needed for state activation. - * - **includes** `{object}` - returns an object that includes every state that - * would pass a `$state.includes()` test. - * - * @example - *
    -     * // Override the internal 'views' builder with a function that takes the state
    -     * // definition, and a reference to the internal function being overridden:
    -     * $stateProvider.decorator('views', function (state, parent) {
    -     *   var result = {},
    -     *       views = parent(state);
    -     *
    -     *   angular.forEach(views, function (config, name) {
    -     *     var autoName = (state.name + '.' + name).replace('.', '/');
    -     *     config.templateUrl = config.templateUrl || '/partials/' + autoName + '.html';
    -     *     result[name] = config;
    -     *   });
    -     *   return result;
    -     * });
    -     *
    -     * $stateProvider.state('home', {
    -     *   views: {
    -     *     'contact.list': { controller: 'ListController' },
    -     *     'contact.item': { controller: 'ItemController' }
    -     *   }
    -     * });
    -     *
    -     * // ...
    -     *
    -     * $state.go('home');
    -     * // Auto-populates list and item views with /partials/home/contact/list.html,
    -     * // and /partials/home/contact/item.html, respectively.
    -     * 
    - * - * @param {string} name The name of the builder function to decorate. - * @param {object} func A function that is responsible for decorating the original - * builder function. The function receives two parameters: - * - * - `{object}` - state - The state config object. - * - `{object}` - super - The original builder function. - * - * @return {object} $stateProvider - $stateProvider instance - */ - this.decorator = decorator; - function decorator(name, func) { - /*jshint validthis: true */ - if (isString(name) && !isDefined(func)) { - return stateBuilder[name]; - } - if (!isFunction(func) || !isString(name)) { - return this; - } - if (stateBuilder[name] && !stateBuilder.$delegates[name]) { - stateBuilder.$delegates[name] = stateBuilder[name]; - } - stateBuilder[name] = func; - return this; - } - - /** - * @ngdoc function - * @name ui.router.state.$stateProvider#state - * @methodOf ui.router.state.$stateProvider - * - * @description - * Registers a state configuration under a given state name. The stateConfig object - * has the following acceptable properties. - * - * @param {string} name A unique state name, e.g. "home", "about", "contacts". - * To create a parent/child state use a dot, e.g. "about.sales", "home.newest". - * @param {object} stateConfig State configuration object. - * @param {string|function=} stateConfig.template - * - * html template as a string or a function that returns - * an html template as a string which should be used by the uiView directives. This property - * takes precedence over templateUrl. - * - * If `template` is a function, it will be called with the following parameters: - * - * - {array.<object>} - state parameters extracted from the current $location.path() by - * applying the current state - * - *
    template:
    -     *   "

    inline template definition

    " + - * "
    "
    - *
    template: function(params) {
    -     *       return "

    generated template

    "; }
    - *
    {/* Used some quick-and-dirty inline CSS styles here because the form-footer should be styled in the mother application. The HTML markup can be changed as well. */} - {t('survey.back')} - - {t('survey.next')} -
    {t('survey.powered-by')} enketo logo
    + + {t('survey.next')} + +
    + {t('survey.powered-by')}{' '} + + enketo logo + {' '} +
    {/*
      */}

      U00KL03lN-s! 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%0D*m3Xm=i? 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 {Zs|Q)nj1^ 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

    1. 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 DZU5^; diff --git a/www/img/minus.gif b/www/img/minus.gif deleted file mode 100644 index 0115810b967dea899c60d902875799227699703e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4635 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 E2mOAAEdT%j diff --git a/www/img/nileredsea_126b5740_small.png b/www/img/nileredsea_126b5740_small.png deleted file mode 100644 index a1e11ca2459f7d5e4078001b369b83ea5fc3ad02..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 36171 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 LNkvXXu0mjf8l@wp diff --git a/www/img/pacman.gif b/www/img/pacman.gif deleted file mode 100644 index 8201c5d6e3e5c76de38fd51b0b7993206146c34b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 80346 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

      - * - * @param {string|function=} stateConfig.templateUrl - * - * - * path or function that returns a path to an html - * template that should be used by uiView. - * - * If `templateUrl` is a function, it will be called with the following parameters: - * - * - {array.<object>} - state parameters extracted from the current $location.path() by - * applying the current state - * - *
      templateUrl: "home.html"
      - *
      templateUrl: function(params) {
      -     *     return myTemplates[params.pageId]; }
      - * - * @param {function=} stateConfig.templateProvider - * - * Provider function that returns HTML content string. - *
       templateProvider:
      -     *       function(MyTemplateService, params) {
      -     *         return MyTemplateService.getTemplate(params.pageId);
      -     *       }
      - * - * @param {string|function=} stateConfig.controller - * - * - * Controller fn that should be associated with newly - * related scope or the name of a registered controller if passed as a string. - * Optionally, the ControllerAs may be declared here. - *
      controller: "MyRegisteredController"
      - *
      controller:
      -     *     "MyRegisteredController as fooCtrl"}
      - *
      controller: function($scope, MyService) {
      -     *     $scope.data = MyService.getData(); }
      - * - * @param {function=} stateConfig.controllerProvider - * - * - * Injectable provider function that returns the actual controller or string. - *
      controllerProvider:
      -     *   function(MyResolveData) {
      -     *     if (MyResolveData.foo)
      -     *       return "FooCtrl"
      -     *     else if (MyResolveData.bar)
      -     *       return "BarCtrl";
      -     *     else return function($scope) {
      -     *       $scope.baz = "Qux";
      -     *     }
      -     *   }
      - * - * @param {string=} stateConfig.controllerAs - * - * - * A controller alias name. If present the controller will be - * published to scope under the controllerAs name. - *
      controllerAs: "myCtrl"
      - * - * @param {object=} stateConfig.resolve - * - * - * An optional map<string, function> of dependencies which - * should be injected into the controller. If any of these dependencies are promises, - * the router will wait for them all to be resolved before the controller is instantiated. - * If all the promises are resolved successfully, the $stateChangeSuccess event is fired - * and the values of the resolved promises are injected into any controllers that reference them. - * If any of the promises are rejected the $stateChangeError event is fired. - * - * The map object is: - * - * - key - {string}: name of dependency to be injected into controller - * - factory - {string|function}: If string then it is alias for service. Otherwise if function, - * it is injected and return value it treated as dependency. If result is a promise, it is - * resolved before its value is injected into controller. - * - *
      resolve: {
      -     *     myResolve1:
      -     *       function($http, $stateParams) {
      -     *         return $http.get("/api/foos/"+stateParams.fooID);
      -     *       }
      -     *     }
      - * - * @param {string=} stateConfig.url - * - * - * A url fragment with optional parameters. When a state is navigated or - * transitioned to, the `$stateParams` service will be populated with any - * parameters that were passed. - * - * examples: - *
      url: "/home"
      -     * url: "/users/:userid"
      -     * url: "/books/{bookid:[a-zA-Z_-]}"
      -     * url: "/books/{categoryid:int}"
      -     * url: "/books/{publishername:string}/{categoryid:int}"
      -     * url: "/messages?before&after"
      -     * url: "/messages?{before:date}&{after:date}"
      - * url: "/messages/:mailboxid?{before:date}&{after:date}" - * - * @param {object=} stateConfig.views - * - * an optional map<string, object> which defined multiple views, or targets views - * manually/explicitly. - * - * Examples: - * - * Targets three named `ui-view`s in the parent state's template - *
      views: {
      -     *     header: {
      -     *       controller: "headerCtrl",
      -     *       templateUrl: "header.html"
      -     *     }, body: {
      -     *       controller: "bodyCtrl",
      -     *       templateUrl: "body.html"
      -     *     }, footer: {
      -     *       controller: "footCtrl",
      -     *       templateUrl: "footer.html"
      -     *     }
      -     *   }
      - * - * Targets named `ui-view="header"` from grandparent state 'top''s template, and named `ui-view="body" from parent state's template. - *
      views: {
      -     *     'header@top': {
      -     *       controller: "msgHeaderCtrl",
      -     *       templateUrl: "msgHeader.html"
      -     *     }, 'body': {
      -     *       controller: "messagesCtrl",
      -     *       templateUrl: "messages.html"
      -     *     }
      -     *   }
      - * - * @param {boolean=} [stateConfig.abstract=false] - * - * An abstract state will never be directly activated, - * but can provide inherited properties to its common children states. - *
      abstract: true
      - * - * @param {function=} stateConfig.onEnter - * - * - * Callback function for when a state is entered. Good way - * to trigger an action or dispatch an event, such as opening a dialog. - * If minifying your scripts, make sure to explictly annotate this function, - * because it won't be automatically annotated by your build tools. - * - *
      onEnter: function(MyService, $stateParams) {
      -     *     MyService.foo($stateParams.myParam);
      -     * }
      - * - * @param {function=} stateConfig.onExit - * - * - * Callback function for when a state is exited. Good way to - * trigger an action or dispatch an event, such as opening a dialog. - * If minifying your scripts, make sure to explictly annotate this function, - * because it won't be automatically annotated by your build tools. - * - *
      onExit: function(MyService, $stateParams) {
      -     *     MyService.cleanup($stateParams.myParam);
      -     * }
      - * - * @param {boolean=} [stateConfig.reloadOnSearch=true] - * - * - * If `false`, will not retrigger the same state - * just because a search/query parameter has changed (via $location.search() or $location.hash()). - * Useful for when you'd like to modify $location.search() without triggering a reload. - *
      reloadOnSearch: false
      - * - * @param {object=} stateConfig.data - * - * - * Arbitrary data object, useful for custom configuration. The parent state's `data` is - * prototypally inherited. In other words, adding a data property to a state adds it to - * the entire subtree via prototypal inheritance. - * - *
      data: {
      -     *     requiredRole: 'foo'
      -     * } 
      - * - * @param {object=} stateConfig.params - * - * - * A map which optionally configures parameters declared in the `url`, or - * defines additional non-url parameters. For each parameter being - * configured, add a configuration object keyed to the name of the parameter. - * - * Each parameter configuration object may contain the following properties: - * - * - ** value ** - {object|function=}: specifies the default value for this - * parameter. This implicitly sets this parameter as optional. - * - * When UI-Router routes to a state and no value is - * specified for this parameter in the URL or transition, the - * default value will be used instead. If `value` is a function, - * it will be injected and invoked, and the return value used. - * - * *Note*: `undefined` is treated as "no default value" while `null` - * is treated as "the default value is `null`". - * - * *Shorthand*: If you only need to configure the default value of the - * parameter, you may use a shorthand syntax. In the **`params`** - * map, instead mapping the param name to a full parameter configuration - * object, simply set map it to the default parameter value, e.g.: - * - *
      // define a parameter's default value
      -     * params: {
      -     *     param1: { value: "defaultValue" }
      -     * }
      -     * // shorthand default values
      -     * params: {
      -     *     param1: "defaultValue",
      -     *     param2: "param2Default"
      -     * }
      - * - * - ** array ** - {boolean=}: *(default: false)* If true, the param value will be - * treated as an array of values. If you specified a Type, the value will be - * treated as an array of the specified Type. Note: query parameter values - * default to a special `"auto"` mode. - * - * For query parameters in `"auto"` mode, if multiple values for a single parameter - * are present in the URL (e.g.: `/foo?bar=1&bar=2&bar=3`) then the values - * are mapped to an array (e.g.: `{ foo: [ '1', '2', '3' ] }`). However, if - * only one value is present (e.g.: `/foo?bar=1`) then the value is treated as single - * value (e.g.: `{ foo: '1' }`). - * - *
      params: {
      -     *     param1: { array: true }
      -     * }
      - * - * - ** squash ** - {bool|string=}: `squash` configures how a default parameter value is represented in the URL when - * the current parameter value is the same as the default value. If `squash` is not set, it uses the - * configured default squash policy. - * (See {@link ui.router.util.$urlMatcherFactory#methods_defaultSquashPolicy `defaultSquashPolicy()`}) - * - * There are three squash settings: - * - * - false: The parameter's default value is not squashed. It is encoded and included in the URL - * - true: The parameter's default value is omitted from the URL. If the parameter is preceeded and followed - * by slashes in the state's `url` declaration, then one of those slashes are omitted. - * This can allow for cleaner looking URLs. - * - `""`: The parameter's default value is replaced with an arbitrary placeholder of your choice. - * - *
      params: {
      -     *     param1: {
      -     *       value: "defaultId",
      -     *       squash: true
      -     * } }
      -     * // squash "defaultValue" to "~"
      -     * params: {
      -     *     param1: {
      -     *       value: "defaultValue",
      -     *       squash: "~"
      -     * } }
      -     * 
      - * - * - * @example - *
      -     * // Some state name examples
      -     *
      -     * // stateName can be a single top-level name (must be unique).
      -     * $stateProvider.state("home", {});
      -     *
      -     * // Or it can be a nested state name. This state is a child of the
      -     * // above "home" state.
      -     * $stateProvider.state("home.newest", {});
      -     *
      -     * // Nest states as deeply as needed.
      -     * $stateProvider.state("home.newest.abc.xyz.inception", {});
      -     *
      -     * // state() returns $stateProvider, so you can chain state declarations.
      -     * $stateProvider
      -     *   .state("home", {})
      -     *   .state("about", {})
      -     *   .state("contacts", {});
      -     * 
      - * - */ - this.state = state; - function state(name, definition) { - /*jshint validthis: true */ - if (isObject(name)) definition = name; - else definition.name = name; - registerState(definition); - return this; - } - - /** - * @ngdoc object - * @name ui.router.state.$state - * - * @requires $rootScope - * @requires $q - * @requires ui.router.state.$view - * @requires $injector - * @requires ui.router.util.$resolve - * @requires ui.router.state.$stateParams - * @requires ui.router.router.$urlRouter - * - * @property {object} params A param object, e.g. {sectionId: section.id)}, that - * you'd like to test against the current active state. - * @property {object} current A reference to the state's config object. However - * you passed it in. Useful for accessing custom data. - * @property {object} transition Currently pending transition. A promise that'll - * resolve or reject. - * - * @description - * `$state` service is responsible for representing states as well as transitioning - * between them. It also provides interfaces to ask for current state or even states - * you're coming from. - */ - this.$get = $get; - $get.$inject = ['$rootScope', '$q', '$view', '$injector', '$resolve', '$stateParams', '$urlRouter', '$location', '$urlMatcherFactory']; - function $get( $rootScope, $q, $view, $injector, $resolve, $stateParams, $urlRouter, $location, $urlMatcherFactory) { - - var TransitionSuperseded = $q.reject(new Error('transition superseded')); - var TransitionPrevented = $q.reject(new Error('transition prevented')); - var TransitionAborted = $q.reject(new Error('transition aborted')); - var TransitionFailed = $q.reject(new Error('transition failed')); - - // Handles the case where a state which is the target of a transition is not found, and the user - // can optionally retry or defer the transition - function handleRedirect(redirect, state, params, options) { - /** - * @ngdoc event - * @name ui.router.state.$state#$stateNotFound - * @eventOf ui.router.state.$state - * @eventType broadcast on root scope - * @description - * Fired when a requested state **cannot be found** using the provided state name during transition. - * The event is broadcast allowing any handlers a single chance to deal with the error (usually by - * lazy-loading the unfound state). A special `unfoundState` object is passed to the listener handler, - * you can see its three properties in the example. You can use `event.preventDefault()` to abort the - * transition and the promise returned from `go` will be rejected with a `'transition aborted'` value. - * - * @param {Object} event Event object. - * @param {Object} unfoundState Unfound State information. Contains: `to, toParams, options` properties. - * @param {State} fromState Current state object. - * @param {Object} fromParams Current state params. - * - * @example - * - *
      -         * // somewhere, assume lazy.state has not been defined
      -         * $state.go("lazy.state", {a:1, b:2}, {inherit:false});
      -         *
      -         * // somewhere else
      -         * $scope.$on('$stateNotFound',
      -         * function(event, unfoundState, fromState, fromParams){
      -         *     console.log(unfoundState.to); // "lazy.state"
      -         *     console.log(unfoundState.toParams); // {a:1, b:2}
      -         *     console.log(unfoundState.options); // {inherit:false} + default options
      -         * })
      -         * 
      - */ - var evt = $rootScope.$broadcast('$stateNotFound', redirect, state, params); - - if (evt.defaultPrevented) { - $urlRouter.update(); - return TransitionAborted; - } - - if (!evt.retry) { - return null; - } - - // Allow the handler to return a promise to defer state lookup retry - if (options.$retry) { - $urlRouter.update(); - return TransitionFailed; - } - var retryTransition = $state.transition = $q.when(evt.retry); - - retryTransition.then(function() { - if (retryTransition !== $state.transition) return TransitionSuperseded; - redirect.options.$retry = true; - return $state.transitionTo(redirect.to, redirect.toParams, redirect.options); - }, function() { - return TransitionAborted; - }); - $urlRouter.update(); - - return retryTransition; - } - - root.locals = { resolve: null, globals: { $stateParams: {} } }; - - $state = { - params: {}, - current: root.self, - $current: root, - transition: null - }; - - /** - * @ngdoc function - * @name ui.router.state.$state#reload - * @methodOf ui.router.state.$state - * - * @description - * A method that force reloads the current state. All resolves are re-resolved, events are not re-fired, - * and controllers reinstantiated (bug with controllers reinstantiating right now, fixing soon). - * - * @example - *
      -       * var app angular.module('app', ['ui.router']);
      -       *
      -       * app.controller('ctrl', function ($scope, $state) {
      -       *   $scope.reload = function(){
      -       *     $state.reload();
      -       *   }
      -       * });
      -       * 
      - * - * `reload()` is just an alias for: - *
      -       * $state.transitionTo($state.current, $stateParams, { 
      -       *   reload: true, inherit: false, notify: true
      -       * });
      -       * 
      - * - * @returns {promise} A promise representing the state of the new transition. See - * {@link ui.router.state.$state#methods_go $state.go}. - */ - $state.reload = function reload() { - return $state.transitionTo($state.current, $stateParams, { reload: true, inherit: false, notify: true }); - }; - - /** - * @ngdoc function - * @name ui.router.state.$state#go - * @methodOf ui.router.state.$state - * - * @description - * Convenience method for transitioning to a new state. `$state.go` calls - * `$state.transitionTo` internally but automatically sets options to - * `{ location: true, inherit: true, relative: $state.$current, notify: true }`. - * This allows you to easily use an absolute or relative to path and specify - * only the parameters you'd like to update (while letting unspecified parameters - * inherit from the currently active ancestor states). - * - * @example - *
      -       * var app = angular.module('app', ['ui.router']);
      -       *
      -       * app.controller('ctrl', function ($scope, $state) {
      -       *   $scope.changeState = function () {
      -       *     $state.go('contact.detail');
      -       *   };
      -       * });
      -       * 
      - * - * - * @param {string} to Absolute state name or relative state path. Some examples: - * - * - `$state.go('contact.detail')` - will go to the `contact.detail` state - * - `$state.go('^')` - will go to a parent state - * - `$state.go('^.sibling')` - will go to a sibling state - * - `$state.go('.child.grandchild')` - will go to grandchild state - * - * @param {object=} params A map of the parameters that will be sent to the state, - * will populate $stateParams. Any parameters that are not specified will be inherited from currently - * defined parameters. This allows, for example, going to a sibling state that shares parameters - * specified in a parent state. Parameter inheritance only works between common ancestor states, I.e. - * transitioning to a sibling will get you the parameters for all parents, transitioning to a child - * will get you all current parameters, etc. - * @param {object=} options Options object. The options are: - * - * - **`location`** - {boolean=true|string=} - If `true` will update the url in the location bar, if `false` - * will not. If string, must be `"replace"`, which will update url and also replace last history record. - * - **`inherit`** - {boolean=true}, If `true` will inherit url parameters from current url. - * - **`relative`** - {object=$state.$current}, When transitioning with relative path (e.g '^'), - * defines which state to be relative from. - * - **`notify`** - {boolean=true}, If `true` will broadcast $stateChangeStart and $stateChangeSuccess events. - * - **`reload`** (v0.2.5) - {boolean=false}, If `true` will force transition even if the state or params - * have not changed, aka a reload of the same state. It differs from reloadOnSearch because you'd - * use this when you want to force a reload when *everything* is the same, including search params. - * - * @returns {promise} A promise representing the state of the new transition. - * - * Possible success values: - * - * - $state.current - * - *
      Possible rejection values: - * - * - 'transition superseded' - when a newer transition has been started after this one - * - 'transition prevented' - when `event.preventDefault()` has been called in a `$stateChangeStart` listener - * - 'transition aborted' - when `event.preventDefault()` has been called in a `$stateNotFound` listener or - * when a `$stateNotFound` `event.retry` promise errors. - * - 'transition failed' - when a state has been unsuccessfully found after 2 tries. - * - *resolve error* - when an error has occurred with a `resolve` - * - */ - $state.go = function go(to, params, options) { - return $state.transitionTo(to, params, extend({ inherit: true, relative: $state.$current }, options)); - }; - - /** - * @ngdoc function - * @name ui.router.state.$state#transitionTo - * @methodOf ui.router.state.$state - * - * @description - * Low-level method for transitioning to a new state. {@link ui.router.state.$state#methods_go $state.go} - * uses `transitionTo` internally. `$state.go` is recommended in most situations. - * - * @example - *
      -       * var app = angular.module('app', ['ui.router']);
      -       *
      -       * app.controller('ctrl', function ($scope, $state) {
      -       *   $scope.changeState = function () {
      -       *     $state.transitionTo('contact.detail');
      -       *   };
      -       * });
      -       * 
      - * - * @param {string} to State name. - * @param {object=} toParams A map of the parameters that will be sent to the state, - * will populate $stateParams. - * @param {object=} options Options object. The options are: - * - * - **`location`** - {boolean=true|string=} - If `true` will update the url in the location bar, if `false` - * will not. If string, must be `"replace"`, which will update url and also replace last history record. - * - **`inherit`** - {boolean=false}, If `true` will inherit url parameters from current url. - * - **`relative`** - {object=}, When transitioning with relative path (e.g '^'), - * defines which state to be relative from. - * - **`notify`** - {boolean=true}, If `true` will broadcast $stateChangeStart and $stateChangeSuccess events. - * - **`reload`** (v0.2.5) - {boolean=false}, If `true` will force transition even if the state or params - * have not changed, aka a reload of the same state. It differs from reloadOnSearch because you'd - * use this when you want to force a reload when *everything* is the same, including search params. - * - * @returns {promise} A promise representing the state of the new transition. See - * {@link ui.router.state.$state#methods_go $state.go}. - */ - $state.transitionTo = function transitionTo(to, toParams, options) { - toParams = toParams || {}; - options = extend({ - location: true, inherit: false, relative: null, notify: true, reload: false, $retry: false - }, options || {}); - - var from = $state.$current, fromParams = $state.params, fromPath = from.path; - var evt, toState = findState(to, options.relative); - - if (!isDefined(toState)) { - var redirect = { to: to, toParams: toParams, options: options }; - var redirectResult = handleRedirect(redirect, from.self, fromParams, options); - - if (redirectResult) { - return redirectResult; - } - - // Always retry once if the $stateNotFound was not prevented - // (handles either redirect changed or state lazy-definition) - to = redirect.to; - toParams = redirect.toParams; - options = redirect.options; - toState = findState(to, options.relative); - - if (!isDefined(toState)) { - if (!options.relative) throw new Error("No such state '" + to + "'"); - throw new Error("Could not resolve '" + to + "' from state '" + options.relative + "'"); - } - } - if (toState[abstractKey]) throw new Error("Cannot transition to abstract state '" + to + "'"); - if (options.inherit) toParams = inheritParams($stateParams, toParams || {}, $state.$current, toState); - if (!toState.params.$$validates(toParams)) return TransitionFailed; - - toParams = toState.params.$$values(toParams); - to = toState; - - var toPath = to.path; - - // Starting from the root of the path, keep all levels that haven't changed - var keep = 0, state = toPath[keep], locals = root.locals, toLocals = []; - - if (!options.reload) { - while (state && state === fromPath[keep] && state.ownParams.$$equals(toParams, fromParams)) { - locals = toLocals[keep] = state.locals; - keep++; - state = toPath[keep]; - } - } - - // If we're going to the same state and all locals are kept, we've got nothing to do. - // But clear 'transition', as we still want to cancel any other pending transitions. - // TODO: We may not want to bump 'transition' if we're called from a location change - // that we've initiated ourselves, because we might accidentally abort a legitimate - // transition initiated from code? - if (shouldTriggerReload(to, from, locals, options)) { - if (to.self.reloadOnSearch !== false) $urlRouter.update(); - $state.transition = null; - return $q.when($state.current); - } - - // Filter parameters before we pass them to event handlers etc. - toParams = filterByKeys(to.params.$$keys(), toParams || {}); - - // Broadcast start event and cancel the transition if requested - if (options.notify) { - /** - * @ngdoc event - * @name ui.router.state.$state#$stateChangeStart - * @eventOf ui.router.state.$state - * @eventType broadcast on root scope - * @description - * Fired when the state transition **begins**. You can use `event.preventDefault()` - * to prevent the transition from happening and then the transition promise will be - * rejected with a `'transition prevented'` value. - * - * @param {Object} event Event object. - * @param {State} toState The state being transitioned to. - * @param {Object} toParams The params supplied to the `toState`. - * @param {State} fromState The current state, pre-transition. - * @param {Object} fromParams The params supplied to the `fromState`. - * - * @example - * - *
      -           * $rootScope.$on('$stateChangeStart',
      -           * function(event, toState, toParams, fromState, fromParams){
      -           *     event.preventDefault();
      -           *     // transitionTo() promise will be rejected with
      -           *     // a 'transition prevented' error
      -           * })
      -           * 
      - */ - if ($rootScope.$broadcast('$stateChangeStart', to.self, toParams, from.self, fromParams).defaultPrevented) { - $urlRouter.update(); - return TransitionPrevented; - } - } - - // Resolve locals for the remaining states, but don't update any global state just - // yet -- if anything fails to resolve the current state needs to remain untouched. - // We also set up an inheritance chain for the locals here. This allows the view directive - // to quickly look up the correct definition for each view in the current state. Even - // though we create the locals object itself outside resolveState(), it is initially - // empty and gets filled asynchronously. We need to keep track of the promise for the - // (fully resolved) current locals, and pass this down the chain. - var resolved = $q.when(locals); - - for (var l = keep; l < toPath.length; l++, state = toPath[l]) { - locals = toLocals[l] = inherit(locals); - resolved = resolveState(state, toParams, state === to, resolved, locals, options); - } - - // Once everything is resolved, we are ready to perform the actual transition - // and return a promise for the new state. We also keep track of what the - // current promise is, so that we can detect overlapping transitions and - // keep only the outcome of the last transition. - var transition = $state.transition = resolved.then(function () { - var l, entering, exiting; - - if ($state.transition !== transition) return TransitionSuperseded; - - // Exit 'from' states not kept - for (l = fromPath.length - 1; l >= keep; l--) { - exiting = fromPath[l]; - if (exiting.self.onExit) { - $injector.invoke(exiting.self.onExit, exiting.self, exiting.locals.globals); - } - exiting.locals = null; - } - - // Enter 'to' states not kept - for (l = keep; l < toPath.length; l++) { - entering = toPath[l]; - entering.locals = toLocals[l]; - if (entering.self.onEnter) { - $injector.invoke(entering.self.onEnter, entering.self, entering.locals.globals); - } - } - - // Run it again, to catch any transitions in callbacks - if ($state.transition !== transition) return TransitionSuperseded; - - // Update globals in $state - $state.$current = to; - $state.current = to.self; - $state.params = toParams; - copy($state.params, $stateParams); - $state.transition = null; - - if (options.location && to.navigable) { - $urlRouter.push(to.navigable.url, to.navigable.locals.globals.$stateParams, { - $$avoidResync: true, replace: options.location === 'replace' - }); - } - - if (options.notify) { - /** - * @ngdoc event - * @name ui.router.state.$state#$stateChangeSuccess - * @eventOf ui.router.state.$state - * @eventType broadcast on root scope - * @description - * Fired once the state transition is **complete**. - * - * @param {Object} event Event object. - * @param {State} toState The state being transitioned to. - * @param {Object} toParams The params supplied to the `toState`. - * @param {State} fromState The current state, pre-transition. - * @param {Object} fromParams The params supplied to the `fromState`. - */ - $rootScope.$broadcast('$stateChangeSuccess', to.self, toParams, from.self, fromParams); - } - $urlRouter.update(true); - - return $state.current; - }, function (error) { - if ($state.transition !== transition) return TransitionSuperseded; - - $state.transition = null; - /** - * @ngdoc event - * @name ui.router.state.$state#$stateChangeError - * @eventOf ui.router.state.$state - * @eventType broadcast on root scope - * @description - * Fired when an **error occurs** during transition. It's important to note that if you - * have any errors in your resolve functions (javascript errors, non-existent services, etc) - * they will not throw traditionally. You must listen for this $stateChangeError event to - * catch **ALL** errors. - * - * @param {Object} event Event object. - * @param {State} toState The state being transitioned to. - * @param {Object} toParams The params supplied to the `toState`. - * @param {State} fromState The current state, pre-transition. - * @param {Object} fromParams The params supplied to the `fromState`. - * @param {Error} error The resolve error object. - */ - evt = $rootScope.$broadcast('$stateChangeError', to.self, toParams, from.self, fromParams, error); - - if (!evt.defaultPrevented) { - $urlRouter.update(); - } - - return $q.reject(error); - }); - - return transition; - }; - - /** - * @ngdoc function - * @name ui.router.state.$state#is - * @methodOf ui.router.state.$state - * - * @description - * Similar to {@link ui.router.state.$state#methods_includes $state.includes}, - * but only checks for the full state name. If params is supplied then it will be - * tested for strict equality against the current active params object, so all params - * must match with none missing and no extras. - * - * @example - *
      -       * $state.$current.name = 'contacts.details.item';
      -       *
      -       * // absolute name
      -       * $state.is('contact.details.item'); // returns true
      -       * $state.is(contactDetailItemStateObject); // returns true
      -       *
      -       * // relative name (. and ^), typically from a template
      -       * // E.g. from the 'contacts.details' template
      -       * 
      Item
      - *
      - * - * @param {string|object} stateOrName The state name (absolute or relative) or state object you'd like to check. - * @param {object=} params A param object, e.g. `{sectionId: section.id}`, that you'd like - * to test against the current active state. - * @param {object=} options An options object. The options are: - * - * - **`relative`** - {string|object} - If `stateOrName` is a relative state name and `options.relative` is set, .is will - * test relative to `options.relative` state (or name). - * - * @returns {boolean} Returns true if it is the state. - */ - $state.is = function is(stateOrName, params, options) { - options = extend({ relative: $state.$current }, options || {}); - var state = findState(stateOrName, options.relative); - - if (!isDefined(state)) { return undefined; } - if ($state.$current !== state) { return false; } - return params ? equalForKeys(state.params.$$values(params), $stateParams) : true; - }; - - /** - * @ngdoc function - * @name ui.router.state.$state#includes - * @methodOf ui.router.state.$state - * - * @description - * A method to determine if the current active state is equal to or is the child of the - * state stateName. If any params are passed then they will be tested for a match as well. - * Not all the parameters need to be passed, just the ones you'd like to test for equality. - * - * @example - * Partial and relative names - *
      -       * $state.$current.name = 'contacts.details.item';
      -       *
      -       * // Using partial names
      -       * $state.includes("contacts"); // returns true
      -       * $state.includes("contacts.details"); // returns true
      -       * $state.includes("contacts.details.item"); // returns true
      -       * $state.includes("contacts.list"); // returns false
      -       * $state.includes("about"); // returns false
      -       *
      -       * // Using relative names (. and ^), typically from a template
      -       * // E.g. from the 'contacts.details' template
      -       * 
      Item
      - *
      - * - * Basic globbing patterns - *
      -       * $state.$current.name = 'contacts.details.item.url';
      -       *
      -       * $state.includes("*.details.*.*"); // returns true
      -       * $state.includes("*.details.**"); // returns true
      -       * $state.includes("**.item.**"); // returns true
      -       * $state.includes("*.details.item.url"); // returns true
      -       * $state.includes("*.details.*.url"); // returns true
      -       * $state.includes("*.details.*"); // returns false
      -       * $state.includes("item.**"); // returns false
      -       * 
      - * - * @param {string} stateOrName A partial name, relative name, or glob pattern - * to be searched for within the current state name. - * @param {object=} params A param object, e.g. `{sectionId: section.id}`, - * that you'd like to test against the current active state. - * @param {object=} options An options object. The options are: - * - * - **`relative`** - {string|object=} - If `stateOrName` is a relative state reference and `options.relative` is set, - * .includes will test relative to `options.relative` state (or name). - * - * @returns {boolean} Returns true if it does include the state - */ - $state.includes = function includes(stateOrName, params, options) { - options = extend({ relative: $state.$current }, options || {}); - if (isString(stateOrName) && isGlob(stateOrName)) { - if (!doesStateMatchGlob(stateOrName)) { - return false; - } - stateOrName = $state.$current.name; - } - - var state = findState(stateOrName, options.relative); - if (!isDefined(state)) { return undefined; } - if (!isDefined($state.$current.includes[state.name])) { return false; } - return params ? equalForKeys(state.params.$$values(params), $stateParams, objectKeys(params)) : true; - }; - - - /** - * @ngdoc function - * @name ui.router.state.$state#href - * @methodOf ui.router.state.$state - * - * @description - * A url generation method that returns the compiled url for the given state populated with the given params. - * - * @example - *
      -       * expect($state.href("about.person", { person: "bob" })).toEqual("/about/bob");
      -       * 
      - * - * @param {string|object} stateOrName The state name or state object you'd like to generate a url from. - * @param {object=} params An object of parameter values to fill the state's required parameters. - * @param {object=} options Options object. The options are: - * - * - **`lossy`** - {boolean=true} - If true, and if there is no url associated with the state provided in the - * first parameter, then the constructed href url will be built from the first navigable ancestor (aka - * ancestor with a valid url). - * - **`inherit`** - {boolean=true}, If `true` will inherit url parameters from current url. - * - **`relative`** - {object=$state.$current}, When transitioning with relative path (e.g '^'), - * defines which state to be relative from. - * - **`absolute`** - {boolean=false}, If true will generate an absolute url, e.g. "http://www.example.com/fullurl". - * - * @returns {string} compiled state url - */ - $state.href = function href(stateOrName, params, options) { - options = extend({ - lossy: true, - inherit: true, - absolute: false, - relative: $state.$current - }, options || {}); - - var state = findState(stateOrName, options.relative); - - if (!isDefined(state)) return null; - if (options.inherit) params = inheritParams($stateParams, params || {}, $state.$current, state); - - var nav = (state && options.lossy) ? state.navigable : state; - - if (!nav || nav.url === undefined || nav.url === null) { - return null; - } - return $urlRouter.href(nav.url, filterByKeys(state.params.$$keys(), params || {}), { - absolute: options.absolute - }); - }; - - /** - * @ngdoc function - * @name ui.router.state.$state#get - * @methodOf ui.router.state.$state - * - * @description - * Returns the state configuration object for any specific state or all states. - * - * @param {string|object=} stateOrName (absolute or relative) If provided, will only get the config for - * the requested state. If not provided, returns an array of ALL state configs. - * @param {string|object=} context When stateOrName is a relative state reference, the state will be retrieved relative to context. - * @returns {Object|Array} State configuration object or array of all objects. - */ - $state.get = function (stateOrName, context) { - if (arguments.length === 0) return map(objectKeys(states), function(name) { return states[name].self; }); - var state = findState(stateOrName, context || $state.$current); - return (state && state.self) ? state.self : null; - }; - - function resolveState(state, params, paramsAreFiltered, inherited, dst, options) { - // Make a restricted $stateParams with only the parameters that apply to this state if - // necessary. In addition to being available to the controller and onEnter/onExit callbacks, - // we also need $stateParams to be available for any $injector calls we make during the - // dependency resolution process. - var $stateParams = (paramsAreFiltered) ? params : filterByKeys(state.params.$$keys(), params); - var locals = { $stateParams: $stateParams }; - - // Resolve 'global' dependencies for the state, i.e. those not specific to a view. - // We're also including $stateParams in this; that way the parameters are restricted - // to the set that should be visible to the state, and are independent of when we update - // the global $state and $stateParams values. - dst.resolve = $resolve.resolve(state.resolve, locals, dst.resolve, state); - var promises = [dst.resolve.then(function (globals) { - dst.globals = globals; - })]; - if (inherited) promises.push(inherited); - - // Resolve template and dependencies for all views. - forEach(state.views, function (view, name) { - var injectables = (view.resolve && view.resolve !== state.resolve ? view.resolve : {}); - injectables.$template = [ function () { - return $view.load(name, { view: view, locals: locals, params: $stateParams, notify: options.notify }) || ''; - }]; - - promises.push($resolve.resolve(injectables, locals, dst.resolve, state).then(function (result) { - // References to the controller (only instantiated at link time) - if (isFunction(view.controllerProvider) || isArray(view.controllerProvider)) { - var injectLocals = angular.extend({}, injectables, locals); - result.$$controller = $injector.invoke(view.controllerProvider, null, injectLocals); - } else { - result.$$controller = view.controller; - } - // Provide access to the state itself for internal use - result.$$state = state; - result.$$controllerAs = view.controllerAs; - dst[name] = result; - })); - }); - - // Wait for all the promises and then return the activation object - return $q.all(promises).then(function (values) { - return dst; - }); - } - - return $state; - } - - function shouldTriggerReload(to, from, locals, options) { - if (to === from && ((locals === from.locals && !options.reload) || (to.self.reloadOnSearch === false))) { - return true; - } - } - } - - angular.module('ui.router.state') - .value('$stateParams', {}) - .provider('$state', $StateProvider); - - - $ViewProvider.$inject = []; - function $ViewProvider() { - - this.$get = $get; - /** - * @ngdoc object - * @name ui.router.state.$view - * - * @requires ui.router.util.$templateFactory - * @requires $rootScope - * - * @description - * - */ - $get.$inject = ['$rootScope', '$templateFactory']; - function $get( $rootScope, $templateFactory) { - return { - // $view.load('full.viewName', { template: ..., controller: ..., resolve: ..., async: false, params: ... }) - /** - * @ngdoc function - * @name ui.router.state.$view#load - * @methodOf ui.router.state.$view - * - * @description - * - * @param {string} name name - * @param {object} options option object. - */ - load: function load(name, options) { - var result, defaults = { - template: null, controller: null, view: null, locals: null, notify: true, async: true, params: {} - }; - options = extend(defaults, options); - - if (options.view) { - result = $templateFactory.fromConfig(options.view, options.params, options.locals); - } - if (result && options.notify) { - /** - * @ngdoc event - * @name ui.router.state.$state#$viewContentLoading - * @eventOf ui.router.state.$view - * @eventType broadcast on root scope - * @description - * - * Fired once the view **begins loading**, *before* the DOM is rendered. - * - * @param {Object} event Event object. - * @param {Object} viewConfig The view config properties (template, controller, etc). - * - * @example - * - *
      -           * $scope.$on('$viewContentLoading',
      -           * function(event, viewConfig){
      -           *     // Access to all the view config properties.
      -           *     // and one special property 'targetView'
      -           *     // viewConfig.targetView
      -           * });
      -           * 
      - */ - $rootScope.$broadcast('$viewContentLoading', options); - } - return result; - } - }; - } - } - - angular.module('ui.router.state').provider('$view', $ViewProvider); - - /** - * @ngdoc object - * @name ui.router.state.$uiViewScrollProvider - * - * @description - * Provider that returns the {@link ui.router.state.$uiViewScroll} service function. - */ - function $ViewScrollProvider() { - - var useAnchorScroll = false; - - /** - * @ngdoc function - * @name ui.router.state.$uiViewScrollProvider#useAnchorScroll - * @methodOf ui.router.state.$uiViewScrollProvider - * - * @description - * Reverts back to using the core [`$anchorScroll`](http://docs.angularjs.org/api/ng.$anchorScroll) service for - * scrolling based on the url anchor. - */ - this.useAnchorScroll = function () { - useAnchorScroll = true; - }; - - /** - * @ngdoc object - * @name ui.router.state.$uiViewScroll - * - * @requires $anchorScroll - * @requires $timeout - * - * @description - * When called with a jqLite element, it scrolls the element into view (after a - * `$timeout` so the DOM has time to refresh). - * - * If you prefer to rely on `$anchorScroll` to scroll the view to the anchor, - * this can be enabled by calling {@link ui.router.state.$uiViewScrollProvider#methods_useAnchorScroll `$uiViewScrollProvider.useAnchorScroll()`}. - */ - this.$get = ['$anchorScroll', '$timeout', function ($anchorScroll, $timeout) { - if (useAnchorScroll) { - return $anchorScroll; - } - - return function ($element) { - $timeout(function () { - $element[0].scrollIntoView(); - }, 0, false); - }; - }]; - } - - angular.module('ui.router.state').provider('$uiViewScroll', $ViewScrollProvider); - - /** - * @ngdoc directive - * @name ui.router.state.directive:ui-view - * - * @requires ui.router.state.$state - * @requires $compile - * @requires $controller - * @requires $injector - * @requires ui.router.state.$uiViewScroll - * @requires $document - * - * @restrict ECA - * - * @description - * The ui-view directive tells $state where to place your templates. - * - * @param {string=} name A view name. The name should be unique amongst the other views in the - * same state. You can have views of the same name that live in different states. - * - * @param {string=} autoscroll It allows you to set the scroll behavior of the browser window - * when a view is populated. By default, $anchorScroll is overridden by ui-router's custom scroll - * service, {@link ui.router.state.$uiViewScroll}. This custom service let's you - * scroll ui-view elements into view when they are populated during a state activation. - * - * *Note: To revert back to old [`$anchorScroll`](http://docs.angularjs.org/api/ng.$anchorScroll) - * functionality, call `$uiViewScrollProvider.useAnchorScroll()`.* - * - * @param {string=} onload Expression to evaluate whenever the view updates. - * - * @example - * A view can be unnamed or named. - *
      -   * 
      -   * 
      - * - * - *
      - *
      - * - * You can only have one unnamed view within any template (or root html). If you are only using a - * single view and it is unnamed then you can populate it like so: - *
      -   * 
      - * $stateProvider.state("home", { - * template: "

      HELLO!

      " - * }) - *
      - * - * The above is a convenient shortcut equivalent to specifying your view explicitly with the {@link ui.router.state.$stateProvider#views `views`} - * config property, by name, in this case an empty name: - *
      -   * $stateProvider.state("home", {
      -   *   views: {
      -   *     "": {
      -   *       template: "

      HELLO!

      " - * } - * } - * }) - *
      - * - * But typically you'll only use the views property if you name your view or have more than one view - * in the same template. There's not really a compelling reason to name a view if its the only one, - * but you could if you wanted, like so: - *
      -   * 
      - *
      - *
      -   * $stateProvider.state("home", {
      -   *   views: {
      -   *     "main": {
      -   *       template: "

      HELLO!

      " - * } - * } - * }) - *
      - * - * Really though, you'll use views to set up multiple views: - *
      -   * 
      - *
      - *
      - *
      - * - *
      -   * $stateProvider.state("home", {
      -   *   views: {
      -   *     "": {
      -   *       template: "

      HELLO!

      " - * }, - * "chart": { - * template: "" - * }, - * "data": { - * template: "" - * } - * } - * }) - *
      - * - * Examples for `autoscroll`: - * - *
      -   * 
      -   * 
      -   *
      -   * 
      -   * 
      -   * 
      -   * 
      -   * 
      - */ - $ViewDirective.$inject = ['$state', '$injector', '$uiViewScroll', '$interpolate']; - function $ViewDirective( $state, $injector, $uiViewScroll, $interpolate) { - - function getService() { - return ($injector.has) ? function(service) { - return $injector.has(service) ? $injector.get(service) : null; - } : function(service) { - try { - return $injector.get(service); - } catch (e) { - return null; - } - }; - } - - var service = getService(), - $animator = service('$animator'), - $animate = service('$animate'); - - // Returns a set of DOM manipulation functions based on which Angular version - // it should use - function getRenderer(attrs, scope) { - var statics = function() { - return { - enter: function (element, target, cb) { target.after(element); cb(); }, - leave: function (element, cb) { element.remove(); cb(); } - }; - }; - - if ($animate) { - return { - enter: function(element, target, cb) { - var promise = $animate.enter(element, null, target, cb); - if (promise && promise.then) promise.then(cb); - }, - leave: function(element, cb) { - var promise = $animate.leave(element, cb); - if (promise && promise.then) promise.then(cb); - } - }; - } - - if ($animator) { - var animate = $animator && $animator(scope, attrs); - - return { - enter: function(element, target, cb) {animate.enter(element, null, target); cb(); }, - leave: function(element, cb) { animate.leave(element); cb(); } - }; - } - - return statics(); - } - - var directive = { - restrict: 'ECA', - terminal: true, - priority: 400, - transclude: 'element', - compile: function (tElement, tAttrs, $transclude) { - return function (scope, $element, attrs) { - var previousEl, currentEl, currentScope, latestLocals, - onloadExp = attrs.onload || '', - autoScrollExp = attrs.autoscroll, - renderer = getRenderer(attrs, scope); - - scope.$on('$stateChangeSuccess', function() { - updateView(false); - }); - scope.$on('$viewContentLoading', function() { - updateView(false); - }); - - updateView(true); - - function cleanupLastView() { - if (previousEl) { - previousEl.remove(); - previousEl = null; - } - - if (currentScope) { - currentScope.$destroy(); - currentScope = null; - } - - if (currentEl) { - renderer.leave(currentEl, function() { - previousEl = null; - }); - - previousEl = currentEl; - currentEl = null; - } - } - - function updateView(firstTime) { - var newScope, - name = getUiViewName(scope, attrs, $element, $interpolate), - previousLocals = name && $state.$current && $state.$current.locals[name]; - - if (!firstTime && previousLocals === latestLocals) return; // nothing to do - newScope = scope.$new(); - latestLocals = $state.$current.locals[name]; - - var clone = $transclude(newScope, function(clone) { - renderer.enter(clone, $element, function onUiViewEnter() { - if(currentScope) { - currentScope.$emit('$viewContentAnimationEnded'); - } - - if (angular.isDefined(autoScrollExp) && !autoScrollExp || scope.$eval(autoScrollExp)) { - $uiViewScroll(clone); - } - }); - cleanupLastView(); - }); - - currentEl = clone; - currentScope = newScope; - /** - * @ngdoc event - * @name ui.router.state.directive:ui-view#$viewContentLoaded - * @eventOf ui.router.state.directive:ui-view - * @eventType emits on ui-view directive scope - * @description * - * Fired once the view is **loaded**, *after* the DOM is rendered. - * - * @param {Object} event Event object. - */ - currentScope.$emit('$viewContentLoaded'); - currentScope.$eval(onloadExp); - } - }; - } - }; - - return directive; - } - - $ViewDirectiveFill.$inject = ['$compile', '$controller', '$state', '$interpolate']; - function $ViewDirectiveFill ( $compile, $controller, $state, $interpolate) { - return { - restrict: 'ECA', - priority: -400, - compile: function (tElement) { - var initial = tElement.html(); - return function (scope, $element, attrs) { - var current = $state.$current, - name = getUiViewName(scope, attrs, $element, $interpolate), - locals = current && current.locals[name]; - - if (! locals) { - return; - } - - $element.data('$uiView', { name: name, state: locals.$$state }); - $element.html(locals.$template ? locals.$template : initial); - - var link = $compile($element.contents()); - - if (locals.$$controller) { - locals.$scope = scope; - var controller = $controller(locals.$$controller, locals); - if (locals.$$controllerAs) { - scope[locals.$$controllerAs] = controller; - } - $element.data('$ngControllerController', controller); - $element.children().data('$ngControllerController', controller); - } - - link(scope); - }; - } - }; - } - - /** - * Shared ui-view code for both directives: - * Given scope, element, and its attributes, return the view's name - */ - function getUiViewName(scope, attrs, element, $interpolate) { - var name = $interpolate(attrs.uiView || attrs.name || '')(scope); - var inherited = element.inheritedData('$uiView'); - return name.indexOf('@') >= 0 ? name : (name + '@' + (inherited ? inherited.state.name : '')); - } - - angular.module('ui.router.state').directive('uiView', $ViewDirective); - angular.module('ui.router.state').directive('uiView', $ViewDirectiveFill); - - function parseStateRef(ref, current) { - var preparsed = ref.match(/^\s*({[^}]*})\s*$/), parsed; - if (preparsed) ref = current + '(' + preparsed[1] + ')'; - parsed = ref.replace(/\n/g, " ").match(/^([^(]+?)\s*(\((.*)\))?$/); - if (!parsed || parsed.length !== 4) throw new Error("Invalid state ref '" + ref + "'"); - return { state: parsed[1], paramExpr: parsed[3] || null }; - } - - function stateContext(el) { - var stateData = el.parent().inheritedData('$uiView'); - - if (stateData && stateData.state && stateData.state.name) { - return stateData.state; - } - } - - /** - * @ngdoc directive - * @name ui.router.state.directive:ui-sref - * - * @requires ui.router.state.$state - * @requires $timeout - * - * @restrict A - * - * @description - * A directive that binds a link (`` tag) to a state. If the state has an associated - * URL, the directive will automatically generate & update the `href` attribute via - * the {@link ui.router.state.$state#methods_href $state.href()} method. Clicking - * the link will trigger a state transition with optional parameters. - * - * Also middle-clicking, right-clicking, and ctrl-clicking on the link will be - * handled natively by the browser. - * - * You can also use relative state paths within ui-sref, just like the relative - * paths passed to `$state.go()`. You just need to be aware that the path is relative - * to the state that the link lives in, in other words the state that loaded the - * template containing the link. - * - * You can specify options to pass to {@link ui.router.state.$state#go $state.go()} - * using the `ui-sref-opts` attribute. Options are restricted to `location`, `inherit`, - * and `reload`. - * - * @example - * Here's an example of how you'd use ui-sref and how it would compile. If you have the - * following template: - *
      -   * Home | About | Next page
      -   * 
      -   * 
      -   * 
      - * - * Then the compiled html would be (assuming Html5Mode is off and current state is contacts): - *
      -   * Home | About | Next page
      -   * 
      -   * 
        - *
      • - * Joe - *
      • - *
      • - * Alice - *
      • - *
      • - * Bob - *
      • - *
      - * - * Home - *
      - * - * @param {string} ui-sref 'stateName' can be any valid absolute or relative state - * @param {Object} ui-sref-opts options to pass to {@link ui.router.state.$state#go $state.go()} - */ - $StateRefDirective.$inject = ['$state', '$timeout']; - function $StateRefDirective($state, $timeout) { - var allowedOptions = ['location', 'inherit', 'reload']; - - return { - restrict: 'A', - require: ['?^uiSrefActive', '?^uiSrefActiveEq'], - link: function(scope, element, attrs, uiSrefActive) { - var ref = parseStateRef(attrs.uiSref, $state.current.name); - var params = null, url = null, base = stateContext(element) || $state.$current; - var newHref = null, isAnchor = element.prop("tagName") === "A"; - var isForm = element[0].nodeName === "FORM"; - var attr = isForm ? "action" : "href", nav = true; - - var options = { relative: base, inherit: true }; - var optionsOverride = scope.$eval(attrs.uiSrefOpts) || {}; - - angular.forEach(allowedOptions, function(option) { - if (option in optionsOverride) { - options[option] = optionsOverride[option]; - } - }); - - var update = function(newVal) { - if (newVal) params = angular.copy(newVal); - if (!nav) return; - - newHref = $state.href(ref.state, params, options); - - var activeDirective = uiSrefActive[1] || uiSrefActive[0]; - if (activeDirective) { - activeDirective.$$setStateInfo(ref.state, params); - } - if (newHref === null) { - nav = false; - return false; - } - attrs.$set(attr, newHref); - }; - - if (ref.paramExpr) { - scope.$watch(ref.paramExpr, function(newVal, oldVal) { - if (newVal !== params) update(newVal); - }, true); - params = angular.copy(scope.$eval(ref.paramExpr)); - } - update(); - - if (isForm) return; - - element.bind("click", function(e) { - var button = e.which || e.button; - if ( !(button > 1 || e.ctrlKey || e.metaKey || e.shiftKey || element.attr('target')) ) { - // HACK: This is to allow ng-clicks to be processed before the transition is initiated: - var transition = $timeout(function() { - $state.go(ref.state, params, options); - }); - e.preventDefault(); - - // if the state has no URL, ignore one preventDefault from the directive. - var ignorePreventDefaultCount = isAnchor && !newHref ? 1: 0; - e.preventDefault = function() { - if (ignorePreventDefaultCount-- <= 0) - $timeout.cancel(transition); - }; - } - }); - } - }; - } - - /** - * @ngdoc directive - * @name ui.router.state.directive:ui-sref-active - * - * @requires ui.router.state.$state - * @requires ui.router.state.$stateParams - * @requires $interpolate - * - * @restrict A - * - * @description - * A directive working alongside ui-sref to add classes to an element when the - * related ui-sref directive's state is active, and removing them when it is inactive. - * The primary use-case is to simplify the special appearance of navigation menus - * relying on `ui-sref`, by having the "active" state's menu button appear different, - * distinguishing it from the inactive menu items. - * - * ui-sref-active can live on the same element as ui-sref or on a parent element. The first - * ui-sref-active found at the same level or above the ui-sref will be used. - * - * Will activate when the ui-sref's target state or any child state is active. If you - * need to activate only when the ui-sref target state is active and *not* any of - * it's children, then you will use - * {@link ui.router.state.directive:ui-sref-active-eq ui-sref-active-eq} - * - * @example - * Given the following template: - *
      -   * 
      -   * 
      - * - * - * When the app state is "app.user" (or any children states), and contains the state parameter "user" with value "bilbobaggins", - * the resulting HTML will appear as (note the 'active' class): - *
      -   * 
      -   * 
      - * - * The class name is interpolated **once** during the directives link time (any further changes to the - * interpolated value are ignored). - * - * Multiple classes may be specified in a space-separated format: - *
      -   * 
        - *
      • - * link - *
      • - *
      - *
      - */ - - /** - * @ngdoc directive - * @name ui.router.state.directive:ui-sref-active-eq - * - * @requires ui.router.state.$state - * @requires ui.router.state.$stateParams - * @requires $interpolate - * - * @restrict A - * - * @description - * The same as {@link ui.router.state.directive:ui-sref-active ui-sref-active} but will only activate - * when the exact target state used in the `ui-sref` is active; no child states. - * - */ - $StateRefActiveDirective.$inject = ['$state', '$stateParams', '$interpolate']; - function $StateRefActiveDirective($state, $stateParams, $interpolate) { - return { - restrict: "A", - controller: ['$scope', '$element', '$attrs', function ($scope, $element, $attrs) { - var state, params, activeClass; - - // There probably isn't much point in $observing this - // uiSrefActive and uiSrefActiveEq share the same directive object with some - // slight difference in logic routing - activeClass = $interpolate($attrs.uiSrefActiveEq || $attrs.uiSrefActive || '', false)($scope); - - // Allow uiSref to communicate with uiSrefActive[Equals] - this.$$setStateInfo = function (newState, newParams) { - state = $state.get(newState, stateContext($element)); - params = newParams; - update(); - }; - - $scope.$on('$stateChangeSuccess', update); - - // Update route state - function update() { - if (isMatch()) { - $element.addClass(activeClass); - } else { - $element.removeClass(activeClass); - } - } - - function isMatch() { - if (typeof $attrs.uiSrefActiveEq !== 'undefined') { - return state && $state.is(state.name, params); - } else { - return state && $state.includes(state.name, params); - } - } - }] - }; - } - - angular.module('ui.router.state') - .directive('uiSref', $StateRefDirective) - .directive('uiSrefActive', $StateRefActiveDirective) - .directive('uiSrefActiveEq', $StateRefActiveDirective); - - /** - * @ngdoc filter - * @name ui.router.state.filter:isState - * - * @requires ui.router.state.$state - * - * @description - * Translates to {@link ui.router.state.$state#methods_is $state.is("stateName")}. - */ - $IsStateFilter.$inject = ['$state']; - function $IsStateFilter($state) { - var isFilter = function (state) { - return $state.is(state); - }; - isFilter.$stateful = true; - return isFilter; - } - - /** - * @ngdoc filter - * @name ui.router.state.filter:includedByState - * - * @requires ui.router.state.$state - * - * @description - * Translates to {@link ui.router.state.$state#methods_includes $state.includes('fullOrPartialStateName')}. - */ - $IncludedByStateFilter.$inject = ['$state']; - function $IncludedByStateFilter($state) { - var includesFilter = function (state) { - return $state.includes(state); - }; - includesFilter.$stateful = true; - return includesFilter; - } - - angular.module('ui.router.state') - .filter('isState', $IsStateFilter) - .filter('includedByState', $IncludedByStateFilter); - })(window, window.angular); diff --git a/www/manual_lib/ionic/.bower.json b/www/manual_lib/ionic/.bower.json deleted file mode 100644 index 21b915ce3..000000000 --- a/www/manual_lib/ionic/.bower.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "ionic", - "version": "1.3.3", - "codename": "germany", - "homepage": "https://github.com/driftyco/ionic", - "authors": [ - "Max Lynch ", - "Adam Bradley ", - "Ben Sperry " - ], - "description": "Advanced HTML5 hybrid mobile app development framework.", - "main": [ - "css/ionic.css", - "fonts/*", - "js/ionic.js", - "js/ionic-angular.js" - ], - "keywords": [ - "mobile", - "html5", - "ionic", - "cordova", - "phonegap", - "trigger", - "triggerio", - "angularjs", - "angular" - ], - "license": "MIT", - "private": false, - "dependencies": { - "angular": "1.5.3", - "angular-animate": "1.5.3", - "angular-sanitize": "1.5.3", - "angular-ui-router": "0.2.13" - }, - "_release": "1.3.3", - "_resolution": { - "type": "version", - "tag": "v1.3.3", - "commit": "fc606f21d09bbdc8df6467ec942d51e95afc8036" - }, - "_source": "https://github.com/driftyco/ionic-bower.git", - "_target": "1.3.3", - "_originalSource": "driftyco/ionic-bower" -} \ No newline at end of file diff --git a/www/manual_lib/ionic/README.md b/www/manual_lib/ionic/README.md deleted file mode 100644 index f750bd8c8..000000000 --- a/www/manual_lib/ionic/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# ionic-bower - -Bower repository for [Ionic Framework](http://github.com/driftyco/ionic) - -### Usage - -Include `js/ionic.bundle.js` to get ionic and all of its dependencies. - -Alternatively, include the individual ionic files with the dependencies separately. - -### Versions - -To install the latest stable version, `bower install driftyco/ionic-bower#v1.1.1` - -To install the latest nightly release, `bower install driftyco/ionic-bower#master` diff --git a/www/manual_lib/ionic/bower.json b/www/manual_lib/ionic/bower.json deleted file mode 100644 index 4c71a80df..000000000 --- a/www/manual_lib/ionic/bower.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "ionic", - "version": "1.3.3", - "codename": "germany", - "homepage": "https://github.com/driftyco/ionic", - "authors": [ - "Max Lynch ", - "Adam Bradley ", - "Ben Sperry " - ], - "description": "Advanced HTML5 hybrid mobile app development framework.", - "main": [ - "css/ionic.css", - "fonts/*", - "js/ionic.js", - "js/ionic-angular.js" - ], - "keywords": [ - "mobile", - "html5", - "ionic", - "cordova", - "phonegap", - "trigger", - "triggerio", - "angularjs", - "angular" - ], - "license": "MIT", - "private": false, - "dependencies": { - "angular": "1.5.3", - "angular-animate": "1.5.3", - "angular-sanitize": "1.5.3", - "angular-ui-router": "0.2.13" - } -} diff --git a/www/manual_lib/ionic/css/ionic.css b/www/manual_lib/ionic/css/ionic.css deleted file mode 100644 index f5921de24..000000000 --- a/www/manual_lib/ionic/css/ionic.css +++ /dev/null @@ -1,9813 +0,0 @@ -@charset "UTF-8"; -/*! - * Copyright 2015 Drifty Co. - * http://drifty.com/ - * - * Ionic, v1.3.3 - * A powerful HTML5 mobile app framework. - * http://ionicframework.com/ - * - * By @maxlynch, @benjsperry, @adamdbradley <3 - * - * Licensed under the MIT license. Please see LICENSE for more information. - * - */ -/*! - Ionicons, v2.0.1 - Created by Ben Sperry for the Ionic Framework, http://ionicons.com/ - https://twitter.com/benjsperry https://twitter.com/ionicframework - MIT License: https://github.com/driftyco/ionicons - - Android-style icons originally built by Google’s - Material Design Icons: https://github.com/google/material-design-icons - used under CC BY http://creativecommons.org/licenses/by/4.0/ - Modified icons to fit ionicon’s grid from original. -*/ -@font-face { - font-family: "Ionicons"; - src: url("../fonts/ionicons.eot?v=2.0.1"); - src: url("../fonts/ionicons.eot?v=2.0.1#iefix") format("embedded-opentype"), url("../fonts/ionicons.ttf?v=2.0.1") format("truetype"), url("../fonts/ionicons.woff?v=2.0.1") format("woff"), url("../fonts/ionicons.woff") format("woff"), url("../fonts/ionicons.svg?v=2.0.1#Ionicons") format("svg"); - font-weight: normal; - font-style: normal; } - -.ion, .ionicons, -.ion-alert:before, -.ion-alert-circled:before, -.ion-android-add:before, -.ion-android-add-circle:before, -.ion-android-alarm-clock:before, -.ion-android-alert:before, -.ion-android-apps:before, -.ion-android-archive:before, -.ion-android-arrow-back:before, -.ion-android-arrow-down:before, -.ion-android-arrow-dropdown:before, -.ion-android-arrow-dropdown-circle:before, -.ion-android-arrow-dropleft:before, -.ion-android-arrow-dropleft-circle:before, -.ion-android-arrow-dropright:before, -.ion-android-arrow-dropright-circle:before, -.ion-android-arrow-dropup:before, -.ion-android-arrow-dropup-circle:before, -.ion-android-arrow-forward:before, -.ion-android-arrow-up:before, -.ion-android-attach:before, -.ion-android-bar:before, -.ion-android-bicycle:before, -.ion-android-boat:before, -.ion-android-bookmark:before, -.ion-android-bulb:before, -.ion-android-bus:before, -.ion-android-calendar:before, -.ion-android-call:before, -.ion-android-camera:before, -.ion-android-cancel:before, -.ion-android-car:before, -.ion-android-cart:before, -.ion-android-chat:before, -.ion-android-checkbox:before, -.ion-android-checkbox-blank:before, -.ion-android-checkbox-outline:before, -.ion-android-checkbox-outline-blank:before, -.ion-android-checkmark-circle:before, -.ion-android-clipboard:before, -.ion-android-close:before, -.ion-android-cloud:before, -.ion-android-cloud-circle:before, -.ion-android-cloud-done:before, -.ion-android-cloud-outline:before, -.ion-android-color-palette:before, -.ion-android-compass:before, -.ion-android-contact:before, -.ion-android-contacts:before, -.ion-android-contract:before, -.ion-android-create:before, -.ion-android-delete:before, -.ion-android-desktop:before, -.ion-android-document:before, -.ion-android-done:before, -.ion-android-done-all:before, -.ion-android-download:before, -.ion-android-drafts:before, -.ion-android-exit:before, -.ion-android-expand:before, -.ion-android-favorite:before, -.ion-android-favorite-outline:before, -.ion-android-film:before, -.ion-android-folder:before, -.ion-android-folder-open:before, -.ion-android-funnel:before, -.ion-android-globe:before, -.ion-android-hand:before, -.ion-android-hangout:before, -.ion-android-happy:before, -.ion-android-home:before, -.ion-android-image:before, -.ion-android-laptop:before, -.ion-android-list:before, -.ion-android-locate:before, -.ion-android-lock:before, -.ion-android-mail:before, -.ion-android-map:before, -.ion-android-menu:before, -.ion-android-microphone:before, -.ion-android-microphone-off:before, -.ion-android-more-horizontal:before, -.ion-android-more-vertical:before, -.ion-android-navigate:before, -.ion-android-notifications:before, -.ion-android-notifications-none:before, -.ion-android-notifications-off:before, -.ion-android-open:before, -.ion-android-options:before, -.ion-android-people:before, -.ion-android-person:before, -.ion-android-person-add:before, -.ion-android-phone-landscape:before, -.ion-android-phone-portrait:before, -.ion-android-pin:before, -.ion-android-plane:before, -.ion-android-playstore:before, -.ion-android-print:before, -.ion-android-radio-button-off:before, -.ion-android-radio-button-on:before, -.ion-android-refresh:before, -.ion-android-remove:before, -.ion-android-remove-circle:before, -.ion-android-restaurant:before, -.ion-android-sad:before, -.ion-android-search:before, -.ion-android-send:before, -.ion-android-settings:before, -.ion-android-share:before, -.ion-android-share-alt:before, -.ion-android-star:before, -.ion-android-star-half:before, -.ion-android-star-outline:before, -.ion-android-stopwatch:before, -.ion-android-subway:before, -.ion-android-sunny:before, -.ion-android-sync:before, -.ion-android-textsms:before, -.ion-android-time:before, -.ion-android-train:before, -.ion-android-unlock:before, -.ion-android-upload:before, -.ion-android-volume-down:before, -.ion-android-volume-mute:before, -.ion-android-volume-off:before, -.ion-android-volume-up:before, -.ion-android-walk:before, -.ion-android-warning:before, -.ion-android-watch:before, -.ion-android-wifi:before, -.ion-aperture:before, -.ion-archive:before, -.ion-arrow-down-a:before, -.ion-arrow-down-b:before, -.ion-arrow-down-c:before, -.ion-arrow-expand:before, -.ion-arrow-graph-down-left:before, -.ion-arrow-graph-down-right:before, -.ion-arrow-graph-up-left:before, -.ion-arrow-graph-up-right:before, -.ion-arrow-left-a:before, -.ion-arrow-left-b:before, -.ion-arrow-left-c:before, -.ion-arrow-move:before, -.ion-arrow-resize:before, -.ion-arrow-return-left:before, -.ion-arrow-return-right:before, -.ion-arrow-right-a:before, -.ion-arrow-right-b:before, -.ion-arrow-right-c:before, -.ion-arrow-shrink:before, -.ion-arrow-swap:before, -.ion-arrow-up-a:before, -.ion-arrow-up-b:before, -.ion-arrow-up-c:before, -.ion-asterisk:before, -.ion-at:before, -.ion-backspace:before, -.ion-backspace-outline:before, -.ion-bag:before, -.ion-battery-charging:before, -.ion-battery-empty:before, -.ion-battery-full:before, -.ion-battery-half:before, -.ion-battery-low:before, -.ion-beaker:before, -.ion-beer:before, -.ion-bluetooth:before, -.ion-bonfire:before, -.ion-bookmark:before, -.ion-bowtie:before, -.ion-briefcase:before, -.ion-bug:before, -.ion-calculator:before, -.ion-calendar:before, -.ion-camera:before, -.ion-card:before, -.ion-cash:before, -.ion-chatbox:before, -.ion-chatbox-working:before, -.ion-chatboxes:before, -.ion-chatbubble:before, -.ion-chatbubble-working:before, -.ion-chatbubbles:before, -.ion-checkmark:before, -.ion-checkmark-circled:before, -.ion-checkmark-round:before, -.ion-chevron-down:before, -.ion-chevron-left:before, -.ion-chevron-right:before, -.ion-chevron-up:before, -.ion-clipboard:before, -.ion-clock:before, -.ion-close:before, -.ion-close-circled:before, -.ion-close-round:before, -.ion-closed-captioning:before, -.ion-cloud:before, -.ion-code:before, -.ion-code-download:before, -.ion-code-working:before, -.ion-coffee:before, -.ion-compass:before, -.ion-compose:before, -.ion-connection-bars:before, -.ion-contrast:before, -.ion-crop:before, -.ion-cube:before, -.ion-disc:before, -.ion-document:before, -.ion-document-text:before, -.ion-drag:before, -.ion-earth:before, -.ion-easel:before, -.ion-edit:before, -.ion-egg:before, -.ion-eject:before, -.ion-email:before, -.ion-email-unread:before, -.ion-erlenmeyer-flask:before, -.ion-erlenmeyer-flask-bubbles:before, -.ion-eye:before, -.ion-eye-disabled:before, -.ion-female:before, -.ion-filing:before, -.ion-film-marker:before, -.ion-fireball:before, -.ion-flag:before, -.ion-flame:before, -.ion-flash:before, -.ion-flash-off:before, -.ion-folder:before, -.ion-fork:before, -.ion-fork-repo:before, -.ion-forward:before, -.ion-funnel:before, -.ion-gear-a:before, -.ion-gear-b:before, -.ion-grid:before, -.ion-hammer:before, -.ion-happy:before, -.ion-happy-outline:before, -.ion-headphone:before, -.ion-heart:before, -.ion-heart-broken:before, -.ion-help:before, -.ion-help-buoy:before, -.ion-help-circled:before, -.ion-home:before, -.ion-icecream:before, -.ion-image:before, -.ion-images:before, -.ion-information:before, -.ion-information-circled:before, -.ion-ionic:before, -.ion-ios-alarm:before, -.ion-ios-alarm-outline:before, -.ion-ios-albums:before, -.ion-ios-albums-outline:before, -.ion-ios-americanfootball:before, -.ion-ios-americanfootball-outline:before, -.ion-ios-analytics:before, -.ion-ios-analytics-outline:before, -.ion-ios-arrow-back:before, -.ion-ios-arrow-down:before, -.ion-ios-arrow-forward:before, -.ion-ios-arrow-left:before, -.ion-ios-arrow-right:before, -.ion-ios-arrow-thin-down:before, -.ion-ios-arrow-thin-left:before, -.ion-ios-arrow-thin-right:before, -.ion-ios-arrow-thin-up:before, -.ion-ios-arrow-up:before, -.ion-ios-at:before, -.ion-ios-at-outline:before, -.ion-ios-barcode:before, -.ion-ios-barcode-outline:before, -.ion-ios-baseball:before, -.ion-ios-baseball-outline:before, -.ion-ios-basketball:before, -.ion-ios-basketball-outline:before, -.ion-ios-bell:before, -.ion-ios-bell-outline:before, -.ion-ios-body:before, -.ion-ios-body-outline:before, -.ion-ios-bolt:before, -.ion-ios-bolt-outline:before, -.ion-ios-book:before, -.ion-ios-book-outline:before, -.ion-ios-bookmarks:before, -.ion-ios-bookmarks-outline:before, -.ion-ios-box:before, -.ion-ios-box-outline:before, -.ion-ios-briefcase:before, -.ion-ios-briefcase-outline:before, -.ion-ios-browsers:before, -.ion-ios-browsers-outline:before, -.ion-ios-calculator:before, -.ion-ios-calculator-outline:before, -.ion-ios-calendar:before, -.ion-ios-calendar-outline:before, -.ion-ios-camera:before, -.ion-ios-camera-outline:before, -.ion-ios-cart:before, -.ion-ios-cart-outline:before, -.ion-ios-chatboxes:before, -.ion-ios-chatboxes-outline:before, -.ion-ios-chatbubble:before, -.ion-ios-chatbubble-outline:before, -.ion-ios-checkmark:before, -.ion-ios-checkmark-empty:before, -.ion-ios-checkmark-outline:before, -.ion-ios-circle-filled:before, -.ion-ios-circle-outline:before, -.ion-ios-clock:before, -.ion-ios-clock-outline:before, -.ion-ios-close:before, -.ion-ios-close-empty:before, -.ion-ios-close-outline:before, -.ion-ios-cloud:before, -.ion-ios-cloud-download:before, -.ion-ios-cloud-download-outline:before, -.ion-ios-cloud-outline:before, -.ion-ios-cloud-upload:before, -.ion-ios-cloud-upload-outline:before, -.ion-ios-cloudy:before, -.ion-ios-cloudy-night:before, -.ion-ios-cloudy-night-outline:before, -.ion-ios-cloudy-outline:before, -.ion-ios-cog:before, -.ion-ios-cog-outline:before, -.ion-ios-color-filter:before, -.ion-ios-color-filter-outline:before, -.ion-ios-color-wand:before, -.ion-ios-color-wand-outline:before, -.ion-ios-compose:before, -.ion-ios-compose-outline:before, -.ion-ios-contact:before, -.ion-ios-contact-outline:before, -.ion-ios-copy:before, -.ion-ios-copy-outline:before, -.ion-ios-crop:before, -.ion-ios-crop-strong:before, -.ion-ios-download:before, -.ion-ios-download-outline:before, -.ion-ios-drag:before, -.ion-ios-email:before, -.ion-ios-email-outline:before, -.ion-ios-eye:before, -.ion-ios-eye-outline:before, -.ion-ios-fastforward:before, -.ion-ios-fastforward-outline:before, -.ion-ios-filing:before, -.ion-ios-filing-outline:before, -.ion-ios-film:before, -.ion-ios-film-outline:before, -.ion-ios-flag:before, -.ion-ios-flag-outline:before, -.ion-ios-flame:before, -.ion-ios-flame-outline:before, -.ion-ios-flask:before, -.ion-ios-flask-outline:before, -.ion-ios-flower:before, -.ion-ios-flower-outline:before, -.ion-ios-folder:before, -.ion-ios-folder-outline:before, -.ion-ios-football:before, -.ion-ios-football-outline:before, -.ion-ios-game-controller-a:before, -.ion-ios-game-controller-a-outline:before, -.ion-ios-game-controller-b:before, -.ion-ios-game-controller-b-outline:before, -.ion-ios-gear:before, -.ion-ios-gear-outline:before, -.ion-ios-glasses:before, -.ion-ios-glasses-outline:before, -.ion-ios-grid-view:before, -.ion-ios-grid-view-outline:before, -.ion-ios-heart:before, -.ion-ios-heart-outline:before, -.ion-ios-help:before, -.ion-ios-help-empty:before, -.ion-ios-help-outline:before, -.ion-ios-home:before, -.ion-ios-home-outline:before, -.ion-ios-infinite:before, -.ion-ios-infinite-outline:before, -.ion-ios-information:before, -.ion-ios-information-empty:before, -.ion-ios-information-outline:before, -.ion-ios-ionic-outline:before, -.ion-ios-keypad:before, -.ion-ios-keypad-outline:before, -.ion-ios-lightbulb:before, -.ion-ios-lightbulb-outline:before, -.ion-ios-list:before, -.ion-ios-list-outline:before, -.ion-ios-location:before, -.ion-ios-location-outline:before, -.ion-ios-locked:before, -.ion-ios-locked-outline:before, -.ion-ios-loop:before, -.ion-ios-loop-strong:before, -.ion-ios-medical:before, -.ion-ios-medical-outline:before, -.ion-ios-medkit:before, -.ion-ios-medkit-outline:before, -.ion-ios-mic:before, -.ion-ios-mic-off:before, -.ion-ios-mic-outline:before, -.ion-ios-minus:before, -.ion-ios-minus-empty:before, -.ion-ios-minus-outline:before, -.ion-ios-monitor:before, -.ion-ios-monitor-outline:before, -.ion-ios-moon:before, -.ion-ios-moon-outline:before, -.ion-ios-more:before, -.ion-ios-more-outline:before, -.ion-ios-musical-note:before, -.ion-ios-musical-notes:before, -.ion-ios-navigate:before, -.ion-ios-navigate-outline:before, -.ion-ios-nutrition:before, -.ion-ios-nutrition-outline:before, -.ion-ios-paper:before, -.ion-ios-paper-outline:before, -.ion-ios-paperplane:before, -.ion-ios-paperplane-outline:before, -.ion-ios-partlysunny:before, -.ion-ios-partlysunny-outline:before, -.ion-ios-pause:before, -.ion-ios-pause-outline:before, -.ion-ios-paw:before, -.ion-ios-paw-outline:before, -.ion-ios-people:before, -.ion-ios-people-outline:before, -.ion-ios-person:before, -.ion-ios-person-outline:before, -.ion-ios-personadd:before, -.ion-ios-personadd-outline:before, -.ion-ios-photos:before, -.ion-ios-photos-outline:before, -.ion-ios-pie:before, -.ion-ios-pie-outline:before, -.ion-ios-pint:before, -.ion-ios-pint-outline:before, -.ion-ios-play:before, -.ion-ios-play-outline:before, -.ion-ios-plus:before, -.ion-ios-plus-empty:before, -.ion-ios-plus-outline:before, -.ion-ios-pricetag:before, -.ion-ios-pricetag-outline:before, -.ion-ios-pricetags:before, -.ion-ios-pricetags-outline:before, -.ion-ios-printer:before, -.ion-ios-printer-outline:before, -.ion-ios-pulse:before, -.ion-ios-pulse-strong:before, -.ion-ios-rainy:before, -.ion-ios-rainy-outline:before, -.ion-ios-recording:before, -.ion-ios-recording-outline:before, -.ion-ios-redo:before, -.ion-ios-redo-outline:before, -.ion-ios-refresh:before, -.ion-ios-refresh-empty:before, -.ion-ios-refresh-outline:before, -.ion-ios-reload:before, -.ion-ios-reverse-camera:before, -.ion-ios-reverse-camera-outline:before, -.ion-ios-rewind:before, -.ion-ios-rewind-outline:before, -.ion-ios-rose:before, -.ion-ios-rose-outline:before, -.ion-ios-search:before, -.ion-ios-search-strong:before, -.ion-ios-settings:before, -.ion-ios-settings-strong:before, -.ion-ios-shuffle:before, -.ion-ios-shuffle-strong:before, -.ion-ios-skipbackward:before, -.ion-ios-skipbackward-outline:before, -.ion-ios-skipforward:before, -.ion-ios-skipforward-outline:before, -.ion-ios-snowy:before, -.ion-ios-speedometer:before, -.ion-ios-speedometer-outline:before, -.ion-ios-star:before, -.ion-ios-star-half:before, -.ion-ios-star-outline:before, -.ion-ios-stopwatch:before, -.ion-ios-stopwatch-outline:before, -.ion-ios-sunny:before, -.ion-ios-sunny-outline:before, -.ion-ios-telephone:before, -.ion-ios-telephone-outline:before, -.ion-ios-tennisball:before, -.ion-ios-tennisball-outline:before, -.ion-ios-thunderstorm:before, -.ion-ios-thunderstorm-outline:before, -.ion-ios-time:before, -.ion-ios-time-outline:before, -.ion-ios-timer:before, -.ion-ios-timer-outline:before, -.ion-ios-toggle:before, -.ion-ios-toggle-outline:before, -.ion-ios-trash:before, -.ion-ios-trash-outline:before, -.ion-ios-undo:before, -.ion-ios-undo-outline:before, -.ion-ios-unlocked:before, -.ion-ios-unlocked-outline:before, -.ion-ios-upload:before, -.ion-ios-upload-outline:before, -.ion-ios-videocam:before, -.ion-ios-videocam-outline:before, -.ion-ios-volume-high:before, -.ion-ios-volume-low:before, -.ion-ios-wineglass:before, -.ion-ios-wineglass-outline:before, -.ion-ios-world:before, -.ion-ios-world-outline:before, -.ion-ipad:before, -.ion-iphone:before, -.ion-ipod:before, -.ion-jet:before, -.ion-key:before, -.ion-knife:before, -.ion-laptop:before, -.ion-leaf:before, -.ion-levels:before, -.ion-lightbulb:before, -.ion-link:before, -.ion-load-a:before, -.ion-load-b:before, -.ion-load-c:before, -.ion-load-d:before, -.ion-location:before, -.ion-lock-combination:before, -.ion-locked:before, -.ion-log-in:before, -.ion-log-out:before, -.ion-loop:before, -.ion-magnet:before, -.ion-male:before, -.ion-man:before, -.ion-map:before, -.ion-medkit:before, -.ion-merge:before, -.ion-mic-a:before, -.ion-mic-b:before, -.ion-mic-c:before, -.ion-minus:before, -.ion-minus-circled:before, -.ion-minus-round:before, -.ion-model-s:before, -.ion-monitor:before, -.ion-more:before, -.ion-mouse:before, -.ion-music-note:before, -.ion-navicon:before, -.ion-navicon-round:before, -.ion-navigate:before, -.ion-network:before, -.ion-no-smoking:before, -.ion-nuclear:before, -.ion-outlet:before, -.ion-paintbrush:before, -.ion-paintbucket:before, -.ion-paper-airplane:before, -.ion-paperclip:before, -.ion-pause:before, -.ion-person:before, -.ion-person-add:before, -.ion-person-stalker:before, -.ion-pie-graph:before, -.ion-pin:before, -.ion-pinpoint:before, -.ion-pizza:before, -.ion-plane:before, -.ion-planet:before, -.ion-play:before, -.ion-playstation:before, -.ion-plus:before, -.ion-plus-circled:before, -.ion-plus-round:before, -.ion-podium:before, -.ion-pound:before, -.ion-power:before, -.ion-pricetag:before, -.ion-pricetags:before, -.ion-printer:before, -.ion-pull-request:before, -.ion-qr-scanner:before, -.ion-quote:before, -.ion-radio-waves:before, -.ion-record:before, -.ion-refresh:before, -.ion-reply:before, -.ion-reply-all:before, -.ion-ribbon-a:before, -.ion-ribbon-b:before, -.ion-sad:before, -.ion-sad-outline:before, -.ion-scissors:before, -.ion-search:before, -.ion-settings:before, -.ion-share:before, -.ion-shuffle:before, -.ion-skip-backward:before, -.ion-skip-forward:before, -.ion-social-android:before, -.ion-social-android-outline:before, -.ion-social-angular:before, -.ion-social-angular-outline:before, -.ion-social-apple:before, -.ion-social-apple-outline:before, -.ion-social-bitcoin:before, -.ion-social-bitcoin-outline:before, -.ion-social-buffer:before, -.ion-social-buffer-outline:before, -.ion-social-chrome:before, -.ion-social-chrome-outline:before, -.ion-social-codepen:before, -.ion-social-codepen-outline:before, -.ion-social-css3:before, -.ion-social-css3-outline:before, -.ion-social-designernews:before, -.ion-social-designernews-outline:before, -.ion-social-dribbble:before, -.ion-social-dribbble-outline:before, -.ion-social-dropbox:before, -.ion-social-dropbox-outline:before, -.ion-social-euro:before, -.ion-social-euro-outline:before, -.ion-social-facebook:before, -.ion-social-facebook-outline:before, -.ion-social-foursquare:before, -.ion-social-foursquare-outline:before, -.ion-social-freebsd-devil:before, -.ion-social-github:before, -.ion-social-github-outline:before, -.ion-social-google:before, -.ion-social-google-outline:before, -.ion-social-googleplus:before, -.ion-social-googleplus-outline:before, -.ion-social-hackernews:before, -.ion-social-hackernews-outline:before, -.ion-social-html5:before, -.ion-social-html5-outline:before, -.ion-social-instagram:before, -.ion-social-instagram-outline:before, -.ion-social-javascript:before, -.ion-social-javascript-outline:before, -.ion-social-linkedin:before, -.ion-social-linkedin-outline:before, -.ion-social-markdown:before, -.ion-social-nodejs:before, -.ion-social-octocat:before, -.ion-social-pinterest:before, -.ion-social-pinterest-outline:before, -.ion-social-python:before, -.ion-social-reddit:before, -.ion-social-reddit-outline:before, -.ion-social-rss:before, -.ion-social-rss-outline:before, -.ion-social-sass:before, -.ion-social-skype:before, -.ion-social-skype-outline:before, -.ion-social-snapchat:before, -.ion-social-snapchat-outline:before, -.ion-social-tumblr:before, -.ion-social-tumblr-outline:before, -.ion-social-tux:before, -.ion-social-twitch:before, -.ion-social-twitch-outline:before, -.ion-social-twitter:before, -.ion-social-twitter-outline:before, -.ion-social-usd:before, -.ion-social-usd-outline:before, -.ion-social-vimeo:before, -.ion-social-vimeo-outline:before, -.ion-social-whatsapp:before, -.ion-social-whatsapp-outline:before, -.ion-social-windows:before, -.ion-social-windows-outline:before, -.ion-social-wordpress:before, -.ion-social-wordpress-outline:before, -.ion-social-yahoo:before, -.ion-social-yahoo-outline:before, -.ion-social-yen:before, -.ion-social-yen-outline:before, -.ion-social-youtube:before, -.ion-social-youtube-outline:before, -.ion-soup-can:before, -.ion-soup-can-outline:before, -.ion-speakerphone:before, -.ion-speedometer:before, -.ion-spoon:before, -.ion-star:before, -.ion-stats-bars:before, -.ion-steam:before, -.ion-stop:before, -.ion-thermometer:before, -.ion-thumbsdown:before, -.ion-thumbsup:before, -.ion-toggle:before, -.ion-toggle-filled:before, -.ion-transgender:before, -.ion-trash-a:before, -.ion-trash-b:before, -.ion-trophy:before, -.ion-tshirt:before, -.ion-tshirt-outline:before, -.ion-umbrella:before, -.ion-university:before, -.ion-unlocked:before, -.ion-upload:before, -.ion-usb:before, -.ion-videocamera:before, -.ion-volume-high:before, -.ion-volume-low:before, -.ion-volume-medium:before, -.ion-volume-mute:before, -.ion-wand:before, -.ion-waterdrop:before, -.ion-wifi:before, -.ion-wineglass:before, -.ion-woman:before, -.ion-wrench:before, -.ion-xbox:before { - display: inline-block; - font-family: "Ionicons"; - speak: none; - font-style: normal; - font-weight: normal; - font-variant: normal; - text-transform: none; - text-rendering: auto; - line-height: 1; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; } - -.ion-alert:before { - content: ""; } - -.ion-alert-circled:before { - content: ""; } - -.ion-android-add:before { - content: ""; } - -.ion-android-add-circle:before { - content: ""; } - -.ion-android-alarm-clock:before { - content: ""; } - -.ion-android-alert:before { - content: ""; } - -.ion-android-apps:before { - content: ""; } - -.ion-android-archive:before { - content: ""; } - -.ion-android-arrow-back:before { - content: ""; } - -.ion-android-arrow-down:before { - content: ""; } - -.ion-android-arrow-dropdown:before { - content: ""; } - -.ion-android-arrow-dropdown-circle:before { - content: ""; } - -.ion-android-arrow-dropleft:before { - content: ""; } - -.ion-android-arrow-dropleft-circle:before { - content: ""; } - -.ion-android-arrow-dropright:before { - content: ""; } - -.ion-android-arrow-dropright-circle:before { - content: ""; } - -.ion-android-arrow-dropup:before { - content: ""; } - -.ion-android-arrow-dropup-circle:before { - content: ""; } - -.ion-android-arrow-forward:before { - content: ""; } - -.ion-android-arrow-up:before { - content: ""; } - -.ion-android-attach:before { - content: ""; } - -.ion-android-bar:before { - content: ""; } - -.ion-android-bicycle:before { - content: ""; } - -.ion-android-boat:before { - content: ""; } - -.ion-android-bookmark:before { - content: ""; } - -.ion-android-bulb:before { - content: ""; } - -.ion-android-bus:before { - content: ""; } - -.ion-android-calendar:before { - content: ""; } - -.ion-android-call:before { - content: ""; } - -.ion-android-camera:before { - content: ""; } - -.ion-android-cancel:before { - content: ""; } - -.ion-android-car:before { - content: ""; } - -.ion-android-cart:before { - content: ""; } - -.ion-android-chat:before { - content: ""; } - -.ion-android-checkbox:before { - content: ""; } - -.ion-android-checkbox-blank:before { - content: ""; } - -.ion-android-checkbox-outline:before { - content: ""; } - -.ion-android-checkbox-outline-blank:before { - content: ""; } - -.ion-android-checkmark-circle:before { - content: ""; } - -.ion-android-clipboard:before { - content: ""; } - -.ion-android-close:before { - content: ""; } - -.ion-android-cloud:before { - content: ""; } - -.ion-android-cloud-circle:before { - content: ""; } - -.ion-android-cloud-done:before { - content: ""; } - -.ion-android-cloud-outline:before { - content: ""; } - -.ion-android-color-palette:before { - content: ""; } - -.ion-android-compass:before { - content: ""; } - -.ion-android-contact:before { - content: ""; } - -.ion-android-contacts:before { - content: ""; } - -.ion-android-contract:before { - content: ""; } - -.ion-android-create:before { - content: ""; } - -.ion-android-delete:before { - content: ""; } - -.ion-android-desktop:before { - content: ""; } - -.ion-android-document:before { - content: ""; } - -.ion-android-done:before { - content: ""; } - -.ion-android-done-all:before { - content: ""; } - -.ion-android-download:before { - content: ""; } - -.ion-android-drafts:before { - content: ""; } - -.ion-android-exit:before { - content: ""; } - -.ion-android-expand:before { - content: ""; } - -.ion-android-favorite:before { - content: ""; } - -.ion-android-favorite-outline:before { - content: ""; } - -.ion-android-film:before { - content: ""; } - -.ion-android-folder:before { - content: ""; } - -.ion-android-folder-open:before { - content: ""; } - -.ion-android-funnel:before { - content: ""; } - -.ion-android-globe:before { - content: ""; } - -.ion-android-hand:before { - content: ""; } - -.ion-android-hangout:before { - content: ""; } - -.ion-android-happy:before { - content: ""; } - -.ion-android-home:before { - content: ""; } - -.ion-android-image:before { - content: ""; } - -.ion-android-laptop:before { - content: ""; } - -.ion-android-list:before { - content: ""; } - -.ion-android-locate:before { - content: ""; } - -.ion-android-lock:before { - content: ""; } - -.ion-android-mail:before { - content: ""; } - -.ion-android-map:before { - content: ""; } - -.ion-android-menu:before { - content: ""; } - -.ion-android-microphone:before { - content: ""; } - -.ion-android-microphone-off:before { - content: ""; } - -.ion-android-more-horizontal:before { - content: ""; } - -.ion-android-more-vertical:before { - content: ""; } - -.ion-android-navigate:before { - content: ""; } - -.ion-android-notifications:before { - content: ""; } - -.ion-android-notifications-none:before { - content: ""; } - -.ion-android-notifications-off:before { - content: ""; } - -.ion-android-open:before { - content: ""; } - -.ion-android-options:before { - content: ""; } - -.ion-android-people:before { - content: ""; } - -.ion-android-person:before { - content: ""; } - -.ion-android-person-add:before { - content: ""; } - -.ion-android-phone-landscape:before { - content: ""; } - -.ion-android-phone-portrait:before { - content: ""; } - -.ion-android-pin:before { - content: ""; } - -.ion-android-plane:before { - content: ""; } - -.ion-android-playstore:before { - content: ""; } - -.ion-android-print:before { - content: ""; } - -.ion-android-radio-button-off:before { - content: ""; } - -.ion-android-radio-button-on:before { - content: ""; } - -.ion-android-refresh:before { - content: ""; } - -.ion-android-remove:before { - content: ""; } - -.ion-android-remove-circle:before { - content: ""; } - -.ion-android-restaurant:before { - content: ""; } - -.ion-android-sad:before { - content: ""; } - -.ion-android-search:before { - content: ""; } - -.ion-android-send:before { - content: ""; } - -.ion-android-settings:before { - content: ""; } - -.ion-android-share:before { - content: ""; } - -.ion-android-share-alt:before { - content: ""; } - -.ion-android-star:before { - content: ""; } - -.ion-android-star-half:before { - content: ""; } - -.ion-android-star-outline:before { - content: ""; } - -.ion-android-stopwatch:before { - content: ""; } - -.ion-android-subway:before { - content: ""; } - -.ion-android-sunny:before { - content: ""; } - -.ion-android-sync:before { - content: ""; } - -.ion-android-textsms:before { - content: ""; } - -.ion-android-time:before { - content: ""; } - -.ion-android-train:before { - content: ""; } - -.ion-android-unlock:before { - content: ""; } - -.ion-android-upload:before { - content: ""; } - -.ion-android-volume-down:before { - content: ""; } - -.ion-android-volume-mute:before { - content: ""; } - -.ion-android-volume-off:before { - content: ""; } - -.ion-android-volume-up:before { - content: ""; } - -.ion-android-walk:before { - content: ""; } - -.ion-android-warning:before { - content: ""; } - -.ion-android-watch:before { - content: ""; } - -.ion-android-wifi:before { - content: ""; } - -.ion-aperture:before { - content: ""; } - -.ion-archive:before { - content: ""; } - -.ion-arrow-down-a:before { - content: ""; } - -.ion-arrow-down-b:before { - content: ""; } - -.ion-arrow-down-c:before { - content: ""; } - -.ion-arrow-expand:before { - content: ""; } - -.ion-arrow-graph-down-left:before { - content: ""; } - -.ion-arrow-graph-down-right:before { - content: ""; } - -.ion-arrow-graph-up-left:before { - content: ""; } - -.ion-arrow-graph-up-right:before { - content: ""; } - -.ion-arrow-left-a:before { - content: ""; } - -.ion-arrow-left-b:before { - content: ""; } - -.ion-arrow-left-c:before { - content: ""; } - -.ion-arrow-move:before { - content: ""; } - -.ion-arrow-resize:before { - content: ""; } - -.ion-arrow-return-left:before { - content: ""; } - -.ion-arrow-return-right:before { - content: ""; } - -.ion-arrow-right-a:before { - content: ""; } - -.ion-arrow-right-b:before { - content: ""; } - -.ion-arrow-right-c:before { - content: ""; } - -.ion-arrow-shrink:before { - content: ""; } - -.ion-arrow-swap:before { - content: ""; } - -.ion-arrow-up-a:before { - content: ""; } - -.ion-arrow-up-b:before { - content: ""; } - -.ion-arrow-up-c:before { - content: ""; } - -.ion-asterisk:before { - content: ""; } - -.ion-at:before { - content: ""; } - -.ion-backspace:before { - content: ""; } - -.ion-backspace-outline:before { - content: ""; } - -.ion-bag:before { - content: ""; } - -.ion-battery-charging:before { - content: ""; } - -.ion-battery-empty:before { - content: ""; } - -.ion-battery-full:before { - content: ""; } - -.ion-battery-half:before { - content: ""; } - -.ion-battery-low:before { - content: ""; } - -.ion-beaker:before { - content: ""; } - -.ion-beer:before { - content: ""; } - -.ion-bluetooth:before { - content: ""; } - -.ion-bonfire:before { - content: ""; } - -.ion-bookmark:before { - content: ""; } - -.ion-bowtie:before { - content: ""; } - -.ion-briefcase:before { - content: ""; } - -.ion-bug:before { - content: ""; } - -.ion-calculator:before { - content: ""; } - -.ion-calendar:before { - content: ""; } - -.ion-camera:before { - content: ""; } - -.ion-card:before { - content: ""; } - -.ion-cash:before { - content: ""; } - -.ion-chatbox:before { - content: ""; } - -.ion-chatbox-working:before { - content: ""; } - -.ion-chatboxes:before { - content: ""; } - -.ion-chatbubble:before { - content: ""; } - -.ion-chatbubble-working:before { - content: ""; } - -.ion-chatbubbles:before { - content: ""; } - -.ion-checkmark:before { - content: ""; } - -.ion-checkmark-circled:before { - content: ""; } - -.ion-checkmark-round:before { - content: ""; } - -.ion-chevron-down:before { - content: ""; } - -.ion-chevron-left:before { - content: ""; } - -.ion-chevron-right:before { - content: ""; } - -.ion-chevron-up:before { - content: ""; } - -.ion-clipboard:before { - content: ""; } - -.ion-clock:before { - content: ""; } - -.ion-close:before { - content: ""; } - -.ion-close-circled:before { - content: ""; } - -.ion-close-round:before { - content: ""; } - -.ion-closed-captioning:before { - content: ""; } - -.ion-cloud:before { - content: ""; } - -.ion-code:before { - content: ""; } - -.ion-code-download:before { - content: ""; } - -.ion-code-working:before { - content: ""; } - -.ion-coffee:before { - content: ""; } - -.ion-compass:before { - content: ""; } - -.ion-compose:before { - content: ""; } - -.ion-connection-bars:before { - content: ""; } - -.ion-contrast:before { - content: ""; } - -.ion-crop:before { - content: ""; } - -.ion-cube:before { - content: ""; } - -.ion-disc:before { - content: ""; } - -.ion-document:before { - content: ""; } - -.ion-document-text:before { - content: ""; } - -.ion-drag:before { - content: ""; } - -.ion-earth:before { - content: ""; } - -.ion-easel:before { - content: ""; } - -.ion-edit:before { - content: ""; } - -.ion-egg:before { - content: ""; } - -.ion-eject:before { - content: ""; } - -.ion-email:before { - content: ""; } - -.ion-email-unread:before { - content: ""; } - -.ion-erlenmeyer-flask:before { - content: ""; } - -.ion-erlenmeyer-flask-bubbles:before { - content: ""; } - -.ion-eye:before { - content: ""; } - -.ion-eye-disabled:before { - content: ""; } - -.ion-female:before { - content: ""; } - -.ion-filing:before { - content: ""; } - -.ion-film-marker:before { - content: ""; } - -.ion-fireball:before { - content: ""; } - -.ion-flag:before { - content: ""; } - -.ion-flame:before { - content: ""; } - -.ion-flash:before { - content: ""; } - -.ion-flash-off:before { - content: ""; } - -.ion-folder:before { - content: ""; } - -.ion-fork:before { - content: ""; } - -.ion-fork-repo:before { - content: ""; } - -.ion-forward:before { - content: ""; } - -.ion-funnel:before { - content: ""; } - -.ion-gear-a:before { - content: ""; } - -.ion-gear-b:before { - content: ""; } - -.ion-grid:before { - content: ""; } - -.ion-hammer:before { - content: ""; } - -.ion-happy:before { - content: ""; } - -.ion-happy-outline:before { - content: ""; } - -.ion-headphone:before { - content: ""; } - -.ion-heart:before { - content: ""; } - -.ion-heart-broken:before { - content: ""; } - -.ion-help:before { - content: ""; } - -.ion-help-buoy:before { - content: ""; } - -.ion-help-circled:before { - content: ""; } - -.ion-home:before { - content: ""; } - -.ion-icecream:before { - content: ""; } - -.ion-image:before { - content: ""; } - -.ion-images:before { - content: ""; } - -.ion-information:before { - content: ""; } - -.ion-information-circled:before { - content: ""; } - -.ion-ionic:before { - content: ""; } - -.ion-ios-alarm:before { - content: ""; } - -.ion-ios-alarm-outline:before { - content: ""; } - -.ion-ios-albums:before { - content: ""; } - -.ion-ios-albums-outline:before { - content: ""; } - -.ion-ios-americanfootball:before { - content: ""; } - -.ion-ios-americanfootball-outline:before { - content: ""; } - -.ion-ios-analytics:before { - content: ""; } - -.ion-ios-analytics-outline:before { - content: ""; } - -.ion-ios-arrow-back:before { - content: ""; } - -.ion-ios-arrow-down:before { - content: ""; } - -.ion-ios-arrow-forward:before { - content: ""; } - -.ion-ios-arrow-left:before { - content: ""; } - -.ion-ios-arrow-right:before { - content: ""; } - -.ion-ios-arrow-thin-down:before { - content: ""; } - -.ion-ios-arrow-thin-left:before { - content: ""; } - -.ion-ios-arrow-thin-right:before { - content: ""; } - -.ion-ios-arrow-thin-up:before { - content: ""; } - -.ion-ios-arrow-up:before { - content: ""; } - -.ion-ios-at:before { - content: ""; } - -.ion-ios-at-outline:before { - content: ""; } - -.ion-ios-barcode:before { - content: ""; } - -.ion-ios-barcode-outline:before { - content: ""; } - -.ion-ios-baseball:before { - content: ""; } - -.ion-ios-baseball-outline:before { - content: ""; } - -.ion-ios-basketball:before { - content: ""; } - -.ion-ios-basketball-outline:before { - content: ""; } - -.ion-ios-bell:before { - content: ""; } - -.ion-ios-bell-outline:before { - content: ""; } - -.ion-ios-body:before { - content: ""; } - -.ion-ios-body-outline:before { - content: ""; } - -.ion-ios-bolt:before { - content: ""; } - -.ion-ios-bolt-outline:before { - content: ""; } - -.ion-ios-book:before { - content: ""; } - -.ion-ios-book-outline:before { - content: ""; } - -.ion-ios-bookmarks:before { - content: ""; } - -.ion-ios-bookmarks-outline:before { - content: ""; } - -.ion-ios-box:before { - content: ""; } - -.ion-ios-box-outline:before { - content: ""; } - -.ion-ios-briefcase:before { - content: ""; } - -.ion-ios-briefcase-outline:before { - content: ""; } - -.ion-ios-browsers:before { - content: ""; } - -.ion-ios-browsers-outline:before { - content: ""; } - -.ion-ios-calculator:before { - content: ""; } - -.ion-ios-calculator-outline:before { - content: ""; } - -.ion-ios-calendar:before { - content: ""; } - -.ion-ios-calendar-outline:before { - content: ""; } - -.ion-ios-camera:before { - content: ""; } - -.ion-ios-camera-outline:before { - content: ""; } - -.ion-ios-cart:before { - content: ""; } - -.ion-ios-cart-outline:before { - content: ""; } - -.ion-ios-chatboxes:before { - content: ""; } - -.ion-ios-chatboxes-outline:before { - content: ""; } - -.ion-ios-chatbubble:before { - content: ""; } - -.ion-ios-chatbubble-outline:before { - content: ""; } - -.ion-ios-checkmark:before { - content: ""; } - -.ion-ios-checkmark-empty:before { - content: ""; } - -.ion-ios-checkmark-outline:before { - content: ""; } - -.ion-ios-circle-filled:before { - content: ""; } - -.ion-ios-circle-outline:before { - content: ""; } - -.ion-ios-clock:before { - content: ""; } - -.ion-ios-clock-outline:before { - content: ""; } - -.ion-ios-close:before { - content: ""; } - -.ion-ios-close-empty:before { - content: ""; } - -.ion-ios-close-outline:before { - content: ""; } - -.ion-ios-cloud:before { - content: ""; } - -.ion-ios-cloud-download:before { - content: ""; } - -.ion-ios-cloud-download-outline:before { - content: ""; } - -.ion-ios-cloud-outline:before { - content: ""; } - -.ion-ios-cloud-upload:before { - content: ""; } - -.ion-ios-cloud-upload-outline:before { - content: ""; } - -.ion-ios-cloudy:before { - content: ""; } - -.ion-ios-cloudy-night:before { - content: ""; } - -.ion-ios-cloudy-night-outline:before { - content: ""; } - -.ion-ios-cloudy-outline:before { - content: ""; } - -.ion-ios-cog:before { - content: ""; } - -.ion-ios-cog-outline:before { - content: ""; } - -.ion-ios-color-filter:before { - content: ""; } - -.ion-ios-color-filter-outline:before { - content: ""; } - -.ion-ios-color-wand:before { - content: ""; } - -.ion-ios-color-wand-outline:before { - content: ""; } - -.ion-ios-compose:before { - content: ""; } - -.ion-ios-compose-outline:before { - content: ""; } - -.ion-ios-contact:before { - content: ""; } - -.ion-ios-contact-outline:before { - content: ""; } - -.ion-ios-copy:before { - content: ""; } - -.ion-ios-copy-outline:before { - content: ""; } - -.ion-ios-crop:before { - content: ""; } - -.ion-ios-crop-strong:before { - content: ""; } - -.ion-ios-download:before { - content: ""; } - -.ion-ios-download-outline:before { - content: ""; } - -.ion-ios-drag:before { - content: ""; } - -.ion-ios-email:before { - content: ""; } - -.ion-ios-email-outline:before { - content: ""; } - -.ion-ios-eye:before { - content: ""; } - -.ion-ios-eye-outline:before { - content: ""; } - -.ion-ios-fastforward:before { - content: ""; } - -.ion-ios-fastforward-outline:before { - content: ""; } - -.ion-ios-filing:before { - content: ""; } - -.ion-ios-filing-outline:before { - content: ""; } - -.ion-ios-film:before { - content: ""; } - -.ion-ios-film-outline:before { - content: ""; } - -.ion-ios-flag:before { - content: ""; } - -.ion-ios-flag-outline:before { - content: ""; } - -.ion-ios-flame:before { - content: ""; } - -.ion-ios-flame-outline:before { - content: ""; } - -.ion-ios-flask:before { - content: ""; } - -.ion-ios-flask-outline:before { - content: ""; } - -.ion-ios-flower:before { - content: ""; } - -.ion-ios-flower-outline:before { - content: ""; } - -.ion-ios-folder:before { - content: ""; } - -.ion-ios-folder-outline:before { - content: ""; } - -.ion-ios-football:before { - content: ""; } - -.ion-ios-football-outline:before { - content: ""; } - -.ion-ios-game-controller-a:before { - content: ""; } - -.ion-ios-game-controller-a-outline:before { - content: ""; } - -.ion-ios-game-controller-b:before { - content: ""; } - -.ion-ios-game-controller-b-outline:before { - content: ""; } - -.ion-ios-gear:before { - content: ""; } - -.ion-ios-gear-outline:before { - content: ""; } - -.ion-ios-glasses:before { - content: ""; } - -.ion-ios-glasses-outline:before { - content: ""; } - -.ion-ios-grid-view:before { - content: ""; } - -.ion-ios-grid-view-outline:before { - content: ""; } - -.ion-ios-heart:before { - content: ""; } - -.ion-ios-heart-outline:before { - content: ""; } - -.ion-ios-help:before { - content: ""; } - -.ion-ios-help-empty:before { - content: ""; } - -.ion-ios-help-outline:before { - content: ""; } - -.ion-ios-home:before { - content: ""; } - -.ion-ios-home-outline:before { - content: ""; } - -.ion-ios-infinite:before { - content: ""; } - -.ion-ios-infinite-outline:before { - content: ""; } - -.ion-ios-information:before { - content: ""; } - -.ion-ios-information-empty:before { - content: ""; } - -.ion-ios-information-outline:before { - content: ""; } - -.ion-ios-ionic-outline:before { - content: ""; } - -.ion-ios-keypad:before { - content: ""; } - -.ion-ios-keypad-outline:before { - content: ""; } - -.ion-ios-lightbulb:before { - content: ""; } - -.ion-ios-lightbulb-outline:before { - content: ""; } - -.ion-ios-list:before { - content: ""; } - -.ion-ios-list-outline:before { - content: ""; } - -.ion-ios-location:before { - content: ""; } - -.ion-ios-location-outline:before { - content: ""; } - -.ion-ios-locked:before { - content: ""; } - -.ion-ios-locked-outline:before { - content: ""; } - -.ion-ios-loop:before { - content: ""; } - -.ion-ios-loop-strong:before { - content: ""; } - -.ion-ios-medical:before { - content: ""; } - -.ion-ios-medical-outline:before { - content: ""; } - -.ion-ios-medkit:before { - content: ""; } - -.ion-ios-medkit-outline:before { - content: ""; } - -.ion-ios-mic:before { - content: ""; } - -.ion-ios-mic-off:before { - content: ""; } - -.ion-ios-mic-outline:before { - content: ""; } - -.ion-ios-minus:before { - content: ""; } - -.ion-ios-minus-empty:before { - content: ""; } - -.ion-ios-minus-outline:before { - content: ""; } - -.ion-ios-monitor:before { - content: ""; } - -.ion-ios-monitor-outline:before { - content: ""; } - -.ion-ios-moon:before { - content: ""; } - -.ion-ios-moon-outline:before { - content: ""; } - -.ion-ios-more:before { - content: ""; } - -.ion-ios-more-outline:before { - content: ""; } - -.ion-ios-musical-note:before { - content: ""; } - -.ion-ios-musical-notes:before { - content: ""; } - -.ion-ios-navigate:before { - content: ""; } - -.ion-ios-navigate-outline:before { - content: ""; } - -.ion-ios-nutrition:before { - content: ""; } - -.ion-ios-nutrition-outline:before { - content: ""; } - -.ion-ios-paper:before { - content: ""; } - -.ion-ios-paper-outline:before { - content: ""; } - -.ion-ios-paperplane:before { - content: ""; } - -.ion-ios-paperplane-outline:before { - content: ""; } - -.ion-ios-partlysunny:before { - content: ""; } - -.ion-ios-partlysunny-outline:before { - content: ""; } - -.ion-ios-pause:before { - content: ""; } - -.ion-ios-pause-outline:before { - content: ""; } - -.ion-ios-paw:before { - content: ""; } - -.ion-ios-paw-outline:before { - content: ""; } - -.ion-ios-people:before { - content: ""; } - -.ion-ios-people-outline:before { - content: ""; } - -.ion-ios-person:before { - content: ""; } - -.ion-ios-person-outline:before { - content: ""; } - -.ion-ios-personadd:before { - content: ""; } - -.ion-ios-personadd-outline:before { - content: ""; } - -.ion-ios-photos:before { - content: ""; } - -.ion-ios-photos-outline:before { - content: ""; } - -.ion-ios-pie:before { - content: ""; } - -.ion-ios-pie-outline:before { - content: ""; } - -.ion-ios-pint:before { - content: ""; } - -.ion-ios-pint-outline:before { - content: ""; } - -.ion-ios-play:before { - content: ""; } - -.ion-ios-play-outline:before { - content: ""; } - -.ion-ios-plus:before { - content: ""; } - -.ion-ios-plus-empty:before { - content: ""; } - -.ion-ios-plus-outline:before { - content: ""; } - -.ion-ios-pricetag:before { - content: ""; } - -.ion-ios-pricetag-outline:before { - content: ""; } - -.ion-ios-pricetags:before { - content: ""; } - -.ion-ios-pricetags-outline:before { - content: ""; } - -.ion-ios-printer:before { - content: ""; } - -.ion-ios-printer-outline:before { - content: ""; } - -.ion-ios-pulse:before { - content: ""; } - -.ion-ios-pulse-strong:before { - content: ""; } - -.ion-ios-rainy:before { - content: ""; } - -.ion-ios-rainy-outline:before { - content: ""; } - -.ion-ios-recording:before { - content: ""; } - -.ion-ios-recording-outline:before { - content: ""; } - -.ion-ios-redo:before { - content: ""; } - -.ion-ios-redo-outline:before { - content: ""; } - -.ion-ios-refresh:before { - content: ""; } - -.ion-ios-refresh-empty:before { - content: ""; } - -.ion-ios-refresh-outline:before { - content: ""; } - -.ion-ios-reload:before { - content: ""; } - -.ion-ios-reverse-camera:before { - content: ""; } - -.ion-ios-reverse-camera-outline:before { - content: ""; } - -.ion-ios-rewind:before { - content: ""; } - -.ion-ios-rewind-outline:before { - content: ""; } - -.ion-ios-rose:before { - content: ""; } - -.ion-ios-rose-outline:before { - content: ""; } - -.ion-ios-search:before { - content: ""; } - -.ion-ios-search-strong:before { - content: ""; } - -.ion-ios-settings:before { - content: ""; } - -.ion-ios-settings-strong:before { - content: ""; } - -.ion-ios-shuffle:before { - content: ""; } - -.ion-ios-shuffle-strong:before { - content: ""; } - -.ion-ios-skipbackward:before { - content: ""; } - -.ion-ios-skipbackward-outline:before { - content: ""; } - -.ion-ios-skipforward:before { - content: ""; } - -.ion-ios-skipforward-outline:before { - content: ""; } - -.ion-ios-snowy:before { - content: ""; } - -.ion-ios-speedometer:before { - content: ""; } - -.ion-ios-speedometer-outline:before { - content: ""; } - -.ion-ios-star:before { - content: ""; } - -.ion-ios-star-half:before { - content: ""; } - -.ion-ios-star-outline:before { - content: ""; } - -.ion-ios-stopwatch:before { - content: ""; } - -.ion-ios-stopwatch-outline:before { - content: ""; } - -.ion-ios-sunny:before { - content: ""; } - -.ion-ios-sunny-outline:before { - content: ""; } - -.ion-ios-telephone:before { - content: ""; } - -.ion-ios-telephone-outline:before { - content: ""; } - -.ion-ios-tennisball:before { - content: ""; } - -.ion-ios-tennisball-outline:before { - content: ""; } - -.ion-ios-thunderstorm:before { - content: ""; } - -.ion-ios-thunderstorm-outline:before { - content: ""; } - -.ion-ios-time:before { - content: ""; } - -.ion-ios-time-outline:before { - content: ""; } - -.ion-ios-timer:before { - content: ""; } - -.ion-ios-timer-outline:before { - content: ""; } - -.ion-ios-toggle:before { - content: ""; } - -.ion-ios-toggle-outline:before { - content: ""; } - -.ion-ios-trash:before { - content: ""; } - -.ion-ios-trash-outline:before { - content: ""; } - -.ion-ios-undo:before { - content: ""; } - -.ion-ios-undo-outline:before { - content: ""; } - -.ion-ios-unlocked:before { - content: ""; } - -.ion-ios-unlocked-outline:before { - content: ""; } - -.ion-ios-upload:before { - content: ""; } - -.ion-ios-upload-outline:before { - content: ""; } - -.ion-ios-videocam:before { - content: ""; } - -.ion-ios-videocam-outline:before { - content: ""; } - -.ion-ios-volume-high:before { - content: ""; } - -.ion-ios-volume-low:before { - content: ""; } - -.ion-ios-wineglass:before { - content: ""; } - -.ion-ios-wineglass-outline:before { - content: ""; } - -.ion-ios-world:before { - content: ""; } - -.ion-ios-world-outline:before { - content: ""; } - -.ion-ipad:before { - content: ""; } - -.ion-iphone:before { - content: ""; } - -.ion-ipod:before { - content: ""; } - -.ion-jet:before { - content: ""; } - -.ion-key:before { - content: ""; } - -.ion-knife:before { - content: ""; } - -.ion-laptop:before { - content: ""; } - -.ion-leaf:before { - content: ""; } - -.ion-levels:before { - content: ""; } - -.ion-lightbulb:before { - content: ""; } - -.ion-link:before { - content: ""; } - -.ion-load-a:before { - content: ""; } - -.ion-load-b:before { - content: ""; } - -.ion-load-c:before { - content: ""; } - -.ion-load-d:before { - content: ""; } - -.ion-location:before { - content: ""; } - -.ion-lock-combination:before { - content: ""; } - -.ion-locked:before { - content: ""; } - -.ion-log-in:before { - content: ""; } - -.ion-log-out:before { - content: ""; } - -.ion-loop:before { - content: ""; } - -.ion-magnet:before { - content: ""; } - -.ion-male:before { - content: ""; } - -.ion-man:before { - content: ""; } - -.ion-map:before { - content: ""; } - -.ion-medkit:before { - content: ""; } - -.ion-merge:before { - content: ""; } - -.ion-mic-a:before { - content: ""; } - -.ion-mic-b:before { - content: ""; } - -.ion-mic-c:before { - content: ""; } - -.ion-minus:before { - content: ""; } - -.ion-minus-circled:before { - content: ""; } - -.ion-minus-round:before { - content: ""; } - -.ion-model-s:before { - content: ""; } - -.ion-monitor:before { - content: ""; } - -.ion-more:before { - content: ""; } - -.ion-mouse:before { - content: ""; } - -.ion-music-note:before { - content: ""; } - -.ion-navicon:before { - content: ""; } - -.ion-navicon-round:before { - content: ""; } - -.ion-navigate:before { - content: ""; } - -.ion-network:before { - content: ""; } - -.ion-no-smoking:before { - content: ""; } - -.ion-nuclear:before { - content: ""; } - -.ion-outlet:before { - content: ""; } - -.ion-paintbrush:before { - content: ""; } - -.ion-paintbucket:before { - content: ""; } - -.ion-paper-airplane:before { - content: ""; } - -.ion-paperclip:before { - content: ""; } - -.ion-pause:before { - content: ""; } - -.ion-person:before { - content: ""; } - -.ion-person-add:before { - content: ""; } - -.ion-person-stalker:before { - content: ""; } - -.ion-pie-graph:before { - content: ""; } - -.ion-pin:before { - content: ""; } - -.ion-pinpoint:before { - content: ""; } - -.ion-pizza:before { - content: ""; } - -.ion-plane:before { - content: ""; } - -.ion-planet:before { - content: ""; } - -.ion-play:before { - content: ""; } - -.ion-playstation:before { - content: ""; } - -.ion-plus:before { - content: ""; } - -.ion-plus-circled:before { - content: ""; } - -.ion-plus-round:before { - content: ""; } - -.ion-podium:before { - content: ""; } - -.ion-pound:before { - content: ""; } - -.ion-power:before { - content: ""; } - -.ion-pricetag:before { - content: ""; } - -.ion-pricetags:before { - content: ""; } - -.ion-printer:before { - content: ""; } - -.ion-pull-request:before { - content: ""; } - -.ion-qr-scanner:before { - content: ""; } - -.ion-quote:before { - content: ""; } - -.ion-radio-waves:before { - content: ""; } - -.ion-record:before { - content: ""; } - -.ion-refresh:before { - content: ""; } - -.ion-reply:before { - content: ""; } - -.ion-reply-all:before { - content: ""; } - -.ion-ribbon-a:before { - content: ""; } - -.ion-ribbon-b:before { - content: ""; } - -.ion-sad:before { - content: ""; } - -.ion-sad-outline:before { - content: ""; } - -.ion-scissors:before { - content: ""; } - -.ion-search:before { - content: ""; } - -.ion-settings:before { - content: ""; } - -.ion-share:before { - content: ""; } - -.ion-shuffle:before { - content: ""; } - -.ion-skip-backward:before { - content: ""; } - -.ion-skip-forward:before { - content: ""; } - -.ion-social-android:before { - content: ""; } - -.ion-social-android-outline:before { - content: ""; } - -.ion-social-angular:before { - content: ""; } - -.ion-social-angular-outline:before { - content: ""; } - -.ion-social-apple:before { - content: ""; } - -.ion-social-apple-outline:before { - content: ""; } - -.ion-social-bitcoin:before { - content: ""; } - -.ion-social-bitcoin-outline:before { - content: ""; } - -.ion-social-buffer:before { - content: ""; } - -.ion-social-buffer-outline:before { - content: ""; } - -.ion-social-chrome:before { - content: ""; } - -.ion-social-chrome-outline:before { - content: ""; } - -.ion-social-codepen:before { - content: ""; } - -.ion-social-codepen-outline:before { - content: ""; } - -.ion-social-css3:before { - content: ""; } - -.ion-social-css3-outline:before { - content: ""; } - -.ion-social-designernews:before { - content: ""; } - -.ion-social-designernews-outline:before { - content: ""; } - -.ion-social-dribbble:before { - content: ""; } - -.ion-social-dribbble-outline:before { - content: ""; } - -.ion-social-dropbox:before { - content: ""; } - -.ion-social-dropbox-outline:before { - content: ""; } - -.ion-social-euro:before { - content: ""; } - -.ion-social-euro-outline:before { - content: ""; } - -.ion-social-facebook:before { - content: ""; } - -.ion-social-facebook-outline:before { - content: ""; } - -.ion-social-foursquare:before { - content: ""; } - -.ion-social-foursquare-outline:before { - content: ""; } - -.ion-social-freebsd-devil:before { - content: ""; } - -.ion-social-github:before { - content: ""; } - -.ion-social-github-outline:before { - content: ""; } - -.ion-social-google:before { - content: ""; } - -.ion-social-google-outline:before { - content: ""; } - -.ion-social-googleplus:before { - content: ""; } - -.ion-social-googleplus-outline:before { - content: ""; } - -.ion-social-hackernews:before { - content: ""; } - -.ion-social-hackernews-outline:before { - content: ""; } - -.ion-social-html5:before { - content: ""; } - -.ion-social-html5-outline:before { - content: ""; } - -.ion-social-instagram:before { - content: ""; } - -.ion-social-instagram-outline:before { - content: ""; } - -.ion-social-javascript:before { - content: ""; } - -.ion-social-javascript-outline:before { - content: ""; } - -.ion-social-linkedin:before { - content: ""; } - -.ion-social-linkedin-outline:before { - content: ""; } - -.ion-social-markdown:before { - content: ""; } - -.ion-social-nodejs:before { - content: ""; } - -.ion-social-octocat:before { - content: ""; } - -.ion-social-pinterest:before { - content: ""; } - -.ion-social-pinterest-outline:before { - content: ""; } - -.ion-social-python:before { - content: ""; } - -.ion-social-reddit:before { - content: ""; } - -.ion-social-reddit-outline:before { - content: ""; } - -.ion-social-rss:before { - content: ""; } - -.ion-social-rss-outline:before { - content: ""; } - -.ion-social-sass:before { - content: ""; } - -.ion-social-skype:before { - content: ""; } - -.ion-social-skype-outline:before { - content: ""; } - -.ion-social-snapchat:before { - content: ""; } - -.ion-social-snapchat-outline:before { - content: ""; } - -.ion-social-tumblr:before { - content: ""; } - -.ion-social-tumblr-outline:before { - content: ""; } - -.ion-social-tux:before { - content: ""; } - -.ion-social-twitch:before { - content: ""; } - -.ion-social-twitch-outline:before { - content: ""; } - -.ion-social-twitter:before { - content: ""; } - -.ion-social-twitter-outline:before { - content: ""; } - -.ion-social-usd:before { - content: ""; } - -.ion-social-usd-outline:before { - content: ""; } - -.ion-social-vimeo:before { - content: ""; } - -.ion-social-vimeo-outline:before { - content: ""; } - -.ion-social-whatsapp:before { - content: ""; } - -.ion-social-whatsapp-outline:before { - content: ""; } - -.ion-social-windows:before { - content: ""; } - -.ion-social-windows-outline:before { - content: ""; } - -.ion-social-wordpress:before { - content: ""; } - -.ion-social-wordpress-outline:before { - content: ""; } - -.ion-social-yahoo:before { - content: ""; } - -.ion-social-yahoo-outline:before { - content: ""; } - -.ion-social-yen:before { - content: ""; } - -.ion-social-yen-outline:before { - content: ""; } - -.ion-social-youtube:before { - content: ""; } - -.ion-social-youtube-outline:before { - content: ""; } - -.ion-soup-can:before { - content: ""; } - -.ion-soup-can-outline:before { - content: ""; } - -.ion-speakerphone:before { - content: ""; } - -.ion-speedometer:before { - content: ""; } - -.ion-spoon:before { - content: ""; } - -.ion-star:before { - content: ""; } - -.ion-stats-bars:before { - content: ""; } - -.ion-steam:before { - content: ""; } - -.ion-stop:before { - content: ""; } - -.ion-thermometer:before { - content: ""; } - -.ion-thumbsdown:before { - content: ""; } - -.ion-thumbsup:before { - content: ""; } - -.ion-toggle:before { - content: ""; } - -.ion-toggle-filled:before { - content: ""; } - -.ion-transgender:before { - content: ""; } - -.ion-trash-a:before { - content: ""; } - -.ion-trash-b:before { - content: ""; } - -.ion-trophy:before { - content: ""; } - -.ion-tshirt:before { - content: ""; } - -.ion-tshirt-outline:before { - content: ""; } - -.ion-umbrella:before { - content: ""; } - -.ion-university:before { - content: ""; } - -.ion-unlocked:before { - content: ""; } - -.ion-upload:before { - content: ""; } - -.ion-usb:before { - content: ""; } - -.ion-videocamera:before { - content: ""; } - -.ion-volume-high:before { - content: ""; } - -.ion-volume-low:before { - content: ""; } - -.ion-volume-medium:before { - content: ""; } - -.ion-volume-mute:before { - content: ""; } - -.ion-wand:before { - content: ""; } - -.ion-waterdrop:before { - content: ""; } - -.ion-wifi:before { - content: ""; } - -.ion-wineglass:before { - content: ""; } - -.ion-woman:before { - content: ""; } - -.ion-wrench:before { - content: ""; } - -.ion-xbox:before { - content: ""; } - -/** - * Resets - * -------------------------------------------------- - * Adapted from normalize.css and some reset.css. We don't care even one - * bit about old IE, so we don't need any hacks for that in here. - * - * There are probably other things we could remove here, as well. - * - * normalize.css v2.1.2 | MIT License | git.io/normalize - - * Eric Meyer's Reset CSS v2.0 (http://meyerweb.com/eric/tools/css/reset/) - * http://cssreset.com - */ -html, body, div, span, applet, object, iframe, -h1, h2, h3, h4, h5, h6, p, blockquote, pre, -a, abbr, acronym, address, big, cite, code, -del, dfn, em, img, ins, kbd, q, s, samp, -small, strike, strong, sub, sup, tt, var, -b, i, u, center, -dl, dt, dd, ol, ul, li, -fieldset, form, label, legend, -table, caption, tbody, tfoot, thead, tr, th, td, -article, aside, canvas, details, embed, fieldset, -figure, figcaption, footer, header, hgroup, -menu, nav, output, ruby, section, summary, -time, mark, audio, video { - margin: 0; - padding: 0; - border: 0; - vertical-align: baseline; - font: inherit; - font-size: 100%; } - -ol, ul { - list-style: none; } - -blockquote, q { - quotes: none; } - -blockquote:before, blockquote:after, -q:before, q:after { - content: ''; - content: none; } - -/** - * Prevent modern browsers from displaying `audio` without controls. - * Remove excess height in iOS 5 devices. - */ -audio:not([controls]) { - display: none; - height: 0; } - -/** - * Hide the `template` element in IE, Safari, and Firefox < 22. - */ -[hidden], -template { - display: none; } - -script { - display: none !important; } - -/* ========================================================================== - Base - ========================================================================== */ -/** - * 1. Set default font family to sans-serif. - * 2. Prevent iOS text size adjust after orientation change, without disabling - * user zoom. - */ -html { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - font-family: sans-serif; - /* 1 */ - -webkit-text-size-adjust: 100%; - -ms-text-size-adjust: 100%; - /* 2 */ - -webkit-text-size-adjust: 100%; - /* 2 */ } - -/** - * Remove default margin. - */ -body { - margin: 0; - line-height: 1; } - -/** - * Remove default outlines. - */ -a, -button, -:focus, -a:focus, -button:focus, -a:active, -a:hover { - outline: 0; } - -/* * - * Remove tap highlight color - */ -a { - -webkit-user-drag: none; - -webkit-tap-highlight-color: transparent; - -webkit-tap-highlight-color: transparent; } - a[href]:hover { - cursor: pointer; } - -/* ========================================================================== - Typography - ========================================================================== */ -/** - * Address style set to `bolder` in Firefox 4+, Safari 5, and Chrome. - */ -b, -strong { - font-weight: bold; } - -/** - * Address styling not present in Safari 5 and Chrome. - */ -dfn { - font-style: italic; } - -/** - * Address differences between Firefox and other browsers. - */ -hr { - -moz-box-sizing: content-box; - box-sizing: content-box; - height: 0; } - -/** - * Correct font family set oddly in Safari 5 and Chrome. - */ -code, -kbd, -pre, -samp { - font-size: 1em; - font-family: monospace, serif; } - -/** - * Improve readability of pre-formatted text in all browsers. - */ -pre { - white-space: pre-wrap; } - -/** - * Set consistent quote types. - */ -q { - quotes: "\201C" "\201D" "\2018" "\2019"; } - -/** - * Address inconsistent and variable font size in all browsers. - */ -small { - font-size: 80%; } - -/** - * Prevent `sub` and `sup` affecting `line-height` in all browsers. - */ -sub, -sup { - position: relative; - vertical-align: baseline; - font-size: 75%; - line-height: 0; } - -sup { - top: -0.5em; } - -sub { - bottom: -0.25em; } - -/** - * Define consistent border, margin, and padding. - */ -fieldset { - margin: 0 2px; - padding: 0.35em 0.625em 0.75em; - border: 1px solid #c0c0c0; } - -/** - * 1. Correct `color` not being inherited in IE 8/9. - * 2. Remove padding so people aren't caught out if they zero out fieldsets. - */ -legend { - padding: 0; - /* 2 */ - border: 0; - /* 1 */ } - -/** - * 1. Correct font family not being inherited in all browsers. - * 2. Correct font size not being inherited in all browsers. - * 3. Address margins set differently in Firefox 4+, Safari 5, and Chrome. - * 4. Remove any default :focus styles - * 5. Make sure webkit font smoothing is being inherited - * 6. Remove default gradient in Android Firefox / FirefoxOS - */ -button, -input, -select, -textarea { - margin: 0; - /* 3 */ - font-size: 100%; - /* 2 */ - font-family: inherit; - /* 1 */ - outline-offset: 0; - /* 4 */ - outline-style: none; - /* 4 */ - outline-width: 0; - /* 4 */ - -webkit-font-smoothing: inherit; - /* 5 */ - background-image: none; - /* 6 */ } - -/** - * Address Firefox 4+ setting `line-height` on `input` using `importnt` in - * the UA stylesheet. - */ -button, -input { - line-height: normal; } - -/** - * Address inconsistent `text-transform` inheritance for `button` and `select`. - * All other form control elements do not inherit `text-transform` values. - * Correct `button` style inheritance in Chrome, Safari 5+, and IE 8+. - * Correct `select` style inheritance in Firefox 4+ and Opera. - */ -button, -select { - text-transform: none; } - -/** - * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` - * and `video` controls. - * 2. Correct inability to style clickable `input` types in iOS. - * 3. Improve usability and consistency of cursor style between image-type - * `input` and others. - */ -button, -html input[type="button"], -input[type="reset"], -input[type="submit"] { - cursor: pointer; - /* 3 */ - -webkit-appearance: button; - /* 2 */ } - -/** - * Re-set default cursor for disabled elements. - */ -button[disabled], -html input[disabled] { - cursor: default; } - -/** - * 1. Address `appearance` set to `searchfield` in Safari 5 and Chrome. - * 2. Address `box-sizing` set to `border-box` in Safari 5 and Chrome - * (include `-moz` to future-proof). - */ -input[type="search"] { - -webkit-box-sizing: content-box; - /* 2 */ - -moz-box-sizing: content-box; - box-sizing: content-box; - -webkit-appearance: textfield; - /* 1 */ } - -/** - * Remove inner padding and search cancel button in Safari 5 and Chrome - * on OS X. - */ -input[type="search"]::-webkit-search-cancel-button, -input[type="search"]::-webkit-search-decoration { - -webkit-appearance: none; } - -/** - * Remove inner padding and border in Firefox 4+. - */ -button::-moz-focus-inner, -input::-moz-focus-inner { - padding: 0; - border: 0; } - -/** - * 1. Remove default vertical scrollbar in IE 8/9. - * 2. Improve readability and alignment in all browsers. - */ -textarea { - overflow: auto; - /* 1 */ - vertical-align: top; - /* 2 */ } - -img { - -webkit-user-drag: none; } - -/* ========================================================================== - Tables - ========================================================================== */ -/** - * Remove most spacing between table cells. - */ -table { - border-spacing: 0; - border-collapse: collapse; } - -/** - * Scaffolding - * -------------------------------------------------- - */ -*, -*:before, -*:after { - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; } - -html { - overflow: hidden; - -ms-touch-action: pan-y; - touch-action: pan-y; } - -body, -.ionic-body { - -webkit-touch-callout: none; - -webkit-font-smoothing: antialiased; - font-smoothing: antialiased; - -webkit-text-size-adjust: none; - -moz-text-size-adjust: none; - text-size-adjust: none; - -webkit-tap-highlight-color: transparent; - -webkit-tap-highlight-color: transparent; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - top: 0; - right: 0; - bottom: 0; - left: 0; - overflow: hidden; - margin: 0; - padding: 0; - color: #000; - word-wrap: break-word; - font-size: 14px; - font-family: -apple-system; - font-family: "-apple-system", "Helvetica Neue", "Roboto", "Segoe UI", sans-serif; - line-height: 20px; - text-rendering: optimizeLegibility; - -webkit-backface-visibility: hidden; - -webkit-user-drag: none; - -ms-content-zooming: none; } - -body.grade-b, -body.grade-c { - text-rendering: auto; } - -.content { - position: relative; } - -.scroll-content { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - overflow: hidden; - margin-top: -1px; - padding-top: 1px; - margin-bottom: -1px; - width: auto; - height: auto; } - -.menu .scroll-content.scroll-content-false { - z-index: 11; } - -.scroll-view { - position: relative; - display: block; - overflow: hidden; - margin-top: -1px; } - .scroll-view.overflow-scroll { - position: relative; } - .scroll-view.scroll-x { - overflow-x: scroll; - overflow-y: hidden; } - .scroll-view.scroll-y { - overflow-x: hidden; - overflow-y: scroll; } - .scroll-view.scroll-xy { - overflow-x: scroll; - overflow-y: scroll; } - -/** - * Scroll is the scroll view component available for complex and custom - * scroll view functionality. - */ -.scroll { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - -webkit-touch-callout: none; - -webkit-text-size-adjust: none; - -moz-text-size-adjust: none; - text-size-adjust: none; - -webkit-transform-origin: left top; - transform-origin: left top; } - -/** - * Set ms-viewport to prevent MS "page squish" and allow fluid scrolling - * https://msdn.microsoft.com/en-us/library/ie/hh869615(v=vs.85).aspx - */ -@-ms-viewport { - width: device-width; } - -.scroll-bar { - position: absolute; - z-index: 9999; } - -.ng-animate .scroll-bar { - visibility: hidden; } - -.scroll-bar-h { - right: 2px; - bottom: 3px; - left: 2px; - height: 3px; } - .scroll-bar-h .scroll-bar-indicator { - height: 100%; } - -.scroll-bar-v { - top: 2px; - right: 3px; - bottom: 2px; - width: 3px; } - .scroll-bar-v .scroll-bar-indicator { - width: 100%; } - -.scroll-bar-indicator { - position: absolute; - border-radius: 4px; - background: rgba(0, 0, 0, 0.3); - opacity: 1; - -webkit-transition: opacity 0.3s linear; - transition: opacity 0.3s linear; } - .scroll-bar-indicator.scroll-bar-fade-out { - opacity: 0; } - -.platform-android .scroll-bar-indicator { - border-radius: 0; } - -.grade-b .scroll-bar-indicator, -.grade-c .scroll-bar-indicator { - background: #aaa; } - .grade-b .scroll-bar-indicator.scroll-bar-fade-out, - .grade-c .scroll-bar-indicator.scroll-bar-fade-out { - -webkit-transition: none; - transition: none; } - -ion-infinite-scroll { - height: 60px; - width: 100%; - display: block; - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-direction: normal; - -webkit-box-orient: horizontal; - -webkit-flex-direction: row; - -moz-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - -webkit-box-pack: center; - -ms-flex-pack: center; - -webkit-justify-content: center; - -moz-justify-content: center; - justify-content: center; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; } - ion-infinite-scroll .icon { - color: #666666; - font-size: 30px; - color: #666666; } - ion-infinite-scroll:not(.active) .spinner, - ion-infinite-scroll:not(.active) .icon:before { - display: none; } - -.overflow-scroll { - overflow-x: hidden; - overflow-y: scroll; - -webkit-overflow-scrolling: touch; - -ms-overflow-style: -ms-autohiding-scrollbar; - top: 0; - right: 0; - bottom: 0; - left: 0; - position: absolute; } - .overflow-scroll.pane { - overflow-x: hidden; - overflow-y: scroll; } - .overflow-scroll .scroll { - position: static; - height: 100%; - -webkit-transform: translate3d(0, 0, 0); } - -/* If you change these, change platform.scss as well */ -.has-header { - top: 44px; } - -.no-header { - top: 0; } - -.has-subheader { - top: 88px; } - -.has-tabs-top { - top: 93px; } - -.has-header.has-subheader.has-tabs-top { - top: 137px; } - -.has-footer { - bottom: 44px; } - -.has-subfooter { - bottom: 88px; } - -.has-tabs, -.bar-footer.has-tabs { - bottom: 49px; } - .has-tabs.pane, - .bar-footer.has-tabs.pane { - bottom: 49px; - height: auto; } - -.bar-subfooter.has-tabs { - bottom: 93px; } - -.has-footer.has-tabs { - bottom: 93px; } - -.pane { - -webkit-transform: translate3d(0, 0, 0); - transform: translate3d(0, 0, 0); - -webkit-transition-duration: 0; - transition-duration: 0; - z-index: 1; } - -.view { - z-index: 1; } - -.pane, -.view { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - width: 100%; - height: 100%; - background-color: #fff; - overflow: hidden; } - -.view-container { - position: absolute; - display: block; - width: 100%; - height: 100%; } - -/** - * Typography - * -------------------------------------------------- - */ -p { - margin: 0 0 10px; } - -small { - font-size: 85%; } - -cite { - font-style: normal; } - -.text-left { - text-align: left; } - -.text-right { - text-align: right; } - -.text-center { - text-align: center; } - -h1, h2, h3, h4, h5, h6, -.h1, .h2, .h3, .h4, .h5, .h6 { - color: #000; - font-weight: 500; - font-family: "-apple-system", "Helvetica Neue", "Roboto", "Segoe UI", sans-serif; - line-height: 1.2; } - h1 small, h2 small, h3 small, h4 small, h5 small, h6 small, - .h1 small, .h2 small, .h3 small, .h4 small, .h5 small, .h6 small { - font-weight: normal; - line-height: 1; } - -h1, .h1, -h2, .h2, -h3, .h3 { - margin-top: 20px; - margin-bottom: 10px; } - h1:first-child, .h1:first-child, - h2:first-child, .h2:first-child, - h3:first-child, .h3:first-child { - margin-top: 0; } - h1 + h1, h1 + .h1, - h1 + h2, h1 + .h2, - h1 + h3, h1 + .h3, .h1 + h1, .h1 + .h1, - .h1 + h2, .h1 + .h2, - .h1 + h3, .h1 + .h3, - h2 + h1, - h2 + .h1, - h2 + h2, - h2 + .h2, - h2 + h3, - h2 + .h3, .h2 + h1, .h2 + .h1, - .h2 + h2, .h2 + .h2, - .h2 + h3, .h2 + .h3, - h3 + h1, - h3 + .h1, - h3 + h2, - h3 + .h2, - h3 + h3, - h3 + .h3, .h3 + h1, .h3 + .h1, - .h3 + h2, .h3 + .h2, - .h3 + h3, .h3 + .h3 { - margin-top: 10px; } - -h4, .h4, -h5, .h5, -h6, .h6 { - margin-top: 10px; - margin-bottom: 10px; } - -h1, .h1 { - font-size: 36px; } - -h2, .h2 { - font-size: 30px; } - -h3, .h3 { - font-size: 24px; } - -h4, .h4 { - font-size: 18px; } - -h5, .h5 { - font-size: 14px; } - -h6, .h6 { - font-size: 12px; } - -h1 small, .h1 small { - font-size: 24px; } - -h2 small, .h2 small { - font-size: 18px; } - -h3 small, .h3 small, -h4 small, .h4 small { - font-size: 14px; } - -dl { - margin-bottom: 20px; } - -dt, -dd { - line-height: 1.42857; } - -dt { - font-weight: bold; } - -blockquote { - margin: 0 0 20px; - padding: 10px 20px; - border-left: 5px solid gray; } - blockquote p { - font-weight: 300; - font-size: 17.5px; - line-height: 1.25; } - blockquote p:last-child { - margin-bottom: 0; } - blockquote small { - display: block; - line-height: 1.42857; } - blockquote small:before { - content: '\2014 \00A0'; } - -q:before, -q:after, -blockquote:before, -blockquote:after { - content: ""; } - -address { - display: block; - margin-bottom: 20px; - font-style: normal; - line-height: 1.42857; } - -a { - color: #387ef5; } - -a.subdued { - padding-right: 10px; - color: #888; - text-decoration: none; } - a.subdued:hover { - text-decoration: none; } - a.subdued:last-child { - padding-right: 0; } - -/** - * Action Sheets - * -------------------------------------------------- - */ -.action-sheet-backdrop { - -webkit-transition: background-color 150ms ease-in-out; - transition: background-color 150ms ease-in-out; - position: fixed; - top: 0; - left: 0; - z-index: 11; - width: 100%; - height: 100%; - background-color: transparent; } - .action-sheet-backdrop.active { - background-color: rgba(0, 0, 0, 0.4); } - -.action-sheet-wrapper { - -webkit-transform: translate3d(0, 100%, 0); - transform: translate3d(0, 100%, 0); - -webkit-transition: all cubic-bezier(0.36, 0.66, 0.04, 1) 500ms; - transition: all cubic-bezier(0.36, 0.66, 0.04, 1) 500ms; - position: absolute; - bottom: 0; - left: 0; - right: 0; - width: 100%; - max-width: 500px; - margin: auto; } - -.action-sheet-up { - -webkit-transform: translate3d(0, 0, 0); - transform: translate3d(0, 0, 0); } - -.action-sheet { - margin-left: 8px; - margin-right: 8px; - width: auto; - z-index: 11; - overflow: hidden; } - .action-sheet .button { - display: block; - padding: 1px; - width: 100%; - border-radius: 0; - border-color: #d1d3d6; - background-color: transparent; - color: #007aff; - font-size: 21px; } - .action-sheet .button:hover { - color: #007aff; } - .action-sheet .button.destructive { - color: #ff3b30; } - .action-sheet .button.destructive:hover { - color: #ff3b30; } - .action-sheet .button.active, .action-sheet .button.activated { - box-shadow: none; - border-color: #d1d3d6; - color: #007aff; - background: #e4e5e7; } - -.action-sheet-has-icons .icon { - position: absolute; - left: 16px; } - -.action-sheet-title { - padding: 16px; - color: #8f8f8f; - text-align: center; - font-size: 13px; } - -.action-sheet-group { - margin-bottom: 8px; - border-radius: 4px; - background-color: #fff; - overflow: hidden; } - .action-sheet-group .button { - border-width: 1px 0px 0px 0px; } - .action-sheet-group .button:first-child:last-child { - border-width: 0; } - -.action-sheet-options { - background: #f1f2f3; } - -.action-sheet-cancel .button { - font-weight: 500; } - -.action-sheet-open { - pointer-events: none; } - .action-sheet-open.modal-open .modal { - pointer-events: none; } - .action-sheet-open .action-sheet-backdrop { - pointer-events: auto; } - -.platform-android .action-sheet-backdrop.active { - background-color: rgba(0, 0, 0, 0.2); } - -.platform-android .action-sheet { - margin: 0; } - .platform-android .action-sheet .action-sheet-title, - .platform-android .action-sheet .button { - text-align: left; - border-color: transparent; - font-size: 16px; - color: inherit; } - .platform-android .action-sheet .action-sheet-title { - font-size: 14px; - padding: 16px; - color: #666; } - .platform-android .action-sheet .button.active, - .platform-android .action-sheet .button.activated { - background: #e8e8e8; } - -.platform-android .action-sheet-group { - margin: 0; - border-radius: 0; - background-color: #fafafa; } - -.platform-android .action-sheet-cancel { - display: none; } - -.platform-android .action-sheet-has-icons .button { - padding-left: 56px; } - -.backdrop { - position: fixed; - top: 0; - left: 0; - z-index: 11; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.4); - visibility: hidden; - opacity: 0; - -webkit-transition: 0.1s opacity linear; - transition: 0.1s opacity linear; } - .backdrop.visible { - visibility: visible; } - .backdrop.active { - opacity: 1; } - -/** - * Bar (Headers and Footers) - * -------------------------------------------------- - */ -.bar { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-transform: translate3d(0, 0, 0); - transform: translate3d(0, 0, 0); - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - position: absolute; - right: 0; - left: 0; - z-index: 9; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; - padding: 5px; - width: 100%; - height: 44px; - border-width: 0; - border-style: solid; - border-top: 1px solid transparent; - border-bottom: 1px solid #ddd; - background-color: white; - /* border-width: 1px will actually create 2 device pixels on retina */ - /* this nifty trick sets an actual 1px border on hi-res displays */ - background-size: 0; } - @media (min--moz-device-pixel-ratio: 1.5), (-webkit-min-device-pixel-ratio: 1.5), (min-device-pixel-ratio: 1.5), (min-resolution: 144dpi), (min-resolution: 1.5dppx) { - .bar { - border: none; - background-image: linear-gradient(0deg, #ddd, #ddd 50%, transparent 50%); - background-position: bottom; - background-size: 100% 1px; - background-repeat: no-repeat; } } - .bar.bar-clear { - border: none; - background: none; - color: #fff; } - .bar.bar-clear .button { - color: #fff; } - .bar.bar-clear .title { - color: #fff; } - .bar.item-input-inset .item-input-wrapper { - margin-top: -1px; } - .bar.item-input-inset .item-input-wrapper input { - padding-left: 8px; - width: 94%; - height: 28px; - background: transparent; } - .bar.bar-light { - border-color: #ddd; - background-color: white; - background-image: linear-gradient(0deg, #ddd, #ddd 50%, transparent 50%); - color: #444; } - .bar.bar-light .title { - color: #444; } - .bar.bar-light.bar-footer { - background-image: linear-gradient(180deg, #ddd, #ddd 50%, transparent 50%); } - .bar.bar-stable { - border-color: #b2b2b2; - background-color: #f8f8f8; - background-image: linear-gradient(0deg, #b2b2b2, #b2b2b2 50%, transparent 50%); - color: #444; } - .bar.bar-stable .title { - color: #444; } - .bar.bar-stable.bar-footer { - background-image: linear-gradient(180deg, #b2b2b2, #b2b2b2 50%, transparent 50%); } - .bar.bar-positive { - border-color: #0c60ee; - background-color: #387ef5; - background-image: linear-gradient(0deg, #0c60ee, #0c60ee 50%, transparent 50%); - color: #fff; } - .bar.bar-positive .title { - color: #fff; } - .bar.bar-positive.bar-footer { - background-image: linear-gradient(180deg, #0c60ee, #0c60ee 50%, transparent 50%); } - .bar.bar-calm { - border-color: #0a9dc7; - background-color: #11c1f3; - background-image: linear-gradient(0deg, #0a9dc7, #0a9dc7 50%, transparent 50%); - color: #fff; } - .bar.bar-calm .title { - color: #fff; } - .bar.bar-calm.bar-footer { - background-image: linear-gradient(180deg, #0a9dc7, #0a9dc7 50%, transparent 50%); } - .bar.bar-assertive { - border-color: #e42112; - background-color: #ef473a; - background-image: linear-gradient(0deg, #e42112, #e42112 50%, transparent 50%); - color: #fff; } - .bar.bar-assertive .title { - color: #fff; } - .bar.bar-assertive.bar-footer { - background-image: linear-gradient(180deg, #e42112, #e42112 50%, transparent 50%); } - .bar.bar-balanced { - border-color: #28a54c; - background-color: #33cd5f; - background-image: linear-gradient(0deg, #28a54c, #28a54c 50%, transparent 50%); - color: #fff; } - .bar.bar-balanced .title { - color: #fff; } - .bar.bar-balanced.bar-footer { - background-image: linear-gradient(180deg, #28a54c, #28a54c 50%, transparent 50%); } - .bar.bar-energized { - border-color: #e6b500; - background-color: #ffc900; - background-image: linear-gradient(0deg, #e6b500, #e6b500 50%, transparent 50%); - color: #fff; } - .bar.bar-energized .title { - color: #fff; } - .bar.bar-energized.bar-footer { - background-image: linear-gradient(180deg, #e6b500, #e6b500 50%, transparent 50%); } - .bar.bar-royal { - border-color: #6b46e5; - background-color: #886aea; - background-image: linear-gradient(0deg, #6b46e5, #6b46e5 50%, transparent 50%); - color: #fff; } - .bar.bar-royal .title { - color: #fff; } - .bar.bar-royal.bar-footer { - background-image: linear-gradient(180deg, #6b46e5, #6b46e5 50%, transparent 50%); } - .bar.bar-dark { - border-color: #111; - background-color: #444444; - background-image: linear-gradient(0deg, #111, #111 50%, transparent 50%); - color: #fff; } - .bar.bar-dark .title { - color: #fff; } - .bar.bar-dark.bar-footer { - background-image: linear-gradient(180deg, #111, #111 50%, transparent 50%); } - .bar .title { - display: block; - position: absolute; - top: 0; - right: 0; - left: 0; - z-index: 0; - overflow: hidden; - margin: 0 10px; - min-width: 30px; - height: 43px; - text-align: center; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 17px; - font-weight: 500; - line-height: 44px; } - .bar .title.title-left { - text-align: left; } - .bar .title.title-right { - text-align: right; } - .bar .title a { - color: inherit; } - .bar .button, .bar button { - z-index: 1; - padding: 0 8px; - min-width: initial; - min-height: 31px; - font-weight: 400; - font-size: 13px; - line-height: 32px; } - .bar .button.button-icon:before, - .bar .button .icon:before, .bar .button.icon:before, .bar .button.icon-left:before, .bar .button.icon-right:before, .bar button.button-icon:before, - .bar button .icon:before, .bar button.icon:before, .bar button.icon-left:before, .bar button.icon-right:before { - padding-right: 2px; - padding-left: 2px; - font-size: 20px; - line-height: 32px; } - .bar .button.button-icon, .bar button.button-icon { - font-size: 17px; } - .bar .button.button-icon .icon:before, .bar .button.button-icon:before, .bar .button.button-icon.icon-left:before, .bar .button.button-icon.icon-right:before, .bar button.button-icon .icon:before, .bar button.button-icon:before, .bar button.button-icon.icon-left:before, .bar button.button-icon.icon-right:before { - vertical-align: top; - font-size: 32px; - line-height: 32px; } - .bar .button.button-clear, .bar button.button-clear { - padding-right: 2px; - padding-left: 2px; - font-weight: 300; - font-size: 17px; } - .bar .button.button-clear .icon:before, .bar .button.button-clear.icon:before, .bar .button.button-clear.icon-left:before, .bar .button.button-clear.icon-right:before, .bar button.button-clear .icon:before, .bar button.button-clear.icon:before, .bar button.button-clear.icon-left:before, .bar button.button-clear.icon-right:before { - font-size: 32px; - line-height: 32px; } - .bar .button.back-button, .bar button.back-button { - display: block; - margin-right: 5px; - padding: 0; - white-space: nowrap; - font-weight: 400; } - .bar .button.back-button.active, .bar .button.back-button.activated, .bar button.back-button.active, .bar button.back-button.activated { - opacity: 0.2; } - .bar .button-bar > .button, - .bar .buttons > .button { - min-height: 31px; - line-height: 32px; } - .bar .button-bar + .button, - .bar .button + .button-bar { - margin-left: 5px; } - .bar .buttons, - .bar .buttons.primary-buttons, - .bar .buttons.secondary-buttons { - display: inherit; } - .bar .buttons span { - display: inline-block; } - .bar .buttons-left span { - margin-right: 5px; - display: inherit; } - .bar .buttons-right span { - margin-left: 5px; - display: inherit; } - .bar .title + .button:last-child, - .bar > .button + .button:last-child, - .bar > .button.pull-right, - .bar .buttons.pull-right, - .bar .title + .buttons { - position: absolute; - top: 5px; - right: 5px; - bottom: 5px; } - -.platform-android .nav-bar-has-subheader .bar { - background-image: none; } - -.platform-android .bar .back-button .icon:before { - font-size: 24px; } - -.platform-android .bar .title { - font-size: 19px; - line-height: 44px; } - -.bar-light .button { - border-color: #ddd; - background-color: white; - color: #444; } - .bar-light .button:hover { - color: #444; - text-decoration: none; } - .bar-light .button.active, .bar-light .button.activated { - border-color: #ccc; - background-color: #fafafa; } - .bar-light .button.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #444; - font-size: 17px; } - .bar-light .button.button-icon { - border-color: transparent; - background: none; } - -.bar-stable .button { - border-color: #b2b2b2; - background-color: #f8f8f8; - color: #444; } - .bar-stable .button:hover { - color: #444; - text-decoration: none; } - .bar-stable .button.active, .bar-stable .button.activated { - border-color: #a2a2a2; - background-color: #e5e5e5; } - .bar-stable .button.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #444; - font-size: 17px; } - .bar-stable .button.button-icon { - border-color: transparent; - background: none; } - -.bar-positive .button { - border-color: #0c60ee; - background-color: #387ef5; - color: #fff; } - .bar-positive .button:hover { - color: #fff; - text-decoration: none; } - .bar-positive .button.active, .bar-positive .button.activated { - border-color: #0c60ee; - background-color: #0c60ee; } - .bar-positive .button.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #fff; - font-size: 17px; } - .bar-positive .button.button-icon { - border-color: transparent; - background: none; } - -.bar-calm .button { - border-color: #0a9dc7; - background-color: #11c1f3; - color: #fff; } - .bar-calm .button:hover { - color: #fff; - text-decoration: none; } - .bar-calm .button.active, .bar-calm .button.activated { - border-color: #0a9dc7; - background-color: #0a9dc7; } - .bar-calm .button.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #fff; - font-size: 17px; } - .bar-calm .button.button-icon { - border-color: transparent; - background: none; } - -.bar-assertive .button { - border-color: #e42112; - background-color: #ef473a; - color: #fff; } - .bar-assertive .button:hover { - color: #fff; - text-decoration: none; } - .bar-assertive .button.active, .bar-assertive .button.activated { - border-color: #e42112; - background-color: #e42112; } - .bar-assertive .button.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #fff; - font-size: 17px; } - .bar-assertive .button.button-icon { - border-color: transparent; - background: none; } - -.bar-balanced .button { - border-color: #28a54c; - background-color: #33cd5f; - color: #fff; } - .bar-balanced .button:hover { - color: #fff; - text-decoration: none; } - .bar-balanced .button.active, .bar-balanced .button.activated { - border-color: #28a54c; - background-color: #28a54c; } - .bar-balanced .button.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #fff; - font-size: 17px; } - .bar-balanced .button.button-icon { - border-color: transparent; - background: none; } - -.bar-energized .button { - border-color: #e6b500; - background-color: #ffc900; - color: #fff; } - .bar-energized .button:hover { - color: #fff; - text-decoration: none; } - .bar-energized .button.active, .bar-energized .button.activated { - border-color: #e6b500; - background-color: #e6b500; } - .bar-energized .button.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #fff; - font-size: 17px; } - .bar-energized .button.button-icon { - border-color: transparent; - background: none; } - -.bar-royal .button { - border-color: #6b46e5; - background-color: #886aea; - color: #fff; } - .bar-royal .button:hover { - color: #fff; - text-decoration: none; } - .bar-royal .button.active, .bar-royal .button.activated { - border-color: #6b46e5; - background-color: #6b46e5; } - .bar-royal .button.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #fff; - font-size: 17px; } - .bar-royal .button.button-icon { - border-color: transparent; - background: none; } - -.bar-dark .button { - border-color: #111; - background-color: #444444; - color: #fff; } - .bar-dark .button:hover { - color: #fff; - text-decoration: none; } - .bar-dark .button.active, .bar-dark .button.activated { - border-color: #000; - background-color: #262626; } - .bar-dark .button.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #fff; - font-size: 17px; } - .bar-dark .button.button-icon { - border-color: transparent; - background: none; } - -.bar-header { - top: 0; - border-top-width: 0; - border-bottom-width: 1px; } - .bar-header.has-tabs-top { - border-bottom-width: 0px; - background-image: none; } - -.tabs-top .bar-header { - border-bottom-width: 0px; - background-image: none; } - -.bar-footer { - bottom: 0; - border-top-width: 1px; - border-bottom-width: 0; - background-position: top; - height: 44px; } - .bar-footer.item-input-inset { - position: absolute; } - .bar-footer .title { - height: 43px; - line-height: 44px; } - -.bar-tabs { - padding: 0; } - -.bar-subheader { - top: 44px; - height: 44px; } - .bar-subheader .title { - height: 43px; - line-height: 44px; } - -.bar-subfooter { - bottom: 44px; - height: 44px; } - .bar-subfooter .title { - height: 43px; - line-height: 44px; } - -.nav-bar-block { - position: absolute; - top: 0; - right: 0; - left: 0; - z-index: 9; } - -.bar .back-button.hide, -.bar .buttons .hide { - display: none; } - -.nav-bar-tabs-top .bar { - background-image: none; } - -/** - * Tabs - * -------------------------------------------------- - * A navigation bar with any number of tab items supported. - */ -.tabs { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-direction: normal; - -webkit-box-orient: horizontal; - -webkit-flex-direction: horizontal; - -moz-flex-direction: horizontal; - -ms-flex-direction: horizontal; - flex-direction: horizontal; - -webkit-box-pack: center; - -ms-flex-pack: center; - -webkit-justify-content: center; - -moz-justify-content: center; - justify-content: center; - -webkit-transform: translate3d(0, 0, 0); - transform: translate3d(0, 0, 0); - border-color: #b2b2b2; - background-color: #f8f8f8; - background-image: linear-gradient(0deg, #b2b2b2, #b2b2b2 50%, transparent 50%); - color: #444; - position: absolute; - bottom: 0; - z-index: 5; - width: 100%; - height: 49px; - border-style: solid; - border-top-width: 1px; - background-size: 0; - line-height: 49px; } - .tabs .tab-item .badge { - background-color: #444; - color: #f8f8f8; } - @media (min--moz-device-pixel-ratio: 1.5), (-webkit-min-device-pixel-ratio: 1.5), (min-device-pixel-ratio: 1.5), (min-resolution: 144dpi), (min-resolution: 1.5dppx) { - .tabs { - padding-top: 2px; - border-top: none !important; - border-bottom: none; - background-position: top; - background-size: 100% 1px; - background-repeat: no-repeat; } } - -/* Allow parent element of tabs to define color, or just the tab itself */ -.tabs-light > .tabs, -.tabs.tabs-light { - border-color: #ddd; - background-color: #fff; - background-image: linear-gradient(0deg, #ddd, #ddd 50%, transparent 50%); - color: #444; } - .tabs-light > .tabs .tab-item .badge, - .tabs.tabs-light .tab-item .badge { - background-color: #444; - color: #fff; } - -.tabs-stable > .tabs, -.tabs.tabs-stable { - border-color: #b2b2b2; - background-color: #f8f8f8; - background-image: linear-gradient(0deg, #b2b2b2, #b2b2b2 50%, transparent 50%); - color: #444; } - .tabs-stable > .tabs .tab-item .badge, - .tabs.tabs-stable .tab-item .badge { - background-color: #444; - color: #f8f8f8; } - -.tabs-positive > .tabs, -.tabs.tabs-positive { - border-color: #0c60ee; - background-color: #387ef5; - background-image: linear-gradient(0deg, #0c60ee, #0c60ee 50%, transparent 50%); - color: #fff; } - .tabs-positive > .tabs .tab-item .badge, - .tabs.tabs-positive .tab-item .badge { - background-color: #fff; - color: #387ef5; } - -.tabs-calm > .tabs, -.tabs.tabs-calm { - border-color: #0a9dc7; - background-color: #11c1f3; - background-image: linear-gradient(0deg, #0a9dc7, #0a9dc7 50%, transparent 50%); - color: #fff; } - .tabs-calm > .tabs .tab-item .badge, - .tabs.tabs-calm .tab-item .badge { - background-color: #fff; - color: #11c1f3; } - -.tabs-assertive > .tabs, -.tabs.tabs-assertive { - border-color: #e42112; - background-color: #ef473a; - background-image: linear-gradient(0deg, #e42112, #e42112 50%, transparent 50%); - color: #fff; } - .tabs-assertive > .tabs .tab-item .badge, - .tabs.tabs-assertive .tab-item .badge { - background-color: #fff; - color: #ef473a; } - -.tabs-balanced > .tabs, -.tabs.tabs-balanced { - border-color: #28a54c; - background-color: #33cd5f; - background-image: linear-gradient(0deg, #28a54c, #28a54c 50%, transparent 50%); - color: #fff; } - .tabs-balanced > .tabs .tab-item .badge, - .tabs.tabs-balanced .tab-item .badge { - background-color: #fff; - color: #33cd5f; } - -.tabs-energized > .tabs, -.tabs.tabs-energized { - border-color: #e6b500; - background-color: #ffc900; - background-image: linear-gradient(0deg, #e6b500, #e6b500 50%, transparent 50%); - color: #fff; } - .tabs-energized > .tabs .tab-item .badge, - .tabs.tabs-energized .tab-item .badge { - background-color: #fff; - color: #ffc900; } - -.tabs-royal > .tabs, -.tabs.tabs-royal { - border-color: #6b46e5; - background-color: #886aea; - background-image: linear-gradient(0deg, #6b46e5, #6b46e5 50%, transparent 50%); - color: #fff; } - .tabs-royal > .tabs .tab-item .badge, - .tabs.tabs-royal .tab-item .badge { - background-color: #fff; - color: #886aea; } - -.tabs-dark > .tabs, -.tabs.tabs-dark { - border-color: #111; - background-color: #444; - background-image: linear-gradient(0deg, #111, #111 50%, transparent 50%); - color: #fff; } - .tabs-dark > .tabs .tab-item .badge, - .tabs.tabs-dark .tab-item .badge { - background-color: #fff; - color: #444; } - -.tabs-striped .tabs { - background-color: white; - background-image: none; - border: none; - border-bottom: 1px solid #ddd; - padding-top: 2px; } - -.tabs-striped .tab-item.tab-item-active, .tabs-striped .tab-item.active, .tabs-striped .tab-item.activated { - margin-top: -2px; - border-style: solid; - border-width: 2px 0 0 0; - border-color: #444; } - .tabs-striped .tab-item.tab-item-active .badge, .tabs-striped .tab-item.active .badge, .tabs-striped .tab-item.activated .badge { - top: 2px; - opacity: 1; } - -.tabs-striped.tabs-light .tabs { - background-color: #fff; } - -.tabs-striped.tabs-light .tab-item { - color: rgba(68, 68, 68, 0.4); - opacity: 1; } - .tabs-striped.tabs-light .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-light .tab-item.tab-item-active, .tabs-striped.tabs-light .tab-item.active, .tabs-striped.tabs-light .tab-item.activated { - margin-top: -2px; - color: #444; - border-style: solid; - border-width: 2px 0 0 0; - border-color: #444; } - -.tabs-striped.tabs-top .tab-item.tab-item-active .badge, .tabs-striped.tabs-top .tab-item.active .badge, .tabs-striped.tabs-top .tab-item.activated .badge { - top: 4%; } - -.tabs-striped.tabs-stable .tabs { - background-color: #f8f8f8; } - -.tabs-striped.tabs-stable .tab-item { - color: rgba(68, 68, 68, 0.4); - opacity: 1; } - .tabs-striped.tabs-stable .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-stable .tab-item.tab-item-active, .tabs-striped.tabs-stable .tab-item.active, .tabs-striped.tabs-stable .tab-item.activated { - margin-top: -2px; - color: #444; - border-style: solid; - border-width: 2px 0 0 0; - border-color: #444; } - -.tabs-striped.tabs-top .tab-item.tab-item-active .badge, .tabs-striped.tabs-top .tab-item.active .badge, .tabs-striped.tabs-top .tab-item.activated .badge { - top: 4%; } - -.tabs-striped.tabs-positive .tabs { - background-color: #387ef5; } - -.tabs-striped.tabs-positive .tab-item { - color: rgba(255, 255, 255, 0.4); - opacity: 1; } - .tabs-striped.tabs-positive .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-positive .tab-item.tab-item-active, .tabs-striped.tabs-positive .tab-item.active, .tabs-striped.tabs-positive .tab-item.activated { - margin-top: -2px; - color: #fff; - border-style: solid; - border-width: 2px 0 0 0; - border-color: #fff; } - -.tabs-striped.tabs-top .tab-item.tab-item-active .badge, .tabs-striped.tabs-top .tab-item.active .badge, .tabs-striped.tabs-top .tab-item.activated .badge { - top: 4%; } - -.tabs-striped.tabs-calm .tabs { - background-color: #11c1f3; } - -.tabs-striped.tabs-calm .tab-item { - color: rgba(255, 255, 255, 0.4); - opacity: 1; } - .tabs-striped.tabs-calm .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-calm .tab-item.tab-item-active, .tabs-striped.tabs-calm .tab-item.active, .tabs-striped.tabs-calm .tab-item.activated { - margin-top: -2px; - color: #fff; - border-style: solid; - border-width: 2px 0 0 0; - border-color: #fff; } - -.tabs-striped.tabs-top .tab-item.tab-item-active .badge, .tabs-striped.tabs-top .tab-item.active .badge, .tabs-striped.tabs-top .tab-item.activated .badge { - top: 4%; } - -.tabs-striped.tabs-assertive .tabs { - background-color: #ef473a; } - -.tabs-striped.tabs-assertive .tab-item { - color: rgba(255, 255, 255, 0.4); - opacity: 1; } - .tabs-striped.tabs-assertive .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-assertive .tab-item.tab-item-active, .tabs-striped.tabs-assertive .tab-item.active, .tabs-striped.tabs-assertive .tab-item.activated { - margin-top: -2px; - color: #fff; - border-style: solid; - border-width: 2px 0 0 0; - border-color: #fff; } - -.tabs-striped.tabs-top .tab-item.tab-item-active .badge, .tabs-striped.tabs-top .tab-item.active .badge, .tabs-striped.tabs-top .tab-item.activated .badge { - top: 4%; } - -.tabs-striped.tabs-balanced .tabs { - background-color: #33cd5f; } - -.tabs-striped.tabs-balanced .tab-item { - color: rgba(255, 255, 255, 0.4); - opacity: 1; } - .tabs-striped.tabs-balanced .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-balanced .tab-item.tab-item-active, .tabs-striped.tabs-balanced .tab-item.active, .tabs-striped.tabs-balanced .tab-item.activated { - margin-top: -2px; - color: #fff; - border-style: solid; - border-width: 2px 0 0 0; - border-color: #fff; } - -.tabs-striped.tabs-top .tab-item.tab-item-active .badge, .tabs-striped.tabs-top .tab-item.active .badge, .tabs-striped.tabs-top .tab-item.activated .badge { - top: 4%; } - -.tabs-striped.tabs-energized .tabs { - background-color: #ffc900; } - -.tabs-striped.tabs-energized .tab-item { - color: rgba(255, 255, 255, 0.4); - opacity: 1; } - .tabs-striped.tabs-energized .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-energized .tab-item.tab-item-active, .tabs-striped.tabs-energized .tab-item.active, .tabs-striped.tabs-energized .tab-item.activated { - margin-top: -2px; - color: #fff; - border-style: solid; - border-width: 2px 0 0 0; - border-color: #fff; } - -.tabs-striped.tabs-top .tab-item.tab-item-active .badge, .tabs-striped.tabs-top .tab-item.active .badge, .tabs-striped.tabs-top .tab-item.activated .badge { - top: 4%; } - -.tabs-striped.tabs-royal .tabs { - background-color: #886aea; } - -.tabs-striped.tabs-royal .tab-item { - color: rgba(255, 255, 255, 0.4); - opacity: 1; } - .tabs-striped.tabs-royal .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-royal .tab-item.tab-item-active, .tabs-striped.tabs-royal .tab-item.active, .tabs-striped.tabs-royal .tab-item.activated { - margin-top: -2px; - color: #fff; - border-style: solid; - border-width: 2px 0 0 0; - border-color: #fff; } - -.tabs-striped.tabs-top .tab-item.tab-item-active .badge, .tabs-striped.tabs-top .tab-item.active .badge, .tabs-striped.tabs-top .tab-item.activated .badge { - top: 4%; } - -.tabs-striped.tabs-dark .tabs { - background-color: #444; } - -.tabs-striped.tabs-dark .tab-item { - color: rgba(255, 255, 255, 0.4); - opacity: 1; } - .tabs-striped.tabs-dark .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-dark .tab-item.tab-item-active, .tabs-striped.tabs-dark .tab-item.active, .tabs-striped.tabs-dark .tab-item.activated { - margin-top: -2px; - color: #fff; - border-style: solid; - border-width: 2px 0 0 0; - border-color: #fff; } - -.tabs-striped.tabs-top .tab-item.tab-item-active .badge, .tabs-striped.tabs-top .tab-item.active .badge, .tabs-striped.tabs-top .tab-item.activated .badge { - top: 4%; } - -.tabs-striped.tabs-background-light .tabs { - background-color: #fff; - background-image: none; } - -.tabs-striped.tabs-background-stable .tabs { - background-color: #f8f8f8; - background-image: none; } - -.tabs-striped.tabs-background-positive .tabs { - background-color: #387ef5; - background-image: none; } - -.tabs-striped.tabs-background-calm .tabs { - background-color: #11c1f3; - background-image: none; } - -.tabs-striped.tabs-background-assertive .tabs { - background-color: #ef473a; - background-image: none; } - -.tabs-striped.tabs-background-balanced .tabs { - background-color: #33cd5f; - background-image: none; } - -.tabs-striped.tabs-background-energized .tabs { - background-color: #ffc900; - background-image: none; } - -.tabs-striped.tabs-background-royal .tabs { - background-color: #886aea; - background-image: none; } - -.tabs-striped.tabs-background-dark .tabs { - background-color: #444; - background-image: none; } - -.tabs-striped.tabs-color-light .tab-item { - color: rgba(255, 255, 255, 0.4); - opacity: 1; } - .tabs-striped.tabs-color-light .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-color-light .tab-item.tab-item-active, .tabs-striped.tabs-color-light .tab-item.active, .tabs-striped.tabs-color-light .tab-item.activated { - margin-top: -2px; - color: #fff; - border: 0 solid #fff; - border-top-width: 2px; } - .tabs-striped.tabs-color-light .tab-item.tab-item-active .badge, .tabs-striped.tabs-color-light .tab-item.active .badge, .tabs-striped.tabs-color-light .tab-item.activated .badge { - top: 2px; - opacity: 1; } - -.tabs-striped.tabs-color-stable .tab-item { - color: rgba(248, 248, 248, 0.4); - opacity: 1; } - .tabs-striped.tabs-color-stable .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-color-stable .tab-item.tab-item-active, .tabs-striped.tabs-color-stable .tab-item.active, .tabs-striped.tabs-color-stable .tab-item.activated { - margin-top: -2px; - color: #f8f8f8; - border: 0 solid #f8f8f8; - border-top-width: 2px; } - .tabs-striped.tabs-color-stable .tab-item.tab-item-active .badge, .tabs-striped.tabs-color-stable .tab-item.active .badge, .tabs-striped.tabs-color-stable .tab-item.activated .badge { - top: 2px; - opacity: 1; } - -.tabs-striped.tabs-color-positive .tab-item { - color: rgba(56, 126, 245, 0.4); - opacity: 1; } - .tabs-striped.tabs-color-positive .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-color-positive .tab-item.tab-item-active, .tabs-striped.tabs-color-positive .tab-item.active, .tabs-striped.tabs-color-positive .tab-item.activated { - margin-top: -2px; - color: #387ef5; - border: 0 solid #387ef5; - border-top-width: 2px; } - .tabs-striped.tabs-color-positive .tab-item.tab-item-active .badge, .tabs-striped.tabs-color-positive .tab-item.active .badge, .tabs-striped.tabs-color-positive .tab-item.activated .badge { - top: 2px; - opacity: 1; } - -.tabs-striped.tabs-color-calm .tab-item { - color: rgba(17, 193, 243, 0.4); - opacity: 1; } - .tabs-striped.tabs-color-calm .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-color-calm .tab-item.tab-item-active, .tabs-striped.tabs-color-calm .tab-item.active, .tabs-striped.tabs-color-calm .tab-item.activated { - margin-top: -2px; - color: #11c1f3; - border: 0 solid #11c1f3; - border-top-width: 2px; } - .tabs-striped.tabs-color-calm .tab-item.tab-item-active .badge, .tabs-striped.tabs-color-calm .tab-item.active .badge, .tabs-striped.tabs-color-calm .tab-item.activated .badge { - top: 2px; - opacity: 1; } - -.tabs-striped.tabs-color-assertive .tab-item { - color: rgba(239, 71, 58, 0.4); - opacity: 1; } - .tabs-striped.tabs-color-assertive .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-color-assertive .tab-item.tab-item-active, .tabs-striped.tabs-color-assertive .tab-item.active, .tabs-striped.tabs-color-assertive .tab-item.activated { - margin-top: -2px; - color: #ef473a; - border: 0 solid #ef473a; - border-top-width: 2px; } - .tabs-striped.tabs-color-assertive .tab-item.tab-item-active .badge, .tabs-striped.tabs-color-assertive .tab-item.active .badge, .tabs-striped.tabs-color-assertive .tab-item.activated .badge { - top: 2px; - opacity: 1; } - -.tabs-striped.tabs-color-balanced .tab-item { - color: rgba(51, 205, 95, 0.4); - opacity: 1; } - .tabs-striped.tabs-color-balanced .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-color-balanced .tab-item.tab-item-active, .tabs-striped.tabs-color-balanced .tab-item.active, .tabs-striped.tabs-color-balanced .tab-item.activated { - margin-top: -2px; - color: #33cd5f; - border: 0 solid #33cd5f; - border-top-width: 2px; } - .tabs-striped.tabs-color-balanced .tab-item.tab-item-active .badge, .tabs-striped.tabs-color-balanced .tab-item.active .badge, .tabs-striped.tabs-color-balanced .tab-item.activated .badge { - top: 2px; - opacity: 1; } - -.tabs-striped.tabs-color-energized .tab-item { - color: rgba(255, 201, 0, 0.4); - opacity: 1; } - .tabs-striped.tabs-color-energized .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-color-energized .tab-item.tab-item-active, .tabs-striped.tabs-color-energized .tab-item.active, .tabs-striped.tabs-color-energized .tab-item.activated { - margin-top: -2px; - color: #ffc900; - border: 0 solid #ffc900; - border-top-width: 2px; } - .tabs-striped.tabs-color-energized .tab-item.tab-item-active .badge, .tabs-striped.tabs-color-energized .tab-item.active .badge, .tabs-striped.tabs-color-energized .tab-item.activated .badge { - top: 2px; - opacity: 1; } - -.tabs-striped.tabs-color-royal .tab-item { - color: rgba(136, 106, 234, 0.4); - opacity: 1; } - .tabs-striped.tabs-color-royal .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-color-royal .tab-item.tab-item-active, .tabs-striped.tabs-color-royal .tab-item.active, .tabs-striped.tabs-color-royal .tab-item.activated { - margin-top: -2px; - color: #886aea; - border: 0 solid #886aea; - border-top-width: 2px; } - .tabs-striped.tabs-color-royal .tab-item.tab-item-active .badge, .tabs-striped.tabs-color-royal .tab-item.active .badge, .tabs-striped.tabs-color-royal .tab-item.activated .badge { - top: 2px; - opacity: 1; } - -.tabs-striped.tabs-color-dark .tab-item { - color: rgba(68, 68, 68, 0.4); - opacity: 1; } - .tabs-striped.tabs-color-dark .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-color-dark .tab-item.tab-item-active, .tabs-striped.tabs-color-dark .tab-item.active, .tabs-striped.tabs-color-dark .tab-item.activated { - margin-top: -2px; - color: #444; - border: 0 solid #444; - border-top-width: 2px; } - .tabs-striped.tabs-color-dark .tab-item.tab-item-active .badge, .tabs-striped.tabs-color-dark .tab-item.active .badge, .tabs-striped.tabs-color-dark .tab-item.activated .badge { - top: 2px; - opacity: 1; } - -.tabs-background-light .tabs, -.tabs-background-light > .tabs { - background-color: #fff; - background-image: linear-gradient(0deg, #ddd, #ddd 50%, transparent 50%); - border-color: #ddd; } - -.tabs-background-stable .tabs, -.tabs-background-stable > .tabs { - background-color: #f8f8f8; - background-image: linear-gradient(0deg, #b2b2b2, #b2b2b2 50%, transparent 50%); - border-color: #b2b2b2; } - -.tabs-background-positive .tabs, -.tabs-background-positive > .tabs { - background-color: #387ef5; - background-image: linear-gradient(0deg, #0c60ee, #0c60ee 50%, transparent 50%); - border-color: #0c60ee; } - -.tabs-background-calm .tabs, -.tabs-background-calm > .tabs { - background-color: #11c1f3; - background-image: linear-gradient(0deg, #0a9dc7, #0a9dc7 50%, transparent 50%); - border-color: #0a9dc7; } - -.tabs-background-assertive .tabs, -.tabs-background-assertive > .tabs { - background-color: #ef473a; - background-image: linear-gradient(0deg, #e42112, #e42112 50%, transparent 50%); - border-color: #e42112; } - -.tabs-background-balanced .tabs, -.tabs-background-balanced > .tabs { - background-color: #33cd5f; - background-image: linear-gradient(0deg, #28a54c, #28a54c 50%, transparent 50%); - border-color: #28a54c; } - -.tabs-background-energized .tabs, -.tabs-background-energized > .tabs { - background-color: #ffc900; - background-image: linear-gradient(0deg, #e6b500, #e6b500 50%, transparent 50%); - border-color: #e6b500; } - -.tabs-background-royal .tabs, -.tabs-background-royal > .tabs { - background-color: #886aea; - background-image: linear-gradient(0deg, #6b46e5, #6b46e5 50%, transparent 50%); - border-color: #6b46e5; } - -.tabs-background-dark .tabs, -.tabs-background-dark > .tabs { - background-color: #444; - background-image: linear-gradient(0deg, #111, #111 50%, transparent 50%); - border-color: #111; } - -.tabs-color-light .tab-item { - color: rgba(255, 255, 255, 0.4); - opacity: 1; } - .tabs-color-light .tab-item .badge { - opacity: 0.4; } - .tabs-color-light .tab-item.tab-item-active, .tabs-color-light .tab-item.active, .tabs-color-light .tab-item.activated { - color: #fff; - border: 0 solid #fff; } - .tabs-color-light .tab-item.tab-item-active .badge, .tabs-color-light .tab-item.active .badge, .tabs-color-light .tab-item.activated .badge { - opacity: 1; } - -.tabs-color-stable .tab-item { - color: rgba(248, 248, 248, 0.4); - opacity: 1; } - .tabs-color-stable .tab-item .badge { - opacity: 0.4; } - .tabs-color-stable .tab-item.tab-item-active, .tabs-color-stable .tab-item.active, .tabs-color-stable .tab-item.activated { - color: #f8f8f8; - border: 0 solid #f8f8f8; } - .tabs-color-stable .tab-item.tab-item-active .badge, .tabs-color-stable .tab-item.active .badge, .tabs-color-stable .tab-item.activated .badge { - opacity: 1; } - -.tabs-color-positive .tab-item { - color: rgba(56, 126, 245, 0.4); - opacity: 1; } - .tabs-color-positive .tab-item .badge { - opacity: 0.4; } - .tabs-color-positive .tab-item.tab-item-active, .tabs-color-positive .tab-item.active, .tabs-color-positive .tab-item.activated { - color: #387ef5; - border: 0 solid #387ef5; } - .tabs-color-positive .tab-item.tab-item-active .badge, .tabs-color-positive .tab-item.active .badge, .tabs-color-positive .tab-item.activated .badge { - opacity: 1; } - -.tabs-color-calm .tab-item { - color: rgba(17, 193, 243, 0.4); - opacity: 1; } - .tabs-color-calm .tab-item .badge { - opacity: 0.4; } - .tabs-color-calm .tab-item.tab-item-active, .tabs-color-calm .tab-item.active, .tabs-color-calm .tab-item.activated { - color: #11c1f3; - border: 0 solid #11c1f3; } - .tabs-color-calm .tab-item.tab-item-active .badge, .tabs-color-calm .tab-item.active .badge, .tabs-color-calm .tab-item.activated .badge { - opacity: 1; } - -.tabs-color-assertive .tab-item { - color: rgba(239, 71, 58, 0.4); - opacity: 1; } - .tabs-color-assertive .tab-item .badge { - opacity: 0.4; } - .tabs-color-assertive .tab-item.tab-item-active, .tabs-color-assertive .tab-item.active, .tabs-color-assertive .tab-item.activated { - color: #ef473a; - border: 0 solid #ef473a; } - .tabs-color-assertive .tab-item.tab-item-active .badge, .tabs-color-assertive .tab-item.active .badge, .tabs-color-assertive .tab-item.activated .badge { - opacity: 1; } - -.tabs-color-balanced .tab-item { - color: rgba(51, 205, 95, 0.4); - opacity: 1; } - .tabs-color-balanced .tab-item .badge { - opacity: 0.4; } - .tabs-color-balanced .tab-item.tab-item-active, .tabs-color-balanced .tab-item.active, .tabs-color-balanced .tab-item.activated { - color: #33cd5f; - border: 0 solid #33cd5f; } - .tabs-color-balanced .tab-item.tab-item-active .badge, .tabs-color-balanced .tab-item.active .badge, .tabs-color-balanced .tab-item.activated .badge { - opacity: 1; } - -.tabs-color-energized .tab-item { - color: rgba(255, 201, 0, 0.4); - opacity: 1; } - .tabs-color-energized .tab-item .badge { - opacity: 0.4; } - .tabs-color-energized .tab-item.tab-item-active, .tabs-color-energized .tab-item.active, .tabs-color-energized .tab-item.activated { - color: #ffc900; - border: 0 solid #ffc900; } - .tabs-color-energized .tab-item.tab-item-active .badge, .tabs-color-energized .tab-item.active .badge, .tabs-color-energized .tab-item.activated .badge { - opacity: 1; } - -.tabs-color-royal .tab-item { - color: rgba(136, 106, 234, 0.4); - opacity: 1; } - .tabs-color-royal .tab-item .badge { - opacity: 0.4; } - .tabs-color-royal .tab-item.tab-item-active, .tabs-color-royal .tab-item.active, .tabs-color-royal .tab-item.activated { - color: #886aea; - border: 0 solid #886aea; } - .tabs-color-royal .tab-item.tab-item-active .badge, .tabs-color-royal .tab-item.active .badge, .tabs-color-royal .tab-item.activated .badge { - opacity: 1; } - -.tabs-color-dark .tab-item { - color: rgba(68, 68, 68, 0.4); - opacity: 1; } - .tabs-color-dark .tab-item .badge { - opacity: 0.4; } - .tabs-color-dark .tab-item.tab-item-active, .tabs-color-dark .tab-item.active, .tabs-color-dark .tab-item.activated { - color: #444; - border: 0 solid #444; } - .tabs-color-dark .tab-item.tab-item-active .badge, .tabs-color-dark .tab-item.active .badge, .tabs-color-dark .tab-item.activated .badge { - opacity: 1; } - -ion-tabs.tabs-color-active-light .tab-item { - color: #444; } - ion-tabs.tabs-color-active-light .tab-item.tab-item-active, ion-tabs.tabs-color-active-light .tab-item.active, ion-tabs.tabs-color-active-light .tab-item.activated { - color: #fff; } - -ion-tabs.tabs-striped.tabs-color-active-light .tab-item.tab-item-active, ion-tabs.tabs-striped.tabs-color-active-light .tab-item.active, ion-tabs.tabs-striped.tabs-color-active-light .tab-item.activated { - border-color: #fff; - color: #fff; } - -ion-tabs.tabs-color-active-stable .tab-item { - color: #444; } - ion-tabs.tabs-color-active-stable .tab-item.tab-item-active, ion-tabs.tabs-color-active-stable .tab-item.active, ion-tabs.tabs-color-active-stable .tab-item.activated { - color: #f8f8f8; } - -ion-tabs.tabs-striped.tabs-color-active-stable .tab-item.tab-item-active, ion-tabs.tabs-striped.tabs-color-active-stable .tab-item.active, ion-tabs.tabs-striped.tabs-color-active-stable .tab-item.activated { - border-color: #f8f8f8; - color: #f8f8f8; } - -ion-tabs.tabs-color-active-positive .tab-item { - color: #444; } - ion-tabs.tabs-color-active-positive .tab-item.tab-item-active, ion-tabs.tabs-color-active-positive .tab-item.active, ion-tabs.tabs-color-active-positive .tab-item.activated { - color: #387ef5; } - -ion-tabs.tabs-striped.tabs-color-active-positive .tab-item.tab-item-active, ion-tabs.tabs-striped.tabs-color-active-positive .tab-item.active, ion-tabs.tabs-striped.tabs-color-active-positive .tab-item.activated { - border-color: #387ef5; - color: #387ef5; } - -ion-tabs.tabs-color-active-calm .tab-item { - color: #444; } - ion-tabs.tabs-color-active-calm .tab-item.tab-item-active, ion-tabs.tabs-color-active-calm .tab-item.active, ion-tabs.tabs-color-active-calm .tab-item.activated { - color: #11c1f3; } - -ion-tabs.tabs-striped.tabs-color-active-calm .tab-item.tab-item-active, ion-tabs.tabs-striped.tabs-color-active-calm .tab-item.active, ion-tabs.tabs-striped.tabs-color-active-calm .tab-item.activated { - border-color: #11c1f3; - color: #11c1f3; } - -ion-tabs.tabs-color-active-assertive .tab-item { - color: #444; } - ion-tabs.tabs-color-active-assertive .tab-item.tab-item-active, ion-tabs.tabs-color-active-assertive .tab-item.active, ion-tabs.tabs-color-active-assertive .tab-item.activated { - color: #ef473a; } - -ion-tabs.tabs-striped.tabs-color-active-assertive .tab-item.tab-item-active, ion-tabs.tabs-striped.tabs-color-active-assertive .tab-item.active, ion-tabs.tabs-striped.tabs-color-active-assertive .tab-item.activated { - border-color: #ef473a; - color: #ef473a; } - -ion-tabs.tabs-color-active-balanced .tab-item { - color: #444; } - ion-tabs.tabs-color-active-balanced .tab-item.tab-item-active, ion-tabs.tabs-color-active-balanced .tab-item.active, ion-tabs.tabs-color-active-balanced .tab-item.activated { - color: #33cd5f; } - -ion-tabs.tabs-striped.tabs-color-active-balanced .tab-item.tab-item-active, ion-tabs.tabs-striped.tabs-color-active-balanced .tab-item.active, ion-tabs.tabs-striped.tabs-color-active-balanced .tab-item.activated { - border-color: #33cd5f; - color: #33cd5f; } - -ion-tabs.tabs-color-active-energized .tab-item { - color: #444; } - ion-tabs.tabs-color-active-energized .tab-item.tab-item-active, ion-tabs.tabs-color-active-energized .tab-item.active, ion-tabs.tabs-color-active-energized .tab-item.activated { - color: #ffc900; } - -ion-tabs.tabs-striped.tabs-color-active-energized .tab-item.tab-item-active, ion-tabs.tabs-striped.tabs-color-active-energized .tab-item.active, ion-tabs.tabs-striped.tabs-color-active-energized .tab-item.activated { - border-color: #ffc900; - color: #ffc900; } - -ion-tabs.tabs-color-active-royal .tab-item { - color: #444; } - ion-tabs.tabs-color-active-royal .tab-item.tab-item-active, ion-tabs.tabs-color-active-royal .tab-item.active, ion-tabs.tabs-color-active-royal .tab-item.activated { - color: #886aea; } - -ion-tabs.tabs-striped.tabs-color-active-royal .tab-item.tab-item-active, ion-tabs.tabs-striped.tabs-color-active-royal .tab-item.active, ion-tabs.tabs-striped.tabs-color-active-royal .tab-item.activated { - border-color: #886aea; - color: #886aea; } - -ion-tabs.tabs-color-active-dark .tab-item { - color: #fff; } - ion-tabs.tabs-color-active-dark .tab-item.tab-item-active, ion-tabs.tabs-color-active-dark .tab-item.active, ion-tabs.tabs-color-active-dark .tab-item.activated { - color: #444; } - -ion-tabs.tabs-striped.tabs-color-active-dark .tab-item.tab-item-active, ion-tabs.tabs-striped.tabs-color-active-dark .tab-item.active, ion-tabs.tabs-striped.tabs-color-active-dark .tab-item.activated { - border-color: #444; - color: #444; } - -.tabs-top.tabs-striped { - padding-bottom: 0; } - .tabs-top.tabs-striped .tab-item { - background: transparent; - -webkit-transition: color .1s ease; - -moz-transition: color .1s ease; - -ms-transition: color .1s ease; - -o-transition: color .1s ease; - transition: color .1s ease; } - .tabs-top.tabs-striped .tab-item.tab-item-active, .tabs-top.tabs-striped .tab-item.active, .tabs-top.tabs-striped .tab-item.activated { - margin-top: 1px; - border-width: 0px 0px 2px 0px !important; - border-style: solid; } - .tabs-top.tabs-striped .tab-item.tab-item-active > .badge, .tabs-top.tabs-striped .tab-item.tab-item-active > i, .tabs-top.tabs-striped .tab-item.active > .badge, .tabs-top.tabs-striped .tab-item.active > i, .tabs-top.tabs-striped .tab-item.activated > .badge, .tabs-top.tabs-striped .tab-item.activated > i { - margin-top: -1px; } - .tabs-top.tabs-striped .tab-item .badge { - -webkit-transition: color .2s ease; - -moz-transition: color .2s ease; - -ms-transition: color .2s ease; - -o-transition: color .2s ease; - transition: color .2s ease; } - .tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.tab-item-active .tab-title, .tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.tab-item-active i, .tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.active .tab-title, .tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.active i, .tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.activated .tab-title, .tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.activated i { - display: block; - margin-top: -1px; } - .tabs-top.tabs-striped.tabs-icon-left .tab-item { - margin-top: 1px; } - .tabs-top.tabs-striped.tabs-icon-left .tab-item.tab-item-active .tab-title, .tabs-top.tabs-striped.tabs-icon-left .tab-item.tab-item-active i, .tabs-top.tabs-striped.tabs-icon-left .tab-item.active .tab-title, .tabs-top.tabs-striped.tabs-icon-left .tab-item.active i, .tabs-top.tabs-striped.tabs-icon-left .tab-item.activated .tab-title, .tabs-top.tabs-striped.tabs-icon-left .tab-item.activated i { - margin-top: -0.1em; } - -/* Allow parent element to have tabs-top */ -/* If you change this, change platform.scss as well */ -.tabs-top > .tabs, -.tabs.tabs-top { - top: 44px; - padding-top: 0; - background-position: bottom; - border-top-width: 0; - border-bottom-width: 1px; } - .tabs-top > .tabs .tab-item.tab-item-active .badge, .tabs-top > .tabs .tab-item.active .badge, .tabs-top > .tabs .tab-item.activated .badge, - .tabs.tabs-top .tab-item.tab-item-active .badge, - .tabs.tabs-top .tab-item.active .badge, - .tabs.tabs-top .tab-item.activated .badge { - top: 4%; } - -.tabs-top ~ .bar-header { - border-bottom-width: 0; } - -.tab-item { - -webkit-box-flex: 1; - -webkit-flex: 1; - -moz-box-flex: 1; - -moz-flex: 1; - -ms-flex: 1; - flex: 1; - display: block; - overflow: hidden; - max-width: 150px; - height: 100%; - color: inherit; - text-align: center; - text-decoration: none; - text-overflow: ellipsis; - white-space: nowrap; - font-weight: 400; - font-size: 14px; - font-family: "-apple-system", "Helvetica Neue", "Roboto", "Segoe UI", sans-serif; - opacity: 0.7; } - .tab-item:hover { - cursor: pointer; } - .tab-item.tab-hidden { - display: none; } - -.tabs-item-hide > .tabs, -.tabs.tabs-item-hide { - display: none; } - -.tabs-icon-top > .tabs .tab-item, -.tabs-icon-top.tabs .tab-item, -.tabs-icon-bottom > .tabs .tab-item, -.tabs-icon-bottom.tabs .tab-item { - font-size: 10px; - line-height: 14px; } - -.tab-item .icon { - display: block; - margin: 0 auto; - height: 32px; - font-size: 32px; } - -.tabs-icon-left.tabs .tab-item, -.tabs-icon-left > .tabs .tab-item, -.tabs-icon-right.tabs .tab-item, -.tabs-icon-right > .tabs .tab-item { - font-size: 10px; } - .tabs-icon-left.tabs .tab-item .icon, .tabs-icon-left.tabs .tab-item .tab-title, - .tabs-icon-left > .tabs .tab-item .icon, - .tabs-icon-left > .tabs .tab-item .tab-title, - .tabs-icon-right.tabs .tab-item .icon, - .tabs-icon-right.tabs .tab-item .tab-title, - .tabs-icon-right > .tabs .tab-item .icon, - .tabs-icon-right > .tabs .tab-item .tab-title { - display: inline-block; - vertical-align: top; - margin-top: -.1em; } - .tabs-icon-left.tabs .tab-item .icon:before, .tabs-icon-left.tabs .tab-item .tab-title:before, - .tabs-icon-left > .tabs .tab-item .icon:before, - .tabs-icon-left > .tabs .tab-item .tab-title:before, - .tabs-icon-right.tabs .tab-item .icon:before, - .tabs-icon-right.tabs .tab-item .tab-title:before, - .tabs-icon-right > .tabs .tab-item .icon:before, - .tabs-icon-right > .tabs .tab-item .tab-title:before { - font-size: 24px; - line-height: 49px; } - -.tabs-icon-left > .tabs .tab-item .icon, -.tabs-icon-left.tabs .tab-item .icon { - padding-right: 3px; } - -.tabs-icon-right > .tabs .tab-item .icon, -.tabs-icon-right.tabs .tab-item .icon { - padding-left: 3px; } - -.tabs-icon-only > .tabs .icon, -.tabs-icon-only.tabs .icon { - line-height: inherit; } - -.tab-item.has-badge { - position: relative; } - -.tab-item .badge { - position: absolute; - top: 4%; - right: 33%; - right: calc(50% - 26px); - padding: 1px 6px; - height: auto; - font-size: 12px; - line-height: 16px; } - -/* Navigational tab */ -/* Active state for tab */ -.tab-item.tab-item-active, -.tab-item.active, -.tab-item.activated { - opacity: 1; } - .tab-item.tab-item-active.tab-item-light, - .tab-item.active.tab-item-light, - .tab-item.activated.tab-item-light { - color: #fff; } - .tab-item.tab-item-active.tab-item-stable, - .tab-item.active.tab-item-stable, - .tab-item.activated.tab-item-stable { - color: #f8f8f8; } - .tab-item.tab-item-active.tab-item-positive, - .tab-item.active.tab-item-positive, - .tab-item.activated.tab-item-positive { - color: #387ef5; } - .tab-item.tab-item-active.tab-item-calm, - .tab-item.active.tab-item-calm, - .tab-item.activated.tab-item-calm { - color: #11c1f3; } - .tab-item.tab-item-active.tab-item-assertive, - .tab-item.active.tab-item-assertive, - .tab-item.activated.tab-item-assertive { - color: #ef473a; } - .tab-item.tab-item-active.tab-item-balanced, - .tab-item.active.tab-item-balanced, - .tab-item.activated.tab-item-balanced { - color: #33cd5f; } - .tab-item.tab-item-active.tab-item-energized, - .tab-item.active.tab-item-energized, - .tab-item.activated.tab-item-energized { - color: #ffc900; } - .tab-item.tab-item-active.tab-item-royal, - .tab-item.active.tab-item-royal, - .tab-item.activated.tab-item-royal { - color: #886aea; } - .tab-item.tab-item-active.tab-item-dark, - .tab-item.active.tab-item-dark, - .tab-item.activated.tab-item-dark { - color: #444; } - -.item.tabs { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - padding: 0; } - .item.tabs .icon:before { - position: relative; } - -.tab-item.disabled, -.tab-item[disabled] { - opacity: .4; - cursor: default; - pointer-events: none; } - -.nav-bar-tabs-top.hide ~ .view-container .tabs-top .tabs { - top: 0; } - -.pane[hide-nav-bar="true"] .has-tabs-top { - top: 49px; } - -/** - * Menus - * -------------------------------------------------- - * Side panel structure - */ -.menu { - position: absolute; - top: 0; - bottom: 0; - z-index: 0; - overflow: hidden; - min-height: 100%; - max-height: 100%; - width: 275px; - background-color: #fff; } - .menu .scroll-content { - z-index: 10; } - .menu .bar-header { - z-index: 11; } - -.menu-content { - -webkit-transform: none; - transform: none; - box-shadow: -1px 0px 2px rgba(0, 0, 0, 0.2), 1px 0px 2px rgba(0, 0, 0, 0.2); } - -.menu-open .menu-content .pane, -.menu-open .menu-content .scroll-content { - pointer-events: none; } - -.menu-open .menu-content .scroll-content .scroll { - pointer-events: none; } - -.menu-open .menu-content .scroll-content:not(.overflow-scroll) { - overflow: hidden; } - -.grade-b .menu-content, -.grade-c .menu-content { - -webkit-box-sizing: content-box; - -moz-box-sizing: content-box; - box-sizing: content-box; - right: -1px; - left: -1px; - border-right: 1px solid #ccc; - border-left: 1px solid #ccc; - box-shadow: none; } - -.menu-left { - left: 0; } - -.menu-right { - right: 0; } - -.aside-open.aside-resizing .menu-right { - display: none; } - -.menu-animated { - -webkit-transition: -webkit-transform 200ms ease; - transition: transform 200ms ease; } - -/** - * Modals - * -------------------------------------------------- - * Modals are independent windows that slide in from off-screen. - */ -.modal-backdrop, -.modal-backdrop-bg { - position: fixed; - top: 0; - left: 0; - z-index: 10; - width: 100%; - height: 100%; } - -.modal-backdrop-bg { - pointer-events: none; } - -.modal { - display: block; - position: absolute; - top: 0; - z-index: 10; - overflow: hidden; - min-height: 100%; - width: 100%; - background-color: #fff; } - -@media (min-width: 680px) { - .modal { - top: 20%; - right: 20%; - bottom: 20%; - left: 20%; - min-height: 240px; - width: 60%; } - .modal.ng-leave-active { - bottom: 0; } - .platform-ios.platform-cordova .modal-wrapper .modal .bar-header:not(.bar-subheader) { - height: 44px; } - .platform-ios.platform-cordova .modal-wrapper .modal .bar-header:not(.bar-subheader) > * { - margin-top: 0; } - .platform-ios.platform-cordova .modal-wrapper .modal .tabs-top > .tabs, - .platform-ios.platform-cordova .modal-wrapper .modal .tabs.tabs-top { - top: 44px; } - .platform-ios.platform-cordova .modal-wrapper .modal .has-header, - .platform-ios.platform-cordova .modal-wrapper .modal .bar-subheader { - top: 44px; } - .platform-ios.platform-cordova .modal-wrapper .modal .has-subheader { - top: 88px; } - .platform-ios.platform-cordova .modal-wrapper .modal .has-header.has-tabs-top { - top: 93px; } - .platform-ios.platform-cordova .modal-wrapper .modal .has-header.has-subheader.has-tabs-top { - top: 137px; } - .modal-backdrop-bg { - -webkit-transition: opacity 300ms ease-in-out; - transition: opacity 300ms ease-in-out; - background-color: #000; - opacity: 0; } - .active .modal-backdrop-bg { - opacity: 0.5; } } - -.modal-open { - pointer-events: none; } - .modal-open .modal, - .modal-open .modal-backdrop { - pointer-events: auto; } - .modal-open.loading-active .modal, - .modal-open.loading-active .modal-backdrop { - pointer-events: none; } - -/** - * Popovers - * -------------------------------------------------- - * Popovers are independent views which float over content - */ -.popover-backdrop { - position: fixed; - top: 0; - left: 0; - z-index: 10; - width: 100%; - height: 100%; - background-color: transparent; } - .popover-backdrop.active { - background-color: rgba(0, 0, 0, 0.1); } - -.popover { - position: absolute; - top: 25%; - left: 50%; - z-index: 10; - display: block; - margin-top: 12px; - margin-left: -110px; - height: 280px; - width: 220px; - background-color: #fff; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); - opacity: 0; } - .popover .item:first-child { - border-top: 0; } - .popover .item:last-child { - border-bottom: 0; } - .popover.popover-bottom { - margin-top: -12px; } - -.popover, -.popover .bar-header { - border-radius: 2px; } - -.popover .scroll-content { - z-index: 1; - margin: 2px 0; } - -.popover .bar-header { - border-bottom-right-radius: 0; - border-bottom-left-radius: 0; } - -.popover .has-header { - border-top-right-radius: 0; - border-top-left-radius: 0; } - -.popover-arrow { - display: none; } - -.platform-ios .popover { - box-shadow: 0 0 40px rgba(0, 0, 0, 0.08); - border-radius: 10px; } - -.platform-ios .popover .bar-header { - -webkit-border-top-right-radius: 10px; - border-top-right-radius: 10px; - -webkit-border-top-left-radius: 10px; - border-top-left-radius: 10px; } - -.platform-ios .popover .scroll-content { - margin: 8px 0; - border-radius: 10px; } - -.platform-ios .popover .scroll-content.has-header { - margin-top: 0; } - -.platform-ios .popover-arrow { - position: absolute; - display: block; - top: -17px; - width: 30px; - height: 19px; - overflow: hidden; } - .platform-ios .popover-arrow:after { - position: absolute; - top: 12px; - left: 5px; - width: 20px; - height: 20px; - background-color: #fff; - border-radius: 3px; - content: ''; - -webkit-transform: rotate(-45deg); - transform: rotate(-45deg); } - -.platform-ios .popover-bottom .popover-arrow { - top: auto; - bottom: -10px; } - .platform-ios .popover-bottom .popover-arrow:after { - top: -6px; } - -.platform-android .popover { - margin-top: -32px; - background-color: #fafafa; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35); } - .platform-android .popover .item { - border-color: #fafafa; - background-color: #fafafa; - color: #4d4d4d; } - .platform-android .popover.popover-bottom { - margin-top: 32px; } - -.platform-android .popover-backdrop, -.platform-android .popover-backdrop.active { - background-color: transparent; } - -.popover-open { - pointer-events: none; } - .popover-open .popover, - .popover-open .popover-backdrop { - pointer-events: auto; } - .popover-open.loading-active .popover, - .popover-open.loading-active .popover-backdrop { - pointer-events: none; } - -@media (min-width: 680px) { - .popover { - width: 360px; - margin-left: -180px; } } - -/** - * Popups - * -------------------------------------------------- - */ -.popup-container { - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - background: transparent; - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-pack: center; - -ms-flex-pack: center; - -webkit-justify-content: center; - -moz-justify-content: center; - justify-content: center; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; - z-index: 12; - visibility: hidden; } - .popup-container.popup-showing { - visibility: visible; } - .popup-container.popup-hidden .popup { - -webkit-animation-name: scaleOut; - animation-name: scaleOut; - -webkit-animation-duration: 0.1s; - animation-duration: 0.1s; - -webkit-animation-timing-function: ease-in-out; - animation-timing-function: ease-in-out; - -webkit-animation-fill-mode: both; - animation-fill-mode: both; } - .popup-container.active .popup { - -webkit-animation-name: superScaleIn; - animation-name: superScaleIn; - -webkit-animation-duration: 0.2s; - animation-duration: 0.2s; - -webkit-animation-timing-function: ease-in-out; - animation-timing-function: ease-in-out; - -webkit-animation-fill-mode: both; - animation-fill-mode: both; } - .popup-container .popup { - width: 250px; - max-width: 100%; - max-height: 90%; - border-radius: 0px; - background-color: rgba(255, 255, 255, 0.9); - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-direction: normal; - -webkit-box-orient: vertical; - -webkit-flex-direction: column; - -moz-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; } - .popup-container input, - .popup-container textarea { - width: 100%; } - -.popup-head { - padding: 15px 10px; - border-bottom: 1px solid #eee; - text-align: center; } - -.popup-title { - margin: 0; - padding: 0; - font-size: 15px; } - -.popup-sub-title { - margin: 5px 0 0 0; - padding: 0; - font-weight: normal; - font-size: 11px; } - -.popup-body { - padding: 10px; - overflow: auto; } - -.popup-buttons { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-direction: normal; - -webkit-box-orient: horizontal; - -webkit-flex-direction: row; - -moz-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - padding: 10px; - min-height: 65px; } - .popup-buttons .button { - -webkit-box-flex: 1; - -webkit-flex: 1; - -moz-box-flex: 1; - -moz-flex: 1; - -ms-flex: 1; - flex: 1; - display: block; - min-height: 45px; - border-radius: 2px; - line-height: 20px; - margin-right: 5px; } - .popup-buttons .button:last-child { - margin-right: 0px; } - -.popup-open { - pointer-events: none; } - .popup-open.modal-open .modal { - pointer-events: none; } - .popup-open .popup-backdrop, .popup-open .popup { - pointer-events: auto; } - -/** - * Loading - * -------------------------------------------------- - */ -.loading-container { - position: absolute; - left: 0; - top: 0; - right: 0; - bottom: 0; - z-index: 13; - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-pack: center; - -ms-flex-pack: center; - -webkit-justify-content: center; - -moz-justify-content: center; - justify-content: center; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; - -webkit-transition: 0.2s opacity linear; - transition: 0.2s opacity linear; - visibility: hidden; - opacity: 0; } - .loading-container:not(.visible) .icon, - .loading-container:not(.visible) .spinner { - display: none; } - .loading-container.visible { - visibility: visible; } - .loading-container.active { - opacity: 1; } - .loading-container .loading { - padding: 20px; - border-radius: 5px; - background-color: rgba(0, 0, 0, 0.7); - color: #fff; - text-align: center; - text-overflow: ellipsis; - font-size: 15px; } - .loading-container .loading h1, .loading-container .loading h2, .loading-container .loading h3, .loading-container .loading h4, .loading-container .loading h5, .loading-container .loading h6 { - color: #fff; } - -/** - * Items - * -------------------------------------------------- - */ -.item { - border-color: #ddd; - background-color: #fff; - color: #444; - position: relative; - z-index: 2; - display: block; - margin: -1px; - padding: 16px; - border-width: 1px; - border-style: solid; - font-size: 16px; } - .item h2 { - margin: 0 0 2px 0; - font-size: 16px; - font-weight: normal; } - .item h3 { - margin: 0 0 4px 0; - font-size: 14px; } - .item h4 { - margin: 0 0 4px 0; - font-size: 12px; } - .item h5, .item h6 { - margin: 0 0 3px 0; - font-size: 10px; } - .item p { - color: #666; - font-size: 14px; - margin-bottom: 2px; } - .item h1:last-child, - .item h2:last-child, - .item h3:last-child, - .item h4:last-child, - .item h5:last-child, - .item h6:last-child, - .item p:last-child { - margin-bottom: 0; } - .item .badge { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - position: absolute; - top: 16px; - right: 32px; } - .item.item-button-right .badge { - right: 67px; } - .item.item-divider .badge { - top: 8px; } - .item .badge + .badge { - margin-right: 5px; } - .item.item-light { - border-color: #ddd; - background-color: #fff; - color: #444; } - .item.item-stable { - border-color: #b2b2b2; - background-color: #f8f8f8; - color: #444; } - .item.item-positive { - border-color: #0c60ee; - background-color: #387ef5; - color: #fff; } - .item.item-calm { - border-color: #0a9dc7; - background-color: #11c1f3; - color: #fff; } - .item.item-assertive { - border-color: #e42112; - background-color: #ef473a; - color: #fff; } - .item.item-balanced { - border-color: #28a54c; - background-color: #33cd5f; - color: #fff; } - .item.item-energized { - border-color: #e6b500; - background-color: #ffc900; - color: #fff; } - .item.item-royal { - border-color: #6b46e5; - background-color: #886aea; - color: #fff; } - .item.item-dark { - border-color: #111; - background-color: #444; - color: #fff; } - .item[ng-click]:hover { - cursor: pointer; } - -.list-borderless .item, -.item-borderless { - border-width: 0; } - -.item.active, -.item.activated, -.item-complex.active .item-content, -.item-complex.activated .item-content, -.item .item-content.active, -.item .item-content.activated { - border-color: #ccc; - background-color: #D9D9D9; } - .item.active.item-complex > .item-content, - .item.activated.item-complex > .item-content, - .item-complex.active .item-content.item-complex > .item-content, - .item-complex.activated .item-content.item-complex > .item-content, - .item .item-content.active.item-complex > .item-content, - .item .item-content.activated.item-complex > .item-content { - border-color: #ccc; - background-color: #D9D9D9; } - .item.active.item-light, - .item.activated.item-light, - .item-complex.active .item-content.item-light, - .item-complex.activated .item-content.item-light, - .item .item-content.active.item-light, - .item .item-content.activated.item-light { - border-color: #ccc; - background-color: #fafafa; } - .item.active.item-light.item-complex > .item-content, - .item.activated.item-light.item-complex > .item-content, - .item-complex.active .item-content.item-light.item-complex > .item-content, - .item-complex.activated .item-content.item-light.item-complex > .item-content, - .item .item-content.active.item-light.item-complex > .item-content, - .item .item-content.activated.item-light.item-complex > .item-content { - border-color: #ccc; - background-color: #fafafa; } - .item.active.item-stable, - .item.activated.item-stable, - .item-complex.active .item-content.item-stable, - .item-complex.activated .item-content.item-stable, - .item .item-content.active.item-stable, - .item .item-content.activated.item-stable { - border-color: #a2a2a2; - background-color: #e5e5e5; } - .item.active.item-stable.item-complex > .item-content, - .item.activated.item-stable.item-complex > .item-content, - .item-complex.active .item-content.item-stable.item-complex > .item-content, - .item-complex.activated .item-content.item-stable.item-complex > .item-content, - .item .item-content.active.item-stable.item-complex > .item-content, - .item .item-content.activated.item-stable.item-complex > .item-content { - border-color: #a2a2a2; - background-color: #e5e5e5; } - .item.active.item-positive, - .item.activated.item-positive, - .item-complex.active .item-content.item-positive, - .item-complex.activated .item-content.item-positive, - .item .item-content.active.item-positive, - .item .item-content.activated.item-positive { - border-color: #0c60ee; - background-color: #0c60ee; } - .item.active.item-positive.item-complex > .item-content, - .item.activated.item-positive.item-complex > .item-content, - .item-complex.active .item-content.item-positive.item-complex > .item-content, - .item-complex.activated .item-content.item-positive.item-complex > .item-content, - .item .item-content.active.item-positive.item-complex > .item-content, - .item .item-content.activated.item-positive.item-complex > .item-content { - border-color: #0c60ee; - background-color: #0c60ee; } - .item.active.item-calm, - .item.activated.item-calm, - .item-complex.active .item-content.item-calm, - .item-complex.activated .item-content.item-calm, - .item .item-content.active.item-calm, - .item .item-content.activated.item-calm { - border-color: #0a9dc7; - background-color: #0a9dc7; } - .item.active.item-calm.item-complex > .item-content, - .item.activated.item-calm.item-complex > .item-content, - .item-complex.active .item-content.item-calm.item-complex > .item-content, - .item-complex.activated .item-content.item-calm.item-complex > .item-content, - .item .item-content.active.item-calm.item-complex > .item-content, - .item .item-content.activated.item-calm.item-complex > .item-content { - border-color: #0a9dc7; - background-color: #0a9dc7; } - .item.active.item-assertive, - .item.activated.item-assertive, - .item-complex.active .item-content.item-assertive, - .item-complex.activated .item-content.item-assertive, - .item .item-content.active.item-assertive, - .item .item-content.activated.item-assertive { - border-color: #e42112; - background-color: #e42112; } - .item.active.item-assertive.item-complex > .item-content, - .item.activated.item-assertive.item-complex > .item-content, - .item-complex.active .item-content.item-assertive.item-complex > .item-content, - .item-complex.activated .item-content.item-assertive.item-complex > .item-content, - .item .item-content.active.item-assertive.item-complex > .item-content, - .item .item-content.activated.item-assertive.item-complex > .item-content { - border-color: #e42112; - background-color: #e42112; } - .item.active.item-balanced, - .item.activated.item-balanced, - .item-complex.active .item-content.item-balanced, - .item-complex.activated .item-content.item-balanced, - .item .item-content.active.item-balanced, - .item .item-content.activated.item-balanced { - border-color: #28a54c; - background-color: #28a54c; } - .item.active.item-balanced.item-complex > .item-content, - .item.activated.item-balanced.item-complex > .item-content, - .item-complex.active .item-content.item-balanced.item-complex > .item-content, - .item-complex.activated .item-content.item-balanced.item-complex > .item-content, - .item .item-content.active.item-balanced.item-complex > .item-content, - .item .item-content.activated.item-balanced.item-complex > .item-content { - border-color: #28a54c; - background-color: #28a54c; } - .item.active.item-energized, - .item.activated.item-energized, - .item-complex.active .item-content.item-energized, - .item-complex.activated .item-content.item-energized, - .item .item-content.active.item-energized, - .item .item-content.activated.item-energized { - border-color: #e6b500; - background-color: #e6b500; } - .item.active.item-energized.item-complex > .item-content, - .item.activated.item-energized.item-complex > .item-content, - .item-complex.active .item-content.item-energized.item-complex > .item-content, - .item-complex.activated .item-content.item-energized.item-complex > .item-content, - .item .item-content.active.item-energized.item-complex > .item-content, - .item .item-content.activated.item-energized.item-complex > .item-content { - border-color: #e6b500; - background-color: #e6b500; } - .item.active.item-royal, - .item.activated.item-royal, - .item-complex.active .item-content.item-royal, - .item-complex.activated .item-content.item-royal, - .item .item-content.active.item-royal, - .item .item-content.activated.item-royal { - border-color: #6b46e5; - background-color: #6b46e5; } - .item.active.item-royal.item-complex > .item-content, - .item.activated.item-royal.item-complex > .item-content, - .item-complex.active .item-content.item-royal.item-complex > .item-content, - .item-complex.activated .item-content.item-royal.item-complex > .item-content, - .item .item-content.active.item-royal.item-complex > .item-content, - .item .item-content.activated.item-royal.item-complex > .item-content { - border-color: #6b46e5; - background-color: #6b46e5; } - .item.active.item-dark, - .item.activated.item-dark, - .item-complex.active .item-content.item-dark, - .item-complex.activated .item-content.item-dark, - .item .item-content.active.item-dark, - .item .item-content.activated.item-dark { - border-color: #000; - background-color: #262626; } - .item.active.item-dark.item-complex > .item-content, - .item.activated.item-dark.item-complex > .item-content, - .item-complex.active .item-content.item-dark.item-complex > .item-content, - .item-complex.activated .item-content.item-dark.item-complex > .item-content, - .item .item-content.active.item-dark.item-complex > .item-content, - .item .item-content.activated.item-dark.item-complex > .item-content { - border-color: #000; - background-color: #262626; } - -.item, -.item h1, -.item h2, -.item h3, -.item h4, -.item h5, -.item h6, -.item p, -.item-content, -.item-content h1, -.item-content h2, -.item-content h3, -.item-content h4, -.item-content h5, -.item-content h6, -.item-content p { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } - -a.item { - color: inherit; - text-decoration: none; } - a.item:hover, a.item:focus { - text-decoration: none; } - -/** - * Complex Items - * -------------------------------------------------- - * Adding .item-complex allows the .item to be slidable and - * have options underneath the button, but also requires an - * additional .item-content element inside .item. - * Basically .item-complex removes any default settings which - * .item added, so that .item-content looks them as just .item. - */ -.item-complex, -a.item.item-complex, -button.item.item-complex { - padding: 0; } - -.item-complex .item-content, -.item-radio .item-content { - position: relative; - z-index: 2; - padding: 16px 49px 16px 16px; - border: none; - background-color: #fff; } - -a.item-content { - display: block; - color: inherit; - text-decoration: none; } - -.item-text-wrap .item, -.item-text-wrap .item-content, -.item-text-wrap, -.item-text-wrap h1, -.item-text-wrap h2, -.item-text-wrap h3, -.item-text-wrap h4, -.item-text-wrap h5, -.item-text-wrap h6, -.item-text-wrap p, -.item-complex.item-text-wrap .item-content, -.item-body h1, -.item-body h2, -.item-body h3, -.item-body h4, -.item-body h5, -.item-body h6, -.item-body p { - overflow: visible; - white-space: normal; } - -.item-complex.item-text-wrap, -.item-complex.item-text-wrap h1, -.item-complex.item-text-wrap h2, -.item-complex.item-text-wrap h3, -.item-complex.item-text-wrap h4, -.item-complex.item-text-wrap h5, -.item-complex.item-text-wrap h6, -.item-complex.item-text-wrap p { - overflow: visible; - white-space: normal; } - -.item-complex.item-light > .item-content { - border-color: #ddd; - background-color: #fff; - color: #444; } - .item-complex.item-light > .item-content.active, .item-complex.item-light > .item-content:active { - border-color: #ccc; - background-color: #fafafa; } - .item-complex.item-light > .item-content.active.item-complex > .item-content, .item-complex.item-light > .item-content:active.item-complex > .item-content { - border-color: #ccc; - background-color: #fafafa; } - -.item-complex.item-stable > .item-content { - border-color: #b2b2b2; - background-color: #f8f8f8; - color: #444; } - .item-complex.item-stable > .item-content.active, .item-complex.item-stable > .item-content:active { - border-color: #a2a2a2; - background-color: #e5e5e5; } - .item-complex.item-stable > .item-content.active.item-complex > .item-content, .item-complex.item-stable > .item-content:active.item-complex > .item-content { - border-color: #a2a2a2; - background-color: #e5e5e5; } - -.item-complex.item-positive > .item-content { - border-color: #0c60ee; - background-color: #387ef5; - color: #fff; } - .item-complex.item-positive > .item-content.active, .item-complex.item-positive > .item-content:active { - border-color: #0c60ee; - background-color: #0c60ee; } - .item-complex.item-positive > .item-content.active.item-complex > .item-content, .item-complex.item-positive > .item-content:active.item-complex > .item-content { - border-color: #0c60ee; - background-color: #0c60ee; } - -.item-complex.item-calm > .item-content { - border-color: #0a9dc7; - background-color: #11c1f3; - color: #fff; } - .item-complex.item-calm > .item-content.active, .item-complex.item-calm > .item-content:active { - border-color: #0a9dc7; - background-color: #0a9dc7; } - .item-complex.item-calm > .item-content.active.item-complex > .item-content, .item-complex.item-calm > .item-content:active.item-complex > .item-content { - border-color: #0a9dc7; - background-color: #0a9dc7; } - -.item-complex.item-assertive > .item-content { - border-color: #e42112; - background-color: #ef473a; - color: #fff; } - .item-complex.item-assertive > .item-content.active, .item-complex.item-assertive > .item-content:active { - border-color: #e42112; - background-color: #e42112; } - .item-complex.item-assertive > .item-content.active.item-complex > .item-content, .item-complex.item-assertive > .item-content:active.item-complex > .item-content { - border-color: #e42112; - background-color: #e42112; } - -.item-complex.item-balanced > .item-content { - border-color: #28a54c; - background-color: #33cd5f; - color: #fff; } - .item-complex.item-balanced > .item-content.active, .item-complex.item-balanced > .item-content:active { - border-color: #28a54c; - background-color: #28a54c; } - .item-complex.item-balanced > .item-content.active.item-complex > .item-content, .item-complex.item-balanced > .item-content:active.item-complex > .item-content { - border-color: #28a54c; - background-color: #28a54c; } - -.item-complex.item-energized > .item-content { - border-color: #e6b500; - background-color: #ffc900; - color: #fff; } - .item-complex.item-energized > .item-content.active, .item-complex.item-energized > .item-content:active { - border-color: #e6b500; - background-color: #e6b500; } - .item-complex.item-energized > .item-content.active.item-complex > .item-content, .item-complex.item-energized > .item-content:active.item-complex > .item-content { - border-color: #e6b500; - background-color: #e6b500; } - -.item-complex.item-royal > .item-content { - border-color: #6b46e5; - background-color: #886aea; - color: #fff; } - .item-complex.item-royal > .item-content.active, .item-complex.item-royal > .item-content:active { - border-color: #6b46e5; - background-color: #6b46e5; } - .item-complex.item-royal > .item-content.active.item-complex > .item-content, .item-complex.item-royal > .item-content:active.item-complex > .item-content { - border-color: #6b46e5; - background-color: #6b46e5; } - -.item-complex.item-dark > .item-content { - border-color: #111; - background-color: #444; - color: #fff; } - .item-complex.item-dark > .item-content.active, .item-complex.item-dark > .item-content:active { - border-color: #000; - background-color: #262626; } - .item-complex.item-dark > .item-content.active.item-complex > .item-content, .item-complex.item-dark > .item-content:active.item-complex > .item-content { - border-color: #000; - background-color: #262626; } - -/** - * Item Icons - * -------------------------------------------------- - */ -.item-icon-left .icon, -.item-icon-right .icon { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; - position: absolute; - top: 0; - height: 100%; - font-size: 32px; } - .item-icon-left .icon:before, - .item-icon-right .icon:before { - display: block; - width: 32px; - text-align: center; } - -.item .fill-icon { - min-width: 30px; - min-height: 30px; - font-size: 28px; } - -.item-icon-left { - padding-left: 54px; } - .item-icon-left .icon { - left: 11px; } - -.item-complex.item-icon-left { - padding-left: 0; } - .item-complex.item-icon-left .item-content { - padding-left: 54px; } - -.item-icon-right { - padding-right: 54px; } - .item-icon-right .icon { - right: 11px; } - -.item-complex.item-icon-right { - padding-right: 0; } - .item-complex.item-icon-right .item-content { - padding-right: 54px; } - -.item-icon-left.item-icon-right .icon:first-child { - right: auto; } - -.item-icon-left.item-icon-right .icon:last-child, -.item-icon-left .item-delete .icon { - left: auto; } - -.item-icon-left .icon-accessory, -.item-icon-right .icon-accessory { - color: #ccc; - font-size: 16px; } - -.item-icon-left .icon-accessory { - left: 3px; } - -.item-icon-right .icon-accessory { - right: 3px; } - -/** - * Item Button - * -------------------------------------------------- - * An item button is a child button inside an .item (not the entire .item) - */ -.item-button-left { - padding-left: 72px; } - -.item-button-left > .button, -.item-button-left .item-content > .button { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; - position: absolute; - top: 8px; - left: 11px; - min-width: 34px; - min-height: 34px; - font-size: 18px; - line-height: 32px; } - .item-button-left > .button .icon:before, - .item-button-left .item-content > .button .icon:before { - position: relative; - left: auto; - width: auto; - line-height: 31px; } - .item-button-left > .button > .button, - .item-button-left .item-content > .button > .button { - margin: 0px 2px; - min-height: 34px; - font-size: 18px; - line-height: 32px; } - -.item-button-right, -a.item.item-button-right, -button.item.item-button-right { - padding-right: 80px; } - -.item-button-right > .button, -.item-button-right .item-content > .button, -.item-button-right > .buttons, -.item-button-right .item-content > .buttons { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; - position: absolute; - top: 8px; - right: 16px; - min-width: 34px; - min-height: 34px; - font-size: 18px; - line-height: 32px; } - .item-button-right > .button .icon:before, - .item-button-right .item-content > .button .icon:before, - .item-button-right > .buttons .icon:before, - .item-button-right .item-content > .buttons .icon:before { - position: relative; - left: auto; - width: auto; - line-height: 31px; } - .item-button-right > .button > .button, - .item-button-right .item-content > .button > .button, - .item-button-right > .buttons > .button, - .item-button-right .item-content > .buttons > .button { - margin: 0px 2px; - min-width: 34px; - min-height: 34px; - font-size: 18px; - line-height: 32px; } - -.item-button-left.item-button-right .button:first-child { - right: auto; } - -.item-button-left.item-button-right .button:last-child { - left: auto; } - -.item-avatar, -.item-avatar .item-content, -.item-avatar-left, -.item-avatar-left .item-content { - padding-left: 72px; - min-height: 72px; } - .item-avatar > img:first-child, - .item-avatar .item-image, - .item-avatar .item-content > img:first-child, - .item-avatar .item-content .item-image, - .item-avatar-left > img:first-child, - .item-avatar-left .item-image, - .item-avatar-left .item-content > img:first-child, - .item-avatar-left .item-content .item-image { - position: absolute; - top: 16px; - left: 16px; - max-width: 40px; - max-height: 40px; - width: 100%; - height: 100%; - border-radius: 50%; } - -.item-avatar-right, -.item-avatar-right .item-content { - padding-right: 72px; - min-height: 72px; } - .item-avatar-right > img:first-child, - .item-avatar-right .item-image, - .item-avatar-right .item-content > img:first-child, - .item-avatar-right .item-content .item-image { - position: absolute; - top: 16px; - right: 16px; - max-width: 40px; - max-height: 40px; - width: 100%; - height: 100%; - border-radius: 50%; } - -.item-thumbnail-left, -.item-thumbnail-left .item-content { - padding-top: 8px; - padding-left: 106px; - min-height: 100px; } - .item-thumbnail-left > img:first-child, - .item-thumbnail-left .item-image, - .item-thumbnail-left .item-content > img:first-child, - .item-thumbnail-left .item-content .item-image { - position: absolute; - top: 10px; - left: 10px; - max-width: 80px; - max-height: 80px; - width: 100%; - height: 100%; } - -.item-avatar.item-complex, -.item-avatar-left.item-complex, -.item-thumbnail-left.item-complex { - padding-top: 0; - padding-left: 0; } - -.item-thumbnail-right, -.item-thumbnail-right .item-content { - padding-top: 8px; - padding-right: 106px; - min-height: 100px; } - .item-thumbnail-right > img:first-child, - .item-thumbnail-right .item-image, - .item-thumbnail-right .item-content > img:first-child, - .item-thumbnail-right .item-content .item-image { - position: absolute; - top: 10px; - right: 10px; - max-width: 80px; - max-height: 80px; - width: 100%; - height: 100%; } - -.item-avatar-right.item-complex, -.item-thumbnail-right.item-complex { - padding-top: 0; - padding-right: 0; } - -.item-image { - padding: 0; - text-align: center; } - .item-image img:first-child, .item-image .list-img { - width: 100%; - vertical-align: middle; } - -.item-body { - overflow: auto; - padding: 16px; - text-overflow: inherit; - white-space: normal; } - .item-body h1, .item-body h2, .item-body h3, .item-body h4, .item-body h5, .item-body h6, .item-body p { - margin-top: 16px; - margin-bottom: 16px; } - -.item-divider { - padding-top: 8px; - padding-bottom: 8px; - min-height: 30px; - background-color: #f5f5f5; - color: #222; - font-weight: 500; } - -.platform-ios .item-divider-platform, -.item-divider-ios { - padding-top: 26px; - text-transform: uppercase; - font-weight: 300; - font-size: 13px; - background-color: #efeff4; - color: #555; } - -.platform-android .item-divider-platform, -.item-divider-android { - font-weight: 300; - font-size: 13px; } - -.item-note { - float: right; - color: #aaa; - font-size: 14px; } - -.item-left-editable .item-content, -.item-right-editable .item-content { - -webkit-transition-duration: 250ms; - transition-duration: 250ms; - -webkit-transition-timing-function: ease-in-out; - transition-timing-function: ease-in-out; - -webkit-transition-property: -webkit-transform; - -moz-transition-property: -moz-transform; - transition-property: transform; } - -.list-left-editing .item-left-editable .item-content, -.item-left-editing.item-left-editable .item-content { - -webkit-transform: translate3d(50px, 0, 0); - transform: translate3d(50px, 0, 0); } - -.item-remove-animate.ng-leave { - -webkit-transition-duration: 300ms; - transition-duration: 300ms; } - -.item-remove-animate.ng-leave .item-content, .item-remove-animate.ng-leave:last-of-type { - -webkit-transition-duration: 300ms; - transition-duration: 300ms; - -webkit-transition-timing-function: ease-in; - transition-timing-function: ease-in; - -webkit-transition-property: all; - transition-property: all; } - -.item-remove-animate.ng-leave.ng-leave-active .item-content { - opacity: 0; - -webkit-transform: translate3d(-100%, 0, 0) !important; - transform: translate3d(-100%, 0, 0) !important; } - -.item-remove-animate.ng-leave.ng-leave-active:last-of-type { - opacity: 0; } - -.item-remove-animate.ng-leave.ng-leave-active ~ ion-item:not(.ng-leave) { - -webkit-transform: translate3d(0, -webkit-calc(-100% + 1px), 0); - transform: translate3d(0, calc(-100% + 1px), 0); - -webkit-transition-duration: 300ms; - transition-duration: 300ms; - -webkit-transition-timing-function: cubic-bezier(0.25, 0.81, 0.24, 1); - transition-timing-function: cubic-bezier(0.25, 0.81, 0.24, 1); - -webkit-transition-property: all; - transition-property: all; } - -.item-left-edit { - -webkit-transition: all ease-in-out 125ms; - transition: all ease-in-out 125ms; - position: absolute; - top: 0; - left: 0; - z-index: 0; - width: 50px; - height: 100%; - line-height: 100%; - display: none; - opacity: 0; - -webkit-transform: translate3d(-21px, 0, 0); - transform: translate3d(-21px, 0, 0); } - .item-left-edit .button { - height: 100%; } - .item-left-edit .button.icon { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; - position: absolute; - top: 0; - height: 100%; } - .item-left-edit.visible { - display: block; } - .item-left-edit.visible.active { - opacity: 1; - -webkit-transform: translate3d(8px, 0, 0); - transform: translate3d(8px, 0, 0); } - -.list-left-editing .item-left-edit { - -webkit-transition-delay: 125ms; - transition-delay: 125ms; } - -.item-delete .button.icon { - color: #ef473a; - font-size: 24px; } - .item-delete .button.icon:hover { - opacity: .7; } - -.item-right-edit { - -webkit-transition: all ease-in-out 250ms; - transition: all ease-in-out 250ms; - position: absolute; - top: 0; - right: 0; - z-index: 3; - width: 75px; - height: 100%; - background: inherit; - padding-left: 20px; - display: block; - opacity: 0; - -webkit-transform: translate3d(75px, 0, 0); - transform: translate3d(75px, 0, 0); } - .item-right-edit .button { - min-width: 50px; - height: 100%; } - .item-right-edit .button.icon { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; - position: absolute; - top: 0; - height: 100%; - font-size: 32px; } - .item-right-edit.visible { - display: block; } - .item-right-edit.visible.active { - opacity: 1; - -webkit-transform: translate3d(0, 0, 0); - transform: translate3d(0, 0, 0); } - -.item-reorder .button.icon { - color: #444; - font-size: 32px; } - -.item-reordering { - position: absolute; - left: 0; - top: 0; - z-index: 9; - width: 100%; - box-shadow: 0px 0px 10px 0px #aaa; } - .item-reordering .item-reorder { - z-index: 9; } - -.item-placeholder { - opacity: 0.7; } - -/** - * The hidden right-side buttons that can be exposed under a list item - * with dragging. - */ -.item-options { - position: absolute; - top: 0; - right: 0; - z-index: 1; - height: 100%; } - .item-options .button { - height: 100%; - border: none; - border-radius: 0; - display: -webkit-inline-box; - display: -webkit-inline-flex; - display: -moz-inline-flex; - display: -ms-inline-flexbox; - display: inline-flex; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; } - .item-options .button:before { - margin: 0 auto; } - -/** - * Lists - * -------------------------------------------------- - */ -.list { - position: relative; - padding-top: 1px; - padding-bottom: 1px; - padding-left: 0; - margin-bottom: 20px; } - -.list:last-child { - margin-bottom: 0px; } - .list:last-child.card { - margin-bottom: 40px; } - -/** - * List Header - * -------------------------------------------------- - */ -.list-header { - margin-top: 20px; - padding: 5px 15px; - background-color: transparent; - color: #222; - font-weight: bold; } - -.card.list .list-item { - padding-right: 1px; - padding-left: 1px; } - -/** - * Cards and Inset Lists - * -------------------------------------------------- - * A card and list-inset are close to the same thing, except a card as a box shadow. - */ -.card, -.list-inset { - overflow: hidden; - margin: 20px 10px; - border-radius: 2px; - background-color: #fff; } - -.card { - padding-top: 1px; - padding-bottom: 1px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); } - .card .item { - border-left: 0; - border-right: 0; } - .card .item:first-child { - border-top: 0; } - .card .item:last-child { - border-bottom: 0; } - -.padding .card, .padding .list-inset { - margin-left: 0; - margin-right: 0; } - -.card .item:first-child, -.list-inset .item:first-child, -.padding > .list .item:first-child { - border-top-left-radius: 2px; - border-top-right-radius: 2px; } - .card .item:first-child .item-content, - .list-inset .item:first-child .item-content, - .padding > .list .item:first-child .item-content { - border-top-left-radius: 2px; - border-top-right-radius: 2px; } - -.card .item:last-child, -.list-inset .item:last-child, -.padding > .list .item:last-child { - border-bottom-right-radius: 2px; - border-bottom-left-radius: 2px; } - .card .item:last-child .item-content, - .list-inset .item:last-child .item-content, - .padding > .list .item:last-child .item-content { - border-bottom-right-radius: 2px; - border-bottom-left-radius: 2px; } - -.card .item:last-child, -.list-inset .item:last-child { - margin-bottom: -1px; } - -.card .item, -.list-inset .item, -.padding > .list .item, -.padding-horizontal > .list .item { - margin-right: 0; - margin-left: 0; } - .card .item.item-input input, - .list-inset .item.item-input input, - .padding > .list .item.item-input input, - .padding-horizontal > .list .item.item-input input { - padding-right: 44px; } - -.padding-left > .list .item { - margin-left: 0; } - -.padding-right > .list .item { - margin-right: 0; } - -/** - * Badges - * -------------------------------------------------- - */ -.badge { - background-color: transparent; - color: #AAAAAA; - z-index: 1; - display: inline-block; - padding: 3px 8px; - min-width: 10px; - border-radius: 10px; - vertical-align: baseline; - text-align: center; - white-space: nowrap; - font-weight: bold; - font-size: 14px; - line-height: 16px; } - .badge:empty { - display: none; } - -.tabs .tab-item .badge.badge-light, -.badge.badge-light { - background-color: #fff; - color: #444; } - -.tabs .tab-item .badge.badge-stable, -.badge.badge-stable { - background-color: #f8f8f8; - color: #444; } - -.tabs .tab-item .badge.badge-positive, -.badge.badge-positive { - background-color: #387ef5; - color: #fff; } - -.tabs .tab-item .badge.badge-calm, -.badge.badge-calm { - background-color: #11c1f3; - color: #fff; } - -.tabs .tab-item .badge.badge-assertive, -.badge.badge-assertive { - background-color: #ef473a; - color: #fff; } - -.tabs .tab-item .badge.badge-balanced, -.badge.badge-balanced { - background-color: #33cd5f; - color: #fff; } - -.tabs .tab-item .badge.badge-energized, -.badge.badge-energized { - background-color: #ffc900; - color: #fff; } - -.tabs .tab-item .badge.badge-royal, -.badge.badge-royal { - background-color: #886aea; - color: #fff; } - -.tabs .tab-item .badge.badge-dark, -.badge.badge-dark { - background-color: #444; - color: #fff; } - -.button .badge { - position: relative; - top: -1px; } - -/** - * Slide Box - * -------------------------------------------------- - */ -.slider { - position: relative; - visibility: hidden; - overflow: hidden; } - -.slider-slides { - position: relative; - height: 100%; } - -.slider-slide { - position: relative; - display: block; - float: left; - width: 100%; - height: 100%; - vertical-align: top; } - -.slider-slide-image > img { - width: 100%; } - -.slider-pager { - position: absolute; - bottom: 20px; - z-index: 1; - width: 100%; - height: 15px; - text-align: center; } - .slider-pager .slider-pager-page { - display: inline-block; - margin: 0px 3px; - width: 15px; - color: #000; - text-decoration: none; - opacity: 0.3; } - .slider-pager .slider-pager-page.active { - -webkit-transition: opacity 0.4s ease-in; - transition: opacity 0.4s ease-in; - opacity: 1; } - -.slider-slide.ng-enter, .slider-slide.ng-leave, .slider-slide.ng-animate, -.slider-pager-page.ng-enter, -.slider-pager-page.ng-leave, -.slider-pager-page.ng-animate { - -webkit-transition: none !important; - transition: none !important; } - -.slider-slide.ng-animate, -.slider-pager-page.ng-animate { - -webkit-animation: none 0s; - animation: none 0s; } - -/** - * Swiper 3.2.7 - * Most modern mobile touch slider and framework with hardware accelerated transitions - * - * http://www.idangero.us/swiper/ - * - * Copyright 2015, Vladimir Kharlampidi - * The iDangero.us - * http://www.idangero.us/ - * - * Licensed under MIT - * - * Released on: December 7, 2015 - */ -.swiper-container { - margin: 0 auto; - position: relative; - overflow: hidden; - /* Fix of Webkit flickering */ - z-index: 1; } - -.swiper-container-no-flexbox .swiper-slide { - float: left; } - -.swiper-container-vertical > .swiper-wrapper { - -webkit-box-orient: vertical; - -moz-box-orient: vertical; - -ms-flex-direction: column; - -webkit-flex-direction: column; - flex-direction: column; } - -.swiper-wrapper { - position: relative; - width: 100%; - height: 100%; - z-index: 1; - display: -webkit-box; - display: -moz-box; - display: -ms-flexbox; - display: -webkit-flex; - display: flex; - -webkit-transition-property: -webkit-transform; - -moz-transition-property: -moz-transform; - -o-transition-property: -o-transform; - -ms-transition-property: -ms-transform; - transition-property: transform; - -webkit-box-sizing: content-box; - -moz-box-sizing: content-box; - box-sizing: content-box; } - -.swiper-container-android .swiper-slide, -.swiper-wrapper { - -webkit-transform: translate3d(0px, 0, 0); - -moz-transform: translate3d(0px, 0, 0); - -o-transform: translate(0px, 0px); - -ms-transform: translate3d(0px, 0, 0); - transform: translate3d(0px, 0, 0); } - -.swiper-container-multirow > .swiper-wrapper { - -webkit-box-lines: multiple; - -moz-box-lines: multiple; - -ms-flex-wrap: wrap; - -webkit-flex-wrap: wrap; - flex-wrap: wrap; } - -.swiper-container-free-mode > .swiper-wrapper { - -webkit-transition-timing-function: ease-out; - -moz-transition-timing-function: ease-out; - -ms-transition-timing-function: ease-out; - -o-transition-timing-function: ease-out; - transition-timing-function: ease-out; - margin: 0 auto; } - -.swiper-slide { - display: block; - -webkit-flex-shrink: 0; - -ms-flex: 0 0 auto; - flex-shrink: 0; - width: 100%; - height: 100%; - position: relative; } - -/* Auto Height */ -.swiper-container-autoheight, -.swiper-container-autoheight .swiper-slide { - height: auto; } - -.swiper-container-autoheight .swiper-wrapper { - -webkit-box-align: start; - -ms-flex-align: start; - -webkit-align-items: flex-start; - align-items: flex-start; - -webkit-transition-property: -webkit-transform, height; - -moz-transition-property: -moz-transform; - -o-transition-property: -o-transform; - -ms-transition-property: -ms-transform; - transition-property: transform, height; } - -/* a11y */ -.swiper-container .swiper-notification { - position: absolute; - left: 0; - top: 0; - pointer-events: none; - opacity: 0; - z-index: -1000; } - -/* IE10 Windows Phone 8 Fixes */ -.swiper-wp8-horizontal { - -ms-touch-action: pan-y; - touch-action: pan-y; } - -.swiper-wp8-vertical { - -ms-touch-action: pan-x; - touch-action: pan-x; } - -/* Arrows */ -.swiper-button-prev, -.swiper-button-next { - position: absolute; - top: 50%; - width: 27px; - height: 44px; - margin-top: -22px; - z-index: 10; - cursor: pointer; - -moz-background-size: 27px 44px; - -webkit-background-size: 27px 44px; - background-size: 27px 44px; - background-position: center; - background-repeat: no-repeat; } - -.swiper-button-prev.swiper-button-disabled, -.swiper-button-next.swiper-button-disabled { - opacity: 0.35; - cursor: auto; - pointer-events: none; } - -.swiper-button-prev, -.swiper-container-rtl .swiper-button-next { - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M0%2C22L22%2C0l2.1%2C2.1L4.2%2C22l19.9%2C19.9L22%2C44L0%2C22L0%2C22L0%2C22z'%20fill%3D'%23007aff'%2F%3E%3C%2Fsvg%3E"); - left: 10px; - right: auto; } - -.swiper-button-prev.swiper-button-black, -.swiper-container-rtl .swiper-button-next.swiper-button-black { - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M0%2C22L22%2C0l2.1%2C2.1L4.2%2C22l19.9%2C19.9L22%2C44L0%2C22L0%2C22L0%2C22z'%20fill%3D'%23000000'%2F%3E%3C%2Fsvg%3E"); } - -.swiper-button-prev.swiper-button-white, -.swiper-container-rtl .swiper-button-next.swiper-button-white { - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M0%2C22L22%2C0l2.1%2C2.1L4.2%2C22l19.9%2C19.9L22%2C44L0%2C22L0%2C22L0%2C22z'%20fill%3D'%23ffffff'%2F%3E%3C%2Fsvg%3E"); } - -.swiper-button-next, -.swiper-container-rtl .swiper-button-prev { - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M27%2C22L27%2C22L5%2C44l-2.1-2.1L22.8%2C22L2.9%2C2.1L5%2C0L27%2C22L27%2C22z'%20fill%3D'%23007aff'%2F%3E%3C%2Fsvg%3E"); - right: 10px; - left: auto; } - -.swiper-button-next.swiper-button-black, -.swiper-container-rtl .swiper-button-prev.swiper-button-black { - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M27%2C22L27%2C22L5%2C44l-2.1-2.1L22.8%2C22L2.9%2C2.1L5%2C0L27%2C22L27%2C22z'%20fill%3D'%23000000'%2F%3E%3C%2Fsvg%3E"); } - -.swiper-button-next.swiper-button-white, -.swiper-container-rtl .swiper-button-prev.swiper-button-white { - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M27%2C22L27%2C22L5%2C44l-2.1-2.1L22.8%2C22L2.9%2C2.1L5%2C0L27%2C22L27%2C22z'%20fill%3D'%23ffffff'%2F%3E%3C%2Fsvg%3E"); } - -/* Pagination Styles */ -.swiper-pagination { - position: absolute; - text-align: center; - -webkit-transition: 300ms; - -moz-transition: 300ms; - -o-transition: 300ms; - transition: 300ms; - -webkit-transform: translate3d(0, 0, 0); - -ms-transform: translate3d(0, 0, 0); - -o-transform: translate3d(0, 0, 0); - transform: translate3d(0, 0, 0); - z-index: 10; } - -.swiper-pagination.swiper-pagination-hidden { - opacity: 0; } - -.swiper-pagination-bullet { - width: 8px; - height: 8px; - display: inline-block; - border-radius: 100%; - background: #000; - opacity: 0.2; } - -button.swiper-pagination-bullet { - border: none; - margin: 0; - padding: 0; - box-shadow: none; - -moz-appearance: none; - -ms-appearance: none; - -webkit-appearance: none; - appearance: none; } - -.swiper-pagination-clickable .swiper-pagination-bullet { - cursor: pointer; } - -.swiper-pagination-white .swiper-pagination-bullet { - background: #fff; } - -.swiper-pagination-bullet-active { - opacity: 1; } - -.swiper-pagination-white .swiper-pagination-bullet-active { - background: #fff; } - -.swiper-pagination-black .swiper-pagination-bullet-active { - background: #000; } - -.swiper-container-vertical > .swiper-pagination { - right: 10px; - top: 50%; - -webkit-transform: translate3d(0px, -50%, 0); - -moz-transform: translate3d(0px, -50%, 0); - -o-transform: translate(0px, -50%); - -ms-transform: translate3d(0px, -50%, 0); - transform: translate3d(0px, -50%, 0); } - -.swiper-container-vertical > .swiper-pagination .swiper-pagination-bullet { - margin: 5px 0; - display: block; } - -.swiper-container-horizontal > .swiper-pagination { - bottom: 10px; - left: 0; - width: 100%; } - -.swiper-container-horizontal > .swiper-pagination .swiper-pagination-bullet { - margin: 0 5px; } - -/* 3D Container */ -.swiper-container-3d { - -webkit-perspective: 1200px; - -moz-perspective: 1200px; - -o-perspective: 1200px; - perspective: 1200px; } - -.swiper-container-3d .swiper-wrapper, -.swiper-container-3d .swiper-slide, -.swiper-container-3d .swiper-slide-shadow-left, -.swiper-container-3d .swiper-slide-shadow-right, -.swiper-container-3d .swiper-slide-shadow-top, -.swiper-container-3d .swiper-slide-shadow-bottom, -.swiper-container-3d .swiper-cube-shadow { - -webkit-transform-style: preserve-3d; - -moz-transform-style: preserve-3d; - -ms-transform-style: preserve-3d; - transform-style: preserve-3d; } - -.swiper-container-3d .swiper-slide-shadow-left, -.swiper-container-3d .swiper-slide-shadow-right, -.swiper-container-3d .swiper-slide-shadow-top, -.swiper-container-3d .swiper-slide-shadow-bottom { - position: absolute; - left: 0; - top: 0; - width: 100%; - height: 100%; - pointer-events: none; - z-index: 10; } - -.swiper-container-3d .swiper-slide-shadow-left { - background-image: -webkit-gradient(linear, left top, right top, from(rgba(0, 0, 0, 0.5)), to(transparent)); - /* Safari 4+, Chrome */ - background-image: -webkit-linear-gradient(right, rgba(0, 0, 0, 0.5), transparent); - /* Chrome 10+, Safari 5.1+, iOS 5+ */ - background-image: -moz-linear-gradient(right, rgba(0, 0, 0, 0.5), transparent); - /* Firefox 3.6-15 */ - background-image: -o-linear-gradient(right, rgba(0, 0, 0, 0.5), transparent); - /* Opera 11.10-12.00 */ - background-image: linear-gradient(to left, rgba(0, 0, 0, 0.5), transparent); - /* Firefox 16+, IE10, Opera 12.50+ */ } - -.swiper-container-3d .swiper-slide-shadow-right { - background-image: -webkit-gradient(linear, right top, left top, from(rgba(0, 0, 0, 0.5)), to(transparent)); - /* Safari 4+, Chrome */ - background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, 0.5), transparent); - /* Chrome 10+, Safari 5.1+, iOS 5+ */ - background-image: -moz-linear-gradient(left, rgba(0, 0, 0, 0.5), transparent); - /* Firefox 3.6-15 */ - background-image: -o-linear-gradient(left, rgba(0, 0, 0, 0.5), transparent); - /* Opera 11.10-12.00 */ - background-image: linear-gradient(to right, rgba(0, 0, 0, 0.5), transparent); - /* Firefox 16+, IE10, Opera 12.50+ */ } - -.swiper-container-3d .swiper-slide-shadow-top { - background-image: -webkit-gradient(linear, left top, left bottom, from(rgba(0, 0, 0, 0.5)), to(transparent)); - /* Safari 4+, Chrome */ - background-image: -webkit-linear-gradient(bottom, rgba(0, 0, 0, 0.5), transparent); - /* Chrome 10+, Safari 5.1+, iOS 5+ */ - background-image: -moz-linear-gradient(bottom, rgba(0, 0, 0, 0.5), transparent); - /* Firefox 3.6-15 */ - background-image: -o-linear-gradient(bottom, rgba(0, 0, 0, 0.5), transparent); - /* Opera 11.10-12.00 */ - background-image: linear-gradient(to top, rgba(0, 0, 0, 0.5), transparent); - /* Firefox 16+, IE10, Opera 12.50+ */ } - -.swiper-container-3d .swiper-slide-shadow-bottom { - background-image: -webkit-gradient(linear, left bottom, left top, from(rgba(0, 0, 0, 0.5)), to(transparent)); - /* Safari 4+, Chrome */ - background-image: -webkit-linear-gradient(top, rgba(0, 0, 0, 0.5), transparent); - /* Chrome 10+, Safari 5.1+, iOS 5+ */ - background-image: -moz-linear-gradient(top, rgba(0, 0, 0, 0.5), transparent); - /* Firefox 3.6-15 */ - background-image: -o-linear-gradient(top, rgba(0, 0, 0, 0.5), transparent); - /* Opera 11.10-12.00 */ - background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.5), transparent); - /* Firefox 16+, IE10, Opera 12.50+ */ } - -/* Coverflow */ -.swiper-container-coverflow .swiper-wrapper { - /* Windows 8 IE 10 fix */ - -ms-perspective: 1200px; } - -/* Fade */ -.swiper-container-fade.swiper-container-free-mode .swiper-slide { - -webkit-transition-timing-function: ease-out; - -moz-transition-timing-function: ease-out; - -ms-transition-timing-function: ease-out; - -o-transition-timing-function: ease-out; - transition-timing-function: ease-out; } - -.swiper-container-fade .swiper-slide { - pointer-events: none; } - -.swiper-container-fade .swiper-slide .swiper-slide { - pointer-events: none; } - -.swiper-container-fade .swiper-slide-active, -.swiper-container-fade .swiper-slide-active .swiper-slide-active { - pointer-events: auto; } - -/* Cube */ -.swiper-container-cube { - overflow: visible; } - -.swiper-container-cube .swiper-slide { - pointer-events: none; - visibility: hidden; - -webkit-transform-origin: 0 0; - -moz-transform-origin: 0 0; - -ms-transform-origin: 0 0; - transform-origin: 0 0; - -webkit-backface-visibility: hidden; - -moz-backface-visibility: hidden; - -ms-backface-visibility: hidden; - backface-visibility: hidden; - width: 100%; - height: 100%; - z-index: 1; } - -.swiper-container-cube.swiper-container-rtl .swiper-slide { - -webkit-transform-origin: 100% 0; - -moz-transform-origin: 100% 0; - -ms-transform-origin: 100% 0; - transform-origin: 100% 0; } - -.swiper-container-cube .swiper-slide-active, -.swiper-container-cube .swiper-slide-next, -.swiper-container-cube .swiper-slide-prev, -.swiper-container-cube .swiper-slide-next + .swiper-slide { - pointer-events: auto; - visibility: visible; } - -.swiper-container-cube .swiper-slide-shadow-top, -.swiper-container-cube .swiper-slide-shadow-bottom, -.swiper-container-cube .swiper-slide-shadow-left, -.swiper-container-cube .swiper-slide-shadow-right { - z-index: 0; - -webkit-backface-visibility: hidden; - -moz-backface-visibility: hidden; - -ms-backface-visibility: hidden; - backface-visibility: hidden; } - -.swiper-container-cube .swiper-cube-shadow { - position: absolute; - left: 0; - bottom: 0px; - width: 100%; - height: 100%; - background: #000; - opacity: 0.6; - -webkit-filter: blur(50px); - filter: blur(50px); - z-index: 0; } - -/* Scrollbar */ -.swiper-scrollbar { - border-radius: 10px; - position: relative; - -ms-touch-action: none; - background: rgba(0, 0, 0, 0.1); } - -.swiper-container-horizontal > .swiper-scrollbar { - position: absolute; - left: 1%; - bottom: 3px; - z-index: 50; - height: 5px; - width: 98%; } - -.swiper-container-vertical > .swiper-scrollbar { - position: absolute; - right: 3px; - top: 1%; - z-index: 50; - width: 5px; - height: 98%; } - -.swiper-scrollbar-drag { - height: 100%; - width: 100%; - position: relative; - background: rgba(0, 0, 0, 0.5); - border-radius: 10px; - left: 0; - top: 0; } - -.swiper-scrollbar-cursor-drag { - cursor: move; } - -/* Preloader */ -.swiper-lazy-preloader { - width: 42px; - height: 42px; - position: absolute; - left: 50%; - top: 50%; - margin-left: -21px; - margin-top: -21px; - z-index: 10; - -webkit-transform-origin: 50%; - -moz-transform-origin: 50%; - transform-origin: 50%; - -webkit-animation: swiper-preloader-spin 1s steps(12, end) infinite; - -moz-animation: swiper-preloader-spin 1s steps(12, end) infinite; - animation: swiper-preloader-spin 1s steps(12, end) infinite; } - -.swiper-lazy-preloader:after { - display: block; - content: ""; - width: 100%; - height: 100%; - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20viewBox%3D'0%200%20120%20120'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20xmlns%3Axlink%3D'http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink'%3E%3Cdefs%3E%3Cline%20id%3D'l'%20x1%3D'60'%20x2%3D'60'%20y1%3D'7'%20y2%3D'27'%20stroke%3D'%236c6c6c'%20stroke-width%3D'11'%20stroke-linecap%3D'round'%2F%3E%3C%2Fdefs%3E%3Cg%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(30%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(60%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(90%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(120%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(150%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.37'%20transform%3D'rotate(180%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.46'%20transform%3D'rotate(210%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.56'%20transform%3D'rotate(240%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.66'%20transform%3D'rotate(270%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.75'%20transform%3D'rotate(300%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.85'%20transform%3D'rotate(330%2060%2C60)'%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E"); - background-position: 50%; - -webkit-background-size: 100%; - background-size: 100%; - background-repeat: no-repeat; } - -.swiper-lazy-preloader-white:after { - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20viewBox%3D'0%200%20120%20120'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20xmlns%3Axlink%3D'http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink'%3E%3Cdefs%3E%3Cline%20id%3D'l'%20x1%3D'60'%20x2%3D'60'%20y1%3D'7'%20y2%3D'27'%20stroke%3D'%23fff'%20stroke-width%3D'11'%20stroke-linecap%3D'round'%2F%3E%3C%2Fdefs%3E%3Cg%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(30%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(60%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(90%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(120%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(150%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.37'%20transform%3D'rotate(180%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.46'%20transform%3D'rotate(210%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.56'%20transform%3D'rotate(240%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.66'%20transform%3D'rotate(270%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.75'%20transform%3D'rotate(300%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.85'%20transform%3D'rotate(330%2060%2C60)'%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E"); } - -@-webkit-keyframes swiper-preloader-spin { - 100% { - -webkit-transform: rotate(360deg); } } - -@keyframes swiper-preloader-spin { - 100% { - transform: rotate(360deg); } } - -ion-slides { - width: 100%; - height: 100%; - display: block; } - -.slide-zoom { - display: block; - width: 100%; - text-align: center; } - -.swiper-container { - width: 100%; - height: 100%; - padding: 0; - overflow: hidden; } - -.swiper-wrapper { - position: absolute; - left: 0; - top: 0; - width: 100%; - height: 100%; - padding: 0; } - -.swiper-slide { - width: 100%; - height: 100%; - box-sizing: border-box; - /* Center slide text vertically */ } - .swiper-slide img { - width: auto; - height: auto; - max-width: 100%; - max-height: 100%; } - -.scroll-refresher { - position: absolute; - top: -60px; - right: 0; - left: 0; - overflow: hidden; - margin: auto; - height: 60px; } - .scroll-refresher .ionic-refresher-content { - position: absolute; - bottom: 15px; - left: 0; - width: 100%; - color: #666666; - text-align: center; - font-size: 30px; } - .scroll-refresher .ionic-refresher-content .text-refreshing, - .scroll-refresher .ionic-refresher-content .text-pulling { - font-size: 16px; - line-height: 16px; } - .scroll-refresher .ionic-refresher-content.ionic-refresher-with-text { - bottom: 10px; } - .scroll-refresher .icon-refreshing, - .scroll-refresher .icon-pulling { - width: 100%; - -webkit-backface-visibility: hidden; - backface-visibility: hidden; - -webkit-transform-style: preserve-3d; - transform-style: preserve-3d; } - .scroll-refresher .icon-pulling { - -webkit-animation-name: refresh-spin-back; - animation-name: refresh-spin-back; - -webkit-animation-duration: 200ms; - animation-duration: 200ms; - -webkit-animation-timing-function: linear; - animation-timing-function: linear; - -webkit-animation-fill-mode: none; - animation-fill-mode: none; - -webkit-transform: translate3d(0, 0, 0) rotate(0deg); - transform: translate3d(0, 0, 0) rotate(0deg); } - .scroll-refresher .icon-refreshing, - .scroll-refresher .text-refreshing { - display: none; } - .scroll-refresher .icon-refreshing { - -webkit-animation-duration: 1.5s; - animation-duration: 1.5s; } - .scroll-refresher.active .icon-pulling:not(.pulling-rotation-disabled) { - -webkit-animation-name: refresh-spin; - animation-name: refresh-spin; - -webkit-transform: translate3d(0, 0, 0) rotate(-180deg); - transform: translate3d(0, 0, 0) rotate(-180deg); } - .scroll-refresher.active.refreshing { - -webkit-transition: -webkit-transform 0.2s; - transition: -webkit-transform 0.2s; - -webkit-transition: transform 0.2s; - transition: transform 0.2s; - -webkit-transform: scale(1, 1); - transform: scale(1, 1); } - .scroll-refresher.active.refreshing .icon-pulling, - .scroll-refresher.active.refreshing .text-pulling { - display: none; } - .scroll-refresher.active.refreshing .icon-refreshing, - .scroll-refresher.active.refreshing .text-refreshing { - display: block; } - .scroll-refresher.active.refreshing.refreshing-tail { - -webkit-transform: scale(0, 0); - transform: scale(0, 0); } - -.overflow-scroll > .scroll { - -webkit-overflow-scrolling: touch; - width: 100%; } - .overflow-scroll > .scroll.overscroll { - position: fixed; - right: 0; - left: 0; } - -.overflow-scroll.padding > .scroll.overscroll { - padding: 10px; } - -@-webkit-keyframes refresh-spin { - 0% { - -webkit-transform: translate3d(0, 0, 0) rotate(0); } - 100% { - -webkit-transform: translate3d(0, 0, 0) rotate(180deg); } } - -@keyframes refresh-spin { - 0% { - transform: translate3d(0, 0, 0) rotate(0); } - 100% { - transform: translate3d(0, 0, 0) rotate(180deg); } } - -@-webkit-keyframes refresh-spin-back { - 0% { - -webkit-transform: translate3d(0, 0, 0) rotate(180deg); } - 100% { - -webkit-transform: translate3d(0, 0, 0) rotate(0); } } - -@keyframes refresh-spin-back { - 0% { - transform: translate3d(0, 0, 0) rotate(180deg); } - 100% { - transform: translate3d(0, 0, 0) rotate(0); } } - -/** - * Spinners - * -------------------------------------------------- - */ -.spinner { - stroke: #444; - fill: #444; } - .spinner svg { - width: 28px; - height: 28px; } - .spinner.spinner-light { - stroke: #fff; - fill: #fff; } - .spinner.spinner-stable { - stroke: #f8f8f8; - fill: #f8f8f8; } - .spinner.spinner-positive { - stroke: #387ef5; - fill: #387ef5; } - .spinner.spinner-calm { - stroke: #11c1f3; - fill: #11c1f3; } - .spinner.spinner-balanced { - stroke: #33cd5f; - fill: #33cd5f; } - .spinner.spinner-assertive { - stroke: #ef473a; - fill: #ef473a; } - .spinner.spinner-energized { - stroke: #ffc900; - fill: #ffc900; } - .spinner.spinner-royal { - stroke: #886aea; - fill: #886aea; } - .spinner.spinner-dark { - stroke: #444; - fill: #444; } - -.spinner-android { - stroke: #4b8bf4; } - -.spinner-ios, -.spinner-ios-small { - stroke: #69717d; } - -.spinner-spiral .stop1 { - stop-color: #fff; - stop-opacity: 0; } - -.spinner-spiral.spinner-light .stop1 { - stop-color: #444; } - -.spinner-spiral.spinner-light .stop2 { - stop-color: #fff; } - -.spinner-spiral.spinner-stable .stop2 { - stop-color: #f8f8f8; } - -.spinner-spiral.spinner-positive .stop2 { - stop-color: #387ef5; } - -.spinner-spiral.spinner-calm .stop2 { - stop-color: #11c1f3; } - -.spinner-spiral.spinner-balanced .stop2 { - stop-color: #33cd5f; } - -.spinner-spiral.spinner-assertive .stop2 { - stop-color: #ef473a; } - -.spinner-spiral.spinner-energized .stop2 { - stop-color: #ffc900; } - -.spinner-spiral.spinner-royal .stop2 { - stop-color: #886aea; } - -.spinner-spiral.spinner-dark .stop2 { - stop-color: #444; } - -/** - * Forms - * -------------------------------------------------- - */ -form { - margin: 0 0 1.42857; } - -legend { - display: block; - margin-bottom: 1.42857; - padding: 0; - width: 100%; - border: 1px solid #ddd; - color: #444; - font-size: 21px; - line-height: 2.85714; } - legend small { - color: #f8f8f8; - font-size: 1.07143; } - -label, -input, -button, -select, -textarea { - font-weight: normal; - font-size: 14px; - line-height: 1.42857; } - -input, -button, -select, -textarea { - font-family: "-apple-system", "Helvetica Neue", "Roboto", "Segoe UI", sans-serif; } - -.item-input { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; - position: relative; - overflow: hidden; - padding: 6px 0 5px 16px; } - .item-input input { - -webkit-border-radius: 0; - border-radius: 0; - -webkit-box-flex: 1; - -webkit-flex: 1 220px; - -moz-box-flex: 1; - -moz-flex: 1 220px; - -ms-flex: 1 220px; - flex: 1 220px; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - margin: 0; - padding-right: 24px; - background-color: transparent; } - .item-input .button .icon { - -webkit-box-flex: 0; - -webkit-flex: 0 0 24px; - -moz-box-flex: 0; - -moz-flex: 0 0 24px; - -ms-flex: 0 0 24px; - flex: 0 0 24px; - position: static; - display: inline-block; - height: auto; - text-align: center; - font-size: 16px; } - .item-input .button-bar { - -webkit-border-radius: 0; - border-radius: 0; - -webkit-box-flex: 1; - -webkit-flex: 1 0 220px; - -moz-box-flex: 1; - -moz-flex: 1 0 220px; - -ms-flex: 1 0 220px; - flex: 1 0 220px; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; } - .item-input .icon { - min-width: 14px; } - -.platform-windowsphone .item-input input { - flex-shrink: 1; } - -.item-input-inset { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; - position: relative; - overflow: hidden; - padding: 10.66667px; } - -.item-input-wrapper { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-flex: 1; - -webkit-flex: 1 0; - -moz-box-flex: 1; - -moz-flex: 1 0; - -ms-flex: 1 0; - flex: 1 0; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; - -webkit-border-radius: 4px; - border-radius: 4px; - padding-right: 8px; - padding-left: 8px; - background: #eee; } - -.item-input-inset .item-input-wrapper input { - padding-left: 4px; - height: 29px; - background: transparent; - line-height: 18px; } - -.item-input-wrapper ~ .button { - margin-left: 10.66667px; } - -.input-label { - display: table; - padding: 7px 10px 7px 0px; - max-width: 200px; - width: 35%; - color: #444; - font-size: 16px; } - -.placeholder-icon { - color: #aaa; } - .placeholder-icon:first-child { - padding-right: 6px; } - .placeholder-icon:last-child { - padding-left: 6px; } - -.item-stacked-label { - display: block; - background-color: transparent; - box-shadow: none; } - .item-stacked-label .input-label, .item-stacked-label .icon { - display: inline-block; - padding: 4px 0 0 0px; - vertical-align: middle; } - -.item-stacked-label input, -.item-stacked-label textarea { - -webkit-border-radius: 2px; - border-radius: 2px; - padding: 4px 8px 3px 0; - border: none; - background-color: #fff; } - -.item-stacked-label input { - overflow: hidden; - height: 46px; } - -.item-select.item-stacked-label select { - position: relative; - padding: 0px; - max-width: 90%; - direction: ltr; - white-space: pre-wrap; - margin: -3px; } - -.item-floating-label { - display: block; - background-color: transparent; - box-shadow: none; } - .item-floating-label .input-label { - position: relative; - padding: 5px 0 0 0; - opacity: 0; - top: 10px; - -webkit-transition: opacity 0.15s ease-in, top 0.2s linear; - transition: opacity 0.15s ease-in, top 0.2s linear; } - .item-floating-label .input-label.has-input { - opacity: 1; - top: 0; - -webkit-transition: opacity 0.15s ease-in, top 0.2s linear; - transition: opacity 0.15s ease-in, top 0.2s linear; } - -textarea, -input[type="text"], -input[type="password"], -input[type="datetime"], -input[type="datetime-local"], -input[type="date"], -input[type="month"], -input[type="time"], -input[type="week"], -input[type="number"], -input[type="email"], -input[type="url"], -input[type="search"], -input[type="tel"], -input[type="color"] { - display: block; - padding-top: 2px; - padding-left: 0; - height: 34px; - color: #111; - vertical-align: middle; - font-size: 14px; - line-height: 16px; } - -.platform-ios input[type="datetime-local"], -.platform-ios input[type="date"], -.platform-ios input[type="month"], -.platform-ios input[type="time"], -.platform-ios input[type="week"], -.platform-android input[type="datetime-local"], -.platform-android input[type="date"], -.platform-android input[type="month"], -.platform-android input[type="time"], -.platform-android input[type="week"] { - padding-top: 8px; } - -.item-input input, -.item-input textarea { - width: 100%; } - -textarea { - padding-left: 0; } - textarea::-moz-placeholder { - color: #aaaaaa; } - textarea:-ms-input-placeholder { - color: #aaaaaa; } - textarea::-webkit-input-placeholder { - color: #aaaaaa; - text-indent: -3px; } - -textarea { - height: auto; } - -textarea, -input[type="text"], -input[type="password"], -input[type="datetime"], -input[type="datetime-local"], -input[type="date"], -input[type="month"], -input[type="time"], -input[type="week"], -input[type="number"], -input[type="email"], -input[type="url"], -input[type="search"], -input[type="tel"], -input[type="color"] { - border: 0; } - -input[type="radio"], -input[type="checkbox"] { - margin: 0; - line-height: normal; } - -.item-input input[type="file"], -.item-input input[type="image"], -.item-input input[type="submit"], -.item-input input[type="reset"], -.item-input input[type="button"], -.item-input input[type="radio"], -.item-input input[type="checkbox"] { - width: auto; } - -input[type="file"] { - line-height: 34px; } - -.previous-input-focus, -.cloned-text-input + input, -.cloned-text-input + textarea { - position: absolute !important; - left: -9999px; - width: 200px; } - -input::-moz-placeholder, -textarea::-moz-placeholder { - color: #aaaaaa; } - -input:-ms-input-placeholder, -textarea:-ms-input-placeholder { - color: #aaaaaa; } - -input::-webkit-input-placeholder, -textarea::-webkit-input-placeholder { - color: #aaaaaa; - text-indent: 0; } - -input[disabled], -select[disabled], -textarea[disabled], -input[readonly]:not(.cloned-text-input), -textarea[readonly]:not(.cloned-text-input), -select[readonly] { - background-color: #f8f8f8; - cursor: not-allowed; } - -input[type="radio"][disabled], -input[type="checkbox"][disabled], -input[type="radio"][readonly], -input[type="checkbox"][readonly] { - background-color: transparent; } - -/** - * Checkbox - * -------------------------------------------------- - */ -.checkbox { - position: relative; - display: inline-block; - padding: 7px 7px; - cursor: pointer; } - .checkbox input:before, - .checkbox .checkbox-icon:before { - border-color: #ddd; } - .checkbox input:checked:before, - .checkbox input:checked + .checkbox-icon:before { - background: #387ef5; - border-color: #387ef5; } - -.checkbox-light input:before, -.checkbox-light .checkbox-icon:before { - border-color: #ddd; } - -.checkbox-light input:checked:before, -.checkbox-light input:checked + .checkbox-icon:before { - background: #ddd; - border-color: #ddd; } - -.checkbox-stable input:before, -.checkbox-stable .checkbox-icon:before { - border-color: #b2b2b2; } - -.checkbox-stable input:checked:before, -.checkbox-stable input:checked + .checkbox-icon:before { - background: #b2b2b2; - border-color: #b2b2b2; } - -.checkbox-positive input:before, -.checkbox-positive .checkbox-icon:before { - border-color: #387ef5; } - -.checkbox-positive input:checked:before, -.checkbox-positive input:checked + .checkbox-icon:before { - background: #387ef5; - border-color: #387ef5; } - -.checkbox-calm input:before, -.checkbox-calm .checkbox-icon:before { - border-color: #11c1f3; } - -.checkbox-calm input:checked:before, -.checkbox-calm input:checked + .checkbox-icon:before { - background: #11c1f3; - border-color: #11c1f3; } - -.checkbox-assertive input:before, -.checkbox-assertive .checkbox-icon:before { - border-color: #ef473a; } - -.checkbox-assertive input:checked:before, -.checkbox-assertive input:checked + .checkbox-icon:before { - background: #ef473a; - border-color: #ef473a; } - -.checkbox-balanced input:before, -.checkbox-balanced .checkbox-icon:before { - border-color: #33cd5f; } - -.checkbox-balanced input:checked:before, -.checkbox-balanced input:checked + .checkbox-icon:before { - background: #33cd5f; - border-color: #33cd5f; } - -.checkbox-energized input:before, -.checkbox-energized .checkbox-icon:before { - border-color: #ffc900; } - -.checkbox-energized input:checked:before, -.checkbox-energized input:checked + .checkbox-icon:before { - background: #ffc900; - border-color: #ffc900; } - -.checkbox-royal input:before, -.checkbox-royal .checkbox-icon:before { - border-color: #886aea; } - -.checkbox-royal input:checked:before, -.checkbox-royal input:checked + .checkbox-icon:before { - background: #886aea; - border-color: #886aea; } - -.checkbox-dark input:before, -.checkbox-dark .checkbox-icon:before { - border-color: #444; } - -.checkbox-dark input:checked:before, -.checkbox-dark input:checked + .checkbox-icon:before { - background: #444; - border-color: #444; } - -.checkbox input:disabled:before, -.checkbox input:disabled + .checkbox-icon:before { - border-color: #ddd; } - -.checkbox input:disabled:checked:before, -.checkbox input:disabled:checked + .checkbox-icon:before { - background: #ddd; } - -.checkbox.checkbox-input-hidden input { - display: none !important; } - -.checkbox input, -.checkbox-icon { - position: relative; - width: 28px; - height: 28px; - display: block; - border: 0; - background: transparent; - cursor: pointer; - -webkit-appearance: none; } - .checkbox input:before, - .checkbox-icon:before { - display: table; - width: 100%; - height: 100%; - border-width: 1px; - border-style: solid; - border-radius: 28px; - background: #fff; - content: ' '; - -webkit-transition: background-color 20ms ease-in-out; - transition: background-color 20ms ease-in-out; } - -.checkbox input:checked:before, -input:checked + .checkbox-icon:before { - border-width: 2px; } - -.checkbox input:after, -.checkbox-icon:after { - -webkit-transition: opacity 0.05s ease-in-out; - transition: opacity 0.05s ease-in-out; - -webkit-transform: rotate(-45deg); - transform: rotate(-45deg); - position: absolute; - top: 33%; - left: 25%; - display: table; - width: 14px; - height: 6px; - border: 1px solid #fff; - border-top: 0; - border-right: 0; - content: ' '; - opacity: 0; } - -.platform-android .checkbox-platform input:before, -.platform-android .checkbox-platform .checkbox-icon:before, -.checkbox-square input:before, -.checkbox-square .checkbox-icon:before { - border-radius: 2px; - width: 72%; - height: 72%; - margin-top: 14%; - margin-left: 14%; - border-width: 2px; } - -.platform-android .checkbox-platform input:after, -.platform-android .checkbox-platform .checkbox-icon:after, -.checkbox-square input:after, -.checkbox-square .checkbox-icon:after { - border-width: 2px; - top: 19%; - left: 25%; - width: 13px; - height: 7px; } - -.platform-android .item-checkbox-right .checkbox-square .checkbox-icon::after { - top: 31%; } - -.grade-c .checkbox input:after, -.grade-c .checkbox-icon:after { - -webkit-transform: rotate(0); - transform: rotate(0); - top: 3px; - left: 4px; - border: none; - color: #fff; - content: '\2713'; - font-weight: bold; - font-size: 20px; } - -.checkbox input:checked:after, -input:checked + .checkbox-icon:after { - opacity: 1; } - -.item-checkbox { - padding-left: 60px; } - .item-checkbox.active { - box-shadow: none; } - -.item-checkbox .checkbox { - position: absolute; - top: 50%; - right: 8px; - left: 8px; - z-index: 3; - margin-top: -21px; } - -.item-checkbox.item-checkbox-right { - padding-right: 60px; - padding-left: 16px; } - -.item-checkbox-right .checkbox input, -.item-checkbox-right .checkbox-icon { - float: right; } - -/** - * Toggle - * -------------------------------------------------- - */ -.item-toggle { - pointer-events: none; } - -.toggle { - position: relative; - display: inline-block; - pointer-events: auto; - margin: -5px; - padding: 5px; } - .toggle input:checked + .track { - border-color: #4cd964; - background-color: #4cd964; } - .toggle.dragging .handle { - background-color: #f2f2f2 !important; } - -.toggle.toggle-light input:checked + .track { - border-color: #ddd; - background-color: #ddd; } - -.toggle.toggle-stable input:checked + .track { - border-color: #b2b2b2; - background-color: #b2b2b2; } - -.toggle.toggle-positive input:checked + .track { - border-color: #387ef5; - background-color: #387ef5; } - -.toggle.toggle-calm input:checked + .track { - border-color: #11c1f3; - background-color: #11c1f3; } - -.toggle.toggle-assertive input:checked + .track { - border-color: #ef473a; - background-color: #ef473a; } - -.toggle.toggle-balanced input:checked + .track { - border-color: #33cd5f; - background-color: #33cd5f; } - -.toggle.toggle-energized input:checked + .track { - border-color: #ffc900; - background-color: #ffc900; } - -.toggle.toggle-royal input:checked + .track { - border-color: #886aea; - background-color: #886aea; } - -.toggle.toggle-dark input:checked + .track { - border-color: #444; - background-color: #444; } - -.toggle input { - display: none; } - -/* the track appearance when the toggle is "off" */ -.toggle .track { - -webkit-transition-timing-function: ease-in-out; - transition-timing-function: ease-in-out; - -webkit-transition-duration: 0.3s; - transition-duration: 0.3s; - -webkit-transition-property: background-color, border; - transition-property: background-color, border; - display: inline-block; - box-sizing: border-box; - width: 51px; - height: 31px; - border: solid 2px #e6e6e6; - border-radius: 20px; - background-color: #fff; - content: ' '; - cursor: pointer; - pointer-events: none; } - -/* Fix to avoid background color bleeding */ -/* (occurred on (at least) Android 4.2, Asus MeMO Pad HD7 ME173X) */ -.platform-android4_2 .toggle .track { - -webkit-background-clip: padding-box; } - -/* the handle (circle) thats inside the toggle's track area */ -/* also the handle's appearance when it is "off" */ -.toggle .handle { - -webkit-transition: 0.3s cubic-bezier(0, 1.1, 1, 1.1); - transition: 0.3s cubic-bezier(0, 1.1, 1, 1.1); - -webkit-transition-property: background-color, transform; - transition-property: background-color, transform; - position: absolute; - display: block; - width: 27px; - height: 27px; - border-radius: 27px; - background-color: #fff; - top: 7px; - left: 7px; - box-shadow: 0 2px 7px rgba(0, 0, 0, 0.35), 0 1px 1px rgba(0, 0, 0, 0.15); } - .toggle .handle:before { - position: absolute; - top: -4px; - left: -21.5px; - padding: 18.5px 34px; - content: " "; } - -.toggle input:checked + .track .handle { - -webkit-transform: translate3d(20px, 0, 0); - transform: translate3d(20px, 0, 0); - background-color: #fff; } - -.item-toggle.active { - box-shadow: none; } - -.item-toggle, -.item-toggle.item-complex .item-content { - padding-right: 99px; } - -.item-toggle.item-complex { - padding-right: 0; } - -.item-toggle .toggle { - position: absolute; - top: 10px; - right: 16px; - z-index: 3; } - -.toggle input:disabled + .track { - opacity: .6; } - -.toggle-small .track { - border: 0; - width: 34px; - height: 15px; - background: #9e9e9e; } - -.toggle-small input:checked + .track { - background: rgba(0, 150, 137, 0.5); } - -.toggle-small .handle { - top: 2px; - left: 4px; - width: 21px; - height: 21px; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.25); } - -.toggle-small input:checked + .track .handle { - -webkit-transform: translate3d(16px, 0, 0); - transform: translate3d(16px, 0, 0); - background: #009689; } - -.toggle-small.item-toggle .toggle { - top: 19px; } - -.toggle-small .toggle-light input:checked + .track { - background-color: rgba(221, 221, 221, 0.5); } - -.toggle-small .toggle-light input:checked + .track .handle { - background-color: #ddd; } - -.toggle-small .toggle-stable input:checked + .track { - background-color: rgba(178, 178, 178, 0.5); } - -.toggle-small .toggle-stable input:checked + .track .handle { - background-color: #b2b2b2; } - -.toggle-small .toggle-positive input:checked + .track { - background-color: rgba(56, 126, 245, 0.5); } - -.toggle-small .toggle-positive input:checked + .track .handle { - background-color: #387ef5; } - -.toggle-small .toggle-calm input:checked + .track { - background-color: rgba(17, 193, 243, 0.5); } - -.toggle-small .toggle-calm input:checked + .track .handle { - background-color: #11c1f3; } - -.toggle-small .toggle-assertive input:checked + .track { - background-color: rgba(239, 71, 58, 0.5); } - -.toggle-small .toggle-assertive input:checked + .track .handle { - background-color: #ef473a; } - -.toggle-small .toggle-balanced input:checked + .track { - background-color: rgba(51, 205, 95, 0.5); } - -.toggle-small .toggle-balanced input:checked + .track .handle { - background-color: #33cd5f; } - -.toggle-small .toggle-energized input:checked + .track { - background-color: rgba(255, 201, 0, 0.5); } - -.toggle-small .toggle-energized input:checked + .track .handle { - background-color: #ffc900; } - -.toggle-small .toggle-royal input:checked + .track { - background-color: rgba(136, 106, 234, 0.5); } - -.toggle-small .toggle-royal input:checked + .track .handle { - background-color: #886aea; } - -.toggle-small .toggle-dark input:checked + .track { - background-color: rgba(68, 68, 68, 0.5); } - -.toggle-small .toggle-dark input:checked + .track .handle { - background-color: #444; } - -/** - * Radio Button Inputs - * -------------------------------------------------- - */ -.item-radio { - padding: 0; } - .item-radio:hover { - cursor: pointer; } - -.item-radio .item-content { - /* give some room to the right for the checkmark icon */ - padding-right: 64px; } - -.item-radio .radio-icon { - /* checkmark icon will be hidden by default */ - position: absolute; - top: 0; - right: 0; - z-index: 3; - visibility: hidden; - padding: 14px; - height: 100%; - font-size: 24px; } - -.item-radio input { - /* hide any radio button inputs elements (the ugly circles) */ - position: absolute; - left: -9999px; } - .item-radio input:checked + .radio-content .item-content { - /* style the item content when its checked */ - background: #f7f7f7; } - .item-radio input:checked + .radio-content .radio-icon { - /* show the checkmark icon when its checked */ - visibility: visible; } - -/** - * Range - * -------------------------------------------------- - */ -.range input { - display: inline-block; - overflow: hidden; - margin-top: 5px; - margin-bottom: 5px; - padding-right: 2px; - padding-left: 1px; - width: auto; - height: 43px; - outline: none; - background: -webkit-gradient(linear, 50% 0%, 50% 100%, color-stop(0%, #ccc), color-stop(100%, #ccc)); - background: linear-gradient(to right, #ccc 0%, #ccc 100%); - background-position: center; - background-size: 99% 2px; - background-repeat: no-repeat; - -webkit-appearance: none; - /* - &::-ms-track{ - background: transparent; - border-color: transparent; - border-width: 11px 0 16px; - color:transparent; - margin-top:20px; - } - &::-ms-thumb { - width: $range-slider-width; - height: $range-slider-height; - border-radius: $range-slider-border-radius; - background-color: $toggle-handle-off-bg-color; - border-color:$toggle-handle-off-bg-color; - box-shadow: $range-slider-box-shadow; - margin-left:1px; - margin-right:1px; - outline:none; - } - &::-ms-fill-upper { - height: $range-track-height; - background:$range-default-track-bg; - } - */ } - .range input::-moz-focus-outer { - /* hide the focus outline in Firefox */ - border: 0; } - .range input::-webkit-slider-thumb { - position: relative; - width: 28px; - height: 28px; - border-radius: 50%; - background-color: #fff; - box-shadow: 0 0 2px rgba(0, 0, 0, 0.3), 0 3px 5px rgba(0, 0, 0, 0.2); - cursor: pointer; - -webkit-appearance: none; - border: 0; } - .range input::-webkit-slider-thumb:before { - /* what creates the colorful line on the left side of the slider */ - position: absolute; - top: 13px; - left: -2001px; - width: 2000px; - height: 2px; - background: #444; - content: ' '; } - .range input::-webkit-slider-thumb:after { - /* create a larger (but hidden) hit area */ - position: absolute; - top: -15px; - left: -15px; - padding: 30px; - content: ' '; } - .range input::-ms-fill-lower { - height: 2px; - background: #444; } - -.range { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; - padding: 2px 11px; } - .range.range-light input::-webkit-slider-thumb:before { - background: #ddd; } - .range.range-light input::-ms-fill-lower { - background: #ddd; } - .range.range-stable input::-webkit-slider-thumb:before { - background: #b2b2b2; } - .range.range-stable input::-ms-fill-lower { - background: #b2b2b2; } - .range.range-positive input::-webkit-slider-thumb:before { - background: #387ef5; } - .range.range-positive input::-ms-fill-lower { - background: #387ef5; } - .range.range-calm input::-webkit-slider-thumb:before { - background: #11c1f3; } - .range.range-calm input::-ms-fill-lower { - background: #11c1f3; } - .range.range-balanced input::-webkit-slider-thumb:before { - background: #33cd5f; } - .range.range-balanced input::-ms-fill-lower { - background: #33cd5f; } - .range.range-assertive input::-webkit-slider-thumb:before { - background: #ef473a; } - .range.range-assertive input::-ms-fill-lower { - background: #ef473a; } - .range.range-energized input::-webkit-slider-thumb:before { - background: #ffc900; } - .range.range-energized input::-ms-fill-lower { - background: #ffc900; } - .range.range-royal input::-webkit-slider-thumb:before { - background: #886aea; } - .range.range-royal input::-ms-fill-lower { - background: #886aea; } - .range.range-dark input::-webkit-slider-thumb:before { - background: #444; } - .range.range-dark input::-ms-fill-lower { - background: #444; } - -.range .icon { - -webkit-box-flex: 0; - -webkit-flex: 0; - -moz-box-flex: 0; - -moz-flex: 0; - -ms-flex: 0; - flex: 0; - display: block; - min-width: 24px; - text-align: center; - font-size: 24px; } - -.range input { - -webkit-box-flex: 1; - -webkit-flex: 1; - -moz-box-flex: 1; - -moz-flex: 1; - -ms-flex: 1; - flex: 1; - display: block; - margin-right: 10px; - margin-left: 10px; } - -.range-label { - -webkit-box-flex: 0; - -webkit-flex: 0 0 auto; - -moz-box-flex: 0; - -moz-flex: 0 0 auto; - -ms-flex: 0 0 auto; - flex: 0 0 auto; - display: block; - white-space: nowrap; } - -.range-label:first-child { - padding-left: 5px; } - -.range input + .range-label { - padding-right: 5px; - padding-left: 0; } - -.platform-windowsphone .range input { - height: auto; } - -/** - * Select - * -------------------------------------------------- - */ -.item-select { - position: relative; } - .item-select select { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - position: absolute; - top: 0; - bottom: 0; - right: 0; - padding: 0 48px 0 16px; - max-width: 65%; - border: none; - background: #fff; - color: #333; - text-indent: .01px; - text-overflow: ''; - white-space: nowrap; - font-size: 14px; - cursor: pointer; - direction: rtl; } - .item-select select::-ms-expand { - display: none; } - .item-select option { - direction: ltr; } - .item-select:after { - position: absolute; - top: 50%; - right: 16px; - margin-top: -3px; - width: 0; - height: 0; - border-top: 5px solid; - border-right: 5px solid transparent; - border-left: 5px solid transparent; - color: #999; - content: ""; - pointer-events: none; } - .item-select.item-light select { - background: #fff; - color: #444; } - .item-select.item-stable select { - background: #f8f8f8; - color: #444; } - .item-select.item-stable:after, .item-select.item-stable .input-label { - color: #666666; } - .item-select.item-positive select { - background: #387ef5; - color: #fff; } - .item-select.item-positive:after, .item-select.item-positive .input-label { - color: #fff; } - .item-select.item-calm select { - background: #11c1f3; - color: #fff; } - .item-select.item-calm:after, .item-select.item-calm .input-label { - color: #fff; } - .item-select.item-assertive select { - background: #ef473a; - color: #fff; } - .item-select.item-assertive:after, .item-select.item-assertive .input-label { - color: #fff; } - .item-select.item-balanced select { - background: #33cd5f; - color: #fff; } - .item-select.item-balanced:after, .item-select.item-balanced .input-label { - color: #fff; } - .item-select.item-energized select { - background: #ffc900; - color: #fff; } - .item-select.item-energized:after, .item-select.item-energized .input-label { - color: #fff; } - .item-select.item-royal select { - background: #886aea; - color: #fff; } - .item-select.item-royal:after, .item-select.item-royal .input-label { - color: #fff; } - .item-select.item-dark select { - background: #444; - color: #fff; } - .item-select.item-dark:after, .item-select.item-dark .input-label { - color: #fff; } - -select[multiple], select[size] { - height: auto; } - -/** - * Progress - * -------------------------------------------------- - */ -progress { - display: block; - margin: 15px auto; - width: 100%; } - -/** - * Buttons - * -------------------------------------------------- - */ -.button { - border-color: transparent; - background-color: #f8f8f8; - color: #444; - position: relative; - display: inline-block; - margin: 0; - padding: 0 12px; - min-width: 52px; - min-height: 47px; - border-width: 1px; - border-style: solid; - border-radius: 4px; - vertical-align: top; - text-align: center; - text-overflow: ellipsis; - font-size: 16px; - line-height: 42px; - cursor: pointer; } - .button:hover { - color: #444; - text-decoration: none; } - .button.active, .button.activated { - border-color: #a2a2a2; - background-color: #e5e5e5; } - .button:after { - position: absolute; - top: -6px; - right: -6px; - bottom: -6px; - left: -6px; - content: ' '; } - .button .icon { - vertical-align: top; - pointer-events: none; } - .button .icon:before, .button.icon:before, .button.icon-left:before, .button.icon-right:before { - display: inline-block; - padding: 0 0 1px 0; - vertical-align: inherit; - font-size: 24px; - line-height: 41px; - pointer-events: none; } - .button.icon-left:before { - float: left; - padding-right: .2em; - padding-left: 0; } - .button.icon-right:before { - float: right; - padding-right: 0; - padding-left: .2em; } - .button.button-block, .button.button-full { - margin-top: 10px; - margin-bottom: 10px; } - .button.button-light { - border-color: transparent; - background-color: #fff; - color: #444; } - .button.button-light:hover { - color: #444; - text-decoration: none; } - .button.button-light.active, .button.button-light.activated { - border-color: #a2a2a2; - background-color: #fafafa; } - .button.button-light.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #ddd; } - .button.button-light.button-icon { - border-color: transparent; - background: none; } - .button.button-light.button-outline { - border-color: #ddd; - background: transparent; - color: #ddd; } - .button.button-light.button-outline.active, .button.button-light.button-outline.activated { - background-color: #ddd; - box-shadow: none; - color: #fff; } - .button.button-stable { - border-color: transparent; - background-color: #f8f8f8; - color: #444; } - .button.button-stable:hover { - color: #444; - text-decoration: none; } - .button.button-stable.active, .button.button-stable.activated { - border-color: #a2a2a2; - background-color: #e5e5e5; } - .button.button-stable.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #b2b2b2; } - .button.button-stable.button-icon { - border-color: transparent; - background: none; } - .button.button-stable.button-outline { - border-color: #b2b2b2; - background: transparent; - color: #b2b2b2; } - .button.button-stable.button-outline.active, .button.button-stable.button-outline.activated { - background-color: #b2b2b2; - box-shadow: none; - color: #fff; } - .button.button-positive { - border-color: transparent; - background-color: #387ef5; - color: #fff; } - .button.button-positive:hover { - color: #fff; - text-decoration: none; } - .button.button-positive.active, .button.button-positive.activated { - border-color: #a2a2a2; - background-color: #0c60ee; } - .button.button-positive.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #387ef5; } - .button.button-positive.button-icon { - border-color: transparent; - background: none; } - .button.button-positive.button-outline { - border-color: #387ef5; - background: transparent; - color: #387ef5; } - .button.button-positive.button-outline.active, .button.button-positive.button-outline.activated { - background-color: #387ef5; - box-shadow: none; - color: #fff; } - .button.button-calm { - border-color: transparent; - background-color: #11c1f3; - color: #fff; } - .button.button-calm:hover { - color: #fff; - text-decoration: none; } - .button.button-calm.active, .button.button-calm.activated { - border-color: #a2a2a2; - background-color: #0a9dc7; } - .button.button-calm.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #11c1f3; } - .button.button-calm.button-icon { - border-color: transparent; - background: none; } - .button.button-calm.button-outline { - border-color: #11c1f3; - background: transparent; - color: #11c1f3; } - .button.button-calm.button-outline.active, .button.button-calm.button-outline.activated { - background-color: #11c1f3; - box-shadow: none; - color: #fff; } - .button.button-assertive { - border-color: transparent; - background-color: #ef473a; - color: #fff; } - .button.button-assertive:hover { - color: #fff; - text-decoration: none; } - .button.button-assertive.active, .button.button-assertive.activated { - border-color: #a2a2a2; - background-color: #e42112; } - .button.button-assertive.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #ef473a; } - .button.button-assertive.button-icon { - border-color: transparent; - background: none; } - .button.button-assertive.button-outline { - border-color: #ef473a; - background: transparent; - color: #ef473a; } - .button.button-assertive.button-outline.active, .button.button-assertive.button-outline.activated { - background-color: #ef473a; - box-shadow: none; - color: #fff; } - .button.button-balanced { - border-color: transparent; - background-color: #33cd5f; - color: #fff; } - .button.button-balanced:hover { - color: #fff; - text-decoration: none; } - .button.button-balanced.active, .button.button-balanced.activated { - border-color: #a2a2a2; - background-color: #28a54c; } - .button.button-balanced.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #33cd5f; } - .button.button-balanced.button-icon { - border-color: transparent; - background: none; } - .button.button-balanced.button-outline { - border-color: #33cd5f; - background: transparent; - color: #33cd5f; } - .button.button-balanced.button-outline.active, .button.button-balanced.button-outline.activated { - background-color: #33cd5f; - box-shadow: none; - color: #fff; } - .button.button-energized { - border-color: transparent; - background-color: #ffc900; - color: #fff; } - .button.button-energized:hover { - color: #fff; - text-decoration: none; } - .button.button-energized.active, .button.button-energized.activated { - border-color: #a2a2a2; - background-color: #e6b500; } - .button.button-energized.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #ffc900; } - .button.button-energized.button-icon { - border-color: transparent; - background: none; } - .button.button-energized.button-outline { - border-color: #ffc900; - background: transparent; - color: #ffc900; } - .button.button-energized.button-outline.active, .button.button-energized.button-outline.activated { - background-color: #ffc900; - box-shadow: none; - color: #fff; } - .button.button-royal { - border-color: transparent; - background-color: #886aea; - color: #fff; } - .button.button-royal:hover { - color: #fff; - text-decoration: none; } - .button.button-royal.active, .button.button-royal.activated { - border-color: #a2a2a2; - background-color: #6b46e5; } - .button.button-royal.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #886aea; } - .button.button-royal.button-icon { - border-color: transparent; - background: none; } - .button.button-royal.button-outline { - border-color: #886aea; - background: transparent; - color: #886aea; } - .button.button-royal.button-outline.active, .button.button-royal.button-outline.activated { - background-color: #886aea; - box-shadow: none; - color: #fff; } - .button.button-dark { - border-color: transparent; - background-color: #444; - color: #fff; } - .button.button-dark:hover { - color: #fff; - text-decoration: none; } - .button.button-dark.active, .button.button-dark.activated { - border-color: #a2a2a2; - background-color: #262626; } - .button.button-dark.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #444; } - .button.button-dark.button-icon { - border-color: transparent; - background: none; } - .button.button-dark.button-outline { - border-color: #444; - background: transparent; - color: #444; } - .button.button-dark.button-outline.active, .button.button-dark.button-outline.activated { - background-color: #444; - box-shadow: none; - color: #fff; } - -.button-small { - padding: 2px 4px 1px; - min-width: 28px; - min-height: 30px; - font-size: 12px; - line-height: 26px; } - .button-small .icon:before, .button-small.icon:before, .button-small.icon-left:before, .button-small.icon-right:before { - font-size: 16px; - line-height: 19px; - margin-top: 3px; } - -.button-large { - padding: 0 16px; - min-width: 68px; - min-height: 59px; - font-size: 20px; - line-height: 53px; } - .button-large .icon:before, .button-large.icon:before, .button-large.icon-left:before, .button-large.icon-right:before { - padding-bottom: 2px; - font-size: 32px; - line-height: 51px; } - -.button-icon { - -webkit-transition: opacity 0.1s; - transition: opacity 0.1s; - padding: 0 6px; - min-width: initial; - border-color: transparent; - background: none; } - .button-icon.button.active, .button-icon.button.activated { - border-color: transparent; - background: none; - box-shadow: none; - opacity: 0.3; } - .button-icon .icon:before, .button-icon.icon:before { - font-size: 32px; } - -.button-clear { - -webkit-transition: opacity 0.1s; - transition: opacity 0.1s; - padding: 0 6px; - max-height: 42px; - border-color: transparent; - background: none; - box-shadow: none; } - .button-clear.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: transparent; } - .button-clear.button-icon { - border-color: transparent; - background: none; } - .button-clear.active, .button-clear.activated { - opacity: 0.3; } - -.button-outline { - -webkit-transition: opacity 0.1s; - transition: opacity 0.1s; - background: none; - box-shadow: none; } - .button-outline.button-outline { - border-color: transparent; - background: transparent; - color: transparent; } - .button-outline.button-outline.active, .button-outline.button-outline.activated { - background-color: transparent; - box-shadow: none; - color: #fff; } - -.padding > .button.button-block:first-child { - margin-top: 0; } - -.button-block { - display: block; - clear: both; } - .button-block:after { - clear: both; } - -.button-full, -.button-full > .button { - display: block; - margin-right: 0; - margin-left: 0; - border-right-width: 0; - border-left-width: 0; - border-radius: 0; } - -button.button-block, -button.button-full, -.button-full > button.button, -input.button.button-block { - width: 100%; } - -a.button { - text-decoration: none; } - a.button .icon:before, a.button.icon:before, a.button.icon-left:before, a.button.icon-right:before { - margin-top: 2px; } - -.button.disabled, -.button[disabled] { - opacity: .4; - cursor: default !important; - pointer-events: none; } - -/** - * Button Bar - * -------------------------------------------------- - */ -.button-bar { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-flex: 1; - -webkit-flex: 1; - -moz-box-flex: 1; - -moz-flex: 1; - -ms-flex: 1; - flex: 1; - width: 100%; } - .button-bar.button-bar-inline { - display: block; - width: auto; - *zoom: 1; } - .button-bar.button-bar-inline:before, .button-bar.button-bar-inline:after { - display: table; - content: ""; - line-height: 0; } - .button-bar.button-bar-inline:after { - clear: both; } - .button-bar.button-bar-inline > .button { - width: auto; - display: inline-block; - float: left; } - .button-bar.bar-light > .button { - border-color: #ddd; } - .button-bar.bar-stable > .button { - border-color: #b2b2b2; } - .button-bar.bar-positive > .button { - border-color: #0c60ee; } - .button-bar.bar-calm > .button { - border-color: #0a9dc7; } - .button-bar.bar-assertive > .button { - border-color: #e42112; } - .button-bar.bar-balanced > .button { - border-color: #28a54c; } - .button-bar.bar-energized > .button { - border-color: #e6b500; } - .button-bar.bar-royal > .button { - border-color: #6b46e5; } - .button-bar.bar-dark > .button { - border-color: #111; } - -.button-bar > .button { - -webkit-box-flex: 1; - -webkit-flex: 1; - -moz-box-flex: 1; - -moz-flex: 1; - -ms-flex: 1; - flex: 1; - display: block; - overflow: hidden; - padding: 0 16px; - width: 0; - border-width: 1px 0px 1px 1px; - border-radius: 0; - text-align: center; - text-overflow: ellipsis; - white-space: nowrap; } - .button-bar > .button:before, - .button-bar > .button .icon:before { - line-height: 44px; } - .button-bar > .button:first-child { - border-radius: 4px 0px 0px 4px; } - .button-bar > .button:last-child { - border-right-width: 1px; - border-radius: 0px 4px 4px 0px; } - .button-bar > .button:only-child { - border-radius: 4px; } - -.button-bar > .button-small:before, -.button-bar > .button-small .icon:before { - line-height: 28px; } - -/** - * Grid - * -------------------------------------------------- - * Using flexbox for the grid, inspired by Philip Walton: - * http://philipwalton.github.io/solved-by-flexbox/demos/grids/ - * By default each .col within a .row will evenly take up - * available width, and the height of each .col with take - * up the height of the tallest .col in the same .row. - */ -.row { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - padding: 5px; - width: 100%; } - -.row-wrap { - -webkit-flex-wrap: wrap; - -moz-flex-wrap: wrap; - -ms-flex-wrap: wrap; - flex-wrap: wrap; } - -.row-no-padding { - padding: 0; } - .row-no-padding > .col { - padding: 0; } - -.row + .row { - margin-top: -5px; - padding-top: 0; } - -.col { - -webkit-box-flex: 1; - -webkit-flex: 1; - -moz-box-flex: 1; - -moz-flex: 1; - -ms-flex: 1; - flex: 1; - display: block; - padding: 5px; - width: 100%; } - -/* Vertically Align Columns */ -/* .row-* vertically aligns every .col in the .row */ -.row-top { - -webkit-box-align: start; - -ms-flex-align: start; - -webkit-align-items: flex-start; - -moz-align-items: flex-start; - align-items: flex-start; } - -.row-bottom { - -webkit-box-align: end; - -ms-flex-align: end; - -webkit-align-items: flex-end; - -moz-align-items: flex-end; - align-items: flex-end; } - -.row-center { - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; } - -.row-stretch { - -webkit-box-align: stretch; - -ms-flex-align: stretch; - -webkit-align-items: stretch; - -moz-align-items: stretch; - align-items: stretch; } - -.row-baseline { - -webkit-box-align: baseline; - -ms-flex-align: baseline; - -webkit-align-items: baseline; - -moz-align-items: baseline; - align-items: baseline; } - -/* .col-* vertically aligns an individual .col */ -.col-top { - -webkit-align-self: flex-start; - -moz-align-self: flex-start; - -ms-flex-item-align: start; - align-self: flex-start; } - -.col-bottom { - -webkit-align-self: flex-end; - -moz-align-self: flex-end; - -ms-flex-item-align: end; - align-self: flex-end; } - -.col-center { - -webkit-align-self: center; - -moz-align-self: center; - -ms-flex-item-align: center; - align-self: center; } - -/* Column Offsets */ -.col-offset-10 { - margin-left: 10%; } - -.col-offset-20 { - margin-left: 20%; } - -.col-offset-25 { - margin-left: 25%; } - -.col-offset-33, .col-offset-34 { - margin-left: 33.3333%; } - -.col-offset-50 { - margin-left: 50%; } - -.col-offset-66, .col-offset-67 { - margin-left: 66.6666%; } - -.col-offset-75 { - margin-left: 75%; } - -.col-offset-80 { - margin-left: 80%; } - -.col-offset-90 { - margin-left: 90%; } - -/* Explicit Column Percent Sizes */ -/* By default each grid column will evenly distribute */ -/* across the grid. However, you can specify individual */ -/* columns to take up a certain size of the available area */ -.col-10 { - -webkit-box-flex: 0; - -webkit-flex: 0 0 10%; - -moz-box-flex: 0; - -moz-flex: 0 0 10%; - -ms-flex: 0 0 10%; - flex: 0 0 10%; - max-width: 10%; } - -.col-20 { - -webkit-box-flex: 0; - -webkit-flex: 0 0 20%; - -moz-box-flex: 0; - -moz-flex: 0 0 20%; - -ms-flex: 0 0 20%; - flex: 0 0 20%; - max-width: 20%; } - -.col-25 { - -webkit-box-flex: 0; - -webkit-flex: 0 0 25%; - -moz-box-flex: 0; - -moz-flex: 0 0 25%; - -ms-flex: 0 0 25%; - flex: 0 0 25%; - max-width: 25%; } - -.col-33, .col-34 { - -webkit-box-flex: 0; - -webkit-flex: 0 0 33.3333%; - -moz-box-flex: 0; - -moz-flex: 0 0 33.3333%; - -ms-flex: 0 0 33.3333%; - flex: 0 0 33.3333%; - max-width: 33.3333%; } - -.col-40 { - -webkit-box-flex: 0; - -webkit-flex: 0 0 40%; - -moz-box-flex: 0; - -moz-flex: 0 0 40%; - -ms-flex: 0 0 40%; - flex: 0 0 40%; - max-width: 40%; } - -.col-50 { - -webkit-box-flex: 0; - -webkit-flex: 0 0 50%; - -moz-box-flex: 0; - -moz-flex: 0 0 50%; - -ms-flex: 0 0 50%; - flex: 0 0 50%; - max-width: 50%; } - -.col-60 { - -webkit-box-flex: 0; - -webkit-flex: 0 0 60%; - -moz-box-flex: 0; - -moz-flex: 0 0 60%; - -ms-flex: 0 0 60%; - flex: 0 0 60%; - max-width: 60%; } - -.col-66, .col-67 { - -webkit-box-flex: 0; - -webkit-flex: 0 0 66.6666%; - -moz-box-flex: 0; - -moz-flex: 0 0 66.6666%; - -ms-flex: 0 0 66.6666%; - flex: 0 0 66.6666%; - max-width: 66.6666%; } - -.col-75 { - -webkit-box-flex: 0; - -webkit-flex: 0 0 75%; - -moz-box-flex: 0; - -moz-flex: 0 0 75%; - -ms-flex: 0 0 75%; - flex: 0 0 75%; - max-width: 75%; } - -.col-80 { - -webkit-box-flex: 0; - -webkit-flex: 0 0 80%; - -moz-box-flex: 0; - -moz-flex: 0 0 80%; - -ms-flex: 0 0 80%; - flex: 0 0 80%; - max-width: 80%; } - -.col-90 { - -webkit-box-flex: 0; - -webkit-flex: 0 0 90%; - -moz-box-flex: 0; - -moz-flex: 0 0 90%; - -ms-flex: 0 0 90%; - flex: 0 0 90%; - max-width: 90%; } - -/* Responsive Grid Classes */ -/* Adding a class of responsive-X to a row */ -/* will trigger the flex-direction to */ -/* change to column and add some margin */ -/* to any columns in the row for clearity */ -@media (max-width: 567px) { - .responsive-sm { - -webkit-box-direction: normal; - -moz-box-direction: normal; - -webkit-box-orient: vertical; - -moz-box-orient: vertical; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; } - .responsive-sm .col, .responsive-sm .col-10, .responsive-sm .col-20, .responsive-sm .col-25, .responsive-sm .col-33, .responsive-sm .col-34, .responsive-sm .col-50, .responsive-sm .col-66, .responsive-sm .col-67, .responsive-sm .col-75, .responsive-sm .col-80, .responsive-sm .col-90 { - -webkit-box-flex: 1; - -webkit-flex: 1; - -moz-box-flex: 1; - -moz-flex: 1; - -ms-flex: 1; - flex: 1; - margin-bottom: 15px; - margin-left: 0; - max-width: 100%; - width: 100%; } } - -@media (max-width: 767px) { - .responsive-md { - -webkit-box-direction: normal; - -moz-box-direction: normal; - -webkit-box-orient: vertical; - -moz-box-orient: vertical; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; } - .responsive-md .col, .responsive-md .col-10, .responsive-md .col-20, .responsive-md .col-25, .responsive-md .col-33, .responsive-md .col-34, .responsive-md .col-50, .responsive-md .col-66, .responsive-md .col-67, .responsive-md .col-75, .responsive-md .col-80, .responsive-md .col-90 { - -webkit-box-flex: 1; - -webkit-flex: 1; - -moz-box-flex: 1; - -moz-flex: 1; - -ms-flex: 1; - flex: 1; - margin-bottom: 15px; - margin-left: 0; - max-width: 100%; - width: 100%; } } - -@media (max-width: 1023px) { - .responsive-lg { - -webkit-box-direction: normal; - -moz-box-direction: normal; - -webkit-box-orient: vertical; - -moz-box-orient: vertical; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; } - .responsive-lg .col, .responsive-lg .col-10, .responsive-lg .col-20, .responsive-lg .col-25, .responsive-lg .col-33, .responsive-lg .col-34, .responsive-lg .col-50, .responsive-lg .col-66, .responsive-lg .col-67, .responsive-lg .col-75, .responsive-lg .col-80, .responsive-lg .col-90 { - -webkit-box-flex: 1; - -webkit-flex: 1; - -moz-box-flex: 1; - -moz-flex: 1; - -ms-flex: 1; - flex: 1; - margin-bottom: 15px; - margin-left: 0; - max-width: 100%; - width: 100%; } } - -/** - * Utility Classes - * -------------------------------------------------- - */ -.hide { - display: none; } - -.opacity-hide { - opacity: 0; } - -.grade-b .opacity-hide, -.grade-c .opacity-hide { - opacity: 1; - display: none; } - -.show { - display: block; } - -.opacity-show { - opacity: 1; } - -.invisible { - visibility: hidden; } - -.keyboard-open .hide-on-keyboard-open { - display: none; } - -.keyboard-open .tabs.hide-on-keyboard-open + .pane .has-tabs, -.keyboard-open .bar-footer.hide-on-keyboard-open + .pane .has-footer { - bottom: 0; } - -.inline { - display: inline-block; } - -.disable-pointer-events { - pointer-events: none; } - -.enable-pointer-events { - pointer-events: auto; } - -.disable-user-behavior { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - -webkit-touch-callout: none; - -webkit-tap-highlight-color: transparent; - -webkit-tap-highlight-color: transparent; - -webkit-user-drag: none; - -ms-touch-action: none; - -ms-content-zooming: none; } - -.click-block { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - opacity: 0; - z-index: 99999; - -webkit-transform: translate3d(0, 0, 0); - transform: translate3d(0, 0, 0); - overflow: hidden; } - -.click-block-hide { - -webkit-transform: translate3d(-9999px, 0, 0); - transform: translate3d(-9999px, 0, 0); } - -.no-resize { - resize: none; } - -.block { - display: block; - clear: both; } - .block:after { - display: block; - visibility: hidden; - clear: both; - height: 0; - content: "."; } - -.full-image { - width: 100%; } - -.clearfix { - *zoom: 1; } - .clearfix:before, .clearfix:after { - display: table; - content: ""; - line-height: 0; } - .clearfix:after { - clear: both; } - -/** - * Content Padding - * -------------------------------------------------- - */ -.padding { - padding: 10px; } - -.padding-top, -.padding-vertical { - padding-top: 10px; } - -.padding-right, -.padding-horizontal { - padding-right: 10px; } - -.padding-bottom, -.padding-vertical { - padding-bottom: 10px; } - -.padding-left, -.padding-horizontal { - padding-left: 10px; } - -/** - * Scrollable iFrames - * -------------------------------------------------- - */ -.iframe-wrapper { - position: fixed; - -webkit-overflow-scrolling: touch; - overflow: scroll; } - .iframe-wrapper iframe { - height: 100%; - width: 100%; } - -/** - * Rounded - * -------------------------------------------------- - */ -.rounded { - border-radius: 4px; } - -/** - * Utility Colors - * -------------------------------------------------- - * Utility colors are added to help set a naming convention. You'll - * notice we purposely do not use words like "red" or "blue", but - * instead have colors which represent an emotion or generic theme. - */ -.light, a.light { - color: #fff; } - -.light-bg { - background-color: #fff; } - -.light-border { - border-color: #ddd; } - -.stable, a.stable { - color: #f8f8f8; } - -.stable-bg { - background-color: #f8f8f8; } - -.stable-border { - border-color: #b2b2b2; } - -.positive, a.positive { - color: #387ef5; } - -.positive-bg { - background-color: #387ef5; } - -.positive-border { - border-color: #0c60ee; } - -.calm, a.calm { - color: #11c1f3; } - -.calm-bg { - background-color: #11c1f3; } - -.calm-border { - border-color: #0a9dc7; } - -.assertive, a.assertive { - color: #ef473a; } - -.assertive-bg { - background-color: #ef473a; } - -.assertive-border { - border-color: #e42112; } - -.balanced, a.balanced { - color: #33cd5f; } - -.balanced-bg { - background-color: #33cd5f; } - -.balanced-border { - border-color: #28a54c; } - -.energized, a.energized { - color: #ffc900; } - -.energized-bg { - background-color: #ffc900; } - -.energized-border { - border-color: #e6b500; } - -.royal, a.royal { - color: #886aea; } - -.royal-bg { - background-color: #886aea; } - -.royal-border { - border-color: #6b46e5; } - -.dark, a.dark { - color: #444; } - -.dark-bg { - background-color: #444; } - -.dark-border { - border-color: #111; } - -[collection-repeat] { - /* Position is set by transforms */ - left: 0 !important; - top: 0 !important; - position: absolute !important; - z-index: 1; } - -.collection-repeat-container { - position: relative; - z-index: 1; } - -.collection-repeat-after-container { - z-index: 0; - display: block; - /* when scrolling horizontally, make sure the after container doesn't take up 100% width */ } - .collection-repeat-after-container.horizontal { - display: inline-block; } - -[ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, -.x-ng-cloak, .ng-hide:not(.ng-hide-animate) { - display: none !important; } - -/** - * Platform - * -------------------------------------------------- - * Platform specific tweaks - */ -.platform-ios.platform-cordova:not(.fullscreen) .bar-header:not(.bar-subheader) { - height: 64px; } - .platform-ios.platform-cordova:not(.fullscreen) .bar-header:not(.bar-subheader).item-input-inset .item-input-wrapper { - margin-top: 19px !important; } - .platform-ios.platform-cordova:not(.fullscreen) .bar-header:not(.bar-subheader) > * { - margin-top: 20px; } - -.platform-ios.platform-cordova:not(.fullscreen) .tabs-top > .tabs, -.platform-ios.platform-cordova:not(.fullscreen) .tabs.tabs-top { - top: 64px; } - -.platform-ios.platform-cordova:not(.fullscreen) .has-header, -.platform-ios.platform-cordova:not(.fullscreen) .bar-subheader { - top: 64px; } - -.platform-ios.platform-cordova:not(.fullscreen) .has-subheader { - top: 108px; } - -.platform-ios.platform-cordova:not(.fullscreen) .has-header.has-tabs-top { - top: 113px; } - -.platform-ios.platform-cordova:not(.fullscreen) .has-header.has-subheader.has-tabs-top { - top: 157px; } - -.platform-ios.platform-cordova .popover .bar-header:not(.bar-subheader) { - height: 44px; } - .platform-ios.platform-cordova .popover .bar-header:not(.bar-subheader).item-input-inset .item-input-wrapper { - margin-top: -1px; } - .platform-ios.platform-cordova .popover .bar-header:not(.bar-subheader) > * { - margin-top: 0; } - -.platform-ios.platform-cordova .popover .has-header, -.platform-ios.platform-cordova .popover .bar-subheader { - top: 44px; } - -.platform-ios.platform-cordova .popover .has-subheader { - top: 88px; } - -.platform-ios.platform-cordova.status-bar-hide { - margin-bottom: 20px; } - -@media (orientation: landscape) { - .platform-ios.platform-browser.platform-ipad { - position: fixed; } } - -.platform-c:not(.enable-transitions) * { - -webkit-transition: none !important; - transition: none !important; } - -.slide-in-up { - -webkit-transform: translate3d(0, 100%, 0); - transform: translate3d(0, 100%, 0); } - -.slide-in-up.ng-enter, -.slide-in-up > .ng-enter { - -webkit-transition: all cubic-bezier(0.1, 0.7, 0.1, 1) 400ms; - transition: all cubic-bezier(0.1, 0.7, 0.1, 1) 400ms; } - -.slide-in-up.ng-enter-active, -.slide-in-up > .ng-enter-active { - -webkit-transform: translate3d(0, 0, 0); - transform: translate3d(0, 0, 0); } - -.slide-in-up.ng-leave, -.slide-in-up > .ng-leave { - -webkit-transition: all ease-in-out 250ms; - transition: all ease-in-out 250ms; } - -@-webkit-keyframes scaleOut { - from { - -webkit-transform: scale(1); - opacity: 1; } - to { - -webkit-transform: scale(0.8); - opacity: 0; } } - -@keyframes scaleOut { - from { - transform: scale(1); - opacity: 1; } - to { - transform: scale(0.8); - opacity: 0; } } - -@-webkit-keyframes superScaleIn { - from { - -webkit-transform: scale(1.2); - opacity: 0; } - to { - -webkit-transform: scale(1); - opacity: 1; } } - -@keyframes superScaleIn { - from { - transform: scale(1.2); - opacity: 0; } - to { - transform: scale(1); - opacity: 1; } } - -[nav-view-transition="ios"] [nav-view="entering"], -[nav-view-transition="ios"] [nav-view="leaving"] { - -webkit-transition-duration: 500ms; - transition-duration: 500ms; - -webkit-transition-timing-function: cubic-bezier(0.36, 0.66, 0.04, 1); - transition-timing-function: cubic-bezier(0.36, 0.66, 0.04, 1); - -webkit-transition-property: opacity, -webkit-transform, box-shadow; - transition-property: opacity, transform, box-shadow; } - -[nav-view-transition="ios"][nav-view-direction="forward"], [nav-view-transition="ios"][nav-view-direction="back"] { - background-color: #000; } - -[nav-view-transition="ios"] [nav-view="active"], -[nav-view-transition="ios"][nav-view-direction="forward"] [nav-view="entering"], -[nav-view-transition="ios"][nav-view-direction="back"] [nav-view="leaving"] { - z-index: 3; } - -[nav-view-transition="ios"][nav-view-direction="back"] [nav-view="entering"], -[nav-view-transition="ios"][nav-view-direction="forward"] [nav-view="leaving"] { - z-index: 2; } - -[nav-bar-transition="ios"] .title, -[nav-bar-transition="ios"] .buttons, -[nav-bar-transition="ios"] .back-text { - -webkit-transition-duration: 500ms; - transition-duration: 500ms; - -webkit-transition-timing-function: cubic-bezier(0.36, 0.66, 0.04, 1); - transition-timing-function: cubic-bezier(0.36, 0.66, 0.04, 1); - -webkit-transition-property: opacity, -webkit-transform; - transition-property: opacity, transform; } - -[nav-bar-transition="ios"] [nav-bar="active"], -[nav-bar-transition="ios"] [nav-bar="entering"] { - z-index: 10; } - [nav-bar-transition="ios"] [nav-bar="active"] .bar, - [nav-bar-transition="ios"] [nav-bar="entering"] .bar { - background: transparent; } - -[nav-bar-transition="ios"] [nav-bar="cached"] { - display: block; } - [nav-bar-transition="ios"] [nav-bar="cached"] .header-item { - display: none; } - -[nav-view-transition="android"] [nav-view="entering"], -[nav-view-transition="android"] [nav-view="leaving"] { - -webkit-transition-duration: 200ms; - transition-duration: 200ms; - -webkit-transition-timing-function: cubic-bezier(0.4, 0.6, 0.2, 1); - transition-timing-function: cubic-bezier(0.4, 0.6, 0.2, 1); - -webkit-transition-property: -webkit-transform; - transition-property: transform; } - -[nav-view-transition="android"] [nav-view="active"], -[nav-view-transition="android"][nav-view-direction="forward"] [nav-view="entering"], -[nav-view-transition="android"][nav-view-direction="back"] [nav-view="leaving"] { - z-index: 3; } - -[nav-view-transition="android"][nav-view-direction="back"] [nav-view="entering"], -[nav-view-transition="android"][nav-view-direction="forward"] [nav-view="leaving"] { - z-index: 2; } - -[nav-bar-transition="android"] .title, -[nav-bar-transition="android"] .buttons { - -webkit-transition-duration: 200ms; - transition-duration: 200ms; - -webkit-transition-timing-function: cubic-bezier(0.4, 0.6, 0.2, 1); - transition-timing-function: cubic-bezier(0.4, 0.6, 0.2, 1); - -webkit-transition-property: opacity; - transition-property: opacity; } - -[nav-bar-transition="android"] [nav-bar="active"], -[nav-bar-transition="android"] [nav-bar="entering"] { - z-index: 10; } - [nav-bar-transition="android"] [nav-bar="active"] .bar, - [nav-bar-transition="android"] [nav-bar="entering"] .bar { - background: transparent; } - -[nav-bar-transition="android"] [nav-bar="cached"] { - display: block; } - [nav-bar-transition="android"] [nav-bar="cached"] .header-item { - display: none; } - -[nav-swipe="fast"] [nav-view], -[nav-swipe="fast"] .title, -[nav-swipe="fast"] .buttons, -[nav-swipe="fast"] .back-text { - -webkit-transition-duration: 50ms; - transition-duration: 50ms; - -webkit-transition-timing-function: linear; - transition-timing-function: linear; } - -[nav-swipe="slow"] [nav-view], -[nav-swipe="slow"] .title, -[nav-swipe="slow"] .buttons, -[nav-swipe="slow"] .back-text { - -webkit-transition-duration: 160ms; - transition-duration: 160ms; - -webkit-transition-timing-function: linear; - transition-timing-function: linear; } - -[nav-view="cached"], -[nav-bar="cached"] { - display: none; } - -[nav-view="stage"] { - opacity: 0; - -webkit-transition-duration: 0; - transition-duration: 0; } - -[nav-bar="stage"] .title, -[nav-bar="stage"] .buttons, -[nav-bar="stage"] .back-text { - position: absolute; - opacity: 0; - -webkit-transition-duration: 0s; - transition-duration: 0s; } diff --git a/www/manual_lib/ionic/css/ionic.min.css b/www/manual_lib/ionic/css/ionic.min.css deleted file mode 100644 index 9159a0901..000000000 --- a/www/manual_lib/ionic/css/ionic.min.css +++ /dev/null @@ -1,23 +0,0 @@ -@charset "UTF-8";/*! - * Copyright 2015 Drifty Co. - * http://drifty.com/ - * - * Ionic, v1.3.3 - * A powerful HTML5 mobile app framework. - * http://ionicframework.com/ - * - * By @maxlynch, @benjsperry, @adamdbradley <3 - * - * Licensed under the MIT license. Please see LICENSE for more information. - * - *//*! - Ionicons, v2.0.1 - Created by Ben Sperry for the Ionic Framework, http://ionicons.com/ - https://twitter.com/benjsperry https://twitter.com/ionicframework - MIT License: https://github.com/driftyco/ionicons - - Android-style icons originally built by Google’s - Material Design Icons: https://github.com/google/material-design-icons - used under CC BY http://creativecommons.org/licenses/by/4.0/ - Modified icons to fit ionicon’s grid from original. -*/@font-face{font-family:Ionicons;src:url(../fonts/ionicons.eot?v=2.0.1);src:url(../fonts/ionicons.eot?v=2.0.1#iefix) format("embedded-opentype"),url(../fonts/ionicons.ttf?v=2.0.1) format("truetype"),url(../fonts/ionicons.woff?v=2.0.1) format("woff"),url(../fonts/ionicons.woff) format("woff"),url(../fonts/ionicons.svg?v=2.0.1#Ionicons) format("svg");font-weight:400;font-style:normal}.ion,.ion-alert-circled:before,.ion-alert:before,.ion-android-add-circle:before,.ion-android-add:before,.ion-android-alarm-clock:before,.ion-android-alert:before,.ion-android-apps:before,.ion-android-archive:before,.ion-android-arrow-back:before,.ion-android-arrow-down:before,.ion-android-arrow-dropdown-circle:before,.ion-android-arrow-dropdown:before,.ion-android-arrow-dropleft-circle:before,.ion-android-arrow-dropleft:before,.ion-android-arrow-dropright-circle:before,.ion-android-arrow-dropright:before,.ion-android-arrow-dropup-circle:before,.ion-android-arrow-dropup:before,.ion-android-arrow-forward:before,.ion-android-arrow-up:before,.ion-android-attach:before,.ion-android-bar:before,.ion-android-bicycle:before,.ion-android-boat:before,.ion-android-bookmark:before,.ion-android-bulb:before,.ion-android-bus:before,.ion-android-calendar:before,.ion-android-call:before,.ion-android-camera:before,.ion-android-cancel:before,.ion-android-car:before,.ion-android-cart:before,.ion-android-chat:before,.ion-android-checkbox-blank:before,.ion-android-checkbox-outline-blank:before,.ion-android-checkbox-outline:before,.ion-android-checkbox:before,.ion-android-checkmark-circle:before,.ion-android-clipboard:before,.ion-android-close:before,.ion-android-cloud-circle:before,.ion-android-cloud-done:before,.ion-android-cloud-outline:before,.ion-android-cloud:before,.ion-android-color-palette:before,.ion-android-compass:before,.ion-android-contact:before,.ion-android-contacts:before,.ion-android-contract:before,.ion-android-create:before,.ion-android-delete:before,.ion-android-desktop:before,.ion-android-document:before,.ion-android-done-all:before,.ion-android-done:before,.ion-android-download:before,.ion-android-drafts:before,.ion-android-exit:before,.ion-android-expand:before,.ion-android-favorite-outline:before,.ion-android-favorite:before,.ion-android-film:before,.ion-android-folder-open:before,.ion-android-folder:before,.ion-android-funnel:before,.ion-android-globe:before,.ion-android-hand:before,.ion-android-hangout:before,.ion-android-happy:before,.ion-android-home:before,.ion-android-image:before,.ion-android-laptop:before,.ion-android-list:before,.ion-android-locate:before,.ion-android-lock:before,.ion-android-mail:before,.ion-android-map:before,.ion-android-menu:before,.ion-android-microphone-off:before,.ion-android-microphone:before,.ion-android-more-horizontal:before,.ion-android-more-vertical:before,.ion-android-navigate:before,.ion-android-notifications-none:before,.ion-android-notifications-off:before,.ion-android-notifications:before,.ion-android-open:before,.ion-android-options:before,.ion-android-people:before,.ion-android-person-add:before,.ion-android-person:before,.ion-android-phone-landscape:before,.ion-android-phone-portrait:before,.ion-android-pin:before,.ion-android-plane:before,.ion-android-playstore:before,.ion-android-print:before,.ion-android-radio-button-off:before,.ion-android-radio-button-on:before,.ion-android-refresh:before,.ion-android-remove-circle:before,.ion-android-remove:before,.ion-android-restaurant:before,.ion-android-sad:before,.ion-android-search:before,.ion-android-send:before,.ion-android-settings:before,.ion-android-share-alt:before,.ion-android-share:before,.ion-android-star-half:before,.ion-android-star-outline:before,.ion-android-star:before,.ion-android-stopwatch:before,.ion-android-subway:before,.ion-android-sunny:before,.ion-android-sync:before,.ion-android-textsms:before,.ion-android-time:before,.ion-android-train:before,.ion-android-unlock:before,.ion-android-upload:before,.ion-android-volume-down:before,.ion-android-volume-mute:before,.ion-android-volume-off:before,.ion-android-volume-up:before,.ion-android-walk:before,.ion-android-warning:before,.ion-android-watch:before,.ion-android-wifi:before,.ion-aperture:before,.ion-archive:before,.ion-arrow-down-a:before,.ion-arrow-down-b:before,.ion-arrow-down-c:before,.ion-arrow-expand:before,.ion-arrow-graph-down-left:before,.ion-arrow-graph-down-right:before,.ion-arrow-graph-up-left:before,.ion-arrow-graph-up-right:before,.ion-arrow-left-a:before,.ion-arrow-left-b:before,.ion-arrow-left-c:before,.ion-arrow-move:before,.ion-arrow-resize:before,.ion-arrow-return-left:before,.ion-arrow-return-right:before,.ion-arrow-right-a:before,.ion-arrow-right-b:before,.ion-arrow-right-c:before,.ion-arrow-shrink:before,.ion-arrow-swap:before,.ion-arrow-up-a:before,.ion-arrow-up-b:before,.ion-arrow-up-c:before,.ion-asterisk:before,.ion-at:before,.ion-backspace-outline:before,.ion-backspace:before,.ion-bag:before,.ion-battery-charging:before,.ion-battery-empty:before,.ion-battery-full:before,.ion-battery-half:before,.ion-battery-low:before,.ion-beaker:before,.ion-beer:before,.ion-bluetooth:before,.ion-bonfire:before,.ion-bookmark:before,.ion-bowtie:before,.ion-briefcase:before,.ion-bug:before,.ion-calculator:before,.ion-calendar:before,.ion-camera:before,.ion-card:before,.ion-cash:before,.ion-chatbox-working:before,.ion-chatbox:before,.ion-chatboxes:before,.ion-chatbubble-working:before,.ion-chatbubble:before,.ion-chatbubbles:before,.ion-checkmark-circled:before,.ion-checkmark-round:before,.ion-checkmark:before,.ion-chevron-down:before,.ion-chevron-left:before,.ion-chevron-right:before,.ion-chevron-up:before,.ion-clipboard:before,.ion-clock:before,.ion-close-circled:before,.ion-close-round:before,.ion-close:before,.ion-closed-captioning:before,.ion-cloud:before,.ion-code-download:before,.ion-code-working:before,.ion-code:before,.ion-coffee:before,.ion-compass:before,.ion-compose:before,.ion-connection-bars:before,.ion-contrast:before,.ion-crop:before,.ion-cube:before,.ion-disc:before,.ion-document-text:before,.ion-document:before,.ion-drag:before,.ion-earth:before,.ion-easel:before,.ion-edit:before,.ion-egg:before,.ion-eject:before,.ion-email-unread:before,.ion-email:before,.ion-erlenmeyer-flask-bubbles:before,.ion-erlenmeyer-flask:before,.ion-eye-disabled:before,.ion-eye:before,.ion-female:before,.ion-filing:before,.ion-film-marker:before,.ion-fireball:before,.ion-flag:before,.ion-flame:before,.ion-flash-off:before,.ion-flash:before,.ion-folder:before,.ion-fork-repo:before,.ion-fork:before,.ion-forward:before,.ion-funnel:before,.ion-gear-a:before,.ion-gear-b:before,.ion-grid:before,.ion-hammer:before,.ion-happy-outline:before,.ion-happy:before,.ion-headphone:before,.ion-heart-broken:before,.ion-heart:before,.ion-help-buoy:before,.ion-help-circled:before,.ion-help:before,.ion-home:before,.ion-icecream:before,.ion-image:before,.ion-images:before,.ion-information-circled:before,.ion-information:before,.ion-ionic:before,.ion-ios-alarm-outline:before,.ion-ios-alarm:before,.ion-ios-albums-outline:before,.ion-ios-albums:before,.ion-ios-americanfootball-outline:before,.ion-ios-americanfootball:before,.ion-ios-analytics-outline:before,.ion-ios-analytics:before,.ion-ios-arrow-back:before,.ion-ios-arrow-down:before,.ion-ios-arrow-forward:before,.ion-ios-arrow-left:before,.ion-ios-arrow-right:before,.ion-ios-arrow-thin-down:before,.ion-ios-arrow-thin-left:before,.ion-ios-arrow-thin-right:before,.ion-ios-arrow-thin-up:before,.ion-ios-arrow-up:before,.ion-ios-at-outline:before,.ion-ios-at:before,.ion-ios-barcode-outline:before,.ion-ios-barcode:before,.ion-ios-baseball-outline:before,.ion-ios-baseball:before,.ion-ios-basketball-outline:before,.ion-ios-basketball:before,.ion-ios-bell-outline:before,.ion-ios-bell:before,.ion-ios-body-outline:before,.ion-ios-body:before,.ion-ios-bolt-outline:before,.ion-ios-bolt:before,.ion-ios-book-outline:before,.ion-ios-book:before,.ion-ios-bookmarks-outline:before,.ion-ios-bookmarks:before,.ion-ios-box-outline:before,.ion-ios-box:before,.ion-ios-briefcase-outline:before,.ion-ios-briefcase:before,.ion-ios-browsers-outline:before,.ion-ios-browsers:before,.ion-ios-calculator-outline:before,.ion-ios-calculator:before,.ion-ios-calendar-outline:before,.ion-ios-calendar:before,.ion-ios-camera-outline:before,.ion-ios-camera:before,.ion-ios-cart-outline:before,.ion-ios-cart:before,.ion-ios-chatboxes-outline:before,.ion-ios-chatboxes:before,.ion-ios-chatbubble-outline:before,.ion-ios-chatbubble:before,.ion-ios-checkmark-empty:before,.ion-ios-checkmark-outline:before,.ion-ios-checkmark:before,.ion-ios-circle-filled:before,.ion-ios-circle-outline:before,.ion-ios-clock-outline:before,.ion-ios-clock:before,.ion-ios-close-empty:before,.ion-ios-close-outline:before,.ion-ios-close:before,.ion-ios-cloud-download-outline:before,.ion-ios-cloud-download:before,.ion-ios-cloud-outline:before,.ion-ios-cloud-upload-outline:before,.ion-ios-cloud-upload:before,.ion-ios-cloud:before,.ion-ios-cloudy-night-outline:before,.ion-ios-cloudy-night:before,.ion-ios-cloudy-outline:before,.ion-ios-cloudy:before,.ion-ios-cog-outline:before,.ion-ios-cog:before,.ion-ios-color-filter-outline:before,.ion-ios-color-filter:before,.ion-ios-color-wand-outline:before,.ion-ios-color-wand:before,.ion-ios-compose-outline:before,.ion-ios-compose:before,.ion-ios-contact-outline:before,.ion-ios-contact:before,.ion-ios-copy-outline:before,.ion-ios-copy:before,.ion-ios-crop-strong:before,.ion-ios-crop:before,.ion-ios-download-outline:before,.ion-ios-download:before,.ion-ios-drag:before,.ion-ios-email-outline:before,.ion-ios-email:before,.ion-ios-eye-outline:before,.ion-ios-eye:before,.ion-ios-fastforward-outline:before,.ion-ios-fastforward:before,.ion-ios-filing-outline:before,.ion-ios-filing:before,.ion-ios-film-outline:before,.ion-ios-film:before,.ion-ios-flag-outline:before,.ion-ios-flag:before,.ion-ios-flame-outline:before,.ion-ios-flame:before,.ion-ios-flask-outline:before,.ion-ios-flask:before,.ion-ios-flower-outline:before,.ion-ios-flower:before,.ion-ios-folder-outline:before,.ion-ios-folder:before,.ion-ios-football-outline:before,.ion-ios-football:before,.ion-ios-game-controller-a-outline:before,.ion-ios-game-controller-a:before,.ion-ios-game-controller-b-outline:before,.ion-ios-game-controller-b:before,.ion-ios-gear-outline:before,.ion-ios-gear:before,.ion-ios-glasses-outline:before,.ion-ios-glasses:before,.ion-ios-grid-view-outline:before,.ion-ios-grid-view:before,.ion-ios-heart-outline:before,.ion-ios-heart:before,.ion-ios-help-empty:before,.ion-ios-help-outline:before,.ion-ios-help:before,.ion-ios-home-outline:before,.ion-ios-home:before,.ion-ios-infinite-outline:before,.ion-ios-infinite:before,.ion-ios-information-empty:before,.ion-ios-information-outline:before,.ion-ios-information:before,.ion-ios-ionic-outline:before,.ion-ios-keypad-outline:before,.ion-ios-keypad:before,.ion-ios-lightbulb-outline:before,.ion-ios-lightbulb:before,.ion-ios-list-outline:before,.ion-ios-list:before,.ion-ios-location-outline:before,.ion-ios-location:before,.ion-ios-locked-outline:before,.ion-ios-locked:before,.ion-ios-loop-strong:before,.ion-ios-loop:before,.ion-ios-medical-outline:before,.ion-ios-medical:before,.ion-ios-medkit-outline:before,.ion-ios-medkit:before,.ion-ios-mic-off:before,.ion-ios-mic-outline:before,.ion-ios-mic:before,.ion-ios-minus-empty:before,.ion-ios-minus-outline:before,.ion-ios-minus:before,.ion-ios-monitor-outline:before,.ion-ios-monitor:before,.ion-ios-moon-outline:before,.ion-ios-moon:before,.ion-ios-more-outline:before,.ion-ios-more:before,.ion-ios-musical-note:before,.ion-ios-musical-notes:before,.ion-ios-navigate-outline:before,.ion-ios-navigate:before,.ion-ios-nutrition-outline:before,.ion-ios-nutrition:before,.ion-ios-paper-outline:before,.ion-ios-paper:before,.ion-ios-paperplane-outline:before,.ion-ios-paperplane:before,.ion-ios-partlysunny-outline:before,.ion-ios-partlysunny:before,.ion-ios-pause-outline:before,.ion-ios-pause:before,.ion-ios-paw-outline:before,.ion-ios-paw:before,.ion-ios-people-outline:before,.ion-ios-people:before,.ion-ios-person-outline:before,.ion-ios-person:before,.ion-ios-personadd-outline:before,.ion-ios-personadd:before,.ion-ios-photos-outline:before,.ion-ios-photos:before,.ion-ios-pie-outline:before,.ion-ios-pie:before,.ion-ios-pint-outline:before,.ion-ios-pint:before,.ion-ios-play-outline:before,.ion-ios-play:before,.ion-ios-plus-empty:before,.ion-ios-plus-outline:before,.ion-ios-plus:before,.ion-ios-pricetag-outline:before,.ion-ios-pricetag:before,.ion-ios-pricetags-outline:before,.ion-ios-pricetags:before,.ion-ios-printer-outline:before,.ion-ios-printer:before,.ion-ios-pulse-strong:before,.ion-ios-pulse:before,.ion-ios-rainy-outline:before,.ion-ios-rainy:before,.ion-ios-recording-outline:before,.ion-ios-recording:before,.ion-ios-redo-outline:before,.ion-ios-redo:before,.ion-ios-refresh-empty:before,.ion-ios-refresh-outline:before,.ion-ios-refresh:before,.ion-ios-reload:before,.ion-ios-reverse-camera-outline:before,.ion-ios-reverse-camera:before,.ion-ios-rewind-outline:before,.ion-ios-rewind:before,.ion-ios-rose-outline:before,.ion-ios-rose:before,.ion-ios-search-strong:before,.ion-ios-search:before,.ion-ios-settings-strong:before,.ion-ios-settings:before,.ion-ios-shuffle-strong:before,.ion-ios-shuffle:before,.ion-ios-skipbackward-outline:before,.ion-ios-skipbackward:before,.ion-ios-skipforward-outline:before,.ion-ios-skipforward:before,.ion-ios-snowy:before,.ion-ios-speedometer-outline:before,.ion-ios-speedometer:before,.ion-ios-star-half:before,.ion-ios-star-outline:before,.ion-ios-star:before,.ion-ios-stopwatch-outline:before,.ion-ios-stopwatch:before,.ion-ios-sunny-outline:before,.ion-ios-sunny:before,.ion-ios-telephone-outline:before,.ion-ios-telephone:before,.ion-ios-tennisball-outline:before,.ion-ios-tennisball:before,.ion-ios-thunderstorm-outline:before,.ion-ios-thunderstorm:before,.ion-ios-time-outline:before,.ion-ios-time:before,.ion-ios-timer-outline:before,.ion-ios-timer:before,.ion-ios-toggle-outline:before,.ion-ios-toggle:before,.ion-ios-trash-outline:before,.ion-ios-trash:before,.ion-ios-undo-outline:before,.ion-ios-undo:before,.ion-ios-unlocked-outline:before,.ion-ios-unlocked:before,.ion-ios-upload-outline:before,.ion-ios-upload:before,.ion-ios-videocam-outline:before,.ion-ios-videocam:before,.ion-ios-volume-high:before,.ion-ios-volume-low:before,.ion-ios-wineglass-outline:before,.ion-ios-wineglass:before,.ion-ios-world-outline:before,.ion-ios-world:before,.ion-ipad:before,.ion-iphone:before,.ion-ipod:before,.ion-jet:before,.ion-key:before,.ion-knife:before,.ion-laptop:before,.ion-leaf:before,.ion-levels:before,.ion-lightbulb:before,.ion-link:before,.ion-load-a:before,.ion-load-b:before,.ion-load-c:before,.ion-load-d:before,.ion-location:before,.ion-lock-combination:before,.ion-locked:before,.ion-log-in:before,.ion-log-out:before,.ion-loop:before,.ion-magnet:before,.ion-male:before,.ion-man:before,.ion-map:before,.ion-medkit:before,.ion-merge:before,.ion-mic-a:before,.ion-mic-b:before,.ion-mic-c:before,.ion-minus-circled:before,.ion-minus-round:before,.ion-minus:before,.ion-model-s:before,.ion-monitor:before,.ion-more:before,.ion-mouse:before,.ion-music-note:before,.ion-navicon-round:before,.ion-navicon:before,.ion-navigate:before,.ion-network:before,.ion-no-smoking:before,.ion-nuclear:before,.ion-outlet:before,.ion-paintbrush:before,.ion-paintbucket:before,.ion-paper-airplane:before,.ion-paperclip:before,.ion-pause:before,.ion-person-add:before,.ion-person-stalker:before,.ion-person:before,.ion-pie-graph:before,.ion-pin:before,.ion-pinpoint:before,.ion-pizza:before,.ion-plane:before,.ion-planet:before,.ion-play:before,.ion-playstation:before,.ion-plus-circled:before,.ion-plus-round:before,.ion-plus:before,.ion-podium:before,.ion-pound:before,.ion-power:before,.ion-pricetag:before,.ion-pricetags:before,.ion-printer:before,.ion-pull-request:before,.ion-qr-scanner:before,.ion-quote:before,.ion-radio-waves:before,.ion-record:before,.ion-refresh:before,.ion-reply-all:before,.ion-reply:before,.ion-ribbon-a:before,.ion-ribbon-b:before,.ion-sad-outline:before,.ion-sad:before,.ion-scissors:before,.ion-search:before,.ion-settings:before,.ion-share:before,.ion-shuffle:before,.ion-skip-backward:before,.ion-skip-forward:before,.ion-social-android-outline:before,.ion-social-android:before,.ion-social-angular-outline:before,.ion-social-angular:before,.ion-social-apple-outline:before,.ion-social-apple:before,.ion-social-bitcoin-outline:before,.ion-social-bitcoin:before,.ion-social-buffer-outline:before,.ion-social-buffer:before,.ion-social-chrome-outline:before,.ion-social-chrome:before,.ion-social-codepen-outline:before,.ion-social-codepen:before,.ion-social-css3-outline:before,.ion-social-css3:before,.ion-social-designernews-outline:before,.ion-social-designernews:before,.ion-social-dribbble-outline:before,.ion-social-dribbble:before,.ion-social-dropbox-outline:before,.ion-social-dropbox:before,.ion-social-euro-outline:before,.ion-social-euro:before,.ion-social-facebook-outline:before,.ion-social-facebook:before,.ion-social-foursquare-outline:before,.ion-social-foursquare:before,.ion-social-freebsd-devil:before,.ion-social-github-outline:before,.ion-social-github:before,.ion-social-google-outline:before,.ion-social-google:before,.ion-social-googleplus-outline:before,.ion-social-googleplus:before,.ion-social-hackernews-outline:before,.ion-social-hackernews:before,.ion-social-html5-outline:before,.ion-social-html5:before,.ion-social-instagram-outline:before,.ion-social-instagram:before,.ion-social-javascript-outline:before,.ion-social-javascript:before,.ion-social-linkedin-outline:before,.ion-social-linkedin:before,.ion-social-markdown:before,.ion-social-nodejs:before,.ion-social-octocat:before,.ion-social-pinterest-outline:before,.ion-social-pinterest:before,.ion-social-python:before,.ion-social-reddit-outline:before,.ion-social-reddit:before,.ion-social-rss-outline:before,.ion-social-rss:before,.ion-social-sass:before,.ion-social-skype-outline:before,.ion-social-skype:before,.ion-social-snapchat-outline:before,.ion-social-snapchat:before,.ion-social-tumblr-outline:before,.ion-social-tumblr:before,.ion-social-tux:before,.ion-social-twitch-outline:before,.ion-social-twitch:before,.ion-social-twitter-outline:before,.ion-social-twitter:before,.ion-social-usd-outline:before,.ion-social-usd:before,.ion-social-vimeo-outline:before,.ion-social-vimeo:before,.ion-social-whatsapp-outline:before,.ion-social-whatsapp:before,.ion-social-windows-outline:before,.ion-social-windows:before,.ion-social-wordpress-outline:before,.ion-social-wordpress:before,.ion-social-yahoo-outline:before,.ion-social-yahoo:before,.ion-social-yen-outline:before,.ion-social-yen:before,.ion-social-youtube-outline:before,.ion-social-youtube:before,.ion-soup-can-outline:before,.ion-soup-can:before,.ion-speakerphone:before,.ion-speedometer:before,.ion-spoon:before,.ion-star:before,.ion-stats-bars:before,.ion-steam:before,.ion-stop:before,.ion-thermometer:before,.ion-thumbsdown:before,.ion-thumbsup:before,.ion-toggle-filled:before,.ion-toggle:before,.ion-transgender:before,.ion-trash-a:before,.ion-trash-b:before,.ion-trophy:before,.ion-tshirt-outline:before,.ion-tshirt:before,.ion-umbrella:before,.ion-university:before,.ion-unlocked:before,.ion-upload:before,.ion-usb:before,.ion-videocamera:before,.ion-volume-high:before,.ion-volume-low:before,.ion-volume-medium:before,.ion-volume-mute:before,.ion-wand:before,.ion-waterdrop:before,.ion-wifi:before,.ion-wineglass:before,.ion-woman:before,.ion-wrench:before,.ion-xbox:before,.ionicons{display:inline-block;font-family:Ionicons;speak:none;font-style:normal;font-weight:400;font-variant:normal;text-transform:none;text-rendering:auto;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.ion-alert:before{content:""}.ion-alert-circled:before{content:""}.ion-android-add:before{content:""}.ion-android-add-circle:before{content:""}.ion-android-alarm-clock:before{content:""}.ion-android-alert:before{content:""}.ion-android-apps:before{content:""}.ion-android-archive:before{content:""}.ion-android-arrow-back:before{content:""}.ion-android-arrow-down:before{content:""}.ion-android-arrow-dropdown:before{content:""}.ion-android-arrow-dropdown-circle:before{content:""}.ion-android-arrow-dropleft:before{content:""}.ion-android-arrow-dropleft-circle:before{content:""}.ion-android-arrow-dropright:before{content:""}.ion-android-arrow-dropright-circle:before{content:""}.ion-android-arrow-dropup:before{content:""}.ion-android-arrow-dropup-circle:before{content:""}.ion-android-arrow-forward:before{content:""}.ion-android-arrow-up:before{content:""}.ion-android-attach:before{content:""}.ion-android-bar:before{content:""}.ion-android-bicycle:before{content:""}.ion-android-boat:before{content:""}.ion-android-bookmark:before{content:""}.ion-android-bulb:before{content:""}.ion-android-bus:before{content:""}.ion-android-calendar:before{content:""}.ion-android-call:before{content:""}.ion-android-camera:before{content:""}.ion-android-cancel:before{content:""}.ion-android-car:before{content:""}.ion-android-cart:before{content:""}.ion-android-chat:before{content:""}.ion-android-checkbox:before{content:""}.ion-android-checkbox-blank:before{content:""}.ion-android-checkbox-outline:before{content:""}.ion-android-checkbox-outline-blank:before{content:""}.ion-android-checkmark-circle:before{content:""}.ion-android-clipboard:before{content:""}.ion-android-close:before{content:""}.ion-android-cloud:before{content:""}.ion-android-cloud-circle:before{content:""}.ion-android-cloud-done:before{content:""}.ion-android-cloud-outline:before{content:""}.ion-android-color-palette:before{content:""}.ion-android-compass:before{content:""}.ion-android-contact:before{content:""}.ion-android-contacts:before{content:""}.ion-android-contract:before{content:""}.ion-android-create:before{content:""}.ion-android-delete:before{content:""}.ion-android-desktop:before{content:""}.ion-android-document:before{content:""}.ion-android-done:before{content:""}.ion-android-done-all:before{content:""}.ion-android-download:before{content:""}.ion-android-drafts:before{content:""}.ion-android-exit:before{content:""}.ion-android-expand:before{content:""}.ion-android-favorite:before{content:""}.ion-android-favorite-outline:before{content:""}.ion-android-film:before{content:""}.ion-android-folder:before{content:""}.ion-android-folder-open:before{content:""}.ion-android-funnel:before{content:""}.ion-android-globe:before{content:""}.ion-android-hand:before{content:""}.ion-android-hangout:before{content:""}.ion-android-happy:before{content:""}.ion-android-home:before{content:""}.ion-android-image:before{content:""}.ion-android-laptop:before{content:""}.ion-android-list:before{content:""}.ion-android-locate:before{content:""}.ion-android-lock:before{content:""}.ion-android-mail:before{content:""}.ion-android-map:before{content:""}.ion-android-menu:before{content:""}.ion-android-microphone:before{content:""}.ion-android-microphone-off:before{content:""}.ion-android-more-horizontal:before{content:""}.ion-android-more-vertical:before{content:""}.ion-android-navigate:before{content:""}.ion-android-notifications:before{content:""}.ion-android-notifications-none:before{content:""}.ion-android-notifications-off:before{content:""}.ion-android-open:before{content:""}.ion-android-options:before{content:""}.ion-android-people:before{content:""}.ion-android-person:before{content:""}.ion-android-person-add:before{content:""}.ion-android-phone-landscape:before{content:""}.ion-android-phone-portrait:before{content:""}.ion-android-pin:before{content:""}.ion-android-plane:before{content:""}.ion-android-playstore:before{content:""}.ion-android-print:before{content:""}.ion-android-radio-button-off:before{content:""}.ion-android-radio-button-on:before{content:""}.ion-android-refresh:before{content:""}.ion-android-remove:before{content:""}.ion-android-remove-circle:before{content:""}.ion-android-restaurant:before{content:""}.ion-android-sad:before{content:""}.ion-android-search:before{content:""}.ion-android-send:before{content:""}.ion-android-settings:before{content:""}.ion-android-share:before{content:""}.ion-android-share-alt:before{content:""}.ion-android-star:before{content:""}.ion-android-star-half:before{content:""}.ion-android-star-outline:before{content:""}.ion-android-stopwatch:before{content:""}.ion-android-subway:before{content:""}.ion-android-sunny:before{content:""}.ion-android-sync:before{content:""}.ion-android-textsms:before{content:""}.ion-android-time:before{content:""}.ion-android-train:before{content:""}.ion-android-unlock:before{content:""}.ion-android-upload:before{content:""}.ion-android-volume-down:before{content:""}.ion-android-volume-mute:before{content:""}.ion-android-volume-off:before{content:""}.ion-android-volume-up:before{content:""}.ion-android-walk:before{content:""}.ion-android-warning:before{content:""}.ion-android-watch:before{content:""}.ion-android-wifi:before{content:""}.ion-aperture:before{content:""}.ion-archive:before{content:""}.ion-arrow-down-a:before{content:""}.ion-arrow-down-b:before{content:""}.ion-arrow-down-c:before{content:""}.ion-arrow-expand:before{content:""}.ion-arrow-graph-down-left:before{content:""}.ion-arrow-graph-down-right:before{content:""}.ion-arrow-graph-up-left:before{content:""}.ion-arrow-graph-up-right:before{content:""}.ion-arrow-left-a:before{content:""}.ion-arrow-left-b:before{content:""}.ion-arrow-left-c:before{content:""}.ion-arrow-move:before{content:""}.ion-arrow-resize:before{content:""}.ion-arrow-return-left:before{content:""}.ion-arrow-return-right:before{content:""}.ion-arrow-right-a:before{content:""}.ion-arrow-right-b:before{content:""}.ion-arrow-right-c:before{content:""}.ion-arrow-shrink:before{content:""}.ion-arrow-swap:before{content:""}.ion-arrow-up-a:before{content:""}.ion-arrow-up-b:before{content:""}.ion-arrow-up-c:before{content:""}.ion-asterisk:before{content:""}.ion-at:before{content:""}.ion-backspace:before{content:""}.ion-backspace-outline:before{content:""}.ion-bag:before{content:""}.ion-battery-charging:before{content:""}.ion-battery-empty:before{content:""}.ion-battery-full:before{content:""}.ion-battery-half:before{content:""}.ion-battery-low:before{content:""}.ion-beaker:before{content:""}.ion-beer:before{content:""}.ion-bluetooth:before{content:""}.ion-bonfire:before{content:""}.ion-bookmark:before{content:""}.ion-bowtie:before{content:""}.ion-briefcase:before{content:""}.ion-bug:before{content:""}.ion-calculator:before{content:""}.ion-calendar:before{content:""}.ion-camera:before{content:""}.ion-card:before{content:""}.ion-cash:before{content:""}.ion-chatbox:before{content:""}.ion-chatbox-working:before{content:""}.ion-chatboxes:before{content:""}.ion-chatbubble:before{content:""}.ion-chatbubble-working:before{content:""}.ion-chatbubbles:before{content:""}.ion-checkmark:before{content:""}.ion-checkmark-circled:before{content:""}.ion-checkmark-round:before{content:""}.ion-chevron-down:before{content:""}.ion-chevron-left:before{content:""}.ion-chevron-right:before{content:""}.ion-chevron-up:before{content:""}.ion-clipboard:before{content:""}.ion-clock:before{content:""}.ion-close:before{content:""}.ion-close-circled:before{content:""}.ion-close-round:before{content:""}.ion-closed-captioning:before{content:""}.ion-cloud:before{content:""}.ion-code:before{content:""}.ion-code-download:before{content:""}.ion-code-working:before{content:""}.ion-coffee:before{content:""}.ion-compass:before{content:""}.ion-compose:before{content:""}.ion-connection-bars:before{content:""}.ion-contrast:before{content:""}.ion-crop:before{content:""}.ion-cube:before{content:""}.ion-disc:before{content:""}.ion-document:before{content:""}.ion-document-text:before{content:""}.ion-drag:before{content:""}.ion-earth:before{content:""}.ion-easel:before{content:""}.ion-edit:before{content:""}.ion-egg:before{content:""}.ion-eject:before{content:""}.ion-email:before{content:""}.ion-email-unread:before{content:""}.ion-erlenmeyer-flask:before{content:""}.ion-erlenmeyer-flask-bubbles:before{content:""}.ion-eye:before{content:""}.ion-eye-disabled:before{content:""}.ion-female:before{content:""}.ion-filing:before{content:""}.ion-film-marker:before{content:""}.ion-fireball:before{content:""}.ion-flag:before{content:""}.ion-flame:before{content:""}.ion-flash:before{content:""}.ion-flash-off:before{content:""}.ion-folder:before{content:""}.ion-fork:before{content:""}.ion-fork-repo:before{content:""}.ion-forward:before{content:""}.ion-funnel:before{content:""}.ion-gear-a:before{content:""}.ion-gear-b:before{content:""}.ion-grid:before{content:""}.ion-hammer:before{content:""}.ion-happy:before{content:""}.ion-happy-outline:before{content:""}.ion-headphone:before{content:""}.ion-heart:before{content:""}.ion-heart-broken:before{content:""}.ion-help:before{content:""}.ion-help-buoy:before{content:""}.ion-help-circled:before{content:""}.ion-home:before{content:""}.ion-icecream:before{content:""}.ion-image:before{content:""}.ion-images:before{content:""}.ion-information:before{content:""}.ion-information-circled:before{content:""}.ion-ionic:before{content:""}.ion-ios-alarm:before{content:""}.ion-ios-alarm-outline:before{content:""}.ion-ios-albums:before{content:""}.ion-ios-albums-outline:before{content:""}.ion-ios-americanfootball:before{content:""}.ion-ios-americanfootball-outline:before{content:""}.ion-ios-analytics:before{content:""}.ion-ios-analytics-outline:before{content:""}.ion-ios-arrow-back:before{content:""}.ion-ios-arrow-down:before{content:""}.ion-ios-arrow-forward:before{content:""}.ion-ios-arrow-left:before{content:""}.ion-ios-arrow-right:before{content:""}.ion-ios-arrow-thin-down:before{content:""}.ion-ios-arrow-thin-left:before{content:""}.ion-ios-arrow-thin-right:before{content:""}.ion-ios-arrow-thin-up:before{content:""}.ion-ios-arrow-up:before{content:""}.ion-ios-at:before{content:""}.ion-ios-at-outline:before{content:""}.ion-ios-barcode:before{content:""}.ion-ios-barcode-outline:before{content:""}.ion-ios-baseball:before{content:""}.ion-ios-baseball-outline:before{content:""}.ion-ios-basketball:before{content:""}.ion-ios-basketball-outline:before{content:""}.ion-ios-bell:before{content:""}.ion-ios-bell-outline:before{content:""}.ion-ios-body:before{content:""}.ion-ios-body-outline:before{content:""}.ion-ios-bolt:before{content:""}.ion-ios-bolt-outline:before{content:""}.ion-ios-book:before{content:""}.ion-ios-book-outline:before{content:""}.ion-ios-bookmarks:before{content:""}.ion-ios-bookmarks-outline:before{content:""}.ion-ios-box:before{content:""}.ion-ios-box-outline:before{content:""}.ion-ios-briefcase:before{content:""}.ion-ios-briefcase-outline:before{content:""}.ion-ios-browsers:before{content:""}.ion-ios-browsers-outline:before{content:""}.ion-ios-calculator:before{content:""}.ion-ios-calculator-outline:before{content:""}.ion-ios-calendar:before{content:""}.ion-ios-calendar-outline:before{content:""}.ion-ios-camera:before{content:""}.ion-ios-camera-outline:before{content:""}.ion-ios-cart:before{content:""}.ion-ios-cart-outline:before{content:""}.ion-ios-chatboxes:before{content:""}.ion-ios-chatboxes-outline:before{content:""}.ion-ios-chatbubble:before{content:""}.ion-ios-chatbubble-outline:before{content:""}.ion-ios-checkmark:before{content:""}.ion-ios-checkmark-empty:before{content:""}.ion-ios-checkmark-outline:before{content:""}.ion-ios-circle-filled:before{content:""}.ion-ios-circle-outline:before{content:""}.ion-ios-clock:before{content:""}.ion-ios-clock-outline:before{content:""}.ion-ios-close:before{content:""}.ion-ios-close-empty:before{content:""}.ion-ios-close-outline:before{content:""}.ion-ios-cloud:before{content:""}.ion-ios-cloud-download:before{content:""}.ion-ios-cloud-download-outline:before{content:""}.ion-ios-cloud-outline:before{content:""}.ion-ios-cloud-upload:before{content:""}.ion-ios-cloud-upload-outline:before{content:""}.ion-ios-cloudy:before{content:""}.ion-ios-cloudy-night:before{content:""}.ion-ios-cloudy-night-outline:before{content:""}.ion-ios-cloudy-outline:before{content:""}.ion-ios-cog:before{content:""}.ion-ios-cog-outline:before{content:""}.ion-ios-color-filter:before{content:""}.ion-ios-color-filter-outline:before{content:""}.ion-ios-color-wand:before{content:""}.ion-ios-color-wand-outline:before{content:""}.ion-ios-compose:before{content:""}.ion-ios-compose-outline:before{content:""}.ion-ios-contact:before{content:""}.ion-ios-contact-outline:before{content:""}.ion-ios-copy:before{content:""}.ion-ios-copy-outline:before{content:""}.ion-ios-crop:before{content:""}.ion-ios-crop-strong:before{content:""}.ion-ios-download:before{content:""}.ion-ios-download-outline:before{content:""}.ion-ios-drag:before{content:""}.ion-ios-email:before{content:""}.ion-ios-email-outline:before{content:""}.ion-ios-eye:before{content:""}.ion-ios-eye-outline:before{content:""}.ion-ios-fastforward:before{content:""}.ion-ios-fastforward-outline:before{content:""}.ion-ios-filing:before{content:""}.ion-ios-filing-outline:before{content:""}.ion-ios-film:before{content:""}.ion-ios-film-outline:before{content:""}.ion-ios-flag:before{content:""}.ion-ios-flag-outline:before{content:""}.ion-ios-flame:before{content:""}.ion-ios-flame-outline:before{content:""}.ion-ios-flask:before{content:""}.ion-ios-flask-outline:before{content:""}.ion-ios-flower:before{content:""}.ion-ios-flower-outline:before{content:""}.ion-ios-folder:before{content:""}.ion-ios-folder-outline:before{content:""}.ion-ios-football:before{content:""}.ion-ios-football-outline:before{content:""}.ion-ios-game-controller-a:before{content:""}.ion-ios-game-controller-a-outline:before{content:""}.ion-ios-game-controller-b:before{content:""}.ion-ios-game-controller-b-outline:before{content:""}.ion-ios-gear:before{content:""}.ion-ios-gear-outline:before{content:""}.ion-ios-glasses:before{content:""}.ion-ios-glasses-outline:before{content:""}.ion-ios-grid-view:before{content:""}.ion-ios-grid-view-outline:before{content:""}.ion-ios-heart:before{content:""}.ion-ios-heart-outline:before{content:""}.ion-ios-help:before{content:""}.ion-ios-help-empty:before{content:""}.ion-ios-help-outline:before{content:""}.ion-ios-home:before{content:""}.ion-ios-home-outline:before{content:""}.ion-ios-infinite:before{content:""}.ion-ios-infinite-outline:before{content:""}.ion-ios-information:before{content:""}.ion-ios-information-empty:before{content:""}.ion-ios-information-outline:before{content:""}.ion-ios-ionic-outline:before{content:""}.ion-ios-keypad:before{content:""}.ion-ios-keypad-outline:before{content:""}.ion-ios-lightbulb:before{content:""}.ion-ios-lightbulb-outline:before{content:""}.ion-ios-list:before{content:""}.ion-ios-list-outline:before{content:""}.ion-ios-location:before{content:""}.ion-ios-location-outline:before{content:""}.ion-ios-locked:before{content:""}.ion-ios-locked-outline:before{content:""}.ion-ios-loop:before{content:""}.ion-ios-loop-strong:before{content:""}.ion-ios-medical:before{content:""}.ion-ios-medical-outline:before{content:""}.ion-ios-medkit:before{content:""}.ion-ios-medkit-outline:before{content:""}.ion-ios-mic:before{content:""}.ion-ios-mic-off:before{content:""}.ion-ios-mic-outline:before{content:""}.ion-ios-minus:before{content:""}.ion-ios-minus-empty:before{content:""}.ion-ios-minus-outline:before{content:""}.ion-ios-monitor:before{content:""}.ion-ios-monitor-outline:before{content:""}.ion-ios-moon:before{content:""}.ion-ios-moon-outline:before{content:""}.ion-ios-more:before{content:""}.ion-ios-more-outline:before{content:""}.ion-ios-musical-note:before{content:""}.ion-ios-musical-notes:before{content:""}.ion-ios-navigate:before{content:""}.ion-ios-navigate-outline:before{content:""}.ion-ios-nutrition:before{content:""}.ion-ios-nutrition-outline:before{content:""}.ion-ios-paper:before{content:""}.ion-ios-paper-outline:before{content:""}.ion-ios-paperplane:before{content:""}.ion-ios-paperplane-outline:before{content:""}.ion-ios-partlysunny:before{content:""}.ion-ios-partlysunny-outline:before{content:""}.ion-ios-pause:before{content:""}.ion-ios-pause-outline:before{content:""}.ion-ios-paw:before{content:""}.ion-ios-paw-outline:before{content:""}.ion-ios-people:before{content:""}.ion-ios-people-outline:before{content:""}.ion-ios-person:before{content:""}.ion-ios-person-outline:before{content:""}.ion-ios-personadd:before{content:""}.ion-ios-personadd-outline:before{content:""}.ion-ios-photos:before{content:""}.ion-ios-photos-outline:before{content:""}.ion-ios-pie:before{content:""}.ion-ios-pie-outline:before{content:""}.ion-ios-pint:before{content:""}.ion-ios-pint-outline:before{content:""}.ion-ios-play:before{content:""}.ion-ios-play-outline:before{content:""}.ion-ios-plus:before{content:""}.ion-ios-plus-empty:before{content:""}.ion-ios-plus-outline:before{content:""}.ion-ios-pricetag:before{content:""}.ion-ios-pricetag-outline:before{content:""}.ion-ios-pricetags:before{content:""}.ion-ios-pricetags-outline:before{content:""}.ion-ios-printer:before{content:""}.ion-ios-printer-outline:before{content:""}.ion-ios-pulse:before{content:""}.ion-ios-pulse-strong:before{content:""}.ion-ios-rainy:before{content:""}.ion-ios-rainy-outline:before{content:""}.ion-ios-recording:before{content:""}.ion-ios-recording-outline:before{content:""}.ion-ios-redo:before{content:""}.ion-ios-redo-outline:before{content:""}.ion-ios-refresh:before{content:""}.ion-ios-refresh-empty:before{content:""}.ion-ios-refresh-outline:before{content:""}.ion-ios-reload:before{content:""}.ion-ios-reverse-camera:before{content:""}.ion-ios-reverse-camera-outline:before{content:""}.ion-ios-rewind:before{content:""}.ion-ios-rewind-outline:before{content:""}.ion-ios-rose:before{content:""}.ion-ios-rose-outline:before{content:""}.ion-ios-search:before{content:""}.ion-ios-search-strong:before{content:""}.ion-ios-settings:before{content:""}.ion-ios-settings-strong:before{content:""}.ion-ios-shuffle:before{content:""}.ion-ios-shuffle-strong:before{content:""}.ion-ios-skipbackward:before{content:""}.ion-ios-skipbackward-outline:before{content:""}.ion-ios-skipforward:before{content:""}.ion-ios-skipforward-outline:before{content:""}.ion-ios-snowy:before{content:""}.ion-ios-speedometer:before{content:""}.ion-ios-speedometer-outline:before{content:""}.ion-ios-star:before{content:""}.ion-ios-star-half:before{content:""}.ion-ios-star-outline:before{content:""}.ion-ios-stopwatch:before{content:""}.ion-ios-stopwatch-outline:before{content:""}.ion-ios-sunny:before{content:""}.ion-ios-sunny-outline:before{content:""}.ion-ios-telephone:before{content:""}.ion-ios-telephone-outline:before{content:""}.ion-ios-tennisball:before{content:""}.ion-ios-tennisball-outline:before{content:""}.ion-ios-thunderstorm:before{content:""}.ion-ios-thunderstorm-outline:before{content:""}.ion-ios-time:before{content:""}.ion-ios-time-outline:before{content:""}.ion-ios-timer:before{content:""}.ion-ios-timer-outline:before{content:""}.ion-ios-toggle:before{content:""}.ion-ios-toggle-outline:before{content:""}.ion-ios-trash:before{content:""}.ion-ios-trash-outline:before{content:""}.ion-ios-undo:before{content:""}.ion-ios-undo-outline:before{content:""}.ion-ios-unlocked:before{content:""}.ion-ios-unlocked-outline:before{content:""}.ion-ios-upload:before{content:""}.ion-ios-upload-outline:before{content:""}.ion-ios-videocam:before{content:""}.ion-ios-videocam-outline:before{content:""}.ion-ios-volume-high:before{content:""}.ion-ios-volume-low:before{content:""}.ion-ios-wineglass:before{content:""}.ion-ios-wineglass-outline:before{content:""}.ion-ios-world:before{content:""}.ion-ios-world-outline:before{content:""}.ion-ipad:before{content:""}.ion-iphone:before{content:""}.ion-ipod:before{content:""}.ion-jet:before{content:""}.ion-key:before{content:""}.ion-knife:before{content:""}.ion-laptop:before{content:""}.ion-leaf:before{content:""}.ion-levels:before{content:""}.ion-lightbulb:before{content:""}.ion-link:before{content:""}.ion-load-a:before{content:""}.ion-load-b:before{content:""}.ion-load-c:before{content:""}.ion-load-d:before{content:""}.ion-location:before{content:""}.ion-lock-combination:before{content:""}.ion-locked:before{content:""}.ion-log-in:before{content:""}.ion-log-out:before{content:""}.ion-loop:before{content:""}.ion-magnet:before{content:""}.ion-male:before{content:""}.ion-man:before{content:""}.ion-map:before{content:""}.ion-medkit:before{content:""}.ion-merge:before{content:""}.ion-mic-a:before{content:""}.ion-mic-b:before{content:""}.ion-mic-c:before{content:""}.ion-minus:before{content:""}.ion-minus-circled:before{content:""}.ion-minus-round:before{content:""}.ion-model-s:before{content:""}.ion-monitor:before{content:""}.ion-more:before{content:""}.ion-mouse:before{content:""}.ion-music-note:before{content:""}.ion-navicon:before{content:""}.ion-navicon-round:before{content:""}.ion-navigate:before{content:""}.ion-network:before{content:""}.ion-no-smoking:before{content:""}.ion-nuclear:before{content:""}.ion-outlet:before{content:""}.ion-paintbrush:before{content:""}.ion-paintbucket:before{content:""}.ion-paper-airplane:before{content:""}.ion-paperclip:before{content:""}.ion-pause:before{content:""}.ion-person:before{content:""}.ion-person-add:before{content:""}.ion-person-stalker:before{content:""}.ion-pie-graph:before{content:""}.ion-pin:before{content:""}.ion-pinpoint:before{content:""}.ion-pizza:before{content:""}.ion-plane:before{content:""}.ion-planet:before{content:""}.ion-play:before{content:""}.ion-playstation:before{content:""}.ion-plus:before{content:""}.ion-plus-circled:before{content:""}.ion-plus-round:before{content:""}.ion-podium:before{content:""}.ion-pound:before{content:""}.ion-power:before{content:""}.ion-pricetag:before{content:""}.ion-pricetags:before{content:""}.ion-printer:before{content:""}.ion-pull-request:before{content:""}.ion-qr-scanner:before{content:""}.ion-quote:before{content:""}.ion-radio-waves:before{content:""}.ion-record:before{content:""}.ion-refresh:before{content:""}.ion-reply:before{content:""}.ion-reply-all:before{content:""}.ion-ribbon-a:before{content:""}.ion-ribbon-b:before{content:""}.ion-sad:before{content:""}.ion-sad-outline:before{content:""}.ion-scissors:before{content:""}.ion-search:before{content:""}.ion-settings:before{content:""}.ion-share:before{content:""}.ion-shuffle:before{content:""}.ion-skip-backward:before{content:""}.ion-skip-forward:before{content:""}.ion-social-android:before{content:""}.ion-social-android-outline:before{content:""}.ion-social-angular:before{content:""}.ion-social-angular-outline:before{content:""}.ion-social-apple:before{content:""}.ion-social-apple-outline:before{content:""}.ion-social-bitcoin:before{content:""}.ion-social-bitcoin-outline:before{content:""}.ion-social-buffer:before{content:""}.ion-social-buffer-outline:before{content:""}.ion-social-chrome:before{content:""}.ion-social-chrome-outline:before{content:""}.ion-social-codepen:before{content:""}.ion-social-codepen-outline:before{content:""}.ion-social-css3:before{content:""}.ion-social-css3-outline:before{content:""}.ion-social-designernews:before{content:""}.ion-social-designernews-outline:before{content:""}.ion-social-dribbble:before{content:""}.ion-social-dribbble-outline:before{content:""}.ion-social-dropbox:before{content:""}.ion-social-dropbox-outline:before{content:""}.ion-social-euro:before{content:""}.ion-social-euro-outline:before{content:""}.ion-social-facebook:before{content:""}.ion-social-facebook-outline:before{content:""}.ion-social-foursquare:before{content:""}.ion-social-foursquare-outline:before{content:""}.ion-social-freebsd-devil:before{content:""}.ion-social-github:before{content:""}.ion-social-github-outline:before{content:""}.ion-social-google:before{content:""}.ion-social-google-outline:before{content:""}.ion-social-googleplus:before{content:""}.ion-social-googleplus-outline:before{content:""}.ion-social-hackernews:before{content:""}.ion-social-hackernews-outline:before{content:""}.ion-social-html5:before{content:""}.ion-social-html5-outline:before{content:""}.ion-social-instagram:before{content:""}.ion-social-instagram-outline:before{content:""}.ion-social-javascript:before{content:""}.ion-social-javascript-outline:before{content:""}.ion-social-linkedin:before{content:""}.ion-social-linkedin-outline:before{content:""}.ion-social-markdown:before{content:""}.ion-social-nodejs:before{content:""}.ion-social-octocat:before{content:""}.ion-social-pinterest:before{content:""}.ion-social-pinterest-outline:before{content:""}.ion-social-python:before{content:""}.ion-social-reddit:before{content:""}.ion-social-reddit-outline:before{content:""}.ion-social-rss:before{content:""}.ion-social-rss-outline:before{content:""}.ion-social-sass:before{content:""}.ion-social-skype:before{content:""}.ion-social-skype-outline:before{content:""}.ion-social-snapchat:before{content:""}.ion-social-snapchat-outline:before{content:""}.ion-social-tumblr:before{content:""}.ion-social-tumblr-outline:before{content:""}.ion-social-tux:before{content:""}.ion-social-twitch:before{content:""}.ion-social-twitch-outline:before{content:""}.ion-social-twitter:before{content:""}.ion-social-twitter-outline:before{content:""}.ion-social-usd:before{content:""}.ion-social-usd-outline:before{content:""}.ion-social-vimeo:before{content:""}.ion-social-vimeo-outline:before{content:""}.ion-social-whatsapp:before{content:""}.ion-social-whatsapp-outline:before{content:""}.ion-social-windows:before{content:""}.ion-social-windows-outline:before{content:""}.ion-social-wordpress:before{content:""}.ion-social-wordpress-outline:before{content:""}.ion-social-yahoo:before{content:""}.ion-social-yahoo-outline:before{content:""}.ion-social-yen:before{content:""}.ion-social-yen-outline:before{content:""}.ion-social-youtube:before{content:""}.ion-social-youtube-outline:before{content:""}.ion-soup-can:before{content:""}.ion-soup-can-outline:before{content:""}.ion-speakerphone:before{content:""}.ion-speedometer:before{content:""}.ion-spoon:before{content:""}.ion-star:before{content:""}.ion-stats-bars:before{content:""}.ion-steam:before{content:""}.ion-stop:before{content:""}.ion-thermometer:before{content:""}.ion-thumbsdown:before{content:""}.ion-thumbsup:before{content:""}.ion-toggle:before{content:""}.ion-toggle-filled:before{content:""}.ion-transgender:before{content:""}.ion-trash-a:before{content:""}.ion-trash-b:before{content:""}.ion-trophy:before{content:""}.ion-tshirt:before{content:""}.ion-tshirt-outline:before{content:""}.ion-umbrella:before{content:""}.ion-university:before{content:""}.ion-unlocked:before{content:""}.ion-upload:before{content:""}.ion-usb:before{content:""}.ion-videocamera:before{content:""}.ion-volume-high:before{content:""}.ion-volume-low:before{content:""}.ion-volume-medium:before{content:""}.ion-volume-mute:before{content:""}.ion-wand:before{content:""}.ion-waterdrop:before{content:""}.ion-wifi:before{content:""}.ion-wineglass:before{content:""}.ion-woman:before{content:""}.ion-wrench:before{content:""}.ion-xbox:before{content:""}a,abbr,acronym,address,applet,article,aside,audio,b,big,blockquote,body,canvas,caption,center,cite,code,dd,del,details,dfn,div,dl,dt,em,embed,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,hgroup,html,i,iframe,img,ins,kbd,label,legend,li,mark,menu,nav,object,ol,output,p,pre,q,ruby,s,samp,section,small,span,strike,strong,sub,summary,sup,table,tbody,td,tfoot,th,thead,time,tr,tt,u,ul,var,video{margin:0;padding:0;border:0;vertical-align:baseline;font:inherit;font-size:100%}ol,ul{list-style:none}blockquote,q{quotes:none}audio:not([controls]){display:none;height:0}[hidden],template{display:none}script{display:none!important}html{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}:focus,a,a:active,a:focus,a:hover,button,button:focus{outline:0}a{-webkit-user-drag:none;-webkit-tap-highlight-color:transparent;-webkit-tap-highlight-color:transparent}a[href]:hover{cursor:pointer}b,strong{font-weight:700}dfn{font-style:italic}hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0}code,kbd,pre,samp{font-size:1em;font-family:monospace,serif}pre{white-space:pre-wrap}q{quotes:"\201C" "\201D" "\2018" "\2019"}sub,sup{position:relative;vertical-align:baseline;font-size:75%;line-height:0}sup{top:-.5em}sub{bottom:-.25em}fieldset{margin:0 2px;padding:.35em .625em .75em;border:1px solid silver}button,input,select,textarea{margin:0;outline-offset:0;outline-style:none;outline-width:0;-webkit-font-smoothing:inherit;background-image:none}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{cursor:pointer;-webkit-appearance:button}button[disabled],html input[disabled]{cursor:default}input[type=search]{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;-webkit-appearance:textfield}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0}textarea{overflow:auto}img{-webkit-user-drag:none}table{border-spacing:0;border-collapse:collapse}*,:after,:before{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{overflow:hidden;-ms-touch-action:pan-y;touch-action:pan-y}.ionic-body,body{-webkit-touch-callout:none;-webkit-font-smoothing:antialiased;font-smoothing:antialiased;-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none;-webkit-tap-highlight-color:transparent;-webkit-tap-highlight-color:transparent;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;top:0;right:0;bottom:0;left:0;overflow:hidden;margin:0;padding:0;color:#000;word-wrap:break-word;font-size:14px;font-family:-apple-system;font-family:"-apple-system","Helvetica Neue",Roboto,"Segoe UI",sans-serif;line-height:20px;text-rendering:optimizeLegibility;-webkit-backface-visibility:hidden;-webkit-user-drag:none;-ms-content-zooming:none}body.grade-b,body.grade-c{text-rendering:auto}.content{position:relative}.scroll-content{position:absolute;top:0;right:0;bottom:0;left:0;overflow:hidden;margin-top:-1px;padding-top:1px;margin-bottom:-1px;width:auto;height:auto}.menu .scroll-content.scroll-content-false{z-index:11}.scroll-view{position:relative;display:block;overflow:hidden;margin-top:-1px}.scroll-view.overflow-scroll{position:relative}.scroll-view.scroll-x{overflow-x:scroll;overflow-y:hidden}.scroll-view.scroll-y{overflow-x:hidden;overflow-y:scroll}.scroll-view.scroll-xy{overflow-x:scroll;overflow-y:scroll}.scroll{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-touch-callout:none;-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none;-webkit-transform-origin:left top;transform-origin:left top}@-ms-viewport{width:device-width}.scroll-bar{position:absolute;z-index:9999}.ng-animate .scroll-bar{visibility:hidden}.scroll-bar-h{right:2px;bottom:3px;left:2px;height:3px}.scroll-bar-h .scroll-bar-indicator{height:100%}.scroll-bar-v{top:2px;right:3px;bottom:2px;width:3px}.scroll-bar-v .scroll-bar-indicator{width:100%}.scroll-bar-indicator{position:absolute;border-radius:4px;background:rgba(0,0,0,.3);opacity:1;-webkit-transition:opacity .3s linear;transition:opacity .3s linear}.scroll-bar-indicator.scroll-bar-fade-out{opacity:0}.platform-android .scroll-bar-indicator{border-radius:0}.grade-b .scroll-bar-indicator,.grade-c .scroll-bar-indicator{background:#aaa}.grade-b .scroll-bar-indicator.scroll-bar-fade-out,.grade-c .scroll-bar-indicator.scroll-bar-fade-out{-webkit-transition:none;transition:none}ion-infinite-scroll{height:60px;width:100%;display:block;display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-direction:normal;-webkit-box-orient:horizontal;-webkit-flex-direction:row;-moz-flex-direction:row;-ms-flex-direction:row;flex-direction:row;-webkit-box-pack:center;-ms-flex-pack:center;-webkit-justify-content:center;-moz-justify-content:center;justify-content:center;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center}ion-infinite-scroll .icon{font-size:30px;color:#666}ion-infinite-scroll:not(.active) .icon:before,ion-infinite-scroll:not(.active) .spinner{display:none}.overflow-scroll{overflow-x:hidden;overflow-y:scroll;-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar;top:0;right:0;bottom:0;left:0;position:absolute}.overflow-scroll.pane{overflow-x:hidden;overflow-y:scroll}.overflow-scroll .scroll{position:static;height:100%;-webkit-transform:translate3d(0,0,0)}.has-header{top:44px}.no-header{top:0}.has-subheader{top:88px}.has-tabs-top{top:93px}.has-header.has-subheader.has-tabs-top{top:137px}.has-footer{bottom:44px}.has-subfooter{bottom:88px}.bar-footer.has-tabs,.has-tabs{bottom:49px}.bar-footer.has-tabs.pane,.has-tabs.pane{bottom:49px;height:auto}.bar-subfooter.has-tabs,.has-footer.has-tabs{bottom:93px}.pane{-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);-webkit-transition-duration:0;transition-duration:0;z-index:1}.view{z-index:1}.pane,.view{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;background-color:#fff;overflow:hidden}.view-container{position:absolute;display:block;width:100%;height:100%}p{margin:0 0 10px}small{font-size:85%}cite{font-style:normal}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{color:#000;font-weight:500;font-family:"-apple-system","Helvetica Neue",Roboto,"Segoe UI",sans-serif;line-height:1.2}.h1 small,.h2 small,.h3 small,.h4 small,.h5 small,.h6 small,h1 small,h2 small,h3 small,h4 small,h5 small,h6 small{font-weight:400;line-height:1}.h1,.h2,.h3,h1,h2,h3{margin-top:20px;margin-bottom:10px}.h1:first-child,.h2:first-child,.h3:first-child,h1:first-child,h2:first-child,h3:first-child{margin-top:0}.h1+.h1,.h1+.h2,.h1+.h3,.h1+h1,.h1+h2,.h1+h3,.h2+.h1,.h2+.h2,.h2+.h3,.h2+h1,.h2+h2,.h2+h3,.h3+.h1,.h3+.h2,.h3+.h3,.h3+h1,.h3+h2,.h3+h3,h1+.h1,h1+.h2,h1+.h3,h1+h1,h1+h2,h1+h3,h2+.h1,h2+.h2,h2+.h3,h2+h1,h2+h2,h2+h3,h3+.h1,h3+.h2,h3+.h3,h3+h1,h3+h2,h3+h3{margin-top:10px}.h4,.h5,.h6,h4,h5,h6{margin-top:10px;margin-bottom:10px}.h1,h1{font-size:36px}.h2,h2{font-size:30px}.h3,h3{font-size:24px}.h4,h4{font-size:18px}.h5,h5{font-size:14px}.h6,h6{font-size:12px}.h1 small,h1 small{font-size:24px}.h2 small,h2 small{font-size:18px}.h3 small,.h4 small,h3 small,h4 small{font-size:14px}dl{margin-bottom:20px}dd,dt{line-height:1.42857}dt{font-weight:700}blockquote{margin:0 0 20px;padding:10px 20px;border-left:5px solid gray}blockquote p{font-weight:300;font-size:17.5px;line-height:1.25}blockquote p:last-child{margin-bottom:0}blockquote small{display:block;line-height:1.42857}blockquote small:before{content:'\2014 \00A0'}blockquote:after,blockquote:before,q:after,q:before{content:""}address{display:block;margin-bottom:20px;font-style:normal;line-height:1.42857}a{color:#387ef5}a.subdued{padding-right:10px;color:#888;text-decoration:none}a.subdued:hover{text-decoration:none}a.subdued:last-child{padding-right:0}.action-sheet-backdrop{-webkit-transition:background-color 150ms ease-in-out;transition:background-color 150ms ease-in-out;position:fixed;top:0;left:0;z-index:11;width:100%;height:100%;background-color:transparent}.action-sheet-backdrop.active{background-color:rgba(0,0,0,.4)}.action-sheet-wrapper{-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0);-webkit-transition:all cubic-bezier(.36,.66,.04,1) 500ms;transition:all cubic-bezier(.36,.66,.04,1) 500ms;position:absolute;bottom:0;left:0;right:0;width:100%;max-width:500px;margin:auto}.action-sheet-up{-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.action-sheet{margin-left:8px;margin-right:8px;width:auto;z-index:11;overflow:hidden}.action-sheet .button{display:block;padding:1px;width:100%;border-radius:0;border-color:#d1d3d6;background-color:transparent;color:#007aff;font-size:21px}.action-sheet .button:hover{color:#007aff}.action-sheet .button.destructive,.action-sheet .button.destructive:hover{color:#ff3b30}.action-sheet .button.activated,.action-sheet .button.active{box-shadow:none;border-color:#d1d3d6;color:#007aff;background:#e4e5e7}.action-sheet-has-icons .icon{position:absolute;left:16px}.action-sheet-title{padding:16px;color:#8f8f8f;text-align:center;font-size:13px}.action-sheet-group{margin-bottom:8px;border-radius:4px;background-color:#fff;overflow:hidden}.action-sheet-group .button{border-width:1px 0 0 0}.action-sheet-group .button:first-child:last-child{border-width:0}.action-sheet-options{background:#f1f2f3}.action-sheet-cancel .button{font-weight:500}.action-sheet-open,.action-sheet-open.modal-open .modal{pointer-events:none}.action-sheet-open .action-sheet-backdrop{pointer-events:auto}.platform-android .action-sheet-backdrop.active{background-color:rgba(0,0,0,.2)}.platform-android .action-sheet{margin:0}.platform-android .action-sheet .action-sheet-title,.platform-android .action-sheet .button{text-align:left;border-color:transparent;font-size:16px;color:inherit}.platform-android .action-sheet .action-sheet-title{font-size:14px;padding:16px;color:#666}.platform-android .action-sheet .button.activated,.platform-android .action-sheet .button.active{background:#e8e8e8}.platform-android .action-sheet-group{margin:0;border-radius:0;background-color:#fafafa}.platform-android .action-sheet-cancel{display:none}.platform-android .action-sheet-has-icons .button{padding-left:56px}.backdrop{position:fixed;top:0;left:0;z-index:11;width:100%;height:100%;background-color:rgba(0,0,0,.4);visibility:hidden;opacity:0;-webkit-transition:.1s opacity linear;transition:.1s opacity linear}.backdrop.visible{visibility:visible}.backdrop.active{opacity:1}.bar{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;position:absolute;right:0;left:0;z-index:9;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:5px;width:100%;height:44px;border-width:0;border-style:solid;border-top:1px solid transparent;border-bottom:1px solid #ddd;background-color:#fff;background-size:0}@media (min--moz-device-pixel-ratio:1.5),(-webkit-min-device-pixel-ratio:1.5),(min-device-pixel-ratio:1.5),(min-resolution:144dpi),(min-resolution:1.5dppx){.bar{border:none;background-image:linear-gradient(0deg,#ddd,#ddd 50%,transparent 50%);background-position:bottom;background-size:100% 1px;background-repeat:no-repeat}}.bar.bar-clear{border:none;background:0 0;color:#fff}.bar.bar-clear .button,.bar.bar-clear .title{color:#fff}.bar.item-input-inset .item-input-wrapper{margin-top:-1px}.bar.item-input-inset .item-input-wrapper input{padding-left:8px;width:94%;height:28px;background:0 0}.bar.bar-light{border-color:#ddd;background-color:#fff;background-image:linear-gradient(0deg,#ddd,#ddd 50%,transparent 50%);color:#444}.bar.bar-light .title{color:#444}.bar.bar-light.bar-footer{background-image:linear-gradient(180deg,#ddd,#ddd 50%,transparent 50%)}.bar.bar-stable{border-color:#b2b2b2;background-color:#f8f8f8;background-image:linear-gradient(0deg,#b2b2b2,#b2b2b2 50%,transparent 50%);color:#444}.bar.bar-stable .title{color:#444}.bar.bar-stable.bar-footer{background-image:linear-gradient(180deg,#b2b2b2,#b2b2b2 50%,transparent 50%)}.bar.bar-positive{border-color:#0c60ee;background-color:#387ef5;background-image:linear-gradient(0deg,#0c60ee,#0c60ee 50%,transparent 50%);color:#fff}.bar.bar-positive .title{color:#fff}.bar.bar-positive.bar-footer{background-image:linear-gradient(180deg,#0c60ee,#0c60ee 50%,transparent 50%)}.bar.bar-calm{border-color:#0a9dc7;background-color:#11c1f3;background-image:linear-gradient(0deg,#0a9dc7,#0a9dc7 50%,transparent 50%);color:#fff}.bar.bar-calm .title{color:#fff}.bar.bar-calm.bar-footer{background-image:linear-gradient(180deg,#0a9dc7,#0a9dc7 50%,transparent 50%)}.bar.bar-assertive{border-color:#e42112;background-color:#ef473a;background-image:linear-gradient(0deg,#e42112,#e42112 50%,transparent 50%);color:#fff}.bar.bar-assertive .title{color:#fff}.bar.bar-assertive.bar-footer{background-image:linear-gradient(180deg,#e42112,#e42112 50%,transparent 50%)}.bar.bar-balanced{border-color:#28a54c;background-color:#33cd5f;background-image:linear-gradient(0deg,#28a54c,#28a54c 50%,transparent 50%);color:#fff}.bar.bar-balanced .title{color:#fff}.bar.bar-balanced.bar-footer{background-image:linear-gradient(180deg,#28a54c,#28a54c 50%,transparent 50%)}.bar.bar-energized{border-color:#e6b500;background-color:#ffc900;background-image:linear-gradient(0deg,#e6b500,#e6b500 50%,transparent 50%);color:#fff}.bar.bar-energized .title{color:#fff}.bar.bar-energized.bar-footer{background-image:linear-gradient(180deg,#e6b500,#e6b500 50%,transparent 50%)}.bar.bar-royal{border-color:#6b46e5;background-color:#886aea;background-image:linear-gradient(0deg,#6b46e5,#6b46e5 50%,transparent 50%);color:#fff}.bar.bar-royal .title{color:#fff}.bar.bar-royal.bar-footer{background-image:linear-gradient(180deg,#6b46e5,#6b46e5 50%,transparent 50%)}.bar.bar-dark{border-color:#111;background-color:#444;background-image:linear-gradient(0deg,#111,#111 50%,transparent 50%);color:#fff}.bar.bar-dark .title{color:#fff}.bar.bar-dark.bar-footer{background-image:linear-gradient(180deg,#111,#111 50%,transparent 50%)}.bar .title{display:block;position:absolute;top:0;right:0;left:0;z-index:0;overflow:hidden;margin:0 10px;min-width:30px;height:43px;text-align:center;text-overflow:ellipsis;white-space:nowrap;font-size:17px;font-weight:500;line-height:44px}.bar .title.title-left{text-align:left}.bar .title.title-right{text-align:right}.bar .title a{color:inherit}.bar .button,.bar button{z-index:1;padding:0 8px;min-width:initial;min-height:31px;font-weight:400;font-size:13px;line-height:32px}.bar .button .icon:before,.bar .button.button-icon:before,.bar .button.icon-left:before,.bar .button.icon-right:before,.bar .button.icon:before,.bar button .icon:before,.bar button.button-icon:before,.bar button.icon-left:before,.bar button.icon-right:before,.bar button.icon:before{padding-right:2px;padding-left:2px;font-size:20px;line-height:32px}.bar .button.button-icon,.bar button.button-icon{font-size:17px}.bar .button.button-icon .icon:before,.bar .button.button-icon.icon-left:before,.bar .button.button-icon.icon-right:before,.bar .button.button-icon:before,.bar button.button-icon .icon:before,.bar button.button-icon.icon-left:before,.bar button.button-icon.icon-right:before,.bar button.button-icon:before{vertical-align:top;font-size:32px;line-height:32px}.bar .button.button-clear,.bar button.button-clear{padding-right:2px;padding-left:2px;font-weight:300;font-size:17px}.bar .button.button-clear .icon:before,.bar .button.button-clear.icon-left:before,.bar .button.button-clear.icon-right:before,.bar .button.button-clear.icon:before,.bar button.button-clear .icon:before,.bar button.button-clear.icon-left:before,.bar button.button-clear.icon-right:before,.bar button.button-clear.icon:before{font-size:32px;line-height:32px}.bar .button.back-button,.bar button.back-button{display:block;margin-right:5px;padding:0;white-space:nowrap;font-weight:400}.bar .button.back-button.activated,.bar .button.back-button.active,.bar button.back-button.activated,.bar button.back-button.active{opacity:.2}.bar .button-bar>.button,.bar .buttons>.button{min-height:31px;line-height:32px}.bar .button+.button-bar,.bar .button-bar+.button{margin-left:5px}.bar .buttons,.bar .buttons.primary-buttons,.bar .buttons.secondary-buttons{display:inherit}.bar .buttons span{display:inline-block}.bar .buttons-left span{margin-right:5px;display:inherit}.bar .buttons-right span{margin-left:5px;display:inherit}.bar .buttons.pull-right,.bar .title+.button:last-child,.bar .title+.buttons,.bar>.button+.button:last-child,.bar>.button.pull-right{position:absolute;top:5px;right:5px;bottom:5px}.platform-android .nav-bar-has-subheader .bar{background-image:none}.platform-android .bar .back-button .icon:before{font-size:24px}.platform-android .bar .title{font-size:19px;line-height:44px}.bar-light .button{border-color:#ddd;background-color:#fff;color:#444}.bar-light .button:hover{color:#444;text-decoration:none}.bar-light .button.activated,.bar-light .button.active{border-color:#ccc;background-color:#fafafa}.bar-light .button.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#444;font-size:17px}.bar-light .button.button-icon{border-color:transparent;background:0 0}.bar-stable .button{border-color:#b2b2b2;background-color:#f8f8f8;color:#444}.bar-stable .button:hover{color:#444;text-decoration:none}.bar-stable .button.activated,.bar-stable .button.active{border-color:#a2a2a2;background-color:#e5e5e5}.bar-stable .button.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#444;font-size:17px}.bar-stable .button.button-icon{border-color:transparent;background:0 0}.bar-positive .button{border-color:#0c60ee;background-color:#387ef5;color:#fff}.bar-positive .button:hover{color:#fff;text-decoration:none}.bar-positive .button.activated,.bar-positive .button.active{border-color:#0c60ee;background-color:#0c60ee}.bar-positive .button.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#fff;font-size:17px}.bar-positive .button.button-icon{border-color:transparent;background:0 0}.bar-calm .button{border-color:#0a9dc7;background-color:#11c1f3;color:#fff}.bar-calm .button:hover{color:#fff;text-decoration:none}.bar-calm .button.activated,.bar-calm .button.active{border-color:#0a9dc7;background-color:#0a9dc7}.bar-calm .button.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#fff;font-size:17px}.bar-calm .button.button-icon{border-color:transparent;background:0 0}.bar-assertive .button{border-color:#e42112;background-color:#ef473a;color:#fff}.bar-assertive .button:hover{color:#fff;text-decoration:none}.bar-assertive .button.activated,.bar-assertive .button.active{border-color:#e42112;background-color:#e42112}.bar-assertive .button.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#fff;font-size:17px}.bar-assertive .button.button-icon{border-color:transparent;background:0 0}.bar-balanced .button{border-color:#28a54c;background-color:#33cd5f;color:#fff}.bar-balanced .button:hover{color:#fff;text-decoration:none}.bar-balanced .button.activated,.bar-balanced .button.active{border-color:#28a54c;background-color:#28a54c}.bar-balanced .button.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#fff;font-size:17px}.bar-balanced .button.button-icon{border-color:transparent;background:0 0}.bar-energized .button{border-color:#e6b500;background-color:#ffc900;color:#fff}.bar-energized .button:hover{color:#fff;text-decoration:none}.bar-energized .button.activated,.bar-energized .button.active{border-color:#e6b500;background-color:#e6b500}.bar-energized .button.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#fff;font-size:17px}.bar-energized .button.button-icon{border-color:transparent;background:0 0}.bar-royal .button{border-color:#6b46e5;background-color:#886aea;color:#fff}.bar-royal .button:hover{color:#fff;text-decoration:none}.bar-royal .button.activated,.bar-royal .button.active{border-color:#6b46e5;background-color:#6b46e5}.bar-royal .button.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#fff;font-size:17px}.bar-royal .button.button-icon{border-color:transparent;background:0 0}.bar-dark .button{border-color:#111;background-color:#444;color:#fff}.bar-dark .button:hover{color:#fff;text-decoration:none}.bar-dark .button.activated,.bar-dark .button.active{border-color:#000;background-color:#262626}.bar-dark .button.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#fff;font-size:17px}.bar-dark .button.button-icon{border-color:transparent;background:0 0}.bar-header{top:0;border-top-width:0;border-bottom-width:1px}.bar-header.has-tabs-top,.tabs-top .bar-header{border-bottom-width:0;background-image:none}.bar-footer{bottom:0;border-top-width:1px;border-bottom-width:0;background-position:top;height:44px}.bar-footer.item-input-inset{position:absolute}.bar-footer .title{height:43px;line-height:44px}.bar-tabs{padding:0}.bar-subheader{top:44px;height:44px}.bar-subheader .title{height:43px;line-height:44px}.bar-subfooter{bottom:44px;height:44px}.bar-subfooter .title{height:43px;line-height:44px}.nav-bar-block{position:absolute;top:0;right:0;left:0;z-index:9}.bar .back-button.hide,.bar .buttons .hide{display:none}.nav-bar-tabs-top .bar{background-image:none}.tabs{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-direction:normal;-webkit-box-orient:horizontal;-webkit-flex-direction:horizontal;-moz-flex-direction:horizontal;-ms-flex-direction:horizontal;flex-direction:horizontal;-webkit-box-pack:center;-ms-flex-pack:center;-webkit-justify-content:center;-moz-justify-content:center;justify-content:center;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);border-color:#b2b2b2;background-color:#f8f8f8;background-image:linear-gradient(0deg,#b2b2b2,#b2b2b2 50%,transparent 50%);color:#444;position:absolute;bottom:0;z-index:5;width:100%;height:49px;border-style:solid;border-top-width:1px;background-size:0;line-height:49px}.tabs .tab-item .badge{background-color:#444;color:#f8f8f8}@media (min--moz-device-pixel-ratio:1.5),(-webkit-min-device-pixel-ratio:1.5),(min-device-pixel-ratio:1.5),(min-resolution:144dpi),(min-resolution:1.5dppx){.tabs{padding-top:2px;border-top:none!important;border-bottom:none;background-position:top;background-size:100% 1px;background-repeat:no-repeat}}.tabs-light>.tabs,.tabs.tabs-light{border-color:#ddd;background-color:#fff;background-image:linear-gradient(0deg,#ddd,#ddd 50%,transparent 50%);color:#444}.tabs-light>.tabs .tab-item .badge,.tabs.tabs-light .tab-item .badge{background-color:#444;color:#fff}.tabs-stable>.tabs,.tabs.tabs-stable{border-color:#b2b2b2;background-color:#f8f8f8;background-image:linear-gradient(0deg,#b2b2b2,#b2b2b2 50%,transparent 50%);color:#444}.tabs-stable>.tabs .tab-item .badge,.tabs.tabs-stable .tab-item .badge{background-color:#444;color:#f8f8f8}.tabs-positive>.tabs,.tabs.tabs-positive{border-color:#0c60ee;background-color:#387ef5;background-image:linear-gradient(0deg,#0c60ee,#0c60ee 50%,transparent 50%);color:#fff}.tabs-positive>.tabs .tab-item .badge,.tabs.tabs-positive .tab-item .badge{background-color:#fff;color:#387ef5}.tabs-calm>.tabs,.tabs.tabs-calm{border-color:#0a9dc7;background-color:#11c1f3;background-image:linear-gradient(0deg,#0a9dc7,#0a9dc7 50%,transparent 50%);color:#fff}.tabs-calm>.tabs .tab-item .badge,.tabs.tabs-calm .tab-item .badge{background-color:#fff;color:#11c1f3}.tabs-assertive>.tabs,.tabs.tabs-assertive{border-color:#e42112;background-color:#ef473a;background-image:linear-gradient(0deg,#e42112,#e42112 50%,transparent 50%);color:#fff}.tabs-assertive>.tabs .tab-item .badge,.tabs.tabs-assertive .tab-item .badge{background-color:#fff;color:#ef473a}.tabs-balanced>.tabs,.tabs.tabs-balanced{border-color:#28a54c;background-color:#33cd5f;background-image:linear-gradient(0deg,#28a54c,#28a54c 50%,transparent 50%);color:#fff}.tabs-balanced>.tabs .tab-item .badge,.tabs.tabs-balanced .tab-item .badge{background-color:#fff;color:#33cd5f}.tabs-energized>.tabs,.tabs.tabs-energized{border-color:#e6b500;background-color:#ffc900;background-image:linear-gradient(0deg,#e6b500,#e6b500 50%,transparent 50%);color:#fff}.tabs-energized>.tabs .tab-item .badge,.tabs.tabs-energized .tab-item .badge{background-color:#fff;color:#ffc900}.tabs-royal>.tabs,.tabs.tabs-royal{border-color:#6b46e5;background-color:#886aea;background-image:linear-gradient(0deg,#6b46e5,#6b46e5 50%,transparent 50%);color:#fff}.tabs-royal>.tabs .tab-item .badge,.tabs.tabs-royal .tab-item .badge{background-color:#fff;color:#886aea}.tabs-dark>.tabs,.tabs.tabs-dark{border-color:#111;background-color:#444;background-image:linear-gradient(0deg,#111,#111 50%,transparent 50%);color:#fff}.tabs-dark>.tabs .tab-item .badge,.tabs.tabs-dark .tab-item .badge{background-color:#fff;color:#444}.tabs-striped .tabs{background-color:#fff;background-image:none;border:none;border-bottom:1px solid #ddd;padding-top:2px}.tabs-striped .tab-item.activated,.tabs-striped .tab-item.active,.tabs-striped .tab-item.tab-item-active{margin-top:-2px;border-style:solid;border-width:2px 0 0 0;border-color:#444}.tabs-striped .tab-item.activated .badge,.tabs-striped .tab-item.active .badge,.tabs-striped .tab-item.tab-item-active .badge{top:2px;opacity:1}.tabs-striped.tabs-light .tabs{background-color:#fff}.tabs-striped.tabs-light .tab-item{color:rgba(68,68,68,.4);opacity:1}.tabs-striped.tabs-light .tab-item .badge{opacity:.4}.tabs-striped.tabs-light .tab-item.activated,.tabs-striped.tabs-light .tab-item.active,.tabs-striped.tabs-light .tab-item.tab-item-active{margin-top:-2px;color:#444;border-style:solid;border-width:2px 0 0 0;border-color:#444}.tabs-striped.tabs-stable .tabs{background-color:#f8f8f8}.tabs-striped.tabs-stable .tab-item{color:rgba(68,68,68,.4);opacity:1}.tabs-striped.tabs-stable .tab-item .badge{opacity:.4}.tabs-striped.tabs-stable .tab-item.activated,.tabs-striped.tabs-stable .tab-item.active,.tabs-striped.tabs-stable .tab-item.tab-item-active{margin-top:-2px;color:#444;border-style:solid;border-width:2px 0 0 0;border-color:#444}.tabs-striped.tabs-positive .tabs{background-color:#387ef5}.tabs-striped.tabs-positive .tab-item{color:rgba(255,255,255,.4);opacity:1}.tabs-striped.tabs-positive .tab-item .badge{opacity:.4}.tabs-striped.tabs-positive .tab-item.activated,.tabs-striped.tabs-positive .tab-item.active,.tabs-striped.tabs-positive .tab-item.tab-item-active{margin-top:-2px;color:#fff;border-style:solid;border-width:2px 0 0 0;border-color:#fff}.tabs-striped.tabs-calm .tabs{background-color:#11c1f3}.tabs-striped.tabs-calm .tab-item{color:rgba(255,255,255,.4);opacity:1}.tabs-striped.tabs-calm .tab-item .badge{opacity:.4}.tabs-striped.tabs-calm .tab-item.activated,.tabs-striped.tabs-calm .tab-item.active,.tabs-striped.tabs-calm .tab-item.tab-item-active{margin-top:-2px;color:#fff;border-style:solid;border-width:2px 0 0 0;border-color:#fff}.tabs-striped.tabs-assertive .tabs{background-color:#ef473a}.tabs-striped.tabs-assertive .tab-item{color:rgba(255,255,255,.4);opacity:1}.tabs-striped.tabs-assertive .tab-item .badge{opacity:.4}.tabs-striped.tabs-assertive .tab-item.activated,.tabs-striped.tabs-assertive .tab-item.active,.tabs-striped.tabs-assertive .tab-item.tab-item-active{margin-top:-2px;color:#fff;border-style:solid;border-width:2px 0 0 0;border-color:#fff}.tabs-striped.tabs-balanced .tabs{background-color:#33cd5f}.tabs-striped.tabs-balanced .tab-item{color:rgba(255,255,255,.4);opacity:1}.tabs-striped.tabs-balanced .tab-item .badge{opacity:.4}.tabs-striped.tabs-balanced .tab-item.activated,.tabs-striped.tabs-balanced .tab-item.active,.tabs-striped.tabs-balanced .tab-item.tab-item-active{margin-top:-2px;color:#fff;border-style:solid;border-width:2px 0 0 0;border-color:#fff}.tabs-striped.tabs-energized .tabs{background-color:#ffc900}.tabs-striped.tabs-energized .tab-item{color:rgba(255,255,255,.4);opacity:1}.tabs-striped.tabs-energized .tab-item .badge{opacity:.4}.tabs-striped.tabs-energized .tab-item.activated,.tabs-striped.tabs-energized .tab-item.active,.tabs-striped.tabs-energized .tab-item.tab-item-active{margin-top:-2px;color:#fff;border-style:solid;border-width:2px 0 0 0;border-color:#fff}.tabs-striped.tabs-royal .tabs{background-color:#886aea}.tabs-striped.tabs-royal .tab-item{color:rgba(255,255,255,.4);opacity:1}.tabs-striped.tabs-royal .tab-item .badge{opacity:.4}.tabs-striped.tabs-royal .tab-item.activated,.tabs-striped.tabs-royal .tab-item.active,.tabs-striped.tabs-royal .tab-item.tab-item-active{margin-top:-2px;color:#fff;border-style:solid;border-width:2px 0 0 0;border-color:#fff}.tabs-striped.tabs-dark .tabs{background-color:#444}.tabs-striped.tabs-dark .tab-item{color:rgba(255,255,255,.4);opacity:1}.tabs-striped.tabs-dark .tab-item .badge{opacity:.4}.tabs-striped.tabs-dark .tab-item.activated,.tabs-striped.tabs-dark .tab-item.active,.tabs-striped.tabs-dark .tab-item.tab-item-active{margin-top:-2px;color:#fff;border-style:solid;border-width:2px 0 0 0;border-color:#fff}.tabs-striped.tabs-top .tab-item.activated .badge,.tabs-striped.tabs-top .tab-item.active .badge,.tabs-striped.tabs-top .tab-item.tab-item-active .badge{top:4%}.tabs-striped.tabs-background-light .tabs{background-color:#fff;background-image:none}.tabs-striped.tabs-background-stable .tabs{background-color:#f8f8f8;background-image:none}.tabs-striped.tabs-background-positive .tabs{background-color:#387ef5;background-image:none}.tabs-striped.tabs-background-calm .tabs{background-color:#11c1f3;background-image:none}.tabs-striped.tabs-background-assertive .tabs{background-color:#ef473a;background-image:none}.tabs-striped.tabs-background-balanced .tabs{background-color:#33cd5f;background-image:none}.tabs-striped.tabs-background-energized .tabs{background-color:#ffc900;background-image:none}.tabs-striped.tabs-background-royal .tabs{background-color:#886aea;background-image:none}.tabs-striped.tabs-background-dark .tabs{background-color:#444;background-image:none}.tabs-striped.tabs-color-light .tab-item{color:rgba(255,255,255,.4);opacity:1}.tabs-striped.tabs-color-light .tab-item .badge{opacity:.4}.tabs-striped.tabs-color-light .tab-item.activated,.tabs-striped.tabs-color-light .tab-item.active,.tabs-striped.tabs-color-light .tab-item.tab-item-active{margin-top:-2px;color:#fff;border:0 solid #fff;border-top-width:2px}.tabs-striped.tabs-color-light .tab-item.activated .badge,.tabs-striped.tabs-color-light .tab-item.active .badge,.tabs-striped.tabs-color-light .tab-item.tab-item-active .badge{top:2px;opacity:1}.tabs-striped.tabs-color-stable .tab-item{color:rgba(248,248,248,.4);opacity:1}.tabs-striped.tabs-color-stable .tab-item .badge{opacity:.4}.tabs-striped.tabs-color-stable .tab-item.activated,.tabs-striped.tabs-color-stable .tab-item.active,.tabs-striped.tabs-color-stable .tab-item.tab-item-active{margin-top:-2px;color:#f8f8f8;border:0 solid #f8f8f8;border-top-width:2px}.tabs-striped.tabs-color-stable .tab-item.activated .badge,.tabs-striped.tabs-color-stable .tab-item.active .badge,.tabs-striped.tabs-color-stable .tab-item.tab-item-active .badge{top:2px;opacity:1}.tabs-striped.tabs-color-positive .tab-item{color:rgba(56,126,245,.4);opacity:1}.tabs-striped.tabs-color-positive .tab-item .badge{opacity:.4}.tabs-striped.tabs-color-positive .tab-item.activated,.tabs-striped.tabs-color-positive .tab-item.active,.tabs-striped.tabs-color-positive .tab-item.tab-item-active{margin-top:-2px;color:#387ef5;border:0 solid #387ef5;border-top-width:2px}.tabs-striped.tabs-color-positive .tab-item.activated .badge,.tabs-striped.tabs-color-positive .tab-item.active .badge,.tabs-striped.tabs-color-positive .tab-item.tab-item-active .badge{top:2px;opacity:1}.tabs-striped.tabs-color-calm .tab-item{color:rgba(17,193,243,.4);opacity:1}.tabs-striped.tabs-color-calm .tab-item .badge{opacity:.4}.tabs-striped.tabs-color-calm .tab-item.activated,.tabs-striped.tabs-color-calm .tab-item.active,.tabs-striped.tabs-color-calm .tab-item.tab-item-active{margin-top:-2px;color:#11c1f3;border:0 solid #11c1f3;border-top-width:2px}.tabs-striped.tabs-color-calm .tab-item.activated .badge,.tabs-striped.tabs-color-calm .tab-item.active .badge,.tabs-striped.tabs-color-calm .tab-item.tab-item-active .badge{top:2px;opacity:1}.tabs-striped.tabs-color-assertive .tab-item{color:rgba(239,71,58,.4);opacity:1}.tabs-striped.tabs-color-assertive .tab-item .badge{opacity:.4}.tabs-striped.tabs-color-assertive .tab-item.activated,.tabs-striped.tabs-color-assertive .tab-item.active,.tabs-striped.tabs-color-assertive .tab-item.tab-item-active{margin-top:-2px;color:#ef473a;border:0 solid #ef473a;border-top-width:2px}.tabs-striped.tabs-color-assertive .tab-item.activated .badge,.tabs-striped.tabs-color-assertive .tab-item.active .badge,.tabs-striped.tabs-color-assertive .tab-item.tab-item-active .badge{top:2px;opacity:1}.tabs-striped.tabs-color-balanced .tab-item{color:rgba(51,205,95,.4);opacity:1}.tabs-striped.tabs-color-balanced .tab-item .badge{opacity:.4}.tabs-striped.tabs-color-balanced .tab-item.activated,.tabs-striped.tabs-color-balanced .tab-item.active,.tabs-striped.tabs-color-balanced .tab-item.tab-item-active{margin-top:-2px;color:#33cd5f;border:0 solid #33cd5f;border-top-width:2px}.tabs-striped.tabs-color-balanced .tab-item.activated .badge,.tabs-striped.tabs-color-balanced .tab-item.active .badge,.tabs-striped.tabs-color-balanced .tab-item.tab-item-active .badge{top:2px;opacity:1}.tabs-striped.tabs-color-energized .tab-item{color:rgba(255,201,0,.4);opacity:1}.tabs-striped.tabs-color-energized .tab-item .badge{opacity:.4}.tabs-striped.tabs-color-energized .tab-item.activated,.tabs-striped.tabs-color-energized .tab-item.active,.tabs-striped.tabs-color-energized .tab-item.tab-item-active{margin-top:-2px;color:#ffc900;border:0 solid #ffc900;border-top-width:2px}.tabs-striped.tabs-color-energized .tab-item.activated .badge,.tabs-striped.tabs-color-energized .tab-item.active .badge,.tabs-striped.tabs-color-energized .tab-item.tab-item-active .badge{top:2px;opacity:1}.tabs-striped.tabs-color-royal .tab-item{color:rgba(136,106,234,.4);opacity:1}.tabs-striped.tabs-color-royal .tab-item .badge{opacity:.4}.tabs-striped.tabs-color-royal .tab-item.activated,.tabs-striped.tabs-color-royal .tab-item.active,.tabs-striped.tabs-color-royal .tab-item.tab-item-active{margin-top:-2px;color:#886aea;border:0 solid #886aea;border-top-width:2px}.tabs-striped.tabs-color-royal .tab-item.activated .badge,.tabs-striped.tabs-color-royal .tab-item.active .badge,.tabs-striped.tabs-color-royal .tab-item.tab-item-active .badge{top:2px;opacity:1}.tabs-striped.tabs-color-dark .tab-item{color:rgba(68,68,68,.4);opacity:1}.tabs-striped.tabs-color-dark .tab-item .badge{opacity:.4}.tabs-striped.tabs-color-dark .tab-item.activated,.tabs-striped.tabs-color-dark .tab-item.active,.tabs-striped.tabs-color-dark .tab-item.tab-item-active{margin-top:-2px;color:#444;border:0 solid #444;border-top-width:2px}.tabs-striped.tabs-color-dark .tab-item.activated .badge,.tabs-striped.tabs-color-dark .tab-item.active .badge,.tabs-striped.tabs-color-dark .tab-item.tab-item-active .badge{top:2px;opacity:1}.tabs-background-light .tabs,.tabs-background-light>.tabs{background-color:#fff;background-image:linear-gradient(0deg,#ddd,#ddd 50%,transparent 50%);border-color:#ddd}.tabs-background-stable .tabs,.tabs-background-stable>.tabs{background-color:#f8f8f8;background-image:linear-gradient(0deg,#b2b2b2,#b2b2b2 50%,transparent 50%);border-color:#b2b2b2}.tabs-background-positive .tabs,.tabs-background-positive>.tabs{background-color:#387ef5;background-image:linear-gradient(0deg,#0c60ee,#0c60ee 50%,transparent 50%);border-color:#0c60ee}.tabs-background-calm .tabs,.tabs-background-calm>.tabs{background-color:#11c1f3;background-image:linear-gradient(0deg,#0a9dc7,#0a9dc7 50%,transparent 50%);border-color:#0a9dc7}.tabs-background-assertive .tabs,.tabs-background-assertive>.tabs{background-color:#ef473a;background-image:linear-gradient(0deg,#e42112,#e42112 50%,transparent 50%);border-color:#e42112}.tabs-background-balanced .tabs,.tabs-background-balanced>.tabs{background-color:#33cd5f;background-image:linear-gradient(0deg,#28a54c,#28a54c 50%,transparent 50%);border-color:#28a54c}.tabs-background-energized .tabs,.tabs-background-energized>.tabs{background-color:#ffc900;background-image:linear-gradient(0deg,#e6b500,#e6b500 50%,transparent 50%);border-color:#e6b500}.tabs-background-royal .tabs,.tabs-background-royal>.tabs{background-color:#886aea;background-image:linear-gradient(0deg,#6b46e5,#6b46e5 50%,transparent 50%);border-color:#6b46e5}.tabs-background-dark .tabs,.tabs-background-dark>.tabs{background-color:#444;background-image:linear-gradient(0deg,#111,#111 50%,transparent 50%);border-color:#111}.tabs-color-light .tab-item{color:rgba(255,255,255,.4);opacity:1}.tabs-color-light .tab-item .badge{opacity:.4}.tabs-color-light .tab-item.activated,.tabs-color-light .tab-item.active,.tabs-color-light .tab-item.tab-item-active{color:#fff;border:0 solid #fff}.tabs-color-light .tab-item.activated .badge,.tabs-color-light .tab-item.active .badge,.tabs-color-light .tab-item.tab-item-active .badge{opacity:1}.tabs-color-stable .tab-item{color:rgba(248,248,248,.4);opacity:1}.tabs-color-stable .tab-item .badge{opacity:.4}.tabs-color-stable .tab-item.activated,.tabs-color-stable .tab-item.active,.tabs-color-stable .tab-item.tab-item-active{color:#f8f8f8;border:0 solid #f8f8f8}.tabs-color-stable .tab-item.activated .badge,.tabs-color-stable .tab-item.active .badge,.tabs-color-stable .tab-item.tab-item-active .badge{opacity:1}.tabs-color-positive .tab-item{color:rgba(56,126,245,.4);opacity:1}.tabs-color-positive .tab-item .badge{opacity:.4}.tabs-color-positive .tab-item.activated,.tabs-color-positive .tab-item.active,.tabs-color-positive .tab-item.tab-item-active{color:#387ef5;border:0 solid #387ef5}.tabs-color-positive .tab-item.activated .badge,.tabs-color-positive .tab-item.active .badge,.tabs-color-positive .tab-item.tab-item-active .badge{opacity:1}.tabs-color-calm .tab-item{color:rgba(17,193,243,.4);opacity:1}.tabs-color-calm .tab-item .badge{opacity:.4}.tabs-color-calm .tab-item.activated,.tabs-color-calm .tab-item.active,.tabs-color-calm .tab-item.tab-item-active{color:#11c1f3;border:0 solid #11c1f3}.tabs-color-calm .tab-item.activated .badge,.tabs-color-calm .tab-item.active .badge,.tabs-color-calm .tab-item.tab-item-active .badge{opacity:1}.tabs-color-assertive .tab-item{color:rgba(239,71,58,.4);opacity:1}.tabs-color-assertive .tab-item .badge{opacity:.4}.tabs-color-assertive .tab-item.activated,.tabs-color-assertive .tab-item.active,.tabs-color-assertive .tab-item.tab-item-active{color:#ef473a;border:0 solid #ef473a}.tabs-color-assertive .tab-item.activated .badge,.tabs-color-assertive .tab-item.active .badge,.tabs-color-assertive .tab-item.tab-item-active .badge{opacity:1}.tabs-color-balanced .tab-item{color:rgba(51,205,95,.4);opacity:1}.tabs-color-balanced .tab-item .badge{opacity:.4}.tabs-color-balanced .tab-item.activated,.tabs-color-balanced .tab-item.active,.tabs-color-balanced .tab-item.tab-item-active{color:#33cd5f;border:0 solid #33cd5f}.tabs-color-balanced .tab-item.activated .badge,.tabs-color-balanced .tab-item.active .badge,.tabs-color-balanced .tab-item.tab-item-active .badge{opacity:1}.tabs-color-energized .tab-item{color:rgba(255,201,0,.4);opacity:1}.tabs-color-energized .tab-item .badge{opacity:.4}.tabs-color-energized .tab-item.activated,.tabs-color-energized .tab-item.active,.tabs-color-energized .tab-item.tab-item-active{color:#ffc900;border:0 solid #ffc900}.tabs-color-energized .tab-item.activated .badge,.tabs-color-energized .tab-item.active .badge,.tabs-color-energized .tab-item.tab-item-active .badge{opacity:1}.tabs-color-royal .tab-item{color:rgba(136,106,234,.4);opacity:1}.tabs-color-royal .tab-item .badge{opacity:.4}.tabs-color-royal .tab-item.activated,.tabs-color-royal .tab-item.active,.tabs-color-royal .tab-item.tab-item-active{color:#886aea;border:0 solid #886aea}.tabs-color-royal .tab-item.activated .badge,.tabs-color-royal .tab-item.active .badge,.tabs-color-royal .tab-item.tab-item-active .badge{opacity:1}.tabs-color-dark .tab-item{color:rgba(68,68,68,.4);opacity:1}.tabs-color-dark .tab-item .badge{opacity:.4}.tabs-color-dark .tab-item.activated,.tabs-color-dark .tab-item.active,.tabs-color-dark .tab-item.tab-item-active{color:#444;border:0 solid #444}.tabs-color-dark .tab-item.activated .badge,.tabs-color-dark .tab-item.active .badge,.tabs-color-dark .tab-item.tab-item-active .badge{opacity:1}ion-tabs.tabs-color-active-light .tab-item{color:#444}ion-tabs.tabs-color-active-light .tab-item.activated,ion-tabs.tabs-color-active-light .tab-item.active,ion-tabs.tabs-color-active-light .tab-item.tab-item-active{color:#fff}ion-tabs.tabs-striped.tabs-color-active-light .tab-item.activated,ion-tabs.tabs-striped.tabs-color-active-light .tab-item.active,ion-tabs.tabs-striped.tabs-color-active-light .tab-item.tab-item-active{border-color:#fff;color:#fff}ion-tabs.tabs-color-active-stable .tab-item{color:#444}ion-tabs.tabs-color-active-stable .tab-item.activated,ion-tabs.tabs-color-active-stable .tab-item.active,ion-tabs.tabs-color-active-stable .tab-item.tab-item-active{color:#f8f8f8}ion-tabs.tabs-striped.tabs-color-active-stable .tab-item.activated,ion-tabs.tabs-striped.tabs-color-active-stable .tab-item.active,ion-tabs.tabs-striped.tabs-color-active-stable .tab-item.tab-item-active{border-color:#f8f8f8;color:#f8f8f8}ion-tabs.tabs-color-active-positive .tab-item{color:#444}ion-tabs.tabs-color-active-positive .tab-item.activated,ion-tabs.tabs-color-active-positive .tab-item.active,ion-tabs.tabs-color-active-positive .tab-item.tab-item-active{color:#387ef5}ion-tabs.tabs-striped.tabs-color-active-positive .tab-item.activated,ion-tabs.tabs-striped.tabs-color-active-positive .tab-item.active,ion-tabs.tabs-striped.tabs-color-active-positive .tab-item.tab-item-active{border-color:#387ef5;color:#387ef5}ion-tabs.tabs-color-active-calm .tab-item{color:#444}ion-tabs.tabs-color-active-calm .tab-item.activated,ion-tabs.tabs-color-active-calm .tab-item.active,ion-tabs.tabs-color-active-calm .tab-item.tab-item-active{color:#11c1f3}ion-tabs.tabs-striped.tabs-color-active-calm .tab-item.activated,ion-tabs.tabs-striped.tabs-color-active-calm .tab-item.active,ion-tabs.tabs-striped.tabs-color-active-calm .tab-item.tab-item-active{border-color:#11c1f3;color:#11c1f3}ion-tabs.tabs-color-active-assertive .tab-item{color:#444}ion-tabs.tabs-color-active-assertive .tab-item.activated,ion-tabs.tabs-color-active-assertive .tab-item.active,ion-tabs.tabs-color-active-assertive .tab-item.tab-item-active{color:#ef473a}ion-tabs.tabs-striped.tabs-color-active-assertive .tab-item.activated,ion-tabs.tabs-striped.tabs-color-active-assertive .tab-item.active,ion-tabs.tabs-striped.tabs-color-active-assertive .tab-item.tab-item-active{border-color:#ef473a;color:#ef473a}ion-tabs.tabs-color-active-balanced .tab-item{color:#444}ion-tabs.tabs-color-active-balanced .tab-item.activated,ion-tabs.tabs-color-active-balanced .tab-item.active,ion-tabs.tabs-color-active-balanced .tab-item.tab-item-active{color:#33cd5f}ion-tabs.tabs-striped.tabs-color-active-balanced .tab-item.activated,ion-tabs.tabs-striped.tabs-color-active-balanced .tab-item.active,ion-tabs.tabs-striped.tabs-color-active-balanced .tab-item.tab-item-active{border-color:#33cd5f;color:#33cd5f}ion-tabs.tabs-color-active-energized .tab-item{color:#444}ion-tabs.tabs-color-active-energized .tab-item.activated,ion-tabs.tabs-color-active-energized .tab-item.active,ion-tabs.tabs-color-active-energized .tab-item.tab-item-active{color:#ffc900}ion-tabs.tabs-striped.tabs-color-active-energized .tab-item.activated,ion-tabs.tabs-striped.tabs-color-active-energized .tab-item.active,ion-tabs.tabs-striped.tabs-color-active-energized .tab-item.tab-item-active{border-color:#ffc900;color:#ffc900}ion-tabs.tabs-color-active-royal .tab-item{color:#444}ion-tabs.tabs-color-active-royal .tab-item.activated,ion-tabs.tabs-color-active-royal .tab-item.active,ion-tabs.tabs-color-active-royal .tab-item.tab-item-active{color:#886aea}ion-tabs.tabs-striped.tabs-color-active-royal .tab-item.activated,ion-tabs.tabs-striped.tabs-color-active-royal .tab-item.active,ion-tabs.tabs-striped.tabs-color-active-royal .tab-item.tab-item-active{border-color:#886aea;color:#886aea}ion-tabs.tabs-color-active-dark .tab-item{color:#fff}ion-tabs.tabs-color-active-dark .tab-item.activated,ion-tabs.tabs-color-active-dark .tab-item.active,ion-tabs.tabs-color-active-dark .tab-item.tab-item-active{color:#444}ion-tabs.tabs-striped.tabs-color-active-dark .tab-item.activated,ion-tabs.tabs-striped.tabs-color-active-dark .tab-item.active,ion-tabs.tabs-striped.tabs-color-active-dark .tab-item.tab-item-active{border-color:#444;color:#444}.tabs-top.tabs-striped{padding-bottom:0}.tabs-top.tabs-striped .tab-item{background:0 0;-webkit-transition:color .1s ease;-moz-transition:color .1s ease;-ms-transition:color .1s ease;-o-transition:color .1s ease;transition:color .1s ease}.tabs-top.tabs-striped .tab-item.activated,.tabs-top.tabs-striped .tab-item.active,.tabs-top.tabs-striped .tab-item.tab-item-active{margin-top:1px;border-width:0 0 2px 0!important;border-style:solid}.tabs-top.tabs-striped .tab-item.activated>.badge,.tabs-top.tabs-striped .tab-item.activated>i,.tabs-top.tabs-striped .tab-item.active>.badge,.tabs-top.tabs-striped .tab-item.active>i,.tabs-top.tabs-striped .tab-item.tab-item-active>.badge,.tabs-top.tabs-striped .tab-item.tab-item-active>i{margin-top:-1px}.tabs-top.tabs-striped .tab-item .badge{-webkit-transition:color .2s ease;-moz-transition:color .2s ease;-ms-transition:color .2s ease;-o-transition:color .2s ease;transition:color .2s ease}.tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.activated .tab-title,.tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.activated i,.tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.active .tab-title,.tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.active i,.tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.tab-item-active .tab-title,.tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.tab-item-active i{display:block;margin-top:-1px}.tabs-top.tabs-striped.tabs-icon-left .tab-item{margin-top:1px}.tabs-top.tabs-striped.tabs-icon-left .tab-item.activated .tab-title,.tabs-top.tabs-striped.tabs-icon-left .tab-item.activated i,.tabs-top.tabs-striped.tabs-icon-left .tab-item.active .tab-title,.tabs-top.tabs-striped.tabs-icon-left .tab-item.active i,.tabs-top.tabs-striped.tabs-icon-left .tab-item.tab-item-active .tab-title,.tabs-top.tabs-striped.tabs-icon-left .tab-item.tab-item-active i{margin-top:-.1em}.tabs-top>.tabs,.tabs.tabs-top{top:44px;padding-top:0;background-position:bottom;border-top-width:0;border-bottom-width:1px}.tabs-top>.tabs .tab-item.activated .badge,.tabs-top>.tabs .tab-item.active .badge,.tabs-top>.tabs .tab-item.tab-item-active .badge,.tabs.tabs-top .tab-item.activated .badge,.tabs.tabs-top .tab-item.active .badge,.tabs.tabs-top .tab-item.tab-item-active .badge{top:4%}.tabs-top~.bar-header{border-bottom-width:0}.tab-item{-webkit-box-flex:1;-webkit-flex:1;-moz-box-flex:1;-moz-flex:1;-ms-flex:1;flex:1;display:block;overflow:hidden;max-width:150px;height:100%;color:inherit;text-align:center;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;font-weight:400;font-size:14px;font-family:"-apple-system","Helvetica Neue",Roboto,"Segoe UI",sans-serif;opacity:.7}.tab-item:hover{cursor:pointer}.tab-item.tab-hidden,.tabs-item-hide>.tabs,.tabs.tabs-item-hide{display:none}.tabs-icon-bottom.tabs .tab-item,.tabs-icon-bottom>.tabs .tab-item,.tabs-icon-top.tabs .tab-item,.tabs-icon-top>.tabs .tab-item{font-size:10px;line-height:14px}.tab-item .icon{display:block;margin:0 auto;height:32px;font-size:32px}.tabs-icon-left.tabs .tab-item,.tabs-icon-left>.tabs .tab-item,.tabs-icon-right.tabs .tab-item,.tabs-icon-right>.tabs .tab-item{font-size:10px}.tabs-icon-left.tabs .tab-item .icon,.tabs-icon-left.tabs .tab-item .tab-title,.tabs-icon-left>.tabs .tab-item .icon,.tabs-icon-left>.tabs .tab-item .tab-title,.tabs-icon-right.tabs .tab-item .icon,.tabs-icon-right.tabs .tab-item .tab-title,.tabs-icon-right>.tabs .tab-item .icon,.tabs-icon-right>.tabs .tab-item .tab-title{display:inline-block;vertical-align:top;margin-top:-.1em}.tabs-icon-left.tabs .tab-item .icon:before,.tabs-icon-left.tabs .tab-item .tab-title:before,.tabs-icon-left>.tabs .tab-item .icon:before,.tabs-icon-left>.tabs .tab-item .tab-title:before,.tabs-icon-right.tabs .tab-item .icon:before,.tabs-icon-right.tabs .tab-item .tab-title:before,.tabs-icon-right>.tabs .tab-item .icon:before,.tabs-icon-right>.tabs .tab-item .tab-title:before{font-size:24px;line-height:49px}.tabs-icon-left.tabs .tab-item .icon,.tabs-icon-left>.tabs .tab-item .icon{padding-right:3px}.tabs-icon-right.tabs .tab-item .icon,.tabs-icon-right>.tabs .tab-item .icon{padding-left:3px}.tabs-icon-only.tabs .icon,.tabs-icon-only>.tabs .icon{line-height:inherit}.tab-item.has-badge{position:relative}.tab-item .badge{position:absolute;top:4%;right:33%;right:calc(50% - 26px);padding:1px 6px;height:auto;font-size:12px;line-height:16px}.tab-item.activated,.tab-item.active,.tab-item.tab-item-active{opacity:1}.tab-item.activated.tab-item-light,.tab-item.active.tab-item-light,.tab-item.tab-item-active.tab-item-light{color:#fff}.tab-item.activated.tab-item-stable,.tab-item.active.tab-item-stable,.tab-item.tab-item-active.tab-item-stable{color:#f8f8f8}.tab-item.activated.tab-item-positive,.tab-item.active.tab-item-positive,.tab-item.tab-item-active.tab-item-positive{color:#387ef5}.tab-item.activated.tab-item-calm,.tab-item.active.tab-item-calm,.tab-item.tab-item-active.tab-item-calm{color:#11c1f3}.tab-item.activated.tab-item-assertive,.tab-item.active.tab-item-assertive,.tab-item.tab-item-active.tab-item-assertive{color:#ef473a}.tab-item.activated.tab-item-balanced,.tab-item.active.tab-item-balanced,.tab-item.tab-item-active.tab-item-balanced{color:#33cd5f}.tab-item.activated.tab-item-energized,.tab-item.active.tab-item-energized,.tab-item.tab-item-active.tab-item-energized{color:#ffc900}.tab-item.activated.tab-item-royal,.tab-item.active.tab-item-royal,.tab-item.tab-item-active.tab-item-royal{color:#886aea}.tab-item.activated.tab-item-dark,.tab-item.active.tab-item-dark,.tab-item.tab-item-active.tab-item-dark{color:#444}.item.tabs{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;padding:0}.item.tabs .icon:before{position:relative}.tab-item.disabled,.tab-item[disabled]{opacity:.4;cursor:default;pointer-events:none}.nav-bar-tabs-top.hide~.view-container .tabs-top .tabs{top:0}.pane[hide-nav-bar=true] .has-tabs-top{top:49px}.menu{position:absolute;top:0;bottom:0;z-index:0;overflow:hidden;min-height:100%;max-height:100%;width:275px;background-color:#fff}.menu .scroll-content{z-index:10}.menu .bar-header{z-index:11}.menu-content{-webkit-transform:none;transform:none;box-shadow:-1px 0 2px rgba(0,0,0,.2),1px 0 2px rgba(0,0,0,.2)}.menu-open .menu-content .pane,.menu-open .menu-content .scroll-content,.menu-open .menu-content .scroll-content .scroll{pointer-events:none}.menu-open .menu-content .scroll-content:not(.overflow-scroll){overflow:hidden}.grade-b .menu-content,.grade-c .menu-content{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;right:-1px;left:-1px;border-right:1px solid #ccc;border-left:1px solid #ccc;box-shadow:none}.menu-left{left:0}.menu-right{right:0}.aside-open.aside-resizing .menu-right{display:none}.menu-animated{-webkit-transition:-webkit-transform 200ms ease;transition:transform 200ms ease}.modal-backdrop,.modal-backdrop-bg{position:fixed;top:0;left:0;z-index:10;width:100%;height:100%}.modal-backdrop-bg{pointer-events:none}.modal{display:block;position:absolute;top:0;z-index:10;overflow:hidden;min-height:100%;width:100%;background-color:#fff}@media (min-width:680px){.modal{top:20%;right:20%;bottom:20%;left:20%;min-height:240px;width:60%}.modal.ng-leave-active{bottom:0}.platform-ios.platform-cordova .modal-wrapper .modal .bar-header:not(.bar-subheader){height:44px}.platform-ios.platform-cordova .modal-wrapper .modal .bar-header:not(.bar-subheader)>*{margin-top:0}.platform-ios.platform-cordova .modal-wrapper .modal .bar-subheader,.platform-ios.platform-cordova .modal-wrapper .modal .has-header,.platform-ios.platform-cordova .modal-wrapper .modal .tabs-top>.tabs,.platform-ios.platform-cordova .modal-wrapper .modal .tabs.tabs-top{top:44px}.platform-ios.platform-cordova .modal-wrapper .modal .has-subheader{top:88px}.platform-ios.platform-cordova .modal-wrapper .modal .has-header.has-tabs-top{top:93px}.platform-ios.platform-cordova .modal-wrapper .modal .has-header.has-subheader.has-tabs-top{top:137px}.modal-backdrop-bg{-webkit-transition:opacity 300ms ease-in-out;transition:opacity 300ms ease-in-out;background-color:#000;opacity:0}.active .modal-backdrop-bg{opacity:.5}}.modal-open{pointer-events:none}.modal-open .modal,.modal-open .modal-backdrop{pointer-events:auto}.modal-open.loading-active .modal,.modal-open.loading-active .modal-backdrop{pointer-events:none}.popover-backdrop{position:fixed;top:0;left:0;z-index:10;width:100%;height:100%;background-color:transparent}.popover-backdrop.active{background-color:rgba(0,0,0,.1)}.popover{position:absolute;top:25%;left:50%;z-index:10;display:block;margin-top:12px;margin-left:-110px;height:280px;width:220px;background-color:#fff;box-shadow:0 1px 3px rgba(0,0,0,.4);opacity:0}.popover .item:first-child{border-top:0}.popover .item:last-child{border-bottom:0}.popover.popover-bottom{margin-top:-12px}.popover,.popover .bar-header{border-radius:2px}.popover .scroll-content{z-index:1;margin:2px 0}.popover .bar-header{border-bottom-right-radius:0;border-bottom-left-radius:0}.popover .has-header{border-top-right-radius:0;border-top-left-radius:0}.popover-arrow{display:none}.platform-ios .popover{box-shadow:0 0 40px rgba(0,0,0,.08);border-radius:10px}.platform-ios .popover .bar-header{-webkit-border-top-right-radius:10px;border-top-right-radius:10px;-webkit-border-top-left-radius:10px;border-top-left-radius:10px}.platform-ios .popover .scroll-content{margin:8px 0;border-radius:10px}.platform-ios .popover .scroll-content.has-header{margin-top:0}.platform-ios .popover-arrow{position:absolute;display:block;top:-17px;width:30px;height:19px;overflow:hidden}.platform-ios .popover-arrow:after{position:absolute;top:12px;left:5px;width:20px;height:20px;background-color:#fff;border-radius:3px;content:'';-webkit-transform:rotate(-45deg);transform:rotate(-45deg)}.platform-ios .popover-bottom .popover-arrow{top:auto;bottom:-10px}.platform-ios .popover-bottom .popover-arrow:after{top:-6px}.platform-android .popover{margin-top:-32px;background-color:#fafafa;box-shadow:0 2px 6px rgba(0,0,0,.35)}.platform-android .popover .item{border-color:#fafafa;background-color:#fafafa;color:#4d4d4d}.platform-android .popover.popover-bottom{margin-top:32px}.platform-android .popover-backdrop,.platform-android .popover-backdrop.active{background-color:transparent}.popover-open{pointer-events:none}.popover-open .popover,.popover-open .popover-backdrop{pointer-events:auto}.popover-open.loading-active .popover,.popover-open.loading-active .popover-backdrop{pointer-events:none}@media (min-width:680px){.popover{width:360px;margin-left:-180px}}.popup-container{position:absolute;top:0;left:0;bottom:0;right:0;background:0 0;display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-pack:center;-ms-flex-pack:center;-webkit-justify-content:center;-moz-justify-content:center;justify-content:center;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center;z-index:12;visibility:hidden}.popup-container.popup-showing{visibility:visible}.popup-container.popup-hidden .popup{-webkit-animation-name:scaleOut;animation-name:scaleOut;-webkit-animation-duration:.1s;animation-duration:.1s;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out;-webkit-animation-fill-mode:both;animation-fill-mode:both}.popup-container.active .popup{-webkit-animation-name:superScaleIn;animation-name:superScaleIn;-webkit-animation-duration:.2s;animation-duration:.2s;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out;-webkit-animation-fill-mode:both;animation-fill-mode:both}.popup-container .popup{width:250px;max-width:100%;max-height:90%;border-radius:0;background-color:rgba(255,255,255,.9);display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-direction:normal;-webkit-box-orient:vertical;-webkit-flex-direction:column;-moz-flex-direction:column;-ms-flex-direction:column;flex-direction:column}.popup-container input,.popup-container textarea{width:100%}.popup-head{padding:15px 10px;border-bottom:1px solid #eee;text-align:center}.popup-title{margin:0;padding:0;font-size:15px}.popup-sub-title{margin:5px 0 0 0;padding:0;font-weight:400;font-size:11px}.popup-body{padding:10px;overflow:auto}.popup-buttons{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-direction:normal;-webkit-box-orient:horizontal;-webkit-flex-direction:row;-moz-flex-direction:row;-ms-flex-direction:row;flex-direction:row;padding:10px;min-height:65px}.popup-buttons .button{-webkit-box-flex:1;-webkit-flex:1;-moz-box-flex:1;-moz-flex:1;-ms-flex:1;flex:1;display:block;min-height:45px;border-radius:2px;line-height:20px;margin-right:5px}.popup-buttons .button:last-child{margin-right:0}.popup-open,.popup-open.modal-open .modal{pointer-events:none}.popup-open .popup,.popup-open .popup-backdrop{pointer-events:auto}.loading-container{position:absolute;left:0;top:0;right:0;bottom:0;z-index:13;display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-pack:center;-ms-flex-pack:center;-webkit-justify-content:center;-moz-justify-content:center;justify-content:center;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center;-webkit-transition:.2s opacity linear;transition:.2s opacity linear;visibility:hidden;opacity:0}.loading-container:not(.visible) .icon,.loading-container:not(.visible) .spinner{display:none}.loading-container.visible{visibility:visible}.loading-container.active{opacity:1}.loading-container .loading{padding:20px;border-radius:5px;background-color:rgba(0,0,0,.7);color:#fff;text-align:center;text-overflow:ellipsis;font-size:15px}.loading-container .loading h1,.loading-container .loading h2,.loading-container .loading h3,.loading-container .loading h4,.loading-container .loading h5,.loading-container .loading h6{color:#fff}.item{border-color:#ddd;background-color:#fff;color:#444;position:relative;z-index:2;display:block;margin:-1px;padding:16px;border-width:1px;border-style:solid;font-size:16px}.item h2{margin:0 0 2px 0;font-size:16px;font-weight:400}.item h3{margin:0 0 4px 0;font-size:14px}.item h4{margin:0 0 4px 0;font-size:12px}.item h5,.item h6{margin:0 0 3px 0;font-size:10px}.item p{color:#666;font-size:14px;margin-bottom:2px}.item h1:last-child,.item h2:last-child,.item h3:last-child,.item h4:last-child,.item h5:last-child,.item h6:last-child,.item p:last-child{margin-bottom:0}.item .badge{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;position:absolute;top:16px;right:32px}.item.item-button-right .badge{right:67px}.item.item-divider .badge{top:8px}.item .badge+.badge{margin-right:5px}.item.item-light{border-color:#ddd;background-color:#fff;color:#444}.item.item-stable{border-color:#b2b2b2;background-color:#f8f8f8;color:#444}.item.item-positive{border-color:#0c60ee;background-color:#387ef5;color:#fff}.item.item-calm{border-color:#0a9dc7;background-color:#11c1f3;color:#fff}.item.item-assertive{border-color:#e42112;background-color:#ef473a;color:#fff}.item.item-balanced{border-color:#28a54c;background-color:#33cd5f;color:#fff}.item.item-energized{border-color:#e6b500;background-color:#ffc900;color:#fff}.item.item-royal{border-color:#6b46e5;background-color:#886aea;color:#fff}.item.item-dark{border-color:#111;background-color:#444;color:#fff}.item[ng-click]:hover{cursor:pointer}.item-borderless,.list-borderless .item{border-width:0}.item .item-content.activated,.item .item-content.activated.item-complex>.item-content,.item .item-content.active,.item .item-content.active.item-complex>.item-content,.item-complex.activated .item-content,.item-complex.activated .item-content.item-complex>.item-content,.item-complex.active .item-content,.item-complex.active .item-content.item-complex>.item-content,.item.activated,.item.activated.item-complex>.item-content,.item.active,.item.active.item-complex>.item-content{border-color:#ccc;background-color:#D9D9D9}.item .item-content.activated.item-light,.item .item-content.activated.item-light.item-complex>.item-content,.item .item-content.active.item-light,.item .item-content.active.item-light.item-complex>.item-content,.item-complex.activated .item-content.item-light,.item-complex.activated .item-content.item-light.item-complex>.item-content,.item-complex.active .item-content.item-light,.item-complex.active .item-content.item-light.item-complex>.item-content,.item.activated.item-light,.item.activated.item-light.item-complex>.item-content,.item.active.item-light,.item.active.item-light.item-complex>.item-content{border-color:#ccc;background-color:#fafafa}.item .item-content.activated.item-stable,.item .item-content.activated.item-stable.item-complex>.item-content,.item .item-content.active.item-stable,.item .item-content.active.item-stable.item-complex>.item-content,.item-complex.activated .item-content.item-stable,.item-complex.activated .item-content.item-stable.item-complex>.item-content,.item-complex.active .item-content.item-stable,.item-complex.active .item-content.item-stable.item-complex>.item-content,.item.activated.item-stable,.item.activated.item-stable.item-complex>.item-content,.item.active.item-stable,.item.active.item-stable.item-complex>.item-content{border-color:#a2a2a2;background-color:#e5e5e5}.item .item-content.activated.item-positive,.item .item-content.activated.item-positive.item-complex>.item-content,.item .item-content.active.item-positive,.item .item-content.active.item-positive.item-complex>.item-content,.item-complex.activated .item-content.item-positive,.item-complex.activated .item-content.item-positive.item-complex>.item-content,.item-complex.active .item-content.item-positive,.item-complex.active .item-content.item-positive.item-complex>.item-content,.item.activated.item-positive,.item.activated.item-positive.item-complex>.item-content,.item.active.item-positive,.item.active.item-positive.item-complex>.item-content{border-color:#0c60ee;background-color:#0c60ee}.item .item-content.activated.item-calm,.item .item-content.activated.item-calm.item-complex>.item-content,.item .item-content.active.item-calm,.item .item-content.active.item-calm.item-complex>.item-content,.item-complex.activated .item-content.item-calm,.item-complex.activated .item-content.item-calm.item-complex>.item-content,.item-complex.active .item-content.item-calm,.item-complex.active .item-content.item-calm.item-complex>.item-content,.item.activated.item-calm,.item.activated.item-calm.item-complex>.item-content,.item.active.item-calm,.item.active.item-calm.item-complex>.item-content{border-color:#0a9dc7;background-color:#0a9dc7}.item .item-content.activated.item-assertive,.item .item-content.activated.item-assertive.item-complex>.item-content,.item .item-content.active.item-assertive,.item .item-content.active.item-assertive.item-complex>.item-content,.item-complex.activated .item-content.item-assertive,.item-complex.activated .item-content.item-assertive.item-complex>.item-content,.item-complex.active .item-content.item-assertive,.item-complex.active .item-content.item-assertive.item-complex>.item-content,.item.activated.item-assertive,.item.activated.item-assertive.item-complex>.item-content,.item.active.item-assertive,.item.active.item-assertive.item-complex>.item-content{border-color:#e42112;background-color:#e42112}.item .item-content.activated.item-balanced,.item .item-content.activated.item-balanced.item-complex>.item-content,.item .item-content.active.item-balanced,.item .item-content.active.item-balanced.item-complex>.item-content,.item-complex.activated .item-content.item-balanced,.item-complex.activated .item-content.item-balanced.item-complex>.item-content,.item-complex.active .item-content.item-balanced,.item-complex.active .item-content.item-balanced.item-complex>.item-content,.item.activated.item-balanced,.item.activated.item-balanced.item-complex>.item-content,.item.active.item-balanced,.item.active.item-balanced.item-complex>.item-content{border-color:#28a54c;background-color:#28a54c}.item .item-content.activated.item-energized,.item .item-content.activated.item-energized.item-complex>.item-content,.item .item-content.active.item-energized,.item .item-content.active.item-energized.item-complex>.item-content,.item-complex.activated .item-content.item-energized,.item-complex.activated .item-content.item-energized.item-complex>.item-content,.item-complex.active .item-content.item-energized,.item-complex.active .item-content.item-energized.item-complex>.item-content,.item.activated.item-energized,.item.activated.item-energized.item-complex>.item-content,.item.active.item-energized,.item.active.item-energized.item-complex>.item-content{border-color:#e6b500;background-color:#e6b500}.item .item-content.activated.item-royal,.item .item-content.activated.item-royal.item-complex>.item-content,.item .item-content.active.item-royal,.item .item-content.active.item-royal.item-complex>.item-content,.item-complex.activated .item-content.item-royal,.item-complex.activated .item-content.item-royal.item-complex>.item-content,.item-complex.active .item-content.item-royal,.item-complex.active .item-content.item-royal.item-complex>.item-content,.item.activated.item-royal,.item.activated.item-royal.item-complex>.item-content,.item.active.item-royal,.item.active.item-royal.item-complex>.item-content{border-color:#6b46e5;background-color:#6b46e5}.item .item-content.activated.item-dark,.item .item-content.activated.item-dark.item-complex>.item-content,.item .item-content.active.item-dark,.item .item-content.active.item-dark.item-complex>.item-content,.item-complex.activated .item-content.item-dark,.item-complex.activated .item-content.item-dark.item-complex>.item-content,.item-complex.active .item-content.item-dark,.item-complex.active .item-content.item-dark.item-complex>.item-content,.item.activated.item-dark,.item.activated.item-dark.item-complex>.item-content,.item.active.item-dark,.item.active.item-dark.item-complex>.item-content{border-color:#000;background-color:#262626}.item,.item h1,.item h2,.item h3,.item h4,.item h5,.item h6,.item p,.item-content,.item-content h1,.item-content h2,.item-content h3,.item-content h4,.item-content h5,.item-content h6,.item-content p{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}a.item{color:inherit;text-decoration:none}a.item:focus,a.item:hover{text-decoration:none}.item-complex,a.item.item-complex,button.item.item-complex{padding:0}.item-complex .item-content,.item-radio .item-content{position:relative;z-index:2;padding:16px 49px 16px 16px;border:none;background-color:#fff}a.item-content{display:block;color:inherit;text-decoration:none}.item-body h1,.item-body h2,.item-body h3,.item-body h4,.item-body h5,.item-body h6,.item-body p,.item-complex.item-text-wrap,.item-complex.item-text-wrap .item-content,.item-complex.item-text-wrap h1,.item-complex.item-text-wrap h2,.item-complex.item-text-wrap h3,.item-complex.item-text-wrap h4,.item-complex.item-text-wrap h5,.item-complex.item-text-wrap h6,.item-complex.item-text-wrap p,.item-text-wrap,.item-text-wrap .item,.item-text-wrap .item-content,.item-text-wrap h1,.item-text-wrap h2,.item-text-wrap h3,.item-text-wrap h4,.item-text-wrap h5,.item-text-wrap h6,.item-text-wrap p{overflow:visible;white-space:normal}.item-complex.item-light>.item-content{border-color:#ddd;background-color:#fff;color:#444}.item-complex.item-light>.item-content.active,.item-complex.item-light>.item-content.active.item-complex>.item-content,.item-complex.item-light>.item-content:active,.item-complex.item-light>.item-content:active.item-complex>.item-content{border-color:#ccc;background-color:#fafafa}.item-complex.item-stable>.item-content{border-color:#b2b2b2;background-color:#f8f8f8;color:#444}.item-complex.item-stable>.item-content.active,.item-complex.item-stable>.item-content.active.item-complex>.item-content,.item-complex.item-stable>.item-content:active,.item-complex.item-stable>.item-content:active.item-complex>.item-content{border-color:#a2a2a2;background-color:#e5e5e5}.item-complex.item-positive>.item-content{border-color:#0c60ee;background-color:#387ef5;color:#fff}.item-complex.item-positive>.item-content.active,.item-complex.item-positive>.item-content.active.item-complex>.item-content,.item-complex.item-positive>.item-content:active,.item-complex.item-positive>.item-content:active.item-complex>.item-content{border-color:#0c60ee;background-color:#0c60ee}.item-complex.item-calm>.item-content{border-color:#0a9dc7;background-color:#11c1f3;color:#fff}.item-complex.item-calm>.item-content.active,.item-complex.item-calm>.item-content.active.item-complex>.item-content,.item-complex.item-calm>.item-content:active,.item-complex.item-calm>.item-content:active.item-complex>.item-content{border-color:#0a9dc7;background-color:#0a9dc7}.item-complex.item-assertive>.item-content{border-color:#e42112;background-color:#ef473a;color:#fff}.item-complex.item-assertive>.item-content.active,.item-complex.item-assertive>.item-content.active.item-complex>.item-content,.item-complex.item-assertive>.item-content:active,.item-complex.item-assertive>.item-content:active.item-complex>.item-content{border-color:#e42112;background-color:#e42112}.item-complex.item-balanced>.item-content{border-color:#28a54c;background-color:#33cd5f;color:#fff}.item-complex.item-balanced>.item-content.active,.item-complex.item-balanced>.item-content.active.item-complex>.item-content,.item-complex.item-balanced>.item-content:active,.item-complex.item-balanced>.item-content:active.item-complex>.item-content{border-color:#28a54c;background-color:#28a54c}.item-complex.item-energized>.item-content{border-color:#e6b500;background-color:#ffc900;color:#fff}.item-complex.item-energized>.item-content.active,.item-complex.item-energized>.item-content.active.item-complex>.item-content,.item-complex.item-energized>.item-content:active,.item-complex.item-energized>.item-content:active.item-complex>.item-content{border-color:#e6b500;background-color:#e6b500}.item-complex.item-royal>.item-content{border-color:#6b46e5;background-color:#886aea;color:#fff}.item-complex.item-royal>.item-content.active,.item-complex.item-royal>.item-content.active.item-complex>.item-content,.item-complex.item-royal>.item-content:active,.item-complex.item-royal>.item-content:active.item-complex>.item-content{border-color:#6b46e5;background-color:#6b46e5}.item-complex.item-dark>.item-content{border-color:#111;background-color:#444;color:#fff}.item-complex.item-dark>.item-content.active,.item-complex.item-dark>.item-content.active.item-complex>.item-content,.item-complex.item-dark>.item-content:active,.item-complex.item-dark>.item-content:active.item-complex>.item-content{border-color:#000;background-color:#262626}.item-icon-left .icon,.item-icon-right .icon{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center;position:absolute;top:0;height:100%;font-size:32px}.item-icon-left .icon:before,.item-icon-right .icon:before{display:block;width:32px;text-align:center}.item .fill-icon{min-width:30px;min-height:30px;font-size:28px}.item-icon-left{padding-left:54px}.item-icon-left .icon{left:11px}.item-complex.item-icon-left{padding-left:0}.item-complex.item-icon-left .item-content{padding-left:54px}.item-icon-right{padding-right:54px}.item-icon-right .icon{right:11px}.item-complex.item-icon-right{padding-right:0}.item-complex.item-icon-right .item-content{padding-right:54px}.item-icon-left.item-icon-right .icon:first-child{right:auto}.item-icon-left .item-delete .icon,.item-icon-left.item-icon-right .icon:last-child{left:auto}.item-icon-left .icon-accessory,.item-icon-right .icon-accessory{color:#ccc;font-size:16px}.item-icon-left .icon-accessory{left:3px}.item-icon-right .icon-accessory{right:3px}.item-button-left{padding-left:72px}.item-button-left .item-content>.button,.item-button-left>.button{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center;position:absolute;top:8px;left:11px;min-width:34px;min-height:34px;font-size:18px;line-height:32px}.item-button-left .item-content>.button .icon:before,.item-button-left>.button .icon:before{position:relative;left:auto;width:auto;line-height:31px}.item-button-left .item-content>.button>.button,.item-button-left>.button>.button{margin:0 2px;min-height:34px;font-size:18px;line-height:32px}.item-button-right,a.item.item-button-right,button.item.item-button-right{padding-right:80px}.item-button-right .item-content>.button,.item-button-right .item-content>.buttons,.item-button-right>.button,.item-button-right>.buttons{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center;position:absolute;top:8px;right:16px;min-width:34px;min-height:34px;font-size:18px;line-height:32px}.item-button-right .item-content>.button .icon:before,.item-button-right .item-content>.buttons .icon:before,.item-button-right>.button .icon:before,.item-button-right>.buttons .icon:before{position:relative;left:auto;width:auto;line-height:31px}.item-button-right .item-content>.button>.button,.item-button-right .item-content>.buttons>.button,.item-button-right>.button>.button,.item-button-right>.buttons>.button{margin:0 2px;min-width:34px;min-height:34px;font-size:18px;line-height:32px}.item-button-left.item-button-right .button:first-child{right:auto}.item-button-left.item-button-right .button:last-child{left:auto}.item-avatar,.item-avatar .item-content,.item-avatar-left,.item-avatar-left .item-content{padding-left:72px;min-height:72px}.item-avatar .item-content .item-image,.item-avatar .item-content>img:first-child,.item-avatar .item-image,.item-avatar-left .item-content .item-image,.item-avatar-left .item-content>img:first-child,.item-avatar-left .item-image,.item-avatar-left>img:first-child,.item-avatar>img:first-child{position:absolute;top:16px;left:16px;max-width:40px;max-height:40px;width:100%;height:100%;border-radius:50%}.item-avatar-right,.item-avatar-right .item-content{padding-right:72px;min-height:72px}.item-avatar-right .item-content .item-image,.item-avatar-right .item-content>img:first-child,.item-avatar-right .item-image,.item-avatar-right>img:first-child{position:absolute;top:16px;right:16px;max-width:40px;max-height:40px;width:100%;height:100%;border-radius:50%}.item-thumbnail-left,.item-thumbnail-left .item-content{padding-top:8px;padding-left:106px;min-height:100px}.item-thumbnail-left .item-content .item-image,.item-thumbnail-left .item-content>img:first-child,.item-thumbnail-left .item-image,.item-thumbnail-left>img:first-child{position:absolute;top:10px;left:10px;max-width:80px;max-height:80px;width:100%;height:100%}.item-avatar-left.item-complex,.item-avatar.item-complex,.item-thumbnail-left.item-complex{padding-top:0;padding-left:0}.item-thumbnail-right,.item-thumbnail-right .item-content{padding-top:8px;padding-right:106px;min-height:100px}.item-thumbnail-right .item-content .item-image,.item-thumbnail-right .item-content>img:first-child,.item-thumbnail-right .item-image,.item-thumbnail-right>img:first-child{position:absolute;top:10px;right:10px;max-width:80px;max-height:80px;width:100%;height:100%}.item-avatar-right.item-complex,.item-thumbnail-right.item-complex{padding-top:0;padding-right:0}.item-image{padding:0;text-align:center}.item-image .list-img,.item-image img:first-child{width:100%;vertical-align:middle}.item-body{overflow:auto;padding:16px;text-overflow:inherit;white-space:normal}.item-body h1,.item-body h2,.item-body h3,.item-body h4,.item-body h5,.item-body h6,.item-body p{margin-top:16px;margin-bottom:16px}.item-divider{padding-top:8px;padding-bottom:8px;min-height:30px;background-color:#f5f5f5;color:#222;font-weight:500}.item-divider-ios,.platform-ios .item-divider-platform{padding-top:26px;text-transform:uppercase;font-weight:300;font-size:13px;background-color:#efeff4;color:#555}.item-divider-android,.platform-android .item-divider-platform{font-weight:300;font-size:13px}.item-note{float:right;color:#aaa;font-size:14px}.item-left-editable .item-content,.item-right-editable .item-content{-webkit-transition-duration:250ms;transition-duration:250ms;-webkit-transition-timing-function:ease-in-out;transition-timing-function:ease-in-out;-webkit-transition-property:-webkit-transform;-moz-transition-property:-moz-transform;transition-property:transform}.item-left-editing.item-left-editable .item-content,.list-left-editing .item-left-editable .item-content{-webkit-transform:translate3d(50px,0,0);transform:translate3d(50px,0,0)}.item-remove-animate.ng-leave{-webkit-transition-duration:300ms;transition-duration:300ms}.item-remove-animate.ng-leave .item-content,.item-remove-animate.ng-leave:last-of-type{-webkit-transition-duration:300ms;transition-duration:300ms;-webkit-transition-timing-function:ease-in;transition-timing-function:ease-in;-webkit-transition-property:all;transition-property:all}.item-remove-animate.ng-leave.ng-leave-active .item-content{opacity:0;-webkit-transform:translate3d(-100%,0,0)!important;transform:translate3d(-100%,0,0)!important}.item-remove-animate.ng-leave.ng-leave-active:last-of-type{opacity:0}.item-remove-animate.ng-leave.ng-leave-active~ion-item:not(.ng-leave){-webkit-transform:translate3d(0,-webkit-calc(-100% + 1px),0);transform:translate3d(0,calc(-100% + 1px),0);-webkit-transition-duration:300ms;transition-duration:300ms;-webkit-transition-timing-function:cubic-bezier(.25,.81,.24,1);transition-timing-function:cubic-bezier(.25,.81,.24,1);-webkit-transition-property:all;transition-property:all}.item-left-edit{-webkit-transition:all ease-in-out 125ms;transition:all ease-in-out 125ms;position:absolute;top:0;left:0;z-index:0;width:50px;height:100%;line-height:100%;display:none;opacity:0;-webkit-transform:translate3d(-21px,0,0);transform:translate3d(-21px,0,0)}.item-left-edit .button{height:100%}.item-left-edit .button.icon{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center;position:absolute;top:0;height:100%}.item-left-edit.visible{display:block}.item-left-edit.visible.active{opacity:1;-webkit-transform:translate3d(8px,0,0);transform:translate3d(8px,0,0)}.list-left-editing .item-left-edit{-webkit-transition-delay:125ms;transition-delay:125ms}.item-delete .button.icon{color:#ef473a;font-size:24px}.item-delete .button.icon:hover{opacity:.7}.item-right-edit{-webkit-transition:all ease-in-out 250ms;transition:all ease-in-out 250ms;position:absolute;top:0;right:0;z-index:3;width:75px;height:100%;background:inherit;padding-left:20px;display:block;opacity:0;-webkit-transform:translate3d(75px,0,0);transform:translate3d(75px,0,0)}.item-right-edit .button{min-width:50px;height:100%}.item-right-edit .button.icon{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center;position:absolute;top:0;height:100%;font-size:32px}.item-right-edit.visible{display:block}.item-right-edit.visible.active{opacity:1;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.item-reorder .button.icon{color:#444;font-size:32px}.item-reordering{position:absolute;left:0;top:0;z-index:9;width:100%;box-shadow:0 0 10px 0 #aaa}.item-reordering .item-reorder{z-index:9}.item-placeholder{opacity:.7}.item-options{position:absolute;top:0;right:0;z-index:1;height:100%}.item-options .button{height:100%;border:none;border-radius:0;display:-webkit-inline-box;display:-webkit-inline-flex;display:-moz-inline-flex;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center}.item-options .button:before{margin:0 auto}.list{position:relative;padding-top:1px;padding-bottom:1px;padding-left:0;margin-bottom:20px}.list:last-child{margin-bottom:0}.list:last-child.card{margin-bottom:40px}.list-header{margin-top:20px;padding:5px 15px;background-color:transparent;color:#222;font-weight:700}.card.list .list-item{padding-right:1px;padding-left:1px}.card,.list-inset{overflow:hidden;margin:20px 10px;border-radius:2px;background-color:#fff}.card{padding-top:1px;padding-bottom:1px;box-shadow:0 1px 3px rgba(0,0,0,.3)}.card .item{border-left:0;border-right:0}.card .item:first-child{border-top:0}.card .item:last-child{border-bottom:0}.padding .card,.padding .list-inset{margin-left:0;margin-right:0}.card .item:first-child,.card .item:first-child .item-content,.list-inset .item:first-child,.list-inset .item:first-child .item-content,.padding>.list .item:first-child,.padding>.list .item:first-child .item-content{border-top-left-radius:2px;border-top-right-radius:2px}.card .item:last-child,.card .item:last-child .item-content,.list-inset .item:last-child,.list-inset .item:last-child .item-content,.padding>.list .item:last-child,.padding>.list .item:last-child .item-content{border-bottom-right-radius:2px;border-bottom-left-radius:2px}.card .item:last-child,.list-inset .item:last-child{margin-bottom:-1px}.card .item,.list-inset .item,.padding-horizontal>.list .item,.padding>.list .item{margin-right:0;margin-left:0}.card .item.item-input input,.list-inset .item.item-input input,.padding-horizontal>.list .item.item-input input,.padding>.list .item.item-input input{padding-right:44px}.padding-left>.list .item{margin-left:0}.padding-right>.list .item{margin-right:0}.badge{background-color:transparent;color:#AAA;z-index:1;display:inline-block;padding:3px 8px;min-width:10px;border-radius:10px;vertical-align:baseline;text-align:center;white-space:nowrap;font-weight:700;font-size:14px;line-height:16px}.badge:empty{display:none}.badge.badge-light,.tabs .tab-item .badge.badge-light{background-color:#fff;color:#444}.badge.badge-stable,.tabs .tab-item .badge.badge-stable{background-color:#f8f8f8;color:#444}.badge.badge-positive,.tabs .tab-item .badge.badge-positive{background-color:#387ef5;color:#fff}.badge.badge-calm,.tabs .tab-item .badge.badge-calm{background-color:#11c1f3;color:#fff}.badge.badge-assertive,.tabs .tab-item .badge.badge-assertive{background-color:#ef473a;color:#fff}.badge.badge-balanced,.tabs .tab-item .badge.badge-balanced{background-color:#33cd5f;color:#fff}.badge.badge-energized,.tabs .tab-item .badge.badge-energized{background-color:#ffc900;color:#fff}.badge.badge-royal,.tabs .tab-item .badge.badge-royal{background-color:#886aea;color:#fff}.badge.badge-dark,.tabs .tab-item .badge.badge-dark{background-color:#444;color:#fff}.button .badge{position:relative;top:-1px}.slider{position:relative;visibility:hidden;overflow:hidden}.slider-slides{position:relative;height:100%}.slider-slide{position:relative;display:block;float:left;width:100%;height:100%;vertical-align:top}.slider-slide-image>img{width:100%}.slider-pager{position:absolute;bottom:20px;z-index:1;width:100%;height:15px;text-align:center}.slider-pager .slider-pager-page{display:inline-block;margin:0 3px;width:15px;color:#000;text-decoration:none;opacity:.3}.slider-pager .slider-pager-page.active{-webkit-transition:opacity .4s ease-in;transition:opacity .4s ease-in;opacity:1}.slider-pager-page.ng-animate,.slider-pager-page.ng-enter,.slider-pager-page.ng-leave,.slider-slide.ng-animate,.slider-slide.ng-enter,.slider-slide.ng-leave{-webkit-transition:none!important;transition:none!important}.slider-pager-page.ng-animate,.slider-slide.ng-animate{-webkit-animation:none 0s;animation:none 0s}.swiper-container{margin:0 auto;position:relative;z-index:1}.swiper-container-no-flexbox .swiper-slide{float:left}.swiper-container-vertical>.swiper-wrapper{-webkit-box-orient:vertical;-moz-box-orient:vertical;-ms-flex-direction:column;-webkit-flex-direction:column;flex-direction:column}.swiper-wrapper{z-index:1;display:-webkit-box;display:-moz-box;display:-ms-flexbox;display:-webkit-flex;display:flex;-webkit-transition-property:-webkit-transform;-moz-transition-property:-moz-transform;-o-transition-property:-o-transform;-ms-transition-property:-ms-transform;transition-property:transform;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}.swiper-container-android .swiper-slide,.swiper-wrapper{-webkit-transform:translate3d(0,0,0);-moz-transform:translate3d(0,0,0);-o-transform:translate(0,0);-ms-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.swiper-container-multirow>.swiper-wrapper{-webkit-box-lines:multiple;-moz-box-lines:multiple;-ms-flex-wrap:wrap;-webkit-flex-wrap:wrap;flex-wrap:wrap}.swiper-container-free-mode>.swiper-wrapper{-webkit-transition-timing-function:ease-out;-moz-transition-timing-function:ease-out;-ms-transition-timing-function:ease-out;-o-transition-timing-function:ease-out;transition-timing-function:ease-out;margin:0 auto}.swiper-slide{display:block;-webkit-flex-shrink:0;-ms-flex:0 0 auto;flex-shrink:0;position:relative}.swiper-container-autoheight,.swiper-container-autoheight .swiper-slide{height:auto}.swiper-container-autoheight .swiper-wrapper{-webkit-box-align:start;-ms-flex-align:start;-webkit-align-items:flex-start;align-items:flex-start;-webkit-transition-property:-webkit-transform,height;-moz-transition-property:-moz-transform;-o-transition-property:-o-transform;-ms-transition-property:-ms-transform;transition-property:transform,height}.swiper-container .swiper-notification{position:absolute;left:0;top:0;pointer-events:none;opacity:0;z-index:-1000}.swiper-wp8-horizontal{-ms-touch-action:pan-y;touch-action:pan-y}.swiper-wp8-vertical{-ms-touch-action:pan-x;touch-action:pan-x}.swiper-button-next,.swiper-button-prev{position:absolute;top:50%;width:27px;height:44px;margin-top:-22px;z-index:10;cursor:pointer;-moz-background-size:27px 44px;-webkit-background-size:27px 44px;background-size:27px 44px;background-position:center;background-repeat:no-repeat}.swiper-button-next.swiper-button-disabled,.swiper-button-prev.swiper-button-disabled{opacity:.35;cursor:auto;pointer-events:none}.swiper-button-prev,.swiper-container-rtl .swiper-button-next{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M0%2C22L22%2C0l2.1%2C2.1L4.2%2C22l19.9%2C19.9L22%2C44L0%2C22L0%2C22L0%2C22z'%20fill%3D'%23007aff'%2F%3E%3C%2Fsvg%3E");left:10px;right:auto}.swiper-button-prev.swiper-button-black,.swiper-container-rtl .swiper-button-next.swiper-button-black{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M0%2C22L22%2C0l2.1%2C2.1L4.2%2C22l19.9%2C19.9L22%2C44L0%2C22L0%2C22L0%2C22z'%20fill%3D'%23000000'%2F%3E%3C%2Fsvg%3E")}.swiper-button-prev.swiper-button-white,.swiper-container-rtl .swiper-button-next.swiper-button-white{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M0%2C22L22%2C0l2.1%2C2.1L4.2%2C22l19.9%2C19.9L22%2C44L0%2C22L0%2C22L0%2C22z'%20fill%3D'%23ffffff'%2F%3E%3C%2Fsvg%3E")}.swiper-button-next,.swiper-container-rtl .swiper-button-prev{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M27%2C22L27%2C22L5%2C44l-2.1-2.1L22.8%2C22L2.9%2C2.1L5%2C0L27%2C22L27%2C22z'%20fill%3D'%23007aff'%2F%3E%3C%2Fsvg%3E");right:10px;left:auto}.swiper-button-next.swiper-button-black,.swiper-container-rtl .swiper-button-prev.swiper-button-black{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M27%2C22L27%2C22L5%2C44l-2.1-2.1L22.8%2C22L2.9%2C2.1L5%2C0L27%2C22L27%2C22z'%20fill%3D'%23000000'%2F%3E%3C%2Fsvg%3E")}.swiper-button-next.swiper-button-white,.swiper-container-rtl .swiper-button-prev.swiper-button-white{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M27%2C22L27%2C22L5%2C44l-2.1-2.1L22.8%2C22L2.9%2C2.1L5%2C0L27%2C22L27%2C22z'%20fill%3D'%23ffffff'%2F%3E%3C%2Fsvg%3E")}.swiper-pagination{position:absolute;text-align:center;-webkit-transition:300ms;-moz-transition:300ms;-o-transition:300ms;transition:300ms;-webkit-transform:translate3d(0,0,0);-ms-transform:translate3d(0,0,0);-o-transform:translate3d(0,0,0);transform:translate3d(0,0,0);z-index:10}.swiper-pagination.swiper-pagination-hidden{opacity:0}.swiper-pagination-bullet{width:8px;height:8px;display:inline-block;border-radius:100%;background:#000;opacity:.2}button.swiper-pagination-bullet{border:none;margin:0;padding:0;box-shadow:none;-moz-appearance:none;-ms-appearance:none;-webkit-appearance:none;appearance:none}.swiper-pagination-clickable .swiper-pagination-bullet{cursor:pointer}.swiper-pagination-white .swiper-pagination-bullet{background:#fff}.swiper-pagination-bullet-active{opacity:1}.swiper-pagination-white .swiper-pagination-bullet-active{background:#fff}.swiper-pagination-black .swiper-pagination-bullet-active{background:#000}.swiper-container-vertical>.swiper-pagination{right:10px;top:50%;-webkit-transform:translate3d(0,-50%,0);-moz-transform:translate3d(0,-50%,0);-o-transform:translate(0,-50%);-ms-transform:translate3d(0,-50%,0);transform:translate3d(0,-50%,0)}.swiper-container-vertical>.swiper-pagination .swiper-pagination-bullet{margin:5px 0;display:block}.swiper-container-horizontal>.swiper-pagination{bottom:10px;left:0;width:100%}.swiper-container-horizontal>.swiper-pagination .swiper-pagination-bullet{margin:0 5px}.swiper-container-3d{-webkit-perspective:1200px;-moz-perspective:1200px;-o-perspective:1200px;perspective:1200px}.swiper-container-3d .swiper-cube-shadow,.swiper-container-3d .swiper-slide,.swiper-container-3d .swiper-slide-shadow-bottom,.swiper-container-3d .swiper-slide-shadow-left,.swiper-container-3d .swiper-slide-shadow-right,.swiper-container-3d .swiper-slide-shadow-top,.swiper-container-3d .swiper-wrapper{-webkit-transform-style:preserve-3d;-moz-transform-style:preserve-3d;-ms-transform-style:preserve-3d;transform-style:preserve-3d}.swiper-container-3d .swiper-slide-shadow-bottom,.swiper-container-3d .swiper-slide-shadow-left,.swiper-container-3d .swiper-slide-shadow-right,.swiper-container-3d .swiper-slide-shadow-top{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none;z-index:10}.swiper-container-3d .swiper-slide-shadow-left{background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.5)),to(transparent));background-image:-webkit-linear-gradient(right,rgba(0,0,0,.5),transparent);background-image:-moz-linear-gradient(right,rgba(0,0,0,.5),transparent);background-image:-o-linear-gradient(right,rgba(0,0,0,.5),transparent);background-image:linear-gradient(to left,rgba(0,0,0,.5),transparent)}.swiper-container-3d .swiper-slide-shadow-right{background-image:-webkit-gradient(linear,right top,left top,from(rgba(0,0,0,.5)),to(transparent));background-image:-webkit-linear-gradient(left,rgba(0,0,0,.5),transparent);background-image:-moz-linear-gradient(left,rgba(0,0,0,.5),transparent);background-image:-o-linear-gradient(left,rgba(0,0,0,.5),transparent);background-image:linear-gradient(to right,rgba(0,0,0,.5),transparent)}.swiper-container-3d .swiper-slide-shadow-top{background-image:-webkit-gradient(linear,left top,left bottom,from(rgba(0,0,0,.5)),to(transparent));background-image:-webkit-linear-gradient(bottom,rgba(0,0,0,.5),transparent);background-image:-moz-linear-gradient(bottom,rgba(0,0,0,.5),transparent);background-image:-o-linear-gradient(bottom,rgba(0,0,0,.5),transparent);background-image:linear-gradient(to top,rgba(0,0,0,.5),transparent)}.swiper-container-3d .swiper-slide-shadow-bottom{background-image:-webkit-gradient(linear,left bottom,left top,from(rgba(0,0,0,.5)),to(transparent));background-image:-webkit-linear-gradient(top,rgba(0,0,0,.5),transparent);background-image:-moz-linear-gradient(top,rgba(0,0,0,.5),transparent);background-image:-o-linear-gradient(top,rgba(0,0,0,.5),transparent);background-image:linear-gradient(to bottom,rgba(0,0,0,.5),transparent)}.swiper-container-coverflow .swiper-wrapper{-ms-perspective:1200px}.swiper-container-fade.swiper-container-free-mode .swiper-slide{-webkit-transition-timing-function:ease-out;-moz-transition-timing-function:ease-out;-ms-transition-timing-function:ease-out;-o-transition-timing-function:ease-out;transition-timing-function:ease-out}.swiper-container-fade .swiper-slide,.swiper-container-fade .swiper-slide .swiper-slide{pointer-events:none}.swiper-container-fade .swiper-slide-active,.swiper-container-fade .swiper-slide-active .swiper-slide-active{pointer-events:auto}.swiper-container-cube{overflow:visible}.swiper-container-cube .swiper-slide{pointer-events:none;visibility:hidden;-webkit-transform-origin:0 0;-moz-transform-origin:0 0;-ms-transform-origin:0 0;transform-origin:0 0;-webkit-backface-visibility:hidden;-moz-backface-visibility:hidden;-ms-backface-visibility:hidden;backface-visibility:hidden;width:100%;height:100%;z-index:1}.swiper-container-cube.swiper-container-rtl .swiper-slide{-webkit-transform-origin:100% 0;-moz-transform-origin:100% 0;-ms-transform-origin:100% 0;transform-origin:100% 0}.swiper-container-cube .swiper-slide-active,.swiper-container-cube .swiper-slide-next,.swiper-container-cube .swiper-slide-next+.swiper-slide,.swiper-container-cube .swiper-slide-prev{pointer-events:auto;visibility:visible}.swiper-container-cube .swiper-slide-shadow-bottom,.swiper-container-cube .swiper-slide-shadow-left,.swiper-container-cube .swiper-slide-shadow-right,.swiper-container-cube .swiper-slide-shadow-top{z-index:0;-webkit-backface-visibility:hidden;-moz-backface-visibility:hidden;-ms-backface-visibility:hidden;backface-visibility:hidden}.swiper-container-cube .swiper-cube-shadow{position:absolute;left:0;bottom:0;width:100%;height:100%;background:#000;opacity:.6;-webkit-filter:blur(50px);filter:blur(50px);z-index:0}.swiper-scrollbar{border-radius:10px;position:relative;-ms-touch-action:none;background:rgba(0,0,0,.1)}.swiper-container-horizontal>.swiper-scrollbar{position:absolute;left:1%;bottom:3px;z-index:50;height:5px;width:98%}.swiper-container-vertical>.swiper-scrollbar{position:absolute;right:3px;top:1%;z-index:50;width:5px;height:98%}.swiper-scrollbar-drag{height:100%;width:100%;position:relative;background:rgba(0,0,0,.5);border-radius:10px;left:0;top:0}.swiper-scrollbar-cursor-drag{cursor:move}.swiper-lazy-preloader{width:42px;height:42px;position:absolute;left:50%;top:50%;margin-left:-21px;margin-top:-21px;z-index:10;-webkit-transform-origin:50%;-moz-transform-origin:50%;transform-origin:50%;-webkit-animation:swiper-preloader-spin 1s steps(12,end) infinite;-moz-animation:swiper-preloader-spin 1s steps(12,end) infinite;animation:swiper-preloader-spin 1s steps(12,end) infinite}.swiper-lazy-preloader:after{display:block;content:"";width:100%;height:100%;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg%20viewBox%3D'0%200%20120%20120'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20xmlns%3Axlink%3D'http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink'%3E%3Cdefs%3E%3Cline%20id%3D'l'%20x1%3D'60'%20x2%3D'60'%20y1%3D'7'%20y2%3D'27'%20stroke%3D'%236c6c6c'%20stroke-width%3D'11'%20stroke-linecap%3D'round'%2F%3E%3C%2Fdefs%3E%3Cg%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(30%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(60%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(90%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(120%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(150%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.37'%20transform%3D'rotate(180%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.46'%20transform%3D'rotate(210%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.56'%20transform%3D'rotate(240%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.66'%20transform%3D'rotate(270%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.75'%20transform%3D'rotate(300%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.85'%20transform%3D'rotate(330%2060%2C60)'%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");background-position:50%;-webkit-background-size:100%;background-size:100%;background-repeat:no-repeat}.swiper-lazy-preloader-white:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg%20viewBox%3D'0%200%20120%20120'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20xmlns%3Axlink%3D'http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink'%3E%3Cdefs%3E%3Cline%20id%3D'l'%20x1%3D'60'%20x2%3D'60'%20y1%3D'7'%20y2%3D'27'%20stroke%3D'%23fff'%20stroke-width%3D'11'%20stroke-linecap%3D'round'%2F%3E%3C%2Fdefs%3E%3Cg%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(30%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(60%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(90%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(120%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(150%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.37'%20transform%3D'rotate(180%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.46'%20transform%3D'rotate(210%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.56'%20transform%3D'rotate(240%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.66'%20transform%3D'rotate(270%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.75'%20transform%3D'rotate(300%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.85'%20transform%3D'rotate(330%2060%2C60)'%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")}@-webkit-keyframes swiper-preloader-spin{100%{-webkit-transform:rotate(360deg)}}@keyframes swiper-preloader-spin{100%{transform:rotate(360deg)}}ion-slides{width:100%;height:100%;display:block}.slide-zoom{display:block;width:100%;text-align:center}.swiper-container{width:100%;height:100%;padding:0;overflow:hidden}.swiper-wrapper{position:absolute;left:0;top:0;width:100%;height:100%;padding:0}.swiper-slide{width:100%;height:100%;box-sizing:border-box}.swiper-slide img{width:auto;height:auto;max-width:100%;max-height:100%}.scroll-refresher{position:absolute;top:-60px;right:0;left:0;overflow:hidden;margin:auto;height:60px}.scroll-refresher .ionic-refresher-content{position:absolute;bottom:15px;left:0;width:100%;color:#666;text-align:center;font-size:30px}.scroll-refresher .ionic-refresher-content .text-pulling,.scroll-refresher .ionic-refresher-content .text-refreshing{font-size:16px;line-height:16px}.scroll-refresher .ionic-refresher-content.ionic-refresher-with-text{bottom:10px}.scroll-refresher .icon-pulling,.scroll-refresher .icon-refreshing{width:100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-transform-style:preserve-3d;transform-style:preserve-3d}.scroll-refresher .icon-pulling{-webkit-animation-name:refresh-spin-back;animation-name:refresh-spin-back;-webkit-animation-duration:200ms;animation-duration:200ms;-webkit-animation-timing-function:linear;animation-timing-function:linear;-webkit-animation-fill-mode:none;animation-fill-mode:none;-webkit-transform:translate3d(0,0,0) rotate(0deg);transform:translate3d(0,0,0) rotate(0deg)}.scroll-refresher .icon-refreshing,.scroll-refresher .text-refreshing{display:none}.scroll-refresher .icon-refreshing{-webkit-animation-duration:1.5s;animation-duration:1.5s}.scroll-refresher.active .icon-pulling:not(.pulling-rotation-disabled){-webkit-animation-name:refresh-spin;animation-name:refresh-spin;-webkit-transform:translate3d(0,0,0) rotate(-180deg);transform:translate3d(0,0,0) rotate(-180deg)}.scroll-refresher.active.refreshing{-webkit-transition:transform .2s;transition:transform .2s;-webkit-transform:scale(1,1);transform:scale(1,1)}.scroll-refresher.active.refreshing .icon-pulling,.scroll-refresher.active.refreshing .text-pulling{display:none}.scroll-refresher.active.refreshing .icon-refreshing,.scroll-refresher.active.refreshing .text-refreshing{display:block}.scroll-refresher.active.refreshing.refreshing-tail{-webkit-transform:scale(0,0);transform:scale(0,0)}.overflow-scroll>.scroll{-webkit-overflow-scrolling:touch;width:100%}.overflow-scroll>.scroll.overscroll{position:fixed;right:0;left:0}.overflow-scroll.padding>.scroll.overscroll{padding:10px}@-webkit-keyframes refresh-spin{0%{-webkit-transform:translate3d(0,0,0) rotate(0)}100%{-webkit-transform:translate3d(0,0,0) rotate(180deg)}}@keyframes refresh-spin{0%{transform:translate3d(0,0,0) rotate(0)}100%{transform:translate3d(0,0,0) rotate(180deg)}}@-webkit-keyframes refresh-spin-back{0%{-webkit-transform:translate3d(0,0,0) rotate(180deg)}100%{-webkit-transform:translate3d(0,0,0) rotate(0)}}@keyframes refresh-spin-back{0%{transform:translate3d(0,0,0) rotate(180deg)}100%{transform:translate3d(0,0,0) rotate(0)}}.spinner{stroke:#444;fill:#444}.spinner svg{width:28px;height:28px}.spinner.spinner-light{stroke:#fff;fill:#fff}.spinner.spinner-stable{stroke:#f8f8f8;fill:#f8f8f8}.spinner.spinner-positive{stroke:#387ef5;fill:#387ef5}.spinner.spinner-calm{stroke:#11c1f3;fill:#11c1f3}.spinner.spinner-balanced{stroke:#33cd5f;fill:#33cd5f}.spinner.spinner-assertive{stroke:#ef473a;fill:#ef473a}.spinner.spinner-energized{stroke:#ffc900;fill:#ffc900}.spinner.spinner-royal{stroke:#886aea;fill:#886aea}.spinner.spinner-dark{stroke:#444;fill:#444}.spinner-android{stroke:#4b8bf4}.spinner-ios,.spinner-ios-small{stroke:#69717d}.spinner-spiral .stop1{stop-color:#fff;stop-opacity:0}.spinner-spiral.spinner-light .stop1{stop-color:#444}.spinner-spiral.spinner-light .stop2{stop-color:#fff}.spinner-spiral.spinner-stable .stop2{stop-color:#f8f8f8}.spinner-spiral.spinner-positive .stop2{stop-color:#387ef5}.spinner-spiral.spinner-calm .stop2{stop-color:#11c1f3}.spinner-spiral.spinner-balanced .stop2{stop-color:#33cd5f}.spinner-spiral.spinner-assertive .stop2{stop-color:#ef473a}.spinner-spiral.spinner-energized .stop2{stop-color:#ffc900}.spinner-spiral.spinner-royal .stop2{stop-color:#886aea}.spinner-spiral.spinner-dark .stop2{stop-color:#444}form{margin:0 0 1.42857}legend{display:block;margin-bottom:1.42857;padding:0;width:100%;border:1px solid #ddd;color:#444;font-size:21px;line-height:2.85714}legend small{color:#f8f8f8;font-size:1.07143}button,input,label,select,textarea{font-weight:400;font-size:14px;line-height:1.42857}button,input,select,textarea{font-family:"-apple-system","Helvetica Neue",Roboto,"Segoe UI",sans-serif}.item-input{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center;position:relative;overflow:hidden;padding:6px 0 5px 16px}.item-input input{-webkit-border-radius:0;border-radius:0;-webkit-box-flex:1;-webkit-flex:1 220px;-moz-box-flex:1;-moz-flex:1 220px;-ms-flex:1 220px;flex:1 220px;-webkit-appearance:none;-moz-appearance:none;appearance:none;margin:0;padding-right:24px;background-color:transparent}.item-input .button .icon{-webkit-box-flex:0;-webkit-flex:0 0 24px;-moz-box-flex:0;-moz-flex:0 0 24px;-ms-flex:0 0 24px;flex:0 0 24px;position:static;display:inline-block;height:auto;text-align:center;font-size:16px}.item-input .button-bar{-webkit-border-radius:0;border-radius:0;-webkit-box-flex:1;-webkit-flex:1 0 220px;-moz-box-flex:1;-moz-flex:1 0 220px;-ms-flex:1 0 220px;flex:1 0 220px;-webkit-appearance:none;-moz-appearance:none;appearance:none}.item-input .icon{min-width:14px}.platform-windowsphone .item-input input{flex-shrink:1}.item-input-inset{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center;position:relative;overflow:hidden;padding:10.67px}.item-input-wrapper{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-webkit-flex:1 0;-moz-box-flex:1;-moz-flex:1 0;-ms-flex:1 0;flex:1 0;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center;-webkit-border-radius:4px;border-radius:4px;padding-right:8px;padding-left:8px;background:#eee}.item-input-inset .item-input-wrapper input{padding-left:4px;height:29px;background:0 0;line-height:18px}.item-input-wrapper~.button{margin-left:10.67px}.input-label{display:table;padding:7px 10px 7px 0;max-width:200px;width:35%;color:#444;font-size:16px}.placeholder-icon{color:#aaa}.placeholder-icon:first-child{padding-right:6px}.placeholder-icon:last-child{padding-left:6px}.item-stacked-label{display:block;background-color:transparent;box-shadow:none}.item-stacked-label .icon,.item-stacked-label .input-label{display:inline-block;padding:4px 0 0 0;vertical-align:middle}.item-stacked-label input,.item-stacked-label textarea{-webkit-border-radius:2px;border-radius:2px;padding:4px 8px 3px 0;border:none;background-color:#fff}.item-stacked-label input{overflow:hidden;height:46px}.item-select.item-stacked-label select{position:relative;padding:0;max-width:90%;direction:ltr;white-space:pre-wrap;margin:-3px}.item-floating-label{display:block;background-color:transparent;box-shadow:none}.item-floating-label .input-label{position:relative;padding:5px 0 0 0;opacity:0;top:10px;-webkit-transition:opacity .15s ease-in,top .2s linear;transition:opacity .15s ease-in,top .2s linear}.item-floating-label .input-label.has-input{opacity:1;top:0;-webkit-transition:opacity .15s ease-in,top .2s linear;transition:opacity .15s ease-in,top .2s linear}input[type=search],input[type=text],input[type=password],input[type=datetime],input[type=datetime-local],input[type=date],input[type=month],input[type=time],input[type=week],input[type=number],input[type=email],input[type=url],input[type=tel],input[type=color],textarea{display:block;padding-top:2px;padding-left:0;height:34px;color:#111;vertical-align:middle;font-size:14px;line-height:16px}.platform-android input[type=datetime-local],.platform-android input[type=date],.platform-android input[type=month],.platform-android input[type=time],.platform-android input[type=week],.platform-ios input[type=datetime-local],.platform-ios input[type=date],.platform-ios input[type=month],.platform-ios input[type=time],.platform-ios input[type=week]{padding-top:8px}.item-input input,.item-input textarea{width:100%}textarea{padding-left:0}textarea::-moz-placeholder{color:#aaa}textarea:-ms-input-placeholder{color:#aaa}textarea::-webkit-input-placeholder{color:#aaa;text-indent:-3px}textarea{height:auto}input[type=search],input[type=text],input[type=password],input[type=datetime],input[type=datetime-local],input[type=date],input[type=month],input[type=time],input[type=week],input[type=number],input[type=email],input[type=url],input[type=tel],input[type=color],textarea{border:0}input[type=radio],input[type=checkbox]{margin:0;line-height:normal}.item-input input[type=button],.item-input input[type=reset],.item-input input[type=submit],.item-input input[type=radio],.item-input input[type=checkbox],.item-input input[type=file],.item-input input[type=image]{width:auto}input[type=file]{line-height:34px}.cloned-text-input+input,.cloned-text-input+textarea,.previous-input-focus{position:absolute!important;left:-9999px;width:200px}input::-moz-placeholder,textarea::-moz-placeholder{color:#aaa}input:-ms-input-placeholder,textarea:-ms-input-placeholder{color:#aaa}input::-webkit-input-placeholder,textarea::-webkit-input-placeholder{color:#aaa;text-indent:0}input[disabled],input[readonly]:not(.cloned-text-input),select[disabled],select[readonly],textarea[disabled],textarea[readonly]:not(.cloned-text-input){background-color:#f8f8f8;cursor:not-allowed}input[type=radio][disabled],input[type=radio][readonly],input[type=checkbox][disabled],input[type=checkbox][readonly]{background-color:transparent}.checkbox{position:relative;display:inline-block;padding:7px 7px;cursor:pointer}.checkbox .checkbox-icon:before,.checkbox input:before{border-color:#ddd}.checkbox input:checked+.checkbox-icon:before,.checkbox input:checked:before{background:#387ef5;border-color:#387ef5}.checkbox-light .checkbox-icon:before,.checkbox-light input:before{border-color:#ddd}.checkbox-light input:checked+.checkbox-icon:before,.checkbox-light input:checked:before{background:#ddd;border-color:#ddd}.checkbox-stable .checkbox-icon:before,.checkbox-stable input:before{border-color:#b2b2b2}.checkbox-stable input:checked+.checkbox-icon:before,.checkbox-stable input:checked:before{background:#b2b2b2;border-color:#b2b2b2}.checkbox-positive .checkbox-icon:before,.checkbox-positive input:before{border-color:#387ef5}.checkbox-positive input:checked+.checkbox-icon:before,.checkbox-positive input:checked:before{background:#387ef5;border-color:#387ef5}.checkbox-calm .checkbox-icon:before,.checkbox-calm input:before{border-color:#11c1f3}.checkbox-calm input:checked+.checkbox-icon:before,.checkbox-calm input:checked:before{background:#11c1f3;border-color:#11c1f3}.checkbox-assertive .checkbox-icon:before,.checkbox-assertive input:before{border-color:#ef473a}.checkbox-assertive input:checked+.checkbox-icon:before,.checkbox-assertive input:checked:before{background:#ef473a;border-color:#ef473a}.checkbox-balanced .checkbox-icon:before,.checkbox-balanced input:before{border-color:#33cd5f}.checkbox-balanced input:checked+.checkbox-icon:before,.checkbox-balanced input:checked:before{background:#33cd5f;border-color:#33cd5f}.checkbox-energized .checkbox-icon:before,.checkbox-energized input:before{border-color:#ffc900}.checkbox-energized input:checked+.checkbox-icon:before,.checkbox-energized input:checked:before{background:#ffc900;border-color:#ffc900}.checkbox-royal .checkbox-icon:before,.checkbox-royal input:before{border-color:#886aea}.checkbox-royal input:checked+.checkbox-icon:before,.checkbox-royal input:checked:before{background:#886aea;border-color:#886aea}.checkbox-dark .checkbox-icon:before,.checkbox-dark input:before{border-color:#444}.checkbox-dark input:checked+.checkbox-icon:before,.checkbox-dark input:checked:before{background:#444;border-color:#444}.checkbox input:disabled+.checkbox-icon:before,.checkbox input:disabled:before{border-color:#ddd}.checkbox input:disabled:checked+.checkbox-icon:before,.checkbox input:disabled:checked:before{background:#ddd}.checkbox.checkbox-input-hidden input{display:none!important}.checkbox input,.checkbox-icon{position:relative;width:28px;height:28px;display:block;border:0;background:0 0;cursor:pointer;-webkit-appearance:none}.checkbox input:before,.checkbox-icon:before{display:table;width:100%;height:100%;border-width:1px;border-style:solid;border-radius:28px;background:#fff;content:' ';-webkit-transition:background-color 20ms ease-in-out;transition:background-color 20ms ease-in-out}.checkbox input:checked:before,input:checked+.checkbox-icon:before{border-width:2px}.checkbox input:after,.checkbox-icon:after{-webkit-transition:opacity .05s ease-in-out;transition:opacity .05s ease-in-out;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);position:absolute;top:33%;left:25%;display:table;width:14px;height:6px;border:1px solid #fff;border-top:0;border-right:0;content:' ';opacity:0}.checkbox-square .checkbox-icon:before,.checkbox-square input:before,.platform-android .checkbox-platform .checkbox-icon:before,.platform-android .checkbox-platform input:before{border-radius:2px;width:72%;height:72%;margin-top:14%;margin-left:14%;border-width:2px}.checkbox-square .checkbox-icon:after,.checkbox-square input:after,.platform-android .checkbox-platform .checkbox-icon:after,.platform-android .checkbox-platform input:after{border-width:2px;top:19%;left:25%;width:13px;height:7px}.platform-android .item-checkbox-right .checkbox-square .checkbox-icon::after{top:31%}.grade-c .checkbox input:after,.grade-c .checkbox-icon:after{-webkit-transform:rotate(0);transform:rotate(0);top:3px;left:4px;border:none;color:#fff;content:'\2713';font-weight:700;font-size:20px}.checkbox input:checked:after,input:checked+.checkbox-icon:after{opacity:1}.item-checkbox{padding-left:60px}.item-checkbox.active{box-shadow:none}.item-checkbox .checkbox{position:absolute;top:50%;right:8px;left:8px;z-index:3;margin-top:-21px}.item-checkbox.item-checkbox-right{padding-right:60px;padding-left:16px}.item-checkbox-right .checkbox input,.item-checkbox-right .checkbox-icon{float:right}.item-toggle{pointer-events:none}.toggle{position:relative;display:inline-block;pointer-events:auto;margin:-5px;padding:5px}.toggle input:checked+.track{border-color:#4cd964;background-color:#4cd964}.toggle.dragging .handle{background-color:#f2f2f2!important}.toggle.toggle-light input:checked+.track{border-color:#ddd;background-color:#ddd}.toggle.toggle-stable input:checked+.track{border-color:#b2b2b2;background-color:#b2b2b2}.toggle.toggle-positive input:checked+.track{border-color:#387ef5;background-color:#387ef5}.toggle.toggle-calm input:checked+.track{border-color:#11c1f3;background-color:#11c1f3}.toggle.toggle-assertive input:checked+.track{border-color:#ef473a;background-color:#ef473a}.toggle.toggle-balanced input:checked+.track{border-color:#33cd5f;background-color:#33cd5f}.toggle.toggle-energized input:checked+.track{border-color:#ffc900;background-color:#ffc900}.toggle.toggle-royal input:checked+.track{border-color:#886aea;background-color:#886aea}.toggle.toggle-dark input:checked+.track{border-color:#444;background-color:#444}.toggle input{display:none}.toggle .track{-webkit-transition-timing-function:ease-in-out;transition-timing-function:ease-in-out;-webkit-transition-duration:.3s;transition-duration:.3s;-webkit-transition-property:background-color,border;transition-property:background-color,border;display:inline-block;box-sizing:border-box;width:51px;height:31px;border:solid 2px #e6e6e6;border-radius:20px;background-color:#fff;content:' ';cursor:pointer;pointer-events:none}.platform-android4_2 .toggle .track{-webkit-background-clip:padding-box}.toggle .handle{-webkit-transition:.3s cubic-bezier(0,1.1,1,1.1);transition:.3s cubic-bezier(0,1.1,1,1.1);-webkit-transition-property:background-color,transform;transition-property:background-color,transform;position:absolute;display:block;width:27px;height:27px;border-radius:27px;background-color:#fff;top:7px;left:7px;box-shadow:0 2px 7px rgba(0,0,0,.35),0 1px 1px rgba(0,0,0,.15)}.toggle .handle:before{position:absolute;top:-4px;left:-21.5px;padding:18.5px 34px;content:" "}.toggle input:checked+.track .handle{-webkit-transform:translate3d(20px,0,0);transform:translate3d(20px,0,0);background-color:#fff}.item-toggle.active{box-shadow:none}.item-toggle,.item-toggle.item-complex .item-content{padding-right:99px}.item-toggle.item-complex{padding-right:0}.item-toggle .toggle{position:absolute;top:10px;right:16px;z-index:3}.toggle input:disabled+.track{opacity:.6}.toggle-small .track{border:0;width:34px;height:15px;background:#9e9e9e}.toggle-small input:checked+.track{background:rgba(0,150,137,.5)}.toggle-small .handle{top:2px;left:4px;width:21px;height:21px;box-shadow:0 2px 5px rgba(0,0,0,.25)}.toggle-small input:checked+.track .handle{-webkit-transform:translate3d(16px,0,0);transform:translate3d(16px,0,0);background:#009689}.toggle-small.item-toggle .toggle{top:19px}.toggle-small .toggle-light input:checked+.track{background-color:rgba(221,221,221,.5)}.toggle-small .toggle-light input:checked+.track .handle{background-color:#ddd}.toggle-small .toggle-stable input:checked+.track{background-color:rgba(178,178,178,.5)}.toggle-small .toggle-stable input:checked+.track .handle{background-color:#b2b2b2}.toggle-small .toggle-positive input:checked+.track{background-color:rgba(56,126,245,.5)}.toggle-small .toggle-positive input:checked+.track .handle{background-color:#387ef5}.toggle-small .toggle-calm input:checked+.track{background-color:rgba(17,193,243,.5)}.toggle-small .toggle-calm input:checked+.track .handle{background-color:#11c1f3}.toggle-small .toggle-assertive input:checked+.track{background-color:rgba(239,71,58,.5)}.toggle-small .toggle-assertive input:checked+.track .handle{background-color:#ef473a}.toggle-small .toggle-balanced input:checked+.track{background-color:rgba(51,205,95,.5)}.toggle-small .toggle-balanced input:checked+.track .handle{background-color:#33cd5f}.toggle-small .toggle-energized input:checked+.track{background-color:rgba(255,201,0,.5)}.toggle-small .toggle-energized input:checked+.track .handle{background-color:#ffc900}.toggle-small .toggle-royal input:checked+.track{background-color:rgba(136,106,234,.5)}.toggle-small .toggle-royal input:checked+.track .handle{background-color:#886aea}.toggle-small .toggle-dark input:checked+.track{background-color:rgba(68,68,68,.5)}.toggle-small .toggle-dark input:checked+.track .handle{background-color:#444}.item-radio{padding:0}.item-radio:hover{cursor:pointer}.item-radio .item-content{padding-right:64px}.item-radio .radio-icon{position:absolute;top:0;right:0;z-index:3;visibility:hidden;padding:14px;height:100%;font-size:24px}.item-radio input{position:absolute;left:-9999px}.item-radio input:checked+.radio-content .item-content{background:#f7f7f7}.item-radio input:checked+.radio-content .radio-icon{visibility:visible}.range input{overflow:hidden;margin-top:5px;margin-bottom:5px;padding-right:2px;padding-left:1px;width:auto;height:43px;outline:0;background:-webkit-gradient(linear,50% 0,50% 100%,color-stop(0,#ccc),color-stop(100%,#ccc));background:linear-gradient(to right,#ccc 0,#ccc 100%);background-position:center;background-size:99% 2px;background-repeat:no-repeat;-webkit-appearance:none}.range input::-moz-focus-outer{border:0}.range input::-webkit-slider-thumb{position:relative;width:28px;height:28px;border-radius:50%;background-color:#fff;box-shadow:0 0 2px rgba(0,0,0,.3),0 3px 5px rgba(0,0,0,.2);cursor:pointer;-webkit-appearance:none;border:0}.range input::-webkit-slider-thumb:before{position:absolute;top:13px;left:-2001px;width:2000px;height:2px;background:#444;content:' '}.range input::-webkit-slider-thumb:after{position:absolute;top:-15px;left:-15px;padding:30px;content:' '}.range input::-ms-fill-lower{height:2px;background:#444}.range{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center;padding:2px 11px}.range.range-light input::-webkit-slider-thumb:before{background:#ddd}.range.range-light input::-ms-fill-lower{background:#ddd}.range.range-stable input::-webkit-slider-thumb:before{background:#b2b2b2}.range.range-stable input::-ms-fill-lower{background:#b2b2b2}.range.range-positive input::-webkit-slider-thumb:before{background:#387ef5}.range.range-positive input::-ms-fill-lower{background:#387ef5}.range.range-calm input::-webkit-slider-thumb:before{background:#11c1f3}.range.range-calm input::-ms-fill-lower{background:#11c1f3}.range.range-balanced input::-webkit-slider-thumb:before{background:#33cd5f}.range.range-balanced input::-ms-fill-lower{background:#33cd5f}.range.range-assertive input::-webkit-slider-thumb:before{background:#ef473a}.range.range-assertive input::-ms-fill-lower{background:#ef473a}.range.range-energized input::-webkit-slider-thumb:before{background:#ffc900}.range.range-energized input::-ms-fill-lower{background:#ffc900}.range.range-royal input::-webkit-slider-thumb:before{background:#886aea}.range.range-royal input::-ms-fill-lower{background:#886aea}.range.range-dark input::-webkit-slider-thumb:before{background:#444}.range.range-dark input::-ms-fill-lower{background:#444}.range .icon{-webkit-box-flex:0;-webkit-flex:0;-moz-box-flex:0;-moz-flex:0;-ms-flex:0;flex:0;display:block;min-width:24px;text-align:center;font-size:24px}.range input{-webkit-box-flex:1;-webkit-flex:1;-moz-box-flex:1;-moz-flex:1;-ms-flex:1;flex:1;display:block;margin-right:10px;margin-left:10px}.range-label{-webkit-box-flex:0;-webkit-flex:0 0 auto;-moz-box-flex:0;-moz-flex:0 0 auto;-ms-flex:0 0 auto;flex:0 0 auto;display:block;white-space:nowrap}.range-label:first-child{padding-left:5px}.range input+.range-label{padding-right:5px;padding-left:0}.platform-windowsphone .range input{height:auto}.item-select{position:relative}.item-select select{-webkit-appearance:none;-moz-appearance:none;appearance:none;position:absolute;top:0;bottom:0;right:0;padding:0 48px 0 16px;max-width:65%;border:none;background:#fff;color:#333;text-indent:.01px;text-overflow:'';white-space:nowrap;font-size:14px;cursor:pointer;direction:rtl}.item-select select::-ms-expand{display:none}.item-select option{direction:ltr}.item-select:after{position:absolute;top:50%;right:16px;margin-top:-3px;width:0;height:0;border-top:5px solid;border-right:5px solid transparent;border-left:5px solid transparent;color:#999;content:"";pointer-events:none}.item-select.item-light select{background:#fff;color:#444}.item-select.item-stable select{background:#f8f8f8;color:#444}.item-select.item-stable .input-label,.item-select.item-stable:after{color:#666}.item-select.item-positive select{background:#387ef5;color:#fff}.item-select.item-positive .input-label,.item-select.item-positive:after{color:#fff}.item-select.item-calm select{background:#11c1f3;color:#fff}.item-select.item-calm .input-label,.item-select.item-calm:after{color:#fff}.item-select.item-assertive select{background:#ef473a;color:#fff}.item-select.item-assertive .input-label,.item-select.item-assertive:after{color:#fff}.item-select.item-balanced select{background:#33cd5f;color:#fff}.item-select.item-balanced .input-label,.item-select.item-balanced:after{color:#fff}.item-select.item-energized select{background:#ffc900;color:#fff}.item-select.item-energized .input-label,.item-select.item-energized:after{color:#fff}.item-select.item-royal select{background:#886aea;color:#fff}.item-select.item-royal .input-label,.item-select.item-royal:after{color:#fff}.item-select.item-dark select{background:#444;color:#fff}.item-select.item-dark .input-label,.item-select.item-dark:after{color:#fff}select[multiple],select[size]{height:auto}progress{display:block;margin:15px auto;width:100%}.button{border-color:transparent;background-color:#f8f8f8;color:#444;position:relative;display:inline-block;margin:0;padding:0 12px;min-width:52px;min-height:47px;border-width:1px;border-style:solid;border-radius:4px;vertical-align:top;text-align:center;text-overflow:ellipsis;font-size:16px;line-height:42px;cursor:pointer}.button:hover{color:#444;text-decoration:none}.button.activated,.button.active{border-color:#a2a2a2;background-color:#e5e5e5}.button:after{position:absolute;top:-6px;right:-6px;bottom:-6px;left:-6px;content:' '}.button .icon{vertical-align:top;pointer-events:none}.button .icon:before,.button.icon-left:before,.button.icon-right:before,.button.icon:before{display:inline-block;padding:0 0 1px 0;vertical-align:inherit;font-size:24px;line-height:41px;pointer-events:none}.button.icon-left:before{float:left;padding-right:.2em;padding-left:0}.button.icon-right:before{float:right;padding-right:0;padding-left:.2em}.button.button-block,.button.button-full{margin-top:10px;margin-bottom:10px}.button.button-light{border-color:transparent;background-color:#fff;color:#444}.button.button-light:hover{color:#444;text-decoration:none}.button.button-light.activated,.button.button-light.active{border-color:#a2a2a2;background-color:#fafafa}.button.button-light.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#ddd}.button.button-light.button-icon{border-color:transparent;background:0 0}.button.button-light.button-outline{border-color:#ddd;background:0 0;color:#ddd}.button.button-light.button-outline.activated,.button.button-light.button-outline.active{background-color:#ddd;box-shadow:none;color:#fff}.button.button-stable{border-color:transparent;background-color:#f8f8f8;color:#444}.button.button-stable:hover{color:#444;text-decoration:none}.button.button-stable.activated,.button.button-stable.active{border-color:#a2a2a2;background-color:#e5e5e5}.button.button-stable.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#b2b2b2}.button.button-stable.button-icon{border-color:transparent;background:0 0}.button.button-stable.button-outline{border-color:#b2b2b2;background:0 0;color:#b2b2b2}.button.button-stable.button-outline.activated,.button.button-stable.button-outline.active{background-color:#b2b2b2;box-shadow:none;color:#fff}.button.button-positive{border-color:transparent;background-color:#387ef5;color:#fff}.button.button-positive:hover{color:#fff;text-decoration:none}.button.button-positive.activated,.button.button-positive.active{border-color:#a2a2a2;background-color:#0c60ee}.button.button-positive.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#387ef5}.button.button-positive.button-icon{border-color:transparent;background:0 0}.button.button-positive.button-outline{border-color:#387ef5;background:0 0;color:#387ef5}.button.button-positive.button-outline.activated,.button.button-positive.button-outline.active{background-color:#387ef5;box-shadow:none;color:#fff}.button.button-calm{border-color:transparent;background-color:#11c1f3;color:#fff}.button.button-calm:hover{color:#fff;text-decoration:none}.button.button-calm.activated,.button.button-calm.active{border-color:#a2a2a2;background-color:#0a9dc7}.button.button-calm.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#11c1f3}.button.button-calm.button-icon{border-color:transparent;background:0 0}.button.button-calm.button-outline{border-color:#11c1f3;background:0 0;color:#11c1f3}.button.button-calm.button-outline.activated,.button.button-calm.button-outline.active{background-color:#11c1f3;box-shadow:none;color:#fff}.button.button-assertive{border-color:transparent;background-color:#ef473a;color:#fff}.button.button-assertive:hover{color:#fff;text-decoration:none}.button.button-assertive.activated,.button.button-assertive.active{border-color:#a2a2a2;background-color:#e42112}.button.button-assertive.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#ef473a}.button.button-assertive.button-icon{border-color:transparent;background:0 0}.button.button-assertive.button-outline{border-color:#ef473a;background:0 0;color:#ef473a}.button.button-assertive.button-outline.activated,.button.button-assertive.button-outline.active{background-color:#ef473a;box-shadow:none;color:#fff}.button.button-balanced{border-color:transparent;background-color:#33cd5f;color:#fff}.button.button-balanced:hover{color:#fff;text-decoration:none}.button.button-balanced.activated,.button.button-balanced.active{border-color:#a2a2a2;background-color:#28a54c}.button.button-balanced.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#33cd5f}.button.button-balanced.button-icon{border-color:transparent;background:0 0}.button.button-balanced.button-outline{border-color:#33cd5f;background:0 0;color:#33cd5f}.button.button-balanced.button-outline.activated,.button.button-balanced.button-outline.active{background-color:#33cd5f;box-shadow:none;color:#fff}.button.button-energized{border-color:transparent;background-color:#ffc900;color:#fff}.button.button-energized:hover{color:#fff;text-decoration:none}.button.button-energized.activated,.button.button-energized.active{border-color:#a2a2a2;background-color:#e6b500}.button.button-energized.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#ffc900}.button.button-energized.button-icon{border-color:transparent;background:0 0}.button.button-energized.button-outline{border-color:#ffc900;background:0 0;color:#ffc900}.button.button-energized.button-outline.activated,.button.button-energized.button-outline.active{background-color:#ffc900;box-shadow:none;color:#fff}.button.button-royal{border-color:transparent;background-color:#886aea;color:#fff}.button.button-royal:hover{color:#fff;text-decoration:none}.button.button-royal.activated,.button.button-royal.active{border-color:#a2a2a2;background-color:#6b46e5}.button.button-royal.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#886aea}.button.button-royal.button-icon{border-color:transparent;background:0 0}.button.button-royal.button-outline{border-color:#886aea;background:0 0;color:#886aea}.button.button-royal.button-outline.activated,.button.button-royal.button-outline.active{background-color:#886aea;box-shadow:none;color:#fff}.button.button-dark{border-color:transparent;background-color:#444;color:#fff}.button.button-dark:hover{color:#fff;text-decoration:none}.button.button-dark.activated,.button.button-dark.active{border-color:#a2a2a2;background-color:#262626}.button.button-dark.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#444}.button.button-dark.button-icon{border-color:transparent;background:0 0}.button.button-dark.button-outline{border-color:#444;background:0 0;color:#444}.button.button-dark.button-outline.activated,.button.button-dark.button-outline.active{background-color:#444;box-shadow:none;color:#fff}.button-small{padding:2px 4px 1px;min-width:28px;min-height:30px;font-size:12px;line-height:26px}.button-small .icon:before,.button-small.icon-left:before,.button-small.icon-right:before,.button-small.icon:before{font-size:16px;line-height:19px;margin-top:3px}.button-large{padding:0 16px;min-width:68px;min-height:59px;font-size:20px;line-height:53px}.button-large .icon:before,.button-large.icon-left:before,.button-large.icon-right:before,.button-large.icon:before{padding-bottom:2px;font-size:32px;line-height:51px}.button-icon{-webkit-transition:opacity .1s;transition:opacity .1s;padding:0 6px;min-width:initial;border-color:transparent;background:0 0}.button-icon.button.activated,.button-icon.button.active{border-color:transparent;background:0 0;box-shadow:none;opacity:.3}.button-icon .icon:before,.button-icon.icon:before{font-size:32px}.button-clear{-webkit-transition:opacity .1s;transition:opacity .1s;padding:0 6px;max-height:42px;border-color:transparent;background:0 0;box-shadow:none}.button-clear.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:transparent}.button-clear.button-icon{border-color:transparent;background:0 0}.button-clear.activated,.button-clear.active{opacity:.3}.button-outline{-webkit-transition:opacity .1s;transition:opacity .1s;background:0 0;box-shadow:none}.button-outline.button-outline{border-color:transparent;background:0 0;color:transparent}.button-outline.button-outline.activated,.button-outline.button-outline.active{background-color:transparent;box-shadow:none;color:#fff}.padding>.button.button-block:first-child{margin-top:0}.button-block{display:block;clear:both}.button-block:after{clear:both}.button-full,.button-full>.button{display:block;margin-right:0;margin-left:0;border-right-width:0;border-left-width:0;border-radius:0}.button-full>button.button,button.button-block,button.button-full,input.button.button-block{width:100%}a.button{text-decoration:none}a.button .icon:before,a.button.icon-left:before,a.button.icon-right:before,a.button.icon:before{margin-top:2px}.button.disabled,.button[disabled]{opacity:.4;cursor:default!important;pointer-events:none}.button-bar{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-webkit-flex:1;-moz-box-flex:1;-moz-flex:1;-ms-flex:1;flex:1;width:100%}.button-bar.button-bar-inline{display:block;width:auto}.button-bar.button-bar-inline:after,.button-bar.button-bar-inline:before{display:table;content:"";line-height:0}.button-bar.button-bar-inline:after{clear:both}.button-bar.button-bar-inline>.button{width:auto;display:inline-block;float:left}.button-bar.bar-light>.button{border-color:#ddd}.button-bar.bar-stable>.button{border-color:#b2b2b2}.button-bar.bar-positive>.button{border-color:#0c60ee}.button-bar.bar-calm>.button{border-color:#0a9dc7}.button-bar.bar-assertive>.button{border-color:#e42112}.button-bar.bar-balanced>.button{border-color:#28a54c}.button-bar.bar-energized>.button{border-color:#e6b500}.button-bar.bar-royal>.button{border-color:#6b46e5}.button-bar.bar-dark>.button{border-color:#111}.button-bar>.button{-webkit-box-flex:1;-webkit-flex:1;-moz-box-flex:1;-moz-flex:1;-ms-flex:1;flex:1;display:block;overflow:hidden;padding:0 16px;width:0;border-width:1px 0 1px 1px;border-radius:0;text-align:center;text-overflow:ellipsis;white-space:nowrap}.button-bar>.button .icon:before,.button-bar>.button:before{line-height:44px}.button-bar>.button:first-child{border-radius:4px 0 0 4px}.button-bar>.button:last-child{border-right-width:1px;border-radius:0 4px 4px 0}.button-bar>.button:only-child{border-radius:4px}.button-bar>.button-small .icon:before,.button-bar>.button-small:before{line-height:28px}.row{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;padding:5px;width:100%}.row-wrap{-webkit-flex-wrap:wrap;-moz-flex-wrap:wrap;-ms-flex-wrap:wrap;flex-wrap:wrap}.row-no-padding,.row-no-padding>.col{padding:0}.row+.row{margin-top:-5px;padding-top:0}.col{-webkit-box-flex:1;-webkit-flex:1;-moz-box-flex:1;-moz-flex:1;-ms-flex:1;flex:1;display:block;padding:5px;width:100%}.row-top{-webkit-box-align:start;-ms-flex-align:start;-webkit-align-items:flex-start;-moz-align-items:flex-start;align-items:flex-start}.row-bottom{-webkit-box-align:end;-ms-flex-align:end;-webkit-align-items:flex-end;-moz-align-items:flex-end;align-items:flex-end}.row-center{-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center}.row-stretch{-webkit-box-align:stretch;-ms-flex-align:stretch;-webkit-align-items:stretch;-moz-align-items:stretch;align-items:stretch}.row-baseline{-webkit-box-align:baseline;-ms-flex-align:baseline;-webkit-align-items:baseline;-moz-align-items:baseline;align-items:baseline}.col-top{-webkit-align-self:flex-start;-moz-align-self:flex-start;-ms-flex-item-align:start;align-self:flex-start}.col-bottom{-webkit-align-self:flex-end;-moz-align-self:flex-end;-ms-flex-item-align:end;align-self:flex-end}.col-center{-webkit-align-self:center;-moz-align-self:center;-ms-flex-item-align:center;align-self:center}.col-offset-10{margin-left:10%}.col-offset-20{margin-left:20%}.col-offset-25{margin-left:25%}.col-offset-33,.col-offset-34{margin-left:33.3333%}.col-offset-50{margin-left:50%}.col-offset-66,.col-offset-67{margin-left:66.6666%}.col-offset-75{margin-left:75%}.col-offset-80{margin-left:80%}.col-offset-90{margin-left:90%}.col-10{-webkit-box-flex:0;-webkit-flex:0 0 10%;-moz-box-flex:0;-moz-flex:0 0 10%;-ms-flex:0 0 10%;flex:0 0 10%;max-width:10%}.col-20{-webkit-box-flex:0;-webkit-flex:0 0 20%;-moz-box-flex:0;-moz-flex:0 0 20%;-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.col-25{-webkit-box-flex:0;-webkit-flex:0 0 25%;-moz-box-flex:0;-moz-flex:0 0 25%;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-33,.col-34{-webkit-box-flex:0;-webkit-flex:0 0 33.3333%;-moz-box-flex:0;-moz-flex:0 0 33.3333%;-ms-flex:0 0 33.3333%;flex:0 0 33.3333%;max-width:33.3333%}.col-40{-webkit-box-flex:0;-webkit-flex:0 0 40%;-moz-box-flex:0;-moz-flex:0 0 40%;-ms-flex:0 0 40%;flex:0 0 40%;max-width:40%}.col-50{-webkit-box-flex:0;-webkit-flex:0 0 50%;-moz-box-flex:0;-moz-flex:0 0 50%;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-60{-webkit-box-flex:0;-webkit-flex:0 0 60%;-moz-box-flex:0;-moz-flex:0 0 60%;-ms-flex:0 0 60%;flex:0 0 60%;max-width:60%}.col-66,.col-67{-webkit-box-flex:0;-webkit-flex:0 0 66.6666%;-moz-box-flex:0;-moz-flex:0 0 66.6666%;-ms-flex:0 0 66.6666%;flex:0 0 66.6666%;max-width:66.6666%}.col-75{-webkit-box-flex:0;-webkit-flex:0 0 75%;-moz-box-flex:0;-moz-flex:0 0 75%;-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-80{-webkit-box-flex:0;-webkit-flex:0 0 80%;-moz-box-flex:0;-moz-flex:0 0 80%;-ms-flex:0 0 80%;flex:0 0 80%;max-width:80%}.col-90{-webkit-box-flex:0;-webkit-flex:0 0 90%;-moz-box-flex:0;-moz-flex:0 0 90%;-ms-flex:0 0 90%;flex:0 0 90%;max-width:90%}@media (max-width:567px){.responsive-sm{-webkit-box-direction:normal;-moz-box-direction:normal;-webkit-box-orient:vertical;-moz-box-orient:vertical;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column}.responsive-sm .col,.responsive-sm .col-10,.responsive-sm .col-20,.responsive-sm .col-25,.responsive-sm .col-33,.responsive-sm .col-34,.responsive-sm .col-50,.responsive-sm .col-66,.responsive-sm .col-67,.responsive-sm .col-75,.responsive-sm .col-80,.responsive-sm .col-90{-webkit-box-flex:1;-webkit-flex:1;-moz-box-flex:1;-moz-flex:1;-ms-flex:1;flex:1;margin-bottom:15px;margin-left:0;max-width:100%;width:100%}}@media (max-width:767px){.responsive-md{-webkit-box-direction:normal;-moz-box-direction:normal;-webkit-box-orient:vertical;-moz-box-orient:vertical;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column}.responsive-md .col,.responsive-md .col-10,.responsive-md .col-20,.responsive-md .col-25,.responsive-md .col-33,.responsive-md .col-34,.responsive-md .col-50,.responsive-md .col-66,.responsive-md .col-67,.responsive-md .col-75,.responsive-md .col-80,.responsive-md .col-90{-webkit-box-flex:1;-webkit-flex:1;-moz-box-flex:1;-moz-flex:1;-ms-flex:1;flex:1;margin-bottom:15px;margin-left:0;max-width:100%;width:100%}}@media (max-width:1023px){.responsive-lg{-webkit-box-direction:normal;-moz-box-direction:normal;-webkit-box-orient:vertical;-moz-box-orient:vertical;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column}.responsive-lg .col,.responsive-lg .col-10,.responsive-lg .col-20,.responsive-lg .col-25,.responsive-lg .col-33,.responsive-lg .col-34,.responsive-lg .col-50,.responsive-lg .col-66,.responsive-lg .col-67,.responsive-lg .col-75,.responsive-lg .col-80,.responsive-lg .col-90{-webkit-box-flex:1;-webkit-flex:1;-moz-box-flex:1;-moz-flex:1;-ms-flex:1;flex:1;margin-bottom:15px;margin-left:0;max-width:100%;width:100%}}.hide{display:none}.opacity-hide{opacity:0}.grade-b .opacity-hide,.grade-c .opacity-hide{opacity:1;display:none}.show{display:block}.opacity-show{opacity:1}.invisible{visibility:hidden}.keyboard-open .hide-on-keyboard-open{display:none}.keyboard-open .bar-footer.hide-on-keyboard-open+.pane .has-footer,.keyboard-open .tabs.hide-on-keyboard-open+.pane .has-tabs{bottom:0}.inline{display:inline-block}.disable-pointer-events{pointer-events:none}.enable-pointer-events{pointer-events:auto}.disable-user-behavior{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-touch-callout:none;-webkit-tap-highlight-color:transparent;-webkit-tap-highlight-color:transparent;-webkit-user-drag:none;-ms-touch-action:none;-ms-content-zooming:none}.click-block{position:absolute;top:0;right:0;bottom:0;left:0;opacity:0;z-index:99999;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);overflow:hidden}.click-block-hide{-webkit-transform:translate3d(-9999px,0,0);transform:translate3d(-9999px,0,0)}.no-resize{resize:none}.block{display:block;clear:both}.block:after{display:block;visibility:hidden;clear:both;height:0;content:"."}.full-image{width:100%}.clearfix:after,.clearfix:before{display:table;content:"";line-height:0}.clearfix:after{clear:both}.padding{padding:10px}.padding-top,.padding-vertical{padding-top:10px}.padding-horizontal,.padding-right{padding-right:10px}.padding-bottom,.padding-vertical{padding-bottom:10px}.padding-horizontal,.padding-left{padding-left:10px}.iframe-wrapper{position:fixed;-webkit-overflow-scrolling:touch;overflow:scroll}.iframe-wrapper iframe{height:100%;width:100%}.rounded{border-radius:4px}.light,a.light{color:#fff}.light-bg{background-color:#fff}.light-border{border-color:#ddd}.stable,a.stable{color:#f8f8f8}.stable-bg{background-color:#f8f8f8}.stable-border{border-color:#b2b2b2}.positive,a.positive{color:#387ef5}.positive-bg{background-color:#387ef5}.positive-border{border-color:#0c60ee}.calm,a.calm{color:#11c1f3}.calm-bg{background-color:#11c1f3}.calm-border{border-color:#0a9dc7}.assertive,a.assertive{color:#ef473a}.assertive-bg{background-color:#ef473a}.assertive-border{border-color:#e42112}.balanced,a.balanced{color:#33cd5f}.balanced-bg{background-color:#33cd5f}.balanced-border{border-color:#28a54c}.energized,a.energized{color:#ffc900}.energized-bg{background-color:#ffc900}.energized-border{border-color:#e6b500}.royal,a.royal{color:#886aea}.royal-bg{background-color:#886aea}.royal-border{border-color:#6b46e5}.dark,a.dark{color:#444}.dark-bg{background-color:#444}.dark-border{border-color:#111}[collection-repeat]{left:0!important;top:0!important;position:absolute!important;z-index:1}.collection-repeat-container{position:relative;z-index:1}.collection-repeat-after-container{z-index:0;display:block}.collection-repeat-after-container.horizontal{display:inline-block}.ng-cloak,.ng-hide:not(.ng-hide-animate),.x-ng-cloak,[data-ng-cloak],[ng-cloak],[ng\:cloak],[x-ng-cloak]{display:none!important}.platform-ios.platform-cordova:not(.fullscreen) .bar-header:not(.bar-subheader){height:64px}.platform-ios.platform-cordova:not(.fullscreen) .bar-header:not(.bar-subheader).item-input-inset .item-input-wrapper{margin-top:19px!important}.platform-ios.platform-cordova:not(.fullscreen) .bar-header:not(.bar-subheader)>*{margin-top:20px}.platform-ios.platform-cordova:not(.fullscreen) .bar-subheader,.platform-ios.platform-cordova:not(.fullscreen) .has-header,.platform-ios.platform-cordova:not(.fullscreen) .tabs-top>.tabs,.platform-ios.platform-cordova:not(.fullscreen) .tabs.tabs-top{top:64px}.platform-ios.platform-cordova:not(.fullscreen) .has-subheader{top:108px}.platform-ios.platform-cordova:not(.fullscreen) .has-header.has-tabs-top{top:113px}.platform-ios.platform-cordova:not(.fullscreen) .has-header.has-subheader.has-tabs-top{top:157px}.platform-ios.platform-cordova .popover .bar-header:not(.bar-subheader){height:44px}.platform-ios.platform-cordova .popover .bar-header:not(.bar-subheader).item-input-inset .item-input-wrapper{margin-top:-1px}.platform-ios.platform-cordova .popover .bar-header:not(.bar-subheader)>*{margin-top:0}.platform-ios.platform-cordova .popover .bar-subheader,.platform-ios.platform-cordova .popover .has-header{top:44px}.platform-ios.platform-cordova .popover .has-subheader{top:88px}.platform-ios.platform-cordova.status-bar-hide{margin-bottom:20px}@media (orientation:landscape){.platform-ios.platform-browser.platform-ipad{position:fixed}}.platform-c:not(.enable-transitions) *{-webkit-transition:none!important;transition:none!important}.slide-in-up{-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}.slide-in-up.ng-enter,.slide-in-up>.ng-enter{-webkit-transition:all cubic-bezier(.1,.7,.1,1) 400ms;transition:all cubic-bezier(.1,.7,.1,1) 400ms}.slide-in-up.ng-enter-active,.slide-in-up>.ng-enter-active{-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.slide-in-up.ng-leave,.slide-in-up>.ng-leave{-webkit-transition:all ease-in-out 250ms;transition:all ease-in-out 250ms}@-webkit-keyframes scaleOut{from{-webkit-transform:scale(1);opacity:1}to{-webkit-transform:scale(.8);opacity:0}}@keyframes scaleOut{from{transform:scale(1);opacity:1}to{transform:scale(.8);opacity:0}}@-webkit-keyframes superScaleIn{from{-webkit-transform:scale(1.2);opacity:0}to{-webkit-transform:scale(1);opacity:1}}@keyframes superScaleIn{from{transform:scale(1.2);opacity:0}to{transform:scale(1);opacity:1}}[nav-view-transition=ios] [nav-view=entering],[nav-view-transition=ios] [nav-view=leaving]{-webkit-transition-duration:500ms;transition-duration:500ms;-webkit-transition-timing-function:cubic-bezier(.36,.66,.04,1);transition-timing-function:cubic-bezier(.36,.66,.04,1);-webkit-transition-property:opacity,-webkit-transform,box-shadow;transition-property:opacity,transform,box-shadow}[nav-view-transition=ios][nav-view-direction=forward],[nav-view-transition=ios][nav-view-direction=back]{background-color:#000}[nav-view-transition=ios] [nav-view=active],[nav-view-transition=ios][nav-view-direction=forward] [nav-view=entering],[nav-view-transition=ios][nav-view-direction=back] [nav-view=leaving]{z-index:3}[nav-view-transition=ios][nav-view-direction=forward] [nav-view=leaving],[nav-view-transition=ios][nav-view-direction=back] [nav-view=entering]{z-index:2}[nav-bar-transition=ios] .back-text,[nav-bar-transition=ios] .buttons,[nav-bar-transition=ios] .title{-webkit-transition-duration:500ms;transition-duration:500ms;-webkit-transition-timing-function:cubic-bezier(.36,.66,.04,1);transition-timing-function:cubic-bezier(.36,.66,.04,1);-webkit-transition-property:opacity,-webkit-transform;transition-property:opacity,transform}[nav-bar-transition=ios] [nav-bar=entering],[nav-bar-transition=ios] [nav-bar=active]{z-index:10}[nav-bar-transition=ios] [nav-bar=entering] .bar,[nav-bar-transition=ios] [nav-bar=active] .bar{background:0 0}[nav-bar-transition=ios] [nav-bar=cached]{display:block}[nav-bar-transition=ios] [nav-bar=cached] .header-item{display:none}[nav-view-transition=android] [nav-view=entering],[nav-view-transition=android] [nav-view=leaving]{-webkit-transition-duration:200ms;transition-duration:200ms;-webkit-transition-timing-function:cubic-bezier(.4,.6,.2,1);transition-timing-function:cubic-bezier(.4,.6,.2,1);-webkit-transition-property:-webkit-transform;transition-property:transform}[nav-view-transition=android] [nav-view=active],[nav-view-transition=android][nav-view-direction=forward] [nav-view=entering],[nav-view-transition=android][nav-view-direction=back] [nav-view=leaving]{z-index:3}[nav-view-transition=android][nav-view-direction=forward] [nav-view=leaving],[nav-view-transition=android][nav-view-direction=back] [nav-view=entering]{z-index:2}[nav-bar-transition=android] .buttons,[nav-bar-transition=android] .title{-webkit-transition-duration:200ms;transition-duration:200ms;-webkit-transition-timing-function:cubic-bezier(.4,.6,.2,1);transition-timing-function:cubic-bezier(.4,.6,.2,1);-webkit-transition-property:opacity;transition-property:opacity}[nav-bar-transition=android] [nav-bar=entering],[nav-bar-transition=android] [nav-bar=active]{z-index:10}[nav-bar-transition=android] [nav-bar=entering] .bar,[nav-bar-transition=android] [nav-bar=active] .bar{background:0 0}[nav-bar-transition=android] [nav-bar=cached]{display:block}[nav-bar-transition=android] [nav-bar=cached] .header-item{display:none}[nav-swipe=fast] .back-text,[nav-swipe=fast] .buttons,[nav-swipe=fast] .title,[nav-swipe=fast] [nav-view]{-webkit-transition-duration:50ms;transition-duration:50ms;-webkit-transition-timing-function:linear;transition-timing-function:linear}[nav-swipe=slow] .back-text,[nav-swipe=slow] .buttons,[nav-swipe=slow] .title,[nav-swipe=slow] [nav-view]{-webkit-transition-duration:160ms;transition-duration:160ms;-webkit-transition-timing-function:linear;transition-timing-function:linear}[nav-bar=cached],[nav-view=cached]{display:none}[nav-view=stage]{opacity:0;-webkit-transition-duration:0;transition-duration:0}[nav-bar=stage] .back-text,[nav-bar=stage] .buttons,[nav-bar=stage] .title{position:absolute;opacity:0;-webkit-transition-duration:0s;transition-duration:0s} \ No newline at end of file diff --git a/www/manual_lib/ionic/fonts/ionicons.eot b/www/manual_lib/ionic/fonts/ionicons.eot deleted file mode 100644 index 92a3f20a39267ae7f45144f412a995a663730360..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 120724 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

      @FHUiu&=5w(f-3S1h*&)&h2 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 - - - - -Created by FontForge 20120731 at Thu Dec 4 09:51:48 2014 - By Adam Bradley -Created by Adam Bradley with FontForge 2.0 (http://fontforge.sf.net) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/www/manual_lib/ionic/fonts/ionicons.ttf b/www/manual_lib/ionic/fonts/ionicons.ttf deleted file mode 100644 index c4e4632486d863337c1c73478ddb3c20726c55a0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 188508 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

      8E#xmG!w{$tO5=<$!EHPzZXo}IBc1_sqA&q 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 diff --git a/www/manual_lib/ionic/fonts/ionicons.woff b/www/manual_lib/ionic/fonts/ionicons.woff deleted file mode 100644 index 5f3a14e0a5ca6d20cc4fac708979e807b0d51bc3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 67904 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

    2. F(S(gioucm()OHA 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 E0LEz`i~s-t diff --git a/www/manual_lib/ionic/js/ionic-angular.js b/www/manual_lib/ionic/js/ionic-angular.js deleted file mode 100644 index eba49d27f..000000000 --- a/www/manual_lib/ionic/js/ionic-angular.js +++ /dev/null @@ -1,14399 +0,0 @@ -/*! - * Copyright 2015 Drifty Co. - * http://drifty.com/ - * - * Ionic, v1.3.3 - * A powerful HTML5 mobile app framework. - * http://ionicframework.com/ - * - * By @maxlynch, @benjsperry, @adamdbradley <3 - * - * Licensed under the MIT license. Please see LICENSE for more information. - * - */ - -(function() { -/* eslint no-unused-vars:0 */ -var IonicModule = angular.module('ionic', ['ngAnimate', 'ngSanitize', 'ui.router', 'ngIOS9UIWebViewPatch']), - extend = angular.extend, - forEach = angular.forEach, - isDefined = angular.isDefined, - isNumber = angular.isNumber, - isString = angular.isString, - jqLite = angular.element, - noop = angular.noop; - -/** - * @ngdoc service - * @name $ionicActionSheet - * @module ionic - * @description - * The Action Sheet is a slide-up pane that lets the user choose from a set of options. - * Dangerous options are highlighted in red and made obvious. - * - * There are easy ways to cancel out of the action sheet, such as tapping the backdrop or even - * hitting escape on the keyboard for desktop testing. - * - * ![Action Sheet](http://ionicframework.com.s3.amazonaws.com/docs/controllers/actionSheet.gif) - * - * @usage - * To trigger an Action Sheet in your code, use the $ionicActionSheet service in your angular controllers: - * - * ```js - * angular.module('mySuperApp', ['ionic']) - * .controller(function($scope, $ionicActionSheet, $timeout) { - * - * // Triggered on a button click, or some other target - * $scope.show = function() { - * - * // Show the action sheet - * var hideSheet = $ionicActionSheet.show({ - * buttons: [ - * { text: 'Share This' }, - * { text: 'Move' } - * ], - * destructiveText: 'Delete', - * titleText: 'Modify your album', - * cancelText: 'Cancel', - * cancel: function() { - // add cancel code.. - }, - * buttonClicked: function(index) { - * return true; - * } - * }); - * - * // For example's sake, hide the sheet after two seconds - * $timeout(function() { - * hideSheet(); - * }, 2000); - * - * }; - * }); - * ``` - * - */ -IonicModule -.factory('$ionicActionSheet', [ - '$rootScope', - '$compile', - '$animate', - '$timeout', - '$ionicTemplateLoader', - '$ionicPlatform', - '$ionicBody', - 'IONIC_BACK_PRIORITY', -function($rootScope, $compile, $animate, $timeout, $ionicTemplateLoader, $ionicPlatform, $ionicBody, IONIC_BACK_PRIORITY) { - - return { - show: actionSheet - }; - - /** - * @ngdoc method - * @name $ionicActionSheet#show - * @description - * Load and return a new action sheet. - * - * A new isolated scope will be created for the - * action sheet and the new element will be appended into the body. - * - * @param {object} options The options for this ActionSheet. Properties: - * - * - `[Object]` `buttons` Which buttons to show. Each button is an object with a `text` field. - * - `{string}` `titleText` The title to show on the action sheet. - * - `{string=}` `cancelText` the text for a 'cancel' button on the action sheet. - * - `{string=}` `destructiveText` The text for a 'danger' on the action sheet. - * - `{function=}` `cancel` Called if the cancel button is pressed, the backdrop is tapped or - * the hardware back button is pressed. - * - `{function=}` `buttonClicked` Called when one of the non-destructive buttons is clicked, - * with the index of the button that was clicked and the button object. Return true to close - * the action sheet, or false to keep it opened. - * - `{function=}` `destructiveButtonClicked` Called when the destructive button is clicked. - * Return true to close the action sheet, or false to keep it opened. - * - `{boolean=}` `cancelOnStateChange` Whether to cancel the actionSheet when navigating - * to a new state. Default true. - * - `{string}` `cssClass` The custom CSS class name. - * - * @returns {function} `hideSheet` A function which, when called, hides & cancels the action sheet. - */ - function actionSheet(opts) { - var scope = $rootScope.$new(true); - - extend(scope, { - cancel: noop, - destructiveButtonClicked: noop, - buttonClicked: noop, - $deregisterBackButton: noop, - buttons: [], - cancelOnStateChange: true - }, opts || {}); - - function textForIcon(text) { - if (text && /icon/.test(text)) { - scope.$actionSheetHasIcon = true; - } - } - - for (var x = 0; x < scope.buttons.length; x++) { - textForIcon(scope.buttons[x].text); - } - textForIcon(scope.cancelText); - textForIcon(scope.destructiveText); - - // Compile the template - var element = scope.element = $compile('')(scope); - - // Grab the sheet element for animation - var sheetEl = jqLite(element[0].querySelector('.action-sheet-wrapper')); - - var stateChangeListenDone = scope.cancelOnStateChange ? - $rootScope.$on('$stateChangeSuccess', function() { scope.cancel(); }) : - noop; - - // removes the actionSheet from the screen - scope.removeSheet = function(done) { - if (scope.removed) return; - - scope.removed = true; - sheetEl.removeClass('action-sheet-up'); - $timeout(function() { - // wait to remove this due to a 300ms delay native - // click which would trigging whatever was underneath this - $ionicBody.removeClass('action-sheet-open'); - }, 400); - scope.$deregisterBackButton(); - stateChangeListenDone(); - - $animate.removeClass(element, 'active').then(function() { - scope.$destroy(); - element.remove(); - // scope.cancel.$scope is defined near the bottom - scope.cancel.$scope = sheetEl = null; - (done || noop)(opts.buttons); - }); - }; - - scope.showSheet = function(done) { - if (scope.removed) return; - - $ionicBody.append(element) - .addClass('action-sheet-open'); - - $animate.addClass(element, 'active').then(function() { - if (scope.removed) return; - (done || noop)(); - }); - $timeout(function() { - if (scope.removed) return; - sheetEl.addClass('action-sheet-up'); - }, 20, false); - }; - - // registerBackButtonAction returns a callback to deregister the action - scope.$deregisterBackButton = $ionicPlatform.registerBackButtonAction( - function() { - $timeout(scope.cancel); - }, - IONIC_BACK_PRIORITY.actionSheet - ); - - // called when the user presses the cancel button - scope.cancel = function() { - // after the animation is out, call the cancel callback - scope.removeSheet(opts.cancel); - }; - - scope.buttonClicked = function(index) { - // Check if the button click event returned true, which means - // we can close the action sheet - if (opts.buttonClicked(index, opts.buttons[index]) === true) { - scope.removeSheet(); - } - }; - - scope.destructiveButtonClicked = function() { - // Check if the destructive button click event returned true, which means - // we can close the action sheet - if (opts.destructiveButtonClicked() === true) { - scope.removeSheet(); - } - }; - - scope.showSheet(); - - // Expose the scope on $ionicActionSheet's return value for the sake - // of testing it. - scope.cancel.$scope = scope; - - return scope.cancel; - } -}]); - - -jqLite.prototype.addClass = function(cssClasses) { - var x, y, cssClass, el, splitClasses, existingClasses; - if (cssClasses && cssClasses != 'ng-scope' && cssClasses != 'ng-isolate-scope') { - for (x = 0; x < this.length; x++) { - el = this[x]; - if (el.setAttribute) { - - if (cssClasses.indexOf(' ') < 0 && el.classList.add) { - el.classList.add(cssClasses); - } else { - existingClasses = (' ' + (el.getAttribute('class') || '') + ' ') - .replace(/[\n\t]/g, " "); - splitClasses = cssClasses.split(' '); - - for (y = 0; y < splitClasses.length; y++) { - cssClass = splitClasses[y].trim(); - if (existingClasses.indexOf(' ' + cssClass + ' ') === -1) { - existingClasses += cssClass + ' '; - } - } - el.setAttribute('class', existingClasses.trim()); - } - } - } - } - return this; -}; - -jqLite.prototype.removeClass = function(cssClasses) { - var x, y, splitClasses, cssClass, el; - if (cssClasses) { - for (x = 0; x < this.length; x++) { - el = this[x]; - if (el.getAttribute) { - if (cssClasses.indexOf(' ') < 0 && el.classList.remove) { - el.classList.remove(cssClasses); - } else { - splitClasses = cssClasses.split(' '); - - for (y = 0; y < splitClasses.length; y++) { - cssClass = splitClasses[y]; - el.setAttribute('class', ( - (" " + (el.getAttribute('class') || '') + " ") - .replace(/[\n\t]/g, " ") - .replace(" " + cssClass.trim() + " ", " ")).trim() - ); - } - } - } - } - } - return this; -}; - -/** - * @ngdoc service - * @name $ionicBackdrop - * @module ionic - * @description - * Shows and hides a backdrop over the UI. Appears behind popups, loading, - * and other overlays. - * - * Often, multiple UI components require a backdrop, but only one backdrop is - * ever needed in the DOM at a time. - * - * Therefore, each component that requires the backdrop to be shown calls - * `$ionicBackdrop.retain()` when it wants the backdrop, then `$ionicBackdrop.release()` - * when it is done with the backdrop. - * - * For each time `retain` is called, the backdrop will be shown until `release` is called. - * - * For example, if `retain` is called three times, the backdrop will be shown until `release` - * is called three times. - * - * **Notes:** - * - The backdrop service will broadcast 'backdrop.shown' and 'backdrop.hidden' events from the root scope, - * this is useful for alerting native components not in html. - * - * @usage - * - * ```js - * function MyController($scope, $ionicBackdrop, $timeout, $rootScope) { - * //Show a backdrop for one second - * $scope.action = function() { - * $ionicBackdrop.retain(); - * $timeout(function() { - * $ionicBackdrop.release(); - * }, 1000); - * }; - * - * // Execute action on backdrop disappearing - * $scope.$on('backdrop.hidden', function() { - * // Execute action - * }); - * - * // Execute action on backdrop appearing - * $scope.$on('backdrop.shown', function() { - * // Execute action - * }); - * - * } - * ``` - */ -IonicModule -.factory('$ionicBackdrop', [ - '$document', '$timeout', '$$rAF', '$rootScope', -function($document, $timeout, $$rAF, $rootScope) { - - var el = jqLite('
      '); - var backdropHolds = 0; - - $document[0].body.appendChild(el[0]); - - return { - /** - * @ngdoc method - * @name $ionicBackdrop#retain - * @description Retains the backdrop. - */ - retain: retain, - /** - * @ngdoc method - * @name $ionicBackdrop#release - * @description - * Releases the backdrop. - */ - release: release, - - getElement: getElement, - - // exposed for testing - _element: el - }; - - function retain() { - backdropHolds++; - if (backdropHolds === 1) { - el.addClass('visible'); - $rootScope.$broadcast('backdrop.shown'); - $$rAF(function() { - // If we're still at >0 backdropHolds after async... - if (backdropHolds >= 1) el.addClass('active'); - }); - } - } - function release() { - if (backdropHolds === 1) { - el.removeClass('active'); - $rootScope.$broadcast('backdrop.hidden'); - $timeout(function() { - // If we're still at 0 backdropHolds after async... - if (backdropHolds === 0) el.removeClass('visible'); - }, 400, false); - } - backdropHolds = Math.max(0, backdropHolds - 1); - } - - function getElement() { - return el; - } - -}]); - -/** - * @private - */ -IonicModule -.factory('$ionicBind', ['$parse', '$interpolate', function($parse, $interpolate) { - var LOCAL_REGEXP = /^\s*([@=&])(\??)\s*(\w*)\s*$/; - return function(scope, attrs, bindDefinition) { - forEach(bindDefinition || {}, function(definition, scopeName) { - //Adapted from angular.js $compile - var match = definition.match(LOCAL_REGEXP) || [], - attrName = match[3] || scopeName, - mode = match[1], // @, =, or & - parentGet, - unwatch; - - switch (mode) { - case '@': - if (!attrs[attrName]) { - return; - } - attrs.$observe(attrName, function(value) { - scope[scopeName] = value; - }); - // we trigger an interpolation to ensure - // the value is there for use immediately - if (attrs[attrName]) { - scope[scopeName] = $interpolate(attrs[attrName])(scope); - } - break; - - case '=': - if (!attrs[attrName]) { - return; - } - unwatch = scope.$watch(attrs[attrName], function(value) { - scope[scopeName] = value; - }); - //Destroy parent scope watcher when this scope is destroyed - scope.$on('$destroy', unwatch); - break; - - case '&': - /* jshint -W044 */ - if (attrs[attrName] && attrs[attrName].match(RegExp(scopeName + '\(.*?\)'))) { - throw new Error('& expression binding "' + scopeName + '" looks like it will recursively call "' + - attrs[attrName] + '" and cause a stack overflow! Please choose a different scopeName.'); - } - parentGet = $parse(attrs[attrName]); - scope[scopeName] = function(locals) { - return parentGet(scope, locals); - }; - break; - } - }); - }; -}]); - -/** - * @ngdoc service - * @name $ionicBody - * @module ionic - * @description An angular utility service to easily and efficiently - * add and remove CSS classes from the document's body element. - */ -IonicModule -.factory('$ionicBody', ['$document', function($document) { - return { - /** - * @ngdoc method - * @name $ionicBody#addClass - * @description Add a class to the document's body element. - * @param {string} class Each argument will be added to the body element. - * @returns {$ionicBody} The $ionicBody service so methods can be chained. - */ - addClass: function() { - for (var x = 0; x < arguments.length; x++) { - $document[0].body.classList.add(arguments[x]); - } - return this; - }, - /** - * @ngdoc method - * @name $ionicBody#removeClass - * @description Remove a class from the document's body element. - * @param {string} class Each argument will be removed from the body element. - * @returns {$ionicBody} The $ionicBody service so methods can be chained. - */ - removeClass: function() { - for (var x = 0; x < arguments.length; x++) { - $document[0].body.classList.remove(arguments[x]); - } - return this; - }, - /** - * @ngdoc method - * @name $ionicBody#enableClass - * @description Similar to the `add` method, except the first parameter accepts a boolean - * value determining if the class should be added or removed. Rather than writing user code, - * such as "if true then add the class, else then remove the class", this method can be - * given a true or false value which reduces redundant code. - * @param {boolean} shouldEnableClass A true/false value if the class should be added or removed. - * @param {string} class Each remaining argument would be added or removed depending on - * the first argument. - * @returns {$ionicBody} The $ionicBody service so methods can be chained. - */ - enableClass: function(shouldEnableClass) { - var args = Array.prototype.slice.call(arguments).slice(1); - if (shouldEnableClass) { - this.addClass.apply(this, args); - } else { - this.removeClass.apply(this, args); - } - return this; - }, - /** - * @ngdoc method - * @name $ionicBody#append - * @description Append a child to the document's body. - * @param {element} element The element to be appended to the body. The passed in element - * can be either a jqLite element, or a DOM element. - * @returns {$ionicBody} The $ionicBody service so methods can be chained. - */ - append: function(ele) { - $document[0].body.appendChild(ele.length ? ele[0] : ele); - return this; - }, - /** - * @ngdoc method - * @name $ionicBody#get - * @description Get the document's body element. - * @returns {element} Returns the document's body element. - */ - get: function() { - return $document[0].body; - } - }; -}]); - -IonicModule -.factory('$ionicClickBlock', [ - '$document', - '$ionicBody', - '$timeout', -function($document, $ionicBody, $timeout) { - var CSS_HIDE = 'click-block-hide'; - var cbEle, fallbackTimer, pendingShow; - - function preventClick(ev) { - ev.preventDefault(); - ev.stopPropagation(); - } - - function addClickBlock() { - if (pendingShow) { - if (cbEle) { - cbEle.classList.remove(CSS_HIDE); - } else { - cbEle = $document[0].createElement('div'); - cbEle.className = 'click-block'; - $ionicBody.append(cbEle); - cbEle.addEventListener('touchstart', preventClick); - cbEle.addEventListener('mousedown', preventClick); - } - pendingShow = false; - } - } - - function removeClickBlock() { - cbEle && cbEle.classList.add(CSS_HIDE); - } - - return { - show: function(autoExpire) { - pendingShow = true; - $timeout.cancel(fallbackTimer); - fallbackTimer = $timeout(this.hide, autoExpire || 310, false); - addClickBlock(); - }, - hide: function() { - pendingShow = false; - $timeout.cancel(fallbackTimer); - removeClickBlock(); - } - }; -}]); - -/** - * @ngdoc service - * @name $ionicGesture - * @module ionic - * @description An angular service exposing ionic - * {@link ionic.utility:ionic.EventController}'s gestures. - */ -IonicModule -.factory('$ionicGesture', [function() { - return { - /** - * @ngdoc method - * @name $ionicGesture#on - * @description Add an event listener for a gesture on an element. See {@link ionic.utility:ionic.EventController#onGesture}. - * @param {string} eventType The gesture event to listen for. - * @param {function(e)} callback The function to call when the gesture - * happens. - * @param {element} $element The angular element to listen for the event on. - * @param {object} options object. - * @returns {ionic.Gesture} The gesture object (use this to remove the gesture later on). - */ - on: function(eventType, cb, $element, options) { - return window.ionic.onGesture(eventType, cb, $element[0], options); - }, - /** - * @ngdoc method - * @name $ionicGesture#off - * @description Remove an event listener for a gesture on an element. See {@link ionic.utility:ionic.EventController#offGesture}. - * @param {ionic.Gesture} gesture The gesture that should be removed. - * @param {string} eventType The gesture event to remove the listener for. - * @param {function(e)} callback The listener to remove. - */ - off: function(gesture, eventType, cb) { - return window.ionic.offGesture(gesture, eventType, cb); - } - }; -}]); - -/** - * @ngdoc service - * @name $ionicHistory - * @module ionic - * @description - * $ionicHistory keeps track of views as the user navigates through an app. Similar to the way a - * browser behaves, an Ionic app is able to keep track of the previous view, the current view, and - * the forward view (if there is one). However, a typical web browser only keeps track of one - * history stack in a linear fashion. - * - * Unlike a traditional browser environment, apps and webapps have parallel independent histories, - * such as with tabs. Should a user navigate few pages deep on one tab, and then switch to a new - * tab and back, the back button relates not to the previous tab, but to the previous pages - * visited within _that_ tab. - * - * `$ionicHistory` facilitates this parallel history architecture. - */ - -IonicModule -.factory('$ionicHistory', [ - '$rootScope', - '$state', - '$location', - '$window', - '$timeout', - '$ionicViewSwitcher', - '$ionicNavViewDelegate', -function($rootScope, $state, $location, $window, $timeout, $ionicViewSwitcher, $ionicNavViewDelegate) { - - // history actions while navigating views - var ACTION_INITIAL_VIEW = 'initialView'; - var ACTION_NEW_VIEW = 'newView'; - var ACTION_MOVE_BACK = 'moveBack'; - var ACTION_MOVE_FORWARD = 'moveForward'; - - // direction of navigation - var DIRECTION_BACK = 'back'; - var DIRECTION_FORWARD = 'forward'; - var DIRECTION_ENTER = 'enter'; - var DIRECTION_EXIT = 'exit'; - var DIRECTION_SWAP = 'swap'; - var DIRECTION_NONE = 'none'; - - var stateChangeCounter = 0; - var lastStateId, nextViewOptions, deregisterStateChangeListener, nextViewExpireTimer, forcedNav; - - var viewHistory = { - histories: { root: { historyId: 'root', parentHistoryId: null, stack: [], cursor: -1 } }, - views: {}, - backView: null, - forwardView: null, - currentView: null - }; - - var View = function() {}; - View.prototype.initialize = function(data) { - if (data) { - for (var name in data) this[name] = data[name]; - return this; - } - return null; - }; - View.prototype.go = function() { - - if (this.stateName) { - return $state.go(this.stateName, this.stateParams); - } - - if (this.url && this.url !== $location.url()) { - - if (viewHistory.backView === this) { - return $window.history.go(-1); - } else if (viewHistory.forwardView === this) { - return $window.history.go(1); - } - - $location.url(this.url); - } - - return null; - }; - View.prototype.destroy = function() { - if (this.scope) { - this.scope.$destroy && this.scope.$destroy(); - this.scope = null; - } - }; - - - function getViewById(viewId) { - return (viewId ? viewHistory.views[ viewId ] : null); - } - - function getBackView(view) { - return (view ? getViewById(view.backViewId) : null); - } - - function getForwardView(view) { - return (view ? getViewById(view.forwardViewId) : null); - } - - function getHistoryById(historyId) { - return (historyId ? viewHistory.histories[ historyId ] : null); - } - - function getHistory(scope) { - var histObj = getParentHistoryObj(scope); - - if (!viewHistory.histories[ histObj.historyId ]) { - // this history object exists in parent scope, but doesn't - // exist in the history data yet - viewHistory.histories[ histObj.historyId ] = { - historyId: histObj.historyId, - parentHistoryId: getParentHistoryObj(histObj.scope.$parent).historyId, - stack: [], - cursor: -1 - }; - } - return getHistoryById(histObj.historyId); - } - - function getParentHistoryObj(scope) { - var parentScope = scope; - while (parentScope) { - if (parentScope.hasOwnProperty('$historyId')) { - // this parent scope has a historyId - return { historyId: parentScope.$historyId, scope: parentScope }; - } - // nothing found keep climbing up - parentScope = parentScope.$parent; - } - // no history for the parent, use the root - return { historyId: 'root', scope: $rootScope }; - } - - function setNavViews(viewId) { - viewHistory.currentView = getViewById(viewId); - viewHistory.backView = getBackView(viewHistory.currentView); - viewHistory.forwardView = getForwardView(viewHistory.currentView); - } - - function getCurrentStateId() { - var id; - if ($state && $state.current && $state.current.name) { - id = $state.current.name; - if ($state.params) { - for (var key in $state.params) { - if ($state.params.hasOwnProperty(key) && $state.params[key]) { - id += "_" + key + "=" + $state.params[key]; - } - } - } - return id; - } - // if something goes wrong make sure its got a unique stateId - return ionic.Utils.nextUid(); - } - - function getCurrentStateParams() { - var rtn; - if ($state && $state.params) { - for (var key in $state.params) { - if ($state.params.hasOwnProperty(key)) { - rtn = rtn || {}; - rtn[key] = $state.params[key]; - } - } - } - return rtn; - } - - - return { - - register: function(parentScope, viewLocals) { - - var currentStateId = getCurrentStateId(), - hist = getHistory(parentScope), - currentView = viewHistory.currentView, - backView = viewHistory.backView, - forwardView = viewHistory.forwardView, - viewId = null, - action = null, - direction = DIRECTION_NONE, - historyId = hist.historyId, - url = $location.url(), - tmp, x, ele; - - if (lastStateId !== currentStateId) { - lastStateId = currentStateId; - stateChangeCounter++; - } - - if (forcedNav) { - // we've previously set exactly what to do - viewId = forcedNav.viewId; - action = forcedNav.action; - direction = forcedNav.direction; - forcedNav = null; - - } else if (backView && backView.stateId === currentStateId) { - // they went back one, set the old current view as a forward view - viewId = backView.viewId; - historyId = backView.historyId; - action = ACTION_MOVE_BACK; - if (backView.historyId === currentView.historyId) { - // went back in the same history - direction = DIRECTION_BACK; - - } else if (currentView) { - direction = DIRECTION_EXIT; - - tmp = getHistoryById(backView.historyId); - if (tmp && tmp.parentHistoryId === currentView.historyId) { - direction = DIRECTION_ENTER; - - } else { - tmp = getHistoryById(currentView.historyId); - if (tmp && tmp.parentHistoryId === hist.parentHistoryId) { - direction = DIRECTION_SWAP; - } - } - } - - } else if (forwardView && forwardView.stateId === currentStateId) { - // they went to the forward one, set the forward view to no longer a forward view - viewId = forwardView.viewId; - historyId = forwardView.historyId; - action = ACTION_MOVE_FORWARD; - if (forwardView.historyId === currentView.historyId) { - direction = DIRECTION_FORWARD; - - } else if (currentView) { - direction = DIRECTION_EXIT; - - if (currentView.historyId === hist.parentHistoryId) { - direction = DIRECTION_ENTER; - - } else { - tmp = getHistoryById(currentView.historyId); - if (tmp && tmp.parentHistoryId === hist.parentHistoryId) { - direction = DIRECTION_SWAP; - } - } - } - - tmp = getParentHistoryObj(parentScope); - if (forwardView.historyId && tmp.scope) { - // if a history has already been created by the forward view then make sure it stays the same - tmp.scope.$historyId = forwardView.historyId; - historyId = forwardView.historyId; - } - - } else if (currentView && currentView.historyId !== historyId && - hist.cursor > -1 && hist.stack.length > 0 && hist.cursor < hist.stack.length && - hist.stack[hist.cursor].stateId === currentStateId) { - // they just changed to a different history and the history already has views in it - var switchToView = hist.stack[hist.cursor]; - viewId = switchToView.viewId; - historyId = switchToView.historyId; - action = ACTION_MOVE_BACK; - direction = DIRECTION_SWAP; - - tmp = getHistoryById(currentView.historyId); - if (tmp && tmp.parentHistoryId === historyId) { - direction = DIRECTION_EXIT; - - } else { - tmp = getHistoryById(historyId); - if (tmp && tmp.parentHistoryId === currentView.historyId) { - direction = DIRECTION_ENTER; - } - } - - // if switching to a different history, and the history of the view we're switching - // to has an existing back view from a different history than itself, then - // it's back view would be better represented using the current view as its back view - tmp = getViewById(switchToView.backViewId); - if (tmp && switchToView.historyId !== tmp.historyId) { - // the new view is being removed from it's old position in the history and being placed at the top, - // so we need to update any views that reference it as a backview, otherwise there will be infinitely loops - var viewIds = Object.keys(viewHistory.views); - viewIds.forEach(function(viewId) { - var view = viewHistory.views[viewId]; - if ((view.backViewId === switchToView.viewId) && (view.historyId !== switchToView.historyId)) { - view.backViewId = null; - } - }); - - hist.stack[hist.cursor].backViewId = currentView.viewId; - } - - } else { - - // create an element from the viewLocals template - ele = $ionicViewSwitcher.createViewEle(viewLocals); - if (this.isAbstractEle(ele, viewLocals)) { - return { - action: 'abstractView', - direction: DIRECTION_NONE, - ele: ele - }; - } - - // set a new unique viewId - viewId = ionic.Utils.nextUid(); - - if (currentView) { - // set the forward view if there is a current view (ie: if its not the first view) - currentView.forwardViewId = viewId; - - action = ACTION_NEW_VIEW; - - // check if there is a new forward view within the same history - if (forwardView && currentView.stateId !== forwardView.stateId && - currentView.historyId === forwardView.historyId) { - // they navigated to a new view but the stack already has a forward view - // since its a new view remove any forwards that existed - tmp = getHistoryById(forwardView.historyId); - if (tmp) { - // the forward has a history - for (x = tmp.stack.length - 1; x >= forwardView.index; x--) { - // starting from the end destroy all forwards in this history from this point - var stackItem = tmp.stack[x]; - stackItem && stackItem.destroy && stackItem.destroy(); - tmp.stack.splice(x); - } - historyId = forwardView.historyId; - } - } - - // its only moving forward if its in the same history - if (hist.historyId === currentView.historyId) { - direction = DIRECTION_FORWARD; - - } else if (currentView.historyId !== hist.historyId) { - // DB: this is a new view in a different tab - direction = DIRECTION_ENTER; - - tmp = getHistoryById(currentView.historyId); - if (tmp && tmp.parentHistoryId === hist.parentHistoryId) { - direction = DIRECTION_SWAP; - - } else { - tmp = getHistoryById(tmp.parentHistoryId); - if (tmp && tmp.historyId === hist.historyId) { - direction = DIRECTION_EXIT; - } - } - } - - } else { - // there's no current view, so this must be the initial view - action = ACTION_INITIAL_VIEW; - } - - if (stateChangeCounter < 2) { - // views that were spun up on the first load should not animate - direction = DIRECTION_NONE; - } - - // add the new view - viewHistory.views[viewId] = this.createView({ - viewId: viewId, - index: hist.stack.length, - historyId: hist.historyId, - backViewId: (currentView && currentView.viewId ? currentView.viewId : null), - forwardViewId: null, - stateId: currentStateId, - stateName: this.currentStateName(), - stateParams: getCurrentStateParams(), - url: url, - canSwipeBack: canSwipeBack(ele, viewLocals) - }); - - // add the new view to this history's stack - hist.stack.push(viewHistory.views[viewId]); - } - - deregisterStateChangeListener && deregisterStateChangeListener(); - $timeout.cancel(nextViewExpireTimer); - if (nextViewOptions) { - if (nextViewOptions.disableAnimate) direction = DIRECTION_NONE; - if (nextViewOptions.disableBack) viewHistory.views[viewId].backViewId = null; - if (nextViewOptions.historyRoot) { - for (x = 0; x < hist.stack.length; x++) { - if (hist.stack[x].viewId === viewId) { - hist.stack[x].index = 0; - hist.stack[x].backViewId = hist.stack[x].forwardViewId = null; - } else { - delete viewHistory.views[hist.stack[x].viewId]; - } - } - hist.stack = [viewHistory.views[viewId]]; - } - nextViewOptions = null; - } - - setNavViews(viewId); - - if (viewHistory.backView && historyId == viewHistory.backView.historyId && currentStateId == viewHistory.backView.stateId && url == viewHistory.backView.url) { - for (x = 0; x < hist.stack.length; x++) { - if (hist.stack[x].viewId == viewId) { - action = 'dupNav'; - direction = DIRECTION_NONE; - if (x > 0) { - hist.stack[x - 1].forwardViewId = null; - } - viewHistory.forwardView = null; - viewHistory.currentView.index = viewHistory.backView.index; - viewHistory.currentView.backViewId = viewHistory.backView.backViewId; - viewHistory.backView = getBackView(viewHistory.backView); - hist.stack.splice(x, 1); - break; - } - } - } - - hist.cursor = viewHistory.currentView.index; - - return { - viewId: viewId, - action: action, - direction: direction, - historyId: historyId, - enableBack: this.enabledBack(viewHistory.currentView), - isHistoryRoot: (viewHistory.currentView.index === 0), - ele: ele - }; - }, - - registerHistory: function(scope) { - scope.$historyId = ionic.Utils.nextUid(); - }, - - createView: function(data) { - var newView = new View(); - return newView.initialize(data); - }, - - getViewById: getViewById, - - /** - * @ngdoc method - * @name $ionicHistory#viewHistory - * @description The app's view history data, such as all the views and histories, along - * with how they are ordered and linked together within the navigation stack. - * @returns {object} Returns an object containing the apps view history data. - */ - viewHistory: function() { - return viewHistory; - }, - - /** - * @ngdoc method - * @name $ionicHistory#currentView - * @description The app's current view. - * @returns {object} Returns the current view. - */ - currentView: function(view) { - if (arguments.length) { - viewHistory.currentView = view; - } - return viewHistory.currentView; - }, - - /** - * @ngdoc method - * @name $ionicHistory#currentHistoryId - * @description The ID of the history stack which is the parent container of the current view. - * @returns {string} Returns the current history ID. - */ - currentHistoryId: function() { - return viewHistory.currentView ? viewHistory.currentView.historyId : null; - }, - - /** - * @ngdoc method - * @name $ionicHistory#currentTitle - * @description Gets and sets the current view's title. - * @param {string=} val The title to update the current view with. - * @returns {string} Returns the current view's title. - */ - currentTitle: function(val) { - if (viewHistory.currentView) { - if (arguments.length) { - viewHistory.currentView.title = val; - } - return viewHistory.currentView.title; - } - }, - - /** - * @ngdoc method - * @name $ionicHistory#backView - * @description Returns the view that was before the current view in the history stack. - * If the user navigated from View A to View B, then View A would be the back view, and - * View B would be the current view. - * @returns {object} Returns the back view. - */ - backView: function(view) { - if (arguments.length) { - viewHistory.backView = view; - } - return viewHistory.backView; - }, - - /** - * @ngdoc method - * @name $ionicHistory#backTitle - * @description Gets the back view's title. - * @returns {string} Returns the back view's title. - */ - backTitle: function(view) { - var backView = (view && getViewById(view.backViewId)) || viewHistory.backView; - return backView && backView.title; - }, - - /** - * @ngdoc method - * @name $ionicHistory#forwardView - * @description Returns the view that was in front of the current view in the history stack. - * A forward view would exist if the user navigated from View A to View B, then - * navigated back to View A. At this point then View B would be the forward view, and View - * A would be the current view. - * @returns {object} Returns the forward view. - */ - forwardView: function(view) { - if (arguments.length) { - viewHistory.forwardView = view; - } - return viewHistory.forwardView; - }, - - /** - * @ngdoc method - * @name $ionicHistory#currentStateName - * @description Returns the current state name. - * @returns {string} - */ - currentStateName: function() { - return ($state && $state.current ? $state.current.name : null); - }, - - isCurrentStateNavView: function(navView) { - return !!($state && $state.current && $state.current.views && $state.current.views[navView]); - }, - - goToHistoryRoot: function(historyId) { - if (historyId) { - var hist = getHistoryById(historyId); - if (hist && hist.stack.length) { - if (viewHistory.currentView && viewHistory.currentView.viewId === hist.stack[0].viewId) { - return; - } - forcedNav = { - viewId: hist.stack[0].viewId, - action: ACTION_MOVE_BACK, - direction: DIRECTION_BACK - }; - hist.stack[0].go(); - } - } - }, - - /** - * @ngdoc method - * @name $ionicHistory#goBack - * @param {number=} backCount Optional negative integer setting how many views to go - * back. By default it'll go back one view by using the value `-1`. To go back two - * views you would use `-2`. If the number goes farther back than the number of views - * in the current history's stack then it'll go to the first view in the current history's - * stack. If the number is zero or greater then it'll do nothing. It also does not - * cross history stacks, meaning it can only go as far back as the current history. - * @description Navigates the app to the back view, if a back view exists. - */ - goBack: function(backCount) { - if (isDefined(backCount) && backCount !== -1) { - if (backCount > -1) return; - - var currentHistory = viewHistory.histories[this.currentHistoryId()]; - var newCursor = currentHistory.cursor + backCount + 1; - if (newCursor < 1) { - newCursor = 1; - } - - currentHistory.cursor = newCursor; - setNavViews(currentHistory.stack[newCursor].viewId); - - var cursor = newCursor - 1; - var clearStateIds = []; - var fwdView = getViewById(currentHistory.stack[cursor].forwardViewId); - while (fwdView) { - clearStateIds.push(fwdView.stateId || fwdView.viewId); - cursor++; - if (cursor >= currentHistory.stack.length) break; - fwdView = getViewById(currentHistory.stack[cursor].forwardViewId); - } - - var self = this; - if (clearStateIds.length) { - $timeout(function() { - self.clearCache(clearStateIds); - }, 300); - } - } - - viewHistory.backView && viewHistory.backView.go(); - }, - - /** - * @ngdoc method - * @name $ionicHistory#removeBackView - * @description Remove the previous view from the history completely, including the - * cached element and scope (if they exist). - */ - removeBackView: function() { - var self = this; - var currentHistory = viewHistory.histories[this.currentHistoryId()]; - var currentCursor = currentHistory.cursor; - - var currentView = currentHistory.stack[currentCursor]; - var backView = currentHistory.stack[currentCursor - 1]; - var replacementView = currentHistory.stack[currentCursor - 2]; - - // fail if we dont have enough views in the history - if (!backView || !replacementView) { - return; - } - - // remove the old backView and the cached element/scope - currentHistory.stack.splice(currentCursor - 1, 1); - self.clearCache([backView.viewId]); - // make the replacementView and currentView point to each other (bypass the old backView) - currentView.backViewId = replacementView.viewId; - currentView.index = currentView.index - 1; - replacementView.forwardViewId = currentView.viewId; - // update the cursor and set new backView - viewHistory.backView = replacementView; - currentHistory.currentCursor += -1; - }, - - enabledBack: function(view) { - var backView = getBackView(view); - return !!(backView && backView.historyId === view.historyId); - }, - - /** - * @ngdoc method - * @name $ionicHistory#clearHistory - * @description Clears out the app's entire history, except for the current view. - */ - clearHistory: function() { - var - histories = viewHistory.histories, - currentView = viewHistory.currentView; - - if (histories) { - for (var historyId in histories) { - - if (histories[historyId].stack) { - histories[historyId].stack = []; - histories[historyId].cursor = -1; - } - - if (currentView && currentView.historyId === historyId) { - currentView.backViewId = currentView.forwardViewId = null; - histories[historyId].stack.push(currentView); - } else if (histories[historyId].destroy) { - histories[historyId].destroy(); - } - - } - } - - for (var viewId in viewHistory.views) { - if (viewId !== currentView.viewId) { - delete viewHistory.views[viewId]; - } - } - - if (currentView) { - setNavViews(currentView.viewId); - } - }, - - /** - * @ngdoc method - * @name $ionicHistory#clearCache - * @return promise - * @description Removes all cached views within every {@link ionic.directive:ionNavView}. - * This both removes the view element from the DOM, and destroy it's scope. - */ - clearCache: function(stateIds) { - return $timeout(function() { - $ionicNavViewDelegate._instances.forEach(function(instance) { - instance.clearCache(stateIds); - }); - }); - }, - - /** - * @ngdoc method - * @name $ionicHistory#nextViewOptions - * @description Sets options for the next view. This method can be useful to override - * certain view/transition defaults right before a view transition happens. For example, - * the {@link ionic.directive:menuClose} directive uses this method internally to ensure - * an animated view transition does not happen when a side menu is open, and also sets - * the next view as the root of its history stack. After the transition these options - * are set back to null. - * - * Available options: - * - * * `disableAnimate`: Do not animate the next transition. - * * `disableBack`: The next view should forget its back view, and set it to null. - * * `historyRoot`: The next view should become the root view in its history stack. - * - * ```js - * $ionicHistory.nextViewOptions({ - * disableAnimate: true, - * disableBack: true - * }); - * ``` - */ - nextViewOptions: function(opts) { - deregisterStateChangeListener && deregisterStateChangeListener(); - if (arguments.length) { - $timeout.cancel(nextViewExpireTimer); - if (opts === null) { - nextViewOptions = opts; - } else { - nextViewOptions = nextViewOptions || {}; - extend(nextViewOptions, opts); - if (nextViewOptions.expire) { - deregisterStateChangeListener = $rootScope.$on('$stateChangeSuccess', function() { - nextViewExpireTimer = $timeout(function() { - nextViewOptions = null; - }, nextViewOptions.expire); - }); - } - } - } - return nextViewOptions; - }, - - isAbstractEle: function(ele, viewLocals) { - if (viewLocals && viewLocals.$$state && viewLocals.$$state.self['abstract']) { - return true; - } - return !!(ele && (isAbstractTag(ele) || isAbstractTag(ele.children()))); - }, - - isActiveScope: function(scope) { - if (!scope) return false; - - var climbScope = scope; - var currentHistoryId = this.currentHistoryId(); - var foundHistoryId; - - while (climbScope) { - if (climbScope.$$disconnected) { - return false; - } - - if (!foundHistoryId && climbScope.hasOwnProperty('$historyId')) { - foundHistoryId = true; - } - - if (currentHistoryId) { - if (climbScope.hasOwnProperty('$historyId') && currentHistoryId == climbScope.$historyId) { - return true; - } - if (climbScope.hasOwnProperty('$activeHistoryId')) { - if (currentHistoryId == climbScope.$activeHistoryId) { - if (climbScope.hasOwnProperty('$historyId')) { - return true; - } - if (!foundHistoryId) { - return true; - } - } - } - } - - if (foundHistoryId && climbScope.hasOwnProperty('$activeHistoryId')) { - foundHistoryId = false; - } - - climbScope = climbScope.$parent; - } - - return currentHistoryId ? currentHistoryId == 'root' : true; - } - - }; - - function isAbstractTag(ele) { - return ele && ele.length && /ion-side-menus|ion-tabs/i.test(ele[0].tagName); - } - - function canSwipeBack(ele, viewLocals) { - if (viewLocals && viewLocals.$$state && viewLocals.$$state.self.canSwipeBack === false) { - return false; - } - if (ele && ele.attr('can-swipe-back') === 'false') { - return false; - } - var eleChild = ele.find('ion-view'); - if (eleChild && eleChild.attr('can-swipe-back') === 'false') { - return false; - } - return true; - } - -}]) - -.run([ - '$rootScope', - '$state', - '$location', - '$document', - '$ionicPlatform', - '$ionicHistory', - 'IONIC_BACK_PRIORITY', -function($rootScope, $state, $location, $document, $ionicPlatform, $ionicHistory, IONIC_BACK_PRIORITY) { - - // always reset the keyboard state when change stage - $rootScope.$on('$ionicView.beforeEnter', function() { - ionic.keyboard && ionic.keyboard.hide && ionic.keyboard.hide(); - }); - - $rootScope.$on('$ionicHistory.change', function(e, data) { - if (!data) return null; - - var viewHistory = $ionicHistory.viewHistory(); - - var hist = (data.historyId ? viewHistory.histories[ data.historyId ] : null); - if (hist && hist.cursor > -1 && hist.cursor < hist.stack.length) { - // the history they're going to already exists - // go to it's last view in its stack - var view = hist.stack[ hist.cursor ]; - return view.go(data); - } - - // this history does not have a URL, but it does have a uiSref - // figure out its URL from the uiSref - if (!data.url && data.uiSref) { - data.url = $state.href(data.uiSref); - } - - if (data.url) { - // don't let it start with a #, messes with $location.url() - if (data.url.indexOf('#') === 0) { - data.url = data.url.replace('#', ''); - } - if (data.url !== $location.url()) { - // we've got a good URL, ready GO! - $location.url(data.url); - } - } - }); - - $rootScope.$ionicGoBack = function(backCount) { - $ionicHistory.goBack(backCount); - }; - - // Set the document title when a new view is shown - $rootScope.$on('$ionicView.afterEnter', function(ev, data) { - if (data && data.title) { - $document[0].title = data.title; - } - }); - - // Triggered when devices with a hardware back button (Android) is clicked by the user - // This is a Cordova/Phonegap platform specifc method - function onHardwareBackButton(e) { - var backView = $ionicHistory.backView(); - if (backView) { - // there is a back view, go to it - backView.go(); - } else { - // there is no back view, so close the app instead - ionic.Platform.exitApp(); - } - e.preventDefault(); - return false; - } - $ionicPlatform.registerBackButtonAction( - onHardwareBackButton, - IONIC_BACK_PRIORITY.view - ); - -}]); - -/** - * @ngdoc provider - * @name $ionicConfigProvider - * @module ionic - * @description - * Ionic automatically takes platform configurations into account to adjust things like what - * transition style to use and whether tab icons should show on the top or bottom. For example, - * iOS will move forward by transitioning the entering view from right to center and the leaving - * view from center to left. However, Android will transition with the entering view going from - * bottom to center, covering the previous view, which remains stationary. It should be noted - * that when a platform is not iOS or Android, then it'll default to iOS. So if you are - * developing on a desktop browser, it's going to take on iOS default configs. - * - * These configs can be changed using the `$ionicConfigProvider` during the configuration phase - * of your app. Additionally, `$ionicConfig` can also set and get config values during the run - * phase and within the app itself. - * - * By default, all base config variables are set to `'platform'`, which means it'll take on the - * default config of the platform on which it's running. Config variables can be set at this - * level so all platforms follow the same setting, rather than its platform config. - * The following code would set the same config variable for all platforms: - * - * ```js - * $ionicConfigProvider.views.maxCache(10); - * ``` - * - * Additionally, each platform can have its own config within the `$ionicConfigProvider.platform` - * property. The config below would only apply to Android devices. - * - * ```js - * $ionicConfigProvider.platform.android.views.maxCache(5); - * ``` - * - * @usage - * ```js - * var myApp = angular.module('reallyCoolApp', ['ionic']); - * - * myApp.config(function($ionicConfigProvider) { - * $ionicConfigProvider.views.maxCache(5); - * - * // note that you can also chain configs - * $ionicConfigProvider.backButton.text('Go Back').icon('ion-chevron-left'); - * }); - * ``` - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#views.transition - * @description Animation style when transitioning between views. Default `platform`. - * - * @param {string} transition Which style of view transitioning to use. - * - * * `platform`: Dynamically choose the correct transition style depending on the platform - * the app is running from. If the platform is not `ios` or `android` then it will default - * to `ios`. - * * `ios`: iOS style transition. - * * `android`: Android style transition. - * * `none`: Do not perform animated transitions. - * - * @returns {string} value - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#views.maxCache - * @description Maximum number of view elements to cache in the DOM. When the max number is - * exceeded, the view with the longest time period since it was accessed is removed. Views that - * stay in the DOM cache the view's scope, current state, and scroll position. The scope is - * disconnected from the `$watch` cycle when it is cached and reconnected when it enters again. - * When the maximum cache is `0`, the leaving view's element will be removed from the DOM after - * each view transition, and the next time the same view is shown, it will have to re-compile, - * attach to the DOM, and link the element again. This disables caching, in effect. - * @param {number} maxNumber Maximum number of views to retain. Default `10`. - * @returns {number} How many views Ionic will hold onto until the a view is removed. - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#views.forwardCache - * @description By default, when navigating, views that were recently visited are cached, and - * the same instance data and DOM elements are referenced when navigating back. However, when - * navigating back in the history, the "forward" views are removed from the cache. If you - * navigate forward to the same view again, it'll create a new DOM element and controller - * instance. Basically, any forward views are reset each time. Set this config to `true` to have - * forward views cached and not reset on each load. - * @param {boolean} value - * @returns {boolean} - */ - - /** - * @ngdoc method - * @name $ionicConfigProvider#views.swipeBackEnabled - * @description By default on iOS devices, swipe to go back functionality is enabled by default. - * This method can be used to disable it globally, or on a per-view basis. - * Note: This functionality is only supported on iOS. - * @param {boolean} value - * @returns {boolean} - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#scrolling.jsScrolling - * @description Whether to use JS or Native scrolling. Defaults to native scrolling. Setting this to - * `true` has the same effect as setting each `ion-content` to have `overflow-scroll='false'`. - * @param {boolean} value Defaults to `false` as of Ionic 1.2 - * @returns {boolean} - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#backButton.icon - * @description Back button icon. - * @param {string} value - * @returns {string} - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#backButton.text - * @description Back button text. - * @param {string} value Defaults to `Back`. - * @returns {string} - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#backButton.previousTitleText - * @description If the previous title text should become the back button text. This - * is the default for iOS. - * @param {boolean} value - * @returns {boolean} - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#form.checkbox - * @description Checkbox style. Android defaults to `square` and iOS defaults to `circle`. - * @param {string} value - * @returns {string} - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#form.toggle - * @description Toggle item style. Android defaults to `small` and iOS defaults to `large`. - * @param {string} value - * @returns {string} - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#spinner.icon - * @description Default spinner icon to use. - * @param {string} value Can be: `android`, `ios`, `ios-small`, `bubbles`, `circles`, `crescent`, - * `dots`, `lines`, `ripple`, or `spiral`. - * @returns {string} - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#tabs.style - * @description Tab style. Android defaults to `striped` and iOS defaults to `standard`. - * @param {string} value Available values include `striped` and `standard`. - * @returns {string} - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#tabs.position - * @description Tab position. Android defaults to `top` and iOS defaults to `bottom`. - * @param {string} value Available values include `top` and `bottom`. - * @returns {string} - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#templates.maxPrefetch - * @description Sets the maximum number of templates to prefetch from the templateUrls defined in - * $stateProvider.state. If set to `0`, the user will have to wait - * for a template to be fetched the first time when navigating to a new page. Default `30`. - * @param {integer} value Max number of template to prefetch from the templateUrls defined in - * `$stateProvider.state()`. - * @returns {integer} - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#navBar.alignTitle - * @description Which side of the navBar to align the title. Default `center`. - * - * @param {string} value side of the navBar to align the title. - * - * * `platform`: Dynamically choose the correct title style depending on the platform - * the app is running from. If the platform is `ios`, it will default to `center`. - * If the platform is `android`, it will default to `left`. If the platform is not - * `ios` or `android`, it will default to `center`. - * - * * `left`: Left align the title in the navBar - * * `center`: Center align the title in the navBar - * * `right`: Right align the title in the navBar. - * - * @returns {string} value - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#navBar.positionPrimaryButtons - * @description Which side of the navBar to align the primary navBar buttons. Default `left`. - * - * @param {string} value side of the navBar to align the primary navBar buttons. - * - * * `platform`: Dynamically choose the correct title style depending on the platform - * the app is running from. If the platform is `ios`, it will default to `left`. - * If the platform is `android`, it will default to `right`. If the platform is not - * `ios` or `android`, it will default to `left`. - * - * * `left`: Left align the primary navBar buttons in the navBar - * * `right`: Right align the primary navBar buttons in the navBar. - * - * @returns {string} value - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#navBar.positionSecondaryButtons - * @description Which side of the navBar to align the secondary navBar buttons. Default `right`. - * - * @param {string} value side of the navBar to align the secondary navBar buttons. - * - * * `platform`: Dynamically choose the correct title style depending on the platform - * the app is running from. If the platform is `ios`, it will default to `right`. - * If the platform is `android`, it will default to `right`. If the platform is not - * `ios` or `android`, it will default to `right`. - * - * * `left`: Left align the secondary navBar buttons in the navBar - * * `right`: Right align the secondary navBar buttons in the navBar. - * - * @returns {string} value - */ - -IonicModule -.provider('$ionicConfig', function() { - - var provider = this; - provider.platform = {}; - var PLATFORM = 'platform'; - - var configProperties = { - views: { - maxCache: PLATFORM, - forwardCache: PLATFORM, - transition: PLATFORM, - swipeBackEnabled: PLATFORM, - swipeBackHitWidth: PLATFORM - }, - navBar: { - alignTitle: PLATFORM, - positionPrimaryButtons: PLATFORM, - positionSecondaryButtons: PLATFORM, - transition: PLATFORM - }, - backButton: { - icon: PLATFORM, - text: PLATFORM, - previousTitleText: PLATFORM - }, - form: { - checkbox: PLATFORM, - toggle: PLATFORM - }, - scrolling: { - jsScrolling: PLATFORM - }, - spinner: { - icon: PLATFORM - }, - tabs: { - style: PLATFORM, - position: PLATFORM - }, - templates: { - maxPrefetch: PLATFORM - }, - platform: {} - }; - createConfig(configProperties, provider, ''); - - - - // Default - // ------------------------- - setPlatformConfig('default', { - - views: { - maxCache: 10, - forwardCache: false, - transition: 'ios', - swipeBackEnabled: true, - swipeBackHitWidth: 45 - }, - - navBar: { - alignTitle: 'center', - positionPrimaryButtons: 'left', - positionSecondaryButtons: 'right', - transition: 'view' - }, - - backButton: { - icon: 'ion-ios-arrow-back', - text: 'Back', - previousTitleText: true - }, - - form: { - checkbox: 'circle', - toggle: 'large' - }, - - scrolling: { - jsScrolling: true - }, - - spinner: { - icon: 'ios' - }, - - tabs: { - style: 'standard', - position: 'bottom' - }, - - templates: { - maxPrefetch: 30 - } - - }); - - - - // iOS (it is the default already) - // ------------------------- - setPlatformConfig('ios', {}); - - - - // Android - // ------------------------- - setPlatformConfig('android', { - - views: { - transition: 'android', - swipeBackEnabled: false - }, - - navBar: { - alignTitle: 'left', - positionPrimaryButtons: 'right', - positionSecondaryButtons: 'right' - }, - - backButton: { - icon: 'ion-android-arrow-back', - text: false, - previousTitleText: false - }, - - form: { - checkbox: 'square', - toggle: 'small' - }, - - spinner: { - icon: 'android' - }, - - tabs: { - style: 'striped', - position: 'top' - }, - - scrolling: { - jsScrolling: false - } - }); - - // Windows Phone - // ------------------------- - setPlatformConfig('windowsphone', { - //scrolling: { - // jsScrolling: false - //} - spinner: { - icon: 'android' - } - }); - - - provider.transitions = { - views: {}, - navBar: {} - }; - - - // iOS Transitions - // ----------------------- - provider.transitions.views.ios = function(enteringEle, leavingEle, direction, shouldAnimate) { - - function setStyles(ele, opacity, x, boxShadowOpacity) { - var css = {}; - css[ionic.CSS.TRANSITION_DURATION] = d.shouldAnimate ? '' : 0; - css.opacity = opacity; - if (boxShadowOpacity > -1) { - css.boxShadow = '0 0 10px rgba(0,0,0,' + (d.shouldAnimate ? boxShadowOpacity * 0.45 : 0.3) + ')'; - } - css[ionic.CSS.TRANSFORM] = 'translate3d(' + x + '%,0,0)'; - ionic.DomUtil.cachedStyles(ele, css); - } - - var d = { - run: function(step) { - if (direction == 'forward') { - setStyles(enteringEle, 1, (1 - step) * 99, 1 - step); // starting at 98% prevents a flicker - setStyles(leavingEle, (1 - 0.1 * step), step * -33, -1); - - } else if (direction == 'back') { - setStyles(enteringEle, (1 - 0.1 * (1 - step)), (1 - step) * -33, -1); - setStyles(leavingEle, 1, step * 100, 1 - step); - - } else { - // swap, enter, exit - setStyles(enteringEle, 1, 0, -1); - setStyles(leavingEle, 0, 0, -1); - } - }, - shouldAnimate: shouldAnimate && (direction == 'forward' || direction == 'back') - }; - - return d; - }; - - provider.transitions.navBar.ios = function(enteringHeaderBar, leavingHeaderBar, direction, shouldAnimate) { - - function setStyles(ctrl, opacity, titleX, backTextX) { - var css = {}; - css[ionic.CSS.TRANSITION_DURATION] = d.shouldAnimate ? '' : '0ms'; - css.opacity = opacity === 1 ? '' : opacity; - - ctrl.setCss('buttons-left', css); - ctrl.setCss('buttons-right', css); - ctrl.setCss('back-button', css); - - css[ionic.CSS.TRANSFORM] = 'translate3d(' + backTextX + 'px,0,0)'; - ctrl.setCss('back-text', css); - - css[ionic.CSS.TRANSFORM] = 'translate3d(' + titleX + 'px,0,0)'; - ctrl.setCss('title', css); - } - - function enter(ctrlA, ctrlB, step) { - if (!ctrlA || !ctrlB) return; - var titleX = (ctrlA.titleTextX() + ctrlA.titleWidth()) * (1 - step); - var backTextX = (ctrlB && (ctrlB.titleTextX() - ctrlA.backButtonTextLeft()) * (1 - step)) || 0; - setStyles(ctrlA, step, titleX, backTextX); - } - - function leave(ctrlA, ctrlB, step) { - if (!ctrlA || !ctrlB) return; - var titleX = (-(ctrlA.titleTextX() - ctrlB.backButtonTextLeft()) - (ctrlA.titleLeftRight())) * step; - setStyles(ctrlA, 1 - step, titleX, 0); - } - - var d = { - run: function(step) { - var enteringHeaderCtrl = enteringHeaderBar.controller(); - var leavingHeaderCtrl = leavingHeaderBar && leavingHeaderBar.controller(); - if (d.direction == 'back') { - leave(enteringHeaderCtrl, leavingHeaderCtrl, 1 - step); - enter(leavingHeaderCtrl, enteringHeaderCtrl, 1 - step); - } else { - enter(enteringHeaderCtrl, leavingHeaderCtrl, step); - leave(leavingHeaderCtrl, enteringHeaderCtrl, step); - } - }, - direction: direction, - shouldAnimate: shouldAnimate && (direction == 'forward' || direction == 'back') - }; - - return d; - }; - - - // Android Transitions - // ----------------------- - - provider.transitions.views.android = function(enteringEle, leavingEle, direction, shouldAnimate) { - shouldAnimate = shouldAnimate && (direction == 'forward' || direction == 'back'); - - function setStyles(ele, x, opacity) { - var css = {}; - css[ionic.CSS.TRANSITION_DURATION] = d.shouldAnimate ? '' : 0; - css[ionic.CSS.TRANSFORM] = 'translate3d(' + x + '%,0,0)'; - css.opacity = opacity; - ionic.DomUtil.cachedStyles(ele, css); - } - - var d = { - run: function(step) { - if (direction == 'forward') { - setStyles(enteringEle, (1 - step) * 99, 1); // starting at 98% prevents a flicker - setStyles(leavingEle, step * -100, 1); - - } else if (direction == 'back') { - setStyles(enteringEle, (1 - step) * -100, 1); - setStyles(leavingEle, step * 100, 1); - - } else { - // swap, enter, exit - setStyles(enteringEle, 0, 1); - setStyles(leavingEle, 0, 0); - } - }, - shouldAnimate: shouldAnimate - }; - - return d; - }; - - provider.transitions.navBar.android = function(enteringHeaderBar, leavingHeaderBar, direction, shouldAnimate) { - - function setStyles(ctrl, opacity) { - if (!ctrl) return; - var css = {}; - css.opacity = opacity === 1 ? '' : opacity; - - ctrl.setCss('buttons-left', css); - ctrl.setCss('buttons-right', css); - ctrl.setCss('back-button', css); - ctrl.setCss('back-text', css); - ctrl.setCss('title', css); - } - - return { - run: function(step) { - setStyles(enteringHeaderBar.controller(), step); - setStyles(leavingHeaderBar && leavingHeaderBar.controller(), 1 - step); - }, - shouldAnimate: shouldAnimate && (direction == 'forward' || direction == 'back') - }; - }; - - - // No Transition - // ----------------------- - - provider.transitions.views.none = function(enteringEle, leavingEle) { - return { - run: function(step) { - provider.transitions.views.android(enteringEle, leavingEle, false, false).run(step); - }, - shouldAnimate: false - }; - }; - - provider.transitions.navBar.none = function(enteringHeaderBar, leavingHeaderBar) { - return { - run: function(step) { - provider.transitions.navBar.ios(enteringHeaderBar, leavingHeaderBar, false, false).run(step); - provider.transitions.navBar.android(enteringHeaderBar, leavingHeaderBar, false, false).run(step); - }, - shouldAnimate: false - }; - }; - - - // private: used to set platform configs - function setPlatformConfig(platformName, platformConfigs) { - configProperties.platform[platformName] = platformConfigs; - provider.platform[platformName] = {}; - - addConfig(configProperties, configProperties.platform[platformName]); - - createConfig(configProperties.platform[platformName], provider.platform[platformName], ''); - } - - - // private: used to recursively add new platform configs - function addConfig(configObj, platformObj) { - for (var n in configObj) { - if (n != PLATFORM && configObj.hasOwnProperty(n)) { - if (angular.isObject(configObj[n])) { - if (!isDefined(platformObj[n])) { - platformObj[n] = {}; - } - addConfig(configObj[n], platformObj[n]); - - } else if (!isDefined(platformObj[n])) { - platformObj[n] = null; - } - } - } - } - - - // private: create methods for each config to get/set - function createConfig(configObj, providerObj, platformPath) { - forEach(configObj, function(value, namespace) { - - if (angular.isObject(configObj[namespace])) { - // recursively drill down the config object so we can create a method for each one - providerObj[namespace] = {}; - createConfig(configObj[namespace], providerObj[namespace], platformPath + '.' + namespace); - - } else { - // create a method for the provider/config methods that will be exposed - providerObj[namespace] = function(newValue) { - if (arguments.length) { - configObj[namespace] = newValue; - return providerObj; - } - if (configObj[namespace] == PLATFORM) { - // if the config is set to 'platform', then get this config's platform value - var platformConfig = stringObj(configProperties.platform, ionic.Platform.platform() + platformPath + '.' + namespace); - if (platformConfig || platformConfig === false) { - return platformConfig; - } - // didnt find a specific platform config, now try the default - return stringObj(configProperties.platform, 'default' + platformPath + '.' + namespace); - } - return configObj[namespace]; - }; - } - - }); - } - - function stringObj(obj, str) { - str = str.split("."); - for (var i = 0; i < str.length; i++) { - if (obj && isDefined(obj[str[i]])) { - obj = obj[str[i]]; - } else { - return null; - } - } - return obj; - } - - provider.setPlatformConfig = setPlatformConfig; - - - // private: Service definition for internal Ionic use - /** - * @ngdoc service - * @name $ionicConfig - * @module ionic - * @private - */ - provider.$get = function() { - return provider; - }; -}) -// Fix for URLs in Cordova apps on Windows Phone -// http://blogs.msdn.com/b/msdn_answers/archive/2015/02/10/ -// running-cordova-apps-on-windows-and-windows-phone-8-1-using-ionic-angularjs-and-other-frameworks.aspx -.config(['$compileProvider', function($compileProvider) { - $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|sms|tel|geo|ftp|mailto|file|ghttps?|ms-appx-web|ms-appx|x-wmapp0):/); - $compileProvider.imgSrcSanitizationWhitelist(/^\s*(https?|ftp|file|content|blob|ms-appx|ms-appx-web|x-wmapp0):|data:image\//); -}]); - - -var LOADING_TPL = - '
      ' + - '
      ' + - '
      ' + - '
      '; - -/** - * @ngdoc service - * @name $ionicLoading - * @module ionic - * @description - * An overlay that can be used to indicate activity while blocking user - * interaction. - * - * @usage - * ```js - * angular.module('LoadingApp', ['ionic']) - * .controller('LoadingCtrl', function($scope, $ionicLoading) { - * $scope.show = function() { - * $ionicLoading.show({ - * template: 'Loading...', - * duration: 3000 - * }).then(function(){ - * console.log("The loading indicator is now displayed"); - * }); - * }; - * $scope.hide = function(){ - * $ionicLoading.hide().then(function(){ - * console.log("The loading indicator is now hidden"); - * }); - * }; - * }); - * ``` - */ -/** - * @ngdoc object - * @name $ionicLoadingConfig - * @module ionic - * @description - * Set the default options to be passed to the {@link ionic.service:$ionicLoading} service. - * - * @usage - * ```js - * var app = angular.module('myApp', ['ionic']) - * app.constant('$ionicLoadingConfig', { - * template: 'Default Loading Template...' - * }); - * app.controller('AppCtrl', function($scope, $ionicLoading) { - * $scope.showLoading = function() { - * //options default to values in $ionicLoadingConfig - * $ionicLoading.show().then(function(){ - * console.log("The loading indicator is now displayed"); - * }); - * }; - * }); - * ``` - */ -IonicModule -.constant('$ionicLoadingConfig', { - template: '' -}) -.factory('$ionicLoading', [ - '$ionicLoadingConfig', - '$ionicBody', - '$ionicTemplateLoader', - '$ionicBackdrop', - '$timeout', - '$q', - '$log', - '$compile', - '$ionicPlatform', - '$rootScope', - 'IONIC_BACK_PRIORITY', -function($ionicLoadingConfig, $ionicBody, $ionicTemplateLoader, $ionicBackdrop, $timeout, $q, $log, $compile, $ionicPlatform, $rootScope, IONIC_BACK_PRIORITY) { - - var loaderInstance; - //default values - var deregisterBackAction = noop; - var deregisterStateListener1 = noop; - var deregisterStateListener2 = noop; - var loadingShowDelay = $q.when(); - - return { - /** - * @ngdoc method - * @name $ionicLoading#show - * @description Shows a loading indicator. If the indicator is already shown, - * it will set the options given and keep the indicator shown. - * @returns {promise} A promise which is resolved when the loading indicator is presented. - * @param {object} opts The options for the loading indicator. Available properties: - * - `{string=}` `template` The html content of the indicator. - * - `{string=}` `templateUrl` The url of an html template to load as the content of the indicator. - * - `{object=}` `scope` The scope to be a child of. Default: creates a child of $rootScope. - * - `{boolean=}` `noBackdrop` Whether to hide the backdrop. By default it will be shown. - * - `{boolean=}` `hideOnStateChange` Whether to hide the loading spinner when navigating - * to a new state. Default false. - * - `{number=}` `delay` How many milliseconds to delay showing the indicator. By default there is no delay. - * - `{number=}` `duration` How many milliseconds to wait until automatically - * hiding the indicator. By default, the indicator will be shown until `.hide()` is called. - */ - show: showLoader, - /** - * @ngdoc method - * @name $ionicLoading#hide - * @description Hides the loading indicator, if shown. - * @returns {promise} A promise which is resolved when the loading indicator is hidden. - */ - hide: hideLoader, - /** - * @private for testing - */ - _getLoader: getLoader - }; - - function getLoader() { - if (!loaderInstance) { - loaderInstance = $ionicTemplateLoader.compile({ - template: LOADING_TPL, - appendTo: $ionicBody.get() - }) - .then(function(self) { - self.show = function(options) { - var templatePromise = options.templateUrl ? - $ionicTemplateLoader.load(options.templateUrl) : - //options.content: deprecated - $q.when(options.template || options.content || ''); - - self.scope = options.scope || self.scope; - - if (!self.isShown) { - //options.showBackdrop: deprecated - self.hasBackdrop = !options.noBackdrop && options.showBackdrop !== false; - if (self.hasBackdrop) { - $ionicBackdrop.retain(); - $ionicBackdrop.getElement().addClass('backdrop-loading'); - } - } - - if (options.duration) { - $timeout.cancel(self.durationTimeout); - self.durationTimeout = $timeout( - angular.bind(self, self.hide), - +options.duration - ); - } - - deregisterBackAction(); - //Disable hardware back button while loading - deregisterBackAction = $ionicPlatform.registerBackButtonAction( - noop, - IONIC_BACK_PRIORITY.loading - ); - - templatePromise.then(function(html) { - if (html) { - var loading = self.element.children(); - loading.html(html); - $compile(loading.contents())(self.scope); - } - - //Don't show until template changes - if (self.isShown) { - self.element.addClass('visible'); - ionic.requestAnimationFrame(function() { - if (self.isShown) { - self.element.addClass('active'); - $ionicBody.addClass('loading-active'); - } - }); - } - }); - - self.isShown = true; - }; - self.hide = function() { - - deregisterBackAction(); - if (self.isShown) { - if (self.hasBackdrop) { - $ionicBackdrop.release(); - $ionicBackdrop.getElement().removeClass('backdrop-loading'); - } - self.element.removeClass('active'); - $ionicBody.removeClass('loading-active'); - self.element.removeClass('visible'); - ionic.requestAnimationFrame(function() { - !self.isShown && self.element.removeClass('visible'); - }); - } - $timeout.cancel(self.durationTimeout); - self.isShown = false; - var loading = self.element.children(); - loading.html(""); - }; - - return self; - }); - } - return loaderInstance; - } - - function showLoader(options) { - options = extend({}, $ionicLoadingConfig || {}, options || {}); - // use a default delay of 100 to avoid some issues reported on github - // https://github.com/driftyco/ionic/issues/3717 - var delay = options.delay || options.showDelay || 0; - - deregisterStateListener1(); - deregisterStateListener2(); - if (options.hideOnStateChange) { - deregisterStateListener1 = $rootScope.$on('$stateChangeSuccess', hideLoader); - deregisterStateListener2 = $rootScope.$on('$stateChangeError', hideLoader); - } - - //If loading.show() was called previously, cancel it and show with our new options - $timeout.cancel(loadingShowDelay); - loadingShowDelay = $timeout(noop, delay); - return loadingShowDelay.then(getLoader).then(function(loader) { - return loader.show(options); - }); - } - - function hideLoader() { - deregisterStateListener1(); - deregisterStateListener2(); - $timeout.cancel(loadingShowDelay); - return getLoader().then(function(loader) { - return loader.hide(); - }); - } -}]); - -/** - * @ngdoc service - * @name $ionicModal - * @module ionic - * @codepen gblny - * @description - * - * Related: {@link ionic.controller:ionicModal ionicModal controller}. - * - * The Modal is a content pane that can go over the user's main view - * temporarily. Usually used for making a choice or editing an item. - * - * Put the content of the modal inside of an `` element. - * - * **Notes:** - * - A modal will broadcast 'modal.shown', 'modal.hidden', and 'modal.removed' events from its originating - * scope, passing in itself as an event argument. Both the modal.removed and modal.hidden events are - * called when the modal is removed. - * - * - This example assumes your modal is in your main index file or another template file. If it is in its own - * template file, remove the script tags and call it by file name. - * - * @usage - * ```html - * - * ``` - * ```js - * angular.module('testApp', ['ionic']) - * .controller('MyController', function($scope, $ionicModal) { - * $ionicModal.fromTemplateUrl('my-modal.html', { - * scope: $scope, - * animation: 'slide-in-up' - * }).then(function(modal) { - * $scope.modal = modal; - * }); - * $scope.openModal = function() { - * $scope.modal.show(); - * }; - * $scope.closeModal = function() { - * $scope.modal.hide(); - * }; - * // Cleanup the modal when we're done with it! - * $scope.$on('$destroy', function() { - * $scope.modal.remove(); - * }); - * // Execute action on hide modal - * $scope.$on('modal.hidden', function() { - * // Execute action - * }); - * // Execute action on remove modal - * $scope.$on('modal.removed', function() { - * // Execute action - * }); - * }); - * ``` - */ -IonicModule -.factory('$ionicModal', [ - '$rootScope', - '$ionicBody', - '$compile', - '$timeout', - '$ionicPlatform', - '$ionicTemplateLoader', - '$$q', - '$log', - '$ionicClickBlock', - '$window', - 'IONIC_BACK_PRIORITY', -function($rootScope, $ionicBody, $compile, $timeout, $ionicPlatform, $ionicTemplateLoader, $$q, $log, $ionicClickBlock, $window, IONIC_BACK_PRIORITY) { - - /** - * @ngdoc controller - * @name ionicModal - * @module ionic - * @description - * Instantiated by the {@link ionic.service:$ionicModal} service. - * - * Be sure to call [remove()](#remove) when you are done with each modal - * to clean it up and avoid memory leaks. - * - * Note: a modal will broadcast 'modal.shown', 'modal.hidden', and 'modal.removed' events from its originating - * scope, passing in itself as an event argument. Note: both modal.removed and modal.hidden are - * called when the modal is removed. - */ - var ModalView = ionic.views.Modal.inherit({ - /** - * @ngdoc method - * @name ionicModal#initialize - * @description Creates a new modal controller instance. - * @param {object} options An options object with the following properties: - * - `{object=}` `scope` The scope to be a child of. - * Default: creates a child of $rootScope. - * - `{string=}` `animation` The animation to show & hide with. - * Default: 'slide-in-up' - * - `{boolean=}` `focusFirstInput` Whether to autofocus the first input of - * the modal when shown. Will only show the keyboard on iOS, to force the keyboard to show - * on Android, please use the [Ionic keyboard plugin](https://github.com/driftyco/ionic-plugin-keyboard#keyboardshow). - * Default: false. - * - `{boolean=}` `backdropClickToClose` Whether to close the modal on clicking the backdrop. - * Default: true. - * - `{boolean=}` `hardwareBackButtonClose` Whether the modal can be closed using the hardware - * back button on Android and similar devices. Default: true. - */ - initialize: function(opts) { - ionic.views.Modal.prototype.initialize.call(this, opts); - this.animation = opts.animation || 'slide-in-up'; - }, - - /** - * @ngdoc method - * @name ionicModal#show - * @description Show this modal instance. - * @returns {promise} A promise which is resolved when the modal is finished animating in. - */ - show: function(target) { - var self = this; - - if (self.scope.$$destroyed) { - $log.error('Cannot call ' + self.viewType + '.show() after remove(). Please create a new ' + self.viewType + ' instance.'); - return $$q.when(); - } - - // on iOS, clicks will sometimes bleed through/ghost click on underlying - // elements - $ionicClickBlock.show(600); - stack.add(self); - - var modalEl = jqLite(self.modalEl); - - self.el.classList.remove('hide'); - $timeout(function() { - if (!self._isShown) return; - $ionicBody.addClass(self.viewType + '-open'); - }, 400, false); - - if (!self.el.parentElement) { - modalEl.addClass(self.animation); - $ionicBody.append(self.el); - } - - // if modal was closed while the keyboard was up, reset scroll view on - // next show since we can only resize it once it's visible - var scrollCtrl = modalEl.data('$$ionicScrollController'); - scrollCtrl && scrollCtrl.resize(); - - if (target && self.positionView) { - self.positionView(target, modalEl); - // set up a listener for in case the window size changes - - self._onWindowResize = function() { - if (self._isShown) self.positionView(target, modalEl); - }; - ionic.on('resize', self._onWindowResize, window); - } - - modalEl.addClass('ng-enter active') - .removeClass('ng-leave ng-leave-active'); - - self._isShown = true; - self._deregisterBackButton = $ionicPlatform.registerBackButtonAction( - self.hardwareBackButtonClose ? angular.bind(self, self.hide) : noop, - IONIC_BACK_PRIORITY.modal - ); - - ionic.views.Modal.prototype.show.call(self); - - $timeout(function() { - if (!self._isShown) return; - modalEl.addClass('ng-enter-active'); - ionic.trigger('resize'); - self.scope.$parent && self.scope.$parent.$broadcast(self.viewType + '.shown', self); - self.el.classList.add('active'); - self.scope.$broadcast('$ionicHeader.align'); - self.scope.$broadcast('$ionicFooter.align'); - self.scope.$broadcast('$ionic.modalPresented'); - }, 20); - - return $timeout(function() { - if (!self._isShown) return; - self.$el.on('touchmove', function(e) { - //Don't allow scrolling while open by dragging on backdrop - var isInScroll = ionic.DomUtil.getParentOrSelfWithClass(e.target, 'scroll'); - if (!isInScroll) { - e.preventDefault(); - } - }); - //After animating in, allow hide on backdrop click - self.$el.on('click', function(e) { - if (self.backdropClickToClose && e.target === self.el && stack.isHighest(self)) { - self.hide(); - } - }); - }, 400); - }, - - /** - * @ngdoc method - * @name ionicModal#hide - * @description Hide this modal instance. - * @returns {promise} A promise which is resolved when the modal is finished animating out. - */ - hide: function() { - var self = this; - var modalEl = jqLite(self.modalEl); - - // on iOS, clicks will sometimes bleed through/ghost click on underlying - // elements - $ionicClickBlock.show(600); - stack.remove(self); - - self.el.classList.remove('active'); - modalEl.addClass('ng-leave'); - - $timeout(function() { - if (self._isShown) return; - modalEl.addClass('ng-leave-active') - .removeClass('ng-enter ng-enter-active active'); - - self.scope.$broadcast('$ionic.modalRemoved'); - }, 20, false); - - self.$el.off('click'); - self._isShown = false; - self.scope.$parent && self.scope.$parent.$broadcast(self.viewType + '.hidden', self); - self._deregisterBackButton && self._deregisterBackButton(); - - ionic.views.Modal.prototype.hide.call(self); - - // clean up event listeners - if (self.positionView) { - ionic.off('resize', self._onWindowResize, window); - } - - return $timeout(function() { - if (!modalStack.length) { - $ionicBody.removeClass(self.viewType + '-open'); - } - self.el.classList.add('hide'); - }, self.hideDelay || 320); - }, - - /** - * @ngdoc method - * @name ionicModal#remove - * @description Remove this modal instance from the DOM and clean up. - * @returns {promise} A promise which is resolved when the modal is finished animating out. - */ - remove: function() { - var self = this, - deferred, promise; - self.scope.$parent && self.scope.$parent.$broadcast(self.viewType + '.removed', self); - - // Only hide modal, when it is actually shown! - // The hide function shows a click-block-div for a split second, because on iOS, - // clicks will sometimes bleed through/ghost click on underlying elements. - // However, this will make the app unresponsive for short amount of time. - // We don't want that, if the modal window is already hidden. - if (self._isShown) { - promise = self.hide(); - } else { - deferred = $$q.defer(); - deferred.resolve(); - promise = deferred.promise; - } - - return promise.then(function() { - self.scope.$destroy(); - self.$el.remove(); - }); - }, - - /** - * @ngdoc method - * @name ionicModal#isShown - * @returns boolean Whether this modal is currently shown. - */ - isShown: function() { - return !!this._isShown; - } - }); - - var createModal = function(templateString, options) { - // Create a new scope for the modal - var scope = options.scope && options.scope.$new() || $rootScope.$new(true); - - options.viewType = options.viewType || 'modal'; - - extend(scope, { - $hasHeader: false, - $hasSubheader: false, - $hasFooter: false, - $hasSubfooter: false, - $hasTabs: false, - $hasTabsTop: false - }); - - // Compile the template - var element = $compile('' + templateString + '')(scope); - - options.$el = element; - options.el = element[0]; - options.modalEl = options.el.querySelector('.' + options.viewType); - var modal = new ModalView(options); - - modal.scope = scope; - - // If this wasn't a defined scope, we can assign the viewType to the isolated scope - // we created - if (!options.scope) { - scope[ options.viewType ] = modal; - } - - return modal; - }; - - var modalStack = []; - var stack = { - add: function(modal) { - modalStack.push(modal); - }, - remove: function(modal) { - var index = modalStack.indexOf(modal); - if (index > -1 && index < modalStack.length) { - modalStack.splice(index, 1); - } - }, - isHighest: function(modal) { - var index = modalStack.indexOf(modal); - return (index > -1 && index === modalStack.length - 1); - } - }; - - return { - /** - * @ngdoc method - * @name $ionicModal#fromTemplate - * @param {string} templateString The template string to use as the modal's - * content. - * @param {object} options Options to be passed {@link ionic.controller:ionicModal#initialize ionicModal#initialize} method. - * @returns {object} An instance of an {@link ionic.controller:ionicModal} - * controller. - */ - fromTemplate: function(templateString, options) { - var modal = createModal(templateString, options || {}); - return modal; - }, - /** - * @ngdoc method - * @name $ionicModal#fromTemplateUrl - * @param {string} templateUrl The url to load the template from. - * @param {object} options Options to be passed {@link ionic.controller:ionicModal#initialize ionicModal#initialize} method. - * options object. - * @returns {promise} A promise that will be resolved with an instance of - * an {@link ionic.controller:ionicModal} controller. - */ - fromTemplateUrl: function(url, options, _) { - var cb; - //Deprecated: allow a callback as second parameter. Now we return a promise. - if (angular.isFunction(options)) { - cb = options; - options = _; - } - return $ionicTemplateLoader.load(url).then(function(templateString) { - var modal = createModal(templateString, options || {}); - cb && cb(modal); - return modal; - }); - }, - - stack: stack - }; -}]); - - -/** - * @ngdoc service - * @name $ionicNavBarDelegate - * @module ionic - * @description - * Delegate for controlling the {@link ionic.directive:ionNavBar} directive. - * - * @usage - * - * ```html - * - * - * - * - * - * ``` - * ```js - * function MyCtrl($scope, $ionicNavBarDelegate) { - * $scope.setNavTitle = function(title) { - * $ionicNavBarDelegate.title(title); - * } - * } - * ``` - */ -IonicModule -.service('$ionicNavBarDelegate', ionic.DelegateService([ - /** - * @ngdoc method - * @name $ionicNavBarDelegate#align - * @description Aligns the title with the buttons in a given direction. - * @param {string=} direction The direction to the align the title text towards. - * Available: 'left', 'right', 'center'. Default: 'center'. - */ - 'align', - /** - * @ngdoc method - * @name $ionicNavBarDelegate#showBackButton - * @description - * Set/get whether the {@link ionic.directive:ionNavBackButton} is shown - * (if it exists and there is a previous view that can be navigated to). - * @param {boolean=} show Whether to show the back button. - * @returns {boolean} Whether the back button is shown. - */ - 'showBackButton', - /** - * @ngdoc method - * @name $ionicNavBarDelegate#showBar - * @description - * Set/get whether the {@link ionic.directive:ionNavBar} is shown. - * @param {boolean} show Whether to show the bar. - * @returns {boolean} Whether the bar is shown. - */ - 'showBar', - /** - * @ngdoc method - * @name $ionicNavBarDelegate#title - * @description - * Set the title for the {@link ionic.directive:ionNavBar}. - * @param {string} title The new title to show. - */ - 'title', - - // DEPRECATED, as of v1.0.0-beta14 ------- - 'changeTitle', - 'setTitle', - 'getTitle', - 'back', - 'getPreviousTitle' - // END DEPRECATED ------- -])); - - -IonicModule -.service('$ionicNavViewDelegate', ionic.DelegateService([ - 'clearCache' -])); - - - -/** - * @ngdoc service - * @name $ionicPlatform - * @module ionic - * @description - * An angular abstraction of {@link ionic.utility:ionic.Platform}. - * - * Used to detect the current platform, as well as do things like override the - * Android back button in PhoneGap/Cordova. - */ -IonicModule -.constant('IONIC_BACK_PRIORITY', { - view: 100, - sideMenu: 150, - modal: 200, - actionSheet: 300, - popup: 400, - loading: 500 -}) -.provider('$ionicPlatform', function() { - return { - $get: ['$q', '$ionicScrollDelegate', function($q, $ionicScrollDelegate) { - var self = { - - /** - * @ngdoc method - * @name $ionicPlatform#onHardwareBackButton - * @description - * Some platforms have a hardware back button, so this is one way to - * bind to it. - * @param {function} callback the callback to trigger when this event occurs - */ - onHardwareBackButton: function(cb) { - ionic.Platform.ready(function() { - document.addEventListener('backbutton', cb, false); - }); - }, - - /** - * @ngdoc method - * @name $ionicPlatform#offHardwareBackButton - * @description - * Remove an event listener for the backbutton. - * @param {function} callback The listener function that was - * originally bound. - */ - offHardwareBackButton: function(fn) { - ionic.Platform.ready(function() { - document.removeEventListener('backbutton', fn); - }); - }, - - /** - * @ngdoc method - * @name $ionicPlatform#registerBackButtonAction - * @description - * Register a hardware back button action. Only one action will execute - * when the back button is clicked, so this method decides which of - * the registered back button actions has the highest priority. - * - * For example, if an actionsheet is showing, the back button should - * close the actionsheet, but it should not also go back a page view - * or close a modal which may be open. - * - * The priorities for the existing back button hooks are as follows: - * Return to previous view = 100 - * Close side menu = 150 - * Dismiss modal = 200 - * Close action sheet = 300 - * Dismiss popup = 400 - * Dismiss loading overlay = 500 - * - * Your back button action will override each of the above actions - * whose priority is less than the priority you provide. For example, - * an action assigned a priority of 101 will override the 'return to - * previous view' action, but not any of the other actions. - * - * @param {function} callback Called when the back button is pressed, - * if this listener is the highest priority. - * @param {number} priority Only the highest priority will execute. - * @param {*=} actionId The id to assign this action. Default: a - * random unique id. - * @returns {function} A function that, when called, will deregister - * this backButtonAction. - */ - $backButtonActions: {}, - registerBackButtonAction: function(fn, priority, actionId) { - - if (!self._hasBackButtonHandler) { - // add a back button listener if one hasn't been setup yet - self.$backButtonActions = {}; - self.onHardwareBackButton(self.hardwareBackButtonClick); - self._hasBackButtonHandler = true; - } - - var action = { - id: (actionId ? actionId : ionic.Utils.nextUid()), - priority: (priority ? priority : 0), - fn: fn - }; - self.$backButtonActions[action.id] = action; - - // return a function to de-register this back button action - return function() { - delete self.$backButtonActions[action.id]; - }; - }, - - /** - * @private - */ - hardwareBackButtonClick: function(e) { - // loop through all the registered back button actions - // and only run the last one of the highest priority - var priorityAction, actionId; - for (actionId in self.$backButtonActions) { - if (!priorityAction || self.$backButtonActions[actionId].priority >= priorityAction.priority) { - priorityAction = self.$backButtonActions[actionId]; - } - } - if (priorityAction) { - priorityAction.fn(e); - return priorityAction; - } - }, - - is: function(type) { - return ionic.Platform.is(type); - }, - - /** - * @ngdoc method - * @name $ionicPlatform#on - * @description - * Add Cordova event listeners, such as `pause`, `resume`, `volumedownbutton`, `batterylow`, - * `offline`, etc. More information about available event types can be found in - * [Cordova's event documentation](https://cordova.apache.org/docs/en/latest/cordova/events/events.html). - * @param {string} type Cordova [event type](https://cordova.apache.org/docs/en/latest/cordova/events/events.html). - * @param {function} callback Called when the Cordova event is fired. - * @returns {function} Returns a deregistration function to remove the event listener. - */ - on: function(type, cb) { - ionic.Platform.ready(function() { - document.addEventListener(type, cb, false); - }); - return function() { - ionic.Platform.ready(function() { - document.removeEventListener(type, cb); - }); - }; - }, - - /** - * @ngdoc method - * @name $ionicPlatform#ready - * @description - * Trigger a callback once the device is ready, - * or immediately if the device is already ready. - * @param {function=} callback The function to call. - * @returns {promise} A promise which is resolved when the device is ready. - */ - ready: function(cb) { - var q = $q.defer(); - - ionic.Platform.ready(function() { - - window.addEventListener('statusTap', function() { - $ionicScrollDelegate.scrollTop(true); - }); - - q.resolve(); - cb && cb(); - }); - - return q.promise; - } - }; - - return self; - }] - }; - -}); - -/** - * @ngdoc service - * @name $ionicPopover - * @module ionic - * @description - * - * Related: {@link ionic.controller:ionicPopover ionicPopover controller}. - * - * The Popover is a view that floats above an app’s content. Popovers provide an - * easy way to present or gather information from the user and are - * commonly used in the following situations: - * - * - Show more info about the current view - * - Select a commonly used tool or configuration - * - Present a list of actions to perform inside one of your views - * - * Put the content of the popover inside of an `` element. - * - * @usage - * ```html - *

      - * - *

      - * - * - * ``` - * ```js - * angular.module('testApp', ['ionic']) - * .controller('MyController', function($scope, $ionicPopover) { - * - * // .fromTemplate() method - * var template = '

      My Popover Title

      Hello!
      '; - * - * $scope.popover = $ionicPopover.fromTemplate(template, { - * scope: $scope - * }); - * - * // .fromTemplateUrl() method - * $ionicPopover.fromTemplateUrl('my-popover.html', { - * scope: $scope - * }).then(function(popover) { - * $scope.popover = popover; - * }); - * - * - * $scope.openPopover = function($event) { - * $scope.popover.show($event); - * }; - * $scope.closePopover = function() { - * $scope.popover.hide(); - * }; - * //Cleanup the popover when we're done with it! - * $scope.$on('$destroy', function() { - * $scope.popover.remove(); - * }); - * // Execute action on hidden popover - * $scope.$on('popover.hidden', function() { - * // Execute action - * }); - * // Execute action on remove popover - * $scope.$on('popover.removed', function() { - * // Execute action - * }); - * }); - * ``` - */ - - -IonicModule -.factory('$ionicPopover', ['$ionicModal', '$ionicPosition', '$document', '$window', -function($ionicModal, $ionicPosition, $document, $window) { - - var POPOVER_BODY_PADDING = 6; - - var POPOVER_OPTIONS = { - viewType: 'popover', - hideDelay: 1, - animation: 'none', - positionView: positionView - }; - - function positionView(target, popoverEle) { - var targetEle = jqLite(target.target || target); - var buttonOffset = $ionicPosition.offset(targetEle); - var popoverWidth = popoverEle.prop('offsetWidth'); - var popoverHeight = popoverEle.prop('offsetHeight'); - // Use innerWidth and innerHeight, because clientWidth and clientHeight - // doesn't work consistently for body on all platforms - var bodyWidth = $window.innerWidth; - var bodyHeight = $window.innerHeight; - - var popoverCSS = { - left: buttonOffset.left + buttonOffset.width / 2 - popoverWidth / 2 - }; - var arrowEle = jqLite(popoverEle[0].querySelector('.popover-arrow')); - - if (popoverCSS.left < POPOVER_BODY_PADDING) { - popoverCSS.left = POPOVER_BODY_PADDING; - } else if (popoverCSS.left + popoverWidth + POPOVER_BODY_PADDING > bodyWidth) { - popoverCSS.left = bodyWidth - popoverWidth - POPOVER_BODY_PADDING; - } - - // If the popover when popped down stretches past bottom of screen, - // make it pop up if there's room above - if (buttonOffset.top + buttonOffset.height + popoverHeight > bodyHeight && - buttonOffset.top - popoverHeight > 0) { - popoverCSS.top = buttonOffset.top - popoverHeight; - popoverEle.addClass('popover-bottom'); - } else { - popoverCSS.top = buttonOffset.top + buttonOffset.height; - popoverEle.removeClass('popover-bottom'); - } - - arrowEle.css({ - left: buttonOffset.left + buttonOffset.width / 2 - - arrowEle.prop('offsetWidth') / 2 - popoverCSS.left + 'px' - }); - - popoverEle.css({ - top: popoverCSS.top + 'px', - left: popoverCSS.left + 'px', - marginLeft: '0', - opacity: '1' - }); - - } - - /** - * @ngdoc controller - * @name ionicPopover - * @module ionic - * @description - * Instantiated by the {@link ionic.service:$ionicPopover} service. - * - * Be sure to call [remove()](#remove) when you are done with each popover - * to clean it up and avoid memory leaks. - * - * Note: a popover will broadcast 'popover.shown', 'popover.hidden', and 'popover.removed' events from its originating - * scope, passing in itself as an event argument. Both the popover.removed and popover.hidden events are - * called when the popover is removed. - */ - - /** - * @ngdoc method - * @name ionicPopover#initialize - * @description Creates a new popover controller instance. - * @param {object} options An options object with the following properties: - * - `{object=}` `scope` The scope to be a child of. - * Default: creates a child of $rootScope. - * - `{boolean=}` `focusFirstInput` Whether to autofocus the first input of - * the popover when shown. Default: false. - * - `{boolean=}` `backdropClickToClose` Whether to close the popover on clicking the backdrop. - * Default: true. - * - `{boolean=}` `hardwareBackButtonClose` Whether the popover can be closed using the hardware - * back button on Android and similar devices. Default: true. - */ - - /** - * @ngdoc method - * @name ionicPopover#show - * @description Show this popover instance. - * @param {$event} $event The $event or target element which the popover should align - * itself next to. - * @returns {promise} A promise which is resolved when the popover is finished animating in. - */ - - /** - * @ngdoc method - * @name ionicPopover#hide - * @description Hide this popover instance. - * @returns {promise} A promise which is resolved when the popover is finished animating out. - */ - - /** - * @ngdoc method - * @name ionicPopover#remove - * @description Remove this popover instance from the DOM and clean up. - * @returns {promise} A promise which is resolved when the popover is finished animating out. - */ - - /** - * @ngdoc method - * @name ionicPopover#isShown - * @returns boolean Whether this popover is currently shown. - */ - - return { - /** - * @ngdoc method - * @name $ionicPopover#fromTemplate - * @param {string} templateString The template string to use as the popovers's - * content. - * @param {object} options Options to be passed to the initialize method. - * @returns {object} An instance of an {@link ionic.controller:ionicPopover} - * controller (ionicPopover is built on top of $ionicPopover). - */ - fromTemplate: function(templateString, options) { - return $ionicModal.fromTemplate(templateString, ionic.Utils.extend({}, POPOVER_OPTIONS, options)); - }, - /** - * @ngdoc method - * @name $ionicPopover#fromTemplateUrl - * @param {string} templateUrl The url to load the template from. - * @param {object} options Options to be passed to the initialize method. - * @returns {promise} A promise that will be resolved with an instance of - * an {@link ionic.controller:ionicPopover} controller (ionicPopover is built on top of $ionicPopover). - */ - fromTemplateUrl: function(url, options) { - return $ionicModal.fromTemplateUrl(url, ionic.Utils.extend({}, POPOVER_OPTIONS, options)); - } - }; - -}]); - - -var POPUP_TPL = - ''; - -/** - * @ngdoc service - * @name $ionicPopup - * @module ionic - * @restrict E - * @codepen zkmhJ - * @description - * - * The Ionic Popup service allows programmatically creating and showing popup - * windows that require the user to respond in order to continue. - * - * The popup system has support for more flexible versions of the built in `alert()`, `prompt()`, - * and `confirm()` functions that users are used to, in addition to allowing popups with completely - * custom content and look. - * - * An input can be given an `autofocus` attribute so it automatically receives focus when - * the popup first shows. However, depending on certain use-cases this can cause issues with - * the tap/click system, which is why Ionic prefers using the `autofocus` attribute as - * an opt-in feature and not the default. - * - * @usage - * A few basic examples, see below for details about all of the options available. - * - * ```js - *angular.module('mySuperApp', ['ionic']) - *.controller('PopupCtrl',function($scope, $ionicPopup, $timeout) { - * - * // Triggered on a button click, or some other target - * $scope.showPopup = function() { - * $scope.data = {}; - * - * // An elaborate, custom popup - * var myPopup = $ionicPopup.show({ - * template: '', - * title: 'Enter Wi-Fi Password', - * subTitle: 'Please use normal things', - * scope: $scope, - * buttons: [ - * { text: 'Cancel' }, - * { - * text: 'Save', - * type: 'button-positive', - * onTap: function(e) { - * if (!$scope.data.wifi) { - * //don't allow the user to close unless he enters wifi password - * e.preventDefault(); - * } else { - * return $scope.data.wifi; - * } - * } - * } - * ] - * }); - * - * myPopup.then(function(res) { - * console.log('Tapped!', res); - * }); - * - * $timeout(function() { - * myPopup.close(); //close the popup after 3 seconds for some reason - * }, 3000); - * }; - * - * // A confirm dialog - * $scope.showConfirm = function() { - * var confirmPopup = $ionicPopup.confirm({ - * title: 'Consume Ice Cream', - * template: 'Are you sure you want to eat this ice cream?' - * }); - * - * confirmPopup.then(function(res) { - * if(res) { - * console.log('You are sure'); - * } else { - * console.log('You are not sure'); - * } - * }); - * }; - * - * // An alert dialog - * $scope.showAlert = function() { - * var alertPopup = $ionicPopup.alert({ - * title: 'Don\'t eat that!', - * template: 'It might taste good' - * }); - * - * alertPopup.then(function(res) { - * console.log('Thank you for not eating my delicious ice cream cone'); - * }); - * }; - *}); - *``` - */ - -IonicModule -.factory('$ionicPopup', [ - '$ionicTemplateLoader', - '$ionicBackdrop', - '$q', - '$timeout', - '$rootScope', - '$ionicBody', - '$compile', - '$ionicPlatform', - '$ionicModal', - 'IONIC_BACK_PRIORITY', -function($ionicTemplateLoader, $ionicBackdrop, $q, $timeout, $rootScope, $ionicBody, $compile, $ionicPlatform, $ionicModal, IONIC_BACK_PRIORITY) { - //TODO allow this to be configured - var config = { - stackPushDelay: 75 - }; - var popupStack = []; - - var $ionicPopup = { - /** - * @ngdoc method - * @description - * Show a complex popup. This is the master show function for all popups. - * - * A complex popup has a `buttons` array, with each button having a `text` and `type` - * field, in addition to an `onTap` function. The `onTap` function, called when - * the corresponding button on the popup is tapped, will by default close the popup - * and resolve the popup promise with its return value. If you wish to prevent the - * default and keep the popup open on button tap, call `event.preventDefault()` on the - * passed in tap event. Details below. - * - * @name $ionicPopup#show - * @param {object} options The options for the new popup, of the form: - * - * ``` - * { - * title: '', // String. The title of the popup. - * cssClass: '', // String, The custom CSS class name - * subTitle: '', // String (optional). The sub-title of the popup. - * template: '', // String (optional). The html template to place in the popup body. - * templateUrl: '', // String (optional). The URL of an html template to place in the popup body. - * scope: null, // Scope (optional). A scope to link to the popup content. - * buttons: [{ // Array[Object] (optional). Buttons to place in the popup footer. - * text: 'Cancel', - * type: 'button-default', - * onTap: function(e) { - * // e.preventDefault() will stop the popup from closing when tapped. - * e.preventDefault(); - * } - * }, { - * text: 'OK', - * type: 'button-positive', - * onTap: function(e) { - * // Returning a value will cause the promise to resolve with the given value. - * return scope.data.response; - * } - * }] - * } - * ``` - * - * @returns {object} A promise which is resolved when the popup is closed. Has an additional - * `close` function, which can be used to programmatically close the popup. - */ - show: showPopup, - - /** - * @ngdoc method - * @name $ionicPopup#alert - * @description Show a simple alert popup with a message and one button that the user can - * tap to close the popup. - * - * @param {object} options The options for showing the alert, of the form: - * - * ``` - * { - * title: '', // String. The title of the popup. - * cssClass: '', // String, The custom CSS class name - * subTitle: '', // String (optional). The sub-title of the popup. - * template: '', // String (optional). The html template to place in the popup body. - * templateUrl: '', // String (optional). The URL of an html template to place in the popup body. - * okText: '', // String (default: 'OK'). The text of the OK button. - * okType: '', // String (default: 'button-positive'). The type of the OK button. - * } - * ``` - * - * @returns {object} A promise which is resolved when the popup is closed. Has one additional - * function `close`, which can be called with any value to programmatically close the popup - * with the given value. - */ - alert: showAlert, - - /** - * @ngdoc method - * @name $ionicPopup#confirm - * @description - * Show a simple confirm popup with a Cancel and OK button. - * - * Resolves the promise with true if the user presses the OK button, and false if the - * user presses the Cancel button. - * - * @param {object} options The options for showing the confirm popup, of the form: - * - * ``` - * { - * title: '', // String. The title of the popup. - * cssClass: '', // String, The custom CSS class name - * subTitle: '', // String (optional). The sub-title of the popup. - * template: '', // String (optional). The html template to place in the popup body. - * templateUrl: '', // String (optional). The URL of an html template to place in the popup body. - * cancelText: '', // String (default: 'Cancel'). The text of the Cancel button. - * cancelType: '', // String (default: 'button-default'). The type of the Cancel button. - * okText: '', // String (default: 'OK'). The text of the OK button. - * okType: '', // String (default: 'button-positive'). The type of the OK button. - * } - * ``` - * - * @returns {object} A promise which is resolved when the popup is closed. Has one additional - * function `close`, which can be called with any value to programmatically close the popup - * with the given value. - */ - confirm: showConfirm, - - /** - * @ngdoc method - * @name $ionicPopup#prompt - * @description Show a simple prompt popup, which has an input, OK button, and Cancel button. - * Resolves the promise with the value of the input if the user presses OK, and with undefined - * if the user presses Cancel. - * - * ```javascript - * $ionicPopup.prompt({ - * title: 'Password Check', - * template: 'Enter your secret password', - * inputType: 'password', - * inputPlaceholder: 'Your password' - * }).then(function(res) { - * console.log('Your password is', res); - * }); - * ``` - * @param {object} options The options for showing the prompt popup, of the form: - * - * ``` - * { - * title: '', // String. The title of the popup. - * cssClass: '', // String, The custom CSS class name - * subTitle: '', // String (optional). The sub-title of the popup. - * template: '', // String (optional). The html template to place in the popup body. - * templateUrl: '', // String (optional). The URL of an html template to place in the popup body. - * inputType: // String (default: 'text'). The type of input to use - * defaultText: // String (default: ''). The initial value placed into the input. - * maxLength: // Integer (default: null). Specify a maxlength attribute for the input. - * inputPlaceholder: // String (default: ''). A placeholder to use for the input. - * cancelText: // String (default: 'Cancel'. The text of the Cancel button. - * cancelType: // String (default: 'button-default'). The type of the Cancel button. - * okText: // String (default: 'OK'). The text of the OK button. - * okType: // String (default: 'button-positive'). The type of the OK button. - * } - * ``` - * - * @returns {object} A promise which is resolved when the popup is closed. Has one additional - * function `close`, which can be called with any value to programmatically close the popup - * with the given value. - */ - prompt: showPrompt, - /** - * @private for testing - */ - _createPopup: createPopup, - _popupStack: popupStack - }; - - return $ionicPopup; - - function createPopup(options) { - options = extend({ - scope: null, - title: '', - buttons: [] - }, options || {}); - - var self = {}; - self.scope = (options.scope || $rootScope).$new(); - self.element = jqLite(POPUP_TPL); - self.responseDeferred = $q.defer(); - - $ionicBody.get().appendChild(self.element[0]); - $compile(self.element)(self.scope); - - extend(self.scope, { - title: options.title, - buttons: options.buttons, - subTitle: options.subTitle, - cssClass: options.cssClass, - $buttonTapped: function(button, event) { - var result = (button.onTap || noop).apply(self, [event]); - event = event.originalEvent || event; //jquery events - - if (!event.defaultPrevented) { - self.responseDeferred.resolve(result); - } - } - }); - - $q.when( - options.templateUrl ? - $ionicTemplateLoader.load(options.templateUrl) : - (options.template || options.content || '') - ).then(function(template) { - var popupBody = jqLite(self.element[0].querySelector('.popup-body')); - if (template) { - popupBody.html(template); - $compile(popupBody.contents())(self.scope); - } else { - popupBody.remove(); - } - }); - - self.show = function() { - if (self.isShown || self.removed) return; - - $ionicModal.stack.add(self); - self.isShown = true; - ionic.requestAnimationFrame(function() { - //if hidden while waiting for raf, don't show - if (!self.isShown) return; - - self.element.removeClass('popup-hidden'); - self.element.addClass('popup-showing active'); - focusInput(self.element); - }); - }; - - self.hide = function(callback) { - callback = callback || noop; - if (!self.isShown) return callback(); - - $ionicModal.stack.remove(self); - self.isShown = false; - self.element.removeClass('active'); - self.element.addClass('popup-hidden'); - $timeout(callback, 250, false); - }; - - self.remove = function() { - if (self.removed) return; - - self.hide(function() { - self.element.remove(); - self.scope.$destroy(); - }); - - self.removed = true; - }; - - return self; - } - - function onHardwareBackButton() { - var last = popupStack[popupStack.length - 1]; - last && last.responseDeferred.resolve(); - } - - function showPopup(options) { - var popup = $ionicPopup._createPopup(options); - var showDelay = 0; - - if (popupStack.length > 0) { - showDelay = config.stackPushDelay; - $timeout(popupStack[popupStack.length - 1].hide, showDelay, false); - } else { - //Add popup-open & backdrop if this is first popup - $ionicBody.addClass('popup-open'); - $ionicBackdrop.retain(); - //only show the backdrop on the first popup - $ionicPopup._backButtonActionDone = $ionicPlatform.registerBackButtonAction( - onHardwareBackButton, - IONIC_BACK_PRIORITY.popup - ); - } - - // Expose a 'close' method on the returned promise - popup.responseDeferred.promise.close = function popupClose(result) { - if (!popup.removed) popup.responseDeferred.resolve(result); - }; - //DEPRECATED: notify the promise with an object with a close method - popup.responseDeferred.notify({ close: popup.responseDeferred.close }); - - doShow(); - - return popup.responseDeferred.promise; - - function doShow() { - popupStack.push(popup); - $timeout(popup.show, showDelay, false); - - popup.responseDeferred.promise.then(function(result) { - var index = popupStack.indexOf(popup); - if (index !== -1) { - popupStack.splice(index, 1); - } - - popup.remove(); - - if (popupStack.length > 0) { - popupStack[popupStack.length - 1].show(); - } else { - $ionicBackdrop.release(); - //Remove popup-open & backdrop if this is last popup - $timeout(function() { - // wait to remove this due to a 300ms delay native - // click which would trigging whatever was underneath this - if (!popupStack.length) { - $ionicBody.removeClass('popup-open'); - } - }, 400, false); - ($ionicPopup._backButtonActionDone || noop)(); - } - - - return result; - }); - - } - - } - - function focusInput(element) { - var focusOn = element[0].querySelector('[autofocus]'); - if (focusOn) { - focusOn.focus(); - } - } - - function showAlert(opts) { - return showPopup(extend({ - buttons: [{ - text: opts.okText || 'OK', - type: opts.okType || 'button-positive', - onTap: function() { - return true; - } - }] - }, opts || {})); - } - - function showConfirm(opts) { - return showPopup(extend({ - buttons: [{ - text: opts.cancelText || 'Cancel', - type: opts.cancelType || 'button-default', - onTap: function() { return false; } - }, { - text: opts.okText || 'OK', - type: opts.okType || 'button-positive', - onTap: function() { return true; } - }] - }, opts || {})); - } - - function showPrompt(opts) { - var scope = $rootScope.$new(true); - scope.data = {}; - scope.data.fieldtype = opts.inputType ? opts.inputType : 'text'; - scope.data.response = opts.defaultText ? opts.defaultText : ''; - scope.data.placeholder = opts.inputPlaceholder ? opts.inputPlaceholder : ''; - scope.data.maxlength = opts.maxLength ? parseInt(opts.maxLength) : ''; - var text = ''; - if (opts.template && /<[a-z][\s\S]*>/i.test(opts.template) === false) { - text = '' + opts.template + ''; - delete opts.template; - } - return showPopup(extend({ - template: text + '', - scope: scope, - buttons: [{ - text: opts.cancelText || 'Cancel', - type: opts.cancelType || 'button-default', - onTap: function() {} - }, { - text: opts.okText || 'OK', - type: opts.okType || 'button-positive', - onTap: function() { - return scope.data.response || ''; - } - }] - }, opts || {})); - } -}]); - -/** - * @ngdoc service - * @name $ionicPosition - * @module ionic - * @description - * A set of utility methods that can be use to retrieve position of DOM elements. - * It is meant to be used where we need to absolute-position DOM elements in - * relation to other, existing elements (this is the case for tooltips, popovers, etc.). - * - * Adapted from [AngularUI Bootstrap](https://github.com/angular-ui/bootstrap/blob/master/src/position/position.js), - * ([license](https://github.com/angular-ui/bootstrap/blob/master/LICENSE)) - */ -IonicModule -.factory('$ionicPosition', ['$document', '$window', function($document, $window) { - - function getStyle(el, cssprop) { - if (el.currentStyle) { //IE - return el.currentStyle[cssprop]; - } else if ($window.getComputedStyle) { - return $window.getComputedStyle(el)[cssprop]; - } - // finally try and get inline style - return el.style[cssprop]; - } - - /** - * Checks if a given element is statically positioned - * @param element - raw DOM element - */ - function isStaticPositioned(element) { - return (getStyle(element, 'position') || 'static') === 'static'; - } - - /** - * returns the closest, non-statically positioned parentOffset of a given element - * @param element - */ - var parentOffsetEl = function(element) { - var docDomEl = $document[0]; - var offsetParent = element.offsetParent || docDomEl; - while (offsetParent && offsetParent !== docDomEl && isStaticPositioned(offsetParent)) { - offsetParent = offsetParent.offsetParent; - } - return offsetParent || docDomEl; - }; - - return { - /** - * @ngdoc method - * @name $ionicPosition#position - * @description Get the current coordinates of the element, relative to the offset parent. - * Read-only equivalent of [jQuery's position function](http://api.jquery.com/position/). - * @param {element} element The element to get the position of. - * @returns {object} Returns an object containing the properties top, left, width and height. - */ - position: function(element) { - var elBCR = this.offset(element); - var offsetParentBCR = { top: 0, left: 0 }; - var offsetParentEl = parentOffsetEl(element[0]); - if (offsetParentEl != $document[0]) { - offsetParentBCR = this.offset(jqLite(offsetParentEl)); - offsetParentBCR.top += offsetParentEl.clientTop - offsetParentEl.scrollTop; - offsetParentBCR.left += offsetParentEl.clientLeft - offsetParentEl.scrollLeft; - } - - var boundingClientRect = element[0].getBoundingClientRect(); - return { - width: boundingClientRect.width || element.prop('offsetWidth'), - height: boundingClientRect.height || element.prop('offsetHeight'), - top: elBCR.top - offsetParentBCR.top, - left: elBCR.left - offsetParentBCR.left - }; - }, - - /** - * @ngdoc method - * @name $ionicPosition#offset - * @description Get the current coordinates of the element, relative to the document. - * Read-only equivalent of [jQuery's offset function](http://api.jquery.com/offset/). - * @param {element} element The element to get the offset of. - * @returns {object} Returns an object containing the properties top, left, width and height. - */ - offset: function(element) { - var boundingClientRect = element[0].getBoundingClientRect(); - return { - width: boundingClientRect.width || element.prop('offsetWidth'), - height: boundingClientRect.height || element.prop('offsetHeight'), - top: boundingClientRect.top + ($window.pageYOffset || $document[0].documentElement.scrollTop), - left: boundingClientRect.left + ($window.pageXOffset || $document[0].documentElement.scrollLeft) - }; - } - - }; -}]); - - -/** - * @ngdoc service - * @name $ionicScrollDelegate - * @module ionic - * @description - * Delegate for controlling scrollViews (created by - * {@link ionic.directive:ionContent} and - * {@link ionic.directive:ionScroll} directives). - * - * Methods called directly on the $ionicScrollDelegate service will control all scroll - * views. Use the {@link ionic.service:$ionicScrollDelegate#$getByHandle $getByHandle} - * method to control specific scrollViews. - * - * @usage - * - * ```html - * - * - * - * - * - * ``` - * ```js - * function MainCtrl($scope, $ionicScrollDelegate) { - * $scope.scrollTop = function() { - * $ionicScrollDelegate.scrollTop(); - * }; - * } - * ``` - * - * Example of advanced usage, with two scroll areas using `delegate-handle` - * for fine control. - * - * ```html - * - * - * - * - * - * - * - * - * ``` - * ```js - * function MainCtrl($scope, $ionicScrollDelegate) { - * $scope.scrollMainToTop = function() { - * $ionicScrollDelegate.$getByHandle('mainScroll').scrollTop(); - * }; - * $scope.scrollSmallToTop = function() { - * $ionicScrollDelegate.$getByHandle('small').scrollTop(); - * }; - * } - * ``` - */ -IonicModule -.service('$ionicScrollDelegate', ionic.DelegateService([ - /** - * @ngdoc method - * @name $ionicScrollDelegate#resize - * @description Tell the scrollView to recalculate the size of its container. - */ - 'resize', - /** - * @ngdoc method - * @name $ionicScrollDelegate#scrollTop - * @param {boolean=} shouldAnimate Whether the scroll should animate. - */ - 'scrollTop', - /** - * @ngdoc method - * @name $ionicScrollDelegate#scrollBottom - * @param {boolean=} shouldAnimate Whether the scroll should animate. - */ - 'scrollBottom', - /** - * @ngdoc method - * @name $ionicScrollDelegate#scrollTo - * @param {number} left The x-value to scroll to. - * @param {number} top The y-value to scroll to. - * @param {boolean=} shouldAnimate Whether the scroll should animate. - */ - 'scrollTo', - /** - * @ngdoc method - * @name $ionicScrollDelegate#scrollBy - * @param {number} left The x-offset to scroll by. - * @param {number} top The y-offset to scroll by. - * @param {boolean=} shouldAnimate Whether the scroll should animate. - */ - 'scrollBy', - /** - * @ngdoc method - * @name $ionicScrollDelegate#zoomTo - * @param {number} level Level to zoom to. - * @param {boolean=} animate Whether to animate the zoom. - * @param {number=} originLeft Zoom in at given left coordinate. - * @param {number=} originTop Zoom in at given top coordinate. - */ - 'zoomTo', - /** - * @ngdoc method - * @name $ionicScrollDelegate#zoomBy - * @param {number} factor The factor to zoom by. - * @param {boolean=} animate Whether to animate the zoom. - * @param {number=} originLeft Zoom in at given left coordinate. - * @param {number=} originTop Zoom in at given top coordinate. - */ - 'zoomBy', - /** - * @ngdoc method - * @name $ionicScrollDelegate#getScrollPosition - * @returns {object} The scroll position of this view, with the following properties: - * - `{number}` `left` The distance the user has scrolled from the left (starts at 0). - * - `{number}` `top` The distance the user has scrolled from the top (starts at 0). - * - `{number}` `zoom` The current zoom level. - */ - 'getScrollPosition', - /** - * @ngdoc method - * @name $ionicScrollDelegate#anchorScroll - * @description Tell the scrollView to scroll to the element with an id - * matching window.location.hash. - * - * If no matching element is found, it will scroll to top. - * - * @param {boolean=} shouldAnimate Whether the scroll should animate. - */ - 'anchorScroll', - /** - * @ngdoc method - * @name $ionicScrollDelegate#freezeScroll - * @description Does not allow this scroll view to scroll either x or y. - * @param {boolean=} shouldFreeze Should this scroll view be prevented from scrolling or not. - * @returns {boolean} If the scroll view is being prevented from scrolling or not. - */ - 'freezeScroll', - /** - * @ngdoc method - * @name $ionicScrollDelegate#freezeAllScrolls - * @description Does not allow any of the app's scroll views to scroll either x or y. - * @param {boolean=} shouldFreeze Should all app scrolls be prevented from scrolling or not. - */ - 'freezeAllScrolls', - /** - * @ngdoc method - * @name $ionicScrollDelegate#getScrollView - * @returns {object} The scrollView associated with this delegate. - */ - 'getScrollView' - /** - * @ngdoc method - * @name $ionicScrollDelegate#$getByHandle - * @param {string} handle - * @returns `delegateInstance` A delegate instance that controls only the - * scrollViews with `delegate-handle` matching the given handle. - * - * Example: `$ionicScrollDelegate.$getByHandle('my-handle').scrollTop();` - */ -])); - - -/** - * @ngdoc service - * @name $ionicSideMenuDelegate - * @module ionic - * - * @description - * Delegate for controlling the {@link ionic.directive:ionSideMenus} directive. - * - * Methods called directly on the $ionicSideMenuDelegate service will control all side - * menus. Use the {@link ionic.service:$ionicSideMenuDelegate#$getByHandle $getByHandle} - * method to control specific ionSideMenus instances. - * - * @usage - * - * ```html - * - * - * - * Content! - * - * - * - * Left Menu! - * - * - * - * ``` - * ```js - * function MainCtrl($scope, $ionicSideMenuDelegate) { - * $scope.toggleLeftSideMenu = function() { - * $ionicSideMenuDelegate.toggleLeft(); - * }; - * } - * ``` - */ -IonicModule -.service('$ionicSideMenuDelegate', ionic.DelegateService([ - /** - * @ngdoc method - * @name $ionicSideMenuDelegate#toggleLeft - * @description Toggle the left side menu (if it exists). - * @param {boolean=} isOpen Whether to open or close the menu. - * Default: Toggles the menu. - */ - 'toggleLeft', - /** - * @ngdoc method - * @name $ionicSideMenuDelegate#toggleRight - * @description Toggle the right side menu (if it exists). - * @param {boolean=} isOpen Whether to open or close the menu. - * Default: Toggles the menu. - */ - 'toggleRight', - /** - * @ngdoc method - * @name $ionicSideMenuDelegate#getOpenRatio - * @description Gets the ratio of open amount over menu width. For example, a - * menu of width 100 that is opened by 50 pixels is 50% opened, and would return - * a ratio of 0.5. - * - * @returns {float} 0 if nothing is open, between 0 and 1 if left menu is - * opened/opening, and between 0 and -1 if right menu is opened/opening. - */ - 'getOpenRatio', - /** - * @ngdoc method - * @name $ionicSideMenuDelegate#isOpen - * @returns {boolean} Whether either the left or right menu is currently opened. - */ - 'isOpen', - /** - * @ngdoc method - * @name $ionicSideMenuDelegate#isOpenLeft - * @returns {boolean} Whether the left menu is currently opened. - */ - 'isOpenLeft', - /** - * @ngdoc method - * @name $ionicSideMenuDelegate#isOpenRight - * @returns {boolean} Whether the right menu is currently opened. - */ - 'isOpenRight', - /** - * @ngdoc method - * @name $ionicSideMenuDelegate#canDragContent - * @param {boolean=} canDrag Set whether the content can or cannot be dragged to open - * side menus. - * @returns {boolean} Whether the content can be dragged to open side menus. - */ - 'canDragContent', - /** - * @ngdoc method - * @name $ionicSideMenuDelegate#edgeDragThreshold - * @param {boolean|number=} value Set whether the content drag can only start if it is below a certain threshold distance from the edge of the screen. Accepts three different values: - * - If a non-zero number is given, that many pixels is used as the maximum allowed distance from the edge that starts dragging the side menu. - * - If true is given, the default number of pixels (25) is used as the maximum allowed distance. - * - If false or 0 is given, the edge drag threshold is disabled, and dragging from anywhere on the content is allowed. - * @returns {boolean} Whether the drag can start only from within the edge of screen threshold. - */ - 'edgeDragThreshold' - /** - * @ngdoc method - * @name $ionicSideMenuDelegate#$getByHandle - * @param {string} handle - * @returns `delegateInstance` A delegate instance that controls only the - * {@link ionic.directive:ionSideMenus} directives with `delegate-handle` matching - * the given handle. - * - * Example: `$ionicSideMenuDelegate.$getByHandle('my-handle').toggleLeft();` - */ -])); - - -/** - * @ngdoc service - * @name $ionicSlideBoxDelegate - * @module ionic - * @description - * Delegate that controls the {@link ionic.directive:ionSlideBox} directive. - * - * Methods called directly on the $ionicSlideBoxDelegate service will control all slide boxes. Use the {@link ionic.service:$ionicSlideBoxDelegate#$getByHandle $getByHandle} - * method to control specific slide box instances. - * - * @usage - * - * ```html - * - * - * - *
      - * - *
      - *
      - * - *
      - * Slide 2! - *
      - *
      - *
      - *
      - * ``` - * ```js - * function MyCtrl($scope, $ionicSlideBoxDelegate) { - * $scope.nextSlide = function() { - * $ionicSlideBoxDelegate.next(); - * } - * } - * ``` - */ -IonicModule -.service('$ionicSlideBoxDelegate', ionic.DelegateService([ - /** - * @ngdoc method - * @name $ionicSlideBoxDelegate#update - * @description - * Update the slidebox (for example if using Angular with ng-repeat, - * resize it for the elements inside). - */ - 'update', - /** - * @ngdoc method - * @name $ionicSlideBoxDelegate#slide - * @param {number} to The index to slide to. - * @param {number=} speed The number of milliseconds the change should take. - */ - 'slide', - 'select', - /** - * @ngdoc method - * @name $ionicSlideBoxDelegate#enableSlide - * @param {boolean=} shouldEnable Whether to enable sliding the slidebox. - * @returns {boolean} Whether sliding is enabled. - */ - 'enableSlide', - /** - * @ngdoc method - * @name $ionicSlideBoxDelegate#previous - * @param {number=} speed The number of milliseconds the change should take. - * @description Go to the previous slide. Wraps around if at the beginning. - */ - 'previous', - /** - * @ngdoc method - * @name $ionicSlideBoxDelegate#next - * @param {number=} speed The number of milliseconds the change should take. - * @description Go to the next slide. Wraps around if at the end. - */ - 'next', - /** - * @ngdoc method - * @name $ionicSlideBoxDelegate#stop - * @description Stop sliding. The slideBox will not move again until - * explicitly told to do so. - */ - 'stop', - 'autoPlay', - /** - * @ngdoc method - * @name $ionicSlideBoxDelegate#start - * @description Start sliding again if the slideBox was stopped. - */ - 'start', - /** - * @ngdoc method - * @name $ionicSlideBoxDelegate#currentIndex - * @returns number The index of the current slide. - */ - 'currentIndex', - 'selected', - /** - * @ngdoc method - * @name $ionicSlideBoxDelegate#slidesCount - * @returns number The number of slides there are currently. - */ - 'slidesCount', - 'count', - 'loop' - /** - * @ngdoc method - * @name $ionicSlideBoxDelegate#$getByHandle - * @param {string} handle - * @returns `delegateInstance` A delegate instance that controls only the - * {@link ionic.directive:ionSlideBox} directives with `delegate-handle` matching - * the given handle. - * - * Example: `$ionicSlideBoxDelegate.$getByHandle('my-handle').stop();` - */ -])); - - -/** - * @ngdoc service - * @name $ionicTabsDelegate - * @module ionic - * - * @description - * Delegate for controlling the {@link ionic.directive:ionTabs} directive. - * - * Methods called directly on the $ionicTabsDelegate service will control all ionTabs - * directives. Use the {@link ionic.service:$ionicTabsDelegate#$getByHandle $getByHandle} - * method to control specific ionTabs instances. - * - * @usage - * - * ```html - * - * - * - * - * Hello tab 1! - * - * - * Hello tab 2! - * - * - * - * ``` - * ```js - * function MyCtrl($scope, $ionicTabsDelegate) { - * $scope.selectTabWithIndex = function(index) { - * $ionicTabsDelegate.select(index); - * } - * } - * ``` - */ -IonicModule -.service('$ionicTabsDelegate', ionic.DelegateService([ - /** - * @ngdoc method - * @name $ionicTabsDelegate#select - * @description Select the tab matching the given index. - * - * @param {number} index Index of the tab to select. - */ - 'select', - /** - * @ngdoc method - * @name $ionicTabsDelegate#selectedIndex - * @returns `number` The index of the selected tab, or -1. - */ - 'selectedIndex', - /** - * @ngdoc method - * @name $ionicTabsDelegate#showBar - * @description - * Set/get whether the {@link ionic.directive:ionTabs} is shown - * @param {boolean} show Whether to show the bar. - * @returns {boolean} Whether the bar is shown. - */ - 'showBar' - /** - * @ngdoc method - * @name $ionicTabsDelegate#$getByHandle - * @param {string} handle - * @returns `delegateInstance` A delegate instance that controls only the - * {@link ionic.directive:ionTabs} directives with `delegate-handle` matching - * the given handle. - * - * Example: `$ionicTabsDelegate.$getByHandle('my-handle').select(0);` - */ -])); - -// closure to keep things neat -(function() { - var templatesToCache = []; - -/** - * @ngdoc service - * @name $ionicTemplateCache - * @module ionic - * @description A service that preemptively caches template files to eliminate transition flicker and boost performance. - * @usage - * State templates are cached automatically, but you can optionally cache other templates. - * - * ```js - * $ionicTemplateCache('myNgIncludeTemplate.html'); - * ``` - * - * Optionally disable all preemptive caching with the `$ionicConfigProvider` or individual states by setting `prefetchTemplate` - * in the `$state` definition - * - * ```js - * angular.module('myApp', ['ionic']) - * .config(function($stateProvider, $ionicConfigProvider) { - * - * // disable preemptive template caching globally - * $ionicConfigProvider.templates.prefetch(false); - * - * // disable individual states - * $stateProvider - * .state('tabs', { - * url: "/tab", - * abstract: true, - * prefetchTemplate: false, - * templateUrl: "tabs-templates/tabs.html" - * }) - * .state('tabs.home', { - * url: "/home", - * views: { - * 'home-tab': { - * prefetchTemplate: false, - * templateUrl: "tabs-templates/home.html", - * controller: 'HomeTabCtrl' - * } - * } - * }); - * }); - * ``` - */ -IonicModule -.factory('$ionicTemplateCache', [ -'$http', -'$templateCache', -'$timeout', -function($http, $templateCache, $timeout) { - var toCache = templatesToCache, - hasRun; - - function $ionicTemplateCache(templates) { - if (typeof templates === 'undefined') { - return run(); - } - if (isString(templates)) { - templates = [templates]; - } - forEach(templates, function(template) { - toCache.push(template); - }); - if (hasRun) { - run(); - } - } - - // run through methods - internal method - function run() { - var template; - $ionicTemplateCache._runCount++; - - hasRun = true; - // ignore if race condition already zeroed out array - if (toCache.length === 0) return; - - var i = 0; - while (i < 4 && (template = toCache.pop())) { - // note that inline templates are ignored by this request - if (isString(template)) $http.get(template, { cache: $templateCache }); - i++; - } - // only preload 3 templates a second - if (toCache.length) { - $timeout(run, 1000); - } - } - - // exposing for testing - $ionicTemplateCache._runCount = 0; - // default method - return $ionicTemplateCache; -}]) - -// Intercepts the $stateprovider.state() command to look for templateUrls that can be cached -.config([ -'$stateProvider', -'$ionicConfigProvider', -function($stateProvider, $ionicConfigProvider) { - var stateProviderState = $stateProvider.state; - $stateProvider.state = function(stateName, definition) { - // don't even bother if it's disabled. note, another config may run after this, so it's not a catch-all - if (typeof definition === 'object') { - var enabled = definition.prefetchTemplate !== false && templatesToCache.length < $ionicConfigProvider.templates.maxPrefetch(); - if (enabled && isString(definition.templateUrl)) templatesToCache.push(definition.templateUrl); - if (angular.isObject(definition.views)) { - for (var key in definition.views) { - enabled = definition.views[key].prefetchTemplate !== false && templatesToCache.length < $ionicConfigProvider.templates.maxPrefetch(); - if (enabled && isString(definition.views[key].templateUrl)) templatesToCache.push(definition.views[key].templateUrl); - } - } - } - return stateProviderState.call($stateProvider, stateName, definition); - }; -}]) - -// process the templateUrls collected by the $stateProvider, adding them to the cache -.run(['$ionicTemplateCache', function($ionicTemplateCache) { - $ionicTemplateCache(); -}]); - -})(); - -IonicModule -.factory('$ionicTemplateLoader', [ - '$compile', - '$controller', - '$http', - '$q', - '$rootScope', - '$templateCache', -function($compile, $controller, $http, $q, $rootScope, $templateCache) { - - return { - load: fetchTemplate, - compile: loadAndCompile - }; - - function fetchTemplate(url) { - return $http.get(url, {cache: $templateCache}) - .then(function(response) { - return response.data && response.data.trim(); - }); - } - - function loadAndCompile(options) { - options = extend({ - template: '', - templateUrl: '', - scope: null, - controller: null, - locals: {}, - appendTo: null - }, options || {}); - - var templatePromise = options.templateUrl ? - this.load(options.templateUrl) : - $q.when(options.template); - - return templatePromise.then(function(template) { - var controller; - var scope = options.scope || $rootScope.$new(); - - //Incase template doesn't have just one root element, do this - var element = jqLite('
      ').html(template).contents(); - - if (options.controller) { - controller = $controller( - options.controller, - extend(options.locals, { - $scope: scope - }) - ); - element.children().data('$ngControllerController', controller); - } - if (options.appendTo) { - jqLite(options.appendTo).append(element); - } - - $compile(element)(scope); - - return { - element: element, - scope: scope - }; - }); - } - -}]); - -/** - * @private - * DEPRECATED, as of v1.0.0-beta14 ------- - */ -IonicModule -.factory('$ionicViewService', ['$ionicHistory', '$log', function($ionicHistory, $log) { - - function warn(oldMethod, newMethod) { - $log.warn('$ionicViewService' + oldMethod + ' is deprecated, please use $ionicHistory' + newMethod + ' instead: http://ionicframework.com/docs/nightly/api/service/$ionicHistory/'); - } - - warn('', ''); - - var methodsMap = { - getCurrentView: 'currentView', - getBackView: 'backView', - getForwardView: 'forwardView', - getCurrentStateName: 'currentStateName', - nextViewOptions: 'nextViewOptions', - clearHistory: 'clearHistory' - }; - - forEach(methodsMap, function(newMethod, oldMethod) { - methodsMap[oldMethod] = function() { - warn('.' + oldMethod, '.' + newMethod); - return $ionicHistory[newMethod].apply(this, arguments); - }; - }); - - return methodsMap; - -}]); - -/** - * @private - * TODO document - */ - -IonicModule.factory('$ionicViewSwitcher', [ - '$timeout', - '$document', - '$q', - '$ionicClickBlock', - '$ionicConfig', - '$ionicNavBarDelegate', -function($timeout, $document, $q, $ionicClickBlock, $ionicConfig, $ionicNavBarDelegate) { - - var TRANSITIONEND_EVENT = 'webkitTransitionEnd transitionend'; - var DATA_NO_CACHE = '$noCache'; - var DATA_DESTROY_ELE = '$destroyEle'; - var DATA_ELE_IDENTIFIER = '$eleId'; - var DATA_VIEW_ACCESSED = '$accessed'; - var DATA_FALLBACK_TIMER = '$fallbackTimer'; - var DATA_VIEW = '$viewData'; - var NAV_VIEW_ATTR = 'nav-view'; - var VIEW_STATUS_ACTIVE = 'active'; - var VIEW_STATUS_CACHED = 'cached'; - var VIEW_STATUS_STAGED = 'stage'; - - var transitionCounter = 0; - var nextTransition, nextDirection; - ionic.transition = ionic.transition || {}; - ionic.transition.isActive = false; - var isActiveTimer; - var cachedAttr = ionic.DomUtil.cachedAttr; - var transitionPromises = []; - var defaultTimeout = 1100; - - var ionicViewSwitcher = { - - create: function(navViewCtrl, viewLocals, enteringView, leavingView, renderStart, renderEnd) { - // get a reference to an entering/leaving element if they exist - // loop through to see if the view is already in the navViewElement - var enteringEle, leavingEle; - var transitionId = ++transitionCounter; - var alreadyInDom; - - var switcher = { - - init: function(registerData, callback) { - ionicViewSwitcher.isTransitioning(true); - - switcher.loadViewElements(registerData); - - switcher.render(registerData, function() { - callback && callback(); - }); - }, - - loadViewElements: function(registerData) { - var x, l, viewEle; - var viewElements = navViewCtrl.getViewElements(); - var enteringEleIdentifier = getViewElementIdentifier(viewLocals, enteringView); - var navViewActiveEleId = navViewCtrl.activeEleId(); - - for (x = 0, l = viewElements.length; x < l; x++) { - viewEle = viewElements.eq(x); - - if (viewEle.data(DATA_ELE_IDENTIFIER) === enteringEleIdentifier) { - // we found an existing element in the DOM that should be entering the view - if (viewEle.data(DATA_NO_CACHE)) { - // the existing element should not be cached, don't use it - viewEle.data(DATA_ELE_IDENTIFIER, enteringEleIdentifier + ionic.Utils.nextUid()); - viewEle.data(DATA_DESTROY_ELE, true); - - } else { - enteringEle = viewEle; - } - - } else if (isDefined(navViewActiveEleId) && viewEle.data(DATA_ELE_IDENTIFIER) === navViewActiveEleId) { - leavingEle = viewEle; - } - - if (enteringEle && leavingEle) break; - } - - alreadyInDom = !!enteringEle; - - if (!alreadyInDom) { - // still no existing element to use - // create it using existing template/scope/locals - enteringEle = registerData.ele || ionicViewSwitcher.createViewEle(viewLocals); - - // existing elements in the DOM are looked up by their state name and state id - enteringEle.data(DATA_ELE_IDENTIFIER, enteringEleIdentifier); - } - - if (renderEnd) { - navViewCtrl.activeEleId(enteringEleIdentifier); - } - - registerData.ele = null; - }, - - render: function(registerData, callback) { - if (alreadyInDom) { - // it was already found in the DOM, just reconnect the scope - ionic.Utils.reconnectScope(enteringEle.scope()); - - } else { - // the entering element is not already in the DOM - // set that the entering element should be "staged" and its - // styles of where this element will go before it hits the DOM - navViewAttr(enteringEle, VIEW_STATUS_STAGED); - - var enteringData = getTransitionData(viewLocals, enteringEle, registerData.direction, enteringView); - var transitionFn = $ionicConfig.transitions.views[enteringData.transition] || $ionicConfig.transitions.views.none; - transitionFn(enteringEle, null, enteringData.direction, true).run(0); - - enteringEle.data(DATA_VIEW, { - viewId: enteringData.viewId, - historyId: enteringData.historyId, - stateName: enteringData.stateName, - stateParams: enteringData.stateParams - }); - - // if the current state has cache:false - // or the element has cache-view="false" attribute - if (viewState(viewLocals).cache === false || viewState(viewLocals).cache === 'false' || - enteringEle.attr('cache-view') == 'false' || $ionicConfig.views.maxCache() === 0) { - enteringEle.data(DATA_NO_CACHE, true); - } - - // append the entering element to the DOM, create a new scope and run link - var viewScope = navViewCtrl.appendViewElement(enteringEle, viewLocals); - - delete enteringData.direction; - delete enteringData.transition; - viewScope.$emit('$ionicView.loaded', enteringData); - } - - // update that this view was just accessed - enteringEle.data(DATA_VIEW_ACCESSED, Date.now()); - - callback && callback(); - }, - - transition: function(direction, enableBack, allowAnimate) { - var deferred; - var enteringData = getTransitionData(viewLocals, enteringEle, direction, enteringView); - var leavingData = extend(extend({}, enteringData), getViewData(leavingView)); - enteringData.transitionId = leavingData.transitionId = transitionId; - enteringData.fromCache = !!alreadyInDom; - enteringData.enableBack = !!enableBack; - enteringData.renderStart = renderStart; - enteringData.renderEnd = renderEnd; - - cachedAttr(enteringEle.parent(), 'nav-view-transition', enteringData.transition); - cachedAttr(enteringEle.parent(), 'nav-view-direction', enteringData.direction); - - // cancel any previous transition complete fallbacks - $timeout.cancel(enteringEle.data(DATA_FALLBACK_TIMER)); - - // get the transition ready and see if it'll animate - var transitionFn = $ionicConfig.transitions.views[enteringData.transition] || $ionicConfig.transitions.views.none; - var viewTransition = transitionFn(enteringEle, leavingEle, enteringData.direction, - enteringData.shouldAnimate && allowAnimate && renderEnd); - - if (viewTransition.shouldAnimate) { - // attach transitionend events (and fallback timer) - enteringEle.on(TRANSITIONEND_EVENT, completeOnTransitionEnd); - enteringEle.data(DATA_FALLBACK_TIMER, $timeout(transitionComplete, defaultTimeout)); - $ionicClickBlock.show(defaultTimeout); - } - - if (renderStart) { - // notify the views "before" the transition starts - switcher.emit('before', enteringData, leavingData); - - // stage entering element, opacity 0, no transition duration - navViewAttr(enteringEle, VIEW_STATUS_STAGED); - - // render the elements in the correct location for their starting point - viewTransition.run(0); - } - - if (renderEnd) { - // create a promise so we can keep track of when all transitions finish - // only required if this transition should complete - deferred = $q.defer(); - transitionPromises.push(deferred.promise); - } - - if (renderStart && renderEnd) { - // CSS "auto" transitioned, not manually transitioned - // wait a frame so the styles apply before auto transitioning - $timeout(function() { - ionic.requestAnimationFrame(onReflow); - }); - } else if (!renderEnd) { - // just the start of a manual transition - // but it will not render the end of the transition - navViewAttr(enteringEle, 'entering'); - navViewAttr(leavingEle, 'leaving'); - - // return the transition run method so each step can be ran manually - return { - run: viewTransition.run, - cancel: function(shouldAnimate) { - if (shouldAnimate) { - enteringEle.on(TRANSITIONEND_EVENT, cancelOnTransitionEnd); - enteringEle.data(DATA_FALLBACK_TIMER, $timeout(cancelTransition, defaultTimeout)); - $ionicClickBlock.show(defaultTimeout); - } else { - cancelTransition(); - } - viewTransition.shouldAnimate = shouldAnimate; - viewTransition.run(0); - viewTransition = null; - } - }; - - } else if (renderEnd) { - // just the end of a manual transition - // happens after the manual transition has completed - // and a full history change has happened - onReflow(); - } - - - function onReflow() { - // remove that we're staging the entering element so it can auto transition - navViewAttr(enteringEle, viewTransition.shouldAnimate ? 'entering' : VIEW_STATUS_ACTIVE); - navViewAttr(leavingEle, viewTransition.shouldAnimate ? 'leaving' : VIEW_STATUS_CACHED); - - // start the auto transition and let the CSS take over - viewTransition.run(1); - - // trigger auto transitions on the associated nav bars - $ionicNavBarDelegate._instances.forEach(function(instance) { - instance.triggerTransitionStart(transitionId); - }); - - if (!viewTransition.shouldAnimate) { - // no animated auto transition - transitionComplete(); - } - } - - // Make sure that transitionend events bubbling up from children won't fire - // transitionComplete. Will only go forward if ev.target == the element listening. - function completeOnTransitionEnd(ev) { - if (ev.target !== this) return; - transitionComplete(); - } - function transitionComplete() { - if (transitionComplete.x) return; - transitionComplete.x = true; - - enteringEle.off(TRANSITIONEND_EVENT, completeOnTransitionEnd); - $timeout.cancel(enteringEle.data(DATA_FALLBACK_TIMER)); - leavingEle && $timeout.cancel(leavingEle.data(DATA_FALLBACK_TIMER)); - - // resolve that this one transition (there could be many w/ nested views) - deferred && deferred.resolve(navViewCtrl); - - // the most recent transition added has completed and all the active - // transition promises should be added to the services array of promises - if (transitionId === transitionCounter) { - $q.all(transitionPromises).then(ionicViewSwitcher.transitionEnd); - - // emit that the views have finished transitioning - // each parent nav-view will update which views are active and cached - switcher.emit('after', enteringData, leavingData); - switcher.cleanup(enteringData); - } - - // tell the nav bars that the transition has ended - $ionicNavBarDelegate._instances.forEach(function(instance) { - instance.triggerTransitionEnd(); - }); - - - // remove any references that could cause memory issues - nextTransition = nextDirection = enteringView = leavingView = enteringEle = leavingEle = null; - } - - // Make sure that transitionend events bubbling up from children won't fire - // transitionComplete. Will only go forward if ev.target == the element listening. - function cancelOnTransitionEnd(ev) { - if (ev.target !== this) return; - cancelTransition(); - } - function cancelTransition() { - navViewAttr(enteringEle, VIEW_STATUS_CACHED); - navViewAttr(leavingEle, VIEW_STATUS_ACTIVE); - enteringEle.off(TRANSITIONEND_EVENT, cancelOnTransitionEnd); - $timeout.cancel(enteringEle.data(DATA_FALLBACK_TIMER)); - ionicViewSwitcher.transitionEnd([navViewCtrl]); - } - - }, - - emit: function(step, enteringData, leavingData) { - var enteringScope = getScopeForElement(enteringEle, enteringData); - var leavingScope = getScopeForElement(leavingEle, leavingData); - - var prefixesAreEqual; - - if ( !enteringData.viewId || enteringData.abstractView ) { - // it's an abstract view, so treat it accordingly - - // we only get access to the leaving scope once in the transition, - // so dispatch all events right away if it exists - if ( leavingScope ) { - leavingScope.$emit('$ionicView.beforeLeave', leavingData); - leavingScope.$emit('$ionicView.leave', leavingData); - leavingScope.$emit('$ionicView.afterLeave', leavingData); - leavingScope.$broadcast('$ionicParentView.beforeLeave', leavingData); - leavingScope.$broadcast('$ionicParentView.leave', leavingData); - leavingScope.$broadcast('$ionicParentView.afterLeave', leavingData); - } - } - else { - // it's a regular view, so do the normal process - if (step == 'after') { - if (enteringScope) { - enteringScope.$emit('$ionicView.enter', enteringData); - enteringScope.$broadcast('$ionicParentView.enter', enteringData); - } - - if (leavingScope) { - leavingScope.$emit('$ionicView.leave', leavingData); - leavingScope.$broadcast('$ionicParentView.leave', leavingData); - } - else if (enteringScope && leavingData && leavingData.viewId && enteringData.stateName !== leavingData.stateName) { - // we only want to dispatch this when we are doing a single-tier - // state change such as changing a tab, so compare the state - // for the same state-prefix but different suffix - prefixesAreEqual = compareStatePrefixes(enteringData.stateName, leavingData.stateName); - if ( prefixesAreEqual ) { - enteringScope.$emit('$ionicNavView.leave', leavingData); - } - } - } - - if (enteringScope) { - enteringScope.$emit('$ionicView.' + step + 'Enter', enteringData); - enteringScope.$broadcast('$ionicParentView.' + step + 'Enter', enteringData); - } - - if (leavingScope) { - leavingScope.$emit('$ionicView.' + step + 'Leave', leavingData); - leavingScope.$broadcast('$ionicParentView.' + step + 'Leave', leavingData); - - } else if (enteringScope && leavingData && leavingData.viewId && enteringData.stateName !== leavingData.stateName) { - // we only want to dispatch this when we are doing a single-tier - // state change such as changing a tab, so compare the state - // for the same state-prefix but different suffix - prefixesAreEqual = compareStatePrefixes(enteringData.stateName, leavingData.stateName); - if ( prefixesAreEqual ) { - enteringScope.$emit('$ionicNavView.' + step + 'Leave', leavingData); - } - } - } - }, - - cleanup: function(transData) { - // check if any views should be removed - if (leavingEle && transData.direction == 'back' && !$ionicConfig.views.forwardCache()) { - // if they just navigated back we can destroy the forward view - // do not remove forward views if cacheForwardViews config is true - destroyViewEle(leavingEle); - } - - var viewElements = navViewCtrl.getViewElements(); - var viewElementsLength = viewElements.length; - var x, viewElement; - var removeOldestAccess = (viewElementsLength - 1) > $ionicConfig.views.maxCache(); - var removableEle; - var oldestAccess = Date.now(); - - for (x = 0; x < viewElementsLength; x++) { - viewElement = viewElements.eq(x); - - if (removeOldestAccess && viewElement.data(DATA_VIEW_ACCESSED) < oldestAccess) { - // remember what was the oldest element to be accessed so it can be destroyed - oldestAccess = viewElement.data(DATA_VIEW_ACCESSED); - removableEle = viewElements.eq(x); - - } else if (viewElement.data(DATA_DESTROY_ELE) && navViewAttr(viewElement) != VIEW_STATUS_ACTIVE) { - destroyViewEle(viewElement); - } - } - - destroyViewEle(removableEle); - - if (enteringEle.data(DATA_NO_CACHE)) { - enteringEle.data(DATA_DESTROY_ELE, true); - } - }, - - enteringEle: function() { return enteringEle; }, - leavingEle: function() { return leavingEle; } - - }; - - return switcher; - }, - - transitionEnd: function(navViewCtrls) { - forEach(navViewCtrls, function(navViewCtrl) { - navViewCtrl.transitionEnd(); - }); - - ionicViewSwitcher.isTransitioning(false); - $ionicClickBlock.hide(); - transitionPromises = []; - }, - - nextTransition: function(val) { - nextTransition = val; - }, - - nextDirection: function(val) { - nextDirection = val; - }, - - isTransitioning: function(val) { - if (arguments.length) { - ionic.transition.isActive = !!val; - $timeout.cancel(isActiveTimer); - if (val) { - isActiveTimer = $timeout(function() { - ionicViewSwitcher.isTransitioning(false); - }, 999); - } - } - return ionic.transition.isActive; - }, - - createViewEle: function(viewLocals) { - var containerEle = $document[0].createElement('div'); - if (viewLocals && viewLocals.$template) { - containerEle.innerHTML = viewLocals.$template; - if (containerEle.children.length === 1) { - containerEle.children[0].classList.add('pane'); - if ( viewLocals.$$state && viewLocals.$$state.self && viewLocals.$$state.self['abstract'] ) { - angular.element(containerEle.children[0]).attr("abstract", "true"); - } - else { - if ( viewLocals.$$state && viewLocals.$$state.self ) { - angular.element(containerEle.children[0]).attr("state", viewLocals.$$state.self.name); - } - - } - return jqLite(containerEle.children[0]); - } - } - containerEle.className = "pane"; - return jqLite(containerEle); - }, - - viewEleIsActive: function(viewEle, isActiveAttr) { - navViewAttr(viewEle, isActiveAttr ? VIEW_STATUS_ACTIVE : VIEW_STATUS_CACHED); - }, - - getTransitionData: getTransitionData, - navViewAttr: navViewAttr, - destroyViewEle: destroyViewEle - - }; - - return ionicViewSwitcher; - - - function getViewElementIdentifier(locals, view) { - if (viewState(locals)['abstract']) return viewState(locals).name; - if (view) return view.stateId || view.viewId; - return ionic.Utils.nextUid(); - } - - function viewState(locals) { - return locals && locals.$$state && locals.$$state.self || {}; - } - - function getTransitionData(viewLocals, enteringEle, direction, view) { - // Priority - // 1) attribute directive on the button/link to this view - // 2) entering element's attribute - // 3) entering view's $state config property - // 4) view registration data - // 5) global config - // 6) fallback value - - var state = viewState(viewLocals); - var viewTransition = nextTransition || cachedAttr(enteringEle, 'view-transition') || state.viewTransition || $ionicConfig.views.transition() || 'ios'; - var navBarTransition = $ionicConfig.navBar.transition(); - direction = nextDirection || cachedAttr(enteringEle, 'view-direction') || state.viewDirection || direction || 'none'; - - return extend(getViewData(view), { - transition: viewTransition, - navBarTransition: navBarTransition === 'view' ? viewTransition : navBarTransition, - direction: direction, - shouldAnimate: (viewTransition !== 'none' && direction !== 'none') - }); - } - - function getViewData(view) { - view = view || {}; - return { - viewId: view.viewId, - historyId: view.historyId, - stateId: view.stateId, - stateName: view.stateName, - stateParams: view.stateParams - }; - } - - function navViewAttr(ele, value) { - if (arguments.length > 1) { - cachedAttr(ele, NAV_VIEW_ATTR, value); - } else { - return cachedAttr(ele, NAV_VIEW_ATTR); - } - } - - function destroyViewEle(ele) { - // we found an element that should be removed - // destroy its scope, then remove the element - if (ele && ele.length) { - var viewScope = ele.scope(); - if (viewScope) { - viewScope.$emit('$ionicView.unloaded', ele.data(DATA_VIEW)); - viewScope.$destroy(); - } - ele.remove(); - } - } - - function compareStatePrefixes(enteringStateName, exitingStateName) { - var enteringStateSuffixIndex = enteringStateName.lastIndexOf('.'); - var exitingStateSuffixIndex = exitingStateName.lastIndexOf('.'); - - // if either of the prefixes are empty, just return false - if ( enteringStateSuffixIndex < 0 || exitingStateSuffixIndex < 0 ) { - return false; - } - - var enteringPrefix = enteringStateName.substring(0, enteringStateSuffixIndex); - var exitingPrefix = exitingStateName.substring(0, exitingStateSuffixIndex); - - return enteringPrefix === exitingPrefix; - } - - function getScopeForElement(element, stateData) { - if ( !element ) { - return null; - } - // check if it's abstract - var attributeValue = angular.element(element).attr("abstract"); - var stateValue = angular.element(element).attr("state"); - - if ( attributeValue !== "true" ) { - // it's not an abstract view, so make sure the element - // matches the state. Due to abstract view weirdness, - // sometimes it doesn't. If it doesn't, don't dispatch events - // so leave the scope undefined - if ( stateValue === stateData.stateName ) { - return angular.element(element).scope(); - } - return null; - } - else { - // it is an abstract element, so look for element with the "state" attributeValue - // set to the name of the stateData state - var elements = aggregateNavViewChildren(element); - for ( var i = 0; i < elements.length; i++ ) { - var state = angular.element(elements[i]).attr("state"); - if ( state === stateData.stateName ) { - stateData.abstractView = true; - return angular.element(elements[i]).scope(); - } - } - // we didn't find a match, so return null - return null; - } - } - - function aggregateNavViewChildren(element) { - var aggregate = []; - var navViews = angular.element(element).find("ion-nav-view"); - for ( var i = 0; i < navViews.length; i++ ) { - var children = angular.element(navViews[i]).children(); - var childrenAggregated = []; - for ( var j = 0; j < children.length; j++ ) { - childrenAggregated = childrenAggregated.concat(children[j]); - } - aggregate = aggregate.concat(childrenAggregated); - } - return aggregate; - } - -}]); - -/** - * ================== angular-ios9-uiwebview.patch.js v1.1.1 ================== - * - * This patch works around iOS9 UIWebView regression that causes infinite digest - * errors in Angular. - * - * The patch can be applied to Angular 1.2.0 – 1.4.5. Newer versions of Angular - * have the workaround baked in. - * - * To apply this patch load/bundle this file with your application and add a - * dependency on the "ngIOS9UIWebViewPatch" module to your main app module. - * - * For example: - * - * ``` - * angular.module('myApp', ['ngRoute'])` - * ``` - * - * becomes - * - * ``` - * angular.module('myApp', ['ngRoute', 'ngIOS9UIWebViewPatch']) - * ``` - * - * - * More info: - * - https://openradar.appspot.com/22186109 - * - https://github.com/angular/angular.js/issues/12241 - * - https://github.com/driftyco/ionic/issues/4082 - * - * - * @license AngularJS - * (c) 2010-2015 Google, Inc. http://angularjs.org - * License: MIT - */ - -angular.module('ngIOS9UIWebViewPatch', ['ng']).config(['$provide', function($provide) { - 'use strict'; - - $provide.decorator('$browser', ['$delegate', '$window', function($delegate, $window) { - - if (isIOS9UIWebView($window.navigator.userAgent)) { - return applyIOS9Shim($delegate); - } - - return $delegate; - - function isIOS9UIWebView(userAgent) { - return /(iPhone|iPad|iPod).* OS 9_\d/.test(userAgent) && !/Version\/9\./.test(userAgent); - } - - function applyIOS9Shim(browser) { - var pendingLocationUrl = null; - var originalUrlFn = browser.url; - - browser.url = function() { - if (arguments.length) { - pendingLocationUrl = arguments[0]; - return originalUrlFn.apply(browser, arguments); - } - - return pendingLocationUrl || originalUrlFn.apply(browser, arguments); - }; - - window.addEventListener('popstate', clearPendingLocationUrl, false); - window.addEventListener('hashchange', clearPendingLocationUrl, false); - - function clearPendingLocationUrl() { - pendingLocationUrl = null; - } - - return browser; - } - }]); -}]); - -/** - * @private - * Parts of Ionic requires that $scope data is attached to the element. - * We do not want to disable adding $scope data to the $element when - * $compileProvider.debugInfoEnabled(false) is used. - */ -IonicModule.config(['$provide', function($provide) { - $provide.decorator('$compile', ['$delegate', function($compile) { - $compile.$$addScopeInfo = function $$addScopeInfo($element, scope, isolated, noTemplate) { - var dataName = isolated ? (noTemplate ? '$isolateScopeNoTemplate' : '$isolateScope') : '$scope'; - $element.data(dataName, scope); - }; - return $compile; - }]); -}]); - -/** - * @private - */ -IonicModule.config([ - '$provide', -function($provide) { - function $LocationDecorator($location, $timeout) { - - $location.__hash = $location.hash; - //Fix: when window.location.hash is set, the scrollable area - //found nearest to body's scrollTop is set to scroll to an element - //with that ID. - $location.hash = function(value) { - if (isDefined(value) && value.length > 0) { - $timeout(function() { - var scroll = document.querySelector('.scroll-content'); - if (scroll) { - scroll.scrollTop = 0; - } - }, 0, false); - } - return $location.__hash(value); - }; - - return $location; - } - - $provide.decorator('$location', ['$delegate', '$timeout', $LocationDecorator]); -}]); - -IonicModule - -.controller('$ionicHeaderBar', [ - '$scope', - '$element', - '$attrs', - '$q', - '$ionicConfig', - '$ionicHistory', -function($scope, $element, $attrs, $q, $ionicConfig, $ionicHistory) { - var TITLE = 'title'; - var BACK_TEXT = 'back-text'; - var BACK_BUTTON = 'back-button'; - var DEFAULT_TITLE = 'default-title'; - var PREVIOUS_TITLE = 'previous-title'; - var HIDE = 'hide'; - - var self = this; - var titleText = ''; - var previousTitleText = ''; - var titleLeft = 0; - var titleRight = 0; - var titleCss = ''; - var isBackEnabled = false; - var isBackShown = true; - var isNavBackShown = true; - var isBackElementShown = false; - var titleTextWidth = 0; - - - self.beforeEnter = function(viewData) { - $scope.$broadcast('$ionicView.beforeEnter', viewData); - }; - - - self.title = function(newTitleText) { - if (arguments.length && newTitleText !== titleText) { - getEle(TITLE).innerHTML = newTitleText; - titleText = newTitleText; - titleTextWidth = 0; - } - return titleText; - }; - - - self.enableBack = function(shouldEnable, disableReset) { - // whether or not the back button show be visible, according - // to the navigation and history - if (arguments.length) { - isBackEnabled = shouldEnable; - if (!disableReset) self.updateBackButton(); - } - return isBackEnabled; - }; - - - self.showBack = function(shouldShow, disableReset) { - // different from enableBack() because this will always have the back - // visually hidden if false, even if the history says it should show - if (arguments.length) { - isBackShown = shouldShow; - if (!disableReset) self.updateBackButton(); - } - return isBackShown; - }; - - - self.showNavBack = function(shouldShow) { - // different from showBack() because this is for the entire nav bar's - // setting for all of it's child headers. For internal use. - isNavBackShown = shouldShow; - self.updateBackButton(); - }; - - - self.updateBackButton = function() { - var ele; - if ((isBackShown && isNavBackShown && isBackEnabled) !== isBackElementShown) { - isBackElementShown = isBackShown && isNavBackShown && isBackEnabled; - ele = getEle(BACK_BUTTON); - ele && ele.classList[ isBackElementShown ? 'remove' : 'add' ](HIDE); - } - - if (isBackEnabled) { - ele = ele || getEle(BACK_BUTTON); - if (ele) { - if (self.backButtonIcon !== $ionicConfig.backButton.icon()) { - ele = getEle(BACK_BUTTON + ' .icon'); - if (ele) { - self.backButtonIcon = $ionicConfig.backButton.icon(); - ele.className = 'icon ' + self.backButtonIcon; - } - } - - if (self.backButtonText !== $ionicConfig.backButton.text()) { - ele = getEle(BACK_BUTTON + ' .back-text'); - if (ele) { - ele.textContent = self.backButtonText = $ionicConfig.backButton.text(); - } - } - } - } - }; - - - self.titleTextWidth = function() { - var element = getEle(TITLE); - if ( element ) { - // If the element has a nav-bar-title, use that instead - // to calculate the width of the title - var children = angular.element(element).children(); - for ( var i = 0; i < children.length; i++ ) { - if ( angular.element(children[i]).hasClass('nav-bar-title') ) { - element = children[i]; - break; - } - } - } - var bounds = ionic.DomUtil.getTextBounds(element); - titleTextWidth = Math.min(bounds && bounds.width || 30); - return titleTextWidth; - }; - - - self.titleWidth = function() { - var titleWidth = self.titleTextWidth(); - var offsetWidth = getEle(TITLE).offsetWidth; - if (offsetWidth < titleWidth) { - titleWidth = offsetWidth + (titleLeft - titleRight - 5); - } - return titleWidth; - }; - - - self.titleTextX = function() { - return ($element[0].offsetWidth / 2) - (self.titleWidth() / 2); - }; - - - self.titleLeftRight = function() { - return titleLeft - titleRight; - }; - - - self.backButtonTextLeft = function() { - var offsetLeft = 0; - var ele = getEle(BACK_TEXT); - while (ele) { - offsetLeft += ele.offsetLeft; - ele = ele.parentElement; - } - return offsetLeft; - }; - - - self.resetBackButton = function(viewData) { - if ($ionicConfig.backButton.previousTitleText()) { - var previousTitleEle = getEle(PREVIOUS_TITLE); - if (previousTitleEle) { - previousTitleEle.classList.remove(HIDE); - - var view = (viewData && $ionicHistory.getViewById(viewData.viewId)); - var newPreviousTitleText = $ionicHistory.backTitle(view); - - if (newPreviousTitleText !== previousTitleText) { - previousTitleText = previousTitleEle.innerHTML = newPreviousTitleText; - } - } - var defaultTitleEle = getEle(DEFAULT_TITLE); - if (defaultTitleEle) { - defaultTitleEle.classList.remove(HIDE); - } - } - }; - - - self.align = function(textAlign) { - var titleEle = getEle(TITLE); - - textAlign = textAlign || $attrs.alignTitle || $ionicConfig.navBar.alignTitle(); - - var widths = self.calcWidths(textAlign, false); - - if (isBackShown && previousTitleText && $ionicConfig.backButton.previousTitleText()) { - var previousTitleWidths = self.calcWidths(textAlign, true); - - var availableTitleWidth = $element[0].offsetWidth - previousTitleWidths.titleLeft - previousTitleWidths.titleRight; - - if (self.titleTextWidth() <= availableTitleWidth) { - widths = previousTitleWidths; - } - } - - return self.updatePositions(titleEle, widths.titleLeft, widths.titleRight, widths.buttonsLeft, widths.buttonsRight, widths.css, widths.showPrevTitle); - }; - - - self.calcWidths = function(textAlign, isPreviousTitle) { - var titleEle = getEle(TITLE); - var backBtnEle = getEle(BACK_BUTTON); - var x, y, z, b, c, d, childSize, bounds; - var childNodes = $element[0].childNodes; - var buttonsLeft = 0; - var buttonsRight = 0; - var isCountRightOfTitle; - var updateTitleLeft = 0; - var updateTitleRight = 0; - var updateCss = ''; - var backButtonWidth = 0; - - // Compute how wide the left children are - // Skip all titles (there may still be two titles, one leaving the dom) - // Once we encounter a titleEle, realize we are now counting the right-buttons, not left - for (x = 0; x < childNodes.length; x++) { - c = childNodes[x]; - - childSize = 0; - if (c.nodeType == 1) { - // element node - if (c === titleEle) { - isCountRightOfTitle = true; - continue; - } - - if (c.classList.contains(HIDE)) { - continue; - } - - if (isBackShown && c === backBtnEle) { - - for (y = 0; y < c.childNodes.length; y++) { - b = c.childNodes[y]; - - if (b.nodeType == 1) { - - if (b.classList.contains(BACK_TEXT)) { - for (z = 0; z < b.children.length; z++) { - d = b.children[z]; - - if (isPreviousTitle) { - if (d.classList.contains(DEFAULT_TITLE)) continue; - backButtonWidth += d.offsetWidth; - } else { - if (d.classList.contains(PREVIOUS_TITLE)) continue; - backButtonWidth += d.offsetWidth; - } - } - - } else { - backButtonWidth += b.offsetWidth; - } - - } else if (b.nodeType == 3 && b.nodeValue.trim()) { - bounds = ionic.DomUtil.getTextBounds(b); - backButtonWidth += bounds && bounds.width || 0; - } - - } - childSize = backButtonWidth || c.offsetWidth; - - } else { - // not the title, not the back button, not a hidden element - childSize = c.offsetWidth; - } - - } else if (c.nodeType == 3 && c.nodeValue.trim()) { - // text node - bounds = ionic.DomUtil.getTextBounds(c); - childSize = bounds && bounds.width || 0; - } - - if (isCountRightOfTitle) { - buttonsRight += childSize; - } else { - buttonsLeft += childSize; - } - } - - // Size and align the header titleEle based on the sizes of the left and - // right children, and the desired alignment mode - if (textAlign == 'left') { - updateCss = 'title-left'; - if (buttonsLeft) { - updateTitleLeft = buttonsLeft + 15; - } - if (buttonsRight) { - updateTitleRight = buttonsRight + 15; - } - - } else if (textAlign == 'right') { - updateCss = 'title-right'; - if (buttonsLeft) { - updateTitleLeft = buttonsLeft + 15; - } - if (buttonsRight) { - updateTitleRight = buttonsRight + 15; - } - - } else { - // center the default - var margin = Math.max(buttonsLeft, buttonsRight) + 10; - if (margin > 10) { - updateTitleLeft = updateTitleRight = margin; - } - } - - return { - backButtonWidth: backButtonWidth, - buttonsLeft: buttonsLeft, - buttonsRight: buttonsRight, - titleLeft: updateTitleLeft, - titleRight: updateTitleRight, - showPrevTitle: isPreviousTitle, - css: updateCss - }; - }; - - - self.updatePositions = function(titleEle, updateTitleLeft, updateTitleRight, buttonsLeft, buttonsRight, updateCss, showPreviousTitle) { - var deferred = $q.defer(); - - // only make DOM updates when there are actual changes - if (titleEle) { - if (updateTitleLeft !== titleLeft) { - titleEle.style.left = updateTitleLeft ? updateTitleLeft + 'px' : ''; - titleLeft = updateTitleLeft; - } - if (updateTitleRight !== titleRight) { - titleEle.style.right = updateTitleRight ? updateTitleRight + 'px' : ''; - titleRight = updateTitleRight; - } - - if (updateCss !== titleCss) { - updateCss && titleEle.classList.add(updateCss); - titleCss && titleEle.classList.remove(titleCss); - titleCss = updateCss; - } - } - - if ($ionicConfig.backButton.previousTitleText()) { - var prevTitle = getEle(PREVIOUS_TITLE); - var defaultTitle = getEle(DEFAULT_TITLE); - - prevTitle && prevTitle.classList[ showPreviousTitle ? 'remove' : 'add'](HIDE); - defaultTitle && defaultTitle.classList[ showPreviousTitle ? 'add' : 'remove'](HIDE); - } - - ionic.requestAnimationFrame(function() { - if (titleEle && titleEle.offsetWidth + 10 < titleEle.scrollWidth) { - var minRight = buttonsRight + 5; - var testRight = $element[0].offsetWidth - titleLeft - self.titleTextWidth() - 20; - updateTitleRight = testRight < minRight ? minRight : testRight; - if (updateTitleRight !== titleRight) { - titleEle.style.right = updateTitleRight + 'px'; - titleRight = updateTitleRight; - } - } - deferred.resolve(); - }); - - return deferred.promise; - }; - - - self.setCss = function(elementClassname, css) { - ionic.DomUtil.cachedStyles(getEle(elementClassname), css); - }; - - - var eleCache = {}; - function getEle(className) { - if (!eleCache[className]) { - eleCache[className] = $element[0].querySelector('.' + className); - } - return eleCache[className]; - } - - - $scope.$on('$destroy', function() { - for (var n in eleCache) eleCache[n] = null; - }); - -}]); - -IonicModule -.controller('$ionInfiniteScroll', [ - '$scope', - '$attrs', - '$element', - '$timeout', -function($scope, $attrs, $element, $timeout) { - var self = this; - self.isLoading = false; - - $scope.icon = function() { - return isDefined($attrs.icon) ? $attrs.icon : 'ion-load-d'; - }; - - $scope.spinner = function() { - return isDefined($attrs.spinner) ? $attrs.spinner : ''; - }; - - $scope.$on('scroll.infiniteScrollComplete', function() { - finishInfiniteScroll(); - }); - - $scope.$on('$destroy', function() { - if (self.scrollCtrl && self.scrollCtrl.$element) self.scrollCtrl.$element.off('scroll', self.checkBounds); - if (self.scrollEl && self.scrollEl.removeEventListener) { - self.scrollEl.removeEventListener('scroll', self.checkBounds); - } - }); - - // debounce checking infinite scroll events - self.checkBounds = ionic.Utils.throttle(checkInfiniteBounds, 300); - - function onInfinite() { - ionic.requestAnimationFrame(function() { - $element[0].classList.add('active'); - }); - self.isLoading = true; - $scope.$parent && $scope.$parent.$apply($attrs.onInfinite || ''); - } - - function finishInfiniteScroll() { - ionic.requestAnimationFrame(function() { - $element[0].classList.remove('active'); - }); - $timeout(function() { - if (self.jsScrolling) self.scrollView.resize(); - // only check bounds again immediately if the page isn't cached (scroll el has height) - if ((self.jsScrolling && self.scrollView.__container && self.scrollView.__container.offsetHeight > 0) || - !self.jsScrolling) { - self.checkBounds(); - } - }, 30, false); - self.isLoading = false; - } - - // check if we've scrolled far enough to trigger an infinite scroll - function checkInfiniteBounds() { - if (self.isLoading) return; - var maxScroll = {}; - - if (self.jsScrolling) { - maxScroll = self.getJSMaxScroll(); - var scrollValues = self.scrollView.getValues(); - if ((maxScroll.left !== -1 && scrollValues.left >= maxScroll.left) || - (maxScroll.top !== -1 && scrollValues.top >= maxScroll.top)) { - onInfinite(); - } - } else { - maxScroll = self.getNativeMaxScroll(); - if (( - maxScroll.left !== -1 && - self.scrollEl.scrollLeft >= maxScroll.left - self.scrollEl.clientWidth - ) || ( - maxScroll.top !== -1 && - self.scrollEl.scrollTop >= maxScroll.top - self.scrollEl.clientHeight - )) { - onInfinite(); - } - } - } - - // determine the threshold at which we should fire an infinite scroll - // note: this gets processed every scroll event, can it be cached? - self.getJSMaxScroll = function() { - var maxValues = self.scrollView.getScrollMax(); - return { - left: self.scrollView.options.scrollingX ? - calculateMaxValue(maxValues.left) : - -1, - top: self.scrollView.options.scrollingY ? - calculateMaxValue(maxValues.top) : - -1 - }; - }; - - self.getNativeMaxScroll = function() { - var maxValues = { - left: self.scrollEl.scrollWidth, - top: self.scrollEl.scrollHeight - }; - var computedStyle = window.getComputedStyle(self.scrollEl) || {}; - return { - left: maxValues.left && - (computedStyle.overflowX === 'scroll' || - computedStyle.overflowX === 'auto' || - self.scrollEl.style['overflow-x'] === 'scroll') ? - calculateMaxValue(maxValues.left) : -1, - top: maxValues.top && - (computedStyle.overflowY === 'scroll' || - computedStyle.overflowY === 'auto' || - self.scrollEl.style['overflow-y'] === 'scroll' ) ? - calculateMaxValue(maxValues.top) : -1 - }; - }; - - // determine pixel refresh distance based on % or value - function calculateMaxValue(maximum) { - var distance = ($attrs.distance || '2.5%').trim(); - var isPercent = distance.indexOf('%') !== -1; - return isPercent ? - maximum * (1 - parseFloat(distance) / 100) : - maximum - parseFloat(distance); - } - - //for testing - self.__finishInfiniteScroll = finishInfiniteScroll; - -}]); - -/** - * @ngdoc service - * @name $ionicListDelegate - * @module ionic - * - * @description - * Delegate for controlling the {@link ionic.directive:ionList} directive. - * - * Methods called directly on the $ionicListDelegate service will control all lists. - * Use the {@link ionic.service:$ionicListDelegate#$getByHandle $getByHandle} - * method to control specific ionList instances. - * - * @usage - * ```html - * {% raw %} - * - * - * - * - * Hello, {{i}}! - * - * - * - * - * {% endraw %} - * ``` - - * ```js - * function MyCtrl($scope, $ionicListDelegate) { - * $scope.showDeleteButtons = function() { - * $ionicListDelegate.showDelete(true); - * }; - * } - * ``` - */ -IonicModule.service('$ionicListDelegate', ionic.DelegateService([ - /** - * @ngdoc method - * @name $ionicListDelegate#showReorder - * @param {boolean=} showReorder Set whether or not this list is showing its reorder buttons. - * @returns {boolean} Whether the reorder buttons are shown. - */ - 'showReorder', - /** - * @ngdoc method - * @name $ionicListDelegate#showDelete - * @param {boolean=} showDelete Set whether or not this list is showing its delete buttons. - * @returns {boolean} Whether the delete buttons are shown. - */ - 'showDelete', - /** - * @ngdoc method - * @name $ionicListDelegate#canSwipeItems - * @param {boolean=} canSwipeItems Set whether or not this list is able to swipe to show - * option buttons. - * @returns {boolean} Whether the list is able to swipe to show option buttons. - */ - 'canSwipeItems', - /** - * @ngdoc method - * @name $ionicListDelegate#closeOptionButtons - * @description Closes any option buttons on the list that are swiped open. - */ - 'closeOptionButtons' - /** - * @ngdoc method - * @name $ionicListDelegate#$getByHandle - * @param {string} handle - * @returns `delegateInstance` A delegate instance that controls only the - * {@link ionic.directive:ionList} directives with `delegate-handle` matching - * the given handle. - * - * Example: `$ionicListDelegate.$getByHandle('my-handle').showReorder(true);` - */ -])) - -.controller('$ionicList', [ - '$scope', - '$attrs', - '$ionicListDelegate', - '$ionicHistory', -function($scope, $attrs, $ionicListDelegate, $ionicHistory) { - var self = this; - var isSwipeable = true; - var isReorderShown = false; - var isDeleteShown = false; - - var deregisterInstance = $ionicListDelegate._registerInstance( - self, $attrs.delegateHandle, function() { - return $ionicHistory.isActiveScope($scope); - } - ); - $scope.$on('$destroy', deregisterInstance); - - self.showReorder = function(show) { - if (arguments.length) { - isReorderShown = !!show; - } - return isReorderShown; - }; - - self.showDelete = function(show) { - if (arguments.length) { - isDeleteShown = !!show; - } - return isDeleteShown; - }; - - self.canSwipeItems = function(can) { - if (arguments.length) { - isSwipeable = !!can; - } - return isSwipeable; - }; - - self.closeOptionButtons = function() { - self.listView && self.listView.clearDragEffects(); - }; -}]); - -IonicModule - -.controller('$ionicNavBar', [ - '$scope', - '$element', - '$attrs', - '$compile', - '$timeout', - '$ionicNavBarDelegate', - '$ionicConfig', - '$ionicHistory', -function($scope, $element, $attrs, $compile, $timeout, $ionicNavBarDelegate, $ionicConfig, $ionicHistory) { - - var CSS_HIDE = 'hide'; - var DATA_NAV_BAR_CTRL = '$ionNavBarController'; - var PRIMARY_BUTTONS = 'primaryButtons'; - var SECONDARY_BUTTONS = 'secondaryButtons'; - var BACK_BUTTON = 'backButton'; - var ITEM_TYPES = 'primaryButtons secondaryButtons leftButtons rightButtons title'.split(' '); - - var self = this; - var headerBars = []; - var navElementHtml = {}; - var isVisible = true; - var queuedTransitionStart, queuedTransitionEnd, latestTransitionId; - - $element.parent().data(DATA_NAV_BAR_CTRL, self); - - var delegateHandle = $attrs.delegateHandle || 'navBar' + ionic.Utils.nextUid(); - - var deregisterInstance = $ionicNavBarDelegate._registerInstance(self, delegateHandle); - - - self.init = function() { - $element.addClass('nav-bar-container'); - ionic.DomUtil.cachedAttr($element, 'nav-bar-transition', $ionicConfig.views.transition()); - - // create two nav bar blocks which will trade out which one is shown - self.createHeaderBar(false); - self.createHeaderBar(true); - - $scope.$emit('ionNavBar.init', delegateHandle); - }; - - - self.createHeaderBar = function(isActive) { - var containerEle = jqLite('