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