diff --git a/www/__tests__/timelineHelper.test.ts b/www/__tests__/timelineHelper.test.ts index bd817f084..aafe13926 100644 --- a/www/__tests__/timelineHelper.test.ts +++ b/www/__tests__/timelineHelper.test.ts @@ -28,7 +28,7 @@ afterAll(() => { describe('useGeojsonForTrip', () => { it('work with an empty input', () => { - const testVal = useGeojsonForTrip({} as any, {} as any); + const testVal = useGeojsonForTrip({} as any); expect(testVal).toBeFalsy; }); @@ -43,10 +43,7 @@ describe('useGeojsonForTrip', () => { }; it('works without labelMode flag', () => { - const testValue = useGeojsonForTrip( - mockTLH.mockCompDataTwo.phone_data[1].data, - mockTLH.mockLabelOptions, - ) as GeoJSONData; + const testValue = useGeojsonForTrip(mockTLH.mockCompDataTwo.phone_data[1].data) as GeoJSONData; expect(testValue).toBeTruthy; checkGeojson(testValue); expect(testValue.data.features.length).toBe(3); diff --git a/www/i18n/en.json b/www/i18n/en.json index ffbfb8ea8..9a8b6bb61 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -48,7 +48,11 @@ "reminders-time-of-day": "Time of Day for Reminders ({{time}})", "upcoming-notifications": "Upcoming Notifications", "dummy-notification": "Dummy Notification in 5 Seconds", - "log-out": "Log Out" + "log-out": "Log Out", + "refresh-app-config": "Refresh App Configuration", + "current-version": "Current version: {{version}}", + "refreshing-app-config": "Refreshing app configuration, please wait...", + "already-up-to-date": "Already up to date!" }, "general-settings": { diff --git a/www/js/bluetooth/BluetoothCard.tsx b/www/js/bluetooth/BluetoothCard.tsx index e212121a2..a244be7cf 100644 --- a/www/js/bluetooth/BluetoothCard.tsx +++ b/www/js/bluetooth/BluetoothCard.tsx @@ -23,7 +23,7 @@ const BluetoothCard = ({ device, isClassic, isScanningBLE }: Props) => { bgColor = device.in_range ? `rgba(200,250,200,1)` : `rgba(250,200,200,1)`; } - async function fakeMonitorCallback() { + async function fakeMonitorCallback(state: String) { // If we don't do this, the results start accumulating in the device object // first call, we put a result into the device // second call, the device already has a result, so we put another one in... @@ -33,18 +33,29 @@ const BluetoothCard = ({ device, isClassic, isScanningBLE }: Props) => { window['cordova'].plugins.locationManager.getDelegate().didDetermineStateForRegion({ region: deviceWithoutResult, eventType: 'didDetermineStateForRegion', - state: 'CLRegionStateInside', + state: state, }); - let timer: ReturnType = setTimeout(fakeRangeCallback, 500); } async function fakeRangeCallback() { - // If we don't do this, the results start accumulating in the device object - // first call, we put a result into the device - // second call, the device already has a result, so we put another one in... - const deviceWithMajorMinor = { ...device, major: 1234, minor: 4567 }; + const deviceWithBeacons = { ...device }; + deviceWithBeacons.monitorResult = undefined; + deviceWithBeacons.rangeResult = undefined; + const beacons = [ + { + uuid: device.uuid, + major: device.major | 4567, + minor: device.minor | 1945, + proximity: 'ProximityNear', + accuracy: Math.random() * 1.33, + rssi: Math.random() * -62, + }, + ]; + deviceWithBeacons.minor = device.minor | 4567; + deviceWithBeacons.minor = device.minor | 4567; window['cordova'].plugins.locationManager.getDelegate().didRangeBeaconsInRegion({ - region: deviceWithMajorMinor, + region: deviceWithBeacons, + beacons: beacons, eventType: 'didRangeBeaconsInRegion', state: 'CLRegionStateInside', }); @@ -65,9 +76,22 @@ const BluetoothCard = ({ device, isClassic, isScanningBLE }: Props) => { {device.rangeResult} - + + Simulate by sending UI transitions + + + + + + ); diff --git a/www/js/bluetooth/BluetoothScanPage.tsx b/www/js/bluetooth/BluetoothScanPage.tsx index bb96943c7..a4e5bda9e 100644 --- a/www/js/bluetooth/BluetoothScanPage.tsx +++ b/www/js/bluetooth/BluetoothScanPage.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { DateTime } from 'luxon'; import { StyleSheet, Modal, ScrollView, SafeAreaView, View, Text } from 'react-native'; import { gatherBluetoothClassicData } from './bluetoothScanner'; import { logWarn, displayError, displayErrorMsg, logDebug } from '../plugin/logger'; @@ -11,6 +12,7 @@ import { BluetoothClassicDevice, BLEDeviceList, } from '../types/bluetoothDevices'; +import { forceTransition } from '../control/ControlCollectionHelper'; /** * The implementation of this scanner page follows the design of @@ -90,10 +92,22 @@ const BluetoothScanPage = ({ ...props }: any) => { ...prevDevices, [uuid]: { ...prevDevices[uuid], - monitorResult: result, + monitorResult: status ? result : undefined, + rangeResult: undefined, in_range: status, }, })); + let { monitorResult: _, in_range: _, ...noResultDevice } = sampleBLEDevices[uuid]; + window['cordova']?.plugins?.BEMDataCollection.mockBLEObjects( + status ? 'REGION_ENTER' : 'REGION_EXIT', + uuid, + undefined, + undefined, + 1, + ); + if (!status) { + forceTransition('BLE_BEACON_LOST'); + } } function setRangeStatus(uuid: string, result: string) { @@ -104,6 +118,57 @@ const BluetoothScanPage = ({ ...props }: any) => { rangeResult: result, }, })); + // we don't want to exclude monitorResult and rangeResult from the values + // that we save because they are the current or previous result, just + // in a different format + // https://stackoverflow.com/a/34710102 + let { + monitorResult: _, + rangeResult: _, + in_range: _, + ...noResultDevice + } = sampleBLEDevices[uuid]; + let parsedResult = JSON.parse(result); + parsedResult.beacons.forEach((beacon) => { + window['cordova']?.plugins?.BEMDataCollection.mockBLEObjects( + 'RANGE_UPDATE', + uuid, + beacon.major, + beacon.minor, + 5, + ); + }); + // we only check for the transition on "real" callbacks to avoid excessive + // spurious callbacks on android + if (parsedResult.beacons.length > 0) { + // if we have received 3 range responses for the same beacon in the + // last 5 minutes, we generate the transition. we read without metadata + // (last param) + let nowSec = DateTime.now().toUnixInteger(); + let tq = { key: 'write_ts', startTs: nowSec - 5 * 60, endTs: nowSec }; + let readBLEReadingsPromise = window[ + 'cordova' + ]?.plugins?.BEMUserCache.getSensorDataForInterval('background/bluetooth_ble', tq, false); + readBLEReadingsPromise.then((bleResponses) => { + // we add 5 entries at a time, so if we want 3 button presses, + // we really want 15 entries + let lastFifteenResponses = bleResponses.slice(0, 15); + if (!lastFifteenResponses.every((x) => x.eventType == 'RANGE_UPDATE')) { + console.log( + 'Last three entries ' + + lastFifteenResponses.map((x) => x.eventType) + + ' are not all RANGE_UPDATE, skipping transition', + ); + return; + } + + forceTransition('BLE_BEACON_FOUND'); + }); + } + } + + async function simulateLocation(state: String) { + forceTransition(state); } // BLE LOGIC @@ -127,18 +192,21 @@ const BluetoothScanPage = ({ ...props }: any) => { window['cordova'].plugins.locationManager.appendToDeviceLog( '[DOM] didDetermineStateForRegion: ' + pluginResultStr, ); - const beaconRegion = new window['cordova'].plugins.locationManager.BeaconRegion( - STATIC_ID, - pluginResult.region.uuid, - pluginResult.region.major, - pluginResult.region.minor, - ); - window['cordova'].plugins.locationManager - .startRangingBeaconsInRegion(beaconRegion) - .fail(function (e) { - logWarn(e); - }) - .done(); + if (pluginResult.state == 'CLRegionStateInside') { + const beaconRegion = new window['cordova'].plugins.locationManager.BeaconRegion( + STATIC_ID, + pluginResult.region.uuid, + pluginResult.region.major, + pluginResult.region.minor, + ); + console.log('About to start ranging beacons for region ', beaconRegion); + window['cordova'].plugins.locationManager + .startRangingBeaconsInRegion(beaconRegion) + .fail(function (e) { + logWarn(e); + }) + .done(); + } }; delegate.didStartMonitoringForRegion = function (pluginResult) { @@ -317,19 +385,43 @@ const BluetoothScanPage = ({ ...props }: any) => { value={newUUID || ''} onChangeText={(t) => setNewUUID(t.toUpperCase())} /> - setNewMajor(t)} - /> - setNewMinor(t)} - /> + + setNewMajor(t)} + /> + setNewMinor(t)} + /> + + + + Simulate by sending UI transitions + + + + + + + diff --git a/www/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts index 9773a1ead..d9b9f3235 100644 --- a/www/js/config/dynamicConfig.ts +++ b/www/js/config/dynamicConfig.ts @@ -134,16 +134,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 +232,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/control/ProfileSettings.tsx b/www/js/control/ProfileSettings.tsx index f851a6651..ab381e594 100644 --- a/www/js/control/ProfileSettings.tsx +++ b/www/js/control/ProfileSettings.tsx @@ -26,7 +26,7 @@ import ControlCollectionHelper, { helperToggleLowAccuracy, forceTransition, } from './ControlCollectionHelper'; -import { resetDataAndRefresh } from '../config/dynamicConfig'; +import { loadNewConfig, resetDataAndRefresh } from '../config/dynamicConfig'; import { AppContext } from '../App'; import { shareQR } from '../components/QrCode'; import { storageClear } from '../plugin/storage'; @@ -307,6 +307,16 @@ const ProfileSettings = () => { }, 1500); } + async function refreshConfig() { + AlertManager.addMessage({ text: t('control.refreshing-app-config') }); + const updated = await loadNewConfig(authSettings.opcode, appConfig?.version); + if (updated) { + window.location.reload(); + } 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 @@ -434,6 +444,11 @@ const ProfileSettings = () => { textKey="control.email-log" iconName="email" action={() => sendEmail('loggerDB')}> + { const [timelineMap, setTimelineMap] = useState(null); const [timelineLabelMap, setTimelineLabelMap] = useState(null); const [timelineNotesMap, setTimelineNotesMap] = useState(null); + const [timelineBleMap, setTimelineBleMap] = useState(null); const [displayedEntries, setDisplayedEntries] = useState(null); const [refreshTime, setRefreshTime] = useState(null); const [isLoading, setIsLoading] = useState('replace'); @@ -102,6 +109,11 @@ const LabelTab = () => { setTimelineLabelMap(newTimelineLabelMap); setTimelineNotesMap(newTimelineNotesMap); + if (appConfig.vehicle_identities?.length) { + const newTimelineBleMap = mapBleScansToTimelineEntries(allEntries, appConfig); + setTimelineBleMap(newTimelineBleMap); + } + applyFilters(timelineMap, newTimelineLabelMap); } catch (e) { displayError(e, t('errors.while-updating-timeline')); @@ -157,6 +169,15 @@ const LabelTab = () => { logDebug(`LabelTab: After updating unprocessedInputs, unprocessedLabels = ${JSON.stringify(unprocessedLabels)}; unprocessedNotes = ${JSON.stringify(unprocessedNotes)}`); + if (appConfig.vehicle_identities?.length) { + await updateUnprocessedBleScans({ + start_ts: pipelineRange.start_ts, + end_ts: Date.now() / 1000, + }); + logDebug(`LabelTab: After updating unprocessedBleScans, + unprocessedBleScans = ${JSON.stringify(unprocessedBleScans)}; + `); + } setPipelineRange(pipelineRange); } catch (e) { displayError(e, t('errors.while-loading-pipeline-range')); @@ -310,6 +331,14 @@ const LabelTab = () => { return chosenLabel ? labelOptionByValue(chosenLabel, labelType) : undefined; }; + /** + * @param tlEntry The trip or place object to get the confirmed mode for + * @returns Confirmed mode, which could be a vehicle identity as determined by Bluetooth scans, + * or the label option from a user-given 'MODE' label, or undefined if neither exists. + */ + const confirmedModeFor = (tlEntry: TimelineEntry) => + timelineBleMap?.[tlEntry._id.$oid] || labelFor(tlEntry, 'MODE'); + function addUserInputToEntry(oid: string, userInput: any, inputType: 'label' | 'note') { const tlEntry = timelineMap?.get(oid); if (!pipelineRange || !tlEntry) @@ -351,6 +380,7 @@ const LabelTab = () => { userInputFor, labelFor, notesFor, + confirmedModeFor, addUserInputToEntry, displayedEntries, filterInputs, diff --git a/www/js/diary/LabelTabContext.ts b/www/js/diary/LabelTabContext.ts index 9e80cccae..791cb4cd5 100644 --- a/www/js/diary/LabelTabContext.ts +++ b/www/js/diary/LabelTabContext.ts @@ -2,6 +2,7 @@ import { createContext } from 'react'; import { TimelineEntry, TimestampRange, UserInputEntry } from '../types/diaryTypes'; import { LabelOption, LabelOptions, MultilabelKey } from '../types/labelTypes'; import { EnketoUserInputEntry } from '../survey/enketo/enketoHelper'; +import { VehicleIdentity } from '../types/appConfigTypes'; export type UserInputMap = { /* if the key here is 'SURVEY', we are in the ENKETO configuration, meaning the user input @@ -34,6 +35,7 @@ type ContextProps = { userInputFor: (tlEntry: TimelineEntry) => UserInputMap | undefined; notesFor: (tlEntry: TimelineEntry) => UserInputEntry[] | undefined; labelFor: (tlEntry: TimelineEntry, labelType: MultilabelKey) => LabelOption | undefined; + confirmedModeFor: (tlEntry: TimelineEntry) => VehicleIdentity | LabelOption | undefined; addUserInputToEntry: (oid: string, userInput: any, inputType: 'label' | 'note') => void; displayedEntries: TimelineEntry[] | null; filterInputs: LabelTabFilter[]; diff --git a/www/js/diary/cards/ModesIndicator.tsx b/www/js/diary/cards/ModesIndicator.tsx index bba65c107..4e68da4de 100644 --- a/www/js/diary/cards/ModesIndicator.tsx +++ b/www/js/diary/cards/ModesIndicator.tsx @@ -3,22 +3,22 @@ import { View, StyleSheet } from 'react-native'; import color from 'color'; import LabelTabContext from '../LabelTabContext'; 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(LabelTabContext); 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/TripCard.tsx b/www/js/diary/cards/TripCard.tsx index 3504bde16..f0f8a1284 100644 --- a/www/js/diary/cards/TripCard.tsx +++ b/www/js/diary/cards/TripCard.tsx @@ -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(LabelTabContext); 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..9627ebcaa 100644 --- a/www/js/diary/details/LabelDetailsScreen.tsx +++ b/www/js/diary/details/LabelDetailsScreen.tsx @@ -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(LabelTabContext); 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..9e117021c 100644 --- a/www/js/diary/details/TripSectionsDescriptives.tsx +++ b/www/js/diary/details/TripSectionsDescriptives.tsx @@ -5,8 +5,8 @@ import useDerivedProperties from '../useDerivedProperties'; import { getBaseModeByKey, getBaseModeByValue } from '../diaryHelper'; import LabelTabContext from '../LabelTabContext'; -const TripSectionsDescriptives = ({ trip, showLabeledMode = false }) => { - const { labelOptions, labelFor } = useContext(LabelTabContext); +const TripSectionsDescriptives = ({ trip, showConfirmedMode = false }) => { + const { labelOptions, labelFor, confirmedModeFor } = useContext(LabelTabContext); 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/timelineHelper.ts b/www/js/diary/timelineHelper.ts index f140f1750..d82adb4fb 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -14,6 +14,9 @@ import { TimestampRange, CompositeTrip, UnprocessedTrip, + BluetoothBleData, + SectionData, + CompositeTripLocation, } from '../types/diaryTypes'; import { getLabelInputDetails, getLabelInputs } from '../survey/multilabel/confirmHelper'; import { LabelOptions } from '../types/labelTypes'; @@ -26,19 +29,14 @@ 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 = [ @@ -175,6 +173,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; + 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 +230,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 +257,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', + }, }; }); } @@ -288,7 +306,10 @@ const dateTime2localdate = (currtime: DateTime, tz: string) => ({ second: currtime.second, }); -function points2TripProps(locationPoints: Array>) { +/** + * @description Given an array of location points, creates an UnprocessedTrip object. + */ +function points2UnprocessedTrip(locationPoints: Array>): UnprocessedTrip { const startPoint = locationPoints[0]; const endPoint = locationPoints[locationPoints.length - 1]; const tripAndSectionId = `unprocessed_${startPoint.data.ts}_${endPoint.data.ts}`; @@ -318,24 +339,51 @@ 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', + trip_id: { $oid: tripAndSectionId }, + }; + + // the complete UnprocessedTrip: baseProps + properties that are unique to the trip, including the section + return { + ...baseProps, + _id: { $oid: tripAndSectionId }, + additions: [], + confidence_threshold: 0, + expectation: { to_label: true }, + inferred_labels: [], + key: 'UNPROCESSED_trip', + locations: locations, + origin_key: 'UNPROCESSED_trip', + sections: [singleSection], user_input: {}, }; } @@ -343,7 +391,11 @@ 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): Promise { const tripStartTransition = trip[0]; const tripEndTransition = trip[1]; const tq = { @@ -385,20 +437,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; @@ -520,12 +563,12 @@ 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(tripTransitions2UnprocessedTrip); return Promise.all(tripFillPromises).then( (rawTripObjs: (UnprocessedTrip | undefined)[]) => { // Now we need to link up the trips. linking unprocessed trips diff --git a/www/js/diary/useDerivedProperties.tsx b/www/js/diary/useDerivedProperties.tsx index fe324ee3f..a6985a8e5 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, @@ -9,9 +9,11 @@ import { getDetectedModes, isMultiDay, } from './diaryHelper'; +import LabelTabContext from './LabelTabContext'; const useDerivedProperties = (tlEntry) => { const imperialConfig = useImperialConfig(); + const { confirmedModeFor } = useContext(LabelTabContext); return useMemo(() => { const beginFmt = tlEntry.start_fmt_time || tlEntry.enter_fmt_time; @@ -21,6 +23,7 @@ const useDerivedProperties = (tlEntry) => { const tlEntryIsMultiDay = isMultiDay(beginFmt, endFmt); return { + confirmedMode: confirmedModeFor(tlEntry), displayDate: getFormattedDate(beginFmt, endFmt), displayStartTime: getLocalTimeString(beginDt), displayEndTime: getLocalTimeString(endDt), @@ -32,7 +35,7 @@ const useDerivedProperties = (tlEntry) => { distanceSuffix: imperialConfig.distanceSuffix, detectedModes: getDetectedModes(tlEntry), }; - }, [tlEntry, imperialConfig]); + }, [tlEntry, imperialConfig, confirmedModeFor(tlEntry)]); }; export default useDerivedProperties; diff --git a/www/js/survey/enketo/UserInputButton.tsx b/www/js/survey/enketo/UserInputButton.tsx index 5817e7ed3..118b687f8 100644 --- a/www/js/survey/enketo/UserInputButton.tsx +++ b/www/js/survey/enketo/UserInputButton.tsx @@ -17,6 +17,7 @@ import EnketoModal from './EnketoModal'; import LabelTabContext from '../../diary/LabelTabContext'; import useAppConfig from '../../useAppConfig'; import { getSurveyForTimelineEntry } from './conditionalSurveys'; +import useDerivedProperties from '../../diary/useDerivedProperties'; type Props = { timelineEntry: any; @@ -29,6 +30,7 @@ const UserInputButton = ({ timelineEntry }: Props) => { const [prevSurveyResponse, setPrevSurveyResponse] = useState(undefined); const [modalVisible, setModalVisible] = useState(false); const { userInputFor, addUserInputToEntry } = useContext(LabelTabContext); + const derivedTripProps = useDerivedProperties(timelineEntry); // which survey will this button launch? const [surveyName, notFilledInLabel] = useMemo(() => { @@ -38,7 +40,7 @@ const UserInputButton = ({ timelineEntry }: Props) => { return ['TripConfirmSurvey', t('diary.choose-survey')]; } // config lists one or more surveys; find which one to use - const s = getSurveyForTimelineEntry(tripLabelConfig, timelineEntry); + const s = getSurveyForTimelineEntry(tripLabelConfig, timelineEntry, derivedTripProps); const lang = i18n.resolvedLanguage || 'en'; return [s?.surveyName, s?.['not-filled-in-label'][lang]]; }, [appConfig, timelineEntry, i18n.resolvedLanguage]); diff --git a/www/js/survey/enketo/conditionalSurveys.ts b/www/js/survey/enketo/conditionalSurveys.ts index 63f9a9b83..607b49431 100644 --- a/www/js/survey/enketo/conditionalSurveys.ts +++ b/www/js/survey/enketo/conditionalSurveys.ts @@ -1,6 +1,6 @@ import { displayError } from '../../plugin/logger'; import { SurveyButtonConfig } from '../../types/appConfigTypes'; -import { TimelineEntry } from '../../types/diaryTypes'; +import { DerivedProperties, TimelineEntry } from '../../types/diaryTypes'; import { Position } from 'geojson'; const conditionalSurveyFunctions = { @@ -31,6 +31,7 @@ const scopedEval = (script: string, scope: { [k: string]: any }) => export function getSurveyForTimelineEntry( tripLabelConfig: SurveyButtonConfig | SurveyButtonConfig[], tlEntry: TimelineEntry, + derivedProperties: DerivedProperties, ) { // if only one survey is given, just return it if (!(tripLabelConfig instanceof Array)) return tripLabelConfig; @@ -40,6 +41,7 @@ export function getSurveyForTimelineEntry( if (!surveyConfig.showsIf) return surveyConfig; // survey shows unconditionally const scope = { ...tlEntry, + ...derivedProperties, ...conditionalSurveyFunctions, }; try { diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index da802d2e8..b1460194e 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -1,7 +1,18 @@ 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, @@ -11,6 +22,7 @@ import { TimelineLabelMap, TimelineNotesMap } from '../diary/LabelTabContext'; 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; @@ -340,3 +352,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/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts index 5bfedce03..d5a15fe4a 100644 --- a/www/js/types/appConfigTypes.ts +++ b/www/js/types/appConfigTypes.ts @@ -2,6 +2,7 @@ // 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: { @@ -9,6 +10,10 @@ export type AppConfig = { surveys: EnketoSurveyConfig; buttons?: SurveyButtonsConfig; }; + vehicle_identities?: VehicleIdentity[]; + tracking?: { + bluetooth_only: boolean; + }; reminderSchemes?: ReminderSchemesConfig; [k: string]: any; // TODO fill in all the other fields }; @@ -57,6 +62,24 @@ export type SurveyButtonsConfig = { | 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 }; diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 75c43b2d6..9757e95cf 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -3,9 +3,10 @@ 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 { 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 +46,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 +57,27 @@ 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 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 + 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) @@ -98,16 +97,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 +130,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 +166,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 +187,25 @@ 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; 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 +214,7 @@ export type SectionData = { distance: number; }; -// used in timelineHelper's `transitionTrip2TripObj` +// used in timelineHelper's `transitionTrip2UnprocessedTrip` export type FilteredLocation = { accuracy: number; altitude: number;