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;