From b18e6107e3dd2bbf8aaffcebc55a7164f0e9e1b6 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 14 Mar 2024 16:58:02 -0400 Subject: [PATCH 01/33] extract main content of into new component,
will sit within and contain BottomNavigation, which has the 3 tabs LabelTab, MetricsTab, and ProfileSettings. This will allow the tabs to share some information, but be categorically separate from OnboardingStack. --- www/js/App.tsx | 65 +++----------------------------------------- www/js/Main.tsx | 72 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 61 deletions(-) create mode 100644 www/js/Main.tsx 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..c9b0aea8b --- /dev/null +++ b/www/js/Main.tsx @@ -0,0 +1,72 @@ +/* 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'; + +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 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; From 0e86c121901fa3d8c7bd237f0820a939f5a75092 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 19 Mar 2024 11:23:22 -0400 Subject: [PATCH 02/33] refactor LabelTabContext into TimelineContext Up to this point, the Label tab and Dashboard tab have been completely separate in terms of the data they use. The label tab read composite trips and the dashboard tab read metrics from the server. We plan to unify them to both use composite trips. This way, one query provides all the needed information to both tabs and there will no longer be a need to re-fetch when switching between these tabs. To do this, the tabs need to share common data in a context that is higher up in the component tree. Thus, much of the state that was previously kept at the level of , (in LabelTabContext) is being hoisted up to the level of
, and will be kept in a context called TimelineContext, which can be shared between the tabs. The contents of TimelineContext are essentially just copied over from LabelTabContext. Two values are renamed: "isLoading" -> "timelineIsLoading" and "refresh" -> "refreshTimeline". The things that are still specific to the Label tab (i.e. filtering dropdown "To Label" vs "All trips") are kept in LabelTab. Since LabelTabContext now has only 3 state values, and LabelTab.tsx is not that complex anymore, I included LabelTabContext directly in LabelTab.tsx rather than its own file. At the end of this refactoring, the majority of the complexity is now in TimelineContext. It is instantiated in
and provided to the children of
(the tabs). --- www/__tests__/confirmHelper.test.ts | 2 +- www/js/Main.tsx | 28 +- www/js/TimelineContext.ts | 311 ++++++++++++++++ www/js/diary/LabelTab.tsx | 337 +++--------------- www/js/diary/LabelTabContext.ts | 49 --- www/js/diary/cards/ModesIndicator.tsx | 4 +- www/js/diary/cards/PlaceCard.tsx | 4 +- www/js/diary/cards/TripCard.tsx | 4 +- www/js/diary/details/LabelDetailsScreen.tsx | 4 +- .../details/TripSectionsDescriptives.tsx | 4 +- www/js/diary/list/DateSelect.tsx | 4 +- www/js/diary/list/FilterSelect.tsx | 2 +- www/js/diary/list/LabelListScreen.tsx | 17 +- www/js/survey/enketo/AddNoteButton.tsx | 4 +- www/js/survey/enketo/AddedNotesList.tsx | 4 +- www/js/survey/enketo/UserInputButton.tsx | 4 +- www/js/survey/inputMatcher.ts | 2 +- .../multilabel/MultiLabelButtonGroup.tsx | 4 +- www/js/survey/multilabel/confirmHelper.ts | 2 +- 19 files changed, 404 insertions(+), 386 deletions(-) create mode 100644 www/js/TimelineContext.ts delete mode 100644 www/js/diary/LabelTabContext.ts diff --git a/www/__tests__/confirmHelper.test.ts b/www/__tests__/confirmHelper.test.ts index 52cb9c0e8..12657d589 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/js/Main.tsx b/www/js/Main.tsx index c9b0aea8b..6361e6828 100644 --- a/www/js/Main.tsx +++ b/www/js/Main.tsx @@ -10,6 +10,7 @@ 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) => [ { @@ -47,6 +48,7 @@ const Main = () => { const { colors } = useTheme(); const { t } = useTranslation(); const { appConfig } = useContext(AppContext); + const timelineContext = useTimelineContext(); const routes = useMemo(() => { const showMetrics = appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL'; @@ -54,18 +56,20 @@ const Main = () => { }, [appConfig, t]); return ( - + + + ); }; diff --git a/www/js/TimelineContext.ts b/www/js/TimelineContext.ts new file mode 100644 index 000000000..a00d0cd76 --- /dev/null +++ b/www/js/TimelineContext.ts @@ -0,0 +1,311 @@ +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 { + compositeTrips2TimelineMap, + readAllCompositeTrips, + readUnprocessedTrips, + unprocessedLabels, + unprocessedNotes, + updateAllUnprocessedInputs, + updateLocalUnprocessedInputs, +} from './diary/timelineHelper'; +import { getPipelineRangeTs } from './services/commHelper'; +import { getNotDeletedCandidates, mapInputsToTimelineEntries } from './survey/inputMatcher'; +import { publish } from './customEventHandler'; +import { EnketoUserInputEntry } from './survey/enketo/enketoHelper'; + +const ONE_DAY = 24 * 60 * 60; // seconds +const ONE_WEEK = ONE_DAY * 7; // seconds + +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) => LabelOption | undefined; + addUserInputToEntry: (oid: string, userInput: any, inputType: 'label' | 'note') => void; + queriedRange: TimestampRange | null; + pipelineRange: TimestampRange | null; + timelineIsLoading: string | false; + loadAnotherWeek: (when: 'past' | 'future') => void; + loadSpecificWeek: (d: Date) => 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); + // timestamp range that has been loaded into the UI + const [queriedRange, setQueriedRange] = useState(null); + // 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]); + + 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(`LabelTab: After updating unprocessedInputs, + unprocessedLabels = ${JSON.stringify(unprocessedLabels)}; + unprocessedNotes = ${JSON.stringify(unprocessedNotes)}`); + setPipelineRange(pipelineRange); + } catch (e) { + displayError(e, t('errors.while-loading-pipeline-range')); + setTimelineIsLoading(false); + } + } + + 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 (!timelineIsLoading) setTimelineIsLoading('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 (!timelineIsLoading) setTimelineIsLoading('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 (!timelineIsLoading) setTimelineIsLoading('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) { + setTimelineIsLoading(false); + displayError(e, t('errors.while-loading-another-week', { when: when })); + } + } + + async function loadSpecificWeek(day: Date) { + try { + logDebug('LabelTab: loadSpecificWeek for day ' + day); + if (!timelineIsLoading) setTimelineIsLoading('replace'); + 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) { + setTimelineIsLoading(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); + const showPlaces = Boolean(appConfig.survey_info?.buttons?.['place-notes']); + 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, 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; + } + + function refreshTimeline() { + try { + logDebug('timelineContext: refreshTimeline'); + setTimelineIsLoading('replace'); + setQueriedRange(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; + }; + + 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( + () => + 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, + queriedRange, + timelineMap, + timelineIsLoading, + timelineLabelMap, + labelOptions, + loadAnotherWeek, + loadSpecificWeek, + refreshTimeline, + userInputFor, + labelFor, + notesFor, + addUserInputToEntry, + }; +}; + +export type UserInputMap = { + /* if the key here is 'SURVEY', we are in the ENKETO configuration, meaning the user input + value will have the raw 'xmlResponse' string */ + SURVEY?: EnketoUserInputEntry; +} & { + /* all other keys, (e.g. 'MODE', 'PURPOSE') are from the MULTILABEL configuration + and will have the 'label' string but no 'xmlResponse' string */ + [k in MultilabelKey]?: UserInputEntry; +}; + +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/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 0ceaf0505..b3f1d84e2 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -2,116 +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, -} 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'; - -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, loadAnotherWeek } = 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()); @@ -150,221 +102,22 @@ 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)}`); - 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 useEffect(() => { if (pipelineRange && pipelineRange.end_ts) { + updateAllUnprocessedInputs(pipelineRange, appConfig); loadAnotherWeek('past'); } }, [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, 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; - }; - - 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, - 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 9e80cccae..000000000 --- a/www/js/diary/LabelTabContext.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { createContext } from 'react'; -import { TimelineEntry, TimestampRange, UserInputEntry } from '../types/diaryTypes'; -import { LabelOption, LabelOptions, MultilabelKey } from '../types/labelTypes'; -import { EnketoUserInputEntry } from '../survey/enketo/enketoHelper'; - -export type UserInputMap = { - /* if the key here is 'SURVEY', we are in the ENKETO configuration, meaning the user input - value will have the raw 'xmlResponse' string */ - SURVEY?: EnketoUserInputEntry; -} & { - /* all other keys, (e.g. 'MODE', 'PURPOSE') are from the MULTILABEL configuration - and will have the 'label' string but no 'xmlResponse' string */ - [k in MultilabelKey]?: UserInputEntry; -}; - -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; - 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 bba65c107..661bee53e 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 { 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 } = useContext(LabelTabContext); + const { labelOptions, labelFor } = 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 3504bde16..83ef84323 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, labelFor, notesFor } = useContext(LabelTabContext); + const { labelOptions, labelFor, notesFor } = useContext(TimelineContext); const tripGeojson = trip && labelOptions && useGeojsonForTrip(trip, labelOptions, labelFor(trip, 'MODE')?.value); diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx index 4985300cb..6e9837f3b 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, labelFor } = useContext(LabelTabContext); + const { timelineMap, labelOptions, labelFor } = 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 fdab61eb3..6bdb74a7f 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, showLabeledMode = false }) => { - const { labelOptions, labelFor } = useContext(LabelTabContext); + const { labelOptions, labelFor } = useContext(TimelineContext); const { displayStartTime, displayTime, diff --git a/www/js/diary/list/DateSelect.tsx b/www/js/diary/list/DateSelect.tsx index 02b8d1ca1..3ed54aa40 100644 --- a/www/js/diary/list/DateSelect.tsx +++ b/www/js/diary/list/DateSelect.tsx @@ -9,7 +9,7 @@ import React, { useEffect, useState, useMemo, useContext } from 'react'; import { StyleSheet } from 'react-native'; import { DateTime } from 'luxon'; -import LabelTabContext from '../LabelTabContext'; +import TimelineContext from '../../TimelineContext'; import { DatePickerModal } from 'react-native-paper-dates'; import { Text, Divider, useTheme } from 'react-native-paper'; import i18next from 'i18next'; @@ -17,7 +17,7 @@ import { useTranslation } from 'react-i18next'; import { NavBarButton } from '../../components/NavBar'; const DateSelect = ({ tsRange, loadSpecificWeekFn }) => { - const { pipelineRange } = useContext(LabelTabContext); + const { pipelineRange } = useContext(TimelineContext); const { t } = useTranslation(); const { colors } = useTheme(); const [open, setOpen] = React.useState(false); 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..341f037bb 100644 --- a/www/js/diary/list/LabelListScreen.tsx +++ b/www/js/diary/list/LabelListScreen.tsx @@ -4,22 +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'; const LabelListScreen = () => { + const { filterInputs, setFilterInputs, displayedEntries } = useContext(LabelTabContext); const { - filterInputs, - setFilterInputs, timelineMap, - displayedEntries, queriedRange, loadSpecificWeek, - refresh, + refreshTimeline, pipelineRange, loadAnotherWeek, - isLoading, - } = useContext(LabelTabContext); + timelineIsLoading, + } = useContext(TimelineContext); const { colors } = useTheme(); return ( @@ -38,7 +37,7 @@ const LabelListScreen = () => { refresh()} + onPress={() => refreshTimeline()} accessibilityLabel="Refresh" style={{ marginLeft: 'auto' }} /> @@ -49,7 +48,7 @@ const LabelListScreen = () => { queriedRange={queriedRange} pipelineRange={pipelineRange} loadMoreFn={loadAnotherWeek} - isLoading={isLoading} + isLoading={timelineIsLoading} /> 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 5817e7ed3..800b9e761 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'; @@ -28,7 +28,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); // which survey will this button launch? const [surveyName, notFilledInLabel] = useMemo(() => { diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index da802d2e8..4bf23a614 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -7,7 +7,7 @@ import { inputType2retKey, removeManualPrefix, } from './multilabel/confirmHelper'; -import { TimelineLabelMap, TimelineNotesMap } from '../diary/LabelTabContext'; +import { TimelineLabelMap, TimelineNotesMap } 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 58980f3c0..8ba6acca4 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; From 8d344610b83a1b7ae28b1f08b557567cd21f1810 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 22 Mar 2024 11:24:33 -0400 Subject: [PATCH 03/33] refactor TimelineContext to use dateRange The way this has been working up to now is that there are specific functions to load one week of data - either a specific week or the previous/next week. These functions load new trips and then update the queriedRange after the fact. But we need this to now work on the Metrics tab as well (where you can select *any* date range) so we are going to need to make some changes. We need to keep the chosen dates (from datepicker) centrally as state. Then we can watch it for changes; when it changes, we can compare the old and new ranges to see how they intersect: it will be either i) we are loading more data into the past ii) we are loading more data into the future or iii) this is an entirely new range Once we have determined that, we can load in the new part(s) of the range and handle the new data in the same way as before. I chose to store the date range as yyyy-mm-dd strings. The queried date range will also be stored as yyyy-mm-dd strings The initial date range is now set as the default value of `dateRange` in TimelineContext. So the initialy query for the past week can be removed from LabelTab. --- www/js/TimelineContext.ts | 151 ++++++++++++++++++++------------- www/js/diary/LabelTab.tsx | 3 +- www/js/diary/timelineHelper.ts | 15 ++++ 3 files changed, 107 insertions(+), 62 deletions(-) diff --git a/www/js/TimelineContext.ts b/www/js/TimelineContext.ts index a00d0cd76..fed5c145f 100644 --- a/www/js/TimelineContext.ts +++ b/www/js/TimelineContext.ts @@ -1,4 +1,4 @@ -import { createContext, useEffect, useState } from 'react'; +import { createContext, useCallback, useEffect, useState } from 'react'; import { CompositeTrip, TimelineEntry, TimestampRange, UserInputEntry } from './types/diaryTypes'; import useAppConfig from './useAppConfig'; import { LabelOption, LabelOptions, MultilabelKey } from './types/labelTypes'; @@ -7,6 +7,7 @@ import { displayError, displayErrorMsg, logDebug, logWarn } from './plugin/logge import { useTranslation } from 'react-i18next'; import { DateTime } from 'luxon'; import { + isoDateWithOffset, compositeTrips2TimelineMap, readAllCompositeTrips, readUnprocessedTrips, @@ -14,6 +15,7 @@ import { unprocessedNotes, updateAllUnprocessedInputs, updateLocalUnprocessedInputs, + isoDateRangeToTsRange, } from './diary/timelineHelper'; import { getPipelineRangeTs } from './services/commHelper'; import { getNotDeletedCandidates, mapInputsToTimelineEntries } from './survey/inputMatcher'; @@ -23,6 +25,10 @@ import { EnketoUserInputEntry } from './survey/enketo/enketoHelper'; const ONE_DAY = 24 * 60 * 60; // seconds const ONE_WEEK = ONE_DAY * 7; // seconds +// 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; @@ -31,11 +37,13 @@ type ContextProps = { notesFor: (tlEntry: TimelineEntry) => UserInputEntry[] | undefined; labelFor: (tlEntry: TimelineEntry, labelType: MultilabelKey) => LabelOption | undefined; addUserInputToEntry: (oid: string, userInput: any, inputType: 'label' | 'note') => void; - queriedRange: TimestampRange | null; 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; loadAnotherWeek: (when: 'past' | 'future') => void; - loadSpecificWeek: (d: Date) => void; + loadSpecificWeek: (d: string) => void; refreshTimeline: () => void; }; @@ -46,8 +54,10 @@ export const useTimelineContext = (): ContextProps => { const [labelOptions, setLabelOptions] = useState(null); // timestamp range that has been processed by the pipeline on the server const [pipelineRange, setPipelineRange] = useState(null); - // timestamp range that has been loaded into the UI - const [queriedRange, setQueriedRange] = 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'); @@ -66,6 +76,51 @@ export const useTimelineContext = (): ContextProps => { } }, [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()); @@ -97,59 +152,26 @@ export const useTimelineContext = (): ContextProps => { } } - 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 (!timelineIsLoading) setTimelineIsLoading('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 (!timelineIsLoading) setTimelineIsLoading('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 (!timelineIsLoading) setTimelineIsLoading('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) { - setTimelineIsLoading(false); - displayError(e, t('errors.while-loading-another-week', { when: when })); + function loadAnotherWeek(when: 'past' | 'future') { + const existingRange = queriedDateRange || initialQueryRange; + logDebug(`Timeline: loadAnotherWeek for ${when}; + queriedDateRange = ${queriedDateRange}; + existingRange = ${existingRange}`); + let newDateRange: [string, string]; + if (when == 'past') { + newDateRange = [isoDateWithOffset(existingRange[0], -7), existingRange[1]]; + } else { + newDateRange = [existingRange[0], isoDateWithOffset(existingRange[1], 7)]; } + logDebug('Timeline: loadAnotherWeek setting new date range = ' + newDateRange); + setDateRange(newDateRange); } - async function loadSpecificWeek(day: Date) { - try { - logDebug('LabelTab: loadSpecificWeek for day ' + day); - if (!timelineIsLoading) setTimelineIsLoading('replace'); - 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) { - setTimelineIsLoading(false); - displayError(e, t('errors.while-loading-specific-week', { day: day })); - } + function loadSpecificWeek(date: string) { + logDebug('Timeline: loadSpecificWeek for date ' + date); + const threeDaysBefore = isoDateWithOffset(date, -3); + const threeDaysAfter = isoDateWithOffset(date, 3); + setDateRange([threeDaysBefore, threeDaysAfter]); } function handleFetchedTrips(ctList, utList, mode: 'prepend' | 'append' | 'replace') { @@ -174,24 +196,31 @@ export const useTimelineContext = (): ContextProps => { } } - async function fetchTripsInRange(startTs: number, endTs: number) { + async function fetchTripsInRange(dateRange: [string, string]) { 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); + const [startTs, endTs] = isoDateRangeToTsRange(dateRange); + + const readCompositePromise = readAllCompositeTrips( + Math.max(startTs, pipelineRange.start_ts), // ensure that we don't read before the pipeline start + Math.min(endTs, pipelineRange.end_ts), // ensure that we don't read after the pipeline end + ); + 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, lastProcessedTrip); + readUnprocessedPromise = readUnprocessedTrips(pipelineRange.end_ts, endTs, 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])}`); @@ -202,7 +231,7 @@ export const useTimelineContext = (): ContextProps => { try { logDebug('timelineContext: refreshTimeline'); setTimelineIsLoading('replace'); - setQueriedRange(null); + setQueriedDateRange(initialQueryRange); setTimelineMap(null); setRefreshTime(new Date()); } catch (e) { @@ -268,7 +297,9 @@ export const useTimelineContext = (): ContextProps => { return { pipelineRange, - queriedRange, + queriedDateRange, + dateRange, + setDateRange, timelineMap, timelineIsLoading, timelineLabelMap, diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index b3f1d84e2..47f566ed4 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -102,11 +102,10 @@ const LabelTab = () => { setDisplayedEntries(entriesToDisplay); } - // 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) { updateAllUnprocessedInputs(pipelineRange, appConfig); - loadAnotherWeek('past'); } }, [pipelineRange]); diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index f140f1750..407f9a700 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -564,3 +564,18 @@ 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(), +]; From 25466299db7be9ab0eef2d138594297047ef040b Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 22 Mar 2024 11:42:41 -0400 Subject: [PATCH 04/33] unify DateSelect for Label tab and Dashboard tab The previous commit implemented a central state for the selected timeline range. The DateSelect component needs to control this, and both the label tab and dashboard tab needs to use it. DateSelect can be made generic such that it supports either a 'single' selection (which selects a whole week on the Label tab), or it supports a 'range' selection (as on the Dashboard tab). The DateSelect will display the queried date range, and it will accept an onChoose function that controls what the selection of date or date range will do. On LabelListScreen and MetricsTab are where DateSelect will be used. On LabelListScreen, the onChoose function calls loadSpecificWeek from TimelineContext (which sets the range to the week of [3 days before X, 3 days after X]. On MetricsTab, onChoose just sets the TimelineContext date range to the same date range from the datepicker. MetricsDateSelect is no longer needed. TimelineScrollList updated to use queriedDateRange instead of queriedRange; also access timeline variables from TimelineContext instead of passing a bunch of props. --- www/js/diary/list/DateSelect.tsx | 87 ++++++++++++--------- www/js/diary/list/LabelListScreen.tsx | 23 ++---- www/js/diary/list/TimelineScrollList.tsx | 31 ++++---- www/js/metrics/MetricsDateSelect.tsx | 96 ------------------------ www/js/metrics/MetricsTab.tsx | 36 +++++---- 5 files changed, 93 insertions(+), 180 deletions(-) delete mode 100644 www/js/metrics/MetricsDateSelect.tsx diff --git a/www/js/diary/list/DateSelect.tsx b/www/js/diary/list/DateSelect.tsx index 3ed54aa40..acf84cedd 100644 --- a/www/js/diary/list/DateSelect.tsx +++ b/www/js/diary/list/DateSelect.tsx @@ -1,28 +1,35 @@ -/* 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 TimelineContext from '../../TimelineContext'; -import { DatePickerModal } from 'react-native-paper-dates'; +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(TimelineContext); +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 { @@ -31,65 +38,71 @@ const DateSelect = ({ tsRange, loadSpecificWeekFn }) => { }; }, [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} ); diff --git a/www/js/diary/list/LabelListScreen.tsx b/www/js/diary/list/LabelListScreen.tsx index 341f037bb..6a31fb627 100644 --- a/www/js/diary/list/LabelListScreen.tsx +++ b/www/js/diary/list/LabelListScreen.tsx @@ -10,15 +10,8 @@ import { LabelTabContext } from '../LabelTab'; const LabelListScreen = () => { const { filterInputs, setFilterInputs, displayedEntries } = useContext(LabelTabContext); - const { - timelineMap, - queriedRange, - loadSpecificWeek, - refreshTimeline, - pipelineRange, - loadAnotherWeek, - timelineIsLoading, - } = useContext(TimelineContext); + const { timelineMap, loadSpecificWeek, refreshTimeline, loadAnotherWeek } = + useContext(TimelineContext); const { colors } = useTheme(); return ( @@ -31,8 +24,8 @@ const LabelListScreen = () => { numListTotal={timelineMap?.size} /> loadSpecificWeek(date.toISOString().substring(0, 10))} /> { /> - + ); diff --git a/www/js/diary/list/TimelineScrollList.tsx b/www/js/diary/list/TimelineScrollList.tsx index 79faffbdf..817098430 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,32 +27,29 @@ 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, loadMoreFn }: Props) => { const { t } = useTranslation(); + const { pipelineRange, queriedDateRange, timelineIsLoading } = 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}> {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}> {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/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..8e4b3e5ab 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'; @@ -19,18 +18,22 @@ import { getAggregateData, getMetrics } from '../services/commHelper'; import { displayError, 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 } from '../diary/timelineHelper'; 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); 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', }; @@ -51,8 +54,8 @@ const MetricsTab = () => { const { t } = useTranslation(); const { getFormattedSpeed, speedSuffix, getFormattedDistance, distanceSuffix } = useImperialConfig(); + const { dateRange, setDateRange, refreshTimeline } = useContext(TimelineContext); - const [dateRange, setDateRange] = useState(getLastTwoWeeksDtRange); const [aggMetrics, setAggMetrics] = useState(undefined); const [userMetrics, setUserMetrics] = useState(undefined); @@ -62,7 +65,10 @@ const MetricsTab = () => { loadMetricsForPopulation('aggregate', dateRange); }, [dateRange, appConfig?.server]); - async function loadMetricsForPopulation(population: 'user' | 'aggregate', dateRange: DateTime[]) { + async function loadMetricsForPopulation( + population: 'user' | 'aggregate', + dateRange: [string, string], + ) { try { logDebug(`MetricsTab: fetching metrics for population ${population}' in date range ${JSON.stringify(dateRange)}`); @@ -88,10 +94,6 @@ const MetricsTab = () => { } } - function refresh() { - setDateRange(getLastTwoWeeksDtRange()); - } - const { width: windowWidth } = useWindowDimensions(); const cardWidth = windowWidth * 0.88; @@ -99,8 +101,16 @@ const MetricsTab = () => { <> - - + + setDateRange([ + startDate.toISOString().substring(0, 10), + endDate.toISOString().substring(0, 10), + ]) + } + /> + From 49c49892284d400de21d4f23695b40cef9a726bb Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 22 Mar 2024 11:45:27 -0400 Subject: [PATCH 05/33] TimelineContext: update log statements & remove unused --- www/js/TimelineContext.ts | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/www/js/TimelineContext.ts b/www/js/TimelineContext.ts index fed5c145f..0c0edf383 100644 --- a/www/js/TimelineContext.ts +++ b/www/js/TimelineContext.ts @@ -1,4 +1,4 @@ -import { createContext, useCallback, useEffect, useState } from 'react'; +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'; @@ -22,9 +22,6 @@ import { getNotDeletedCandidates, mapInputsToTimelineEntries } from './survey/in import { publish } from './customEventHandler'; import { EnketoUserInputEntry } from './survey/enketo/enketoHelper'; -const ONE_DAY = 24 * 60 * 60; // seconds -const ONE_WEEK = ONE_DAY * 7; // seconds - // 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]; @@ -142,7 +139,7 @@ export const useTimelineContext = (): ContextProps => { try { const pipelineRange = await getPipelineRangeTs(); await updateAllUnprocessedInputs(pipelineRange, appConfig); - logDebug(`LabelTab: After updating unprocessedInputs, + logDebug(`Timeline: After updating unprocessedInputs, unprocessedLabels = ${JSON.stringify(unprocessedLabels)}; unprocessedNotes = ${JSON.stringify(unprocessedNotes)}`); setPipelineRange(pipelineRange); @@ -175,15 +172,15 @@ export const useTimelineContext = (): ContextProps => { } function handleFetchedTrips(ctList, utList, mode: 'prepend' | 'append' | 'replace') { - logDebug(`LabelTab: handleFetchedTrips with + logDebug(`Timeline: handleFetchedTrips with mode = ${mode}; - ctList = ${JSON.stringify(ctList)}; - utList = ${JSON.stringify(utList)}`); + 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(`LabelTab: after composite trips converted, + logDebug(`Timeline: after composite trips converted, readTimelineMap = ${[...readTimelineMap.entries()]}`); if (mode == 'append') { setTimelineMap(new Map([...(timelineMap || []), ...readTimelineMap])); @@ -199,8 +196,7 @@ export const useTimelineContext = (): ContextProps => { async function fetchTripsInRange(dateRange: [string, string]) { 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); + logDebug('Timeline: fetchTripsInRange from ' + dateRange[0] + ' to ' + dateRange[1]); const [startTs, endTs] = isoDateRangeToTsRange(dateRange); const readCompositePromise = readAllCompositeTrips( @@ -222,8 +218,8 @@ export const useTimelineContext = (): ContextProps => { } const results = await Promise.all([readCompositePromise, readUnprocessedPromise]); - logDebug(`LabelTab: readCompositePromise resolved as: ${JSON.stringify(results[0])}; - readUnprocessedPromise resolved as: ${JSON.stringify(results[1])}`); + logDebug(`Timeline: readCompositePromise resolved with ${results[0]?.length} trips; + readUnprocessedPromise resolved with ${results[1]?.length} trips`); return results; } @@ -263,7 +259,7 @@ export const useTimelineContext = (): ContextProps => { for (const [inputType, labelValue] of Object.entries(userInput)) { newLabels[inputType] = { data: labelValue, metadata: nowTs }; } - logDebug('LabelTab: newLabels = ' + JSON.stringify(newLabels)); + logDebug('Timeline: newLabels = ' + JSON.stringify(newLabels)); const newTimelineLabelMap: TimelineLabelMap = { ...timelineLabelMap, [oid]: { From 45e3811edba5adb9903f0cab649a9e525a243a95 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 22 Mar 2024 12:41:42 -0400 Subject: [PATCH 06/33] handle edge cases in loadSpecificWeek Normally with loadSpecifcWeek, we use start date of 3 days before and end date of 3 days after. But we must handle edge cases wehre the user selects a date within 3 days of either the pipeline start or today --- www/js/TimelineContext.ts | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/www/js/TimelineContext.ts b/www/js/TimelineContext.ts index 0c0edf383..9aa0c30da 100644 --- a/www/js/TimelineContext.ts +++ b/www/js/TimelineContext.ts @@ -166,9 +166,28 @@ export const useTimelineContext = (): ContextProps => { function loadSpecificWeek(date: string) { logDebug('Timeline: loadSpecificWeek for date ' + date); - const threeDaysBefore = isoDateWithOffset(date, -3); - const threeDaysAfter = isoDateWithOffset(date, 3); - setDateRange([threeDaysBefore, threeDaysAfter]); + 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') { From 1c195bf5e52fdf24399ae857f3d5d2ebb3e5b4c3 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 22 Mar 2024 12:44:01 -0400 Subject: [PATCH 07/33] DateSelect: use today as maximum, not pipeline end Because of unprocessed trips / user not being online for a while, they could have unprocessed trips on days up to and/or including today. So the datepicker should be able to go past pipelineEnd --- www/js/diary/list/DateSelect.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/js/diary/list/DateSelect.tsx b/www/js/diary/list/DateSelect.tsx index acf84cedd..f2ae05a3f 100644 --- a/www/js/diary/list/DateSelect.tsx +++ b/www/js/diary/list/DateSelect.tsx @@ -33,8 +33,8 @@ const DateSelect = ({ mode, onChoose, ...rest }: Props) => { 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]); From 368f6c8d643bf51496a9ca26e0808d7539fc11ea Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 22 Mar 2024 12:47:36 -0400 Subject: [PATCH 08/33] DateSelect: close datepicker after chosen date(s) If mode is 'single', we don't need to wait for user to confirm / press "Save". From onChange, we can call onChoose and dismiss immediately. If mode is 'range' we will do nothing onChange, but onConfirm we will call onChoose and then dismiss. --- www/js/diary/list/DateSelect.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/www/js/diary/list/DateSelect.tsx b/www/js/diary/list/DateSelect.tsx index f2ae05a3f..df3bbbd66 100644 --- a/www/js/diary/list/DateSelect.tsx +++ b/www/js/diary/list/DateSelect.tsx @@ -100,8 +100,20 @@ const DateSelect = ({ mode, onChoose, ...rest }: Props) => { endDate={mode == 'range' ? queriedRangeAsJsDates?.[1] : undefined} validRange={minMaxDates} onDismiss={onDismissSingle} - onChange={mode == 'single' ? onChoose : undefined} - onConfirm={mode == 'single' ? onDismissSingle : onChoose} + onChange={(params) => { + if (mode == 'single') { + onChoose(params); + onDismissSingle(); + } + }} + onConfirm={(params) => { + if (mode == 'range') { + onChoose(params); + onDismissSingle(); + } else { + onDismissSingle(); + } + }} {...rest} /> From 1a12b8bd7e37e27a8eba561e06ec331d31713197 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 22 Mar 2024 12:50:16 -0400 Subject: [PATCH 09/33] fix wrong dates date selections Apparently, JS Date's toISOString() does not include the local timezone offset; it is UTC. We want the local date here. So we need to use Luxon. --- www/js/diary/list/LabelListScreen.tsx | 8 +++++++- www/js/metrics/MetricsTab.tsx | 14 +++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/www/js/diary/list/LabelListScreen.tsx b/www/js/diary/list/LabelListScreen.tsx index 6a31fb627..a606f9c62 100644 --- a/www/js/diary/list/LabelListScreen.tsx +++ b/www/js/diary/list/LabelListScreen.tsx @@ -7,6 +7,8 @@ import TimelineScrollList from './TimelineScrollList'; 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, displayedEntries } = useContext(LabelTabContext); @@ -25,7 +27,11 @@ const LabelListScreen = () => { /> loadSpecificWeek(date.toISOString().substring(0, 10))} + onChoose={({ date }) => { + const d = DateTime.fromJSDate(date).toISODate(); + if (!d) return displayErrorMsg('Invalid date'); + loadSpecificWeek(d); + }} /> { - setDateRange([ - startDate.toISOString().substring(0, 10), - endDate.toISOString().substring(0, 10), - ]) - } + onChoose={({ startDate, endDate }) => { + const start = DateTime.fromJSDate(startDate).toISODate(); + const end = DateTime.fromJSDate(endDate).toISODate(); + if (!start || !end) return displayErrorMsg('Invalid date'); + setDateRange([start, end]); + }} /> From f165a3fb7a6c5e5dbefb33129246b840bd450b2a Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 25 Mar 2024 11:37:35 -0400 Subject: [PATCH 10/33] compute user metrics on phone using e-mission-common In MetricsTab, userMetrics will be populated not by a call to 'fetchMetricsFromServer', but by MetricsSummaries.generate_summaries from e-mission-common, which will compute user metrics from timeline data (ie timelineMap and timelineLabelMap) aggMetrics will still be the same as before, fetched from the server. There are a couple differences between metrics computed on e-misison-server and metrics computed on e-mission-common. (see metricsTypes.ts where DayOfMetricData can now be either DayOfClientMetricData or DayOfServerMetricData) To reconcile these differences, added helper functions `dateForDayOfMetricData`, `tsForDayOfMetricData`, and `valueForModeOnDay` --- www/js/metrics/ActiveMinutesTableCard.tsx | 10 +++++-- www/js/metrics/DailyActiveMinutesCard.tsx | 3 +- www/js/metrics/MetricsCard.tsx | 19 ++++++++---- www/js/metrics/MetricsTab.tsx | 17 +++++++++-- www/js/metrics/WeeklyActiveMinutesCard.tsx | 9 ++++-- www/js/metrics/metricsHelper.ts | 34 ++++++++++++++++------ www/js/metrics/metricsTypes.ts | 10 ++++++- 7 files changed, 78 insertions(+), 24 deletions(-) diff --git a/www/js/metrics/ActiveMinutesTableCard.tsx b/www/js/metrics/ActiveMinutesTableCard.tsx index 92a6ac768..51bdfb47f 100644 --- a/www/js/metrics/ActiveMinutesTableCard.tsx +++ b/www/js/metrics/ActiveMinutesTableCard.tsx @@ -7,6 +7,7 @@ import { formatDateRangeOfDays, secondsToMinutes, segmentDaysByWeeks, + valueForModeOnDay, } from './metricsHelper'; import { useTranslation } from 'react-i18next'; import { ACTIVE_MODES } from './WeeklyActiveMinutesCard'; @@ -21,7 +22,10 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { 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); @@ -35,7 +39,7 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => { .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 +53,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/DailyActiveMinutesCard.tsx b/www/js/metrics/DailyActiveMinutesCard.tsx index c6ba7cbf0..558c3862c 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 { valueForModeOnDay } from './metricsHelper'; const ACTIVE_MODES = ['walk', 'bike'] as const; type ActiveMode = (typeof ACTIVE_MODES)[number]; @@ -21,7 +22,7 @@ const DailyActiveMinutesCard = ({ userMetrics }: Props) => { const recentDays = userMetrics?.duration?.slice(-14); recentDays?.forEach((day) => { ACTIVE_MODES.forEach((mode) => { - const activeSeconds = day[`label_${mode}`]; + const activeSeconds = valueForModeOnDay(day, mode); if (activeSeconds) { records.push({ label: labelKeyToRichMode(mode), 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/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 73d35c3ce..1e21145df 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -21,6 +21,7 @@ import { ServerConnConfig } from '../types/appConfigTypes'; import DateSelect from '../diary/list/DateSelect'; import TimelineContext from '../TimelineContext'; import { isoDateRangeToTsRange } from '../diary/timelineHelper'; +import { MetricsSummaries } from '../../../../e-mission-common/js'; export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; @@ -54,17 +55,29 @@ const MetricsTab = () => { const { t } = useTranslation(); const { getFormattedSpeed, speedSuffix, getFormattedDistance, distanceSuffix } = useImperialConfig(); - const { dateRange, setDateRange, refreshTimeline } = useContext(TimelineContext); + const { dateRange, setDateRange, timelineMap, timelineLabelMap, refreshTimeline } = + useContext(TimelineContext); const [aggMetrics, setAggMetrics] = useState(undefined); const [userMetrics, setUserMetrics] = useState(undefined); + // aggregate metrics are fetched from the server useEffect(() => { if (!appConfig?.server) return; - loadMetricsForPopulation('user', dateRange); loadMetricsForPopulation('aggregate', dateRange); }, [dateRange, appConfig?.server]); + // user metrics are computed on the phone from the timeline data + useEffect(() => { + if (!timelineMap) return; + const userMetrics = MetricsSummaries.generate_summaries( + METRIC_LIST, + [...timelineMap.values()], + timelineLabelMap, + ) as MetricsData; + setUserMetrics(userMetrics); + }, [timelineMap]); + async function loadMetricsForPopulation( population: 'user' | 'aggregate', dateRange: [string, string], diff --git a/www/js/metrics/WeeklyActiveMinutesCard.tsx b/www/js/metrics/WeeklyActiveMinutesCard.tsx index eb1a29939..cf105e019 100644 --- a/www/js/metrics/WeeklyActiveMinutesCard.tsx +++ b/www/js/metrics/WeeklyActiveMinutesCard.tsx @@ -3,7 +3,7 @@ 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'; @@ -22,13 +22,16 @@ const WeeklyActiveMinutesCard = ({ userMetrics }: Props) => { const records: { x: string; y: number; label: string }[] = []; const [recentWeek, prevWeek] = segmentDaysByWeeks(userMetrics?.duration, 2); ACTIVE_MODES.forEach((mode) => { - const prevSum = prevWeek?.reduce((acc, day) => acc + (day[`label_${mode}`] || 0), 0); + const prevSum = prevWeek?.reduce((acc, day) => acc + (valueForModeOnDay(day, mode) || 0), 0); if (prevSum) { // `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})` 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); + const recentSum = recentWeek?.reduce( + (acc, day) => acc + (valueForModeOnDay(day, 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 }); diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index ca3846806..fbf989687 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -7,8 +7,9 @@ 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 +19,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[]); @@ -39,14 +41,18 @@ export function segmentDaysByWeeks(days: DayOfMetricData[], nWeeks?: number) { } 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 +121,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 +144,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[]; }; From 751c4d09917316f6be1d5057cdf807c0a67aef7e Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 29 Mar 2024 14:20:09 -0400 Subject: [PATCH 11/33] TimelineContext: refactor loadAnotherWeek -> loadMoreDays Refactored to a more generic function that accepts a second parameter for to load any number of days and not always 1 week (7 days) Replaced uses of loadAnotherWeek with loadMoreDays. (Except in LabelTab.tsx, where loadAnotherWeek was unused so it was just removed rather than replaced by the new function) --- www/js/TimelineContext.ts | 14 +++++++------- www/js/diary/LabelTab.tsx | 2 +- www/js/diary/list/LabelListScreen.tsx | 5 ++--- www/js/diary/list/TimelineScrollList.tsx | 10 +++++----- 4 files changed, 15 insertions(+), 16 deletions(-) diff --git a/www/js/TimelineContext.ts b/www/js/TimelineContext.ts index 9aa0c30da..ad1e1e012 100644 --- a/www/js/TimelineContext.ts +++ b/www/js/TimelineContext.ts @@ -39,7 +39,7 @@ type ContextProps = { dateRange: [string, string]; // YYYY-MM-DD format setDateRange: (d: [string, string]) => void; timelineIsLoading: string | false; - loadAnotherWeek: (when: 'past' | 'future') => void; + loadMoreDays: (when: 'past' | 'future', nDays: number) => void; loadSpecificWeek: (d: string) => void; refreshTimeline: () => void; }; @@ -149,18 +149,18 @@ export const useTimelineContext = (): ContextProps => { } } - function loadAnotherWeek(when: 'past' | 'future') { + function loadMoreDays(when: 'past' | 'future', nDays: number) { const existingRange = queriedDateRange || initialQueryRange; - logDebug(`Timeline: loadAnotherWeek for ${when}; + logDebug(`Timeline: loadMoreDays, ${nDays} days into the ${when}; queriedDateRange = ${queriedDateRange}; existingRange = ${existingRange}`); let newDateRange: [string, string]; if (when == 'past') { - newDateRange = [isoDateWithOffset(existingRange[0], -7), existingRange[1]]; + newDateRange = [isoDateWithOffset(existingRange[0], -nDays), existingRange[1]]; } else { - newDateRange = [existingRange[0], isoDateWithOffset(existingRange[1], 7)]; + newDateRange = [existingRange[0], isoDateWithOffset(existingRange[1], nDays)]; } - logDebug('Timeline: loadAnotherWeek setting new date range = ' + newDateRange); + logDebug('Timeline: loadMoreDays setting new date range = ' + newDateRange); setDateRange(newDateRange); } @@ -319,7 +319,7 @@ export const useTimelineContext = (): ContextProps => { timelineIsLoading, timelineLabelMap, labelOptions, - loadAnotherWeek, + loadMoreDays, loadSpecificWeek, refreshTimeline, userInputFor, diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 47f566ed4..65526dc8a 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -29,7 +29,7 @@ export const LabelTabContext = createContext({} as LabelConte const LabelTab = () => { const { appConfig } = useContext(AppContext); - const { pipelineRange, timelineMap, loadAnotherWeek } = useContext(TimelineContext); + const { pipelineRange, timelineMap } = useContext(TimelineContext); const [filterInputs, setFilterInputs] = useState([]); const [displayedEntries, setDisplayedEntries] = useState(null); diff --git a/www/js/diary/list/LabelListScreen.tsx b/www/js/diary/list/LabelListScreen.tsx index a606f9c62..bc3f16620 100644 --- a/www/js/diary/list/LabelListScreen.tsx +++ b/www/js/diary/list/LabelListScreen.tsx @@ -12,8 +12,7 @@ import { displayErrorMsg } from '../../plugin/logger'; const LabelListScreen = () => { const { filterInputs, setFilterInputs, displayedEntries } = useContext(LabelTabContext); - const { timelineMap, loadSpecificWeek, refreshTimeline, loadAnotherWeek } = - useContext(TimelineContext); + const { timelineMap, loadSpecificWeek, refreshTimeline } = useContext(TimelineContext); const { colors } = useTheme(); return ( @@ -42,7 +41,7 @@ const LabelListScreen = () => { /> - + ); diff --git a/www/js/diary/list/TimelineScrollList.tsx b/www/js/diary/list/TimelineScrollList.tsx index 817098430..57842bbcf 100644 --- a/www/js/diary/list/TimelineScrollList.tsx +++ b/www/js/diary/list/TimelineScrollList.tsx @@ -27,11 +27,11 @@ const smallSpinner = ; type Props = { listEntries: any[] | null; - loadMoreFn: (direction: string) => void; }; -const TimelineScrollList = ({ listEntries, loadMoreFn }: Props) => { +const TimelineScrollList = ({ listEntries }: Props) => { const { t } = useTranslation(); - const { pipelineRange, queriedDateRange, timelineIsLoading } = useContext(TimelineContext); + 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 @@ -45,13 +45,13 @@ const TimelineScrollList = ({ listEntries, loadMoreFn }: Props) => { }, [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 header = ( - loadMoreFn('future')} disabled={reachedPipelineEnd}> + loadMoreDays('future', 7)} disabled={reachedPipelineEnd}> {reachedPipelineEnd ? t('diary.no-more-travel') : t('diary.show-more-travel')} ); From 94e40204385e99c9f4f39dc6976a68b9f1f4489d Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 1 Apr 2024 10:55:23 -0400 Subject: [PATCH 12/33] allow missing days for segmentDaysByWeeks With the server calls, the metrics data always had 1 entry for every date in the query. Even if there was no data for that date, it would give a blank entry. e-mission-common isn't set up to work like this; it only gives entries on days that had trips. This change makes segmentDaysByWeeks work if there are missing dates. it requires an extra parameter for 'end date' I also noticed there is a lot of repeated code between these "Card" components; this should be refactored later --- www/js/diary/timelineHelper.ts | 8 ++++++++ www/js/metrics/ActiveMinutesTableCard.tsx | 6 ++++-- www/js/metrics/CarbonFootprintCard.tsx | 21 ++++++++++++++++----- www/js/metrics/CarbonTextCard.tsx | 21 ++++++++++++++++----- www/js/metrics/WeeklyActiveMinutesCard.tsx | 6 ++++-- www/js/metrics/metricsHelper.ts | 21 +++++++++++++++------ 6 files changed, 63 insertions(+), 20 deletions(-) diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 407f9a700..5a1f5f85b 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -579,3 +579,11 @@ 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/metrics/ActiveMinutesTableCard.tsx b/www/js/metrics/ActiveMinutesTableCard.tsx index 51bdfb47f..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'; @@ -12,10 +12,12 @@ import { 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(() => { @@ -34,7 +36,7 @@ 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 = {}; 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/WeeklyActiveMinutesCard.tsx b/www/js/metrics/WeeklyActiveMinutesCard.tsx index cf105e019..fc631c07c 100644 --- a/www/js/metrics/WeeklyActiveMinutesCard.tsx +++ b/www/js/metrics/WeeklyActiveMinutesCard.tsx @@ -1,4 +1,4 @@ -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'; @@ -8,6 +8,7 @@ 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,12 +16,13 @@ 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 + (valueForModeOnDay(day, mode) || 0), 0); if (prevSum) { diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index fbf989687..d7ed5ec7b 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -2,6 +2,7 @@ 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[] = []; @@ -31,13 +32,21 @@ 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) { +export function segmentDaysByWeeks(days: DayOfMetricData[], lastDate: string) { const weeks: DayOfMetricData[][] = []; - for (let i = days?.length - 1; i >= 0; i -= 7) { - weeks.push(days.slice(Math.max(i - 6, 0), i + 1)); - } - if (nWeeks) return weeks.slice(0, nWeeks); - return weeks; + let weekIndex = 0; + let cutoff = isoDateWithOffset(lastDate, -7 * (weekIndex + 1)); + [...days].reverse().forEach((d) => { + const date = dateForDayOfMetricData(d); + // if date is older than cutoff, start a new week + if (isoDatesDifference(date, cutoff) > 0) { + weekIndex += 1; + cutoff = isoDateWithOffset(lastDate, -7 * (weekIndex + 1)); + } + if (!weeks[weekIndex]) weeks[weekIndex] = []; + weeks[weekIndex].push(d); + }); + return weeks.map((week) => week.reverse()); } export function formatDate(day: DayOfMetricData) { From 072c2202c57ebfdd829390dad0bb15fecb8f244e Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 1 Apr 2024 11:14:34 -0400 Subject: [PATCH 13/33] MetricsTab: load more data if timeline dateRange < 14 days The MetricsTab compares past week to previous week, which means it makes the most sense with 2 weeks of data loaded. The common scenario is switching from the label tab, where the last 7 days are shown by default, to the metrics tab. We would need to load the previous week. --- www/js/metrics/MetricsTab.tsx | 49 +++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 1e21145df..8a5ad9066 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -20,7 +20,7 @@ import useAppConfig from '../useAppConfig'; import { ServerConnConfig } from '../types/appConfigTypes'; import DateSelect from '../diary/list/DateSelect'; import TimelineContext from '../TimelineContext'; -import { isoDateRangeToTsRange } from '../diary/timelineHelper'; +import { isoDateRangeToTsRange, isoDatesDifference } from '../diary/timelineHelper'; import { MetricsSummaries } from '../../../../e-mission-common/js'; export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; @@ -55,29 +55,50 @@ const MetricsTab = () => { const { t } = useTranslation(); const { getFormattedSpeed, speedSuffix, getFormattedDistance, distanceSuffix } = useImperialConfig(); - const { dateRange, setDateRange, timelineMap, timelineLabelMap, refreshTimeline } = - useContext(TimelineContext); + const { + dateRange, + setDateRange, + timelineMap, + timelineLabelMap, + timelineIsLoading, + refreshTimeline, + loadMoreDays, + } = useContext(TimelineContext); const [aggMetrics, setAggMetrics] = useState(undefined); - const [userMetrics, setUserMetrics] = useState(undefined); - - // aggregate metrics are fetched from the server - useEffect(() => { - if (!appConfig?.server) return; - loadMetricsForPopulation('aggregate', dateRange); - }, [dateRange, appConfig?.server]); // user metrics are computed on the phone from the timeline data - useEffect(() => { + const userMetrics = useMemo(() => { if (!timelineMap) return; - const userMetrics = MetricsSummaries.generate_summaries( + return MetricsSummaries.generate_summaries( METRIC_LIST, [...timelineMap.values()], timelineLabelMap, ) as MetricsData; - setUserMetrics(userMetrics); }, [timelineMap]); + // aggregate metrics are fetched from the server + useEffect(() => { + if (!appConfig?.server) return; + const dateRangeDays = isoDatesDifference(...dateRange); + + // this tab uses the last 2 weeks of data; if we need more, we should fetch it + if (dateRangeDays < 14) { + if (timelineIsLoading) { + console.debug('MetricsTab: timeline is still loading, not loading more days yet'); + } else { + console.debug('MetricsTab: loading more days'); + loadMoreDays('past', 14 - dateRangeDays); + } + } else { + loadMetricsForPopulation('aggregate', dateRange); + } + }, [dateRange, timelineIsLoading, appConfig?.server]); + + useEffect(() => { + console.debug('MetricsTab: userMetrics updated to ' + JSON.stringify(userMetrics)); + }, [userMetrics]); + async function loadMetricsForPopulation( population: 'user' | 'aggregate', dateRange: [string, string], @@ -98,7 +119,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); } From c5f69702282603b15767f85157b244b238121d64 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 1 Apr 2024 13:44:11 -0400 Subject: [PATCH 14/33] fix ts on DailyActiveMinutesCard --- www/js/metrics/DailyActiveMinutesCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/js/metrics/DailyActiveMinutesCard.tsx b/www/js/metrics/DailyActiveMinutesCard.tsx index 558c3862c..94fff2544 100644 --- a/www/js/metrics/DailyActiveMinutesCard.tsx +++ b/www/js/metrics/DailyActiveMinutesCard.tsx @@ -7,7 +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 { valueForModeOnDay } from './metricsHelper'; +import { tsForDayOfMetricData, valueForModeOnDay } from './metricsHelper'; const ACTIVE_MODES = ['walk', 'bike'] as const; type ActiveMode = (typeof ACTIVE_MODES)[number]; @@ -26,7 +26,7 @@ const DailyActiveMinutesCard = ({ userMetrics }: Props) => { if (activeSeconds) { records.push({ label: labelKeyToRichMode(mode), - x: `${day.ts * 1000}`, // vertical chart, milliseconds on X axis + x: `${tsForDayOfMetricData(day) * 1000}`, // vertical chart, milliseconds on X axis y: activeSeconds && activeSeconds / 60, // minutes on Y axis }); } From 6c07e442a67bf49672fc0fb83abcd35e8aad8c24 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 1 Apr 2024 13:45:05 -0400 Subject: [PATCH 15/33] MetricsTab: add console.time to userMetrics computation For debugging performance --- www/js/metrics/MetricsTab.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 8a5ad9066..bda561712 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -69,12 +69,18 @@ const MetricsTab = () => { // user metrics are computed on the phone from the timeline data const userMetrics = useMemo(() => { + console.time('MetricsTab: generate_summaries'); if (!timelineMap) return; - return MetricsSummaries.generate_summaries( + console.time('MetricsTab: timelineMap.values()'); + const timelineValues = [...timelineMap.values()]; + console.timeEnd('MetricsTab: timelineMap.values()'); + const result = MetricsSummaries.generate_summaries( METRIC_LIST, - [...timelineMap.values()], + timelineValues, timelineLabelMap, ) as MetricsData; + console.timeEnd('MetricsTab: generate_summaries'); + return result; }, [timelineMap]); // aggregate metrics are fetched from the server From 4a2222731f4c0e26642bae6eb66798232d401b9f Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 1 Apr 2024 14:00:56 -0400 Subject: [PATCH 16/33] cleanup code in MetricsTab getLastTwoWeeksDtRange is not used, debug statements made into logDebugs if useful and removed if unnecessary --- www/js/metrics/MetricsTab.tsx | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index bda561712..267a031f4 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -31,6 +31,7 @@ async function fetchMetricsFromServer( 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: startTs, @@ -42,13 +43,6 @@ 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(); @@ -83,7 +77,7 @@ const MetricsTab = () => { return result; }, [timelineMap]); - // aggregate metrics are fetched from the server + // at least 2 weeks of timeline data should be loaded for the user metrics useEffect(() => { if (!appConfig?.server) return; const dateRangeDays = isoDatesDifference(...dateRange); @@ -91,19 +85,26 @@ const MetricsTab = () => { // this tab uses the last 2 weeks of data; if we need more, we should fetch it if (dateRangeDays < 14) { if (timelineIsLoading) { - console.debug('MetricsTab: timeline is still loading, not loading more days yet'); + logDebug('MetricsTab: timeline is still loading, not loading more days yet'); } else { - console.debug('MetricsTab: loading more days'); + logDebug('MetricsTab: loading more days'); loadMoreDays('past', 14 - dateRangeDays); } } else { - loadMetricsForPopulation('aggregate', dateRange); + logDebug('MetricsTab: date range >= 14 days, not loading more days'); } }, [dateRange, timelineIsLoading, appConfig?.server]); + // aggregate metrics fetched from the server whenever the date range is set useEffect(() => { - console.debug('MetricsTab: userMetrics updated to ' + JSON.stringify(userMetrics)); - }, [userMetrics]); + logDebug('MetricsTab: dateRange updated to ' + JSON.stringify(dateRange)); + const dateRangeDays = isoDatesDifference(...dateRange); + if (dateRangeDays < 14) { + logDebug('MetricsTab: date range < 14 days, not loading aggregate metrics yet'); + } else { + loadMetricsForPopulation('aggregate', dateRange); + } + }, [dateRange]); async function loadMetricsForPopulation( population: 'user' | 'aggregate', From ed768a72134b53318bd61c8d5f265ac4148262e2 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 1 Apr 2024 14:01:34 -0400 Subject: [PATCH 17/33] fix refreshTimeline() It should return to the initial state which has queriedDateRange as null and dateRange as the initial range. --- www/js/TimelineContext.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/www/js/TimelineContext.ts b/www/js/TimelineContext.ts index ad1e1e012..6f4d9d615 100644 --- a/www/js/TimelineContext.ts +++ b/www/js/TimelineContext.ts @@ -246,7 +246,8 @@ export const useTimelineContext = (): ContextProps => { try { logDebug('timelineContext: refreshTimeline'); setTimelineIsLoading('replace'); - setQueriedDateRange(initialQueryRange); + setDateRange(initialQueryRange); + setQueriedDateRange(null); setTimelineMap(null); setRefreshTime(new Date()); } catch (e) { From 126e46a0cae920d950b9b5dac42cc2d6e9c070c5 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 1 Apr 2024 14:22:47 -0400 Subject: [PATCH 18/33] getFootprintForMetrics: use forEach instead of for..in for..in loop can pick up on extra properties in the object, while forEach will only pick up on the numbered index values (like array values) Python lists that come from e-mission-common will have extra properties, (e.g. __class__) So we should make sure to use forEach instead of for..in. --- www/js/metrics/footprintHelper.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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; } From baf79c09da5792a1b94cf87c997b65e3f8a393bc Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 1 Apr 2024 15:13:25 -0400 Subject: [PATCH 19/33] update e-mission-common import I was using a local filesystem import for the purpose of dev testing. Changing to a normal package import now that this is done and working. --- www/js/metrics/MetricsTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 267a031f4..1e1625201 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -21,7 +21,7 @@ import { ServerConnConfig } from '../types/appConfigTypes'; import DateSelect from '../diary/list/DateSelect'; import TimelineContext from '../TimelineContext'; import { isoDateRangeToTsRange, isoDatesDifference } from '../diary/timelineHelper'; -import { MetricsSummaries } from '../../../../e-mission-common/js'; +import { MetricsSummaries } from 'e-mission-common'; export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; From 13ce50137f8f852d6876470b80a6af057f033a8e Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 1 Apr 2024 15:13:25 -0400 Subject: [PATCH 20/33] update e-mission-common import I was using a local filesystem import for the purpose of dev testing. Changing to a normal package import now that this is done and working. --- package.cordovabuild.json | 1 + package.serve.json | 1 + www/js/metrics/MetricsTab.tsx | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/package.cordovabuild.json b/package.cordovabuild.json index 77da54bdb..5d6636aff 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -133,6 +133,7 @@ "cordova-plugin-local-notification-12": "github:e-mission/cordova-plugin-local-notification-12#v0.1.4-fix-android-action", "cordova-plugin-x-socialsharing": "6.0.4", "core-js": "^2.5.7", + "e-mission-common": "https://gitpkg.now.sh/JGreenlee/e-mission-common/js?0.1.2", "enketo-core": "^6.1.7", "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", diff --git a/package.serve.json b/package.serve.json index dceeb2267..9c719a3cf 100644 --- a/package.serve.json +++ b/package.serve.json @@ -64,6 +64,7 @@ "chartjs-adapter-luxon": "^1.3.1", "chartjs-plugin-annotation": "^3.0.1", "core-js": "^2.5.7", + "e-mission-common": "https://gitpkg.now.sh/JGreenlee/e-mission-common/js?0.1.2", "enketo-core": "^6.1.7", "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 267a031f4..1e1625201 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -21,7 +21,7 @@ import { ServerConnConfig } from '../types/appConfigTypes'; import DateSelect from '../diary/list/DateSelect'; import TimelineContext from '../TimelineContext'; import { isoDateRangeToTsRange, isoDatesDifference } from '../diary/timelineHelper'; -import { MetricsSummaries } from '../../../../e-mission-common/js'; +import { MetricsSummaries } from 'e-mission-common'; export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; From 0136eeb22284432954c7160ba75d9ebcec8984cb Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Mon, 1 Apr 2024 16:33:32 -0400 Subject: [PATCH 21/33] add a bunch of tests for metricsHelper --- www/__tests__/metricsHelper.test.ts | 128 ++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 www/__tests__/metricsHelper.test.ts 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'); + }); + }); +}); From dfd337660278072f3e853006178ac1be5a88af5a Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 2 Apr 2024 00:37:10 -0400 Subject: [PATCH 22/33] fix ActiveMinutes components WeeklyActiveMinutesCard.tsx: Add records even if the total is 0 so that both "Walk" and "Regular bike" will show up in the legend. DailyActiveMinutesCard.tsx: The line chart was not showing any points at all. Turns out it's because the timestamp values were strings and needed to be numbers. We will now add records with 'null' values even if there is no value for a mode on a day. Both "Walk" and "Regular bike" now show in the legend. Adding 'spanGaps' to the chart options (in Chart.tsx), causes a line on the chart to connect only values that were on consecutive days; otherwise there will be a gap between entries on non-consecutive days. This gives users a nice way to visualize having a "streak" of days they used a particular active mode. --- www/js/components/Chart.tsx | 1 + www/js/metrics/DailyActiveMinutesCard.tsx | 16 +++++++--------- www/js/metrics/WeeklyActiveMinutesCard.tsx | 14 +++++++------- 3 files changed, 15 insertions(+), 16 deletions(-) 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/metrics/DailyActiveMinutesCard.tsx b/www/js/metrics/DailyActiveMinutesCard.tsx index 94fff2544..b72c20ec6 100644 --- a/www/js/metrics/DailyActiveMinutesCard.tsx +++ b/www/js/metrics/DailyActiveMinutesCard.tsx @@ -18,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 = valueForModeOnDay(day, mode); - if (activeSeconds) { - records.push({ - label: labelKeyToRichMode(mode), - x: `${tsForDayOfMetricData(day) * 1000}`, // vertical chart, milliseconds on X axis - y: activeSeconds && activeSeconds / 60, // minutes on Y axis - }); - } + 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/WeeklyActiveMinutesCard.tsx b/www/js/metrics/WeeklyActiveMinutesCard.tsx index fc631c07c..5078f2cfc 100644 --- a/www/js/metrics/WeeklyActiveMinutesCard.tsx +++ b/www/js/metrics/WeeklyActiveMinutesCard.tsx @@ -24,9 +24,11 @@ const WeeklyActiveMinutesCard = ({ userMetrics }: Props) => { const records: { x: string; y: number; label: string }[] = []; const [recentWeek, prevWeek] = segmentDaysByWeeks(userMetrics?.duration, dateRange[1]); ACTIVE_MODES.forEach((mode) => { - const prevSum = prevWeek?.reduce((acc, day) => acc + (valueForModeOnDay(day, 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 }); } @@ -34,10 +36,8 @@ const WeeklyActiveMinutesCard = ({ userMetrics }: Props) => { (acc, day) => acc + (valueForModeOnDay(day, 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 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]); From 4ca6e3f3f057b673d94a9adf291824493b67ef6c Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 2 Apr 2024 02:24:01 -0400 Subject: [PATCH 23/33] add tests for TimelineContext Basic test for loading composite trips into the timeline. Mocks a unified query response with 3 composite trips. Then checks to make sure those 3 trips can be rendered in a dummy component by reading 'timelineMap'. in timelineHelper.ts, do not attempt to unpack server data that doesn't exist (added because start_confirmed_place and end_confirmed_place don't exist on the dummy trips used for testing) In cordovaMocks.ts, resolve with [] instead of returning undefined --- www/__mocks__/cordovaMocks.ts | 4 +- www/__tests__/TimelineContext.test.tsx | 74 ++++++++++++++++++++++++++ www/js/diary/timelineHelper.ts | 13 ++--- 3 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 www/__tests__/TimelineContext.test.tsx 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/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 5a1f5f85b..b4297cb5b 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -249,12 +249,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')]; From 5ae3ea8510d10b6a369c1bd9ddae475a8f31d34f Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 2 Apr 2024 03:25:07 -0400 Subject: [PATCH 24/33] fix segmentDaysByWeeks fixed bug where the returned array had 1 element but mismatched length, causing a second undefined element. This was caused by assigning elements to the array by arr[index] rather than using arr.push(). New implementation uses .push() and also removes the unneeded weekIndex variable --- www/js/metrics/metricsHelper.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index d7ed5ec7b..4fdb86c2d 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -33,18 +33,16 @@ export const secondsToHours = (seconds: number) => formatForDisplay(seconds / 36 // segments metricsDays into weeks, with the most recent week first export function segmentDaysByWeeks(days: DayOfMetricData[], lastDate: string) { - const weeks: DayOfMetricData[][] = []; - let weekIndex = 0; - let cutoff = isoDateWithOffset(lastDate, -7 * (weekIndex + 1)); + const weeks: DayOfMetricData[][] = [[]]; + let cutoff = isoDateWithOffset(lastDate, -7 * weeks.length); [...days].reverse().forEach((d) => { const date = dateForDayOfMetricData(d); // if date is older than cutoff, start a new week if (isoDatesDifference(date, cutoff) > 0) { - weekIndex += 1; - cutoff = isoDateWithOffset(lastDate, -7 * (weekIndex + 1)); + weeks.push([]); + cutoff = isoDateWithOffset(lastDate, -7 * weeks.length); } - if (!weeks[weekIndex]) weeks[weekIndex] = []; - weeks[weekIndex].push(d); + weeks[weeks.length - 1].push(d); }); return weeks.map((week) => week.reverse()); } From 00c655c690f83dbc8cb3d8b386c6c0a4b871f464 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 2 Apr 2024 03:40:49 -0400 Subject: [PATCH 25/33] only query draft trips in the dateRange Up to this point, we have been querying all unprocessed/draft trips from pipelineRange start up to the endTs. This unifies the start ts for both queries. (the end ts still differs because unprocessed trips could exist after the pipeline end, while processed trips will always be before pipeline end). If for some reason there's an unprocessed trip older than the daterange start, it won't be included in the timeline anymore. --- www/js/TimelineContext.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/www/js/TimelineContext.ts b/www/js/TimelineContext.ts index 6f4d9d615..16bad4dbf 100644 --- a/www/js/TimelineContext.ts +++ b/www/js/TimelineContext.ts @@ -216,13 +216,12 @@ export const useTimelineContext = (): ContextProps => { 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 readCompositePromise = readAllCompositeTrips( - Math.max(startTs, pipelineRange.start_ts), // ensure that we don't read before the pipeline start - Math.min(endTs, pipelineRange.end_ts), // ensure that we don't read after the pipeline end - ); + 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; @@ -231,7 +230,7 @@ export const useTimelineContext = (): ContextProps => { .reverse() .find((trip) => trip.origin_key.includes('trip')) as CompositeTrip; } - readUnprocessedPromise = readUnprocessedTrips(pipelineRange.end_ts, endTs, lastProcessedTrip); + readUnprocessedPromise = readUnprocessedTrips(maxStartTs, endTs, lastProcessedTrip); } else { readUnprocessedPromise = Promise.resolve([]); } From 82e03822cd068136c1d4e8f607e5670e8d7b9635 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 2 Apr 2024 17:02:57 -0400 Subject: [PATCH 26/33] bump e-mission-common to 0.1.3 as described in patch notes https://github.com/JGreenlee/e-mission-common/releases/tag/0.1.3, the `@memoize` decorator caused slowness in the metrics computations, so I removed it and the sumaries are generated much faster now (difference of ~5000ms to ~10ms) --- package.cordovabuild.json | 2 +- package.serve.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.cordovabuild.json b/package.cordovabuild.json index dc55976be..4d370c114 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -139,7 +139,7 @@ "cordova-custom-config": "^5.1.1", "cordova-plugin-ibeacon": "git+https://github.com/louisg1337/cordova-plugin-ibeacon.git", "core-js": "^2.5.7", - "e-mission-common": "https://gitpkg.now.sh/JGreenlee/e-mission-common/js?0.1.2", + "e-mission-common": "https://gitpkg.now.sh/JGreenlee/e-mission-common/js?0.1.3", "enketo-core": "^6.1.7", "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", diff --git a/package.serve.json b/package.serve.json index 9c719a3cf..be6f94c84 100644 --- a/package.serve.json +++ b/package.serve.json @@ -64,7 +64,7 @@ "chartjs-adapter-luxon": "^1.3.1", "chartjs-plugin-annotation": "^3.0.1", "core-js": "^2.5.7", - "e-mission-common": "https://gitpkg.now.sh/JGreenlee/e-mission-common/js?0.1.2", + "e-mission-common": "https://gitpkg.now.sh/JGreenlee/e-mission-common/js?0.1.3", "enketo-core": "^6.1.7", "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", From fe4e6eb9cce27b27569750ca9bf95b140e6e2e79 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 2 Apr 2024 17:07:27 -0400 Subject: [PATCH 27/33] TimelineContext: fix excessive draft trips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This was an obvious mistake in a recent commit – we should never query for unprocessed trips before the pipeline end. Any trip before the pipeline end is, by definition, not unprocessed. --- www/js/TimelineContext.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/www/js/TimelineContext.ts b/www/js/TimelineContext.ts index 16bad4dbf..b18877331 100644 --- a/www/js/TimelineContext.ts +++ b/www/js/TimelineContext.ts @@ -230,7 +230,11 @@ export const useTimelineContext = (): ContextProps => { .reverse() .find((trip) => trip.origin_key.includes('trip')) as CompositeTrip; } - readUnprocessedPromise = readUnprocessedTrips(maxStartTs, endTs, lastProcessedTrip); + readUnprocessedPromise = readUnprocessedTrips( + Math.max(pipelineRange.end_ts, startTs), + endTs, + lastProcessedTrip, + ); } else { readUnprocessedPromise = Promise.resolve([]); } From 4da2761b26250e798260bcf637c504e4ba5f59e6 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 2 Apr 2024 17:13:42 -0400 Subject: [PATCH 28/33] add pulsing loading line at the bottom of NavBar With the recent dashboard tab changes, we can show some preliminary metrics while lazy-loading additional trips. We need some way to communicate to the user that work is being done. This will add a pulsing line animation (aka indeterminate progress bar) at the bottom of the NavBar whenever timelineIsLoading. --- www/js/components/NavBar.tsx | 17 +++++++++++++---- www/js/diary/list/LabelListScreen.tsx | 5 +++-- www/js/metrics/MetricsTab.tsx | 2 +- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/www/js/components/NavBar.tsx b/www/js/components/NavBar.tsx index 291f0b9e9..5b57472f8 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/list/LabelListScreen.tsx b/www/js/diary/list/LabelListScreen.tsx index bc3f16620..54df33500 100644 --- a/www/js/diary/list/LabelListScreen.tsx +++ b/www/js/diary/list/LabelListScreen.tsx @@ -12,12 +12,13 @@ import { displayErrorMsg } from '../../plugin/logger'; const LabelListScreen = () => { const { filterInputs, setFilterInputs, displayedEntries } = useContext(LabelTabContext); - const { timelineMap, loadSpecificWeek, refreshTimeline } = useContext(TimelineContext); + const { timelineMap, loadSpecificWeek, timelineIsLoading, refreshTimeline } = + useContext(TimelineContext); const { colors } = useTheme(); return ( <> - + { return ( <> - + Date: Tue, 2 Apr 2024 20:17:01 -0400 Subject: [PATCH 29/33] don't show ChangeIndicator when change is undefined the early return at `if (!change) return;` would cause the ChangeIndicator to still be rendered with the text "undefined". This is because it was rendered if `changeText != ''`. A cleaner way is just checking for truthiness. Also generally refactored to simplify and add some comments inside the changeText useMemo() --- www/js/metrics/ChangeIndicator.tsx | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) 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 = { From 1269aad4afaf08d4432661953cded68839060e12 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 5 Apr 2024 01:15:10 -0400 Subject: [PATCH 30/33] MetricsTab: const N_DAYS_TO_LOAD = 14 From suggestion https://github.com/e-mission/e-mission-phone/pull/1138#discussion_r1552355571 --- www/js/metrics/MetricsTab.tsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 6d8744c85..52d824979 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -23,6 +23,8 @@ import TimelineContext from '../TimelineContext'; import { isoDateRangeToTsRange, isoDatesDifference } from '../diary/timelineHelper'; import { MetricsSummaries } 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( @@ -77,21 +79,21 @@ const MetricsTab = () => { return result; }, [timelineMap]); - // at least 2 weeks of timeline data should be loaded for the user metrics + // at least N_DAYS_TO_LOAD of timeline data should be loaded for the user metrics useEffect(() => { if (!appConfig?.server) return; const dateRangeDays = isoDatesDifference(...dateRange); - // this tab uses the last 2 weeks of data; if we need more, we should fetch it - if (dateRangeDays < 14) { + // 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', 14 - dateRangeDays); + loadMoreDays('past', N_DAYS_TO_LOAD - dateRangeDays); } } else { - logDebug('MetricsTab: date range >= 14 days, not loading more days'); + logDebug(`MetricsTab: date range >= ${N_DAYS_TO_LOAD} days, not loading more days`); } }, [dateRange, timelineIsLoading, appConfig?.server]); @@ -99,8 +101,10 @@ const MetricsTab = () => { useEffect(() => { logDebug('MetricsTab: dateRange updated to ' + JSON.stringify(dateRange)); const dateRangeDays = isoDatesDifference(...dateRange); - if (dateRangeDays < 14) { - logDebug('MetricsTab: date range < 14 days, not loading aggregate metrics yet'); + if (dateRangeDays < N_DAYS_TO_LOAD) { + logDebug( + `MetricsTab: date range < ${N_DAYS_TO_LOAD} days, not loading aggregate metrics yet`, + ); } else { loadMetricsForPopulation('aggregate', dateRange); } From f1961a638fdd11eb4d1eb08a41655041acf3b01e Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 5 Apr 2024 01:16:05 -0400 Subject: [PATCH 31/33] segmentDaysByWeeks: use for loop, not forEach From suggestion https://github.com/e-mission/e-mission-phone/pull/1138#discussion_r1552388094 --- www/js/metrics/metricsHelper.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index 4fdb86c2d..8097e460f 100644 --- a/www/js/metrics/metricsHelper.ts +++ b/www/js/metrics/metricsHelper.ts @@ -35,15 +35,15 @@ export const secondsToHours = (seconds: number) => formatForDisplay(seconds / 36 export function segmentDaysByWeeks(days: DayOfMetricData[], lastDate: string) { const weeks: DayOfMetricData[][] = [[]]; let cutoff = isoDateWithOffset(lastDate, -7 * weeks.length); - [...days].reverse().forEach((d) => { - const date = dateForDayOfMetricData(d); + 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(d); - }); + weeks[weeks.length - 1].push(days[i]); + } return weeks.map((week) => week.reverse()); } From cf9aaf72abe11a5653f2c90083458f624a8d7ac6 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 12 Apr 2024 01:44:35 -0400 Subject: [PATCH 32/33] update e-mission-common to v0.3.2 --- package.cordovabuild.json | 2 +- package.serve.json | 2 +- www/js/metrics/MetricsTab.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.cordovabuild.json b/package.cordovabuild.json index 4d370c114..0ca7717bd 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -139,7 +139,7 @@ "cordova-custom-config": "^5.1.1", "cordova-plugin-ibeacon": "git+https://github.com/louisg1337/cordova-plugin-ibeacon.git", "core-js": "^2.5.7", - "e-mission-common": "https://gitpkg.now.sh/JGreenlee/e-mission-common/js?0.1.3", + "e-mission-common": "git+https://github.com/JGreenlee/e-mission-common.git#0.3.2", "enketo-core": "^6.1.7", "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", diff --git a/package.serve.json b/package.serve.json index be6f94c84..bf39fd68d 100644 --- a/package.serve.json +++ b/package.serve.json @@ -64,7 +64,7 @@ "chartjs-adapter-luxon": "^1.3.1", "chartjs-plugin-annotation": "^3.0.1", "core-js": "^2.5.7", - "e-mission-common": "https://gitpkg.now.sh/JGreenlee/e-mission-common/js?0.1.3", + "e-mission-common": "git+https://github.com/JGreenlee/e-mission-common.git#0.3.2", "enketo-core": "^6.1.7", "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 52d824979..91b444946 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -21,7 +21,7 @@ import { ServerConnConfig } from '../types/appConfigTypes'; import DateSelect from '../diary/list/DateSelect'; import TimelineContext from '../TimelineContext'; import { isoDateRangeToTsRange, isoDatesDifference } from '../diary/timelineHelper'; -import { MetricsSummaries } from 'e-mission-common'; +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 @@ -70,7 +70,7 @@ const MetricsTab = () => { console.time('MetricsTab: timelineMap.values()'); const timelineValues = [...timelineMap.values()]; console.timeEnd('MetricsTab: timelineMap.values()'); - const result = MetricsSummaries.generate_summaries( + const result = metrics_summaries.generate_summaries( METRIC_LIST, timelineValues, timelineLabelMap, From 91af26c8627b51da61f9061be8c0a23194f1a9ca Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 17 Apr 2024 01:41:27 -0400 Subject: [PATCH 33/33] don't show infinite progressbar at top of Profile tab ProgressBar wants this to be explicitly 'false' or it will still show up. The profile tab does not define 'isLoading' because it does not use timeline data and should not have the progress indicator visible. --- www/js/components/NavBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/components/NavBar.tsx b/www/js/components/NavBar.tsx index 5b57472f8..cf2a19dff 100644 --- a/www/js/components/NavBar.tsx +++ b/www/js/components/NavBar.tsx @@ -11,7 +11,7 @@ const NavBar = ({ children, isLoading }: NavBarProps) => { {children}