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[];
+ };
+ };
+};