diff --git a/.github/workflows/android-automated-sdk-install.yml b/.github/workflows/android-automated-sdk-install.yml index f4275a98d..2ece9029f 100644 --- a/.github/workflows/android-automated-sdk-install.yml +++ b/.github/workflows/android-automated-sdk-install.yml @@ -6,10 +6,12 @@ on: push: paths: - 'setup/prereq_android_sdk_install.sh' + - 'setup/android_sdk_packages' - '.github/workflows/android-automated-sdk-install.yml' pull_request: paths: - 'setup/prereq_android_sdk_install.sh' + - 'setup/android_sdk_packages' - '.github/workflows/android-automated-sdk-install.yml' schedule: # * is a special character in YAML so you have to quote this string @@ -64,7 +66,6 @@ jobs: ls -al $ANDROID_SDK_ROOT if [ ! -d $ANDROID_SDK_ROOT/emulator ]; then exit 1; fi if [ ! -d $ANDROID_SDK_ROOT/build-tools ]; then exit 1; fi - if [ ! -d $ANDROID_SDK_ROOT/patcher ]; then exit 1; fi if [ ! -d $ANDROID_SDK_ROOT/extras ]; then exit 1; fi if [ ! -d $ANDROID_SDK_ROOT/platforms ]; then exit 1; fi if [ ! -d $ANDROID_SDK_ROOT/platform-tools ]; then exit 1; fi diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index 25eb65317..ddc6b2ee0 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -22,20 +22,24 @@ jobs: # This workflow contains a single job called "build" build: # The type of runner that the job will run on - runs-on: macos-latest + runs-on: macos-14 # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v2 + # Runs a single command using the runners shell + - name: Prints related environment variables so that we can know what to set + run: env | egrep "JAVA|PATH|ANDROID" + # Runs a single command using the runners shell - name: Print the java and gradle versions run: | echo "Default java version" java -version echo "Setting to Java 11 instead" - export JAVA_HOME=$JAVA_HOME_11_X64 + export JAVA_HOME=$JAVA_HOME_17_arm64 java -version echo "Checking gradle" which gradle @@ -49,13 +53,13 @@ jobs: - name: Setup the cordova environment shell: bash -l {0} run: | - export JAVA_HOME=$JAVA_HOME_11_X64 + export JAVA_HOME=$JAVA_HOME_17_arm64 bash setup/setup_android_native.sh - name: Check tool versions shell: bash -l {0} run: | - export JAVA_HOME=$JAVA_HOME_11_X64 + export JAVA_HOME=$JAVA_HOME_17_arm64 source setup/activate_native.sh echo "cordova version" npx cordova -version @@ -73,7 +77,7 @@ jobs: gradle -version echo "Let's rerun the activation" source setup/activate_native.sh - export JAVA_HOME=$JAVA_HOME_11_X64 + export JAVA_HOME=$JAVA_HOME_17_arm64 echo $PATH which gradle gradle --version diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml index b0e94db22..dc1af47ac 100644 --- a/.github/workflows/code-coverage.yml +++ b/.github/workflows/code-coverage.yml @@ -27,8 +27,9 @@ jobs: npx jest - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: + token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage/coverage-final.json flags: unit fail_ci_if_error: ${{ github.repository == 'e-mission/e-mission-phone' }} diff --git a/.github/workflows/ios-build.yml b/.github/workflows/ios-build.yml index ad0ce2f01..695ed02de 100644 --- a/.github/workflows/ios-build.yml +++ b/.github/workflows/ios-build.yml @@ -22,7 +22,7 @@ jobs: # This workflow contains a single job called "build" build: # The type of runner that the job will run on - runs-on: macos-latest + runs-on: macos-14 # Steps represent a sequence of tasks that will be executed as part of the job steps: diff --git a/README.md b/README.md index 121684e0a..d75b62f6f 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,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?branch=master&event=push)](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). @@ -87,9 +87,9 @@ If you wish to connect to a different server, create your own config file accord 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) +[![osx-build-ios](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml/badge.svg?branch=master&event=push)](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml?event-push) +[![osx-build-android](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml/badge.svg?branch=master&event=push)](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml?event=push) +[![osx-android-prereq-sdk-install](https://github.com/e-mission/e-mission-phone/actions/workflows/android-automated-sdk-install.yml/badge.svg?branch=master&event=push)](https://github.com/e-mission/e-mission-phone/actions/workflows/android-automated-sdk-install.yml?event=push) Pre-requisites --- diff --git a/config.cordovabuild.xml b/config.cordovabuild.xml index d3d562802..3401b9b9b 100644 --- a/config.cordovabuild.xml +++ b/config.cordovabuild.xml @@ -36,6 +36,8 @@ + + diff --git a/hooks/before_build/ios/ios_change_deployment.js b/hooks/before_build/ios/ios_change_deployment.js new file mode 100644 index 000000000..ad381162d --- /dev/null +++ b/hooks/before_build/ios/ios_change_deployment.js @@ -0,0 +1,37 @@ +const fs = require('fs'); +const path = require('path'); + +function findFilePathsByFilename(directory, filename) { + const files = fs.readdirSync(directory); + const filePaths = []; + + for (const file of files) { + const filePath = path.join(directory, file); + const stats = fs.statSync(filePath); + + if (stats.isDirectory()) { + // Recursively search in subdirectories + const subdirectoryFilePaths = findFilePathsByFilename(filePath, filename); + filePaths.push(...subdirectoryFilePaths); + } else if (stats.isFile() && file === filename) { + // If the file matches the filename, add its path to the result + filePaths.push(filePath); + } + } + return filePaths; +} + + +const paths1 = findFilePathsByFilename('.', 'project.pbxproj'); +const paths2 = findFilePathsByFilename('.', 'Pods.xcodeproj'); +const paths = paths1.concat(paths2) + +console.log('Apply patch to', paths); + +for (let path of paths) { + let content = fs.readFileSync(path, { encoding: 'utf-8' }); + content = content.replace(/IPHONEOS_DEPLOYMENT_TARGET = [0-9]+.0;/g, 'IPHONEOS_DEPLOYMENT_TARGET = 13.0;'); + fs.writeFileSync(path, content); +} + +console.log('Done setting IPHONEOS_DEPLOYMENT_TARGET'); diff --git a/jest.config.js b/jest.config.js index 6a1dcd42b..c47992ee5 100644 --- a/jest.config.js +++ b/jest.config.js @@ -12,10 +12,17 @@ module.exports = { "^.+\\.(ts|tsx|js|jsx)$": "babel-jest" }, transformIgnorePatterns: [ - "node_modules/(?!((enketo-transformer/dist/enketo-transformer/web)|(jest-)?react-native(-.*)?|@react-native(-community)?)/)", + "node_modules/(?!((enketo-transformer/dist/enketo-transformer/web)|(jest-)?react-native(-.*)?|@react-native(-community)?|e-mission-common)/)" ], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], moduleDirectories: ["node_modules", "src"], globals: {"__DEV__": false}, collectCoverage: true, + collectCoverageFrom: [ + "www/js/**/*.{ts,tsx,js,jsx}", + "!www/js/**/index.{ts,tsx,js,jsx}", + "!www/js/types/**/*.{ts,tsx,js,jsx}", + ], + // several functions in commHelper do not have unit tests; see note in commHelper.test.ts + coveragePathIgnorePatterns: ['www/js/services/commHelper.ts'], }; diff --git a/package.cordovabuild.json b/package.cordovabuild.json index 77da54bdb..a4a729beb 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -26,7 +26,7 @@ "@babel/preset-typescript": "^7.21.4", "@ionic/cli": "6.20.8", "@types/luxon": "^3.3.0", - "@types/react": "^18.2.20", + "@types/react": "~18.2.0", "babel-loader": "^9.1.2", "babel-plugin-optional-require": "^0.3.1", "concurrently": "^8.0.1", @@ -67,7 +67,6 @@ "ANDROID_HOST": " ", "ANDROID_PATHPREFIX": "/" }, - "cordova-plugin-email-composer": {}, "cordova-plugin-x-socialsharing": { "PHOTO_LIBRARY_ADD_USAGE_DESCRIPTION": "This app requires photo library access to share photos on social media.", "PHOTO_LIBRARY_USAGE_DESCRIPTION": "This app requires photo library access to share photos on social media." @@ -96,7 +95,10 @@ "cordova-plugin-androidx-adapter": {}, "phonegap-plugin-barcodescanner": { "ANDROID_SUPPORT_V4_VERSION": "27.+" - } + }, + "cordova-plugin-bluetooth-classic-serial-port": {}, + "cordova-custom-config": {}, + "cordova-plugin-ibeacon": {} } }, "dependencies": { @@ -118,21 +120,24 @@ "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.8.2", + "cordova-plugin-em-datacollection": "git+https://github.com/e-mission/e-mission-data-collection.git#v1.8.8", "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", "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.8", - "cordova-plugin-email-composer": "git+https://github.com/katzer/cordova-plugin-email-composer.git#0.10.1", + "cordova-plugin-em-usercache": "git+https://github.com/e-mission/cordova-usercache.git#v1.1.9", "cordova-plugin-file": "8.0.0", "cordova-plugin-inappbrowser": "5.0.0", "cordova-plugin-ionic-keyboard": "2.2.0", "cordova-plugin-ionic-webview": "5.0.0", "cordova-plugin-local-notification-12": "github:e-mission/cordova-plugin-local-notification-12#v0.1.4-fix-android-action", "cordova-plugin-x-socialsharing": "6.0.4", + "cordova-plugin-bluetooth-classic-serial-port": "git+https://github.com/louisg1337/cordova-plugin-bluetooth-classic-serial-port.git", + "cordova-custom-config": "^5.1.1", + "cordova-plugin-ibeacon": "git+https://github.com/louisg1337/cordova-plugin-ibeacon.git", "core-js": "^2.5.7", + "e-mission-common": "github:JGreenlee/e-mission-common#semver:0.5.1", "enketo-core": "^6.1.7", "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", @@ -144,11 +149,11 @@ "leaflet": "^1.9.4", "luxon": "^3.3.0", "npm": "^9.6.3", - "phonegap-plugin-barcodescanner": "git+https://github.com/phonegap/phonegap-plugin-barcodescanner#v8.1.0", + "phonegap-plugin-barcodescanner": "git+https://github.com/e-mission/phonegap-plugin-barcodescanner.git", "prop-types": "^15.8.1", - "react": "^18.2.*", + "react": "~18.2.0", "react-chartjs-2": "^5.2.0", - "react-dom": "^18.2.*", + "react-dom": "~18.2.0", "react-i18next": "^13.5.0", "react-native-paper": "^5.11.0", "react-native-paper-dates": "^0.18.12", diff --git a/package.expo.json b/package.expo.json index 04c206586..2f0e8e899 100644 --- a/package.expo.json +++ b/package.expo.json @@ -20,28 +20,38 @@ "@react-navigation/native": "^6.1.7", "@react-navigation/stack": "^6.3.17", "@shopify/flash-list": "^1.3.1", - "@types/react": "~18.2.45", "@types/jest": "^29.5.5", "@types/leaflet": "^1.9.4", + "@types/react": "~18.2.45", "bottleneck": "^2.19.5", "chart.js": "^4.3.0", "chartjs-adapter-luxon": "^1.3.1", "chartjs-plugin-annotation": "^3.0.1", + "e-mission-common": "github:JGreenlee/e-mission-common#semver:0.5.1", + "em-connection-settings": "file:modules/em-connection-settings", + "em-data-collection": "file:modules/em-data-collection", + "em-opcodeauth": "file:modules/em-opcodeauth", + "em-servercomm": "file:modules/em-servercomm", + "em-serversync": "file:modules/em-serversync", + "em-unified-logger": "file:modules/em-unified-logger", + "em-usercache": "file:modules/em-usercache", "enketo-core": "^6.1.7", "enketo-transformer": "^4.0.0", - "fast-xml-parser": "^4.2.2", "expo": "~50.0.8", "expo-dev-client": "~3.3.9", + "expo-device": "^6.0.2", "expo-status-bar": "~1.11.1", + "fast-xml-parser": "^4.2.2", "humanize-duration": "^3.31.0", "i18next": "^23.7.6", "leaflet": "^1.9.4", "luxon": "^3.3.0", - "react": "18.2.0", + "react": "~18.2.0", "react-chartjs-2": "^5.2.0", - "react-dom": "18.2.0", + "react-dom": "~18.2.0", "react-i18next": "^13.5.0", "react-native": "0.73.4", + "react-native-html-parser": "^0.1.0", "react-native-paper": "^5.12.3", "react-native-paper-dates": "^0.18.12", "react-native-safe-area-context": "^4.6.3", @@ -49,10 +59,13 @@ "react-native-vector-icons": "^9.2.0", "react-native-web": "^0.19.10", "react-qr-code": "^2.0.11", - "typescript": "^5.3.0" + "typescript": "^5.3.0", + "your-module": "file:modules/your-module" }, "devDependencies": { "@babel/core": "^7.20.0", + "expo-build-properties": "^0.12.5", + "expo-gradle-ext-vars": "^0.1.2", "sass": "^1.71.1" } } diff --git a/package.serve.json b/package.serve.json index dceeb2267..ff9bf5879 100644 --- a/package.serve.json +++ b/package.serve.json @@ -26,7 +26,7 @@ "@ionic/cli": "6.20.8", "@testing-library/react-native": "^12.3.0", "@types/luxon": "^3.3.0", - "@types/react": "^18.2.20", + "@types/react": "~18.2.0", "babel-jest": "^29.7.0", "babel-loader": "^9.1.2", "babel-plugin-optional-require": "^0.3.1", @@ -40,6 +40,7 @@ "jest-environment-jsdom": "^29.7.0", "phonegap": "9.0.0+cordova.9.0.0", "process": "^0.11.10", + "react-test-renderer": "~18.2.0", "sass": "^1.62.1", "sass-loader": "^13.3.1", "style-loader": "^3.3.3", @@ -64,6 +65,7 @@ "chartjs-adapter-luxon": "^1.3.1", "chartjs-plugin-annotation": "^3.0.1", "core-js": "^2.5.7", + "e-mission-common": "github:JGreenlee/e-mission-common#semver:0.5.1", "enketo-core": "^6.1.7", "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", @@ -76,9 +78,9 @@ "luxon": "^3.3.0", "npm": "^9.6.3", "prop-types": "^15.8.1", - "react": "^18.2.*", + "react": "~18.2.0", "react-chartjs-2": "^5.2.0", - "react-dom": "^18.2.*", + "react-dom": "~18.2.0", "react-i18next": "^13.5.0", "react-native-paper": "^5.11.0", "react-native-paper-dates": "^0.18.12", diff --git a/setup/android_sdk_packages b/setup/android_sdk_packages index a62d6b18a..a870852f1 100644 --- a/setup/android_sdk_packages +++ b/setup/android_sdk_packages @@ -4,7 +4,6 @@ build-tools;33.0.2 build-tools;34.0.0 emulator extras;google;google_play_services -patcher;v4 platform-tools platforms;android-30 platforms;android-31 diff --git a/setup/autoreload/macos-index.js b/setup/autoreload/macos-index.js index 4c2ad690d..23cee52ab 100644 --- a/setup/autoreload/macos-index.js +++ b/setup/autoreload/macos-index.js @@ -2,6 +2,7 @@ const os = require('os'); const nameMap = new Map([ + [23, ['Sonoma', '14.3.1']], [22, ['Ventura', '13']], [21, ['Monterey', '12']], [20, ['Big Sur', '11']], diff --git a/setup/export_shared_dep_versions.sh b/setup/export_shared_dep_versions.sh index 2ac27d61b..ae3a5d58e 100644 --- a/setup/export_shared_dep_versions.sh +++ b/setup/export_shared_dep_versions.sh @@ -6,7 +6,7 @@ export NPM_VERSION=9.3.1 # ideally, this would be the same version as the CI # Looks like brew supports only major and minor, not patch version export RUBY_VERSION=3.0 -export COCOAPODS_VERSION=1.11.3 +export COCOAPODS_VERSION=1.12.1 export GRADLE_VERSION=7.6 export OSX_EXP_VERSION=12 diff --git a/setup/setup_shared_native.sh b/setup/setup_shared_native.sh index 00c72a375..dfc52f072 100644 --- a/setup/setup_shared_native.sh +++ b/setup/setup_shared_native.sh @@ -23,7 +23,7 @@ sed -i -e "s|/usr/bin/env node|/usr/bin/env node --unhandled-rejections=strict|" npx cordova prepare -EXPECTED_COUNT=23 +EXPECTED_COUNT=26 INSTALLED_COUNT=`npx cordova plugin list | wc -l` echo "Found $INSTALLED_COUNT plugins, expected $EXPECTED_COUNT" if [ $INSTALLED_COUNT -lt $EXPECTED_COUNT ]; diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 40ddf09da..70fcc729f 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -163,15 +163,15 @@ export const mockBEMUserCache = () => { return { key: 'write_ts', startTs: 0, endTs: Date.now() / 1000 }; }, getSensorDataForInterval: (key, tq, withMetadata) => { - return new Promise((rs, rj) => - setTimeout(() => { - if (key == `manual/demographic_survey`) { - rs([{ metadata: { write_ts: '1699897723' }, data: 'completed', time: '01/01/2001' }]); - } else { - rs([]); - } - }, 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 Promise.resolve([]); + } }, }; window['cordova'] ||= {}; @@ -277,6 +277,7 @@ export const mockBEMServerCom = () => { }; pushGetJSON(relativeUrl, msgFiller, successCallback, errorCallback); }, + getUserPersonalData: function (relativeUrl, successCallback, errorCallback) { const msgFiller = (message) => { // nop. we don't really send any data for what are effectively get calls @@ -284,6 +285,8 @@ export const mockBEMServerCom = () => { pushGetJSON(relativeUrl, msgFiller, successCallback, errorCallback); }, }; + window['cordova'] ||= {}; + window['cordova'].plugins ||= {}; window['cordova'].plugins.BEMServerComm = mockBEMServerCom; }; diff --git a/www/__tests__/Carousel.test.tsx b/www/__tests__/Carousel.test.tsx new file mode 100644 index 000000000..7b8601109 --- /dev/null +++ b/www/__tests__/Carousel.test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { View } from 'react-native'; +import Carousel from '../js/components/Carousel'; + +describe('Carousel component', () => { + const child1 = Child 1; + const child2 = Child 2; + const cardWidth = 100; + const cardMargin = 10; + + it('renders children correctly', () => { + const { getByTestId } = render( + + {child1} + {child2} + , + ); + + const renderedChild1 = getByTestId('child1'); + const renderedChild2 = getByTestId('child2'); + + expect(renderedChild1).toBeTruthy(); + expect(renderedChild2).toBeTruthy(); + }); +}); diff --git a/www/__tests__/DateSelect.test.tsx b/www/__tests__/DateSelect.test.tsx new file mode 100644 index 000000000..79fdc1997 --- /dev/null +++ b/www/__tests__/DateSelect.test.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react-native'; +import DateSelect from '../js/diary/list/DateSelect'; + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 30, left: 0, right: 0, top: 30 }), +})); +jest.spyOn(React, 'useState').mockImplementation((initialValue) => [initialValue, jest.fn()]); +jest.spyOn(React, 'useEffect').mockImplementation((effect: () => void) => effect()); + +describe('DateSelect', () => { + it('renders correctly', () => { + const onChooseMock = jest.fn(); + const { getByText } = render(); + + expect(screen.getByTestId('button-container')).toBeTruthy(); + expect(screen.getByTestId('button')).toBeTruthy(); + }); +}); diff --git a/www/__tests__/TimelineContext.test.tsx b/www/__tests__/TimelineContext.test.tsx new file mode 100644 index 000000000..49b62315f --- /dev/null +++ b/www/__tests__/TimelineContext.test.tsx @@ -0,0 +1,74 @@ +import React, { useEffect } from 'react'; +import { View, Text } from 'react-native'; +import { act, render, screen, waitFor } from '@testing-library/react-native'; +import { useTimelineContext } from '../js/TimelineContext'; +import { mockLogger } from '../__mocks__/globalMocks'; +import { mockBEMServerCom, mockBEMUserCache } from '../__mocks__/cordovaMocks'; + +mockLogger(); +mockBEMUserCache(); + +jest.mock('../js/services/commHelper', () => ({ + getPipelineRangeTs: jest.fn(() => Promise.resolve({ start_ts: 1, end_ts: 10 })), + getRawEntries: jest.fn((key_list, _, __) => { + let phone_data: any[] = []; + if (key_list.includes('analysis/composite_trip')) { + phone_data = [ + { + _id: { $oid: 'trip1' }, + metadata: { write_ts: 1, origin_key: 'analysis/confirmed_trip' }, + data: { start_ts: 1, end_ts: 2 }, + }, + { + _id: { $oid: 'trip2' }, + metadata: { write_ts: 2, origin_key: 'analysis/confirmed_trip' }, + data: { start_ts: 3, end_ts: 4 }, + }, + { + _id: { $oid: 'trip3' }, + metadata: { write_ts: 3, origin_key: 'analysis/confirmed_trip' }, + data: { start_ts: 5, end_ts: 6 }, + }, + ]; + } + return Promise.resolve({ phone_data }); + }), + fetchUrlCached: jest.fn(() => Promise.resolve(null)), +})); + +// Mock useAppConfig default export +jest.mock('../js/useAppConfig', () => { + return jest.fn(() => ({ intro: {} })); +}); + +const TimelineContextTestComponent = () => { + const { timelineMap, setDateRange } = useTimelineContext(); + + useEffect(() => { + // setDateRange(['2021-01-01', '2021-01-07']); + }, []); + + if (!timelineMap) return null; + + console.debug('timelineMap', timelineMap); + + return ( + + {[...timelineMap.values()].map((entry, i) => ( + {'entry ID: ' + entry._id.$oid} + ))} + + ); +}; + +describe('TimelineContext', () => { + it('renders correctly', async () => { + render(); + await waitFor(() => { + // make sure timeline entries are rendered + expect(screen.getByTestId('timeline-entries')).toBeTruthy(); + // make sure number of Text components matches number of timeline entries + expect(screen.getAllByText(/entry ID:/).length).toBe(3); + }); + }); +}); diff --git a/www/__tests__/appTheme.test.ts b/www/__tests__/appTheme.test.ts new file mode 100644 index 000000000..9ec3e0fdf --- /dev/null +++ b/www/__tests__/appTheme.test.ts @@ -0,0 +1,22 @@ +import { getTheme } from '../js/appTheme'; + +describe('getTheme', () => { + it('should return the right theme with place', () => { + const theme = getTheme('place'); + expect(theme.colors.elevation.level1).toEqual('#cbe6ff'); + }); + + it('should return the right theme with untracked', () => { + const theme = getTheme('untracked'); + expect(theme.colors.primary).toEqual('#8c4a57'); + expect(theme.colors.primaryContainer).toEqual('#e3bdc2'); + expect(theme.colors.elevation.level1).toEqual('#f8ebec'); + }); + + it('should return the right theme with draft', () => { + const theme = getTheme('draft'); + expect(theme.colors.primary).toEqual('#616971'); + expect(theme.colors.primaryContainer).toEqual('#b6bcc2'); + expect(theme.colors.background).toEqual('#eef1f4'); + }); +}); diff --git a/www/__tests__/commHelper.test.ts b/www/__tests__/commHelper.test.ts index d7018abb5..c66fb5f36 100644 --- a/www/__tests__/commHelper.test.ts +++ b/www/__tests__/commHelper.test.ts @@ -47,4 +47,8 @@ it('fetches text from a URL and caches it so the next call is faster', async () * - updateUser * - getUser * - putOne + * - getUserCustomLabels + * - insertUserCustomLabel + * - updateUserCustomLabel + * - deleteUserCustomLabel */ diff --git a/www/__tests__/confirmHelper.test.ts b/www/__tests__/confirmHelper.test.ts index 52cb9c0e8..6642b6ed4 100644 --- a/www/__tests__/confirmHelper.test.ts +++ b/www/__tests__/confirmHelper.test.ts @@ -15,7 +15,7 @@ import { import initializedI18next from '../js/i18nextInit'; import { CompositeTrip, UserInputEntry } from '../js/types/diaryTypes'; -import { UserInputMap } from '../js/diary/LabelTabContext'; +import { UserInputMap } from '../js/TimelineContext'; window['i18next'] = initializedI18next; mockLogger(); @@ -45,6 +45,16 @@ const fakeDefaultLabelOptions = { }, }, }; +const fakeInputs = { + MODE: [ + { data: { label: 'walk', start_ts: 1245, end_ts: 5678 } }, + { data: { label: 'bike', start_ts: 1245, end_ts: 5678 } }, + ], + PURPOSE: [ + { data: { label: 'home', start_ts: 1245, end_ts: 5678 } }, + { data: { label: 'work', start_ts: 1245, end_ts: 5678 } }, + ], +}; jest.mock('../js/services/commHelper', () => ({ ...jest.requireActual('../js/services/commHelper'), @@ -62,8 +72,8 @@ describe('confirmHelper', () => { it('returns base labelInputDetails for a labelUserInput which does not have mode of study', () => { const fakeLabelUserInput = { - MODE: fakeDefaultLabelOptions.MODE[1], - PURPOSE: fakeDefaultLabelOptions.PURPOSE[0], + MODE: fakeInputs.MODE[1], + PURPOSE: fakeInputs.PURPOSE[0], }; const labelInputDetails = labelInputDetailsForTrip( fakeLabelUserInput, @@ -74,8 +84,8 @@ describe('confirmHelper', () => { 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], + MODE: fakeInputs.MODE[0], // 'walk' is mode of study + PURPOSE: fakeInputs.PURPOSE[0], }; const labelInputDetails = labelInputDetailsForTrip( fakeLabelUserInput, diff --git a/www/__tests__/inputMatcher.test.ts b/www/__tests__/inputMatcher.test.ts index 062951b35..9ea4d9b02 100644 --- a/www/__tests__/inputMatcher.test.ts +++ b/www/__tests__/inputMatcher.test.ts @@ -1,6 +1,6 @@ import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; import { mockLogger } from '../__mocks__/globalMocks'; -import { unprocessedLabels, updateLocalUnprocessedInputs } from '../js/diary/timelineHelper'; +import { updateLocalUnprocessedInputs } from '../js/diary/timelineHelper'; import * as logger from '../js/plugin/logger'; import { EnketoUserInputEntry } from '../js/survey/enketo/enketoHelper'; import { @@ -376,9 +376,9 @@ describe('mapInputsToTimelineEntries on an ENKETO configuration', () => { user_input: { trip_user_input: { data: { - name: 'TripConfirmSurvey', + name: 'MyCustomSurvey', version: 1, - xmlResponse: '', + xmlResponse: '', start_ts: 1000, end_ts: 3000, }, @@ -417,6 +417,12 @@ describe('mapInputsToTimelineEntries on an ENKETO configuration', () => { ], }, ] as any as TimelineEntry[]; + + // reset local unprocessed inputs to ensure MUTLILABEL inputs don't leak into ENKETO tests + beforeAll(async () => { + await updateLocalUnprocessedInputs({ start_ts: 1000, end_ts: 5000 }, fakeConfigEnketo); + }); + it('creates a map that has the processed responses and notes', () => { const [labelMap, notesMap] = mapInputsToTimelineEntries( timelineEntriesEnketo, @@ -424,8 +430,8 @@ describe('mapInputsToTimelineEntries on an ENKETO configuration', () => { ); expect(labelMap).toMatchObject({ trip1: { - SURVEY: { - data: { xmlResponse: '' }, + MyCustomSurvey: { + data: { xmlResponse: '' }, }, }, }); @@ -460,12 +466,12 @@ describe('mapInputsToTimelineEntries on an ENKETO configuration', () => { expect(labelMap).toMatchObject({ trip1: { - SURVEY: { - data: { xmlResponse: '' }, + MyCustomSurvey: { + data: { xmlResponse: '' }, }, }, trip2: { - SURVEY: { + TripConfirmSurvey: { data: { xmlResponse: '' }, }, }, diff --git a/www/__tests__/metricsHelper.test.ts b/www/__tests__/metricsHelper.test.ts new file mode 100644 index 000000000..c914c5782 --- /dev/null +++ b/www/__tests__/metricsHelper.test.ts @@ -0,0 +1,338 @@ +import { DateTime } from 'luxon'; +import { + calculatePercentChange, + formatDate, + formatDateRangeOfDays, + getLabelsForDay, + getUniqueLabelsForDays, + secondsToHours, + secondsToMinutes, + segmentDaysByWeeks, + metricToValue, + tsForDayOfMetricData, + valueForFieldOnDay, + generateSummaryFromData, + isCustomLabels, + isAllCustom, + isOnFoot, + getUnitUtilsForMetric, +} from '../js/metrics/metricsHelper'; +import { DayOfMetricData } from '../js/metrics/metricsTypes'; +import initializedI18next from '../js/i18nextInit'; +window['i18next'] = initializedI18next; + +describe('metricsHelper', () => { + describe('getUniqueLabelsForDays', () => { + const days1 = [ + { mode_confirm_a: 1, mode_confirm_b: 2 }, + { mode_confirm_b: 1, mode_confirm_c: 3 }, + { mode_confirm_c: 1, mode_confirm_d: 3 }, + ] as any as DayOfMetricData[]; + it("should return unique labels for days with 'mode_confirm_*'", () => { + expect(getUniqueLabelsForDays(days1)).toEqual(['a', 'b', 'c', 'd']); + }); + }); + + describe('getLabelsForDay', () => { + const day1 = { mode_confirm_a: 1, mode_confirm_b: 2 } as any as DayOfMetricData; + it("should return labels for a day with 'mode_confirm_*'", () => { + expect(getLabelsForDay(day1)).toEqual(['a', 'b']); + }); + }); + + describe('secondsToMinutes', () => { + it('should convert from seconds to minutes properly', () => { + expect(secondsToMinutes(360)).toEqual(6); + }); + }); + + describe('secondsToHours', () => { + it('should convert from seconds to hours properly', () => { + expect(secondsToHours(3600)).toEqual(1); + }); + }); + + describe('segmentDaysByWeeks', () => { + const days1 = [ + { date: '2021-01-01' }, + { date: '2021-01-02' }, + { date: '2021-01-04' }, + { date: '2021-01-08' }, + { date: '2021-01-09' }, + { date: '2021-01-10' }, + ] as any as DayOfMetricData[]; + + it("should segment days with 'date' into weeks", () => { + expect(segmentDaysByWeeks(days1, '2021-01-10')).toEqual([ + // most recent week + [ + { date: '2021-01-04' }, + { date: '2021-01-08' }, + { date: '2021-01-09' }, + { date: '2021-01-10' }, + ], + // prior week + [{ date: '2021-01-01' }, { date: '2021-01-02' }], + ]); + }); + }); + + describe('formatDate', () => { + const day1 = { date: '2021-01-01' } as any as DayOfMetricData; + it('should format date', () => { + expect(formatDate(day1)).toEqual('1/1'); + }); + }); + + describe('formatDateRangeOfDays', () => { + const days1 = [ + { date: '2021-01-01' }, + { date: '2021-01-02' }, + { date: '2021-01-04' }, + ] as any as DayOfMetricData[]; + it('should format date range for days with date', () => { + expect(formatDateRangeOfDays(days1)).toEqual('1/1 - 1/4'); + }); + }); + + describe('metricToValue', () => { + const metric = { + walking: 10, + nUsers: 5, + }; + it('returns correct value for user population', () => { + const result = metricToValue('user', metric, 'walking'); + expect(result).toBe(10); + }); + + it('returns correct value for aggregate population', () => { + const result = metricToValue('aggregate', metric, 'walking'); + expect(result).toBe(2); + }); + }); + + describe('isOnFoot', () => { + it('returns true for on foot mode', () => { + const result = isOnFoot('WALKING'); + expect(result).toBe(true); + }); + + it('returns false for non on foot mode', () => { + const result = isOnFoot('DRIVING'); + expect(result).toBe(false); + }); + }); + + describe('calculatePercentChange', () => { + it('calculates percent change correctly for low and high values', () => { + const pastWeekRange = { low: 10, high: 30 }; + const previousWeekRange = { low: 5, high: 10 }; + const result = calculatePercentChange(pastWeekRange, previousWeekRange); + expect(result.low).toBe(100); + expect(result.high).toBe(200); + }); + }); + + describe('tsForDayOfMetricData', () => { + const mockDay = { + date: '2024-05-28T12:00:00Z', + nUsers: 10, + }; + let _datesTsCache; + beforeEach(() => { + _datesTsCache = {}; + }); + + it('calculates timestamp for a given day', () => { + const expectedTimestamp = DateTime.fromISO(mockDay.date).toSeconds(); + const result = tsForDayOfMetricData(mockDay); + expect(result).toBe(expectedTimestamp); + }); + + it('caches the timestamp for subsequent calls with the same day', () => { + const firstResult = tsForDayOfMetricData(mockDay); + const secondResult = tsForDayOfMetricData(mockDay); + expect(secondResult).toBe(firstResult); + }); + }); + + describe('valueForFieldOnDay', () => { + const mockDay = { + date: '2024-05-28T12:00:00Z', + nUsers: 10, + field_key: 'example_value', + }; + + it('returns the value for a specified field and key', () => { + const result = valueForFieldOnDay(mockDay, 'field', 'key'); + expect(result).toBe('example_value'); + }); + }); + + describe('generateSummaryFromData', () => { + const modeMap = [ + { + key: 'mode1', + values: [ + ['value1', 10], + ['value2', 20], + ], + }, + { + key: 'mode2', + values: [ + ['value3', 30], + ['value4', 40], + ], + }, + ]; + it('returns summary with sum for non-speed metric', () => { + const metric = 'some_metric'; + const expectedResult = [ + { key: 'mode1', values: 30 }, + { key: 'mode2', values: 70 }, + ]; + const result = generateSummaryFromData(modeMap, metric); + expect(result).toEqual(expectedResult); + }); + + it('returns summary with average for speed metric', () => { + const metric = 'mean_speed'; + const expectedResult = [ + { key: 'mode1', values: 15 }, + { key: 'mode2', values: 35 }, + ]; + const result = generateSummaryFromData(modeMap, metric); + expect(result).toEqual(expectedResult); + }); + }); + + describe('isCustomLabels', () => { + it('returns true for all custom labels', () => { + const modeMap = [ + { + key: 'label_mode1', + values: [ + ['value1', 10], + ['value2', 20], + ], + }, + { + key: 'label_mode2', + values: [ + ['value3', 30], + ['value4', 40], + ], + }, + ]; + const result = isCustomLabels(modeMap); + expect(result).toBe(true); + }); + + it('returns true for all sensed labels', () => { + const modeMap = [ + { + key: 'label_mode1', + values: [ + ['value1', 10], + ['value2', 20], + ], + }, + { + key: 'label_mode2', + values: [ + ['value3', 30], + ['value4', 40], + ], + }, + ]; + const result = isCustomLabels(modeMap); + expect(result).toBe(true); + }); + + it('returns false for mixed custom and sensed labels', () => { + const modeMap = [ + { + key: 'label_mode1', + values: [ + ['value1', 10], + ['value2', 20], + ], + }, + { + key: 'MODE2', + values: [ + ['value3', 30], + ['value4', 40], + ], + }, + ]; + const result = isCustomLabels(modeMap); + expect(result).toBe(false); + }); + }); + + describe('isAllCustom', () => { + it('returns true when all keys are custom', () => { + const isSensedKeys = [false, false, false]; + const isCustomKeys = [true, true, true]; + const result = isAllCustom(isSensedKeys, isCustomKeys); + expect(result).toBe(true); + }); + + it('returns false when all keys are sensed', () => { + const isSensedKeys = [true, true, true]; + const isCustomKeys = [false, false, false]; + const result = isAllCustom(isSensedKeys, isCustomKeys); + expect(result).toBe(false); + }); + + it('returns undefined for mixed custom and sensed keys', () => { + const isSensedKeys = [true, false, true]; + const isCustomKeys = [false, true, false]; + const result = isAllCustom(isSensedKeys, isCustomKeys); + expect(result).toBe(undefined); + }); + }); + + describe('getUnitUtilsForMetric', () => { + const imperialConfig = { + distanceSuffix: 'mi', + speedSuffix: 'mph', + convertDistance: jest.fn((d) => d), + convertSpeed: jest.fn((s) => s), + getFormattedDistance: jest.fn((d) => `${d} mi`), + getFormattedSpeed: jest.fn((s) => `${s} mph`), + }; + + it('checks for distance metric', () => { + const result = getUnitUtilsForMetric('distance', imperialConfig); + expect(result).toEqual(['mi', expect.any(Function), expect.any(Function)]); + expect(result[1](1)).toBe(1); + expect(result[2](1)).toBe('1 mi mi'); + }); + + it('checks for duration metric', () => { + const result = getUnitUtilsForMetric('duration', imperialConfig); + expect(result).toEqual(['hours', expect.any(Function), expect.any(Function)]); + expect(result[1](3600)).toBe(1); + expect(result[2](3600)).toBe('1 hours'); + }); + + it('checks for count metric', () => { + const result = getUnitUtilsForMetric('count', imperialConfig); + expect(result).toEqual(['trips', expect.any(Function), expect.any(Function)]); + const mockTrip = { responded: 4, not_responded: 3 }; + expect(result[1](mockTrip)).toBe(mockTrip); + expect(result[2](mockTrip)).toBe(mockTrip + ' trips'); + }); + + it('checks for response_count metric', () => { + const result = getUnitUtilsForMetric('response_count', imperialConfig); + expect(result).toEqual(['responses', expect.any(Function), expect.any(Function)]); + const mockResponse = { responded: 5, not_responded: 2 }; + expect(result[1](mockResponse)).toBe(5); + expect(result[2](mockResponse)).toBe('5/7 responses'); + }); + }); +}); diff --git a/www/__tests__/timelineHelper.test.ts b/www/__tests__/timelineHelper.test.ts index bd817f084..c1c130272 100644 --- a/www/__tests__/timelineHelper.test.ts +++ b/www/__tests__/timelineHelper.test.ts @@ -28,7 +28,7 @@ afterAll(() => { describe('useGeojsonForTrip', () => { it('work with an empty input', () => { - const testVal = useGeojsonForTrip({} as any, {} as any); + const testVal = useGeojsonForTrip({} as any); expect(testVal).toBeFalsy; }); @@ -43,10 +43,7 @@ describe('useGeojsonForTrip', () => { }; it('works without labelMode flag', () => { - const testValue = useGeojsonForTrip( - mockTLH.mockCompDataTwo.phone_data[1].data, - mockTLH.mockLabelOptions, - ) as GeoJSONData; + const testValue = useGeojsonForTrip(mockTLH.mockCompDataTwo.phone_data[1].data) as GeoJSONData; expect(testValue).toBeTruthy; checkGeojson(testValue); expect(testValue.data.features.length).toBe(3); @@ -168,10 +165,10 @@ describe('unprocessedLabels, unprocessedNotes', () => { // update unprocessed inputs and check that the trip survey response shows up in unprocessedLabels await updateAllUnprocessedInputs({ start_ts: 4, end_ts: 6 }, mockTLH.mockConfigEnketo); - expect(unprocessedLabels['SURVEY'][0].data).toEqual(tripSurveyResponse); + expect(unprocessedLabels['TripConfirmSurvey'][0].data).toEqual(tripSurveyResponse); // the second response is ignored for now because we haven't enabled place_user_input yet // so the length is only 1 - expect(unprocessedLabels['SURVEY'].length).toEqual(1); + expect(unprocessedLabels['TripConfirmSurvey'].length).toEqual(1); }); it('has some trip- and place- level additions after they were just recorded', async () => { @@ -294,7 +291,7 @@ jest.mock('../js/services/unifiedDataLoader', () => ({ })); it('works when there are no unprocessed trips...', async () => { - expect(readUnprocessedTrips(-1, -1, {} as any)).resolves.toEqual([]); + expect(readUnprocessedTrips(-1, -1, {} as any, {} as any)).resolves.toEqual([]); }); it('works when there are one or more unprocessed trips...', async () => { @@ -302,6 +299,7 @@ it('works when there are one or more unprocessed trips...', async () => { mockTLH.fakeStartTsOne, mockTLH.fakeEndTsOne, {} as any, + {} as any, ); expect(testValueOne.length).toEqual(1); expect(testValueOne[0]).toEqual( diff --git a/www/__tests__/useImperialConfig.test.ts b/www/__tests__/useImperialConfig.test.ts index 593498aae..33c354271 100644 --- a/www/__tests__/useImperialConfig.test.ts +++ b/www/__tests__/useImperialConfig.test.ts @@ -1,14 +1,22 @@ -import { convertDistance, convertSpeed, formatForDisplay } from '../js/config/useImperialConfig'; +import React from 'react'; +import { + convertDistance, + convertSpeed, + formatForDisplay, + useImperialConfig, +} 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: { + display_config: { use_imperial: false, }, loading: false, })); }); +jest.spyOn(React, 'useState').mockImplementation((initialValue) => [initialValue, jest.fn()]); +jest.spyOn(React, 'useEffect').mockImplementation((effect: () => void) => effect()); describe('formatForDisplay', () => { it('should round to the nearest integer when value is >= 100', () => { @@ -53,3 +61,15 @@ describe('convertSpeed', () => { expect(convertSpeed(6.7056, true)).toBeCloseTo(15); // Approximately 15 mph }); }); + +describe('useImperialConfig', () => { + it('returns ImperialConfig with imperial units', () => { + const imperialConfig = useImperialConfig(); + expect(imperialConfig.distanceSuffix).toBe('km'); + expect(imperialConfig.speedSuffix).toBe('kmph'); + expect(imperialConfig.convertDistance(10)).toBe(0.01); + expect(imperialConfig.convertSpeed(20)).toBe(72); + expect(imperialConfig.getFormattedDistance(10)).toBe('0.01'); + expect(imperialConfig.getFormattedSpeed(20)).toBe('72'); + }); +}); diff --git a/www/i18n/en.json b/www/i18n/en.json index 9a1fadec7..738df38f5 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -8,7 +8,10 @@ "trip-confirm": { "services-please-fill-in": "Please fill in the {{text}} not listed.", "services-cancel": "Cancel", - "services-save": "Save" + "services-save": "Save", + "custom-mode": "Custom Mode", + "custom-purpose": "Custom Purpose", + "custom-labels": "Custom Labels" }, "control": { @@ -23,12 +26,13 @@ "force-sync": "Force sync", "share": "Share", "download-json-dump": "Download json dump", - "email-log": "Email log", + "share-log": "Share log", "upload-log": "Upload log", "view-privacy": "View Privacy Policy", "user-data": "User data", "erase-data": "Erase data", "dev-zone": "Developer zone", + "bluetooth-scan": "Scan for Bluetooth", "refresh": "Refresh", "end-trip-sync": "End trip + sync", "check-consent": "Check consent", @@ -47,7 +51,12 @@ "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" + "log-out": "Log Out", + "refresh-app-config": "Refresh App Configuration", + "current-version": "Current version: {{version}}", + "refreshing-app-config": "Refreshing app configuration, please wait...", + "already-up-to-date": "Already up to date!", + "manage-custom-labels": "Manage Custom Labels" }, "general-settings": { @@ -110,7 +119,9 @@ "trips": "trips", "hours": "hours", "minutes": "minutes", - "custom": "Custom" + "responses": "responses", + "custom": "Custom", + "no-data": "No data" }, "diary": { @@ -131,7 +142,6 @@ "choose-mode": "Mode", "choose-replaced-mode": "Replaces", "choose-purpose": "Purpose", - "choose-survey": "Add Trip Details", "select-mode-scroll": "Mode (scroll for more)", "select-replaced-mode-scroll": "Replaces (scroll for more)", "select-purpose-scroll": "Purpose (scroll for more)", @@ -188,8 +198,9 @@ "chart": "Chart", "change-data": "Change dates:", "distance": "Distance", - "trips": "Trips", + "count": "Trip Count", "duration": "Duration", + "response_count": "Response Count", "fav-mode": "My Favorite Mode", "speed": "My Speed", "footprint": "My Footprint", @@ -218,7 +229,22 @@ "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₂)" + "footprint-label": "Footprint (kg CO₂)", + "surveys": "Surveys", + "leaderboard": "Leaderboard", + "survey-response-rate": "Survey Response Rate (%)", + "survey-leaderboard-desc": "This data has been accumulated since ", + "comparison": "Comparison", + "you": "You", + "others": "Others in group", + "trip-categories": "Trip Categories", + "ev-roading-trip": "EV Roaming trip", + "ev-return-trip": "EV Return trip", + "gas-car-trip": "Gas Car trip", + "response": "Response", + "no-response": "No Response", + "you-are-in": "You're in", + "place": " place!" }, "details": { @@ -230,21 +256,40 @@ "list-datepicker-close": "Close", "list-datepicker-set": "Set", + "bluetooth": { + "title": { + "ble": "BLE Beacon Scanner", + "classic": "Bluetooth Classic Scanner" + }, + "scan": { + "for-ble": "Scan for BLE Beacons", + "for-bluetooth": "Scan for Classic Devices", + "stop": "Stop Scanning" + }, + "is-scanning": "Scanning...", + "device-info": { + "id": "ID", + "name": "Name" + }, + "switch-to": { + "classic": "Switch to Classic", + "ble": "Switch to BLE" + } + }, + "service": { "reading-server": "Reading from server...", "reading-unprocessed-data": "Reading unprocessed data..." }, - "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": { + "shareFile-service": { + "send-to": "Please send this file to openpath@nrel.gov, or another OpenPATH team member.", + "send-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": { + "send-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" } @@ -409,7 +454,8 @@ "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 " + "while-max-index": "While getting max index ", + "while-scanning-bluetooth": "While scanning for Bluetooth Devices: " }, "consent-text": { "title": "NREL OPENPATH PRIVACY POLICY/TERMS OF USE", diff --git a/www/js/App.tsx b/www/js/App.tsx index 4f9650e34..2ea317789 100644 --- a/www/js/App.tsx +++ b/www/js/App.tsx @@ -1,11 +1,7 @@ +import React, { useEffect, useState, createContext } from 'react'; import { registerRootComponent } from 'expo'; -import React, { useEffect, useState, createContext, useMemo } from 'react'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { PaperProvider, ActivityIndicator, BottomNavigation, 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 { PaperProvider, ActivityIndicator } from 'react-native-paper'; import useAppConfig from './useAppConfig'; import OnboardingStack from './onboarding/OnboardingStack'; import { @@ -19,9 +15,10 @@ import usePermissionStatus from './usePermissionStatus'; import { initPushNotify } from './splash/pushNotifySettings'; import { initStoreDeviceSettings } from './splash/storeDeviceSettings'; import { initRemoteNotifyHandler } from './splash/remoteNotifyHandler'; -import { withErrorBoundary } from './plugin/ErrorBoundary'; +import { getUserCustomLabels } from './services/commHelper'; import { initCustomDatasetHelper } from './metrics/customMetricsHelper'; import AlertBar from './components/AlertBar'; +import Main from './Main'; import { getTheme } from './appTheme'; import initializedI18next from '../js/i18nextInit'; @@ -30,56 +27,21 @@ window['i18next'] = initializedI18next; import { setupExpoCompat, IS_EXPO } from './expoCompat'; setupExpoCompat(); -const defaultRoutes = (t) => [ - { - key: 'label', - title: t('diary.label-tab'), - focusedIcon: 'check-bold', - unfocusedIcon: 'check-outline', - accessibilityLabel: t('diary.label-tab'), - }, - { - key: 'metrics', - title: t('metrics.dashboard-tab'), - focusedIcon: 'chart-box', - unfocusedIcon: 'chart-box-outline', - accessibilityLabel: t('metrics.dashboard-tab'), - }, - { - key: 'control', - title: t('control.profile-tab'), - focusedIcon: 'account', - unfocusedIcon: 'account-outline', - accessibilityLabel: t('control.profile-tab'), - }, -]; - export const AppContext = createContext({}); - -const scenes = { - label: withErrorBoundary(LabelTab), - metrics: withErrorBoundary(MetricsTab), - control: withErrorBoundary(ProfileSettings), +const CUSTOM_LABEL_KEYS_IN_DATABASE = ['mode', 'purpose']; +type CustomLabelMap = { + [k: string]: string[]; }; const theme = getTheme(); const App = () => { - const [index, setIndex] = useState(0); // will remain null while the onboarding state is still being determined const [onboardingState, setOnboardingState] = useState(null); const [permissionsPopupVis, setPermissionsPopupVis] = useState(false); + const [customLabelMap, setCustomLabelMap] = useState({}); const appConfig = useAppConfig(); const permissionStatus = usePermissionStatus(); - const { colors } = useTheme(); - const { t } = useTranslation(); - - 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(scenes); const refreshOnboardingState = () => getPendingOnboardingState().then(setOnboardingState); useEffect(() => { @@ -94,6 +56,7 @@ const App = () => { initPushNotify(); initStoreDeviceSettings(); initRemoteNotifyHandler(); + getUserCustomLabels(CUSTOM_LABEL_KEYS_IN_DATABASE).then((res) => setCustomLabelMap(res)); initCustomDatasetHelper(appConfig); }, [appConfig]); @@ -105,6 +68,8 @@ const App = () => { permissionStatus, permissionsPopupVis, setPermissionsPopupVis, + customLabelMap, + setCustomLabelMap, }; let appContent; @@ -113,20 +78,7 @@ const App = () => { appContent = ; } else if (onboardingState?.route == OnboardingRoute.DONE) { // if onboarding route is DONE, show the main app with navigation between tabs - appContent = ( - - ); + appContent =
; } else { // if there is an onboarding route that is not DONE, show the onboarding stack appContent = ; @@ -134,23 +86,15 @@ const App = () => { 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 ? ( + This also includes if onboarding is DONE altogether (because "DONE" is > "PROTOCOL") */} + {onboardingState && onboardingState.route > OnboardingRoute.PROTOCOL && ( - ) : null} + )} @@ -158,5 +102,4 @@ const App = () => { ); }; -// if using Expo, register the root component before exporting. else, just export export default IS_EXPO ? registerRootComponent(App) : App; diff --git a/www/js/Main.tsx b/www/js/Main.tsx new file mode 100644 index 000000000..650ed4044 --- /dev/null +++ b/www/js/Main.tsx @@ -0,0 +1,84 @@ +/* Once onboarding is done, this is the main app content. + Includes the bottom navigation bar and each of the tabs. */ + +import React, { useEffect } from 'react'; +import { useContext, useMemo, useState } from 'react'; +import { BottomNavigation, useTheme } from 'react-native-paper'; +import { AppContext } from './App'; +import { useTranslation } from 'react-i18next'; +import { withErrorBoundary } from './plugin/ErrorBoundary'; +import LabelTab from './diary/LabelTab'; +import MetricsTab from './metrics/MetricsTab'; +import ProfileSettings from './control/ProfileSettings'; +import TimelineContext, { useTimelineContext } from './TimelineContext'; + +const defaultRoutes = (t) => [ + { + key: 'label', + title: t('diary.label-tab'), + focusedIcon: 'check-bold', + unfocusedIcon: 'check-outline', + accessibilityLabel: t('diary.label-tab'), + }, + { + key: 'metrics', + title: t('metrics.dashboard-tab'), + focusedIcon: 'chart-box', + unfocusedIcon: 'chart-box-outline', + accessibilityLabel: t('metrics.dashboard-tab'), + }, + { + key: 'control', + title: t('control.profile-tab'), + focusedIcon: 'account', + unfocusedIcon: 'account-outline', + accessibilityLabel: t('control.profile-tab'), + }, +]; + +const scenes = { + label: withErrorBoundary(LabelTab), + metrics: withErrorBoundary(MetricsTab), + control: withErrorBoundary(ProfileSettings), +}; +const renderScene = BottomNavigation.SceneMap(scenes); + +const Main = () => { + const [index, setIndex] = useState(0); + const { colors } = useTheme(); + const { t } = useTranslation(); + const { appConfig } = useContext(AppContext); + const timelineContext = useTimelineContext(); + + const routes = useMemo(() => { + const showMetrics = + appConfig?.metrics?.phone_dashboard_ui || + appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL'; + return showMetrics ? defaultRoutes(t) : defaultRoutes(t).filter((r) => r.key != 'metrics'); + }, [appConfig, t]); + + useEffect(() => { + const { setShouldUpdateTimeline } = timelineContext; + // update TimelineScrollList component only when the active tab is 'label' to fix leaflet map issue + setShouldUpdateTimeline(!index); + }, [index]); + + return ( + + + + ); +}; + +export default Main; diff --git a/www/js/TimelineContext.ts b/www/js/TimelineContext.ts new file mode 100644 index 000000000..203051094 --- /dev/null +++ b/www/js/TimelineContext.ts @@ -0,0 +1,378 @@ +import { createContext, useEffect, useState } from 'react'; +import { CompositeTrip, TimelineEntry, TimestampRange, UserInputEntry } from './types/diaryTypes'; +import useAppConfig from './useAppConfig'; +import { LabelOption, LabelOptions, MultilabelKey } from './types/labelTypes'; +import { getLabelOptions, labelOptionByValue } from './survey/multilabel/confirmHelper'; +import { displayError, displayErrorMsg, logDebug, logWarn } from './plugin/logger'; +import { useTranslation } from 'react-i18next'; +import { DateTime } from 'luxon'; +import { + isoDateWithOffset, + compositeTrips2TimelineMap, + readAllCompositeTrips, + readUnprocessedTrips, + unprocessedLabels, + unprocessedNotes, + updateUnprocessedBleScans, + unprocessedBleScans, + updateAllUnprocessedInputs, + updateLocalUnprocessedInputs, + isoDateRangeToTsRange, +} from './diary/timelineHelper'; +import { getPipelineRangeTs } from './services/commHelper'; +import { getNotDeletedCandidates, mapInputsToTimelineEntries } from './survey/inputMatcher'; +import { EnketoUserInputEntry } from './survey/enketo/enketoHelper'; +import { VehicleIdentity } from './types/appConfigTypes'; +import { primarySectionForTrip } from './diary/diaryHelper'; + +const TODAY_DATE = DateTime.now().toISODate(); + +type ContextProps = { + labelOptions: LabelOptions | null; + timelineMap: TimelineMap | null; + timelineLabelMap: TimelineLabelMap | null; + userInputFor: (tlEntry: TimelineEntry) => UserInputMap | undefined; + notesFor: (tlEntry: TimelineEntry) => UserInputEntry[] | undefined; + labelFor: ( + tlEntry: TimelineEntry, + labelType: MultilabelKey, + ) => VehicleIdentity | LabelOption | undefined; + confirmedModeFor: (tlEntry: TimelineEntry) => LabelOption | undefined; + addUserInputToEntry: (oid: string, userInput: any, inputType: 'label' | 'note') => void; + pipelineRange: TimestampRange | null; + queriedDateRange: [string, string] | null; // YYYY-MM-DD format + dateRange: [string, string] | null; // YYYY-MM-DD format + timelineIsLoading: string | false; + loadMoreDays: (when: 'past' | 'future', nDays: number) => boolean | void; + loadDateRange: (d: [string, string]) => boolean | void; + refreshTimeline: () => void; + shouldUpdateTimeline: Boolean; + setShouldUpdateTimeline: React.Dispatch>; +}; + +export const useTimelineContext = (): ContextProps => { + const { t } = useTranslation(); + const appConfig = useAppConfig(); + + const [labelOptions, setLabelOptions] = useState(null); + // timestamp range that has been processed by the pipeline on the server + const [pipelineRange, setPipelineRange] = useState(null); + // date range (inclusive) that has been loaded into the UI [YYYY-MM-DD, YYYY-MM-DD] + const [queriedDateRange, setQueriedDateRange] = useState<[string, string] | null>(null); + // date range (inclusive) chosen by datepicker [YYYY-MM-DD, YYYY-MM-DD] + const [dateRange, setDateRange] = useState<[string, string] | null>(null); + // map of timeline entries (trips, places, untracked time), ids to objects + const [timelineMap, setTimelineMap] = useState(null); + const [timelineIsLoading, setTimelineIsLoading] = useState('replace'); + const [timelineLabelMap, setTimelineLabelMap] = useState(null); + const [timelineNotesMap, setTimelineNotesMap] = useState(null); + const [refreshTime, setRefreshTime] = useState(null); + // Leaflet map encounters an error when prerendered, so we need to render the TimelineScrollList component when the active tab is 'label' + // 'shouldUpdateTimeline' gets updated based on the current tab index, and we can use it to determine whether to render the timeline or not + const [shouldUpdateTimeline, setShouldUpdateTimeline] = useState(true); + + // initialization, once the appConfig is loaded + useEffect(() => { + try { + if (!appConfig) return; + getLabelOptions(appConfig).then((labelOptions) => setLabelOptions(labelOptions)); + loadTimelineEntries(); + } catch (e) { + displayError(e, t('errors.while-initializing-label')); + } + }, [appConfig, refreshTime]); + + // when a new date range is chosen, load more data, then update the queriedDateRange + useEffect(() => { + const onDateRangeChange = async () => { + if (!dateRange) return logDebug('No dateRange chosen, skipping onDateRangeChange'); + logDebug('Timeline: onDateRangeChange with dateRange = ' + dateRange?.join(' to ')); + + // determine if this will be a new range or an expansion of the existing range + let mode: 'replace' | 'prepend' | 'append'; + let dateRangeToQuery = dateRange; + if (queriedDateRange?.[0] == dateRange?.[0] && queriedDateRange?.[1] == dateRange?.[1]) { + // same range, so we are refreshing the data + mode = 'replace'; + } else if (dateRange && queriedDateRange?.[0] == dateRange[0]) { + // same start date, so we are loading more data into the future + mode = 'append'; + const nextDate = isoDateWithOffset(queriedDateRange[1], 1); + dateRangeToQuery = [nextDate, dateRange[1]]; + } else if (dateRange && queriedDateRange?.[1] == dateRange[1]) { + // same end date, so we are loading more data into the past + mode = 'prepend'; + const prevDate = isoDateWithOffset(queriedDateRange[0], -1); + dateRangeToQuery = [dateRange[0], prevDate]; + } else { + // neither start nor end date is the same, so we treat this as a completely new range + mode = 'replace'; + } + setTimelineIsLoading(mode); + const [ctList, utList] = await fetchTripsInRange(dateRangeToQuery); + handleFetchedTrips(ctList, utList, mode); + setQueriedDateRange(dateRange); + }; + + try { + onDateRangeChange(); + } catch (e) { + setTimelineIsLoading(false); + displayError(e, 'While loading date range ' + dateRange?.join(' to ')); + } + }, [dateRange]); + + useEffect(() => { + if (!timelineMap) return; + const allEntries = Array.from(timelineMap.values()); + const [newTimelineLabelMap, newTimelineNotesMap] = mapInputsToTimelineEntries( + allEntries, + appConfig, + ); + setTimelineLabelMap(newTimelineLabelMap); + setTimelineNotesMap(newTimelineNotesMap); + setTimelineIsLoading(false); + }, [timelineMap]); + + async function loadTimelineEntries() { + try { + const pipelineRange = await getPipelineRangeTs(); + await updateAllUnprocessedInputs(pipelineRange, appConfig); + logDebug(`Timeline: After updating unprocessedInputs, + unprocessedLabels = ${JSON.stringify(unprocessedLabels)}; + unprocessedNotes = ${JSON.stringify(unprocessedNotes)}`); + if (appConfig.vehicle_identities?.length) { + await updateUnprocessedBleScans({ + start_ts: pipelineRange.end_ts, + end_ts: Date.now() / 1000, + }); + logDebug(`Timeline: After updating unprocessedBleScans, + unprocessedBleScans = ${JSON.stringify(unprocessedBleScans)}; + `); + } + setPipelineRange(pipelineRange); + if (pipelineRange.end_ts) { + // set initial date range to [pipelineEndDate - 7 days, TODAY_DATE] + setDateRange([ + DateTime.fromSeconds(pipelineRange.end_ts).minus({ days: 7 }).toISODate(), + TODAY_DATE, + ]); + } else { + logWarn('Timeline: no pipeline end date. dateRange will stay null'); + setTimelineIsLoading(false); + } + } catch (e) { + displayError(e, t('errors.while-loading-pipeline-range')); + setTimelineIsLoading(false); + } + } + + function loadMoreDays(when: 'past' | 'future', nDays: number) { + if (!queriedDateRange) { + logWarn('No queriedDateRange yet - early return from loadMoreDays'); + return; + } + logDebug(`Timeline: loadMoreDays, ${nDays} days into the ${when}; + queriedDateRange = ${queriedDateRange}`); + return loadDateRange( + when == 'past' + ? [isoDateWithOffset(queriedDateRange[0], -nDays), queriedDateRange[1]] + : [queriedDateRange[0], isoDateWithOffset(queriedDateRange[1], nDays)], + ); + } + + function loadDateRange(range: [string, string]) { + logDebug('Timeline: loadDateRange with newDateRange = ' + range); + if (!pipelineRange) { + logWarn('No pipelineRange yet - early return from loadDateRange'); + return; + } + const pipelineStartDate = DateTime.fromSeconds(pipelineRange.start_ts).toISODate(); + // clamp range to ensure it is within [pipelineStartDate, TODAY_DATE] + const clampedDateRange: [string, string] = [ + new Date(range[0]) < new Date(pipelineStartDate) ? pipelineStartDate : range[0], + new Date(range[1]) > new Date(TODAY_DATE) ? TODAY_DATE : range[1], + ]; + if (clampedDateRange[0] != dateRange?.[0] || clampedDateRange[1] != dateRange?.[1]) { + logDebug('Timeline: loadDateRange setting new date range = ' + clampedDateRange); + setTimelineIsLoading('queued'); + setDateRange(clampedDateRange); + return true; + } else { + logDebug('Timeline: loadDateRange no change in date range'); + return false; + } + } + + function handleFetchedTrips(ctList, utList, mode: 'prepend' | 'append' | 'replace') { + logDebug(`Timeline: handleFetchedTrips with + mode = ${mode}; + ctList has ${ctList.length} trips; + utList has ${utList.length} trips`); + + const tripsRead = ctList.concat(utList); + const showPlaces = Boolean(appConfig.survey_info?.buttons?.['place-notes']); + const readTimelineMap = compositeTrips2TimelineMap(tripsRead, showPlaces); + logDebug(`Timeline: after composite trips converted, + readTimelineMap = ${[...readTimelineMap.entries()]}`); + if (mode == 'append') { + setTimelineMap(new Map([...(timelineMap || []), ...readTimelineMap])); + } else if (mode == 'prepend') { + setTimelineMap(new Map([...readTimelineMap, ...(timelineMap || [])])); + } else if (mode == 'replace') { + setTimelineMap(readTimelineMap); + } else { + return displayErrorMsg('Unknown insertion mode ' + mode); + } + } + + async function fetchTripsInRange(dateRange: [string, string]) { + if (!pipelineRange?.start_ts || !pipelineRange?.end_ts) + return logWarn('No pipelineRange yet - early return'); + logDebug('Timeline: fetchTripsInRange from ' + dateRange[0] + ' to ' + dateRange[1]); + + const [startTs, endTs] = isoDateRangeToTsRange(dateRange); + const maxStartTs = Math.max(startTs, pipelineRange.start_ts); // ensure that we don't read before the pipeline start + const minEndTs = Math.min(endTs, pipelineRange.end_ts); // ensure that we don't read after the pipeline end + + const readCompositePromise = readAllCompositeTrips(maxStartTs, minEndTs); + let readUnprocessedPromise; + if (endTs >= pipelineRange.end_ts) { + let lastProcessedTrip: CompositeTrip | undefined; + if (timelineMap) { + lastProcessedTrip = [...timelineMap?.values()] + .reverse() + .find((trip) => trip.origin_key.includes('trip')) as CompositeTrip; + } + readUnprocessedPromise = readUnprocessedTrips( + Math.max(pipelineRange.end_ts, startTs), + endTs, + appConfig, + lastProcessedTrip, + ); + } else { + readUnprocessedPromise = Promise.resolve([]); + } + + const results = await Promise.all([readCompositePromise, readUnprocessedPromise]); + logDebug(`Timeline: readCompositePromise resolved with ${results[0]?.length} trips; + readUnprocessedPromise resolved with ${results[1]?.length} trips`); + return results; + } + + function refreshTimeline() { + try { + logDebug('timelineContext: refreshTimeline'); + setTimelineIsLoading('replace'); + setDateRange(null); + setQueriedDateRange(null); + setTimelineMap(null); + setRefreshTime(new Date()); + } catch (e) { + displayError(e, t('errors.while-refreshing-label')); + } + } + + const userInputFor = (tlEntry: TimelineEntry) => + timelineLabelMap?.[tlEntry._id.$oid] || undefined; + const notesFor = (tlEntry: TimelineEntry) => timelineNotesMap?.[tlEntry._id.$oid] || undefined; + + /** + * @param tlEntry The trip or place object to get the label for + * @param labelType The type of label to get (e.g. MODE, PURPOSE, etc.) + * @returns the label option object for the given label type, or undefined if there is no label + */ + const labelFor = (tlEntry: TimelineEntry, labelType: MultilabelKey) => { + const chosenLabel = userInputFor(tlEntry)?.[labelType]?.data.label; + return chosenLabel ? labelOptionByValue(chosenLabel, labelType) : undefined; + }; + + /** + * @param tlEntry The trip or place object to get the confirmed mode for + * @returns Confirmed mode, which could be a vehicle identity as determined by Bluetooth scans, + * or the label option from a user-given 'MODE' label, or undefined if neither exists. + */ + const confirmedModeFor = (tlEntry: CompositeTrip) => + primarySectionForTrip(tlEntry)?.ble_sensed_mode || labelFor(tlEntry, 'MODE'); + + function addUserInputToEntry(oid: string, userInput: any, inputType: 'label' | 'note') { + const tlEntry = timelineMap?.get(oid); + if (!pipelineRange || !tlEntry) + return displayErrorMsg('Item with oid: ' + oid + ' not found in timeline'); + const nowTs = new Date().getTime() / 1000; // epoch seconds + if (inputType == 'label') { + const newLabels: UserInputMap = {}; + for (const [inputType, labelValue] of Object.entries(userInput)) { + newLabels[inputType] = { data: labelValue, metadata: { write_ts: nowTs } as any }; + } + logDebug('Timeline: newLabels = ' + JSON.stringify(newLabels)); + const newTimelineLabelMap: TimelineLabelMap = { + ...timelineLabelMap, + [oid]: { + ...timelineLabelMap?.[oid], + ...newLabels, + }, + }; + setTimelineLabelMap(newTimelineLabelMap); + } else if (inputType == 'note') { + const notesForEntry = timelineNotesMap?.[oid] || []; + const newAddition = { data: userInput, metadata: { write_ts: nowTs } }; + notesForEntry.push(newAddition as UserInputEntry); + const newTimelineNotesMap: TimelineNotesMap = { + ...timelineNotesMap, + [oid]: getNotDeletedCandidates(notesForEntry), + }; + setTimelineNotesMap(newTimelineNotesMap); + } + /* We can update unprocessed inputs in the background, without blocking the completion + of this function. That is why this is not 'await'ed */ + updateLocalUnprocessedInputs(pipelineRange, appConfig); + } + + return { + pipelineRange, + queriedDateRange, + dateRange, + timelineMap, + timelineIsLoading, + timelineLabelMap, + labelOptions, + loadMoreDays, + loadDateRange, + refreshTimeline, + userInputFor, + labelFor, + notesFor, + confirmedModeFor, + addUserInputToEntry, + shouldUpdateTimeline, + setShouldUpdateTimeline, + }; +}; + +export type UserInputMap = { + /* If keys are 'MODE', 'PURPOSE', 'REPLACED_MODE', this is the MULTILABEL configuration. + Values are entries that have a 'label' value in their 'data' */ + [k in MultilabelKey]?: UserInputEntry; +} & { + /* Otherwise we are in the ENKETO configuration, and keys are names of surveys. + Values are entries that have an 'xmlResponse' value in their 'data' */ + [k: string]: EnketoUserInputEntry | undefined; +}; + +export type TimelineMap = Map; // Todo: update to reflect unpacked trips (origin_Key, etc) +export type TimelineLabelMap = { + [k: string]: UserInputMap; +}; +export type TimelineNotesMap = { + [k: string]: UserInputEntry[]; +}; + +export type LabelTabFilter = { + key: string; + text: string; + filter: (trip: TimelineEntry, userInputForTrip: UserInputMap) => boolean; + state?: boolean; +}; + +export default createContext({} as ContextProps); diff --git a/www/js/appTheme.ts b/www/js/appTheme.ts index b66f493e6..d2f13c47e 100644 --- a/www/js/appTheme.ts +++ b/www/js/appTheme.ts @@ -32,7 +32,11 @@ 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), + silver: '#d9d9d9', + skyblue: '#7fcaea', + navy: '#0077aa', + orange: '#f6a063', }, roundness: 5, }; diff --git a/www/js/bluetooth/BluetoothCard.tsx b/www/js/bluetooth/BluetoothCard.tsx new file mode 100644 index 000000000..a244be7cf --- /dev/null +++ b/www/js/bluetooth/BluetoothCard.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { Card, List, Text, Button, useTheme } from 'react-native-paper'; +import { StyleSheet } from 'react-native'; + +type Props = any; +const BluetoothCard = ({ device, isClassic, isScanningBLE }: Props) => { + const { colors } = useTheme(); + if (isClassic) { + return ( + + } + /> + + ); + } + + let bgColor = colors.onPrimary; // 'rgba(225,225,225,1)' + if (isScanningBLE) { + bgColor = device.in_range ? `rgba(200,250,200,1)` : `rgba(250,200,200,1)`; + } + + async function fakeMonitorCallback(state: String) { + // If we don't do this, the results start accumulating in the device object + // first call, we put a result into the device + // second call, the device already has a result, so we put another one in... + const deviceWithoutResult = { ...device }; + deviceWithoutResult.monitorResult = undefined; + deviceWithoutResult.rangeResult = undefined; + window['cordova'].plugins.locationManager.getDelegate().didDetermineStateForRegion({ + region: deviceWithoutResult, + eventType: 'didDetermineStateForRegion', + state: state, + }); + } + + async function fakeRangeCallback() { + const deviceWithBeacons = { ...device }; + deviceWithBeacons.monitorResult = undefined; + deviceWithBeacons.rangeResult = undefined; + const beacons = [ + { + uuid: device.uuid, + major: device.major | 4567, + minor: device.minor | 1945, + proximity: 'ProximityNear', + accuracy: Math.random() * 1.33, + rssi: Math.random() * -62, + }, + ]; + deviceWithBeacons.minor = device.minor | 4567; + deviceWithBeacons.minor = device.minor | 4567; + window['cordova'].plugins.locationManager.getDelegate().didRangeBeaconsInRegion({ + region: deviceWithBeacons, + beacons: beacons, + eventType: 'didRangeBeaconsInRegion', + state: 'CLRegionStateInside', + }); + } + + return ( + + } + /> + + + {device.monitorResult} + + + {device.rangeResult} + + + Simulate by sending UI transitions + + + + + + + + + ); +}; + +export const cardStyles = StyleSheet.create({ + card: { + position: 'relative', + alignSelf: 'center', + marginVertical: 10, + }, + cardContent: { + flex: 1, + width: '100%', + }, +}); + +export default BluetoothCard; diff --git a/www/js/bluetooth/BluetoothScanPage.tsx b/www/js/bluetooth/BluetoothScanPage.tsx new file mode 100644 index 000000000..b50bf5283 --- /dev/null +++ b/www/js/bluetooth/BluetoothScanPage.tsx @@ -0,0 +1,432 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { DateTime } from 'luxon'; +import { StyleSheet, Modal, ScrollView, SafeAreaView, View, Text } from 'react-native'; +import { gatherBluetoothClassicData } from './bluetoothScanner'; +import { logWarn, displayError, displayErrorMsg, logDebug } from '../plugin/logger'; +import BluetoothCard from './BluetoothCard'; +import { Appbar, useTheme, TextInput, Button } from 'react-native-paper'; +import { + BLEBeaconDevice, + BLEPluginCallback, + BluetoothClassicDevice, + BLEDeviceList, +} from '../types/bluetoothDevices'; +import { forceTransition } from '../control/ControlCollectionHelper'; + +/** + * The implementation of this scanner page follows the design of + * `www/js/survey/enketo/EnketoModal.tsx`! + * + * Future work may include refractoring these files to be implementations of a + * single base "pop-up page" component + */ + +const BluetoothScanPage = ({ ...props }: any) => { + const STATIC_ID = 'edu.berkeley.eecs.emission'; + + const { t } = useTranslation(); + const [bluetoothClassicList, setBluetoothClassicList] = useState([]); + const [sampleBLEDevices, setSampleBLEDevices] = useState({ + '426C7565-4368-6172-6D42-6561636F6E74': { + identifier: STATIC_ID, + minor: 4949, + major: 3838, + in_range: false, + }, + '426C7565-4368-6172-6D42-6561636F6E73': { + identifier: STATIC_ID, + minor: 4949, + major: 3838, + in_range: false, + }, + }); + const [isScanningClassic, setIsScanningClassic] = useState(false); + const [isScanningBLE, setIsScanningBLE] = useState(false); + const [isClassic, setIsClassic] = useState(false); + const [newUUID, setNewUUID] = useState(null); + const [newMajor, setNewMajor] = useState(undefined); + const [newMinor, setNewMinor] = useState(undefined); + const { colors } = useTheme(); + + // Flattens the `sampleBeacons` into an array of BLEBeaconDevices + function beaconsToArray() { + return Object.entries(sampleBLEDevices).map(([uuid, device]) => ({ + uuid, + ...device, + })); + } + + // Function to run Bluetooth Classic test and update logs + async function runBluetoothClassicTest() { + // Classic not currently supported on iOS + if (window['cordova'].platformId == 'ios') { + displayErrorMsg('Sorry, iOS is not supported!', 'OSError'); + return; + } + + try { + let response = await window['cordova'].plugins.BEMDataCollection.bluetoothScanPermissions(); + if (response != 'OK') { + displayErrorMsg('Please Enable Bluetooth!', 'Insufficient Permissions'); + return; + } + } catch (e) { + displayError(e, 'Insufficient Permissions'); + return; + } + + try { + setIsScanningClassic(true); + const newLogs = await gatherBluetoothClassicData(t); + setBluetoothClassicList(newLogs); + } catch (error) { + logWarn(error); + } finally { + setIsScanningClassic(false); + } + } + + function setMonitorStatus(uuid: string, result: string, status: boolean) { + setSampleBLEDevices((prevDevices) => ({ + ...prevDevices, + [uuid]: { + ...prevDevices[uuid], + monitorResult: status ? result : undefined, + rangeResult: undefined, + in_range: status, + }, + })); + window['cordova']?.plugins?.BEMDataCollection.mockBLEObjects( + status ? 'REGION_ENTER' : 'REGION_EXIT', + uuid, + undefined, + undefined, + 1, + ); + if (!status) { + forceTransition('BLE_BEACON_LOST'); + } + } + + function setRangeStatus(uuid: string, result: string) { + setSampleBLEDevices((prevDevices) => ({ + ...prevDevices, + [uuid]: { + ...prevDevices[uuid], + rangeResult: result, + }, + })); + let parsedResult = JSON.parse(result); + parsedResult.beacons.forEach((beacon) => { + window['cordova']?.plugins?.BEMDataCollection.mockBLEObjects( + 'RANGE_UPDATE', + uuid, + beacon.major, + beacon.minor, + 5, + ); + }); + // we only check for the transition on "real" callbacks to avoid excessive + // spurious callbacks on android + if (parsedResult.beacons.length > 0) { + // if we have received 3 range responses for the same beacon in the + // last 5 minutes, we generate the transition. we read without metadata + // (last param) + let nowSec = DateTime.now().toUnixInteger(); + let tq = { key: 'write_ts', startTs: nowSec - 5 * 60, endTs: nowSec }; + let readBLEReadingsPromise = window[ + 'cordova' + ]?.plugins?.BEMUserCache.getSensorDataForInterval('background/bluetooth_ble', tq, false); + readBLEReadingsPromise.then((bleResponses) => { + // we add 5 entries at a time, so if we want 3 button presses, + // we really want 15 entries + let lastFifteenResponses = bleResponses.slice(0, 15); + if (!lastFifteenResponses.every((x) => x.eventType == 'RANGE_UPDATE')) { + console.log( + 'Last three entries ' + + lastFifteenResponses.map((x) => x.eventType) + + ' are not all RANGE_UPDATE, skipping transition', + ); + return; + } + + forceTransition('BLE_BEACON_FOUND'); + }); + } + } + + async function simulateLocation(state: String) { + forceTransition(state); + } + + // BLE LOGIC + async function startBeaconScanning() { + setIsScanningBLE(true); + + let delegate = new window['cordova'].plugins.locationManager.Delegate(); + + delegate.didDetermineStateForRegion = function (pluginResult: BLEPluginCallback) { + // `stateInside`is returned when the user enters the beacon region + // `StateOutside` is either (i) left region, or (ii) started scanner (outside region) + const pluginResultStr = JSON.stringify(pluginResult, null, 2); + if (pluginResult.state == 'CLRegionStateInside') { + // need toUpperCase(), b/c callback returns with only lowercase values... + setMonitorStatus(pluginResult.region.uuid.toUpperCase(), pluginResultStr, true); + } else if (pluginResult.state == 'CLRegionStateOutside') { + setMonitorStatus(pluginResult.region.uuid.toUpperCase(), pluginResultStr, false); + } + logDebug('[BLE] didDetermineStateForRegion'); + logDebug(pluginResultStr); + window['cordova'].plugins.locationManager.appendToDeviceLog( + '[DOM] didDetermineStateForRegion: ' + pluginResultStr, + ); + if (pluginResult.state == 'CLRegionStateInside') { + const beaconRegion = new window['cordova'].plugins.locationManager.BeaconRegion( + STATIC_ID, + pluginResult.region.uuid, + pluginResult.region.major, + pluginResult.region.minor, + ); + console.log('About to start ranging beacons for region ', beaconRegion); + window['cordova'].plugins.locationManager + .startRangingBeaconsInRegion(beaconRegion) + .fail(function (e) { + logWarn(e); + }) + .done(); + } + }; + + delegate.didStartMonitoringForRegion = function (pluginResult) { + logDebug('[BLE] didStartMonitoringForRegion'); + logDebug(JSON.stringify(pluginResult)); + }; + + delegate.didRangeBeaconsInRegion = function (pluginResult) { + // Not seeing this called... + logDebug('[BLE] didRangeBeaconsInRegion'); + const pluginResultStr = JSON.stringify(pluginResult, null, 2); + logDebug(pluginResultStr); + setRangeStatus(pluginResult.region.uuid.toUpperCase(), pluginResultStr); + }; + + window['cordova'].plugins.locationManager.setDelegate(delegate); + + // Setup regions for each beacon + beaconsToArray().forEach((sampleBeacon: BLEBeaconDevice) => { + // Use NULL for wildcard + // Need UUID value on iOS only, not Android (2nd parameter) + // https://stackoverflow.com/questions/38580410/how-to-scan-all-nearby-ibeacons-using-coordova-based-hybrid-application + const beaconRegion = new window['cordova'].plugins.locationManager.BeaconRegion( + STATIC_ID, + sampleBeacon.uuid, + sampleBeacon.major, + sampleBeacon.minor, + ); + window['cordova'].plugins.locationManager + .startMonitoringForRegion(beaconRegion) + .fail(function (e) { + logWarn(e); + }) + .done(); + }); + } + + async function stopBeaconScanning() { + setIsScanningBLE(false); + + beaconsToArray().forEach((sampleBeacon: BLEBeaconDevice) => { + setMonitorStatus(sampleBeacon.uuid, false); // "zero out" the beacons + const beaconRegion = new window['cordova'].plugins.locationManager.BeaconRegion( + STATIC_ID, + sampleBeacon.uuid, + sampleBeacon.major, + sampleBeacon.minor, + ); + window['cordova'].plugins.locationManager + .stopMonitoringForRegion(beaconRegion) + .fail(function (e) { + logWarn(e); + }) + .done(); + }); + } + + const switchMode = () => { + setIsClassic(!isClassic); + }; + + // Add a beacon with the new UUID to the list of BLE devices to scan + function addNewUUID(newUUID: string, newMajor: number, newMinor: number) { + console.log('Before adding UUID ' + newUUID + ' entries = ' + sampleBLEDevices); + const devicesWithAddition = { ...sampleBLEDevices }; + devicesWithAddition[newUUID] = { + identifier: STATIC_ID, + minor: newMajor, + major: newMinor, + in_range: false, + }; + setSampleBLEDevices(devicesWithAddition); + setNewUUID(null); + setNewMajor(undefined); + setNewMinor(undefined); + } + + const BluetoothCardList = ({ devices }) => { + if (isClassic) { + // When in classic mode, render devices as normal + return ( +
+ {devices.map((device) => { + if (device) { + return ; + } + return null; + })} +
+ ); + } + const beaconsAsArray = beaconsToArray(); + return ( +
+ {beaconsAsArray.map((beacon) => { + if (beacon) { + return ( + + ); + } + })} +
+ ); + }; + + const ScanButton = () => { + if (isClassic) { + return ( + + + + ); + } + // else, if BLE + return ( + + + + ); + }; + + const BlueScanContent = () => ( +
+ + { + props.onDismiss?.(); + }} + /> + + + + + + + +
+ ); + + return ( + <> + + + + + + setNewUUID(t.toUpperCase())} + /> + + setNewMajor(t)} + /> + setNewMinor(t)} + /> + + + + + Simulate by sending UI transitions + + + + + + + + + + + ); +}; + +const s = StyleSheet.create({ + btnContainer: { + padding: 8, + justifyContent: 'center', + }, + btn: { + height: 38, + fontSize: 11, + margin: 4, + }, +}); + +export default BluetoothScanPage; diff --git a/www/js/bluetooth/bluetoothScanner.ts b/www/js/bluetooth/bluetoothScanner.ts new file mode 100644 index 000000000..d7cb2d297 --- /dev/null +++ b/www/js/bluetooth/bluetoothScanner.ts @@ -0,0 +1,54 @@ +import { logDebug, displayError } from '../plugin/logger'; +import { BluetoothClassicDevice } from '../types/bluetoothDevices'; + +/** + * gatherBluetoothData scans for viewable Bluetooth Classic Devices + * @param t is the i18next translation function + * @returns an array of strings containing device data, formatted ['ID: id Name: name'] + */ +export function gatherBluetoothClassicData(t): Promise { + return new Promise((resolve, reject) => { + logDebug('Running bluetooth discovery test!'); + + // Device List "I/O" + function updatePairingStatus(pairingType: boolean, devices: Array) { + devices.forEach((device) => { + device.is_paired = pairingType; + }); + return devices; + } + + // Plugin Calls + const unpairedDevicesPromise = new Promise((res, rej) => { + window['bluetoothClassicSerial'].discoverUnpaired( + (devices: Array) => { + res(updatePairingStatus(false, devices)); + }, + (e: Error) => { + displayError(e, 'Error'); + rej(e); + }, + ); + }); + + const pairedDevicesPromise = new Promise((res, rej) => { + window['bluetoothClassicSerial'].list( + (devices: Array) => { + res(updatePairingStatus(true, devices)); + }, + (e: Error) => { + displayError(e, 'Error'); + rej(e); + }, + ); + }); + + Promise.all([unpairedDevicesPromise, pairedDevicesPromise]) + .then((logs: Array) => { + resolve(logs.flat()); + }) + .catch((e) => { + reject(e); + }); + }); +} diff --git a/www/js/components/Carousel.tsx b/www/js/components/Carousel.tsx index 92febb32b..8afe6624a 100644 --- a/www/js/components/Carousel.tsx +++ b/www/js/components/Carousel.tsx @@ -16,11 +16,16 @@ const Carousel = ({ children, cardWidth, cardMargin }: Props) => { snapToAlignment={'center'} style={s.carouselScroll(cardMargin)} contentContainerStyle={{ alignItems: 'flex-start' }}> - {React.Children.map(children, (child, i) => ( - - {child} - - ))} + {React.Children.map( + children, + (child, i) => + // If child is `null`, we need to skip it; otherwise, it takes up space + child && ( + + {child} + + ), + )} ); }; diff --git a/www/js/components/Chart.tsx b/www/js/components/Chart.tsx index e283b946c..5db31bb74 100644 --- a/www/js/components/Chart.tsx +++ b/www/js/components/Chart.tsx @@ -32,6 +32,10 @@ export type Props = { isHorizontal?: boolean; timeAxis?: boolean; stacked?: boolean; + showLegend?: boolean; + reverse?: boolean; + enableTooltip?: boolean; + maxBarThickness?: number; }; const Chart = ({ records, @@ -44,6 +48,10 @@ const Chart = ({ isHorizontal, timeAxis, stacked, + showLegend = true, + reverse = true, + enableTooltip = true, + maxBarThickness = 100, }: Props) => { const { colors } = useTheme(); const [numVisibleDatasets, setNumVisibleDatasets] = useState(1); @@ -69,6 +77,7 @@ const Chart = ({ getColorForChartEl?.(chartRef.current, e, barCtx, 'border'), borderWidth: borderWidth || 2, borderRadius: 3, + maxBarThickness: maxBarThickness, })), }; }, [chartDatasets, getColorForLabel]); @@ -113,6 +122,7 @@ const Chart = ({ responsive: true, maintainAspectRatio: false, resizeDelay: 1, + spanGaps: 1000 * 60 * 60 * 24, // 1 day scales: { ...(isHorizontal ? { @@ -149,7 +159,7 @@ const Chart = ({ }, font: { size: 11 }, // default is 12, we want a tad smaller }, - reverse: true, + reverse: reverse, stacked, }, x: { @@ -196,6 +206,12 @@ const Chart = ({ }), }, plugins: { + legend: { + display: showLegend, + }, + tooltip: { + enabled: enableTooltip, + }, ...(lineAnnotations?.length && { annotation: { clip: false, diff --git a/www/js/components/LeafletView.tsx b/www/js/components/LeafletView.tsx index d11aa7fba..367e73445 100644 --- a/www/js/components/LeafletView.tsx +++ b/www/js/components/LeafletView.tsx @@ -83,7 +83,6 @@ const LeafletView = ({ geojson, opts, downscaleTiles, cacheHtml, ...otherProps } // After a Leaflet map is rendered, cache the map to reduce the cost for creating a map const mapHTMLElements = document.getElementById(mapElId); leafletCache.set(mapElId, mapHTMLElements?.innerHTML); - leafletMapRef.current?.remove(); }); } }, [geojson, cacheHtml]); @@ -157,7 +156,9 @@ const LeafletView = ({ geojson, opts, downscaleTiles, cacheHtml, ...otherProps } dangerouslySetInnerHTML={ /* this is not 'dangerous' here because the content is not user-generated; it's just an HTML string that we cached from a previous render */ - cacheHtml && leafletCache.has(mapElId) ? { __html: leafletCache.get(mapElId) } : undefined + cacheHtml && leafletCache?.has(mapElId) + ? { __html: leafletCache.get(mapElId) } + : undefined } /> diff --git a/www/js/components/NavBar.tsx b/www/js/components/NavBar.tsx index 627950179..d3e9b2c11 100644 --- a/www/js/components/NavBar.tsx +++ b/www/js/components/NavBar.tsx @@ -1,13 +1,22 @@ import React from 'react'; import { View, StyleSheet } from 'react-native'; import color from 'color'; -import { Appbar, Button, ButtonProps, Icon, useTheme } from 'react-native-paper'; +import { Appbar, Button, ButtonProps, Icon, ProgressBar, useTheme } from 'react-native-paper'; -const NavBar = ({ children }) => { +type NavBarProps = { children: React.ReactNode; isLoading?: boolean }; +const NavBar = ({ children, isLoading }: NavBarProps) => { const { colors } = useTheme(); return ( {children} + + + ); }; @@ -16,8 +25,8 @@ export default NavBar; // NavBarButton, a greyish button with outline, to be used inside a NavBar -type Props = ButtonProps & { icon?: string; iconSize?: number }; -export const NavBarButton = ({ children, icon, iconSize, ...rest }: Props) => { +type NavBarButtonProps = ButtonProps & { icon?: string; iconSize?: number }; +export const NavBarButton = ({ children, icon, iconSize, ...rest }: NavBarButtonProps) => { const { colors } = useTheme(); const buttonColor = color(colors.onBackground).alpha(0.07).rgb().string(); const outlineColor = color(colors.onBackground).alpha(0.2).rgb().string(); diff --git a/www/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts index 9773a1ead..f92239170 100644 --- a/www/js/config/dynamicConfig.ts +++ b/www/js/config/dynamicConfig.ts @@ -81,6 +81,7 @@ function _fillSurveyInfo(config: Partial): AppConfig { const _backwardsCompatFill = (config: Partial): AppConfig => _fillSurveyInfo(_fillStudyName(config)); +export let _cacheResourcesFetchPromise: Promise<(string | undefined)[]> = Promise.resolve([]); /** * @description 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. @@ -89,15 +90,17 @@ const _backwardsCompatFill = (config: Partial): AppConfig => * @param config The app config */ function cacheResourcesFromConfig(config: AppConfig) { + const fetchPromises: Promise[] = []; 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')); - fetchUrlCached(survey['formPath']); + fetchPromises.push(fetchUrlCached(survey['formPath'], { cache: 'reload' })); }); } if (config.label_options) { - fetchUrlCached(config.label_options); + fetchPromises.push(fetchUrlCached(config.label_options, { cache: 'reload' })); } + _cacheResourcesFetchPromise = Promise.all(fetchPromises); } /** @@ -134,16 +137,21 @@ async function readConfigFromServer(studyLabel: string) { */ async function fetchConfig(studyLabel: string, alreadyTriedLocal?: boolean) { logDebug('Received request to join ' + studyLabel); - const downloadURL = `https://raw.githubusercontent.com/e-mission/nrel-openpath-deploy-configs/main/configs/${studyLabel}.nrel-op.json`; + let downloadURL = `https://raw.githubusercontent.com/e-mission/nrel-openpath-deploy-configs/main/configs/${studyLabel}.nrel-op.json`; if (!__DEV__ || alreadyTriedLocal) { logDebug('Fetching config from github'); - const r = await fetch(downloadURL); + const r = await fetch(downloadURL, { cache: 'reload' }); if (!r.ok) throw new Error('Unable to fetch config from github'); return r.json(); // TODO: validate, make sure it has required fields } else { logDebug('Running in dev environment, checking for locally hosted config'); try { - const r = await fetch('http://localhost:9090/configs/' + studyLabel + '.nrel-op.json'); + if (window['cordova'].platformId == 'android') { + downloadURL = `http://10.0.2.2:9090/configs/${studyLabel}.nrel-op.json`; + } else { + downloadURL = `http://localhost:9090/configs/${studyLabel}.nrel-op.json`; + } + const r = await fetch(downloadURL, { cache: 'reload' }); if (!r.ok) throw new Error('Local config not found'); return r.json(); } catch (err) { @@ -227,7 +235,7 @@ function extractSubgroup(token: string, config: AppConfig): string | undefined { * @param existingVersion If the new config's version is the same, we won't update * @returns boolean representing whether the config was updated or not */ -function loadNewConfig(newToken: string, existingVersion?: number): Promise { +export function loadNewConfig(newToken: string, existingVersion?: number): Promise { const newStudyLabel = extractStudyName(newToken); return readConfigFromServer(newStudyLabel) .then((downloadedConfig) => { diff --git a/www/js/config/useImperialConfig.ts b/www/js/config/useImperialConfig.ts index aa87ed1c6..feb2bb114 100644 --- a/www/js/config/useImperialConfig.ts +++ b/www/js/config/useImperialConfig.ts @@ -5,6 +5,8 @@ import i18next from 'i18next'; export type ImperialConfig = { distanceSuffix: string; speedSuffix: string; + convertDistance: (d: number) => number; + convertSpeed: (s: number) => number; getFormattedDistance: (d: number) => string; getFormattedSpeed: (s: number) => string; }; @@ -50,6 +52,8 @@ export function useImperialConfig(): ImperialConfig { return { distanceSuffix: useImperial ? 'mi' : 'km', speedSuffix: useImperial ? 'mph' : 'kmph', + convertDistance: (d) => convertDistance(d, useImperial), + convertSpeed: (s) => convertSpeed(s, useImperial), getFormattedDistance: useImperial ? (d) => formatForDisplay(convertDistance(d, true)) : (d) => formatForDisplay(convertDistance(d, false)), diff --git a/www/js/control/BluetoothScanSettingRow.tsx b/www/js/control/BluetoothScanSettingRow.tsx new file mode 100644 index 000000000..b32e4cdbf --- /dev/null +++ b/www/js/control/BluetoothScanSettingRow.tsx @@ -0,0 +1,23 @@ +import React, { useState } from 'react'; +import SettingRow from './SettingRow'; +import BluetoothScanPage from '../bluetooth/BluetoothScanPage'; + +const BluetoothScanSettingRow = ({}) => { + const [bluePageVisible, setBluePageVisible] = useState(false); + + async function openPopover() { + setBluePageVisible(true); + } + + return ( + <> + + setBluePageVisible(false)} /> + + ); +}; + +export default BluetoothScanSettingRow; diff --git a/www/js/control/CustomLabelSettingRow.tsx b/www/js/control/CustomLabelSettingRow.tsx new file mode 100644 index 000000000..c106d0985 --- /dev/null +++ b/www/js/control/CustomLabelSettingRow.tsx @@ -0,0 +1,166 @@ +import React, { useState, useContext } from 'react'; +import SettingRow from './SettingRow'; +import { + Modal, + View, + Text, + TouchableOpacity, + StyleSheet, + useWindowDimensions, + ScrollView, +} from 'react-native'; +import { Icon, TextInput, Dialog, Button, useTheme, SegmentedButtons } from 'react-native-paper'; +import { AppContext } from '../App'; +import { useTranslation } from 'react-i18next'; +import { deleteUserCustomLabel, insertUserCustomLabel } from '../services/commHelper'; +import { displayErrorMsg, logDebug } from '../plugin/logger'; +import { labelKeyToReadable, readableLabelToKey } from '../survey/multilabel/confirmHelper'; + +const CustomLabelSettingRow = () => { + const [isCustomLabelModalOpen, setIsCustomLabelModalOpen] = useState(false); + const { customLabelMap, setCustomLabelMap } = useContext(AppContext); + const [isAddLabelOn, setIsAddLabelOn] = useState(false); + const [text, setText] = useState(''); + const [key, setKey] = useState('mode'); + + const { t } = useTranslation(); //this accesses the translations + const { colors } = useTheme(); // use this to get the theme colors instead of hardcoded #hex colors + const { height } = useWindowDimensions(); + + const labelKeysButton = [ + { + value: 'mode', + label: t('diary.mode'), + }, + { + value: 'purpose', + label: t('diary.purpose'), + }, + ]; + + const onDeleteLabel = async (label) => { + const processedLabel = readableLabelToKey(label); + try { + const res = await deleteUserCustomLabel(key, processedLabel); + if (res) { + setCustomLabelMap({ + ...customLabelMap, + [key]: res['label'], + }); + logDebug(`Successfuly deleted custom ${key}, ${JSON.stringify(res)}`); + } + } catch (e) { + displayErrorMsg(e, 'Delete Mode Error'); + } + }; + + const onSaveLabel = async () => { + const processedLabel = readableLabelToKey(text); + if (customLabelMap[key]?.length > 0 && customLabelMap[key].indexOf(processedLabel) > -1) { + return; + } + try { + const res = await insertUserCustomLabel(key, processedLabel); + if (res) { + setText(''); + setCustomLabelMap({ + ...customLabelMap, + [key]: res['label'], + }); + setIsAddLabelOn(false); + logDebug(`Successfuly inserted custom ${key}, ${JSON.stringify(res)}`); + } + } catch (e) { + displayErrorMsg(e, 'Create Mode Error'); + } + }; + + return ( + <> + setIsCustomLabelModalOpen(true)}> + setIsCustomLabelModalOpen(false)} + transparent={true}> + setIsCustomLabelModalOpen(false)}> + + {t('trip-confirm.custom-labels')} + setIsAddLabelOn(true)}> + + + + + + {isAddLabelOn && ( + <> + + + + + + + )} + + {customLabelMap[key]?.length > 0 && + customLabelMap[key].map((label, idx) => { + return ( + + {labelKeyToReadable(label)} + onDeleteLabel(label)}> + + + + ); + })} + + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + itemWrapper: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: 16, + borderBottomWidth: 1, + }, + saveButtonWrapper: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'flex-end', + }, + plusIconWrapper: { + position: 'absolute', + right: 0, + }, +}); + +export default CustomLabelSettingRow; diff --git a/www/js/control/LogPage.tsx b/www/js/control/LogPage.tsx index 508766603..ec34cfedb 100644 --- a/www/js/control/LogPage.tsx +++ b/www/js/control/LogPage.tsx @@ -4,7 +4,7 @@ import { useTheme, Text, Appbar, IconButton } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; import { DateTime } from 'luxon'; import { AlertManager } from '../components/AlertBar'; -import { sendEmail } from './emailService'; +import { sendLocalDBFile } from '../services/shareLocalDBFile'; import { displayError, logDebug } from '../plugin/logger'; import NavBar from '../components/NavBar'; @@ -92,7 +92,7 @@ const LogPage = ({ pageVis, setPageVis }) => { } function emailLog() { - sendEmail('loggerDB'); + sendLocalDBFile('loggerDB'); } const separator = () => ; diff --git a/www/js/control/ProfileSettings.tsx b/www/js/control/ProfileSettings.tsx index c6ca8b848..ef61e0a24 100644 --- a/www/js/control/ProfileSettings.tsx +++ b/www/js/control/ProfileSettings.tsx @@ -6,13 +6,14 @@ import ExpansionSection from './ExpandMenu'; import SettingRow from './SettingRow'; import ControlDataTable from './ControlDataTable'; import DemographicsSettingRow from './DemographicsSettingRow'; +import BluetoothScanSettingRow from './BluetoothScanSettingRow'; import PopOpCode from './PopOpCode'; import ReminderTime from './ReminderTime'; import useAppConfig from '../useAppConfig'; import { AlertManager } from '../components/AlertBar'; import DataDatePicker from './DataDatePicker'; import PrivacyPolicyModal from './PrivacyPolicyModal'; -import { sendEmail } from './emailService'; +import { sendLocalDBFile } from '../services/shareLocalDBFile'; import { uploadFile } from './uploadService'; import ActionMenu from '../components/ActionMenu'; import SensedPage from './SensedPage'; @@ -25,13 +26,18 @@ import ControlCollectionHelper, { helperToggleLowAccuracy, forceTransition, } from './ControlCollectionHelper'; -import { resetDataAndRefresh } from '../config/dynamicConfig'; +import { + _cacheResourcesFetchPromise, + loadNewConfig, + 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 { displayError, displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; +import CustomLabelSettingRow from './CustomLabelSettingRow'; import { fetchOPCode, getSettings } from '../services/controlHelper'; import { updateScheduledNotifs, @@ -306,6 +312,19 @@ const ProfileSettings = () => { }, 1500); } + async function refreshConfig() { + AlertManager.addMessage({ text: t('control.refreshing-app-config') }); + const updated = await loadNewConfig(authSettings.opcode, appConfig?.version); + if (updated) { + // wait for resources to finish downloading before reloading + _cacheResourcesFetchPromise + .then(() => window.location.reload()) + .catch((error) => displayError(error, 'Failed to download a resource')); + } else { + AlertManager.addMessage({ text: t('control.already-up-to-date') }); + } + } + //Platform.OS returns "web" now, but could be used once it's fully a Native app //for now, use window.cordova.platformId @@ -407,6 +426,7 @@ const ProfileSettings = () => { desc={authSettings.opcode} descStyle={settingStyles.monoDesc}> + {appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL' && } { action={() => setDateDumpVis(true)}> {logUploadSection} sendEmail('loggerDB')}> - + action={() => sendLocalDBFile('loggerDB')}> + + { @@ -59,7 +59,7 @@ const SensedPage = ({ pageVis, setPageVis }) => { updateEntries()} /> - sendEmail('userCacheDB')} /> + sendLocalDBFile('userCacheDB')} /> { - return new Promise((resolve, reject) => { - window['cordova'].plugins['email'].hasAccount((hasAct) => { - resolve(hasAct); - }); - }); -} - -export async function sendEmail(database: string) { - let parentDir = 'unknown'; - - if (window['cordova'].platformId == 'ios' && !(await hasAccount())) { - alert(i18next.t('email-service.email-account-not-configured')); - return; - } - - if (window['cordova'].platformId == 'android') { - parentDir = 'app://databases'; - } - - 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'; - } - - 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 = `k.shankari@nrel.gov`; - - 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, () => { - logWarn(`Email app closed while sending, - emailData = ${JSON.stringify(emailData)}`); - }); -} diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 0ceaf0505..b368adc0c 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -2,135 +2,91 @@ that has two screens: LabelListScreen and LabelScreenDetails. LabelListScreen is the main screen, which is a scrollable list of timeline entries, while LabelScreenDetails is the view that shows when the user clicks on a trip. - LabelTabContext is provided to the entire child tree and allows the screens to - share the data that has been loaded and interacted with. */ -import React, { useEffect, useState, useRef } from 'react'; -import useAppConfig from '../useAppConfig'; +import React, { useEffect, useState, useContext, createContext } from 'react'; import { useTranslation } from 'react-i18next'; -import { invalidateMaps } from '../components/LeafletView'; -import { DateTime } from 'luxon'; 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, labelOptionByValue } from '../survey/multilabel/confirmHelper'; -import { displayError, displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; -import { useTheme } from 'react-native-paper'; -import { getPipelineRangeTs } from '../services/commHelper'; -import { getNotDeletedCandidates, mapInputsToTimelineEntries } from '../survey/inputMatcher'; +import { updateAllUnprocessedInputs } from './timelineHelper'; +import { fillLocationNamesOfTrip } from './addressNamesHelper'; +import { logDebug } from '../plugin/logger'; import { configuredFilters as multilabelConfiguredFilters } from '../survey/multilabel/infinite_scroll_filters'; import { configuredFilters as enketoConfiguredFilters } from '../survey/enketo/infinite_scroll_filters'; -import LabelTabContext, { - LabelTabFilter, - TimelineLabelMap, - TimelineMap, - TimelineNotesMap, -} from './LabelTabContext'; -import { readAllCompositeTrips, readUnprocessedTrips } from './timelineHelper'; -import { LabelOptions, MultilabelKey } from '../types/labelTypes'; -import { CompositeTrip, TimelineEntry, TimestampRange, UserInputEntry } from '../types/diaryTypes'; - -let showPlaces; -const ONE_DAY = 24 * 60 * 60; // seconds -const ONE_WEEK = ONE_DAY * 7; // seconds +import { TimelineEntry, isTrip } from '../types/diaryTypes'; +import TimelineContext, { LabelTabFilter } from '../TimelineContext'; +import { AppContext } from '../App'; + +type LabelContextProps = { + displayedEntries: TimelineEntry[] | null; + filterInputs: LabelTabFilter[] | null; + setFilterInputs: (filters: LabelTabFilter[]) => void; +}; +export const LabelTabContext = createContext({} as LabelContextProps); const LabelTab = () => { - const appConfig = useAppConfig(); - const { t } = useTranslation(); - const { colors } = useTheme(); + const { appConfig } = useContext(AppContext); + const { pipelineRange, timelineMap, timelineLabelMap } = useContext(TimelineContext); - const [labelOptions, setLabelOptions] = useState | null>(null); - const [filterInputs, setFilterInputs] = useState([]); - const [lastFilteredTs, setLastFilteredTs] = useState(null); - const [pipelineRange, setPipelineRange] = useState(null); - const [queriedRange, setQueriedRange] = useState(null); - const [timelineMap, setTimelineMap] = useState(null); - const [timelineLabelMap, setTimelineLabelMap] = useState(null); - const [timelineNotesMap, setTimelineNotesMap] = useState(null); + const [filterRefreshTs, setFilterRefreshTs] = useState(0); // used to force a refresh of the filters + const [filterInputs, setFilterInputs] = useState(null); const [displayedEntries, setDisplayedEntries] = useState(null); - const [refreshTime, setRefreshTime] = useState(null); - const [isLoading, setIsLoading] = useState('replace'); - // initialization, once the appConfig is loaded useEffect(() => { - try { - if (!appConfig) return; - showPlaces = appConfig.survey_info?.buttons?.['place-notes']; - getLabelOptions(appConfig).then((labelOptions) => setLabelOptions(labelOptions)); - - // 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 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); - } - loadTimelineEntries(); - } catch (e) { - displayError(e, t('errors.while-initializing-label')); + // if places are shown, we will skip filters and it will just be "show all" + // https://github.com/e-mission/e-mission-docs/issues/894 + if (appConfig.survey_info?.buttons?.['place-notes']) { + setFilterInputs([]); + } else { + // initalize filters + const tripFilters = + appConfig.survey_info?.['trip-labels'] == 'ENKETO' + ? enketoConfiguredFilters + : multilabelConfiguredFilters; + const filtersWithState = tripFilters.map((f, i) => ({ + ...f, + state: i == 0 ? true : false, // only the first filter will have state true on init + })); + setFilterInputs(filtersWithState); } - }, [appConfig, refreshTime]); + }, [appConfig]); - // whenever timelineMap is updated, map unprocessed inputs to timeline entries, and - // update the displayedEntries according to the active filter useEffect(() => { - try { - if (!timelineMap) return setDisplayedEntries(null); - const allEntries = Array.from(timelineMap.values()); - const [newTimelineLabelMap, newTimelineNotesMap] = mapInputsToTimelineEntries( - allEntries, - appConfig, - ); - - setTimelineLabelMap(newTimelineLabelMap); - setTimelineNotesMap(newTimelineNotesMap); - - applyFilters(timelineMap, newTimelineLabelMap); - } catch (e) { - displayError(e, t('errors.while-updating-timeline')); - } - }, [timelineMap, filterInputs]); + if (!timelineMap) return; + const timelineEntries = Array.from(timelineMap.values()); + if (!timelineEntries?.length) return; + timelineEntries.reverse().forEach((entry) => { + if (isTrip(entry)) fillLocationNamesOfTrip(entry); + }); + }, [timelineMap]); useEffect(() => { - if (!timelineMap || !timelineLabelMap) return; - applyFilters(timelineMap, timelineLabelMap); - }, [lastFilteredTs]); - - function applyFilters(timelineMap, labelMap: TimelineLabelMap) { + if (!timelineMap || !timelineLabelMap || !filterInputs) return; + logDebug('Applying filters'); const allEntries: TimelineEntry[] = Array.from(timelineMap.values()); const activeFilter = filterInputs?.find((f) => f.state == true); let entriesToDisplay = allEntries; if (activeFilter) { - const cutoffTs = new Date().getTime() / 1000 - 30; // 30s ago, as epoch seconds + const nowTs = new Date().getTime() / 1000; const entriesAfterFilter = allEntries.filter((e) => { // if the entry has a recently recorded user input, it is immune to filtering - const labels = labelMap[e._id.$oid]; - for (let labelValue of Object.values(labels || [])) { - logDebug(`LabelTab filtering: labelValue = ${JSON.stringify(labelValue)}`); - if (labelValue?.metadata?.write_ts > cutoffTs) { - logDebug('LabelTab filtering: entry has recent user input, keeping'); - return true; - } + const labels = timelineLabelMap[e._id.$oid]; + const mostRecentInputTs = Object.values(labels || []).reduce((acc, label) => { + if (label?.metadata?.write_ts && label.metadata.write_ts > acc) + return label.metadata.write_ts; + return acc; + }, 0); + const entryImmuneUntil = mostRecentInputTs + 30; // 30s after the most recent user input + if (entryImmuneUntil > nowTs) { + logDebug(`LabelTab filtering: entry still immune, skipping. + Re-applying filters at ${entryImmuneUntil}`); + setTimeout(() => setFilterRefreshTs(entryImmuneUntil), (entryImmuneUntil - nowTs) * 1000); + return true; } // otherwise, just apply the filter - return activeFilter?.filter(e, labelMap[e._id.$oid]); + return activeFilter?.filter(e, timelineLabelMap[e._id.$oid]); }); /* next, filter out any untracked time if the trips that came before and after it are no longer displayed */ @@ -148,223 +104,23 @@ const LabelTab = () => { logDebug('No active filter, displaying all entries'); } setDisplayedEntries(entriesToDisplay); - } + }, [timelineMap, filterInputs, timelineLabelMap, filterRefreshTs]); - async function loadTimelineEntries() { - try { - const pipelineRange = await getPipelineRangeTs(); - await updateAllUnprocessedInputs(pipelineRange, appConfig); - logDebug(`LabelTab: After updating unprocessedInputs, - unprocessedLabels = ${JSON.stringify(unprocessedLabels)}; - unprocessedNotes = ${JSON.stringify(unprocessedNotes)}`); - setPipelineRange(pipelineRange); - } catch (e) { - displayError(e, t('errors.while-loading-pipeline-range')); - setIsLoading(false); - } - } - - // once pipelineRange is set, load the most recent week of data + // once pipelineRange is set, update all unprocessed inputs useEffect(() => { if (pipelineRange && pipelineRange.end_ts) { - loadAnotherWeek('past'); + updateAllUnprocessedInputs(pipelineRange, appConfig); } }, [pipelineRange]); - function refresh() { - 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); - if (!pipelineRange?.start_ts || !pipelineRange?.end_ts) - return logWarn('No pipelineRange yet - early return'); - - 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'); - 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 }); - } else if (when == 'past' && !reachedPipelineStart) { - 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, - ); - handleFetchedTrips(ctList, utList, 'prepend'); - setQueriedRange({ start_ts: fetchStartTs, end_ts: queriedRange.end_ts }); - } else if (when == 'future' && !reachedPipelineEnd) { - 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 }); - } - } catch (e) { - setIsLoading(false); - displayError(e, t('errors.while-loading-another-week', { when: when })); - } - } - - async function loadSpecificWeek(day: Date) { - try { - logDebug('LabelTab: loadSpecificWeek for day ' + day); - if (!isLoading) setIsLoading('replace'); - resetNominatimLimiter(); - const threeDaysBefore = DateTime.fromJSDate(day).minus({ days: 3 }).toSeconds(); - const threeDaysAfter = DateTime.fromJSDate(day).plus({ days: 3 }).toSeconds(); - const [ctList, utList] = await fetchTripsInRange(threeDaysBefore, threeDaysAfter); - handleFetchedTrips(ctList, utList, 'replace'); - setQueriedRange({ start_ts: threeDaysBefore, end_ts: threeDaysAfter }); - } catch (e) { - setIsLoading(false); - displayError(e, t('errors.while-loading-specific-week', { day: day })); - } - } - - 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); - // Fill place names on a reversed copy of the list so we fill from the bottom up - tripsRead - .slice() - .reverse() - .forEach((trip, index) => fillLocationNamesOfTrip(trip)); - const readTimelineMap = compositeTrips2TimelineMap(tripsRead, showPlaces); - logDebug(`LabelTab: after composite trips converted, - readTimelineMap = ${[...readTimelineMap.entries()]}`); - if (mode == 'append') { - setTimelineMap(new Map([...(timelineMap || []), ...readTimelineMap])); - } else if (mode == 'prepend') { - setTimelineMap(new Map([...readTimelineMap, ...(timelineMap || [])])); - } else if (mode == 'replace') { - setTimelineMap(readTimelineMap); - } else { - return displayErrorMsg('Unknown insertion mode ' + mode); - } - } - - async function fetchTripsInRange(startTs: number, endTs: number) { - if (!pipelineRange?.start_ts || !pipelineRange?.end_ts) - return logWarn('No pipelineRange yet - early return'); - logDebug('LabelTab: fetchTripsInRange from ' + startTs + ' to ' + endTs); - const readCompositePromise = readAllCompositeTrips(startTs, endTs); - let readUnprocessedPromise; - if (endTs >= pipelineRange.end_ts) { - const nowTs = new Date().getTime() / 1000; - let lastProcessedTrip: CompositeTrip | undefined; - if (timelineMap) { - lastProcessedTrip = [...timelineMap?.values()] - .reverse() - .find((trip) => trip.origin_key.includes('trip')) as CompositeTrip; - } - readUnprocessedPromise = readUnprocessedTrips(pipelineRange.end_ts, nowTs, lastProcessedTrip); - } else { - 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; - } - - useEffect(() => { - if (!displayedEntries) return; - invalidateMaps(); - setIsLoading(false); - }, [displayedEntries]); - - const userInputFor = (tlEntry: TimelineEntry) => - timelineLabelMap?.[tlEntry._id.$oid] || undefined; - const notesFor = (tlEntry: TimelineEntry) => timelineNotesMap?.[tlEntry._id.$oid] || undefined; - - /** - * @param tlEntry The trip or place object to get the label for - * @param labelType The type of label to get (e.g. MODE, PURPOSE, etc.) - * @returns the label option object for the given label type, or undefined if there is no label - */ - const labelFor = (tlEntry: TimelineEntry, labelType: MultilabelKey) => { - const chosenLabel = userInputFor(tlEntry)?.[labelType]?.data.label; - return chosenLabel ? labelOptionByValue(chosenLabel, labelType) : undefined; - }; - - function addUserInputToEntry(oid: string, userInput: any, inputType: 'label' | 'note') { - const tlEntry = timelineMap?.get(oid); - if (!pipelineRange || !tlEntry) - return displayErrorMsg('Item with oid: ' + oid + ' not found in timeline'); - const nowTs = new Date().getTime() / 1000; // epoch seconds - if (inputType == 'label') { - const newLabels = {}; - for (const [inputType, labelValue] of Object.entries(userInput)) { - newLabels[inputType] = { data: labelValue, metadata: nowTs }; - } - logDebug('LabelTab: newLabels = ' + JSON.stringify(newLabels)); - const newTimelineLabelMap: TimelineLabelMap = { - ...timelineLabelMap, - [oid]: { - ...timelineLabelMap?.[oid], - ...newLabels, - }, - }; - setTimelineLabelMap(newTimelineLabelMap); - setTimeout(() => setLastFilteredTs(new Date().getTime() / 1000), 30000); // wait 30s before reapplying filters - } else if (inputType == 'note') { - const notesForEntry = timelineNotesMap?.[oid] || []; - const newAddition = { data: userInput, metadata: { write_ts: nowTs } }; - notesForEntry.push(newAddition as UserInputEntry); - const newTimelineNotesMap: TimelineNotesMap = { - ...timelineNotesMap, - [oid]: getNotDeletedCandidates(notesForEntry), - }; - setTimelineNotesMap(newTimelineNotesMap); - } - /* We can update unprocessed inputs in the background, without blocking the completion - of this function. That is why this is not 'await'ed */ - updateLocalUnprocessedInputs(pipelineRange, appConfig); - } + const Tab = createStackNavigator(); - const contextVals = { - labelOptions, - timelineMap, - userInputFor, - labelFor, - notesFor, - addUserInputToEntry, + const contextVals: LabelContextProps = { displayedEntries, filterInputs, setFilterInputs, - queriedRange, - pipelineRange, - isLoading, - loadAnotherWeek, - loadSpecificWeek, - refresh, }; - const Tab = createStackNavigator(); - return ( diff --git a/www/js/diary/LabelTabContext.ts b/www/js/diary/LabelTabContext.ts deleted file mode 100644 index 9e80cccae..000000000 --- a/www/js/diary/LabelTabContext.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { createContext } from 'react'; -import { TimelineEntry, TimestampRange, UserInputEntry } from '../types/diaryTypes'; -import { LabelOption, LabelOptions, MultilabelKey } from '../types/labelTypes'; -import { EnketoUserInputEntry } from '../survey/enketo/enketoHelper'; - -export type UserInputMap = { - /* if the key here is 'SURVEY', we are in the ENKETO configuration, meaning the user input - value will have the raw 'xmlResponse' string */ - SURVEY?: EnketoUserInputEntry; -} & { - /* all other keys, (e.g. 'MODE', 'PURPOSE') are from the MULTILABEL configuration - and will have the 'label' string but no 'xmlResponse' string */ - [k in MultilabelKey]?: UserInputEntry; -}; - -export type TimelineMap = Map; // Todo: update to reflect unpacked trips (origin_Key, etc) -export type TimelineLabelMap = { - [k: string]: UserInputMap; -}; -export type TimelineNotesMap = { - [k: string]: UserInputEntry[]; -}; - -export type LabelTabFilter = { - key: string; - text: string; - filter: (trip: TimelineEntry, userInputForTrip: UserInputMap) => boolean; - state?: boolean; -}; - -type ContextProps = { - labelOptions: LabelOptions | null; - timelineMap: TimelineMap | null; - userInputFor: (tlEntry: TimelineEntry) => UserInputMap | undefined; - notesFor: (tlEntry: TimelineEntry) => UserInputEntry[] | undefined; - labelFor: (tlEntry: TimelineEntry, labelType: MultilabelKey) => LabelOption | undefined; - addUserInputToEntry: (oid: string, userInput: any, inputType: 'label' | 'note') => void; - displayedEntries: TimelineEntry[] | null; - filterInputs: LabelTabFilter[]; - setFilterInputs: (filters: LabelTabFilter[]) => void; - queriedRange: TimestampRange | null; - pipelineRange: TimestampRange | null; - isLoading: string | false; - loadAnotherWeek: (when: 'past' | 'future') => void; - loadSpecificWeek: (d: Date) => void; - refresh: () => void; -}; - -export default createContext({} as ContextProps); diff --git a/www/js/diary/addressNamesHelper.ts b/www/js/diary/addressNamesHelper.ts index 85888d989..350e4db64 100644 --- a/www/js/diary/addressNamesHelper.ts +++ b/www/js/diary/addressNamesHelper.ts @@ -75,6 +75,7 @@ export function useLocalStorage(key: string, initialValue: T) { import Bottleneck from 'bottleneck'; import { displayError, logDebug } from '../plugin/logger'; +import { CompositeTrip } from '../types/diaryTypes'; let nominatimLimiter = new Bottleneck({ maxConcurrent: 2, minTime: 500 }); export const resetNominatimLimiter = () => { @@ -137,7 +138,7 @@ async function fetchNominatimLocName(loc_geojson) { } // Schedules nominatim fetches for the start and end locations of a trip -export function fillLocationNamesOfTrip(trip) { +export function fillLocationNamesOfTrip(trip: CompositeTrip) { nominatimLimiter.schedule(() => fetchNominatimLocName(trip.end_loc)); nominatimLimiter.schedule(() => fetchNominatimLocName(trip.start_loc)); } diff --git a/www/js/diary/cards/ModesIndicator.tsx b/www/js/diary/cards/ModesIndicator.tsx index bba65c107..2ec5d9dc2 100644 --- a/www/js/diary/cards/ModesIndicator.tsx +++ b/www/js/diary/cards/ModesIndicator.tsx @@ -1,24 +1,24 @@ import React, { useContext } from 'react'; import { View, StyleSheet } from 'react-native'; import color from 'color'; -import LabelTabContext from '../LabelTabContext'; +import TimelineContext from '../../TimelineContext'; import { logDebug } from '../../plugin/logger'; -import { getBaseModeByValue } from '../diaryHelper'; +import { getBaseModeByKey, getBaseModeByValue } from '../diaryHelper'; import { Text, Icon, useTheme } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; const ModesIndicator = ({ trip, detectedModes }) => { const { t } = useTranslation(); - const { labelOptions, labelFor } = useContext(LabelTabContext); + const { labelOptions, labelFor, confirmedModeFor } = useContext(TimelineContext); const { colors } = useTheme(); const indicatorBackgroundColor = color(colors.onPrimary).alpha(0.8).rgb().string(); let indicatorBorderColor = color('black').alpha(0.5).rgb().string(); let modeViews; - const labeledModeForTrip = labelFor(trip, 'MODE'); - if (labelOptions && labeledModeForTrip?.value) { - const baseMode = getBaseModeByValue(labeledModeForTrip.value, labelOptions); + const confirmedModeForTrip = confirmedModeFor(trip); + if (labelOptions && confirmedModeForTrip?.value) { + const baseMode = getBaseModeByKey(confirmedModeForTrip.baseMode); indicatorBorderColor = baseMode.color; logDebug(`TripCard: got baseMode = ${JSON.stringify(baseMode)}`); modeViews = ( @@ -32,7 +32,7 @@ const ModesIndicator = ({ trip, detectedModes }) => { fontWeight: '500', textDecorationLine: 'underline', }}> - {labelFor(trip, 'MODE')?.text} + {confirmedModeForTrip.text} ); diff --git a/www/js/diary/cards/PlaceCard.tsx b/www/js/diary/cards/PlaceCard.tsx index 6936146e6..3dcc02018 100644 --- a/www/js/diary/cards/PlaceCard.tsx +++ b/www/js/diary/cards/PlaceCard.tsx @@ -17,14 +17,14 @@ import { DiaryCard, cardStyles } from './DiaryCard'; import { useAddressNames } from '../addressNamesHelper'; import useDerivedProperties from '../useDerivedProperties'; import StartEndLocations from '../components/StartEndLocations'; -import LabelTabContext from '../LabelTabContext'; +import TimelineContext from '../../TimelineContext'; import { ConfirmedPlace } from '../../types/diaryTypes'; import { EnketoUserInputEntry } from '../../survey/enketo/enketoHelper'; type Props = { place: ConfirmedPlace }; const PlaceCard = ({ place }: Props) => { const appConfig = useAppConfig(); - const { notesFor } = useContext(LabelTabContext); + const { notesFor } = useContext(TimelineContext); const { displayStartTime, displayEndTime, displayDate } = useDerivedProperties(place); let [placeDisplayName] = useAddressNames(place); diff --git a/www/js/diary/cards/TripCard.tsx b/www/js/diary/cards/TripCard.tsx index a68930681..c1b586f03 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, cardWidth } from './DiaryCard'; import { useNavigation } from '@react-navigation/native'; import { useAddressNames } from '../addressNamesHelper'; -import LabelTabContext from '../LabelTabContext'; +import TimelineContext from '../../TimelineContext'; import useDerivedProperties from '../useDerivedProperties'; import StartEndLocations from '../components/StartEndLocations'; import ModesIndicator from './ModesIndicator'; @@ -42,9 +42,9 @@ const TripCard = ({ trip, isFirstInList }: Props) => { } = useDerivedProperties(trip); let [tripStartDisplayName, tripEndDisplayName] = useAddressNames(trip); const navigation = useNavigation(); - const { labelOptions, labelFor, notesFor } = useContext(LabelTabContext); + const { labelOptions, confirmedModeFor, notesFor } = useContext(TimelineContext); const tripGeojson = - trip && labelOptions && useGeojsonForTrip(trip, labelOptions, labelFor(trip, 'MODE')?.value); + trip && labelOptions && useGeojsonForTrip(trip, confirmedModeFor(trip)?.baseMode); const isDraft = trip.key.includes('UNPROCESSED'); const flavoredTheme = getTheme(isDraft ? 'draft' : undefined); diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx index 4985300cb..8fee14d07 100644 --- a/www/js/diary/details/LabelDetailsScreen.tsx +++ b/www/js/diary/details/LabelDetailsScreen.tsx @@ -13,7 +13,7 @@ import { Text, useTheme, } from 'react-native-paper'; -import LabelTabContext from '../LabelTabContext'; +import TimelineContext from '../../TimelineContext'; import LeafletView from '../../components/LeafletView'; import { useTranslation } from 'react-i18next'; import MultilabelButtonGroup from '../../survey/multilabel/MultiLabelButtonGroup'; @@ -31,7 +31,7 @@ import { CompositeTrip } from '../../types/diaryTypes'; import NavBar from '../../components/NavBar'; const LabelScreenDetails = ({ route, navigation }) => { - const { timelineMap, labelOptions, labelFor } = useContext(LabelTabContext); + const { timelineMap, labelOptions, confirmedModeFor } = useContext(TimelineContext); const { t } = useTranslation(); const { height: windowHeight } = useWindowDimensions(); const appConfig = useAppConfig(); @@ -41,17 +41,16 @@ const LabelScreenDetails = ({ route, navigation }) => { const { displayDate, displayStartTime, displayEndTime } = useDerivedProperties(trip); const [tripStartDisplayName, tripEndDisplayName] = useAddressNames(trip); - const [modesShown, setModesShown] = useState<'labeled' | 'detected'>(() => + const [modesShown, setModesShown] = useState<'confirmed' | 'detected'>(() => // if trip has a labeled mode, initial state shows that; otherwise, show detected modes - trip && labelFor(trip, 'MODE')?.value ? 'labeled' : 'detected', + trip && confirmedModeFor(trip)?.value ? 'confirmed' : 'detected', ); const tripGeojson = trip && labelOptions && useGeojsonForTrip( trip, - labelOptions, - modesShown == 'labeled' ? labelFor(trip, 'MODE')?.value : undefined, + modesShown == 'confirmed' ? confirmedModeFor(trip)?.baseMode : undefined, ); const mapOpts = { minZoom: 3, maxZoom: 17 }; @@ -86,23 +85,24 @@ const LabelScreenDetails = ({ route, navigation }) => { )} - - {/* Full-size Leaflet map, with zoom controls */} - + {tripGeojson && ( + // 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 && labelFor(trip, 'MODE')?.value ? ( + {trip && confirmedModeFor(trip)?.value ? ( setModesShown(v)} + onValueChange={(v: 'confirmed' | 'detected') => setModesShown(v)} value={modesShown} density="medium" buttons={[ - { label: t('diary.labeled-mode'), value: 'labeled' }, + { label: t('diary.labeled-mode'), value: 'confirmed' }, { label: t('diary.detected-modes'), value: 'detected' }, ]} /> @@ -118,7 +118,7 @@ const LabelScreenDetails = ({ route, navigation }) => { )} {/* 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. */} diff --git a/www/js/diary/details/TripSectionsDescriptives.tsx b/www/js/diary/details/TripSectionsDescriptives.tsx index fdab61eb3..13c15019d 100644 --- a/www/js/diary/details/TripSectionsDescriptives.tsx +++ b/www/js/diary/details/TripSectionsDescriptives.tsx @@ -3,10 +3,10 @@ import { View, StyleSheet } from 'react-native'; import { Icon, Text, useTheme } from 'react-native-paper'; import useDerivedProperties from '../useDerivedProperties'; import { getBaseModeByKey, getBaseModeByValue } from '../diaryHelper'; -import LabelTabContext from '../LabelTabContext'; +import TimelineContext from '../../TimelineContext'; -const TripSectionsDescriptives = ({ trip, showLabeledMode = false }) => { - const { labelOptions, labelFor } = useContext(LabelTabContext); +const TripSectionsDescriptives = ({ trip, showConfirmedMode = false }) => { + const { labelOptions, labelFor, confirmedModeFor } = useContext(TimelineContext); const { displayStartTime, displayTime, @@ -17,14 +17,14 @@ const TripSectionsDescriptives = ({ trip, showLabeledMode = false }) => { const { colors } = useTheme(); - const labeledModeForTrip = labelFor(trip, 'MODE'); + const confirmedModeForTrip = confirmedModeFor(trip); 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 ((showConfirmedMode && confirmedModeForTrip) || !trip.sections?.length) { let baseMode; - if (showLabeledMode && labelOptions && labeledModeForTrip) { - baseMode = getBaseModeByValue(labeledModeForTrip.value, labelOptions); + if (showConfirmedMode && labelOptions && confirmedModeForTrip) { + baseMode = getBaseModeByKey(confirmedModeForTrip.baseMode); } else { baseMode = getBaseModeByKey('UNPROCESSED'); } @@ -62,9 +62,9 @@ const TripSectionsDescriptives = ({ trip, showLabeledMode = false }) => { - {showLabeledMode && labeledModeForTrip && ( + {showConfirmedMode && confirmedModeForTrip && ( - {labeledModeForTrip.text} + {confirmedModeForTrip.text} )} diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts index f02797fff..12495742d 100644 --- a/www/js/diary/diaryHelper.ts +++ b/www/js/diary/diaryHelper.ts @@ -192,6 +192,15 @@ export function getFormattedSectionProperties(trip: CompositeTrip, imperialConfi })); } +/** + * @param trip A composite trip object + * @return the primary section of the trip, i.e. the section with the greatest distance + */ +export function primarySectionForTrip(trip: CompositeTrip) { + if (!trip.sections?.length) return undefined; + return trip.sections.reduce((prev, curr) => (prev.distance > curr.distance ? prev : curr)); +} + export function getLocalTimeString(dt?: LocalDt) { if (!dt) return; const dateTime = DateTime.fromObject({ diff --git a/www/js/diary/list/DateSelect.tsx b/www/js/diary/list/DateSelect.tsx index 02b8d1ca1..d79568e91 100644 --- a/www/js/diary/list/DateSelect.tsx +++ b/www/js/diary/list/DateSelect.tsx @@ -1,95 +1,120 @@ -/* This button launches a modal to select a date, which determines which week of - travel should be displayed in the Label screen. - The button itself is a NavBarButton, which shows the currently selected date range, +/* This button reflects what date range for which the timeline is currently loaded. + If mode is 'single', one date can be selected; if 'range', start and end dates can be selected. + The button itself is a NavBarButton, which shows the currently loaded date range, a calendar icon, and launches the modal when clicked. The modal is a DatePickerModal from react-native-paper-dates, which shows a calendar - and allows the user to select a date. + and allows the user to select date(s). */ -import React, { useEffect, useState, useMemo, useContext } from 'react'; +import React, { useMemo, useContext } from 'react'; import { StyleSheet } from 'react-native'; import { DateTime } from 'luxon'; -import LabelTabContext from '../LabelTabContext'; -import { DatePickerModal } from 'react-native-paper-dates'; +import TimelineContext from '../../TimelineContext'; +import { + DatePickerModal, + DatePickerModalRangeProps, + DatePickerModalSingleProps, +} 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/NavBar'; +import { isoDateRangeToTsRange } from '../timelineHelper'; -const DateSelect = ({ tsRange, loadSpecificWeekFn }) => { - const { pipelineRange } = useContext(LabelTabContext); +type Props = Partial & { + mode: 'single' | 'range'; + onChoose: (params) => void; +}; +const DateSelect = ({ mode, onChoose, ...rest }: Props) => { + const { pipelineRange, queriedDateRange } = useContext(TimelineContext); const { t } = useTranslation(); const { colors } = useTheme(); const [open, setOpen] = React.useState(false); - const [dateRange, setDateRange] = useState([null, null]); - const [selDate, setSelDate] = useState(new Date()); const minMaxDates = useMemo(() => { - if (!pipelineRange) return { startDate: new Date(), endDate: new Date() }; + if (!pipelineRange?.start_ts) return { startDate: new Date(), endDate: new Date() }; return { - startDate: new Date(pipelineRange?.start_ts * 1000), - endDate: new Date(pipelineRange?.end_ts * 1000), + startDate: new Date(pipelineRange?.start_ts * 1000), // start of pipeline + endDate: new Date(), // today }; }, [pipelineRange]); - useEffect(() => { - if (!pipelineRange || !tsRange.oldestTs) return; - const displayStartTs = Math.max(tsRange.oldestTs, pipelineRange.start_ts); + const queriedRangeAsJsDates = useMemo( + () => queriedDateRange?.map((d) => DateTime.fromISO(d).toJSDate()), + [queriedDateRange], + ); + + const displayDateRange = useMemo(() => { + if (!pipelineRange || !queriedDateRange?.[0]) return null; + const [queriedStartTs, queriedEndTs] = isoDateRangeToTsRange(queriedDateRange); + const displayStartTs = Math.max(queriedStartTs, pipelineRange.start_ts); const displayStartDate = DateTime.fromSeconds(displayStartTs).toLocaleString( DateTime.DATE_SHORT, ); - let displayEndDate; - if (tsRange.latestTs < pipelineRange.end_ts) { - displayEndDate = DateTime.fromSeconds(tsRange.latestTs).toLocaleString(DateTime.DATE_SHORT); + if (queriedEndTs < pipelineRange.end_ts) { + displayEndDate = DateTime.fromSeconds(queriedEndTs).toLocaleString(DateTime.DATE_SHORT); } - setDateRange([displayStartDate, displayEndDate]); + return [displayStartDate, displayEndDate]; + }, [pipelineRange, queriedDateRange]); - const mid = (tsRange.oldestTs + tsRange.latestTs) / 2; - const d = new Date(Math.min(mid, pipelineRange.end_ts) * 1000); - setSelDate(d); - }, [tsRange]); + const midpointDate = useMemo(() => { + if (!pipelineRange || !queriedDateRange?.[0]) return undefined; + const [queriedStartTs, queriedEndTs] = isoDateRangeToTsRange(queriedDateRange); + const mid = (queriedStartTs + queriedEndTs) / 2; + return new Date(Math.min(mid, pipelineRange.end_ts) * 1000); + }, [queriedDateRange]); const onDismissSingle = React.useCallback(() => { setOpen(false); }, [setOpen]); - const onChoose = React.useCallback( - (params) => { - loadSpecificWeekFn(params.date); - setOpen(false); - }, - [setOpen, loadSpecificWeekFn], - ); - const dateRangeEnd = dateRange[1] || t('diary.today'); + const displayDateRangeEnd = displayDateRange?.[1] || t('diary.today'); return ( <> setOpen(true)}> - {dateRange[0] && ( + {displayDateRange?.[0] && ( <> - {dateRange[0]} + {displayDateRange?.[0]} )} - {dateRangeEnd} + {displayDateRangeEnd} { + if (mode == 'single') { + onChoose(params); + onDismissSingle(); + } + }} + onConfirm={(params) => { + if (mode == 'range') { + onChoose(params); + onDismissSingle(); + } else { + onDismissSingle(); + } + }} + {...rest} /> ); diff --git a/www/js/diary/list/FilterSelect.tsx b/www/js/diary/list/FilterSelect.tsx index 039d76be0..c9d23d602 100644 --- a/www/js/diary/list/FilterSelect.tsx +++ b/www/js/diary/list/FilterSelect.tsx @@ -12,10 +12,10 @@ import { Modal } from 'react-native'; import { useTranslation } from 'react-i18next'; import { RadioButton, Text, Dialog } from 'react-native-paper'; import { NavBarButton } from '../../components/NavBar'; -import { LabelTabFilter } from '../LabelTabContext'; +import { LabelTabFilter } from '../../TimelineContext'; type Props = { - filters: LabelTabFilter[]; + filters: LabelTabFilter[] | null; setFilters: (filters: LabelTabFilter[]) => void; numListDisplayed?: number; numListTotal?: number; @@ -32,6 +32,7 @@ const FilterSelect = ({ filters, setFilters, numListDisplayed, numListTotal }: P }, [filters, numListDisplayed, numListTotal]); function chooseFilter(filterKey) { + if (!filters) return; if (filterKey == 'show-all') { setFilters(filters.map((f) => ({ ...f, state: false }))); } else { @@ -62,9 +63,7 @@ const FilterSelect = ({ filters, setFilters, numListDisplayed, numListTotal }: P {/* {t('diary.filter-travel')} */} chooseFilter(k)} value={selectedFilter}> - {filters.map((f) => ( - - ))} + {filters?.map((f) => )} { - const { - filterInputs, - setFilterInputs, - timelineMap, - displayedEntries, - queriedRange, - loadSpecificWeek, - refresh, - pipelineRange, - loadAnotherWeek, - isLoading, - } = useContext(LabelTabContext); + const { filterInputs, setFilterInputs, displayedEntries } = useContext(LabelTabContext); + const { timelineMap, loadDateRange, timelineIsLoading, refreshTimeline, shouldUpdateTimeline } = + useContext(TimelineContext); const { colors } = useTheme(); return ( <> - + { numListTotal={timelineMap?.size} /> { + const start = DateTime.fromJSDate(startDate).toISODate(); + const end = DateTime.fromJSDate(endDate).toISODate(); + if (!start || !end) return displayErrorMsg('Invalid date'); + loadDateRange([start, end]); + }} /> refresh()} + onPress={() => refreshTimeline()} accessibilityLabel="Refresh" style={{ marginLeft: 'auto' }} /> - + {shouldUpdateTimeline && } ); diff --git a/www/js/diary/list/TimelineScrollList.tsx b/www/js/diary/list/TimelineScrollList.tsx index e68cf35d1..897cc7474 100644 --- a/www/js/diary/list/TimelineScrollList.tsx +++ b/www/js/diary/list/TimelineScrollList.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useContext, useMemo } from 'react'; import TripCard from '../cards/TripCard'; import PlaceCard from '../cards/PlaceCard'; import UntrackedTimeCard from '../cards/UntrackedTimeCard'; @@ -6,6 +6,8 @@ import { View, FlatList, Platform } from 'react-native'; import { ActivityIndicator, Banner, Icon, Text } from 'react-native-paper'; import LoadMoreButton from './LoadMoreButton'; import { useTranslation } from 'react-i18next'; +import { isoDateRangeToTsRange } from '../timelineHelper'; +import TimelineContext from '../../TimelineContext'; function renderCard({ item: listEntry, index }) { if (listEntry.origin_key.includes('trip')) { @@ -25,34 +27,31 @@ const smallSpinner = ; type Props = { listEntries: any[] | null; - queriedRange: any; - pipelineRange: any; - loadMoreFn: (direction: string) => void; - isLoading: boolean | string; }; -const TimelineScrollList = ({ - listEntries, - queriedRange, - pipelineRange, - loadMoreFn, - isLoading, -}: Props) => { +const TimelineScrollList = ({ listEntries }: Props) => { const { t } = useTranslation(); + const { pipelineRange, queriedDateRange, timelineIsLoading, loadMoreDays } = + useContext(TimelineContext); const listRef = React.useRef(null); // 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 [reachedPipelineStart, reachedPipelineEnd] = useMemo(() => { + if (!queriedDateRange || !pipelineRange) return [false, false]; + + const [queriedStartTs, queriedEndTs] = isoDateRangeToTsRange(queriedDateRange); + return [queriedStartTs <= pipelineRange.start_ts, queriedEndTs >= pipelineRange.end_ts]; + }, [queriedDateRange, pipelineRange]); + const footer = ( - loadMoreFn('past')} disabled={reachedPipelineStart}> + loadMoreDays('past', 7)} 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}> + loadMoreDays('future', 7)} disabled={reachedPipelineEnd}> {reachedPipelineEnd ? t('diary.no-more-travel') : t('diary.show-more-travel')} ); @@ -70,7 +69,7 @@ const TimelineScrollList = ({ /* 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 (timelineIsLoading == 'replace') { /* Condition: we're loading an entirely new batch of trips, so show a big spinner */ return bigSpinner; } else if (listEntries && listEntries.length == 0) { @@ -90,9 +89,9 @@ const TimelineScrollList = ({ This might be a nicer experience than the current header and footer buttons. */ // onScroll={e => console.debug(e.nativeEvent.contentOffset.y)} ListHeaderComponent={ - isLoading == 'append' ? smallSpinner : !reachedPipelineEnd ? header : null + timelineIsLoading == 'append' ? smallSpinner : !reachedPipelineEnd ? header : null } - ListFooterComponent={isLoading == 'prepend' ? smallSpinner : footer} + ListFooterComponent={timelineIsLoading == 'prepend' ? smallSpinner : footer} ItemSeparatorComponent={separator} /* use column-reverse so that the list is 'inverted', meaning it should start scrolling from the bottom, and the bottom-most item should be first in the DOM tree diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index c56789b85..4bdf5591b 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -15,31 +15,35 @@ import { TimestampRange, CompositeTrip, UnprocessedTrip, + BluetoothBleData, + SectionData, + CompositeTripLocation, + SectionSummary, } from '../types/diaryTypes'; import { getLabelInputDetails, getLabelInputs } from '../survey/multilabel/confirmHelper'; import { LabelOptions } from '../types/labelTypes'; -import { EnketoUserInputEntry, filterByNameAndVersion } from '../survey/enketo/enketoHelper'; +import { + EnketoUserInputEntry, + filterByNameAndVersion, + resolveSurveyButtonConfig, +} from '../survey/enketo/enketoHelper'; import { AppConfig } from '../types/appConfigTypes'; import { Point, Feature } from 'geojson'; +import { ble_matching } from 'e-mission-common'; 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?: string, -) { +export function useGeojsonForTrip(trip: CompositeTrip, baseMode?: string) { if (!trip?._id?.$oid) return; - const gjKey = `trip-${trip._id.$oid}-${labeledMode || 'detected'}`; + const gjKey = `trip-${trip._id.$oid}-${baseMode || 'detected'}`; if (cachedGeojsons.has(gjKey)) { return cachedGeojsons.get(gjKey); } - const trajectoryColor = - (labeledMode && getBaseModeByValue(labeledMode, labelOptions)?.color) || undefined; + const trajectoryColor = (baseMode && getBaseModeByKey(baseMode)?.color) || undefined; logDebug("Reading trip's " + trip.locations.length + ' location points at ' + new Date()); const features = [ @@ -92,7 +96,8 @@ export function compositeTrips2TimelineMap(ctList: Array, unpackPlaces?: bo } /* '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) */ + (e.g. 'MODE' and 'PURPOSE' for MULTILABEL configuration, or the name of the survey + for ENKETO configuration) */ export let unprocessedLabels: { [key: string]: UserInputEntry[] } = {}; /* 'NOTES' are 1:n - each trip or place can have any number of notes */ export let unprocessedNotes: EnketoUserInputEntry[] = []; @@ -116,10 +121,14 @@ function updateUnprocessedInputs( const labelResults = comboResults.slice(0, labelsPromises.length); const notesResults = comboResults.slice(labelsPromises.length).flat(2); // fill in the unprocessedLabels object with the labels we just read + unprocessedLabels = {}; labelResults.forEach((r, i) => { if (appConfig.survey_info?.['trip-labels'] == 'ENKETO') { - const filtered = filterByNameAndVersion('TripConfirmSurvey', r, appConfig); - unprocessedLabels['SURVEY'] = filtered as UserInputEntry[]; + const tripSurveys = resolveSurveyButtonConfig(appConfig, 'trip-label'); + tripSurveys.forEach((survey) => { + const filtered = filterByNameAndVersion(survey.surveyName, r, appConfig); + unprocessedLabels[survey.surveyName] = filtered as UserInputEntry[]; + }); } else { unprocessedLabels[getLabelInputs()[i]] = r; } @@ -176,6 +185,23 @@ export async function updateAllUnprocessedInputs( await updateUnprocessedInputs(labelsPromises, notesPromises, appConfig); } +export let unprocessedBleScans: BEMData[] = []; + +export async function updateUnprocessedBleScans(queryRange: TimestampRange) { + const tq = { + key: 'write_ts', + startTs: queryRange.start_ts, + endTs: queryRange.end_ts, + }; + const getMethod = window['cordova'].plugins.BEMUserCache.getSensorDataForInterval; + await getUnifiedDataForInterval('background/bluetooth_ble', tq, getMethod).then( + (bleScans: BEMData[]) => { + logDebug(`Read ${bleScans.length} BLE scans`); + unprocessedBleScans = bleScans; + }, + ); +} + export function keysForLabelInputs(appConfig: AppConfig) { if (appConfig.survey_info?.['trip-labels'] == 'ENKETO') { return ['manual/trip_user_input']; @@ -216,10 +242,10 @@ const location2GeojsonPoint = (locationPoint: Point, featureType: string): Featu */ function locations2GeojsonTrajectory( trip: CompositeTrip, - locationList: Array, + locationList: CompositeTripLocation[], trajectoryColor?: string, -) { - let sectionsPoints; +): Feature[] { + let sectionsPoints: CompositeTripLocation[][]; if (!trip.sections) { // this is a unimodal trip so we put all the locations in one section sectionsPoints = [locationList]; @@ -243,6 +269,9 @@ function locations2GeojsonTrajectory( color for the sensed mode of this section, and fall back to dark grey */ color: trajectoryColor || getBaseModeByKey(section?.sensed_mode_str)?.color || '#333', }, + properties: { + feature_type: 'section_trajectory', + }, }; }); } @@ -250,12 +279,13 @@ function locations2GeojsonTrajectory( // 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: BEMData) => ({ - ...obj.data, - _id: obj._id, - key: obj.metadata.key, - origin_key: obj.metadata.origin_key || obj.metadata.key, -}); +const unpackServerData = (obj: BEMData) => + obj && { + ...obj.data, + _id: obj._id, + key: obj.metadata.key, + origin_key: obj.metadata.origin_key || obj.metadata.key, + }; export function readAllCompositeTrips(startTs: number, endTs: number) { const readPromises = [getRawEntries(['analysis/composite_trip'], startTs, endTs, 'data.end_ts')]; @@ -289,7 +319,25 @@ const dateTime2localdate = (currtime: DateTime, tz: string) => ({ second: currtime.second, }); -function points2TripProps(locationPoints: Array>) { +/* Compute a section summary, which is really simple for unprocessed trips because they are + always assumed to be unimodal. +/* maybe unify with eaum.get_section_summary on e-mission-server at some point */ +const getSectionSummaryForUnprocessed = (section: SectionData, modeProp): SectionSummary => { + const baseMode = section[modeProp] || 'UNKNOWN'; + return { + count: { [baseMode]: 1 }, + distance: { [baseMode]: section.distance }, + duration: { [baseMode]: section.duration }, + }; +}; + +/** + * @description Given an array of location points, creates an UnprocessedTrip object. + */ +function points2UnprocessedTrip( + locationPoints: Array>, + appConfig: AppConfig, +): UnprocessedTrip { const startPoint = locationPoints[0]; const endPoint = locationPoints[locationPoints.length - 1]; const tripAndSectionId = `unprocessed_${startPoint.data.ts}_${endPoint.data.ts}`; @@ -319,24 +367,60 @@ function points2TripProps(locationPoints: Array>) { speed: speeds[i], })); - return { - _id: { $oid: tripAndSectionId }, - key: 'UNPROCESSED_trip', - origin_key: 'UNPROCESSED_trip', - additions: [], - confidence_threshold: 0, + // baseProps: these are the properties that are the same between the trip and its section + const baseProps = { distance: dists.reduce((a, b) => a + b, 0), duration: endPoint.data.ts - startPoint.data.ts, end_fmt_time: endTime.toISO() || displayErrorMsg('end_fmt_time: invalid DateTime') || '', + end_loc: { + type: 'Point', + coordinates: [endPoint.data.longitude, endPoint.data.latitude], + } as Point, 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.toISO() || displayErrorMsg('start_fmt_time: invalid DateTime') || '', + start_loc: { + type: 'Point', + coordinates: [startPoint.data.longitude, startPoint.data.latitude], + } as Point, start_local_dt: dateTime2localdate(startTime, startPoint.metadata.time_zone), start_ts: startPoint.data.ts, + } as const; + + // section: baseProps + some properties that are unique to the section + const singleSection: SectionData = { + ...baseProps, + _id: { $oid: `unprocessed_section_${tripAndSectionId}` }, + cleaned_section: { $oid: `unprocessed_section_${tripAndSectionId}` }, + key: 'UNPROCESSED_section', + origin_key: 'UNPROCESSED_section', + sensed_mode: 4, // MotionTypes.UNKNOWN (4) + sensed_mode_str: 'UNKNOWN', + ble_sensed_mode: ble_matching.get_ble_sensed_vehicle_for_section( + unprocessedBleScans, + baseProps.start_ts, + baseProps.end_ts, + appConfig, + ), + trip_id: { $oid: tripAndSectionId }, + }; + + // the complete UnprocessedTrip: baseProps + properties that are unique to the trip, including the section + return { + ...baseProps, + _id: { $oid: tripAndSectionId }, + additions: [], + ble_sensed_summary: getSectionSummaryForUnprocessed(singleSection, 'ble_sensed_mode'), + cleaned_section_summary: getSectionSummaryForUnprocessed(singleSection, 'sensed_mode_str'), + inferred_section_summary: getSectionSummaryForUnprocessed(singleSection, 'sensed_mode_str'), + confidence_threshold: 0, + expectation: { to_label: true }, + inferred_labels: [], + key: 'UNPROCESSED_trip', + locations: locations, + origin_key: 'UNPROCESSED_trip', + sections: [singleSection], user_input: {}, }; } @@ -344,7 +428,14 @@ function points2TripProps(locationPoints: Array>) { const tsEntrySort = (e1: BEMData, e2: BEMData) => e1.data.ts - e2.data.ts; // compare timestamps -function transitionTrip2TripObj(trip: Array): Promise { +/** + * @description Given an array of 2 transitions, queries the location data during that time and promises an UnprocessedTrip object. + * @param trip An array of transitions representing one trip; i.e. [start transition, end transition] + */ +function tripTransitions2UnprocessedTrip( + trip: Array, + appConfig: AppConfig, +): Promise { const tripStartTransition = trip[0]; const tripEndTransition = trip[1]; const tq = { @@ -386,20 +477,7 @@ function transitionTrip2TripObj(trip: Array): Promise) { // 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. + +/** + * @description Given an array of transitions, finds which transitions represent the start and end of a detected trip and returns them as pairs. + * @returns An 2D array of transitions, where each inner array represents one trip; i.e. [start transition, end transition] */ -function transitions2Trips(transitionList: Array>) { +function transitions2TripTransitions(transitionList: Array>) { + /* 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. + */ let inTrip = false; const tripList: [BEMData, BEMData][] = []; let currStartTransitionIndex = -1; @@ -509,6 +591,7 @@ function linkTrips(trip1, trip2) { export function readUnprocessedTrips( startTs: number, endTs: number, + appConfig: AppConfig, lastProcessedTrip?: CompositeTrip, ) { const tq = { key: 'write_ts', startTs, endTs }; @@ -521,12 +604,14 @@ export function readUnprocessedTrips( return []; } else { logDebug(`Found ${transitionList.length} transitions. yay!`); - const tripsList = transitions2Trips(transitionList); + const tripsList = transitions2TripTransitions(transitionList); logDebug(`Mapped into ${tripsList.length} trips. yay!`); tripsList.forEach((trip) => { logDebug(JSON.stringify(trip, null, 2)); }); - const tripFillPromises = tripsList.map(transitionTrip2TripObj); + const tripFillPromises = tripsList.map((t) => + tripTransitions2UnprocessedTrip(t, appConfig), + ); return Promise.all(tripFillPromises).then( (rawTripObjs: (UnprocessedTrip | undefined)[]) => { // Now we need to link up the trips. linking unprocessed trips @@ -565,3 +650,26 @@ export function readUnprocessedTrips( }, ); } + +/** + * @example IsoDateWithOffset('2024-03-22', 1) -> '2024-03-23' + * @example IsoDateWithOffset('2024-03-22', -1000) -> '2021-06-26' + */ +export function isoDateWithOffset(date: string, offset: number) { + let d = new Date(date); + d.setUTCDate(d.getUTCDate() + offset); + return d.toISOString().substring(0, 10); +} + +export const isoDateRangeToTsRange = (dateRange: [string, string], zone?) => [ + DateTime.fromISO(dateRange[0], { zone: zone }).startOf('day').toSeconds(), + DateTime.fromISO(dateRange[1], { zone: zone }).endOf('day').toSeconds(), +]; + +/** + * @example isoDatesDifference('2024-03-22', '2024-03-29') -> 7 + * @example isoDatesDifference('2024-03-22', '2021-06-26') -> 1000 + * @example isoDatesDifference('2024-03-29', '2024-03-25') -> -4 + */ +export const isoDatesDifference = (date1: string, date2: string) => + -DateTime.fromISO(date1).diff(DateTime.fromISO(date2), 'days').days; diff --git a/www/js/diary/useDerivedProperties.tsx b/www/js/diary/useDerivedProperties.tsx index fe324ee3f..f13c1862d 100644 --- a/www/js/diary/useDerivedProperties.tsx +++ b/www/js/diary/useDerivedProperties.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useContext, useMemo } from 'react'; import { useImperialConfig } from '../config/useImperialConfig'; import { getFormattedDate, @@ -8,10 +8,13 @@ import { getLocalTimeString, getDetectedModes, isMultiDay, + primarySectionForTrip, } from './diaryHelper'; +import TimelineContext from '../TimelineContext'; const useDerivedProperties = (tlEntry) => { const imperialConfig = useImperialConfig(); + const { confirmedModeFor } = useContext(TimelineContext); return useMemo(() => { const beginFmt = tlEntry.start_fmt_time || tlEntry.enter_fmt_time; @@ -21,6 +24,8 @@ const useDerivedProperties = (tlEntry) => { const tlEntryIsMultiDay = isMultiDay(beginFmt, endFmt); return { + confirmedMode: confirmedModeFor(tlEntry), + primary_ble_sensed_mode: primarySectionForTrip(tlEntry)?.ble_sensed_mode?.baseMode, displayDate: getFormattedDate(beginFmt, endFmt), displayStartTime: getLocalTimeString(beginDt), displayEndTime: getLocalTimeString(endDt), @@ -32,7 +37,7 @@ const useDerivedProperties = (tlEntry) => { distanceSuffix: imperialConfig.distanceSuffix, detectedModes: getDetectedModes(tlEntry), }; - }, [tlEntry, imperialConfig]); + }, [tlEntry, imperialConfig, confirmedModeFor(tlEntry)]); }; export default useDerivedProperties; diff --git a/www/js/metrics/ActiveMinutesTableCard.tsx b/www/js/metrics/ActiveMinutesTableCard.tsx index 92a6ac768..aa8bc389f 100644 --- a/www/js/metrics/ActiveMinutesTableCard.tsx +++ b/www/js/metrics/ActiveMinutesTableCard.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useContext, useMemo, useState } from 'react'; import { Card, DataTable, useTheme } from 'react-native-paper'; import { MetricsData } from './metricsTypes'; import { cardStyles } from './MetricsTab'; @@ -7,21 +7,32 @@ import { formatDateRangeOfDays, secondsToMinutes, segmentDaysByWeeks, + valueForFieldOnDay, } from './metricsHelper'; import { useTranslation } from 'react-i18next'; import { ACTIVE_MODES } from './WeeklyActiveMinutesCard'; import { labelKeyToRichMode } from '../survey/multilabel/confirmHelper'; +import TimelineContext from '../TimelineContext'; +import useAppConfig from '../useAppConfig'; type Props = { userMetrics?: MetricsData }; const ActiveMinutesTableCard = ({ userMetrics }: Props) => { const { colors } = useTheme(); + const { dateRange } = useContext(TimelineContext); const { t } = useTranslation(); + const appConfig = useAppConfig(); + // modes to consider as "active" for the purpose of calculating "active minutes", default : ['walk', 'bike'] + const activeModes = + appConfig?.metrics?.phone_dashboard_ui?.active_travel_options?.modes_list ?? ACTIVE_MODES; 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); + activeModes.forEach((mode) => { + const sum = userMetrics.duration.reduce( + (acc, day) => acc + (valueForFieldOnDay(day, 'mode_confirm', mode) || 0), + 0, + ); totals[mode] = secondsToMinutes(sum); }); totals['period'] = formatDateRangeOfDays(userMetrics.duration); @@ -30,12 +41,15 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { const recentWeeksActiveModesTotals = useMemo(() => { if (!userMetrics?.duration) return []; - return segmentDaysByWeeks(userMetrics.duration) + return segmentDaysByWeeks(userMetrics.duration, dateRange[1]) .reverse() .map((week) => { const totals = {}; - ACTIVE_MODES.forEach((mode) => { - const sum = week.reduce((acc, day) => acc + (day[`label_${mode}`] || 0), 0); + activeModes.forEach((mode) => { + const sum = week.reduce( + (acc, day) => acc + (valueForFieldOnDay(day, 'mode_confirm', mode) || 0), + 0, + ); totals[mode] = secondsToMinutes(sum); }); totals['period'] = formatDateRangeOfDays(week); @@ -48,8 +62,8 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { return userMetrics.duration .map((day) => { const totals = {}; - ACTIVE_MODES.forEach((mode) => { - const sum = day[`label_${mode}`] || 0; + activeModes.forEach((mode) => { + const sum = valueForFieldOnDay(day, 'mode_confirm', mode) || 0; totals[mode] = secondsToMinutes(sum); }); totals['period'] = formatDate(day); @@ -79,7 +93,7 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { - {ACTIVE_MODES.map((mode, i) => ( + {activeModes.map((mode, i) => ( {labelKeyToRichMode(mode)} @@ -88,7 +102,7 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { {allTotals.slice(from, to).map((total, i) => ( {total['period']} - {ACTIVE_MODES.map((mode, j) => ( + {activeModes.map((mode, j) => ( {total[mode]} {t('metrics.minutes')} diff --git a/www/js/metrics/CarbonFootprintCard.tsx b/www/js/metrics/CarbonFootprintCard.tsx index 56e955f60..9624e10df 100644 --- a/www/js/metrics/CarbonFootprintCard.tsx +++ b/www/js/metrics/CarbonFootprintCard.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useContext } from 'react'; import { View } from 'react-native'; import { Card, Text } from 'react-native-paper'; import { MetricsData } from './metricsTypes'; @@ -23,23 +23,33 @@ import ChangeIndicator, { CarbonChange } from './ChangeIndicator'; import color from 'color'; import { useAppTheme } from '../appTheme'; import { logDebug, logWarn } from '../plugin/logger'; +import TimelineContext from '../TimelineContext'; +import { isoDatesDifference } from '../diary/timelineHelper'; +import useAppConfig from '../useAppConfig'; type Props = { userMetrics?: MetricsData; aggMetrics?: MetricsData }; const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { const { colors } = useAppTheme(); + const { dateRange } = useContext(TimelineContext); + const appConfig = useAppConfig(); const { t } = useTranslation(); - + // Whether to show the uncertainty on the carbon footprint charts, default: true + const showUnlabeledMetrics = + appConfig?.metrics?.phone_dashboard_ui?.footprint_options?.unlabeled_uncertainty ?? true; const [emissionsChange, setEmissionsChange] = useState(undefined); const userCarbonRecords = useMemo(() => { if (userMetrics?.distance?.length) { //separate data into weeks - const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks(userMetrics?.distance, 2); + const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks( + userMetrics?.distance, + dateRange[1], + ); //formatted data from last week, if exists (14 days ago -> 8 days ago) let userLastWeekModeMap = {}; let userLastWeekSummaryMap = {}; - if (lastWeekDistance && lastWeekDistance?.length == 7) { + if (lastWeekDistance && isoDatesDifference(dateRange[0], lastWeekDistance[0].date) >= 0) { userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user'); userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance'); } @@ -62,11 +72,13 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { low: getFootprintForMetrics(userLastWeekSummaryMap, 0), high: getFootprintForMetrics(userLastWeekSummaryMap, getHighestFootprint()), }; - graphRecords.push({ - label: t('main-metrics.unlabeled'), - x: userPrevWeek.high - userPrevWeek.low, - y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`, - }); + if (showUnlabeledMetrics) { + 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, @@ -79,11 +91,13 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { low: getFootprintForMetrics(userThisWeekSummaryMap, 0), high: getFootprintForMetrics(userThisWeekSummaryMap, getHighestFootprint()), }; - graphRecords.push({ - label: t('main-metrics.unlabeled'), - x: userPastWeek.high - userPastWeek.low, - y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, - }); + if (showUnlabeledMetrics) { + 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, @@ -101,7 +115,6 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { x: worstCarbon, y: `${t('main-metrics.worst-case')}`, }); - return graphRecords; } }, [userMetrics?.distance]); @@ -109,7 +122,7 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { const groupCarbonRecords = useMemo(() => { if (aggMetrics?.distance?.length) { //separate data into weeks - const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, 1)[0]; + const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, dateRange[1])[0]; logDebug(`groupCarbonRecords: aggMetrics = ${JSON.stringify(aggMetrics)}; thisWeekDistance = ${JSON.stringify(thisWeekDistance)}`); @@ -135,11 +148,13 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { high: getFootprintForMetrics(aggCarbonData, getHighestFootprint()), }; logDebug(`groupCarbonRecords: aggCarbon = ${JSON.stringify(aggCarbon)}`); - groupRecords.push({ - label: t('main-metrics.unlabeled'), - x: aggCarbon.high - aggCarbon.low, - y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, - }); + if (showUnlabeledMetrics) { + 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, @@ -164,7 +179,8 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { const cardSubtitleText = useMemo(() => { if (!aggMetrics?.distance?.length) return; - const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, 2) + const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, dateRange[1]) + .slice(0, 2) .reverse() .flat(); const recentEntriesRange = formatDateRangeOfDays(recentEntries); @@ -216,11 +232,9 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { ) : ( - - - {t('metrics.chart-no-data')} - - + + {t('metrics.chart-no-data')} + )} diff --git a/www/js/metrics/CarbonTextCard.tsx b/www/js/metrics/CarbonTextCard.tsx index bf89bdb49..ca9f50fdc 100644 --- a/www/js/metrics/CarbonTextCard.tsx +++ b/www/js/metrics/CarbonTextCard.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useContext, useMemo } from 'react'; import { View } from 'react-native'; import { Card, Text, useTheme } from 'react-native-paper'; import { MetricsData } from './metricsTypes'; @@ -18,21 +18,32 @@ import { MetricsSummary, } from './metricsHelper'; import { logDebug, logWarn } from '../plugin/logger'; +import TimelineContext from '../TimelineContext'; +import { isoDatesDifference } from '../diary/timelineHelper'; +import useAppConfig from '../useAppConfig'; type Props = { userMetrics?: MetricsData; aggMetrics?: MetricsData }; const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { const { colors } = useTheme(); + const { dateRange } = useContext(TimelineContext); const { t } = useTranslation(); + const appConfig = useAppConfig(); + // Whether to show the uncertainty on the carbon footprint charts, default: true + const showUnlabeledMetrics = + appConfig?.metrics?.phone_dashboard_ui?.footprint_options?.unlabeled_uncertainty ?? true; const userText = useMemo(() => { if (userMetrics?.distance?.length) { //separate data into weeks - const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks(userMetrics?.distance, 2); + const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks( + userMetrics?.distance, + dateRange[1], + ); //formatted data from last week, if exists (14 days ago -> 8 days ago) let userLastWeekModeMap = {}; let userLastWeekSummaryMap = {}; - if (lastWeekDistance && lastWeekDistance?.length == 7) { + if (lastWeekDistance && isoDatesDifference(dateRange[0], lastWeekDistance[0].date) >= 0) { userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user'); userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance'); } @@ -89,7 +100,7 @@ const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { const groupText = useMemo(() => { if (aggMetrics?.distance?.length) { //separate data into weeks - const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, 1)[0]; + const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, dateRange[1])[0]; let aggThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'aggregate'); let aggThisWeekSummary = generateSummaryFromData(aggThisWeekModeMap, 'distance'); @@ -139,7 +150,8 @@ const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { const cardSubtitleText = useMemo(() => { if (!aggMetrics?.distance?.length) return; - const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, 2) + const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, dateRange[1]) + .slice(0, 2) .reverse() .flat(); const recentEntriesRange = formatDateRangeOfDays(recentEntries); @@ -170,11 +182,13 @@ const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { {textEntries[i].value + ' ' + 'kg CO₂'} ))} - - {t('main-metrics.range-uncertain-footnote')} - + {showUnlabeledMetrics && ( + + {t('main-metrics.range-uncertain-footnote')} + + )} ); diff --git a/www/js/metrics/ChangeIndicator.tsx b/www/js/metrics/ChangeIndicator.tsx index 137113ac1..8118d59ad 100644 --- a/www/js/metrics/ChangeIndicator.tsx +++ b/www/js/metrics/ChangeIndicator.tsx @@ -18,26 +18,21 @@ const ChangeIndicator = ({ change }: Props) => { if (!change) return; let low = isFinite(change.low) ? Math.round(Math.abs(change.low)) : '∞'; let high = isFinite(change.high) ? Math.round(Math.abs(change.high)) : '∞'; - + if (low == '∞' && high == '∞') return; // both are ∞, no information is really conveyed; don't show 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; + // high and low being the same means there is no uncertainty; show just one percentage + return changeSign(change.low) + low + '%'; } + // when there is uncertainty, show both percentages separated by a slash + return `${changeSign(change.low) + low}% / ${changeSign(change.high) + high}%`; }, [change]); - return changeText != '' ? ( + return changeText ? ( 0 ? colors.danger : colors.success)}> {changeText + '\n'} {`${t('metrics.this-week')}`} - ) : ( - <> - ); + ) : null; }; const styles: any = { diff --git a/www/js/metrics/DailyActiveMinutesCard.tsx b/www/js/metrics/DailyActiveMinutesCard.tsx index c6ba7cbf0..f70b60587 100644 --- a/www/js/metrics/DailyActiveMinutesCard.tsx +++ b/www/js/metrics/DailyActiveMinutesCard.tsx @@ -7,31 +7,33 @@ import { useTranslation } from 'react-i18next'; import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper'; import LineChart from '../components/LineChart'; import { getBaseModeByText } from '../diary/diaryHelper'; - -const ACTIVE_MODES = ['walk', 'bike'] as const; -type ActiveMode = (typeof ACTIVE_MODES)[number]; +import { tsForDayOfMetricData, valueForFieldOnDay } from './metricsHelper'; +import useAppConfig from '../useAppConfig'; +import { ACTIVE_MODES } from './WeeklyActiveMinutesCard'; type Props = { userMetrics?: MetricsData }; const DailyActiveMinutesCard = ({ userMetrics }: Props) => { const { colors } = useTheme(); const { t } = useTranslation(); + const appConfig = useAppConfig(); + // modes to consider as "active" for the purpose of calculating "active minutes", default : ['walk', 'bike'] + const activeModes = + appConfig?.metrics?.phone_dashboard_ui?.active_travel_options?.modes_list ?? ACTIVE_MODES; const dailyActiveMinutesRecords = useMemo(() => { - const records: { label: string; x: string; y: number }[] = []; + const records: { label: string; x: number; y: number }[] = []; const recentDays = userMetrics?.duration?.slice(-14); recentDays?.forEach((day) => { - ACTIVE_MODES.forEach((mode) => { - const activeSeconds = day[`label_${mode}`]; - if (activeSeconds) { - records.push({ - label: labelKeyToRichMode(mode), - x: `${day.ts * 1000}`, // vertical chart, milliseconds on X axis - y: activeSeconds && activeSeconds / 60, // minutes on Y axis - }); - } + activeModes.forEach((mode) => { + const activeSeconds = valueForFieldOnDay(day, 'mode_confirm', mode); + records.push({ + label: labelKeyToRichMode(mode), + x: tsForDayOfMetricData(day) * 1000, // vertical chart, milliseconds on X axis + y: activeSeconds ? activeSeconds / 60 : null, // minutes on Y axis + }); }); }); - return records as { label: ActiveMode; x: string; y: number }[]; + return records as { label: ActiveMode; x: number; y: number }[]; }, [userMetrics?.duration]); return ( @@ -54,11 +56,9 @@ const DailyActiveMinutesCard = ({ userMetrics }: Props) => { getColorForLabel={(l) => getBaseModeByText(l, labelOptions).color} /> ) : ( - - - {t('metrics.chart-no-data')} - - + + {t('metrics.chart-no-data')} + )} diff --git a/www/js/metrics/MetricsCard.tsx b/www/js/metrics/MetricsCard.tsx index 6662762c2..287193711 100644 --- a/www/js/metrics/MetricsCard.tsx +++ b/www/js/metrics/MetricsCard.tsx @@ -4,29 +4,39 @@ import { Card, Checkbox, Text, useTheme } from 'react-native-paper'; import colorLib from 'color'; import BarChart from '../components/BarChart'; import { DayOfMetricData } from './metricsTypes'; -import { formatDateRangeOfDays, getLabelsForDay, getUniqueLabelsForDays } from './metricsHelper'; +import { + formatDateRangeOfDays, + getLabelsForDay, + tsForDayOfMetricData, + getUniqueLabelsForDays, + valueForFieldOnDay, + getUnitUtilsForMetric, +} from './metricsHelper'; import ToggleSwitch from '../components/ToggleSwitch'; import { cardStyles } from './MetricsTab'; import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper'; -import { getBaseModeByKey, getBaseModeByText } from '../diary/diaryHelper'; +import { getBaseModeByKey, getBaseModeByText, modeColors } from '../diary/diaryHelper'; import { useTranslation } from 'react-i18next'; +import { GroupingField, MetricName } from '../types/appConfigTypes'; +import { useImperialConfig } from '../config/useImperialConfig'; type Props = { + metricName: MetricName; + groupingFields: GroupingField[]; cardTitle: string; userMetricsDays?: DayOfMetricData[]; aggMetricsDays?: DayOfMetricData[]; - axisUnits: string; - unitFormatFn?: (val: number) => string | number; }; const MetricsCard = ({ + metricName, + groupingFields, cardTitle, userMetricsDays, aggMetricsDays, - axisUnits, - unitFormatFn, }: Props) => { const { colors } = useTheme(); const { t } = useTranslation(); + const imperialConfig = useImperialConfig(); const [viewMode, setViewMode] = useState<'details' | 'graph'>('details'); const [populationMode, setPopulationMode] = useState<'user' | 'aggregate'>('user'); const [graphIsStacked, setGraphIsStacked] = useState(true); @@ -35,6 +45,11 @@ const MetricsCard = ({ [populationMode, userMetricsDays, aggMetricsDays], ); + const [axisUnits, unitConvertFn, unitDisplayFn] = useMemo( + () => getUnitUtilsForMetric(metricName, imperialConfig), + [metricName], + ); + // for each label on each day, create a record for the chart const chartData = useMemo(() => { if (!metricDataDays || viewMode != 'graph') return []; @@ -42,12 +57,12 @@ const MetricsCard = ({ metricDataDays.forEach((day) => { const labels = getLabelsForDay(day); labels.forEach((label) => { - const rawVal = day[`label_${label}`]; + const rawVal = valueForFieldOnDay(day, groupingFields[0], label); if (rawVal) { records.push({ label: labelKeyToRichMode(label), - x: unitFormatFn ? unitFormatFn(rawVal) : rawVal, - y: day.ts * 1000, // time (as milliseconds) will go on Y axis because it will be a horizontal chart + x: unitConvertFn(rawVal), + y: tsForDayOfMetricData(day) * 1000, // time (as milliseconds) will go on Y axis because it will be a horizontal chart }); } }); @@ -76,8 +91,22 @@ const MetricsCard = ({ // 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); - vals[label] = unitFormatFn ? unitFormatFn(sum) : sum; + const sum: any = metricDataDays.reduce((acc, day) => { + const val = valueForFieldOnDay(day, groupingFields[0], label); + // if val is number, add it to the accumulator + if (!isNaN(val)) { + return acc + val; + } else if (val && typeof val == 'object') { + // if val is object, add its values to the accumulator's values + acc = acc || {}; + for (let key in val) { + acc[key] = (acc[key] || 0) + val[key]; + } + return acc; + } + return acc; + }, 0); + vals[label] = unitDisplayFn(sum); }); return vals; }, [metricDataDays, viewMode]); @@ -93,7 +122,7 @@ const MetricsCard = ({ }; return ( - + - {viewMode == 'details' && ( - - {Object.keys(metricSumValues).map((label, i) => ( - - {labelKeyToRichMode(label)} - {metricSumValues[label] + ' ' + axisUnits} - - ))} - - )} - {viewMode == 'graph' && ( - <> - - - Stack bars: - setGraphIsStacked(!graphIsStacked)} - /> + {viewMode == 'details' && + (Object.keys(metricSumValues).length ? ( + + {Object.keys(metricSumValues).map((label, i) => ( + + {labelKeyToRichMode(label)} + {metricSumValues[label]} + + ))} - - )} + ) : ( + + {t('metrics.chart-no-data')} + + ))} + {viewMode == 'graph' && + (chartData.length ? ( + <> + + + Stack bars: + setGraphIsStacked(!graphIsStacked)} + /> + + + ) : ( + + {t('metrics.chart-no-data')} + + ))} ); diff --git a/www/js/metrics/MetricsDateSelect.tsx b/www/js/metrics/MetricsDateSelect.tsx deleted file mode 100644 index 07656ec25..000000000 --- a/www/js/metrics/MetricsDateSelect.tsx +++ /dev/null @@ -1,96 +0,0 @@ -/* This button launches a modal to select a date range, which determines what time period - for which metrics should be displayed. - The button itself is a NavBarButton, which shows the currently selected date range, - a calendar icon, and launches the modal when clicked. - The modal is a DatePickerModal from react-native-paper-dates, which shows a calendar - 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 { DateTime } from 'luxon'; -import { NavBarButton } from '../components/NavBar'; - -type Props = { - 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 onDismiss = useCallback(() => { - setOpen(false); - }, [setOpen]); - - const onChoose = useCallback( - ({ startDate, endDate }) => { - const dtStartDate = DateTime.fromJSDate(startDate).startOf('day'); - let dtEndDate; - - if (!endDate) { - // If no end date selected, pull range from then till present day - dtEndDate = DateTime.now(); - } else if ( - dtStartDate.toString() === DateTime.fromJSDate(endDate).startOf('day').toString() - ) { - // For when only one day is selected - // NOTE: As written, this technically timestamp will technically fetch _two_ days. - // For more info, see: https://github.com/e-mission/e-mission-docs/issues/1027 - dtEndDate = dtStartDate.endOf('day'); - } else { - dtEndDate = DateTime.fromJSDate(endDate).startOf('day'); - } - setOpen(false); - setDateRange([dtStartDate, dtEndDate]); - }, - [setOpen, setDateRange], - ); - - return ( - <> - setOpen(true)}> - {dateRange[0] && ( - <> - {dateRange[0].toLocaleString()} - - - )} - {dateRange[1]?.toLocaleString() || t('diary.today')} - - - - ); -}; - -export const s = StyleSheet.create({ - divider: { - width: 25, - marginHorizontal: 'auto', - }, -}); - -export default MetricsDateSelect; diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 7533022a5..3d4748f67 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -1,145 +1,214 @@ -import React, { useEffect, useState, useMemo } from 'react'; -import { View, ScrollView, useWindowDimensions } from 'react-native'; +import React, { useEffect, useState, useMemo, useContext } from 'react'; +import { ScrollView, useWindowDimensions } from 'react-native'; import { Appbar, useTheme } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; import { DateTime } from 'luxon'; import NavBar from '../components/NavBar'; 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 { displayError, logDebug, logWarn } from '../plugin/logger'; +import { getAggregateData } from '../services/commHelper'; +import { displayError, displayErrorMsg, logDebug } from '../plugin/logger'; import useAppConfig from '../useAppConfig'; -import { ServerConnConfig } from '../types/appConfigTypes'; +import { + AppConfig, + GroupingField, + MetricName, + MetricList, + MetricsUiSection, +} from '../types/appConfigTypes'; +import DateSelect from '../diary/list/DateSelect'; +import TimelineContext, { TimelineLabelMap, TimelineMap } from '../TimelineContext'; +import { isoDatesDifference } from '../diary/timelineHelper'; +import { metrics_summaries } from 'e-mission-common'; +import SurveyLeaderboardCard from './SurveyLeaderboardCard'; +import SurveyTripCategoriesCard from './SurveyTripCategoriesCard'; +import SurveyComparisonCard from './SurveyComparisonCard'; -export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; +// 2 weeks of data is needed in order to compare "past week" vs "previous week" +const N_DAYS_TO_LOAD = 14; // 2 weeks +const DEFAULT_SECTIONS_TO_SHOW: MetricsUiSection[] = [ + 'footprint', + 'active_travel', + 'summary', +] as const; +export const DEFAULT_METRIC_LIST: MetricList = { + distance: ['mode_confirm'], + duration: ['mode_confirm'], + count: ['mode_confirm'], +}; + +async function computeUserMetrics( + metricList: MetricList, + timelineMap: TimelineMap, + timelineLabelMap: TimelineLabelMap | null, + appConfig: AppConfig, +) { + try { + const timelineValues = [...timelineMap.values()]; + const result = metrics_summaries.generate_summaries( + { ...metricList }, + timelineValues, + appConfig, + timelineLabelMap, + ); + logDebug('MetricsTab: computed userMetrics'); + console.debug('MetricsTab: computed userMetrics', result); + return result as MetricsData; + } catch (e) { + displayError(e, 'Error computing user metrics'); + } +} -async function fetchMetricsFromServer( - type: 'user' | 'aggregate', - dateRange: DateTime[], - serverConnConfig: ServerConnConfig, +async function fetchAggMetrics( + metricList: MetricList, + dateRange: [string, string], + appConfig: AppConfig, ) { + logDebug('MetricsTab: fetching agg metrics from server for dateRange ' + dateRange); const query = { freq: 'D', - start_time: dateRange[0].toSeconds(), - end_time: dateRange[1].toSeconds(), - metric_list: METRIC_LIST, - is_return_aggregate: type == 'aggregate', + start_time: dateRange[0], + end_time: dateRange[1], + metric_list: metricList, + is_return_aggregate: true, + app_config: { survey_info: appConfig.survey_info }, }; - if (type == 'user') return getMetrics('timestamp', query); - return getAggregateData('result/metrics/timestamp', query, serverConnConfig); -} - -function getLastTwoWeeksDtRange() { - const now = DateTime.now().startOf('day'); - const start = now.minus({ days: 15 }); - const end = now.minus({ days: 1 }); - return [start, end]; + return getAggregateData('result/metrics/yyyy_mm_dd', query, appConfig.server) + .then((response) => { + logDebug('MetricsTab: received aggMetrics'); + console.debug('MetricsTab: received aggMetrics', response); + return response as MetricsData; + }) + .catch((e) => { + displayError(e, 'Error fetching aggregate metrics'); + return undefined; + }); } const MetricsTab = () => { const appConfig = useAppConfig(); - const { colors } = useTheme(); const { t } = useTranslation(); - const { getFormattedSpeed, speedSuffix, getFormattedDistance, distanceSuffix } = - useImperialConfig(); + const { + dateRange, + timelineMap, + timelineLabelMap, + timelineIsLoading, + refreshTimeline, + loadMoreDays, + loadDateRange, + } = useContext(TimelineContext); - const [dateRange, setDateRange] = useState(getLastTwoWeeksDtRange); - const [aggMetrics, setAggMetrics] = useState(undefined); - const [userMetrics, setUserMetrics] = useState(undefined); + const metricList = appConfig?.metrics?.phone_dashboard_ui?.metric_list ?? DEFAULT_METRIC_LIST; - useEffect(() => { - if (!appConfig?.server) return; - loadMetricsForPopulation('user', dateRange); - loadMetricsForPopulation('aggregate', dateRange); - }, [dateRange, appConfig?.server]); + const [userMetrics, setUserMetrics] = useState(undefined); + const [aggMetrics, setAggMetrics] = useState(undefined); + const [aggMetricsIsLoading, setAggMetricsIsLoading] = useState(false); - async function loadMetricsForPopulation(population: 'user' | 'aggregate', dateRange: DateTime[]) { - try { - logDebug(`MetricsTab: fetching metrics for population ${population}' - in date range ${JSON.stringify(dateRange)}`); - const serverResponse: any = await fetchMetricsFromServer( - population, - dateRange, - appConfig.server, - ); - 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) { - logWarn(e + t('errors.while-loading-metrics')); // replace with displayErr + const readyToLoad = useMemo(() => { + if (!appConfig || !dateRange) return false; + const dateRangeDays = isoDatesDifference(...dateRange); + if (dateRangeDays < N_DAYS_TO_LOAD) { + logDebug('MetricsTab: not enough days loaded, trying to load more'); + const loadingMore = loadMoreDays('past', N_DAYS_TO_LOAD - dateRangeDays); + if (loadingMore !== false) return false; + logDebug('MetricsTab: no more days can be loaded, continuing with what we have'); } - } + return true; + }, [appConfig, dateRange]); - function refresh() { - setDateRange(getLastTwoWeeksDtRange()); - } + useEffect(() => { + if (!readyToLoad || !appConfig || timelineIsLoading || !timelineMap || !timelineLabelMap) + return; + logDebug('MetricsTab: ready to compute userMetrics'); + computeUserMetrics(metricList, timelineMap, timelineLabelMap, appConfig).then((result) => + setUserMetrics(result), + ); + }, [readyToLoad, appConfig, timelineIsLoading, timelineMap, timelineLabelMap]); + + useEffect(() => { + if (!readyToLoad || !appConfig || !dateRange) return; + logDebug('MetricsTab: ready to fetch aggMetrics'); + setAggMetricsIsLoading(true); + fetchAggMetrics(metricList, dateRange, appConfig).then((response) => { + setAggMetricsIsLoading(false); + setAggMetrics(response); + }); + }, [readyToLoad, appConfig, dateRange]); + const sectionsToShow = + appConfig?.metrics?.phone_dashboard_ui?.sections || DEFAULT_SECTIONS_TO_SHOW; const { width: windowWidth } = useWindowDimensions(); const cardWidth = windowWidth * 0.88; + const studyStartDate = `${appConfig?.intro.start_month} / ${appConfig?.intro.start_year}`; return ( <> - + - - + { + const start = DateTime.fromJSDate(startDate).toISODate(); + const end = DateTime.fromJSDate(endDate).toISODate(); + if (!start || !end) return displayErrorMsg('Invalid date'); + loadDateRange([start, end]); + }} + /> + - - - - - - - - - - - - - - {/* */} - + {sectionsToShow.includes('footprint') && ( + + + + + )} + {sectionsToShow.includes('active_travel') && ( + + + + + + )} + {sectionsToShow.includes('summary') && ( + + {Object.entries(metricList).map( + ([metricName, groupingFields]: [MetricName, GroupingField[]]) => { + return ( + + ); + }, + )} + + )} + {sectionsToShow.includes('surveys') && ( + + + + + )} + {/* we will implement leaderboard later */} + {/* {sectionsToShow.includes('engagement') && ( + + + + )} */} ); diff --git a/www/js/metrics/SurveyComparisonCard.tsx b/www/js/metrics/SurveyComparisonCard.tsx new file mode 100644 index 000000000..a99a604eb --- /dev/null +++ b/www/js/metrics/SurveyComparisonCard.tsx @@ -0,0 +1,183 @@ +import React, { useMemo } from 'react'; +import { View } from 'react-native'; +import { Icon, Card, Text } from 'react-native-paper'; +import { useTranslation } from 'react-i18next'; +import { useAppTheme } from '../appTheme'; +import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'; +import { Doughnut } from 'react-chartjs-2'; +import { cardStyles } from './MetricsTab'; +import { DayOfMetricData, MetricsData } from './metricsTypes'; +import { getUniqueLabelsForDays } from './metricsHelper'; +ChartJS.register(ArcElement, Tooltip, Legend); + +/** + * @description Calculates the percentage of 'responded' values across days of 'response_count' data. + * @returns Percentage as a whole number (0-100), or null if no data. + */ +function getResponsePctForDays(days: DayOfMetricData<'response_count'>[]) { + const surveys = getUniqueLabelsForDays(days); + let acc = { responded: 0, not_responded: 0 }; + days.forEach((day) => { + surveys.forEach((survey) => { + acc.responded += day[`survey_${survey}`]?.responded || 0; + acc.not_responded += day[`survey_${survey}`]?.not_responded || 0; + }); + }); + const total = acc.responded + acc.not_responded; + if (total === 0) return null; + return Math.round((acc.responded / total) * 100); +} + +type Props = { + userMetrics: MetricsData | undefined; + aggMetrics: MetricsData | undefined; +}; + +export type SurveyComparison = { + me: number; + others: number; +}; + +export const LabelPanel = ({ first, second }) => { + const { colors } = useAppTheme(); + + return ( + + + + {first} + + + + {second} + + + ); +}; + +const SurveyComparisonCard = ({ userMetrics, aggMetrics }: Props) => { + const { colors } = useAppTheme(); + const { t } = useTranslation(); + + const myResponsePct = useMemo(() => { + if (!userMetrics?.response_count) return; + return getResponsePctForDays(userMetrics.response_count); + }, [userMetrics]); + + const othersResponsePct = useMemo(() => { + if (!aggMetrics?.response_count) return; + return getResponsePctForDays(aggMetrics.response_count); + }, [aggMetrics]); + + const renderDoughnutChart = (rate, chartColor, myResponse) => { + const data = { + datasets: [ + { + data: [rate, 100 - rate], + backgroundColor: [chartColor, colors.silver], + borderColor: [chartColor, colors.silver], + borderWidth: 1, + }, + ], + }; + return ( + + + {myResponse ? ( + + ) : ( + + )} + {rate === null ? t('metrics.no-data') : rate + '%'} + + + + ); + }; + + return ( + + + + {typeof myResponsePct !== 'number' || typeof othersResponsePct !== 'number' ? ( + + {t('metrics.chart-no-data')} + + ) : ( + + {t('main-metrics.survey-response-rate')} + + {renderDoughnutChart(myResponsePct, colors.navy, true)} + {renderDoughnutChart(othersResponsePct, colors.orange, false)} + + + + )} + + + ); +}; + +const styles: any = { + chartTitle: { + alignSelf: 'center', + fontWeight: 'bold', + fontSize: 14, + marginBottom: 10, + }, + statusTextWrapper: { + alignSelf: 'center', + display: 'flex', + flexDirection: 'row', + fontSize: 16, + }, + chartWrapper: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-around', + }, + textWrapper: { + position: 'absolute', + width: 140, + height: 140, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + labelWrapper: { + alignSelf: 'center', + display: 'flex', + gap: 10, + marginTop: 10, + }, + labelItem: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, +}; + +export default SurveyComparisonCard; diff --git a/www/js/metrics/SurveyLeaderboardCard.tsx b/www/js/metrics/SurveyLeaderboardCard.tsx new file mode 100644 index 000000000..34341616d --- /dev/null +++ b/www/js/metrics/SurveyLeaderboardCard.tsx @@ -0,0 +1,128 @@ +import React, { useMemo } from 'react'; +import { View, Text } from 'react-native'; +import { Card } from 'react-native-paper'; +import { cardStyles, SurveyMetric, SurveyObject } from './MetricsTab'; +import { useTranslation } from 'react-i18next'; +import BarChart from '../components/BarChart'; +import { useAppTheme } from '../appTheme'; +import { Chart as ChartJS, registerables } from 'chart.js'; +import Annotation from 'chartjs-plugin-annotation'; + +ChartJS.register(...registerables, Annotation); + +type Props = { + studyStartDate: string; + surveyMetric: SurveyMetric; +}; + +type LeaderboardRecord = { + label: string; + x: number; + y: string; +}; + +const SurveyLeaderboardCard = ({ studyStartDate, surveyMetric }: Props) => { + const { colors } = useAppTheme(); + const { t } = useTranslation(); + + const myRank = surveyMetric.me.rank; + const mySurveyMetric = surveyMetric.me.overview; + + function getLabel(rank: number): string { + if (rank === 0) { + return '🏆 #1:'; + } else if (rank === 1) { + return '🥈 #2:'; + } else if (rank === 2) { + return '🥉 #3:'; + } else { + return `#${rank + 1}:`; + } + } + + const leaderboardRecords: LeaderboardRecord[] = useMemo(() => { + const combinedLeaderboard: SurveyObject[] = [...surveyMetric.others.leaderboard]; + combinedLeaderboard.splice(myRank, 0, mySurveyMetric); + + // This is to prevent the leaderboard from being too long for UX purposes. + // For a total of 20 members, we only show the top 5 members, myself, and the bottom 3 members. + const numberOfTopUsers = 5; + const numberOfBottomUsers = surveyMetric.others.leaderboard.length - 3; + + return combinedLeaderboard + .map((item, idx) => ({ + isMe: idx === myRank, + rank: idx, + answered: item.answered, + unanswered: item.unanswered, + mismatched: item.mismatched, + })) + .filter( + (item) => item.isMe || item.rank < numberOfTopUsers || item.rank >= numberOfBottomUsers, + ) + .map((item) => ({ + label: item.isMe ? `${item.rank}-me` : `${item.rank}-other`, + x: Math.round((item.answered / (item.answered + item.unanswered)) * 100), + y: getLabel(item.rank), + })); + }, [surveyMetric]); + + return ( + + + + + + * {t('main-metrics.survey-leaderboard-desc')} + {studyStartDate} + + {t('main-metrics.survey-response-rate')} + (l === `${myRank}-me` ? colors.skyblue : colors.silver)} + getColorForChartEl={(l) => (l === `${myRank}-me` ? colors.skyblue : colors.silver)} + showLegend={false} + reverse={false} + enableTooltip={false} + /> + + {t('main-metrics.you-are-in')} + #{myRank + 1} + {t('main-metrics.place')} + + + + + ); +}; + +const styles: any = { + chartTitle: { + alignSelf: 'center', + fontWeight: 'bold', + fontSize: 14, + }, + chartDesc: { + fontSoze: 12, + marginBottom: 10, + }, + statusTextWrapper: { + alignSelf: 'center', + display: 'flex', + flexDirection: 'row', + fontSize: 16, + }, +}; + +export default SurveyLeaderboardCard; diff --git a/www/js/metrics/SurveyTripCategoriesCard.tsx b/www/js/metrics/SurveyTripCategoriesCard.tsx new file mode 100644 index 000000000..77df43abf --- /dev/null +++ b/www/js/metrics/SurveyTripCategoriesCard.tsx @@ -0,0 +1,90 @@ +import React, { useMemo } from 'react'; +import { Text, Card } from 'react-native-paper'; +import { cardStyles } from './MetricsTab'; +import { useTranslation } from 'react-i18next'; +import BarChart from '../components/BarChart'; +import { useAppTheme } from '../appTheme'; +import { LabelPanel } from './SurveyComparisonCard'; +import { DayOfMetricData, MetricsData } from './metricsTypes'; +import { GroupingField } from '../types/appConfigTypes'; +import { getUniqueLabelsForDays } from './metricsHelper'; + +function sumResponseCountsForValue( + days: DayOfMetricData<'response_count'>[], + value: `${GroupingField}_${string}`, +) { + const acc = { responded: 0, not_responded: 0 }; + days.forEach((day) => { + acc.responded += day[value]?.responded || 0; + acc.not_responded += day[value]?.not_responded || 0; + }); + return acc; +} + +type SurveyTripRecord = { + label: string; + x: string; + y: number; +}; + +type Props = { + userMetrics: MetricsData | undefined; + aggMetrics: MetricsData | undefined; +}; +const SurveyTripCategoriesCard = ({ userMetrics, aggMetrics }: Props) => { + const { colors } = useAppTheme(); + const { t } = useTranslation(); + + const records = useMemo(() => { + if (!userMetrics?.response_count) return []; + const surveys = getUniqueLabelsForDays(userMetrics.response_count); + const records: SurveyTripRecord[] = []; + surveys.forEach((survey) => { + const { responded, not_responded } = sumResponseCountsForValue( + userMetrics.response_count, + `survey_${survey}`, + ); + records.push({ label: 'Response', x: survey, y: responded || 0 }); + records.push({ label: 'No Response', x: survey, y: not_responded || 0 }); + }); + return records; + }, [userMetrics]); + + return ( + + + + {records.length ? ( + <> + (l === 'Response' ? colors.navy : colors.orange)} + getColorForChartEl={(l) => (l === 'Response' ? colors.navy : colors.orange)} + showLegend={false} + reverse={false} + maxBarThickness={60} + /> + + + ) : ( + + {t('metrics.chart-no-data')} + + )} + + + ); +}; + +export default SurveyTripCategoriesCard; diff --git a/www/js/metrics/WeeklyActiveMinutesCard.tsx b/www/js/metrics/WeeklyActiveMinutesCard.tsx index eb1a29939..4201f993e 100644 --- a/www/js/metrics/WeeklyActiveMinutesCard.tsx +++ b/www/js/metrics/WeeklyActiveMinutesCard.tsx @@ -1,13 +1,15 @@ -import React, { useMemo, useState } from 'react'; +import React, { useContext, useMemo, useState } from 'react'; import { View } from 'react-native'; import { Card, Text, useTheme } from 'react-native-paper'; import { MetricsData } from './metricsTypes'; import { cardMargin, cardStyles } from './MetricsTab'; -import { formatDateRangeOfDays, segmentDaysByWeeks } from './metricsHelper'; +import { formatDateRangeOfDays, segmentDaysByWeeks, valueForFieldOnDay } from './metricsHelper'; import { useTranslation } from 'react-i18next'; import BarChart from '../components/BarChart'; import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper'; import { getBaseModeByText } from '../diary/diaryHelper'; +import TimelineContext from '../TimelineContext'; +import useAppConfig from '../useAppConfig'; export const ACTIVE_MODES = ['walk', 'bike'] as const; type ActiveMode = (typeof ACTIVE_MODES)[number]; @@ -15,24 +17,31 @@ type ActiveMode = (typeof ACTIVE_MODES)[number]; type Props = { userMetrics?: MetricsData }; const WeeklyActiveMinutesCard = ({ userMetrics }: Props) => { const { colors } = useTheme(); + const { dateRange } = useContext(TimelineContext); const { t } = useTranslation(); - + const appConfig = useAppConfig(); + // modes to consider as "active" for the purpose of calculating "active minutes", default : ['walk', 'bike'] + const activeModes = + appConfig?.metrics?.phone_dashboard_ui?.active_travel_options?.modes_list ?? ACTIVE_MODES; const weeklyActiveMinutesRecords = useMemo(() => { if (!userMetrics?.duration) return []; const records: { x: string; y: number; label: string }[] = []; - 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) { - // `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})` + const [recentWeek, prevWeek] = segmentDaysByWeeks(userMetrics?.duration, dateRange[1]); + activeModes.forEach((mode) => { + if (prevWeek) { + const prevSum = prevWeek?.reduce( + (acc, day) => acc + (valueForFieldOnDay(day, 'mode_confirm', mode) || 0), + 0, + ); const xLabel = `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(prevWeek)})`; records.push({ label: labelKeyToRichMode(mode), x: xLabel, y: prevSum / 60 }); } - const recentSum = recentWeek?.reduce((acc, day) => acc + (day[`label_${mode}`] || 0), 0); - if (recentSum) { - const xLabel = `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(recentWeek)})`; - records.push({ label: labelKeyToRichMode(mode), x: xLabel, y: recentSum / 60 }); - } + const recentSum = recentWeek?.reduce( + (acc, day) => acc + (valueForFieldOnDay(day, 'mode_confirm', mode) || 0), + 0, + ); + const xLabel = `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(recentWeek)})`; + records.push({ label: labelKeyToRichMode(mode), x: xLabel, y: recentSum / 60 }); }); return records as { label: ActiveMode; x: string; y: number }[]; }, [userMetrics?.duration]); @@ -67,11 +76,9 @@ const WeeklyActiveMinutesCard = ({ userMetrics }: Props) => { ) : ( - - - {t('metrics.chart-no-data')} - - + + {t('metrics.chart-no-data')} + )} diff --git a/www/js/metrics/footprintHelper.ts b/www/js/metrics/footprintHelper.ts index c37d8de92..2a02ea133 100644 --- a/www/js/metrics/footprintHelper.ts +++ b/www/js/metrics/footprintHelper.ts @@ -44,12 +44,12 @@ export function getFootprintForMetrics(userMetrics, defaultIfMissing = 0) { const footprint = getFootprint(); logDebug('getting footprint for ' + userMetrics + ' with ' + footprint); let result = 0; - for (let i in userMetrics) { - let mode = userMetrics[i].key; + userMetrics.forEach((userMetric) => { + let mode = userMetric.key; //either the mode is in our custom footprint or it is not if (mode in footprint) { - result += footprint[mode] * mtokm(userMetrics[i].values); + result += footprint[mode] * mtokm(userMetric.values); } else if (mode == 'IN_VEHICLE') { const sum = footprint['CAR'] + @@ -58,16 +58,16 @@ export function getFootprintForMetrics(userMetrics, defaultIfMissing = 0) { footprint['TRAIN'] + footprint['TRAM'] + footprint['SUBWAY']; - result += (sum / 6) * mtokm(userMetrics[i].values); + result += (sum / 6) * mtokm(userMetric.values); } else { logWarn( `WARNING getFootprintFromMetrics() was requested for an unknown mode: ${mode} metrics JSON: ${JSON.stringify( userMetrics, )}`, ); - result += defaultIfMissing * mtokm(userMetrics[i].values); + result += defaultIfMissing * mtokm(userMetric.values); } - } + }); return result; } diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index ca3846806..65337690b 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -1,52 +1,72 @@ import { DateTime } from 'luxon'; -import { formatForDisplay } from '../config/useImperialConfig'; import { DayOfMetricData } from './metricsTypes'; import { logDebug } from '../plugin/logger'; +import { isoDateWithOffset, isoDatesDifference } from '../diary/timelineHelper'; +import { MetricName, groupingFields } from '../types/appConfigTypes'; +import { ImperialConfig, formatForDisplay } from '../config/useImperialConfig'; +import i18next from 'i18next'; export function getUniqueLabelsForDays(metricDataDays: DayOfMetricData[]) { const uniqueLabels: string[] = []; 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); + const trimmed = trimGroupingPrefix(k); + if (trimmed && !uniqueLabels.includes(trimmed)) { + uniqueLabels.push(trimmed); } }); }); return uniqueLabels; } +/** + * @description Trims the "grouping field" prefix from a metrics key. Grouping fields are defined in appConfigTypes.ts + * @example removeGroupingPrefix('mode_purpose_access_recreation') => 'access_recreation' + * @example removeGroupingPrefix('primary_ble_sensed_mode_CAR') => 'CAR' + * @returns The key without the prefix (or undefined if the key didn't start with a grouping field) + */ +export const trimGroupingPrefix = (label: string) => { + for (let field of groupingFields) { + if (label.startsWith(field)) { + return label.substring(field.length + 1); + } + } +}; + 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 - } + const trimmed = trimGroupingPrefix(k); + if (trimmed) acc.push(trimmed); return acc; }, [] as string[]); -export const secondsToMinutes = (seconds: number) => formatForDisplay(seconds / 60); - -export const secondsToHours = (seconds: number) => formatForDisplay(seconds / 3600); +export const secondsToMinutes = (seconds: number) => seconds / 60; +export const secondsToHours = (seconds: number) => seconds / 3600; // segments metricsDays into weeks, with the most recent week first -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)); +export function segmentDaysByWeeks(days: DayOfMetricData[], lastDate: string) { + const weeks: DayOfMetricData[][] = [[]]; + let cutoff = isoDateWithOffset(lastDate, -7 * weeks.length); + for (let i = days.length - 1; i >= 0; i--) { + // if date is older than cutoff, start a new week + if (isoDatesDifference(days[i].date, cutoff) > 0) { + weeks.push([]); + cutoff = isoDateWithOffset(lastDate, -7 * weeks.length); + } + weeks[weeks.length - 1].push(days[i]); } - if (nWeeks) return weeks.slice(0, nWeeks); - return weeks; + return weeks.map((week) => week.reverse()); } export function formatDate(day: DayOfMetricData) { - const dt = DateTime.fromISO(day.fmt_time, { zone: 'utc' }); + const dt = DateTime.fromISO(day.date, { zone: 'utc' }); 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 firstDayDt = DateTime.fromISO(days[0].date, { zone: 'utc' }); + const lastDayDt = DateTime.fromISO(days[days.length - 1].date, { zone: 'utc' }); const firstDay = firstDayDt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined }); const lastDay = lastDayDt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined }); return `${firstDay} - ${lastDay}`; @@ -55,7 +75,7 @@ export function formatDateRangeOfDays(days: DayOfMetricData[]) { /* formatting data form carbon footprint calculations */ //modes considered on foot for carbon calculation, expandable as needed -const ON_FOOT_MODES = ['WALKING', 'RUNNING', 'ON_FOOT'] as const; +export const ON_FOOT_MODES = ['WALKING', 'RUNNING', 'ON_FOOT'] as const; /* * metric2val is a function that takes a metric entry and a field and returns @@ -63,13 +83,13 @@ const ON_FOOT_MODES = ['WALKING', 'RUNNING', 'ON_FOOT'] as const; * for regular data (user-specific), this will return the field value * for avg data (aggregate), this will return the field value/nUsers */ -const metricToValue = (population: 'user' | 'aggregate', metric, field) => +export const metricToValue = (population: 'user' | 'aggregate', metric, field) => population == 'user' ? metric[field] : metric[field] / metric.nUsers; //testing agains global list of what is "on foot" //returns true | false -function isOnFoot(mode: string) { - for (let ped_mode in ON_FOOT_MODES) { +export function isOnFoot(mode: string) { + for (let ped_mode of ON_FOOT_MODES) { if (mode === ped_mode) { return true; } @@ -114,14 +134,13 @@ export function parseDataFromMetrics(metrics, population) { ]); } } - //this section handles user lables, assuming 'label_' prefix - if (field.startsWith('label_')) { - let actualMode = field.slice(6, field.length); //remove prefix - logDebug('Mapped field ' + field + ' to mode ' + actualMode); - if (!(actualMode in mode_bins)) { - mode_bins[actualMode] = []; + const trimmedField = trimGroupingPrefix(field); + if (trimmedField) { + logDebug('Mapped field ' + field + ' to mode ' + trimmedField); + if (!(trimmedField in mode_bins)) { + mode_bins[trimmedField] = []; } - mode_bins[actualMode].push([ + mode_bins[trimmedField].push([ metric.ts, Math.round(metricToValue(population, metric, field)), DateTime.fromISO(metric.fmt_time).toISO() as string, @@ -137,6 +156,16 @@ export function parseDataFromMetrics(metrics, population) { return Object.entries(mode_bins).map(([key, values]) => ({ key, values })); } +const _datesTsCache = {}; +export const tsForDayOfMetricData = (day: DayOfMetricData) => { + if (_datesTsCache[day.date] == undefined) + _datesTsCache[day.date] = DateTime.fromISO(day.date).toSeconds(); + return _datesTsCache[day.date]; +}; + +export const valueForFieldOnDay = (day: DayOfMetricData, field: string, key: string) => + day[`${field}_${key}`]; + export type MetricsSummary = { key: string; values: number }; export function generateSummaryFromData(modeMap, metric) { logDebug(`Invoked getSummaryDataRaw on ${JSON.stringify(modeMap)} with ${metric}`); @@ -186,7 +215,7 @@ export function isCustomLabels(modeMap) { return isAllCustom(metricSummaryChecksSensed, metricSummaryChecksCustom); } -function isAllCustom(isSensedKeys, isCustomKeys) { +export function isAllCustom(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); @@ -201,3 +230,35 @@ function isAllCustom(isSensedKeys, isCustomKeys) { // "Please report to your program admin"); return undefined; } + +// [unit suffix, unit conversion function, unit display function] +// e.g. ['hours', (seconds) => seconds/3600, (seconds) => seconds/3600 + ' hours'] +type UnitUtils = [string, (v) => number, (v) => string]; +export function getUnitUtilsForMetric( + metricName: MetricName, + imperialConfig: ImperialConfig, +): UnitUtils { + const fns: { [k in MetricName]: UnitUtils } = { + distance: [ + imperialConfig.distanceSuffix, + (x) => imperialConfig.convertDistance(x), + (x) => imperialConfig.getFormattedDistance(x) + ' ' + imperialConfig.distanceSuffix, + ], + duration: [ + i18next.t('metrics.hours'), + (v) => secondsToHours(v), + (v) => formatForDisplay(secondsToHours(v)) + ' ' + i18next.t('metrics.hours'), + ], + count: [i18next.t('metrics.trips'), (v) => v, (v) => v + ' ' + i18next.t('metrics.trips')], + response_count: [ + i18next.t('metrics.responses'), + (v) => v.responded || 0, + (v) => { + const responded = v.responded || 0; + const total = responded + (v.not_responded || 0); + return `${responded}/${total} ${i18next.t('metrics.responses')}`; + }, + ], + }; + return fns[metricName]; +} diff --git a/www/js/metrics/metricsTypes.ts b/www/js/metrics/metricsTypes.ts index cce1cd243..d6105c30a 100644 --- a/www/js/metrics/metricsTypes.ts +++ b/www/js/metrics/metricsTypes.ts @@ -1,15 +1,21 @@ -import { LocalDt } from '../types/serverData'; -import { METRIC_LIST } from './MetricsTab'; +import { GroupingField, MetricName } from '../types/appConfigTypes'; -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; +// distance, duration, and count use number values in meters, seconds, and count respectively +// response_count uses object values containing responded and not_responded counts +type MetricValue = T extends 'response_count' + ? { responded?: number; not_responded?: number } + : number; + +export type DayOfMetricData = { + date: string; // yyyy-mm-dd nUsers: number; - local_dt: LocalDt; +} & { + // each key is a value for a specific grouping field + // and the value is the respective metric value + // e.g. { mode_confirm_bikeshare: 123, survey_TripConfirmSurvey: { responded: 4, not_responded: 5 } + [k in `${GroupingField}_${string}`]: MetricValue; }; export type MetricsData = { - [key in MetricName]: DayOfMetricData[]; + [key in MetricName]: DayOfMetricData[]; }; diff --git a/www/js/services/commHelper.ts b/www/js/services/commHelper.ts index b6c3913dc..247cdd399 100644 --- a/www/js/services/commHelper.ts +++ b/www/js/services/commHelper.ts @@ -13,21 +13,22 @@ const localFiles = { /** * @param url URL endpoint for the request + * @param fetchOpts (optional) options for the fetch request. If 'cache' is set to 'reload', the cache will be ignored * @returns Promise of the fetched response (as text) or cached text from local storage */ -export async function fetchUrlCached(url) { +export async function fetchUrlCached(url: string, fetchOpts?: RequestInit) { if (localFiles[url]) { logDebug(`fetchUrlCached: ${url} is a local file, returning`); return JSON.stringify(localFiles[url]); } const stored = window['localStorage']?.getItem(url); - if (stored) { + if (stored && fetchOpts?.cache != 'reload') { logDebug(`fetchUrlCached: found cached data for url ${url}, returning`); return Promise.resolve(stored); } try { - logDebug(`fetchUrlCached: found no cached data for url ${url}, fetching`); - const response = await fetch(url); + logDebug(`fetchUrlCached: cache had ${stored} for url ${url}, not using; fetching`); + const response = await fetch(url, fetchOpts); const text = await response.text(); window['localStorage']?.setItem(url, text); logDebug(`fetchUrlCached: fetched data for url ${url}, returning`); @@ -147,8 +148,13 @@ export function getMetrics(timeType: 'timestamp' | 'local_date', metricsQuery) { }); } -export function getAggregateData(path: string, query, serverConnConfig: ServerConnConfig) { +export function getAggregateData(path: string, query, serverConnConfig?: ServerConnConfig) { return new Promise((rs, rj) => { + // when app config does not have "server", localhost is used and no user authentication is required + serverConnConfig ||= { + connectUrl: 'http://localhost:8080' as any, + aggregate_call_auth: 'no_auth', + }; const fullUrl = `${serverConnConfig.connectUrl}/${path}`; query['aggregate'] = true; @@ -239,3 +245,77 @@ export function putOne(key, data) { throw error; }); } + +export function getUserCustomLabels(keys) { + return new Promise((rs, rj) => { + window['cordova'].plugins.BEMServerComm.postUserPersonalData( + '/customlabel/get', + 'keys', + keys, + rs, + rj, + ); + }).catch((error) => { + error = 'While getting labels, ' + error; + throw error; + }); +} + +export function insertUserCustomLabel(key, newLabel) { + const insertedLabel = { + key: key, + label: newLabel, + }; + return new Promise((rs, rj) => { + window['cordova'].plugins.BEMServerComm.postUserPersonalData( + '/customlabel/insert', + 'inserted_label', + insertedLabel, + rs, + rj, + ); + }).catch((error) => { + error = `While inserting one ${key}, ${error}`; + throw error; + }); +} + +export function updateUserCustomLabel(key, oldLabel, newLabel, isNewLabelMustAdded) { + const updatedLabel = { + key: key, + old_label: oldLabel, + new_label: newLabel, + is_new_label_must_added: isNewLabelMustAdded, + }; + return new Promise((rs, rj) => { + window['cordova'].plugins.BEMServerComm.postUserPersonalData( + '/customlabel/update', + 'updated_label', + updatedLabel, + rs, + rj, + ); + }).catch((error) => { + error = `While updating one ${key}, ${error}`; + throw error; + }); +} + +export function deleteUserCustomLabel(key, newLabel) { + const deletedLabel = { + key: key, + label: newLabel, + }; + return new Promise((rs, rj) => { + window['cordova'].plugins.BEMServerComm.postUserPersonalData( + '/customlabel/delete', + 'deleted_label', + deletedLabel, + rs, + rj, + ); + }).catch((error) => { + error = `While deleting one ${key}, ${error}`; + throw error; + }); +} diff --git a/www/js/services/controlHelper.ts b/www/js/services/controlHelper.ts index 1a9016557..da2e60ed7 100644 --- a/www/js/services/controlHelper.ts +++ b/www/js/services/controlHelper.ts @@ -54,9 +54,9 @@ export function getMyDataHelpers(fileName: string, startTimeString: string, endT const shareObj = { files: [attachFile], message: i18next.t( - 'email-service.email-data.body-data-consists-of-list-of-entries', + 'shareFile-service.send-data.body-data-consists-of-list-of-entries', ), - subject: i18next.t('email-service.email-data.subject-data-dump-from-to', { + subject: i18next.t('shareFile-service.send-data.subject-data-dump-from-to', { start: startTimeString, end: endTimeString, }), diff --git a/www/js/services/shareLocalDBFile.ts b/www/js/services/shareLocalDBFile.ts new file mode 100644 index 000000000..484604371 --- /dev/null +++ b/www/js/services/shareLocalDBFile.ts @@ -0,0 +1,121 @@ +import i18next from 'i18next'; +import { displayError, displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; + +function localDBHelpers(fileName: string, fileExtension: string = '.txt') { + async function localCopyFile() { + return new Promise((resolve, reject) => { + let pathToFile, parentDirectory; + if (window['cordova'].platformId == 'android') { + // parentDirectory: file:///data/user/0/edu.berkeley.eecs.emission/files/ + parentDirectory = window['cordova'].file.dataDirectory.replace('files', 'databases'); + // pathToFile: /data/user/0/edu.berkeley.eecs.emission/files/ + pathToFile = parentDirectory.replace('file://', '') + fileName; + } else if (window['cordova'].platformId == 'ios') { + // parentDirectory: file:///var/mobile/Containers/Data/Application/<32-hex-digit-id>/Library/NoCloud/../ + parentDirectory = window['cordova'].file.dataDirectory + '../'; + pathToFile = 'LocalDatabase/' + fileName; + } else { + displayErrorMsg('Error: Unknown OS!'); + throw new Error('Error: Unknown OS!'); + } + + window['resolveLocalFileSystemURL'](parentDirectory, (fs) => { + // On iOS, pass in relative path to getFile https://github.com/e-mission/e-mission-phone/pull/1160#issuecomment-2192112472 + // On Android, pass in absolute path to getFile https://github.com/e-mission/e-mission-phone/pull/1160#issuecomment-2204297874 + fs.filesystem.root.getFile(pathToFile, { create: false, exclusive: false }, (fileEntry) => { + // logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`); + logDebug(`fileEntry is: ${JSON.stringify(fileEntry, null, 2)}`); + window['resolveLocalFileSystemURL'](window['cordova'].file.cacheDirectory, (copyDir) => { + logDebug(`DirectoryEntry is: ${JSON.stringify(copyDir.filesystem.root, null, 2)}`); + + fileEntry.copyTo( + copyDir.filesystem.root, + fileName + fileExtension, + (res) => { + logDebug(`Res: ${res}`); + resolve(); + }, + (rej) => { + displayErrorMsg(`Rej: ${JSON.stringify(rej, null, 2)}`); + reject(); + }, + ); + }); + }); + }); + }); + } + + function localShareFile() { + return new Promise((resolve, reject) => { + window['resolveLocalFileSystemURL'](window['cordova'].file.cacheDirectory, (fs) => { + fs.filesystem.root.getFile( + fileName + fileExtension, + null, + (fileEntry) => { + const shareObj = { + files: [fileEntry.nativeURL], + message: i18next.t('shareFile-service.send-log.body-please-fill-in-what-is-wrong'), + subject: i18next.t('shareFile-service.send-log.subject-logs'), + }; + window['plugins'].socialsharing.shareWithOptions( + shareObj, + (result) => { + logDebug(`Share Completed? ${result.completed}`); // On Android, most likely returns false + logDebug(`Shared to app: ${result.app}`); + resolve(); + }, + (error) => { + displayError(error, `Sharing failed with error`); + }, + ); + }, + (error) => { + displayError(error, 'Error while sharing logs'); + reject(error); + }, + ); + }); + }); + } + + function localClearTmpFile() { + return new Promise((resolve, reject) => { + window['resolveLocalFileSystemURL'](window['cordova'].file.cacheDirectory, (fs) => { + fs.filesystem.root.getFile(fileName + fileExtension, null, (fileEntry) => { + fileEntry.remove( + () => { + logDebug(`Successfully cleaned up file ${fileName}`); + resolve(); + }, + (err) => { + displayError(err, `Error deleting ${fileName}`); + reject(err); + }, + ); + }); + }); + }); + } + + return { + copyFile: localCopyFile, + shareData: localShareFile, + clearTmpFile: localClearTmpFile, + }; +} +export async function sendLocalDBFile(database: string) { + alert(i18next.t('shareFile-service.send-to')); + + const dataMethods = localDBHelpers(database); + dataMethods + .copyFile() + .then(dataMethods.shareData) + .then(dataMethods.clearTmpFile) + .then(() => { + logDebug(`File Shared!`); + }) + .catch((err) => { + displayError(err); + }); +} diff --git a/www/js/splash/pushNotifySettings.ts b/www/js/splash/pushNotifySettings.ts index d392412bd..eb9c1e945 100644 --- a/www/js/splash/pushNotifySettings.ts +++ b/www/js/splash/pushNotifySettings.ts @@ -27,7 +27,7 @@ let push; * assigns on 'notification' functionality */ function startupInit() { - push = window['PushNotification'].init({ + push = window['PushNotification']?.init({ ios: { badge: true, sound: true, @@ -40,7 +40,7 @@ function startupInit() { clearNotifications: true, }, }); - push.on('notification', (data) => { + push?.on('notification', (data) => { if (window['cordova'].platformId == 'ios') { // Parse the iOS values that are returned as strings if (data && data.additionalData) { @@ -69,14 +69,14 @@ function startupInit() { function registerPromise() { return new Promise<{ token: string; type: string }>((resolve, reject) => { startupInit(); - push.on('registration', (data) => { + push?.on('registration', (data) => { logDebug('Got registration ' + data); resolve({ token: data.registrationId, type: data.registrationType, }); }); - push.on('error', (error) => { + push?.on('error', (error) => { logWarn('Got push error ' + error); reject(error); }); diff --git a/www/js/survey/enketo/AddNoteButton.tsx b/www/js/survey/enketo/AddNoteButton.tsx index 8f2b11726..79a8cf982 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 { DateTime } from 'luxon'; -import LabelTabContext from '../../diary/LabelTabContext'; +import TimelineContext from '../../TimelineContext'; import EnketoModal from './EnketoModal'; import { displayErrorMsg, logDebug } from '../../plugin/logger'; import { isTrip } from '../../types/diaryTypes'; @@ -24,7 +24,7 @@ type Props = { const AddNoteButton = ({ timelineEntry, notesConfig, storeKey }: Props) => { const { t, i18n } = useTranslation(); const [displayLabel, setDisplayLabel] = useState(''); - const { notesFor, addUserInputToEntry } = useContext(LabelTabContext); + const { notesFor, addUserInputToEntry } = useContext(TimelineContext); useEffect(() => { let newLabel: string; diff --git a/www/js/survey/enketo/AddedNotesList.tsx b/www/js/survey/enketo/AddedNotesList.tsx index 91cea8536..155dabace 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 { DateTime } from 'luxon'; import { Modal } from 'react-native'; import { Text, Button, DataTable, Dialog, Icon } from 'react-native-paper'; -import LabelTabContext from '../../diary/LabelTabContext'; +import TimelineContext from '../../TimelineContext'; import { getFormattedDateAbbr, isMultiDay } from '../../diary/diaryHelper'; import EnketoModal from './EnketoModal'; import { useTranslation } from 'react-i18next'; @@ -19,7 +19,7 @@ type Props = { }; const AddedNotesList = ({ timelineEntry, additionEntries }: Props) => { const { t } = useTranslation(); - const { addUserInputToEntry } = useContext(LabelTabContext); + const { addUserInputToEntry } = useContext(TimelineContext); const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] = useState(false); const [surveyModalVisible, setSurveyModalVisible] = useState(false); const [editingEntry, setEditingEntry] = useState(undefined); diff --git a/www/js/survey/enketo/EnketoModal.tsx b/www/js/survey/enketo/EnketoModal.tsx index 41e28725f..d1fe579b3 100644 --- a/www/js/survey/enketo/EnketoModal.tsx +++ b/www/js/survey/enketo/EnketoModal.tsx @@ -112,17 +112,20 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest }: Props) => { validateAndSave()} style={{ width: 200, margin: 'auto' }}> - + {/* */} - - + + {/* */}
{t('survey.powered-by')}{' '} diff --git a/www/js/survey/enketo/UserInputButton.tsx b/www/js/survey/enketo/UserInputButton.tsx index 249d7f366..c4d28eee8 100644 --- a/www/js/survey/enketo/UserInputButton.tsx +++ b/www/js/survey/enketo/UserInputButton.tsx @@ -14,28 +14,46 @@ 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 TimelineContext from '../../TimelineContext'; +import useAppConfig from '../../useAppConfig'; +import { getSurveyForTimelineEntry } from './conditionalSurveys'; +import useDerivedProperties from '../../diary/useDerivedProperties'; +import { resolveSurveyButtonConfig } from './enketoHelper'; +import { SurveyButtonConfig } from '../../types/appConfigTypes'; type Props = { timelineEntry: any; }; const UserInputButton = ({ timelineEntry }: Props) => { const { colors } = useTheme(); + const appConfig = useAppConfig(); const { t, i18n } = useTranslation(); const [prevSurveyResponse, setPrevSurveyResponse] = useState(undefined); const [modalVisible, setModalVisible] = useState(false); - const { userInputFor, addUserInputToEntry } = useContext(LabelTabContext); + const { userInputFor, addUserInputToEntry } = useContext(TimelineContext); + const derivedTripProps = useDerivedProperties(timelineEntry); - // the label resolved from the survey response, or null if there is no response yet - const responseLabel = useMemo( - () => userInputFor(timelineEntry)?.['SURVEY']?.data.label || undefined, - [userInputFor(timelineEntry)?.['SURVEY']?.data.label], - ); + // which survey will this button launch? + const survey = useMemo(() => { + if (!appConfig) return null; // no config loaded yet; show blank for now + const possibleSurveysForButton = resolveSurveyButtonConfig(appConfig, 'trip-label'); + // if there is only one survey, no need to check further + if (possibleSurveysForButton.length == 1) return possibleSurveysForButton[0]; + // config lists one or more surveys; find which one to use for this timeline entry + return getSurveyForTimelineEntry(possibleSurveysForButton, timelineEntry, derivedTripProps); + }, [appConfig, timelineEntry, i18n.resolvedLanguage]); + + // the label resolved from the survey response, or undefined if there is no response yet + const responseLabel = useMemo(() => { + if (!survey) return undefined; + return userInputFor(timelineEntry)?.[survey.surveyName]?.data.label || undefined; + }, [survey, userInputFor(timelineEntry)?.[survey?.surveyName || '']?.data.label]); function launchUserInputSurvey() { + if (!survey) return displayErrorMsg('UserInputButton: no survey to launch'); logDebug('UserInputButton: About to launch survey'); - const prevResponse = userInputFor(timelineEntry)?.['SURVEY']; + const prevResponse = userInputFor(timelineEntry)?.[survey.surveyName]; if (prevResponse?.data?.xmlResponse) { setPrevSurveyResponse(prevResponse.data.xmlResponse); } @@ -46,29 +64,27 @@ const UserInputButton = ({ timelineEntry }: Props) => { if (result) { logDebug(`UserInputButton: response was saved, about to addUserInputToEntry; result = ${JSON.stringify(result)}`); - addUserInputToEntry(timelineEntry._id.$oid, { SURVEY: result }, 'label'); + addUserInputToEntry(timelineEntry._id.$oid, { [result.name]: result }, 'label'); } else { displayErrorMsg('UserInputButton: response was not saved, result=', result); } } + if (!survey) return <>; // no survey to launch return ( <> launchUserInputSurvey()}> - {/* if no response yet, show the default label */} - {responseLabel || t('diary.choose-survey')} + {responseLabel || survey['not-filled-in-label'][i18n.resolvedLanguage || 'en']} 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. */ + surveyName={survey.surveyName} opts={{ timelineEntry, prefilledSurveyResponse: prevSurveyResponse }} /> diff --git a/www/js/survey/enketo/conditionalSurveys.ts b/www/js/survey/enketo/conditionalSurveys.ts new file mode 100644 index 000000000..a96ee2de8 --- /dev/null +++ b/www/js/survey/enketo/conditionalSurveys.ts @@ -0,0 +1,52 @@ +import { displayError } from '../../plugin/logger'; +import { SurveyButtonConfig } from '../../types/appConfigTypes'; +import { DerivedProperties, TimelineEntry } from '../../types/diaryTypes'; +import { Position } from 'geojson'; + +const conditionalSurveyFunctions = { + /** + @description Returns true if the given point is within the given bounds. + Coordinates are in [longitude, latitude] order, since that is the GeoJSON spec. + @param pt point to check as [lon, lat] + @param bounds NW and SE corners as [[lon, lat], [lon, lat]] + @returns true if pt is within bounds + */ + pointIsWithinBounds: (pt: Position, bounds: Position[]) => { + // pt's lon must be east of, or greater than, NW's lon; and west of, or less than, SE's lon + const lonInRange = pt[0] > bounds[0][0] && pt[0] < bounds[1][0]; + // pt's lat must be south of, or less than, NW's lat; and north of, or greater than, SE's lat + const latInRange = pt[1] < bounds[0][1] && pt[1] > bounds[1][1]; + return latInRange && lonInRange; + }, +}; + +/** + * @description Executes a JS expression `script` in a restricted `scope` + * @example scopedEval('console.log(foo)', { foo: 'bar' }) + */ +const scopedEval = (script: string, scope: { [k: string]: any }) => + Function(...Object.keys(scope), `return ${script}`)(...Object.values(scope)); + +// the first survey in the list that passes its condition will be returned +export function getSurveyForTimelineEntry( + possibleSurveys: SurveyButtonConfig[], + tlEntry: TimelineEntry, + derivedProperties: DerivedProperties, +) { + for (let survey of possibleSurveys) { + if (!survey.showsIf) return survey; // survey shows unconditionally + const scope = { + ...tlEntry, + ...derivedProperties, + ...conditionalSurveyFunctions, + }; + try { + const evalResult = scopedEval(survey.showsIf, scope); + if (evalResult) return survey; + } catch (e) { + displayError(e, `Error evaluating survey condition "${survey.showsIf}"`); + } + } + // TODO if none of the surveys passed conditions?? should we return null, throw error, or return a default? + return null; +} diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index 2113c5deb..6ebe5f630 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -8,7 +8,7 @@ import { getConfig } from '../../config/dynamicConfig'; import { DateTime } from 'luxon'; import { fetchUrlCached } from '../../services/commHelper'; import { getUnifiedDataForInterval } from '../../services/unifiedDataLoader'; -import { AppConfig, EnketoSurveyConfig } from '../../types/appConfigTypes'; +import { AppConfig, EnketoSurveyConfig, SurveyButtonConfig } from '../../types/appConfigTypes'; import { CompositeTrip, ConfirmedPlace, @@ -315,6 +315,32 @@ export function loadPreviousResponseForSurvey(dataKey: string) { ); } +/** + * @description Returns an array of surveys that could be prompted for one button in the UI (trip label, trip notes, place label, or place notes) + * (If multiple are returned, they will show conditionally in the UI based on their `showsIf` field) + * Includes backwards compats for app config fields that didn't use to exist + */ +export function resolveSurveyButtonConfig( + config: AppConfig, + button: 'trip-label' | 'trip-notes' | 'place-label' | 'place-notes', +): SurveyButtonConfig[] { + const buttonConfig = config.survey_info.buttons?.[button]; + // backwards compat: default to the trip confirm survey if this button isn't configured + if (!buttonConfig) { + return [ + { + surveyName: 'TripConfirmSurvey', + 'not-filled-in-label': { + en: 'Add Trip Details', + es: 'Agregar detalles del viaje', + lo: 'ເພີ່ມລາຍລະອຽດການເດີນທາງ', + }, + }, + ]; + } + return buttonConfig instanceof Array ? buttonConfig : [buttonConfig]; +} + export async function fetchSurvey(url: string) { logDebug('fetchSurvey: url = ' + url); const responseText = await fetchUrlCached(url); diff --git a/www/js/survey/enketo/infinite_scroll_filters.ts b/www/js/survey/enketo/infinite_scroll_filters.ts index d4b281713..512b272c4 100644 --- a/www/js/survey/enketo/infinite_scroll_filters.ts +++ b/www/js/survey/enketo/infinite_scroll_filters.ts @@ -8,7 +8,8 @@ import i18next from 'i18next'; -const unlabeledCheck = (trip, userInputForTrip) => !userInputForTrip?.['SURVEY']; +const unlabeledCheck = (trip, userInputForTrip) => + !userInputForTrip || !Object.values(userInputForTrip).some((input) => input); const TO_LABEL = { key: 'to_label', diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index da802d2e8..604c533b2 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -1,16 +1,28 @@ import { logDebug, displayErrorMsg } from '../plugin/logger'; import { DateTime } from 'luxon'; -import { CompositeTrip, ConfirmedPlace, TimelineEntry, UserInputEntry } from '../types/diaryTypes'; -import { keysForLabelInputs, unprocessedLabels, unprocessedNotes } from '../diary/timelineHelper'; +import { + BluetoothBleData, + CompositeTrip, + ConfirmedPlace, + TimelineEntry, + UserInputEntry, +} from '../types/diaryTypes'; +import { + keysForLabelInputs, + unprocessedBleScans, + unprocessedLabels, + unprocessedNotes, +} from '../diary/timelineHelper'; import { getLabelInputDetails, inputType2retKey, removeManualPrefix, } from './multilabel/confirmHelper'; -import { TimelineLabelMap, TimelineNotesMap } from '../diary/LabelTabContext'; +import { TimelineLabelMap, TimelineNotesMap, UserInputMap } from '../TimelineContext'; import { MultilabelKey } from '../types/labelTypes'; import { EnketoUserInputEntry } from './enketo/enketoHelper'; import { AppConfig } from '../types/appConfigTypes'; +import { BEMData } from '../types/serverData'; const EPOCH_MAXIMUM = 2 ** 31 - 1; @@ -204,9 +216,8 @@ export function getAdditionsForTimelineEntry( 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) => + // filter out additions that do not start within the bounds of the timeline entry + const matchingAdditions = additionsList.filter((ui) => validUserInputForTimelineEntry(entry, nextEntry, ui, logsEnabled), ); @@ -268,16 +279,16 @@ export function mapInputsToTimelineEntries( 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 + // ENKETO configuration: consider reponses from all surveys in unprocessedLabels const userInputForTrip = getUserInputForTimelineEntry( tlEntry, nextEntry, - unprocessedLabels['SURVEY'], + Object.values(unprocessedLabels).flat(1), ) as EnketoUserInputEntry; if (userInputForTrip) { - timelineLabelMap[tlEntry._id.$oid] = { SURVEY: userInputForTrip }; + timelineLabelMap[tlEntry._id.$oid] = { [userInputForTrip.data.name]: userInputForTrip }; } else { - let processedSurveyResponse; + let processedSurveyResponse: EnketoUserInputEntry | undefined; for (const dataKey of keysForLabelInputs(appConfig)) { const key = removeManualPrefix(dataKey); if (tlEntry.user_input?.[key]) { @@ -285,12 +296,16 @@ export function mapInputsToTimelineEntries( break; } } - timelineLabelMap[tlEntry._id.$oid] = { SURVEY: processedSurveyResponse }; + if (processedSurveyResponse) { + timelineLabelMap[tlEntry._id.$oid] = { + [processedSurveyResponse.data.name]: processedSurveyResponse, + }; + } } } else { // MULTILABEL configuration: use the label inputs from the labelOptions to determine which // keys to look for in the unprocessedInputs - const labelsForTrip: { [k: string]: UserInputEntry | undefined } = {}; + const labelsForTrip: UserInputMap = {}; Object.keys(getLabelInputDetails(appConfig)).forEach((label: MultilabelKey) => { // Check unprocessed labels first since they are more recent const userInputForTrip = getUserInputForTimelineEntry( @@ -340,3 +355,85 @@ export function mapInputsToTimelineEntries( return [timelineLabelMap, timelineNotesMap]; } + +function validBleScanForTimelineEntry(tlEntry: TimelineEntry, bleScan: BEMData) { + let entryStart = (tlEntry as CompositeTrip).start_ts || (tlEntry as ConfirmedPlace).enter_ts; + let entryEnd = (tlEntry as CompositeTrip).end_ts || (tlEntry as ConfirmedPlace).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; + } + + return bleScan.data.ts >= entryStart && bleScan.data.ts <= entryEnd; +} + +/** + * @description Get BLE scans that are of type RANGE_UPDATE and are within the time range of the timeline entry + */ +function getBleRangingScansForTimelineEntry( + tlEntry: TimelineEntry, + bleScans: BEMData[], +) { + return bleScans.filter( + (scan) => + /* RANGE_UPDATE is the string value, but the server uses an enum, so once processed it becomes 2 */ + (scan.data.eventType == 'RANGE_UPDATE' || scan.data.eventType == 2) && + validBleScanForTimelineEntry(tlEntry, scan), + ); +} + +/** + * @description Convert a decimal number to a hexadecimal string, with optional padding + * @example decimalToHex(245) => 'f5' + * @example decimalToHex(245, 4) => '00f5' + */ +function decimalToHex(d: string | number, padding?: number) { + let hex = Number(d).toString(16); + while (hex.length < (padding || 0)) { + hex = '0' + hex; + } + return hex; +} + +export function mapBleScansToTimelineEntries(allEntries: TimelineEntry[], appConfig: AppConfig) { + const timelineBleMap = {}; + for (const tlEntry of allEntries) { + const rangingScans = getBleRangingScansForTimelineEntry(tlEntry, unprocessedBleScans); + if (!rangingScans.length) { + continue; + } + + // count the number of occurrences of each major:minor pair + const majorMinorCounts = {}; + rangingScans.forEach((scan) => { + const major = decimalToHex(scan.data.major, 4); + const minor = decimalToHex(scan.data.minor, 4); + const majorMinor = major + ':' + minor; + majorMinorCounts[majorMinor] = majorMinorCounts[majorMinor] + ? majorMinorCounts[majorMinor] + 1 + : 1; + }); + // determine the major:minor pair with the highest count + const match = Object.keys(majorMinorCounts).reduce((a, b) => + majorMinorCounts[a] > majorMinorCounts[b] ? a : b, + ); + // find the vehicle identity that uses this major:minor pair + const vehicleIdentity = appConfig.vehicle_identities?.find((vi) => + vi.bluetooth_major_minor.includes(match), + ); + if (vehicleIdentity) { + timelineBleMap[tlEntry._id.$oid] = vehicleIdentity; + } else { + displayErrorMsg(`No vehicle identity found for major:minor pair ${match}`); + } + } + return timelineBleMap; +} diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index 517223141..466bb9868 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -12,10 +12,11 @@ import { RadioButton, Button, TextInput, + Divider, } from 'react-native-paper'; import DiaryButton from '../../components/DiaryButton'; import { useTranslation } from 'react-i18next'; -import LabelTabContext, { UserInputMap } from '../../diary/LabelTabContext'; +import TimelineContext, { UserInputMap } from '../../TimelineContext'; import { displayErrorMsg, logDebug } from '../../plugin/logger'; import { getLabelInputDetails, @@ -29,22 +30,23 @@ import { } from './confirmHelper'; import useAppConfig from '../../useAppConfig'; import { MultilabelKey } from '../../types/labelTypes'; +import { updateUserCustomLabel } from '../../services/commHelper'; +import { AppContext } from '../../App'; const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { const { colors } = useTheme(); const { t } = useTranslation(); const appConfig = useAppConfig(); - const { labelOptions, labelFor, userInputFor, addUserInputToEntry } = useContext(LabelTabContext); + const { labelOptions, labelFor, userInputFor, addUserInputToEntry } = useContext(TimelineContext); + const { customLabelMap, setCustomLabelMap } = useContext(AppContext); const { height: windowHeight } = useWindowDimensions(); - // modal visible for which input type? (MODE or PURPOSE or REPLACED_MODE, null if not visible) const [modalVisibleFor, setModalVisibleFor] = useState(null); const [otherLabel, setOtherLabel] = useState(null); - const chosenLabel = useMemo(() => { + const initialLabel = useMemo(() => { if (modalVisibleFor == null) return null; - if (otherLabel != null) return 'other'; return labelFor(trip, modalVisibleFor)?.value || null; - }, [modalVisibleFor, otherLabel]); + }, [modalVisibleFor]); // to mark 'inferred' labels as 'confirmed'; turn yellow labels blue function verifyTrip() { @@ -81,16 +83,36 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { if (!Object.keys(inputs).length) return displayErrorMsg('No inputs to store'); const inputsToStore: UserInputMap = {}; const storePromises: any[] = []; - for (let [inputType, chosenLabel] of Object.entries(inputs)) { + + for (let [inputType, newLabel] of Object.entries(inputs)) { 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); + newLabel = readableLabelToKey(newLabel); + } + // If a user saves a new customized label or makes changes to/from customized labels, the labels need to be updated. + const key = inputType.toLowerCase(); + if ( + isOther || + (initialLabel && customLabelMap[key].indexOf(initialLabel) > -1) || + (newLabel && customLabelMap[key].indexOf(newLabel) > -1) + ) { + updateUserCustomLabel(key, initialLabel ?? '', newLabel, isOther ?? false) + .then((res) => { + setCustomLabelMap({ + ...customLabelMap, + [key]: res['label'], + }); + logDebug('Successfuly stored custom label ' + JSON.stringify(res)); + }) + .catch((e) => { + displayErrorMsg(e, 'Create Label Error'); + }); } const inputDataToStore = { start_ts: trip.start_ts, end_ts: trip.end_ts, - label: chosenLabel, + label: newLabel, }; inputsToStore[inputType] = inputDataToStore; @@ -107,6 +129,8 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { } const tripInputDetails = labelInputDetailsForTrip(userInputFor(trip), appConfig); + const customLabelKeyInDatabase = modalVisibleFor === 'PURPOSE' ? 'purpose' : 'mode'; + return ( <> @@ -164,16 +188,47 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { onChooseLabel(val)} - value={chosenLabel || ''}> + // if 'other' button is selected and input component shows up, make 'other' radio button filled + value={otherLabel !== null ? 'other' : initialLabel || ''}> {modalVisibleFor && - labelOptions?.[modalVisibleFor]?.map((o, i) => ( - - ))} + labelOptions?.[modalVisibleFor]?.map((o, i) => { + const radioItemForOption = ( + + ); + /* if this is the 'other' option and there are some custom labels, + show the custom labels section before 'other' */ + if (o.value == 'other' && customLabelMap[customLabelKeyInDatabase]?.length) { + return ( + <> + + + {(modalVisibleFor === 'MODE' || + modalVisibleFor === 'REPLACED_MODE') && + t('trip-confirm.custom-mode')} + {modalVisibleFor === 'PURPOSE' && t('trip-confirm.custom-purpose')} + + {customLabelMap[customLabelKeyInDatabase].map((key, i) => ( + + ))} + + {radioItemForOption} + + ); + } + // otherwise, just show the radio item as normal + return radioItemForOption; + })} @@ -185,6 +240,7 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { })} value={otherLabel || ''} onChangeText={(t) => setOtherLabel(t)} + maxLength={25} />