diff --git a/package.cordovabuild.json b/package.cordovabuild.json
index 6f44bb60d..d5c621310 100644
--- a/package.cordovabuild.json
+++ b/package.cordovabuild.json
@@ -98,7 +98,8 @@
},
"cordova-plugin-bluetooth-classic-serial-port": {},
"cordova-custom-config": {},
- "com.unarin.cordova.beacon": {}
+ "com.unarin.cordova.beacon": {},
+ "cordova-plugin-statusbar": {}
}
},
"dependencies": {
@@ -136,8 +137,9 @@
"cordova-plugin-bluetooth-classic-serial-port": "git+https://github.com/e-mission/cordova-plugin-bluetooth-classic-serial-port.git",
"cordova-custom-config": "^5.1.1",
"com.unarin.cordova.beacon": "github:e-mission/cordova-plugin-ibeacon",
+ "cordova-plugin-statusbar": "^4.0.0",
"core-js": "^2.5.7",
- "e-mission-common": "github:JGreenlee/e-mission-common#semver:0.5.4",
+ "e-mission-common": "github:JGreenlee/e-mission-common#semver:0.6.1",
"enketo-core": "^6.1.7",
"enketo-transformer": "^4.0.0",
"fast-xml-parser": "^4.2.2",
@@ -156,7 +158,8 @@
"react-dom": "~18.2.0",
"react-i18next": "^13.5.0",
"react-native-paper": "^5.11.0",
- "react-native-paper-dates": "^0.18.12",
+ "react-native-paper-dates": "0.21.8",
+ "react-native-paper-tabs": "^0.10.4",
"react-native-safe-area-context": "^4.6.3",
"react-native-screens": "^3.22.0",
"react-native-vector-icons": "^9.2.0",
diff --git a/package.serve.json b/package.serve.json
index a96c29657..a0d86cafc 100644
--- a/package.serve.json
+++ b/package.serve.json
@@ -65,7 +65,7 @@
"chartjs-adapter-luxon": "^1.3.1",
"chartjs-plugin-annotation": "^3.0.1",
"core-js": "^2.5.7",
- "e-mission-common": "github:JGreenlee/e-mission-common#semver:0.5.4",
+ "e-mission-common": "github:JGreenlee/e-mission-common#semver:0.6.1",
"enketo-core": "^6.1.7",
"enketo-transformer": "^4.0.0",
"fast-xml-parser": "^4.2.2",
@@ -83,7 +83,8 @@
"react-dom": "~18.2.0",
"react-i18next": "^13.5.0",
"react-native-paper": "^5.11.0",
- "react-native-paper-dates": "^0.18.12",
+ "react-native-paper-dates": "0.21.8",
+ "react-native-paper-tabs": "^0.10.4",
"react-native-safe-area-context": "^4.6.3",
"react-native-screens": "^3.22.0",
"react-native-vector-icons": "^9.2.0",
diff --git a/setup/setup_native.sh b/setup/setup_native.sh
index 05624a693..a7c396ab2 100644
--- a/setup/setup_native.sh
+++ b/setup/setup_native.sh
@@ -121,7 +121,7 @@ sed -i -e "s|/usr/bin/env node|/usr/bin/env node --unhandled-rejections=strict|"
npx cordova prepare$PLATFORMS
-EXPECTED_COUNT=25
+EXPECTED_COUNT=26
INSTALLED_COUNT=`npx cordova plugin list | wc -l`
echo "Found $INSTALLED_COUNT plugins, expected $EXPECTED_COUNT"
if [ $INSTALLED_COUNT -lt $EXPECTED_COUNT ];
diff --git a/www/__tests__/LoadMoreButton.test.tsx b/www/__tests__/LoadMoreButton.test.tsx
deleted file mode 100644
index b3c9cc956..000000000
--- a/www/__tests__/LoadMoreButton.test.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-/**
- * @jest-environment jsdom
- */
-import React from 'react';
-import { render, fireEvent, waitFor, screen } from '@testing-library/react-native';
-import LoadMoreButton from '../js/diary/list/LoadMoreButton';
-
-describe('LoadMoreButton', () => {
- it('renders correctly', async () => {
- render( {}}>{});
- await waitFor(() => {
- expect(screen.getByTestId('load-button')).toBeTruthy();
- });
- }, 15000);
-
- it('calls onPressFn when clicked', async () => {
- const mockFn = jest.fn();
- const { getByTestId } = render({});
- const loadButton = getByTestId('load-button');
- fireEvent.press(loadButton);
- await waitFor(() => {
- expect(mockFn).toHaveBeenCalled();
- });
- }, 15000);
-});
diff --git a/www/__tests__/confirmHelper.test.ts b/www/__tests__/confirmHelper.test.ts
index 72cdfb773..42f5b686c 100644
--- a/www/__tests__/confirmHelper.test.ts
+++ b/www/__tests__/confirmHelper.test.ts
@@ -5,8 +5,7 @@ import {
inferFinalLabels,
labelInputDetailsForTrip,
labelKeyToReadable,
- labelKeyToRichMode,
- labelOptionByValue,
+ labelKeyToText,
readableLabelToKey,
verifiabilityForTrip,
} from '../js/survey/multilabel/confirmHelper';
@@ -16,11 +15,7 @@ import { CompositeTrip, UserInputEntry } from '../js/types/diaryTypes';
import { UserInputMap } from '../js/TimelineContext';
window['i18next'] = initializedI18next;
-const fakeAppConfig = {
- label_options: 'json/label-options.json.sample',
-};
const fakeAppConfigWithModeOfStudy = {
- ...fakeAppConfig,
intro: {
mode_studied: 'walk',
},
@@ -61,10 +56,10 @@ jest.mock('../js/services/commHelper', () => ({
}));
describe('confirmHelper', () => {
- it('returns labelOptions given an appConfig', async () => {
- const labelOptions = await getLabelOptions(fakeAppConfig);
+ it('returns default labelOptions given a blank appConfig', async () => {
+ const labelOptions = await getLabelOptions({});
expect(labelOptions).toBeTruthy();
- expect(labelOptions.MODE[0].text).toEqual('Walk'); // translation is filled in
+ expect(labelOptions.MODE[0].value).toEqual('walk');
});
it('returns base labelInputDetails for a labelUserInput which does not have mode of study', () => {
@@ -112,10 +107,10 @@ describe('confirmHelper', () => {
it('looks up a rich mode from a label key, or humanizes the label key if there is no rich mode', () => {
const key = 'walk';
- const richMode = labelKeyToRichMode(key);
+ const richMode = labelKeyToText(key);
expect(richMode).toEqual('Walk');
const key2 = 'scooby_doo_mystery_machine';
- const readableMode = labelKeyToRichMode(key2);
+ const readableMode = labelKeyToText(key2);
expect(readableMode).toEqual('Scooby Doo Mystery Machine');
});
diff --git a/www/__tests__/customMetricsHelper.test.ts b/www/__tests__/customMetricsHelper.test.ts
deleted file mode 100644
index 77bb009b0..000000000
--- a/www/__tests__/customMetricsHelper.test.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { getConfig } from '../js/config/dynamicConfig';
-import {
- _test_clearCustomMetrics,
- getCustomFootprint,
- getCustomMETs,
- initCustomDatasetHelper,
-} from '../js/metrics/customMetricsHelper';
-import { mockBEMUserCache } from '../__mocks__/cordovaMocks';
-import fakeLabels from '../__mocks__/fakeLabels.json';
-import fakeConfig from '../__mocks__/fakeConfig.json';
-
-mockBEMUserCache(fakeConfig);
-
-beforeEach(() => {
- _test_clearCustomMetrics();
-});
-
-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('has no footprint or mets before initialized', () => {
- expect(getCustomFootprint()).toBeUndefined();
- expect(getCustomMETs()).toBeUndefined();
-});
-
-it('gets the custom mets', async () => {
- const appConfig = await getConfig();
- await initCustomDatasetHelper(appConfig!);
- //expecting the keys from fakeLabels.json NOT metrics/metDataset.ts
- 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!);
- //numbers from fakeLabels.json
- expect(getCustomFootprint()).toMatchObject({
- walk: 0,
- bike: 0,
- bikeshare: 0,
- 'e-bike': 0.00728,
- scootershare: 0.00894,
- drove_alone: 0.22031,
- });
-});
diff --git a/www/__tests__/diaryHelper.test.ts b/www/__tests__/diaryHelper.test.ts
index 58cd20246..416e0979f 100644
--- a/www/__tests__/diaryHelper.test.ts
+++ b/www/__tests__/diaryHelper.test.ts
@@ -1,99 +1,136 @@
import {
- getFormattedDate,
- isMultiDay,
- getFormattedDateAbbr,
- getFormattedTimeRange,
getDetectedModes,
+ getFormattedSectionProperties,
+ primarySectionForTrip,
+ getLocalTimeString,
} from '../js/diary/diaryHelper';
import { base_modes } from 'e-mission-common';
import initializedI18next from '../js/i18nextInit';
+import { getImperialConfig } from '../js/config/useImperialConfig';
+import { LocalDt } from '../js/types/serverData';
window['i18next'] = initializedI18next;
-it('returns a formatted date', () => {
- expect(getFormattedDate('2023-09-18T00:00:00-07:00')).toBe('Mon, September 18, 2023');
- expect(getFormattedDate('')).toBeUndefined();
- expect(getFormattedDate('2023-09-18T00:00:00-07:00', '2023-09-21T00:00:00-07:00')).toBe(
- 'Mon, September 18, 2023 - Thu, September 21, 2023',
- );
-});
+describe('diaryHelper', () => {
+ /* fake trips with 'distance' in their section summaries
+ ('count' and 'duration' are not used bygetDetectedModes) */
+ let myFakeTrip = {
+ distance: 6729.0444371031606,
+ cleaned_section_summary: {
+ // count: {...}
+ // duration: {...}
+ distance: {
+ BICYCLING: 6013.73657416706,
+ WALKING: 715.3078629361006,
+ },
+ },
+ } as any;
-it('returns an abbreviated formatted date', () => {
- expect(getFormattedDateAbbr('2023-09-18T00:00:00-07:00')).toBe('Mon, Sep 18');
- expect(getFormattedDateAbbr('')).toBeUndefined();
- expect(getFormattedDateAbbr('2023-09-18T00:00:00-07:00', '2023-09-21T00:00:00-07:00')).toBe(
- 'Mon, Sep 18 - Thu, Sep 21',
- );
-});
+ let myFakeTrip2 = {
+ ...myFakeTrip,
+ inferred_section_summary: {
+ // count: {...}
+ // duration: {...}
+ distance: {
+ BICYCLING: 6729.0444371031606,
+ },
+ },
+ };
-it('returns a human readable time range', () => {
- expect(getFormattedTimeRange('2023-09-18T00:00:00-07:00', '2023-09-18T00:00:00-09:20')).toBe(
- '2 hours',
- );
- expect(getFormattedTimeRange('2023-09-18T00:00:00-07:00', '2023-09-18T00:00:00-09:30')).toBe(
- '3 hours',
- );
- expect(getFormattedTimeRange('', '2023-09-18T00:00:00-09:30')).toBeFalsy();
-});
+ let myFakeDetectedModes = [
+ { mode: 'BICYCLING', icon: 'bike', color: base_modes.mode_colors['green'], pct: 89 },
+ { mode: 'WALKING', icon: 'walk', color: base_modes.mode_colors['blue'], pct: 11 },
+ ];
-it('returns a Base Mode for a given key', () => {
- expect(base_modes.get_base_mode_by_key('WALKING')).toMatchObject({
- icon: 'walk',
- color: base_modes.mode_colors['blue'],
- });
- expect(base_modes.get_base_mode_by_key('MotionTypes.WALKING')).toMatchObject({
- icon: 'walk',
- color: base_modes.mode_colors['blue'],
- });
- expect(base_modes.get_base_mode_by_key('I made this type up')).toMatchObject({
- icon: 'help',
- color: base_modes.mode_colors['grey'],
+ let myFakeDetectedModes2 = [
+ { mode: 'BICYCLING', icon: 'bike', color: base_modes.mode_colors['green'], pct: 100 },
+ ];
+
+ describe('getDetectedModes', () => {
+ it('returns the detected modes, with percentages, for a trip', () => {
+ expect(getDetectedModes(myFakeTrip)).toEqual(myFakeDetectedModes);
+ expect(getDetectedModes(myFakeTrip2)).toEqual(myFakeDetectedModes2);
+ expect(getDetectedModes({} as any)).toEqual([]); // empty trip, no sections, no modes
+ });
});
-});
-it('returns true/false is multi day', () => {
- expect(isMultiDay('2023-09-18T00:00:00-07:00', '2023-09-19T00:00:00-07:00')).toBeTruthy();
- expect(isMultiDay('2023-09-18T00:00:00-07:00', '2023-09-18T00:00:00-09:00')).toBeFalsy();
- expect(isMultiDay('', '2023-09-18T00:00:00-09:00')).toBeFalsy();
-});
+ const myFakeTripWithSections = {
+ sections: [
+ {
+ start_fmt_time: '2024-09-18T08:30:00',
+ start_local_dt: { year: 2024, month: 9, day: 18, hour: 8, minute: 30, second: 0 },
+ end_fmt_time: '2024-09-18T08:45:00',
+ end_local_dt: { year: 2024, month: 9, day: 18, hour: 8, minute: 45, second: 0 },
+ distance: 1000,
+ sensed_mode_str: 'WALKING',
+ },
+ {
+ start_fmt_time: '2024-09-18T08:45:00',
+ start_local_dt: { year: 2024, month: 9, day: 18, hour: 8, minute: 45, second: 0 },
+ end_fmt_time: '2024-09-18T09:00:00',
+ end_local_dt: { year: 2024, month: 9, day: 18, hour: 9, minute: 0, second: 0 },
+ distance: 2000,
+ sensed_mode_str: 'BICYCLING',
+ },
+ ],
+ } as any;
-/* fake trips with 'distance' in their section summaries
- ('count' and 'duration' are not used bygetDetectedModes) */
-let myFakeTrip = {
- distance: 6729.0444371031606,
- cleaned_section_summary: {
- // count: {...}
- // duration: {...}
- distance: {
- BICYCLING: 6013.73657416706,
- WALKING: 715.3078629361006,
- },
- },
-} as any;
+ const imperialConfg = getImperialConfig(true);
-let myFakeTrip2 = {
- ...myFakeTrip,
- inferred_section_summary: {
- // count: {...}
- // duration: {...}
- distance: {
- BICYCLING: 6729.0444371031606,
- },
- },
-};
+ describe('getFormattedSectionProperties', () => {
+ it('returns the formatted section properties for a trip', () => {
+ expect(getFormattedSectionProperties(myFakeTripWithSections, imperialConfg)).toEqual([
+ {
+ startTime: '8:30 AM',
+ duration: '15 minutes',
+ distance: '0.62',
+ distanceSuffix: 'mi',
+ icon: 'walk',
+ color: base_modes.mode_colors['blue'],
+ },
+ {
+ startTime: '8:45 AM',
+ duration: '15 minutes',
+ distance: '1.24',
+ distanceSuffix: 'mi',
+ icon: 'bike',
+ color: base_modes.mode_colors['green'],
+ },
+ ]);
+ });
+ });
+
+ describe('primarySectionForTrip', () => {
+ it('returns the section with the greatest distance for a trip', () => {
+ expect(primarySectionForTrip(myFakeTripWithSections)).toEqual(
+ myFakeTripWithSections.sections[1],
+ );
+ });
+ });
-let myFakeDetectedModes = [
- { mode: 'BICYCLING', icon: 'bike', color: base_modes.mode_colors['green'], pct: 89 },
- { mode: 'WALKING', icon: 'walk', color: base_modes.mode_colors['blue'], pct: 11 },
-];
+ describe('getLocalTimeString', () => {
+ it('returns the formatted time string for a full LocalDt object', () => {
+ expect(
+ getLocalTimeString({
+ year: 2024,
+ month: 9,
+ day: 18,
+ hour: 15,
+ minute: 30,
+ second: 8,
+ weekday: 3,
+ timezone: 'America/Los_Angeles',
+ }),
+ ).toEqual('3:30 PM');
+ });
-let myFakeDetectedModes2 = [
- { mode: 'BICYCLING', icon: 'bike', color: base_modes.mode_colors['green'], pct: 100 },
-];
+ it('returns the formatted time string for a LocalDt object with only hour and minute', () => {
+ expect(getLocalTimeString({ hour: 8, minute: 30 } as LocalDt)).toEqual('8:30 AM');
+ });
-it('returns the detected modes, with percentages, for a trip', () => {
- expect(getDetectedModes(myFakeTrip)).toEqual(myFakeDetectedModes);
- expect(getDetectedModes(myFakeTrip2)).toEqual(myFakeDetectedModes2);
- expect(getDetectedModes({} as any)).toEqual([]); // empty trip, no sections, no modes
+ it('returns undefined for an undefined LocalDt object', () => {
+ expect(getLocalTimeString()).toBeUndefined();
+ });
+ });
});
diff --git a/www/__tests__/footprintHelper.test.ts b/www/__tests__/footprintHelper.test.ts
index 666e36c92..e197e87d3 100644
--- a/www/__tests__/footprintHelper.test.ts
+++ b/www/__tests__/footprintHelper.test.ts
@@ -1,87 +1,66 @@
-import {
- _test_clearCustomMetrics,
- 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 fakeLabels from '../__mocks__/fakeLabels.json';
-import fakeConfig from '../__mocks__/fakeConfig.json';
+import i18next from 'i18next';
+import { getFootprintGoals } from '../js/metrics/footprint/footprintHelper';
+import AppConfig from '../js/types/appConfigTypes';
-mockBEMUserCache(fakeConfig);
-
-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();
- _test_clearCustomMetrics();
-});
-
-const custom_metrics = [
- { key: 'ON_FOOT', values: 3000 }, //hits fallback under custom paradigm
- { key: 'bike', values: 6500 },
- { key: 'drove_alone', values: 10000 },
- { key: 'scootershare', values: 25000 },
- { key: 'unicycle', values: 5000 },
-];
-
-/*
- 3*0 + 6.5*0 + 10*0.22031 + 25*0.00894 + 5*0
- 0 + 0 + 2.2031 + 0.2235 + 0
- 2.4266
-*/
-it('gets footprint for metrics (custom, fallback 0)', async () => {
- const appConfig = await getConfig();
- await initCustomDatasetHelper(appConfig!);
- expect(getFootprintForMetrics(custom_metrics, 0)).toBe(2.4266);
-});
-
-/*
- 3*0.1 + 6.5*0 + 10*0.22031 + 25*0.00894 + 5*0.1
- 0.3 + 0 + 2.2031 + 0.2235 + 0.5
- 0.3 2.4266 + 0.5
-*/
-it('gets footprint for metrics (custom, fallback 0.1)', async () => {
- const appConfig = await getConfig();
- await initCustomDatasetHelper(appConfig!);
- expect(getFootprintForMetrics(custom_metrics, 0.1)).toBe(3.2266);
-});
-
-//expects TAXI from the fake labels
-it('gets the highest footprint from the dataset, custom', async () => {
- const appConfig = await getConfig();
- await initCustomDatasetHelper(appConfig!);
- expect(getHighestFootprint()).toBe(0.30741);
-});
-
-/*
- TAXI co2/km * meters/1000
-*/
-it('gets the highest footprint for distance, custom', async () => {
- const appConfig = await getConfig();
- await initCustomDatasetHelper(appConfig!);
- expect(getHighestFootprintForDistance(12345)).toBe(0.30741 * (12345 / 1000));
-});
+describe('footprintHelper', () => {
+ const fakeAppConfig1 = {
+ metrics: {
+ phone_dashboard_ui: {
+ footprint_options: {
+ goals: {
+ carbon: [
+ {
+ label: { en: 'Foo goal' },
+ value: 1.1,
+ color: 'rgb(255, 0, 0)',
+ },
+ {
+ label: { en: 'Bar goal' },
+ value: 5.5,
+ color: 'rgb(0, 255, 0)',
+ },
+ ],
+ energy: [
+ {
+ label: { en: 'Baz goal' },
+ value: 4.4,
+ color: 'rgb(0, 0, 255)',
+ },
+ {
+ label: { en: 'Zab goal' },
+ value: 9.9,
+ color: 'rgb(255, 255, 0)',
+ },
+ ],
+ goals_footnote: { en: 'Foobar footnote' },
+ },
+ },
+ },
+ },
+ };
-it('errors out if not initialized', () => {
- const t = () => {
- getFootprintForMetrics(custom_metrics, 0);
+ const myFakeFootnotes: string[] = [];
+ const addFakeFootnote = (footnote: string) => {
+ myFakeFootnotes.push(footnote);
+ return myFakeFootnotes.length.toString();
};
- expect(t).toThrow(Error);
+ describe('getFootprintGoals', () => {
+ it('should use default goals if appConfig is blank / does not have goals, extract the label, and add footnote', () => {
+ myFakeFootnotes.length = 0;
+ const goals = getFootprintGoals({} as any as AppConfig, addFakeFootnote);
+ expect(goals.carbon[0].label).toEqual(i18next.t('metrics.footprint.us-2050-goal') + '1');
+ expect(goals.carbon[1].label).toEqual(i18next.t('metrics.footprint.us-2030-goal') + '1');
+ expect(goals.energy[0].label).toEqual(i18next.t('metrics.footprint.us-2050-goal') + '1');
+ expect(goals.energy[1].label).toEqual(i18next.t('metrics.footprint.us-2030-goal') + '1');
+ });
+
+ it('should use goals from appConfig when provided, extract the label, and add footnote', () => {
+ myFakeFootnotes.length = 0;
+ const goals = getFootprintGoals(fakeAppConfig1 as any as AppConfig, addFakeFootnote);
+ expect(goals.carbon[0].label).toEqual('Foo goal1');
+ expect(goals.carbon[1].label).toEqual('Bar goal1');
+ expect(goals.energy[0].label).toEqual('Baz goal1');
+ expect(goals.energy[1].label).toEqual('Zab goal1');
+ });
+ });
});
diff --git a/www/__tests__/metHelper.test.ts b/www/__tests__/metHelper.test.ts
deleted file mode 100644
index d7478d6c0..000000000
--- a/www/__tests__/metHelper.test.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-import { getMet } from '../js/metrics/metHelper';
-import { mockBEMUserCache } from '../__mocks__/cordovaMocks';
-import fakeLabels from '../__mocks__/fakeLabels.json';
-import { getConfig } from '../js/config/dynamicConfig';
-import { initCustomDatasetHelper } from '../js/metrics/customMetricsHelper';
-import fakeConfig from '../__mocks__/fakeConfig.json';
-
-mockBEMUserCache(fakeConfig);
-
-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); //1.47523 mps = 3.299 mph -> 4.3 METs
- expect(getMet('BICYCLING', 4.5, 0)).toBe(6.8); //4.5 mps = 10.07 mph = 6.8 METs
- expect(getMet('UNICYCLE', 100, 0)).toBe(0); //unkown mode, 0 METs
- expect(getMet('CAR', 25, 1)).toBe(0); //0 METs in CAR
- expect(getMet('ON_FOOT', 1.47523, 0)).toBe(4.3); //same as walking!
- expect(getMet('WALKING', -2, 0)).toBe(0); //negative speed -> 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); //1.47523 mps = 3.299 mph -> 4.3 METs
- expect(getMet('bike', 4.5, 0)).toBe(6.8); //4.5 mps = 10.07 mph = 6.8 METs
- expect(getMet('unicycle', 100, 0)).toBe(0); //unkown mode, 0 METs
- expect(getMet('drove_alone', 25, 1)).toBe(0); //0 METs IN_VEHICLE
- expect(getMet('e-bike', 6, 1)).toBe(4.9); //e-bike is 4.9 for all speeds
- expect(getMet('e-bike', 12, 1)).toBe(4.9); //e-bike is 4.9 for all speeds
- expect(getMet('walk', -2, 1)).toBe(0); //negative speed -> 0
-});
diff --git a/www/__tests__/metricsHelper.test.ts b/www/__tests__/metricsHelper.test.ts
index c914c5782..b8ab4dae0 100644
--- a/www/__tests__/metricsHelper.test.ts
+++ b/www/__tests__/metricsHelper.test.ts
@@ -3,22 +3,30 @@ import {
calculatePercentChange,
formatDate,
formatDateRangeOfDays,
+ getActiveModes,
getLabelsForDay,
+ trimGroupingPrefix,
getUniqueLabelsForDays,
secondsToHours,
secondsToMinutes,
segmentDaysByWeeks,
- metricToValue,
tsForDayOfMetricData,
valueForFieldOnDay,
- generateSummaryFromData,
- isCustomLabels,
- isAllCustom,
- isOnFoot,
getUnitUtilsForMetric,
+ aggMetricEntries,
+ sumMetricEntry,
+ sumMetricEntries,
+ getColorForModeLabel,
} from '../js/metrics/metricsHelper';
import { DayOfMetricData } from '../js/metrics/metricsTypes';
import initializedI18next from '../js/i18nextInit';
+import { LabelOptions } from '../js/types/labelTypes';
+import {
+ getLabelOptions,
+ labelKeyToText,
+ labelOptions,
+} from '../js/survey/multilabel/confirmHelper';
+import { base_modes } from 'e-mission-common';
window['i18next'] = initializedI18next;
describe('metricsHelper', () => {
@@ -33,6 +41,17 @@ describe('metricsHelper', () => {
});
});
+ describe('trimGroupingPrefix', () => {
+ it('should trim the grouping field prefix from a metrics key', () => {
+ expect(trimGroupingPrefix('mode_confirm_access_recreation')).toEqual('access_recreation');
+ expect(trimGroupingPrefix('primary_ble_sensed_mode_CAR')).toEqual('CAR');
+ });
+
+ it('should return "" if the key did not start with a grouping field', () => {
+ expect(trimGroupingPrefix('invalid_foo')).toEqual('');
+ });
+ });
+
describe('getLabelsForDay', () => {
const day1 = { mode_confirm_a: 1, mode_confirm_b: 2 } as any as DayOfMetricData;
it("should return labels for a day with 'mode_confirm_*'", () => {
@@ -80,7 +99,7 @@ describe('metricsHelper', () => {
describe('formatDate', () => {
const day1 = { date: '2021-01-01' } as any as DayOfMetricData;
it('should format date', () => {
- expect(formatDate(day1)).toEqual('1/1');
+ expect(formatDate(day1)).toEqual('Jan 1');
});
});
@@ -91,35 +110,23 @@ describe('metricsHelper', () => {
{ date: '2021-01-04' },
] as any as DayOfMetricData[];
it('should format date range for days with date', () => {
- expect(formatDateRangeOfDays(days1)).toEqual('1/1 - 1/4');
+ expect(formatDateRangeOfDays(days1)).toEqual('Jan 1 – Jan 4'); // note: en dash
});
});
- describe('metricToValue', () => {
- const metric = {
- walking: 10,
- nUsers: 5,
- };
- it('returns correct value for user population', () => {
- const result = metricToValue('user', metric, 'walking');
- expect(result).toBe(10);
- });
-
- it('returns correct value for aggregate population', () => {
- const result = metricToValue('aggregate', metric, 'walking');
- expect(result).toBe(2);
- });
- });
-
- describe('isOnFoot', () => {
- it('returns true for on foot mode', () => {
- const result = isOnFoot('WALKING');
- expect(result).toBe(true);
- });
-
- it('returns false for non on foot mode', () => {
- const result = isOnFoot('DRIVING');
- expect(result).toBe(false);
+ describe('getActiveModes', () => {
+ const fakeLabelOptions = {
+ MODE: [
+ { value: 'walk', base_mode: 'WALKING' },
+ { value: 'bike', base_mode: 'BICYCLING' },
+ { value: 'ebike', base_mode: 'E_BIKE' },
+ { value: 'car', base_mode: 'CAR' },
+ { value: 'bus', base_mode: 'BUS' },
+ { value: 'myskateboard', met: { ZOOMING: { mets: 5 } } },
+ ],
+ } as LabelOptions;
+ it('should return active modes', () => {
+ expect(getActiveModes(fakeLabelOptions)).toEqual(['walk', 'bike', 'ebike', 'myskateboard']);
});
});
@@ -169,132 +176,6 @@ describe('metricsHelper', () => {
});
});
- describe('generateSummaryFromData', () => {
- const modeMap = [
- {
- key: 'mode1',
- values: [
- ['value1', 10],
- ['value2', 20],
- ],
- },
- {
- key: 'mode2',
- values: [
- ['value3', 30],
- ['value4', 40],
- ],
- },
- ];
- it('returns summary with sum for non-speed metric', () => {
- const metric = 'some_metric';
- const expectedResult = [
- { key: 'mode1', values: 30 },
- { key: 'mode2', values: 70 },
- ];
- const result = generateSummaryFromData(modeMap, metric);
- expect(result).toEqual(expectedResult);
- });
-
- it('returns summary with average for speed metric', () => {
- const metric = 'mean_speed';
- const expectedResult = [
- { key: 'mode1', values: 15 },
- { key: 'mode2', values: 35 },
- ];
- const result = generateSummaryFromData(modeMap, metric);
- expect(result).toEqual(expectedResult);
- });
- });
-
- describe('isCustomLabels', () => {
- it('returns true for all custom labels', () => {
- const modeMap = [
- {
- key: 'label_mode1',
- values: [
- ['value1', 10],
- ['value2', 20],
- ],
- },
- {
- key: 'label_mode2',
- values: [
- ['value3', 30],
- ['value4', 40],
- ],
- },
- ];
- const result = isCustomLabels(modeMap);
- expect(result).toBe(true);
- });
-
- it('returns true for all sensed labels', () => {
- const modeMap = [
- {
- key: 'label_mode1',
- values: [
- ['value1', 10],
- ['value2', 20],
- ],
- },
- {
- key: 'label_mode2',
- values: [
- ['value3', 30],
- ['value4', 40],
- ],
- },
- ];
- const result = isCustomLabels(modeMap);
- expect(result).toBe(true);
- });
-
- it('returns false for mixed custom and sensed labels', () => {
- const modeMap = [
- {
- key: 'label_mode1',
- values: [
- ['value1', 10],
- ['value2', 20],
- ],
- },
- {
- key: 'MODE2',
- values: [
- ['value3', 30],
- ['value4', 40],
- ],
- },
- ];
- const result = isCustomLabels(modeMap);
- expect(result).toBe(false);
- });
- });
-
- describe('isAllCustom', () => {
- it('returns true when all keys are custom', () => {
- const isSensedKeys = [false, false, false];
- const isCustomKeys = [true, true, true];
- const result = isAllCustom(isSensedKeys, isCustomKeys);
- expect(result).toBe(true);
- });
-
- it('returns false when all keys are sensed', () => {
- const isSensedKeys = [true, true, true];
- const isCustomKeys = [false, false, false];
- const result = isAllCustom(isSensedKeys, isCustomKeys);
- expect(result).toBe(false);
- });
-
- it('returns undefined for mixed custom and sensed keys', () => {
- const isSensedKeys = [true, false, true];
- const isCustomKeys = [false, true, false];
- const result = isAllCustom(isSensedKeys, isCustomKeys);
- expect(result).toBe(undefined);
- });
- });
-
describe('getUnitUtilsForMetric', () => {
const imperialConfig = {
distanceSuffix: 'mi',
@@ -335,4 +216,81 @@ describe('metricsHelper', () => {
expect(result[2](mockResponse)).toBe('5/7 responses');
});
});
+
+ const fakeFootprintEntries = [
+ {
+ date: '2024-05-28',
+ nUsers: 10,
+ mode_confirm_a: { kwh: 1, kg_co2: 2 },
+ },
+ {
+ date: '2024-05-29',
+ nUsers: 20,
+ mode_confirm_a: { kwh: 5, kg_co2: 8 },
+ mode_confirm_b: { kwh: 2, kg_co2: 4, kwh_uncertain: 1, kg_co2_uncertain: 2 },
+ },
+ ];
+
+ describe('aggMetricEntries', () => {
+ it('aggregates footprint metric entries', () => {
+ const result = aggMetricEntries(fakeFootprintEntries, 'footprint');
+ expect(result).toEqual({
+ nUsers: 30,
+ mode_confirm_a: expect.objectContaining({
+ kwh: 6,
+ kg_co2: 10,
+ }),
+ mode_confirm_b: expect.objectContaining({
+ kwh: 2,
+ kg_co2: 4,
+ kwh_uncertain: 1,
+ kg_co2_uncertain: 2,
+ }),
+ });
+ });
+ });
+
+ describe('sumMetricEntry', () => {
+ it('sums a single footprint metric entry', () => {
+ expect(sumMetricEntry(fakeFootprintEntries[0], 'footprint')).toEqual(
+ expect.objectContaining({
+ nUsers: 10,
+ kwh: 1,
+ kg_co2: 2,
+ }),
+ );
+ });
+ });
+
+ describe('sumMetricEntries', () => {
+ it('aggregates and sums footprint metric entries', () => {
+ expect(sumMetricEntries(fakeFootprintEntries, 'footprint')).toEqual(
+ expect.objectContaining({
+ nUsers: 30,
+ kwh: 8,
+ kg_co2: 14,
+ kwh_uncertain: 1,
+ kg_co2_uncertain: 2,
+ }),
+ );
+ });
+ });
+
+ describe('getColorForModeLabel', () => {
+ // initialize label options (blank appconfig so the default label options will be used)
+ getLabelOptions({});
+ // access the text for each mode option to initialize the color map
+ labelOptions.MODE.forEach((mode) => labelKeyToText(mode.value));
+
+ it('returns semi-transparent grey if the label starts with "Unlabeled"', () => {
+ expect(getColorForModeLabel('Unlabeledzzzzz')).toBe('rgba(85, 85, 85, 0.12)');
+ });
+
+ it('returns color for modes that exist in the label options', () => {
+ expect(getColorForModeLabel('walk')).toBe(base_modes.BASE_MODES['WALKING'].color);
+ expect(getColorForModeLabel('bike')).toBe(base_modes.BASE_MODES['BICYCLING'].color);
+ expect(getColorForModeLabel('e-bike')).toBe(base_modes.BASE_MODES['E_BIKE'].color);
+ expect(getColorForModeLabel('bus')).toBe(base_modes.BASE_MODES['BUS'].color);
+ });
+ });
});
diff --git a/www/__tests__/useImperialConfig.test.ts b/www/__tests__/useImperialConfig.test.ts
index 33c354271..2bebfb589 100644
--- a/www/__tests__/useImperialConfig.test.ts
+++ b/www/__tests__/useImperialConfig.test.ts
@@ -1,46 +1,4 @@
-import React from 'react';
-import {
- convertDistance,
- convertSpeed,
- formatForDisplay,
- useImperialConfig,
-} from '../js/config/useImperialConfig';
-
-// This mock is required, or else the test will dive into the import chain of useAppConfig.ts and fail when it gets to the root
-jest.mock('../js/useAppConfig', () => {
- return jest.fn(() => ({
- display_config: {
- use_imperial: false,
- },
- loading: false,
- }));
-});
-jest.spyOn(React, 'useState').mockImplementation((initialValue) => [initialValue, jest.fn()]);
-jest.spyOn(React, 'useEffect').mockImplementation((effect: () => void) => effect());
-
-describe('formatForDisplay', () => {
- it('should round to the nearest integer when value is >= 100', () => {
- expect(formatForDisplay(105)).toBe('105');
- expect(formatForDisplay(119.01)).toBe('119');
- expect(formatForDisplay(119.91)).toBe('120');
- });
-
- it('should round to 3 significant digits when 1 <= value < 100', () => {
- expect(formatForDisplay(7.02)).toBe('7.02');
- expect(formatForDisplay(9.6262)).toBe('9.63');
- expect(formatForDisplay(11.333)).toBe('11.3');
- expect(formatForDisplay(99.99)).toBe('100');
- });
-
- it('should round to 2 decimal places when value < 1', () => {
- expect(formatForDisplay(0.07178)).toBe('0.07');
- expect(formatForDisplay(0.08978)).toBe('0.09');
- expect(formatForDisplay(0.75)).toBe('0.75');
- expect(formatForDisplay(0.001)).toBe('0');
- expect(formatForDisplay(0.006)).toBe('0.01');
- expect(formatForDisplay(0.00001)).toBe('0');
- });
-});
+import { convertDistance, convertSpeed, getImperialConfig } from '../js/config/useImperialConfig';
describe('convertDistance', () => {
it('should convert meters to kilometers by default', () => {
@@ -62,9 +20,9 @@ describe('convertSpeed', () => {
});
});
-describe('useImperialConfig', () => {
- it('returns ImperialConfig with imperial units', () => {
- const imperialConfig = useImperialConfig();
+describe('getImperialConfig', () => {
+ it('gives an ImperialConfig that works in metric units', () => {
+ const imperialConfig = getImperialConfig(false);
expect(imperialConfig.distanceSuffix).toBe('km');
expect(imperialConfig.speedSuffix).toBe('kmph');
expect(imperialConfig.convertDistance(10)).toBe(0.01);
@@ -72,4 +30,14 @@ describe('useImperialConfig', () => {
expect(imperialConfig.getFormattedDistance(10)).toBe('0.01');
expect(imperialConfig.getFormattedSpeed(20)).toBe('72');
});
+
+ it('gives an ImperialConfig that works in imperial units', () => {
+ const imperialConfig = getImperialConfig(true);
+ expect(imperialConfig.distanceSuffix).toBe('mi');
+ expect(imperialConfig.speedSuffix).toBe('mph');
+ expect(imperialConfig.convertDistance(10)).toBeCloseTo(0.01);
+ expect(imperialConfig.convertSpeed(20)).toBeCloseTo(44.74);
+ expect(imperialConfig.getFormattedDistance(10)).toBe('0.01');
+ expect(imperialConfig.getFormattedSpeed(20)).toBe('44.7');
+ });
});
diff --git a/www/__tests__/util.ts b/www/__tests__/util.ts
new file mode 100644
index 000000000..0a8678a34
--- /dev/null
+++ b/www/__tests__/util.ts
@@ -0,0 +1,27 @@
+import { formatForDisplay } from '../js/util';
+
+describe('util.ts', () => {
+ describe('formatForDisplay', () => {
+ it('should round to the nearest integer when value is >= 100', () => {
+ expect(formatForDisplay(105)).toBe('105');
+ expect(formatForDisplay(119.01)).toBe('119');
+ expect(formatForDisplay(119.91)).toBe('120');
+ });
+
+ it('should round to 3 significant digits when 1 <= value < 100', () => {
+ expect(formatForDisplay(7.02)).toBe('7.02');
+ expect(formatForDisplay(9.6262)).toBe('9.63');
+ expect(formatForDisplay(11.333)).toBe('11.3');
+ expect(formatForDisplay(99.99)).toBe('100');
+ });
+
+ it('should round to 2 decimal places when value < 1', () => {
+ expect(formatForDisplay(0.07178)).toBe('0.07');
+ expect(formatForDisplay(0.08978)).toBe('0.09');
+ expect(formatForDisplay(0.75)).toBe('0.75');
+ expect(formatForDisplay(0.001)).toBe('0');
+ expect(formatForDisplay(0.006)).toBe('0.01');
+ expect(formatForDisplay(0.00001)).toBe('0');
+ });
+ });
+});
diff --git a/www/i18n/en.json b/www/i18n/en.json
index 738df38f5..b9318b16b 100644
--- a/www/i18n/en.json
+++ b/www/i18n/en.json
@@ -84,44 +84,74 @@
"metrics": {
"dashboard-tab": "Dashboard",
- "cancel": "Cancel",
- "confirm": "Confirm",
- "get": "Get",
- "range": "Range",
- "filter": "Filter",
- "from": "From:",
- "to": "To:",
- "last-week": "last week",
- "frequency": "Frequency:",
- "pandafreqoptions-daily": "DAILY",
- "pandafreqoptions-weekly": "WEEKLY",
- "pandafreqoptions-biweekly": "BIWEEKLY",
- "pandafreqoptions-monthly": "MONTHLY",
- "pandafreqoptions-yearly": "YEARLY",
- "freqoptions-daily": "DAILY",
- "freqoptions-monthly": "MONTHLY",
- "freqoptions-yearly": "YEARLY",
- "select-pandafrequency": "Select summary freqency",
- "select-frequency": "Select summary freqency",
- "chart-xaxis-date": "Date",
- "chart-no-data": "No Data Available",
- "trips-yaxis-number": "Number",
- "calorie-data-change": " change",
- "calorie-data-unknown": "Unknown...",
- "greater-than": " greater than ",
- "greater": " greater ",
- "or": "or",
- "less-than": " less than ",
- "less": " less ",
- "week-before": "vs. week before",
- "this-week": "this week",
- "pick-a-date": "Pick a date",
- "trips": "trips",
- "hours": "hours",
- "minutes": "minutes",
- "responses": "responses",
- "custom": "Custom",
- "no-data": "No data"
+ "no-data": "No data",
+ "no-data-available": "No data available",
+ "footprint": {
+ "footprint": "Footprint",
+ "estimated-footprint": "Estimated Footprint",
+ "ghg-emissions": "GHG Emissions",
+ "energy-usage": "Energy Usage",
+ "us-2030-goal": "2030 Guideline",
+ "us-2050-goal": "2050 Guideline",
+ "us-goals-footnote": "Guidelines are based on US decarbonization goals, scaled to per-capita travel-related footprint.",
+ "labeled": "Labeled",
+ "unlabeled": "Unlabeled",
+ "uncertainty-footnote": "Due to the uncertainty of unlabeled trips, estimates may fall anywhere within the shown range. Label more trips for richer estimates.",
+ "daily-emissions-by-week": "Daily Emissions by Week",
+ "kg-co2e-per-day": "Average kg CO₂e / day",
+ "daily-energy-by-week": "Daily Energy Usage by Week",
+ "kwh-per-day": "Average kWh / day",
+ "daily-emissions-comparison": "Daily Emissions vs. Group",
+ "daily-energy-comparison": "Daily Energy Usage vs. Group",
+ "you": "You",
+ "group-average": "Group Average"
+ },
+ "movement": {
+ "movement": "Movement",
+ "active-minutes": "Active Minutes",
+ "daily-active-minutes": "Daily Minutes of Active Travel",
+ "weekly-active-minutes": "Weekly Minutes of Active Travel",
+ "active-minutes-table": "Table of Active Minutes",
+ "weekly-goal": "Weekly Goal",
+ "weekly-goal-footnote": "*Weekly goal based on CDC recommendation of 150 minutes of moderate activity per week.",
+ "minutes": "minutes"
+ },
+ "travel": {
+ "travel": "Travel",
+ "count": "Trip Count",
+ "distance": "Distance",
+ "duration": "Duration",
+ "trips": "trips",
+ "hours": "hours",
+ "user-totals": "My Totals",
+ "group-totals": "Group Totals"
+ },
+ "surveys": {
+ "surveys": "Surveys",
+ "survey-response-rate": "Survey Response Rate (%)",
+ "survey-leaderboard-desc": "This data has been accumulated since ",
+ "trip-categories": "Trip Categories",
+ "response": "Response",
+ "no-response": "No Response",
+ "responses": "responses",
+ "comparison": "Comparison",
+ "you": "You",
+ "others": "Others in group"
+ },
+ "leaderboard": {
+ "leaderboard": "Leaderboard",
+ "you-are-in-x-place": "You are in #{{x}} place",
+ "data-accumulated-since-date": "This data has been accumulated since {{date}}"
+ },
+ "stack-bars": "Stack bars:",
+ "split-by": "Split by {{field}}:",
+ "grouping-fields": {
+ "mode_confirm": "Mode",
+ "purpose_confirm": "Purpose",
+ "replaced_mode_confirm": "Replaced Mode",
+ "primary_ble_sensed_mode": "Detected Mode",
+ "survey": "Survey"
+ }
},
"diary": {
@@ -154,7 +184,8 @@
"show-more-travel": "Show More Travel",
"show-older-travel": "Show Older Travel",
"no-travel": "No travel to show",
- "no-travel-hint": "To see more, change the filters above or go record some travel!"
+ "no-travel-hint": "To see more, change the filters above or go record some travel!",
+ "jump-to-last-processed-week": "Jump to the last processed week"
},
"multilabel": {
@@ -193,60 +224,6 @@
"other": "Other"
},
- "main-metrics": {
- "summary": "My Summary",
- "chart": "Chart",
- "change-data": "Change dates:",
- "distance": "Distance",
- "count": "Trip Count",
- "duration": "Duration",
- "response_count": "Response Count",
- "fav-mode": "My Favorite Mode",
- "speed": "My Speed",
- "footprint": "My Footprint",
- "estimated-emissions": "Estimated CO₂ emissions",
- "how-it-compares": "Ballpark comparisons",
- "optimal": "Optimal (perfect mode choice for all my trips)",
- "average": "Group Avg.",
- "worst-case": "Worse Case",
- "label-to-squish": "Label trips to collapse the range into a single number",
- "range-uncertain-footnote": "²Due to the uncertainty of unlabeled trips, estimates may fall anywhere within the shown range. Label more trips for richer estimates.",
- "lastweek": "My last week value:",
- "us-2030-goal": "2030 Guideline¹",
- "us-2050-goal": "2050 Guideline¹",
- "us-goals-footnote": "¹Guidelines based on US decarbonization goals, scaled to per-capita travel-related emissions.",
- "past-week": "Past Week",
- "prev-week": "Prev. Week",
- "no-summary-data": "No summary data",
- "mean-speed": "My Average Speed",
- "user-totals": "My Totals",
- "group-totals": "Group Totals",
- "active-minutes": "Active Minutes",
- "weekly-active-minutes": "Weekly minutes of active travel",
- "daily-active-minutes": "Daily minutes of active travel",
- "active-minutes-table": "Table of active minutes metrics",
- "weekly-goal": "Weekly Goal³",
- "weekly-goal-footnote": "³Weekly goal based on CDC recommendation of 150 minutes of moderate activity per week.",
- "labeled": "Labeled",
- "unlabeled": "Unlabeled²",
- "footprint-label": "Footprint (kg CO₂)",
- "surveys": "Surveys",
- "leaderboard": "Leaderboard",
- "survey-response-rate": "Survey Response Rate (%)",
- "survey-leaderboard-desc": "This data has been accumulated since ",
- "comparison": "Comparison",
- "you": "You",
- "others": "Others in group",
- "trip-categories": "Trip Categories",
- "ev-roading-trip": "EV Roaming trip",
- "ev-return-trip": "EV Return trip",
- "gas-car-trip": "Gas Car trip",
- "response": "Response",
- "no-response": "No Response",
- "you-are-in": "You're in",
- "place": " place!"
- },
-
"details": {
"speed": "Speed",
"time": "Time"
diff --git a/www/index.js b/www/index.js
index cd4757f3f..4ea9e93ac 100644
--- a/www/index.js
+++ b/www/index.js
@@ -23,9 +23,8 @@ window.skipLocalNotificationReady = true;
deviceReady.then(() => {
logDebug('deviceReady');
- /* give status bar dark text because we have a light background
- https://cordova.apache.org/docs/en/10.x/reference/cordova-plugin-statusbar/#statusbarstyledefault */
- if (window['StatusBar']) window['StatusBar'].styleDefault();
+ // On init, use 'default' status bar (black text)
+ window['StatusBar']?.styleDefault();
cordova.plugin.http.setDataSerializer('json');
const rootEl = document.getElementById('appRoot');
const reactRoot = createRoot(rootEl);
@@ -42,7 +41,9 @@ deviceReady.then(() => {
}
`}
-
+ {/* The background color of this SafeAreaView effectively controls the status bar background color.
+ Set to theme.colors.elevation.level2 to match the background of the elevated AppBars present on each tab. */}
+
,
diff --git a/www/js/App.tsx b/www/js/App.tsx
index c9593f3ad..a35987c31 100644
--- a/www/js/App.tsx
+++ b/www/js/App.tsx
@@ -14,7 +14,6 @@ import { initPushNotify } from './splash/pushNotifySettings';
import { initStoreDeviceSettings } from './splash/storeDeviceSettings';
import { initRemoteNotifyHandler } from './splash/remoteNotifyHandler';
// import { getUserCustomLabels } from './services/commHelper';
-import { initCustomDatasetHelper } from './metrics/customMetricsHelper';
import AlertBar from './components/AlertBar';
import Main from './Main';
@@ -46,7 +45,6 @@ const App = () => {
initStoreDeviceSettings();
initRemoteNotifyHandler();
// getUserCustomLabels(CUSTOM_LABEL_KEYS_IN_DATABASE).then((res) => setCustomLabelMap(res));
- initCustomDatasetHelper(appConfig);
}, [appConfig]);
const appContextValue = {
diff --git a/www/js/Main.tsx b/www/js/Main.tsx
index cb232535d..4a7a77e97 100644
--- a/www/js/Main.tsx
+++ b/www/js/Main.tsx
@@ -80,8 +80,7 @@ const Main = () => {
renderScene={renderScene}
// Place at bottom, color of 'surface' (white) by default, and 68px tall (default was 80)
safeAreaInsets={{ bottom: 0 }}
- style={{ backgroundColor: colors.surface }}
- barStyle={{ height: 68, justifyContent: 'center', backgroundColor: 'rgba(0,0,0,0)' }}
+ barStyle={{ height: 68, justifyContent: 'center' }}
// BottomNavigation uses secondaryContainer color for the background, but we want primaryContainer
// (light blue), so we override here.
theme={{ colors: { secondaryContainer: colors.primaryContainer } }}
diff --git a/www/js/TimelineContext.ts b/www/js/TimelineContext.ts
index 1aa1b9c04..2a94d2b0b 100644
--- a/www/js/TimelineContext.ts
+++ b/www/js/TimelineContext.ts
@@ -1,13 +1,12 @@
import { createContext, useEffect, useState } from 'react';
import { CompositeTrip, TimelineEntry, TimestampRange, UserInputEntry } from './types/diaryTypes';
import useAppConfig from './useAppConfig';
-import { LabelOption, LabelOptions, MultilabelKey } from './types/labelTypes';
+import { LabelOption, LabelOptions, MultilabelKey, RichMode } from './types/labelTypes';
import { getLabelOptions, labelOptionByValue } from './survey/multilabel/confirmHelper';
import { displayError, displayErrorMsg, logDebug, logWarn } from './plugin/logger';
import { useTranslation } from 'react-i18next';
import { DateTime } from 'luxon';
import {
- isoDateWithOffset,
compositeTrips2TimelineMap,
readAllCompositeTrips,
readUnprocessedTrips,
@@ -17,16 +16,18 @@ import {
unprocessedBleScans,
updateAllUnprocessedInputs,
updateLocalUnprocessedInputs,
- isoDateRangeToTsRange,
} from './diary/timelineHelper';
import { getPipelineRangeTs } from './services/commHelper';
import { getNotDeletedCandidates, mapInputsToTimelineEntries } from './survey/inputMatcher';
import { EnketoUserInputEntry } from './survey/enketo/enketoHelper';
-import { VehicleIdentity } from './types/appConfigTypes';
import { primarySectionForTrip } from './diary/diaryHelper';
import useAppStateChange from './useAppStateChange';
+import { isoDateRangeToTsRange, isoDateWithOffset } from './util';
+import { base_modes } from 'e-mission-common';
const TODAY_DATE = DateTime.now().toISODate();
+// initial date range is the past week: [TODAY - 6 days, TODAY]
+const INITIAL_DATE_RANGE: [string, string] = [isoDateWithOffset(TODAY_DATE, -6), TODAY_DATE];
type ContextProps = {
labelOptions: LabelOptions | null;
@@ -34,15 +35,12 @@ type ContextProps = {
timelineLabelMap: TimelineLabelMap | null;
userInputFor: (tlEntry: TimelineEntry) => UserInputMap | undefined;
notesFor: (tlEntry: TimelineEntry) => UserInputEntry[] | undefined;
- labelFor: (
- tlEntry: TimelineEntry,
- labelType: MultilabelKey,
- ) => VehicleIdentity | LabelOption | undefined;
- confirmedModeFor: (tlEntry: TimelineEntry) => LabelOption | undefined;
+ labelFor: (tlEntry: TimelineEntry, labelType: MultilabelKey) => LabelOption | undefined;
+ confirmedModeFor: (tlEntry: TimelineEntry) => RichMode | undefined;
addUserInputToEntry: (oid: string, userInput: any, inputType: 'label' | 'note') => void;
pipelineRange: TimestampRange | null;
queriedDateRange: [string, string] | null; // YYYY-MM-DD format
- dateRange: [string, string] | null; // YYYY-MM-DD format
+ dateRange: [string, string]; // YYYY-MM-DD format
timelineIsLoading: string | false;
loadMoreDays: (when: 'past' | 'future', nDays: number) => boolean | void;
loadDateRange: (d: [string, string]) => boolean | void;
@@ -62,7 +60,7 @@ export const useTimelineContext = (): ContextProps => {
// date range (inclusive) that has been loaded into the UI [YYYY-MM-DD, YYYY-MM-DD]
const [queriedDateRange, setQueriedDateRange] = useState<[string, string] | null>(null);
// date range (inclusive) chosen by datepicker [YYYY-MM-DD, YYYY-MM-DD]
- const [dateRange, setDateRange] = useState<[string, string] | null>(null);
+ const [dateRange, setDateRange] = useState<[string, string]>(INITIAL_DATE_RANGE);
// map of timeline entries (trips, places, untracked time), ids to objects
const [timelineMap, setTimelineMap] = useState(null);
const [timelineIsLoading, setTimelineIsLoading] = useState('replace');
@@ -86,8 +84,11 @@ export const useTimelineContext = (): ContextProps => {
// when a new date range is chosen, load more data, then update the queriedDateRange
useEffect(() => {
+ if (!pipelineRange) {
+ logDebug('No pipelineRange yet - skipping dateRange useEffect');
+ return;
+ }
const onDateRangeChange = async () => {
- if (!dateRange) return logDebug('No dateRange chosen, skipping onDateRangeChange');
logDebug('Timeline: onDateRangeChange with dateRange = ' + dateRange?.join(' to '));
// determine if this will be a new range or an expansion of the existing range
@@ -122,7 +123,7 @@ export const useTimelineContext = (): ContextProps => {
setTimelineIsLoading(false);
displayError(e, 'While loading date range ' + dateRange?.join(' to '));
}
- }, [dateRange]);
+ }, [dateRange, pipelineRange]);
useEffect(() => {
if (!timelineMap) return;
@@ -153,16 +154,6 @@ export const useTimelineContext = (): ContextProps => {
`);
}
setPipelineRange(pipelineRange);
- if (pipelineRange.end_ts) {
- // set initial date range to [pipelineEndDate - 7 days, TODAY_DATE]
- setDateRange([
- DateTime.fromSeconds(pipelineRange.end_ts).minus({ days: 7 }).toISODate(),
- TODAY_DATE,
- ]);
- } else {
- logWarn('Timeline: no pipeline end date. dateRange will stay null');
- setTimelineIsLoading(false);
- }
} catch (e) {
displayError(e, t('errors.while-loading-pipeline-range'));
setTimelineIsLoading(false);
@@ -266,7 +257,7 @@ export const useTimelineContext = (): ContextProps => {
try {
logDebug('timelineContext: refreshTimeline');
setTimelineIsLoading('replace');
- setDateRange(null);
+ setDateRange(INITIAL_DATE_RANGE);
setQueriedDateRange(null);
setTimelineMap(null);
setRefreshTime(new Date());
@@ -291,11 +282,13 @@ export const useTimelineContext = (): ContextProps => {
/**
* @param tlEntry The trip or place object to get the confirmed mode for
- * @returns Confirmed mode, which could be a vehicle identity as determined by Bluetooth scans,
+ * @returns Rich confirmed mode, which could be a vehicle identity as determined by Bluetooth scans,
* or the label option from a user-given 'MODE' label, or undefined if neither exists.
*/
const confirmedModeFor = (tlEntry: CompositeTrip) =>
- primarySectionForTrip(tlEntry)?.ble_sensed_mode || labelFor(tlEntry, 'MODE');
+ base_modes.get_rich_mode(
+ primarySectionForTrip(tlEntry)?.ble_sensed_mode || labelFor(tlEntry, 'MODE'),
+ ) as RichMode;
function addUserInputToEntry(oid: string, userInput: any, inputType: 'label' | 'note') {
const tlEntry = timelineMap?.get(oid);
diff --git a/www/js/appTheme.ts b/www/js/appTheme.ts
index d2f13c47e..430848907 100644
--- a/www/js/appTheme.ts
+++ b/www/js/appTheme.ts
@@ -13,8 +13,8 @@ const AppTheme = {
secondary: '#c08331', // lch(60% 55 70)
secondaryContainer: '#fcefda', // lch(95% 12 80)
onSecondaryContainer: '#45392e', // lch(25% 10 65)
- background: '#edf1f6', // lch(95% 3 250) - background of label screen, other screens still have this as CSS .pane
- surface: '#fafdff', // lch(99% 30 250)
+ background: '#f9fdff', // lch(99% 2 250)
+ surface: '#f9fdff', // lch(99% 2 250)
surfaceVariant: '#e0f0ff', // lch(94% 50 250) - background of DataTable
surfaceDisabled: '#c7e0f7', // lch(88% 15 250)
onSurfaceDisabled: '#3a4955', // lch(30% 10 250)
@@ -24,11 +24,11 @@ const AppTheme = {
inverseOnSurface: '#edf1f6', // lch(95% 3 250) - SnackBar text
elevation: {
level0: 'transparent',
- level1: '#fafdff', // lch(99% 30 250)
- level2: '#f2f9ff', // lch(97.5% 50 250)
- level3: '#ebf5ff', // lch(96% 50 250)
- level4: '#e0f0ff', // lch(94% 50 250)
- level5: '#d6ebff', // lch(92% 50 250)
+ level1: '#f4f7fa', // lch(97% 2 250)
+ level2: '#edf1f6', // lch(95% 3 250)
+ level3: '#e7eff7', // lch(94% 5 250)
+ level4: '#e4ecf4', // lch(93% 5 250)
+ level5: '#e1e9f1', // lch(92% 5 250)
},
success: '#00a665', // lch(60% 55 155)
warn: '#f8cf53', //lch(85% 65 85)
@@ -103,3 +103,5 @@ export function getTheme(flavor?: keyof typeof flavorOverrides) {
};
return { ...AppTheme, colors: scopedColors };
}
+
+export const colors = AppTheme.colors;
diff --git a/www/js/components/AlertBar.tsx b/www/js/components/AlertBar.tsx
index 6bdd8d157..8b1b39fcf 100644
--- a/www/js/components/AlertBar.tsx
+++ b/www/js/components/AlertBar.tsx
@@ -2,8 +2,7 @@
Alerts can be added to the queue from anywhere by calling AlertManager.addMessage. */
import React, { useState, useEffect } from 'react';
-import { Snackbar } from 'react-native-paper';
-import { Modal } from 'react-native';
+import { Portal, Snackbar } from 'react-native-paper';
import { useTranslation } from 'react-i18next';
import { ParseKeys } from 'i18next';
@@ -41,7 +40,7 @@ const AlertBar = () => {
const { msgKey, text } = messages[0];
const alertText = [msgKey && t(msgKey), text].filter((x) => x).join(' ');
return (
-
+
{
}}>
{alertText}
-
+
);
};
diff --git a/www/js/components/BarChart.tsx b/www/js/components/BarChart.tsx
index ccf1a6f74..0c63c0fe4 100644
--- a/www/js/components/BarChart.tsx
+++ b/www/js/components/BarChart.tsx
@@ -4,7 +4,7 @@ import { useTheme } from 'react-native-paper';
import { getGradient } from './charting';
type Props = Omit & {
- meter?: { high: number; middle: number; dash_key: string };
+ meter?: { high: number; middle: number; uncertainty_prefix: string };
};
const BarChart = ({ meter, ...rest }: Props) => {
const { colors } = useTheme();
diff --git a/www/js/components/Carousel.tsx b/www/js/components/Carousel.tsx
index 8afe6624a..800b8b118 100644
--- a/www/js/components/Carousel.tsx
+++ b/www/js/components/Carousel.tsx
@@ -1,13 +1,14 @@
import React from 'react';
-import { ScrollView, View } from 'react-native';
+import { ScrollView, View, useWindowDimensions } from 'react-native';
-type Props = {
- children: React.ReactNode;
- cardWidth: number;
- cardMargin: number;
-};
-const Carousel = ({ children, cardWidth, cardMargin }: Props) => {
+const cardMargin = 10;
+
+type Props = { children: React.ReactNode };
+const Carousel = ({ children }: Props) => {
const numCards = React.Children.count(children);
+ const { width: windowWidth } = useWindowDimensions();
+ const cardWidth = windowWidth * 0.88;
+
return (
string;
diff --git a/www/js/components/NavBar.tsx b/www/js/components/NavBar.tsx
index cf2a19dff..a785c2f44 100644
--- a/www/js/components/NavBar.tsx
+++ b/www/js/components/NavBar.tsx
@@ -1,13 +1,21 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import color from 'color';
-import { Appbar, Button, ButtonProps, Icon, ProgressBar, useTheme } from 'react-native-paper';
+import {
+ Appbar,
+ AppbarHeaderProps,
+ Button,
+ ButtonProps,
+ Icon,
+ ProgressBar,
+ useTheme,
+} from 'react-native-paper';
-type NavBarProps = { children: React.ReactNode; isLoading?: boolean };
-const NavBar = ({ children, isLoading }: NavBarProps) => {
+type NavBarProps = AppbarHeaderProps & { isLoading?: boolean };
+const NavBar = ({ children, isLoading, ...rest }: NavBarProps) => {
const { colors } = useTheme();
return (
-
+
{children}
{
const { colors } = useTheme();
- const buttonColor = color(colors.onBackground).alpha(0.07).rgb().string();
- const outlineColor = color(colors.onBackground).alpha(0.2).rgb().string();
+ const buttonColor = color(colors.onBackground).alpha(0.05).rgb().string();
+ const borderColor = color(colors.onBackground).alpha(0.1).rgb().string();
return (
<>
@@ -38,7 +46,7 @@ export const NavBarButton = ({ children, icon, iconSize, ...rest }: NavBarButton
buttonColor={buttonColor}
textColor={colors.onBackground}
contentStyle={[s.btnContent, rest.contentStyle]}
- style={[s.btn(outlineColor), rest.style]}
+ style={[rest.style, { borderColor }]}
labelStyle={[s.btnLabel, rest.labelStyle]}
{...rest}>
{children}
@@ -53,20 +61,15 @@ export const NavBarButton = ({ children, icon, iconSize, ...rest }: NavBarButton
};
const s = StyleSheet.create({
- navBar: (backgroundColor) => ({
- backgroundColor,
- height: 56,
+ navBar: {
+ height: 60,
paddingHorizontal: 8,
gap: 5,
- }),
- btn: (borderColor) => ({
- borderColor,
- borderRadius: 10,
- }),
+ },
btnContent: {
- height: 44,
+ height: 40,
flexDirection: 'row',
- paddingHorizontal: 2,
+ paddingHorizontal: 8,
},
btnLabel: {
fontSize: 12.5,
@@ -75,6 +78,7 @@ const s = StyleSheet.create({
marginHorizontal: 'auto',
marginVertical: 'auto',
display: 'flex',
+ gap: 5,
},
icon: {
margin: 'auto',
@@ -83,7 +87,6 @@ const s = StyleSheet.create({
},
textWrapper: {
lineHeight: '100%',
- marginHorizontal: 5,
justifyContent: 'space-evenly',
alignItems: 'center',
},
diff --git a/www/js/components/ToggleSwitch.tsx b/www/js/components/ToggleSwitch.tsx
index 671228b36..a4e20d69b 100644
--- a/www/js/components/ToggleSwitch.tsx
+++ b/www/js/components/ToggleSwitch.tsx
@@ -14,8 +14,6 @@ const ToggleSwitch = ({ value, buttons, ...rest }: SegmentedButtonsProps) => {
showSelectedCheck: true,
style: {
minWidth: 0,
- borderTopWidth: rest.density == 'high' ? 0 : 1,
- borderBottomWidth: rest.density == 'high' ? 0 : 1,
backgroundColor: value == o.value ? colors.elevation.level1 : colors.surfaceDisabled,
},
...o,
diff --git a/www/js/components/charting.ts b/www/js/components/charting.ts
index 11ae43be7..657a3b8ab 100644
--- a/www/js/components/charting.ts
+++ b/www/js/components/charting.ts
@@ -2,6 +2,8 @@ import color from 'color';
import { readableLabelToKey } from '../survey/multilabel/confirmHelper';
import { logDebug } from '../plugin/logger';
+export const UNCERTAIN_OPACITY = 0.12;
+
export const defaultPalette = [
'#c95465', // red oklch(60% 0.15 14)
'#4a71b1', // blue oklch(55% 0.11 260)
@@ -89,7 +91,7 @@ export function getMeteredBackgroundColor(meter, currDataset, barCtx, colors, da
return color(meteredColor).darken(darken).hex();
}
//if "unlabeled", etc -> stripes
- if (currDataset.label == meter.dash_key) {
+ if (currDataset.label == meter.uncertainty_prefix) {
return createDiagonalPattern(meteredColor);
}
//if :labeled", etc -> solid
@@ -115,7 +117,7 @@ export function getGradient(
if (!chartArea) return null;
let gradient: CanvasGradient;
const total = getBarHeight(barCtx.parsed._stacks);
- alpha = alpha || (currDataset.label == meter.dash_key ? 0.2 : 1);
+ alpha = alpha || (currDataset.label.startsWith(meter.uncertainty_prefix) ? UNCERTAIN_OPACITY : 1);
if (total < meter.middle) {
const adjColor =
darken || alpha
diff --git a/www/js/config/useImperialConfig.ts b/www/js/config/useImperialConfig.ts
index feb2bb114..900c9854e 100644
--- a/www/js/config/useImperialConfig.ts
+++ b/www/js/config/useImperialConfig.ts
@@ -1,6 +1,6 @@
-import React, { useEffect, useState } from 'react';
+import React, { useMemo } from 'react';
import useAppConfig from '../useAppConfig';
-import i18next from 'i18next';
+import { formatForDisplay } from '../util';
export type ImperialConfig = {
distanceSuffix: string;
@@ -14,22 +14,6 @@ export type ImperialConfig = {
const KM_TO_MILES = 0.621371;
const MPS_TO_KMPH = 3.6;
-// it might make sense to move this to a more general location in the codebase
-/* formatting units for display:
- - if value >= 100, round to the nearest integer
- e.g. "105 mi", "119 kmph"
- - if 1 <= value < 100, round to 3 significant digits
- e.g. "7.02 km", "11.3 mph"
- - if value < 1, round to 2 decimal places
- e.g. "0.07 mi", "0.75 km" */
-export function formatForDisplay(value: number): string {
- let opts: Intl.NumberFormatOptions = {};
- if (value >= 100) opts.maximumFractionDigits = 0;
- else if (value >= 1) opts.maximumSignificantDigits = 3;
- else opts.maximumFractionDigits = 2;
- return Intl.NumberFormat(i18next.resolvedLanguage, opts).format(value);
-}
-
export function convertDistance(distMeters: number, imperial: boolean): number {
if (imperial) return (distMeters / 1000) * KM_TO_MILES;
return distMeters / 1000;
@@ -40,25 +24,21 @@ export function convertSpeed(speedMetersPerSec: number, imperial: boolean): numb
return speedMetersPerSec * MPS_TO_KMPH;
}
+export const getImperialConfig = (useImperial: boolean): ImperialConfig => ({
+ distanceSuffix: useImperial ? 'mi' : 'km',
+ speedSuffix: useImperial ? 'mph' : 'kmph',
+ convertDistance: (d) => convertDistance(d, useImperial),
+ convertSpeed: (s) => convertSpeed(s, useImperial),
+ getFormattedDistance: useImperial
+ ? (d) => formatForDisplay(convertDistance(d, true))
+ : (d) => formatForDisplay(convertDistance(d, false)),
+ getFormattedSpeed: useImperial
+ ? (s) => formatForDisplay(convertSpeed(s, true))
+ : (s) => formatForDisplay(convertSpeed(s, false)),
+});
+
export function useImperialConfig(): ImperialConfig {
const appConfig = useAppConfig();
- const [useImperial, setUseImperial] = useState(false);
-
- useEffect(() => {
- if (!appConfig) return;
- setUseImperial(appConfig.display_config.use_imperial);
- }, [appConfig]);
-
- return {
- distanceSuffix: useImperial ? 'mi' : 'km',
- speedSuffix: useImperial ? 'mph' : 'kmph',
- convertDistance: (d) => convertDistance(d, useImperial),
- convertSpeed: (s) => convertSpeed(s, useImperial),
- getFormattedDistance: useImperial
- ? (d) => formatForDisplay(convertDistance(d, true))
- : (d) => formatForDisplay(convertDistance(d, false)),
- getFormattedSpeed: useImperial
- ? (s) => formatForDisplay(convertSpeed(s, true))
- : (s) => formatForDisplay(convertSpeed(s, false)),
- };
+ const useImperial = useMemo(() => appConfig?.display_config.use_imperial, [appConfig]);
+ return getImperialConfig(useImperial);
}
diff --git a/www/js/control/ProfileSettings.tsx b/www/js/control/ProfileSettings.tsx
index 1ea3631fa..41029d4ee 100644
--- a/www/js/control/ProfileSettings.tsx
+++ b/www/js/control/ProfileSettings.tsx
@@ -411,7 +411,7 @@ const ProfileSettings = () => {
return (
<>
-
+
setLogoutVis(true)}>
{t('control.log-out')}
diff --git a/www/js/control/SensedPage.tsx b/www/js/control/SensedPage.tsx
index 1ceaf1178..99cbe8d81 100644
--- a/www/js/control/SensedPage.tsx
+++ b/www/js/control/SensedPage.tsx
@@ -52,7 +52,7 @@ const SensedPage = ({ pageVis, setPageVis }) => {
return (
setPageVis(false)}>
-
+
setPageVis(false)} />
diff --git a/www/js/diary/cards/ModesIndicator.tsx b/www/js/diary/cards/ModesIndicator.tsx
index 8a4cf1689..eba2a5683 100644
--- a/www/js/diary/cards/ModesIndicator.tsx
+++ b/www/js/diary/cards/ModesIndicator.tsx
@@ -6,6 +6,7 @@ import { logDebug } from '../../plugin/logger';
import { Text, Icon, useTheme } from 'react-native-paper';
import { useTranslation } from 'react-i18next';
import { base_modes } from 'e-mission-common';
+import { labelKeyToText } from '../../survey/multilabel/confirmHelper';
const ModesIndicator = ({ trip, detectedModes }) => {
const { t } = useTranslation();
@@ -16,23 +17,22 @@ const ModesIndicator = ({ trip, detectedModes }) => {
let indicatorBorderColor = color('black').alpha(0.5).rgb().string();
let modeViews;
- const confirmedModeForTrip = confirmedModeFor(trip);
- if (labelOptions && confirmedModeForTrip?.value) {
- const baseMode = base_modes.get_base_mode_by_key(confirmedModeForTrip.baseMode);
- indicatorBorderColor = baseMode.color;
- logDebug(`TripCard: got baseMode = ${JSON.stringify(baseMode)}`);
+ const confirmedMode = confirmedModeFor(trip);
+ if (labelOptions && confirmedMode?.value) {
+ indicatorBorderColor = confirmedMode.color;
+ logDebug(`TripCard: got confirmedMode = ${JSON.stringify(confirmedMode)}`);
modeViews = (
-
+
- {confirmedModeForTrip.text}
+ {labelKeyToText(confirmedMode.value)}
);
diff --git a/www/js/diary/cards/TripCard.tsx b/www/js/diary/cards/TripCard.tsx
index 23d7db224..5cbb7a0ab 100644
--- a/www/js/diary/cards/TripCard.tsx
+++ b/www/js/diary/cards/TripCard.tsx
@@ -43,9 +43,8 @@ const TripCard = ({ trip, isFirstInList }: Props) => {
} = useDerivedProperties(trip);
let [tripStartDisplayName, tripEndDisplayName] = useAddressNames(trip);
const navigation = useNavigation();
- const { labelOptions, confirmedModeFor, notesFor } = useContext(TimelineContext);
- const tripGeojson =
- trip && labelOptions && useGeojsonForTrip(trip, confirmedModeFor(trip)?.baseMode);
+ const { confirmedModeFor, notesFor } = useContext(TimelineContext);
+ const tripGeojson = trip && useGeojsonForTrip(trip, confirmedModeFor(trip));
const isDraft = trip.key.includes('UNPROCESSED');
const flavoredTheme = getTheme(isDraft ? 'draft' : undefined);
diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx
index 8fee14d07..8e2cdf33d 100644
--- a/www/js/diary/details/LabelDetailsScreen.tsx
+++ b/www/js/diary/details/LabelDetailsScreen.tsx
@@ -48,16 +48,13 @@ const LabelScreenDetails = ({ route, navigation }) => {
const tripGeojson =
trip &&
labelOptions &&
- useGeojsonForTrip(
- trip,
- modesShown == 'confirmed' ? confirmedModeFor(trip)?.baseMode : undefined,
- );
+ useGeojsonForTrip(trip, modesShown == 'confirmed' ? confirmedModeFor(trip) : undefined);
const mapOpts = { minZoom: 3, maxZoom: 17 };
const modal = (
-
+
{
navigation.goBack();
diff --git a/www/js/diary/details/TripSectionsDescriptives.tsx b/www/js/diary/details/TripSectionsDescriptives.tsx
index 4592c838f..c678048e0 100644
--- a/www/js/diary/details/TripSectionsDescriptives.tsx
+++ b/www/js/diary/details/TripSectionsDescriptives.tsx
@@ -4,6 +4,7 @@ import { Icon, Text, useTheme } from 'react-native-paper';
import useDerivedProperties from '../useDerivedProperties';
import TimelineContext from '../../TimelineContext';
import { base_modes } from 'e-mission-common';
+import { labelKeyToText } from '../../survey/multilabel/confirmHelper';
const TripSectionsDescriptives = ({ trip, showConfirmedMode = false }) => {
const { labelOptions, labelFor, confirmedModeFor } = useContext(TimelineContext);
@@ -17,25 +18,19 @@ const TripSectionsDescriptives = ({ trip, showConfirmedMode = false }) => {
const { colors } = useTheme();
- const confirmedModeForTrip = confirmedModeFor(trip);
+ const confirmedModeForTrip = showConfirmedMode && confirmedModeFor(trip);
let sections = formattedSectionProperties;
/* if we're only showing the labeled mode, or there are no sections (i.e. unprocessed trip),
we treat this as unimodal and use trip-level attributes to construct a single section */
- if ((showConfirmedMode && confirmedModeForTrip) || !trip.sections?.length) {
- let baseMode;
- if (showConfirmedMode && labelOptions && confirmedModeForTrip) {
- baseMode = base_modes.get_base_mode_by_key(confirmedModeForTrip.baseMode);
- } else {
- baseMode = base_modes.get_base_mode_by_key('UNPROCESSED');
- }
+ if (confirmedModeForTrip || !trip.sections?.length) {
sections = [
{
startTime: displayStartTime,
duration: displayTime,
distance: formattedDistance,
distanceSuffix,
- color: baseMode.color,
- icon: baseMode.icon,
+ color: (confirmedModeForTrip || base_modes.BASE_MODES['UNPROCESSED']).color,
+ icon: (confirmedModeForTrip || base_modes.BASE_MODES['UNPROCESSED']).icon,
},
];
}
@@ -62,9 +57,9 @@ const TripSectionsDescriptives = ({ trip, showConfirmedMode = false }) => {
- {showConfirmedMode && confirmedModeForTrip && (
+ {confirmedModeForTrip && (
- {confirmedModeForTrip.text}
+ {labelKeyToText(confirmedModeForTrip.value)}
)}
diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts
index 4af19c2cf..e182835c4 100644
--- a/www/js/diary/diaryHelper.ts
+++ b/www/js/diary/diaryHelper.ts
@@ -1,15 +1,10 @@
-// here we have some helper functions used throughout the label tab
-// these functions are being gradually migrated out of services.js
-
-import i18next from 'i18next';
import { DateTime } from 'luxon';
import { CompositeTrip } from '../types/diaryTypes';
import { LabelOptions } from '../types/labelTypes';
import { LocalDt } from '../types/serverData';
-import humanizeDuration from 'humanize-duration';
-import { AppConfig } from '../types/appConfigTypes';
import { ImperialConfig } from '../config/useImperialConfig';
import { base_modes } from 'e-mission-common';
+import { humanizeIsoRange } from '../util';
export type BaseModeKey = string; // TODO figure out how to get keyof typeof base_modes.BASE_MODES
@@ -27,81 +22,6 @@ export type MotionTypeKey =
| 'STOPPED_WHILE_IN_VEHICLE'
| 'AIR_OR_HSR';
-export function getBaseModeByText(text: string, labelOptions: LabelOptions) {
- const modeOption = labelOptions?.MODE?.find((opt) => opt.text == text);
- return base_modes.get_base_mode_by_key(modeOption?.baseMode || 'OTHER');
-}
-
-/**
- * @param beginFmtTime An ISO 8601 formatted timestamp (with timezone)
- * @param endTs An ISO 8601 formatted timestamp (with timezone)
- * @returns true if the start and end timestamps fall on different days
- * @example isMultiDay("2023-07-13T00:00:00-07:00", "2023-07-14T00:00:00-07:00") => true
- */
-export function isMultiDay(beginFmtTime?: string, endFmtTime?: string) {
- if (!beginFmtTime || !endFmtTime) return false;
- return (
- DateTime.fromISO(beginFmtTime, { setZone: true }).toFormat('YYYYMMDD') !=
- DateTime.fromISO(endFmtTime, { setZone: true }).toFormat('YYYYMMDD')
- );
-}
-
-/**
- * @param beginFmtTime An ISO 8601 formatted timestamp (with timezone)
- * @param endTs An ISO 8601 formatted timestamp (with timezone)
- * @returns A formatted range if both params are defined, one formatted date if only one is defined
- * @example getFormattedDate("2023-07-14T00:00:00-07:00") => "Fri, Jul 14, 2023"
- */
-export function getFormattedDate(beginFmtTime?: string, endFmtTime?: string) {
- if (!beginFmtTime && !endFmtTime) return;
- if (isMultiDay(beginFmtTime, endFmtTime)) {
- return `${getFormattedDate(beginFmtTime)} - ${getFormattedDate(endFmtTime)}`;
- }
- // only one day given, or both are the same day
- const t = DateTime.fromISO(beginFmtTime || endFmtTime || '', { setZone: true });
- // We use toLocale to get Wed May 3, 2023 or equivalent,
- const tConversion = t.toLocaleString({
- weekday: 'short',
- month: 'long',
- day: '2-digit',
- year: 'numeric',
- });
- return tConversion;
-}
-
-/**
- * @param beginFmtTime An ISO 8601 formatted timestamp (with timezone)
- * @param endTs An ISO 8601 formatted timestamp (with timezone)
- * @returns A formatted range if both params are defined, one formatted date if only one is defined
- * @example getFormattedDate("2023-07-14T00:00:00-07:00") => "Fri, Jul 14"
- */
-export function getFormattedDateAbbr(beginFmtTime?: string, endFmtTime?: string) {
- if (!beginFmtTime && !endFmtTime) return;
- if (isMultiDay(beginFmtTime, endFmtTime)) {
- return `${getFormattedDateAbbr(beginFmtTime)} - ${getFormattedDateAbbr(endFmtTime)}`;
- }
- // only one day given, or both are the same day
- const dt = DateTime.fromISO(beginFmtTime || endFmtTime || '', { setZone: true });
- return dt.toLocaleString({ weekday: 'short', month: 'short', day: 'numeric' });
-}
-
-/**
- * @param beginFmtTime An ISO 8601 formatted timestamp (with timezone)
- * @param endFmtTime An ISO 8601 formatted timestamp (with timezone)
- * @returns A human-readable, approximate time range, e.g. "2 hours"
- */
-export function getFormattedTimeRange(beginFmtTime: string, endFmtTime: string) {
- if (!beginFmtTime || !endFmtTime) return;
- const beginTime = DateTime.fromISO(beginFmtTime, { setZone: true });
- const endTime = DateTime.fromISO(endFmtTime, { setZone: true });
- const range = endTime.diff(beginTime, ['hours', 'minutes']);
- return humanizeDuration(range.as('milliseconds'), {
- language: i18next.resolvedLanguage,
- largest: 1,
- round: true,
- });
-}
-
/**
* @param trip A composite trip object
* @returns An array of objects containing the mode key, icon, color, and percentage for each mode
@@ -124,7 +44,7 @@ export function getDetectedModes(trip: CompositeTrip) {
export function getFormattedSectionProperties(trip: CompositeTrip, imperialConfig: ImperialConfig) {
return trip.sections?.map((s) => ({
startTime: getLocalTimeString(s.start_local_dt),
- duration: getFormattedTimeRange(s.start_fmt_time, s.end_fmt_time),
+ duration: humanizeIsoRange(s.start_fmt_time, s.end_fmt_time),
distance: imperialConfig.getFormattedDistance(s.distance),
distanceSuffix: imperialConfig.distanceSuffix,
icon: base_modes.get_base_mode_by_key(s.sensed_mode_str)?.icon,
diff --git a/www/js/diary/list/DateSelect.tsx b/www/js/diary/list/DateSelect.tsx
index d79568e91..2f629b3d1 100644
--- a/www/js/diary/list/DateSelect.tsx
+++ b/www/js/diary/list/DateSelect.tsx
@@ -15,11 +15,17 @@ import {
DatePickerModalRangeProps,
DatePickerModalSingleProps,
} from 'react-native-paper-dates';
-import { Text, Divider, useTheme } from 'react-native-paper';
+import { Text, useTheme } from 'react-native-paper';
import i18next from 'i18next';
import { useTranslation } from 'react-i18next';
import { NavBarButton } from '../../components/NavBar';
-import { isoDateRangeToTsRange } from '../timelineHelper';
+import { formatIsoNoYear, isoDateRangeToTsRange } from '../../util';
+
+// formats as e.g. 'Aug 1'
+const MONTH_DAY_SHORT: Intl.DateTimeFormatOptions = {
+ month: 'short',
+ day: 'numeric',
+};
type Props = Partial & {
mode: 'single' | 'range';
@@ -43,18 +49,15 @@ const DateSelect = ({ mode, onChoose, ...rest }: Props) => {
[queriedDateRange],
);
- const displayDateRange = useMemo(() => {
- if (!pipelineRange || !queriedDateRange?.[0]) return null;
- const [queriedStartTs, queriedEndTs] = isoDateRangeToTsRange(queriedDateRange);
- const displayStartTs = Math.max(queriedStartTs, pipelineRange.start_ts);
- const displayStartDate = DateTime.fromSeconds(displayStartTs).toLocaleString(
- DateTime.DATE_SHORT,
- );
- let displayEndDate;
- if (queriedEndTs < pipelineRange.end_ts) {
- displayEndDate = DateTime.fromSeconds(queriedEndTs).toLocaleString(DateTime.DATE_SHORT);
+ const displayDateText = useMemo(() => {
+ if (!pipelineRange || !queriedDateRange?.[0]) {
+ return ' – '; // en dash surrounded by em spaces
+ }
+ const displayDateRange = [...queriedDateRange];
+ if (queriedDateRange[1] == DateTime.now().toISODate()) {
+ displayDateRange[1] = t('diary.today');
}
- return [displayStartDate, displayEndDate];
+ return formatIsoNoYear(...displayDateRange);
}, [pipelineRange, queriedDateRange]);
const midpointDate = useMemo(() => {
@@ -68,27 +71,13 @@ const DateSelect = ({ mode, onChoose, ...rest }: Props) => {
setOpen(false);
}, [setOpen]);
- const displayDateRangeEnd = displayDateRange?.[1] || t('diary.today');
return (
<>
setOpen(true)}>
- {displayDateRange?.[0] && (
- <>
- {displayDateRange?.[0]}
-
- >
- )}
- {displayDateRangeEnd}
+ {displayDateText}
{
return (
<>
-
+
{
size={32}
onPress={() => refreshTimeline()}
accessibilityLabel="Refresh"
- style={{ marginLeft: 'auto' }}
+ style={{ margin: 0, marginLeft: 'auto' }}
/>
diff --git a/www/js/diary/list/TimelineScrollList.tsx b/www/js/diary/list/TimelineScrollList.tsx
index 57842bbcf..98f12d139 100644
--- a/www/js/diary/list/TimelineScrollList.tsx
+++ b/www/js/diary/list/TimelineScrollList.tsx
@@ -3,11 +3,12 @@ import TripCard from '../cards/TripCard';
import PlaceCard from '../cards/PlaceCard';
import UntrackedTimeCard from '../cards/UntrackedTimeCard';
import { View, FlatList } from 'react-native';
-import { ActivityIndicator, Banner, Icon, Text } from 'react-native-paper';
+import { ActivityIndicator, Banner, Button, Icon, Text } from 'react-native-paper';
import LoadMoreButton from './LoadMoreButton';
import { useTranslation } from 'react-i18next';
-import { isoDateRangeToTsRange } from '../timelineHelper';
import TimelineContext from '../../TimelineContext';
+import { isoDateRangeToTsRange, isoDateWithOffset } from '../../util';
+import { DateTime } from 'luxon';
function renderCard({ item: listEntry, index }) {
if (listEntry.origin_key.includes('trip')) {
@@ -30,7 +31,7 @@ type Props = {
};
const TimelineScrollList = ({ listEntries }: Props) => {
const { t } = useTranslation();
- const { pipelineRange, queriedDateRange, timelineIsLoading, loadMoreDays } =
+ const { pipelineRange, queriedDateRange, timelineIsLoading, loadMoreDays, loadDateRange } =
useContext(TimelineContext);
const listRef = React.useRef(null);
@@ -56,11 +57,22 @@ const TimelineScrollList = ({ listEntries }: Props) => {
);
+ const pipelineEndDate = pipelineRange && DateTime.fromSeconds(pipelineRange.end_ts).toISODate();
const noTravelBanner = (
}>
{t('diary.no-travel')}
{t('diary.no-travel-hint')}
+ {queriedDateRange?.[0] && pipelineEndDate && queriedDateRange?.[0] > pipelineEndDate && (
+
+ )}
);
diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts
index 3786bda5b..e3e9d379c 100644
--- a/www/js/diary/timelineHelper.ts
+++ b/www/js/diary/timelineHelper.ts
@@ -19,7 +19,7 @@ import {
SectionSummary,
} from '../types/diaryTypes';
import { getLabelInputDetails, getLabelInputs } from '../survey/multilabel/confirmHelper';
-import { LabelOptions } from '../types/labelTypes';
+import { RichMode } from '../types/labelTypes';
import {
EnketoUserInputEntry,
filterByNameAndVersion,
@@ -34,21 +34,18 @@ const cachedGeojsons: Map = new Map();
/**
* @description Gets a formatted GeoJSON object for a trip, including the start and end places and the trajectory.
*/
-export function useGeojsonForTrip(trip: CompositeTrip, baseMode?: string) {
+export function useGeojsonForTrip(trip: CompositeTrip, richMode?: RichMode) {
if (!trip?._id?.$oid) return;
- const gjKey = `trip-${trip._id.$oid}-${baseMode || 'detected'}`;
+ const gjKey = `trip-${trip._id.$oid}-${richMode?.value || 'detected'}`;
if (cachedGeojsons.has(gjKey)) {
return cachedGeojsons.get(gjKey);
}
- const trajectoryColor =
- (baseMode && base_modes.get_base_mode_by_key(baseMode)?.color) || undefined;
-
logDebug("Reading trip's " + trip.locations.length + ' location points at ' + new Date());
const features = [
location2GeojsonPoint(trip.start_loc, 'start_place'),
location2GeojsonPoint(trip.end_loc, 'end_place'),
- ...locations2GeojsonTrajectory(trip, trip.locations, trajectoryColor),
+ ...locations2GeojsonTrajectory(trip, trip.locations, richMode?.color),
];
const gj: GeoJSONData = {
@@ -649,26 +646,3 @@ export function readUnprocessedTrips(
},
);
}
-
-/**
- * @example IsoDateWithOffset('2024-03-22', 1) -> '2024-03-23'
- * @example IsoDateWithOffset('2024-03-22', -1000) -> '2021-06-26'
- */
-export function isoDateWithOffset(date: string, offset: number) {
- let d = new Date(date);
- d.setUTCDate(d.getUTCDate() + offset);
- return d.toISOString().substring(0, 10);
-}
-
-export const isoDateRangeToTsRange = (dateRange: [string, string], zone?) => [
- DateTime.fromISO(dateRange[0], { zone: zone }).startOf('day').toSeconds(),
- DateTime.fromISO(dateRange[1], { zone: zone }).endOf('day').toSeconds(),
-];
-
-/**
- * @example isoDatesDifference('2024-03-22', '2024-03-29') -> 7
- * @example isoDatesDifference('2024-03-22', '2021-06-26') -> 1000
- * @example isoDatesDifference('2024-03-29', '2024-03-25') -> -4
- */
-export const isoDatesDifference = (date1: string, date2: string) =>
- -DateTime.fromISO(date1).diff(DateTime.fromISO(date2), 'days').days;
diff --git a/www/js/diary/useDerivedProperties.tsx b/www/js/diary/useDerivedProperties.tsx
index f13c1862d..4071000f7 100644
--- a/www/js/diary/useDerivedProperties.tsx
+++ b/www/js/diary/useDerivedProperties.tsx
@@ -1,16 +1,13 @@
import { useContext, useMemo } from 'react';
import { useImperialConfig } from '../config/useImperialConfig';
import {
- getFormattedDate,
- getFormattedDateAbbr,
getFormattedSectionProperties,
- getFormattedTimeRange,
getLocalTimeString,
getDetectedModes,
- isMultiDay,
primarySectionForTrip,
} from './diaryHelper';
import TimelineContext from '../TimelineContext';
+import { formatIsoNoYear, formatIsoWeekday, humanizeIsoRange, isoDatesDifference } from '../util';
const useDerivedProperties = (tlEntry) => {
const imperialConfig = useImperialConfig();
@@ -21,17 +18,17 @@ const useDerivedProperties = (tlEntry) => {
const endFmt = tlEntry.end_fmt_time || tlEntry.exit_fmt_time;
const beginDt = tlEntry.start_local_dt || tlEntry.enter_local_dt;
const endDt = tlEntry.end_local_dt || tlEntry.exit_local_dt;
- const tlEntryIsMultiDay = isMultiDay(beginFmt, endFmt);
+ const tlEntryIsMultiDay = isoDatesDifference(beginFmt, endFmt);
return {
confirmedMode: confirmedModeFor(tlEntry),
primary_ble_sensed_mode: primarySectionForTrip(tlEntry)?.ble_sensed_mode?.baseMode,
- displayDate: getFormattedDate(beginFmt, endFmt),
+ displayDate: formatIsoWeekday(beginFmt, endFmt),
displayStartTime: getLocalTimeString(beginDt),
displayEndTime: getLocalTimeString(endDt),
- displayTime: getFormattedTimeRange(beginFmt, endFmt),
- displayStartDateAbbr: tlEntryIsMultiDay ? getFormattedDateAbbr(beginFmt) : null,
- displayEndDateAbbr: tlEntryIsMultiDay ? getFormattedDateAbbr(endFmt) : null,
+ displayTime: humanizeIsoRange(beginFmt, endFmt),
+ displayStartDateAbbr: tlEntryIsMultiDay ? formatIsoNoYear(beginFmt) : null,
+ displayEndDateAbbr: tlEntryIsMultiDay ? formatIsoNoYear(endFmt) : null,
formattedDistance: imperialConfig.getFormattedDistance(tlEntry.distance),
formattedSectionProperties: getFormattedSectionProperties(tlEntry, imperialConfig),
distanceSuffix: imperialConfig.distanceSuffix,
diff --git a/www/js/metrics/CarbonFootprintCard.tsx b/www/js/metrics/CarbonFootprintCard.tsx
deleted file mode 100644
index c40254256..000000000
--- a/www/js/metrics/CarbonFootprintCard.tsx
+++ /dev/null
@@ -1,243 +0,0 @@
-import React, { useState, useMemo, useContext } from 'react';
-import { View } from 'react-native';
-import { Card, Text } from 'react-native-paper';
-import { MetricsData } from './metricsTypes';
-import { cardStyles } from './MetricsTab';
-import {
- getFootprintForMetrics,
- getHighestFootprint,
- getHighestFootprintForDistance,
-} from './footprintHelper';
-import {
- formatDateRangeOfDays,
- parseDataFromMetrics,
- generateSummaryFromData,
- calculatePercentChange,
- segmentDaysByWeeks,
- isCustomLabels,
- MetricsSummary,
-} from './metricsHelper';
-import { useTranslation } from 'react-i18next';
-import BarChart from '../components/BarChart';
-import ChangeIndicator, { CarbonChange } from './ChangeIndicator';
-import color from 'color';
-import { useAppTheme } from '../appTheme';
-import { logDebug, logWarn } from '../plugin/logger';
-import TimelineContext from '../TimelineContext';
-import { isoDatesDifference } from '../diary/timelineHelper';
-import useAppConfig from '../useAppConfig';
-
-type Props = { userMetrics?: MetricsData; aggMetrics?: MetricsData };
-const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => {
- const { colors } = useAppTheme();
- const { dateRange } = useContext(TimelineContext);
- const appConfig = useAppConfig();
- const { t } = useTranslation();
- // Whether to show the uncertainty on the carbon footprint charts, default: true
- const showUnlabeledMetrics =
- appConfig?.metrics?.phone_dashboard_ui?.footprint_options?.unlabeled_uncertainty ?? true;
- const [emissionsChange, setEmissionsChange] = useState(undefined);
-
- const userCarbonRecords = useMemo(() => {
- if (userMetrics?.distance?.length) {
- //separate data into weeks
- const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks(
- userMetrics?.distance,
- dateRange[1],
- );
-
- //formatted data from last week, if exists (14 days ago -> 8 days ago)
- let userLastWeekModeMap = {};
- let userLastWeekSummaryMap = {};
- if (lastWeekDistance && isoDatesDifference(dateRange[0], lastWeekDistance[0].date) >= 0) {
- userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user');
- userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance');
- }
-
- //formatted distance data from this week (7 days ago -> yesterday)
- let userThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'user');
- let userThisWeekSummaryMap = generateSummaryFromData(userThisWeekModeMap, 'distance');
- let worstDistance = userThisWeekSummaryMap.reduce(
- (prevDistance, currModeSummary) => prevDistance + currModeSummary.values,
- 0,
- );
-
- //setting up data to be displayed
- let graphRecords: { label: string; x: number | string; y: number | string }[] = [];
-
- //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago)
- let userPrevWeek;
- if (userLastWeekSummaryMap[0]) {
- userPrevWeek = {
- low: getFootprintForMetrics(userLastWeekSummaryMap, 0),
- high: getFootprintForMetrics(userLastWeekSummaryMap, getHighestFootprint()),
- };
- if (showUnlabeledMetrics) {
- graphRecords.push({
- label: t('main-metrics.unlabeled'),
- x: userPrevWeek.high - userPrevWeek.low,
- y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`,
- });
- }
- graphRecords.push({
- label: t('main-metrics.labeled'),
- x: userPrevWeek.low,
- y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`,
- });
- }
-
- //calculate low-high and format range for past week (7 days ago -> yesterday)
- let userPastWeek = {
- low: getFootprintForMetrics(userThisWeekSummaryMap, 0),
- high: getFootprintForMetrics(userThisWeekSummaryMap, getHighestFootprint()),
- };
- if (showUnlabeledMetrics) {
- graphRecords.push({
- label: t('main-metrics.unlabeled'),
- x: userPastWeek.high - userPastWeek.low,
- y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`,
- });
- }
- graphRecords.push({
- label: t('main-metrics.labeled'),
- x: userPastWeek.low,
- y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`,
- });
- if (userPrevWeek) {
- let pctChange = calculatePercentChange(userPastWeek, userPrevWeek);
- setEmissionsChange(pctChange);
- }
-
- //calculate worst-case carbon footprint
- let worstCarbon = getHighestFootprintForDistance(worstDistance);
- graphRecords.push({
- label: t('main-metrics.labeled'),
- x: worstCarbon,
- y: `${t('main-metrics.worst-case')}`,
- });
- return graphRecords;
- }
- }, [userMetrics?.distance]);
-
- const groupCarbonRecords = useMemo(() => {
- if (aggMetrics?.distance?.length) {
- //separate data into weeks
- const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, dateRange[1])[0];
- logDebug(`groupCarbonRecords: aggMetrics = ${JSON.stringify(aggMetrics)};
- thisWeekDistance = ${JSON.stringify(thisWeekDistance)}`);
-
- let aggThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'aggregate');
- let aggThisWeekSummary = generateSummaryFromData(aggThisWeekModeMap, 'distance');
-
- // Issue 422:
- // https://github.com/e-mission/e-mission-docs/issues/422
- let aggCarbonData: MetricsSummary[] = aggThisWeekSummary.map((summaryEntry) => {
- if (isNaN(summaryEntry.values)) {
- logWarn(`WARNING in calculating groupCarbonRecords: value is NaN for mode
- ${summaryEntry.key}, changing to 0`);
- summaryEntry.values = 0;
- }
- return summaryEntry;
- });
-
- let groupRecords: { label: string; x: number | string; y: number | string }[] = [];
-
- let aggCarbon = {
- low: getFootprintForMetrics(aggCarbonData, 0),
- high: getFootprintForMetrics(aggCarbonData, getHighestFootprint()),
- };
- logDebug(`groupCarbonRecords: aggCarbon = ${JSON.stringify(aggCarbon)}`);
- if (showUnlabeledMetrics) {
- groupRecords.push({
- label: t('main-metrics.unlabeled'),
- x: aggCarbon.high - aggCarbon.low,
- y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`,
- });
- }
- groupRecords.push({
- label: t('main-metrics.labeled'),
- x: aggCarbon.low,
- y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`,
- });
-
- return groupRecords;
- }
- }, [aggMetrics]);
-
- const chartData = useMemo(() => {
- let tempChartData: { label: string; x: number | string; y: number | string }[] = [];
- if (userCarbonRecords?.length) {
- tempChartData = tempChartData.concat(userCarbonRecords);
- }
- if (groupCarbonRecords?.length) {
- tempChartData = tempChartData.concat(groupCarbonRecords);
- }
- tempChartData = tempChartData.reverse();
- return tempChartData;
- }, [userCarbonRecords, groupCarbonRecords]);
-
- const cardSubtitleText = useMemo(() => {
- if (!aggMetrics?.distance?.length) return;
- const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, dateRange[1])
- .slice(0, 2)
- .reverse()
- .flat();
- const recentEntriesRange = formatDateRangeOfDays(recentEntries);
- return `${t('main-metrics.estimated-emissions')}, (${recentEntriesRange})`;
- }, [aggMetrics?.distance]);
-
- //hardcoded here, could be read from config at later customization?
- let carbonGoals = [
- {
- label: t('main-metrics.us-2050-goal'),
- value: 14,
- color: color(colors.warn).darken(0.65).saturate(0.5).rgb().toString(),
- },
- {
- label: t('main-metrics.us-2030-goal'),
- value: 54,
- color: color(colors.danger).saturate(0.5).rgb().toString(),
- },
- ];
- let meter = { dash_key: t('main-metrics.unlabeled'), high: 54, middle: 14 };
-
- return (
-
- }
- style={cardStyles.title(colors)}
- />
-
- {chartData?.length > 0 ? (
-
-
-
- {t('main-metrics.us-goals-footnote')}
-
-
- ) : (
-
- {t('metrics.chart-no-data')}
-
- )}
-
-
- );
-};
-
-export default CarbonFootprintCard;
diff --git a/www/js/metrics/CarbonTextCard.tsx b/www/js/metrics/CarbonTextCard.tsx
deleted file mode 100644
index 225942af1..000000000
--- a/www/js/metrics/CarbonTextCard.tsx
+++ /dev/null
@@ -1,196 +0,0 @@
-import React, { useContext, useMemo } from 'react';
-import { View } from 'react-native';
-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,
- generateSummaryFromData,
- calculatePercentChange,
- segmentDaysByWeeks,
- MetricsSummary,
-} from './metricsHelper';
-import { logDebug, logWarn } from '../plugin/logger';
-import TimelineContext from '../TimelineContext';
-import { isoDatesDifference } from '../diary/timelineHelper';
-import useAppConfig from '../useAppConfig';
-
-type Props = { userMetrics?: MetricsData; aggMetrics?: MetricsData };
-const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => {
- const { colors } = useTheme();
- const { dateRange } = useContext(TimelineContext);
- const { t } = useTranslation();
- const appConfig = useAppConfig();
- // Whether to show the uncertainty on the carbon footprint charts, default: true
- const showUnlabeledMetrics =
- appConfig?.metrics?.phone_dashboard_ui?.footprint_options?.unlabeled_uncertainty ?? true;
-
- const userText = useMemo(() => {
- if (userMetrics?.distance?.length) {
- //separate data into weeks
- const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks(
- userMetrics?.distance,
- dateRange[1],
- );
-
- //formatted data from last week, if exists (14 days ago -> 8 days ago)
- let userLastWeekModeMap = {};
- let userLastWeekSummaryMap = {};
- if (lastWeekDistance && isoDatesDifference(dateRange[0], lastWeekDistance[0].date) >= 0) {
- userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user');
- userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance');
- }
-
- //formatted distance data from this week (7 days ago -> yesterday)
- let userThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'user');
- let userThisWeekSummaryMap = generateSummaryFromData(userThisWeekModeMap, 'distance');
- let worstDistance = userThisWeekSummaryMap.reduce(
- (prevDistance, currModeSummary) => prevDistance + currModeSummary.values,
- 0,
- );
-
- //setting up data to be displayed
- let textList: { label: string; value: string }[] = [];
-
- //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago)
- if (userLastWeekSummaryMap[0]) {
- let userPrevWeek = {
- low: getFootprintForMetrics(userLastWeekSummaryMap, 0),
- high: getFootprintForMetrics(userLastWeekSummaryMap, getHighestFootprint()),
- };
- const label = `${t('main-metrics.prev-week')} (${formatDateRangeOfDays(lastWeekDistance)})`;
- if (userPrevWeek.low == userPrevWeek.high)
- textList.push({ label: label, value: `${Math.round(userPrevWeek.low)}` });
- else
- textList.push({
- label: label + '²',
- value: `${Math.round(userPrevWeek.low)} - ${Math.round(userPrevWeek.high)}`,
- });
- }
-
- //calculate low-high and format range for past week (7 days ago -> yesterday)
- let userPastWeek = {
- low: getFootprintForMetrics(userThisWeekSummaryMap, 0),
- high: getFootprintForMetrics(userThisWeekSummaryMap, getHighestFootprint()),
- };
- const label = `${t('main-metrics.past-week')} (${formatDateRangeOfDays(thisWeekDistance)})`;
- if (userPastWeek.low == userPastWeek.high)
- textList.push({ label: label, value: `${Math.round(userPastWeek.low)}` });
- else
- textList.push({
- label: label + '²',
- value: `${Math.round(userPastWeek.low)} - ${Math.round(userPastWeek.high)}`,
- });
-
- //calculate worst-case carbon footprint
- let worstCarbon = getHighestFootprintForDistance(worstDistance);
- textList.push({ label: t('main-metrics.worst-case'), value: `${Math.round(worstCarbon)}` });
-
- return textList;
- }
- }, [userMetrics]);
-
- const groupText = useMemo(() => {
- if (aggMetrics?.distance?.length) {
- //separate data into weeks
- const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, dateRange[1])[0];
-
- let aggThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'aggregate');
- let aggThisWeekSummary = generateSummaryFromData(aggThisWeekModeMap, 'distance');
-
- // Issue 422:
- // https://github.com/e-mission/e-mission-docs/issues/422
- let aggCarbonData: MetricsSummary[] = aggThisWeekSummary.map((summaryEntry) => {
- if (isNaN(summaryEntry.values)) {
- logWarn(`WARNING in calculating groupCarbonRecords: value is NaN for mode
- ${summaryEntry.key}, changing to 0`);
- summaryEntry.values = 0;
- }
- return summaryEntry;
- });
-
- let groupText: { label: string; value: string }[] = [];
-
- let aggCarbon = {
- low: getFootprintForMetrics(aggCarbonData, 0),
- high: getFootprintForMetrics(aggCarbonData, getHighestFootprint()),
- };
- logDebug(`groupText: aggCarbon = ${JSON.stringify(aggCarbon)}`);
- const label = t('main-metrics.average');
- if (aggCarbon.low == aggCarbon.high)
- groupText.push({ label: label, value: `${Math.round(aggCarbon.low)}` });
- else
- groupText.push({
- label: label + '²',
- value: `${Math.round(aggCarbon.low)} - ${Math.round(aggCarbon.high)}`,
- });
-
- return groupText;
- }
- }, [aggMetrics]);
-
- const textEntries = useMemo(() => {
- let tempText: { label: string; value: string }[] = [];
- if (userText?.length) {
- tempText = tempText.concat(userText);
- }
- if (groupText?.length) {
- tempText = tempText.concat(groupText);
- }
- return tempText;
- }, [userText, groupText]);
-
- const cardSubtitleText = useMemo(() => {
- if (!aggMetrics?.distance?.length) return;
- const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, dateRange[1])
- .slice(0, 2)
- .reverse()
- .flat();
- const recentEntriesRange = formatDateRangeOfDays(recentEntries);
- return `${t('main-metrics.estimated-emissions')}, (${recentEntriesRange})`;
- }, [aggMetrics?.distance]);
-
- return (
-
-
-
- {textEntries?.length > 0 &&
- Object.keys(textEntries).map((i) => (
-
- {textEntries[i].label}
- {textEntries[i].value + ' ' + 'kg CO₂'}
-
- ))}
- {showUnlabeledMetrics && (
-
- {t('main-metrics.range-uncertain-footnote')}
-
- )}
-
-
- );
-};
-
-export default CarbonTextCard;
diff --git a/www/js/metrics/DailyActiveMinutesCard.tsx b/www/js/metrics/DailyActiveMinutesCard.tsx
deleted file mode 100644
index f70b60587..000000000
--- a/www/js/metrics/DailyActiveMinutesCard.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-import React, { useMemo } from 'react';
-import { View } from 'react-native';
-import { Card, Text, useTheme } from 'react-native-paper';
-import { MetricsData } from './metricsTypes';
-import { cardStyles } from './MetricsTab';
-import { useTranslation } from 'react-i18next';
-import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper';
-import LineChart from '../components/LineChart';
-import { getBaseModeByText } from '../diary/diaryHelper';
-import { tsForDayOfMetricData, valueForFieldOnDay } from './metricsHelper';
-import useAppConfig from '../useAppConfig';
-import { ACTIVE_MODES } from './WeeklyActiveMinutesCard';
-
-type Props = { userMetrics?: MetricsData };
-const DailyActiveMinutesCard = ({ userMetrics }: Props) => {
- const { colors } = useTheme();
- const { t } = useTranslation();
- const appConfig = useAppConfig();
- // modes to consider as "active" for the purpose of calculating "active minutes", default : ['walk', 'bike']
- const activeModes =
- appConfig?.metrics?.phone_dashboard_ui?.active_travel_options?.modes_list ?? ACTIVE_MODES;
-
- const dailyActiveMinutesRecords = useMemo(() => {
- const records: { label: string; x: number; y: number }[] = [];
- const recentDays = userMetrics?.duration?.slice(-14);
- recentDays?.forEach((day) => {
- activeModes.forEach((mode) => {
- const activeSeconds = valueForFieldOnDay(day, 'mode_confirm', mode);
- records.push({
- label: labelKeyToRichMode(mode),
- x: tsForDayOfMetricData(day) * 1000, // vertical chart, milliseconds on X axis
- y: activeSeconds ? activeSeconds / 60 : null, // minutes on Y axis
- });
- });
- });
- return records as { label: ActiveMode; x: number; y: number }[];
- }, [userMetrics?.duration]);
-
- return (
-
-
-
- {dailyActiveMinutesRecords.length ? (
- getBaseModeByText(l, labelOptions).color}
- />
- ) : (
-
- {t('metrics.chart-no-data')}
-
- )}
-
-
- );
-};
-
-export default DailyActiveMinutesCard;
diff --git a/www/js/metrics/MetricsScreen.tsx b/www/js/metrics/MetricsScreen.tsx
new file mode 100644
index 000000000..18b8c3744
--- /dev/null
+++ b/www/js/metrics/MetricsScreen.tsx
@@ -0,0 +1,73 @@
+import React, { useState } from 'react';
+import { ScrollView, StyleSheet, ViewStyle } from 'react-native';
+import { shadow } from 'react-native-paper';
+import { TabsProvider, Tabs, TabScreen } from 'react-native-paper-tabs';
+import FootprintSection from './footprint/FootprintSection';
+import MovementSection from './movement/MovementSection';
+import TravelSection from './travel/TravelSection';
+import useAppConfig from '../useAppConfig';
+import { MetricsUiSection } from '../types/appConfigTypes';
+import SurveysSection from './surveys/SurveysSection';
+import { useAppTheme } from '../appTheme';
+import i18next from 'i18next';
+
+const DEFAULT_SECTIONS_TO_SHOW: MetricsUiSection[] = ['footprint', 'movement', 'travel'];
+
+const SECTIONS: Record = {
+ footprint: [FootprintSection, 'shoe-print', i18next.t('metrics.footprint.footprint')],
+ movement: [MovementSection, 'run', i18next.t('metrics.movement.movement')],
+ travel: [TravelSection, 'chart-timeline', i18next.t('metrics.travel.travel')],
+ surveys: [SurveysSection, 'clipboard-list', i18next.t('metrics.surveys.surveys')],
+};
+
+const MetricsScreen = ({ userMetrics, aggMetrics, metricList }) => {
+ const { colors } = useAppTheme();
+ const appConfig = useAppConfig();
+ const sectionsToShow: string[] =
+ appConfig?.metrics?.phone_dashboard_ui?.sections || DEFAULT_SECTIONS_TO_SHOW;
+ const [selectedSection, setSelectedSection] = useState(sectionsToShow[0]);
+
+ const studyStartDate = `${appConfig?.intro.start_month} / ${appConfig?.intro.start_year}`;
+
+ return (
+
+ 2 ? 'scrollable' : 'fixed'}
+ style={{ backgroundColor: colors.elevation.level2, ...(shadow(2) as ViewStyle) }}>
+ {Object.entries(SECTIONS).map(([section, [Component, icon, label]]) =>
+ sectionsToShow.includes(section) ? (
+
+
+
+
+
+ ) : null,
+ )}
+
+
+ );
+};
+
+export const metricsStyles = StyleSheet.create({
+ card: {
+ overflow: 'hidden',
+ minHeight: 300,
+ },
+ subtitleText: {
+ fontSize: 13,
+ lineHeight: 13,
+ fontWeight: '400',
+ fontStyle: 'italic',
+ },
+ content: {
+ gap: 12,
+ flex: 1,
+ },
+});
+
+export default MetricsScreen;
diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx
index 2c70d3a83..d4fdddc35 100644
--- a/www/js/metrics/MetricsTab.tsx
+++ b/www/js/metrics/MetricsTab.tsx
@@ -1,43 +1,24 @@
import React, { useEffect, useState, useMemo, useContext } from 'react';
-import { ScrollView, useWindowDimensions } from 'react-native';
-import { Appbar, useTheme } from 'react-native-paper';
+import { Appbar } from 'react-native-paper';
import { useTranslation } from 'react-i18next';
import { DateTime } from 'luxon';
import NavBar from '../components/NavBar';
import { MetricsData } from './metricsTypes';
-import MetricsCard from './MetricsCard';
-import WeeklyActiveMinutesCard from './WeeklyActiveMinutesCard';
-import CarbonFootprintCard from './CarbonFootprintCard';
-import Carousel from '../components/Carousel';
-import DailyActiveMinutesCard from './DailyActiveMinutesCard';
-import CarbonTextCard from './CarbonTextCard';
-import ActiveMinutesTableCard from './ActiveMinutesTableCard';
import { getAggregateData } from '../services/commHelper';
import { displayError, displayErrorMsg, logDebug } from '../plugin/logger';
import useAppConfig from '../useAppConfig';
-import {
- AppConfig,
- GroupingField,
- MetricName,
- MetricList,
- MetricsUiSection,
-} from '../types/appConfigTypes';
+import { AppConfig, MetricList } from '../types/appConfigTypes';
import DateSelect from '../diary/list/DateSelect';
import TimelineContext, { TimelineLabelMap, TimelineMap } from '../TimelineContext';
-import { isoDatesDifference } from '../diary/timelineHelper';
import { metrics_summaries } from 'e-mission-common';
-import SurveyLeaderboardCard from './SurveyLeaderboardCard';
-import SurveyTripCategoriesCard from './SurveyTripCategoriesCard';
-import SurveyComparisonCard from './SurveyComparisonCard';
+import MetricsScreen from './MetricsScreen';
+import { LabelOptions } from '../types/labelTypes';
+import { useAppTheme } from '../appTheme';
+import { isoDatesDifference } from '../util';
-// 2 weeks of data is needed in order to compare "past week" vs "previous week"
const N_DAYS_TO_LOAD = 14; // 2 weeks
-const DEFAULT_SECTIONS_TO_SHOW: MetricsUiSection[] = [
- 'footprint',
- 'active_travel',
- 'summary',
-] as const;
export const DEFAULT_METRIC_LIST: MetricList = {
+ footprint: ['mode_confirm'],
distance: ['mode_confirm'],
duration: ['mode_confirm'],
count: ['mode_confirm'],
@@ -46,15 +27,20 @@ export const DEFAULT_METRIC_LIST: MetricList = {
async function computeUserMetrics(
metricList: MetricList,
timelineMap: TimelineMap,
- timelineLabelMap: TimelineLabelMap | null,
appConfig: AppConfig,
+ timelineLabelMap: TimelineLabelMap | null,
+ labelOptions: LabelOptions,
) {
try {
const timelineValues = [...timelineMap.values()];
- const result = metrics_summaries.generate_summaries(
+ const app_config = {
+ ...appConfig,
+ ...(metricList.footprint ? { label_options: labelOptions } : {}),
+ };
+ const result = await metrics_summaries.generate_summaries(
{ ...metricList },
timelineValues,
- appConfig,
+ app_config,
timelineLabelMap,
);
logDebug('MetricsTab: computed userMetrics');
@@ -69,6 +55,7 @@ async function fetchAggMetrics(
metricList: MetricList,
dateRange: [string, string],
appConfig: AppConfig,
+ labelOptions: LabelOptions,
) {
logDebug('MetricsTab: fetching agg metrics from server for dateRange ' + dateRange);
const query = {
@@ -77,7 +64,10 @@ async function fetchAggMetrics(
end_time: dateRange[1],
metric_list: metricList,
is_return_aggregate: true,
- app_config: { survey_info: appConfig.survey_info },
+ app_config: {
+ ...(metricList.response_count ? { survey_info: appConfig.survey_info } : {}),
+ ...(metricList.footprint ? { label_options: labelOptions } : {}),
+ },
};
return getAggregateData('result/metrics/yyyy_mm_dd', query, appConfig.server)
.then((response) => {
@@ -91,64 +81,82 @@ async function fetchAggMetrics(
}
const MetricsTab = () => {
+ const { colors } = useAppTheme();
const appConfig = useAppConfig();
const { t } = useTranslation();
const {
- dateRange,
+ queriedDateRange,
timelineMap,
timelineLabelMap,
+ labelOptions,
timelineIsLoading,
refreshTimeline,
loadMoreDays,
loadDateRange,
} = useContext(TimelineContext);
- const metricList = appConfig?.metrics?.phone_dashboard_ui?.metric_list ?? DEFAULT_METRIC_LIST;
+ const metricList = appConfig?.metrics?.phone_dashboard_ui?.metric_list || DEFAULT_METRIC_LIST;
const [userMetrics, setUserMetrics] = useState(undefined);
const [aggMetrics, setAggMetrics] = useState(undefined);
const [aggMetricsIsLoading, setAggMetricsIsLoading] = useState(false);
+ const [isInitialized, setIsInitialized] = useState(false);
- const readyToLoad = useMemo(() => {
- if (!appConfig || !dateRange) return false;
- const dateRangeDays = isoDatesDifference(...dateRange);
- if (dateRangeDays < N_DAYS_TO_LOAD) {
- logDebug('MetricsTab: not enough days loaded, trying to load more');
- const loadingMore = loadMoreDays('past', N_DAYS_TO_LOAD - dateRangeDays);
- if (loadingMore !== false) return false;
- logDebug('MetricsTab: no more days can be loaded, continuing with what we have');
+ useEffect(() => {
+ if (!isInitialized && appConfig && queriedDateRange) {
+ logDebug('MetricsTab: initializing');
+ const queriedNumDays = isoDatesDifference(...queriedDateRange) + 1;
+ if (queriedNumDays < N_DAYS_TO_LOAD) {
+ logDebug('MetricsTab: not enough days loaded, trying to load more');
+ const loadingMore = loadMoreDays('past', N_DAYS_TO_LOAD - queriedNumDays);
+ if (!loadingMore) {
+ logDebug('MetricsTab: no more days can be loaded, continuing with what we have');
+ setIsInitialized(true);
+ }
+ } else {
+ setIsInitialized(true);
+ }
}
- return true;
- }, [appConfig, dateRange]);
+ }, [appConfig, queriedDateRange]);
useEffect(() => {
- if (!readyToLoad || !appConfig || timelineIsLoading || !timelineMap || !timelineLabelMap)
+ if (
+ !isInitialized ||
+ !appConfig ||
+ timelineIsLoading ||
+ !timelineMap ||
+ !timelineLabelMap ||
+ !labelOptions
+ )
return;
logDebug('MetricsTab: ready to compute userMetrics');
- computeUserMetrics(metricList, timelineMap, timelineLabelMap, appConfig).then((result) =>
- setUserMetrics(result),
+ computeUserMetrics(metricList, timelineMap, appConfig, timelineLabelMap, labelOptions).then(
+ (result) => setUserMetrics(result),
);
- }, [readyToLoad, appConfig, timelineIsLoading, timelineMap, timelineLabelMap]);
+ }, [isInitialized, appConfig, timelineIsLoading, timelineMap, timelineLabelMap]);
useEffect(() => {
- if (!readyToLoad || !appConfig || !dateRange) return;
+ if (!isInitialized || !appConfig || !queriedDateRange || !labelOptions) return;
logDebug('MetricsTab: ready to fetch aggMetrics');
setAggMetricsIsLoading(true);
- fetchAggMetrics(metricList, dateRange, appConfig).then((response) => {
+ fetchAggMetrics(metricList, queriedDateRange, appConfig, labelOptions).then((response) => {
setAggMetricsIsLoading(false);
setAggMetrics(response);
});
- }, [readyToLoad, appConfig, dateRange]);
+ }, [isInitialized, appConfig, queriedDateRange]);
- const sectionsToShow =
- appConfig?.metrics?.phone_dashboard_ui?.sections || DEFAULT_SECTIONS_TO_SHOW;
- const { width: windowWidth } = useWindowDimensions();
- const cardWidth = windowWidth * 0.88;
- const studyStartDate = `${appConfig?.intro.start_month} / ${appConfig?.intro.start_year}`;
+ function refresh() {
+ refreshTimeline();
+ setIsInitialized(false);
+ setAggMetricsIsLoading(true);
+ }
return (
<>
-
+
{
loadDateRange([start, end]);
}}
/>
-
+
-
- {sectionsToShow.includes('footprint') && (
-
-
-
-
- )}
- {sectionsToShow.includes('active_travel') && (
-
-
-
-
-
- )}
- {sectionsToShow.includes('summary') && (
-
- {Object.entries(metricList).map(
- ([metricName, groupingFields]: [MetricName, GroupingField[]]) => {
- return (
-
- );
- },
- )}
-
- )}
- {sectionsToShow.includes('surveys') && (
-
-
-
-
- )}
- {/* we will implement leaderboard later */}
- {/* {sectionsToShow.includes('engagement') && (
-
-
-
- )} */}
-
+
>
);
};
-export const cardMargin = 10;
-
-export const cardStyles: any = {
- card: {
- overflow: 'hidden',
- minHeight: 300,
- },
- title: (colors) => ({
- backgroundColor: colors.primary,
- paddingHorizontal: 8,
- minHeight: 52,
- }),
- titleText: (colors) => ({
- color: colors.onPrimary,
- fontWeight: '500',
- textAlign: 'center',
- }),
- subtitleText: {
- fontSize: 13,
- lineHeight: 13,
- fontWeight: '400',
- fontStyle: 'italic',
- },
- content: {
- padding: 8,
- paddingBottom: 12,
- flex: 1,
- },
-};
-
export default MetricsTab;
diff --git a/www/js/metrics/SumaryCard.tsx b/www/js/metrics/SumaryCard.tsx
new file mode 100644
index 000000000..7f3170c5d
--- /dev/null
+++ b/www/js/metrics/SumaryCard.tsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import { View, StyleSheet } from 'react-native';
+import { Card, Text } from 'react-native-paper';
+import { formatForDisplay } from '../util';
+import { colors } from '../appTheme';
+import { t } from 'i18next';
+import { FootprintGoal } from '../types/appConfigTypes';
+import { metricsStyles } from './MetricsScreen';
+
+type Value = [number, number];
+type Props = {
+ title: string;
+ unit: string;
+ value: Value;
+ nDays: number;
+ goals: FootprintGoal[];
+};
+const SummaryCard = ({ title, unit, value, nDays, goals }: Props) => {
+ const valueIsRange = value[0] != value[1];
+ const perDayValue = value.map((v) => v / nDays) as Value;
+
+ const formatVal = (v: Value) => {
+ const opts = { maximumFractionDigits: 1 };
+ if (valueIsRange) return `${formatForDisplay(v[0], opts)} - ${formatForDisplay(v[1], opts)}`;
+ return `${formatForDisplay(v[0], opts)}`;
+ };
+
+ const colorFn = (v: Value) => {
+ const low = v[0];
+ const high = v[1];
+ if (high < goals[0]?.value) return colors.success;
+ if (low > goals[goals.length - 1]?.value) return colors.error;
+ return colors.onSurfaceVariant;
+ };
+
+ return (
+
+
+ {!isNaN(value[0]) ? (
+
+
+ {formatVal(value)} {unit}
+
+
+
+
+ {formatVal(perDayValue)} {unit}
+
+ per day
+
+
+
+ ) : (
+
+ {t('metrics.no-data')}
+
+ )}
+
+ );
+};
+
+const s = StyleSheet.create({
+ titleText: {
+ fontSize: 24,
+ fontWeight: 'bold',
+ margin: 'auto',
+ },
+ perDay: {
+ borderLeftWidth: 5,
+ paddingLeft: 12,
+ marginLeft: 4,
+ },
+});
+
+export default SummaryCard;
diff --git a/www/js/metrics/WeeklyActiveMinutesCard.tsx b/www/js/metrics/WeeklyActiveMinutesCard.tsx
deleted file mode 100644
index 4201f993e..000000000
--- a/www/js/metrics/WeeklyActiveMinutesCard.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import React, { useContext, useMemo, useState } from 'react';
-import { View } from 'react-native';
-import { Card, Text, useTheme } from 'react-native-paper';
-import { MetricsData } from './metricsTypes';
-import { cardMargin, cardStyles } from './MetricsTab';
-import { formatDateRangeOfDays, segmentDaysByWeeks, valueForFieldOnDay } from './metricsHelper';
-import { useTranslation } from 'react-i18next';
-import BarChart from '../components/BarChart';
-import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper';
-import { getBaseModeByText } from '../diary/diaryHelper';
-import TimelineContext from '../TimelineContext';
-import useAppConfig from '../useAppConfig';
-
-export const ACTIVE_MODES = ['walk', 'bike'] as const;
-type ActiveMode = (typeof ACTIVE_MODES)[number];
-
-type Props = { userMetrics?: MetricsData };
-const WeeklyActiveMinutesCard = ({ userMetrics }: Props) => {
- const { colors } = useTheme();
- const { dateRange } = useContext(TimelineContext);
- const { t } = useTranslation();
- const appConfig = useAppConfig();
- // modes to consider as "active" for the purpose of calculating "active minutes", default : ['walk', 'bike']
- const activeModes =
- appConfig?.metrics?.phone_dashboard_ui?.active_travel_options?.modes_list ?? ACTIVE_MODES;
- const weeklyActiveMinutesRecords = useMemo(() => {
- if (!userMetrics?.duration) return [];
- const records: { x: string; y: number; label: string }[] = [];
- const [recentWeek, prevWeek] = segmentDaysByWeeks(userMetrics?.duration, dateRange[1]);
- activeModes.forEach((mode) => {
- if (prevWeek) {
- const prevSum = prevWeek?.reduce(
- (acc, day) => acc + (valueForFieldOnDay(day, 'mode_confirm', mode) || 0),
- 0,
- );
- const xLabel = `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(prevWeek)})`;
- records.push({ label: labelKeyToRichMode(mode), x: xLabel, y: prevSum / 60 });
- }
- const recentSum = recentWeek?.reduce(
- (acc, day) => acc + (valueForFieldOnDay(day, 'mode_confirm', mode) || 0),
- 0,
- );
- const xLabel = `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(recentWeek)})`;
- records.push({ label: labelKeyToRichMode(mode), x: xLabel, y: recentSum / 60 });
- });
- return records as { label: ActiveMode; x: string; y: number }[];
- }, [userMetrics?.duration]);
-
- return (
-
-
-
- {weeklyActiveMinutesRecords.length ? (
-
- getBaseModeByText(l, labelOptions).color}
- />
-
- {t('main-metrics.weekly-goal-footnote')}
-
-
- ) : (
-
- {t('metrics.chart-no-data')}
-
- )}
-
-
- );
-};
-
-export default WeeklyActiveMinutesCard;
diff --git a/www/js/metrics/customMetricsHelper.ts b/www/js/metrics/customMetricsHelper.ts
deleted file mode 100644
index b5f099d06..000000000
--- a/www/js/metrics/customMetricsHelper.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-import { getLabelOptions } from '../survey/multilabel/confirmHelper';
-import { displayError, logDebug, logWarn } from '../plugin/logger';
-import { standardMETs } from './metDataset';
-import { AppConfig } from '../types/appConfigTypes';
-
-//variables to store values locally
-let _customMETs: { [key: string]: { [key: string]: { range: number[]; met: number } } };
-let _customPerKmFootprint: { [key: string]: number };
-let _labelOptions;
-
-/**
- * ONLY USED IN TESTING
- * @function clears the locally stored variables
- */
-export function _test_clearCustomMetrics() {
- _customMETs = undefined;
- _customPerKmFootprint = undefined;
- _labelOptions = undefined;
-}
-
-/**
- * @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) {
- 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) => typeof e !== 'undefined'));
- 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 (typeof opt.kgCo2PerKm !== 'undefined') {
- return [opt.value, opt.kgCo2PerKm];
- } else {
- return undefined;
- }
- })
- .filter((modeCO2) => typeof modeCO2 !== 'undefined');
- _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');
- const labelOptions = await getLabelOptions(newConfig);
- _labelOptions = labelOptions;
- populateCustomMETs();
- populateCustomFootprints();
- } catch (e) {
- setTimeout(() => {
- displayError(e, 'Error while initializing custom dataset helper');
- }, 1000);
- }
-}
diff --git a/www/js/metrics/ChangeIndicator.tsx b/www/js/metrics/footprint/ChangeIndicator.tsx
similarity index 97%
rename from www/js/metrics/ChangeIndicator.tsx
rename to www/js/metrics/footprint/ChangeIndicator.tsx
index 8118d59ad..fca1ca146 100644
--- a/www/js/metrics/ChangeIndicator.tsx
+++ b/www/js/metrics/footprint/ChangeIndicator.tsx
@@ -3,7 +3,7 @@ import { View } from 'react-native';
import { Text } from 'react-native-paper';
import { useTranslation } from 'react-i18next';
import colorLib from 'color';
-import { useAppTheme } from '../appTheme';
+import { useAppTheme } from '../../appTheme';
export type CarbonChange = { low: number; high: number } | undefined;
type Props = { change: CarbonChange };
diff --git a/www/js/metrics/footprint/FootprintComparisonCard.tsx b/www/js/metrics/footprint/FootprintComparisonCard.tsx
new file mode 100644
index 000000000..344a94164
--- /dev/null
+++ b/www/js/metrics/footprint/FootprintComparisonCard.tsx
@@ -0,0 +1,100 @@
+import React, { useContext, useMemo } from 'react';
+import { Card, Text } from 'react-native-paper';
+import { metricsStyles } from '../MetricsScreen';
+import BarChart from '../../components/BarChart';
+import { useTranslation } from 'react-i18next';
+import { ChartRecord } from '../../components/Chart';
+import TimelineContext from '../../TimelineContext';
+import { formatIsoNoYear } from '../../util';
+
+const FootprintComparisonCard = ({
+ type,
+ unit,
+ userCumulativeFootprint,
+ groupCumulativeFootprint,
+ title,
+ addFootnote,
+ axisTitle,
+ goals,
+ showUncertainty,
+ nDays,
+}) => {
+ const { t } = useTranslation();
+ const { queriedDateRange } = useContext(TimelineContext);
+
+ const chartRecords = useMemo(() => {
+ let records: ChartRecord[] = [];
+ if (!queriedDateRange || !userCumulativeFootprint || !groupCumulativeFootprint) return records;
+
+ const nAggUserDays = groupCumulativeFootprint['nUsers'];
+ const yAxisLabelGroup =
+ t('metrics.footprint.group-average') + '\n' + formatIsoNoYear(...queriedDateRange);
+ records.push({
+ label: t('metrics.footprint.labeled'),
+ x: groupCumulativeFootprint[unit] / nAggUserDays,
+ y: yAxisLabelGroup,
+ });
+ if (showUncertainty && groupCumulativeFootprint[`${unit}_uncertain`]) {
+ records.push({
+ label:
+ t('metrics.footprint.unlabeled') +
+ addFootnote(t('metrics.footprint.uncertainty-footnote')),
+ x: groupCumulativeFootprint[`${unit}_uncertain`]! / nAggUserDays,
+ y: yAxisLabelGroup,
+ });
+ }
+
+ const yAxisLabelUser = t('metrics.footprint.you') + '\n' + formatIsoNoYear(...queriedDateRange);
+ records.push({
+ label: t('metrics.footprint.labeled'),
+ x: userCumulativeFootprint[unit] / nDays,
+ y: yAxisLabelUser,
+ });
+ if (showUncertainty && userCumulativeFootprint[`${unit}_uncertain`]) {
+ records.push({
+ label:
+ t('metrics.footprint.unlabeled') +
+ addFootnote(t('metrics.footprint.uncertainty-footnote')),
+ x: userCumulativeFootprint[`${unit}_uncertain`]! / nDays,
+ y: yAxisLabelUser,
+ });
+ }
+
+ return records;
+ }, [userCumulativeFootprint, groupCumulativeFootprint]);
+
+ let meter = goals[type]?.length
+ ? {
+ uncertainty_prefix: t('metrics.footprint.unlabeled'),
+ middle: goals[type][0].value,
+ high: goals[type][goals[type].length - 1].value,
+ }
+ : undefined;
+
+ return (
+
+
+
+ {chartRecords?.length > 0 ? (
+ <>
+
+ >
+ ) : (
+
+ {t('metrics.no-data')}
+
+ )}
+
+
+ );
+};
+
+export default FootprintComparisonCard;
diff --git a/www/js/metrics/footprint/FootprintSection.tsx b/www/js/metrics/footprint/FootprintSection.tsx
new file mode 100644
index 000000000..c871f9b07
--- /dev/null
+++ b/www/js/metrics/footprint/FootprintSection.tsx
@@ -0,0 +1,146 @@
+import React, { useContext, useMemo, useState } from 'react';
+import { View } from 'react-native';
+import { Text } from 'react-native-paper';
+import color from 'color';
+import SummaryCard from '../SumaryCard';
+import { useTranslation } from 'react-i18next';
+import { sumMetricEntries } from '../metricsHelper';
+import TimelineContext from '../../TimelineContext';
+import { formatIso, isoDatesDifference } from '../../util';
+import WeeklyFootprintCard from './WeeklyFootprintCard';
+import useAppConfig from '../../useAppConfig';
+import { getFootprintGoals } from './footprintHelper';
+import FootprintComparisonCard from './FootprintComparisonCard';
+
+const FootprintSection = ({ userMetrics, aggMetrics, metricList }) => {
+ const { t } = useTranslation();
+ const appConfig = useAppConfig();
+ const { queriedDateRange } = useContext(TimelineContext);
+
+ const [footnotes, setFootnotes] = useState([]);
+
+ function addFootnote(note: string) {
+ const i = footnotes.findIndex((n) => n == note);
+ let footnoteNumber: number;
+ const superscriptDigits = '⁰¹²³⁴⁵⁶⁷⁸⁹';
+ if (i >= 0) {
+ footnoteNumber = i + 1;
+ } else {
+ setFootnotes([...footnotes, note]);
+ footnoteNumber = footnotes.length + 1;
+ }
+ return footnoteNumber
+ .toString()
+ .split('')
+ .map((d) => superscriptDigits[parseInt(d)])
+ .join('');
+ }
+
+ const userCumulativeFootprint = useMemo(
+ () =>
+ userMetrics?.footprint?.length ? sumMetricEntries(userMetrics?.footprint, 'footprint') : null,
+ [userMetrics?.footprint],
+ );
+
+ const groupCumulativeFootprint = useMemo(
+ () =>
+ aggMetrics?.footprint?.length ? sumMetricEntries(aggMetrics?.footprint, 'footprint') : null,
+ [aggMetrics?.footprint],
+ );
+
+ const goals = getFootprintGoals(appConfig, addFootnote);
+
+ // defaults to true if not defined in config
+ const showUncertainty =
+ appConfig?.metrics?.phone_dashboard_ui?.footprint_options?.unlabeled_uncertainty !== false;
+
+ if (!queriedDateRange) return null;
+ const nDays = isoDatesDifference(...queriedDateRange) + 1;
+
+ return (
+ <>
+
+ {t('metrics.footprint.estimated-footprint')}
+ {`${formatIso(...queriedDateRange)} (${nDays} days)`}
+
+ {userCumulativeFootprint && (
+
+
+
+
+ )}
+
+
+
+
+ {footnotes.length && (
+
+ {footnotes.map((note, i) => (
+
+ {addFootnote(note)}
+ {note}
+
+ ))}
+
+ )}
+ >
+ );
+};
+
+export default FootprintSection;
diff --git a/www/js/metrics/footprint/WeeklyFootprintCard.tsx b/www/js/metrics/footprint/WeeklyFootprintCard.tsx
new file mode 100644
index 000000000..5f4968f1a
--- /dev/null
+++ b/www/js/metrics/footprint/WeeklyFootprintCard.tsx
@@ -0,0 +1,151 @@
+import React, { useContext, useMemo, useState } from 'react';
+import { View } from 'react-native';
+import { Card, Checkbox, Text } from 'react-native-paper';
+import { metricsStyles } from '../MetricsScreen';
+import TimelineContext from '../../TimelineContext';
+import {
+ aggMetricEntries,
+ getColorForModeLabel,
+ segmentDaysByWeeks,
+ sumMetricEntries,
+ trimGroupingPrefix,
+} from '../metricsHelper';
+import { formatIsoNoYear, isoDateWithOffset } from '../../util';
+import { useTranslation } from 'react-i18next';
+import BarChart from '../../components/BarChart';
+import { ChartRecord } from '../../components/Chart';
+import i18next from 'i18next';
+import { MetricsData } from '../metricsTypes';
+import { GroupingField, MetricList } from '../../types/appConfigTypes';
+import { labelKeyToText } from '../../survey/multilabel/confirmHelper';
+
+type Props = {
+ type: 'carbon' | 'energy';
+ unit: 'kg_co2' | 'kwh';
+ title: string;
+ axisTitle: string;
+ goals;
+ addFootnote: (string) => string;
+ showUncertainty: boolean;
+ userMetrics: MetricsData;
+ metricList: MetricList;
+};
+const WeeklyFootprintCard = ({
+ type,
+ unit,
+ title,
+ axisTitle,
+ goals,
+ showUncertainty,
+ addFootnote,
+ userMetrics,
+ metricList,
+}: Props) => {
+ const { t } = useTranslation();
+ const { dateRange } = useContext(TimelineContext);
+ const [groupingField, setGroupingField] = useState(null);
+
+ const weekFootprints = useMemo(() => {
+ if (!userMetrics?.footprint?.length) return [];
+ const weeks = segmentDaysByWeeks(userMetrics?.footprint, dateRange[1]);
+ return weeks.map(
+ (week) => [sumMetricEntries(week, 'footprint'), aggMetricEntries(week, 'footprint')] as const,
+ );
+ }, [userMetrics]);
+
+ const chartRecords = useMemo(() => {
+ let records: ChartRecord[] = [];
+ weekFootprints.forEach(([weekSum, weekAgg], i) => {
+ const startDate = isoDateWithOffset(dateRange[1], -7 * (i + 1) + 1);
+ if (startDate < dateRange[0]) return; // partial week at beginning of queried range; skip
+ const endDate = isoDateWithOffset(dateRange[1], -7 * i);
+ const displayDateRange = formatIsoNoYear(startDate, endDate);
+ if (groupingField) {
+ Object.keys(weekAgg)
+ .filter((key) => key.startsWith(groupingField) && !key.endsWith('UNLABELED'))
+ .forEach((key) => {
+ if (weekAgg[key][unit]) {
+ records.push({
+ label: labelKeyToText(trimGroupingPrefix(key)),
+ x: weekAgg[key][unit] / 7,
+ y: displayDateRange,
+ });
+ }
+ });
+ } else {
+ records.push({
+ label: t('metrics.footprint.labeled'),
+ x: weekSum[unit] / 7,
+ y: displayDateRange,
+ });
+ }
+ if (showUncertainty && weekSum[`${unit}_uncertain`]) {
+ records.push({
+ label:
+ t('metrics.footprint.unlabeled') +
+ addFootnote(t('metrics.footprint.uncertainty-footnote')),
+ x: weekSum[`${unit}_uncertain`]! / 7,
+ y: displayDateRange,
+ });
+ }
+ });
+ return records;
+ }, [weekFootprints, groupingField]);
+
+ let meter = goals[type]?.length
+ ? {
+ uncertainty_prefix: t('metrics.footprint.unlabeled'),
+ middle: goals[type][0].value,
+ high: goals[type][goals[type].length - 1].value,
+ }
+ : undefined;
+
+ return (
+
+
+
+ {chartRecords?.length > 0 ? (
+ <>
+
+ {metricList.footprint!.map((gf: GroupingField) => (
+
+
+ {t('metrics.split-by', { field: t(`metrics.grouping-fields.${gf}`) })}
+
+
+ groupingField == gf ? setGroupingField(null) : setGroupingField(gf)
+ }
+ />
+
+ ))}
+ >
+ ) : (
+
+ {t('metrics.no-data')}
+
+ )}
+
+
+ );
+};
+
+export default WeeklyFootprintCard;
diff --git a/www/js/metrics/footprint/footprintHelper.ts b/www/js/metrics/footprint/footprintHelper.ts
new file mode 100644
index 000000000..99dfa738b
--- /dev/null
+++ b/www/js/metrics/footprint/footprintHelper.ts
@@ -0,0 +1,51 @@
+import i18next from 'i18next';
+import color from 'color';
+import { colors } from '../../appTheme';
+import AppConfig from '../../types/appConfigTypes';
+
+const lang = i18next.resolvedLanguage || 'en';
+const darkWarn = color(colors.warn).darken(0.65).saturate(0.5).rgb().toString();
+const darkDanger = color(colors.danger).darken(0.65).saturate(0.5).rgb().toString();
+const DEFAULT_FOOTPRINT_GOALS = {
+ carbon: [
+ {
+ label: { [lang]: i18next.t('metrics.footprint.us-2050-goal') },
+ value: 2,
+ color: darkDanger,
+ },
+ {
+ label: { [lang]: i18next.t('metrics.footprint.us-2030-goal') },
+ value: 7.7,
+ color: darkWarn,
+ },
+ ],
+ energy: [
+ {
+ label: { [lang]: i18next.t('metrics.footprint.us-2050-goal') },
+ value: 5.7,
+ color: darkDanger,
+ },
+ {
+ label: { [lang]: i18next.t('metrics.footprint.us-2030-goal') },
+ value: 22,
+ color: darkWarn,
+ },
+ ],
+ goals_footnote: { [lang]: i18next.t('metrics.footprint.us-goals-footnote') },
+};
+
+export function getFootprintGoals(appConfig: AppConfig, addFootnote: (footnote: string) => any) {
+ const goals = {
+ ...(appConfig?.metrics?.phone_dashboard_ui?.footprint_options?.goals ??
+ DEFAULT_FOOTPRINT_GOALS),
+ };
+ const footnoteNumber = goals.goals_footnote ? addFootnote(goals.goals_footnote[lang]) : '';
+ for (const goalType of ['carbon', 'energy']) {
+ for (const goal of goals[goalType] || []) {
+ if (typeof goal.label == 'object') {
+ goal.label = goal.label[lang] + footnoteNumber;
+ }
+ }
+ }
+ return goals;
+}
diff --git a/www/js/metrics/footprintHelper.ts b/www/js/metrics/footprintHelper.ts
deleted file mode 100644
index 2a02ea133..000000000
--- a/www/js/metrics/footprintHelper.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import { displayError, displayErrorMsg, logDebug, logWarn } from '../plugin/logger';
-import { getCustomFootprint } from './customMetricsHelper';
-
-//variables for the highest footprint in the set and if using custom
-let highestFootprint: number | undefined = 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"
- * @returns the footprint or undefined
- */
-function getFootprint() {
- let footprint = getCustomFootprint();
- if (footprint) {
- return footprint;
- } else {
- throw new Error('In Footprint Calculatins, failed to use custom labels');
- }
-}
-
-/**
- * @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;
- userMetrics.forEach((userMetric) => {
- let mode = userMetric.key;
-
- //either the mode is in our custom footprint or it is not
- if (mode in footprint) {
- result += footprint[mode] * mtokm(userMetric.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(userMetric.values);
- } else {
- logWarn(
- `WARNING getFootprintFromMetrics() was requested for an unknown mode: ${mode} metrics JSON: ${JSON.stringify(
- userMetrics,
- )}`,
- );
- result += defaultIfMissing * mtokm(userMetric.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: number[] = [];
- 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
deleted file mode 100644
index 901c17ae6..000000000
--- a/www/js/metrics/metDataset.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-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
deleted file mode 100644
index 25bcc2e7e..000000000
--- a/www/js/metrics/metHelper.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-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/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts
index 65337690b..f0cc458c5 100644
--- a/www/js/metrics/metricsHelper.ts
+++ b/www/js/metrics/metricsHelper.ts
@@ -1,10 +1,15 @@
import { DateTime } from 'luxon';
-import { DayOfMetricData } from './metricsTypes';
+import color from 'color';
+import { DayOfMetricData, MetricEntry, MetricValue } from './metricsTypes';
import { logDebug } from '../plugin/logger';
-import { isoDateWithOffset, isoDatesDifference } from '../diary/timelineHelper';
import { MetricName, groupingFields } from '../types/appConfigTypes';
-import { ImperialConfig, formatForDisplay } from '../config/useImperialConfig';
+import { ImperialConfig } from '../config/useImperialConfig';
import i18next from 'i18next';
+import { base_modes, metrics_summaries } from 'e-mission-common';
+import { formatForDisplay, formatIsoNoYear, isoDatesDifference, isoDateWithOffset } from '../util';
+import { LabelOptions, RichMode } from '../types/labelTypes';
+import { labelOptions, textToLabelKey } from '../survey/multilabel/confirmHelper';
+import { UNCERTAIN_OPACITY } from '../components/charting';
export function getUniqueLabelsForDays(metricDataDays: DayOfMetricData[]) {
const uniqueLabels: string[] = [];
@@ -31,6 +36,7 @@ export const trimGroupingPrefix = (label: string) => {
return label.substring(field.length + 1);
}
}
+ return '';
};
export const getLabelsForDay = (metricDataDay: DayOfMetricData) =>
@@ -49,7 +55,7 @@ export function segmentDaysByWeeks(days: DayOfMetricData[], lastDate: string) {
let cutoff = isoDateWithOffset(lastDate, -7 * weeks.length);
for (let i = days.length - 1; i >= 0; i--) {
// if date is older than cutoff, start a new week
- if (isoDatesDifference(days[i].date, cutoff) > 0) {
+ while (isoDatesDifference(days[i].date, cutoff) >= 0) {
weeks.push([]);
cutoff = isoDateWithOffset(lastDate, -7 * weeks.length);
}
@@ -58,43 +64,20 @@ export function segmentDaysByWeeks(days: DayOfMetricData[], lastDate: string) {
return weeks.map((week) => week.reverse());
}
-export function formatDate(day: DayOfMetricData) {
- const dt = DateTime.fromISO(day.date, { zone: 'utc' });
- return dt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined });
-}
+export const formatDate = (day: DayOfMetricData) => formatIsoNoYear(day.date);
export function formatDateRangeOfDays(days: DayOfMetricData[]) {
if (!days?.length) return '';
- const firstDayDt = DateTime.fromISO(days[0].date, { zone: 'utc' });
- const lastDayDt = DateTime.fromISO(days[days.length - 1].date, { zone: 'utc' });
- const firstDay = firstDayDt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined });
- const lastDay = lastDayDt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined });
- return `${firstDay} - ${lastDay}`;
+ const startIsoDate = days[0].date;
+ const endIsoDate = days[days.length - 1].date;
+ return formatIsoNoYear(startIsoDate, endIsoDate);
}
-/* formatting data form carbon footprint calculations */
-
-//modes considered on foot for carbon calculation, expandable as needed
-export const ON_FOOT_MODES = ['WALKING', 'RUNNING', 'ON_FOOT'] as const;
-
-/*
- * metric2val is a function that takes a metric entry and a field and returns
- * the appropriate value.
- * for regular data (user-specific), this will return the field value
- * for avg data (aggregate), this will return the field value/nUsers
- */
-export const metricToValue = (population: 'user' | 'aggregate', metric, field) =>
- population == 'user' ? metric[field] : metric[field] / metric.nUsers;
-
-//testing agains global list of what is "on foot"
-//returns true | false
-export function isOnFoot(mode: string) {
- for (let ped_mode of ON_FOOT_MODES) {
- if (mode === ped_mode) {
- return true;
- }
- }
- return false;
+export function getActiveModes(labelOptions: LabelOptions) {
+ return labelOptions.MODE.filter((mode) => {
+ const richMode = base_modes.get_rich_mode(mode) as RichMode;
+ return richMode.met && Object.values(richMode.met).some((met) => met?.mets || -1 > 0);
+ }).map((mode) => mode.value);
}
//from two weeks fo low and high values, calculates low and high change
@@ -106,56 +89,6 @@ export function calculatePercentChange(pastWeekRange, previousWeekRange) {
return greaterLesserPct;
}
-export function parseDataFromMetrics(metrics, population) {
- logDebug(`parseDataFromMetrics: metrics = ${JSON.stringify(metrics)};
- population = ${population}`);
- let mode_bins: { [k: string]: [number, number, string][] } = {};
- metrics?.forEach((metric) => {
- let onFootVal = 0;
-
- for (let field in metric) {
- /*For modes inferred from sensor data, we check if the string is all upper case
- by converting it to upper case and seeing if it is changed*/
- if (field == field.toUpperCase()) {
- /*sum all possible on foot modes: see https://github.com/e-mission/e-mission-docs/issues/422 */
- if (isOnFoot(field)) {
- onFootVal += metricToValue(population, metric, field);
- field = 'ON_FOOT';
- }
- if (!(field in mode_bins)) {
- mode_bins[field] = [];
- }
- //for all except onFoot, add to bin - could discover mult onFoot modes
- if (field != 'ON_FOOT') {
- mode_bins[field].push([
- metric.ts,
- metricToValue(population, metric, field),
- metric.fmt_time,
- ]);
- }
- }
- const trimmedField = trimGroupingPrefix(field);
- if (trimmedField) {
- logDebug('Mapped field ' + field + ' to mode ' + trimmedField);
- if (!(trimmedField in mode_bins)) {
- mode_bins[trimmedField] = [];
- }
- mode_bins[trimmedField].push([
- metric.ts,
- Math.round(metricToValue(population, metric, field)),
- DateTime.fromISO(metric.fmt_time).toISO() as string,
- ]);
- }
- }
- //handle the ON_FOOT modes once all have been summed
- if ('ON_FOOT' in mode_bins) {
- mode_bins['ON_FOOT'].push([metric.ts, Math.round(onFootVal), metric.fmt_time]);
- }
- });
-
- return Object.entries(mode_bins).map(([key, values]) => ({ key, values }));
-}
-
const _datesTsCache = {};
export const tsForDayOfMetricData = (day: DayOfMetricData) => {
if (_datesTsCache[day.date] == undefined)
@@ -163,74 +96,9 @@ export const tsForDayOfMetricData = (day: DayOfMetricData) => {
return _datesTsCache[day.date];
};
-export const valueForFieldOnDay = (day: DayOfMetricData, field: string, key: string) =>
+export const valueForFieldOnDay = (day: MetricEntry, field: string, key: string) =>
day[`${field}_${key}`];
-export type MetricsSummary = { key: string; values: number };
-export function generateSummaryFromData(modeMap, metric) {
- logDebug(`Invoked getSummaryDataRaw on ${JSON.stringify(modeMap)} with ${metric}`);
-
- let summaryMap: MetricsSummary[] = [];
-
- for (let i = 0; i < modeMap.length; i++) {
- let vals = 0;
- for (let j = 0; j < modeMap[i].values.length; j++) {
- vals += modeMap[i].values[j][1]; //2nd item of array is value
- }
- if (metric === 'mean_speed') {
- // For speed, we take the avg. For other metrics we keep the sum
- vals = vals / modeMap[i].values.length;
- }
- summaryMap.push({
- key: modeMap[i].key,
- values: Math.round(vals),
- });
- }
-
- return summaryMap;
-}
-
-/*
- * We use the results to determine whether these results are from custom
- * labels or from the automatically sensed labels. Automatically sensedV
- * labels are in all caps, custom labels are prefixed by label, but have had
- * the label_prefix stripped out before this. Results should have either all
- * sensed labels or all custom labels.
- */
-export function isCustomLabels(modeMap) {
- const isSensed = (mode) => mode == mode.toUpperCase();
- const isCustom = (mode) => mode == mode.toLowerCase();
- const metricSummaryChecksCustom: boolean[] = [];
- const metricSummaryChecksSensed: boolean[] = [];
-
- const distanceKeys = modeMap.map((e) => e.key);
- const isSensedKeys = distanceKeys.map(isSensed);
- const isCustomKeys = distanceKeys.map(isCustom);
- logDebug(`Checking metric keys ${distanceKeys}; sensed ${isSensedKeys}; custom ${isCustomKeys}`);
- const isAllCustomForMetric = isAllCustom(isSensedKeys, isCustomKeys);
- metricSummaryChecksSensed.push(!isAllCustomForMetric);
- metricSummaryChecksCustom.push(Boolean(isAllCustomForMetric));
- logDebug(`overall custom/not results for each metric
- is ${JSON.stringify(metricSummaryChecksCustom)}`);
- return isAllCustom(metricSummaryChecksSensed, metricSummaryChecksCustom);
-}
-
-export function isAllCustom(isSensedKeys, isCustomKeys) {
- const allSensed = isSensedKeys.reduce((a, b) => a && b, true);
- const anySensed = isSensedKeys.reduce((a, b) => a || b, false);
- const allCustom = isCustomKeys.reduce((a, b) => a && b, true);
- const anyCustom = isCustomKeys.reduce((a, b) => a || b, false);
- if (allSensed && !anyCustom) {
- return false; // sensed, not custom
- }
- if (!anySensed && allCustom) {
- return true; // custom, not sensed; false implies that the other option is true
- }
- // Logger.displayError("Mixed entries that combine sensed and custom labels",
- // "Please report to your program admin");
- return undefined;
-}
-
// [unit suffix, unit conversion function, unit display function]
// e.g. ['hours', (seconds) => seconds/3600, (seconds) => seconds/3600 + ' hours']
type UnitUtils = [string, (v) => number, (v) => string];
@@ -245,20 +113,75 @@ export function getUnitUtilsForMetric(
(x) => imperialConfig.getFormattedDistance(x) + ' ' + imperialConfig.distanceSuffix,
],
duration: [
- i18next.t('metrics.hours'),
+ i18next.t('metrics.travel.hours'),
(v) => secondsToHours(v),
- (v) => formatForDisplay(secondsToHours(v)) + ' ' + i18next.t('metrics.hours'),
+ (v) => formatForDisplay(secondsToHours(v)) + ' ' + i18next.t('metrics.travel.hours'),
+ ],
+ count: [
+ i18next.t('metrics.travel.trips'),
+ (v) => v,
+ (v) => v + ' ' + i18next.t('metrics.travel.trips'),
],
- count: [i18next.t('metrics.trips'), (v) => v, (v) => v + ' ' + i18next.t('metrics.trips')],
response_count: [
- i18next.t('metrics.responses'),
+ i18next.t('metrics.surveys.responses'),
(v) => v.responded || 0,
(v) => {
const responded = v.responded || 0;
const total = responded + (v.not_responded || 0);
- return `${responded}/${total} ${i18next.t('metrics.responses')}`;
+ return `${responded}/${total} ${i18next.t('metrics.surveys.responses')}`;
},
],
+ footprint: [] as any, // TODO
};
return fns[metricName];
}
+
+/**
+ * @param entries an array of metric entries
+ * @param metricName the metric that the values are for
+ * @returns a metric entry with fields of the same name summed across all entries
+ */
+export function aggMetricEntries(entries: MetricEntry[], metricName: T) {
+ let acc = {};
+ entries?.forEach((e) => {
+ for (let field in e) {
+ if (groupingFields.some((f) => field.startsWith(f))) {
+ acc[field] = metrics_summaries.acc_value_of_metric(metricName, acc?.[field], e[field]);
+ } else if (field == 'nUsers') {
+ acc[field] = (acc[field] || 0) + e[field];
+ }
+ }
+ });
+ return acc as MetricEntry;
+}
+
+/**
+ * @param a metric entry
+ * @param metricName the metric that the values are for
+ * @returns the result of summing the values across all fields in the entry
+ */
+export function sumMetricEntry(entry: MetricEntry, metricName: T) {
+ let acc;
+ for (let field in entry) {
+ if (groupingFields.some((f) => field.startsWith(f))) {
+ acc = metrics_summaries.acc_value_of_metric(metricName, acc, entry[field]);
+ }
+ }
+ if (acc && typeof acc == 'object') {
+ acc['nUsers'] = entry['nUsers'] || 1;
+ }
+ return (acc || {}) as MetricValue;
+}
+
+export const sumMetricEntries = (days: DayOfMetricData[], metricName: T) =>
+ sumMetricEntry(aggMetricEntries(days, metricName) as any, metricName);
+
+// Unlabelled data shows up as 'UNKNOWN' grey and mostly transparent
+// All other modes are colored according to their base mode
+export function getColorForModeLabel(label: string) {
+ if (label.startsWith(i18next.t('metrics.footprint.unlabeled'))) {
+ const unknownModeColor = base_modes.get_base_mode_by_key('UNKNOWN').color;
+ return color(unknownModeColor).alpha(UNCERTAIN_OPACITY).rgb().string();
+ }
+ return base_modes.get_rich_mode_for_value(textToLabelKey(label), labelOptions).color;
+}
diff --git a/www/js/metrics/metricsTypes.ts b/www/js/metrics/metricsTypes.ts
index d6105c30a..366f7ada6 100644
--- a/www/js/metrics/metricsTypes.ts
+++ b/www/js/metrics/metricsTypes.ts
@@ -1,20 +1,26 @@
import { GroupingField, MetricName } from '../types/appConfigTypes';
+type TravelMetricName = 'distance' | 'duration' | 'count';
+
// distance, duration, and count use number values in meters, seconds, and count respectively
// response_count uses object values containing responded and not_responded counts
-type MetricValue = T extends 'response_count'
- ? { responded?: number; not_responded?: number }
- : number;
+// footprint uses object values containing kg_co2 and kwh values with optional _uncertain values
+export type MetricValue = T extends TravelMetricName
+ ? number
+ : T extends 'response_count'
+ ? { responded?: number; not_responded?: number }
+ : T extends 'footprint'
+ ? { kg_co2: number; kg_co2_uncertain?: number; kwh: number; kwh_uncertain?: number }
+ : never;
+
+export type MetricEntry = {
+ [k in `${GroupingField}_${string}`]?: MetricValue;
+};
-export type DayOfMetricData = {
+export type DayOfMetricData = {
date: string; // yyyy-mm-dd
nUsers: number;
-} & {
- // each key is a value for a specific grouping field
- // and the value is the respective metric value
- // e.g. { mode_confirm_bikeshare: 123, survey_TripConfirmSurvey: { responded: 4, not_responded: 5 }
- [k in `${GroupingField}_${string}`]: MetricValue;
-};
+} & MetricEntry;
export type MetricsData = {
[key in MetricName]: DayOfMetricData[];
diff --git a/www/js/metrics/ActiveMinutesTableCard.tsx b/www/js/metrics/movement/ActiveMinutesTableCard.tsx
similarity index 71%
rename from www/js/metrics/ActiveMinutesTableCard.tsx
rename to www/js/metrics/movement/ActiveMinutesTableCard.tsx
index aa8bc389f..847894b1d 100644
--- a/www/js/metrics/ActiveMinutesTableCard.tsx
+++ b/www/js/metrics/movement/ActiveMinutesTableCard.tsx
@@ -1,29 +1,23 @@
import React, { useContext, useMemo, useState } from 'react';
-import { Card, DataTable, useTheme } from 'react-native-paper';
-import { MetricsData } from './metricsTypes';
-import { cardStyles } from './MetricsTab';
+import { Card, DataTable, Text, useTheme } from 'react-native-paper';
+import { MetricsData } from '../metricsTypes';
+import { metricsStyles } from '../MetricsScreen';
import {
formatDate,
formatDateRangeOfDays,
secondsToMinutes,
segmentDaysByWeeks,
valueForFieldOnDay,
-} from './metricsHelper';
+} from '../metricsHelper';
import { useTranslation } from 'react-i18next';
-import { ACTIVE_MODES } from './WeeklyActiveMinutesCard';
-import { labelKeyToRichMode } from '../survey/multilabel/confirmHelper';
-import TimelineContext from '../TimelineContext';
-import useAppConfig from '../useAppConfig';
+import { labelKeyToText } from '../../survey/multilabel/confirmHelper';
+import TimelineContext from '../../TimelineContext';
-type Props = { userMetrics?: MetricsData };
-const ActiveMinutesTableCard = ({ userMetrics }: Props) => {
+type Props = { userMetrics?: MetricsData; activeModes: string[] };
+const ActiveMinutesTableCard = ({ userMetrics, activeModes }: Props) => {
const { colors } = useTheme();
const { dateRange } = useContext(TimelineContext);
const { t } = useTranslation();
- const appConfig = useAppConfig();
- // modes to consider as "active" for the purpose of calculating "active minutes", default : ['walk', 'bike']
- const activeModes =
- appConfig?.metrics?.phone_dashboard_ui?.active_travel_options?.modes_list ?? ACTIVE_MODES;
const cumulativeTotals = useMemo(() => {
if (!userMetrics?.duration) return [];
@@ -80,22 +74,18 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => {
const to = Math.min((page + 1) * itemsPerPage, allTotals.length);
return (
-
+
-
+
{activeModes.map((mode, i) => (
- {labelKeyToRichMode(mode)}
+ {labelKeyToText(mode)}
))}
@@ -104,7 +94,7 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => {
{total['period']}
{activeModes.map((mode, j) => (
- {total[mode]} {t('metrics.minutes')}
+ {total[mode]} {t('metrics.movement.minutes')}
))}
diff --git a/www/js/metrics/movement/DailyActiveMinutesCard.tsx b/www/js/metrics/movement/DailyActiveMinutesCard.tsx
new file mode 100644
index 000000000..cf6ff5f35
--- /dev/null
+++ b/www/js/metrics/movement/DailyActiveMinutesCard.tsx
@@ -0,0 +1,53 @@
+import React, { useMemo } from 'react';
+import { Card, Text } from 'react-native-paper';
+import { MetricsData } from '../metricsTypes';
+import { metricsStyles } from '../MetricsScreen';
+import { useTranslation } from 'react-i18next';
+import { labelKeyToText } from '../../survey/multilabel/confirmHelper';
+import LineChart from '../../components/LineChart';
+import { getColorForModeLabel, tsForDayOfMetricData, valueForFieldOnDay } from '../metricsHelper';
+
+type Props = { userMetrics?: MetricsData; activeModes: string[] };
+const DailyActiveMinutesCard = ({ userMetrics, activeModes }: Props) => {
+ const { t } = useTranslation();
+
+ const dailyActiveMinutesRecords = useMemo(() => {
+ const records: { label: string; x: number; y: number }[] = [];
+ activeModes.forEach((modeKey) => {
+ if (userMetrics?.duration?.some((d) => valueForFieldOnDay(d, 'mode_confirm', modeKey))) {
+ userMetrics?.duration?.forEach((day) => {
+ const activeSeconds = valueForFieldOnDay(day, 'mode_confirm', modeKey);
+ records.push({
+ label: labelKeyToText(modeKey),
+ x: tsForDayOfMetricData(day) * 1000, // vertical chart, milliseconds on X axis
+ y: (activeSeconds || 0) / 60, // minutes on Y axis
+ });
+ });
+ }
+ });
+ return records as { label: string; x: number; y: number }[];
+ }, [userMetrics?.duration]);
+
+ return (
+
+
+
+ {dailyActiveMinutesRecords.length ? (
+
+ ) : (
+
+ {t('metrics.no-data-available')}
+
+ )}
+
+
+ );
+};
+
+export default DailyActiveMinutesCard;
diff --git a/www/js/metrics/movement/MovementSection.tsx b/www/js/metrics/movement/MovementSection.tsx
new file mode 100644
index 000000000..6870736ed
--- /dev/null
+++ b/www/js/metrics/movement/MovementSection.tsx
@@ -0,0 +1,21 @@
+import React, { useContext } from 'react';
+import WeeklyActiveMinutesCard from './WeeklyActiveMinutesCard';
+import DailyActiveMinutesCard from './DailyActiveMinutesCard';
+import ActiveMinutesTableCard from './ActiveMinutesTableCard';
+import TimelineContext from '../../TimelineContext';
+import { getActiveModes } from '../metricsHelper';
+
+const MovementSection = ({ userMetrics }) => {
+ const { labelOptions } = useContext(TimelineContext);
+ const activeModes = labelOptions ? getActiveModes(labelOptions) : [];
+
+ return (
+ <>
+
+
+
+ >
+ );
+};
+
+export default MovementSection;
diff --git a/www/js/metrics/movement/WeeklyActiveMinutesCard.tsx b/www/js/metrics/movement/WeeklyActiveMinutesCard.tsx
new file mode 100644
index 000000000..3fcdade74
--- /dev/null
+++ b/www/js/metrics/movement/WeeklyActiveMinutesCard.tsx
@@ -0,0 +1,84 @@
+import React, { useContext, useMemo } from 'react';
+import { View } from 'react-native';
+import { Card, Text, useTheme } from 'react-native-paper';
+import { MetricsData } from '../metricsTypes';
+import { metricsStyles } from '../MetricsScreen';
+import {
+ aggMetricEntries,
+ getColorForModeLabel,
+ segmentDaysByWeeks,
+ valueForFieldOnDay,
+} from '../metricsHelper';
+import { useTranslation } from 'react-i18next';
+import BarChart from '../../components/BarChart';
+import { labelKeyToText } from '../../survey/multilabel/confirmHelper';
+import TimelineContext from '../../TimelineContext';
+import { formatIsoNoYear, isoDateWithOffset } from '../../util';
+
+type Props = { userMetrics?: MetricsData; activeModes: string[] };
+const WeeklyActiveMinutesCard = ({ userMetrics, activeModes }: Props) => {
+ const { colors } = useTheme();
+ const { dateRange } = useContext(TimelineContext);
+ const { t } = useTranslation();
+
+ const weekDurations = useMemo(() => {
+ if (!userMetrics?.duration?.length) return [];
+ const weeks = segmentDaysByWeeks(userMetrics?.duration, dateRange[1]);
+ return weeks.map((week, i) => ({
+ ...aggMetricEntries(week, 'duration'),
+ }));
+ }, [userMetrics]);
+
+ const weeklyActiveMinutesRecords = useMemo(() => {
+ let records: { label: string; x: string; y: number }[] = [];
+ activeModes.forEach((modeKey) => {
+ if (weekDurations.some((week) => valueForFieldOnDay(week, 'mode_confirm', modeKey))) {
+ weekDurations.forEach((week, i) => {
+ const startDate = isoDateWithOffset(dateRange[1], -7 * (i + 1) + 1);
+ if (startDate < dateRange[0]) return; // partial week at beginning of queried range; skip
+ const endDate = isoDateWithOffset(dateRange[1], -7 * i);
+ const val = valueForFieldOnDay(week, 'mode_confirm', modeKey);
+ records.push({
+ label: labelKeyToText(modeKey),
+ x: formatIsoNoYear(startDate, endDate),
+ y: val / 60,
+ });
+ });
+ }
+ });
+ return records;
+ }, [weekDurations]);
+
+ return (
+
+
+
+ {weeklyActiveMinutesRecords.length ? (
+
+
+
+ {t('metrics.movement.weekly-goal-footnote')}
+
+
+ ) : (
+
+ {t('metrics.no-data-available')}
+
+ )}
+
+
+ );
+};
+
+export default WeeklyActiveMinutesCard;
diff --git a/www/js/metrics/SurveyComparisonCard.tsx b/www/js/metrics/surveys/SurveyComparisonCard.tsx
similarity index 84%
rename from www/js/metrics/SurveyComparisonCard.tsx
rename to www/js/metrics/surveys/SurveyComparisonCard.tsx
index a99a604eb..8a6faa7d9 100644
--- a/www/js/metrics/SurveyComparisonCard.tsx
+++ b/www/js/metrics/surveys/SurveyComparisonCard.tsx
@@ -2,12 +2,12 @@ import React, { useMemo } from 'react';
import { View } from 'react-native';
import { Icon, Card, Text } from 'react-native-paper';
import { useTranslation } from 'react-i18next';
-import { useAppTheme } from '../appTheme';
+import { useAppTheme } from '../../appTheme';
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js';
import { Doughnut } from 'react-chartjs-2';
-import { cardStyles } from './MetricsTab';
-import { DayOfMetricData, MetricsData } from './metricsTypes';
-import { getUniqueLabelsForDays } from './metricsHelper';
+import { metricsStyles } from '../MetricsScreen';
+import { DayOfMetricData, MetricsData } from '../metricsTypes';
+import { getUniqueLabelsForDays } from '../metricsHelper';
ChartJS.register(ArcElement, Tooltip, Legend);
/**
@@ -111,28 +111,25 @@ const SurveyComparisonCard = ({ userMetrics, aggMetrics }: Props) => {
};
return (
-
+
-
+
{typeof myResponsePct !== 'number' || typeof othersResponsePct !== 'number' ? (
- {t('metrics.chart-no-data')}
+ {t('metrics.no-data-available')}
) : (
- {t('main-metrics.survey-response-rate')}
+ {t('metrics.surveys.survey-response-rate')}
{renderDoughnutChart(myResponsePct, colors.navy, true)}
{renderDoughnutChart(othersResponsePct, colors.orange, false)}
-
+
)}
diff --git a/www/js/metrics/SurveyLeaderboardCard.tsx b/www/js/metrics/surveys/SurveyLeaderboardCard.tsx
similarity index 73%
rename from www/js/metrics/SurveyLeaderboardCard.tsx
rename to www/js/metrics/surveys/SurveyLeaderboardCard.tsx
index 34341616d..faed8da48 100644
--- a/www/js/metrics/SurveyLeaderboardCard.tsx
+++ b/www/js/metrics/surveys/SurveyLeaderboardCard.tsx
@@ -1,10 +1,10 @@
import React, { useMemo } from 'react';
import { View, Text } from 'react-native';
import { Card } from 'react-native-paper';
-import { cardStyles, SurveyMetric, SurveyObject } from './MetricsTab';
+import { metricsStyles } from '../MetricsScreen';
import { useTranslation } from 'react-i18next';
-import BarChart from '../components/BarChart';
-import { useAppTheme } from '../appTheme';
+import BarChart from '../../components/BarChart';
+import { useAppTheme } from '../../appTheme';
import { Chart as ChartJS, registerables } from 'chart.js';
import Annotation from 'chartjs-plugin-annotation';
@@ -12,7 +12,7 @@ ChartJS.register(...registerables, Annotation);
type Props = {
studyStartDate: string;
- surveyMetric: SurveyMetric;
+ surveyMetric;
};
type LeaderboardRecord = {
@@ -41,7 +41,7 @@ const SurveyLeaderboardCard = ({ studyStartDate, surveyMetric }: Props) => {
}
const leaderboardRecords: LeaderboardRecord[] = useMemo(() => {
- const combinedLeaderboard: SurveyObject[] = [...surveyMetric.others.leaderboard];
+ const combinedLeaderboard = [...surveyMetric.others.leaderboard];
combinedLeaderboard.splice(myRank, 0, mySurveyMetric);
// This is to prevent the leaderboard from being too long for UX purposes.
@@ -68,22 +68,18 @@ const SurveyLeaderboardCard = ({ studyStartDate, surveyMetric }: Props) => {
}, [surveyMetric]);
return (
-
+
-
+
- * {t('main-metrics.survey-leaderboard-desc')}
- {studyStartDate}
+ * {t('metrics.leaderboard.data-accumulated-since-date', { date: studyStartDate })}
- {t('main-metrics.survey-response-rate')}
+ {t('metrics.surveys.survey-response-rate')}
{
enableTooltip={false}
/>
- {t('main-metrics.you-are-in')}
- #{myRank + 1}
- {t('main-metrics.place')}
+ {t('metrics.leaderboard.you-are-in-x-place', { x: myRank + 1 })}
diff --git a/www/js/metrics/SurveyTripCategoriesCard.tsx b/www/js/metrics/surveys/SurveyTripCategoriesCard.tsx
similarity index 71%
rename from www/js/metrics/SurveyTripCategoriesCard.tsx
rename to www/js/metrics/surveys/SurveyTripCategoriesCard.tsx
index 77df43abf..e68005605 100644
--- a/www/js/metrics/SurveyTripCategoriesCard.tsx
+++ b/www/js/metrics/surveys/SurveyTripCategoriesCard.tsx
@@ -1,13 +1,13 @@
import React, { useMemo } from 'react';
import { Text, Card } from 'react-native-paper';
-import { cardStyles } from './MetricsTab';
+import { metricsStyles } from '../MetricsScreen';
import { useTranslation } from 'react-i18next';
-import BarChart from '../components/BarChart';
-import { useAppTheme } from '../appTheme';
+import BarChart from '../../components/BarChart';
+import { useAppTheme } from '../../appTheme';
import { LabelPanel } from './SurveyComparisonCard';
-import { DayOfMetricData, MetricsData } from './metricsTypes';
-import { GroupingField } from '../types/appConfigTypes';
-import { getUniqueLabelsForDays } from './metricsHelper';
+import { DayOfMetricData, MetricsData } from '../metricsTypes';
+import { GroupingField } from '../../types/appConfigTypes';
+import { getUniqueLabelsForDays } from '../metricsHelper';
function sumResponseCountsForValue(
days: DayOfMetricData<'response_count'>[],
@@ -51,16 +51,13 @@ const SurveyTripCategoriesCard = ({ userMetrics, aggMetrics }: Props) => {
}, [userMetrics]);
return (
-
+
-
+
{records.length ? (
<>
{
reverse={false}
maxBarThickness={60}
/>
-
+
>
) : (
- {t('metrics.chart-no-data')}
+ {t('metrics.no-data-available')}
)}
diff --git a/www/js/metrics/surveys/SurveysSection.tsx b/www/js/metrics/surveys/SurveysSection.tsx
new file mode 100644
index 000000000..04f0a110b
--- /dev/null
+++ b/www/js/metrics/surveys/SurveysSection.tsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import SurveyComparisonCard from './SurveyComparisonCard';
+import SurveyTripCategoriesCard from './SurveyTripCategoriesCard';
+
+const SurveysSection = ({ userMetrics, aggMetrics }) => {
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default SurveysSection;
diff --git a/www/js/metrics/MetricsCard.tsx b/www/js/metrics/travel/MetricsCard.tsx
similarity index 76%
rename from www/js/metrics/MetricsCard.tsx
rename to www/js/metrics/travel/MetricsCard.tsx
index 241ab8208..6f6f11a9f 100644
--- a/www/js/metrics/MetricsCard.tsx
+++ b/www/js/metrics/travel/MetricsCard.tsx
@@ -2,8 +2,8 @@ import React, { useMemo, useState } from 'react';
import { View } from 'react-native';
import { Card, Checkbox, Text, useTheme } from 'react-native-paper';
import colorLib from 'color';
-import BarChart from '../components/BarChart';
-import { DayOfMetricData } from './metricsTypes';
+import BarChart from '../../components/BarChart';
+import { DayOfMetricData } from '../metricsTypes';
import {
formatDateRangeOfDays,
getLabelsForDay,
@@ -11,15 +11,14 @@ import {
getUniqueLabelsForDays,
valueForFieldOnDay,
getUnitUtilsForMetric,
-} from './metricsHelper';
-import ToggleSwitch from '../components/ToggleSwitch';
-import { cardStyles } from './MetricsTab';
-import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper';
-import { getBaseModeByText } from '../diary/diaryHelper';
+ getColorForModeLabel,
+} from '../metricsHelper';
+import ToggleSwitch from '../../components/ToggleSwitch';
+import { metricsStyles } from '../MetricsScreen';
+import { labelKeyToText } from '../../survey/multilabel/confirmHelper';
import { useTranslation } from 'react-i18next';
-import { GroupingField, MetricName } from '../types/appConfigTypes';
-import { useImperialConfig } from '../config/useImperialConfig';
-import { base_modes } from 'e-mission-common';
+import { GroupingField, MetricName } from '../../types/appConfigTypes';
+import { useImperialConfig } from '../../config/useImperialConfig';
type Props = {
metricName: MetricName;
@@ -61,7 +60,7 @@ const MetricsCard = ({
const rawVal = valueForFieldOnDay(day, groupingFields[0], label);
if (rawVal) {
records.push({
- label: labelKeyToRichMode(label),
+ label: labelKeyToText(label),
x: unitConvertFn(rawVal),
y: tsForDayOfMetricData(day) * 1000, // time (as milliseconds) will go on Y axis because it will be a horizontal chart
});
@@ -80,7 +79,7 @@ const MetricsCard = ({
const cardSubtitleText = useMemo(() => {
if (!metricDataDays) return;
const groupText =
- populationMode == 'user' ? t('main-metrics.user-totals') : t('main-metrics.group-totals');
+ populationMode == 'user' ? t('metrics.travel.user-totals') : t('metrics.travel.group-totals');
return `${groupText} (${formatDateRangeOfDays(metricDataDays)})`;
}, [metricDataDays, populationMode]);
@@ -112,28 +111,16 @@ const MetricsCard = ({
return vals;
}, [metricDataDays, viewMode]);
- // Unlabelled data shows up as 'UNKNOWN' grey and mostly transparent
- // All other modes are colored according to their base mode
- const getColorForLabel = (label: string) => {
- if (label == 'Unlabeled') {
- const unknownModeColor = base_modes.get_base_mode_by_key('UNKNOWN').color;
- return colorLib(unknownModeColor).alpha(0.15).rgb().string();
- }
- return getBaseModeByText(label, labelOptions).color;
- };
-
return (
-
+
(
-
+
setViewMode(v as any)}
buttons={[
@@ -142,7 +129,7 @@ const MetricsCard = ({
]}
/>
setPopulationMode(p as any)}
buttons={[
@@ -152,22 +139,21 @@ const MetricsCard = ({
/>
)}
- style={cardStyles.title(colors)}
/>
-
+
{viewMode == 'details' &&
(Object.keys(metricSumValues).length ? (
-
+
{Object.keys(metricSumValues).map((label, i) => (
- {labelKeyToRichMode(label)}
+ {labelKeyToText(label)}
{metricSumValues[label]}
))}
) : (
- {t('metrics.chart-no-data')}
+ {t('metrics.no-data-available')}
))}
{viewMode == 'graph' &&
@@ -179,7 +165,7 @@ const MetricsCard = ({
isHorizontal={true}
timeAxis={true}
stacked={graphIsStacked}
- getColorForLabel={getColorForLabel}
+ getColorForLabel={getColorForModeLabel}
/>
- Stack bars:
+ {t('metrics.stack-bars')}
setGraphIsStacked(!graphIsStacked)}
@@ -197,7 +183,7 @@ const MetricsCard = ({
>
) : (
- {t('metrics.chart-no-data')}
+ {t('metrics.no-data-available')}
))}
diff --git a/www/js/metrics/travel/TravelSection.tsx b/www/js/metrics/travel/TravelSection.tsx
new file mode 100644
index 000000000..f862cb00a
--- /dev/null
+++ b/www/js/metrics/travel/TravelSection.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { GroupingField } from '../../types/appConfigTypes';
+import MetricsCard from './MetricsCard';
+import { t } from 'i18next';
+
+const TRAVEL_METRICS = ['count', 'distance', 'duration'] as const;
+export type TravelMetricName = (typeof TRAVEL_METRICS)[number];
+
+const TravelSection = ({ userMetrics, aggMetrics, metricList }) => {
+ return (
+ <>
+ {Object.entries(metricList).map(
+ ([metricName, groupingFields]: [TravelMetricName, GroupingField[]]) =>
+ TRAVEL_METRICS.includes(metricName) ? (
+
+ ) : null,
+ )}
+ >
+ );
+};
+
+export default TravelSection;
diff --git a/www/js/onboarding/OnboardingStack.tsx b/www/js/onboarding/OnboardingStack.tsx
index 9682156ae..38def9cc3 100644
--- a/www/js/onboarding/OnboardingStack.tsx
+++ b/www/js/onboarding/OnboardingStack.tsx
@@ -13,8 +13,13 @@ const OnboardingStack = () => {
const { onboardingState } = useContext(AppContext);
if (onboardingState.route == OnboardingRoute.WELCOME) {
+ // This page needs 'light content' status bar (white text) due to blue header at the top
+ window['StatusBar']?.styleLightContent();
return ;
- } else if (onboardingState.route == OnboardingRoute.SUMMARY) {
+ }
+ // All other pages go back to 'default' (black text)
+ window['StatusBar']?.styleDefault();
+ if (onboardingState.route == OnboardingRoute.SUMMARY) {
return ;
} else if (onboardingState.route == OnboardingRoute.PROTOCOL) {
return ;
diff --git a/www/js/survey/enketo/AddedNotesList.tsx b/www/js/survey/enketo/AddedNotesList.tsx
index 155dabace..caf53e89c 100644
--- a/www/js/survey/enketo/AddedNotesList.tsx
+++ b/www/js/survey/enketo/AddedNotesList.tsx
@@ -7,11 +7,11 @@ import { DateTime } from 'luxon';
import { Modal } from 'react-native';
import { Text, Button, DataTable, Dialog, Icon } from 'react-native-paper';
import TimelineContext from '../../TimelineContext';
-import { getFormattedDateAbbr, isMultiDay } from '../../diary/diaryHelper';
import EnketoModal from './EnketoModal';
import { useTranslation } from 'react-i18next';
import { EnketoUserInputEntry } from './enketoHelper';
import { displayErrorMsg, logDebug } from '../../plugin/logger';
+import { formatIsoNoYear, isoDatesDifference } from '../../util';
type Props = {
timelineEntry: any;
@@ -43,8 +43,8 @@ const AddedNotesList = ({ timelineEntry, additionEntries }: Props) => {
const beginIso = DateTime.fromSeconds(beginTs).setZone(timezone).toISO() || undefined;
const stopIso = DateTime.fromSeconds(stopTs).setZone(timezone).toISO() || undefined;
let d;
- if (isMultiDay(beginIso, stopIso)) {
- d = getFormattedDateAbbr(beginIso, stopIso);
+ if (beginIso && stopIso && isoDatesDifference(beginIso, stopIso)) {
+ d = formatIsoNoYear(beginIso, stopIso);
}
const begin = DateTime.fromSeconds(beginTs)
.setZone(timezone)
diff --git a/www/js/survey/enketo/EnketoModal.tsx b/www/js/survey/enketo/EnketoModal.tsx
index f8b503407..4291d297a 100644
--- a/www/js/survey/enketo/EnketoModal.tsx
+++ b/www/js/survey/enketo/EnketoModal.tsx
@@ -63,6 +63,13 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest }: Props) => {
useEffect(() => {
if (!rest.visible || !appConfig) return;
initSurvey();
+
+ // on dev builds, allow skipping survey with ESC
+ if (__DEV__) {
+ const handleKeyDown = (e) => e.key === 'Escape' && onResponseSaved(null);
+ document.addEventListener('keydown', handleKeyDown);
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }
}, [appConfig, rest.visible]);
/* adapted from the template given by enketo-core:
diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts
index 48c2b3509..3b0ce81b8 100644
--- a/www/js/survey/enketo/enketoHelper.ts
+++ b/www/js/survey/enketo/enketoHelper.ts
@@ -49,6 +49,7 @@ type EnketoResponse = {
export type EnketoUserInputData = UserInputData & {
key?: string;
+ name: string;
version: number;
xmlResponse: string;
jsonDocResponse: { [k: string]: any };
diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx
index 5cc1de3d7..850a79db9 100644
--- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx
+++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx
@@ -24,7 +24,7 @@ import {
inferFinalLabels,
labelInputDetailsForTrip,
labelKeyToReadable,
- labelKeyToRichMode,
+ labelKeyToText,
readableLabelToKey,
verifiabilityForTrip,
} from './confirmHelper';
@@ -33,6 +33,7 @@ import { MultilabelKey } from '../../types/labelTypes';
// import { updateUserCustomLabel } from '../../services/commHelper';
import { AppContext } from '../../App';
import { addStatReading } from '../../plugin/clientStats';
+import { UserInputData } from '../../types/diaryTypes';
const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => {
const { colors } = useTheme();
@@ -87,7 +88,7 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => {
and inform LabelTab of new inputs */
function store(inputs: { [k in MultilabelKey]?: string }, isOther?) {
if (!Object.keys(inputs).length) return displayErrorMsg('No inputs to store');
- const inputsToStore: UserInputMap = {};
+ const inputsToStore: { [k in MultilabelKey]?: UserInputData } = {};
const storePromises: any[] = [];
for (let [inputType, newLabel] of Object.entries(inputs)) {
@@ -144,17 +145,18 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => {
{Object.keys(tripInputDetails).map((key, i) => {
const input = tripInputDetails[key];
- const inputIsConfirmed = labelFor(trip, input.name);
- const inputIsInferred = inferFinalLabels(trip, userInputFor(trip))[input.name];
+ const confirmedInput = labelFor(trip, input.name);
+ const inferredInput = inferFinalLabels(trip, userInputFor(trip))[input.name];
let fillColor, textColor, borderColor;
- if (inputIsConfirmed) {
+ if (confirmedInput) {
fillColor = colors.primary;
- } else if (inputIsInferred) {
+ } else if (inferredInput) {
fillColor = colors.secondaryContainer;
borderColor = colors.secondary;
textColor = colors.onSecondaryContainer;
}
- const btnText = inputIsConfirmed?.text || inputIsInferred?.text || input.choosetext;
+ const labelOption = confirmedInput || inferredInput;
+ const btnText = labelOption ? labelKeyToText(labelOption.value) : t(input.choosetext);
return (
@@ -164,7 +166,7 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => {
borderColor={borderColor}
textColor={textColor}
onPress={(e) => openModalFor(input.name)}>
- {t(btnText)}
+ {btnText}
);
@@ -202,7 +204,7 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => {
const radioItemForOption = (
@@ -211,7 +213,7 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => {
show the custom labels section before 'other' */
if (o.value == 'other' && customLabelMap[customLabelKeyInDatabase]?.length) {
return (
- <>
+
@@ -230,7 +232,7 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => {
))}
{radioItemForOption}
- >
+
);
}
// otherwise, just show the radio item as normal
diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts
index c3a5417dd..4e4c4a6c3 100644
--- a/www/js/survey/multilabel/confirmHelper.ts
+++ b/www/js/survey/multilabel/confirmHelper.ts
@@ -1,13 +1,13 @@
import { fetchUrlCached } from '../../services/commHelper';
import i18next from 'i18next';
-import enJson from '../../../i18n/en.json';
import { logDebug } from '../../plugin/logger';
import { LabelOption, LabelOptions, MultilabelKey, InputDetails } from '../../types/labelTypes';
import { CompositeTrip, InferredLabels, TimelineEntry } from '../../types/diaryTypes';
import { UserInputMap } from '../../TimelineContext';
+import DEFAULT_LABEL_OPTIONS from 'e-mission-common/src/emcommon/resources/label-options.default.json';
let appConfig;
-export let labelOptions: LabelOptions;
+export let labelOptions: LabelOptions;
export let inputDetails: InputDetails;
export async function getLabelOptions(appConfigParam?) {
@@ -19,31 +19,11 @@ export async function getLabelOptions(appConfigParam?) {
logDebug(`label_options found in config, using dynamic label options
at ${appConfig.label_options}`);
labelOptions = JSON.parse(labelOptionsJson) as LabelOptions;
+ } else {
+ throw new Error('Label options were falsy from ' + appConfig.label_options);
}
} else {
- const defaultLabelOptionsURL = 'json/label-options.json.sample';
- logDebug(`No label_options found in config, using default label options
- at ${defaultLabelOptionsURL}`);
- const defaultLabelOptionsJson = await fetchUrlCached(defaultLabelOptionsURL);
- if (defaultLabelOptionsJson) {
- labelOptions = JSON.parse(defaultLabelOptionsJson) as LabelOptions;
- }
- }
- /* fill in the translations to the 'text' fields of the labelOptions,
- according to the current language */
- const lang = i18next.resolvedLanguage || 'en';
- for (const opt in labelOptions) {
- labelOptions[opt]?.forEach?.((o, i) => {
- const translationKey = o.value;
- /* If translation exists in labelOptions, use that. Otherwise, try i18next translations. */
- const translationFromLabelOptions = labelOptions.translations?.[lang]?.[translationKey];
- if (translationFromLabelOptions) {
- labelOptions[opt][i].text = translationFromLabelOptions;
- } else {
- const i18nextKey = translationKey as keyof typeof enJson.multilabel; // cast for type safety
- labelOptions[opt][i].text = i18next.t(`multilabel.${i18nextKey}`);
- }
- });
+ labelOptions = DEFAULT_LABEL_OPTIONS;
}
return labelOptions;
}
@@ -124,13 +104,23 @@ export const readableLabelToKey = (otherText: string) =>
export function getFakeEntry(otherValue): Partial | undefined {
if (!otherValue) return undefined;
return {
- text: labelKeyToReadable(otherValue),
value: otherValue,
};
}
-export const labelKeyToRichMode = (labelKey: string) =>
- labelOptionByValue(labelKey, 'MODE')?.text || labelKeyToReadable(labelKey);
+export let labelTextToKeyMap: { [key: string]: string } = {};
+
+export const labelKeyToText = (labelKey: string) => {
+ const lang = i18next.resolvedLanguage || 'en';
+ const text =
+ labelOptions?.translations?.[lang]?.[labelKey] ||
+ labelOptions?.translations?.[lang]?.[labelKey] ||
+ labelKeyToReadable(labelKey);
+ labelTextToKeyMap[text] = labelKey;
+ return text;
+};
+
+export const textToLabelKey = (text: string) => labelTextToKeyMap[text] || readableLabelToKey(text);
/** @description e.g. manual/mode_confirm becomes mode_confirm */
export const removeManualPrefix = (key: string) => key.split('/')[1];
diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts
index 87a4b4e85..882f09e9a 100644
--- a/www/js/types/appConfigTypes.ts
+++ b/www/js/types/appConfigTypes.ts
@@ -95,7 +95,7 @@ export type ReminderSchemesConfig = {
};
// the available metrics that can be displayed in the phone dashboard
-export type MetricName = 'distance' | 'count' | 'duration' | 'response_count';
+export type MetricName = 'distance' | 'count' | 'duration' | 'response_count' | 'footprint';
// the available trip / userinput properties that can be used to group the metrics
export const groupingFields = [
'mode_confirm',
@@ -106,7 +106,17 @@ export const groupingFields = [
] as const;
export type GroupingField = (typeof groupingFields)[number];
export type MetricList = { [k in MetricName]?: GroupingField[] };
-export type MetricsUiSection = 'footprint' | 'active_travel' | 'summary' | 'engagement' | 'surveys';
+export type MetricsUiSection = 'footprint' | 'movement' | 'surveys' | 'travel';
+export type FootprintGoal = {
+ label: { [lang: string]: string };
+ value: number;
+ color?: string;
+};
+export type FootprintGoals = {
+ carbon: FootprintGoal[];
+ energy: FootprintGoal[];
+ goals_footnote?: { [lang: string]: string };
+};
export type MetricsConfig = {
include_test_users: boolean;
phone_dashboard_ui?: {
@@ -114,13 +124,12 @@ export type MetricsConfig = {
metric_list: MetricList;
footprint_options?: {
unlabeled_uncertainty: boolean;
+ goals?: FootprintGoals;
};
- summary_options?: {};
- engagement_options?: {
- leaderboard_metric: [string, string];
- };
- active_travel_options?: {
- modes_list: string[];
- };
+ movement_options?: {};
+ surveys_options?: {};
+ travel_options?: {};
};
};
+
+export default AppConfig;
diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts
index 53b618be0..df7c85ad6 100644
--- a/www/js/types/diaryTypes.ts
+++ b/www/js/types/diaryTypes.ts
@@ -156,7 +156,6 @@ export type UserInputData = {
end_local_dt?: LocalDt;
status?: string;
match_id?: string;
- name: string;
};
export type UserInputEntry = {
data: T;
diff --git a/www/js/types/labelTypes.ts b/www/js/types/labelTypes.ts
index 8ac720adc..681462ba9 100644
--- a/www/js/types/labelTypes.ts
+++ b/www/js/types/labelTypes.ts
@@ -6,17 +6,37 @@ export type InputDetails = {
key: string;
};
};
-export type LabelOption = {
+
+type FootprintFuelType = 'gasoline' | 'diesel' | 'electric' | 'cng' | 'lpg' | 'hydrogen';
+
+export type RichMode = {
value: string;
- baseMode: string;
- met?: { range: any[]; mets: number };
- met_equivalent?: string;
- kgCo2PerKm: number;
- text?: string;
+ base_mode: string;
+ icon: string;
+ color: string;
+ met?: { [k in string]?: { range: [number, number]; mets: number } };
+ footprint?: {
+ [f in FootprintFuelType]?: {
+ wh_per_km?: number;
+ wh_per_trip?: number;
+ };
+ };
};
+
+export type LabelOption = T extends 'MODE'
+ ? {
+ value: string;
+ base_mode: string;
+ } & Partial
+ : {
+ value: string;
+ };
+
export type MultilabelKey = 'MODE' | 'PURPOSE' | 'REPLACED_MODE';
-export type LabelOptions = {
- [k in T]: LabelOption[];
+export type LabelOptions = {
+ MODE: LabelOption<'MODE'>[];
+ PURPOSE: LabelOption<'PURPOSE'>[];
+ REPLACED_MODE?: LabelOption<'REPLACED_MODE'>[];
} & {
translations: {
[lang: string]: { [translationKey: string]: string };
diff --git a/www/js/util.ts b/www/js/util.ts
new file mode 100644
index 000000000..e0b8fa2f5
--- /dev/null
+++ b/www/js/util.ts
@@ -0,0 +1,93 @@
+import i18next from 'i18next';
+import { DateTime } from 'luxon';
+import humanizeDuration from 'humanize-duration';
+
+/* formatting units for display:
+ - if value >= 100, round to the nearest integer
+ e.g. "105 mi", "119 kmph"
+ - if 10 <= value < 100, round to 1 decimal place
+ e.g. "77.2 km", "11.3 mph"
+ - if value < 10, round to 2 decimal places
+ e.g. "7.27 mi", "0.75 km" */
+export function formatForDisplay(value: number, opts: Intl.NumberFormatOptions = {}): string {
+ if (value >= 100) opts.maximumFractionDigits ??= 0;
+ else if (value >= 10) opts.maximumFractionDigits ??= 1;
+ else opts.maximumFractionDigits ??= 2;
+ return Intl.NumberFormat(i18next.resolvedLanguage, opts).format(value);
+}
+
+/**
+ * @param isoStr1 An ISO 8601 string (with/without timezone)
+ * @param isoStr2 An ISO 8601 string (with/without timezone)
+ * @param opts Intl.DateTimeFormatOptions for formatting (optional, defaults to { weekday: 'short', month: 'short', day: 'numeric' })
+ * @returns A formatted range if both params are defined, one formatted date if only one is defined
+ * @example getFormattedDate("2023-07-14T00:00:00-07:00") => "Jul 14, 2023"
+ * @example getFormattedDate("2023-07-14", "2023-07-15") => "Jul 14, 2023 - Jul 15, 2023"
+ */
+export function formatIso(isoStr1?: string, isoStr2?: string, opts?: Intl.DateTimeFormatOptions) {
+ if (!isoStr1 && !isoStr2) return;
+ // both dates are given and are different, show a range
+ if (isoStr1 && isoStr2 && isoStr1.substring(0, 10) != isoStr2.substring(0, 10)) {
+ // separate the two dates with an en dash
+ return `${formatIso(isoStr1, undefined, opts)} – ${formatIso(isoStr2, undefined, opts)}`;
+ }
+ const isoStr = isoStr1 || isoStr2 || '';
+ // only one day given, or both are the same day, show a single date
+ const dt = DateTime.fromISO(isoStr, { setZone: true });
+ if (!dt.isValid) return isoStr;
+ return dt.toLocaleString(opts ?? { month: 'short', day: 'numeric', year: 'numeric' });
+}
+
+export const formatIsoNoYear = (isoStr1?: string, isoStr2?: string) =>
+ formatIso(isoStr1, isoStr2, { month: 'short', day: 'numeric' });
+
+export const formatIsoWeekday = (isoStr1?: string, isoStr2?: string) =>
+ formatIso(isoStr1, isoStr2, {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
+
+export const formatIsoWeekdayNoYear = (isoStr1?: string, isoStr2?: string) =>
+ formatIso(isoStr1, isoStr2, { weekday: 'short', month: 'short', day: 'numeric' });
+
+/**
+ * @example IsoDateWithOffset('2024-03-22', 1) -> '2024-03-23'
+ * @example IsoDateWithOffset('2024-03-22', -1000) -> '2021-06-26'
+ */
+export function isoDateWithOffset(date: string, offset: number) {
+ let d = new Date(date);
+ d.setUTCDate(d.getUTCDate() + offset);
+ return d.toISOString().substring(0, 10);
+}
+
+export const isoDateRangeToTsRange = (isoDateRange: [string, string], zone?) => [
+ DateTime.fromISO(isoDateRange[0], { zone: zone }).startOf('day').toSeconds(),
+ DateTime.fromISO(isoDateRange[1], { zone: zone }).endOf('day').toSeconds(),
+];
+
+/**
+ * @example isoDatesDifference('2024-03-22', '2024-03-29') -> 7
+ * @example isoDatesDifference('2024-03-22', '2021-06-26') -> 1000
+ * @example isoDatesDifference('2024-03-29', '2024-03-25') -> -4
+ */
+export const isoDatesDifference = (isoStr1: string, isoStr2: string) =>
+ -DateTime.fromISO(isoStr1).diff(DateTime.fromISO(isoStr2), 'days').days;
+
+/**
+ * @param isoStr1 An ISO 8601 formatted timestamp (with timezone)
+ * @param isoStr2 An ISO 8601 formatted timestamp (with timezone)
+ * @returns A human-readable, approximate time range, e.g. "2 hours"
+ */
+export function humanizeIsoRange(isoStr1: string, isoStr2: string) {
+ if (!isoStr1 || !isoStr2) return;
+ const beginTime = DateTime.fromISO(isoStr1, { setZone: true });
+ const endTime = DateTime.fromISO(isoStr2, { setZone: true });
+ const range = endTime.diff(beginTime, ['hours', 'minutes']);
+ return humanizeDuration(range.as('milliseconds'), {
+ language: i18next.resolvedLanguage,
+ largest: 1,
+ round: true,
+ });
+}