diff --git a/.github/workflows/serve-install.yml b/.github/workflows/serve-install.yml index c78ce1f86..f7902b2ea 100644 --- a/.github/workflows/serve-install.yml +++ b/.github/workflows/serve-install.yml @@ -64,6 +64,13 @@ jobs: run: | npx jest + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./coverage/coverage-final.json + flags: unit + fail_ci_if_error: ${{ github.repository == 'e-mission/e-mission-phone' }} + # TODO: figure out how to check that a server started correctly # - name: Try starting it # run: npx run serve diff --git a/.gitignore b/.gitignore index 6801f890d..bdf59df4f 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ app-settings.json www/dist/ config.xml package.json +coverage/ diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 000000000..125b560ef --- /dev/null +++ b/codecov.yml @@ -0,0 +1,8 @@ +comment: + layout: " diff, flags, files" + behavior: default + require_changes: false + require_base: false + require_head: true + hide_project_coverage: false + \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index 21184bdd7..e00409ff1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -12,8 +12,10 @@ module.exports = { "^.+\\.(ts|tsx|js|jsx)$": "babel-jest" }, transformIgnorePatterns: [ - "node_modules/(?!((jest-)?react-native(-.*)?|@react-native(-community)?)/)" + "node_modules/(?!((enketo-transformer/dist/enketo-transformer/web)|(jest-)?react-native(-.*)?|@react-native(-community)?)/)", ], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], moduleDirectories: ["node_modules", "src"], + globals: {"__DEV__": false}, + collectCoverage: true, }; diff --git a/package.cordovabuild.json b/package.cordovabuild.json index ad55e2ccf..7175c347f 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -102,6 +102,7 @@ }, "dependencies": { "@havesource/cordova-plugin-push": "git+https://github.com/havesource/cordova-plugin-push.git#4.0.0-dev.0", + "@messageformat/core": "^3.2.0", "@react-navigation/native": "^6.1.7", "@react-navigation/stack": "^6.3.17", "@shopify/flash-list": "^1.3.1", @@ -142,6 +143,7 @@ "cordova-plugin-x-socialsharing": "6.0.4", "core-js": "^2.5.7", "enketo-core": "^6.1.7", + "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", "fs-extra": "^9.0.1", "humanize-duration": "3.31.0", @@ -153,7 +155,6 @@ "klaw-sync": "^6.0.0", "leaflet": "^1.9.4", "luxon": "^3.3.0", - "messageformat": "^2.3.0", "moment": "^2.29.4", "moment-timezone": "^0.5.43", "ng-i18next": "^1.0.7", diff --git a/package.serve.json b/package.serve.json index c2a9706bb..d35ab71de 100644 --- a/package.serve.json +++ b/package.serve.json @@ -53,6 +53,7 @@ "prettier": "3.0.3" }, "dependencies": { + "@messageformat/core": "^3.2.0", "@react-navigation/native": "^6.1.7", "@react-navigation/stack": "^6.3.17", "@shopify/flash-list": "^1.3.1", @@ -73,6 +74,7 @@ "chartjs-plugin-annotation": "^3.0.1", "core-js": "^2.5.7", "enketo-core": "^6.1.7", + "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", "fs-extra": "^9.0.1", "humanize-duration": "3.31.0", @@ -84,7 +86,6 @@ "klaw-sync": "^6.0.0", "leaflet": "^1.9.4", "luxon": "^3.3.0", - "messageformat": "^2.3.0", "moment": "^2.29.4", "moment-timezone": "^0.5.43", "ng-i18next": "^1.0.7", diff --git a/webpack.config.js b/webpack.config.js index 1e504ac5f..3e7e6d368 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -79,7 +79,12 @@ module.exports = { /* Enketo expects its per-app configuration to be available as 'enketo-config', so we have to alias it here. https://github.com/enketo/enketo-core#global-configuration */ - 'enketo/config': path.resolve(__dirname, 'www/js/config/enketo-config') + 'enketo/config': path.resolve(__dirname, 'www/js/config/enketo-config'), + /* enketo-transformer has 'libxslt' as an optional peer dependency. + We don't need it since we are only doing client-side transformations via + enketo-transformer/web (https://github.com/enketo/enketo-transformer#web). + So, we can tell webpack it's ok to ignore libxslt by aliasing it to false. */ + 'libxslt': false, }, extensions: ['.web.js', '.jsx', '.tsx', '.ts', '.js'], }, diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 2954c1351..ab334016c 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -1,4 +1,6 @@ import packageJsonBuild from '../../package.cordovabuild.json'; +import fakeConfig from './fakeConfig.json'; + export const mockCordova = () => { window['cordova'] ||= {}; window['cordova'].platformId ||= 'ios'; @@ -35,7 +37,7 @@ const _storage = {}; type MessageData = any; type Message = { key: string; data: MessageData; metadata: { write_ts: number; [k: string]: any } }; -export const mockBEMUserCache = () => { +export const mockBEMUserCache = (config?) => { const _cache = {}; const messages: Message[] = []; const mockBEMUserCache = { @@ -119,11 +121,20 @@ export const mockBEMUserCache = () => { // Used for getUnifiedDataForInterval }, getDocument: (key: string, withMetadata?: boolean) => { - return new Promise((rs, rj) => - setTimeout(() => { - rs(_storage[key]); - }, 100), - ); + //returns the config provided as a paramenter to this mock! + if (key == 'config/app_ui_config') { + return new Promise((rs, rj) => + setTimeout(() => { + rs(config || fakeConfig); + }, 100), + ); + } else { + return new Promise((rs, rj) => + setTimeout(() => { + rs(_storage[key]); + }, 100), + ); + } }, isEmptyDoc: (doc) => { if (doc == undefined) { @@ -136,6 +147,20 @@ export const mockBEMUserCache = () => { return false; } }, + getAllTimeQuery: () => { + return { key: 'write_ts', startTs: 0, endTs: Date.now() / 1000 }; + }, + getSensorDataForInterval: (key, tq, withMetadata) => { + if (key == `manual/demographic_survey`) { + return new Promise((rs, rj) => + setTimeout(() => { + rs({ metadata: { write_ts: '1699897723' }, data: 'completed', time: '01/01/2001' }); + }, 100), + ); + } else { + return undefined; + } + }, }; window['cordova'] ||= {}; window['cordova'].plugins ||= {}; diff --git a/www/__mocks__/fakeConfig.json b/www/__mocks__/fakeConfig.json new file mode 100644 index 000000000..dabec6cd9 --- /dev/null +++ b/www/__mocks__/fakeConfig.json @@ -0,0 +1,88 @@ +{ + "version": 1, + "ts": 1655143472, + "server": { + "connectUrl": "https://openpath-test.nrel.gov/api/", + "aggregate_call_auth": "user_only" + }, + "intro": { + "program_or_study": "study", + "start_month": "10", + "start_year": "2023", + "program_admin_contact": "K. Shankari", + "deployment_partner_name": "NREL", + "translated_text": { + "en": { + "deployment_partner_name": "NREL", + "deployment_name": "Testing environment for Jest testing", + "summary_line_1": "", + "summary_line_2": "", + "summary_line_3": "", + "short_textual_description": "", + "why_we_collect": "", + "research_questions": ["", ""] + }, + "es": { + "deployment_partner_name": "NREL", + "deployment_name": "Ambiente prueba para las pruebas de Jest", + "summary_line_1": "", + "summary_line_2": "", + "summary_line_3": "", + "short_textual_description": "", + "why_we_collect": "", + "research_questions": ["", ""] + } + } + }, + "survey_info": { + "surveys": { + "TimeUseSurvey": { + "compatibleWith": 1, + "formPath": "https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json", + "labelTemplate": { + "en": "{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }", + "es": "{ erea, plural, =0 {} other {# Empleo/Educación, } }{ da, plural, =0 {} other {# Actividades domesticas, }}" + }, + "labelVars": { + "da": { + "key": "Domestic_activities", + "type": "length" + }, + "erea": { + "key": "Employment_related_a_Education_activities", + "type": "length" + } + }, + "version": 9 + } + }, + "trip-labels": "ENKETO" + }, + "display_config": { + "use_imperial": false + }, + "profile_controls": { + "support_upload": true, + "trip_end_notification": false + }, + "admin_dashboard": { + "overview_users": true, + "overview_active_users": true, + "overview_trips": true, + "overview_signup_trends": true, + "overview_trips_trend": true, + "data_uuids": true, + "data_trips": true, + "data_trips_columns_exclude": [], + "additional_trip_columns": [], + "data_uuids_columns_exclude": [], + "token_generate": true, + "token_prefix": "nrelop", + "map_heatmap": true, + "map_bubble": true, + "map_trip_lines": true, + "push_send": true, + "options_uuids": true, + "options_emails": true + } +} diff --git a/www/__mocks__/fakeLabels.json b/www/__mocks__/fakeLabels.json new file mode 100644 index 000000000..676dc97b6 --- /dev/null +++ b/www/__mocks__/fakeLabels.json @@ -0,0 +1,208 @@ +{ + "MODE": [ + { + "value": "walk", + "baseMode": "WALKING", + "met_equivalent": "WALKING", + "kgCo2PerKm": 0 + }, + { + "value": "e-bike", + "baseMode": "E_BIKE", + "met": { + "ALL": { + "range": [0, -1], + "mets": 4.9 + } + }, + "kgCo2PerKm": 0.00728 + }, + { + "value": "bike", + "baseMode": "BICYCLING", + "met_equivalent": "BICYCLING", + "kgCo2PerKm": 0 + }, + { + "value": "bikeshare", + "baseMode": "BICYCLING", + "met_equivalent": "BICYCLING", + "kgCo2PerKm": 0 + }, + { + "value": "scootershare", + "baseMode": "E_SCOOTER", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.00894 + }, + { + "value": "drove_alone", + "baseMode": "CAR", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.22031 + }, + { + "value": "shared_ride", + "baseMode": "CAR", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.11015 + }, + { + "value": "hybrid_drove_alone", + "baseMode": "CAR", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.127 + }, + { + "value": "hybrid_shared_ride", + "baseMode": "CAR", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.0635 + }, + { + "value": "e_car_drove_alone", + "baseMode": "E_CAR", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.08216 + }, + { + "value": "e_car_shared_ride", + "baseMode": "E_CAR", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.04108 + }, + { + "value": "taxi", + "baseMode": "TAXI", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.30741 + }, + { + "value": "bus", + "baseMode": "BUS", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.20727 + }, + { + "value": "train", + "baseMode": "TRAIN", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.12256 + }, + { + "value": "free_shuttle", + "baseMode": "BUS", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.20727 + }, + { + "value": "air", + "baseMode": "AIR", + "met_equivalent": "IN_VEHICLE", + "kgCo2PerKm": 0.09975 + }, + { + "value": "not_a_trip", + "baseMode": "UNKNOWN", + "met_equivalent": "UNKNOWN", + "kgCo2PerKm": 0 + }, + { + "value": "other", + "baseMode": "OTHER", + "met_equivalent": "UNKNOWN", + "kgCo2PerKm": 0 + } + ], + "PURPOSE": [ + { + "value": "home" + }, + { + "value": "work" + }, + { + "value": "at_work" + }, + { + "value": "school" + }, + { + "value": "transit_transfer" + }, + { + "value": "shopping" + }, + { + "value": "meal" + }, + { + "value": "pick_drop_person" + }, + { + "value": "pick_drop_item" + }, + { + "value": "personal_med" + }, + { + "value": "access_recreation" + }, + { + "value": "exercise" + }, + { + "value": "entertainment" + }, + { + "value": "religious" + }, + { + "value": "other" + } + ], + "REPLACED_MODE": [ + { + "value": "no_travel" + }, + { + "value": "walk" + }, + { + "value": "bike" + }, + { + "value": "bikeshare" + }, + { + "value": "scootershare" + }, + { + "value": "drove_alone" + }, + { + "value": "shared_ride" + }, + { + "value": "e_car_drove_alone" + }, + { + "value": "e_car_shared_ride" + }, + { + "value": "taxi" + }, + { + "value": "bus" + }, + { + "value": "train" + }, + { + "value": "free_shuttle" + }, + { + "value": "other" + } + ] +} diff --git a/www/__mocks__/messageFormatMocks.ts b/www/__mocks__/messageFormatMocks.ts new file mode 100644 index 000000000..eadfc0c7c --- /dev/null +++ b/www/__mocks__/messageFormatMocks.ts @@ -0,0 +1,29 @@ +//call signature MessageFormat.compile(templage)(vars); +//in - template an vars -- {... pca: 0, ...} +//out - 1 Personal Care, + +export default class MessageFormat { + constructor(locale: string) {} + + compile(message: string) { + return (vars: {}) => { + let label = ''; + const brokenList = message.split('}{'); + console.log(brokenList); + + for (let key in vars) { + brokenList.forEach((item) => { + let brokenItem = item.split(','); + if (brokenItem[0] == key) { + let getLabel = brokenItem[2].split('#'); + console.log(getLabel); + label = vars[key] + ' ' + getLabel[1]; + return label; + } + }); + } + }; + } +} + +exports.MessageFormat = MessageFormat; diff --git a/www/__tests__/customMetricsHelper.test.ts b/www/__tests__/customMetricsHelper.test.ts new file mode 100644 index 000000000..0ae025bff --- /dev/null +++ b/www/__tests__/customMetricsHelper.test.ts @@ -0,0 +1,51 @@ +import { getConfig } from '../js/config/dynamicConfig'; +import { + getCustomFootprint, + getCustomMETs, + initCustomDatasetHelper, +} from '../js/metrics/customMetricsHelper'; +import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; +import fakeLabels from '../__mocks__/fakeLabels.json'; + +mockBEMUserCache(); +mockLogger(); + +global.fetch = (url: string) => + new Promise((rs, rj) => { + setTimeout(() => + rs({ + text: () => + new Promise((rs, rj) => { + let myJSON = JSON.stringify(fakeLabels); + setTimeout(() => rs(myJSON), 100); + }), + }), + ); + }) as any; + +it('gets the custom mets', async () => { + const appConfig = await getConfig(); + await initCustomDatasetHelper(appConfig); + expect(getCustomMETs()).toMatchObject({ + walk: expect.any(Object), + bike: expect.any(Object), + bikeshare: expect.any(Object), + 'e-bike': expect.any(Object), + scootershare: expect.any(Object), + drove_alone: expect.any(Object), + }); +}); + +it('gets the custom footprint', async () => { + const appConfig = await getConfig(); + await initCustomDatasetHelper(appConfig); + expect(getCustomFootprint()).toMatchObject({ + walk: expect.any(Number), + bike: expect.any(Number), + bikeshare: expect.any(Number), + 'e-bike': expect.any(Number), + scootershare: expect.any(Number), + drove_alone: expect.any(Number), + }); +}); diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts new file mode 100644 index 000000000..c4fda7dc4 --- /dev/null +++ b/www/__tests__/enketoHelper.test.ts @@ -0,0 +1,340 @@ +import { + getInstanceStr, + filterByNameAndVersion, + resolveTimestamps, + resolveLabel, + loadPreviousResponseForSurvey, + saveResponse, +} from '../js/survey/enketo/enketoHelper'; +import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; +import { getConfig, resetStoredConfig } from '../../www/js/config/dynamicConfig'; +import fakeConfig from '../__mocks__/fakeConfig.json'; + +import initializedI18next from '../js/i18nextInit'; +window['i18next'] = initializedI18next; + +mockBEMUserCache(fakeConfig); +mockLogger(); + +global.URL = require('url').URL; +global.Blob = require('node:buffer').Blob; + +beforeEach(() => { + resetStoredConfig(); +}); + +it('gets the survey config', async () => { + //this is aimed at testing my mock of the config + //mocked getDocument for the case of getting the config + let config = await getConfig(); + let mockSurveys = { + TimeUseSurvey: { + compatibleWith: 1, + formPath: + 'https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json', + labelTemplate: { + en: '{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }', + es: '{ erea, plural, =0 {} other {# Empleo/Educación, } }{ da, plural, =0 {} other {# Actividades domesticas, }}', + }, + labelVars: { + da: { key: 'Domestic_activities', type: 'length' }, + erea: { key: 'Employment_related_a_Education_activities', type: 'length' }, + }, + version: 9, + }, + }; + expect(config.survey_info.surveys).toMatchObject(mockSurveys); +}); + +it('gets the model response, if avaliable, or returns null', () => { + const xmlModel = + ''; + const filled = + '2016-07-2517:24:32.928-06:002016-07-2517:30:31.000-06:00'; + const opts = { prefilledSurveyResponse: filled }; + const opts2 = { + prefillFields: { + Start_date: '2016-07-25', + Start_time: '17:24:32.928-06:00', + End_date: '2016-07-25', + End_time: '17:30:31.000-06:00', + }, + }; + + //if no xmlModel, returns null + expect(getInstanceStr(null, opts)).toBe(null); + //if there is a prefilled survey, return it + expect(getInstanceStr(xmlModel, opts)).toBe(filled); + //if there is a model and fields, return prefilled + expect(getInstanceStr(xmlModel, opts2)).toBe(filled); + //if none of those things, also return null + expect(getInstanceStr(xmlModel, {})).toBe(null); +}); + +//resolve timestamps +it('resolves the timestamps', () => { + const xmlParser = new window.DOMParser(); + const timelineEntry = { + end_local_dt: { timezone: 'America/Los_Angeles' }, + start_ts: 1469492672.928242, + end_ts: 1469493031, + }; + + //missing data returns null + const missingData = + ' 2016-08-28 2016-07-25 17:30:31.000-06:00 '; + const missDataDoc = xmlParser.parseFromString(missingData, 'text/html'); + expect(resolveTimestamps(missDataDoc, timelineEntry)).toBeNull(); + //bad time returns undefined + const badTimes = + ' 2016-08-28 2016-07-25 17:32:32.928-06:00 17:30:31.000-06:00 '; + const badTimeDoc = xmlParser.parseFromString(badTimes, 'text/xml'); + expect(resolveTimestamps(badTimeDoc, timelineEntry)).toBeUndefined(); + //if within a minute, timelineEntry timestamps + const timeEntry = + ' 2016-07-25 2016-07-25 17:24:32.928-06:00 17:30:31.000-06:00 '; + const xmlDoc1 = xmlParser.parseFromString(timeEntry, 'text/xml'); + expect(resolveTimestamps(xmlDoc1, timelineEntry)).toMatchObject({ + start_ts: 1469492672.928242, + end_ts: 1469493031, + }); + // else survey timestamps + const timeSurvey = + ' 2016-07-25 2016-07-25 17:22:33.928-06:00 17:33:33.000-06:00 '; + const xmlDoc2 = xmlParser.parseFromString(timeSurvey, 'text/xml'); + expect(resolveTimestamps(xmlDoc2, timelineEntry)).toMatchObject({ + start_ts: 1469492553.928, + end_ts: 1469493213, + }); +}); + +//resolve label +it('resolves the label, normal case', async () => { + const xmlParser = new window.DOMParser(); + const xmlString = ' option_1 '; + const xmlDoc = xmlParser.parseFromString(xmlString, 'text/html'); + const xmlString2 = + ' option_1 option_3 '; + const xmlDoc2 = xmlParser.parseFromString(xmlString2, 'text/xml'); + + //have a custom survey label function TODO: we currently don't have custome label functions, but should test when we do + //no custom function, fallback to UseLabelTemplate (standard case) + mockBEMUserCache(fakeConfig); + expect(await resolveLabel('TimeUseSurvey', xmlDoc)).toBe('3 Domestic'); + expect(await resolveLabel('TimeUseSurvey', xmlDoc2)).toBe('3 Employment/Education, 3 Domestic'); +}); + +it('resolves the label, if no template, returns "Answered"', async () => { + const xmlParser = new window.DOMParser(); + const xmlString = ' option_1 '; + const xmlDoc = xmlParser.parseFromString(xmlString, 'text/html'); + const xmlString2 = + ' option_1 option_3 '; + const xmlDoc2 = xmlParser.parseFromString(xmlString2, 'text/xml'); + + const noTemplate = { + survey_info: { + surveys: { + TimeUseSurvey: { + compatibleWith: 1, + formPath: + 'https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json', + labelVars: { + da: { + key: 'Domestic_activities', + type: 'length', + }, + erea: { + key: 'Employment_related_a_Education_activities', + type: 'length', + }, + }, + version: 9, + }, + }, + 'trip-labels': 'ENKETO', + }, + }; + mockBEMUserCache(noTemplate); + expect(await resolveLabel('TimeUseSurvey', xmlDoc)).toBe('Answered'); + expect(await resolveLabel('TimeUseSurvey', xmlDoc2)).toBe('Answered'); +}); + +it('resolves the label, if no labelVars, returns template', async () => { + const xmlParser = new window.DOMParser(); + const xmlString = ' option_1 '; + const xmlDoc = xmlParser.parseFromString(xmlString, 'text/html'); + const xmlString2 = + ' option_1 option_3 '; + const xmlDoc2 = xmlParser.parseFromString(xmlString2, 'text/xml'); + + const noLabels = { + survey_info: { + surveys: { + TimeUseSurvey: { + compatibleWith: 1, + formPath: + 'https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json', + labelTemplate: { + en: '{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }', + es: '{ erea, plural, =0 {} other {# Empleo/Educación, } }{ da, plural, =0 {} other {# Actividades domesticas, }}', + }, + version: 9, + }, + }, + 'trip-labels': 'ENKETO', + }, + }; + mockBEMUserCache(noLabels); + expect(await resolveLabel('TimeUseSurvey', xmlDoc)).toBe( + '{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }', + ); + expect(await resolveLabel('TimeUseSurvey', xmlDoc2)).toBe( + '{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }', + ); +}); + +/** + * @param surveyName the name of the survey (e.g. "TimeUseSurvey") + * @param enketoForm the Form object from enketo-core that contains this survey + * @param appConfig the dynamic config file for the app + * @param opts object with SurveyOptions like 'timelineEntry' or 'dataKey' + * @returns Promise of the saved result, or an Error if there was a problem + */ +// export function saveResponse(surveyName: string, enketoForm: Form, appConfig, opts: SurveyOptions) { +it('gets the saved result or throws an error', () => { + const surveyName = 'TimeUseSurvey'; + const form = { + getDataStr: () => { + return '2023-10-13T15:05:48.890-06:002023-10-13T15:05:48.892-06:002016-07-2517:24:32.928-06:002016-07-2517:30:31.000-06:00personal_care_activitiesdoing_sportuuid:dc16c287-08b2-4435-95aa-e4d7838b4225'; + }, + }; + //the start time listed is after the end time listed + const badForm = { + getDataStr: () => { + return '2023-10-13T15:05:48.890-06:002023-10-13T15:05:48.892-06:002016-08-2517:24:32.928-06:002016-07-2517:30:31.000-06:00personal_care_activitiesdoing_sportuuid:dc16c287-08b2-4435-95aa-e4d7838b4225'; + }, + }; + const config = { + survey_info: { + surveys: { + TimeUseSurvey: { + compatibleWith: 1, + formPath: + 'https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json', + labelTemplate: { + en: '{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }', + es: '{ erea, plural, =0 {} other {# Empleo/Educación, } }{ da, plural, =0 {} other {# Actividades domesticas, }}', + }, + labelVars: { + da: { key: 'Domestic_activities', type: 'length' }, + erea: { key: 'Employment_related_a_Education_activities', type: 'length' }, + }, + version: 9, + }, + }, + }, + }; + const opts = { + timelineEntry: { + end_local_dt: { timezone: 'America/Los_Angeles' }, + start_ts: 1469492672.928242, + end_ts: 1469493031, + }, + }; + + console.log(config); + expect(saveResponse(surveyName, form, config, opts)).resolves.toMatchObject({ + label: '1 Personal Care', + name: 'TimeUseSurvey', + }); + expect(saveResponse(surveyName, badForm, config, opts)).resolves.toMatchObject({ + message: + 'The times you entered are invalid. Please ensure that the start time is before the end time.', + }); +}); + +/* + * We retrieve all the records every time instead of caching because of the + * usage pattern. We assume that the demographic survey is edited fairly + * rarely, so loading it every time will likely do a bunch of unnecessary work. + * Loading it on demand seems like the way to go. If we choose to experiment + * with incremental updates, we may want to revisit this. + */ +it('loads the previous response to a given survey', () => { + expect(loadPreviousResponseForSurvey('manual/demographic_survey')).resolves.toMatchObject({ + data: 'completed', + time: '01/01/2001', + }); +}); + +/** + * filterByNameAndVersion filter the survey responses by survey name and their version. + * The version for filtering is specified in enketo survey `compatibleWith` config. + * The stored survey response version must be greater than or equal to `compatibleWith` to be included. + */ +it('filters the survey responses by their name and version', () => { + //no response -> no filtered responses + expect(filterByNameAndVersion('TimeUseSurvey', [])).resolves.toStrictEqual([]); + + const response = [ + { + data: { + label: 'Activity', //display label (this value is use for displaying on the button) + ts: '100000000', //the timestamp at which the survey was filled out (in seconds) + fmt_time: '12:36', //the formatted timestamp at which the survey was filled out + name: 'TimeUseSurvey', //survey name + version: '1', //survey version + xmlResponse: '', //survey response XML string + jsonDocResponse: 'this is my json object', //survey response JSON object + }, + metadata: {}, + }, + ]; + + //one response -> that response + expect(filterByNameAndVersion('TimeUseSurvey', response)).resolves.toStrictEqual(response); + + const responses = [ + { + data: { + label: 'Activity', //display label (this value is use for displaying on the button) + ts: '100000000', //the timestamp at which the survey was filled out (in seconds) + fmt_time: '12:36', //the formatted timestamp at which the survey was filled out + name: 'TimeUseSurvey', //survey name + version: '1', //survey version + xmlResponse: '', //survey response XML string + jsonDocResponse: 'this is my json object', //survey response JSON object + }, + metadata: {}, + }, + { + data: { + label: 'Activity', //display label (this value is use for displaying on the button) + ts: '100000000', //the timestamp at which the survey was filled out (in seconds) + fmt_time: '12:36', //the formatted timestamp at which the survey was filled out + name: 'OtherSurvey', //survey name + version: '1', //survey version + xmlResponse: '', //survey response XML string + jsonDocResponse: 'this is my json object', //survey response JSON object + }, + metadata: {}, + }, + { + data: { + label: 'Activity', //display label (this value is use for displaying on the button) + ts: '100000000', //the timestamp at which the survey was filled out (in seconds) + fmt_time: '12:39', //the formatted timestamp at which the survey was filled out + name: 'TimeUseSurvey', //survey name + version: '0.5', //survey version + xmlResponse: '', //survey response XML string + jsonDocResponse: 'this is my json object', //survey response JSON object + }, + metadata: {}, + }, + ]; + + //several responses -> only the one that has a name match + expect(filterByNameAndVersion('TimeUseSurvey', responses)).resolves.toStrictEqual(response); +}); diff --git a/www/__tests__/footprintHelper.test.ts b/www/__tests__/footprintHelper.test.ts new file mode 100644 index 000000000..842442153 --- /dev/null +++ b/www/__tests__/footprintHelper.test.ts @@ -0,0 +1,63 @@ +import { initCustomDatasetHelper } from '../js/metrics/customMetricsHelper'; +import { + clearHighestFootprint, + getFootprintForMetrics, + getHighestFootprint, + getHighestFootprintForDistance, +} from '../js/metrics/footprintHelper'; +import { getConfig } from '../js/config/dynamicConfig'; +import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; +import fakeLabels from '../__mocks__/fakeLabels.json'; + +mockBEMUserCache(); +mockLogger(); + +global.fetch = (url: string) => + new Promise((rs, rj) => { + setTimeout(() => + rs({ + text: () => + new Promise((rs, rj) => { + let myJSON = JSON.stringify(fakeLabels); + setTimeout(() => rs(myJSON), 100); + }), + }), + ); + }) as any; + +beforeEach(() => { + clearHighestFootprint(); +}); + +const custom_metrics = [ + { key: 'walk', values: 3000 }, + { key: 'bike', values: 6500 }, + { key: 'drove_alone', values: 10000 }, + { key: 'scootershare', values: 25000 }, + { key: 'unicycle', values: 5000 }, +]; + +it('gets footprint for metrics (custom, fallback 0)', async () => { + const appConfig = await getConfig(); + await initCustomDatasetHelper(appConfig); + expect(getFootprintForMetrics(custom_metrics, 0)).toBe(2.4266); +}); + +it('gets footprint for metrics (custom, fallback 0.1)', async () => { + const appConfig = await getConfig(); + await initCustomDatasetHelper(appConfig); + expect(getFootprintForMetrics(custom_metrics, 0.1)).toBe(2.4266 + 0.5); +}); + +it('gets the highest footprint from the dataset, custom', async () => { + const appConfig = await getConfig(); + await initCustomDatasetHelper(appConfig); + expect(getHighestFootprint()).toBe(0.30741); +}); + +it('gets the highest footprint for distance, custom', async () => { + const appConfig = await getConfig(); + await initCustomDatasetHelper(appConfig); + expect(getHighestFootprintForDistance(12345)).toBe(0.30741 * (12345 / 1000)); +}); diff --git a/www/__tests__/metHelper.test.ts b/www/__tests__/metHelper.test.ts new file mode 100644 index 000000000..bc477daa0 --- /dev/null +++ b/www/__tests__/metHelper.test.ts @@ -0,0 +1,40 @@ +import { getMet } from '../js/metrics/metHelper'; +import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; +import fakeLabels from '../__mocks__/fakeLabels.json'; +import { getConfig } from '../js/config/dynamicConfig'; +import { initCustomDatasetHelper } from '../js/metrics/customMetricsHelper'; + +mockBEMUserCache(); +mockLogger(); + +global.fetch = (url: string) => + new Promise((rs, rj) => { + setTimeout(() => + rs({ + text: () => + new Promise((rs, rj) => { + let myJSON = JSON.stringify(fakeLabels); + setTimeout(() => rs(myJSON), 100); + }), + }), + ); + }) as any; + +it('gets met for mode and speed', () => { + expect(getMet('WALKING', 1.47523, 0)).toBe(4.3); + expect(getMet('BICYCLING', 4.5, 0)).toBe(6.8); + expect(getMet('UNICYCLE', 100, 0)).toBe(0); + expect(getMet('CAR', 25, 1)).toBe(0); +}); + +it('gets custom met for mode and speed', async () => { + const appConfig = await getConfig(); + await initCustomDatasetHelper(appConfig); + expect(getMet('walk', 1.47523, 0)).toBe(4.3); + expect(getMet('bike', 4.5, 0)).toBe(6.8); + expect(getMet('unicycle', 100, 0)).toBe(0); + expect(getMet('drove_alone', 25, 1)).toBe(0); + expect(getMet('e-bike', 6, 1)).toBe(4.9); + expect(getMet('e-bike', 12, 1)).toBe(4.9); +}); diff --git a/www/css/style.css b/www/css/style.css index a2ac29368..2bc7c6fee 100644 --- a/www/css/style.css +++ b/www/css/style.css @@ -6,8 +6,10 @@ .question.non-select { display: inline-block; } - .question input[name*='_date'], - .question input[name*='_time'] { + .question input[name$='Start_date'], + .question input[name$='Start_time'], + .question input[name$='End_date'], + .question input[name$='End_time'] { width: calc(40vw - 10px); margin-right: 5px; display: flex; diff --git a/www/index.js b/www/index.js index a8d80f02b..3086a31f7 100644 --- a/www/index.js +++ b/www/index.js @@ -12,8 +12,4 @@ import './js/services.js'; import './js/i18n-utils.js'; import './js/main.js'; import './js/diary.js'; -import './js/survey/enketo/answer.js'; -import './js/control/emailService.js'; -import './js/metrics-factory.js'; -import './js/metrics-mappings.js'; import './js/plugin/logger.ts'; diff --git a/www/js/App.tsx b/www/js/App.tsx index a955b032d..2eece7f55 100644 --- a/www/js/App.tsx +++ b/www/js/App.tsx @@ -19,6 +19,7 @@ import { initPushNotify } from './splash/pushNotifySettings'; import { initStoreDeviceSettings } from './splash/storeDeviceSettings'; import { initRemoteNotifyHandler } from './splash/remoteNotifyHandler'; import { withErrorBoundary } from './plugin/ErrorBoundary'; +import { initCustomDatasetHelper } from './metrics/customMetricsHelper'; const defaultRoutes = (t) => [ { @@ -77,6 +78,7 @@ const App = () => { initPushNotify(); initStoreDeviceSettings(); initRemoteNotifyHandler(); + initCustomDatasetHelper(appConfig); }, [appConfig]); const appContextValue = { diff --git a/www/js/components/Chart.tsx b/www/js/components/Chart.tsx index 4ebf49c24..257eb3cf6 100644 --- a/www/js/components/Chart.tsx +++ b/www/js/components/Chart.tsx @@ -138,7 +138,9 @@ const Chart = ({ logDebug(`Horizontal axis callback: i = ${i}; chartDatasets = ${JSON.stringify(chartDatasets)}; chartDatasets[0].data = ${JSON.stringify(chartDatasets[0].data)}`); - const label = chartDatasets[0].data[i].y; + //account for different data possiblities + const label = + chartDatasets[0].data[i]?.y || chartDatasets[i].data[0]?.y; if (typeof label == 'string' && label.includes('\n')) return label.split('\n'); return label; @@ -175,7 +177,9 @@ const Chart = ({ logDebug(`Vertical axis callback: i = ${i}; chartDatasets = ${JSON.stringify(chartDatasets)}; chartDatasets[0].data = ${JSON.stringify(chartDatasets[0].data)}`); - const label = chartDatasets[0].data[i].x; + //account for different data possiblities - one mode per week, one mode both weeks, mixed weeks + const label = + chartDatasets[0].data[i]?.x || chartDatasets[i].data[0]?.x; if (typeof label == 'string' && label.includes('\n')) return label.split('\n'); return label; diff --git a/www/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts index eb709c16c..801e24b07 100644 --- a/www/js/config/dynamicConfig.ts +++ b/www/js/config/dynamicConfig.ts @@ -10,6 +10,11 @@ export let storedConfig = null; export let configChanged = false; export const setConfigChanged = (b) => (configChanged = b); +//used test multiple configs, not used outside of test +export const resetStoredConfig = function () { + storedConfig = null; +}; + const _getStudyName = function (connectUrl) { const orig_host = new URL(connectUrl).hostname; const first_domain = orig_host.split('.')[0]; diff --git a/www/js/control/ControlSyncHelper.tsx b/www/js/control/ControlSyncHelper.tsx index b26d5e85a..5225cf6c6 100644 --- a/www/js/control/ControlSyncHelper.tsx +++ b/www/js/control/ControlSyncHelper.tsx @@ -15,7 +15,7 @@ import { updateUser } from '../services/commHelper'; * BEGIN: Simple read/write wrappers */ export function forcePluginSync() { - return window.cordova.plugins.BEMServerSync.forceSync(); + return window['cordova'].plugins.BEMServerSync.forceSync(); } const formatConfigForDisplay = (configToFormat) => { @@ -27,11 +27,11 @@ const formatConfigForDisplay = (configToFormat) => { }; const setConfig = function (config) { - return window.cordova.plugins.BEMServerSync.setConfig(config); + return window['cordova'].plugins.BEMServerSync.setConfig(config); }; const getConfig = function () { - return window.cordova.plugins.BEMServerSync.getConfig(); + return window['cordova'].plugins.BEMServerSync.getConfig(); }; export async function getHelperSyncSettings() { @@ -39,14 +39,6 @@ export async function getHelperSyncSettings() { return formatConfigForDisplay(tempConfig); } -const getEndTransitionKey = function () { - if (window.cordova.platformId == 'android') { - return 'local.transition.stopped_moving'; - } else if (window.cordova.platformId == 'ios') { - return 'T_TRIP_ENDED'; - } -}; - type syncConfig = { sync_interval: number; ios_use_remote_push: boolean }; //forceSync and endForceSync SettingRows & their actions @@ -60,7 +52,7 @@ export const ForceSyncRow = ({ getState }) => { async function forceSync() { try { - let addedEvent = addStatEvent(statKeys.BUTTON_FORCE_SYNC); + let addedEvent = await addStatEvent(statKeys.BUTTON_FORCE_SYNC); console.log('Added ' + statKeys.BUTTON_FORCE_SYNC + ' event'); let sync = await forcePluginSync(); @@ -70,7 +62,7 @@ export const ForceSyncRow = ({ getState }) => { * See https://github.com/e-mission/e-mission-phone/issues/279 for details */ var sensorKey = 'statemachine/transition'; - let sensorDataList = await window.cordova.plugins.BEMUserCache.getAllMessages( + let sensorDataList = await window['cordova'].plugins.BEMUserCache.getAllMessages( sensorKey, true, ); @@ -104,25 +96,25 @@ export const ForceSyncRow = ({ getState }) => { } const getStartTransitionKey = function () { - if (window.cordova.platformId == 'android') { + if (window['cordova'].platformId == 'android') { return 'local.transition.exited_geofence'; - } else if (window.cordova.platformId == 'ios') { + } else if (window['cordova'].platformId == 'ios') { return 'T_EXITED_GEOFENCE'; } }; const getEndTransitionKey = function () { - if (window.cordova.platformId == 'android') { + if (window['cordova'].platformId == 'android') { return 'local.transition.stopped_moving'; - } else if (window.cordova.platformId == 'ios') { + } else if (window['cordova'].platformId == 'ios') { return 'T_TRIP_ENDED'; } }; const getOngoingTransitionState = function () { - if (window.cordova.platformId == 'android') { + if (window['cordova'].platformId == 'android') { return 'local.state.ongoing_trip'; - } else if (window.cordova.platformId == 'ios') { + } else if (window['cordova'].platformId == 'ios') { return 'STATE_ONGOING_TRIP'; } }; @@ -130,12 +122,12 @@ export const ForceSyncRow = ({ getState }) => { async function getTransition(transKey) { var entry_data = {}; const curr_state = await getState(); - entry_data.curr_state = curr_state; + entry_data['curr_state'] = curr_state; if (transKey == getEndTransitionKey()) { - entry_data.curr_state = getOngoingTransitionState(); + entry_data['curr_state'] = getOngoingTransitionState(); } - entry_data.transition = transKey; - entry_data.ts = moment().unix(); + entry_data['transition'] = transKey; + entry_data['ts'] = moment().unix(); return entry_data; } @@ -144,9 +136,9 @@ export const ForceSyncRow = ({ getState }) => { * result for start so that we ensure ordering */ var sensorKey = 'statemachine/transition'; let entry_data = await getTransition(getStartTransitionKey()); - let messagePut = await window.cordova.plugins.BEMUserCache.putMessage(sensorKey, entry_data); + let messagePut = await window['cordova'].plugins.BEMUserCache.putMessage(sensorKey, entry_data); entry_data = await getTransition(getEndTransitionKey()); - messagePut = await window.cordova.plugins.BEMUserCache.putMessage(sensorKey, entry_data); + messagePut = await window['cordova'].plugins.BEMUserCache.putMessage(sensorKey, entry_data); forceSync(); } @@ -242,7 +234,6 @@ const ControlSyncHelper = ({ editVis, setEditVis }) => { Logger.displayError('Error while setting sync config', err); } } - const onChooseInterval = function (interval) { let tempConfig = { ...localConfig }; tempConfig.sync_interval = interval.value; @@ -259,7 +250,7 @@ const ControlSyncHelper = ({ editVis, setEditVis }) => { * configure the UI */ let toggle; - if (window.cordova.platformId == 'ios') { + if (window['cordova'].platformId == 'ios') { toggle = ( diff --git a/www/js/control/LogPage.tsx b/www/js/control/LogPage.tsx index ad369fbff..6d603f19e 100644 --- a/www/js/control/LogPage.tsx +++ b/www/js/control/LogPage.tsx @@ -6,13 +6,13 @@ import { useTranslation } from 'react-i18next'; import { FlashList } from '@shopify/flash-list'; import moment from 'moment'; import AlertBar from './AlertBar'; +import { sendEmail } from './emailService'; type loadStats = { currentStart: number; gotMaxIndex: boolean; reachedEnd: boolean }; const LogPage = ({ pageVis, setPageVis }) => { const { t } = useTranslation(); const { colors } = useTheme(); - const EmailHelper = getAngularService('EmailHelper'); const [loadStats, setLoadStats] = useState(); const [entries, setEntries] = useState([]); @@ -96,7 +96,7 @@ const LogPage = ({ pageVis, setPageVis }) => { }; const emailLog = function () { - EmailHelper.sendEmail('loggerDB'); + sendEmail('loggerDB'); }; const separator = () => ; diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index b081e642a..914c97d82 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -13,7 +13,7 @@ import useAppConfig from '../useAppConfig'; import AlertBar from './AlertBar'; import DataDatePicker from './DataDatePicker'; import PrivacyPolicyModal from './PrivacyPolicyModal'; - +import { sendEmail } from './emailService'; import { uploadFile } from './uploadService'; import ActionMenu from '../components/ActionMenu'; import SensedPage from './SensedPage'; @@ -43,8 +43,6 @@ const ProfileSettings = () => { const { setPermissionsPopupVis } = useContext(AppContext); //angular services needed - const CarbonDatasetHelper = getAngularService('CarbonDatasetHelper'); - const EmailHelper = getAngularService('EmailHelper'); const NotificationScheduler = getAngularService('NotificationScheduler'); const ControlHelper = getAngularService('ControlHelper'); @@ -55,7 +53,6 @@ const ProfileSettings = () => { //states and variables used to control/create the settings const [opCodeVis, setOpCodeVis] = useState(false); const [nukeSetVis, setNukeVis] = useState(false); - const [carbonDataVis, setCarbonDataVis] = useState(false); const [forceStateVis, setForceStateVis] = useState(false); const [logoutVis, setLogoutVis] = useState(false); const [invalidateSuccessVis, setInvalidateSuccessVis] = useState(false); @@ -83,9 +80,6 @@ const ProfileSettings = () => { const [uploadReason, setUploadReason] = useState(''); const appVersion = useRef(); - let carbonDatasetString = - t('general-settings.carbon-dataset') + ': ' + CarbonDatasetHelper.getCurrentCarbonDatasetCode(); - const carbonOptions = CarbonDatasetHelper.getCarbonDatasetOptions(); const stateActions = [ { text: 'Initialize', transition: 'INITIALIZE' }, { text: 'Start trip', transition: 'EXITED_GEOFENCE' }, @@ -256,7 +250,7 @@ const ProfileSettings = () => { const emailLog = function () { // Passing true, we want to send logs - EmailHelper.sendEmail('loggerDB'); + sendEmail('loggerDB'); }; async function updatePrefReminderTime(storeNewVal = true, newTime) { @@ -359,16 +353,6 @@ const ProfileSettings = () => { forceTransition(stateObject.transition); }; - const onSelectCarbon = function (carbonObject) { - console.log('changeCarbonDataset(): chose locale ' + carbonObject.value); - CarbonDatasetHelper.saveCurrentCarbonDatasetLocale(carbonObject.value); //there's some sort of error here - //Unhandled Promise Rejection: While logging, error -[NSNull UTF8String]: unrecognized selector sent to instance 0x7fff8a625fb0 - carbonDatasetString = - i18next.t('general-settings.carbon-dataset') + - ': ' + - CarbonDatasetHelper.getCurrentCarbonDatasetCode(); - }; - //conditional creation of setting sections let logUploadSection; @@ -439,10 +423,6 @@ const ProfileSettings = () => { textKey="control.medium-accuracy" action={toggleLowAccuracy} switchValue={collectSettings.lowAccuracy}> - setCarbonDataVis(true)}> { - {/* menu for "set carbon dataset - only somewhat working" */} - clearNotifications()}> - {/* force state sheet */} { const { t } = useTranslation(); const { colors } = useTheme(); - const EmailHelper = getAngularService('EmailHelper'); /* Let's keep a reference to the database for convenience */ const [DB, setDB] = useState(); const [entries, setEntries] = useState([]); const emailCache = function () { - EmailHelper.sendEmail('userCacheDB'); + sendEmail('userCacheDB'); }; async function updateEntries() { diff --git a/www/js/control/emailService.js b/www/js/control/emailService.js deleted file mode 100644 index 8eeaf39bb..000000000 --- a/www/js/control/emailService.js +++ /dev/null @@ -1,114 +0,0 @@ -'use strict'; - -import angular from 'angular'; - -angular - .module('emission.services.email', ['emission.plugin.logger']) - - .service('EmailHelper', function ($window, $http, Logger) { - const getEmailConfig = function () { - return new Promise(function (resolve, reject) { - window.Logger.log(window.Logger.LEVEL_INFO, 'About to get email config'); - var address = []; - $http - .get('json/emailConfig.json') - .then(function (emailConfig) { - window.Logger.log( - window.Logger.LEVEL_DEBUG, - 'emailConfigString = ' + JSON.stringify(emailConfig.data), - ); - address.push(emailConfig.data.address); - resolve(address); - }) - .catch(function (err) { - $http - .get('json/emailConfig.json.sample') - .then(function (emailConfig) { - window.Logger.log( - window.Logger.LEVEL_DEBUG, - 'default emailConfigString = ' + JSON.stringify(emailConfig.data), - ); - address.push(emailConfig.data.address); - resolve(address); - }) - .catch(function (err) { - window.Logger.log( - window.Logger.LEVEL_ERROR, - 'Error while reading default email config' + err, - ); - reject(err); - }); - }); - }); - }; - - const hasAccount = function () { - return new Promise(function (resolve, reject) { - $window.cordova.plugins.email.hasAccount(function (hasAct) { - resolve(hasAct); - }); - }); - }; - - this.sendEmail = function (database) { - Promise.all([getEmailConfig(), hasAccount()]).then(function ([address, hasAct]) { - var parentDir = 'unknown'; - - // Check this only for ios, since for android, the check always fails unless - // the user grants the "GET_ACCOUNTS" dynamic permission - // without the permission, we only see the e-mission account which is not valid - // - // https://developer.android.com/reference/android/accounts/AccountManager#getAccounts() - // - // Caller targeting API level below Build.VERSION_CODES.O that - // have not been granted the Manifest.permission.GET_ACCOUNTS - // permission, will only see those accounts managed by - // AbstractAccountAuthenticators whose signature matches the - // client. - // and on android, if the account is not configured, the gmail app will be launched anyway - // on iOS, nothing will happen. So we perform the check only on iOS so that we can - // generate a reasonably relevant error message - - if (ionic.Platform.isIOS() && !hasAct) { - alert(i18next.t('email-service.email-account-not-configured')); - return; - } - - if (ionic.Platform.isAndroid()) { - parentDir = 'app://databases'; - } - if (ionic.Platform.isIOS()) { - alert(i18next.t('email-service.email-account-mail-app')); - parentDir = cordova.file.dataDirectory + '../LocalDatabase'; - } - - if (parentDir == 'unknown') { - alert('parentDir unexpectedly = ' + parentDir + '!'); - } - - window.Logger.log(window.Logger.LEVEL_INFO, 'Going to email ' + database); - parentDir = parentDir + '/' + database; - /* - window.Logger.log(window.Logger.LEVEL_INFO, - "Going to export logs to "+parentDir); - */ - alert(i18next.t('email-service.going-to-email', { parentDir: parentDir })); - var email = { - to: address, - attachments: [parentDir], - subject: i18next.t('email-service.email-log.subject-logs'), - body: i18next.t('email-service.email-log.body-please-fill-in-what-is-wrong'), - }; - - $window.cordova.plugins.email.open(email, function () { - Logger.log( - 'email app closed while sending, ' + - JSON.stringify(email) + - ' not sure if we should do anything', - ); - // alert(i18next.t('email-service.no-email-address-configured') + err); - return; - }); - }); - }; - }); diff --git a/www/js/control/emailService.ts b/www/js/control/emailService.ts new file mode 100644 index 000000000..3a6e8a5c5 --- /dev/null +++ b/www/js/control/emailService.ts @@ -0,0 +1,52 @@ +import i18next from 'i18next'; +import { logDebug, logInfo, logWarn } from '../plugin/logger'; + +async function hasAccount(): Promise { + return new Promise((resolve, reject) => { + window['cordova'].plugins['email'].hasAccount((hasAct) => { + resolve(hasAct); + }); + }); +} + +export async function sendEmail(database: string) { + let parentDir = 'unknown'; + + if (window['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.js b/www/js/diary.js index 93fed24d4..729aa807c 100644 --- a/www/js/diary.js +++ b/www/js/diary.js @@ -2,7 +2,7 @@ import angular from 'angular'; import LabelTab from './diary/LabelTab'; angular - .module('emission.main.diary', ['emission.plugin.logger', 'emission.survey.enketo.answer']) + .module('emission.main.diary', ['emission.plugin.logger']) .config(function ($stateProvider) { $stateProvider.state('root.main.inf_scroll', { diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 65e208957..5115e5588 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -16,6 +16,7 @@ import { } from '../types/diaryTypes'; import { getLabelInputDetails, getLabelInputs } from '../survey/multilabel/confirmHelper'; import { LabelOptions } from '../types/labelTypes'; +import { filterByNameAndVersion } from '../survey/enketo/enketoHelper'; const cachedGeojsons: Map = new Map(); @@ -107,7 +108,9 @@ export function updateUnprocessedInputs(labelsPromises, notesPromises, appConfig // fill in the unprocessedLabels object with the labels we just read labelResults.forEach((r, i) => { if (appConfig.survey_info?.['trip-labels'] == 'ENKETO') { - unprocessedLabels['SURVEY'] = r; + filterByNameAndVersion('TripConfirmSurvey', r).then((filtered) => { + unprocessedLabels['SURVEY'] = filtered; + }); } else { unprocessedLabels[getLabelInputs()[i]] = r; } diff --git a/www/js/main.js b/www/js/main.js index 2b351e2c4..2c789891a 100644 --- a/www/js/main.js +++ b/www/js/main.js @@ -7,8 +7,6 @@ angular 'emission.main.diary', 'emission.i18n.utils', 'emission.splash.notifscheduler', - 'emission.main.metrics.factory', - 'emission.main.metrics.mappings', 'emission.services', ]) diff --git a/www/js/metrics-factory.js b/www/js/metrics-factory.js deleted file mode 100644 index 28ef5ae9e..000000000 --- a/www/js/metrics-factory.js +++ /dev/null @@ -1,271 +0,0 @@ -'use strict'; - -import angular from 'angular'; -import { getBaseModeByValue } from './diary/diaryHelper'; -import { labelOptions } from './survey/multilabel/confirmHelper'; -import { storageGet, storageRemove, storageSet } from './plugin/storage'; - -angular - .module('emission.main.metrics.factory', ['emission.main.metrics.mappings']) - - .factory('FootprintHelper', function (CarbonDatasetHelper, CustomDatasetHelper) { - var fh = {}; - var highestFootprint = 0; - - var mtokm = function (v) { - return v / 1000; - }; - fh.useCustom = false; - - fh.setUseCustomFootprint = function () { - fh.useCustom = true; - }; - - fh.getFootprint = function () { - if (this.useCustom == true) { - return CustomDatasetHelper.getCustomFootprint(); - } else { - return CarbonDatasetHelper.getCurrentCarbonDatasetFootprint(); - } - }; - - fh.readableFormat = function (v) { - return v > 999 ? Math.round(v / 1000) + 'k kg CO₂' : Math.round(v) + ' kg CO₂'; - }; - fh.getFootprintForMetrics = function (userMetrics, defaultIfMissing = 0) { - var footprint = fh.getFootprint(); - var result = 0; - for (var i in userMetrics) { - var mode = userMetrics[i].key; - if (mode == 'ON_FOOT') { - mode = 'WALKING'; - } - - if (mode in footprint) { - result += footprint[mode] * mtokm(userMetrics[i].values); - } else if (mode == 'IN_VEHICLE') { - result += - ((footprint['CAR'] + - footprint['BUS'] + - footprint['LIGHT_RAIL'] + - footprint['TRAIN'] + - footprint['TRAM'] + - footprint['SUBWAY']) / - 6) * - mtokm(userMetrics[i].values); - } else { - console.warn( - 'WARNING FootprintHelper.getFootprintFromMetrics() was requested for an unknown mode: ' + - mode + - ' metrics JSON: ' + - JSON.stringify(userMetrics), - ); - result += defaultIfMissing * mtokm(userMetrics[i].values); - } - } - return result; - }; - fh.getLowestFootprintForDistance = function (distance) { - var footprint = fh.getFootprint(); - var lowestFootprint = Number.MAX_SAFE_INTEGER; - for (var mode in footprint) { - if (mode == 'WALKING' || mode == 'BICYCLING') { - // these modes aren't considered when determining the lowest carbon footprint - } else { - lowestFootprint = Math.min(lowestFootprint, footprint[mode]); - } - } - return lowestFootprint * mtokm(distance); - }; - - fh.getHighestFootprint = function () { - if (!highestFootprint) { - var footprint = fh.getFootprint(); - let footprintList = []; - for (var mode in footprint) { - footprintList.push(footprint[mode]); - } - highestFootprint = Math.max(...footprintList); - } - return highestFootprint; - }; - - fh.getHighestFootprintForDistance = function (distance) { - return fh.getHighestFootprint() * mtokm(distance); - }; - - var getLowestMotorizedNonAirFootprint = function (footprint, rlmCO2) { - var lowestFootprint = Number.MAX_SAFE_INTEGER; - for (var mode in footprint) { - if (mode == 'AIR_OR_HSR' || mode == 'air') { - console.log('Air mode, ignoring'); - } else { - if (footprint[mode] == 0 || footprint[mode] <= rlmCO2) { - console.log( - 'Non motorized mode or footprint <= range_limited_motorized', - mode, - footprint[mode], - rlmCO2, - ); - } else { - lowestFootprint = Math.min(lowestFootprint, footprint[mode]); - } - } - } - return lowestFootprint; - }; - - fh.getOptimalDistanceRanges = function () { - const FIVE_KM = 5 * 1000; - const SIX_HUNDRED_KM = 600 * 1000; - if (!fh.useCustom) { - const defaultFootprint = CarbonDatasetHelper.getCurrentCarbonDatasetFootprint(); - const lowestMotorizedNonAir = getLowestMotorizedNonAirFootprint(defaultFootprint); - const airFootprint = defaultFootprint['AIR_OR_HSR']; - return [ - { low: 0, high: FIVE_KM, optimal: 0 }, - { low: FIVE_KM, high: SIX_HUNDRED_KM, optimal: lowestMotorizedNonAir }, - { low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint }, - ]; - } else { - // custom footprint, let's get the custom values - const customFootprint = CustomDatasetHelper.getCustomFootprint(); - let airFootprint = customFootprint['air']; - if (!airFootprint) { - // 2341 BTU/PMT from - // https://tedb.ornl.gov/wp-content/uploads/2021/02/TEDB_Ed_39.pdf#page=68 - // 159.25 lb per million BTU from EIA - // https://www.eia.gov/environment/emissions/co2_vol_mass.php - // (2341 * (159.25/1000000))/(1.6*2.2) = 0.09975, rounded up a bit - console.log('No entry for air in ', customFootprint, ' using default'); - airFootprint = 0.1; - } - const rlm = CustomDatasetHelper.range_limited_motorized; - if (!rlm) { - return [ - { low: 0, high: FIVE_KM, optimal: 0 }, - { low: FIVE_KM, high: SIX_HUNDRED_KM, optimal: lowestMotorizedNonAir }, - { low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint }, - ]; - } else { - console.log('Found range_limited_motorized mode', rlm); - const lowestMotorizedNonAir = getLowestMotorizedNonAirFootprint( - customFootprint, - rlm.kgCo2PerKm, - ); - return [ - { low: 0, high: FIVE_KM, optimal: 0 }, - { low: FIVE_KM, high: rlm.range_limit_km * 1000, optimal: rlm.kgCo2PerKm }, - { - low: rlm.range_limit_km * 1000, - high: SIX_HUNDRED_KM, - optimal: lowestMotorizedNonAir, - }, - { low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint }, - ]; - } - } - }; - - return fh; - }) - - .factory('CalorieCal', function (METDatasetHelper, CustomDatasetHelper) { - var cc = {}; - var highestMET = 0; - var USER_DATA_KEY = 'user-data'; - cc.useCustom = false; - - cc.setUseCustomFootprint = function () { - cc.useCustom = true; - }; - - cc.getMETs = function () { - if (this.useCustom == true) { - return CustomDatasetHelper.getCustomMETs(); - } else { - return METDatasetHelper.getStandardMETs(); - } - }; - - cc.set = function (info) { - return storageSet(USER_DATA_KEY, info); - }; - cc.get = function () { - return storageGet(USER_DATA_KEY); - }; - cc.delete = function () { - return storageRemove(USER_DATA_KEY); - }; - Number.prototype.between = function (min, max) { - return this >= min && this <= max; - }; - cc.getHighestMET = function () { - if (!highestMET) { - var met = cc.getMETs(); - let metList = []; - for (var mode in met) { - var rangeList = met[mode]; - for (var range in rangeList) { - metList.push(rangeList[range].mets); - } - } - highestMET = Math.max(...metList); - } - return highestMET; - }; - cc.getMet = function (mode, speed, defaultIfMissing) { - if (mode == 'ON_FOOT') { - console.log("CalorieCal.getMet() converted 'ON_FOOT' to 'WALKING'"); - mode = 'WALKING'; - } - let currentMETs = cc.getMETs(); - if (!currentMETs[mode]) { - console.warn('CalorieCal.getMet() Illegal mode: ' + mode); - return defaultIfMissing; //So the calorie sum does not break with wrong return type - } - for (var i in currentMETs[mode]) { - if (mpstomph(speed).between(currentMETs[mode][i].range[0], currentMETs[mode][i].range[1])) { - return currentMETs[mode][i].mets; - } else if (mpstomph(speed) < 0) { - console.log('CalorieCal.getMet() Negative speed: ' + mpstomph(speed)); - return 0; - } - } - }; - var mpstomph = function (mps) { - return 2.23694 * mps; - }; - var lbtokg = function (lb) { - return lb * 0.453592; - }; - var fttocm = function (ft) { - return ft * 30.48; - }; - cc.getCorrectedMet = function (met, gender, age, height, heightUnit, weight, weightUnit) { - var height = heightUnit == 0 ? fttocm(height) : height; - var weight = weightUnit == 0 ? lbtokg(weight) : weight; - if (gender == 1) { - //male - var met = - (met * 3.5) / - (((66.473 + 5.0033 * height + 13.7516 * weight - 6.755 * age) / 1440 / 5 / weight) * - 1000); - return met; - } else if (gender == 0) { - //female - var met = - (met * 3.5) / - (((655.0955 + 1.8496 * height + 9.5634 * weight - 4.6756 * age) / 1440 / 5 / weight) * - 1000); - return met; - } - }; - cc.getuserCalories = function (durationInMin, met) { - return 65 * durationInMin * met; - }; - cc.getCalories = function (weightInKg, durationInMin, met) { - return weightInKg * durationInMin * met; - }; - return cc; - }); diff --git a/www/js/metrics-mappings.js b/www/js/metrics-mappings.js deleted file mode 100644 index 38836a3a1..000000000 --- a/www/js/metrics-mappings.js +++ /dev/null @@ -1,425 +0,0 @@ -import angular from 'angular'; -import { getLabelOptions } from './survey/multilabel/confirmHelper'; -import { getConfig } from './config/dynamicConfig'; -import { storageGet, storageSet } from './plugin/storage'; - -angular - .module('emission.main.metrics.mappings', ['emission.plugin.logger']) - - .service('CarbonDatasetHelper', function () { - var CARBON_DATASET_KEY = 'carbon_dataset_locale'; - - // Values are in Kg/PKm (kilograms per passenger-kilometer) - // Sources for EU values: - // - Tremod: 2017, CO2, CH4 and N2O in CO2-equivalent - // - HBEFA: 2020, CO2 (per country) - // German data uses Tremod. Other EU countries (and Switzerland) use HBEFA for car and bus, - // and Tremod for train and air (because HBEFA doesn't provide these). - // EU data is an average of the Tremod/HBEFA data for the countries listed; - // for this average the HBEFA data was used also in the German set (for car and bus). - var carbonDatasets = { - US: { - regionName: 'United States', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 267 / 1609, - BUS: 278 / 1609, - LIGHT_RAIL: 120 / 1609, - SUBWAY: 74 / 1609, - TRAM: 90 / 1609, - TRAIN: 92 / 1609, - AIR_OR_HSR: 217 / 1609, - }, - }, - EU: { - // Plain average of values for the countries below (using HBEFA for car and bus, Tremod for others) - regionName: 'European Union', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.14515, - BUS: 0.04751, - LIGHT_RAIL: 0.064, - SUBWAY: 0.064, - TRAM: 0.064, - TRAIN: 0.048, - AIR_OR_HSR: 0.201, - }, - }, - DE: { - regionName: 'Germany', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.139, // Tremod (passenger car) - BUS: 0.0535, // Tremod (average city/coach) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201, // Tremod (DE airplane) - }, - }, - FR: { - regionName: 'France', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.13125, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04838, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201, // Tremod (DE airplane) - }, - }, - AT: { - regionName: 'Austria', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.14351, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04625, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201, // Tremod (DE airplane) - }, - }, - SE: { - regionName: 'Sweden', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.13458, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04557, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201, // Tremod (DE airplane) - }, - }, - NO: { - regionName: 'Norway', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.13265, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04185, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201, // Tremod (DE airplane) - }, - }, - CH: { - regionName: 'Switzerland', - footprintData: { - WALKING: 0, - BICYCLING: 0, - CAR: 0.17638, // HBEFA (passenger car, considering 1 passenger) - BUS: 0.04866, // HBEFA (average short/long distance, considering 16/25 passengers) - LIGHT_RAIL: 0.064, // Tremod (DE tram, urban rail and subway) - SUBWAY: 0.064, // Tremod (DE tram, urban rail and subway) - TRAM: 0.064, // Tremod (DE tram, urban rail and subway) - TRAIN: 0.048, // Tremod (DE average short/long distance) - AIR_OR_HSR: 0.201, // Tremod (DE airplane) - }, - }, - }; - - var defaultCarbonDatasetCode = 'US'; - var currentCarbonDatasetCode = defaultCarbonDatasetCode; - - // we need to call the method from within a promise in initialize() - // and using this.setCurrentCarbonDatasetLocale doesn't seem to work - var setCurrentCarbonDatasetLocale = function (localeCode) { - for (var code in carbonDatasets) { - if (code == localeCode) { - currentCarbonDatasetCode = localeCode; - break; - } - } - }; - - this.loadCarbonDatasetLocale = function () { - return storageGet(CARBON_DATASET_KEY).then(function (localeCode) { - Logger.log( - 'CarbonDatasetHelper.loadCarbonDatasetLocale() obtained value from storage [' + - localeCode + - ']', - ); - if (!localeCode) { - localeCode = defaultCarbonDatasetCode; - Logger.log( - 'CarbonDatasetHelper.loadCarbonDatasetLocale() no value in storage, using [' + - localeCode + - '] instead', - ); - } - setCurrentCarbonDatasetLocale(localeCode); - }); - }; - - this.saveCurrentCarbonDatasetLocale = function (localeCode) { - setCurrentCarbonDatasetLocale(localeCode); - storageSet(CARBON_DATASET_KEY, currentCarbonDatasetCode); - Logger.log( - 'CarbonDatasetHelper.saveCurrentCarbonDatasetLocale() saved value [' + - currentCarbonDatasetCode + - '] to storage', - ); - }; - - this.getCarbonDatasetOptions = function () { - var options = []; - for (var code in carbonDatasets) { - options.push({ - text: code, //carbonDatasets[code].regionName, - value: code, - }); - } - return options; - }; - - this.getCurrentCarbonDatasetCode = function () { - return currentCarbonDatasetCode; - }; - - this.getCurrentCarbonDatasetFootprint = function () { - return carbonDatasets[currentCarbonDatasetCode].footprintData; - }; - }) - .service('METDatasetHelper', function () { - var standardMETs = { - WALKING: { - VERY_SLOW: { - range: [0, 2.0], - mets: 2.0, - }, - SLOW: { - range: [2.0, 2.5], - mets: 2.8, - }, - MODERATE_0: { - range: [2.5, 2.8], - mets: 3.0, - }, - MODERATE_1: { - range: [2.8, 3.2], - mets: 3.5, - }, - FAST: { - range: [3.2, 3.5], - mets: 4.3, - }, - VERY_FAST_0: { - range: [3.5, 4.0], - mets: 5.0, - }, - 'VERY_FAST_!': { - range: [4.0, 4.5], - mets: 6.0, - }, - VERY_VERY_FAST: { - range: [4.5, 5], - mets: 7.0, - }, - SUPER_FAST: { - range: [5, 6], - mets: 8.3, - }, - RUNNING: { - range: [6, Number.MAX_VALUE], - mets: 9.8, - }, - }, - BICYCLING: { - VERY_VERY_SLOW: { - range: [0, 5.5], - mets: 3.5, - }, - VERY_SLOW: { - range: [5.5, 10], - mets: 5.8, - }, - SLOW: { - range: [10, 12], - mets: 6.8, - }, - MODERATE: { - range: [12, 14], - mets: 8.0, - }, - FAST: { - range: [14, 16], - mets: 10.0, - }, - VERT_FAST: { - range: [16, 19], - mets: 12.0, - }, - RACING: { - range: [20, Number.MAX_VALUE], - mets: 15.8, - }, - }, - UNKNOWN: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - IN_VEHICLE: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - CAR: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - BUS: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - LIGHT_RAIL: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - TRAIN: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - TRAM: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - SUBWAY: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - AIR_OR_HSR: { - ALL: { - range: [0, Number.MAX_VALUE], - mets: 0, - }, - }, - }; - this.getStandardMETs = function () { - return standardMETs; - }; - }) - .factory('CustomDatasetHelper', function (METDatasetHelper, Logger, $ionicPlatform) { - var cdh = {}; - - cdh.getCustomMETs = function () { - console.log('Getting custom METs', cdh.customMETs); - return cdh.customMETs; - }; - - cdh.getCustomFootprint = function () { - console.log('Getting custom footprint', cdh.customPerKmFootprint); - return cdh.customPerKmFootprint; - }; - - cdh.populateCustomMETs = function () { - let standardMETs = METDatasetHelper.getStandardMETs(); - let modeOptions = cdh.inputParams['MODE']; - let modeMETEntries = modeOptions.map((opt) => { - if (opt.met_equivalent) { - let currMET = standardMETs[opt.met_equivalent]; - return [opt.value, currMET]; - } else { - if (opt.met) { - let currMET = opt.met; - // if the user specifies a custom MET, they can't specify - // Number.MAX_VALUE since it is not valid JSON - // we assume that they specify -1 instead, and we will - // map -1 to Number.MAX_VALUE here by iterating over all the ranges - for (const rangeName in currMET) { - // console.log("Handling range ", rangeName); - currMET[rangeName].range = currMET[rangeName].range.map((i) => - i == -1 ? Number.MAX_VALUE : i, - ); - } - return [opt.value, currMET]; - } else { - console.warn( - 'Did not find either met_equivalent or met for ' + opt.value + ' ignoring entry', - ); - return undefined; - } - } - }); - cdh.customMETs = Object.fromEntries(modeMETEntries.filter((e) => angular.isDefined(e))); - console.log('After populating, custom METs = ', cdh.customMETs); - }; - - cdh.populateCustomFootprints = function () { - let modeOptions = cdh.inputParams['MODE']; - let modeCO2PerKm = modeOptions - .map((opt) => { - if (opt.range_limit_km) { - if (cdh.range_limited_motorized) { - Logger.displayError('Found two range limited motorized options', { - first: cdh.range_limited_motorized, - second: opt, - }); - } - cdh.range_limited_motorized = opt; - console.log('Found range limited motorized mode', cdh.range_limited_motorized); - } - if (angular.isDefined(opt.kgCo2PerKm)) { - return [opt.value, opt.kgCo2PerKm]; - } else { - return undefined; - } - }) - .filter((modeCO2) => angular.isDefined(modeCO2)); - cdh.customPerKmFootprint = Object.fromEntries(modeCO2PerKm); - console.log('After populating, custom perKmFootprint', cdh.customPerKmFootprint); - }; - - cdh.init = function (newConfig) { - try { - getLabelOptions(newConfig).then((inputParams) => { - console.log('Input params = ', inputParams); - cdh.inputParams = inputParams; - cdh.populateCustomMETs(); - cdh.populateCustomFootprints(); - }); - } catch (e) { - setTimeout(() => { - Logger.displayError( - 'Error in metrics-mappings while initializing custom dataset helper', - e, - ); - }, 1000); - } - }; - - $ionicPlatform.ready().then(function () { - getConfig().then((newConfig) => cdh.init(newConfig)); - }); - - return cdh; - }); diff --git a/www/js/metrics/CarbonFootprintCard.tsx b/www/js/metrics/CarbonFootprintCard.tsx index 7c9bf3891..835f20a22 100644 --- a/www/js/metrics/CarbonFootprintCard.tsx +++ b/www/js/metrics/CarbonFootprintCard.tsx @@ -3,6 +3,11 @@ import { View } from 'react-native'; import { Card, Text, useTheme } from 'react-native-paper'; import { MetricsData } from './metricsTypes'; import { cardStyles } from './MetricsTab'; +import { + getFootprintForMetrics, + getHighestFootprint, + getHighestFootprintForDistance, +} from './footprintHelper'; import { formatDateRangeOfDays, parseDataFromMetrics, @@ -13,13 +18,11 @@ import { } from './metricsHelper'; import { useTranslation } from 'react-i18next'; import BarChart from '../components/BarChart'; -import { getAngularService } from '../angular-react-helper'; import ChangeIndicator from './ChangeIndicator'; import color from 'color'; type Props = { userMetrics: MetricsData; aggMetrics: MetricsData }; const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { - const FootprintHelper = getAngularService('FootprintHelper'); const { colors } = useTheme(); const { t } = useTranslation(); @@ -49,20 +52,12 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { //setting up data to be displayed let graphRecords = []; - //set custon dataset, if the labels are custom - if (isCustomLabels(userThisWeekModeMap)) { - FootprintHelper.setUseCustomFootprint(); - } - //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago) let userPrevWeek; if (userLastWeekSummaryMap[0]) { userPrevWeek = { - low: FootprintHelper.getFootprintForMetrics(userLastWeekSummaryMap, 0), - high: FootprintHelper.getFootprintForMetrics( - userLastWeekSummaryMap, - FootprintHelper.getHighestFootprint(), - ), + low: getFootprintForMetrics(userLastWeekSummaryMap, 0), + high: getFootprintForMetrics(userLastWeekSummaryMap, getHighestFootprint()), }; graphRecords.push({ label: t('main-metrics.unlabeled'), @@ -78,11 +73,8 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { //calculate low-high and format range for past week (7 days ago -> yesterday) let userPastWeek = { - low: FootprintHelper.getFootprintForMetrics(userThisWeekSummaryMap, 0), - high: FootprintHelper.getFootprintForMetrics( - userThisWeekSummaryMap, - FootprintHelper.getHighestFootprint(), - ), + low: getFootprintForMetrics(userThisWeekSummaryMap, 0), + high: getFootprintForMetrics(userThisWeekSummaryMap, getHighestFootprint()), }; graphRecords.push({ label: t('main-metrics.unlabeled'), @@ -100,7 +92,7 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { } //calculate worst-case carbon footprint - let worstCarbon = FootprintHelper.getHighestFootprintForDistance(worstDistance); + let worstCarbon = getHighestFootprintForDistance(worstDistance); graphRecords.push({ label: t('main-metrics.labeled'), x: worstCarbon, @@ -138,11 +130,8 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { let groupRecords = []; let aggCarbon = { - low: FootprintHelper.getFootprintForMetrics(aggCarbonData, 0), - high: FootprintHelper.getFootprintForMetrics( - aggCarbonData, - FootprintHelper.getHighestFootprint(), - ), + low: getFootprintForMetrics(aggCarbonData, 0), + high: getFootprintForMetrics(aggCarbonData, getHighestFootprint()), }; console.log('testing group past week', aggCarbon); groupRecords.push({ diff --git a/www/js/metrics/CarbonTextCard.tsx b/www/js/metrics/CarbonTextCard.tsx index 9f1b4490f..bf40c4a61 100644 --- a/www/js/metrics/CarbonTextCard.tsx +++ b/www/js/metrics/CarbonTextCard.tsx @@ -4,6 +4,11 @@ import { Card, Text, useTheme } from 'react-native-paper'; import { MetricsData } from './metricsTypes'; import { cardStyles } from './MetricsTab'; import { useTranslation } from 'react-i18next'; +import { + getFootprintForMetrics, + getHighestFootprint, + getHighestFootprintForDistance, +} from './footprintHelper'; import { formatDateRangeOfDays, parseDataFromMetrics, @@ -17,7 +22,6 @@ type Props = { userMetrics: MetricsData; aggMetrics: MetricsData }; const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { const { colors } = useTheme(); const { t } = useTranslation(); - const FootprintHelper = getAngularService('FootprintHelper'); const userText = useMemo(() => { if (userMetrics?.distance?.length > 0) { @@ -46,11 +50,8 @@ const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago) if (userLastWeekSummaryMap[0]) { let userPrevWeek = { - low: FootprintHelper.getFootprintForMetrics(userLastWeekSummaryMap, 0), - high: FootprintHelper.getFootprintForMetrics( - userLastWeekSummaryMap, - FootprintHelper.getHighestFootprint(), - ), + low: getFootprintForMetrics(userLastWeekSummaryMap, 0), + high: getFootprintForMetrics(userLastWeekSummaryMap, getHighestFootprint()), }; const label = `${t('main-metrics.prev-week')} (${formatDateRangeOfDays(lastWeekDistance)})`; if (userPrevWeek.low == userPrevWeek.high) @@ -64,11 +65,8 @@ const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { //calculate low-high and format range for past week (7 days ago -> yesterday) let userPastWeek = { - low: FootprintHelper.getFootprintForMetrics(userThisWeekSummaryMap, 0), - high: FootprintHelper.getFootprintForMetrics( - userThisWeekSummaryMap, - FootprintHelper.getHighestFootprint(), - ), + low: getFootprintForMetrics(userThisWeekSummaryMap, 0), + high: getFootprintForMetrics(userThisWeekSummaryMap, getHighestFootprint()), }; const label = `${t('main-metrics.past-week')} (${formatDateRangeOfDays(thisWeekDistance)})`; if (userPastWeek.low == userPastWeek.high) @@ -80,7 +78,7 @@ const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { }); //calculate worst-case carbon footprint - let worstCarbon = FootprintHelper.getHighestFootprintForDistance(worstDistance); + let worstCarbon = getHighestFootprintForDistance(worstDistance); textList.push({ label: t('main-metrics.worst-case'), value: Math.round(worstCarbon) }); return textList; @@ -113,11 +111,8 @@ const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { let groupText = []; let aggCarbon = { - low: FootprintHelper.getFootprintForMetrics(aggCarbonData, 0), - high: FootprintHelper.getFootprintForMetrics( - aggCarbonData, - FootprintHelper.getHighestFootprint(), - ), + low: getFootprintForMetrics(aggCarbonData, 0), + high: getFootprintForMetrics(aggCarbonData, getHighestFootprint()), }; console.log('testing group past week', aggCarbon); const label = t('main-metrics.average'); diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index bdc426e62..3c28a4df0 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useState, useMemo } from 'react'; -import { getAngularService } from '../angular-react-helper'; import { View, ScrollView, useWindowDimensions } from 'react-native'; import { Appbar } from 'react-native-paper'; import NavBarButton from '../components/NavBarButton'; @@ -18,10 +17,16 @@ import CarbonTextCard from './CarbonTextCard'; import ActiveMinutesTableCard from './ActiveMinutesTableCard'; import { getAggregateData, getMetrics } from '../services/commHelper'; import { displayError, logDebug, logWarn } from '../plugin/logger'; +import useAppConfig from '../useAppConfig'; +import { ServerConnConfig } from '../types/appConfigTypes'; export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; -async function fetchMetricsFromServer(type: 'user' | 'aggregate', dateRange: DateTime[]) { +async function fetchMetricsFromServer( + type: 'user' | 'aggregate', + dateRange: DateTime[], + serverConnConfig: ServerConnConfig, +) { const query = { freq: 'D', start_time: dateRange[0].toSeconds(), @@ -30,7 +35,7 @@ async function fetchMetricsFromServer(type: 'user' | 'aggregate', dateRange: Dat is_return_aggregate: type == 'aggregate', }; if (type == 'user') return getMetrics('timestamp', query); - return getAggregateData('result/metrics/timestamp', query); + return getAggregateData('result/metrics/timestamp', query, serverConnConfig); } function getLastTwoWeeksDtRange() { @@ -41,6 +46,7 @@ function getLastTwoWeeksDtRange() { } const MetricsTab = () => { + const appConfig = useAppConfig(); const { t } = useTranslation(); const { getFormattedSpeed, speedSuffix, getFormattedDistance, distanceSuffix } = useImperialConfig(); @@ -50,15 +56,16 @@ const MetricsTab = () => { const [userMetrics, setUserMetrics] = useState(null); useEffect(() => { + if (!appConfig?.server) return; loadMetricsForPopulation('user', dateRange); loadMetricsForPopulation('aggregate', dateRange); - }, [dateRange]); + }, [dateRange, appConfig?.server]); async function loadMetricsForPopulation(population: 'user' | 'aggregate', dateRange: DateTime[]) { try { logDebug(`MetricsTab: fetching metrics for population ${population}' in date range ${JSON.stringify(dateRange)}`); - const serverResponse = await fetchMetricsFromServer(population, dateRange); + const serverResponse = await fetchMetricsFromServer(population, dateRange, appConfig.server); logDebug('MetricsTab: received metrics: ' + JSON.stringify(serverResponse)); const metrics = {}; const dataKey = population == 'user' ? 'user_metrics' : 'aggregate_metrics'; diff --git a/www/js/metrics/customMetricsHelper.ts b/www/js/metrics/customMetricsHelper.ts new file mode 100644 index 000000000..317113327 --- /dev/null +++ b/www/js/metrics/customMetricsHelper.ts @@ -0,0 +1,112 @@ +import angular from 'angular'; +import { getLabelOptions } from '../survey/multilabel/confirmHelper'; +import { displayError, displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; +import { standardMETs } from './metDataset'; +import { AppConfig } from '../types/appConfigTypes'; + +//variables to store values locally +let _customMETs; +let _customPerKmFootprint; +let _range_limited_motorized; +let _labelOptions; + +/** + * @function gets custom mets, must be initialized + * @returns the custom mets stored locally + */ +export function getCustomMETs() { + logDebug('Getting custom METs ' + JSON.stringify(_customMETs)); + return _customMETs; +} + +/** + * @function gets the custom footprint, must be initialized + * @returns custom footprint + */ +export function getCustomFootprint() { + logDebug('Getting custom footprint ' + JSON.stringify(_customPerKmFootprint)); + return _customPerKmFootprint; +} + +/** + * @function stores custom mets in local var + * needs _labelOptions, stored after gotten from config + */ +function populateCustomMETs() { + let modeOptions = _labelOptions['MODE']; + let modeMETEntries = modeOptions.map((opt) => { + if (opt.met_equivalent) { + let currMET = standardMETs[opt.met_equivalent]; + return [opt.value, currMET]; + } else { + if (opt.met) { + let currMET = opt.met; + // if the user specifies a custom MET, they can't specify + // Number.MAX_VALUE since it is not valid JSON + // we assume that they specify -1 instead, and we will + // map -1 to Number.MAX_VALUE here by iterating over all the ranges + for (const rangeName in currMET) { + // console.log("Handling range ", rangeName); + currMET[rangeName].range = currMET[rangeName].range.map((i) => + i == -1 ? Number.MAX_VALUE : i, + ); + } + return [opt.value, currMET]; + } else { + logWarn(`Did not find either met_equivalent or met for ${opt.value} ignoring entry`); + return undefined; + } + } + }); + _customMETs = Object.fromEntries(modeMETEntries.filter((e) => angular.isDefined(e))); + logDebug('After populating, custom METs = ' + JSON.stringify(_customMETs)); +} + +/** + * @function stores custom footprint in local var + * needs _inputParams which is stored after gotten from config + */ +function populateCustomFootprints() { + let modeOptions = _labelOptions['MODE']; + let modeCO2PerKm = modeOptions + .map((opt) => { + if (opt.range_limit_km) { + if (_range_limited_motorized) { + displayErrorMsg( + JSON.stringify({ first: _range_limited_motorized, second: opt }), + 'Found two range limited motorized options', + ); + } + _range_limited_motorized = opt; + logDebug(`Found range limited motorized mode - ${_range_limited_motorized}`); + } + if (angular.isDefined(opt.kgCo2PerKm)) { + return [opt.value, opt.kgCo2PerKm]; + } else { + return undefined; + } + }) + .filter((modeCO2) => angular.isDefined(modeCO2)); + _customPerKmFootprint = Object.fromEntries(modeCO2PerKm); + logDebug('After populating, custom perKmFootprint' + JSON.stringify(_customPerKmFootprint)); +} + +/** + * @function initializes the datasets based on configured label options + * calls popuplateCustomMETs and populateCustomFootprint + * @param newConfig the app config file + */ +export async function initCustomDatasetHelper(newConfig: AppConfig) { + try { + logDebug('initializing custom datasets with config' + newConfig); + const labelOptions = await getLabelOptions(newConfig); + logDebug('In custom metrics, label options = ' + JSON.stringify(labelOptions)); + _labelOptions = labelOptions; + populateCustomMETs(); + populateCustomFootprints(); + } catch (e) { + setTimeout(() => { + displayError(e, 'Error while initializing custom dataset helper'); + }, 1000); + } +} diff --git a/www/js/metrics/footprintHelper.ts b/www/js/metrics/footprintHelper.ts new file mode 100644 index 000000000..24677feaf --- /dev/null +++ b/www/js/metrics/footprintHelper.ts @@ -0,0 +1,99 @@ +import { displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; +import { getCustomFootprint } from './customMetricsHelper'; + +//variables for the highest footprint in the set and if using custom +let highestFootprint = 0; + +/** + * @function converts meters to kilometers + * @param {number} v value in meters to be converted + * @returns {number} converted value in km + */ +const mtokm = (v) => v / 1000; + +/** + * @function clears the stored highest footprint + */ +export function clearHighestFootprint() { + //need to clear for testing + highestFootprint = undefined; +} + +/** + * @function gets the footprint + * currently will only be custom, as all labels are "custom" + * fallback is json/label-options.json.sample, with MET and kgCO2 defined + * @returns the footprint or undefined + */ +function getFootprint() { + let footprint = getCustomFootprint(); + if (footprint) { + return footprint; + } else { + displayErrorMsg('failed to use custom labels', 'Error in Footprint Calculatons'); + return undefined; + } +} + +/** + * @function calculates footprint for given metrics + * @param {Array} userMetrics string mode + number distance in meters pairs + * ex: const custom_metrics = [ { key: 'walk', values: 3000 }, { key: 'bike', values: 6500 }, ]; + * @param {number} defaultIfMissing optional, carbon intensity if mode not in footprint + * @returns {number} the sum of carbon emissions for userMetrics given + */ +export 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; + if (mode == 'ON_FOOT') { + mode = 'WALKING'; + } + + if (mode in footprint) { + result += footprint[mode] * mtokm(userMetrics[i].values); + } else if (mode == 'IN_VEHICLE') { + const sum = + footprint['CAR'] + + footprint['BUS'] + + footprint['LIGHT_RAIL'] + + footprint['TRAIN'] + + footprint['TRAM'] + + footprint['SUBWAY']; + result += (sum / 6) * mtokm(userMetrics[i].values); + } else { + logWarn( + `WARNING getFootprintFromMetrics() was requested for an unknown mode: ${mode} metrics JSON: ${JSON.stringify( + userMetrics, + )}`, + ); + result += defaultIfMissing * mtokm(userMetrics[i].values); + } + } + return result; +} + +/** + * @function gets highest co2 intensity in the footprint + * @returns {number} the highest co2 intensity in the footprint + */ +export function getHighestFootprint() { + if (!highestFootprint) { + const footprint = getFootprint(); + let footprintList = []; + for (let mode in footprint) { + footprintList.push(footprint[mode]); + } + highestFootprint = Math.max(...footprintList); + } + return highestFootprint; +} + +/** + * @function gets highest theoretical footprint for given distance + * @param {number} distance in meters to calculate max footprint + * @returns max footprint for given distance + */ +export const getHighestFootprintForDistance = (distance) => getHighestFootprint() * mtokm(distance); diff --git a/www/js/metrics/metDataset.ts b/www/js/metrics/metDataset.ts new file mode 100644 index 000000000..901c17ae6 --- /dev/null +++ b/www/js/metrics/metDataset.ts @@ -0,0 +1,128 @@ +export const standardMETs = { + WALKING: { + VERY_SLOW: { + range: [0, 2.0], + mets: 2.0, + }, + SLOW: { + range: [2.0, 2.5], + mets: 2.8, + }, + MODERATE_0: { + range: [2.5, 2.8], + mets: 3.0, + }, + MODERATE_1: { + range: [2.8, 3.2], + mets: 3.5, + }, + FAST: { + range: [3.2, 3.5], + mets: 4.3, + }, + VERY_FAST_0: { + range: [3.5, 4.0], + mets: 5.0, + }, + 'VERY_FAST_!': { + range: [4.0, 4.5], + mets: 6.0, + }, + VERY_VERY_FAST: { + range: [4.5, 5], + mets: 7.0, + }, + SUPER_FAST: { + range: [5, 6], + mets: 8.3, + }, + RUNNING: { + range: [6, Number.MAX_VALUE], + mets: 9.8, + }, + }, + BICYCLING: { + VERY_VERY_SLOW: { + range: [0, 5.5], + mets: 3.5, + }, + VERY_SLOW: { + range: [5.5, 10], + mets: 5.8, + }, + SLOW: { + range: [10, 12], + mets: 6.8, + }, + MODERATE: { + range: [12, 14], + mets: 8.0, + }, + FAST: { + range: [14, 16], + mets: 10.0, + }, + VERT_FAST: { + range: [16, 19], + mets: 12.0, + }, + RACING: { + range: [20, Number.MAX_VALUE], + mets: 15.8, + }, + }, + UNKNOWN: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, + }, + IN_VEHICLE: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, + }, + CAR: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, + }, + BUS: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, + }, + LIGHT_RAIL: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, + }, + TRAIN: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, + }, + TRAM: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, + }, + SUBWAY: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, + }, + AIR_OR_HSR: { + ALL: { + range: [0, Number.MAX_VALUE], + mets: 0, + }, + }, +}; diff --git a/www/js/metrics/metHelper.ts b/www/js/metrics/metHelper.ts new file mode 100644 index 000000000..25bcc2e7e --- /dev/null +++ b/www/js/metrics/metHelper.ts @@ -0,0 +1,59 @@ +import { logDebug, logWarn } from '../plugin/logger'; +import { getCustomMETs } from './customMetricsHelper'; +import { standardMETs } from './metDataset'; + +/** + * @function gets the METs object + * @returns {object} mets either custom or standard + */ +function getMETs() { + let custom_mets = getCustomMETs(); + if (custom_mets) { + return custom_mets; + } else { + return standardMETs; + } +} + +/** + * @function checks number agains bounds + * @param num the number to check + * @param min lower bound + * @param max upper bound + * @returns {boolean} if number is within given bounds + */ +const between = (num, min, max) => num >= min && num <= max; + +/** + * @function converts meters per second to miles per hour + * @param mps meters per second speed + * @returns speed in miles per hour + */ +const mpstomph = (mps) => 2.23694 * mps; + +/** + * @function gets met for a given mode and speed + * @param {string} mode of travel + * @param {number} speed of travel in meters per second + * @param {number} defaultIfMissing default MET if mode not in METs + * @returns + */ +export function getMet(mode, speed, defaultIfMissing) { + if (mode == 'ON_FOOT') { + logDebug("getMet() converted 'ON_FOOT' to 'WALKING'"); + mode = 'WALKING'; + } + let currentMETs = getMETs(); + if (!currentMETs[mode]) { + logWarn('getMet() Illegal mode: ' + mode); + return defaultIfMissing; //So the calorie sum does not break with wrong return type + } + for (let i in currentMETs[mode]) { + if (between(mpstomph(speed), currentMETs[mode][i].range[0], currentMETs[mode][i].range[1])) { + return currentMETs[mode][i].mets; + } else if (mpstomph(speed) < 0) { + logWarn('getMet() Negative speed: ' + mpstomph(speed)); + return 0; + } + } +} diff --git a/www/js/ngApp.js b/www/js/ngApp.js index 228c2a989..84b9972c4 100644 --- a/www/js/ngApp.js +++ b/www/js/ngApp.js @@ -40,7 +40,6 @@ angular 'emission.services', 'emission.plugin.logger', 'emission.splash.referral', - 'emission.services.email', 'emission.main', 'pascalprecht.translate', 'LocalStorageModule', diff --git a/www/js/services/commHelper.ts b/www/js/services/commHelper.ts index 6dc71160a..206fc77c1 100644 --- a/www/js/services/commHelper.ts +++ b/www/js/services/commHelper.ts @@ -1,5 +1,6 @@ import { DateTime } from 'luxon'; import { logDebug } from '../plugin/logger'; +import { ServerConnConfig } from '../types/appConfigTypes'; /** * @param url URL endpoint for the request @@ -129,20 +130,17 @@ export function getMetrics(timeType: 'timestamp' | 'local_date', metricsQuery) { }); } -export function getAggregateData(path: string, data: any) { +export function getAggregateData(path: string, query, serverConnConfig: ServerConnConfig) { return new Promise((rs, rj) => { - const fullUrl = `${window['$rootScope'].connectUrl}/${path}`; - data['aggregate'] = true; + const fullUrl = `${serverConnConfig.connectUrl}/${path}`; + query['aggregate'] = true; - if (window['$rootScope'].aggregateAuth === 'no_auth') { - logDebug( - `getting aggregate data without user authentication from ${fullUrl} with arguments ${JSON.stringify( - data, - )}`, - ); + if (serverConnConfig.aggregate_call_auth == 'no_auth') { + logDebug(`getting aggregate data without user authentication from ${fullUrl} + with arguments ${JSON.stringify(query)}`); const options = { method: 'post', - data: data, + data: query, responseType: 'json', }; window['cordova'].plugin.http.sendRequest( @@ -156,14 +154,9 @@ export function getAggregateData(path: string, data: any) { }, ); } else { - logDebug( - `getting aggregate data with user authentication from ${fullUrl} with arguments ${JSON.stringify( - data, - )}`, - ); - const msgFiller = (message) => { - return Object.assign(message, data); - }; + logDebug(`getting aggregate data with user authentication from ${fullUrl} + with arguments ${JSON.stringify(query)}`); + const msgFiller = (message) => Object.assign(message, query); window['cordova'].plugins.BEMServerComm.pushGetJSON(`/${path}`, msgFiller, rs, rj); } }).catch((error) => { diff --git a/www/js/survey/enketo/EnketoModal.tsx b/www/js/survey/enketo/EnketoModal.tsx index a7aa9b26c..1d169ee9b 100644 --- a/www/js/survey/enketo/EnketoModal.tsx +++ b/www/js/survey/enketo/EnketoModal.tsx @@ -4,10 +4,8 @@ import { StyleSheet, Modal, ScrollView, SafeAreaView, Pressable } from 'react-na import { ModalProps } from 'react-native-paper'; import useAppConfig from '../../useAppConfig'; import { useTranslation } from 'react-i18next'; -import { SurveyOptions, getInstanceStr, saveResponse } from './enketoHelper'; -import { fetchUrlCached } from '../../services/commHelper'; +import { SurveyOptions, fetchSurvey, getInstanceStr, saveResponse } from './enketoHelper'; import { displayError, displayErrorMsg } from '../../plugin/logger'; -// import { transform } from 'enketo-transformer/web'; type Props = Omit & { surveyName: string; @@ -22,22 +20,6 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest }: Props) => { const enketoForm = useRef
(null); const appConfig = useAppConfig(); - async function fetchSurveyJson(url) { - const responseText = await fetchUrlCached(url); - try { - return JSON.parse(responseText); - } catch ({ name, message }) { - // not JSON, so it must be XML - return Promise.reject( - 'downloaded survey was not JSON; enketo-transformer is not available yet', - ); - /* uncomment once enketo-transformer is available */ - // if `response` is not JSON, it is an XML string and needs transformation to JSON - // const xmlText = await res.text(); - // return await transform({xform: xmlText}); - } - } - async function validateAndSave() { const valid = await enketoForm.current.validate(); if (!valid) return false; @@ -62,7 +44,7 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest }: Props) => { const formPath = appConfig.survey_info?.surveys?.[surveyName]?.formPath; if (!formPath) return console.error('No form path found for survey', surveyName); - fetchSurveyJson(formPath).then(({ form, model }) => { + fetchSurvey(formPath).then(({ form, model }) => { surveyJson.current = { form, model }; headerEl?.current.insertAdjacentHTML('afterend', form); // inject form into DOM const formEl = document.querySelector('form.or'); diff --git a/www/js/survey/enketo/answer.js b/www/js/survey/enketo/answer.js deleted file mode 100644 index cb5745037..000000000 --- a/www/js/survey/enketo/answer.js +++ /dev/null @@ -1,192 +0,0 @@ -import angular from 'angular'; -import MessageFormat from 'messageformat'; -import { getConfig } from '../../config/dynamicConfig'; - -angular - .module('emission.survey.enketo.answer', ['ionic']) - .factory('EnketoSurveyAnswer', function ($http) { - /** - * @typedef EnketoAnswerData - * @type {object} - * @property {string} label - display label (this value is use for displaying on the button) - * @property {string} ts - the timestamp at which the survey was filled out (in seconds) - * @property {string} fmt_time - the formatted timestamp at which the survey was filled out - * @property {string} name - survey name - * @property {string} version - survey version - * @property {string} xmlResponse - survey answer XML string - * @property {string} jsonDocResponse - survey answer JSON object - */ - - /** - * @typedef EnketoAnswer - * @type {object} - * @property {EnketoAnswerData} data - answer data - * @property {{[labelField:string]: string}} [labels] - virtual labels (populated by populateLabels method) - */ - - /** - * @typedef EnketoSurveyConfig - * @type {{ - * [surveyName:string]: { - * formPath: string; - * labelFields: string[]; - * version: number; - * compatibleWith: number; - * } - * }} - */ - - const LABEL_FUNCTIONS = { - UseLabelTemplate: (xmlDoc, name) => { - return _lazyLoadConfig().then((configSurveys) => { - const config = configSurveys[name]; // config for this survey - const lang = i18next.resolvedLanguage; - const labelTemplate = config.labelTemplate?.[lang]; - - if (!labelTemplate) return 'Answered'; // no template given in config - if (!config.labelVars) return labelTemplate; // if no vars given, nothing to interpolate, - // so we return the unaltered template - - // gather vars that will be interpolated into the template according to the survey config - const labelVars = {}; - for (let lblVar in config.labelVars) { - const fieldName = config.labelVars[lblVar].key; - let fieldStr = _getAnswerByTagName(xmlDoc, fieldName); - if (fieldStr == '') fieldStr = null; - if (config.labelVars[lblVar].type == 'length') { - const fieldMatches = fieldStr?.split(' '); - labelVars[lblVar] = fieldMatches?.length || 0; - } else { - throw new Error(`labelVar type ${config.labelVars[lblVar].type} is not supported!`); - } - } - - // use MessageFormat interpolate the label template with the label vars - const mf = new MessageFormat(lang); - const label = mf.compile(labelTemplate)(labelVars); - return label.replace(/^[ ,]+|[ ,]+$/g, ''); // trim leading and trailing spaces and commas - }); - }, - }; - - /** @type {EnketoSurveyConfig} _config */ - let _config; - - /** - * _getAnswerByTagName lookup for the survey answer by tag name form the given XML document. - * @param {XMLDocument} xmlDoc survey answer object - * @param {string} tagName tag name - * @returns {string} answer string. If not found, return "\" - */ - function _getAnswerByTagName(xmlDoc, tagName) { - const vals = xmlDoc.getElementsByTagName(tagName); - const val = vals.length ? vals[0].innerHTML : null; - if (!val) return ''; - return val; - } - - /** - * _lazyLoadConfig load enketo survey config. If already loaded, return the cached config - * @returns {Promise} enketo survey config - */ - function _lazyLoadConfig() { - if (_config !== undefined) { - return Promise.resolve(_config); - } - return getConfig().then((newConfig) => { - Logger.log('Resolved UI_CONFIG_READY promise in answer.js, filling in templates'); - _config = newConfig.survey_info.surveys; - return _config; - }); - } - - /** - * filterByNameAndVersion filter the survey answers by survey name and their version. - * The version for filtering is specified in enketo survey `compatibleWith` config. - * The stored survey answer version must be greater than or equal to `compatibleWith` to be included. - * @param {string} name survey name (defined in enketo survey config) - * @param {EnketoAnswer[]} answers survey answers - * (usually retrieved by calling UnifiedDataLoader.getUnifiedMessagesForInterval('manual/survey_response', tq)) method. - * @return {Promise} filtered survey answers - */ - function filterByNameAndVersion(name, answers) { - return _lazyLoadConfig().then((config) => - answers.filter( - (answer) => - answer.data.name === name && answer.data.version >= config[name].compatibleWith, - ), - ); - } - - /** - * resolve answer label for the survey - * @param {string} name survey name - * @param {XMLDocument} xmlDoc survey answer object - * @returns {Promise} label string Promise - */ - function resolveLabel(name, xmlDoc) { - // Some studies may want a custom label function for their survey. - // Those can be added in LABEL_FUNCTIONS with the survey name as the key. - // Otherwise, UseLabelTemplate will create a label using the template in the config - if (LABEL_FUNCTIONS[name]) return LABEL_FUNCTIONS[name](xmlDoc); - return LABEL_FUNCTIONS.UseLabelTemplate(xmlDoc, name); - } - - /** - * resolve timestamps label from the survey response - * @param {XMLDocument} xmlDoc survey answer object - * @param {object} trip trip object - * @returns {object} object with `start_ts` and `end_ts` - * - null if no timestamps are resolved - * - undefined if the timestamps are invalid - */ - function resolveTimestamps(xmlDoc, timelineEntry) { - // check for Date and Time fields - const startDate = xmlDoc.getElementsByTagName('Start_date')?.[0]?.innerHTML; - let startTime = xmlDoc.getElementsByTagName('Start_time')?.[0]?.innerHTML; - const endDate = xmlDoc.getElementsByTagName('End_date')?.[0]?.innerHTML; - let endTime = xmlDoc.getElementsByTagName('End_time')?.[0]?.innerHTML; - - // if any of the fields are missing, return null - if (!startDate || !startTime || !endDate || !endTime) return null; - - const timezone = - timelineEntry.start_local_dt?.timezone || - timelineEntry.enter_local_dt?.timezone || - timelineEntry.end_local_dt?.timezone || - timelineEntry.exit_local_dt?.timezone; - // split by + or - to get time without offset - startTime = startTime.split(/\-|\+/)[0]; - endTime = endTime.split(/\-|\+/)[0]; - - let additionStartTs = moment.tz(startDate + 'T' + startTime, timezone).unix(); - let additionEndTs = moment.tz(endDate + 'T' + endTime, timezone).unix(); - - if (additionStartTs > additionEndTs) { - return undefined; // if the start time is after the end time, this is an invalid response - } - - /* Enketo survey time inputs are only precise to the minute, while trips/places are precise to - the millisecond. To avoid precision issues, we will check if the start/end timestamps from - the survey response are within the same minute as the start/end or enter/exit timestamps. - If so, we will use the exact trip/place timestamps */ - const entryStartTs = timelineEntry.start_ts || timelineEntry.enter_ts; - const entryEndTs = timelineEntry.end_ts || timelineEntry.exit_ts; - if (additionStartTs - (additionStartTs % 60) == entryStartTs - (entryStartTs % 60)) - additionStartTs = entryStartTs; - if (additionEndTs - (additionEndTs % 60) == entryEndTs - (entryEndTs % 60)) - additionEndTs = entryEndTs; - - // return unix timestamps in seconds - return { - start_ts: additionStartTs, - end_ts: additionEndTs, - }; - } - - return { - filterByNameAndVersion, - resolveLabel, - resolveTimestamps, - }; - }); diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index 4a46e8fb3..c2fd3314d 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -1,9 +1,15 @@ -import { getAngularService } from '../../angular-react-helper'; import { Form } from 'enketo-core'; +import { transform } from 'enketo-transformer/web'; import { XMLParser } from 'fast-xml-parser'; import i18next from 'i18next'; -import { logDebug } from '../../plugin/logger'; +import MessageFormat from '@messageformat/core'; +import { logDebug, logInfo } from '../../plugin/logger'; +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 { CompositeTrip, ConfirmedPlace, TimelineEntry } from '../../types/diaryTypes'; export type PrefillFields = { [key: string]: string }; @@ -15,6 +21,104 @@ export type SurveyOptions = { dataKey?: string; }; +type EnketoResponseData = { + label: string; //display label (this value is use for displaying on the button) + ts: string; //the timestamp at which the survey was filled out (in seconds) + fmt_time: string; //the formatted timestamp at which the survey was filled out + name: string; //survey name + version: string; //survey version + xmlResponse: string; //survey response as XML string + jsonDocResponse: string; //survey response as JSON object +}; + +type EnketoResponse = { + data: EnketoResponseData; //survey response data + metadata: any; +}; + +const LABEL_FUNCTIONS = { + UseLabelTemplate: async (xmlDoc: XMLDocument, name: string) => { + let appConfig = await getConfig(); + const configSurveys = appConfig.survey_info.surveys; + + const config = configSurveys[name]; // config for this survey + const lang = i18next.resolvedLanguage; + const labelTemplate = config.labelTemplate?.[lang]; + + if (!labelTemplate) return 'Answered'; // no template given in config + if (!config.labelVars) return labelTemplate; // if no vars given, nothing to interpolate, + // so we return the unaltered template + + // gather vars that will be interpolated into the template according to the survey config + const labelVars = {}; + for (let lblVar in config.labelVars) { + const fieldName = config.labelVars[lblVar].key; + let fieldStr = _getAnswerByTagName(xmlDoc, fieldName); + if (fieldStr == '') fieldStr = null; + if (config.labelVars[lblVar].type == 'length') { + const fieldMatches = fieldStr?.split(' '); + labelVars[lblVar] = fieldMatches?.length || 0; + } else { + throw new Error(`labelVar type ${config.labelVars[lblVar].type} is not supported!`); + } + } + + // use MessageFormat interpolate the label template with the label vars + const mf = new MessageFormat(lang); + const label = mf.compile(labelTemplate)(labelVars); + return label.replace(/^[ ,]+|[ ,]+$/g, ''); // trim leading and trailing spaces and commas + }, +}; + +/** + * _getAnswerByTagName look up how a question was answered, given the survey response + * and the tag name of the question + * @param {XMLDocument} xmlDoc survey response as XML object + * @param {string} tagName tag name of the question + * @returns {string} answer string. If not found, return "\" + */ +function _getAnswerByTagName(xmlDoc: XMLDocument, tagName: string) { + const vals = xmlDoc.getElementsByTagName(tagName); + const val = vals.length ? vals[0].innerHTML : null; + if (!val) return ''; + return val; +} + +/** @type {EnketoSurveyConfig} _config */ +let _config: EnketoSurveyConfig; + +/** + * filterByNameAndVersion filter the survey responses by survey name and their version. + * The version for filtering is specified in enketo survey `compatibleWith` config. + * The survey version of the response must be greater than or equal to `compatibleWith` to be included. + * @param {string} name survey name (defined in enketo survey config) + * @param {EnketoResponse[]} responses An array of previously recorded responses to Enketo surveys + * (presumably having been retrieved from unifiedDataLoader) + * @return {Promise} filtered survey responses + */ +export function filterByNameAndVersion(name: string, responses: EnketoResponse[]) { + return getConfig().then((config) => + responses.filter( + (r) => + r.data.name === name && r.data.version >= config.survey_info.surveys[name].compatibleWith, + ), + ); +} + +/** + * resolve a label for the survey response + * @param {string} name survey name + * @param {XMLDocument} xmlDoc survey response as XML object + * @returns {Promise} label string Promise + */ +export async function resolveLabel(name: string, xmlDoc: XMLDocument) { + // Some studies may want a custom label function for their survey. + // Those can be added in LABEL_FUNCTIONS with the survey name as the key. + // Otherwise, UseLabelTemplate will create a label using the template in the config + if (LABEL_FUNCTIONS[name]) return await LABEL_FUNCTIONS[name](xmlDoc); + return await LABEL_FUNCTIONS.UseLabelTemplate(xmlDoc, name); +} + /** * @param xmlModel the blank XML model to be prefilled * @param prefillFields an object with keys that are the XML tag names and values that are the values to be prefilled @@ -22,7 +126,7 @@ export type SurveyOptions = { */ function getXmlWithPrefills(xmlModel: string, prefillFields: PrefillFields) { if (!prefillFields) return null; - const xmlParser = new window.DOMParser(); + const xmlParser = new DOMParser(); const xmlDoc = xmlParser.parseFromString(xmlModel, 'text/xml'); for (const [tagName, value] of Object.entries(prefillFields)) { @@ -45,6 +149,62 @@ export function getInstanceStr(xmlModel: string, opts: SurveyOptions): string | return null; } +/** + * resolve timestamps label from the survey response + * @param {XMLDocument} xmlDoc survey response as XML object + * @param {object} timelineEntry trip or place object + * @returns {object} object with `start_ts` and `end_ts` + * - null if no timestamps are resolved + * - undefined if the timestamps are invalid + */ +export function resolveTimestamps(xmlDoc: XMLDocument, timelineEntry: TimelineEntry) { + // check for Date and Time fields + const startDate = xmlDoc.getElementsByTagName('Start_date')?.[0]?.innerHTML; + let startTime = xmlDoc.getElementsByTagName('Start_time')?.[0]?.innerHTML; + const endDate = xmlDoc.getElementsByTagName('End_date')?.[0]?.innerHTML; + let endTime = xmlDoc.getElementsByTagName('End_time')?.[0]?.innerHTML; + + // if any of the fields are missing, return null + if (!startDate || !startTime || !endDate || !endTime) return null; + + const timezone = + (timelineEntry as CompositeTrip).start_local_dt?.timezone || + (timelineEntry as ConfirmedPlace).enter_local_dt?.timezone || + (timelineEntry as CompositeTrip).end_local_dt?.timezone || + (timelineEntry as ConfirmedPlace).exit_local_dt?.timezone; + // split by + or - to get time without offset + startTime = startTime.split(/\-|\+/)[0]; + endTime = endTime.split(/\-|\+/)[0]; + + let additionStartTs = DateTime.fromISO(startDate + 'T' + startTime, { + zone: timezone, + }).toSeconds(); + let additionEndTs = DateTime.fromISO(endDate + 'T' + endTime, { zone: timezone }).toSeconds(); + + if (additionStartTs > additionEndTs) { + return undefined; // if the start time is after the end time, this is an invalid response + } + + /* Enketo survey time inputs are only precise to the minute, while trips/places are precise to + the millisecond. To avoid precision issues, we will check if the start/end timestamps from + the survey response are within the same minute as the start/end or enter/exit timestamps. + If so, we will use the exact trip/place timestamps */ + const entryStartTs = + (timelineEntry as CompositeTrip).start_ts || (timelineEntry as ConfirmedPlace).enter_ts; + const entryEndTs = + (timelineEntry as CompositeTrip).end_ts || (timelineEntry as ConfirmedPlace).exit_ts; + if (additionStartTs - (additionStartTs % 60) == entryStartTs - (entryStartTs % 60)) + additionStartTs = entryStartTs; + if (additionEndTs - (additionEndTs % 60) == entryEndTs - (entryEndTs % 60)) + additionEndTs = entryEndTs; + + // return unix timestamps in seconds + return { + start_ts: additionStartTs, + end_ts: additionEndTs, + }; +} + /** * @param surveyName the name of the survey (e.g. "TimeUseSurvey") * @param enketoForm the Form object from enketo-core that contains this survey @@ -52,14 +212,18 @@ export function getInstanceStr(xmlModel: string, opts: SurveyOptions): string | * @param opts object with SurveyOptions like 'timelineEntry' or 'dataKey' * @returns Promise of the saved result, or an Error if there was a problem */ -export function saveResponse(surveyName: string, enketoForm: Form, appConfig, opts: SurveyOptions) { - const EnketoSurveyAnswer = getAngularService('EnketoSurveyAnswer'); +export function saveResponse( + surveyName: string, + enketoForm: Form, + appConfig: AppConfig, + opts: SurveyOptions, +) { const xmlParser = new window.DOMParser(); const xmlResponse = enketoForm.getDataStr(); const xmlDoc = xmlParser.parseFromString(xmlResponse, 'text/xml'); const xml2js = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: 'attr' }); const jsonDocResponse = xml2js.parse(xmlResponse); - return EnketoSurveyAnswer.resolveLabel(surveyName, xmlDoc) + return resolveLabel(surveyName, xmlDoc) .then((rsLabel) => { const data: any = { label: rsLabel, @@ -69,15 +233,14 @@ export function saveResponse(surveyName: string, enketoForm: Form, appConfig, op jsonDocResponse, }; if (opts.timelineEntry) { - let timestamps = EnketoSurveyAnswer.resolveTimestamps(xmlDoc, opts.timelineEntry); + let timestamps = resolveTimestamps(xmlDoc, opts.timelineEntry); if (timestamps === undefined) { // timestamps were resolved, but they are invalid return new Error(i18next.t('survey.enketo-timestamps-invalid')); //"Timestamps are invalid. Please ensure that the start time is before the end time."); } // if timestamps were not resolved from the survey, we will use the trip or place timestamps - timestamps ||= opts.timelineEntry; - data.start_ts = timestamps.start_ts || timestamps.enter_ts; - data.end_ts = timestamps.end_ts || timestamps.exit_ts; + data.start_ts = timestamps?.start_ts || opts.timelineEntry.enter_ts; + data.end_ts = timestamps?.end_ts || opts.timelineEntry.exit_ts; // UUID generated using this method https://stackoverflow.com/a/66332305 data.match_id = URL.createObjectURL(new Blob([])).slice(-36); } else { @@ -92,10 +255,11 @@ export function saveResponse(surveyName: string, enketoForm: Form, appConfig, op .then((data) => data); } -const _getMostRecent = (answers) => { - answers.sort((a, b) => a.metadata.write_ts < b.metadata.write_ts); - console.log('first answer is ', answers[0], ' last answer is ', answers[answers.length - 1]); - return answers[0]; +const _getMostRecent = (responses) => { + responses.sort((a, b) => a.metadata.write_ts < b.metadata.write_ts); + logDebug(`_getMostRecent: first response is ${responses[0]}; + last response is ${responses.slice(-1)[0]}`); + return responses[0]; }; /* @@ -109,7 +273,17 @@ export function loadPreviousResponseForSurvey(dataKey: string) { const tq = window['cordova'].plugins.BEMUserCache.getAllTimeQuery(); logDebug('loadPreviousResponseForSurvey: dataKey = ' + dataKey + '; tq = ' + tq); const getMethod = window['cordova'].plugins.BEMUserCache.getMessagesForInterval; - return getUnifiedDataForInterval(dataKey, tq, getMethod).then((answers) => - _getMostRecent(answers), + return getUnifiedDataForInterval(dataKey, tq, getMethod).then((responses) => + _getMostRecent(responses), ); } + +export async function fetchSurvey(url: string) { + const responseText = await fetchUrlCached(url); + try { + return JSON.parse(responseText); + } catch (e) { + logDebug(`${e.name}: Survey was not in JSON format. Attempting to transform XML -> JSON...`); + return await transform({ xform: responseText }); + } +} diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index 4c2be1012..6a91095ac 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -13,7 +13,6 @@ export let inputDetails: InputDetails; export async function getLabelOptions(appConfigParam?) { if (appConfigParam) appConfig = appConfigParam; if (labelOptions) return labelOptions; - if (appConfig.label_options) { const labelOptionsJson = await fetchUrlCached(appConfig.label_options); logDebug( diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts new file mode 100644 index 000000000..1a2e50722 --- /dev/null +++ b/www/js/types/appConfigTypes.ts @@ -0,0 +1,27 @@ +// WIP: type definitions for the 'dynamic config' spec +// examples of configs: https://github.com/e-mission/nrel-openpath-deploy-configs/tree/main/configs + +export type AppConfig = { + server: ServerConnConfig; + survey_info: { + 'trip-labels': 'MULTILABEL' | 'ENKETO'; + surveys: EnketoSurveyConfig; + }; + [k: string]: any; // TODO fill in all the other fields +}; + +export type ServerConnConfig = { + connectUrl: `https://${string}`; + aggregate_call_auth: 'no_auth' | 'user_only' | 'never'; +}; + +export type EnketoSurveyConfig = { + [surveyName: string]: { + formPath: string; + labelTemplate: { [lang: string]: string }; + labelVars: { [activity: string]: { [key: string]: string; type: string } }; + version: number; + compatibleWith: number; + dataKey?: string; + }; +}; diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 722d42d8d..ef520881a 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -21,10 +21,15 @@ export type ConfirmedPlace = { additions: UserInputEntry[]; cleaned_place: ObjectId; ending_trip: ObjectId; - enter_fmt_time: string; // ISO string 2023-10-31T12:00:00.000-04:00 + enter_fmt_time: string; // ISO string e.g. 2023-10-31T12:00:00.000-04:00 enter_local_dt: LocalDt; enter_ts: number; // Unix timestamp + exit_fmt_time: string; // ISO string e.g. 2023-10-31T12:00:00.000-04:00 + exit_local_dt: LocalDt; + exit_ts: number; // Unix timestamp + key: string; location: Geometry; + origin_key: string; raw_places: ObjectId[]; source: string; user_input: UserInput; @@ -116,6 +121,11 @@ export type TimelineEntry = ConfirmedPlace | CompositeTrip; export type TimestampRange = { start_ts: number; end_ts: number }; +/* Type guard to disambiguate timeline entries as either trips or places + If it has a 'start_ts' and 'end_ts', it's a trip. Else, it's a place. */ +export const isTrip = (entry: TimelineEntry): entry is CompositeTrip => + entry.hasOwnProperty('start_ts') && entry.hasOwnProperty('end_ts'); + /* These properties aren't received from the server, but are derived from the above properties. They are used in the UI to display trip/place details and are computed by the useDerivedProperties hook. */ export type DerivedProperties = { diff --git a/www/json/emailConfig.json.sample b/www/json/emailConfig.json.sample deleted file mode 100644 index b1e28e63b..000000000 --- a/www/json/emailConfig.json.sample +++ /dev/null @@ -1,3 +0,0 @@ -{ - "address": "shankari@eecs.berkeley.edu" -} \ No newline at end of file