{devices.map((device) => {
if (device) {
- return
;
+ return
;
}
return null;
})}
@@ -188,7 +292,9 @@ const BluetoothScanPage = ({ ...props }: any) => {
{beaconsAsArray.map((beacon) => {
if (beacon) {
- return ;
+ return (
+
+ );
}
})}
@@ -263,6 +369,48 @@ const BluetoothScanPage = ({ ...props }: any) => {
+
setNewUUID(t.toUpperCase())}
+ />
+
+ setNewMajor(t)}
+ />
+ setNewMinor(t)}
+ />
+
+
+
+
+ Simulate by sending UI transitions
+
+
+
+
+
+
+
>
diff --git a/www/js/components/Carousel.tsx b/www/js/components/Carousel.tsx
index 92febb32b..8afe6624a 100644
--- a/www/js/components/Carousel.tsx
+++ b/www/js/components/Carousel.tsx
@@ -16,11 +16,16 @@ const Carousel = ({ children, cardWidth, cardMargin }: Props) => {
snapToAlignment={'center'}
style={s.carouselScroll(cardMargin)}
contentContainerStyle={{ alignItems: 'flex-start' }}>
- {React.Children.map(children, (child, i) => (
-
- {child}
-
- ))}
+ {React.Children.map(
+ children,
+ (child, i) =>
+ // If child is `null`, we need to skip it; otherwise, it takes up space
+ child && (
+
+ {child}
+
+ ),
+ )}
);
};
diff --git a/www/js/components/Chart.tsx b/www/js/components/Chart.tsx
index 2e5e3bd62..e86ef794b 100644
--- a/www/js/components/Chart.tsx
+++ b/www/js/components/Chart.tsx
@@ -31,6 +31,10 @@ export type Props = {
isHorizontal?: boolean;
timeAxis?: boolean;
stacked?: boolean;
+ showLegend?: boolean;
+ reverse?: boolean;
+ enableTooltip?: boolean;
+ maxBarThickness?: number;
};
const Chart = ({
records,
@@ -43,6 +47,10 @@ const Chart = ({
isHorizontal,
timeAxis,
stacked,
+ showLegend = true,
+ reverse = true,
+ enableTooltip = true,
+ maxBarThickness = 100,
}: Props) => {
const { colors } = useTheme();
const [numVisibleDatasets, setNumVisibleDatasets] = useState(1);
@@ -68,6 +76,7 @@ const Chart = ({
getColorForChartEl?.(chartRef.current, e, barCtx, 'border'),
borderWidth: borderWidth || 2,
borderRadius: 3,
+ maxBarThickness: maxBarThickness,
})),
};
}, [chartDatasets, getColorForLabel]);
@@ -112,6 +121,7 @@ const Chart = ({
responsive: true,
maintainAspectRatio: false,
resizeDelay: 1,
+ spanGaps: 1000 * 60 * 60 * 24, // 1 day
scales: {
...(isHorizontal
? {
@@ -148,7 +158,7 @@ const Chart = ({
},
font: { size: 11 }, // default is 12, we want a tad smaller
},
- reverse: true,
+ reverse: reverse,
stacked,
},
x: {
@@ -195,6 +205,12 @@ const Chart = ({
}),
},
plugins: {
+ legend: {
+ display: showLegend,
+ },
+ tooltip: {
+ enabled: enableTooltip,
+ },
...(lineAnnotations?.length && {
annotation: {
clip: false,
diff --git a/www/js/components/NavBar.tsx b/www/js/components/NavBar.tsx
index 291f0b9e9..cf2a19dff 100644
--- a/www/js/components/NavBar.tsx
+++ b/www/js/components/NavBar.tsx
@@ -1,13 +1,22 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import color from 'color';
-import { Appbar, Button, ButtonProps, Icon, useTheme } from 'react-native-paper';
+import { Appbar, Button, ButtonProps, Icon, ProgressBar, useTheme } from 'react-native-paper';
-const NavBar = ({ children }) => {
+type NavBarProps = { children: React.ReactNode; isLoading?: boolean };
+const NavBar = ({ children, isLoading }: NavBarProps) => {
const { colors } = useTheme();
return (
{children}
+
+
+
);
};
@@ -16,8 +25,8 @@ export default NavBar;
// NavBarButton, a greyish button with outline, to be used inside a NavBar
-type Props = ButtonProps & { icon?: string; iconSize?: number };
-export const NavBarButton = ({ children, icon, iconSize, ...rest }: Props) => {
+type NavBarButtonProps = ButtonProps & { icon?: string; iconSize?: number };
+export const NavBarButton = ({ children, icon, iconSize, ...rest }: NavBarButtonProps) => {
const { colors } = useTheme();
const buttonColor = color(colors.onBackground).alpha(0.07).rgb().string();
const outlineColor = color(colors.onBackground).alpha(0.2).rgb().string();
diff --git a/www/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts
index 9773a1ead..f92239170 100644
--- a/www/js/config/dynamicConfig.ts
+++ b/www/js/config/dynamicConfig.ts
@@ -81,6 +81,7 @@ function _fillSurveyInfo(config: Partial): AppConfig {
const _backwardsCompatFill = (config: Partial): AppConfig =>
_fillSurveyInfo(_fillStudyName(config));
+export let _cacheResourcesFetchPromise: Promise<(string | undefined)[]> = Promise.resolve([]);
/**
* @description Fetch and cache any surveys resources that are referenced by URL in the config,
* as well as the label_options config if it is present.
@@ -89,15 +90,17 @@ const _backwardsCompatFill = (config: Partial): AppConfig =>
* @param config The app config
*/
function cacheResourcesFromConfig(config: AppConfig) {
+ const fetchPromises: Promise[] = [];
if (config.survey_info?.surveys) {
Object.values(config.survey_info.surveys).forEach((survey) => {
if (!survey?.['formPath']) throw new Error(i18next.t('config.survey-missing-formpath'));
- fetchUrlCached(survey['formPath']);
+ fetchPromises.push(fetchUrlCached(survey['formPath'], { cache: 'reload' }));
});
}
if (config.label_options) {
- fetchUrlCached(config.label_options);
+ fetchPromises.push(fetchUrlCached(config.label_options, { cache: 'reload' }));
}
+ _cacheResourcesFetchPromise = Promise.all(fetchPromises);
}
/**
@@ -134,16 +137,21 @@ async function readConfigFromServer(studyLabel: string) {
*/
async function fetchConfig(studyLabel: string, alreadyTriedLocal?: boolean) {
logDebug('Received request to join ' + studyLabel);
- const downloadURL = `https://raw.githubusercontent.com/e-mission/nrel-openpath-deploy-configs/main/configs/${studyLabel}.nrel-op.json`;
+ let downloadURL = `https://raw.githubusercontent.com/e-mission/nrel-openpath-deploy-configs/main/configs/${studyLabel}.nrel-op.json`;
if (!__DEV__ || alreadyTriedLocal) {
logDebug('Fetching config from github');
- const r = await fetch(downloadURL);
+ const r = await fetch(downloadURL, { cache: 'reload' });
if (!r.ok) throw new Error('Unable to fetch config from github');
return r.json(); // TODO: validate, make sure it has required fields
} else {
logDebug('Running in dev environment, checking for locally hosted config');
try {
- const r = await fetch('http://localhost:9090/configs/' + studyLabel + '.nrel-op.json');
+ if (window['cordova'].platformId == 'android') {
+ downloadURL = `http://10.0.2.2:9090/configs/${studyLabel}.nrel-op.json`;
+ } else {
+ downloadURL = `http://localhost:9090/configs/${studyLabel}.nrel-op.json`;
+ }
+ const r = await fetch(downloadURL, { cache: 'reload' });
if (!r.ok) throw new Error('Local config not found');
return r.json();
} catch (err) {
@@ -227,7 +235,7 @@ function extractSubgroup(token: string, config: AppConfig): string | undefined {
* @param existingVersion If the new config's version is the same, we won't update
* @returns boolean representing whether the config was updated or not
*/
-function loadNewConfig(newToken: string, existingVersion?: number): Promise {
+export function loadNewConfig(newToken: string, existingVersion?: number): Promise {
const newStudyLabel = extractStudyName(newToken);
return readConfigFromServer(newStudyLabel)
.then((downloadedConfig) => {
diff --git a/www/js/config/useImperialConfig.ts b/www/js/config/useImperialConfig.ts
index aa87ed1c6..feb2bb114 100644
--- a/www/js/config/useImperialConfig.ts
+++ b/www/js/config/useImperialConfig.ts
@@ -5,6 +5,8 @@ import i18next from 'i18next';
export type ImperialConfig = {
distanceSuffix: string;
speedSuffix: string;
+ convertDistance: (d: number) => number;
+ convertSpeed: (s: number) => number;
getFormattedDistance: (d: number) => string;
getFormattedSpeed: (s: number) => string;
};
@@ -50,6 +52,8 @@ export function useImperialConfig(): ImperialConfig {
return {
distanceSuffix: useImperial ? 'mi' : 'km',
speedSuffix: useImperial ? 'mph' : 'kmph',
+ convertDistance: (d) => convertDistance(d, useImperial),
+ convertSpeed: (s) => convertSpeed(s, useImperial),
getFormattedDistance: useImperial
? (d) => formatForDisplay(convertDistance(d, true))
: (d) => formatForDisplay(convertDistance(d, false)),
diff --git a/www/js/control/LogPage.tsx b/www/js/control/LogPage.tsx
index 96ef290b3..0d7a362c0 100644
--- a/www/js/control/LogPage.tsx
+++ b/www/js/control/LogPage.tsx
@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { FlashList } from '@shopify/flash-list';
import { DateTime } from 'luxon';
import { AlertManager } from '../components/AlertBar';
-import { sendEmail } from './emailService';
+import { sendLocalDBFile } from '../services/shareLocalDBFile';
import { displayError, logDebug } from '../plugin/logger';
import NavBar from '../components/NavBar';
@@ -93,7 +93,7 @@ const LogPage = ({ pageVis, setPageVis }) => {
}
function emailLog() {
- sendEmail('loggerDB');
+ sendLocalDBFile('loggerDB');
}
const separator = () => ;
diff --git a/www/js/control/ProfileSettings.tsx b/www/js/control/ProfileSettings.tsx
index e5fc2a120..ef61e0a24 100644
--- a/www/js/control/ProfileSettings.tsx
+++ b/www/js/control/ProfileSettings.tsx
@@ -13,7 +13,7 @@ import useAppConfig from '../useAppConfig';
import { AlertManager } from '../components/AlertBar';
import DataDatePicker from './DataDatePicker';
import PrivacyPolicyModal from './PrivacyPolicyModal';
-import { sendEmail } from './emailService';
+import { sendLocalDBFile } from '../services/shareLocalDBFile';
import { uploadFile } from './uploadService';
import ActionMenu from '../components/ActionMenu';
import SensedPage from './SensedPage';
@@ -26,7 +26,11 @@ import ControlCollectionHelper, {
helperToggleLowAccuracy,
forceTransition,
} from './ControlCollectionHelper';
-import { resetDataAndRefresh } from '../config/dynamicConfig';
+import {
+ _cacheResourcesFetchPromise,
+ loadNewConfig,
+ resetDataAndRefresh,
+} from '../config/dynamicConfig';
import { AppContext } from '../App';
import { shareQR } from '../components/QrCode';
import { storageClear } from '../plugin/storage';
@@ -308,6 +312,19 @@ const ProfileSettings = () => {
}, 1500);
}
+ async function refreshConfig() {
+ AlertManager.addMessage({ text: t('control.refreshing-app-config') });
+ const updated = await loadNewConfig(authSettings.opcode, appConfig?.version);
+ if (updated) {
+ // wait for resources to finish downloading before reloading
+ _cacheResourcesFetchPromise
+ .then(() => window.location.reload())
+ .catch((error) => displayError(error, 'Failed to download a resource'));
+ } else {
+ AlertManager.addMessage({ text: t('control.already-up-to-date') });
+ }
+ }
+
//Platform.OS returns "web" now, but could be used once it's fully a Native app
//for now, use window.cordova.platformId
@@ -433,9 +450,14 @@ const ProfileSettings = () => {
action={() => setDateDumpVis(true)}>
{logUploadSection}
sendEmail('loggerDB')}>
+ action={() => sendLocalDBFile('loggerDB')}>
+
{
@@ -60,7 +60,7 @@ const SensedPage = ({ pageVis, setPageVis }) => {
updateEntries()} />
- sendEmail('userCacheDB')} />
+ sendLocalDBFile('userCacheDB')} />
{
- return new Promise((resolve, reject) => {
- window['cordova'].plugins['email'].hasAccount((hasAct) => {
- resolve(hasAct);
- });
- });
-}
-
-export async function sendEmail(database: string) {
- let parentDir = 'unknown';
-
- if (window['cordova'].platformId == 'ios' && !(await hasAccount())) {
- alert(i18next.t('email-service.email-account-not-configured'));
- return;
- }
-
- if (window['cordova'].platformId == 'android') {
- parentDir = 'app://databases';
- }
-
- if (window['cordova'].platformId == 'ios') {
- alert(i18next.t('email-service.email-account-mail-app'));
- logDebug(window['cordova'].file.dataDirectory);
- parentDir = window['cordova'].file.dataDirectory + '../LocalDatabase';
- }
-
- if (parentDir === 'unknown') {
- alert('parentDir unexpectedly = ' + parentDir + '!');
- }
-
- logInfo('Going to email ' + database);
- parentDir = parentDir + '/' + database;
-
- alert(i18next.t('email-service.going-to-email', { parentDir: parentDir }));
-
- let emailConfig = `k.shankari@nrel.gov`;
-
- let emailData = {
- to: emailConfig,
- attachments: [parentDir],
- subject: i18next.t('email-service.email-log.subject-logs'),
- body: i18next.t('email-service.email-log.body-please-fill-in-what-is-wrong'),
- };
-
- window['cordova'].plugins['email'].open(emailData, () => {
- logWarn(`Email app closed while sending,
- emailData = ${JSON.stringify(emailData)}`);
- });
-}
diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx
index 3934ca57d..b368adc0c 100644
--- a/www/js/diary/LabelTab.tsx
+++ b/www/js/diary/LabelTab.tsx
@@ -2,134 +2,91 @@
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, isTrip } from '../types/diaryTypes';
+import TimelineContext, { LabelTabFilter } from '../TimelineContext';
+import { AppContext } from '../App';
+
+type LabelContextProps = {
+ displayedEntries: TimelineEntry[] | null;
+ filterInputs: LabelTabFilter[] | null;
+ 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, timelineLabelMap } = 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 [filterRefreshTs, setFilterRefreshTs] = useState(0); // used to force a refresh of the filters
+ const [filterInputs, setFilterInputs] = 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'));
+ // if places are shown, we will skip filters and it will just be "show all"
+ // https://github.com/e-mission/e-mission-docs/issues/894
+ if (appConfig.survey_info?.buttons?.['place-notes']) {
+ setFilterInputs([]);
+ } else {
+ // initalize filters
+ const tripFilters =
+ appConfig.survey_info?.['trip-labels'] == 'ENKETO'
+ ? enketoConfiguredFilters
+ : multilabelConfiguredFilters;
+ const filtersWithState = tripFilters.map((f, i) => ({
+ ...f,
+ state: i == 0 ? true : false, // only the first filter will have state true on init
+ }));
+ setFilterInputs(filtersWithState);
}
- }, [appConfig, refreshTime]);
+ }, [appConfig]);
- // 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]);
+ if (!timelineMap) return;
+ const timelineEntries = Array.from(timelineMap.values());
+ if (!timelineEntries?.length) return;
+ timelineEntries.reverse().forEach((entry) => {
+ if (isTrip(entry)) fillLocationNamesOfTrip(entry);
+ });
+ }, [timelineMap]);
useEffect(() => {
- if (!timelineMap || !timelineLabelMap) return;
- applyFilters(timelineMap, timelineLabelMap);
- }, [lastFilteredTs]);
-
- function applyFilters(timelineMap, labelMap: TimelineLabelMap) {
+ if (!timelineMap || !timelineLabelMap || !filterInputs) return;
+ logDebug('Applying filters');
const allEntries: TimelineEntry[] = Array.from(timelineMap.values());
const activeFilter = filterInputs?.find((f) => f.state == true);
let entriesToDisplay = allEntries;
if (activeFilter) {
- const cutoffTs = new Date().getTime() / 1000 - 30; // 30s ago, as epoch seconds
+ const nowTs = new Date().getTime() / 1000;
const entriesAfterFilter = allEntries.filter((e) => {
// if the entry has a recently recorded user input, it is immune to filtering
- const labels = labelMap[e._id.$oid];
- for (let labelValue of Object.values(labels || [])) {
- logDebug(`LabelTab filtering: labelValue = ${JSON.stringify(labelValue)}`);
- if (labelValue?.metadata?.write_ts > cutoffTs) {
- logDebug('LabelTab filtering: entry has recent user input, keeping');
- return true;
- }
+ const labels = timelineLabelMap[e._id.$oid];
+ const mostRecentInputTs = Object.values(labels || []).reduce((acc, label) => {
+ if (label?.metadata?.write_ts && label.metadata.write_ts > acc)
+ return label.metadata.write_ts;
+ return acc;
+ }, 0);
+ const entryImmuneUntil = mostRecentInputTs + 30; // 30s after the most recent user input
+ if (entryImmuneUntil > nowTs) {
+ logDebug(`LabelTab filtering: entry still immune, skipping.
+ Re-applying filters at ${entryImmuneUntil}`);
+ setTimeout(() => setFilterRefreshTs(entryImmuneUntil), (entryImmuneUntil - nowTs) * 1000);
+ return true;
}
// otherwise, just apply the filter
- return activeFilter?.filter(e, labelMap[e._id.$oid]);
+ return activeFilter?.filter(e, timelineLabelMap[e._id.$oid]);
});
/* next, filter out any untracked time if the trips that came before and
after it are no longer displayed */
@@ -147,223 +104,23 @@ const LabelTab = () => {
logDebug('No active filter, displaying all entries');
}
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);
- }
- }
+ }, [timelineMap, filterInputs, timelineLabelMap, filterRefreshTs]);
- // once pipelineRange is set, load the most recent week of data
+ // once pipelineRange is set, update all unprocessed inputs
useEffect(() => {
if (pipelineRange && pipelineRange.end_ts) {
- loadAnotherWeek('past');
+ updateAllUnprocessedInputs(pipelineRange, appConfig);
}
}, [pipelineRange]);
- function refresh() {
- try {
- logDebug('Refreshing LabelTab');
- setIsLoading('replace');
- resetNominatimLimiter();
- setQueriedRange(null);
- setTimelineMap(null);
- setRefreshTime(new Date());
- } catch (e) {
- displayError(e, t('errors.while-refreshing-label'));
- }
- }
-
- async function loadAnotherWeek(when: 'past' | 'future') {
- try {
- logDebug('LabelTab: loadAnotherWeek into the ' + when);
- if (!pipelineRange?.start_ts || !pipelineRange?.end_ts)
- return logWarn('No pipelineRange yet - early return');
-
- const reachedPipelineStart =
- queriedRange?.start_ts && queriedRange.start_ts <= pipelineRange.start_ts;
- const reachedPipelineEnd =
- queriedRange?.end_ts && queriedRange.end_ts >= pipelineRange.end_ts;
-
- if (!queriedRange) {
- // first time loading
- if (!isLoading) setIsLoading('replace');
- const nowTs = new Date().getTime() / 1000;
- const [ctList, utList] = await fetchTripsInRange(pipelineRange.end_ts - ONE_WEEK, nowTs);
- handleFetchedTrips(ctList, utList, 'replace');
- setQueriedRange({ start_ts: pipelineRange.end_ts - ONE_WEEK, end_ts: nowTs });
- } else if (when == 'past' && !reachedPipelineStart) {
- if (!isLoading) setIsLoading('prepend');
- const fetchStartTs = Math.max(queriedRange.start_ts - ONE_WEEK, pipelineRange.start_ts);
- const [ctList, utList] = await fetchTripsInRange(
- queriedRange.start_ts - ONE_WEEK,
- queriedRange.start_ts - 1,
- );
- handleFetchedTrips(ctList, utList, 'prepend');
- setQueriedRange({ start_ts: fetchStartTs, end_ts: queriedRange.end_ts });
- } else if (when == 'future' && !reachedPipelineEnd) {
- if (!isLoading) setIsLoading('append');
- const fetchEndTs = Math.min(queriedRange.end_ts + ONE_WEEK, pipelineRange.end_ts);
- const [ctList, utList] = await fetchTripsInRange(queriedRange.end_ts + 1, fetchEndTs);
- handleFetchedTrips(ctList, utList, 'append');
- setQueriedRange({ start_ts: queriedRange.start_ts, end_ts: fetchEndTs });
- }
- } catch (e) {
- setIsLoading(false);
- displayError(e, t('errors.while-loading-another-week', { when: when }));
- }
- }
-
- async function loadSpecificWeek(day: Date) {
- try {
- logDebug('LabelTab: loadSpecificWeek for day ' + day);
- if (!isLoading) setIsLoading('replace');
- resetNominatimLimiter();
- const threeDaysBefore = DateTime.fromJSDate(day).minus({ days: 3 }).toSeconds();
- const threeDaysAfter = DateTime.fromJSDate(day).plus({ days: 3 }).toSeconds();
- const [ctList, utList] = await fetchTripsInRange(threeDaysBefore, threeDaysAfter);
- handleFetchedTrips(ctList, utList, 'replace');
- setQueriedRange({ start_ts: threeDaysBefore, end_ts: threeDaysAfter });
- } catch (e) {
- setIsLoading(false);
- displayError(e, t('errors.while-loading-specific-week', { day: day }));
- }
- }
-
- function handleFetchedTrips(ctList, utList, mode: 'prepend' | 'append' | 'replace') {
- logDebug(`LabelTab: handleFetchedTrips with
- mode = ${mode};
- ctList = ${JSON.stringify(ctList)};
- utList = ${JSON.stringify(utList)}`);
-
- const tripsRead = ctList.concat(utList);
- // Fill place names on a reversed copy of the list so we fill from the bottom up
- tripsRead
- .slice()
- .reverse()
- .forEach((trip, index) => fillLocationNamesOfTrip(trip));
- const readTimelineMap = compositeTrips2TimelineMap(tripsRead, showPlaces);
- logDebug(`LabelTab: after composite trips converted,
- readTimelineMap = ${[...readTimelineMap.entries()]}`);
- if (mode == 'append') {
- setTimelineMap(new Map([...(timelineMap || []), ...readTimelineMap]));
- } else if (mode == 'prepend') {
- setTimelineMap(new Map([...readTimelineMap, ...(timelineMap || [])]));
- } else if (mode == 'replace') {
- setTimelineMap(readTimelineMap);
- } else {
- return displayErrorMsg('Unknown insertion mode ' + mode);
- }
- }
-
- async function fetchTripsInRange(startTs: number, endTs: number) {
- if (!pipelineRange?.start_ts || !pipelineRange?.end_ts)
- return logWarn('No pipelineRange yet - early return');
- logDebug('LabelTab: fetchTripsInRange from ' + startTs + ' to ' + endTs);
- const readCompositePromise = readAllCompositeTrips(startTs, endTs);
- let readUnprocessedPromise;
- if (endTs >= pipelineRange.end_ts) {
- const nowTs = new Date().getTime() / 1000;
- let lastProcessedTrip: CompositeTrip | undefined;
- if (timelineMap) {
- lastProcessedTrip = [...timelineMap?.values()]
- .reverse()
- .find((trip) => trip.origin_key.includes('trip')) as CompositeTrip;
- }
- readUnprocessedPromise = readUnprocessedTrips(pipelineRange.end_ts, nowTs, 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 c059e44af..000000000
--- a/www/js/diary/LabelTabContext.ts
+++ /dev/null
@@ -1,52 +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 CustomLabelMap = {
- [k: string]: string[];
-};
-
-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/addressNamesHelper.ts b/www/js/diary/addressNamesHelper.ts
index 30740ad19..de3db47ea 100644
--- a/www/js/diary/addressNamesHelper.ts
+++ b/www/js/diary/addressNamesHelper.ts
@@ -75,6 +75,7 @@ export function useLocalStorage(key: string, initialValue: T) {
import Bottleneck from 'bottleneck';
import { displayError, logDebug } from '../plugin/logger';
+import { CompositeTrip } from '../types/diaryTypes';
let nominatimLimiter = new Bottleneck({ maxConcurrent: 2, minTime: 500 });
export const resetNominatimLimiter = () => {
@@ -137,7 +138,7 @@ async function fetchNominatimLocName(loc_geojson) {
}
// Schedules nominatim fetches for the start and end locations of a trip
-export function fillLocationNamesOfTrip(trip) {
+export function fillLocationNamesOfTrip(trip: CompositeTrip) {
nominatimLimiter.schedule(() => fetchNominatimLocName(trip.end_loc));
nominatimLimiter.schedule(() => fetchNominatimLocName(trip.start_loc));
}
diff --git a/www/js/diary/cards/ModesIndicator.tsx b/www/js/diary/cards/ModesIndicator.tsx
index bba65c107..2ec5d9dc2 100644
--- a/www/js/diary/cards/ModesIndicator.tsx
+++ b/www/js/diary/cards/ModesIndicator.tsx
@@ -1,24 +1,24 @@
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 { getBaseModeByKey, getBaseModeByValue } from '../diaryHelper';
import { Text, Icon, useTheme } from 'react-native-paper';
import { useTranslation } from 'react-i18next';
const ModesIndicator = ({ trip, detectedModes }) => {
const { t } = useTranslation();
- const { labelOptions, labelFor } = useContext(LabelTabContext);
+ const { labelOptions, labelFor, confirmedModeFor } = useContext(TimelineContext);
const { colors } = useTheme();
const indicatorBackgroundColor = color(colors.onPrimary).alpha(0.8).rgb().string();
let indicatorBorderColor = color('black').alpha(0.5).rgb().string();
let modeViews;
- const labeledModeForTrip = labelFor(trip, 'MODE');
- if (labelOptions && labeledModeForTrip?.value) {
- const baseMode = getBaseModeByValue(labeledModeForTrip.value, labelOptions);
+ const confirmedModeForTrip = confirmedModeFor(trip);
+ if (labelOptions && confirmedModeForTrip?.value) {
+ const baseMode = getBaseModeByKey(confirmedModeForTrip.baseMode);
indicatorBorderColor = baseMode.color;
logDebug(`TripCard: got baseMode = ${JSON.stringify(baseMode)}`);
modeViews = (
@@ -32,7 +32,7 @@ const ModesIndicator = ({ trip, detectedModes }) => {
fontWeight: '500',
textDecorationLine: 'underline',
}}>
- {labelFor(trip, 'MODE')?.text}
+ {confirmedModeForTrip.text}
);
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..a309d63aa 100644
--- a/www/js/diary/cards/TripCard.tsx
+++ b/www/js/diary/cards/TripCard.tsx
@@ -18,7 +18,7 @@ import { getTheme } from '../../appTheme';
import { DiaryCard, cardStyles } from './DiaryCard';
import { useNavigation } from '@react-navigation/native';
import { useAddressNames } from '../addressNamesHelper';
-import LabelTabContext from '../LabelTabContext';
+import TimelineContext from '../../TimelineContext';
import useDerivedProperties from '../useDerivedProperties';
import StartEndLocations from '../components/StartEndLocations';
import ModesIndicator from './ModesIndicator';
@@ -42,9 +42,9 @@ const TripCard = ({ trip, isFirstInList }: Props) => {
} = useDerivedProperties(trip);
let [tripStartDisplayName, tripEndDisplayName] = useAddressNames(trip);
const navigation = useNavigation();
- const { labelOptions, labelFor, notesFor } = useContext(LabelTabContext);
+ const { labelOptions, confirmedModeFor, notesFor } = useContext(TimelineContext);
const tripGeojson =
- trip && labelOptions && useGeojsonForTrip(trip, labelOptions, labelFor(trip, 'MODE')?.value);
+ trip && labelOptions && useGeojsonForTrip(trip, confirmedModeFor(trip)?.baseMode);
const isDraft = trip.key.includes('UNPROCESSED');
const flavoredTheme = getTheme(isDraft ? 'draft' : undefined);
diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx
index 4985300cb..8fee14d07 100644
--- a/www/js/diary/details/LabelDetailsScreen.tsx
+++ b/www/js/diary/details/LabelDetailsScreen.tsx
@@ -13,7 +13,7 @@ import {
Text,
useTheme,
} from 'react-native-paper';
-import LabelTabContext from '../LabelTabContext';
+import TimelineContext from '../../TimelineContext';
import LeafletView from '../../components/LeafletView';
import { useTranslation } from 'react-i18next';
import MultilabelButtonGroup from '../../survey/multilabel/MultiLabelButtonGroup';
@@ -31,7 +31,7 @@ import { CompositeTrip } from '../../types/diaryTypes';
import NavBar from '../../components/NavBar';
const LabelScreenDetails = ({ route, navigation }) => {
- const { timelineMap, labelOptions, labelFor } = useContext(LabelTabContext);
+ const { timelineMap, labelOptions, confirmedModeFor } = useContext(TimelineContext);
const { t } = useTranslation();
const { height: windowHeight } = useWindowDimensions();
const appConfig = useAppConfig();
@@ -41,17 +41,16 @@ const LabelScreenDetails = ({ route, navigation }) => {
const { displayDate, displayStartTime, displayEndTime } = useDerivedProperties(trip);
const [tripStartDisplayName, tripEndDisplayName] = useAddressNames(trip);
- const [modesShown, setModesShown] = useState<'labeled' | 'detected'>(() =>
+ const [modesShown, setModesShown] = useState<'confirmed' | 'detected'>(() =>
// if trip has a labeled mode, initial state shows that; otherwise, show detected modes
- trip && labelFor(trip, 'MODE')?.value ? 'labeled' : 'detected',
+ trip && confirmedModeFor(trip)?.value ? 'confirmed' : 'detected',
);
const tripGeojson =
trip &&
labelOptions &&
useGeojsonForTrip(
trip,
- labelOptions,
- modesShown == 'labeled' ? labelFor(trip, 'MODE')?.value : undefined,
+ modesShown == 'confirmed' ? confirmedModeFor(trip)?.baseMode : undefined,
);
const mapOpts = { minZoom: 3, maxZoom: 17 };
@@ -86,23 +85,24 @@ const LabelScreenDetails = ({ route, navigation }) => {
)}
-
- {/* Full-size Leaflet map, with zoom controls */}
-
+ {tripGeojson && (
+ // Full-size Leaflet map, with zoom controls
+
+ )}
{/* If trip is labeled, show a toggle to switch between "Labeled Mode" and "Detected Modes"
otherwise, just show "Detected" */}
- {trip && labelFor(trip, 'MODE')?.value ? (
+ {trip && confirmedModeFor(trip)?.value ? (
setModesShown(v)}
+ onValueChange={(v: 'confirmed' | 'detected') => setModesShown(v)}
value={modesShown}
density="medium"
buttons={[
- { label: t('diary.labeled-mode'), value: 'labeled' },
+ { label: t('diary.labeled-mode'), value: 'confirmed' },
{ label: t('diary.detected-modes'), value: 'detected' },
]}
/>
@@ -118,7 +118,7 @@ const LabelScreenDetails = ({ route, navigation }) => {
)}
{/* section-by-section breakdown of duration, distance, and mode */}
-
+
{/* Overall trip duration, distance, and modes.
Only show this when multiple sections are shown, and we are showing detected modes.
If we just showed the labeled mode or a single section, this would be redundant. */}
diff --git a/www/js/diary/details/TripSectionsDescriptives.tsx b/www/js/diary/details/TripSectionsDescriptives.tsx
index fdab61eb3..13c15019d 100644
--- a/www/js/diary/details/TripSectionsDescriptives.tsx
+++ b/www/js/diary/details/TripSectionsDescriptives.tsx
@@ -3,10 +3,10 @@ import { View, StyleSheet } from 'react-native';
import { Icon, Text, useTheme } from 'react-native-paper';
import useDerivedProperties from '../useDerivedProperties';
import { getBaseModeByKey, getBaseModeByValue } from '../diaryHelper';
-import LabelTabContext from '../LabelTabContext';
+import TimelineContext from '../../TimelineContext';
-const TripSectionsDescriptives = ({ trip, showLabeledMode = false }) => {
- const { labelOptions, labelFor } = useContext(LabelTabContext);
+const TripSectionsDescriptives = ({ trip, showConfirmedMode = false }) => {
+ const { labelOptions, labelFor, confirmedModeFor } = useContext(TimelineContext);
const {
displayStartTime,
displayTime,
@@ -17,14 +17,14 @@ const TripSectionsDescriptives = ({ trip, showLabeledMode = false }) => {
const { colors } = useTheme();
- const labeledModeForTrip = labelFor(trip, 'MODE');
+ const confirmedModeForTrip = confirmedModeFor(trip);
let sections = formattedSectionProperties;
/* if we're only showing the labeled mode, or there are no sections (i.e. unprocessed trip),
we treat this as unimodal and use trip-level attributes to construct a single section */
- if ((showLabeledMode && labeledModeForTrip) || !trip.sections?.length) {
+ if ((showConfirmedMode && confirmedModeForTrip) || !trip.sections?.length) {
let baseMode;
- if (showLabeledMode && labelOptions && labeledModeForTrip) {
- baseMode = getBaseModeByValue(labeledModeForTrip.value, labelOptions);
+ if (showConfirmedMode && labelOptions && confirmedModeForTrip) {
+ baseMode = getBaseModeByKey(confirmedModeForTrip.baseMode);
} else {
baseMode = getBaseModeByKey('UNPROCESSED');
}
@@ -62,9 +62,9 @@ const TripSectionsDescriptives = ({ trip, showLabeledMode = false }) => {
- {showLabeledMode && labeledModeForTrip && (
+ {showConfirmedMode && confirmedModeForTrip && (
- {labeledModeForTrip.text}
+ {confirmedModeForTrip.text}
)}
diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts
index f02797fff..12495742d 100644
--- a/www/js/diary/diaryHelper.ts
+++ b/www/js/diary/diaryHelper.ts
@@ -192,6 +192,15 @@ export function getFormattedSectionProperties(trip: CompositeTrip, imperialConfi
}));
}
+/**
+ * @param trip A composite trip object
+ * @return the primary section of the trip, i.e. the section with the greatest distance
+ */
+export function primarySectionForTrip(trip: CompositeTrip) {
+ if (!trip.sections?.length) return undefined;
+ return trip.sections.reduce((prev, curr) => (prev.distance > curr.distance ? prev : curr));
+}
+
export function getLocalTimeString(dt?: LocalDt) {
if (!dt) return;
const dateTime = DateTime.fromObject({
diff --git a/www/js/diary/list/DateSelect.tsx b/www/js/diary/list/DateSelect.tsx
index 02b8d1ca1..d79568e91 100644
--- a/www/js/diary/list/DateSelect.tsx
+++ b/www/js/diary/list/DateSelect.tsx
@@ -1,95 +1,120 @@
-/* This button launches a modal to select a date, which determines which week of
- travel should be displayed in the Label screen.
- The button itself is a NavBarButton, which shows the currently selected date range,
+/* This button reflects what date range for which the timeline is currently loaded.
+ If mode is 'single', one date can be selected; if 'range', start and end dates can be selected.
+ The button itself is a NavBarButton, which shows the currently loaded date range,
a calendar icon, and launches the modal when clicked.
The modal is a DatePickerModal from react-native-paper-dates, which shows a calendar
- and allows the user to select a date.
+ and allows the user to select date(s).
*/
-import React, { useEffect, useState, useMemo, useContext } from 'react';
+import React, { useMemo, useContext } from 'react';
import { StyleSheet } from 'react-native';
import { DateTime } from 'luxon';
-import LabelTabContext from '../LabelTabContext';
-import { DatePickerModal } from 'react-native-paper-dates';
+import TimelineContext from '../../TimelineContext';
+import {
+ DatePickerModal,
+ DatePickerModalRangeProps,
+ DatePickerModalSingleProps,
+} from 'react-native-paper-dates';
import { Text, Divider, useTheme } from 'react-native-paper';
import i18next from 'i18next';
import { useTranslation } from 'react-i18next';
import { NavBarButton } from '../../components/NavBar';
+import { isoDateRangeToTsRange } from '../timelineHelper';
-const DateSelect = ({ tsRange, loadSpecificWeekFn }) => {
- const { pipelineRange } = useContext(LabelTabContext);
+type Props = Partial & {
+ mode: 'single' | 'range';
+ onChoose: (params) => void;
+};
+const DateSelect = ({ mode, onChoose, ...rest }: Props) => {
+ const { pipelineRange, queriedDateRange } = useContext(TimelineContext);
const { t } = useTranslation();
const { colors } = useTheme();
const [open, setOpen] = React.useState(false);
- const [dateRange, setDateRange] = useState([null, null]);
- const [selDate, setSelDate] = useState(new Date());
const minMaxDates = useMemo(() => {
- if (!pipelineRange) return { startDate: new Date(), endDate: new Date() };
+ if (!pipelineRange?.start_ts) return { startDate: new Date(), endDate: new Date() };
return {
- startDate: new Date(pipelineRange?.start_ts * 1000),
- endDate: new Date(pipelineRange?.end_ts * 1000),
+ startDate: new Date(pipelineRange?.start_ts * 1000), // start of pipeline
+ endDate: new Date(), // today
};
}, [pipelineRange]);
- useEffect(() => {
- if (!pipelineRange || !tsRange.oldestTs) return;
- const displayStartTs = Math.max(tsRange.oldestTs, pipelineRange.start_ts);
+ const queriedRangeAsJsDates = useMemo(
+ () => queriedDateRange?.map((d) => DateTime.fromISO(d).toJSDate()),
+ [queriedDateRange],
+ );
+
+ const displayDateRange = useMemo(() => {
+ if (!pipelineRange || !queriedDateRange?.[0]) return null;
+ const [queriedStartTs, queriedEndTs] = isoDateRangeToTsRange(queriedDateRange);
+ const displayStartTs = Math.max(queriedStartTs, pipelineRange.start_ts);
const displayStartDate = DateTime.fromSeconds(displayStartTs).toLocaleString(
DateTime.DATE_SHORT,
);
-
let displayEndDate;
- if (tsRange.latestTs < pipelineRange.end_ts) {
- displayEndDate = DateTime.fromSeconds(tsRange.latestTs).toLocaleString(DateTime.DATE_SHORT);
+ if (queriedEndTs < pipelineRange.end_ts) {
+ displayEndDate = DateTime.fromSeconds(queriedEndTs).toLocaleString(DateTime.DATE_SHORT);
}
- setDateRange([displayStartDate, displayEndDate]);
+ return [displayStartDate, displayEndDate];
+ }, [pipelineRange, queriedDateRange]);
- const mid = (tsRange.oldestTs + tsRange.latestTs) / 2;
- const d = new Date(Math.min(mid, pipelineRange.end_ts) * 1000);
- setSelDate(d);
- }, [tsRange]);
+ const midpointDate = useMemo(() => {
+ if (!pipelineRange || !queriedDateRange?.[0]) return undefined;
+ const [queriedStartTs, queriedEndTs] = isoDateRangeToTsRange(queriedDateRange);
+ const mid = (queriedStartTs + queriedEndTs) / 2;
+ return new Date(Math.min(mid, pipelineRange.end_ts) * 1000);
+ }, [queriedDateRange]);
const onDismissSingle = React.useCallback(() => {
setOpen(false);
}, [setOpen]);
- const onChoose = React.useCallback(
- (params) => {
- loadSpecificWeekFn(params.date);
- setOpen(false);
- },
- [setOpen, loadSpecificWeekFn],
- );
- const dateRangeEnd = dateRange[1] || t('diary.today');
+ const displayDateRangeEnd = displayDateRange?.[1] || t('diary.today');
return (
<>
setOpen(true)}>
- {dateRange[0] && (
+ {displayDateRange?.[0] && (
<>
- {dateRange[0]}
+ {displayDateRange?.[0]}
>
)}
- {dateRangeEnd}
+ {displayDateRangeEnd}
{
+ if (mode == 'single') {
+ onChoose(params);
+ onDismissSingle();
+ }
+ }}
+ onConfirm={(params) => {
+ if (mode == 'range') {
+ onChoose(params);
+ onDismissSingle();
+ } else {
+ onDismissSingle();
+ }
+ }}
+ {...rest}
/>
>
);
diff --git a/www/js/diary/list/FilterSelect.tsx b/www/js/diary/list/FilterSelect.tsx
index 039d76be0..c9d23d602 100644
--- a/www/js/diary/list/FilterSelect.tsx
+++ b/www/js/diary/list/FilterSelect.tsx
@@ -12,10 +12,10 @@ 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[];
+ filters: LabelTabFilter[] | null;
setFilters: (filters: LabelTabFilter[]) => void;
numListDisplayed?: number;
numListTotal?: number;
@@ -32,6 +32,7 @@ const FilterSelect = ({ filters, setFilters, numListDisplayed, numListTotal }: P
}, [filters, numListDisplayed, numListTotal]);
function chooseFilter(filterKey) {
+ if (!filters) return;
if (filterKey == 'show-all') {
setFilters(filters.map((f) => ({ ...f, state: false })));
} else {
@@ -62,9 +63,7 @@ const FilterSelect = ({ filters, setFilters, numListDisplayed, numListTotal }: P
{/* {t('diary.filter-travel')} */}
chooseFilter(k)} value={selectedFilter}>
- {filters.map((f) => (
-
- ))}
+ {filters?.map((f) => )}
{
- const {
- filterInputs,
- setFilterInputs,
- timelineMap,
- displayedEntries,
- queriedRange,
- loadSpecificWeek,
- refresh,
- pipelineRange,
- loadAnotherWeek,
- isLoading,
- } = useContext(LabelTabContext);
+ const { filterInputs, setFilterInputs, displayedEntries } = useContext(LabelTabContext);
+ const { timelineMap, loadDateRange, timelineIsLoading, refreshTimeline, shouldUpdateTimeline } =
+ useContext(TimelineContext);
const { colors } = useTheme();
return (
<>
-
+
{
numListTotal={timelineMap?.size}
/>
{
+ const start = DateTime.fromJSDate(startDate).toISODate();
+ const end = DateTime.fromJSDate(endDate).toISODate();
+ if (!start || !end) return displayErrorMsg('Invalid date');
+ loadDateRange([start, end]);
+ }}
/>
refresh()}
+ onPress={() => refreshTimeline()}
accessibilityLabel="Refresh"
style={{ marginLeft: 'auto' }}
/>
-
+ {shouldUpdateTimeline && }
>
);
diff --git a/www/js/diary/list/TimelineScrollList.tsx b/www/js/diary/list/TimelineScrollList.tsx
index 79faffbdf..57842bbcf 100644
--- a/www/js/diary/list/TimelineScrollList.tsx
+++ b/www/js/diary/list/TimelineScrollList.tsx
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useContext, useMemo } from 'react';
import TripCard from '../cards/TripCard';
import PlaceCard from '../cards/PlaceCard';
import UntrackedTimeCard from '../cards/UntrackedTimeCard';
@@ -6,6 +6,8 @@ import { View, FlatList } from 'react-native';
import { ActivityIndicator, Banner, Icon, Text } from 'react-native-paper';
import LoadMoreButton from './LoadMoreButton';
import { useTranslation } from 'react-i18next';
+import { isoDateRangeToTsRange } from '../timelineHelper';
+import TimelineContext from '../../TimelineContext';
function renderCard({ item: listEntry, index }) {
if (listEntry.origin_key.includes('trip')) {
@@ -25,34 +27,31 @@ const smallSpinner = ;
type Props = {
listEntries: any[] | null;
- queriedRange: any;
- pipelineRange: any;
- loadMoreFn: (direction: string) => void;
- isLoading: boolean | string;
};
-const TimelineScrollList = ({
- listEntries,
- queriedRange,
- pipelineRange,
- loadMoreFn,
- isLoading,
-}: Props) => {
+const TimelineScrollList = ({ listEntries }: Props) => {
const { t } = useTranslation();
+ const { pipelineRange, queriedDateRange, timelineIsLoading, loadMoreDays } =
+ useContext(TimelineContext);
const listRef = React.useRef(null);
// The way that FlashList inverts the scroll view means we have to reverse the order of items too
const reversedListEntries = listEntries ? [...listEntries].reverse() : [];
- const reachedPipelineStart = queriedRange?.start_ts <= pipelineRange?.start_ts;
+ const [reachedPipelineStart, reachedPipelineEnd] = useMemo(() => {
+ if (!queriedDateRange || !pipelineRange) return [false, false];
+
+ const [queriedStartTs, queriedEndTs] = isoDateRangeToTsRange(queriedDateRange);
+ return [queriedStartTs <= pipelineRange.start_ts, queriedEndTs >= pipelineRange.end_ts];
+ }, [queriedDateRange, pipelineRange]);
+
const footer = (
- loadMoreFn('past')} disabled={reachedPipelineStart}>
+ loadMoreDays('past', 7)} disabled={reachedPipelineStart}>
{reachedPipelineStart ? t('diary.no-more-travel') : t('diary.show-older-travel')}
);
- const reachedPipelineEnd = queriedRange?.end_ts >= pipelineRange?.end_ts;
const header = (
- loadMoreFn('future')} disabled={reachedPipelineEnd}>
+ loadMoreDays('future', 7)} disabled={reachedPipelineEnd}>
{reachedPipelineEnd ? t('diary.no-more-travel') : t('diary.show-more-travel')}
);
@@ -70,7 +69,7 @@ const TimelineScrollList = ({
/* Condition: pipelineRange has been fetched but has no defined end, meaning nothing has been
processed for this OPCode yet, and there are no unprocessed trips either. Show 'no travel'. */
return noTravelBanner;
- } else if (isLoading == 'replace') {
+ } else if (timelineIsLoading == 'replace') {
/* Condition: we're loading an entirely new batch of trips, so show a big spinner */
return bigSpinner;
} else if (listEntries && listEntries.length == 0) {
@@ -90,9 +89,9 @@ const TimelineScrollList = ({
This might be a nicer experience than the current header and footer buttons. */
// onScroll={e => console.debug(e.nativeEvent.contentOffset.y)}
ListHeaderComponent={
- isLoading == 'append' ? smallSpinner : !reachedPipelineEnd ? header : null
+ timelineIsLoading == 'append' ? smallSpinner : !reachedPipelineEnd ? header : null
}
- ListFooterComponent={isLoading == 'prepend' ? smallSpinner : footer}
+ ListFooterComponent={timelineIsLoading == 'prepend' ? smallSpinner : footer}
ItemSeparatorComponent={separator}
/* use column-reverse so that the list is 'inverted', meaning it should start
scrolling from the bottom, and the bottom-most item should be first in the DOM tree
diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts
index f140f1750..d5c1e507b 100644
--- a/www/js/diary/timelineHelper.ts
+++ b/www/js/diary/timelineHelper.ts
@@ -14,31 +14,35 @@ import {
TimestampRange,
CompositeTrip,
UnprocessedTrip,
+ BluetoothBleData,
+ SectionData,
+ CompositeTripLocation,
+ SectionSummary,
} from '../types/diaryTypes';
import { getLabelInputDetails, getLabelInputs } from '../survey/multilabel/confirmHelper';
import { LabelOptions } from '../types/labelTypes';
-import { EnketoUserInputEntry, filterByNameAndVersion } from '../survey/enketo/enketoHelper';
+import {
+ EnketoUserInputEntry,
+ filterByNameAndVersion,
+ resolveSurveyButtonConfig,
+} from '../survey/enketo/enketoHelper';
import { AppConfig } from '../types/appConfigTypes';
import { Point, Feature } from 'geojson';
+import { ble_matching } from 'e-mission-common';
const cachedGeojsons: Map = new Map();
/**
* @description Gets a formatted GeoJSON object for a trip, including the start and end places and the trajectory.
*/
-export function useGeojsonForTrip(
- trip: CompositeTrip,
- labelOptions: LabelOptions,
- labeledMode?: string,
-) {
+export function useGeojsonForTrip(trip: CompositeTrip, baseMode?: string) {
if (!trip?._id?.$oid) return;
- const gjKey = `trip-${trip._id.$oid}-${labeledMode || 'detected'}`;
+ const gjKey = `trip-${trip._id.$oid}-${baseMode || 'detected'}`;
if (cachedGeojsons.has(gjKey)) {
return cachedGeojsons.get(gjKey);
}
- const trajectoryColor =
- (labeledMode && getBaseModeByValue(labeledMode, labelOptions)?.color) || undefined;
+ const trajectoryColor = (baseMode && getBaseModeByKey(baseMode)?.color) || undefined;
logDebug("Reading trip's " + trip.locations.length + ' location points at ' + new Date());
const features = [
@@ -91,7 +95,8 @@ export function compositeTrips2TimelineMap(ctList: Array, unpackPlaces?: bo
}
/* 'LABELS' are 1:1 - each trip or place has a single label for each label type
- (e.g. 'MODE' and 'PURPOSE' for MULTILABEL configuration, or 'SURVEY' for ENKETO configuration) */
+ (e.g. 'MODE' and 'PURPOSE' for MULTILABEL configuration, or the name of the survey
+ for ENKETO configuration) */
export let unprocessedLabels: { [key: string]: UserInputEntry[] } = {};
/* 'NOTES' are 1:n - each trip or place can have any number of notes */
export let unprocessedNotes: EnketoUserInputEntry[] = [];
@@ -115,10 +120,14 @@ function updateUnprocessedInputs(
const labelResults = comboResults.slice(0, labelsPromises.length);
const notesResults = comboResults.slice(labelsPromises.length).flat(2);
// fill in the unprocessedLabels object with the labels we just read
+ unprocessedLabels = {};
labelResults.forEach((r, i) => {
if (appConfig.survey_info?.['trip-labels'] == 'ENKETO') {
- const filtered = filterByNameAndVersion('TripConfirmSurvey', r, appConfig);
- unprocessedLabels['SURVEY'] = filtered as UserInputEntry[];
+ const tripSurveys = resolveSurveyButtonConfig(appConfig, 'trip-label');
+ tripSurveys.forEach((survey) => {
+ const filtered = filterByNameAndVersion(survey.surveyName, r, appConfig);
+ unprocessedLabels[survey.surveyName] = filtered as UserInputEntry[];
+ });
} else {
unprocessedLabels[getLabelInputs()[i]] = r;
}
@@ -175,6 +184,23 @@ export async function updateAllUnprocessedInputs(
await updateUnprocessedInputs(labelsPromises, notesPromises, appConfig);
}
+export let unprocessedBleScans: BEMData[] = [];
+
+export async function updateUnprocessedBleScans(queryRange: TimestampRange) {
+ const tq = {
+ key: 'write_ts',
+ startTs: queryRange.start_ts,
+ endTs: queryRange.end_ts,
+ };
+ const getMethod = window['cordova'].plugins.BEMUserCache.getSensorDataForInterval;
+ await getUnifiedDataForInterval('background/bluetooth_ble', tq, getMethod).then(
+ (bleScans: BEMData[]) => {
+ logDebug(`Read ${bleScans.length} BLE scans`);
+ unprocessedBleScans = bleScans;
+ },
+ );
+}
+
export function keysForLabelInputs(appConfig: AppConfig) {
if (appConfig.survey_info?.['trip-labels'] == 'ENKETO') {
return ['manual/trip_user_input'];
@@ -215,10 +241,10 @@ const location2GeojsonPoint = (locationPoint: Point, featureType: string): Featu
*/
function locations2GeojsonTrajectory(
trip: CompositeTrip,
- locationList: Array,
+ locationList: CompositeTripLocation[],
trajectoryColor?: string,
-) {
- let sectionsPoints;
+): Feature[] {
+ let sectionsPoints: CompositeTripLocation[][];
if (!trip.sections) {
// this is a unimodal trip so we put all the locations in one section
sectionsPoints = [locationList];
@@ -242,6 +268,9 @@ function locations2GeojsonTrajectory(
color for the sensed mode of this section, and fall back to dark grey */
color: trajectoryColor || getBaseModeByKey(section?.sensed_mode_str)?.color || '#333',
},
+ properties: {
+ feature_type: 'section_trajectory',
+ },
};
});
}
@@ -249,12 +278,13 @@ function locations2GeojsonTrajectory(
// DB entries retrieved from the server have '_id', 'metadata', and 'data' fields.
// This function returns a shallow copy of the obj, which flattens the
// 'data' field into the top level, while also including '_id' and 'metadata.key'
-const unpackServerData = (obj: BEMData) => ({
- ...obj.data,
- _id: obj._id,
- key: obj.metadata.key,
- origin_key: obj.metadata.origin_key || obj.metadata.key,
-});
+const unpackServerData = (obj: BEMData) =>
+ obj && {
+ ...obj.data,
+ _id: obj._id,
+ key: obj.metadata.key,
+ origin_key: obj.metadata.origin_key || obj.metadata.key,
+ };
export function readAllCompositeTrips(startTs: number, endTs: number) {
const readPromises = [getRawEntries(['analysis/composite_trip'], startTs, endTs, 'data.end_ts')];
@@ -288,7 +318,25 @@ const dateTime2localdate = (currtime: DateTime, tz: string) => ({
second: currtime.second,
});
-function points2TripProps(locationPoints: Array>) {
+/* Compute a section summary, which is really simple for unprocessed trips because they are
+ always assumed to be unimodal.
+/* maybe unify with eaum.get_section_summary on e-mission-server at some point */
+const getSectionSummaryForUnprocessed = (section: SectionData, modeProp): SectionSummary => {
+ const baseMode = section[modeProp] || 'UNKNOWN';
+ return {
+ count: { [baseMode]: 1 },
+ distance: { [baseMode]: section.distance },
+ duration: { [baseMode]: section.duration },
+ };
+};
+
+/**
+ * @description Given an array of location points, creates an UnprocessedTrip object.
+ */
+function points2UnprocessedTrip(
+ locationPoints: Array>,
+ appConfig: AppConfig,
+): UnprocessedTrip {
const startPoint = locationPoints[0];
const endPoint = locationPoints[locationPoints.length - 1];
const tripAndSectionId = `unprocessed_${startPoint.data.ts}_${endPoint.data.ts}`;
@@ -318,24 +366,60 @@ function points2TripProps(locationPoints: Array>) {
speed: speeds[i],
}));
- return {
- _id: { $oid: tripAndSectionId },
- key: 'UNPROCESSED_trip',
- origin_key: 'UNPROCESSED_trip',
- additions: [],
- confidence_threshold: 0,
+ // baseProps: these are the properties that are the same between the trip and its section
+ const baseProps = {
distance: dists.reduce((a, b) => a + b, 0),
duration: endPoint.data.ts - startPoint.data.ts,
end_fmt_time: endTime.toISO() || displayErrorMsg('end_fmt_time: invalid DateTime') || '',
+ end_loc: {
+ type: 'Point',
+ coordinates: [endPoint.data.longitude, endPoint.data.latitude],
+ } as Point,
end_local_dt: dateTime2localdate(endTime, endPoint.metadata.time_zone),
end_ts: endPoint.data.ts,
- expectation: { to_label: true },
- inferred_labels: [],
- locations: locations,
source: 'unprocessed',
start_fmt_time: startTime.toISO() || displayErrorMsg('start_fmt_time: invalid DateTime') || '',
+ start_loc: {
+ type: 'Point',
+ coordinates: [startPoint.data.longitude, startPoint.data.latitude],
+ } as Point,
start_local_dt: dateTime2localdate(startTime, startPoint.metadata.time_zone),
start_ts: startPoint.data.ts,
+ } as const;
+
+ // section: baseProps + some properties that are unique to the section
+ const singleSection: SectionData = {
+ ...baseProps,
+ _id: { $oid: `unprocessed_section_${tripAndSectionId}` },
+ cleaned_section: { $oid: `unprocessed_section_${tripAndSectionId}` },
+ key: 'UNPROCESSED_section',
+ origin_key: 'UNPROCESSED_section',
+ sensed_mode: 4, // MotionTypes.UNKNOWN (4)
+ sensed_mode_str: 'UNKNOWN',
+ ble_sensed_mode: ble_matching.get_ble_sensed_vehicle_for_section(
+ unprocessedBleScans,
+ baseProps.start_ts,
+ baseProps.end_ts,
+ appConfig,
+ ),
+ trip_id: { $oid: tripAndSectionId },
+ };
+
+ // the complete UnprocessedTrip: baseProps + properties that are unique to the trip, including the section
+ return {
+ ...baseProps,
+ _id: { $oid: tripAndSectionId },
+ additions: [],
+ ble_sensed_summary: getSectionSummaryForUnprocessed(singleSection, 'ble_sensed_mode'),
+ cleaned_section_summary: getSectionSummaryForUnprocessed(singleSection, 'sensed_mode_str'),
+ inferred_section_summary: getSectionSummaryForUnprocessed(singleSection, 'sensed_mode_str'),
+ confidence_threshold: 0,
+ expectation: { to_label: true },
+ inferred_labels: [],
+ key: 'UNPROCESSED_trip',
+ locations: locations,
+ origin_key: 'UNPROCESSED_trip',
+ sections: [singleSection],
user_input: {},
};
}
@@ -343,7 +427,14 @@ function points2TripProps(locationPoints: Array>) {
const tsEntrySort = (e1: BEMData, e2: BEMData) =>
e1.data.ts - e2.data.ts; // compare timestamps
-function transitionTrip2TripObj(trip: Array): Promise {
+/**
+ * @description Given an array of 2 transitions, queries the location data during that time and promises an UnprocessedTrip object.
+ * @param trip An array of transitions representing one trip; i.e. [start transition, end transition]
+ */
+function tripTransitions2UnprocessedTrip(
+ trip: Array,
+ appConfig: AppConfig,
+): Promise {
const tripStartTransition = trip[0];
const tripEndTransition = trip[1];
const tq = {
@@ -385,20 +476,7 @@ function transitionTrip2TripObj(trip: Array): Promise) {
// Logger.log("Returning false");
return false;
}
-/*
- * This is going to be a bit tricky. As we can see from
- * https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163,
- * when we read local transitions, they have a string for the transition
- * (e.g. `T_DATA_PUSHED`), while the remote transitions have an integer
- * (e.g. `2`).
- * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286338606
- *
- * Also, at least on iOS, it is possible for trip end to be detected way
- * after the end of the trip, so the trip end transition of a processed
- * trip may actually show up as an unprocessed transition.
- * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163
- *
- * Let's abstract this out into our own minor state machine.
+
+/**
+ * @description Given an array of transitions, finds which transitions represent the start and end of a detected trip and returns them as pairs.
+ * @returns An 2D array of transitions, where each inner array represents one trip; i.e. [start transition, end transition]
*/
-function transitions2Trips(transitionList: Array>) {
+function transitions2TripTransitions(transitionList: Array>) {
+ /* This is going to be a bit tricky. As we can see from
+ * https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163,
+ * when we read local transitions, they have a string for the transition
+ * (e.g. `T_DATA_PUSHED`), while the remote transitions have an integer
+ * (e.g. `2`).
+ * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286338606
+ *
+ * Also, at least on iOS, it is possible for trip end to be detected way
+ * after the end of the trip, so the trip end transition of a processed
+ * trip may actually show up as an unprocessed transition.
+ * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163
+ *
+ * Let's abstract this out into our own minor state machine.
+ */
let inTrip = false;
const tripList: [BEMData, BEMData][] = [];
let currStartTransitionIndex = -1;
@@ -508,6 +590,7 @@ function linkTrips(trip1, trip2) {
export function readUnprocessedTrips(
startTs: number,
endTs: number,
+ appConfig: AppConfig,
lastProcessedTrip?: CompositeTrip,
) {
const tq = { key: 'write_ts', startTs, endTs };
@@ -520,12 +603,14 @@ export function readUnprocessedTrips(
return [];
} else {
logDebug(`Found ${transitionList.length} transitions. yay!`);
- const tripsList = transitions2Trips(transitionList);
+ const tripsList = transitions2TripTransitions(transitionList);
logDebug(`Mapped into ${tripsList.length} trips. yay!`);
tripsList.forEach((trip) => {
logDebug(JSON.stringify(trip, null, 2));
});
- const tripFillPromises = tripsList.map(transitionTrip2TripObj);
+ const tripFillPromises = tripsList.map((t) =>
+ tripTransitions2UnprocessedTrip(t, appConfig),
+ );
return Promise.all(tripFillPromises).then(
(rawTripObjs: (UnprocessedTrip | undefined)[]) => {
// Now we need to link up the trips. linking unprocessed trips
@@ -564,3 +649,26 @@ export function readUnprocessedTrips(
},
);
}
+
+/**
+ * @example IsoDateWithOffset('2024-03-22', 1) -> '2024-03-23'
+ * @example IsoDateWithOffset('2024-03-22', -1000) -> '2021-06-26'
+ */
+export function isoDateWithOffset(date: string, offset: number) {
+ let d = new Date(date);
+ d.setUTCDate(d.getUTCDate() + offset);
+ return d.toISOString().substring(0, 10);
+}
+
+export const isoDateRangeToTsRange = (dateRange: [string, string], zone?) => [
+ DateTime.fromISO(dateRange[0], { zone: zone }).startOf('day').toSeconds(),
+ DateTime.fromISO(dateRange[1], { zone: zone }).endOf('day').toSeconds(),
+];
+
+/**
+ * @example isoDatesDifference('2024-03-22', '2024-03-29') -> 7
+ * @example isoDatesDifference('2024-03-22', '2021-06-26') -> 1000
+ * @example isoDatesDifference('2024-03-29', '2024-03-25') -> -4
+ */
+export const isoDatesDifference = (date1: string, date2: string) =>
+ -DateTime.fromISO(date1).diff(DateTime.fromISO(date2), 'days').days;
diff --git a/www/js/diary/useDerivedProperties.tsx b/www/js/diary/useDerivedProperties.tsx
index fe324ee3f..f13c1862d 100644
--- a/www/js/diary/useDerivedProperties.tsx
+++ b/www/js/diary/useDerivedProperties.tsx
@@ -1,4 +1,4 @@
-import { useMemo } from 'react';
+import { useContext, useMemo } from 'react';
import { useImperialConfig } from '../config/useImperialConfig';
import {
getFormattedDate,
@@ -8,10 +8,13 @@ import {
getLocalTimeString,
getDetectedModes,
isMultiDay,
+ primarySectionForTrip,
} from './diaryHelper';
+import TimelineContext from '../TimelineContext';
const useDerivedProperties = (tlEntry) => {
const imperialConfig = useImperialConfig();
+ const { confirmedModeFor } = useContext(TimelineContext);
return useMemo(() => {
const beginFmt = tlEntry.start_fmt_time || tlEntry.enter_fmt_time;
@@ -21,6 +24,8 @@ const useDerivedProperties = (tlEntry) => {
const tlEntryIsMultiDay = isMultiDay(beginFmt, endFmt);
return {
+ confirmedMode: confirmedModeFor(tlEntry),
+ primary_ble_sensed_mode: primarySectionForTrip(tlEntry)?.ble_sensed_mode?.baseMode,
displayDate: getFormattedDate(beginFmt, endFmt),
displayStartTime: getLocalTimeString(beginDt),
displayEndTime: getLocalTimeString(endDt),
@@ -32,7 +37,7 @@ const useDerivedProperties = (tlEntry) => {
distanceSuffix: imperialConfig.distanceSuffix,
detectedModes: getDetectedModes(tlEntry),
};
- }, [tlEntry, imperialConfig]);
+ }, [tlEntry, imperialConfig, confirmedModeFor(tlEntry)]);
};
export default useDerivedProperties;
diff --git a/www/js/metrics/ActiveMinutesTableCard.tsx b/www/js/metrics/ActiveMinutesTableCard.tsx
index 92a6ac768..aa8bc389f 100644
--- a/www/js/metrics/ActiveMinutesTableCard.tsx
+++ b/www/js/metrics/ActiveMinutesTableCard.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo, useState } from 'react';
+import React, { useContext, useMemo, useState } from 'react';
import { Card, DataTable, useTheme } from 'react-native-paper';
import { MetricsData } from './metricsTypes';
import { cardStyles } from './MetricsTab';
@@ -7,21 +7,32 @@ import {
formatDateRangeOfDays,
secondsToMinutes,
segmentDaysByWeeks,
+ valueForFieldOnDay,
} from './metricsHelper';
import { useTranslation } from 'react-i18next';
import { ACTIVE_MODES } from './WeeklyActiveMinutesCard';
import { labelKeyToRichMode } from '../survey/multilabel/confirmHelper';
+import TimelineContext from '../TimelineContext';
+import useAppConfig from '../useAppConfig';
type Props = { userMetrics?: MetricsData };
const ActiveMinutesTableCard = ({ userMetrics }: Props) => {
const { colors } = useTheme();
+ const { dateRange } = useContext(TimelineContext);
const { t } = useTranslation();
+ const appConfig = useAppConfig();
+ // modes to consider as "active" for the purpose of calculating "active minutes", default : ['walk', 'bike']
+ const activeModes =
+ appConfig?.metrics?.phone_dashboard_ui?.active_travel_options?.modes_list ?? ACTIVE_MODES;
const cumulativeTotals = useMemo(() => {
if (!userMetrics?.duration) return [];
const totals = {};
- ACTIVE_MODES.forEach((mode) => {
- const sum = userMetrics.duration.reduce((acc, day) => acc + (day[`label_${mode}`] || 0), 0);
+ activeModes.forEach((mode) => {
+ const sum = userMetrics.duration.reduce(
+ (acc, day) => acc + (valueForFieldOnDay(day, 'mode_confirm', mode) || 0),
+ 0,
+ );
totals[mode] = secondsToMinutes(sum);
});
totals['period'] = formatDateRangeOfDays(userMetrics.duration);
@@ -30,12 +41,15 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => {
const recentWeeksActiveModesTotals = useMemo(() => {
if (!userMetrics?.duration) return [];
- return segmentDaysByWeeks(userMetrics.duration)
+ return segmentDaysByWeeks(userMetrics.duration, dateRange[1])
.reverse()
.map((week) => {
const totals = {};
- ACTIVE_MODES.forEach((mode) => {
- const sum = week.reduce((acc, day) => acc + (day[`label_${mode}`] || 0), 0);
+ activeModes.forEach((mode) => {
+ const sum = week.reduce(
+ (acc, day) => acc + (valueForFieldOnDay(day, 'mode_confirm', mode) || 0),
+ 0,
+ );
totals[mode] = secondsToMinutes(sum);
});
totals['period'] = formatDateRangeOfDays(week);
@@ -48,8 +62,8 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => {
return userMetrics.duration
.map((day) => {
const totals = {};
- ACTIVE_MODES.forEach((mode) => {
- const sum = day[`label_${mode}`] || 0;
+ activeModes.forEach((mode) => {
+ const sum = valueForFieldOnDay(day, 'mode_confirm', mode) || 0;
totals[mode] = secondsToMinutes(sum);
});
totals['period'] = formatDate(day);
@@ -79,7 +93,7 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => {
- {ACTIVE_MODES.map((mode, i) => (
+ {activeModes.map((mode, i) => (
{labelKeyToRichMode(mode)}
@@ -88,7 +102,7 @@ const ActiveMinutesTableCard = ({ userMetrics }: Props) => {
{allTotals.slice(from, to).map((total, i) => (
{total['period']}
- {ACTIVE_MODES.map((mode, j) => (
+ {activeModes.map((mode, j) => (
{total[mode]} {t('metrics.minutes')}
diff --git a/www/js/metrics/CarbonFootprintCard.tsx b/www/js/metrics/CarbonFootprintCard.tsx
index 56e955f60..9624e10df 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';
@@ -23,23 +23,33 @@ 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';
+import useAppConfig from '../useAppConfig';
type Props = { userMetrics?: MetricsData; aggMetrics?: MetricsData };
const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => {
const { colors } = useAppTheme();
+ const { dateRange } = useContext(TimelineContext);
+ const appConfig = useAppConfig();
const { t } = useTranslation();
-
+ // Whether to show the uncertainty on the carbon footprint charts, default: true
+ const showUnlabeledMetrics =
+ appConfig?.metrics?.phone_dashboard_ui?.footprint_options?.unlabeled_uncertainty ?? true;
const [emissionsChange, setEmissionsChange] = useState(undefined);
const userCarbonRecords = useMemo(() => {
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], lastWeekDistance[0].date) >= 0) {
userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user');
userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance');
}
@@ -62,11 +72,13 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => {
low: getFootprintForMetrics(userLastWeekSummaryMap, 0),
high: getFootprintForMetrics(userLastWeekSummaryMap, getHighestFootprint()),
};
- graphRecords.push({
- label: t('main-metrics.unlabeled'),
- x: userPrevWeek.high - userPrevWeek.low,
- y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`,
- });
+ if (showUnlabeledMetrics) {
+ graphRecords.push({
+ label: t('main-metrics.unlabeled'),
+ x: userPrevWeek.high - userPrevWeek.low,
+ y: `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`,
+ });
+ }
graphRecords.push({
label: t('main-metrics.labeled'),
x: userPrevWeek.low,
@@ -79,11 +91,13 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => {
low: getFootprintForMetrics(userThisWeekSummaryMap, 0),
high: getFootprintForMetrics(userThisWeekSummaryMap, getHighestFootprint()),
};
- graphRecords.push({
- label: t('main-metrics.unlabeled'),
- x: userPastWeek.high - userPastWeek.low,
- y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`,
- });
+ if (showUnlabeledMetrics) {
+ graphRecords.push({
+ label: t('main-metrics.unlabeled'),
+ x: userPastWeek.high - userPastWeek.low,
+ y: `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(thisWeekDistance)})`,
+ });
+ }
graphRecords.push({
label: t('main-metrics.labeled'),
x: userPastWeek.low,
@@ -101,7 +115,6 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => {
x: worstCarbon,
y: `${t('main-metrics.worst-case')}`,
});
-
return graphRecords;
}
}, [userMetrics?.distance]);
@@ -109,7 +122,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)}`);
@@ -135,11 +148,13 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => {
high: getFootprintForMetrics(aggCarbonData, getHighestFootprint()),
};
logDebug(`groupCarbonRecords: aggCarbon = ${JSON.stringify(aggCarbon)}`);
- groupRecords.push({
- label: t('main-metrics.unlabeled'),
- x: aggCarbon.high - aggCarbon.low,
- y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`,
- });
+ if (showUnlabeledMetrics) {
+ groupRecords.push({
+ label: t('main-metrics.unlabeled'),
+ x: aggCarbon.high - aggCarbon.low,
+ y: `${t('main-metrics.average')}\n(${formatDateRangeOfDays(thisWeekDistance)})`,
+ });
+ }
groupRecords.push({
label: t('main-metrics.labeled'),
x: aggCarbon.low,
@@ -164,7 +179,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);
@@ -216,11 +232,9 @@ const CarbonFootprintCard = ({ userMetrics, aggMetrics }: Props) => {
) : (
-
-
- {t('metrics.chart-no-data')}
-
-
+
+ {t('metrics.chart-no-data')}
+
)}
diff --git a/www/js/metrics/CarbonTextCard.tsx b/www/js/metrics/CarbonTextCard.tsx
index bf89bdb49..ca9f50fdc 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';
@@ -18,21 +18,32 @@ import {
MetricsSummary,
} from './metricsHelper';
import { logDebug, logWarn } from '../plugin/logger';
+import TimelineContext from '../TimelineContext';
+import { isoDatesDifference } from '../diary/timelineHelper';
+import useAppConfig from '../useAppConfig';
type Props = { userMetrics?: MetricsData; aggMetrics?: MetricsData };
const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => {
const { colors } = useTheme();
+ const { dateRange } = useContext(TimelineContext);
const { t } = useTranslation();
+ const appConfig = useAppConfig();
+ // Whether to show the uncertainty on the carbon footprint charts, default: true
+ const showUnlabeledMetrics =
+ appConfig?.metrics?.phone_dashboard_ui?.footprint_options?.unlabeled_uncertainty ?? true;
const userText = useMemo(() => {
if (userMetrics?.distance?.length) {
//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], lastWeekDistance[0].date) >= 0) {
userLastWeekModeMap = parseDataFromMetrics(lastWeekDistance, 'user');
userLastWeekSummaryMap = generateSummaryFromData(userLastWeekModeMap, 'distance');
}
@@ -89,7 +100,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 +150,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);
@@ -170,11 +182,13 @@ const CarbonTextCard = ({ userMetrics, aggMetrics }: Props) => {
{textEntries[i].value + ' ' + 'kg CO₂'}
))}
-
- {t('main-metrics.range-uncertain-footnote')}
-
+ {showUnlabeledMetrics && (
+
+ {t('main-metrics.range-uncertain-footnote')}
+
+ )}
);
diff --git a/www/js/metrics/ChangeIndicator.tsx b/www/js/metrics/ChangeIndicator.tsx
index 137113ac1..8118d59ad 100644
--- a/www/js/metrics/ChangeIndicator.tsx
+++ b/www/js/metrics/ChangeIndicator.tsx
@@ -18,26 +18,21 @@ const ChangeIndicator = ({ change }: Props) => {
if (!change) return;
let low = isFinite(change.low) ? Math.round(Math.abs(change.low)) : '∞';
let high = isFinite(change.high) ? Math.round(Math.abs(change.high)) : '∞';
-
+ if (low == '∞' && high == '∞') return; // both are ∞, no information is really conveyed; don't show
if (Math.round(change.low) == Math.round(change.high)) {
- let text = changeSign(change.low) + low + '%';
- return text;
- } else if (!(isFinite(change.low) || isFinite(change.high))) {
- return ''; //if both are not finite, no information is really conveyed, so don't show
- } else {
- let text = `${changeSign(change.low) + low}% / ${changeSign(change.high) + high}%`;
- return text;
+ // high and low being the same means there is no uncertainty; show just one percentage
+ return changeSign(change.low) + low + '%';
}
+ // when there is uncertainty, show both percentages separated by a slash
+ return `${changeSign(change.low) + low}% / ${changeSign(change.high) + high}%`;
}, [change]);
- return changeText != '' ? (
+ return changeText ? (
0 ? colors.danger : colors.success)}>
{changeText + '\n'}
{`${t('metrics.this-week')}`}
- ) : (
- <>>
- );
+ ) : null;
};
const styles: any = {
diff --git a/www/js/metrics/DailyActiveMinutesCard.tsx b/www/js/metrics/DailyActiveMinutesCard.tsx
index c6ba7cbf0..f70b60587 100644
--- a/www/js/metrics/DailyActiveMinutesCard.tsx
+++ b/www/js/metrics/DailyActiveMinutesCard.tsx
@@ -7,31 +7,33 @@ import { useTranslation } from 'react-i18next';
import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper';
import LineChart from '../components/LineChart';
import { getBaseModeByText } from '../diary/diaryHelper';
-
-const ACTIVE_MODES = ['walk', 'bike'] as const;
-type ActiveMode = (typeof ACTIVE_MODES)[number];
+import { tsForDayOfMetricData, valueForFieldOnDay } from './metricsHelper';
+import useAppConfig from '../useAppConfig';
+import { ACTIVE_MODES } from './WeeklyActiveMinutesCard';
type Props = { userMetrics?: MetricsData };
const DailyActiveMinutesCard = ({ userMetrics }: Props) => {
const { colors } = useTheme();
const { t } = useTranslation();
+ const appConfig = useAppConfig();
+ // modes to consider as "active" for the purpose of calculating "active minutes", default : ['walk', 'bike']
+ const activeModes =
+ appConfig?.metrics?.phone_dashboard_ui?.active_travel_options?.modes_list ?? ACTIVE_MODES;
const dailyActiveMinutesRecords = useMemo(() => {
- const records: { label: string; x: string; y: number }[] = [];
+ const records: { label: string; x: number; y: number }[] = [];
const recentDays = userMetrics?.duration?.slice(-14);
recentDays?.forEach((day) => {
- ACTIVE_MODES.forEach((mode) => {
- const activeSeconds = day[`label_${mode}`];
- if (activeSeconds) {
- records.push({
- label: labelKeyToRichMode(mode),
- x: `${day.ts * 1000}`, // vertical chart, milliseconds on X axis
- y: activeSeconds && activeSeconds / 60, // minutes on Y axis
- });
- }
+ activeModes.forEach((mode) => {
+ const activeSeconds = valueForFieldOnDay(day, 'mode_confirm', mode);
+ records.push({
+ label: labelKeyToRichMode(mode),
+ x: tsForDayOfMetricData(day) * 1000, // vertical chart, milliseconds on X axis
+ 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 (
@@ -54,11 +56,9 @@ const DailyActiveMinutesCard = ({ userMetrics }: Props) => {
getColorForLabel={(l) => getBaseModeByText(l, labelOptions).color}
/>
) : (
-
-
- {t('metrics.chart-no-data')}
-
-
+
+ {t('metrics.chart-no-data')}
+
)}
diff --git a/www/js/metrics/MetricsCard.tsx b/www/js/metrics/MetricsCard.tsx
index 6662762c2..287193711 100644
--- a/www/js/metrics/MetricsCard.tsx
+++ b/www/js/metrics/MetricsCard.tsx
@@ -4,29 +4,39 @@ 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,
+ valueForFieldOnDay,
+ getUnitUtilsForMetric,
+} 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';
+import { GroupingField, MetricName } from '../types/appConfigTypes';
+import { useImperialConfig } from '../config/useImperialConfig';
type Props = {
+ metricName: MetricName;
+ groupingFields: GroupingField[];
cardTitle: string;
userMetricsDays?: DayOfMetricData[];
aggMetricsDays?: DayOfMetricData[];
- axisUnits: string;
- unitFormatFn?: (val: number) => string | number;
};
const MetricsCard = ({
+ metricName,
+ groupingFields,
cardTitle,
userMetricsDays,
aggMetricsDays,
- axisUnits,
- unitFormatFn,
}: Props) => {
const { colors } = useTheme();
const { t } = useTranslation();
+ const imperialConfig = useImperialConfig();
const [viewMode, setViewMode] = useState<'details' | 'graph'>('details');
const [populationMode, setPopulationMode] = useState<'user' | 'aggregate'>('user');
const [graphIsStacked, setGraphIsStacked] = useState(true);
@@ -35,6 +45,11 @@ const MetricsCard = ({
[populationMode, userMetricsDays, aggMetricsDays],
);
+ const [axisUnits, unitConvertFn, unitDisplayFn] = useMemo(
+ () => getUnitUtilsForMetric(metricName, imperialConfig),
+ [metricName],
+ );
+
// for each label on each day, create a record for the chart
const chartData = useMemo(() => {
if (!metricDataDays || viewMode != 'graph') return [];
@@ -42,12 +57,12 @@ const MetricsCard = ({
metricDataDays.forEach((day) => {
const labels = getLabelsForDay(day);
labels.forEach((label) => {
- const rawVal = day[`label_${label}`];
+ const rawVal = valueForFieldOnDay(day, groupingFields[0], 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
+ x: unitConvertFn(rawVal),
+ y: tsForDayOfMetricData(day) * 1000, // time (as milliseconds) will go on Y axis because it will be a horizontal chart
});
}
});
@@ -76,8 +91,22 @@ const MetricsCard = ({
// for each label, sum up cumulative values across all days
const vals = {};
uniqueLabels.forEach((label) => {
- const sum = metricDataDays.reduce((acc, day) => acc + (day[`label_${label}`] || 0), 0);
- vals[label] = unitFormatFn ? unitFormatFn(sum) : sum;
+ const sum: any = metricDataDays.reduce((acc, day) => {
+ const val = valueForFieldOnDay(day, groupingFields[0], label);
+ // if val is number, add it to the accumulator
+ if (!isNaN(val)) {
+ return acc + val;
+ } else if (val && typeof val == 'object') {
+ // if val is object, add its values to the accumulator's values
+ acc = acc || {};
+ for (let key in val) {
+ acc[key] = (acc[key] || 0) + val[key];
+ }
+ return acc;
+ }
+ return acc;
+ }, 0);
+ vals[label] = unitDisplayFn(sum);
});
return vals;
}, [metricDataDays, viewMode]);
@@ -93,7 +122,7 @@ const MetricsCard = ({
};
return (
-
+
- {viewMode == 'details' && (
-
- {Object.keys(metricSumValues).map((label, i) => (
-
- {labelKeyToRichMode(label)}
- {metricSumValues[label] + ' ' + axisUnits}
-
- ))}
-
- )}
- {viewMode == 'graph' && (
- <>
-
-
- Stack bars:
- setGraphIsStacked(!graphIsStacked)}
- />
+ {viewMode == 'details' &&
+ (Object.keys(metricSumValues).length ? (
+
+ {Object.keys(metricSumValues).map((label, i) => (
+
+ {labelKeyToRichMode(label)}
+ {metricSumValues[label]}
+
+ ))}
- >
- )}
+ ) : (
+
+ {t('metrics.chart-no-data')}
+
+ ))}
+ {viewMode == 'graph' &&
+ (chartData.length ? (
+ <>
+
+
+ Stack bars:
+ setGraphIsStacked(!graphIsStacked)}
+ />
+
+ >
+ ) : (
+
+ {t('metrics.chart-no-data')}
+
+ ))}
);
diff --git a/www/js/metrics/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..3d4748f67 100644
--- a/www/js/metrics/MetricsTab.tsx
+++ b/www/js/metrics/MetricsTab.tsx
@@ -1,145 +1,214 @@
-import React, { useEffect, useState, useMemo } from 'react';
-import { View, ScrollView, useWindowDimensions } from 'react-native';
+import React, { useEffect, useState, useMemo, useContext } from 'react';
+import { ScrollView, useWindowDimensions } from 'react-native';
import { Appbar, useTheme } from 'react-native-paper';
import { useTranslation } from 'react-i18next';
import { DateTime } from 'luxon';
import NavBar from '../components/NavBar';
import { MetricsData } from './metricsTypes';
import MetricsCard from './MetricsCard';
-import { formatForDisplay, useImperialConfig } from '../config/useImperialConfig';
-import MetricsDateSelect from './MetricsDateSelect';
import WeeklyActiveMinutesCard from './WeeklyActiveMinutesCard';
-import { secondsToHours, secondsToMinutes } from './metricsHelper';
import CarbonFootprintCard from './CarbonFootprintCard';
import Carousel from '../components/Carousel';
import DailyActiveMinutesCard from './DailyActiveMinutesCard';
import CarbonTextCard from './CarbonTextCard';
import ActiveMinutesTableCard from './ActiveMinutesTableCard';
-import { getAggregateData, getMetrics } from '../services/commHelper';
-import { displayError, logDebug, logWarn } from '../plugin/logger';
+import { getAggregateData } from '../services/commHelper';
+import { displayError, displayErrorMsg, logDebug } from '../plugin/logger';
import useAppConfig from '../useAppConfig';
-import { ServerConnConfig } from '../types/appConfigTypes';
+import {
+ AppConfig,
+ GroupingField,
+ MetricName,
+ MetricList,
+ MetricsUiSection,
+} from '../types/appConfigTypes';
+import DateSelect from '../diary/list/DateSelect';
+import TimelineContext, { TimelineLabelMap, TimelineMap } from '../TimelineContext';
+import { isoDatesDifference } from '../diary/timelineHelper';
+import { metrics_summaries } from 'e-mission-common';
+import SurveyLeaderboardCard from './SurveyLeaderboardCard';
+import SurveyTripCategoriesCard from './SurveyTripCategoriesCard';
+import SurveyComparisonCard from './SurveyComparisonCard';
-export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const;
+// 2 weeks of data is needed in order to compare "past week" vs "previous week"
+const N_DAYS_TO_LOAD = 14; // 2 weeks
+const DEFAULT_SECTIONS_TO_SHOW: MetricsUiSection[] = [
+ 'footprint',
+ 'active_travel',
+ 'summary',
+] as const;
+export const DEFAULT_METRIC_LIST: MetricList = {
+ distance: ['mode_confirm'],
+ duration: ['mode_confirm'],
+ count: ['mode_confirm'],
+};
+
+async function computeUserMetrics(
+ metricList: MetricList,
+ timelineMap: TimelineMap,
+ timelineLabelMap: TimelineLabelMap | null,
+ appConfig: AppConfig,
+) {
+ try {
+ const timelineValues = [...timelineMap.values()];
+ const result = metrics_summaries.generate_summaries(
+ { ...metricList },
+ timelineValues,
+ appConfig,
+ timelineLabelMap,
+ );
+ logDebug('MetricsTab: computed userMetrics');
+ console.debug('MetricsTab: computed userMetrics', result);
+ return result as MetricsData;
+ } catch (e) {
+ displayError(e, 'Error computing user metrics');
+ }
+}
-async function fetchMetricsFromServer(
- type: 'user' | 'aggregate',
- dateRange: DateTime[],
- serverConnConfig: ServerConnConfig,
+async function fetchAggMetrics(
+ metricList: MetricList,
+ dateRange: [string, string],
+ appConfig: AppConfig,
) {
+ logDebug('MetricsTab: fetching agg metrics from server for dateRange ' + dateRange);
const query = {
freq: 'D',
- start_time: dateRange[0].toSeconds(),
- end_time: dateRange[1].toSeconds(),
- metric_list: METRIC_LIST,
- is_return_aggregate: type == 'aggregate',
+ start_time: dateRange[0],
+ end_time: dateRange[1],
+ metric_list: metricList,
+ is_return_aggregate: true,
+ app_config: { survey_info: appConfig.survey_info },
};
- if (type == 'user') return getMetrics('timestamp', query);
- return getAggregateData('result/metrics/timestamp', query, serverConnConfig);
-}
-
-function getLastTwoWeeksDtRange() {
- const now = DateTime.now().startOf('day');
- const start = now.minus({ days: 15 });
- const end = now.minus({ days: 1 });
- return [start, end];
+ return getAggregateData('result/metrics/yyyy_mm_dd', query, appConfig.server)
+ .then((response) => {
+ logDebug('MetricsTab: received aggMetrics');
+ console.debug('MetricsTab: received aggMetrics', response);
+ return response as MetricsData;
+ })
+ .catch((e) => {
+ displayError(e, 'Error fetching aggregate metrics');
+ return undefined;
+ });
}
const MetricsTab = () => {
const appConfig = useAppConfig();
- const { colors } = useTheme();
const { t } = useTranslation();
- const { getFormattedSpeed, speedSuffix, getFormattedDistance, distanceSuffix } =
- useImperialConfig();
+ const {
+ dateRange,
+ timelineMap,
+ timelineLabelMap,
+ timelineIsLoading,
+ refreshTimeline,
+ loadMoreDays,
+ loadDateRange,
+ } = useContext(TimelineContext);
- const [dateRange, setDateRange] = useState(getLastTwoWeeksDtRange);
- const [aggMetrics, setAggMetrics] = useState(undefined);
- const [userMetrics, setUserMetrics] = useState(undefined);
+ const metricList = appConfig?.metrics?.phone_dashboard_ui?.metric_list ?? DEFAULT_METRIC_LIST;
- useEffect(() => {
- if (!appConfig?.server) return;
- loadMetricsForPopulation('user', dateRange);
- loadMetricsForPopulation('aggregate', dateRange);
- }, [dateRange, appConfig?.server]);
+ const [userMetrics, setUserMetrics] = useState(undefined);
+ const [aggMetrics, setAggMetrics] = useState(undefined);
+ const [aggMetricsIsLoading, setAggMetricsIsLoading] = useState(false);
- async function loadMetricsForPopulation(population: 'user' | 'aggregate', dateRange: DateTime[]) {
- try {
- logDebug(`MetricsTab: fetching metrics for population ${population}'
- in date range ${JSON.stringify(dateRange)}`);
- const serverResponse: any = await fetchMetricsFromServer(
- population,
- dateRange,
- appConfig.server,
- );
- logDebug('MetricsTab: received metrics: ' + JSON.stringify(serverResponse));
- const metrics = {};
- const dataKey = population == 'user' ? 'user_metrics' : 'aggregate_metrics';
- METRIC_LIST.forEach((metricName, i) => {
- metrics[metricName] = serverResponse[dataKey][i];
- });
- logDebug('MetricsTab: parsed metrics: ' + JSON.stringify(metrics));
- if (population == 'user') {
- setUserMetrics(metrics as MetricsData);
- } else {
- setAggMetrics(metrics as MetricsData);
- }
- } catch (e) {
- logWarn(e + t('errors.while-loading-metrics')); // replace with displayErr
+ const readyToLoad = useMemo(() => {
+ if (!appConfig || !dateRange) return false;
+ const dateRangeDays = isoDatesDifference(...dateRange);
+ if (dateRangeDays < N_DAYS_TO_LOAD) {
+ logDebug('MetricsTab: not enough days loaded, trying to load more');
+ const loadingMore = loadMoreDays('past', N_DAYS_TO_LOAD - dateRangeDays);
+ if (loadingMore !== false) return false;
+ logDebug('MetricsTab: no more days can be loaded, continuing with what we have');
}
- }
+ return true;
+ }, [appConfig, dateRange]);
- function refresh() {
- setDateRange(getLastTwoWeeksDtRange());
- }
+ useEffect(() => {
+ if (!readyToLoad || !appConfig || timelineIsLoading || !timelineMap || !timelineLabelMap)
+ return;
+ logDebug('MetricsTab: ready to compute userMetrics');
+ computeUserMetrics(metricList, timelineMap, timelineLabelMap, appConfig).then((result) =>
+ setUserMetrics(result),
+ );
+ }, [readyToLoad, appConfig, timelineIsLoading, timelineMap, timelineLabelMap]);
+
+ useEffect(() => {
+ if (!readyToLoad || !appConfig || !dateRange) return;
+ logDebug('MetricsTab: ready to fetch aggMetrics');
+ setAggMetricsIsLoading(true);
+ fetchAggMetrics(metricList, dateRange, appConfig).then((response) => {
+ setAggMetricsIsLoading(false);
+ setAggMetrics(response);
+ });
+ }, [readyToLoad, appConfig, dateRange]);
+ const sectionsToShow =
+ appConfig?.metrics?.phone_dashboard_ui?.sections || DEFAULT_SECTIONS_TO_SHOW;
const { width: windowWidth } = useWindowDimensions();
const cardWidth = windowWidth * 0.88;
+ const studyStartDate = `${appConfig?.intro.start_month} / ${appConfig?.intro.start_year}`;
return (
<>
-
+
-
-
+ {
+ const start = DateTime.fromJSDate(startDate).toISODate();
+ const end = DateTime.fromJSDate(endDate).toISODate();
+ if (!start || !end) return displayErrorMsg('Invalid date');
+ loadDateRange([start, end]);
+ }}
+ />
+
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* */}
-
+ {sectionsToShow.includes('footprint') && (
+
+
+
+
+ )}
+ {sectionsToShow.includes('active_travel') && (
+
+
+
+
+
+ )}
+ {sectionsToShow.includes('summary') && (
+
+ {Object.entries(metricList).map(
+ ([metricName, groupingFields]: [MetricName, GroupingField[]]) => {
+ return (
+
+ );
+ },
+ )}
+
+ )}
+ {sectionsToShow.includes('surveys') && (
+
+
+
+
+ )}
+ {/* we will implement leaderboard later */}
+ {/* {sectionsToShow.includes('engagement') && (
+
+
+
+ )} */}
>
);
diff --git a/www/js/metrics/SurveyComparisonCard.tsx b/www/js/metrics/SurveyComparisonCard.tsx
new file mode 100644
index 000000000..a99a604eb
--- /dev/null
+++ b/www/js/metrics/SurveyComparisonCard.tsx
@@ -0,0 +1,183 @@
+import React, { useMemo } from 'react';
+import { View } from 'react-native';
+import { Icon, Card, Text } from 'react-native-paper';
+import { useTranslation } from 'react-i18next';
+import { useAppTheme } from '../appTheme';
+import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js';
+import { Doughnut } from 'react-chartjs-2';
+import { cardStyles } from './MetricsTab';
+import { DayOfMetricData, MetricsData } from './metricsTypes';
+import { getUniqueLabelsForDays } from './metricsHelper';
+ChartJS.register(ArcElement, Tooltip, Legend);
+
+/**
+ * @description Calculates the percentage of 'responded' values across days of 'response_count' data.
+ * @returns Percentage as a whole number (0-100), or null if no data.
+ */
+function getResponsePctForDays(days: DayOfMetricData<'response_count'>[]) {
+ const surveys = getUniqueLabelsForDays(days);
+ let acc = { responded: 0, not_responded: 0 };
+ days.forEach((day) => {
+ surveys.forEach((survey) => {
+ acc.responded += day[`survey_${survey}`]?.responded || 0;
+ acc.not_responded += day[`survey_${survey}`]?.not_responded || 0;
+ });
+ });
+ const total = acc.responded + acc.not_responded;
+ if (total === 0) return null;
+ return Math.round((acc.responded / total) * 100);
+}
+
+type Props = {
+ userMetrics: MetricsData | undefined;
+ aggMetrics: MetricsData | undefined;
+};
+
+export type SurveyComparison = {
+ me: number;
+ others: number;
+};
+
+export const LabelPanel = ({ first, second }) => {
+ const { colors } = useAppTheme();
+
+ return (
+
+
+
+ {first}
+
+
+
+ {second}
+
+
+ );
+};
+
+const SurveyComparisonCard = ({ userMetrics, aggMetrics }: Props) => {
+ const { colors } = useAppTheme();
+ const { t } = useTranslation();
+
+ const myResponsePct = useMemo(() => {
+ if (!userMetrics?.response_count) return;
+ return getResponsePctForDays(userMetrics.response_count);
+ }, [userMetrics]);
+
+ const othersResponsePct = useMemo(() => {
+ if (!aggMetrics?.response_count) return;
+ return getResponsePctForDays(aggMetrics.response_count);
+ }, [aggMetrics]);
+
+ const renderDoughnutChart = (rate, chartColor, myResponse) => {
+ const data = {
+ datasets: [
+ {
+ data: [rate, 100 - rate],
+ backgroundColor: [chartColor, colors.silver],
+ borderColor: [chartColor, colors.silver],
+ borderWidth: 1,
+ },
+ ],
+ };
+ return (
+
+
+ {myResponse ? (
+
+ ) : (
+
+ )}
+ {rate === null ? t('metrics.no-data') : rate + '%'}
+
+
+
+ );
+ };
+
+ return (
+
+
+
+ {typeof myResponsePct !== 'number' || typeof othersResponsePct !== 'number' ? (
+
+ {t('metrics.chart-no-data')}
+
+ ) : (
+
+ {t('main-metrics.survey-response-rate')}
+
+ {renderDoughnutChart(myResponsePct, colors.navy, true)}
+ {renderDoughnutChart(othersResponsePct, colors.orange, false)}
+
+
+
+ )}
+
+
+ );
+};
+
+const styles: any = {
+ chartTitle: {
+ alignSelf: 'center',
+ fontWeight: 'bold',
+ fontSize: 14,
+ marginBottom: 10,
+ },
+ statusTextWrapper: {
+ alignSelf: 'center',
+ display: 'flex',
+ flexDirection: 'row',
+ fontSize: 16,
+ },
+ chartWrapper: {
+ display: 'flex',
+ flexDirection: 'row',
+ justifyContent: 'space-around',
+ },
+ textWrapper: {
+ position: 'absolute',
+ width: 140,
+ height: 140,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ labelWrapper: {
+ alignSelf: 'center',
+ display: 'flex',
+ gap: 10,
+ marginTop: 10,
+ },
+ labelItem: {
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ },
+};
+
+export default SurveyComparisonCard;
diff --git a/www/js/metrics/SurveyLeaderboardCard.tsx b/www/js/metrics/SurveyLeaderboardCard.tsx
new file mode 100644
index 000000000..34341616d
--- /dev/null
+++ b/www/js/metrics/SurveyLeaderboardCard.tsx
@@ -0,0 +1,128 @@
+import React, { useMemo } from 'react';
+import { View, Text } from 'react-native';
+import { Card } from 'react-native-paper';
+import { cardStyles, SurveyMetric, SurveyObject } from './MetricsTab';
+import { useTranslation } from 'react-i18next';
+import BarChart from '../components/BarChart';
+import { useAppTheme } from '../appTheme';
+import { Chart as ChartJS, registerables } from 'chart.js';
+import Annotation from 'chartjs-plugin-annotation';
+
+ChartJS.register(...registerables, Annotation);
+
+type Props = {
+ studyStartDate: string;
+ surveyMetric: SurveyMetric;
+};
+
+type LeaderboardRecord = {
+ label: string;
+ x: number;
+ y: string;
+};
+
+const SurveyLeaderboardCard = ({ studyStartDate, surveyMetric }: Props) => {
+ const { colors } = useAppTheme();
+ const { t } = useTranslation();
+
+ const myRank = surveyMetric.me.rank;
+ const mySurveyMetric = surveyMetric.me.overview;
+
+ function getLabel(rank: number): string {
+ if (rank === 0) {
+ return '🏆 #1:';
+ } else if (rank === 1) {
+ return '🥈 #2:';
+ } else if (rank === 2) {
+ return '🥉 #3:';
+ } else {
+ return `#${rank + 1}:`;
+ }
+ }
+
+ const leaderboardRecords: LeaderboardRecord[] = useMemo(() => {
+ const combinedLeaderboard: SurveyObject[] = [...surveyMetric.others.leaderboard];
+ combinedLeaderboard.splice(myRank, 0, mySurveyMetric);
+
+ // This is to prevent the leaderboard from being too long for UX purposes.
+ // For a total of 20 members, we only show the top 5 members, myself, and the bottom 3 members.
+ const numberOfTopUsers = 5;
+ const numberOfBottomUsers = surveyMetric.others.leaderboard.length - 3;
+
+ return combinedLeaderboard
+ .map((item, idx) => ({
+ isMe: idx === myRank,
+ rank: idx,
+ answered: item.answered,
+ unanswered: item.unanswered,
+ mismatched: item.mismatched,
+ }))
+ .filter(
+ (item) => item.isMe || item.rank < numberOfTopUsers || item.rank >= numberOfBottomUsers,
+ )
+ .map((item) => ({
+ label: item.isMe ? `${item.rank}-me` : `${item.rank}-other`,
+ x: Math.round((item.answered / (item.answered + item.unanswered)) * 100),
+ y: getLabel(item.rank),
+ }));
+ }, [surveyMetric]);
+
+ return (
+
+
+
+
+
+ * {t('main-metrics.survey-leaderboard-desc')}
+ {studyStartDate}
+
+ {t('main-metrics.survey-response-rate')}
+ (l === `${myRank}-me` ? colors.skyblue : colors.silver)}
+ getColorForChartEl={(l) => (l === `${myRank}-me` ? colors.skyblue : colors.silver)}
+ showLegend={false}
+ reverse={false}
+ enableTooltip={false}
+ />
+
+ {t('main-metrics.you-are-in')}
+ #{myRank + 1}
+ {t('main-metrics.place')}
+
+
+
+
+ );
+};
+
+const styles: any = {
+ chartTitle: {
+ alignSelf: 'center',
+ fontWeight: 'bold',
+ fontSize: 14,
+ },
+ chartDesc: {
+ fontSoze: 12,
+ marginBottom: 10,
+ },
+ statusTextWrapper: {
+ alignSelf: 'center',
+ display: 'flex',
+ flexDirection: 'row',
+ fontSize: 16,
+ },
+};
+
+export default SurveyLeaderboardCard;
diff --git a/www/js/metrics/SurveyTripCategoriesCard.tsx b/www/js/metrics/SurveyTripCategoriesCard.tsx
new file mode 100644
index 000000000..77df43abf
--- /dev/null
+++ b/www/js/metrics/SurveyTripCategoriesCard.tsx
@@ -0,0 +1,90 @@
+import React, { useMemo } from 'react';
+import { Text, Card } from 'react-native-paper';
+import { cardStyles } from './MetricsTab';
+import { useTranslation } from 'react-i18next';
+import BarChart from '../components/BarChart';
+import { useAppTheme } from '../appTheme';
+import { LabelPanel } from './SurveyComparisonCard';
+import { DayOfMetricData, MetricsData } from './metricsTypes';
+import { GroupingField } from '../types/appConfigTypes';
+import { getUniqueLabelsForDays } from './metricsHelper';
+
+function sumResponseCountsForValue(
+ days: DayOfMetricData<'response_count'>[],
+ value: `${GroupingField}_${string}`,
+) {
+ const acc = { responded: 0, not_responded: 0 };
+ days.forEach((day) => {
+ acc.responded += day[value]?.responded || 0;
+ acc.not_responded += day[value]?.not_responded || 0;
+ });
+ return acc;
+}
+
+type SurveyTripRecord = {
+ label: string;
+ x: string;
+ y: number;
+};
+
+type Props = {
+ userMetrics: MetricsData | undefined;
+ aggMetrics: MetricsData | undefined;
+};
+const SurveyTripCategoriesCard = ({ userMetrics, aggMetrics }: Props) => {
+ const { colors } = useAppTheme();
+ const { t } = useTranslation();
+
+ const records = useMemo(() => {
+ if (!userMetrics?.response_count) return [];
+ const surveys = getUniqueLabelsForDays(userMetrics.response_count);
+ const records: SurveyTripRecord[] = [];
+ surveys.forEach((survey) => {
+ const { responded, not_responded } = sumResponseCountsForValue(
+ userMetrics.response_count,
+ `survey_${survey}`,
+ );
+ records.push({ label: 'Response', x: survey, y: responded || 0 });
+ records.push({ label: 'No Response', x: survey, y: not_responded || 0 });
+ });
+ return records;
+ }, [userMetrics]);
+
+ return (
+
+
+
+ {records.length ? (
+ <>
+ (l === 'Response' ? colors.navy : colors.orange)}
+ getColorForChartEl={(l) => (l === 'Response' ? colors.navy : colors.orange)}
+ showLegend={false}
+ reverse={false}
+ maxBarThickness={60}
+ />
+
+ >
+ ) : (
+
+ {t('metrics.chart-no-data')}
+
+ )}
+
+
+ );
+};
+
+export default SurveyTripCategoriesCard;
diff --git a/www/js/metrics/WeeklyActiveMinutesCard.tsx b/www/js/metrics/WeeklyActiveMinutesCard.tsx
index eb1a29939..4201f993e 100644
--- a/www/js/metrics/WeeklyActiveMinutesCard.tsx
+++ b/www/js/metrics/WeeklyActiveMinutesCard.tsx
@@ -1,13 +1,15 @@
-import React, { useMemo, useState } from 'react';
+import React, { useContext, useMemo, useState } from 'react';
import { View } from 'react-native';
import { Card, Text, useTheme } from 'react-native-paper';
import { MetricsData } from './metricsTypes';
import { cardMargin, cardStyles } from './MetricsTab';
-import { formatDateRangeOfDays, segmentDaysByWeeks } from './metricsHelper';
+import { formatDateRangeOfDays, segmentDaysByWeeks, valueForFieldOnDay } from './metricsHelper';
import { useTranslation } from 'react-i18next';
import BarChart from '../components/BarChart';
import { labelKeyToRichMode, labelOptions } from '../survey/multilabel/confirmHelper';
import { getBaseModeByText } from '../diary/diaryHelper';
+import TimelineContext from '../TimelineContext';
+import useAppConfig from '../useAppConfig';
export const ACTIVE_MODES = ['walk', 'bike'] as const;
type ActiveMode = (typeof ACTIVE_MODES)[number];
@@ -15,24 +17,31 @@ 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 appConfig = useAppConfig();
+ // modes to consider as "active" for the purpose of calculating "active minutes", default : ['walk', 'bike']
+ const activeModes =
+ appConfig?.metrics?.phone_dashboard_ui?.active_travel_options?.modes_list ?? ACTIVE_MODES;
const weeklyActiveMinutesRecords = useMemo(() => {
if (!userMetrics?.duration) return [];
const records: { x: string; y: number; label: string }[] = [];
- const [recentWeek, prevWeek] = segmentDaysByWeeks(userMetrics?.duration, 2);
- ACTIVE_MODES.forEach((mode) => {
- const prevSum = prevWeek?.reduce((acc, day) => acc + (day[`label_${mode}`] || 0), 0);
- if (prevSum) {
- // `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})`
+ const [recentWeek, prevWeek] = segmentDaysByWeeks(userMetrics?.duration, dateRange[1]);
+ activeModes.forEach((mode) => {
+ if (prevWeek) {
+ const prevSum = prevWeek?.reduce(
+ (acc, day) => acc + (valueForFieldOnDay(day, 'mode_confirm', mode) || 0),
+ 0,
+ );
const xLabel = `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(prevWeek)})`;
records.push({ label: labelKeyToRichMode(mode), x: xLabel, y: prevSum / 60 });
}
- const recentSum = recentWeek?.reduce((acc, day) => acc + (day[`label_${mode}`] || 0), 0);
- if (recentSum) {
- const xLabel = `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(recentWeek)})`;
- records.push({ label: labelKeyToRichMode(mode), x: xLabel, y: recentSum / 60 });
- }
+ const recentSum = recentWeek?.reduce(
+ (acc, day) => acc + (valueForFieldOnDay(day, 'mode_confirm', mode) || 0),
+ 0,
+ );
+ const xLabel = `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(recentWeek)})`;
+ records.push({ label: labelKeyToRichMode(mode), x: xLabel, y: recentSum / 60 });
});
return records as { label: ActiveMode; x: string; y: number }[];
}, [userMetrics?.duration]);
@@ -67,11 +76,9 @@ const WeeklyActiveMinutesCard = ({ userMetrics }: Props) => {
) : (
-
-
- {t('metrics.chart-no-data')}
-
-
+
+ {t('metrics.chart-no-data')}
+
)}
diff --git a/www/js/metrics/footprintHelper.ts b/www/js/metrics/footprintHelper.ts
index c37d8de92..2a02ea133 100644
--- a/www/js/metrics/footprintHelper.ts
+++ b/www/js/metrics/footprintHelper.ts
@@ -44,12 +44,12 @@ export function getFootprintForMetrics(userMetrics, defaultIfMissing = 0) {
const footprint = getFootprint();
logDebug('getting footprint for ' + userMetrics + ' with ' + footprint);
let result = 0;
- for (let i in userMetrics) {
- let mode = userMetrics[i].key;
+ userMetrics.forEach((userMetric) => {
+ let mode = userMetric.key;
//either the mode is in our custom footprint or it is not
if (mode in footprint) {
- result += footprint[mode] * mtokm(userMetrics[i].values);
+ result += footprint[mode] * mtokm(userMetric.values);
} else if (mode == 'IN_VEHICLE') {
const sum =
footprint['CAR'] +
@@ -58,16 +58,16 @@ export function getFootprintForMetrics(userMetrics, defaultIfMissing = 0) {
footprint['TRAIN'] +
footprint['TRAM'] +
footprint['SUBWAY'];
- result += (sum / 6) * mtokm(userMetrics[i].values);
+ result += (sum / 6) * mtokm(userMetric.values);
} else {
logWarn(
`WARNING getFootprintFromMetrics() was requested for an unknown mode: ${mode} metrics JSON: ${JSON.stringify(
userMetrics,
)}`,
);
- result += defaultIfMissing * mtokm(userMetrics[i].values);
+ result += defaultIfMissing * mtokm(userMetric.values);
}
- }
+ });
return result;
}
diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts
index ca3846806..65337690b 100644
--- a/www/js/metrics/metricsHelper.ts
+++ b/www/js/metrics/metricsHelper.ts
@@ -1,52 +1,72 @@
import { DateTime } from 'luxon';
-import { formatForDisplay } from '../config/useImperialConfig';
import { DayOfMetricData } from './metricsTypes';
import { logDebug } from '../plugin/logger';
+import { isoDateWithOffset, isoDatesDifference } from '../diary/timelineHelper';
+import { MetricName, groupingFields } from '../types/appConfigTypes';
+import { ImperialConfig, formatForDisplay } from '../config/useImperialConfig';
+import i18next from 'i18next';
export function getUniqueLabelsForDays(metricDataDays: DayOfMetricData[]) {
const uniqueLabels: string[] = [];
metricDataDays.forEach((e) => {
Object.keys(e).forEach((k) => {
- if (k.startsWith('label_')) {
- const label = k.substring(6); // remove 'label_' prefix leaving just the mode label
- if (!uniqueLabels.includes(label)) uniqueLabels.push(label);
+ const trimmed = trimGroupingPrefix(k);
+ if (trimmed && !uniqueLabels.includes(trimmed)) {
+ uniqueLabels.push(trimmed);
}
});
});
return uniqueLabels;
}
+/**
+ * @description Trims the "grouping field" prefix from a metrics key. Grouping fields are defined in appConfigTypes.ts
+ * @example removeGroupingPrefix('mode_purpose_access_recreation') => 'access_recreation'
+ * @example removeGroupingPrefix('primary_ble_sensed_mode_CAR') => 'CAR'
+ * @returns The key without the prefix (or undefined if the key didn't start with a grouping field)
+ */
+export const trimGroupingPrefix = (label: string) => {
+ for (let field of groupingFields) {
+ if (label.startsWith(field)) {
+ return label.substring(field.length + 1);
+ }
+ }
+};
+
export const getLabelsForDay = (metricDataDay: DayOfMetricData) =>
Object.keys(metricDataDay).reduce((acc, k) => {
- if (k.startsWith('label_')) {
- acc.push(k.substring(6)); // remove 'label_' prefix leaving just the mode label
- }
+ const trimmed = trimGroupingPrefix(k);
+ if (trimmed) acc.push(trimmed);
return acc;
}, [] as string[]);
-export const secondsToMinutes = (seconds: number) => formatForDisplay(seconds / 60);
-
-export const secondsToHours = (seconds: number) => formatForDisplay(seconds / 3600);
+export const secondsToMinutes = (seconds: number) => seconds / 60;
+export const secondsToHours = (seconds: number) => seconds / 3600;
// segments metricsDays into weeks, with the most recent week first
-export function segmentDaysByWeeks(days: DayOfMetricData[], nWeeks?: number) {
- const weeks: DayOfMetricData[][] = [];
- for (let i = days?.length - 1; i >= 0; i -= 7) {
- weeks.push(days.slice(Math.max(i - 6, 0), i + 1));
+export function segmentDaysByWeeks(days: DayOfMetricData[], lastDate: string) {
+ const weeks: DayOfMetricData[][] = [[]];
+ let cutoff = isoDateWithOffset(lastDate, -7 * weeks.length);
+ for (let i = days.length - 1; i >= 0; i--) {
+ // if date is older than cutoff, start a new week
+ if (isoDatesDifference(days[i].date, cutoff) > 0) {
+ weeks.push([]);
+ cutoff = isoDateWithOffset(lastDate, -7 * weeks.length);
+ }
+ weeks[weeks.length - 1].push(days[i]);
}
- if (nWeeks) return weeks.slice(0, nWeeks);
- return weeks;
+ return weeks.map((week) => week.reverse());
}
export function formatDate(day: DayOfMetricData) {
- const dt = DateTime.fromISO(day.fmt_time, { zone: 'utc' });
+ const dt = DateTime.fromISO(day.date, { zone: 'utc' });
return dt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined });
}
export function formatDateRangeOfDays(days: DayOfMetricData[]) {
if (!days?.length) return '';
- const firstDayDt = DateTime.fromISO(days[0].fmt_time, { zone: 'utc' });
- const lastDayDt = DateTime.fromISO(days[days.length - 1].fmt_time, { zone: 'utc' });
+ const firstDayDt = DateTime.fromISO(days[0].date, { zone: 'utc' });
+ const lastDayDt = DateTime.fromISO(days[days.length - 1].date, { zone: 'utc' });
const firstDay = firstDayDt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined });
const lastDay = lastDayDt.toLocaleString({ ...DateTime.DATE_SHORT, year: undefined });
return `${firstDay} - ${lastDay}`;
@@ -55,7 +75,7 @@ export function formatDateRangeOfDays(days: DayOfMetricData[]) {
/* formatting data form carbon footprint calculations */
//modes considered on foot for carbon calculation, expandable as needed
-const ON_FOOT_MODES = ['WALKING', 'RUNNING', 'ON_FOOT'] as const;
+export const ON_FOOT_MODES = ['WALKING', 'RUNNING', 'ON_FOOT'] as const;
/*
* metric2val is a function that takes a metric entry and a field and returns
@@ -63,13 +83,13 @@ const ON_FOOT_MODES = ['WALKING', 'RUNNING', 'ON_FOOT'] as const;
* for regular data (user-specific), this will return the field value
* for avg data (aggregate), this will return the field value/nUsers
*/
-const metricToValue = (population: 'user' | 'aggregate', metric, field) =>
+export const metricToValue = (population: 'user' | 'aggregate', metric, field) =>
population == 'user' ? metric[field] : metric[field] / metric.nUsers;
//testing agains global list of what is "on foot"
//returns true | false
-function isOnFoot(mode: string) {
- for (let ped_mode in ON_FOOT_MODES) {
+export function isOnFoot(mode: string) {
+ for (let ped_mode of ON_FOOT_MODES) {
if (mode === ped_mode) {
return true;
}
@@ -114,14 +134,13 @@ 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
- logDebug('Mapped field ' + field + ' to mode ' + actualMode);
- if (!(actualMode in mode_bins)) {
- mode_bins[actualMode] = [];
+ const trimmedField = trimGroupingPrefix(field);
+ if (trimmedField) {
+ logDebug('Mapped field ' + field + ' to mode ' + trimmedField);
+ if (!(trimmedField in mode_bins)) {
+ mode_bins[trimmedField] = [];
}
- mode_bins[actualMode].push([
+ mode_bins[trimmedField].push([
metric.ts,
Math.round(metricToValue(population, metric, field)),
DateTime.fromISO(metric.fmt_time).toISO() as string,
@@ -137,6 +156,16 @@ export function parseDataFromMetrics(metrics, population) {
return Object.entries(mode_bins).map(([key, values]) => ({ key, values }));
}
+const _datesTsCache = {};
+export const tsForDayOfMetricData = (day: DayOfMetricData) => {
+ if (_datesTsCache[day.date] == undefined)
+ _datesTsCache[day.date] = DateTime.fromISO(day.date).toSeconds();
+ return _datesTsCache[day.date];
+};
+
+export const valueForFieldOnDay = (day: DayOfMetricData, field: string, key: string) =>
+ day[`${field}_${key}`];
+
export type MetricsSummary = { key: string; values: number };
export function generateSummaryFromData(modeMap, metric) {
logDebug(`Invoked getSummaryDataRaw on ${JSON.stringify(modeMap)} with ${metric}`);
@@ -186,7 +215,7 @@ export function isCustomLabels(modeMap) {
return isAllCustom(metricSummaryChecksSensed, metricSummaryChecksCustom);
}
-function isAllCustom(isSensedKeys, isCustomKeys) {
+export function isAllCustom(isSensedKeys, isCustomKeys) {
const allSensed = isSensedKeys.reduce((a, b) => a && b, true);
const anySensed = isSensedKeys.reduce((a, b) => a || b, false);
const allCustom = isCustomKeys.reduce((a, b) => a && b, true);
@@ -201,3 +230,35 @@ function isAllCustom(isSensedKeys, isCustomKeys) {
// "Please report to your program admin");
return undefined;
}
+
+// [unit suffix, unit conversion function, unit display function]
+// e.g. ['hours', (seconds) => seconds/3600, (seconds) => seconds/3600 + ' hours']
+type UnitUtils = [string, (v) => number, (v) => string];
+export function getUnitUtilsForMetric(
+ metricName: MetricName,
+ imperialConfig: ImperialConfig,
+): UnitUtils {
+ const fns: { [k in MetricName]: UnitUtils } = {
+ distance: [
+ imperialConfig.distanceSuffix,
+ (x) => imperialConfig.convertDistance(x),
+ (x) => imperialConfig.getFormattedDistance(x) + ' ' + imperialConfig.distanceSuffix,
+ ],
+ duration: [
+ i18next.t('metrics.hours'),
+ (v) => secondsToHours(v),
+ (v) => formatForDisplay(secondsToHours(v)) + ' ' + i18next.t('metrics.hours'),
+ ],
+ count: [i18next.t('metrics.trips'), (v) => v, (v) => v + ' ' + i18next.t('metrics.trips')],
+ response_count: [
+ i18next.t('metrics.responses'),
+ (v) => v.responded || 0,
+ (v) => {
+ const responded = v.responded || 0;
+ const total = responded + (v.not_responded || 0);
+ return `${responded}/${total} ${i18next.t('metrics.responses')}`;
+ },
+ ],
+ };
+ return fns[metricName];
+}
diff --git a/www/js/metrics/metricsTypes.ts b/www/js/metrics/metricsTypes.ts
index cce1cd243..d6105c30a 100644
--- a/www/js/metrics/metricsTypes.ts
+++ b/www/js/metrics/metricsTypes.ts
@@ -1,15 +1,21 @@
-import { LocalDt } from '../types/serverData';
-import { METRIC_LIST } from './MetricsTab';
+import { GroupingField, MetricName } from '../types/appConfigTypes';
-type MetricName = (typeof METRIC_LIST)[number];
-type LabelProps = { [k in `label_${string}`]?: number }; // label_, where could be anything
-export type DayOfMetricData = LabelProps & {
- ts: number;
- fmt_time: string;
+// distance, duration, and count use number values in meters, seconds, and count respectively
+// response_count uses object values containing responded and not_responded counts
+type MetricValue = T extends 'response_count'
+ ? { responded?: number; not_responded?: number }
+ : number;
+
+export type DayOfMetricData = {
+ date: string; // yyyy-mm-dd
nUsers: number;
- local_dt: LocalDt;
+} & {
+ // each key is a value for a specific grouping field
+ // and the value is the respective metric value
+ // e.g. { mode_confirm_bikeshare: 123, survey_TripConfirmSurvey: { responded: 4, not_responded: 5 }
+ [k in `${GroupingField}_${string}`]: MetricValue;
};
export type MetricsData = {
- [key in MetricName]: DayOfMetricData[];
+ [key in MetricName]: DayOfMetricData[];
};
diff --git a/www/js/services/commHelper.ts b/www/js/services/commHelper.ts
index faa44c465..012089313 100644
--- a/www/js/services/commHelper.ts
+++ b/www/js/services/commHelper.ts
@@ -5,17 +5,18 @@ import { TimestampRange } from '../types/diaryTypes';
/**
* @param url URL endpoint for the request
+ * @param fetchOpts (optional) options for the fetch request. If 'cache' is set to 'reload', the cache will be ignored
* @returns Promise of the fetched response (as text) or cached text from local storage
*/
-export async function fetchUrlCached(url) {
+export async function fetchUrlCached(url: string, fetchOpts?: RequestInit) {
const stored = localStorage.getItem(url);
- if (stored) {
+ if (stored && fetchOpts?.cache != 'reload') {
logDebug(`fetchUrlCached: found cached data for url ${url}, returning`);
return Promise.resolve(stored);
}
try {
- logDebug(`fetchUrlCached: found no cached data for url ${url}, fetching`);
- const response = await fetch(url);
+ logDebug(`fetchUrlCached: cache had ${stored} for url ${url}, not using; fetching`);
+ const response = await fetch(url, fetchOpts);
const text = await response.text();
localStorage.setItem(url, text);
logDebug(`fetchUrlCached: fetched data for url ${url}, returning`);
@@ -135,8 +136,13 @@ export function getMetrics(timeType: 'timestamp' | 'local_date', metricsQuery) {
});
}
-export function getAggregateData(path: string, query, serverConnConfig: ServerConnConfig) {
+export function getAggregateData(path: string, query, serverConnConfig?: ServerConnConfig) {
return new Promise((rs, rj) => {
+ // when app config does not have "server", localhost is used and no user authentication is required
+ serverConnConfig ||= {
+ connectUrl: 'http://localhost:8080' as any,
+ aggregate_call_auth: 'no_auth',
+ };
const fullUrl = `${serverConnConfig.connectUrl}/${path}`;
query['aggregate'] = true;
diff --git a/www/js/services/controlHelper.ts b/www/js/services/controlHelper.ts
index 1a9016557..da2e60ed7 100644
--- a/www/js/services/controlHelper.ts
+++ b/www/js/services/controlHelper.ts
@@ -54,9 +54,9 @@ export function getMyDataHelpers(fileName: string, startTimeString: string, endT
const shareObj = {
files: [attachFile],
message: i18next.t(
- 'email-service.email-data.body-data-consists-of-list-of-entries',
+ 'shareFile-service.send-data.body-data-consists-of-list-of-entries',
),
- subject: i18next.t('email-service.email-data.subject-data-dump-from-to', {
+ subject: i18next.t('shareFile-service.send-data.subject-data-dump-from-to', {
start: startTimeString,
end: endTimeString,
}),
diff --git a/www/js/services/shareLocalDBFile.ts b/www/js/services/shareLocalDBFile.ts
new file mode 100644
index 000000000..484604371
--- /dev/null
+++ b/www/js/services/shareLocalDBFile.ts
@@ -0,0 +1,121 @@
+import i18next from 'i18next';
+import { displayError, displayErrorMsg, logDebug, logWarn } from '../plugin/logger';
+
+function localDBHelpers(fileName: string, fileExtension: string = '.txt') {
+ async function localCopyFile() {
+ return new Promise((resolve, reject) => {
+ let pathToFile, parentDirectory;
+ if (window['cordova'].platformId == 'android') {
+ // parentDirectory: file:///data/user/0/edu.berkeley.eecs.emission/files/
+ parentDirectory = window['cordova'].file.dataDirectory.replace('files', 'databases');
+ // pathToFile: /data/user/0/edu.berkeley.eecs.emission/files/
+ pathToFile = parentDirectory.replace('file://', '') + fileName;
+ } else if (window['cordova'].platformId == 'ios') {
+ // parentDirectory: file:///var/mobile/Containers/Data/Application/<32-hex-digit-id>/Library/NoCloud/../
+ parentDirectory = window['cordova'].file.dataDirectory + '../';
+ pathToFile = 'LocalDatabase/' + fileName;
+ } else {
+ displayErrorMsg('Error: Unknown OS!');
+ throw new Error('Error: Unknown OS!');
+ }
+
+ window['resolveLocalFileSystemURL'](parentDirectory, (fs) => {
+ // On iOS, pass in relative path to getFile https://github.com/e-mission/e-mission-phone/pull/1160#issuecomment-2192112472
+ // On Android, pass in absolute path to getFile https://github.com/e-mission/e-mission-phone/pull/1160#issuecomment-2204297874
+ fs.filesystem.root.getFile(pathToFile, { create: false, exclusive: false }, (fileEntry) => {
+ // logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`);
+ logDebug(`fileEntry is: ${JSON.stringify(fileEntry, null, 2)}`);
+ window['resolveLocalFileSystemURL'](window['cordova'].file.cacheDirectory, (copyDir) => {
+ logDebug(`DirectoryEntry is: ${JSON.stringify(copyDir.filesystem.root, null, 2)}`);
+
+ fileEntry.copyTo(
+ copyDir.filesystem.root,
+ fileName + fileExtension,
+ (res) => {
+ logDebug(`Res: ${res}`);
+ resolve();
+ },
+ (rej) => {
+ displayErrorMsg(`Rej: ${JSON.stringify(rej, null, 2)}`);
+ reject();
+ },
+ );
+ });
+ });
+ });
+ });
+ }
+
+ function localShareFile() {
+ return new Promise((resolve, reject) => {
+ window['resolveLocalFileSystemURL'](window['cordova'].file.cacheDirectory, (fs) => {
+ fs.filesystem.root.getFile(
+ fileName + fileExtension,
+ null,
+ (fileEntry) => {
+ const shareObj = {
+ files: [fileEntry.nativeURL],
+ message: i18next.t('shareFile-service.send-log.body-please-fill-in-what-is-wrong'),
+ subject: i18next.t('shareFile-service.send-log.subject-logs'),
+ };
+ window['plugins'].socialsharing.shareWithOptions(
+ shareObj,
+ (result) => {
+ logDebug(`Share Completed? ${result.completed}`); // On Android, most likely returns false
+ logDebug(`Shared to app: ${result.app}`);
+ resolve();
+ },
+ (error) => {
+ displayError(error, `Sharing failed with error`);
+ },
+ );
+ },
+ (error) => {
+ displayError(error, 'Error while sharing logs');
+ reject(error);
+ },
+ );
+ });
+ });
+ }
+
+ function localClearTmpFile() {
+ return new Promise((resolve, reject) => {
+ window['resolveLocalFileSystemURL'](window['cordova'].file.cacheDirectory, (fs) => {
+ fs.filesystem.root.getFile(fileName + fileExtension, null, (fileEntry) => {
+ fileEntry.remove(
+ () => {
+ logDebug(`Successfully cleaned up file ${fileName}`);
+ resolve();
+ },
+ (err) => {
+ displayError(err, `Error deleting ${fileName}`);
+ reject(err);
+ },
+ );
+ });
+ });
+ });
+ }
+
+ return {
+ copyFile: localCopyFile,
+ shareData: localShareFile,
+ clearTmpFile: localClearTmpFile,
+ };
+}
+export async function sendLocalDBFile(database: string) {
+ alert(i18next.t('shareFile-service.send-to'));
+
+ const dataMethods = localDBHelpers(database);
+ dataMethods
+ .copyFile()
+ .then(dataMethods.shareData)
+ .then(dataMethods.clearTmpFile)
+ .then(() => {
+ logDebug(`File Shared!`);
+ })
+ .catch((err) => {
+ displayError(err);
+ });
+}
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 249d7f366..c4d28eee8 100644
--- a/www/js/survey/enketo/UserInputButton.tsx
+++ b/www/js/survey/enketo/UserInputButton.tsx
@@ -14,28 +14,46 @@ import { useTranslation } from 'react-i18next';
import { useTheme } from 'react-native-paper';
import { displayErrorMsg, logDebug } from '../../plugin/logger';
import EnketoModal from './EnketoModal';
-import LabelTabContext from '../../diary/LabelTabContext';
+import TimelineContext from '../../TimelineContext';
+import useAppConfig from '../../useAppConfig';
+import { getSurveyForTimelineEntry } from './conditionalSurveys';
+import useDerivedProperties from '../../diary/useDerivedProperties';
+import { resolveSurveyButtonConfig } from './enketoHelper';
+import { SurveyButtonConfig } from '../../types/appConfigTypes';
type Props = {
timelineEntry: any;
};
const UserInputButton = ({ timelineEntry }: Props) => {
const { colors } = useTheme();
+ const appConfig = useAppConfig();
const { t, i18n } = useTranslation();
const [prevSurveyResponse, setPrevSurveyResponse] = useState(undefined);
const [modalVisible, setModalVisible] = useState(false);
- const { userInputFor, addUserInputToEntry } = useContext(LabelTabContext);
+ const { userInputFor, addUserInputToEntry } = useContext(TimelineContext);
+ const derivedTripProps = useDerivedProperties(timelineEntry);
- // the label resolved from the survey response, or null if there is no response yet
- const responseLabel = useMemo(
- () => userInputFor(timelineEntry)?.['SURVEY']?.data.label || undefined,
- [userInputFor(timelineEntry)?.['SURVEY']?.data.label],
- );
+ // which survey will this button launch?
+ const survey = useMemo(() => {
+ if (!appConfig) return null; // no config loaded yet; show blank for now
+ const possibleSurveysForButton = resolveSurveyButtonConfig(appConfig, 'trip-label');
+ // if there is only one survey, no need to check further
+ if (possibleSurveysForButton.length == 1) return possibleSurveysForButton[0];
+ // config lists one or more surveys; find which one to use for this timeline entry
+ return getSurveyForTimelineEntry(possibleSurveysForButton, timelineEntry, derivedTripProps);
+ }, [appConfig, timelineEntry, i18n.resolvedLanguage]);
+
+ // the label resolved from the survey response, or undefined if there is no response yet
+ const responseLabel = useMemo(() => {
+ if (!survey) return undefined;
+ return userInputFor(timelineEntry)?.[survey.surveyName]?.data.label || undefined;
+ }, [survey, userInputFor(timelineEntry)?.[survey?.surveyName || '']?.data.label]);
function launchUserInputSurvey() {
+ if (!survey) return displayErrorMsg('UserInputButton: no survey to launch');
logDebug('UserInputButton: About to launch survey');
- const prevResponse = userInputFor(timelineEntry)?.['SURVEY'];
+ const prevResponse = userInputFor(timelineEntry)?.[survey.surveyName];
if (prevResponse?.data?.xmlResponse) {
setPrevSurveyResponse(prevResponse.data.xmlResponse);
}
@@ -46,29 +64,27 @@ const UserInputButton = ({ timelineEntry }: Props) => {
if (result) {
logDebug(`UserInputButton: response was saved, about to addUserInputToEntry;
result = ${JSON.stringify(result)}`);
- addUserInputToEntry(timelineEntry._id.$oid, { SURVEY: result }, 'label');
+ addUserInputToEntry(timelineEntry._id.$oid, { [result.name]: result }, 'label');
} else {
displayErrorMsg('UserInputButton: response was not saved, result=', result);
}
}
+ if (!survey) return <>>; // no survey to launch
return (
<>
launchUserInputSurvey()}>
- {/* if no response yet, show the default label */}
- {responseLabel || t('diary.choose-survey')}
+ {responseLabel || survey['not-filled-in-label'][i18n.resolvedLanguage || 'en']}
setModalVisible(false)}
onResponseSaved={onResponseSaved}
- surveyName={'TripConfirmSurvey'} /* As of now, the survey name is hardcoded.
- In the future, if we ever implement something like
- a "Place Details" survey, we may want to make this
- configurable. */
+ surveyName={survey.surveyName}
opts={{ timelineEntry, prefilledSurveyResponse: prevSurveyResponse }}
/>
>
diff --git a/www/js/survey/enketo/conditionalSurveys.ts b/www/js/survey/enketo/conditionalSurveys.ts
new file mode 100644
index 000000000..a96ee2de8
--- /dev/null
+++ b/www/js/survey/enketo/conditionalSurveys.ts
@@ -0,0 +1,52 @@
+import { displayError } from '../../plugin/logger';
+import { SurveyButtonConfig } from '../../types/appConfigTypes';
+import { DerivedProperties, TimelineEntry } from '../../types/diaryTypes';
+import { Position } from 'geojson';
+
+const conditionalSurveyFunctions = {
+ /**
+ @description Returns true if the given point is within the given bounds.
+ Coordinates are in [longitude, latitude] order, since that is the GeoJSON spec.
+ @param pt point to check as [lon, lat]
+ @param bounds NW and SE corners as [[lon, lat], [lon, lat]]
+ @returns true if pt is within bounds
+ */
+ pointIsWithinBounds: (pt: Position, bounds: Position[]) => {
+ // pt's lon must be east of, or greater than, NW's lon; and west of, or less than, SE's lon
+ const lonInRange = pt[0] > bounds[0][0] && pt[0] < bounds[1][0];
+ // pt's lat must be south of, or less than, NW's lat; and north of, or greater than, SE's lat
+ const latInRange = pt[1] < bounds[0][1] && pt[1] > bounds[1][1];
+ return latInRange && lonInRange;
+ },
+};
+
+/**
+ * @description Executes a JS expression `script` in a restricted `scope`
+ * @example scopedEval('console.log(foo)', { foo: 'bar' })
+ */
+const scopedEval = (script: string, scope: { [k: string]: any }) =>
+ Function(...Object.keys(scope), `return ${script}`)(...Object.values(scope));
+
+// the first survey in the list that passes its condition will be returned
+export function getSurveyForTimelineEntry(
+ possibleSurveys: SurveyButtonConfig[],
+ tlEntry: TimelineEntry,
+ derivedProperties: DerivedProperties,
+) {
+ for (let survey of possibleSurveys) {
+ if (!survey.showsIf) return survey; // survey shows unconditionally
+ const scope = {
+ ...tlEntry,
+ ...derivedProperties,
+ ...conditionalSurveyFunctions,
+ };
+ try {
+ const evalResult = scopedEval(survey.showsIf, scope);
+ if (evalResult) return survey;
+ } catch (e) {
+ displayError(e, `Error evaluating survey condition "${survey.showsIf}"`);
+ }
+ }
+ // TODO if none of the surveys passed conditions?? should we return null, throw error, or return a default?
+ return null;
+}
diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts
index 2df2d3b2d..e90354856 100644
--- a/www/js/survey/enketo/enketoHelper.ts
+++ b/www/js/survey/enketo/enketoHelper.ts
@@ -8,7 +8,7 @@ import { getConfig } from '../../config/dynamicConfig';
import { DateTime } from 'luxon';
import { fetchUrlCached } from '../../services/commHelper';
import { getUnifiedDataForInterval } from '../../services/unifiedDataLoader';
-import { AppConfig, EnketoSurveyConfig } from '../../types/appConfigTypes';
+import { AppConfig, EnketoSurveyConfig, SurveyButtonConfig } from '../../types/appConfigTypes';
import {
CompositeTrip,
ConfirmedPlace,
@@ -315,6 +315,32 @@ export function loadPreviousResponseForSurvey(dataKey: string) {
);
}
+/**
+ * @description Returns an array of surveys that could be prompted for one button in the UI (trip label, trip notes, place label, or place notes)
+ * (If multiple are returned, they will show conditionally in the UI based on their `showsIf` field)
+ * Includes backwards compats for app config fields that didn't use to exist
+ */
+export function resolveSurveyButtonConfig(
+ config: AppConfig,
+ button: 'trip-label' | 'trip-notes' | 'place-label' | 'place-notes',
+): SurveyButtonConfig[] {
+ const buttonConfig = config.survey_info.buttons?.[button];
+ // backwards compat: default to the trip confirm survey if this button isn't configured
+ if (!buttonConfig) {
+ return [
+ {
+ surveyName: 'TripConfirmSurvey',
+ 'not-filled-in-label': {
+ en: 'Add Trip Details',
+ es: 'Agregar detalles del viaje',
+ lo: 'ເພີ່ມລາຍລະອຽດການເດີນທາງ',
+ },
+ },
+ ];
+ }
+ return buttonConfig instanceof Array ? buttonConfig : [buttonConfig];
+}
+
export async function fetchSurvey(url: string) {
const responseText = await fetchUrlCached(url);
if (!responseText) return;
diff --git a/www/js/survey/enketo/infinite_scroll_filters.ts b/www/js/survey/enketo/infinite_scroll_filters.ts
index d4b281713..512b272c4 100644
--- a/www/js/survey/enketo/infinite_scroll_filters.ts
+++ b/www/js/survey/enketo/infinite_scroll_filters.ts
@@ -8,7 +8,8 @@
import i18next from 'i18next';
-const unlabeledCheck = (trip, userInputForTrip) => !userInputForTrip?.['SURVEY'];
+const unlabeledCheck = (trip, userInputForTrip) =>
+ !userInputForTrip || !Object.values(userInputForTrip).some((input) => input);
const TO_LABEL = {
key: 'to_label',
diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts
index da802d2e8..604c533b2 100644
--- a/www/js/survey/inputMatcher.ts
+++ b/www/js/survey/inputMatcher.ts
@@ -1,16 +1,28 @@
import { logDebug, displayErrorMsg } from '../plugin/logger';
import { DateTime } from 'luxon';
-import { CompositeTrip, ConfirmedPlace, TimelineEntry, UserInputEntry } from '../types/diaryTypes';
-import { keysForLabelInputs, unprocessedLabels, unprocessedNotes } from '../diary/timelineHelper';
+import {
+ BluetoothBleData,
+ CompositeTrip,
+ ConfirmedPlace,
+ TimelineEntry,
+ UserInputEntry,
+} from '../types/diaryTypes';
+import {
+ keysForLabelInputs,
+ unprocessedBleScans,
+ unprocessedLabels,
+ unprocessedNotes,
+} from '../diary/timelineHelper';
import {
getLabelInputDetails,
inputType2retKey,
removeManualPrefix,
} from './multilabel/confirmHelper';
-import { TimelineLabelMap, TimelineNotesMap } from '../diary/LabelTabContext';
+import { TimelineLabelMap, TimelineNotesMap, UserInputMap } from '../TimelineContext';
import { MultilabelKey } from '../types/labelTypes';
import { EnketoUserInputEntry } from './enketo/enketoHelper';
import { AppConfig } from '../types/appConfigTypes';
+import { BEMData } from '../types/serverData';
const EPOCH_MAXIMUM = 2 ** 31 - 1;
@@ -204,9 +216,8 @@ export function getAdditionsForTimelineEntry(
return [];
}
- // get additions that have not been deleted and filter out additions that do not start within the bounds of the timeline entry
- const notDeleted = getNotDeletedCandidates(additionsList);
- const matchingAdditions = notDeleted.filter((ui) =>
+ // filter out additions that do not start within the bounds of the timeline entry
+ const matchingAdditions = additionsList.filter((ui) =>
validUserInputForTimelineEntry(entry, nextEntry, ui, logsEnabled),
);
@@ -268,16 +279,16 @@ export function mapInputsToTimelineEntries(
allEntries.forEach((tlEntry, i) => {
const nextEntry = i + 1 < allEntries.length ? allEntries[i + 1] : null;
if (appConfig?.survey_info?.['trip-labels'] == 'ENKETO') {
- // ENKETO configuration: just look for the 'SURVEY' key in the unprocessedInputs
+ // ENKETO configuration: consider reponses from all surveys in unprocessedLabels
const userInputForTrip = getUserInputForTimelineEntry(
tlEntry,
nextEntry,
- unprocessedLabels['SURVEY'],
+ Object.values(unprocessedLabels).flat(1),
) as EnketoUserInputEntry;
if (userInputForTrip) {
- timelineLabelMap[tlEntry._id.$oid] = { SURVEY: userInputForTrip };
+ timelineLabelMap[tlEntry._id.$oid] = { [userInputForTrip.data.name]: userInputForTrip };
} else {
- let processedSurveyResponse;
+ let processedSurveyResponse: EnketoUserInputEntry | undefined;
for (const dataKey of keysForLabelInputs(appConfig)) {
const key = removeManualPrefix(dataKey);
if (tlEntry.user_input?.[key]) {
@@ -285,12 +296,16 @@ export function mapInputsToTimelineEntries(
break;
}
}
- timelineLabelMap[tlEntry._id.$oid] = { SURVEY: processedSurveyResponse };
+ if (processedSurveyResponse) {
+ timelineLabelMap[tlEntry._id.$oid] = {
+ [processedSurveyResponse.data.name]: processedSurveyResponse,
+ };
+ }
}
} else {
// MULTILABEL configuration: use the label inputs from the labelOptions to determine which
// keys to look for in the unprocessedInputs
- const labelsForTrip: { [k: string]: UserInputEntry | undefined } = {};
+ const labelsForTrip: UserInputMap = {};
Object.keys(getLabelInputDetails(appConfig)).forEach((label: MultilabelKey) => {
// Check unprocessed labels first since they are more recent
const userInputForTrip = getUserInputForTimelineEntry(
@@ -340,3 +355,85 @@ export function mapInputsToTimelineEntries(
return [timelineLabelMap, timelineNotesMap];
}
+
+function validBleScanForTimelineEntry(tlEntry: TimelineEntry, bleScan: BEMData) {
+ let entryStart = (tlEntry as CompositeTrip).start_ts || (tlEntry as ConfirmedPlace).enter_ts;
+ let entryEnd = (tlEntry as CompositeTrip).end_ts || (tlEntry as ConfirmedPlace).exit_ts;
+
+ if (!entryStart && entryEnd) {
+ /* if a place has no enter time, this is the first start_place of the first composite trip object
+ so we will set the start time to the start of the day of the end time for the purpose of comparison */
+ entryStart = DateTime.fromSeconds(entryEnd).startOf('day').toUnixInteger();
+ }
+
+ if (!entryEnd) {
+ /* if a place has no exit time, the user hasn't left there yet
+ so we will set the end time as high as possible for the purpose of comparison */
+ entryEnd = EPOCH_MAXIMUM;
+ }
+
+ return bleScan.data.ts >= entryStart && bleScan.data.ts <= entryEnd;
+}
+
+/**
+ * @description Get BLE scans that are of type RANGE_UPDATE and are within the time range of the timeline entry
+ */
+function getBleRangingScansForTimelineEntry(
+ tlEntry: TimelineEntry,
+ bleScans: BEMData[],
+) {
+ return bleScans.filter(
+ (scan) =>
+ /* RANGE_UPDATE is the string value, but the server uses an enum, so once processed it becomes 2 */
+ (scan.data.eventType == 'RANGE_UPDATE' || scan.data.eventType == 2) &&
+ validBleScanForTimelineEntry(tlEntry, scan),
+ );
+}
+
+/**
+ * @description Convert a decimal number to a hexadecimal string, with optional padding
+ * @example decimalToHex(245) => 'f5'
+ * @example decimalToHex(245, 4) => '00f5'
+ */
+function decimalToHex(d: string | number, padding?: number) {
+ let hex = Number(d).toString(16);
+ while (hex.length < (padding || 0)) {
+ hex = '0' + hex;
+ }
+ return hex;
+}
+
+export function mapBleScansToTimelineEntries(allEntries: TimelineEntry[], appConfig: AppConfig) {
+ const timelineBleMap = {};
+ for (const tlEntry of allEntries) {
+ const rangingScans = getBleRangingScansForTimelineEntry(tlEntry, unprocessedBleScans);
+ if (!rangingScans.length) {
+ continue;
+ }
+
+ // count the number of occurrences of each major:minor pair
+ const majorMinorCounts = {};
+ rangingScans.forEach((scan) => {
+ const major = decimalToHex(scan.data.major, 4);
+ const minor = decimalToHex(scan.data.minor, 4);
+ const majorMinor = major + ':' + minor;
+ majorMinorCounts[majorMinor] = majorMinorCounts[majorMinor]
+ ? majorMinorCounts[majorMinor] + 1
+ : 1;
+ });
+ // determine the major:minor pair with the highest count
+ const match = Object.keys(majorMinorCounts).reduce((a, b) =>
+ majorMinorCounts[a] > majorMinorCounts[b] ? a : b,
+ );
+ // find the vehicle identity that uses this major:minor pair
+ const vehicleIdentity = appConfig.vehicle_identities?.find((vi) =>
+ vi.bluetooth_major_minor.includes(match),
+ );
+ if (vehicleIdentity) {
+ timelineBleMap[tlEntry._id.$oid] = vehicleIdentity;
+ } else {
+ displayErrorMsg(`No vehicle identity found for major:minor pair ${match}`);
+ }
+ }
+ return timelineBleMap;
+}
diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx
index 7c24f57ec..466bb9868 100644
--- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx
+++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx
@@ -16,7 +16,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,
@@ -37,7 +37,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 { customLabelMap, setCustomLabelMap } = useContext(AppContext);
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..79695918a 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;
@@ -91,12 +91,12 @@ export function getLabelInputDetails(appConfigParam?) {
export function labelInputDetailsForTrip(userInputForTrip, appConfigParam?) {
if (appConfigParam) appConfig = appConfigParam;
if (appConfig.intro.mode_studied) {
- if (userInputForTrip?.['MODE']?.value == appConfig.intro.mode_studied) {
- logDebug(`Found trip labeled with mode of study, ${appConfig.intro.mode_studied}.
+ if (userInputForTrip?.['MODE']?.data?.label == appConfig.intro.mode_studied) {
+ logDebug(`Found trip labeled with ${userInputForTrip?.['MODE']?.data?.label}, mode of study = ${appConfig.intro.mode_studied}.
Needs REPLACED_MODE`);
return getLabelInputDetails();
} else {
- logDebug(`Found trip not labeled with mode of study, ${appConfig.intro.mode_studied}.
+ logDebug(`Found trip labeled with ${userInputForTrip?.['MODE']?.data?.label}, not labeled with mode of study = ${appConfig.intro.mode_studied}.
Doesn't need REPLACED_MODE`);
return baseLabelInputDetails;
}
@@ -111,6 +111,10 @@ export const getBaseLabelInputs = () => Object.keys(baseLabelInputDetails) as Mu
/** @description replace all underscores with spaces, and capitalizes the first letter of each word */
export function labelKeyToReadable(otherValue: string) {
+ if (otherValue == otherValue.toUpperCase()) {
+ // if all caps, make lowercase
+ otherValue = otherValue.toLowerCase();
+ }
const words = otherValue.replace(/_/g, ' ').trim().split(' ');
if (words.length == 0) return '';
return words.map((word) => word[0].toUpperCase() + word.slice(1)).join(' ');
diff --git a/www/js/types/BluetoothDevices.ts b/www/js/types/BluetoothDevices.ts
index e628731e2..c29f55740 100644
--- a/www/js/types/BluetoothDevices.ts
+++ b/www/js/types/BluetoothDevices.ts
@@ -22,7 +22,14 @@ export type BLEBeaconDevice = {
type_name?: string; // e.g., "BeaconRegion"; used for callback
};
export type BLEDeviceList = {
- [key: string]: { identifier: string; minor: number; major: number; in_range: boolean };
+ [key: string]: {
+ identifier: string;
+ minor: number;
+ major: number;
+ monitorResult: string;
+ rangeResult: string;
+ in_range: boolean;
+ };
};
export type BLEPluginCallback = {
diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts
index 75fb4b091..87a4b4e85 100644
--- a/www/js/types/appConfigTypes.ts
+++ b/www/js/types/appConfigTypes.ts
@@ -2,13 +2,19 @@
// examples of configs: https://github.com/e-mission/nrel-openpath-deploy-configs/tree/main/configs
export type AppConfig = {
+ version: number;
server: ServerConnConfig;
intro: IntroConfig;
survey_info: {
'trip-labels': 'MULTILABEL' | 'ENKETO';
surveys: EnketoSurveyConfig;
- buttons?: any;
+ buttons?: SurveyButtonsConfig;
};
+ vehicle_identities?: VehicleIdentity[];
+ tracking?: {
+ bluetooth_only: boolean;
+ };
+ metrics: MetricsConfig;
reminderSchemes?: ReminderSchemesConfig;
[k: string]: any; // TODO fill in all the other fields
};
@@ -44,6 +50,37 @@ export type EnketoSurveyConfig = {
};
};
+export type SurveyButtonConfig = {
+ surveyName: string;
+ 'not-filled-in-label': {
+ [lang: string]: string;
+ };
+ showsIf?: string; // a JS expression that evaluates to a boolean
+};
+export type SurveyButtonsConfig = {
+ [k in 'trip-label' | 'trip-notes' | 'place-label' | 'place-notes']:
+ | SurveyButtonConfig
+ | SurveyButtonConfig[];
+};
+
+export type VehicleIdentity = {
+ value: string;
+ bluetooth_major_minor: string[]; // e.g. ['aaaa:bbbb', 'cccc:dddd']
+ text: string;
+ baseMode: string;
+ met_equivalent: string;
+ kgCo2PerKm: number;
+ vehicle_info: {
+ type: string;
+ license: string;
+ make: string;
+ model: string;
+ year: number;
+ color: string;
+ engine: 'ICE' | 'HEV' | 'PHEV' | 'BEV' | 'HYDROGENV' | 'BIOV';
+ };
+};
+
export type ReminderSchemesConfig = {
[schemeKey: string]: {
title: { [lang: string]: string };
@@ -56,3 +93,34 @@ export type ReminderSchemesConfig = {
defaultTime?: string; // format is HH:MM in 24 hour time
};
};
+
+// the available metrics that can be displayed in the phone dashboard
+export type MetricName = 'distance' | 'count' | 'duration' | 'response_count';
+// the available trip / userinput properties that can be used to group the metrics
+export const groupingFields = [
+ 'mode_confirm',
+ 'purpose_confirm',
+ 'replaced_mode_confirm',
+ 'primary_ble_sensed_mode',
+ 'survey',
+] as const;
+export type GroupingField = (typeof groupingFields)[number];
+export type MetricList = { [k in MetricName]?: GroupingField[] };
+export type MetricsUiSection = 'footprint' | 'active_travel' | 'summary' | 'engagement' | 'surveys';
+export type MetricsConfig = {
+ include_test_users: boolean;
+ phone_dashboard_ui?: {
+ sections: MetricsUiSection[];
+ metric_list: MetricList;
+ footprint_options?: {
+ unlabeled_uncertainty: boolean;
+ };
+ summary_options?: {};
+ engagement_options?: {
+ leaderboard_metric: [string, string];
+ };
+ active_travel_options?: {
+ modes_list: string[];
+ };
+ };
+};
diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts
index 75c43b2d6..53b618be0 100644
--- a/www/js/types/diaryTypes.ts
+++ b/www/js/types/diaryTypes.ts
@@ -3,9 +3,11 @@
As much as possible, these types parallel the types used in the server code. */
import { BaseModeKey, MotionTypeKey } from '../diary/diaryHelper';
+import useDerivedProperties from '../diary/useDerivedProperties';
+import { VehicleIdentity } from './appConfigTypes';
import { MultilabelKey } from './labelTypes';
import { BEMData, LocalDt } from './serverData';
-import { FeatureCollection, Feature, Geometry, Point } from 'geojson';
+import { FeatureCollection, Feature, Geometry, Point, Position } from 'geojson';
type ObjectId = { $oid: string };
@@ -45,14 +47,9 @@ export type TripTransition = {
ts: number;
};
-export type LocationCoord = {
- type: string; // e.x., "Point"
- coordinates: [number, number];
-};
-
-type CompTripLocations = {
+export type CompositeTripLocation = {
loc: {
- coordinates: number[]; // e.g. [1, 2.3]
+ coordinates: Position; // [lon, lat]
};
speed: number;
ts: number;
@@ -61,24 +58,30 @@ type CompTripLocations = {
// Used for return type of readUnprocessedTrips
export type UnprocessedTrip = {
_id: ObjectId;
- additions: UserInputEntry[];
+ additions: []; // unprocessed trips won't have any matched processed inputs, so this is always empty
+ ble_sensed_summary: SectionSummary;
+ cleaned_section_summary: SectionSummary;
confidence_threshold: number;
distance: number;
duration: number;
end_fmt_time: string;
end_loc: Point;
end_local_dt: LocalDt;
- expectation: any; // TODO "{to_label: boolean}"
- inferred_labels: any[]; // TODO
- key: string;
- locations?: CompTripLocations[];
- origin_key: string; // e.x., UNPROCESSED_trip
- source: string;
+ end_ts: number;
+ expectation: { to_label: true }; // unprocessed trips are always expected to be labeled
+ inferred_labels: []; // unprocessed trips won't have inferred labels
+ inferred_section_summary: SectionSummary;
+ key: 'UNPROCESSED_trip';
+ locations?: CompositeTripLocation[];
+ origin_key: 'UNPROCESSED_trip';
+ sections: SectionData[];
+ source: 'unprocessed';
+ start_fmt_time: string;
start_local_dt: LocalDt;
start_ts: number;
start_loc: Point;
starting_trip?: any;
- user_input: UserInput;
+ user_input: {}; // unprocessed trips won't have any matched processed inputs, so this is always empty
};
/* These are the properties received from the server (basically matches Python code)
@@ -86,6 +89,7 @@ export type UnprocessedTrip = {
export type CompositeTrip = {
_id: ObjectId;
additions: UserInputEntry[];
+ ble_sensed_summary: SectionSummary;
cleaned_section_summary: SectionSummary;
cleaned_trip: ObjectId;
confidence_threshold: number;
@@ -98,16 +102,16 @@ export type CompositeTrip = {
end_local_dt: LocalDt;
end_place: ObjectId;
end_ts: number;
- expectation: any; // TODO "{to_label: boolean}"
+ expectation: { to_label: boolean };
expected_trip: ObjectId;
inferred_labels: InferredLabels;
inferred_section_summary: SectionSummary;
inferred_trip: ObjectId;
key: string;
- locations: any[]; // TODO
+ locations: CompositeTripLocation[];
origin_key: string;
raw_trip: ObjectId;
- sections: any[]; // TODO
+ sections: SectionData[];
source: string;
start_confirmed_place: BEMData;
start_fmt_time: string;
@@ -131,18 +135,7 @@ export type TimestampRange = { start_ts: number; end_ts: number };
/* These properties aren't received from the server, but are derived from the above properties.
They are used in the UI to display trip/place details and are computed by the useDerivedProperties hook. */
-export type DerivedProperties = {
- displayDate: string;
- displayStartTime: string;
- displayEndTime: string;
- displayTime: string;
- displayStartDateAbbr: string;
- displayEndDateAbbr: string;
- formattedDistance: string;
- formattedSectionProperties: any[]; // TODO
- distanceSuffix: string;
- detectedModes: { mode: string; icon: string; color: string; pct: number | string }[];
-};
+export type DerivedProperties = ReturnType;
export type SectionSummary = {
count: { [k: MotionTypeKey | BaseModeKey]: number };
@@ -178,6 +171,17 @@ export type UserInputEntry = {
key?: string;
};
+export type BluetoothBleData = {
+ ts: number;
+ eventType: 'REGION_ENTER' | 'REGION_EXIT' | 'RANGE_UPDATE' | number;
+ uuid: string;
+ major: number; // for our use case, missing for REGION_ENTER or REGION_EXIT
+ minor: number; // for our use case, missing for REGION_ENTER or REGION_EXIT
+ proximity?: string; // only available for RANGE_UPDATE
+ rssi?: string; // only available for RANGE_UPDATE
+ accuracy?: string; // only available for RANGE_UPDATE
+};
+
export type Location = {
speed: number;
heading: number;
@@ -188,23 +192,26 @@ export type Location = {
latitude: number;
fmt_time: string; // ISO
mode: number;
- loc: LocationCoord;
+ loc: Point;
ts: number; // Unix
altitude: number;
distance: number;
};
-// used in readAllCompositeTrips
export type SectionData = {
+ _id: ObjectId;
end_ts: number; // Unix time, e.x. 1696352498.804
- end_loc: LocationCoord;
+ end_loc: Point;
start_fmt_time: string; // ISO time
end_fmt_time: string;
+ key: string;
+ origin_key: string;
trip_id: ObjectId;
+ ble_sensed_mode: VehicleIdentity;
sensed_mode: number;
source: string; // e.x., "SmoothedHighConfidenceMotion"
start_ts: number; // Unix
- start_loc: LocationCoord;
+ start_loc: Point;
cleaned_section: ObjectId;
start_local_dt: LocalDt;
end_local_dt: LocalDt;
@@ -213,7 +220,7 @@ export type SectionData = {
distance: number;
};
-// used in timelineHelper's `transitionTrip2TripObj`
+// used in timelineHelper's `transitionTrip2UnprocessedTrip`
export type FilteredLocation = {
accuracy: number;
altitude: number;
diff --git a/www/js/usePermissionStatus.ts b/www/js/usePermissionStatus.ts
index 8c3128bea..f8fef085b 100644
--- a/www/js/usePermissionStatus.ts
+++ b/www/js/usePermissionStatus.ts
@@ -291,6 +291,41 @@ const usePermissionStatus = () => {
setCheckList(tempChecks);
}
+ function setupAndroidBluetoothChecks() {
+ if (window['device'].version.split('.')[0] >= 10) {
+ let fixPerms = () => {
+ logDebug('fix and refresh bluetooth permissions');
+ return checkOrFix(
+ bluetoothPermissionsCheck,
+ window['cordova'].plugins.BEMDataCollection.fixBluetoothPermissions,
+ true,
+ ).then((error) => {
+ if (error) {
+ bluetoothPermissionsCheck.desc = error;
+ }
+ });
+ };
+ let checkPerms = () => {
+ logDebug('fix and refresh bluetooth permissions');
+ return checkOrFix(
+ bluetoothPermissionsCheck,
+ window['cordova'].plugins.BEMDataCollection.isValidBluetoothPermissions,
+ false,
+ );
+ };
+
+ let bluetoothPermissionsCheck = {
+ name: 'Bluetooth scan permission',
+ desc: 'Scan for BLE beacons to automatically match trips to vehicles',
+ fix: fixPerms,
+ refresh: checkPerms,
+ };
+ let tempChecks = checkList;
+ tempChecks.push(bluetoothPermissionsCheck);
+ setCheckList(tempChecks);
+ }
+ }
+
function setupAndroidNotificationChecks() {
let fixPerms = () => {
logDebug('fix and refresh notification permissions');
@@ -409,6 +444,9 @@ const usePermissionStatus = () => {
if (window['device'].platform.toLowerCase() == 'android') {
setupAndroidLocChecks();
setupAndroidFitnessChecks();
+ if (appConfig.tracking?.bluetooth_only) {
+ setupAndroidBluetoothChecks();
+ }
setupAndroidNotificationChecks();
setupAndroidBackgroundRestrictionChecks();
} else if (window['device'].platform.toLowerCase() == 'ios') {