diff --git a/package.cordovabuild.json b/package.cordovabuild.json index d84baa02c..23d2d38ad 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -139,7 +139,7 @@ "cordova-custom-config": "^5.1.1", "cordova-plugin-ibeacon": "git+https://github.com/louisg1337/cordova-plugin-ibeacon.git", "core-js": "^2.5.7", - "e-mission-common": "git+https://github.com/JGreenlee/e-mission-common.git#0.4.4", + "e-mission-common": "github:JGreenlee/e-mission-common#semver:0.5.1", "enketo-core": "^6.1.7", "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", diff --git a/package.serve.json b/package.serve.json index b610d6121..ff9bf5879 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": "git+https://github.com/JGreenlee/e-mission-common.git#0.4.4", + "e-mission-common": "github:JGreenlee/e-mission-common#semver:0.5.1", "enketo-core": "^6.1.7", "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", diff --git a/www/__tests__/Carousel.test.tsx b/www/__tests__/Carousel.test.tsx new file mode 100644 index 000000000..7b8601109 --- /dev/null +++ b/www/__tests__/Carousel.test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { View } from 'react-native'; +import Carousel from '../js/components/Carousel'; + +describe('Carousel component', () => { + const child1 = Child 1; + const child2 = Child 2; + const cardWidth = 100; + const cardMargin = 10; + + it('renders children correctly', () => { + const { getByTestId } = render( + + {child1} + {child2} + , + ); + + const renderedChild1 = getByTestId('child1'); + const renderedChild2 = getByTestId('child2'); + + expect(renderedChild1).toBeTruthy(); + expect(renderedChild2).toBeTruthy(); + }); +}); diff --git a/www/__tests__/DateSelect.test.tsx b/www/__tests__/DateSelect.test.tsx new file mode 100644 index 000000000..79fdc1997 --- /dev/null +++ b/www/__tests__/DateSelect.test.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react-native'; +import DateSelect from '../js/diary/list/DateSelect'; + +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 30, left: 0, right: 0, top: 30 }), +})); +jest.spyOn(React, 'useState').mockImplementation((initialValue) => [initialValue, jest.fn()]); +jest.spyOn(React, 'useEffect').mockImplementation((effect: () => void) => effect()); + +describe('DateSelect', () => { + it('renders correctly', () => { + const onChooseMock = jest.fn(); + const { getByText } = render(); + + expect(screen.getByTestId('button-container')).toBeTruthy(); + expect(screen.getByTestId('button')).toBeTruthy(); + }); +}); diff --git a/www/__tests__/appTheme.test.ts b/www/__tests__/appTheme.test.ts new file mode 100644 index 000000000..9ec3e0fdf --- /dev/null +++ b/www/__tests__/appTheme.test.ts @@ -0,0 +1,22 @@ +import { getTheme } from '../js/appTheme'; + +describe('getTheme', () => { + it('should return the right theme with place', () => { + const theme = getTheme('place'); + expect(theme.colors.elevation.level1).toEqual('#cbe6ff'); + }); + + it('should return the right theme with untracked', () => { + const theme = getTheme('untracked'); + expect(theme.colors.primary).toEqual('#8c4a57'); + expect(theme.colors.primaryContainer).toEqual('#e3bdc2'); + expect(theme.colors.elevation.level1).toEqual('#f8ebec'); + }); + + it('should return the right theme with draft', () => { + const theme = getTheme('draft'); + expect(theme.colors.primary).toEqual('#616971'); + expect(theme.colors.primaryContainer).toEqual('#b6bcc2'); + expect(theme.colors.background).toEqual('#eef1f4'); + }); +}); diff --git a/www/__tests__/metricsHelper.test.ts b/www/__tests__/metricsHelper.test.ts index 075a9000f..c914c5782 100644 --- a/www/__tests__/metricsHelper.test.ts +++ b/www/__tests__/metricsHelper.test.ts @@ -1,51 +1,56 @@ +import { DateTime } from 'luxon'; import { calculatePercentChange, formatDate, formatDateRangeOfDays, getLabelsForDay, getUniqueLabelsForDays, + secondsToHours, + secondsToMinutes, segmentDaysByWeeks, + metricToValue, + tsForDayOfMetricData, + valueForFieldOnDay, + generateSummaryFromData, + isCustomLabels, + isAllCustom, + isOnFoot, + getUnitUtilsForMetric, } from '../js/metrics/metricsHelper'; -import { - DayOfClientMetricData, - DayOfMetricData, - DayOfServerMetricData, -} from '../js/metrics/metricsTypes'; +import { DayOfMetricData } from '../js/metrics/metricsTypes'; +import initializedI18next from '../js/i18nextInit'; +window['i18next'] = initializedI18next; describe('metricsHelper', () => { describe('getUniqueLabelsForDays', () => { const days1 = [ - { label_a: 1, label_b: 2 }, - { label_c: 1, label_d: 3 }, - ] as any as DayOfServerMetricData[]; - it("should return unique labels for days with 'label_*'", () => { + { mode_confirm_a: 1, mode_confirm_b: 2 }, + { mode_confirm_b: 1, mode_confirm_c: 3 }, + { mode_confirm_c: 1, mode_confirm_d: 3 }, + ] as any as DayOfMetricData[]; + it("should return unique labels for days with 'mode_confirm_*'", () => { expect(getUniqueLabelsForDays(days1)).toEqual(['a', 'b', 'c', 'd']); }); - - const days2 = [ - { mode_a: 1, mode_b: 2 }, - { mode_c: 1, mode_d: 3 }, - ] as any as DayOfClientMetricData[]; - it("should return unique labels for days with 'mode_*'", () => { - expect(getUniqueLabelsForDays(days2)).toEqual(['a', 'b', 'c', 'd']); - }); }); describe('getLabelsForDay', () => { - const day1 = { label_a: 1, label_b: 2 } as any as DayOfServerMetricData; - it("should return labels for a day with 'label_*'", () => { + const day1 = { mode_confirm_a: 1, mode_confirm_b: 2 } as any as DayOfMetricData; + it("should return labels for a day with 'mode_confirm_*'", () => { expect(getLabelsForDay(day1)).toEqual(['a', 'b']); }); + }); - const day2 = { mode_a: 1, mode_b: 2 } as any as DayOfClientMetricData; - it("should return labels for a day with 'mode_*'", () => { - expect(getLabelsForDay(day2)).toEqual(['a', 'b']); + describe('secondsToMinutes', () => { + it('should convert from seconds to minutes properly', () => { + expect(secondsToMinutes(360)).toEqual(6); }); }); - // secondsToMinutes - - // secondsToHours + describe('secondsToHours', () => { + it('should convert from seconds to hours properly', () => { + expect(secondsToHours(3600)).toEqual(1); + }); + }); describe('segmentDaysByWeeks', () => { const days1 = [ @@ -55,7 +60,7 @@ describe('metricsHelper', () => { { date: '2021-01-08' }, { date: '2021-01-09' }, { date: '2021-01-10' }, - ] as any as DayOfClientMetricData[]; + ] as any as DayOfMetricData[]; it("should segment days with 'date' into weeks", () => { expect(segmentDaysByWeeks(days1, '2021-01-10')).toEqual([ @@ -70,40 +75,13 @@ describe('metricsHelper', () => { [{ date: '2021-01-01' }, { date: '2021-01-02' }], ]); }); - - const days2 = [ - { fmt_time: '2021-01-01T00:00:00Z' }, - { fmt_time: '2021-01-02T00:00:00Z' }, - { fmt_time: '2021-01-04T00:00:00Z' }, - { fmt_time: '2021-01-08T00:00:00Z' }, - { fmt_time: '2021-01-09T00:00:00Z' }, - { fmt_time: '2021-01-10T00:00:00Z' }, - ] as any as DayOfServerMetricData[]; - it("should segment days with 'fmt_time' into weeks", () => { - expect(segmentDaysByWeeks(days2, '2021-01-10')).toEqual([ - // most recent week - [ - { fmt_time: '2021-01-04T00:00:00Z' }, - { fmt_time: '2021-01-08T00:00:00Z' }, - { fmt_time: '2021-01-09T00:00:00Z' }, - { fmt_time: '2021-01-10T00:00:00Z' }, - ], - // prior week - [{ fmt_time: '2021-01-01T00:00:00Z' }, { fmt_time: '2021-01-02T00:00:00Z' }], - ]); - }); }); describe('formatDate', () => { - const day1 = { date: '2021-01-01' } as any as DayOfClientMetricData; + const day1 = { date: '2021-01-01' } as any as DayOfMetricData; it('should format date', () => { expect(formatDate(day1)).toEqual('1/1'); }); - - const day2 = { fmt_time: '2021-01-01T00:00:00Z' } as any as DayOfServerMetricData; - it('should format date', () => { - expect(formatDate(day2)).toEqual('1/1'); - }); }); describe('formatDateRangeOfDays', () => { @@ -111,18 +89,250 @@ describe('metricsHelper', () => { { date: '2021-01-01' }, { date: '2021-01-02' }, { date: '2021-01-04' }, - ] as any as DayOfClientMetricData[]; + ] as any as DayOfMetricData[]; it('should format date range for days with date', () => { expect(formatDateRangeOfDays(days1)).toEqual('1/1 - 1/4'); }); + }); + + describe('metricToValue', () => { + const metric = { + walking: 10, + nUsers: 5, + }; + it('returns correct value for user population', () => { + const result = metricToValue('user', metric, 'walking'); + expect(result).toBe(10); + }); + + it('returns correct value for aggregate population', () => { + const result = metricToValue('aggregate', metric, 'walking'); + expect(result).toBe(2); + }); + }); + + describe('isOnFoot', () => { + it('returns true for on foot mode', () => { + const result = isOnFoot('WALKING'); + expect(result).toBe(true); + }); + + it('returns false for non on foot mode', () => { + const result = isOnFoot('DRIVING'); + expect(result).toBe(false); + }); + }); + + describe('calculatePercentChange', () => { + it('calculates percent change correctly for low and high values', () => { + const pastWeekRange = { low: 10, high: 30 }; + const previousWeekRange = { low: 5, high: 10 }; + const result = calculatePercentChange(pastWeekRange, previousWeekRange); + expect(result.low).toBe(100); + expect(result.high).toBe(200); + }); + }); + + describe('tsForDayOfMetricData', () => { + const mockDay = { + date: '2024-05-28T12:00:00Z', + nUsers: 10, + }; + let _datesTsCache; + beforeEach(() => { + _datesTsCache = {}; + }); + + it('calculates timestamp for a given day', () => { + const expectedTimestamp = DateTime.fromISO(mockDay.date).toSeconds(); + const result = tsForDayOfMetricData(mockDay); + expect(result).toBe(expectedTimestamp); + }); + + it('caches the timestamp for subsequent calls with the same day', () => { + const firstResult = tsForDayOfMetricData(mockDay); + const secondResult = tsForDayOfMetricData(mockDay); + expect(secondResult).toBe(firstResult); + }); + }); + + describe('valueForFieldOnDay', () => { + const mockDay = { + date: '2024-05-28T12:00:00Z', + nUsers: 10, + field_key: 'example_value', + }; + + it('returns the value for a specified field and key', () => { + const result = valueForFieldOnDay(mockDay, 'field', 'key'); + expect(result).toBe('example_value'); + }); + }); + + describe('generateSummaryFromData', () => { + const modeMap = [ + { + key: 'mode1', + values: [ + ['value1', 10], + ['value2', 20], + ], + }, + { + key: 'mode2', + values: [ + ['value3', 30], + ['value4', 40], + ], + }, + ]; + it('returns summary with sum for non-speed metric', () => { + const metric = 'some_metric'; + const expectedResult = [ + { key: 'mode1', values: 30 }, + { key: 'mode2', values: 70 }, + ]; + const result = generateSummaryFromData(modeMap, metric); + expect(result).toEqual(expectedResult); + }); + + it('returns summary with average for speed metric', () => { + const metric = 'mean_speed'; + const expectedResult = [ + { key: 'mode1', values: 15 }, + { key: 'mode2', values: 35 }, + ]; + const result = generateSummaryFromData(modeMap, metric); + expect(result).toEqual(expectedResult); + }); + }); + + describe('isCustomLabels', () => { + it('returns true for all custom labels', () => { + const modeMap = [ + { + key: 'label_mode1', + values: [ + ['value1', 10], + ['value2', 20], + ], + }, + { + key: 'label_mode2', + values: [ + ['value3', 30], + ['value4', 40], + ], + }, + ]; + const result = isCustomLabels(modeMap); + expect(result).toBe(true); + }); + + it('returns true for all sensed labels', () => { + const modeMap = [ + { + key: 'label_mode1', + values: [ + ['value1', 10], + ['value2', 20], + ], + }, + { + key: 'label_mode2', + values: [ + ['value3', 30], + ['value4', 40], + ], + }, + ]; + const result = isCustomLabels(modeMap); + expect(result).toBe(true); + }); + + it('returns false for mixed custom and sensed labels', () => { + const modeMap = [ + { + key: 'label_mode1', + values: [ + ['value1', 10], + ['value2', 20], + ], + }, + { + key: 'MODE2', + values: [ + ['value3', 30], + ['value4', 40], + ], + }, + ]; + const result = isCustomLabels(modeMap); + expect(result).toBe(false); + }); + }); + + describe('isAllCustom', () => { + it('returns true when all keys are custom', () => { + const isSensedKeys = [false, false, false]; + const isCustomKeys = [true, true, true]; + const result = isAllCustom(isSensedKeys, isCustomKeys); + expect(result).toBe(true); + }); + + it('returns false when all keys are sensed', () => { + const isSensedKeys = [true, true, true]; + const isCustomKeys = [false, false, false]; + const result = isAllCustom(isSensedKeys, isCustomKeys); + expect(result).toBe(false); + }); + + it('returns undefined for mixed custom and sensed keys', () => { + const isSensedKeys = [true, false, true]; + const isCustomKeys = [false, true, false]; + const result = isAllCustom(isSensedKeys, isCustomKeys); + expect(result).toBe(undefined); + }); + }); + + describe('getUnitUtilsForMetric', () => { + const imperialConfig = { + distanceSuffix: 'mi', + speedSuffix: 'mph', + convertDistance: jest.fn((d) => d), + convertSpeed: jest.fn((s) => s), + getFormattedDistance: jest.fn((d) => `${d} mi`), + getFormattedSpeed: jest.fn((s) => `${s} mph`), + }; + + it('checks for distance metric', () => { + const result = getUnitUtilsForMetric('distance', imperialConfig); + expect(result).toEqual(['mi', expect.any(Function), expect.any(Function)]); + expect(result[1](1)).toBe(1); + expect(result[2](1)).toBe('1 mi mi'); + }); + + it('checks for duration metric', () => { + const result = getUnitUtilsForMetric('duration', imperialConfig); + expect(result).toEqual(['hours', expect.any(Function), expect.any(Function)]); + expect(result[1](3600)).toBe(1); + expect(result[2](3600)).toBe('1 hours'); + }); + + it('checks for count metric', () => { + const result = getUnitUtilsForMetric('count', imperialConfig); + expect(result).toEqual(['trips', expect.any(Function), expect.any(Function)]); + const mockTrip = { responded: 4, not_responded: 3 }; + expect(result[1](mockTrip)).toBe(mockTrip); + expect(result[2](mockTrip)).toBe(mockTrip + ' trips'); + }); - const days2 = [ - { fmt_time: '2021-01-01T00:00:00Z' }, - { fmt_time: '2021-01-02T00:00:00Z' }, - { fmt_time: '2021-01-04T00:00:00Z' }, - ] as any as DayOfServerMetricData[]; - it('should format date range for days with fmt_time', () => { - expect(formatDateRangeOfDays(days2)).toEqual('1/1 - 1/4'); + it('checks for response_count metric', () => { + const result = getUnitUtilsForMetric('response_count', imperialConfig); + expect(result).toEqual(['responses', expect.any(Function), expect.any(Function)]); + const mockResponse = { responded: 5, not_responded: 2 }; + expect(result[1](mockResponse)).toBe(5); + expect(result[2](mockResponse)).toBe('5/7 responses'); }); }); }); diff --git a/www/__tests__/useImperialConfig.test.ts b/www/__tests__/useImperialConfig.test.ts index 593498aae..33c354271 100644 --- a/www/__tests__/useImperialConfig.test.ts +++ b/www/__tests__/useImperialConfig.test.ts @@ -1,14 +1,22 @@ -import { convertDistance, convertSpeed, formatForDisplay } from '../js/config/useImperialConfig'; +import React from 'react'; +import { + convertDistance, + convertSpeed, + formatForDisplay, + useImperialConfig, +} from '../js/config/useImperialConfig'; // This mock is required, or else the test will dive into the import chain of useAppConfig.ts and fail when it gets to the root jest.mock('../js/useAppConfig', () => { return jest.fn(() => ({ - appConfig: { + display_config: { use_imperial: false, }, loading: false, })); }); +jest.spyOn(React, 'useState').mockImplementation((initialValue) => [initialValue, jest.fn()]); +jest.spyOn(React, 'useEffect').mockImplementation((effect: () => void) => effect()); describe('formatForDisplay', () => { it('should round to the nearest integer when value is >= 100', () => { @@ -53,3 +61,15 @@ describe('convertSpeed', () => { expect(convertSpeed(6.7056, true)).toBeCloseTo(15); // Approximately 15 mph }); }); + +describe('useImperialConfig', () => { + it('returns ImperialConfig with imperial units', () => { + const imperialConfig = useImperialConfig(); + expect(imperialConfig.distanceSuffix).toBe('km'); + expect(imperialConfig.speedSuffix).toBe('kmph'); + expect(imperialConfig.convertDistance(10)).toBe(0.01); + expect(imperialConfig.convertSpeed(20)).toBe(72); + expect(imperialConfig.getFormattedDistance(10)).toBe('0.01'); + expect(imperialConfig.getFormattedSpeed(20)).toBe('72'); + }); +}); diff --git a/www/i18n/en.json b/www/i18n/en.json index 2834219af..dfccf1f5e 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -115,7 +115,9 @@ "trips": "trips", "hours": "hours", "minutes": "minutes", - "custom": "Custom" + "responses": "responses", + "custom": "Custom", + "no-data": "No data" }, "diary": { @@ -192,8 +194,9 @@ "chart": "Chart", "change-data": "Change dates:", "distance": "Distance", - "trips": "Trips", + "count": "Trip Count", "duration": "Duration", + "response_count": "Response Count", "fav-mode": "My Favorite Mode", "speed": "My Speed", "footprint": "My Footprint", @@ -222,7 +225,22 @@ "weekly-goal-footnote": "³Weekly goal based on CDC recommendation of 150 minutes of moderate activity per week.", "labeled": "Labeled", "unlabeled": "Unlabeled²", - "footprint-label": "Footprint (kg CO₂)" + "footprint-label": "Footprint (kg CO₂)", + "surveys": "Surveys", + "leaderboard": "Leaderboard", + "survey-response-rate": "Survey Response Rate (%)", + "survey-leaderboard-desc": "This data has been accumulated since ", + "comparison": "Comparison", + "you": "You", + "others": "Others in group", + "trip-categories": "Trip Categories", + "ev-roading-trip": "EV Roaming trip", + "ev-return-trip": "EV Return trip", + "gas-car-trip": "Gas Car trip", + "response": "Response", + "no-response": "No Response", + "you-are-in": "You're in", + "place": " place!" }, "details": { diff --git a/www/js/Main.tsx b/www/js/Main.tsx index f38b80018..650ed4044 100644 --- a/www/js/Main.tsx +++ b/www/js/Main.tsx @@ -51,7 +51,9 @@ const Main = () => { const timelineContext = useTimelineContext(); const routes = useMemo(() => { - const showMetrics = appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL'; + const showMetrics = + appConfig?.metrics?.phone_dashboard_ui || + appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL'; return showMetrics ? defaultRoutes(t) : defaultRoutes(t).filter((r) => r.key != 'metrics'); }, [appConfig, t]); diff --git a/www/js/TimelineContext.ts b/www/js/TimelineContext.ts index aa12dacac..e263ca0bb 100644 --- a/www/js/TimelineContext.ts +++ b/www/js/TimelineContext.ts @@ -44,10 +44,9 @@ type ContextProps = { pipelineRange: TimestampRange | null; queriedDateRange: [string, string] | null; // YYYY-MM-DD format dateRange: [string, string]; // YYYY-MM-DD format - setDateRange: (d: [string, string]) => void; timelineIsLoading: string | false; - loadMoreDays: (when: 'past' | 'future', nDays: number) => void; - loadSpecificWeek: (d: string) => void; + loadMoreDays: (when: 'past' | 'future', nDays: number) => boolean | void; + loadDateRange: (d: [string, string]) => boolean | void; refreshTimeline: () => void; shouldUpdateTimeline: Boolean; setShouldUpdateTimeline: React.Dispatch>; @@ -168,42 +167,36 @@ export const useTimelineContext = (): ContextProps => { function loadMoreDays(when: 'past' | 'future', nDays: number) { const existingRange = queriedDateRange || initialQueryRange; logDebug(`Timeline: loadMoreDays, ${nDays} days into the ${when}; - queriedDateRange = ${queriedDateRange}; - existingRange = ${existingRange}`); - let newDateRange: [string, string]; - if (when == 'past') { - newDateRange = [isoDateWithOffset(existingRange[0], -nDays), existingRange[1]]; - } else { - newDateRange = [existingRange[0], isoDateWithOffset(existingRange[1], nDays)]; - } - logDebug('Timeline: loadMoreDays setting new date range = ' + newDateRange); - setDateRange(newDateRange); + queriedDateRange = ${queriedDateRange}; existingRange = ${existingRange}`); + return loadDateRange( + when == 'past' + ? [isoDateWithOffset(existingRange[0], -nDays), existingRange[1]] + : [existingRange[0], isoDateWithOffset(existingRange[1], nDays)], + ); } - function loadSpecificWeek(date: string) { - logDebug('Timeline: loadSpecificWeek for date ' + date); - if (!pipelineRange) return logWarn('No pipelineRange yet - early return from loadSpecificWeek'); - let newStartDate = isoDateWithOffset(date, -3); // three days before - let newEndDate = isoDateWithOffset(date, 3); // three days after - - const pipelineStart = DateTime.fromSeconds(pipelineRange.start_ts).toISODate(); + function loadDateRange(range: [string, string]) { + logDebug('Timeline: loadDateRange with newDateRange = ' + range); + if (!pipelineRange) { + logWarn('No pipelineRange yet - early return from loadDateRange'); + return; + } + const pipelineStartDate = DateTime.fromSeconds(pipelineRange.start_ts).toISODate(); const todayDate = DateTime.now().toISODate(); - - const wentBeforePipeline = newStartDate.replace(/-/g, '') < pipelineStart.replace(/-/g, ''); - const wentAfterToday = newEndDate.replace(/-/g, '') > todayDate.replace(/-/g, ''); - - if (wentBeforePipeline && wentAfterToday) { - newStartDate = pipelineStart; - newEndDate = todayDate; - } else if (wentBeforePipeline) { - newStartDate = pipelineStart; - newEndDate = isoDateWithOffset(pipelineStart, 6); - } else if (wentAfterToday) { - newStartDate = isoDateWithOffset(todayDate, -6); - newEndDate = todayDate; + // clamp range to ensure it is within [pipelineStartDate, todayDate] + const clampedDateRange: [string, string] = [ + new Date(range[0]) < new Date(pipelineStartDate) ? pipelineStartDate : range[0], + new Date(range[1]) > new Date(todayDate) ? todayDate : range[1], + ]; + if (clampedDateRange[0] != dateRange[0] || clampedDateRange[1] != dateRange[1]) { + logDebug('Timeline: loadDateRange setting new date range = ' + clampedDateRange); + setTimelineIsLoading('queued'); + setDateRange(clampedDateRange); + return true; + } else { + logDebug('Timeline: loadDateRange no change in date range'); + return false; } - logDebug('Timeline: loadSpecificWeek setting new date range = ' + [newStartDate, newEndDate]); - setDateRange([newStartDate, newEndDate]); } function handleFetchedTrips(ctList, utList, mode: 'prepend' | 'append' | 'replace') { @@ -335,13 +328,12 @@ export const useTimelineContext = (): ContextProps => { pipelineRange, queriedDateRange, dateRange, - setDateRange, timelineMap, timelineIsLoading, timelineLabelMap, labelOptions, loadMoreDays, - loadSpecificWeek, + loadDateRange, refreshTimeline, userInputFor, labelFor, diff --git a/www/js/appTheme.ts b/www/js/appTheme.ts index b66f493e6..d2f13c47e 100644 --- a/www/js/appTheme.ts +++ b/www/js/appTheme.ts @@ -32,7 +32,11 @@ const AppTheme = { }, success: '#00a665', // lch(60% 55 155) warn: '#f8cf53', //lch(85% 65 85) - danger: '#f23934', // lch(55% 85 35) + danger: '#f23934', // lch(55% 85 35), + silver: '#d9d9d9', + skyblue: '#7fcaea', + navy: '#0077aa', + orange: '#f6a063', }, roundness: 5, }; diff --git a/www/js/components/Carousel.tsx b/www/js/components/Carousel.tsx index 92febb32b..8afe6624a 100644 --- a/www/js/components/Carousel.tsx +++ b/www/js/components/Carousel.tsx @@ -16,11 +16,16 @@ const Carousel = ({ children, cardWidth, cardMargin }: Props) => { snapToAlignment={'center'} style={s.carouselScroll(cardMargin)} contentContainerStyle={{ alignItems: 'flex-start' }}> - {React.Children.map(children, (child, i) => ( - - {child} - - ))} + {React.Children.map( + children, + (child, i) => + // If child is `null`, we need to skip it; otherwise, it takes up space + child && ( + + {child} + + ), + )} ); }; diff --git a/www/js/components/Chart.tsx b/www/js/components/Chart.tsx index 2ff236b5b..e86ef794b 100644 --- a/www/js/components/Chart.tsx +++ b/www/js/components/Chart.tsx @@ -31,6 +31,10 @@ export type Props = { isHorizontal?: boolean; timeAxis?: boolean; stacked?: boolean; + showLegend?: boolean; + reverse?: boolean; + enableTooltip?: boolean; + maxBarThickness?: number; }; const Chart = ({ records, @@ -43,6 +47,10 @@ const Chart = ({ isHorizontal, timeAxis, stacked, + showLegend = true, + reverse = true, + enableTooltip = true, + maxBarThickness = 100, }: Props) => { const { colors } = useTheme(); const [numVisibleDatasets, setNumVisibleDatasets] = useState(1); @@ -68,6 +76,7 @@ const Chart = ({ getColorForChartEl?.(chartRef.current, e, barCtx, 'border'), borderWidth: borderWidth || 2, borderRadius: 3, + maxBarThickness: maxBarThickness, })), }; }, [chartDatasets, getColorForLabel]); @@ -149,7 +158,7 @@ const Chart = ({ }, font: { size: 11 }, // default is 12, we want a tad smaller }, - reverse: true, + reverse: reverse, stacked, }, x: { @@ -196,6 +205,12 @@ const Chart = ({ }), }, plugins: { + legend: { + display: showLegend, + }, + tooltip: { + enabled: enableTooltip, + }, ...(lineAnnotations?.length && { annotation: { clip: false, diff --git a/www/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts index 5843af3d2..f92239170 100644 --- a/www/js/config/dynamicConfig.ts +++ b/www/js/config/dynamicConfig.ts @@ -81,6 +81,7 @@ function _fillSurveyInfo(config: Partial): AppConfig { const _backwardsCompatFill = (config: Partial): AppConfig => _fillSurveyInfo(_fillStudyName(config)); +export let _cacheResourcesFetchPromise: Promise<(string | undefined)[]> = Promise.resolve([]); /** * @description Fetch and cache any surveys resources that are referenced by URL in the config, * as well as the label_options config if it is present. @@ -89,15 +90,17 @@ const _backwardsCompatFill = (config: Partial): AppConfig => * @param config The app config */ function cacheResourcesFromConfig(config: AppConfig) { + const fetchPromises: Promise[] = []; if (config.survey_info?.surveys) { Object.values(config.survey_info.surveys).forEach((survey) => { if (!survey?.['formPath']) throw new Error(i18next.t('config.survey-missing-formpath')); - fetchUrlCached(survey['formPath'], { cache: 'reload' }); + fetchPromises.push(fetchUrlCached(survey['formPath'], { cache: 'reload' })); }); } if (config.label_options) { - fetchUrlCached(config.label_options, { cache: 'reload' }); + fetchPromises.push(fetchUrlCached(config.label_options, { cache: 'reload' })); } + _cacheResourcesFetchPromise = Promise.all(fetchPromises); } /** diff --git a/www/js/config/useImperialConfig.ts b/www/js/config/useImperialConfig.ts index aa87ed1c6..feb2bb114 100644 --- a/www/js/config/useImperialConfig.ts +++ b/www/js/config/useImperialConfig.ts @@ -5,6 +5,8 @@ import i18next from 'i18next'; export type ImperialConfig = { distanceSuffix: string; speedSuffix: string; + convertDistance: (d: number) => number; + convertSpeed: (s: number) => number; getFormattedDistance: (d: number) => string; getFormattedSpeed: (s: number) => string; }; @@ -50,6 +52,8 @@ export function useImperialConfig(): ImperialConfig { return { distanceSuffix: useImperial ? 'mi' : 'km', speedSuffix: useImperial ? 'mph' : 'kmph', + convertDistance: (d) => convertDistance(d, useImperial), + convertSpeed: (s) => convertSpeed(s, useImperial), getFormattedDistance: useImperial ? (d) => formatForDisplay(convertDistance(d, true)) : (d) => formatForDisplay(convertDistance(d, false)), diff --git a/www/js/control/ProfileSettings.tsx b/www/js/control/ProfileSettings.tsx index ab381e594..794a37bcb 100644 --- a/www/js/control/ProfileSettings.tsx +++ b/www/js/control/ProfileSettings.tsx @@ -26,7 +26,11 @@ import ControlCollectionHelper, { helperToggleLowAccuracy, forceTransition, } from './ControlCollectionHelper'; -import { loadNewConfig, resetDataAndRefresh } from '../config/dynamicConfig'; +import { + _cacheResourcesFetchPromise, + loadNewConfig, + resetDataAndRefresh, +} from '../config/dynamicConfig'; import { AppContext } from '../App'; import { shareQR } from '../components/QrCode'; import { storageClear } from '../plugin/storage'; @@ -311,7 +315,10 @@ const ProfileSettings = () => { AlertManager.addMessage({ text: t('control.refreshing-app-config') }); const updated = await loadNewConfig(authSettings.opcode, appConfig?.version); if (updated) { - window.location.reload(); + // wait for resources to finish downloading before reloading + _cacheResourcesFetchPromise + .then(() => window.location.reload()) + .catch((error) => displayError(error, 'Failed to download a resource')); } else { AlertManager.addMessage({ text: t('control.already-up-to-date') }); } diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index ef8507559..b368adc0c 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -21,7 +21,7 @@ import { AppContext } from '../App'; type LabelContextProps = { displayedEntries: TimelineEntry[] | null; - filterInputs: LabelTabFilter[]; + filterInputs: LabelTabFilter[] | null; setFilterInputs: (filters: LabelTabFilter[]) => void; }; export const LabelTabContext = createContext({} as LabelContextProps); @@ -31,13 +31,15 @@ const LabelTab = () => { const { pipelineRange, timelineMap, timelineLabelMap } = useContext(TimelineContext); const [filterRefreshTs, setFilterRefreshTs] = useState(0); // used to force a refresh of the filters - const [filterInputs, setFilterInputs] = useState([]); + const [filterInputs, setFilterInputs] = useState(null); const [displayedEntries, setDisplayedEntries] = useState(null); useEffect(() => { - // we will show filters if 'additions' are not configured + // if places are shown, we will skip filters and it will just be "show all" // https://github.com/e-mission/e-mission-docs/issues/894 - if (appConfig.survey_info?.buttons == undefined) { + if (appConfig.survey_info?.buttons?.['place-notes']) { + setFilterInputs([]); + } else { // initalize filters const tripFilters = appConfig.survey_info?.['trip-labels'] == 'ENKETO' @@ -61,7 +63,7 @@ const LabelTab = () => { }, [timelineMap]); useEffect(() => { - if (!timelineMap || !timelineLabelMap || !filterInputs.length) return; + if (!timelineMap || !timelineLabelMap || !filterInputs) return; logDebug('Applying filters'); const allEntries: TimelineEntry[] = Array.from(timelineMap.values()); const activeFilter = filterInputs?.find((f) => f.state == true); diff --git a/www/js/diary/list/DateSelect.tsx b/www/js/diary/list/DateSelect.tsx index df3bbbd66..849f23188 100644 --- a/www/js/diary/list/DateSelect.tsx +++ b/www/js/diary/list/DateSelect.tsx @@ -39,7 +39,7 @@ const DateSelect = ({ mode, onChoose, ...rest }: Props) => { }, [pipelineRange]); const queriedRangeAsJsDates = useMemo( - () => queriedDateRange?.map((d) => new Date(d)), + () => queriedDateRange?.map((d) => DateTime.fromISO(d).toJSDate()), [queriedDateRange], ); diff --git a/www/js/diary/list/FilterSelect.tsx b/www/js/diary/list/FilterSelect.tsx index 2bad0c7cb..c9d23d602 100644 --- a/www/js/diary/list/FilterSelect.tsx +++ b/www/js/diary/list/FilterSelect.tsx @@ -15,7 +15,7 @@ import { NavBarButton } from '../../components/NavBar'; import { LabelTabFilter } from '../../TimelineContext'; type Props = { - filters: LabelTabFilter[]; + filters: LabelTabFilter[] | null; setFilters: (filters: LabelTabFilter[]) => void; numListDisplayed?: number; numListTotal?: number; @@ -32,6 +32,7 @@ const FilterSelect = ({ filters, setFilters, numListDisplayed, numListTotal }: P }, [filters, numListDisplayed, numListTotal]); function chooseFilter(filterKey) { + if (!filters) return; if (filterKey == 'show-all') { setFilters(filters.map((f) => ({ ...f, state: false }))); } else { @@ -62,9 +63,7 @@ const FilterSelect = ({ filters, setFilters, numListDisplayed, numListTotal }: P {/* {t('diary.filter-travel')} */} chooseFilter(k)} value={selectedFilter}> - {filters.map((f) => ( - - ))} + {filters?.map((f) => )} { const { filterInputs, setFilterInputs, displayedEntries } = useContext(LabelTabContext); - const { - timelineMap, - loadSpecificWeek, - timelineIsLoading, - refreshTimeline, - shouldUpdateTimeline, - } = useContext(TimelineContext); + const { timelineMap, loadDateRange, timelineIsLoading, refreshTimeline, shouldUpdateTimeline } = + useContext(TimelineContext); const { colors } = useTheme(); return ( @@ -31,11 +26,12 @@ const LabelListScreen = () => { numListTotal={timelineMap?.size} /> { - const d = DateTime.fromJSDate(date).toISODate(); - if (!d) return displayErrorMsg('Invalid date'); - loadSpecificWeek(d); + mode="range" + onChoose={({ startDate, endDate }) => { + const start = DateTime.fromJSDate(startDate).toISODate(); + const end = DateTime.fromJSDate(endDate).toISODate(); + if (!start || !end) return displayErrorMsg('Invalid date'); + loadDateRange([start, end]); }} /> { const { colors } = useTheme(); const { dateRange } = useContext(TimelineContext); const { t } = useTranslation(); + const appConfig = useAppConfig(); + // modes to consider as "active" for the purpose of calculating "active minutes", default : ['walk', 'bike'] + const activeModes = + appConfig?.metrics?.phone_dashboard_ui?.active_travel_options?.modes_list ?? ACTIVE_MODES; const cumulativeTotals = useMemo(() => { if (!userMetrics?.duration) return []; const totals = {}; - ACTIVE_MODES.forEach((mode) => { + activeModes.forEach((mode) => { const sum = userMetrics.duration.reduce( - (acc, day) => acc + (valueForModeOnDay(day, mode) || 0), + (acc, day) => acc + (valueForFieldOnDay(day, 'mode_confirm', mode) || 0), 0, ); totals[mode] = secondsToMinutes(sum); @@ -40,8 +45,11 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { .reverse() .map((week) => { const totals = {}; - ACTIVE_MODES.forEach((mode) => { - const sum = week.reduce((acc, day) => acc + (valueForModeOnDay(day, mode) || 0), 0); + activeModes.forEach((mode) => { + const sum = week.reduce( + (acc, day) => acc + (valueForFieldOnDay(day, 'mode_confirm', mode) || 0), + 0, + ); totals[mode] = secondsToMinutes(sum); }); totals['period'] = formatDateRangeOfDays(week); @@ -54,8 +62,8 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { return userMetrics.duration .map((day) => { const totals = {}; - ACTIVE_MODES.forEach((mode) => { - const sum = valueForModeOnDay(day, mode) || 0; + activeModes.forEach((mode) => { + const sum = valueForFieldOnDay(day, 'mode_confirm', mode) || 0; totals[mode] = secondsToMinutes(sum); }); totals['period'] = formatDate(day); @@ -85,7 +93,7 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { - {ACTIVE_MODES.map((mode, i) => ( + {activeModes.map((mode, i) => ( {labelKeyToRichMode(mode)} @@ -94,7 +102,7 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { {allTotals.slice(from, to).map((total, i) => ( {total['period']} - {ACTIVE_MODES.map((mode, j) => ( + {activeModes.map((mode, j) => ( {total[mode]} {t('metrics.minutes')} diff --git a/www/js/metrics/CarbonFootprintCard.tsx b/www/js/metrics/CarbonFootprintCard.tsx index 68b68c5b1..9624e10df 100644 --- a/www/js/metrics/CarbonFootprintCard.tsx +++ b/www/js/metrics/CarbonFootprintCard.tsx @@ -16,7 +16,6 @@ import { segmentDaysByWeeks, isCustomLabels, MetricsSummary, - dateForDayOfMetricData, } from './metricsHelper'; import { useTranslation } from 'react-i18next'; import BarChart from '../components/BarChart'; @@ -26,13 +25,17 @@ 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(() => { @@ -46,10 +49,7 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { //formatted data from last week, if exists (14 days ago -> 8 days ago) let userLastWeekModeMap = {}; let userLastWeekSummaryMap = {}; - if ( - lastWeekDistance && - isoDatesDifference(dateRange[0], dateForDayOfMetricData(lastWeekDistance[0])) >= 0 - ) { + if (lastWeekDistance && isoDatesDifference(dateRange[0], lastWeekDistance[0].date) >= 0) { userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user'); userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance'); } @@ -72,11 +72,13 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { low: getFootprintForMetrics(userLastWeekSummaryMap, 0), high: getFootprintForMetrics(userLastWeekSummaryMap, getHighestFootprint()), }; - graphRecords.push({ - label: t('main-metrics.unlabeled'), - x: userPrevWeek.high - userPrevWeek.low, - y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`, - }); + if (showUnlabeledMetrics) { + graphRecords.push({ + label: t('main-metrics.unlabeled'), + x: userPrevWeek.high - userPrevWeek.low, + y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`, + }); + } graphRecords.push({ label: t('main-metrics.labeled'), x: userPrevWeek.low, @@ -89,11 +91,13 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { low: getFootprintForMetrics(userThisWeekSummaryMap, 0), high: getFootprintForMetrics(userThisWeekSummaryMap, getHighestFootprint()), }; - graphRecords.push({ - label: t('main-metrics.unlabeled'), - x: userPastWeek.high - userPastWeek.low, - y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, - }); + if (showUnlabeledMetrics) { + graphRecords.push({ + label: t('main-metrics.unlabeled'), + x: userPastWeek.high - userPastWeek.low, + y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, + }); + } graphRecords.push({ label: t('main-metrics.labeled'), x: userPastWeek.low, @@ -111,7 +115,6 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { x: worstCarbon, y: `${t('main-metrics.worst-case')}`, }); - return graphRecords; } }, [userMetrics?.distance]); @@ -145,11 +148,13 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { high: getFootprintForMetrics(aggCarbonData, getHighestFootprint()), }; logDebug(`groupCarbonRecords: aggCarbon = ${JSON.stringify(aggCarbon)}`); - groupRecords.push({ - label: t('main-metrics.unlabeled'), - x: aggCarbon.high - aggCarbon.low, - y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, - }); + if (showUnlabeledMetrics) { + groupRecords.push({ + label: t('main-metrics.unlabeled'), + x: aggCarbon.high - aggCarbon.low, + y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`, + }); + } groupRecords.push({ label: t('main-metrics.labeled'), x: aggCarbon.low, @@ -227,11 +232,9 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => { ) : ( - - - {t('metrics.chart-no-data')} - - + + {t('metrics.chart-no-data')} + )} diff --git a/www/js/metrics/CarbonTextCard.tsx b/www/js/metrics/CarbonTextCard.tsx index dd9e25231..ca9f50fdc 100644 --- a/www/js/metrics/CarbonTextCard.tsx +++ b/www/js/metrics/CarbonTextCard.tsx @@ -16,17 +16,21 @@ import { calculatePercentChange, segmentDaysByWeeks, MetricsSummary, - dateForDayOfMetricData, } 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) { @@ -39,10 +43,7 @@ const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { //formatted data from last week, if exists (14 days ago -> 8 days ago) let userLastWeekModeMap = {}; let userLastWeekSummaryMap = {}; - if ( - lastWeekDistance && - isoDatesDifference(dateRange[0], dateForDayOfMetricData(lastWeekDistance[0])) >= 0 - ) { + if (lastWeekDistance && isoDatesDifference(dateRange[0], lastWeekDistance[0].date) >= 0) { userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user'); userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance'); } @@ -181,11 +182,13 @@ const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => { {textEntries[i].value + ' ' + 'kg CO₂'} ))} - - {t('main-metrics.range-uncertain-footnote')} - + {showUnlabeledMetrics && ( + + {t('main-metrics.range-uncertain-footnote')} + + )} ); diff --git a/www/js/metrics/DailyActiveMinutesCard.tsx b/www/js/metrics/DailyActiveMinutesCard.tsx index b72c20ec6..f70b60587 100644 --- a/www/js/metrics/DailyActiveMinutesCard.tsx +++ b/www/js/metrics/DailyActiveMinutesCard.tsx @@ -7,22 +7,25 @@ import { useTranslation } from 'react-i18next'; import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper'; import LineChart from '../components/LineChart'; import { getBaseModeByText } from '../diary/diaryHelper'; -import { tsForDayOfMetricData, valueForModeOnDay } from './metricsHelper'; - -const ACTIVE_MODES = ['walk', 'bike'] as const; -type ActiveMode = (typeof ACTIVE_MODES)[number]; +import { tsForDayOfMetricData, valueForFieldOnDay } from './metricsHelper'; +import useAppConfig from '../useAppConfig'; +import { ACTIVE_MODES } from './WeeklyActiveMinutesCard'; type Props = { userMetrics?: MetricsData }; const DailyActiveMinutesCard = ({ userMetrics }: Props) => { const { colors } = useTheme(); const { t } = useTranslation(); + const appConfig = useAppConfig(); + // modes to consider as "active" for the purpose of calculating "active minutes", default : ['walk', 'bike'] + const activeModes = + appConfig?.metrics?.phone_dashboard_ui?.active_travel_options?.modes_list ?? ACTIVE_MODES; const dailyActiveMinutesRecords = useMemo(() => { const records: { label: string; x: number; y: number }[] = []; const recentDays = userMetrics?.duration?.slice(-14); recentDays?.forEach((day) => { - ACTIVE_MODES.forEach((mode) => { - const activeSeconds = valueForModeOnDay(day, mode); + 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 @@ -53,11 +56,9 @@ const DailyActiveMinutesCard = ({ userMetrics }: Props) => { getColorForLabel={(l) => getBaseModeByText(l, labelOptions).color} /> ) : ( - - - {t('metrics.chart-no-data')} - - + + {t('metrics.chart-no-data')} + )} diff --git a/www/js/metrics/MetricsCard.tsx b/www/js/metrics/MetricsCard.tsx index 9a13aacdc..287193711 100644 --- a/www/js/metrics/MetricsCard.tsx +++ b/www/js/metrics/MetricsCard.tsx @@ -9,30 +9,34 @@ import { getLabelsForDay, tsForDayOfMetricData, getUniqueLabelsForDays, - valueForModeOnDay, + valueForFieldOnDay, + getUnitUtilsForMetric, } from './metricsHelper'; import ToggleSwitch from '../components/ToggleSwitch'; import { cardStyles } from './MetricsTab'; import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper'; import { getBaseModeByKey, getBaseModeByText, modeColors } from '../diary/diaryHelper'; import { useTranslation } from 'react-i18next'; +import { GroupingField, MetricName } from '../types/appConfigTypes'; +import { useImperialConfig } from '../config/useImperialConfig'; type Props = { + metricName: MetricName; + groupingFields: GroupingField[]; cardTitle: string; userMetricsDays?: DayOfMetricData[]; aggMetricsDays?: DayOfMetricData[]; - axisUnits: string; - unitFormatFn?: (val: number) => string | number; }; const MetricsCard = ({ + metricName, + groupingFields, cardTitle, userMetricsDays, aggMetricsDays, - axisUnits, - unitFormatFn, }: Props) => { const { colors } = useTheme(); const { t } = useTranslation(); + const imperialConfig = useImperialConfig(); const [viewMode, setViewMode] = useState<'details' | 'graph'>('details'); const [populationMode, setPopulationMode] = useState<'user' | 'aggregate'>('user'); const [graphIsStacked, setGraphIsStacked] = useState(true); @@ -41,6 +45,11 @@ const MetricsCard = ({ [populationMode, userMetricsDays, aggMetricsDays], ); + const [axisUnits, unitConvertFn, unitDisplayFn] = useMemo( + () => getUnitUtilsForMetric(metricName, imperialConfig), + [metricName], + ); + // for each label on each day, create a record for the chart const chartData = useMemo(() => { if (!metricDataDays || viewMode != 'graph') return []; @@ -48,11 +57,11 @@ const MetricsCard = ({ metricDataDays.forEach((day) => { const labels = getLabelsForDay(day); labels.forEach((label) => { - const rawVal = valueForModeOnDay(day, label); + const rawVal = valueForFieldOnDay(day, groupingFields[0], label); if (rawVal) { records.push({ label: labelKeyToRichMode(label), - x: unitFormatFn ? unitFormatFn(rawVal) : rawVal, + x: unitConvertFn(rawVal), y: tsForDayOfMetricData(day) * 1000, // time (as milliseconds) will go on Y axis because it will be a horizontal chart }); } @@ -82,11 +91,22 @@ const MetricsCard = ({ // for each label, sum up cumulative values across all days const vals = {}; uniqueLabels.forEach((label) => { - const sum = metricDataDays.reduce( - (acc, day) => acc + (valueForModeOnDay(day, label) || 0), - 0, - ); - vals[label] = unitFormatFn ? unitFormatFn(sum) : sum; + const sum: any = metricDataDays.reduce((acc, day) => { + const val = valueForFieldOnDay(day, groupingFields[0], label); + // if val is number, add it to the accumulator + if (!isNaN(val)) { + return acc + val; + } else if (val && typeof val == 'object') { + // if val is object, add its values to the accumulator's values + acc = acc || {}; + for (let key in val) { + acc[key] = (acc[key] || 0) + val[key]; + } + return acc; + } + return acc; + }, 0); + vals[label] = unitDisplayFn(sum); }); return vals; }, [metricDataDays, viewMode]); @@ -102,7 +122,7 @@ const MetricsCard = ({ }; return ( - + - {viewMode == 'details' && ( - - {Object.keys(metricSumValues).map((label, i) => ( - - {labelKeyToRichMode(label)} - {metricSumValues[label] + ' ' + axisUnits} - - ))} - - )} - {viewMode == 'graph' && ( - <> - - - Stack bars: - setGraphIsStacked(!graphIsStacked)} - /> + {viewMode == 'details' && + (Object.keys(metricSumValues).length ? ( + + {Object.keys(metricSumValues).map((label, i) => ( + + {labelKeyToRichMode(label)} + {metricSumValues[label]} + + ))} - - )} + ) : ( + + {t('metrics.chart-no-data')} + + ))} + {viewMode == 'graph' && + (chartData.length ? ( + <> + + + Stack bars: + setGraphIsStacked(!graphIsStacked)} + /> + + + ) : ( + + {t('metrics.chart-no-data')} + + ))} ); diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 91b444946..6393dc518 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -1,150 +1,155 @@ import React, { useEffect, useState, useMemo, useContext } from 'react'; -import { View, ScrollView, useWindowDimensions } from 'react-native'; +import { ScrollView, useWindowDimensions } from 'react-native'; import { Appbar, useTheme } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; import { DateTime } from 'luxon'; import NavBar from '../components/NavBar'; import { MetricsData } from './metricsTypes'; import MetricsCard from './MetricsCard'; -import { formatForDisplay, useImperialConfig } from '../config/useImperialConfig'; import WeeklyActiveMinutesCard from './WeeklyActiveMinutesCard'; -import { secondsToHours, secondsToMinutes } from './metricsHelper'; import CarbonFootprintCard from './CarbonFootprintCard'; import Carousel from '../components/Carousel'; import DailyActiveMinutesCard from './DailyActiveMinutesCard'; import CarbonTextCard from './CarbonTextCard'; import ActiveMinutesTableCard from './ActiveMinutesTableCard'; -import { getAggregateData, getMetrics } from '../services/commHelper'; -import { displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; +import { getAggregateData } from '../services/commHelper'; +import { displayError, displayErrorMsg, logDebug } from '../plugin/logger'; import useAppConfig from '../useAppConfig'; -import { ServerConnConfig } from '../types/appConfigTypes'; +import { + AppConfig, + GroupingField, + MetricName, + MetricList, + MetricsUiSection, +} from '../types/appConfigTypes'; import DateSelect from '../diary/list/DateSelect'; -import TimelineContext from '../TimelineContext'; -import { isoDateRangeToTsRange, isoDatesDifference } from '../diary/timelineHelper'; +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'; // 2 weeks of data is needed in order to compare "past week" vs "previous week" const N_DAYS_TO_LOAD = 14; // 2 weeks -export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; +const DEFAULT_SECTIONS_TO_SHOW: MetricsUiSection[] = [ + 'footprint', + 'active_travel', + 'summary', +] as const; +export const DEFAULT_METRIC_LIST: MetricList = { + distance: ['mode_confirm'], + duration: ['mode_confirm'], + count: ['mode_confirm'], +}; + +async function computeUserMetrics( + metricList: MetricList, + timelineMap: TimelineMap, + timelineLabelMap: TimelineLabelMap | null, + appConfig: AppConfig, +) { + try { + const timelineValues = [...timelineMap.values()]; + const result = metrics_summaries.generate_summaries( + { ...metricList }, + timelineValues, + appConfig, + timelineLabelMap, + ); + logDebug('MetricsTab: computed userMetrics'); + console.debug('MetricsTab: computed userMetrics', result); + return result as MetricsData; + } catch (e) { + displayError(e, 'Error computing user metrics'); + } +} -async function fetchMetricsFromServer( - type: 'user' | 'aggregate', +async function fetchAggMetrics( + metricList: MetricList, dateRange: [string, string], - serverConnConfig: ServerConnConfig, + appConfig: AppConfig, ) { - const [startTs, endTs] = isoDateRangeToTsRange(dateRange); - logDebug('MetricsTab: fetching metrics from server for ts range ' + startTs + ' to ' + endTs); + logDebug('MetricsTab: fetching agg metrics from server for dateRange ' + dateRange); const query = { freq: 'D', - start_time: startTs, - end_time: endTs, - metric_list: METRIC_LIST, - is_return_aggregate: type == 'aggregate', + start_time: dateRange[0], + end_time: dateRange[1], + metric_list: metricList, + is_return_aggregate: true, + app_config: { survey_info: appConfig.survey_info }, }; - if (type == 'user') return getMetrics('timestamp', query); - return getAggregateData('result/metrics/timestamp', query, serverConnConfig); + return getAggregateData('result/metrics/yyyy_mm_dd', query, appConfig.server) + .then((response) => { + logDebug('MetricsTab: received aggMetrics'); + console.debug('MetricsTab: received aggMetrics', response); + return response as MetricsData; + }) + .catch((e) => { + displayError(e, 'Error fetching aggregate metrics'); + return undefined; + }); } const MetricsTab = () => { const appConfig = useAppConfig(); - const { colors } = useTheme(); const { t } = useTranslation(); - const { getFormattedSpeed, speedSuffix, getFormattedDistance, distanceSuffix } = - useImperialConfig(); const { dateRange, - setDateRange, timelineMap, timelineLabelMap, timelineIsLoading, refreshTimeline, loadMoreDays, + loadDateRange, } = useContext(TimelineContext); - const [aggMetrics, setAggMetrics] = useState(undefined); + const metricList = appConfig?.metrics?.phone_dashboard_ui?.metric_list ?? DEFAULT_METRIC_LIST; - // user metrics are computed on the phone from the timeline data - const userMetrics = useMemo(() => { - console.time('MetricsTab: generate_summaries'); - if (!timelineMap) return; - console.time('MetricsTab: timelineMap.values()'); - const timelineValues = [...timelineMap.values()]; - console.timeEnd('MetricsTab: timelineMap.values()'); - const result = metrics_summaries.generate_summaries( - METRIC_LIST, - timelineValues, - timelineLabelMap, - ) as MetricsData; - console.timeEnd('MetricsTab: generate_summaries'); - return result; - }, [timelineMap]); + const [userMetrics, setUserMetrics] = useState(undefined); + const [aggMetrics, setAggMetrics] = useState(undefined); + const [aggMetricsIsLoading, setAggMetricsIsLoading] = useState(false); - // at least N_DAYS_TO_LOAD of timeline data should be loaded for the user metrics - useEffect(() => { - if (!appConfig?.server) return; + const readyToLoad = useMemo(() => { + if (!appConfig) return false; const dateRangeDays = isoDatesDifference(...dateRange); - - // this tab uses the last N_DAYS_TO_LOAD of data; if we need more, we should fetch it if (dateRangeDays < N_DAYS_TO_LOAD) { - if (timelineIsLoading) { - logDebug('MetricsTab: timeline is still loading, not loading more days yet'); - } else { - logDebug('MetricsTab: loading more days'); - loadMoreDays('past', N_DAYS_TO_LOAD - dateRangeDays); - } - } else { - logDebug(`MetricsTab: date range >= ${N_DAYS_TO_LOAD} days, not loading more days`); + 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'); } - }, [dateRange, timelineIsLoading, appConfig?.server]); + return true; + }, [appConfig, dateRange]); - // aggregate metrics fetched from the server whenever the date range is set useEffect(() => { - logDebug('MetricsTab: dateRange updated to ' + JSON.stringify(dateRange)); - const dateRangeDays = isoDatesDifference(...dateRange); - if (dateRangeDays < N_DAYS_TO_LOAD) { - logDebug( - `MetricsTab: date range < ${N_DAYS_TO_LOAD} days, not loading aggregate metrics yet`, - ); - } else { - loadMetricsForPopulation('aggregate', dateRange); - } - }, [dateRange]); + if (!readyToLoad || !appConfig || timelineIsLoading || !timelineMap || !timelineLabelMap) + return; + logDebug('MetricsTab: ready to compute userMetrics'); + computeUserMetrics(metricList, timelineMap, timelineLabelMap, appConfig).then((result) => + setUserMetrics(result), + ); + }, [readyToLoad, appConfig, timelineIsLoading, timelineMap, timelineLabelMap]); - async function loadMetricsForPopulation( - population: 'user' | 'aggregate', - dateRange: [string, string], - ) { - try { - logDebug(`MetricsTab: fetching metrics for population ${population}' - in date range ${JSON.stringify(dateRange)}`); - const serverResponse: any = await fetchMetricsFromServer( - population, - dateRange, - appConfig.server, - ); - logDebug('MetricsTab: received metrics: ' + JSON.stringify(serverResponse)); - const metrics = {}; - const dataKey = population == 'user' ? 'user_metrics' : 'aggregate_metrics'; - METRIC_LIST.forEach((metricName, i) => { - metrics[metricName] = serverResponse[dataKey][i]; - }); - logDebug('MetricsTab: parsed metrics: ' + JSON.stringify(metrics)); - if (population == 'user') { - // setUserMetrics(metrics as MetricsData); - } else { - setAggMetrics(metrics as MetricsData); - } - } catch (e) { - logWarn(e + t('errors.while-loading-metrics')); // replace with displayErr - } - } + useEffect(() => { + if (!readyToLoad || !appConfig) return; + logDebug('MetricsTab: ready to fetch aggMetrics'); + setAggMetricsIsLoading(true); + fetchAggMetrics(metricList, dateRange, appConfig).then((response) => { + setAggMetricsIsLoading(false); + setAggMetrics(response); + }); + }, [readyToLoad, appConfig, dateRange]); + const sectionsToShow = + appConfig?.metrics?.phone_dashboard_ui?.sections || DEFAULT_SECTIONS_TO_SHOW; const { width: windowWidth } = useWindowDimensions(); const cardWidth = windowWidth * 0.88; + const studyStartDate = `${appConfig?.intro.start_month} / ${appConfig?.intro.start_year}`; return ( <> - + { const start = DateTime.fromJSDate(startDate).toISODate(); const end = DateTime.fromJSDate(endDate).toISODate(); if (!start || !end) return displayErrorMsg('Invalid date'); - setDateRange([start, end]); + loadDateRange([start, end]); }} /> - - - - - - - - - - - - - - {/* */} - + {sectionsToShow.includes('footprint') && ( + + + + + )} + {sectionsToShow.includes('active_travel') && ( + + + + + + )} + {sectionsToShow.includes('summary') && ( + + {Object.entries(metricList).map( + ([metricName, groupingFields]: [MetricName, GroupingField[]]) => { + return ( + + ); + }, + )} + + )} + {sectionsToShow.includes('surveys') && ( + + + + + )} + {/* we will implement leaderboard later */} + {/* {sectionsToShow.includes('engagement') && ( + + + + )} */} ); diff --git a/www/js/metrics/SurveyComparisonCard.tsx b/www/js/metrics/SurveyComparisonCard.tsx new file mode 100644 index 000000000..a99a604eb --- /dev/null +++ b/www/js/metrics/SurveyComparisonCard.tsx @@ -0,0 +1,183 @@ +import React, { useMemo } from 'react'; +import { View } from 'react-native'; +import { Icon, Card, Text } from 'react-native-paper'; +import { useTranslation } from 'react-i18next'; +import { useAppTheme } from '../appTheme'; +import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'; +import { Doughnut } from 'react-chartjs-2'; +import { cardStyles } from './MetricsTab'; +import { DayOfMetricData, MetricsData } from './metricsTypes'; +import { getUniqueLabelsForDays } from './metricsHelper'; +ChartJS.register(ArcElement, Tooltip, Legend); + +/** + * @description Calculates the percentage of 'responded' values across days of 'response_count' data. + * @returns Percentage as a whole number (0-100), or null if no data. + */ +function getResponsePctForDays(days: DayOfMetricData<'response_count'>[]) { + const surveys = getUniqueLabelsForDays(days); + let acc = { responded: 0, not_responded: 0 }; + days.forEach((day) => { + surveys.forEach((survey) => { + acc.responded += day[`survey_${survey}`]?.responded || 0; + acc.not_responded += day[`survey_${survey}`]?.not_responded || 0; + }); + }); + const total = acc.responded + acc.not_responded; + if (total === 0) return null; + return Math.round((acc.responded / total) * 100); +} + +type Props = { + userMetrics: MetricsData | undefined; + aggMetrics: MetricsData | undefined; +}; + +export type SurveyComparison = { + me: number; + others: number; +}; + +export const LabelPanel = ({ first, second }) => { + const { colors } = useAppTheme(); + + return ( + + + + {first} + + + + {second} + + + ); +}; + +const SurveyComparisonCard = ({ userMetrics, aggMetrics }: Props) => { + const { colors } = useAppTheme(); + const { t } = useTranslation(); + + const myResponsePct = useMemo(() => { + if (!userMetrics?.response_count) return; + return getResponsePctForDays(userMetrics.response_count); + }, [userMetrics]); + + const othersResponsePct = useMemo(() => { + if (!aggMetrics?.response_count) return; + return getResponsePctForDays(aggMetrics.response_count); + }, [aggMetrics]); + + const renderDoughnutChart = (rate, chartColor, myResponse) => { + const data = { + datasets: [ + { + data: [rate, 100 - rate], + backgroundColor: [chartColor, colors.silver], + borderColor: [chartColor, colors.silver], + borderWidth: 1, + }, + ], + }; + return ( + + + {myResponse ? ( + + ) : ( + + )} + {rate === null ? t('metrics.no-data') : rate + '%'} + + + + ); + }; + + return ( + + + + {typeof myResponsePct !== 'number' || typeof othersResponsePct !== 'number' ? ( + + {t('metrics.chart-no-data')} + + ) : ( + + {t('main-metrics.survey-response-rate')} + + {renderDoughnutChart(myResponsePct, colors.navy, true)} + {renderDoughnutChart(othersResponsePct, colors.orange, false)} + + + + )} + + + ); +}; + +const styles: any = { + chartTitle: { + alignSelf: 'center', + fontWeight: 'bold', + fontSize: 14, + marginBottom: 10, + }, + statusTextWrapper: { + alignSelf: 'center', + display: 'flex', + flexDirection: 'row', + fontSize: 16, + }, + chartWrapper: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-around', + }, + textWrapper: { + position: 'absolute', + width: 140, + height: 140, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + labelWrapper: { + alignSelf: 'center', + display: 'flex', + gap: 10, + marginTop: 10, + }, + labelItem: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, +}; + +export default SurveyComparisonCard; diff --git a/www/js/metrics/SurveyLeaderboardCard.tsx b/www/js/metrics/SurveyLeaderboardCard.tsx new file mode 100644 index 000000000..34341616d --- /dev/null +++ b/www/js/metrics/SurveyLeaderboardCard.tsx @@ -0,0 +1,128 @@ +import React, { useMemo } from 'react'; +import { View, Text } from 'react-native'; +import { Card } from 'react-native-paper'; +import { cardStyles, SurveyMetric, SurveyObject } from './MetricsTab'; +import { useTranslation } from 'react-i18next'; +import BarChart from '../components/BarChart'; +import { useAppTheme } from '../appTheme'; +import { Chart as ChartJS, registerables } from 'chart.js'; +import Annotation from 'chartjs-plugin-annotation'; + +ChartJS.register(...registerables, Annotation); + +type Props = { + studyStartDate: string; + surveyMetric: SurveyMetric; +}; + +type LeaderboardRecord = { + label: string; + x: number; + y: string; +}; + +const SurveyLeaderboardCard = ({ studyStartDate, surveyMetric }: Props) => { + const { colors } = useAppTheme(); + const { t } = useTranslation(); + + const myRank = surveyMetric.me.rank; + const mySurveyMetric = surveyMetric.me.overview; + + function getLabel(rank: number): string { + if (rank === 0) { + return '🏆 #1:'; + } else if (rank === 1) { + return '🥈 #2:'; + } else if (rank === 2) { + return '🥉 #3:'; + } else { + return `#${rank + 1}:`; + } + } + + const leaderboardRecords: LeaderboardRecord[] = useMemo(() => { + const combinedLeaderboard: SurveyObject[] = [...surveyMetric.others.leaderboard]; + combinedLeaderboard.splice(myRank, 0, mySurveyMetric); + + // This is to prevent the leaderboard from being too long for UX purposes. + // For a total of 20 members, we only show the top 5 members, myself, and the bottom 3 members. + const numberOfTopUsers = 5; + const numberOfBottomUsers = surveyMetric.others.leaderboard.length - 3; + + return combinedLeaderboard + .map((item, idx) => ({ + isMe: idx === myRank, + rank: idx, + answered: item.answered, + unanswered: item.unanswered, + mismatched: item.mismatched, + })) + .filter( + (item) => item.isMe || item.rank < numberOfTopUsers || item.rank >= numberOfBottomUsers, + ) + .map((item) => ({ + label: item.isMe ? `${item.rank}-me` : `${item.rank}-other`, + x: Math.round((item.answered / (item.answered + item.unanswered)) * 100), + y: getLabel(item.rank), + })); + }, [surveyMetric]); + + return ( + + + + + + * {t('main-metrics.survey-leaderboard-desc')} + {studyStartDate} + + {t('main-metrics.survey-response-rate')} + (l === `${myRank}-me` ? colors.skyblue : colors.silver)} + getColorForChartEl={(l) => (l === `${myRank}-me` ? colors.skyblue : colors.silver)} + showLegend={false} + reverse={false} + enableTooltip={false} + /> + + {t('main-metrics.you-are-in')} + #{myRank + 1} + {t('main-metrics.place')} + + + + + ); +}; + +const styles: any = { + chartTitle: { + alignSelf: 'center', + fontWeight: 'bold', + fontSize: 14, + }, + chartDesc: { + fontSoze: 12, + marginBottom: 10, + }, + statusTextWrapper: { + alignSelf: 'center', + display: 'flex', + flexDirection: 'row', + fontSize: 16, + }, +}; + +export default SurveyLeaderboardCard; diff --git a/www/js/metrics/SurveyTripCategoriesCard.tsx b/www/js/metrics/SurveyTripCategoriesCard.tsx new file mode 100644 index 000000000..77df43abf --- /dev/null +++ b/www/js/metrics/SurveyTripCategoriesCard.tsx @@ -0,0 +1,90 @@ +import React, { useMemo } from 'react'; +import { Text, Card } from 'react-native-paper'; +import { cardStyles } from './MetricsTab'; +import { useTranslation } from 'react-i18next'; +import BarChart from '../components/BarChart'; +import { useAppTheme } from '../appTheme'; +import { LabelPanel } from './SurveyComparisonCard'; +import { DayOfMetricData, MetricsData } from './metricsTypes'; +import { GroupingField } from '../types/appConfigTypes'; +import { getUniqueLabelsForDays } from './metricsHelper'; + +function sumResponseCountsForValue( + days: DayOfMetricData<'response_count'>[], + value: `${GroupingField}_${string}`, +) { + const acc = { responded: 0, not_responded: 0 }; + days.forEach((day) => { + acc.responded += day[value]?.responded || 0; + acc.not_responded += day[value]?.not_responded || 0; + }); + return acc; +} + +type SurveyTripRecord = { + label: string; + x: string; + y: number; +}; + +type Props = { + userMetrics: MetricsData | undefined; + aggMetrics: MetricsData | undefined; +}; +const SurveyTripCategoriesCard = ({ userMetrics, aggMetrics }: Props) => { + const { colors } = useAppTheme(); + const { t } = useTranslation(); + + const records = useMemo(() => { + if (!userMetrics?.response_count) return []; + const surveys = getUniqueLabelsForDays(userMetrics.response_count); + const records: SurveyTripRecord[] = []; + surveys.forEach((survey) => { + const { responded, not_responded } = sumResponseCountsForValue( + userMetrics.response_count, + `survey_${survey}`, + ); + records.push({ label: 'Response', x: survey, y: responded || 0 }); + records.push({ label: 'No Response', x: survey, y: not_responded || 0 }); + }); + return records; + }, [userMetrics]); + + return ( + + + + {records.length ? ( + <> + (l === 'Response' ? colors.navy : colors.orange)} + getColorForChartEl={(l) => (l === 'Response' ? colors.navy : colors.orange)} + showLegend={false} + reverse={false} + maxBarThickness={60} + /> + + + ) : ( + + {t('metrics.chart-no-data')} + + )} + + + ); +}; + +export default SurveyTripCategoriesCard; diff --git a/www/js/metrics/WeeklyActiveMinutesCard.tsx b/www/js/metrics/WeeklyActiveMinutesCard.tsx index 5078f2cfc..4201f993e 100644 --- a/www/js/metrics/WeeklyActiveMinutesCard.tsx +++ b/www/js/metrics/WeeklyActiveMinutesCard.tsx @@ -3,12 +3,13 @@ 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, valueForModeOnDay } from './metricsHelper'; +import { formatDateRangeOfDays, segmentDaysByWeeks, valueForFieldOnDay } from './metricsHelper'; import { useTranslation } from 'react-i18next'; import BarChart from '../components/BarChart'; import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper'; import { getBaseModeByText } from '../diary/diaryHelper'; import TimelineContext from '../TimelineContext'; +import useAppConfig from '../useAppConfig'; export const ACTIVE_MODES = ['walk', 'bike'] as const; type ActiveMode = (typeof ACTIVE_MODES)[number]; @@ -18,22 +19,25 @@ 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]); - ACTIVE_MODES.forEach((mode) => { + activeModes.forEach((mode) => { if (prevWeek) { const prevSum = prevWeek?.reduce( - (acc, day) => acc + (valueForModeOnDay(day, mode) || 0), + (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 + (valueForModeOnDay(day, mode) || 0), + (acc, day) => acc + (valueForFieldOnDay(day, 'mode_confirm', mode) || 0), 0, ); const xLabel = `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(recentWeek)})`; @@ -72,11 +76,9 @@ const WeeklyActiveMinutesCard = ({ userMetrics }: Props) => { ) : ( - - - {t('metrics.chart-no-data')} - - + + {t('metrics.chart-no-data')} + )} diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index 8097e460f..65337690b 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -1,44 +1,55 @@ import { DateTime } from 'luxon'; -import { formatForDisplay } from '../config/useImperialConfig'; import { DayOfMetricData } from './metricsTypes'; import { logDebug } from '../plugin/logger'; import { isoDateWithOffset, isoDatesDifference } from '../diary/timelineHelper'; +import { MetricName, groupingFields } from '../types/appConfigTypes'; +import { ImperialConfig, formatForDisplay } from '../config/useImperialConfig'; +import i18next from 'i18next'; export function getUniqueLabelsForDays(metricDataDays: DayOfMetricData[]) { const uniqueLabels: string[] = []; metricDataDays.forEach((e) => { Object.keys(e).forEach((k) => { - if (k.startsWith('label_') || k.startsWith('mode_')) { - let i = k.indexOf('_'); - const label = k.substring(i + 1); // remove prefix leaving just the mode label - if (!uniqueLabels.includes(label)) uniqueLabels.push(label); + const trimmed = trimGroupingPrefix(k); + if (trimmed && !uniqueLabels.includes(trimmed)) { + uniqueLabels.push(trimmed); } }); }); return uniqueLabels; } +/** + * @description Trims the "grouping field" prefix from a metrics key. Grouping fields are defined in appConfigTypes.ts + * @example removeGroupingPrefix('mode_purpose_access_recreation') => 'access_recreation' + * @example removeGroupingPrefix('primary_ble_sensed_mode_CAR') => 'CAR' + * @returns The key without the prefix (or undefined if the key didn't start with a grouping field) + */ +export const trimGroupingPrefix = (label: string) => { + for (let field of groupingFields) { + if (label.startsWith(field)) { + return label.substring(field.length + 1); + } + } +}; + export const getLabelsForDay = (metricDataDay: DayOfMetricData) => Object.keys(metricDataDay).reduce((acc, k) => { - if (k.startsWith('label_') || k.startsWith('mode_')) { - let i = k.indexOf('_'); - acc.push(k.substring(i + 1)); // remove prefix leaving just the mode label - } + const trimmed = trimGroupingPrefix(k); + if (trimmed) acc.push(trimmed); return acc; }, [] as string[]); -export const secondsToMinutes = (seconds: number) => formatForDisplay(seconds / 60); - -export const secondsToHours = (seconds: number) => formatForDisplay(seconds / 3600); +export const secondsToMinutes = (seconds: number) => seconds / 60; +export const secondsToHours = (seconds: number) => seconds / 3600; // segments metricsDays into weeks, with the most recent week first export function segmentDaysByWeeks(days: DayOfMetricData[], lastDate: string) { const weeks: DayOfMetricData[][] = [[]]; let cutoff = isoDateWithOffset(lastDate, -7 * weeks.length); for (let i = days.length - 1; i >= 0; i--) { - const date = dateForDayOfMetricData(days[i]); // if date is older than cutoff, start a new week - if (isoDatesDifference(date, cutoff) > 0) { + if (isoDatesDifference(days[i].date, cutoff) > 0) { weeks.push([]); cutoff = isoDateWithOffset(lastDate, -7 * weeks.length); } @@ -48,18 +59,14 @@ export function segmentDaysByWeeks(days: DayOfMetricData[], lastDate: string) { } export function formatDate(day: DayOfMetricData) { - const dt = DateTime.fromISO(dateForDayOfMetricData(day), { zone: 'utc' }); + const dt = DateTime.fromISO(day.date, { zone: 'utc' }); return dt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined }); } export function formatDateRangeOfDays(days: DayOfMetricData[]) { if (!days?.length) return ''; - const firstDayDt = DateTime.fromISO(dateForDayOfMetricData(days[0]), { - zone: 'utc', - }); - const lastDayDt = DateTime.fromISO(dateForDayOfMetricData(days[days.length - 1]), { - zone: 'utc', - }); + const firstDayDt = DateTime.fromISO(days[0].date, { zone: 'utc' }); + const lastDayDt = DateTime.fromISO(days[days.length - 1].date, { zone: 'utc' }); const firstDay = firstDayDt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined }); const lastDay = lastDayDt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined }); return `${firstDay} - ${lastDay}`; @@ -68,7 +75,7 @@ export function formatDateRangeOfDays(days: DayOfMetricData[]) { /* formatting data form carbon footprint calculations */ //modes considered on foot for carbon calculation, expandable as needed -const ON_FOOT_MODES = ['WALKING', 'RUNNING', 'ON_FOOT'] as const; +export const ON_FOOT_MODES = ['WALKING', 'RUNNING', 'ON_FOOT'] as const; /* * metric2val is a function that takes a metric entry and a field and returns @@ -76,13 +83,13 @@ const ON_FOOT_MODES = ['WALKING', 'RUNNING', 'ON_FOOT'] as const; * for regular data (user-specific), this will return the field value * for avg data (aggregate), this will return the field value/nUsers */ -const metricToValue = (population: 'user' | 'aggregate', metric, field) => +export const metricToValue = (population: 'user' | 'aggregate', metric, field) => population == 'user' ? metric[field] : metric[field] / metric.nUsers; //testing agains global list of what is "on foot" //returns true | false -function isOnFoot(mode: string) { - for (let ped_mode in ON_FOOT_MODES) { +export function isOnFoot(mode: string) { + for (let ped_mode of ON_FOOT_MODES) { if (mode === ped_mode) { return true; } @@ -127,15 +134,13 @@ export function parseDataFromMetrics(metrics, population) { ]); } } - //this section handles user lables, assuming 'label_' prefix - if (field.startsWith('label_') || field.startsWith('mode_')) { - let i = field.indexOf('_'); - let actualMode = field.substring(i + 1); // remove prefix - logDebug('Mapped field ' + field + ' to mode ' + actualMode); - if (!(actualMode in mode_bins)) { - mode_bins[actualMode] = []; + const trimmedField = trimGroupingPrefix(field); + if (trimmedField) { + logDebug('Mapped field ' + field + ' to mode ' + trimmedField); + if (!(trimmedField in mode_bins)) { + mode_bins[trimmedField] = []; } - mode_bins[actualMode].push([ + mode_bins[trimmedField].push([ metric.ts, Math.round(metricToValue(population, metric, field)), DateTime.fromISO(metric.fmt_time).toISO() as string, @@ -151,14 +156,15 @@ export function parseDataFromMetrics(metrics, population) { return Object.entries(mode_bins).map(([key, values]) => ({ key, values })); } -export const dateForDayOfMetricData = (day: DayOfMetricData) => - 'date' in day ? day.date : day.fmt_time.substring(0, 10); - -export const tsForDayOfMetricData = (day: DayOfMetricData) => - DateTime.fromISO(dateForDayOfMetricData(day)).toSeconds(); +const _datesTsCache = {}; +export const tsForDayOfMetricData = (day: DayOfMetricData) => { + if (_datesTsCache[day.date] == undefined) + _datesTsCache[day.date] = DateTime.fromISO(day.date).toSeconds(); + return _datesTsCache[day.date]; +}; -export const valueForModeOnDay = (day: DayOfMetricData, key: string) => - day[`mode_${key}`] || day[`label_${key}`]; +export const valueForFieldOnDay = (day: DayOfMetricData, field: string, key: string) => + day[`${field}_${key}`]; export type MetricsSummary = { key: string; values: number }; export function generateSummaryFromData(modeMap, metric) { @@ -209,7 +215,7 @@ export function isCustomLabels(modeMap) { return isAllCustom(metricSummaryChecksSensed, metricSummaryChecksCustom); } -function isAllCustom(isSensedKeys, isCustomKeys) { +export function isAllCustom(isSensedKeys, isCustomKeys) { const allSensed = isSensedKeys.reduce((a, b) => a && b, true); const anySensed = isSensedKeys.reduce((a, b) => a || b, false); const allCustom = isCustomKeys.reduce((a, b) => a && b, true); @@ -224,3 +230,35 @@ function isAllCustom(isSensedKeys, isCustomKeys) { // "Please report to your program admin"); return undefined; } + +// [unit suffix, unit conversion function, unit display function] +// e.g. ['hours', (seconds) => seconds/3600, (seconds) => seconds/3600 + ' hours'] +type UnitUtils = [string, (v) => number, (v) => string]; +export function getUnitUtilsForMetric( + metricName: MetricName, + imperialConfig: ImperialConfig, +): UnitUtils { + const fns: { [k in MetricName]: UnitUtils } = { + distance: [ + imperialConfig.distanceSuffix, + (x) => imperialConfig.convertDistance(x), + (x) => imperialConfig.getFormattedDistance(x) + ' ' + imperialConfig.distanceSuffix, + ], + duration: [ + i18next.t('metrics.hours'), + (v) => secondsToHours(v), + (v) => formatForDisplay(secondsToHours(v)) + ' ' + i18next.t('metrics.hours'), + ], + count: [i18next.t('metrics.trips'), (v) => v, (v) => v + ' ' + i18next.t('metrics.trips')], + response_count: [ + i18next.t('metrics.responses'), + (v) => v.responded || 0, + (v) => { + const responded = v.responded || 0; + const total = responded + (v.not_responded || 0); + return `${responded}/${total} ${i18next.t('metrics.responses')}`; + }, + ], + }; + return fns[metricName]; +} diff --git a/www/js/metrics/metricsTypes.ts b/www/js/metrics/metricsTypes.ts index 826d7ec70..d6105c30a 100644 --- a/www/js/metrics/metricsTypes.ts +++ b/www/js/metrics/metricsTypes.ts @@ -1,23 +1,21 @@ -import { LocalDt } from '../types/serverData'; -import { METRIC_LIST } from './MetricsTab'; +import { GroupingField, MetricName } from '../types/appConfigTypes'; -type MetricName = (typeof METRIC_LIST)[number]; +// 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; -type LabelProps = { [k in `label_${string}`]?: number }; // label_, where could be anything -export type DayOfServerMetricData = LabelProps & { - ts: number; - fmt_time: string; - nUsers: number; - local_dt: LocalDt; -}; - -type ModeProps = { [k in `mode_${string}`]?: number }; // mode_, where could be anything -export type DayOfClientMetricData = ModeProps & { +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; }; -export type DayOfMetricData = DayOfClientMetricData | DayOfServerMetricData; - export type MetricsData = { - [key in MetricName]: DayOfMetricData[]; + [key in MetricName]: DayOfMetricData[]; }; diff --git a/www/js/services/commHelper.ts b/www/js/services/commHelper.ts index ec2ee9d97..75fdbf8de 100644 --- a/www/js/services/commHelper.ts +++ b/www/js/services/commHelper.ts @@ -136,8 +136,13 @@ export function getMetrics(timeType: 'timestamp' | 'local_date', metricsQuery) { }); } -export function getAggregateData(path: string, query, serverConnConfig: ServerConnConfig) { +export function getAggregateData(path: string, query, serverConnConfig?: ServerConnConfig) { return new Promise((rs, rj) => { + // when app config does not have "server", localhost is used and no user authentication is required + serverConnConfig ||= { + connectUrl: 'http://localhost:8080' as any, + aggregate_call_auth: 'no_auth', + }; const fullUrl = `${serverConnConfig.connectUrl}/${path}`; query['aggregate'] = true; diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index bc3a2b717..79695918a 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -111,6 +111,10 @@ export const getBaseLabelInputs = () => Object.keys(baseLabelInputDetails) as Mu /** @description replace all underscores with spaces, and capitalizes the first letter of each word */ export function labelKeyToReadable(otherValue: string) { + if (otherValue == otherValue.toUpperCase()) { + // if all caps, make lowercase + otherValue = otherValue.toLowerCase(); + } const words = otherValue.replace(/_/g, ' ').trim().split(' '); if (words.length == 0) return ''; return words.map((word) => word[0].toUpperCase() + word.slice(1)).join(' '); diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts index e58b679f5..87a4b4e85 100644 --- a/www/js/types/appConfigTypes.ts +++ b/www/js/types/appConfigTypes.ts @@ -14,6 +14,7 @@ export type AppConfig = { tracking?: { bluetooth_only: boolean; }; + metrics: MetricsConfig; reminderSchemes?: ReminderSchemesConfig; [k: string]: any; // TODO fill in all the other fields }; @@ -92,3 +93,34 @@ export type ReminderSchemesConfig = { defaultTime?: string; // format is HH:MM in 24 hour time }; }; + +// the available metrics that can be displayed in the phone dashboard +export type MetricName = 'distance' | 'count' | 'duration' | 'response_count'; +// the available trip / userinput properties that can be used to group the metrics +export const groupingFields = [ + 'mode_confirm', + 'purpose_confirm', + 'replaced_mode_confirm', + 'primary_ble_sensed_mode', + 'survey', +] 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 MetricsConfig = { + include_test_users: boolean; + phone_dashboard_ui?: { + sections: MetricsUiSection[]; + metric_list: MetricList; + footprint_options?: { + unlabeled_uncertainty: boolean; + }; + summary_options?: {}; + engagement_options?: { + leaderboard_metric: [string, string]; + }; + active_travel_options?: { + modes_list: string[]; + }; + }; +};