diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts
index 60ea4e0c1..eac078058 100644
--- a/www/__mocks__/cordovaMocks.ts
+++ b/www/__mocks__/cordovaMocks.ts
@@ -178,7 +178,7 @@ export const mockBEMUserCache = (config?) => {
}, 100),
);
} else {
- return undefined;
+ return Promise.resolve([]);
}
},
};
@@ -229,6 +229,8 @@ export const mockBEMServerCom = () => {
}, 100);
},
};
+ window['cordova'] ||= {};
+ window['cordova'].plugins ||= {};
window['cordova'].plugins.BEMServerComm = mockBEMServerCom;
};
diff --git a/www/__tests__/TimelineContext.test.tsx b/www/__tests__/TimelineContext.test.tsx
new file mode 100644
index 000000000..49b62315f
--- /dev/null
+++ b/www/__tests__/TimelineContext.test.tsx
@@ -0,0 +1,74 @@
+import React, { useEffect } from 'react';
+import { View, Text } from 'react-native';
+import { act, render, screen, waitFor } from '@testing-library/react-native';
+import { useTimelineContext } from '../js/TimelineContext';
+import { mockLogger } from '../__mocks__/globalMocks';
+import { mockBEMServerCom, mockBEMUserCache } from '../__mocks__/cordovaMocks';
+
+mockLogger();
+mockBEMUserCache();
+
+jest.mock('../js/services/commHelper', () => ({
+ getPipelineRangeTs: jest.fn(() => Promise.resolve({ start_ts: 1, end_ts: 10 })),
+ getRawEntries: jest.fn((key_list, _, __) => {
+ let phone_data: any[] = [];
+ if (key_list.includes('analysis/composite_trip')) {
+ phone_data = [
+ {
+ _id: { $oid: 'trip1' },
+ metadata: { write_ts: 1, origin_key: 'analysis/confirmed_trip' },
+ data: { start_ts: 1, end_ts: 2 },
+ },
+ {
+ _id: { $oid: 'trip2' },
+ metadata: { write_ts: 2, origin_key: 'analysis/confirmed_trip' },
+ data: { start_ts: 3, end_ts: 4 },
+ },
+ {
+ _id: { $oid: 'trip3' },
+ metadata: { write_ts: 3, origin_key: 'analysis/confirmed_trip' },
+ data: { start_ts: 5, end_ts: 6 },
+ },
+ ];
+ }
+ return Promise.resolve({ phone_data });
+ }),
+ fetchUrlCached: jest.fn(() => Promise.resolve(null)),
+}));
+
+// Mock useAppConfig default export
+jest.mock('../js/useAppConfig', () => {
+ return jest.fn(() => ({ intro: {} }));
+});
+
+const TimelineContextTestComponent = () => {
+ const { timelineMap, setDateRange } = useTimelineContext();
+
+ useEffect(() => {
+ // setDateRange(['2021-01-01', '2021-01-07']);
+ }, []);
+
+ if (!timelineMap) return null;
+
+ console.debug('timelineMap', timelineMap);
+
+ return (
+
+ {[...timelineMap.values()].map((entry, i) => (
+ {'entry ID: ' + entry._id.$oid}
+ ))}
+
+ );
+};
+
+describe('TimelineContext', () => {
+ it('renders correctly', async () => {
+ render();
+ await waitFor(() => {
+ // make sure timeline entries are rendered
+ expect(screen.getByTestId('timeline-entries')).toBeTruthy();
+ // make sure number of Text components matches number of timeline entries
+ expect(screen.getAllByText(/entry ID:/).length).toBe(3);
+ });
+ });
+});
diff --git a/www/__tests__/confirmHelper.test.ts b/www/__tests__/confirmHelper.test.ts
index b95b10372..6642b6ed4 100644
--- a/www/__tests__/confirmHelper.test.ts
+++ b/www/__tests__/confirmHelper.test.ts
@@ -15,7 +15,7 @@ import {
import initializedI18next from '../js/i18nextInit';
import { CompositeTrip, UserInputEntry } from '../js/types/diaryTypes';
-import { UserInputMap } from '../js/diary/LabelTabContext';
+import { UserInputMap } from '../js/TimelineContext';
window['i18next'] = initializedI18next;
mockLogger();
diff --git a/www/__tests__/metricsHelper.test.ts b/www/__tests__/metricsHelper.test.ts
new file mode 100644
index 000000000..075a9000f
--- /dev/null
+++ b/www/__tests__/metricsHelper.test.ts
@@ -0,0 +1,128 @@
+import {
+ calculatePercentChange,
+ formatDate,
+ formatDateRangeOfDays,
+ getLabelsForDay,
+ getUniqueLabelsForDays,
+ segmentDaysByWeeks,
+} from '../js/metrics/metricsHelper';
+import {
+ DayOfClientMetricData,
+ DayOfMetricData,
+ DayOfServerMetricData,
+} from '../js/metrics/metricsTypes';
+
+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_*'", () => {
+ 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_*'", () => {
+ 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']);
+ });
+ });
+
+ // secondsToMinutes
+
+ // secondsToHours
+
+ describe('segmentDaysByWeeks', () => {
+ const days1 = [
+ { date: '2021-01-01' },
+ { date: '2021-01-02' },
+ { date: '2021-01-04' },
+ { date: '2021-01-08' },
+ { date: '2021-01-09' },
+ { date: '2021-01-10' },
+ ] as any as DayOfClientMetricData[];
+
+ it("should segment days with 'date' into weeks", () => {
+ expect(segmentDaysByWeeks(days1, '2021-01-10')).toEqual([
+ // most recent week
+ [
+ { date: '2021-01-04' },
+ { date: '2021-01-08' },
+ { date: '2021-01-09' },
+ { date: '2021-01-10' },
+ ],
+ // prior week
+ [{ 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;
+ 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', () => {
+ const days1 = [
+ { date: '2021-01-01' },
+ { date: '2021-01-02' },
+ { date: '2021-01-04' },
+ ] as any as DayOfClientMetricData[];
+ it('should format date range for days with date', () => {
+ expect(formatDateRangeOfDays(days1)).toEqual('1/1 - 1/4');
+ });
+
+ 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');
+ });
+ });
+});
diff --git a/www/js/App.tsx b/www/js/App.tsx
index 648b93d86..d59d7f270 100644
--- a/www/js/App.tsx
+++ b/www/js/App.tsx
@@ -1,9 +1,5 @@
-import React, { useEffect, useState, createContext, useMemo } from 'react';
-import { ActivityIndicator, BottomNavigation, useTheme } from 'react-native-paper';
-import { useTranslation } from 'react-i18next';
-import LabelTab from './diary/LabelTab';
-import MetricsTab from './metrics/MetricsTab';
-import ProfileSettings from './control/ProfileSettings';
+import React, { useEffect, useState, createContext } from 'react';
+import { ActivityIndicator } from 'react-native-paper';
import useAppConfig from './useAppConfig';
import OnboardingStack from './onboarding/OnboardingStack';
import {
@@ -17,58 +13,18 @@ import usePermissionStatus from './usePermissionStatus';
import { initPushNotify } from './splash/pushNotifySettings';
import { initStoreDeviceSettings } from './splash/storeDeviceSettings';
import { initRemoteNotifyHandler } from './splash/remoteNotifyHandler';
-import { withErrorBoundary } from './plugin/ErrorBoundary';
import { initCustomDatasetHelper } from './metrics/customMetricsHelper';
import AlertBar from './components/AlertBar';
-
-const defaultRoutes = (t) => [
- {
- key: 'label',
- title: t('diary.label-tab'),
- focusedIcon: 'check-bold',
- unfocusedIcon: 'check-outline',
- accessibilityLabel: t('diary.label-tab'),
- },
- {
- key: 'metrics',
- title: t('metrics.dashboard-tab'),
- focusedIcon: 'chart-box',
- unfocusedIcon: 'chart-box-outline',
- accessibilityLabel: t('metrics.dashboard-tab'),
- },
- {
- key: 'control',
- title: t('control.profile-tab'),
- focusedIcon: 'account',
- unfocusedIcon: 'account-outline',
- accessibilityLabel: t('control.profile-tab'),
- },
-];
+import Main from './Main';
export const AppContext = createContext({});
-const scenes = {
- label: withErrorBoundary(LabelTab),
- metrics: withErrorBoundary(MetricsTab),
- control: withErrorBoundary(ProfileSettings),
-};
-
const App = () => {
- const [index, setIndex] = useState(0);
// will remain null while the onboarding state is still being determined
const [onboardingState, setOnboardingState] = useState(null);
const [permissionsPopupVis, setPermissionsPopupVis] = useState(false);
const appConfig = useAppConfig();
const permissionStatus = usePermissionStatus();
- const { colors } = useTheme();
- const { t } = useTranslation();
-
- const routes = useMemo(() => {
- const showMetrics = appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL';
- return showMetrics ? defaultRoutes(t) : defaultRoutes(t).filter((r) => r.key != 'metrics');
- }, [appConfig, t]);
-
- const renderScene = BottomNavigation.SceneMap(scenes);
const refreshOnboardingState = () => getPendingOnboardingState().then(setOnboardingState);
useEffect(() => {
@@ -102,20 +58,7 @@ const App = () => {
appContent = ;
} else if (onboardingState?.route == OnboardingRoute.DONE) {
// if onboarding route is DONE, show the main app with navigation between tabs
- appContent = (
-
- );
+ appContent = ;
} else {
// if there is an onboarding route that is not DONE, show the onboarding stack
appContent = ;
diff --git a/www/js/Main.tsx b/www/js/Main.tsx
new file mode 100644
index 000000000..6361e6828
--- /dev/null
+++ b/www/js/Main.tsx
@@ -0,0 +1,76 @@
+/* Once onboarding is done, this is the main app content.
+ Includes the bottom navigation bar and each of the tabs. */
+
+import React from 'react';
+import { useContext, useMemo, useState } from 'react';
+import { BottomNavigation, useTheme } from 'react-native-paper';
+import { AppContext } from './App';
+import { useTranslation } from 'react-i18next';
+import { withErrorBoundary } from './plugin/ErrorBoundary';
+import LabelTab from './diary/LabelTab';
+import MetricsTab from './metrics/MetricsTab';
+import ProfileSettings from './control/ProfileSettings';
+import TimelineContext, { useTimelineContext } from './TimelineContext';
+
+const defaultRoutes = (t) => [
+ {
+ key: 'label',
+ title: t('diary.label-tab'),
+ focusedIcon: 'check-bold',
+ unfocusedIcon: 'check-outline',
+ accessibilityLabel: t('diary.label-tab'),
+ },
+ {
+ key: 'metrics',
+ title: t('metrics.dashboard-tab'),
+ focusedIcon: 'chart-box',
+ unfocusedIcon: 'chart-box-outline',
+ accessibilityLabel: t('metrics.dashboard-tab'),
+ },
+ {
+ key: 'control',
+ title: t('control.profile-tab'),
+ focusedIcon: 'account',
+ unfocusedIcon: 'account-outline',
+ accessibilityLabel: t('control.profile-tab'),
+ },
+];
+
+const scenes = {
+ label: withErrorBoundary(LabelTab),
+ metrics: withErrorBoundary(MetricsTab),
+ control: withErrorBoundary(ProfileSettings),
+};
+const renderScene = BottomNavigation.SceneMap(scenes);
+
+const Main = () => {
+ const [index, setIndex] = useState(0);
+ const { colors } = useTheme();
+ const { t } = useTranslation();
+ const { appConfig } = useContext(AppContext);
+ const timelineContext = useTimelineContext();
+
+ const routes = useMemo(() => {
+ const showMetrics = appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL';
+ return showMetrics ? defaultRoutes(t) : defaultRoutes(t).filter((r) => r.key != 'metrics');
+ }, [appConfig, t]);
+
+ return (
+
+
+
+ );
+};
+
+export default Main;
diff --git a/www/js/TimelineContext.ts b/www/js/TimelineContext.ts
new file mode 100644
index 000000000..bbef214bd
--- /dev/null
+++ b/www/js/TimelineContext.ts
@@ -0,0 +1,387 @@
+import { createContext, useEffect, useState } from 'react';
+import { CompositeTrip, TimelineEntry, TimestampRange, UserInputEntry } from './types/diaryTypes';
+import useAppConfig from './useAppConfig';
+import { LabelOption, LabelOptions, MultilabelKey } from './types/labelTypes';
+import { getLabelOptions, labelOptionByValue } from './survey/multilabel/confirmHelper';
+import { displayError, displayErrorMsg, logDebug, logWarn } from './plugin/logger';
+import { useTranslation } from 'react-i18next';
+import { DateTime } from 'luxon';
+import {
+ isoDateWithOffset,
+ compositeTrips2TimelineMap,
+ readAllCompositeTrips,
+ readUnprocessedTrips,
+ unprocessedLabels,
+ unprocessedNotes,
+ updateUnprocessedBleScans,
+ unprocessedBleScans,
+ updateAllUnprocessedInputs,
+ updateLocalUnprocessedInputs,
+ isoDateRangeToTsRange,
+} from './diary/timelineHelper';
+import { getPipelineRangeTs } from './services/commHelper';
+import { getNotDeletedCandidates, mapInputsToTimelineEntries } from './survey/inputMatcher';
+import { publish } from './customEventHandler';
+import { EnketoUserInputEntry } from './survey/enketo/enketoHelper';
+import { VehicleIdentity } from './types/appConfigTypes';
+import { primarySectionForTrip } from './diary/diaryHelper';
+
+// initial query range is the past 7 days, including today
+const today = DateTime.now().toISODate().substring(0, 10);
+const initialQueryRange: [string, string] = [isoDateWithOffset(today, -6), today];
+
+type ContextProps = {
+ labelOptions: LabelOptions | null;
+ timelineMap: TimelineMap | null;
+ timelineLabelMap: TimelineLabelMap | null;
+ userInputFor: (tlEntry: TimelineEntry) => UserInputMap | undefined;
+ notesFor: (tlEntry: TimelineEntry) => UserInputEntry[] | undefined;
+ labelFor: (
+ tlEntry: TimelineEntry,
+ labelType: MultilabelKey,
+ ) => VehicleIdentity | LabelOption | undefined;
+ confirmedModeFor: (tlEntry: TimelineEntry) => LabelOption | undefined;
+ addUserInputToEntry: (oid: string, userInput: any, inputType: 'label' | 'note') => void;
+ 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;
+ refreshTimeline: () => void;
+};
+
+export const useTimelineContext = (): ContextProps => {
+ const { t } = useTranslation();
+ const appConfig = useAppConfig();
+
+ const [labelOptions, setLabelOptions] = useState(null);
+ // timestamp range that has been processed by the pipeline on the server
+ const [pipelineRange, setPipelineRange] = useState(null);
+ // date range (inclusive) that has been loaded into the UI [YYYY-MM-DD, YYYY-MM-DD]
+ const [queriedDateRange, setQueriedDateRange] = useState<[string, string] | null>(null);
+ // date range (inclusive) chosen by datepicker [YYYY-MM-DD, YYYY-MM-DD]
+ const [dateRange, setDateRange] = useState<[string, string]>(initialQueryRange);
+ // map of timeline entries (trips, places, untracked time), ids to objects
+ const [timelineMap, setTimelineMap] = useState(null);
+ const [timelineIsLoading, setTimelineIsLoading] = useState('replace');
+ const [timelineLabelMap, setTimelineLabelMap] = useState(null);
+ const [timelineNotesMap, setTimelineNotesMap] = useState(null);
+ const [refreshTime, setRefreshTime] = useState(null);
+
+ // initialization, once the appConfig is loaded
+ useEffect(() => {
+ try {
+ if (!appConfig) return;
+ getLabelOptions(appConfig).then((labelOptions) => setLabelOptions(labelOptions));
+ loadTimelineEntries();
+ } catch (e) {
+ displayError(e, t('errors.while-initializing-label'));
+ }
+ }, [appConfig, refreshTime]);
+
+ // when a new date range is chosen, load more date, then update the queriedDateRange
+ useEffect(() => {
+ const onDateRangeChange = async () => {
+ if (!dateRange) return logDebug('No dateRange chosen, skipping onDateRangeChange');
+ if (!pipelineRange) return logDebug('No pipelineRange yet, skipping onDateRangeChange');
+
+ logDebug('Timeline: onDateRangeChange with dateRange = ' + dateRange?.join(' to '));
+
+ // determine if this will be a new range or an expansion of the existing range
+ let mode: 'replace' | 'prepend' | 'append';
+ let dateRangeToQuery = dateRange;
+ if (queriedDateRange?.[0] == dateRange[0] && queriedDateRange?.[1] == dateRange[1]) {
+ // same range, so we are refreshing the data
+ mode = 'replace';
+ } else if (queriedDateRange?.[0] == dateRange[0]) {
+ // same start date, so we are loading more data into the future
+ mode = 'append';
+ const nextDate = isoDateWithOffset(queriedDateRange[1], 1);
+ dateRangeToQuery = [nextDate, dateRange[1]];
+ } else if (queriedDateRange?.[1] == dateRange[1]) {
+ // same end date, so we are loading more data into the past
+ mode = 'prepend';
+ const prevDate = isoDateWithOffset(queriedDateRange[0], -1);
+ dateRangeToQuery = [dateRange[0], prevDate];
+ } else {
+ // neither start nor end date is the same, so we treat this as a completely new range
+ mode = 'replace';
+ }
+ setTimelineIsLoading(mode);
+ const [ctList, utList] = await fetchTripsInRange(dateRangeToQuery);
+ handleFetchedTrips(ctList, utList, mode);
+ setQueriedDateRange(dateRange);
+ };
+
+ try {
+ onDateRangeChange();
+ } catch (e) {
+ setTimelineIsLoading(false);
+ displayError(e, 'While loading date range ' + dateRange?.join(' to '));
+ }
+ }, [dateRange, pipelineRange]);
+
+ // const onDateRangeChange = useCallback(async (dateRange: [string, string], pipelineRange) => {
+ // }, []);
+
+ useEffect(() => {
+ if (!timelineMap) return;
+ const allEntries = Array.from(timelineMap.values());
+ const [newTimelineLabelMap, newTimelineNotesMap] = mapInputsToTimelineEntries(
+ allEntries,
+ appConfig,
+ );
+ setTimelineLabelMap(newTimelineLabelMap);
+ setTimelineNotesMap(newTimelineNotesMap);
+ publish('applyLabelTabFilters', {
+ timelineMap,
+ timelineLabelMap: newTimelineLabelMap,
+ });
+ setTimelineIsLoading(false);
+ }, [timelineMap]);
+
+ async function loadTimelineEntries() {
+ try {
+ const pipelineRange = await getPipelineRangeTs();
+ await updateAllUnprocessedInputs(pipelineRange, appConfig);
+ logDebug(`Timeline: After updating unprocessedInputs,
+ unprocessedLabels = ${JSON.stringify(unprocessedLabels)};
+ unprocessedNotes = ${JSON.stringify(unprocessedNotes)}`);
+ if (appConfig.vehicle_identities?.length) {
+ await updateUnprocessedBleScans({
+ start_ts: pipelineRange.end_ts,
+ end_ts: Date.now() / 1000,
+ });
+ logDebug(`Timeline: After updating unprocessedBleScans,
+ unprocessedBleScans = ${JSON.stringify(unprocessedBleScans)};
+ `);
+ }
+ setPipelineRange(pipelineRange);
+ } catch (e) {
+ displayError(e, t('errors.while-loading-pipeline-range'));
+ setTimelineIsLoading(false);
+ }
+ }
+
+ 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);
+ }
+
+ 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();
+ 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;
+ }
+ logDebug('Timeline: loadSpecificWeek setting new date range = ' + [newStartDate, newEndDate]);
+ setDateRange([newStartDate, newEndDate]);
+ }
+
+ function handleFetchedTrips(ctList, utList, mode: 'prepend' | 'append' | 'replace') {
+ logDebug(`Timeline: handleFetchedTrips with
+ mode = ${mode};
+ ctList has ${ctList.length} trips;
+ utList has ${utList.length} trips`);
+
+ const tripsRead = ctList.concat(utList);
+ const showPlaces = Boolean(appConfig.survey_info?.buttons?.['place-notes']);
+ const readTimelineMap = compositeTrips2TimelineMap(tripsRead, showPlaces);
+ logDebug(`Timeline: after composite trips converted,
+ readTimelineMap = ${[...readTimelineMap.entries()]}`);
+ if (mode == 'append') {
+ setTimelineMap(new Map([...(timelineMap || []), ...readTimelineMap]));
+ } else if (mode == 'prepend') {
+ setTimelineMap(new Map([...readTimelineMap, ...(timelineMap || [])]));
+ } else if (mode == 'replace') {
+ setTimelineMap(readTimelineMap);
+ } else {
+ return displayErrorMsg('Unknown insertion mode ' + mode);
+ }
+ }
+
+ async function fetchTripsInRange(dateRange: [string, string]) {
+ if (!pipelineRange?.start_ts || !pipelineRange?.end_ts)
+ return logWarn('No pipelineRange yet - early return');
+ logDebug('Timeline: fetchTripsInRange from ' + dateRange[0] + ' to ' + dateRange[1]);
+
+ const [startTs, endTs] = isoDateRangeToTsRange(dateRange);
+ const maxStartTs = Math.max(startTs, pipelineRange.start_ts); // ensure that we don't read before the pipeline start
+ const minEndTs = Math.min(endTs, pipelineRange.end_ts); // ensure that we don't read after the pipeline end
+
+ const readCompositePromise = readAllCompositeTrips(maxStartTs, minEndTs);
+ let readUnprocessedPromise;
+ if (endTs >= pipelineRange.end_ts) {
+ let lastProcessedTrip: CompositeTrip | undefined;
+ if (timelineMap) {
+ lastProcessedTrip = [...timelineMap?.values()]
+ .reverse()
+ .find((trip) => trip.origin_key.includes('trip')) as CompositeTrip;
+ }
+ readUnprocessedPromise = readUnprocessedTrips(
+ Math.max(pipelineRange.end_ts, startTs),
+ endTs,
+ appConfig,
+ lastProcessedTrip,
+ );
+ } else {
+ readUnprocessedPromise = Promise.resolve([]);
+ }
+
+ const results = await Promise.all([readCompositePromise, readUnprocessedPromise]);
+ logDebug(`Timeline: readCompositePromise resolved with ${results[0]?.length} trips;
+ readUnprocessedPromise resolved with ${results[1]?.length} trips`);
+ return results;
+ }
+
+ function refreshTimeline() {
+ try {
+ logDebug('timelineContext: refreshTimeline');
+ setTimelineIsLoading('replace');
+ setDateRange(initialQueryRange);
+ setQueriedDateRange(null);
+ setTimelineMap(null);
+ setRefreshTime(new Date());
+ } catch (e) {
+ displayError(e, t('errors.while-refreshing-label'));
+ }
+ }
+
+ const userInputFor = (tlEntry: TimelineEntry) =>
+ timelineLabelMap?.[tlEntry._id.$oid] || undefined;
+ const notesFor = (tlEntry: TimelineEntry) => timelineNotesMap?.[tlEntry._id.$oid] || undefined;
+
+ /**
+ * @param tlEntry The trip or place object to get the label for
+ * @param labelType The type of label to get (e.g. MODE, PURPOSE, etc.)
+ * @returns the label option object for the given label type, or undefined if there is no label
+ */
+ const labelFor = (tlEntry: TimelineEntry, labelType: MultilabelKey) => {
+ const chosenLabel = userInputFor(tlEntry)?.[labelType]?.data.label;
+ return chosenLabel ? labelOptionByValue(chosenLabel, labelType) : undefined;
+ };
+
+ /**
+ * @param tlEntry The trip or place object to get the confirmed mode for
+ * @returns Confirmed mode, which could be a vehicle identity as determined by Bluetooth scans,
+ * or the label option from a user-given 'MODE' label, or undefined if neither exists.
+ */
+ const confirmedModeFor = (tlEntry: CompositeTrip) =>
+ primarySectionForTrip(tlEntry)?.ble_sensed_mode || labelFor(tlEntry, 'MODE');
+
+ function addUserInputToEntry(oid: string, userInput: any, inputType: 'label' | 'note') {
+ const tlEntry = timelineMap?.get(oid);
+ if (!pipelineRange || !tlEntry)
+ return displayErrorMsg('Item with oid: ' + oid + ' not found in timeline');
+ const nowTs = new Date().getTime() / 1000; // epoch seconds
+ if (inputType == 'label') {
+ const newLabels: UserInputMap = {};
+ for (const [inputType, labelValue] of Object.entries(userInput)) {
+ newLabels[inputType] = { data: labelValue, metadata: { write_ts: nowTs } as any };
+ }
+ logDebug('Timeline: newLabels = ' + JSON.stringify(newLabels));
+ const newTimelineLabelMap: TimelineLabelMap = {
+ ...timelineLabelMap,
+ [oid]: {
+ ...timelineLabelMap?.[oid],
+ ...newLabels,
+ },
+ };
+ setTimelineLabelMap(newTimelineLabelMap);
+ setTimeout(
+ () =>
+ publish('applyLabelTabFilters', {
+ timelineMap,
+ timelineLabelMap: newTimelineLabelMap,
+ }),
+ 30000,
+ ); // wait 30s before reapplying filters
+ } else if (inputType == 'note') {
+ const notesForEntry = timelineNotesMap?.[oid] || [];
+ const newAddition = { data: userInput, metadata: { write_ts: nowTs } };
+ notesForEntry.push(newAddition as UserInputEntry);
+ const newTimelineNotesMap: TimelineNotesMap = {
+ ...timelineNotesMap,
+ [oid]: getNotDeletedCandidates(notesForEntry),
+ };
+ setTimelineNotesMap(newTimelineNotesMap);
+ }
+ /* We can update unprocessed inputs in the background, without blocking the completion
+ of this function. That is why this is not 'await'ed */
+ updateLocalUnprocessedInputs(pipelineRange, appConfig);
+ }
+
+ return {
+ pipelineRange,
+ queriedDateRange,
+ dateRange,
+ setDateRange,
+ timelineMap,
+ timelineIsLoading,
+ timelineLabelMap,
+ labelOptions,
+ loadMoreDays,
+ loadSpecificWeek,
+ refreshTimeline,
+ userInputFor,
+ labelFor,
+ notesFor,
+ confirmedModeFor,
+ addUserInputToEntry,
+ };
+};
+
+export type UserInputMap = {
+ /* If keys are 'MODE', 'PURPOSE', 'REPLACED_MODE', this is the MULTILABEL configuration.
+ Values are entries that have a 'label' value in their 'data' */
+ [k in MultilabelKey]?: UserInputEntry;
+} & {
+ /* Otherwise we are in the ENKETO configuration, and keys are names of surveys.
+ Values are entries that have an 'xmlResponse' value in their 'data' */
+ [k: string]: EnketoUserInputEntry | undefined;
+};
+
+export type TimelineMap = Map; // Todo: update to reflect unpacked trips (origin_Key, etc)
+export type TimelineLabelMap = {
+ [k: string]: UserInputMap;
+};
+export type TimelineNotesMap = {
+ [k: string]: UserInputEntry[];
+};
+
+export type LabelTabFilter = {
+ key: string;
+ text: string;
+ filter: (trip: TimelineEntry, userInputForTrip: UserInputMap) => boolean;
+ state?: boolean;
+};
+
+export default createContext({} as ContextProps);
diff --git a/www/js/components/Chart.tsx b/www/js/components/Chart.tsx
index 2e5e3bd62..2ff236b5b 100644
--- a/www/js/components/Chart.tsx
+++ b/www/js/components/Chart.tsx
@@ -112,6 +112,7 @@ const Chart = ({
responsive: true,
maintainAspectRatio: false,
resizeDelay: 1,
+ spanGaps: 1000 * 60 * 60 * 24, // 1 day
scales: {
...(isHorizontal
? {
diff --git a/www/js/components/NavBar.tsx b/www/js/components/NavBar.tsx
index 291f0b9e9..cf2a19dff 100644
--- a/www/js/components/NavBar.tsx
+++ b/www/js/components/NavBar.tsx
@@ -1,13 +1,22 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import color from 'color';
-import { Appbar, Button, ButtonProps, Icon, useTheme } from 'react-native-paper';
+import { Appbar, Button, ButtonProps, Icon, ProgressBar, useTheme } from 'react-native-paper';
-const NavBar = ({ children }) => {
+type NavBarProps = { children: React.ReactNode; isLoading?: boolean };
+const NavBar = ({ children, isLoading }: NavBarProps) => {
const { colors } = useTheme();
return (
{children}
+
+
+
);
};
@@ -16,8 +25,8 @@ export default NavBar;
// NavBarButton, a greyish button with outline, to be used inside a NavBar
-type Props = ButtonProps & { icon?: string; iconSize?: number };
-export const NavBarButton = ({ children, icon, iconSize, ...rest }: Props) => {
+type NavBarButtonProps = ButtonProps & { icon?: string; iconSize?: number };
+export const NavBarButton = ({ children, icon, iconSize, ...rest }: NavBarButtonProps) => {
const { colors } = useTheme();
const buttonColor = color(colors.onBackground).alpha(0.07).rgb().string();
const outlineColor = color(colors.onBackground).alpha(0.2).rgb().string();
diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx
index 1e9dd3521..f619fbf4e 100644
--- a/www/js/diary/LabelTab.tsx
+++ b/www/js/diary/LabelTab.tsx
@@ -2,117 +2,68 @@
that has two screens: LabelListScreen and LabelScreenDetails.
LabelListScreen is the main screen, which is a scrollable list of timeline entries,
while LabelScreenDetails is the view that shows when the user clicks on a trip.
- LabelTabContext is provided to the entire child tree and allows the screens to
- share the data that has been loaded and interacted with.
*/
-import React, { useEffect, useState, useRef } from 'react';
-import useAppConfig from '../useAppConfig';
+import React, { useEffect, useState, useContext, createContext } from 'react';
import { useTranslation } from 'react-i18next';
-import { invalidateMaps } from '../components/LeafletView';
-import { DateTime } from 'luxon';
import LabelListScreen from './list/LabelListScreen';
import { createStackNavigator } from '@react-navigation/stack';
import LabelScreenDetails from './details/LabelDetailsScreen';
import { NavigationContainer } from '@react-navigation/native';
-import {
- compositeTrips2TimelineMap,
- updateAllUnprocessedInputs,
- updateLocalUnprocessedInputs,
- unprocessedLabels,
- unprocessedNotes,
- updateUnprocessedBleScans,
- unprocessedBleScans,
-} from './timelineHelper';
-import { fillLocationNamesOfTrip, resetNominatimLimiter } from './addressNamesHelper';
-import { getLabelOptions, labelOptionByValue } from '../survey/multilabel/confirmHelper';
-import { displayError, displayErrorMsg, logDebug, logWarn } from '../plugin/logger';
-import { useTheme } from 'react-native-paper';
-import { getPipelineRangeTs } from '../services/commHelper';
-import { getNotDeletedCandidates, mapInputsToTimelineEntries } from '../survey/inputMatcher';
+import { updateAllUnprocessedInputs } from './timelineHelper';
+import { fillLocationNamesOfTrip } from './addressNamesHelper';
+import { logDebug } from '../plugin/logger';
import { configuredFilters as multilabelConfiguredFilters } from '../survey/multilabel/infinite_scroll_filters';
import { configuredFilters as enketoConfiguredFilters } from '../survey/enketo/infinite_scroll_filters';
-import LabelTabContext, {
- LabelTabFilter,
- TimelineLabelMap,
- TimelineMap,
- TimelineNotesMap,
-} from './LabelTabContext';
-import { readAllCompositeTrips, readUnprocessedTrips } from './timelineHelper';
-import { LabelOptions, MultilabelKey } from '../types/labelTypes';
-import { CompositeTrip, TimelineEntry, TimestampRange, UserInputEntry } from '../types/diaryTypes';
-import { primarySectionForTrip } from './diaryHelper';
-
-let showPlaces;
-const ONE_DAY = 24 * 60 * 60; // seconds
-const ONE_WEEK = ONE_DAY * 7; // seconds
+import { TimelineEntry } from '../types/diaryTypes';
+import TimelineContext, { LabelTabFilter, TimelineLabelMap } from '../TimelineContext';
+import { AppContext } from '../App';
+import { subscribe } from '../customEventHandler';
+
+type LabelContextProps = {
+ displayedEntries: TimelineEntry[] | null;
+ filterInputs: LabelTabFilter[];
+ setFilterInputs: (filters: LabelTabFilter[]) => void;
+};
+export const LabelTabContext = createContext({} as LabelContextProps);
const LabelTab = () => {
- const appConfig = useAppConfig();
- const { t } = useTranslation();
- const { colors } = useTheme();
+ const { appConfig } = useContext(AppContext);
+ const { pipelineRange, timelineMap } = useContext(TimelineContext);
- const [labelOptions, setLabelOptions] = useState | null>(null);
const [filterInputs, setFilterInputs] = useState([]);
- const [lastFilteredTs, setLastFilteredTs] = useState(null);
- const [pipelineRange, setPipelineRange] = useState(null);
- const [queriedRange, setQueriedRange] = useState(null);
- const [timelineMap, setTimelineMap] = useState(null);
- const [timelineLabelMap, setTimelineLabelMap] = useState(null);
- const [timelineNotesMap, setTimelineNotesMap] = useState(null);
const [displayedEntries, setDisplayedEntries] = useState(null);
- const [refreshTime, setRefreshTime] = useState(null);
- const [isLoading, setIsLoading] = useState('replace');
- // initialization, once the appConfig is loaded
useEffect(() => {
- try {
- if (!appConfig) return;
- showPlaces = appConfig.survey_info?.buttons?.['place-notes'];
- getLabelOptions(appConfig).then((labelOptions) => setLabelOptions(labelOptions));
-
- // we will show filters if 'additions' are not configured
- // https://github.com/e-mission/e-mission-docs/issues/894
- if (appConfig.survey_info?.buttons == undefined) {
- // initalize filters
- const tripFilters =
- appConfig.survey_info?.['trip-labels'] == 'ENKETO'
- ? enketoConfiguredFilters
- : multilabelConfiguredFilters;
- const allFalseFilters = tripFilters.map((f, i) => ({
- ...f,
- state: i == 0 ? true : false, // only the first filter will have state true on init
- }));
- setFilterInputs(allFalseFilters);
- }
- loadTimelineEntries();
- } catch (e) {
- displayError(e, t('errors.while-initializing-label'));
+ // we will show filters if 'additions' are not configured
+ // https://github.com/e-mission/e-mission-docs/issues/894
+ if (appConfig.survey_info?.buttons == undefined) {
+ // initalize filters
+ const tripFilters =
+ appConfig.survey_info?.['trip-labels'] == 'ENKETO'
+ ? enketoConfiguredFilters
+ : multilabelConfiguredFilters;
+ const allFalseFilters = tripFilters.map((f, i) => ({
+ ...f,
+ state: i == 0 ? true : false, // only the first filter will have state true on init
+ }));
+ setFilterInputs(allFalseFilters);
}
- }, [appConfig, refreshTime]);
- // whenever timelineMap is updated, map unprocessed inputs to timeline entries, and
- // update the displayedEntries according to the active filter
- useEffect(() => {
- try {
- if (!timelineMap) return setDisplayedEntries(null);
- const allEntries = Array.from(timelineMap.values());
- const [newTimelineLabelMap, newTimelineNotesMap] = mapInputsToTimelineEntries(
- allEntries,
- appConfig,
- );
- setTimelineLabelMap(newTimelineLabelMap);
- setTimelineNotesMap(newTimelineNotesMap);
- applyFilters(timelineMap, newTimelineLabelMap);
- } catch (e) {
- displayError(e, t('errors.while-updating-timeline'));
- }
- }, [timelineMap, filterInputs]);
+ subscribe('applyLabelTabFilters', (e) => {
+ logDebug('applyLabelTabFilters event received, calling applyFilters');
+ applyFilters(e.detail.timelineMap, e.detail.timelineLabelMap || {});
+ });
+ }, [appConfig]);
useEffect(() => {
- if (!timelineMap || !timelineLabelMap) return;
- applyFilters(timelineMap, timelineLabelMap);
- }, [lastFilteredTs]);
+ if (!timelineMap) return;
+ const tripsRead = Object.values(timelineMap || {});
+ tripsRead
+ .slice()
+ .reverse()
+ .forEach((trip) => fillLocationNamesOfTrip(trip));
+ }, [timelineMap]);
function applyFilters(timelineMap, labelMap: TimelineLabelMap) {
const allEntries: TimelineEntry[] = Array.from(timelineMap.values());
@@ -125,7 +76,7 @@ const LabelTab = () => {
const labels = labelMap[e._id.$oid];
for (let labelValue of Object.values(labels || [])) {
logDebug(`LabelTab filtering: labelValue = ${JSON.stringify(labelValue)}`);
- if (labelValue?.metadata?.write_ts > cutoffTs) {
+ if (labelValue?.metadata?.write_ts || 0 > cutoffTs) {
logDebug('LabelTab filtering: entry has recent user input, keeping');
return true;
}
@@ -151,244 +102,21 @@ const LabelTab = () => {
setDisplayedEntries(entriesToDisplay);
}
- async function loadTimelineEntries() {
- try {
- const pipelineRange = await getPipelineRangeTs();
- await updateAllUnprocessedInputs(pipelineRange, appConfig);
- logDebug(`LabelTab: After updating unprocessedInputs,
- unprocessedLabels = ${JSON.stringify(unprocessedLabels)};
- unprocessedNotes = ${JSON.stringify(unprocessedNotes)}`);
- if (appConfig.vehicle_identities?.length) {
- await updateUnprocessedBleScans({
- start_ts: pipelineRange.end_ts,
- end_ts: Date.now() / 1000,
- });
- logDebug(`LabelTab: After updating unprocessedBleScans,
- unprocessedBleScans = ${JSON.stringify(unprocessedBleScans)};
- `);
- }
- setPipelineRange(pipelineRange);
- } catch (e) {
- displayError(e, t('errors.while-loading-pipeline-range'));
- setIsLoading(false);
- }
- }
-
- // once pipelineRange is set, load the most recent week of data
+ // once pipelineRange is set, update all unprocessed inputs
useEffect(() => {
if (pipelineRange && pipelineRange.end_ts) {
- loadAnotherWeek('past');
+ updateAllUnprocessedInputs(pipelineRange, appConfig);
}
}, [pipelineRange]);
- function refresh() {
- try {
- logDebug('Refreshing LabelTab');
- setIsLoading('replace');
- resetNominatimLimiter();
- setQueriedRange(null);
- setTimelineMap(null);
- setRefreshTime(new Date());
- } catch (e) {
- displayError(e, t('errors.while-refreshing-label'));
- }
- }
-
- async function loadAnotherWeek(when: 'past' | 'future') {
- try {
- logDebug('LabelTab: loadAnotherWeek into the ' + when);
- if (!pipelineRange?.start_ts || !pipelineRange?.end_ts)
- return logWarn('No pipelineRange yet - early return');
-
- const reachedPipelineStart =
- queriedRange?.start_ts && queriedRange.start_ts <= pipelineRange.start_ts;
- const reachedPipelineEnd =
- queriedRange?.end_ts && queriedRange.end_ts >= pipelineRange.end_ts;
-
- if (!queriedRange) {
- // first time loading
- if (!isLoading) setIsLoading('replace');
- const nowTs = new Date().getTime() / 1000;
- const [ctList, utList] = await fetchTripsInRange(pipelineRange.end_ts - ONE_WEEK, nowTs);
- handleFetchedTrips(ctList, utList, 'replace');
- setQueriedRange({ start_ts: pipelineRange.end_ts - ONE_WEEK, end_ts: nowTs });
- } else if (when == 'past' && !reachedPipelineStart) {
- if (!isLoading) setIsLoading('prepend');
- const fetchStartTs = Math.max(queriedRange.start_ts - ONE_WEEK, pipelineRange.start_ts);
- const [ctList, utList] = await fetchTripsInRange(
- queriedRange.start_ts - ONE_WEEK,
- queriedRange.start_ts - 1,
- );
- handleFetchedTrips(ctList, utList, 'prepend');
- setQueriedRange({ start_ts: fetchStartTs, end_ts: queriedRange.end_ts });
- } else if (when == 'future' && !reachedPipelineEnd) {
- if (!isLoading) setIsLoading('append');
- const fetchEndTs = Math.min(queriedRange.end_ts + ONE_WEEK, pipelineRange.end_ts);
- const [ctList, utList] = await fetchTripsInRange(queriedRange.end_ts + 1, fetchEndTs);
- handleFetchedTrips(ctList, utList, 'append');
- setQueriedRange({ start_ts: queriedRange.start_ts, end_ts: fetchEndTs });
- }
- } catch (e) {
- setIsLoading(false);
- displayError(e, t('errors.while-loading-another-week', { when: when }));
- }
- }
-
- async function loadSpecificWeek(day: Date) {
- try {
- logDebug('LabelTab: loadSpecificWeek for day ' + day);
- if (!isLoading) setIsLoading('replace');
- resetNominatimLimiter();
- const threeDaysBefore = DateTime.fromJSDate(day).minus({ days: 3 }).toSeconds();
- const threeDaysAfter = DateTime.fromJSDate(day).plus({ days: 3 }).toSeconds();
- const [ctList, utList] = await fetchTripsInRange(threeDaysBefore, threeDaysAfter);
- handleFetchedTrips(ctList, utList, 'replace');
- setQueriedRange({ start_ts: threeDaysBefore, end_ts: threeDaysAfter });
- } catch (e) {
- setIsLoading(false);
- displayError(e, t('errors.while-loading-specific-week', { day: day }));
- }
- }
-
- function handleFetchedTrips(ctList, utList, mode: 'prepend' | 'append' | 'replace') {
- logDebug(`LabelTab: handleFetchedTrips with
- mode = ${mode};
- ctList = ${JSON.stringify(ctList)};
- utList = ${JSON.stringify(utList)}`);
-
- const tripsRead = ctList.concat(utList);
- // Fill place names on a reversed copy of the list so we fill from the bottom up
- tripsRead
- .slice()
- .reverse()
- .forEach((trip, index) => fillLocationNamesOfTrip(trip));
- const readTimelineMap = compositeTrips2TimelineMap(tripsRead, showPlaces);
- logDebug(`LabelTab: after composite trips converted,
- readTimelineMap = ${[...readTimelineMap.entries()]}`);
- if (mode == 'append') {
- setTimelineMap(new Map([...(timelineMap || []), ...readTimelineMap]));
- } else if (mode == 'prepend') {
- setTimelineMap(new Map([...readTimelineMap, ...(timelineMap || [])]));
- } else if (mode == 'replace') {
- setTimelineMap(readTimelineMap);
- } else {
- return displayErrorMsg('Unknown insertion mode ' + mode);
- }
- }
-
- async function fetchTripsInRange(startTs: number, endTs: number) {
- if (!pipelineRange?.start_ts || !pipelineRange?.end_ts)
- return logWarn('No pipelineRange yet - early return');
- logDebug('LabelTab: fetchTripsInRange from ' + startTs + ' to ' + endTs);
- const readCompositePromise = readAllCompositeTrips(startTs, endTs);
- let readUnprocessedPromise;
- if (endTs >= pipelineRange.end_ts) {
- const nowTs = new Date().getTime() / 1000;
- let lastProcessedTrip: CompositeTrip | undefined;
- if (timelineMap) {
- lastProcessedTrip = [...timelineMap?.values()]
- .reverse()
- .find((trip) => trip.origin_key.includes('trip')) as CompositeTrip;
- }
- readUnprocessedPromise = readUnprocessedTrips(
- pipelineRange.end_ts,
- nowTs,
- appConfig,
- lastProcessedTrip,
- );
- } else {
- readUnprocessedPromise = Promise.resolve([]);
- }
- const results = await Promise.all([readCompositePromise, readUnprocessedPromise]);
- logDebug(`LabelTab: readCompositePromise resolved as: ${JSON.stringify(results[0])};
- readUnprocessedPromise resolved as: ${JSON.stringify(results[1])}`);
- return results;
- }
-
- useEffect(() => {
- if (!displayedEntries) return;
- invalidateMaps();
- setIsLoading(false);
- }, [displayedEntries]);
-
- const userInputFor = (tlEntry: TimelineEntry) =>
- timelineLabelMap?.[tlEntry._id.$oid] || undefined;
- const notesFor = (tlEntry: TimelineEntry) => timelineNotesMap?.[tlEntry._id.$oid] || undefined;
-
- /**
- * @param tlEntry The trip or place object to get the label for
- * @param labelType The type of label to get (e.g. MODE, PURPOSE, etc.)
- * @returns the label option object for the given label type, or undefined if there is no label
- */
- const labelFor = (tlEntry: TimelineEntry, labelType: MultilabelKey) => {
- const chosenLabel = userInputFor(tlEntry)?.[labelType]?.data.label;
- return chosenLabel ? labelOptionByValue(chosenLabel, labelType) : undefined;
- };
-
- /**
- * @param tlEntry The trip or place object to get the confirmed mode for
- * @returns Confirmed mode, which could be a vehicle identity as determined by Bluetooth scans,
- * or the label option from a user-given 'MODE' label, or undefined if neither exists.
- */
- const confirmedModeFor = (tlEntry: CompositeTrip) =>
- primarySectionForTrip(tlEntry)?.ble_sensed_mode || labelFor(tlEntry, 'MODE');
-
- function addUserInputToEntry(oid: string, userInput: any, inputType: 'label' | 'note') {
- const tlEntry = timelineMap?.get(oid);
- if (!pipelineRange || !tlEntry)
- return displayErrorMsg('Item with oid: ' + oid + ' not found in timeline');
- const nowTs = new Date().getTime() / 1000; // epoch seconds
- if (inputType == 'label') {
- const newLabels = {};
- for (const [inputType, labelValue] of Object.entries(userInput)) {
- newLabels[inputType] = { data: labelValue, metadata: nowTs };
- }
- logDebug('LabelTab: newLabels = ' + JSON.stringify(newLabels));
- const newTimelineLabelMap: TimelineLabelMap = {
- ...timelineLabelMap,
- [oid]: {
- ...timelineLabelMap?.[oid],
- ...newLabels,
- },
- };
- setTimelineLabelMap(newTimelineLabelMap);
- setTimeout(() => setLastFilteredTs(new Date().getTime() / 1000), 30000); // wait 30s before reapplying filters
- } else if (inputType == 'note') {
- const notesForEntry = timelineNotesMap?.[oid] || [];
- const newAddition = { data: userInput, metadata: { write_ts: nowTs } };
- notesForEntry.push(newAddition as UserInputEntry);
- const newTimelineNotesMap: TimelineNotesMap = {
- ...timelineNotesMap,
- [oid]: getNotDeletedCandidates(notesForEntry),
- };
- setTimelineNotesMap(newTimelineNotesMap);
- }
- /* We can update unprocessed inputs in the background, without blocking the completion
- of this function. That is why this is not 'await'ed */
- updateLocalUnprocessedInputs(pipelineRange, appConfig);
- }
+ const Tab = createStackNavigator();
- const contextVals = {
- labelOptions,
- timelineMap,
- userInputFor,
- labelFor,
- notesFor,
- confirmedModeFor,
- addUserInputToEntry,
+ const contextVals: LabelContextProps = {
displayedEntries,
filterInputs,
setFilterInputs,
- queriedRange,
- pipelineRange,
- isLoading,
- loadAnotherWeek,
- loadSpecificWeek,
- refresh,
};
- const Tab = createStackNavigator();
-
return (
diff --git a/www/js/diary/LabelTabContext.ts b/www/js/diary/LabelTabContext.ts
deleted file mode 100644
index 46b58b95e..000000000
--- a/www/js/diary/LabelTabContext.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { createContext } from 'react';
-import { CompositeTrip, TimelineEntry, TimestampRange, UserInputEntry } from '../types/diaryTypes';
-import { LabelOption, LabelOptions, MultilabelKey } from '../types/labelTypes';
-import { EnketoUserInputEntry } from '../survey/enketo/enketoHelper';
-import { VehicleIdentity } from '../types/appConfigTypes';
-
-export type UserInputMap = {
- /* If keys are 'MODE', 'PURPOSE', 'REPLACED_MODE', this is the MULTILABEL configuration.
- Values are entries that have a 'label' value in their 'data' */
- [k in MultilabelKey]?: UserInputEntry;
-} & {
- /* Otherwise we are in the ENKETO configuration, and keys are names of surveys.
- Values are entries that have an 'xmlResponse' value in their 'data' */
- [k: string]: EnketoUserInputEntry | undefined;
-};
-
-export type TimelineMap = Map; // Todo: update to reflect unpacked trips (origin_Key, etc)
-export type TimelineLabelMap = {
- [k: string]: UserInputMap;
-};
-export type TimelineNotesMap = {
- [k: string]: UserInputEntry[];
-};
-
-export type LabelTabFilter = {
- key: string;
- text: string;
- filter: (trip: TimelineEntry, userInputForTrip: UserInputMap) => boolean;
- state?: boolean;
-};
-
-type ContextProps = {
- labelOptions: LabelOptions | null;
- timelineMap: TimelineMap | null;
- userInputFor: (tlEntry: TimelineEntry) => UserInputMap | undefined;
- notesFor: (tlEntry: TimelineEntry) => UserInputEntry[] | undefined;
- labelFor: (tlEntry: TimelineEntry, labelType: MultilabelKey) => LabelOption | undefined;
- confirmedModeFor: (tlEntry: CompositeTrip) => VehicleIdentity | LabelOption | undefined;
- addUserInputToEntry: (oid: string, userInput: any, inputType: 'label' | 'note') => void;
- displayedEntries: TimelineEntry[] | null;
- filterInputs: LabelTabFilter[];
- setFilterInputs: (filters: LabelTabFilter[]) => void;
- queriedRange: TimestampRange | null;
- pipelineRange: TimestampRange | null;
- isLoading: string | false;
- loadAnotherWeek: (when: 'past' | 'future') => void;
- loadSpecificWeek: (d: Date) => void;
- refresh: () => void;
-};
-
-export default createContext({} as ContextProps);
diff --git a/www/js/diary/cards/ModesIndicator.tsx b/www/js/diary/cards/ModesIndicator.tsx
index 4e68da4de..2ec5d9dc2 100644
--- a/www/js/diary/cards/ModesIndicator.tsx
+++ b/www/js/diary/cards/ModesIndicator.tsx
@@ -1,7 +1,7 @@
import React, { useContext } from 'react';
import { View, StyleSheet } from 'react-native';
import color from 'color';
-import LabelTabContext from '../LabelTabContext';
+import TimelineContext from '../../TimelineContext';
import { logDebug } from '../../plugin/logger';
import { getBaseModeByKey, getBaseModeByValue } from '../diaryHelper';
import { Text, Icon, useTheme } from 'react-native-paper';
@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next';
const ModesIndicator = ({ trip, detectedModes }) => {
const { t } = useTranslation();
- const { labelOptions, labelFor, confirmedModeFor } = useContext(LabelTabContext);
+ const { labelOptions, labelFor, confirmedModeFor } = useContext(TimelineContext);
const { colors } = useTheme();
const indicatorBackgroundColor = color(colors.onPrimary).alpha(0.8).rgb().string();
diff --git a/www/js/diary/cards/PlaceCard.tsx b/www/js/diary/cards/PlaceCard.tsx
index 6936146e6..3dcc02018 100644
--- a/www/js/diary/cards/PlaceCard.tsx
+++ b/www/js/diary/cards/PlaceCard.tsx
@@ -17,14 +17,14 @@ import { DiaryCard, cardStyles } from './DiaryCard';
import { useAddressNames } from '../addressNamesHelper';
import useDerivedProperties from '../useDerivedProperties';
import StartEndLocations from '../components/StartEndLocations';
-import LabelTabContext from '../LabelTabContext';
+import TimelineContext from '../../TimelineContext';
import { ConfirmedPlace } from '../../types/diaryTypes';
import { EnketoUserInputEntry } from '../../survey/enketo/enketoHelper';
type Props = { place: ConfirmedPlace };
const PlaceCard = ({ place }: Props) => {
const appConfig = useAppConfig();
- const { notesFor } = useContext(LabelTabContext);
+ const { notesFor } = useContext(TimelineContext);
const { displayStartTime, displayEndTime, displayDate } = useDerivedProperties(place);
let [placeDisplayName] = useAddressNames(place);
diff --git a/www/js/diary/cards/TripCard.tsx b/www/js/diary/cards/TripCard.tsx
index f0f8a1284..a309d63aa 100644
--- a/www/js/diary/cards/TripCard.tsx
+++ b/www/js/diary/cards/TripCard.tsx
@@ -18,7 +18,7 @@ import { getTheme } from '../../appTheme';
import { DiaryCard, cardStyles } from './DiaryCard';
import { useNavigation } from '@react-navigation/native';
import { useAddressNames } from '../addressNamesHelper';
-import LabelTabContext from '../LabelTabContext';
+import TimelineContext from '../../TimelineContext';
import useDerivedProperties from '../useDerivedProperties';
import StartEndLocations from '../components/StartEndLocations';
import ModesIndicator from './ModesIndicator';
@@ -42,7 +42,7 @@ const TripCard = ({ trip, isFirstInList }: Props) => {
} = useDerivedProperties(trip);
let [tripStartDisplayName, tripEndDisplayName] = useAddressNames(trip);
const navigation = useNavigation();
- const { labelOptions, confirmedModeFor, notesFor } = useContext(LabelTabContext);
+ const { labelOptions, confirmedModeFor, notesFor } = useContext(TimelineContext);
const tripGeojson =
trip && labelOptions && useGeojsonForTrip(trip, confirmedModeFor(trip)?.baseMode);
diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx
index 9627ebcaa..8fee14d07 100644
--- a/www/js/diary/details/LabelDetailsScreen.tsx
+++ b/www/js/diary/details/LabelDetailsScreen.tsx
@@ -13,7 +13,7 @@ import {
Text,
useTheme,
} from 'react-native-paper';
-import LabelTabContext from '../LabelTabContext';
+import TimelineContext from '../../TimelineContext';
import LeafletView from '../../components/LeafletView';
import { useTranslation } from 'react-i18next';
import MultilabelButtonGroup from '../../survey/multilabel/MultiLabelButtonGroup';
@@ -31,7 +31,7 @@ import { CompositeTrip } from '../../types/diaryTypes';
import NavBar from '../../components/NavBar';
const LabelScreenDetails = ({ route, navigation }) => {
- const { timelineMap, labelOptions, confirmedModeFor } = useContext(LabelTabContext);
+ const { timelineMap, labelOptions, confirmedModeFor } = useContext(TimelineContext);
const { t } = useTranslation();
const { height: windowHeight } = useWindowDimensions();
const appConfig = useAppConfig();
diff --git a/www/js/diary/details/TripSectionsDescriptives.tsx b/www/js/diary/details/TripSectionsDescriptives.tsx
index 9e117021c..13c15019d 100644
--- a/www/js/diary/details/TripSectionsDescriptives.tsx
+++ b/www/js/diary/details/TripSectionsDescriptives.tsx
@@ -3,10 +3,10 @@ import { View, StyleSheet } from 'react-native';
import { Icon, Text, useTheme } from 'react-native-paper';
import useDerivedProperties from '../useDerivedProperties';
import { getBaseModeByKey, getBaseModeByValue } from '../diaryHelper';
-import LabelTabContext from '../LabelTabContext';
+import TimelineContext from '../../TimelineContext';
const TripSectionsDescriptives = ({ trip, showConfirmedMode = false }) => {
- const { labelOptions, labelFor, confirmedModeFor } = useContext(LabelTabContext);
+ const { labelOptions, labelFor, confirmedModeFor } = useContext(TimelineContext);
const {
displayStartTime,
displayTime,
diff --git a/www/js/diary/list/DateSelect.tsx b/www/js/diary/list/DateSelect.tsx
index 02b8d1ca1..df3bbbd66 100644
--- a/www/js/diary/list/DateSelect.tsx
+++ b/www/js/diary/list/DateSelect.tsx
@@ -1,95 +1,120 @@
-/* This button launches a modal to select a date, which determines which week of
- travel should be displayed in the Label screen.
- The button itself is a NavBarButton, which shows the currently selected date range,
+/* This button reflects what date range for which the timeline is currently loaded.
+ If mode is 'single', one date can be selected; if 'range', start and end dates can be selected.
+ The button itself is a NavBarButton, which shows the currently loaded date range,
a calendar icon, and launches the modal when clicked.
The modal is a DatePickerModal from react-native-paper-dates, which shows a calendar
- and allows the user to select a date.
+ and allows the user to select date(s).
*/
-import React, { useEffect, useState, useMemo, useContext } from 'react';
+import React, { useMemo, useContext } from 'react';
import { StyleSheet } from 'react-native';
import { DateTime } from 'luxon';
-import LabelTabContext from '../LabelTabContext';
-import { DatePickerModal } from 'react-native-paper-dates';
+import TimelineContext from '../../TimelineContext';
+import {
+ DatePickerModal,
+ DatePickerModalRangeProps,
+ DatePickerModalSingleProps,
+} from 'react-native-paper-dates';
import { Text, Divider, useTheme } from 'react-native-paper';
import i18next from 'i18next';
import { useTranslation } from 'react-i18next';
import { NavBarButton } from '../../components/NavBar';
+import { isoDateRangeToTsRange } from '../timelineHelper';
-const DateSelect = ({ tsRange, loadSpecificWeekFn }) => {
- const { pipelineRange } = useContext(LabelTabContext);
+type Props = Partial & {
+ mode: 'single' | 'range';
+ onChoose: (params) => void;
+};
+const DateSelect = ({ mode, onChoose, ...rest }: Props) => {
+ const { pipelineRange, queriedDateRange } = useContext(TimelineContext);
const { t } = useTranslation();
const { colors } = useTheme();
const [open, setOpen] = React.useState(false);
- const [dateRange, setDateRange] = useState([null, null]);
- const [selDate, setSelDate] = useState(new Date());
const minMaxDates = useMemo(() => {
if (!pipelineRange) return { startDate: new Date(), endDate: new Date() };
return {
- startDate: new Date(pipelineRange?.start_ts * 1000),
- endDate: new Date(pipelineRange?.end_ts * 1000),
+ startDate: new Date(pipelineRange?.start_ts * 1000), // start of pipeline
+ endDate: new Date(), // today
};
}, [pipelineRange]);
- useEffect(() => {
- if (!pipelineRange || !tsRange.oldestTs) return;
- const displayStartTs = Math.max(tsRange.oldestTs, pipelineRange.start_ts);
+ const queriedRangeAsJsDates = useMemo(
+ () => queriedDateRange?.map((d) => new Date(d)),
+ [queriedDateRange],
+ );
+
+ const displayDateRange = useMemo(() => {
+ if (!pipelineRange || !queriedDateRange?.[0]) return null;
+ const [queriedStartTs, queriedEndTs] = isoDateRangeToTsRange(queriedDateRange);
+ const displayStartTs = Math.max(queriedStartTs, pipelineRange.start_ts);
const displayStartDate = DateTime.fromSeconds(displayStartTs).toLocaleString(
DateTime.DATE_SHORT,
);
-
let displayEndDate;
- if (tsRange.latestTs < pipelineRange.end_ts) {
- displayEndDate = DateTime.fromSeconds(tsRange.latestTs).toLocaleString(DateTime.DATE_SHORT);
+ if (queriedEndTs < pipelineRange.end_ts) {
+ displayEndDate = DateTime.fromSeconds(queriedEndTs).toLocaleString(DateTime.DATE_SHORT);
}
- setDateRange([displayStartDate, displayEndDate]);
+ return [displayStartDate, displayEndDate];
+ }, [pipelineRange, queriedDateRange]);
- const mid = (tsRange.oldestTs + tsRange.latestTs) / 2;
- const d = new Date(Math.min(mid, pipelineRange.end_ts) * 1000);
- setSelDate(d);
- }, [tsRange]);
+ const midpointDate = useMemo(() => {
+ if (!pipelineRange || !queriedDateRange?.[0]) return undefined;
+ const [queriedStartTs, queriedEndTs] = isoDateRangeToTsRange(queriedDateRange);
+ const mid = (queriedStartTs + queriedEndTs) / 2;
+ return new Date(Math.min(mid, pipelineRange.end_ts) * 1000);
+ }, [queriedDateRange]);
const onDismissSingle = React.useCallback(() => {
setOpen(false);
}, [setOpen]);
- const onChoose = React.useCallback(
- (params) => {
- loadSpecificWeekFn(params.date);
- setOpen(false);
- },
- [setOpen, loadSpecificWeekFn],
- );
- const dateRangeEnd = dateRange[1] || t('diary.today');
+ const displayDateRangeEnd = displayDateRange?.[1] || t('diary.today');
return (
<>
setOpen(true)}>
- {dateRange[0] && (
+ {displayDateRange?.[0] && (
<>
- {dateRange[0]}
+ {displayDateRange?.[0]}
>
)}
- {dateRangeEnd}
+ {displayDateRangeEnd}
{
+ if (mode == 'single') {
+ onChoose(params);
+ onDismissSingle();
+ }
+ }}
+ onConfirm={(params) => {
+ if (mode == 'range') {
+ onChoose(params);
+ onDismissSingle();
+ } else {
+ onDismissSingle();
+ }
+ }}
+ {...rest}
/>
>
);
diff --git a/www/js/diary/list/FilterSelect.tsx b/www/js/diary/list/FilterSelect.tsx
index 039d76be0..2bad0c7cb 100644
--- a/www/js/diary/list/FilterSelect.tsx
+++ b/www/js/diary/list/FilterSelect.tsx
@@ -12,7 +12,7 @@ import { Modal } from 'react-native';
import { useTranslation } from 'react-i18next';
import { RadioButton, Text, Dialog } from 'react-native-paper';
import { NavBarButton } from '../../components/NavBar';
-import { LabelTabFilter } from '../LabelTabContext';
+import { LabelTabFilter } from '../../TimelineContext';
type Props = {
filters: LabelTabFilter[];
diff --git a/www/js/diary/list/LabelListScreen.tsx b/www/js/diary/list/LabelListScreen.tsx
index 7dfcb676d..54df33500 100644
--- a/www/js/diary/list/LabelListScreen.tsx
+++ b/www/js/diary/list/LabelListScreen.tsx
@@ -4,27 +4,21 @@ import { Appbar, useTheme } from 'react-native-paper';
import DateSelect from './DateSelect';
import FilterSelect from './FilterSelect';
import TimelineScrollList from './TimelineScrollList';
-import LabelTabContext from '../LabelTabContext';
import NavBar from '../../components/NavBar';
+import TimelineContext from '../../TimelineContext';
+import { LabelTabContext } from '../LabelTab';
+import { DateTime } from 'luxon';
+import { displayErrorMsg } from '../../plugin/logger';
const LabelListScreen = () => {
- const {
- filterInputs,
- setFilterInputs,
- timelineMap,
- displayedEntries,
- queriedRange,
- loadSpecificWeek,
- refresh,
- pipelineRange,
- loadAnotherWeek,
- isLoading,
- } = useContext(LabelTabContext);
+ const { filterInputs, setFilterInputs, displayedEntries } = useContext(LabelTabContext);
+ const { timelineMap, loadSpecificWeek, timelineIsLoading, refreshTimeline } =
+ useContext(TimelineContext);
const { colors } = useTheme();
return (
<>
-
+
{
numListTotal={timelineMap?.size}
/>
{
+ const d = DateTime.fromJSDate(date).toISODate();
+ if (!d) return displayErrorMsg('Invalid date');
+ loadSpecificWeek(d);
+ }}
/>
refresh()}
+ onPress={() => refreshTimeline()}
accessibilityLabel="Refresh"
style={{ marginLeft: 'auto' }}
/>
-
+
>
);
diff --git a/www/js/diary/list/TimelineScrollList.tsx b/www/js/diary/list/TimelineScrollList.tsx
index 79faffbdf..57842bbcf 100644
--- a/www/js/diary/list/TimelineScrollList.tsx
+++ b/www/js/diary/list/TimelineScrollList.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useContext, useMemo } from 'react';
import TripCard from '../cards/TripCard';
import PlaceCard from '../cards/PlaceCard';
import UntrackedTimeCard from '../cards/UntrackedTimeCard';
@@ -6,6 +6,8 @@ import { View, FlatList } from 'react-native';
import { ActivityIndicator, Banner, Icon, Text } from 'react-native-paper';
import LoadMoreButton from './LoadMoreButton';
import { useTranslation } from 'react-i18next';
+import { isoDateRangeToTsRange } from '../timelineHelper';
+import TimelineContext from '../../TimelineContext';
function renderCard({ item: listEntry, index }) {
if (listEntry.origin_key.includes('trip')) {
@@ -25,34 +27,31 @@ const smallSpinner = ;
type Props = {
listEntries: any[] | null;
- queriedRange: any;
- pipelineRange: any;
- loadMoreFn: (direction: string) => void;
- isLoading: boolean | string;
};
-const TimelineScrollList = ({
- listEntries,
- queriedRange,
- pipelineRange,
- loadMoreFn,
- isLoading,
-}: Props) => {
+const TimelineScrollList = ({ listEntries }: Props) => {
const { t } = useTranslation();
+ const { pipelineRange, queriedDateRange, timelineIsLoading, loadMoreDays } =
+ useContext(TimelineContext);
const listRef = React.useRef(null);
// The way that FlashList inverts the scroll view means we have to reverse the order of items too
const reversedListEntries = listEntries ? [...listEntries].reverse() : [];
- const reachedPipelineStart = queriedRange?.start_ts <= pipelineRange?.start_ts;
+ const [reachedPipelineStart, reachedPipelineEnd] = useMemo(() => {
+ if (!queriedDateRange || !pipelineRange) return [false, false];
+
+ const [queriedStartTs, queriedEndTs] = isoDateRangeToTsRange(queriedDateRange);
+ return [queriedStartTs <= pipelineRange.start_ts, queriedEndTs >= pipelineRange.end_ts];
+ }, [queriedDateRange, pipelineRange]);
+
const footer = (
- loadMoreFn('past')} disabled={reachedPipelineStart}>
+ loadMoreDays('past', 7)} disabled={reachedPipelineStart}>
{reachedPipelineStart ? t('diary.no-more-travel') : t('diary.show-older-travel')}
);
- const reachedPipelineEnd = queriedRange?.end_ts >= pipelineRange?.end_ts;
const header = (
- loadMoreFn('future')} disabled={reachedPipelineEnd}>
+ loadMoreDays('future', 7)} disabled={reachedPipelineEnd}>
{reachedPipelineEnd ? t('diary.no-more-travel') : t('diary.show-more-travel')}
);
@@ -70,7 +69,7 @@ const TimelineScrollList = ({
/* Condition: pipelineRange has been fetched but has no defined end, meaning nothing has been
processed for this OPCode yet, and there are no unprocessed trips either. Show 'no travel'. */
return noTravelBanner;
- } else if (isLoading == 'replace') {
+ } else if (timelineIsLoading == 'replace') {
/* Condition: we're loading an entirely new batch of trips, so show a big spinner */
return bigSpinner;
} else if (listEntries && listEntries.length == 0) {
@@ -90,9 +89,9 @@ const TimelineScrollList = ({
This might be a nicer experience than the current header and footer buttons. */
// onScroll={e => console.debug(e.nativeEvent.contentOffset.y)}
ListHeaderComponent={
- isLoading == 'append' ? smallSpinner : !reachedPipelineEnd ? header : null
+ timelineIsLoading == 'append' ? smallSpinner : !reachedPipelineEnd ? header : null
}
- ListFooterComponent={isLoading == 'prepend' ? smallSpinner : footer}
+ ListFooterComponent={timelineIsLoading == 'prepend' ? smallSpinner : footer}
ItemSeparatorComponent={separator}
/* use column-reverse so that the list is 'inverted', meaning it should start
scrolling from the bottom, and the bottom-most item should be first in the DOM tree
diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts
index 3dae4f370..d5c1e507b 100644
--- a/www/js/diary/timelineHelper.ts
+++ b/www/js/diary/timelineHelper.ts
@@ -278,12 +278,13 @@ function locations2GeojsonTrajectory(
// DB entries retrieved from the server have '_id', 'metadata', and 'data' fields.
// This function returns a shallow copy of the obj, which flattens the
// 'data' field into the top level, while also including '_id' and 'metadata.key'
-const unpackServerData = (obj: BEMData) => ({
- ...obj.data,
- _id: obj._id,
- key: obj.metadata.key,
- origin_key: obj.metadata.origin_key || obj.metadata.key,
-});
+const unpackServerData = (obj: BEMData) =>
+ obj && {
+ ...obj.data,
+ _id: obj._id,
+ key: obj.metadata.key,
+ origin_key: obj.metadata.origin_key || obj.metadata.key,
+ };
export function readAllCompositeTrips(startTs: number, endTs: number) {
const readPromises = [getRawEntries(['analysis/composite_trip'], startTs, endTs, 'data.end_ts')];
@@ -648,3 +649,26 @@ export function readUnprocessedTrips(
},
);
}
+
+/**
+ * @example IsoDateWithOffset('2024-03-22', 1) -> '2024-03-23'
+ * @example IsoDateWithOffset('2024-03-22', -1000) -> '2021-06-26'
+ */
+export function isoDateWithOffset(date: string, offset: number) {
+ let d = new Date(date);
+ d.setUTCDate(d.getUTCDate() + offset);
+ return d.toISOString().substring(0, 10);
+}
+
+export const isoDateRangeToTsRange = (dateRange: [string, string], zone?) => [
+ DateTime.fromISO(dateRange[0], { zone: zone }).startOf('day').toSeconds(),
+ DateTime.fromISO(dateRange[1], { zone: zone }).endOf('day').toSeconds(),
+];
+
+/**
+ * @example isoDatesDifference('2024-03-22', '2024-03-29') -> 7
+ * @example isoDatesDifference('2024-03-22', '2021-06-26') -> 1000
+ * @example isoDatesDifference('2024-03-29', '2024-03-25') -> -4
+ */
+export const isoDatesDifference = (date1: string, date2: string) =>
+ -DateTime.fromISO(date1).diff(DateTime.fromISO(date2), 'days').days;
diff --git a/www/js/diary/useDerivedProperties.tsx b/www/js/diary/useDerivedProperties.tsx
index a6985a8e5..06a870fe8 100644
--- a/www/js/diary/useDerivedProperties.tsx
+++ b/www/js/diary/useDerivedProperties.tsx
@@ -9,11 +9,11 @@ import {
getDetectedModes,
isMultiDay,
} from './diaryHelper';
-import LabelTabContext from './LabelTabContext';
+import TimelineContext from '../TimelineContext';
const useDerivedProperties = (tlEntry) => {
const imperialConfig = useImperialConfig();
- const { confirmedModeFor } = useContext(LabelTabContext);
+ const { confirmedModeFor } = useContext(TimelineContext);
return useMemo(() => {
const beginFmt = tlEntry.start_fmt_time || tlEntry.enter_fmt_time;
diff --git a/www/js/metrics/ActiveMinutesTableCard.tsx b/www/js/metrics/ActiveMinutesTableCard.tsx
index 92a6ac768..57587e018 100644
--- a/www/js/metrics/ActiveMinutesTableCard.tsx
+++ b/www/js/metrics/ActiveMinutesTableCard.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo, useState } from 'react';
+import React, { useContext, useMemo, useState } from 'react';
import { Card, DataTable, useTheme } from 'react-native-paper';
import { MetricsData } from './metricsTypes';
import { cardStyles } from './MetricsTab';
@@ -7,21 +7,27 @@ import {
formatDateRangeOfDays,
secondsToMinutes,
segmentDaysByWeeks,
+ valueForModeOnDay,
} from './metricsHelper';
import { useTranslation } from 'react-i18next';
import { ACTIVE_MODES } from './WeeklyActiveMinutesCard';
import { labelKeyToRichMode } from '../survey/multilabel/confirmHelper';
+import TimelineContext from '../TimelineContext';
type Props = { userMetrics?: MetricsData };
const ActiveMinutesTableCard = ({ userMetrics }: Props) => {
const { colors } = useTheme();
+ const { dateRange } = useContext(TimelineContext);
const { t } = useTranslation();
const cumulativeTotals = useMemo(() => {
if (!userMetrics?.duration) return [];
const totals = {};
ACTIVE_MODES.forEach((mode) => {
- const sum = userMetrics.duration.reduce((acc, day) => acc + (day[`label_${mode}`] || 0), 0);
+ const sum = userMetrics.duration.reduce(
+ (acc, day) => acc + (valueForModeOnDay(day, mode) || 0),
+ 0,
+ );
totals[mode] = secondsToMinutes(sum);
});
totals['period'] = formatDateRangeOfDays(userMetrics.duration);
@@ -30,12 +36,12 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => {
const recentWeeksActiveModesTotals = useMemo(() => {
if (!userMetrics?.duration) return [];
- return segmentDaysByWeeks(userMetrics.duration)
+ return segmentDaysByWeeks(userMetrics.duration, dateRange[1])
.reverse()
.map((week) => {
const totals = {};
ACTIVE_MODES.forEach((mode) => {
- const sum = week.reduce((acc, day) => acc + (day[`label_${mode}`] || 0), 0);
+ const sum = week.reduce((acc, day) => acc + (valueForModeOnDay(day, mode) || 0), 0);
totals[mode] = secondsToMinutes(sum);
});
totals['period'] = formatDateRangeOfDays(week);
@@ -49,7 +55,7 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => {
.map((day) => {
const totals = {};
ACTIVE_MODES.forEach((mode) => {
- const sum = day[`label_${mode}`] || 0;
+ const sum = valueForModeOnDay(day, mode) || 0;
totals[mode] = secondsToMinutes(sum);
});
totals['period'] = formatDate(day);
diff --git a/www/js/metrics/CarbonFootprintCard.tsx b/www/js/metrics/CarbonFootprintCard.tsx
index 56e955f60..68b68c5b1 100644
--- a/www/js/metrics/CarbonFootprintCard.tsx
+++ b/www/js/metrics/CarbonFootprintCard.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useMemo } from 'react';
+import React, { useState, useMemo, useContext } from 'react';
import { View } from 'react-native';
import { Card, Text } from 'react-native-paper';
import { MetricsData } from './metricsTypes';
@@ -16,6 +16,7 @@ import {
segmentDaysByWeeks,
isCustomLabels,
MetricsSummary,
+ dateForDayOfMetricData,
} from './metricsHelper';
import { useTranslation } from 'react-i18next';
import BarChart from '../components/BarChart';
@@ -23,10 +24,13 @@ import ChangeIndicator, { CarbonChange } from './ChangeIndicator';
import color from 'color';
import { useAppTheme } from '../appTheme';
import { logDebug, logWarn } from '../plugin/logger';
+import TimelineContext from '../TimelineContext';
+import { isoDatesDifference } from '../diary/timelineHelper';
type Props = { userMetrics?: MetricsData; aggMetrics?: MetricsData };
const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => {
const { colors } = useAppTheme();
+ const { dateRange } = useContext(TimelineContext);
const { t } = useTranslation();
const [emissionsChange, setEmissionsChange] = useState(undefined);
@@ -34,12 +38,18 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => {
const userCarbonRecords = useMemo(() => {
if (userMetrics?.distance?.length) {
//separate data into weeks
- const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks(userMetrics?.distance, 2);
+ const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks(
+ userMetrics?.distance,
+ dateRange[1],
+ );
//formatted data from last week, if exists (14 days ago -> 8 days ago)
let userLastWeekModeMap = {};
let userLastWeekSummaryMap = {};
- if (lastWeekDistance && lastWeekDistance?.length == 7) {
+ if (
+ lastWeekDistance &&
+ isoDatesDifference(dateRange[0], dateForDayOfMetricData(lastWeekDistance[0])) >= 0
+ ) {
userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user');
userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance');
}
@@ -109,7 +119,7 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => {
const groupCarbonRecords = useMemo(() => {
if (aggMetrics?.distance?.length) {
//separate data into weeks
- const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, 1)[0];
+ const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, dateRange[1])[0];
logDebug(`groupCarbonRecords: aggMetrics = ${JSON.stringify(aggMetrics)};
thisWeekDistance = ${JSON.stringify(thisWeekDistance)}`);
@@ -164,7 +174,8 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => {
const cardSubtitleText = useMemo(() => {
if (!aggMetrics?.distance?.length) return;
- const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, 2)
+ const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, dateRange[1])
+ .slice(0, 2)
.reverse()
.flat();
const recentEntriesRange = formatDateRangeOfDays(recentEntries);
diff --git a/www/js/metrics/CarbonTextCard.tsx b/www/js/metrics/CarbonTextCard.tsx
index bf89bdb49..dd9e25231 100644
--- a/www/js/metrics/CarbonTextCard.tsx
+++ b/www/js/metrics/CarbonTextCard.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo } from 'react';
+import React, { useContext, useMemo } from 'react';
import { View } from 'react-native';
import { Card, Text, useTheme } from 'react-native-paper';
import { MetricsData } from './metricsTypes';
@@ -16,23 +16,33 @@ import {
calculatePercentChange,
segmentDaysByWeeks,
MetricsSummary,
+ dateForDayOfMetricData,
} from './metricsHelper';
import { logDebug, logWarn } from '../plugin/logger';
+import TimelineContext from '../TimelineContext';
+import { isoDatesDifference } from '../diary/timelineHelper';
type Props = { userMetrics?: MetricsData; aggMetrics?: MetricsData };
const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => {
const { colors } = useTheme();
+ const { dateRange } = useContext(TimelineContext);
const { t } = useTranslation();
const userText = useMemo(() => {
if (userMetrics?.distance?.length) {
//separate data into weeks
- const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks(userMetrics?.distance, 2);
+ const [thisWeekDistance, lastWeekDistance] = segmentDaysByWeeks(
+ userMetrics?.distance,
+ dateRange[1],
+ );
//formatted data from last week, if exists (14 days ago -> 8 days ago)
let userLastWeekModeMap = {};
let userLastWeekSummaryMap = {};
- if (lastWeekDistance && lastWeekDistance?.length == 7) {
+ if (
+ lastWeekDistance &&
+ isoDatesDifference(dateRange[0], dateForDayOfMetricData(lastWeekDistance[0])) >= 0
+ ) {
userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user');
userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance');
}
@@ -89,7 +99,7 @@ const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => {
const groupText = useMemo(() => {
if (aggMetrics?.distance?.length) {
//separate data into weeks
- const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, 1)[0];
+ const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, dateRange[1])[0];
let aggThisWeekModeMap = parseDataFromMetrics(thisWeekDistance, 'aggregate');
let aggThisWeekSummary = generateSummaryFromData(aggThisWeekModeMap, 'distance');
@@ -139,7 +149,8 @@ const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => {
const cardSubtitleText = useMemo(() => {
if (!aggMetrics?.distance?.length) return;
- const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, 2)
+ const recentEntries = segmentDaysByWeeks(aggMetrics?.distance, dateRange[1])
+ .slice(0, 2)
.reverse()
.flat();
const recentEntriesRange = formatDateRangeOfDays(recentEntries);
diff --git a/www/js/metrics/ChangeIndicator.tsx b/www/js/metrics/ChangeIndicator.tsx
index 137113ac1..8118d59ad 100644
--- a/www/js/metrics/ChangeIndicator.tsx
+++ b/www/js/metrics/ChangeIndicator.tsx
@@ -18,26 +18,21 @@ const ChangeIndicator = ({ change }: Props) => {
if (!change) return;
let low = isFinite(change.low) ? Math.round(Math.abs(change.low)) : '∞';
let high = isFinite(change.high) ? Math.round(Math.abs(change.high)) : '∞';
-
+ if (low == '∞' && high == '∞') return; // both are ∞, no information is really conveyed; don't show
if (Math.round(change.low) == Math.round(change.high)) {
- let text = changeSign(change.low) + low + '%';
- return text;
- } else if (!(isFinite(change.low) || isFinite(change.high))) {
- return ''; //if both are not finite, no information is really conveyed, so don't show
- } else {
- let text = `${changeSign(change.low) + low}% / ${changeSign(change.high) + high}%`;
- return text;
+ // high and low being the same means there is no uncertainty; show just one percentage
+ return changeSign(change.low) + low + '%';
}
+ // when there is uncertainty, show both percentages separated by a slash
+ return `${changeSign(change.low) + low}% / ${changeSign(change.high) + high}%`;
}, [change]);
- return changeText != '' ? (
+ return changeText ? (
0 ? colors.danger : colors.success)}>
{changeText + '\n'}
{`${t('metrics.this-week')}`}
- ) : (
- <>>
- );
+ ) : null;
};
const styles: any = {
diff --git a/www/js/metrics/DailyActiveMinutesCard.tsx b/www/js/metrics/DailyActiveMinutesCard.tsx
index c6ba7cbf0..b72c20ec6 100644
--- a/www/js/metrics/DailyActiveMinutesCard.tsx
+++ b/www/js/metrics/DailyActiveMinutesCard.tsx
@@ -7,6 +7,7 @@ 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];
@@ -17,21 +18,19 @@ const DailyActiveMinutesCard = ({ userMetrics }: Props) => {
const { t } = useTranslation();
const dailyActiveMinutesRecords = useMemo(() => {
- const records: { label: string; x: string; y: number }[] = [];
+ const records: { label: string; x: number; y: number }[] = [];
const recentDays = userMetrics?.duration?.slice(-14);
recentDays?.forEach((day) => {
ACTIVE_MODES.forEach((mode) => {
- const activeSeconds = day[`label_${mode}`];
- if (activeSeconds) {
- records.push({
- label: labelKeyToRichMode(mode),
- x: `${day.ts * 1000}`, // vertical chart, milliseconds on X axis
- y: activeSeconds && activeSeconds / 60, // minutes on Y axis
- });
- }
+ const activeSeconds = valueForModeOnDay(day, mode);
+ records.push({
+ label: labelKeyToRichMode(mode),
+ x: tsForDayOfMetricData(day) * 1000, // vertical chart, milliseconds on X axis
+ y: activeSeconds ? activeSeconds / 60 : null, // minutes on Y axis
+ });
});
});
- return records as { label: ActiveMode; x: string; y: number }[];
+ return records as { label: ActiveMode; x: number; y: number }[];
}, [userMetrics?.duration]);
return (
diff --git a/www/js/metrics/MetricsCard.tsx b/www/js/metrics/MetricsCard.tsx
index 6662762c2..9a13aacdc 100644
--- a/www/js/metrics/MetricsCard.tsx
+++ b/www/js/metrics/MetricsCard.tsx
@@ -4,11 +4,17 @@ import { Card, Checkbox, Text, useTheme } from 'react-native-paper';
import colorLib from 'color';
import BarChart from '../components/BarChart';
import { DayOfMetricData } from './metricsTypes';
-import { formatDateRangeOfDays, getLabelsForDay, getUniqueLabelsForDays } from './metricsHelper';
+import {
+ formatDateRangeOfDays,
+ getLabelsForDay,
+ tsForDayOfMetricData,
+ getUniqueLabelsForDays,
+ valueForModeOnDay,
+} from './metricsHelper';
import ToggleSwitch from '../components/ToggleSwitch';
import { cardStyles } from './MetricsTab';
import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper';
-import { getBaseModeByKey, getBaseModeByText } from '../diary/diaryHelper';
+import { getBaseModeByKey, getBaseModeByText, modeColors } from '../diary/diaryHelper';
import { useTranslation } from 'react-i18next';
type Props = {
@@ -42,12 +48,12 @@ const MetricsCard = ({
metricDataDays.forEach((day) => {
const labels = getLabelsForDay(day);
labels.forEach((label) => {
- const rawVal = day[`label_${label}`];
+ const rawVal = valueForModeOnDay(day, label);
if (rawVal) {
records.push({
label: labelKeyToRichMode(label),
x: unitFormatFn ? unitFormatFn(rawVal) : rawVal,
- y: day.ts * 1000, // time (as milliseconds) will go on Y axis because it will be a horizontal chart
+ y: tsForDayOfMetricData(day) * 1000, // time (as milliseconds) will go on Y axis because it will be a horizontal chart
});
}
});
@@ -76,7 +82,10 @@ const MetricsCard = ({
// for each label, sum up cumulative values across all days
const vals = {};
uniqueLabels.forEach((label) => {
- const sum = metricDataDays.reduce((acc, day) => acc + (day[`label_${label}`] || 0), 0);
+ const sum = metricDataDays.reduce(
+ (acc, day) => acc + (valueForModeOnDay(day, label) || 0),
+ 0,
+ );
vals[label] = unitFormatFn ? unitFormatFn(sum) : sum;
});
return vals;
diff --git a/www/js/metrics/MetricsDateSelect.tsx b/www/js/metrics/MetricsDateSelect.tsx
deleted file mode 100644
index 07656ec25..000000000
--- a/www/js/metrics/MetricsDateSelect.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-/* This button launches a modal to select a date range, which determines what time period
- for which metrics should be displayed.
- The button itself is a NavBarButton, which shows the currently selected date range,
- a calendar icon, and launches the modal when clicked.
- The modal is a DatePickerModal from react-native-paper-dates, which shows a calendar
- and allows the user to select a date.
-*/
-
-import React, { useState, useCallback, useMemo } from 'react';
-import { Text, StyleSheet } from 'react-native';
-import { DatePickerModal } from 'react-native-paper-dates';
-import { Divider, useTheme } from 'react-native-paper';
-import i18next from 'i18next';
-import { useTranslation } from 'react-i18next';
-import { DateTime } from 'luxon';
-import { NavBarButton } from '../components/NavBar';
-
-type Props = {
- dateRange: DateTime[];
- setDateRange: (dateRange: [DateTime, DateTime]) => void;
-};
-const MetricsDateSelect = ({ dateRange, setDateRange }: Props) => {
- const { t } = useTranslation();
- const { colors } = useTheme();
- const [open, setOpen] = useState(false);
- const todayDate = useMemo(() => new Date(), []);
- const dateRangeAsJSDate = useMemo(
- () => [dateRange[0].toJSDate(), dateRange[1].toJSDate()],
- [dateRange],
- );
-
- const onDismiss = useCallback(() => {
- setOpen(false);
- }, [setOpen]);
-
- const onChoose = useCallback(
- ({ startDate, endDate }) => {
- const dtStartDate = DateTime.fromJSDate(startDate).startOf('day');
- let dtEndDate;
-
- if (!endDate) {
- // If no end date selected, pull range from then till present day
- dtEndDate = DateTime.now();
- } else if (
- dtStartDate.toString() === DateTime.fromJSDate(endDate).startOf('day').toString()
- ) {
- // For when only one day is selected
- // NOTE: As written, this technically timestamp will technically fetch _two_ days.
- // For more info, see: https://github.com/e-mission/e-mission-docs/issues/1027
- dtEndDate = dtStartDate.endOf('day');
- } else {
- dtEndDate = DateTime.fromJSDate(endDate).startOf('day');
- }
- setOpen(false);
- setDateRange([dtStartDate, dtEndDate]);
- },
- [setOpen, setDateRange],
- );
-
- return (
- <>
- setOpen(true)}>
- {dateRange[0] && (
- <>
- {dateRange[0].toLocaleString()}
-
- >
- )}
- {dateRange[1]?.toLocaleString() || t('diary.today')}
-
-
- >
- );
-};
-
-export const s = StyleSheet.create({
- divider: {
- width: 25,
- marginHorizontal: 'auto',
- },
-});
-
-export default MetricsDateSelect;
diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx
index 7533022a5..91b444946 100644
--- a/www/js/metrics/MetricsTab.tsx
+++ b/www/js/metrics/MetricsTab.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState, useMemo } from 'react';
+import React, { useEffect, useState, useMemo, useContext } from 'react';
import { View, ScrollView, useWindowDimensions } from 'react-native';
import { Appbar, useTheme } from 'react-native-paper';
import { useTranslation } from 'react-i18next';
@@ -7,7 +7,6 @@ import NavBar from '../components/NavBar';
import { MetricsData } from './metricsTypes';
import MetricsCard from './MetricsCard';
import { formatForDisplay, useImperialConfig } from '../config/useImperialConfig';
-import MetricsDateSelect from './MetricsDateSelect';
import WeeklyActiveMinutesCard from './WeeklyActiveMinutesCard';
import { secondsToHours, secondsToMinutes } from './metricsHelper';
import CarbonFootprintCard from './CarbonFootprintCard';
@@ -16,21 +15,29 @@ import DailyActiveMinutesCard from './DailyActiveMinutesCard';
import CarbonTextCard from './CarbonTextCard';
import ActiveMinutesTableCard from './ActiveMinutesTableCard';
import { getAggregateData, getMetrics } from '../services/commHelper';
-import { displayError, logDebug, logWarn } from '../plugin/logger';
+import { displayErrorMsg, logDebug, logWarn } from '../plugin/logger';
import useAppConfig from '../useAppConfig';
import { ServerConnConfig } from '../types/appConfigTypes';
+import DateSelect from '../diary/list/DateSelect';
+import TimelineContext from '../TimelineContext';
+import { isoDateRangeToTsRange, isoDatesDifference } from '../diary/timelineHelper';
+import { metrics_summaries } from 'e-mission-common';
+// 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;
async function fetchMetricsFromServer(
type: 'user' | 'aggregate',
- dateRange: DateTime[],
+ dateRange: [string, string],
serverConnConfig: ServerConnConfig,
) {
+ const [startTs, endTs] = isoDateRangeToTsRange(dateRange);
+ logDebug('MetricsTab: fetching metrics from server for ts range ' + startTs + ' to ' + endTs);
const query = {
freq: 'D',
- start_time: dateRange[0].toSeconds(),
- end_time: dateRange[1].toSeconds(),
+ start_time: startTs,
+ end_time: endTs,
metric_list: METRIC_LIST,
is_return_aggregate: type == 'aggregate',
};
@@ -38,31 +45,75 @@ async function fetchMetricsFromServer(
return getAggregateData('result/metrics/timestamp', query, serverConnConfig);
}
-function getLastTwoWeeksDtRange() {
- const now = DateTime.now().startOf('day');
- const start = now.minus({ days: 15 });
- const end = now.minus({ days: 1 });
- return [start, end];
-}
-
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,
+ } = useContext(TimelineContext);
- const [dateRange, setDateRange] = useState(getLastTwoWeeksDtRange);
const [aggMetrics, setAggMetrics] = useState(undefined);
- const [userMetrics, setUserMetrics] = useState(undefined);
+ // 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]);
+
+ // at least N_DAYS_TO_LOAD of timeline data should be loaded for the user metrics
useEffect(() => {
if (!appConfig?.server) return;
- loadMetricsForPopulation('user', dateRange);
- loadMetricsForPopulation('aggregate', dateRange);
- }, [dateRange, appConfig?.server]);
+ 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`);
+ }
+ }, [dateRange, timelineIsLoading, appConfig?.server]);
- async function loadMetricsForPopulation(population: 'user' | 'aggregate', dateRange: DateTime[]) {
+ // 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]);
+
+ async function loadMetricsForPopulation(
+ population: 'user' | 'aggregate',
+ dateRange: [string, string],
+ ) {
try {
logDebug(`MetricsTab: fetching metrics for population ${population}'
in date range ${JSON.stringify(dateRange)}`);
@@ -79,7 +130,7 @@ const MetricsTab = () => {
});
logDebug('MetricsTab: parsed metrics: ' + JSON.stringify(metrics));
if (population == 'user') {
- setUserMetrics(metrics as MetricsData);
+ // setUserMetrics(metrics as MetricsData);
} else {
setAggMetrics(metrics as MetricsData);
}
@@ -88,19 +139,23 @@ const MetricsTab = () => {
}
}
- function refresh() {
- setDateRange(getLastTwoWeeksDtRange());
- }
-
const { width: windowWidth } = useWindowDimensions();
const cardWidth = windowWidth * 0.88;
return (
<>
-
+
-
-
+ {
+ const start = DateTime.fromJSDate(startDate).toISODate();
+ const end = DateTime.fromJSDate(endDate).toISODate();
+ if (!start || !end) return displayErrorMsg('Invalid date');
+ setDateRange([start, end]);
+ }}
+ />
+
diff --git a/www/js/metrics/WeeklyActiveMinutesCard.tsx b/www/js/metrics/WeeklyActiveMinutesCard.tsx
index eb1a29939..5078f2cfc 100644
--- a/www/js/metrics/WeeklyActiveMinutesCard.tsx
+++ b/www/js/metrics/WeeklyActiveMinutesCard.tsx
@@ -1,13 +1,14 @@
-import React, { useMemo, useState } from 'react';
+import React, { useContext, useMemo, useState } from 'react';
import { View } from 'react-native';
import { Card, Text, useTheme } from 'react-native-paper';
import { MetricsData } from './metricsTypes';
import { cardMargin, cardStyles } from './MetricsTab';
-import { formatDateRangeOfDays, segmentDaysByWeeks } from './metricsHelper';
+import { formatDateRangeOfDays, segmentDaysByWeeks, valueForModeOnDay } 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';
export const ACTIVE_MODES = ['walk', 'bike'] as const;
type ActiveMode = (typeof ACTIVE_MODES)[number];
@@ -15,24 +16,28 @@ type ActiveMode = (typeof ACTIVE_MODES)[number];
type Props = { userMetrics?: MetricsData };
const WeeklyActiveMinutesCard = ({ userMetrics }: Props) => {
const { colors } = useTheme();
+ const { dateRange } = useContext(TimelineContext);
const { t } = useTranslation();
const weeklyActiveMinutesRecords = useMemo(() => {
if (!userMetrics?.duration) return [];
const records: { x: string; y: number; label: string }[] = [];
- const [recentWeek, prevWeek] = segmentDaysByWeeks(userMetrics?.duration, 2);
+ const [recentWeek, prevWeek] = segmentDaysByWeeks(userMetrics?.duration, dateRange[1]);
ACTIVE_MODES.forEach((mode) => {
- const prevSum = prevWeek?.reduce((acc, day) => acc + (day[`label_${mode}`] || 0), 0);
- if (prevSum) {
- // `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`
+ if (prevWeek) {
+ const prevSum = prevWeek?.reduce(
+ (acc, day) => acc + (valueForModeOnDay(day, 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 + (day[`label_${mode}`] || 0), 0);
- if (recentSum) {
- const xLabel = `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(recentWeek)})`;
- records.push({ label: labelKeyToRichMode(mode), x: xLabel, y: recentSum / 60 });
- }
+ const recentSum = recentWeek?.reduce(
+ (acc, day) => acc + (valueForModeOnDay(day, mode) || 0),
+ 0,
+ );
+ const xLabel = `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(recentWeek)})`;
+ records.push({ label: labelKeyToRichMode(mode), x: xLabel, y: recentSum / 60 });
});
return records as { label: ActiveMode; x: string; y: number }[];
}, [userMetrics?.duration]);
diff --git a/www/js/metrics/footprintHelper.ts b/www/js/metrics/footprintHelper.ts
index c37d8de92..2a02ea133 100644
--- a/www/js/metrics/footprintHelper.ts
+++ b/www/js/metrics/footprintHelper.ts
@@ -44,12 +44,12 @@ export function getFootprintForMetrics(userMetrics, defaultIfMissing = 0) {
const footprint = getFootprint();
logDebug('getting footprint for ' + userMetrics + ' with ' + footprint);
let result = 0;
- for (let i in userMetrics) {
- let mode = userMetrics[i].key;
+ userMetrics.forEach((userMetric) => {
+ let mode = userMetric.key;
//either the mode is in our custom footprint or it is not
if (mode in footprint) {
- result += footprint[mode] * mtokm(userMetrics[i].values);
+ result += footprint[mode] * mtokm(userMetric.values);
} else if (mode == 'IN_VEHICLE') {
const sum =
footprint['CAR'] +
@@ -58,16 +58,16 @@ export function getFootprintForMetrics(userMetrics, defaultIfMissing = 0) {
footprint['TRAIN'] +
footprint['TRAM'] +
footprint['SUBWAY'];
- result += (sum / 6) * mtokm(userMetrics[i].values);
+ result += (sum / 6) * mtokm(userMetric.values);
} else {
logWarn(
`WARNING getFootprintFromMetrics() was requested for an unknown mode: ${mode} metrics JSON: ${JSON.stringify(
userMetrics,
)}`,
);
- result += defaultIfMissing * mtokm(userMetrics[i].values);
+ result += defaultIfMissing * mtokm(userMetric.values);
}
- }
+ });
return result;
}
diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts
index ca3846806..8097e460f 100644
--- a/www/js/metrics/metricsHelper.ts
+++ b/www/js/metrics/metricsHelper.ts
@@ -2,13 +2,15 @@ import { DateTime } from 'luxon';
import { formatForDisplay } from '../config/useImperialConfig';
import { DayOfMetricData } from './metricsTypes';
import { logDebug } from '../plugin/logger';
+import { isoDateWithOffset, isoDatesDifference } from '../diary/timelineHelper';
export function getUniqueLabelsForDays(metricDataDays: DayOfMetricData[]) {
const uniqueLabels: string[] = [];
metricDataDays.forEach((e) => {
Object.keys(e).forEach((k) => {
- if (k.startsWith('label_')) {
- const label = k.substring(6); // remove 'label_' prefix leaving just the mode label
+ 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);
}
});
@@ -18,8 +20,9 @@ export function getUniqueLabelsForDays(metricDataDays: DayOfMetricData[]) {
export const getLabelsForDay = (metricDataDay: DayOfMetricData) =>
Object.keys(metricDataDay).reduce((acc, k) => {
- if (k.startsWith('label_')) {
- acc.push(k.substring(6)); // remove 'label_' prefix leaving just the mode label
+ if (k.startsWith('label_') || k.startsWith('mode_')) {
+ let i = k.indexOf('_');
+ acc.push(k.substring(i + 1)); // remove prefix leaving just the mode label
}
return acc;
}, [] as string[]);
@@ -29,24 +32,34 @@ export const secondsToMinutes = (seconds: number) => formatForDisplay(seconds /
export const secondsToHours = (seconds: number) => formatForDisplay(seconds / 3600);
// segments metricsDays into weeks, with the most recent week first
-export function segmentDaysByWeeks(days: DayOfMetricData[], nWeeks?: number) {
- const weeks: DayOfMetricData[][] = [];
- for (let i = days?.length - 1; i >= 0; i -= 7) {
- weeks.push(days.slice(Math.max(i - 6, 0), i + 1));
+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) {
+ weeks.push([]);
+ cutoff = isoDateWithOffset(lastDate, -7 * weeks.length);
+ }
+ weeks[weeks.length - 1].push(days[i]);
}
- if (nWeeks) return weeks.slice(0, nWeeks);
- return weeks;
+ return weeks.map((week) => week.reverse());
}
export function formatDate(day: DayOfMetricData) {
- const dt = DateTime.fromISO(day.fmt_time, { zone: 'utc' });
+ const dt = DateTime.fromISO(dateForDayOfMetricData(day), { zone: 'utc' });
return dt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined });
}
export function formatDateRangeOfDays(days: DayOfMetricData[]) {
if (!days?.length) return '';
- const firstDayDt = DateTime.fromISO(days[0].fmt_time, { zone: 'utc' });
- const lastDayDt = DateTime.fromISO(days[days.length - 1].fmt_time, { zone: 'utc' });
+ const firstDayDt = DateTime.fromISO(dateForDayOfMetricData(days[0]), {
+ zone: 'utc',
+ });
+ const lastDayDt = DateTime.fromISO(dateForDayOfMetricData(days[days.length - 1]), {
+ zone: 'utc',
+ });
const firstDay = firstDayDt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined });
const lastDay = lastDayDt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined });
return `${firstDay} - ${lastDay}`;
@@ -115,8 +128,9 @@ export function parseDataFromMetrics(metrics, population) {
}
}
//this section handles user lables, assuming 'label_' prefix
- if (field.startsWith('label_')) {
- let actualMode = field.slice(6, field.length); //remove 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] = [];
@@ -137,6 +151,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();
+
+export const valueForModeOnDay = (day: DayOfMetricData, key: string) =>
+ day[`mode_${key}`] || day[`label_${key}`];
+
export type MetricsSummary = { key: string; values: number };
export function generateSummaryFromData(modeMap, metric) {
logDebug(`Invoked getSummaryDataRaw on ${JSON.stringify(modeMap)} with ${metric}`);
diff --git a/www/js/metrics/metricsTypes.ts b/www/js/metrics/metricsTypes.ts
index cce1cd243..826d7ec70 100644
--- a/www/js/metrics/metricsTypes.ts
+++ b/www/js/metrics/metricsTypes.ts
@@ -2,14 +2,22 @@ import { LocalDt } from '../types/serverData';
import { METRIC_LIST } from './MetricsTab';
type MetricName = (typeof METRIC_LIST)[number];
+
type LabelProps = { [k in `label_${string}`]?: number }; // label_, where could be anything
-export type DayOfMetricData = LabelProps & {
+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 & {
+ date: string; // yyyy-mm-dd
+};
+
+export type DayOfMetricData = DayOfClientMetricData | DayOfServerMetricData;
+
export type MetricsData = {
[key in MetricName]: DayOfMetricData[];
};
diff --git a/www/js/survey/enketo/AddNoteButton.tsx b/www/js/survey/enketo/AddNoteButton.tsx
index 8f2b11726..79a8cf982 100644
--- a/www/js/survey/enketo/AddNoteButton.tsx
+++ b/www/js/survey/enketo/AddNoteButton.tsx
@@ -11,7 +11,7 @@ import React, { useEffect, useState, useContext } from 'react';
import DiaryButton from '../../components/DiaryButton';
import { useTranslation } from 'react-i18next';
import { DateTime } from 'luxon';
-import LabelTabContext from '../../diary/LabelTabContext';
+import TimelineContext from '../../TimelineContext';
import EnketoModal from './EnketoModal';
import { displayErrorMsg, logDebug } from '../../plugin/logger';
import { isTrip } from '../../types/diaryTypes';
@@ -24,7 +24,7 @@ type Props = {
const AddNoteButton = ({ timelineEntry, notesConfig, storeKey }: Props) => {
const { t, i18n } = useTranslation();
const [displayLabel, setDisplayLabel] = useState('');
- const { notesFor, addUserInputToEntry } = useContext(LabelTabContext);
+ const { notesFor, addUserInputToEntry } = useContext(TimelineContext);
useEffect(() => {
let newLabel: string;
diff --git a/www/js/survey/enketo/AddedNotesList.tsx b/www/js/survey/enketo/AddedNotesList.tsx
index 91cea8536..155dabace 100644
--- a/www/js/survey/enketo/AddedNotesList.tsx
+++ b/www/js/survey/enketo/AddedNotesList.tsx
@@ -6,7 +6,7 @@ import React, { useContext, useState } from 'react';
import { DateTime } from 'luxon';
import { Modal } from 'react-native';
import { Text, Button, DataTable, Dialog, Icon } from 'react-native-paper';
-import LabelTabContext from '../../diary/LabelTabContext';
+import TimelineContext from '../../TimelineContext';
import { getFormattedDateAbbr, isMultiDay } from '../../diary/diaryHelper';
import EnketoModal from './EnketoModal';
import { useTranslation } from 'react-i18next';
@@ -19,7 +19,7 @@ type Props = {
};
const AddedNotesList = ({ timelineEntry, additionEntries }: Props) => {
const { t } = useTranslation();
- const { addUserInputToEntry } = useContext(LabelTabContext);
+ const { addUserInputToEntry } = useContext(TimelineContext);
const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] = useState(false);
const [surveyModalVisible, setSurveyModalVisible] = useState(false);
const [editingEntry, setEditingEntry] = useState(undefined);
diff --git a/www/js/survey/enketo/UserInputButton.tsx b/www/js/survey/enketo/UserInputButton.tsx
index de66d5d7f..c4d28eee8 100644
--- a/www/js/survey/enketo/UserInputButton.tsx
+++ b/www/js/survey/enketo/UserInputButton.tsx
@@ -14,7 +14,7 @@ import { useTranslation } from 'react-i18next';
import { useTheme } from 'react-native-paper';
import { displayErrorMsg, logDebug } from '../../plugin/logger';
import EnketoModal from './EnketoModal';
-import LabelTabContext from '../../diary/LabelTabContext';
+import TimelineContext from '../../TimelineContext';
import useAppConfig from '../../useAppConfig';
import { getSurveyForTimelineEntry } from './conditionalSurveys';
import useDerivedProperties from '../../diary/useDerivedProperties';
@@ -31,7 +31,7 @@ const UserInputButton = ({ timelineEntry }: Props) => {
const [prevSurveyResponse, setPrevSurveyResponse] = useState(undefined);
const [modalVisible, setModalVisible] = useState(false);
- const { userInputFor, addUserInputToEntry } = useContext(LabelTabContext);
+ const { userInputFor, addUserInputToEntry } = useContext(TimelineContext);
const derivedTripProps = useDerivedProperties(timelineEntry);
// which survey will this button launch?
diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts
index bd7e861e6..604c533b2 100644
--- a/www/js/survey/inputMatcher.ts
+++ b/www/js/survey/inputMatcher.ts
@@ -18,7 +18,7 @@ import {
inputType2retKey,
removeManualPrefix,
} from './multilabel/confirmHelper';
-import { TimelineLabelMap, TimelineNotesMap, UserInputMap } from '../diary/LabelTabContext';
+import { TimelineLabelMap, TimelineNotesMap, UserInputMap } from '../TimelineContext';
import { MultilabelKey } from '../types/labelTypes';
import { EnketoUserInputEntry } from './enketo/enketoHelper';
import { AppConfig } from '../types/appConfigTypes';
diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx
index 517223141..6c4c876ce 100644
--- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx
+++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx
@@ -15,7 +15,7 @@ import {
} from 'react-native-paper';
import DiaryButton from '../../components/DiaryButton';
import { useTranslation } from 'react-i18next';
-import LabelTabContext, { UserInputMap } from '../../diary/LabelTabContext';
+import TimelineContext, { UserInputMap } from '../../TimelineContext';
import { displayErrorMsg, logDebug } from '../../plugin/logger';
import {
getLabelInputDetails,
@@ -34,7 +34,7 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => {
const { colors } = useTheme();
const { t } = useTranslation();
const appConfig = useAppConfig();
- const { labelOptions, labelFor, userInputFor, addUserInputToEntry } = useContext(LabelTabContext);
+ const { labelOptions, labelFor, userInputFor, addUserInputToEntry } = useContext(TimelineContext);
const { height: windowHeight } = useWindowDimensions();
// modal visible for which input type? (MODE or PURPOSE or REPLACED_MODE, null if not visible)
diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts
index f94032f3e..bc3a2b717 100644
--- a/www/js/survey/multilabel/confirmHelper.ts
+++ b/www/js/survey/multilabel/confirmHelper.ts
@@ -4,7 +4,7 @@ import enJson from '../../../i18n/en.json';
import { logDebug } from '../../plugin/logger';
import { LabelOption, LabelOptions, MultilabelKey, InputDetails } from '../../types/labelTypes';
import { CompositeTrip, InferredLabels, TimelineEntry } from '../../types/diaryTypes';
-import { TimelineLabelMap, UserInputMap } from '../../diary/LabelTabContext';
+import { UserInputMap } from '../../TimelineContext';
let appConfig;
export let labelOptions: LabelOptions;