diff --git a/www/__tests__/confirmHelper.test.ts b/www/__tests__/confirmHelper.test.ts new file mode 100644 index 000000000..17c23a972 --- /dev/null +++ b/www/__tests__/confirmHelper.test.ts @@ -0,0 +1,170 @@ +import { mockLogger } from '../__mocks__/globalMocks'; +import * as CommHelper from '../js/commHelper'; +import { + baseLabelInputDetails, + getLabelInputDetails, + getLabelOptions, + inferFinalLabels, + labelInputDetailsForTrip, + labelKeyToReadable, + labelKeyToRichMode, + labelOptionByValue, + readableLabelToKey, + verifiabilityForTrip, +} from '../js/survey/multilabel/confirmHelper'; + +import initializedI18next from '../js/i18nextInit'; +window['i18next'] = initializedI18next; +mockLogger(); + +const fakeAppConfig = { + label_options: 'json/label-options.json.sample', +}; +const fakeAppConfigWithModeOfStudy = { + ...fakeAppConfig, + intro: { + mode_studied: 'walk', + }, +}; +const fakeDefaultLabelOptions = { + MODE: [ + { value: 'walk', baseMode: 'WALKING', met_equivalent: 'WALKING', kgCo2PerKm: 0 }, + { value: 'bike', baseMode: 'BICYCLING', met_equivalent: 'BICYCLING', kgCo2PerKm: 0 }, + ], + PURPOSE: [{ value: 'home' }, { value: 'work' }], + REPLACED_MODE: [{ value: 'no_travel' }, { value: 'walk' }, { value: 'bike' }], + translations: { + en: { + walk: 'Walk', + bike: 'Regular Bike', + no_travel: 'No travel', + home: 'Home', + work: 'To Work', + }, + }, +}; + +CommHelper.fetchUrlCached = jest + .fn() + .mockImplementation(() => JSON.stringify(fakeDefaultLabelOptions)); + +describe('confirmHelper', () => { + it('returns labelOptions given an appConfig', async () => { + const labelOptions = await getLabelOptions(fakeAppConfig); + expect(labelOptions).toBeTruthy(); + expect(labelOptions.MODE[0].text).toEqual('Walk'); // translation is filled in + }); + + it('returns base labelInputDetails for a labelUserInput which does not have mode of study', () => { + const fakeLabelUserInput = { + MODE: fakeDefaultLabelOptions.MODE[1], + PURPOSE: fakeDefaultLabelOptions.PURPOSE[0], + }; + const labelInputDetails = labelInputDetailsForTrip( + fakeLabelUserInput, + fakeAppConfigWithModeOfStudy, + ); + expect(labelInputDetails).toEqual(baseLabelInputDetails); + }); + + it('returns full labelInputDetails for a labelUserInput which has the mode of study', () => { + const fakeLabelUserInput = { + MODE: fakeDefaultLabelOptions.MODE[0], // 'walk' is mode of study + PURPOSE: fakeDefaultLabelOptions.PURPOSE[0], + }; + const labelInputDetails = labelInputDetailsForTrip( + fakeLabelUserInput, + fakeAppConfigWithModeOfStudy, + ); + const fullLabelInputDetails = getLabelInputDetails(fakeAppConfigWithModeOfStudy); + expect(labelInputDetails).toEqual(fullLabelInputDetails); + }); + + it(`converts 'other' text to a label key`, () => { + const mode1 = readableLabelToKey(`Scooby Doo Mystery Machine `); + expect(mode1).toEqual('scooby_doo_mystery_machine'); // trailing space is trimmed + const mode2 = readableLabelToKey(`My niece's tricycle . `); + expect(mode2).toEqual(`my_niece's_tricycle_.`); // apostrophe and period are preserved + const purpose1 = readableLabelToKey(`Going to the store to buy 12 eggs.`); + expect(purpose1).toEqual('going_to_the_store_to_buy_12_eggs.'); // numbers are preserved + }); + + it(`converts keys to readable labels`, () => { + const mode1 = labelKeyToReadable(`scooby_doo_mystery_machine`); + expect(mode1).toEqual(`Scooby Doo Mystery Machine`); + const mode2 = labelKeyToReadable(`my_niece's_tricycle_.`); + expect(mode2).toEqual(`My Niece's Tricycle .`); + const purpose1 = labelKeyToReadable(`going_to_the_store_to_buy_12_eggs.`); + expect(purpose1).toEqual(`Going To The Store To Buy 12 Eggs.`); + }); + + it('looks up a rich mode from a label key, or humanizes the label key if there is no rich mode', () => { + const key = 'walk'; + const richMode = labelKeyToRichMode(key); + expect(richMode).toEqual('Walk'); + const key2 = 'scooby_doo_mystery_machine'; + const readableMode = labelKeyToRichMode(key2); + expect(readableMode).toEqual('Scooby Doo Mystery Machine'); + }); + + /* BEGIN: tests for inferences, which are loosely based on the server-side tests from + e-mission-server -> emission/tests/storageTests/TestTripQueries.py -> testExpandFinalLabels() */ + + it('has no final label for a trip with no user labels or inferred labels', () => { + const fakeTrip = {}; + const fakeUserInput = {}; + expect(inferFinalLabels(fakeTrip, fakeUserInput)).toEqual({}); + expect(verifiabilityForTrip(fakeTrip, fakeUserInput)).toEqual('cannot-verify'); + }); + + it('returns a final inference for a trip no user labels and all high-confidence inferred labels', () => { + const fakeTrip = { + inferred_labels: [{ labels: { mode_confirm: 'walk', purpose_confirm: 'exercise' }, p: 0.9 }], + }; + const fakeUserInput = {}; + const final = inferFinalLabels(fakeTrip, fakeUserInput); + expect(final.MODE.value).toEqual('walk'); + expect(final.PURPOSE.value).toEqual('exercise'); + expect(verifiabilityForTrip(fakeTrip, fakeUserInput)).toEqual('can-verify'); + }); + + it('gives no final inference when there are user labels and no inferred labels', () => { + const fakeTrip = {}; + const fakeUserInput = { + MODE: labelOptionByValue('bike', 'MODE'), + PURPOSE: labelOptionByValue('shopping', 'PURPOSE'), + }; + const final = inferFinalLabels(fakeTrip, fakeUserInput); + expect(final.MODE?.value).toBeUndefined(); + expect(final.PURPOSE?.value).toBeUndefined(); + expect(verifiabilityForTrip(fakeTrip, fakeUserInput)).toEqual('already-verified'); + }); + + it('still gives no final inference when there are user labels and high-confidence inferred labels', () => { + const fakeTrip = { + inferred_labels: [{ labels: { mode_confirm: 'walk', purpose_confirm: 'exercise' }, p: 0.9 }], + }; + const fakeUserInput = { + MODE: labelOptionByValue('bike', 'MODE'), + PURPOSE: labelOptionByValue('shopping', 'PURPOSE'), + }; + const final = inferFinalLabels(fakeTrip, fakeUserInput); + expect(final.MODE?.value).toBeUndefined(); + expect(final.PURPOSE?.value).toBeUndefined(); + expect(verifiabilityForTrip(fakeTrip, fakeUserInput)).toEqual('already-verified'); + }); + + it('mixes user input labels with mixed-confidence inferred labels', () => { + const fakeTrip = { + inferred_labels: [ + { labels: { mode_confirm: 'bike', purpose_confirm: 'shopping' }, p: 0.1 }, + { labels: { mode_confirm: 'walk', purpose_confirm: 'exercise' }, p: 0.9 }, + ], + }; + const fakeUserInput = { MODE: labelOptionByValue('bike', 'MODE') }; + const final = inferFinalLabels(fakeTrip, fakeUserInput); + expect(final.MODE.value).toEqual('bike'); + expect(final.PURPOSE.value).toEqual('shopping'); + expect(verifiabilityForTrip(fakeTrip, fakeUserInput)).toEqual('can-verify'); + }); +}); diff --git a/www/__tests__/diaryHelper.test.ts b/www/__tests__/diaryHelper.test.ts index 1ac143334..26ed03a8f 100644 --- a/www/__tests__/diaryHelper.test.ts +++ b/www/__tests__/diaryHelper.test.ts @@ -58,18 +58,29 @@ it('returns true/false is multi day', () => { expect(isMultiDay('', '2023-09-18T00:00:00-09:00')).toBeFalsy(); }); -//created a fake trip with relevant sections by examining log statements +/* fake trips with 'distance' in their section summaries + ('count' and 'duration' are not used bygetDetectedModes) */ let myFakeTrip = { - sections: [ - { sensed_mode_str: 'BICYCLING', distance: 6013.73657416706 }, - { sensed_mode_str: 'WALKING', distance: 715.3078629361006 }, - ], -}; + distance: 6729.0444371031606, + cleaned_section_summary: { + // count: {...} + // duration: {...} + distance: { + BICYCLING: 6013.73657416706, + WALKING: 715.3078629361006, + }, + }, +} as any; + let myFakeTrip2 = { - sections: [ - { sensed_mode_str: 'BICYCLING', distance: 6013.73657416706 }, - { sensed_mode_str: 'BICYCLING', distance: 715.3078629361006 }, - ], + ...myFakeTrip, + inferred_section_summary: { + // count: {...} + // duration: {...} + distance: { + BICYCLING: 6729.0444371031606, + }, + }, }; let myFakeDetectedModes = [ @@ -82,5 +93,5 @@ let myFakeDetectedModes2 = [{ mode: 'BICYCLING', icon: 'bike', color: modeColors it('returns the detected modes, with percentages, for a trip', () => { expect(getDetectedModes(myFakeTrip)).toEqual(myFakeDetectedModes); expect(getDetectedModes(myFakeTrip2)).toEqual(myFakeDetectedModes2); - expect(getDetectedModes({})).toEqual([]); // empty trip, no sections, no modes + expect(getDetectedModes({} as any)).toEqual([]); // empty trip, no sections, no modes }); diff --git a/www/__tests__/inputMatcher.test.ts b/www/__tests__/inputMatcher.test.ts index 6033df444..e1d64aff5 100644 --- a/www/__tests__/inputMatcher.test.ts +++ b/www/__tests__/inputMatcher.test.ts @@ -4,15 +4,16 @@ import { validUserInputForDraftTrip, validUserInputForTimelineEntry, getNotDeletedCandidates, - getUserInputForTrip, + getUserInputForTimelineEntry, getAdditionsForTimelineEntry, getUniqueEntries, } from '../js/survey/inputMatcher'; -import { TlEntry, UserInput } from '../js/types/diaryTypes'; +import { CompositeTrip, TimelineEntry, UserInputEntry } from '../js/types/diaryTypes'; describe('input-matcher', () => { - let userTrip: UserInput; - let trip: TlEntry; + let userTrip: UserInputEntry; + let trip: TimelineEntry; + let nextTrip: TimelineEntry; beforeEach(() => { /* @@ -46,9 +47,16 @@ describe('input-matcher', () => { enter_ts: 1437605000, exit_ts: 1437605000, duration: 100, - getNextEntry: jest.fn(), }; - + nextTrip = { + key: 'BAR', + origin_key: 'BAR', + start_ts: 1437606000, + end_ts: 1437607000, + enter_ts: 1437607000, + exit_ts: 1437607000, + duration: 100, + }; // mock Logger window['Logger'] = { log: console.log }; }); @@ -78,7 +86,7 @@ describe('input-matcher', () => { const validTrp = { end_ts: 1437604764, start_ts: 1437601247, - }; + } as CompositeTrip; const validUserInput = validUserInputForDraftTrip(validTrp, userTrip, false); expect(validUserInput).toBeTruthy(); }); @@ -87,7 +95,7 @@ describe('input-matcher', () => { const invalidTrip = { end_ts: 0, start_ts: 0, - }; + } as CompositeTrip; const invalidUserInput = validUserInputForDraftTrip(invalidTrip, userTrip, false); expect(invalidUserInput).toBeFalsy(); }); @@ -96,18 +104,23 @@ describe('input-matcher', () => { // we need valid key and origin_key for validUserInputForTimelineEntry test trip['key'] = 'analysis/confirmed_place'; trip['origin_key'] = 'analysis/confirmed_place'; - const validTimelineEntry = validUserInputForTimelineEntry(trip, userTrip, false); + const validTimelineEntry = validUserInputForTimelineEntry(trip, nextTrip, userTrip, false); expect(validTimelineEntry).toBeTruthy(); }); it('tests validUserInputForTimelineEntry with tlEntry with invalid key and origin_key', () => { const invalidTlEntry = trip; - const invalidTimelineEntry = validUserInputForTimelineEntry(invalidTlEntry, userTrip, false); + const invalidTimelineEntry = validUserInputForTimelineEntry( + invalidTlEntry, + null, + userTrip, + false, + ); expect(invalidTimelineEntry).toBeFalsy(); }); it('tests validUserInputForTimelineEntry with tlEntry with invalie start & end time', () => { - const invalidTlEntry: TlEntry = { + const invalidTlEntry: TimelineEntry = { key: 'analysis/confirmed_place', origin_key: 'analysis/confirmed_place', start_ts: 1, @@ -115,9 +128,13 @@ describe('input-matcher', () => { enter_ts: 1, exit_ts: 1, duration: 1, - getNextEntry: jest.fn(), }; - const invalidTimelineEntry = validUserInputForTimelineEntry(invalidTlEntry, userTrip, false); + const invalidTimelineEntry = validUserInputForTimelineEntry( + invalidTlEntry, + null, + userTrip, + false, + ); expect(invalidTimelineEntry).toBeFalsy(); }); @@ -210,13 +227,13 @@ describe('input-matcher', () => { // make the linst unsorted and then check if userInputWriteThird(latest one) is return output const userInputList = [userInputWriteSecond, userInputWriteThird, userInputWriteFirst]; - const mostRecentEntry = getUserInputForTrip(trip, {}, userInputList); + const mostRecentEntry = getUserInputForTimelineEntry(trip, nextTrip, userInputList); expect(mostRecentEntry).toMatchObject(userInputWriteThird); }); it('tests getUserInputForTrip with invalid userInputList', () => { const userInputList = undefined; - const mostRecentEntry = getUserInputForTrip(trip, {}, userInputList); + const mostRecentEntry = getUserInputForTimelineEntry(trip, nextTrip, userInputList); expect(mostRecentEntry).toBe(undefined); }); @@ -226,13 +243,13 @@ describe('input-matcher', () => { trip['origin_key'] = 'analysis/confirmed_place'; // check if the result keep the all valid userTrip items - const matchingAdditions = getAdditionsForTimelineEntry(trip, additionsList); + const matchingAdditions = getAdditionsForTimelineEntry(trip, nextTrip, additionsList); expect(matchingAdditions).toHaveLength(5); }); it('tests getAdditionsForTimelineEntry with invalid additionsList', () => { const additionsList = undefined; - const matchingAdditions = getAdditionsForTimelineEntry(trip, additionsList); + const matchingAdditions = getAdditionsForTimelineEntry(trip, nextTrip, additionsList); expect(matchingAdditions).toMatchObject([]); }); diff --git a/www/i18n/en.json b/www/i18n/en.json index af549d05e..aa41988f3 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -399,6 +399,7 @@ "registration-check-token": "User registration error. Please check your token and try again.", "not-registered-cant-contact": "User is not registered, so the server cannot be contacted.", "while-initializing-label": "While initializing Label tab: ", + "while-loading-pipeline-range": "Error while loading pipeline range", "while-populating-composite": "Error while populating composite trips", "while-loading-another-week": "Error while loading travel of {{when}} week", "while-loading-specific-week": "Error while loading travel for the week of {{day}}", diff --git a/www/index.js b/www/index.js index 83d73adbb..bd353618b 100644 --- a/www/index.js +++ b/www/index.js @@ -11,12 +11,9 @@ import './js/controllers.js'; import './js/services.js'; import './js/i18n-utils.js'; import './js/main.js'; -import './js/survey/multilabel/multi-label-ui.js'; import './js/diary.js'; import './js/diary/services.js'; import './js/survey/enketo/answer.js'; -import './js/survey/enketo/enketo-trip-button.js'; -import './js/survey/enketo/enketo-add-note-button.js'; import './js/control/emailService.js'; import './js/metrics-factory.js'; import './js/metrics-mappings.js'; diff --git a/www/js/diary.js b/www/js/diary.js index 7c8294005..c580ad8f2 100644 --- a/www/js/diary.js +++ b/www/js/diary.js @@ -4,10 +4,8 @@ import LabelTab from './diary/LabelTab'; angular .module('emission.main.diary', [ 'emission.main.diary.services', - 'emission.survey.multilabel.buttons', - 'emission.survey.enketo.add-note-button', - 'emission.survey.enketo.trip.button', 'emission.plugin.logger', + 'emission.survey.enketo.answer', ]) .config(function ($stateProvider) { diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index d5fa8a7c5..9556f0ed4 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -18,64 +18,63 @@ import LabelScreenDetails from './details/LabelDetailsScreen'; import { NavigationContainer } from '@react-navigation/native'; import { compositeTrips2TimelineMap, - getAllUnprocessedInputs, - getLocalUnprocessedInputs, - populateCompositeTrips, + updateAllUnprocessedInputs, + updateLocalUnprocessedInputs, + unprocessedLabels, + unprocessedNotes, } from './timelineHelper'; import { fillLocationNamesOfTrip, resetNominatimLimiter } from './addressNamesHelper'; -import { SurveyOptions } from '../survey/survey'; import { getLabelOptions } from '../survey/multilabel/confirmHelper'; import { displayError, displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; import { useTheme } from 'react-native-paper'; import { getPipelineRangeTs } from '../commHelper'; +import { mapInputsToTimelineEntries } from '../survey/inputMatcher'; +import { configuredFilters as multilabelConfiguredFilters } from '../survey/multilabel/infinite_scroll_filters'; +import { configuredFilters as enketoConfiguredFilters } from '../survey/enketo/infinite_scroll_filters'; +import LabelTabContext, { + TimelineLabelMap, + TimelineMap, + TimelineNotesMap, +} from './LabelTabContext'; -let labelPopulateFactory, labelsResultMap, notesResultMap, showPlaces; +let showPlaces; const ONE_DAY = 24 * 60 * 60; // seconds const ONE_WEEK = ONE_DAY * 7; // seconds -export const LabelTabContext = React.createContext(null); const LabelTab = () => { const appConfig = useAppConfig(); const { t } = useTranslation(); const { colors } = useTheme(); - const [surveyOpt, setSurveyOpt] = useState(null); const [labelOptions, setLabelOptions] = useState(null); const [filterInputs, setFilterInputs] = useState([]); const [pipelineRange, setPipelineRange] = useState(null); const [queriedRange, setQueriedRange] = useState(null); - const [timelineMap, setTimelineMap] = useState(null); + const [timelineMap, setTimelineMap] = useState(null); + const [timelineLabelMap, setTimelineLabelMap] = useState(null); + const [timelineNotesMap, setTimelineNotesMap] = useState(null); const [displayedEntries, setDisplayedEntries] = useState(null); const [refreshTime, setRefreshTime] = useState(null); const [isLoading, setIsLoading] = useState('replace'); - const $rootScope = getAngularService('$rootScope'); - const $state = getAngularService('$state'); - const $ionicPopup = getAngularService('$ionicPopup'); - const Logger = getAngularService('Logger'); const Timeline = getAngularService('Timeline'); - const enbs = getAngularService('EnketoNotesButtonService'); // initialization, once the appConfig is loaded useEffect(() => { try { if (!appConfig) return; - const surveyOptKey = appConfig.survey_info['trip-labels']; - const surveyOpt = SurveyOptions[surveyOptKey]; - setSurveyOpt(surveyOpt); showPlaces = appConfig.survey_info?.buttons?.['place-notes']; getLabelOptions(appConfig).then((labelOptions) => setLabelOptions(labelOptions)); - labelPopulateFactory = getAngularService(surveyOpt.service); - const tripSurveyName = appConfig.survey_info?.buttons?.['trip-notes']?.surveyName; - const placeSurveyName = appConfig.survey_info?.buttons?.['place-notes']?.surveyName; - enbs.initConfig(tripSurveyName, placeSurveyName); // 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 tripFilter = surveyOpt.filter; - const allFalseFilters = tripFilter.map((f, i) => ({ + 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 })); @@ -87,17 +86,25 @@ const LabelTab = () => { } }, [appConfig, refreshTime]); - // whenever timelineMap is updated, update the displayedEntries - // according to the active filter + // 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 allEntries = Array.from(timelineMap.values()); + const [newTimelineLabelMap, newTimelineNotesMap] = mapInputsToTimelineEntries( + allEntries, + appConfig, + ); + + setTimelineLabelMap(newTimelineLabelMap); + setTimelineNotesMap(newTimelineNotesMap); + const activeFilter = filterInputs?.find((f) => f.state == true); let entriesToDisplay = allEntries; if (activeFilter) { const entriesAfterFilter = allEntries.filter( - (t) => t.justRepopulated || activeFilter?.filter(t), + (t) => t.justRepopulated || activeFilter?.filter(t, newTimelineLabelMap[t._id.$oid]), ); /* next, filter out any untracked time if the trips that came before and after it are no longer displayed */ @@ -123,17 +130,13 @@ const LabelTab = () => { async function loadTimelineEntries() { try { const pipelineRange = await getPipelineRangeTs(); - [labelsResultMap, notesResultMap] = await getAllUnprocessedInputs( - pipelineRange, - labelPopulateFactory, - enbs, - ); - logDebug(`LabelTab: After reading unprocessedInputs, - labelsResultMap = ${JSON.stringify(labelsResultMap)}; - notesResultMap = ${JSON.stringify(notesResultMap)}`); + await updateAllUnprocessedInputs(pipelineRange, appConfig); + logDebug(`LabelTab: After updating unprocessedInputs, + unprocessedLabels = ${JSON.stringify(unprocessedLabels)}; + unprocessedNotes = ${JSON.stringify(unprocessedNotes)}`); setPipelineRange(pipelineRange); } catch (e) { - displayError(e, 'Error while loading pipeline range'); + displayError(e, t('errors.while-loading-pipeline-range')); setIsLoading(false); } } @@ -219,14 +222,6 @@ const LabelTab = () => { utList = ${JSON.stringify(utList)}`); const tripsRead = ctList.concat(utList); - populateCompositeTrips( - tripsRead, - showPlaces, - labelPopulateFactory, - labelsResultMap, - enbs, - notesResultMap, - ); // Fill place names on a reversed copy of the list so we fill from the bottom up tripsRead .slice() @@ -286,17 +281,10 @@ const LabelTab = () => { logDebug('LabelTab: Repopulating timeline entry with oid ' + oid); if (!timelineMap.has(oid)) return displayErrorMsg('Item with oid: ' + oid + ' not found in timeline'); - const [newLabels, newNotes] = await getLocalUnprocessedInputs( - pipelineRange, - labelPopulateFactory, - enbs, - ); + await updateLocalUnprocessedInputs(pipelineRange, appConfig); const repopTime = new Date().getTime(); logDebug('LabelTab: creating new entry for oid ' + oid + ' with repopTime ' + repopTime); const newEntry = { ...timelineMap.get(oid), justRepopulated: repopTime }; - labelPopulateFactory.populateInputsAndInferences(newEntry, newLabels); - enbs.populateInputsAndInferences(newEntry, newNotes); - logDebug('LabelTab: after repopulating, newEntry = ' + JSON.stringify(newEntry)); const newTimelineMap = new Map(timelineMap).set(oid, newEntry); setTimelineMap(newTimelineMap); @@ -320,9 +308,10 @@ const LabelTab = () => { } const contextVals = { - surveyOpt, labelOptions, timelineMap, + timelineLabelMap, + timelineNotesMap, displayedEntries, filterInputs, setFilterInputs, diff --git a/www/js/diary/LabelTabContext.ts b/www/js/diary/LabelTabContext.ts new file mode 100644 index 000000000..24d7ade41 --- /dev/null +++ b/www/js/diary/LabelTabContext.ts @@ -0,0 +1,39 @@ +import { createContext } from 'react'; +import { TimelineEntry, UserInputEntry } from '../types/diaryTypes'; +import { LabelOption } from '../survey/multilabel/confirmHelper'; + +export type TimelineMap = Map; +export type TimelineLabelMap = { + [k: string]: { + /* if the key here is 'SURVEY', we are in the ENKETO configuration, meaning the user input + value is a raw survey response */ + SURVEY?: UserInputEntry; + /* all other keys, (e.g. 'MODE', 'PURPOSE') are from the MULTILABEL configuration + and use a LabelOption for the user input value */ + MODE?: LabelOption; + PURPOSE?: LabelOption; + REPLACED_MODE?: LabelOption; + }; +}; +export type TimelineNotesMap = { + [k: string]: UserInputEntry[]; +}; + +type ContextProps = { + labelOptions: any; + timelineMap: TimelineMap; + timelineLabelMap: TimelineLabelMap; + timelineNotesMap: TimelineNotesMap; + displayedEntries: TimelineEntry[]; + filterInputs: any; // TODO + setFilterInputs: any; // TODO + queriedRange: any; // TODO + pipelineRange: any; // TODO + isLoading: string | false; + loadAnotherWeek: any; // TODO + loadSpecificWeek: any; // TODO + refresh: any; // TODO + repopulateTimelineEntry: any; // TODO +}; + +export default createContext(null); diff --git a/www/js/diary/cards/ModesIndicator.tsx b/www/js/diary/cards/ModesIndicator.tsx index 37788a789..89366630d 100644 --- a/www/js/diary/cards/ModesIndicator.tsx +++ b/www/js/diary/cards/ModesIndicator.tsx @@ -1,24 +1,25 @@ import React, { useContext } from 'react'; import { View, StyleSheet } from 'react-native'; import color from 'color'; -import { LabelTabContext } from '../LabelTab'; +import LabelTabContext from '../LabelTabContext'; import { logDebug } from '../../plugin/logger'; -import { getBaseModeOfLabeledTrip } from '../diaryHelper'; +import { getBaseModeByValue } from '../diaryHelper'; import { Icon } from '../../components/Icon'; import { Text, useTheme } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; const ModesIndicator = ({ trip, detectedModes }) => { const { t } = useTranslation(); - const { labelOptions } = useContext(LabelTabContext); + const { labelOptions, timelineLabelMap } = 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; - if (trip.userInput.MODE) { - const baseMode = getBaseModeOfLabeledTrip(trip, labelOptions); + const labeledModeForTrip = timelineLabelMap[trip._id.$oid]?.['MODE']; + if (labeledModeForTrip?.value) { + const baseMode = getBaseModeByValue(labeledModeForTrip.value, labelOptions); indicatorBorderColor = baseMode.color; logDebug(`TripCard: got baseMode = ${JSON.stringify(baseMode)}`); modeViews = ( @@ -32,7 +33,7 @@ const ModesIndicator = ({ trip, detectedModes }) => { fontWeight: '500', textDecorationLine: 'underline', }}> - {trip.userInput.MODE.text} + {timelineLabelMap[trip._id.$oid]?.MODE.text} ); diff --git a/www/js/diary/cards/PlaceCard.tsx b/www/js/diary/cards/PlaceCard.tsx index a351f696f..52ad37c44 100644 --- a/www/js/diary/cards/PlaceCard.tsx +++ b/www/js/diary/cards/PlaceCard.tsx @@ -6,7 +6,7 @@ PlaceCards use the blueish 'place' theme flavor. */ -import React from 'react'; +import React, { useContext } from 'react'; import { View, StyleSheet } from 'react-native'; import { Text } from 'react-native-paper'; import useAppConfig from '../../useAppConfig'; @@ -17,10 +17,12 @@ import { DiaryCard, cardStyles } from './DiaryCard'; import { useAddressNames } from '../addressNamesHelper'; import useDerivedProperties from '../useDerivedProperties'; import StartEndLocations from '../components/StartEndLocations'; +import LabelTabContext from '../LabelTabContext'; type Props = { place: { [key: string]: any } }; const PlaceCard = ({ place }: Props) => { const appConfig = useAppConfig(); + const { timelineNotesMap } = useContext(LabelTabContext); const { displayStartTime, displayEndTime, displayDate } = useDerivedProperties(place); let [placeDisplayName] = useAddressNames(place); @@ -56,7 +58,7 @@ const PlaceCard = ({ place }: Props) => { - + ); diff --git a/www/js/diary/cards/TripCard.tsx b/www/js/diary/cards/TripCard.tsx index 78ef42fe1..5c598f886 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 '../LabelTab'; +import LabelTabContext from '../LabelTabContext'; import useDerivedProperties from '../useDerivedProperties'; import StartEndLocations from '../components/StartEndLocations'; import ModesIndicator from './ModesIndicator'; @@ -40,8 +40,12 @@ const TripCard = ({ trip }: Props) => { } = useDerivedProperties(trip); let [tripStartDisplayName, tripEndDisplayName] = useAddressNames(trip); const navigation = useNavigation(); - const { surveyOpt, labelOptions } = useContext(LabelTabContext); - const tripGeojson = useGeojsonForTrip(trip, labelOptions, trip?.userInput?.MODE?.value); + const { labelOptions, timelineLabelMap, timelineNotesMap } = useContext(LabelTabContext); + const tripGeojson = useGeojsonForTrip( + trip, + labelOptions, + timelineLabelMap[trip._id.$oid]?.MODE?.value, + ); const isDraft = trip.key.includes('UNPROCESSED'); const flavoredTheme = getTheme(isDraft ? 'draft' : undefined); @@ -100,8 +104,10 @@ const TripCard = ({ trip }: Props) => { {/* mode and purpose buttons / survey button */} - {surveyOpt?.elementTag == 'multilabel' && } - {surveyOpt?.elementTag == 'enketo-trip-button' && ( + {appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL' && ( + + )} + {appConfig?.survey_info?.['trip-labels'] == 'ENKETO' && ( )} @@ -127,9 +133,9 @@ const TripCard = ({ trip }: Props) => { )} - {trip.additionsList?.length != 0 && ( + {timelineNotesMap[trip._id.$oid]?.length && ( - + )} diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx index ed48f89c9..89ff822d4 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 '../LabelTab'; +import LabelTabContext from '../LabelTabContext'; import LeafletView from '../../components/LeafletView'; import { useTranslation } from 'react-i18next'; import MultilabelButtonGroup from '../../survey/multilabel/MultiLabelButtonGroup'; @@ -26,11 +26,13 @@ import { useGeojsonForTrip } from '../timelineHelper'; import TripSectionsDescriptives from './TripSectionsDescriptives'; import OverallTripDescriptives from './OverallTripDescriptives'; import ToggleSwitch from '../../components/ToggleSwitch'; +import useAppConfig from '../../useAppConfig'; const LabelScreenDetails = ({ route, navigation }) => { - const { surveyOpt, timelineMap, labelOptions } = useContext(LabelTabContext); + const { timelineMap, labelOptions, timelineLabelMap } = useContext(LabelTabContext); const { t } = useTranslation(); const { height: windowHeight } = useWindowDimensions(); + const appConfig = useAppConfig(); const { tripId, flavoredTheme } = route.params; const trip = timelineMap.get(tripId); const { colors } = flavoredTheme || useTheme(); @@ -41,7 +43,7 @@ const LabelScreenDetails = ({ route, navigation }) => { const tripGeojson = useGeojsonForTrip( trip, labelOptions, - modesShown == 'labeled' && trip?.userInput?.MODE?.value, + modesShown == 'labeled' && timelineLabelMap[trip._id.$oid]?.MODE?.value, ); const mapOpts = { minZoom: 3, maxZoom: 17 }; @@ -74,10 +76,10 @@ const LabelScreenDetails = ({ route, navigation }) => { style={{ margin: 10, paddingHorizontal: 10, rowGap: 12, borderRadius: 15 }}> {/* MultiLabel or UserInput button, inline on one row */} - {surveyOpt?.elementTag == 'multilabel' && ( - + {appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL' && ( + )} - {surveyOpt?.elementTag == 'enketo-trip-button' && ( + {appConfig?.survey_info?.['trip-labels'] == 'ENKETO' && ( )} @@ -91,7 +93,7 @@ const LabelScreenDetails = ({ route, navigation }) => { {/* If trip is labeled, show a toggle to switch between "Labeled Mode" and "Detected Modes" otherwise, just show "Detected" */} - {trip?.userInput?.MODE?.value ? ( + {timelineLabelMap[trip._id.$oid]?.MODE?.value ? ( setModesShown(v)} value={modesShown} diff --git a/www/js/diary/details/TripSectionsDescriptives.tsx b/www/js/diary/details/TripSectionsDescriptives.tsx index 5bd30fdd5..53aad9d34 100644 --- a/www/js/diary/details/TripSectionsDescriptives.tsx +++ b/www/js/diary/details/TripSectionsDescriptives.tsx @@ -3,11 +3,11 @@ import { View } from 'react-native'; import { Text, useTheme } from 'react-native-paper'; import { Icon } from '../../components/Icon'; import useDerivedProperties from '../useDerivedProperties'; -import { getBaseModeByKey, getBaseModeOfLabeledTrip } from '../diaryHelper'; -import { LabelTabContext } from '../LabelTab'; +import { getBaseModeByKey, getBaseModeByValue } from '../diaryHelper'; +import LabelTabContext from '../LabelTabContext'; const TripSectionsDescriptives = ({ trip, showLabeledMode = false }) => { - const { labelOptions } = useContext(LabelTabContext); + const { labelOptions, timelineLabelMap } = useContext(LabelTabContext); const { displayStartTime, displayTime, @@ -18,13 +18,14 @@ const TripSectionsDescriptives = ({ trip, showLabeledMode = false }) => { const { colors } = useTheme(); + const labeledModeForTrip = timelineLabelMap[trip._id.$oid]?.MODE; 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 && trip?.userInput?.MODE) || !trip.sections?.length) { + if ((showLabeledMode && labeledModeForTrip) || !trip.sections?.length) { let baseMode; - if (showLabeledMode && trip?.userInput?.MODE) { - baseMode = getBaseModeOfLabeledTrip(trip, labelOptions); + if (showLabeledMode && labeledModeForTrip) { + baseMode = getBaseModeByValue(labeledModeForTrip.value, labelOptions); } else { baseMode = getBaseModeByKey('UNPROCESSED'); } @@ -35,7 +36,7 @@ const TripSectionsDescriptives = ({ trip, showLabeledMode = false }) => { distance: formattedDistance, color: baseMode.color, icon: baseMode.icon, - text: showLabeledMode && trip.userInput?.MODE?.text, // label text only shown for labeled trips + text: showLabeledMode && labeledModeForTrip?.text, // label text only shown for labeled trips }, ]; } diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts index 48f40322d..616974b7b 100644 --- a/www/js/diary/diaryHelper.ts +++ b/www/js/diary/diaryHelper.ts @@ -4,6 +4,7 @@ import moment from 'moment'; import { DateTime } from 'luxon'; import { LabelOptions, readableLabelToKey } from '../survey/multilabel/confirmHelper'; +import { CompositeTrip } from '../types/diaryTypes'; export const modeColors = { pink: '#c32e85', // oklch(56% 0.2 350) // e-car @@ -24,7 +25,7 @@ type BaseMode = { }; // parallels the server-side MotionTypes enum: https://github.com/e-mission/e-mission-server/blob/94e7478e627fa8c171323662f951c611c0993031/emission/core/wrapper/motionactivity.py#L12 -type MotionTypeKey = +export type MotionTypeKey = | 'IN_VEHICLE' | 'BICYCLING' | 'ON_FOOT' @@ -64,7 +65,7 @@ const BaseModes: { [k: string]: BaseMode } = { OTHER: { name: 'OTHER', icon: 'pencil-circle', color: modeColors.taupe }, }; -type BaseModeKey = keyof typeof BaseModes; +export type BaseModeKey = keyof typeof BaseModes; /** * @param motionName A string like "WALKING" or "MotionTypes.WALKING" * @returns A BaseMode object containing the name, icon, and color of the motion type @@ -77,13 +78,6 @@ export function getBaseModeByKey( return BaseModes[key] || BaseModes.UNKNOWN; } -export function getBaseModeOfLabeledTrip(trip, labelOptions) { - const modeKey = trip?.userInput?.MODE?.value; - if (!modeKey) return null; // trip has no MODE label - const modeOption = labelOptions?.MODE?.find((opt) => opt.value == modeKey); - return getBaseModeByKey(modeOption?.baseMode || 'OTHER'); -} - export function getBaseModeByValue(value, labelOptions: LabelOptions) { const modeOption = labelOptions?.MODE?.find((opt) => opt.value == value); return getBaseModeByKey(modeOption?.baseMode || 'OTHER'); @@ -155,36 +149,23 @@ export function getFormattedTimeRange(beginFmtTime: string, endFmtTime: string) return endMoment.to(beginMoment, true); } -// Temporary function to avoid repear in getDetectedModes ret val. -const filterRunning = (mode) => (mode == 'MotionTypes.RUNNING' ? 'MotionTypes.WALKING' : mode); - -export function getDetectedModes(trip) { - if (!trip.sections?.length) return []; - - // sum up the distances for each mode, as well as the total distance - let totalDist = 0; - const dists: Record = {}; - trip.sections.forEach((section) => { - const filteredMode = filterRunning(section.sensed_mode_str); - dists[filteredMode] = (dists[filteredMode] || 0) + section.distance; - totalDist += section.distance; - }); - - // sort modes by the distance traveled (descending) - const sortedKeys = Object.entries(dists) - .sort((a, b) => b[1] - a[1]) - .map((e) => e[0]); - let sectionPcts = sortedKeys.map(function (mode) { - const fract = dists[mode] / totalDist; - return { - mode: mode, +/** + * @param trip A composite trip object + * @returns An array of objects containing the mode key, icon, color, and percentage for each mode + * detected in the trip + */ +export function getDetectedModes(trip: CompositeTrip) { + const sectionSummary = trip?.inferred_section_summary || trip?.cleaned_section_summary; + if (!sectionSummary?.distance) return []; + + return Object.entries(sectionSummary.distance) + .sort(([modeA, distA], [modeB, distB]) => distB - distA) // sort by distance (highest first) + .map(([mode, dist]: [MotionTypeKey, number]) => ({ + mode, icon: getBaseModeByKey(mode)?.icon, color: getBaseModeByKey(mode)?.color || 'black', - pct: Math.round(fract * 100) || '<1', // if rounds to 0%, show <1% - }; - }); - - return sectionPcts; + pct: Math.round((dist / trip.distance) * 100) || '<1', // if rounds to 0%, show <1% + })); } export function getFormattedSectionProperties(trip, ImperialConfig) { diff --git a/www/js/diary/list/DateSelect.tsx b/www/js/diary/list/DateSelect.tsx index 515553851..91f2f4fb5 100644 --- a/www/js/diary/list/DateSelect.tsx +++ b/www/js/diary/list/DateSelect.tsx @@ -9,7 +9,7 @@ import React, { useEffect, useState, useMemo, useContext } from 'react'; import { StyleSheet } from 'react-native'; import moment from 'moment'; -import { LabelTabContext } from '../LabelTab'; +import LabelTabContext from '../LabelTabContext'; import { DatePickerModal } from 'react-native-paper-dates'; import { Text, Divider, useTheme } from 'react-native-paper'; import i18next from 'i18next'; diff --git a/www/js/diary/list/LabelListScreen.tsx b/www/js/diary/list/LabelListScreen.tsx index 217115938..eb50c05a0 100644 --- a/www/js/diary/list/LabelListScreen.tsx +++ b/www/js/diary/list/LabelListScreen.tsx @@ -4,7 +4,7 @@ import { Appbar, useTheme } from 'react-native-paper'; import DateSelect from './DateSelect'; import FilterSelect from './FilterSelect'; import TimelineScrollList from './TimelineScrollList'; -import { LabelTabContext } from '../LabelTab'; +import LabelTabContext from '../LabelTabContext'; const LabelListScreen = () => { const { diff --git a/www/js/diary/services.js b/www/js/diary/services.js index 92d322f04..a1b238ef8 100644 --- a/www/js/diary/services.js +++ b/www/js/diary/services.js @@ -1,8 +1,6 @@ 'use strict'; import angular from 'angular'; -import { getBaseModeByKey, getBaseModeOfLabeledTrip } from './diaryHelper'; -import { SurveyOptions } from '../survey/survey'; import { getConfig } from '../config/dynamicConfig'; import { getRawEntries } from '../commHelper'; @@ -27,16 +25,6 @@ angular timeline.data.unifiedConfirmsResults = null; timeline.UPDATE_DONE = 'TIMELINE_UPDATE_DONE'; - let manualInputFactory; - $ionicPlatform.ready(function () { - getConfig().then((configObj) => { - const surveyOptKey = configObj.survey_info['trip-labels']; - const surveyOpt = SurveyOptions[surveyOptKey]; - console.log('surveyOpt in services.js is', surveyOpt); - manualInputFactory = $injector.get(surveyOpt.service); - }); - }); - // 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' diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index be6ee1bb3..d6e36c397 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -1,8 +1,11 @@ import moment from 'moment'; import { getAngularService } from '../angular-react-helper'; import { displayError, logDebug } from '../plugin/logger'; -import { getBaseModeByKey, getBaseModeOfLabeledTrip } from './diaryHelper'; +import { getBaseModeByKey, getBaseModeByValue } from './diaryHelper'; import i18next from 'i18next'; +import { UserInputEntry } from '../types/diaryTypes'; +import { getLabelInputDetails, getLabelInputs } from '../survey/multilabel/confirmHelper'; +import { getNotDeletedCandidates, getUniqueEntries } from '../survey/inputMatcher'; const cachedGeojsons = new Map(); /** @@ -17,7 +20,7 @@ export function useGeojsonForTrip(trip, labelOptions, labeledMode?) { let trajectoryColor: string | null; if (labeledMode) { - trajectoryColor = getBaseModeOfLabeledTrip(trip, labelOptions)?.color; + trajectoryColor = getBaseModeByValue(labeledMode, labelOptions)?.color; } logDebug("Reading trip's " + trip.locations.length + ' location points at ' + new Date()); @@ -70,38 +73,11 @@ export function compositeTrips2TimelineMap(ctList: any[], unpackPlaces?: boolean return timelineEntriesMap; } -export function populateCompositeTrips( - ctList, - showPlaces, - labelsFactory, - labelsResultMap, - notesFactory, - notesResultMap, -) { - try { - ctList.forEach((ct, i) => { - if (showPlaces && ct.start_confirmed_place) { - const cp = ct.start_confirmed_place; - cp.getNextEntry = () => ctList[i]; - labelsFactory.populateInputsAndInferences(cp, labelsResultMap); - notesFactory.populateInputsAndInferences(cp, notesResultMap); - } - if (showPlaces && ct.end_confirmed_place) { - const cp = ct.end_confirmed_place; - cp.getNextEntry = () => ctList[i + 1]; - labelsFactory.populateInputsAndInferences(cp, labelsResultMap); - notesFactory.populateInputsAndInferences(cp, notesResultMap); - ct.getNextEntry = () => cp; - } else { - ct.getNextEntry = () => ctList[i + 1]; - } - labelsFactory.populateInputsAndInferences(ct, labelsResultMap); - notesFactory.populateInputsAndInferences(ct, notesResultMap); - }); - } catch (e) { - displayError(e, i18next.t('errors.while-populating-composite')); - } -} +/* '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) */ +export let unprocessedLabels: { [key: string]: UserInputEntry[] } = {}; +/* 'NOTES' are 1:n - each trip or place can have any number of notes */ +export let unprocessedNotes: UserInputEntry[] = []; const getUnprocessedInputQuery = (pipelineRange) => ({ key: 'write_ts', @@ -109,15 +85,23 @@ const getUnprocessedInputQuery = (pipelineRange) => ({ endTs: moment().unix() + 10, }); -function getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, notesPromises) { - return Promise.all([...labelsPromises, ...notesPromises]).then((comboResults) => { - const labelsConfirmResults = {}; - const notesConfirmResults = {}; +function updateUnprocessedInputs(labelsPromises, notesPromises, appConfig) { + Promise.all([...labelsPromises, ...notesPromises]).then((comboResults) => { const labelResults = comboResults.slice(0, labelsPromises.length); - const notesResults = comboResults.slice(labelsPromises.length); - labelsFactory.processManualInputs(labelResults, labelsConfirmResults); - notesFactory.processManualInputs(notesResults, notesConfirmResults); - return [labelsConfirmResults, notesConfirmResults]; + const notesResults = comboResults.slice(labelsPromises.length).flat(2); + // fill in the unprocessedLabels object with the labels we just read + labelResults.forEach((r, i) => { + if (appConfig.survey_info?.['trip-labels'] == 'ENKETO') { + unprocessedLabels['SURVEY'] = r; + } else { + unprocessedLabels[getLabelInputs()[i]] = r; + } + }); + // merge the notes we just read into the existing unprocessedNotes, removing duplicates + const combinedNotes = [...unprocessedNotes, ...notesResults]; + unprocessedNotes = combinedNotes.filter( + (note, i, self) => self.findIndex((n) => n.metadata.write_ts == note.metadata.write_ts) == i, + ); }); } @@ -126,21 +110,19 @@ function getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, note * pipeline range and have not yet been pushed to the server. * @param pipelineRange an object with start_ts and end_ts representing the range of time * for which travel data has been processed through the pipeline on the server - * @param labelsFactory the Angular factory for processing labels (MultilabelService or - * EnketoTripButtonService) - * @param notesFactory the Angular factory for processing notes (EnketoNotesButtonService) + * @param appConfig the app configuration * @returns Promise an array with 1) results for labels and 2) results for notes */ -export function getLocalUnprocessedInputs(pipelineRange, labelsFactory, notesFactory) { +export async function updateLocalUnprocessedInputs(pipelineRange, appConfig) { const BEMUserCache = window['cordova'].plugins.BEMUserCache; const tq = getUnprocessedInputQuery(pipelineRange); - const labelsPromises = labelsFactory.MANUAL_KEYS.map((key) => - BEMUserCache.getMessagesForInterval(key, tq, true).then(labelsFactory.extractResult), + const labelsPromises = keysForLabelInputs(appConfig).map((key) => + BEMUserCache.getMessagesForInterval(key, tq, true), ); - const notesPromises = notesFactory.MANUAL_KEYS.map((key) => - BEMUserCache.getMessagesForInterval(key, tq, true).then(notesFactory.extractResult), + const notesPromises = keysForNotesInputs(appConfig).map((key) => + BEMUserCache.getMessagesForInterval(key, tq, true), ); - return getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, notesPromises); + await updateUnprocessedInputs(labelsPromises, notesPromises, appConfig); } /** @@ -148,23 +130,35 @@ export function getLocalUnprocessedInputs(pipelineRange, labelsFactory, notesFac * pipeline range, including those on the phone and that and have been pushed to the server but not yet processed. * @param pipelineRange an object with start_ts and end_ts representing the range of time * for which travel data has been processed through the pipeline on the server - * @param labelsFactory the Angular factory for processing labels (MultilabelService or - * EnketoTripButtonService) - * @param notesFactory the Angular factory for processing notes (EnketoNotesButtonService) + * @param appConfig the app configuration * @returns Promise an array with 1) results for labels and 2) results for notes */ -export function getAllUnprocessedInputs(pipelineRange, labelsFactory, notesFactory) { +export async function updateAllUnprocessedInputs(pipelineRange, appConfig) { const UnifiedDataLoader = getAngularService('UnifiedDataLoader'); const tq = getUnprocessedInputQuery(pipelineRange); - const labelsPromises = labelsFactory.MANUAL_KEYS.map((key) => - UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true).then( - labelsFactory.extractResult, - ), + const labelsPromises = keysForLabelInputs(appConfig).map((key) => + UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true), ); - const notesPromises = notesFactory.MANUAL_KEYS.map((key) => - UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true).then(notesFactory.extractResult), + const notesPromises = keysForNotesInputs(appConfig).map((key) => + UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true), ); - return getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, notesPromises); + await updateUnprocessedInputs(labelsPromises, notesPromises, appConfig); +} + +export function keysForLabelInputs(appConfig) { + if (appConfig.survey_info?.['trip-labels'] == 'ENKETO') { + return ['manual/trip_user_input']; + } else { + return Object.values(getLabelInputDetails(appConfig)).map((inp) => inp.key); + } +} + +function keysForNotesInputs(appConfig) { + const notesKeys = []; + if (appConfig.survey_info?.buttons?.['trip-notes']) notesKeys.push('manual/trip_addition_input'); + if (appConfig.survey_info?.buttons?.['place-notes']) + notesKeys.push('manual/place_addition_input'); + return notesKeys; } /** diff --git a/www/js/survey/enketo/AddNoteButton.tsx b/www/js/survey/enketo/AddNoteButton.tsx index fb35951ee..c4bbcdade 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 moment from 'moment'; -import { LabelTabContext } from '../../diary/LabelTab'; +import LabelTabContext from '../../diary/LabelTabContext'; import EnketoModal from './EnketoModal'; import { displayErrorMsg, logDebug } from '../../plugin/logger'; @@ -23,12 +23,12 @@ type Props = { const AddNoteButton = ({ timelineEntry, notesConfig, storeKey }: Props) => { const { t, i18n } = useTranslation(); const [displayLabel, setDisplayLabel] = useState(''); - const { repopulateTimelineEntry } = useContext(LabelTabContext); + const { repopulateTimelineEntry, timelineNotesMap } = useContext(LabelTabContext); useEffect(() => { let newLabel: string; const localeCode = i18n.resolvedLanguage; - if (notesConfig?.['filled-in-label'] && timelineEntry.additionsList?.length > 0) { + if (notesConfig?.['filled-in-label'] && timelineNotesMap[timelineEntry._id.$oid]?.length > 0) { newLabel = notesConfig?.['filled-in-label']?.[localeCode]; setDisplayLabel(newLabel); } else { @@ -43,7 +43,7 @@ const AddNoteButton = ({ timelineEntry, notesConfig, storeKey }: Props) => { let stop = timelineEntry.end_ts || timelineEntry.exit_ts; // if addition(s) already present on this timeline entry, `begin` where the last one left off - timelineEntry.additionsList.forEach((a) => { + timelineNotesMap[timelineEntry._id.$oid]?.forEach((a) => { if (a.data.end_ts > (begin || 0) && a.data.end_ts != stop) begin = a.data.end_ts; }); diff --git a/www/js/survey/enketo/AddedNotesList.tsx b/www/js/survey/enketo/AddedNotesList.tsx index f1563c4a9..7cc161779 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 moment from 'moment'; import { Modal } from 'react-native'; import { Text, Button, DataTable, Dialog } from 'react-native-paper'; -import { LabelTabContext } from '../../diary/LabelTab'; +import LabelTabContext from '../../diary/LabelTabContext'; import { getFormattedDateAbbr, isMultiDay } from '../../diary/diaryHelper'; import { Icon } from '../../components/Icon'; import EnketoModal from './EnketoModal'; diff --git a/www/js/survey/enketo/UserInputButton.tsx b/www/js/survey/enketo/UserInputButton.tsx index fa2412b73..f2ed4c6e7 100644 --- a/www/js/survey/enketo/UserInputButton.tsx +++ b/www/js/survey/enketo/UserInputButton.tsx @@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'; import { useTheme } from 'react-native-paper'; import { displayErrorMsg, logDebug } from '../../plugin/logger'; import EnketoModal from './EnketoModal'; -import { LabelTabContext } from '../../diary/LabelTab'; +import LabelTabContext from '../../diary/LabelTabContext'; type Props = { timelineEntry: any; @@ -26,20 +26,17 @@ const UserInputButton = ({ timelineEntry }: Props) => { const [prevSurveyResponse, setPrevSurveyResponse] = useState(null); const [modalVisible, setModalVisible] = useState(false); - const { repopulateTimelineEntry } = useContext(LabelTabContext); - - const EnketoTripButtonService = getAngularService('EnketoTripButtonService'); - const etbsSingleKey = EnketoTripButtonService.SINGLE_KEY; + const { repopulateTimelineEntry, timelineLabelMap } = useContext(LabelTabContext); // the label resolved from the survey response, or null if there is no response yet const responseLabel = useMemo( - () => timelineEntry.userInput?.[etbsSingleKey]?.data?.label || null, + () => timelineLabelMap[timelineEntry._id.$oid]?.['SURVEY']?.data?.label || null, [timelineEntry], ); function launchUserInputSurvey() { logDebug('UserInputButton: About to launch survey'); - const prevResponse = timelineEntry.userInput?.[etbsSingleKey]; + const prevResponse = timelineLabelMap[timelineEntry._id.$oid]?.['SURVEY']; setPrevSurveyResponse(prevResponse?.data?.xmlResponse); setModalVisible(true); } diff --git a/www/js/survey/enketo/enketo-add-note-button.js b/www/js/survey/enketo/enketo-add-note-button.js deleted file mode 100644 index 56a41cb04..000000000 --- a/www/js/survey/enketo/enketo-add-note-button.js +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Directive to display a survey to add notes to a timeline entry (trip or place) - */ - -import angular from 'angular'; -import { getAdditionsForTimelineEntry, getUniqueEntries } from '../inputMatcher'; - -angular - .module('emission.survey.enketo.add-note-button', [ - 'emission.services', - 'emission.survey.enketo.answer', - ]) - .factory('EnketoNotesButtonService', function (EnketoSurveyAnswer, Logger, $timeout) { - var enbs = {}; - console.log('Creating EnketoNotesButtonService'); - enbs.SINGLE_KEY = 'NOTES'; - enbs.MANUAL_KEYS = []; - - /** - * Set the keys for trip and/or place additions whichever will be enabled, - * and sets the name of the surveys they will use. - */ - enbs.initConfig = function (tripSurveyName, placeSurveyName) { - enbs.tripSurveyName = tripSurveyName; - if (tripSurveyName) { - enbs.MANUAL_KEYS.push('manual/trip_addition_input'); - } - enbs.placeSurveyName = placeSurveyName; - if (placeSurveyName) { - enbs.MANUAL_KEYS.push('manual/place_addition_input'); - } - }; - - /** - * Embed 'inputType' to the timelineEntry. - */ - enbs.extractResult = function (results) { - const resultsPromises = [ - EnketoSurveyAnswer.filterByNameAndVersion(enbs.timelineEntrySurveyName, results), - ]; - if (enbs.timelineEntrySurveyName != enbs.placeSurveyName) { - resultsPromises.push( - EnketoSurveyAnswer.filterByNameAndVersion(enbs.placeSurveyName, results), - ); - } - return Promise.all(resultsPromises); - }; - - enbs.processManualInputs = function (manualResults, resultMap) { - console.log('ENKETO: processManualInputs with ', manualResults, ' and ', resultMap); - const surveyResults = manualResults.flat(2); - resultMap[enbs.SINGLE_KEY] = surveyResults; - }; - - enbs.populateInputsAndInferences = function (timelineEntry, manualResultMap) { - console.log( - 'ENKETO: populating timelineEntry,', - timelineEntry, - ' with result map', - manualResultMap, - ); - if (angular.isDefined(timelineEntry)) { - // initialize additions array as empty if it doesn't already exist - timelineEntry.additionsList ||= []; - enbs.populateManualInputs(timelineEntry, enbs.SINGLE_KEY, manualResultMap[enbs.SINGLE_KEY]); - } else { - console.log('timelineEntry information not yet bound, skipping fill'); - } - }; - - /** - * Embed 'inputType' to the timelineEntry - * This is the version that is called from the list, which focuses only on - * manual inputs. It also sets some additional values - */ - enbs.populateManualInputs = function (timelineEntry, inputType, inputList) { - // there is not necessarily just one addition per timeline entry, - // so unlike user inputs, we don't want to replace the server entry with - // the unprocessed entry - // but we also don't want to blindly append the unprocessed entry; what - // if it was a deletion. - // what we really want to do is to merge the unprocessed and processed entries - // taking deletion into account - // one option for that is to just combine the processed and unprocessed entries - // into a single list - // note that this is not necessarily the most performant approach, since we will - // be re-matching entries that have already been matched on the server - // but the number of matched entries is likely to be small, so we can live - // with the performance for now - const unprocessedAdditions = getAdditionsForTimelineEntry(timelineEntry, inputList); - const combinedPotentialAdditionList = timelineEntry.additions.concat(unprocessedAdditions); - const dedupedList = getUniqueEntries(combinedPotentialAdditionList); - Logger.log( - 'After combining unprocessed (' + - unprocessedAdditions.length + - ') with server (' + - timelineEntry.additions.length + - ') for a combined (' + - combinedPotentialAdditionList.length + - '), deduped entries are (' + - dedupedList.length + - ')', - ); - - enbs.populateInput(timelineEntry.additionsList, inputType, dedupedList); - // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); - enbs.editingTrip = angular.undefined; - }; - - /** - * Insert the given userInputLabel into the given inputType's slot in inputField - */ - enbs.populateInput = function (timelineEntryField, inputType, userInputEntry) { - if (angular.isDefined(userInputEntry)) { - timelineEntryField.length = 0; - userInputEntry.forEach((ta) => { - timelineEntryField.push(ta); - }); - } - }; - - return enbs; - }); diff --git a/www/js/survey/enketo/enketo-trip-button.js b/www/js/survey/enketo/enketo-trip-button.js deleted file mode 100644 index 89ae9dc29..000000000 --- a/www/js/survey/enketo/enketo-trip-button.js +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Directive to display a survey for each trip - * Assumptions: - * - The directive is embedded within an ion-view - * - The controller for the ion-view has a function called - * 'recomputeListEntries` which modifies the *list* of trips and places - * as necessary. An example with the label view is removing the labeled trips from - * the "toLabel" filter. Function can be a no-op (for example, in the diary view) - * - The view is associated with a state which we can record in the client stats. - * - The directive implements a `verifyTrip` function that can be invoked by - * other components. - */ - -import angular from 'angular'; -import { getUserInputForTrip } from '../inputMatcher'; - -angular - .module('emission.survey.enketo.trip.button', ['emission.survey.enketo.answer']) - .factory('EnketoTripButtonService', function (EnketoSurveyAnswer, Logger, $timeout) { - var etbs = {}; - console.log('Creating EnketoTripButtonService'); - etbs.key = 'manual/trip_user_input'; - etbs.SINGLE_KEY = 'SURVEY'; - etbs.MANUAL_KEYS = [etbs.key]; - - /** - * Embed 'inputType' to the trip. - */ - etbs.extractResult = (results) => - EnketoSurveyAnswer.filterByNameAndVersion('TripConfirmSurvey', results); - - etbs.processManualInputs = function (manualResults, resultMap) { - if (manualResults.length > 1) { - Logger.displayError('Found ' + manualResults.length + ' results expected 1', manualResults); - } else { - console.log('ENKETO: processManualInputs with ', manualResults, ' and ', resultMap); - const surveyResult = manualResults[0]; - resultMap[etbs.SINGLE_KEY] = surveyResult; - } - }; - - etbs.populateInputsAndInferences = function (trip, manualResultMap) { - console.log('ENKETO: populating trip,', trip, ' with result map', manualResultMap); - if (angular.isDefined(trip)) { - // console.log("Expectation: "+JSON.stringify(trip.expectation)); - // console.log("Inferred labels from server: "+JSON.stringify(trip.inferred_labels)); - trip.userInput = {}; - etbs.populateManualInputs( - trip, - trip.getNextEntry(), - etbs.SINGLE_KEY, - manualResultMap[etbs.SINGLE_KEY], - ); - trip.finalInference = {}; - etbs.inferFinalLabels(trip); - etbs.updateVerifiability(trip); - } else { - console.log('Trip information not yet bound, skipping fill'); - } - }; - - /** - * Embed 'inputType' to the trip - * This is the version that is called from the list, which focuses only on - * manual inputs. It also sets some additional values - */ - etbs.populateManualInputs = function (trip, nextTrip, inputType, inputList) { - // Check unprocessed labels first since they are more recent - const unprocessedLabelEntry = getUserInputForTrip(trip, nextTrip, inputList); - var userInputEntry = unprocessedLabelEntry; - if (!angular.isDefined(userInputEntry)) { - userInputEntry = trip.user_input?.[etbs.inputType2retKey(inputType)]; - } - etbs.populateInput(trip.userInput, inputType, userInputEntry); - // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); - etbs.editingTrip = angular.undefined; - }; - - /** - * Insert the given userInputLabel into the given inputType's slot in inputField - */ - etbs.populateInput = function (tripField, inputType, userInputEntry) { - if (angular.isDefined(userInputEntry)) { - tripField[inputType] = userInputEntry; - } - }; - - /** - * Given the list of possible label tuples we've been sent and what the user has already input for the trip, choose the best labels to actually present to the user. - * The algorithm below operationalizes these principles: - * - Never consider label tuples that contradict a green label - * - Obey "conservation of uncertainty": the sum of probabilities after filtering by green labels must equal the sum of probabilities before - * - After filtering, predict the most likely choices at the level of individual labels, not label tuples - * - Never show user yellow labels that have a lower probability of being correct than confidenceThreshold - */ - etbs.inferFinalLabels = function (trip) { - // currently a NOP since we don't have any other trip properties - return; - }; - - /** - * MODE (becomes manual/mode_confirm) becomes mode_confirm - */ - etbs.inputType2retKey = function (inputType) { - return etbs.key.split('/')[1]; - }; - - etbs.updateVerifiability = function (trip) { - // currently a NOP since we don't have any other trip properties - trip.verifiability = 'cannot-verify'; - return; - }; - - return etbs; - }); diff --git a/www/js/survey/enketo/infinite_scroll_filters.ts b/www/js/survey/enketo/infinite_scroll_filters.ts index bc43591e0..5d17b600e 100644 --- a/www/js/survey/enketo/infinite_scroll_filters.ts +++ b/www/js/survey/enketo/infinite_scroll_filters.ts @@ -7,22 +7,9 @@ */ import i18next from 'i18next'; -import { getAngularService } from '../../angular-react-helper'; -const unlabeledCheck = (t) => { - try { - const EnketoTripButtonService = getAngularService('EnketoTripButtonService'); - const etbsSingleKey = EnketoTripButtonService.SINGLE_KEY; - return typeof t.userInput[etbsSingleKey] === 'undefined'; - } catch (e) { - console.log('Error in retrieving EnketoTripButtonService: ', e); - } -}; - -const UNLABELED = { - key: 'unlabeled', - text: i18next.t('diary.unlabeled'), - filter: unlabeledCheck, +const unlabeledCheck = (trip, userInputForTrip) => { + return !userInputForTrip?.['SURVEY']; }; const TO_LABEL = { @@ -31,4 +18,4 @@ const TO_LABEL = { filter: unlabeledCheck, }; -export const configuredFilters = [TO_LABEL, UNLABELED]; +export const configuredFilters = [TO_LABEL]; diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index 6203b5f27..8f05f9639 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -1,13 +1,22 @@ import { logDebug, displayErrorMsg } from '../plugin/logger'; import { DateTime } from 'luxon'; -import { UserInput, Trip, TlEntry } from '../types/diaryTypes'; +import { CompositeTrip, TimelineEntry, UserInputEntry } from '../types/diaryTypes'; +import { keysForLabelInputs, unprocessedLabels, unprocessedNotes } from '../diary/timelineHelper'; +import { + LabelOption, + MultilabelKey, + getLabelInputDetails, + inputType2retKey, + labelOptionByValue, +} from './multilabel/confirmHelper'; +import { TimelineLabelMap, TimelineNotesMap } from '../diary/LabelTabContext'; const EPOCH_MAXIMUM = 2 ** 31 - 1; export const fmtTs = (ts_in_secs: number, tz: string): string | null => DateTime.fromSeconds(ts_in_secs, { zone: tz }).toISO(); -export const printUserInput = (ui: UserInput): string => `${fmtTs( +export const printUserInput = (ui: UserInputEntry): string => `${fmtTs( ui.data.start_ts, ui.metadata.time_zone, )} (${ui.data.start_ts}) -> @@ -16,8 +25,8 @@ ${fmtTs(ui.data.end_ts, ui.metadata.time_zone)} (${ui.data.end_ts}) ${ui.data.la }`; export const validUserInputForDraftTrip = ( - trip: Trip, - userInput: UserInput, + trip: CompositeTrip, + userInput: UserInputEntry, logsEnabled: boolean, ): boolean => { if (logsEnabled) { @@ -41,8 +50,9 @@ export const validUserInputForDraftTrip = ( }; export const validUserInputForTimelineEntry = ( - tlEntry: TlEntry, - userInput: UserInput, + tlEntry: TimelineEntry, + nextEntry: TimelineEntry | null, + userInput: UserInputEntry, logsEnabled: boolean, ): boolean => { if (!tlEntry.origin_key) return false; @@ -93,9 +103,8 @@ export const validUserInputForTimelineEntry = ( let endChecks = userInput.data.end_ts <= entryEnd || userInput.data.end_ts - entryEnd <= 15 * 60; if (startChecks && !endChecks) { - const nextEntryObj = tlEntry.getNextEntry(); - if (nextEntryObj) { - const nextEntryEnd = nextEntryObj.end_ts || nextEntryObj.exit_ts; + if (nextEntry) { + const nextEntryEnd = nextEntry.end_ts || nextEntry.exit_ts; if (!nextEntryEnd) { // the last place will not have an exit_ts endChecks = true; // so we will just skip the end check @@ -127,7 +136,7 @@ export const validUserInputForTimelineEntry = ( }; // parallels get_not_deleted_candidates() in trip_queries.py -export const getNotDeletedCandidates = (candidates: UserInput[]): UserInput[] => { +export const getNotDeletedCandidates = (candidates: UserInputEntry[]): UserInputEntry[] => { console.log('getNotDeletedCandidates called with ' + candidates.length + ' candidates'); // We want to retain all ACTIVE entries that have not been DELETED @@ -143,14 +152,14 @@ export const getNotDeletedCandidates = (candidates: UserInput[]): UserInput[] => return notDeletedActive; }; -export const getUserInputForTrip = ( - trip: TlEntry, - nextTrip: any, - userInputList: UserInput[], -): undefined | UserInput => { +export const getUserInputForTimelineEntry = ( + entry: TimelineEntry, + nextEntry: TimelineEntry | null, + userInputList: UserInputEntry[], +): undefined | UserInputEntry => { const logsEnabled = userInputList?.length < 20; if (userInputList === undefined) { - logDebug('In getUserInputForTrip, no user input, returning undefined'); + logDebug('In getUserInputForTimelineEntry, no user input, returning undefined'); return undefined; } @@ -158,18 +167,18 @@ export const getUserInputForTrip = ( // undefined !== true, so this covers the label view case as well const potentialCandidates = userInputList.filter((ui) => - validUserInputForTimelineEntry(trip, ui, logsEnabled), + validUserInputForTimelineEntry(entry, nextEntry, ui, logsEnabled), ); if (potentialCandidates.length === 0) { if (logsEnabled) - logDebug('In getUserInputForTripStartEnd, no potential candidates, returning []'); + logDebug('In getUserInputForTimelineEntry, no potential candidates, returning []'); return undefined; } if (potentialCandidates.length === 1) { logDebug( - `In getUserInputForTripStartEnd, one potential candidate, returning ${printUserInput( + `In getUserInputForTimelineEntry, one potential candidate, returning ${printUserInput( potentialCandidates[0], )}`, ); @@ -189,9 +198,10 @@ export const getUserInputForTrip = ( // return array of matching additions for a trip or place export const getAdditionsForTimelineEntry = ( - entry: TlEntry, - additionsList: UserInput[], -): UserInput[] => { + entry: TimelineEntry, + nextEntry: TimelineEntry | null, + additionsList: UserInputEntry[], +): UserInputEntry[] => { const logsEnabled = additionsList?.length < 20; if (additionsList === undefined) { @@ -202,7 +212,7 @@ export const getAdditionsForTimelineEntry = ( // 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) => - validUserInputForTimelineEntry(entry, ui, logsEnabled), + validUserInputForTimelineEntry(entry, nextEntry, ui, logsEnabled), ); if (logsEnabled) console.log(`Matching Addition list ${matchingAdditions.map(printUserInput)}`); @@ -248,3 +258,89 @@ export const getUniqueEntries = (combinedList) => { }); return Array.from(uniqueMap.values()); }; + +/** + * @param allEntries the array of timeline entries to map inputs to + * @returns an array containing: (i) an object mapping timeline entry IDs to label inputs, + * and (ii) an object mapping timeline entry IDs to note inputs + */ +export function mapInputsToTimelineEntries( + allEntries: TimelineEntry[], + appConfig, +): [TimelineLabelMap, TimelineNotesMap] { + const timelineLabelMap: TimelineLabelMap = {}; + const timelineNotesMap: TimelineNotesMap = {}; + + 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 + const userInputForTrip = getUserInputForTimelineEntry( + tlEntry, + nextEntry, + unprocessedLabels['SURVEY'], + ); + if (userInputForTrip) { + timelineLabelMap[tlEntry._id.$oid] = { SURVEY: userInputForTrip }; + } else { + let processedSurveyResponse; + for (const key of keysForLabelInputs(appConfig)) { + if (tlEntry.user_input?.[key]) { + processedSurveyResponse = tlEntry.user_input[key]; + break; + } + } + timelineLabelMap[tlEntry._id.$oid] = { SURVEY: 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]: LabelOption } = {}; + Object.keys(getLabelInputDetails()).forEach((label: MultilabelKey) => { + // Check unprocessed labels first since they are more recent + const userInputForTrip = getUserInputForTimelineEntry( + tlEntry, + nextEntry, + unprocessedLabels[label], + ); + if (userInputForTrip) { + labelsForTrip[label] = labelOptionByValue(userInputForTrip.data.label, label); + } else { + const processedLabelValue = tlEntry.user_input?.[inputType2retKey(label)]; + labelsForTrip[label] = labelOptionByValue(processedLabelValue, label); + } + }); + if (Object.keys(labelsForTrip).length) { + timelineLabelMap[tlEntry._id.$oid] = labelsForTrip; + } + } + }); + + if ( + appConfig?.survey_info?.buttons?.['trip-notes'] || + appConfig?.survey_info?.buttons?.['place-notes'] + ) { + // trip-level or place-level notes are configured, so we need to match additions too + allEntries.forEach((tlEntry, i) => { + /* With additions/notes, we can have multiple entries for a single trip or place. + So, we will read both the processed additions and unprocessed additions + and merge them together, removing duplicates. */ + const nextEntry = i + 1 < allEntries.length ? allEntries[i + 1] : null; + const unprocessedAdditions = getAdditionsForTimelineEntry( + tlEntry, + nextEntry, + unprocessedNotes, + ); + const processedAdditions = tlEntry.additions || []; + + const mergedAdditions = getUniqueEntries( + getNotDeletedCandidates([...unprocessedAdditions, ...processedAdditions]), + ); + if (mergedAdditions?.length) { + timelineNotesMap[tlEntry._id.$oid] = mergedAdditions; + } + }); + } + + return [timelineLabelMap, timelineNotesMap]; +} diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index 36a350bd3..a6023f1f4 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -16,14 +16,24 @@ import { } from 'react-native-paper'; import DiaryButton from '../../components/DiaryButton'; import { useTranslation } from 'react-i18next'; -import { LabelTabContext } from '../../diary/LabelTab'; +import LabelTabContext from '../../diary/LabelTabContext'; import { displayErrorMsg, logDebug } from '../../plugin/logger'; -import { getLabelInputDetails, getLabelInputs, readableLabelToKey } from './confirmHelper'; +import { + getLabelInputDetails, + getLabelInputs, + inferFinalLabels, + labelInputDetailsForTrip, + labelKeyToRichMode, + readableLabelToKey, + verifiabilityForTrip, +} from './confirmHelper'; +import useAppConfig from '../../useAppConfig'; const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { const { colors } = useTheme(); const { t } = useTranslation(); - const { repopulateTimelineEntry, labelOptions } = useContext(LabelTabContext); + const appConfig = useAppConfig(); + const { repopulateTimelineEntry, labelOptions, timelineLabelMap } = useContext(LabelTabContext); const { height: windowHeight } = useWindowDimensions(); // modal visible for which input type? (mode or purpose or replaced_mode, null if not visible) @@ -33,14 +43,16 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { const [otherLabel, setOtherLabel] = useState(null); const chosenLabel = useMemo(() => { if (otherLabel != null) return 'other'; - return trip.userInput[modalVisibleFor]?.value; + return timelineLabelMap[trip._id.$oid]?.[modalVisibleFor]?.value; }, [modalVisibleFor, otherLabel]); // to mark 'inferred' labels as 'confirmed'; turn yellow labels blue function verifyTrip() { + const inferredLabelsForTrip = inferFinalLabels(trip, timelineLabelMap[trip._id.$oid]); for (const inputType of getLabelInputs()) { - const inferred = trip.finalInference[inputType]; - if (inferred?.value && !trip.userInput[inputType]) { + const inferred = inferredLabelsForTrip?.[inputType]; + // if the is an inferred label that is not already confirmed, confirm it now by storing it + if (inferred?.value && !timelineLabelMap[trip._id.$oid]?.[inputType]) { store(inputType, inferred.value, false); } } @@ -81,15 +93,17 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { }); } - const inputKeys = Object.keys(trip.inputDetails); + const tripInputDetails = labelInputDetailsForTrip(timelineLabelMap[trip._id.$oid], appConfig); return ( <> - {inputKeys.map((key, i) => { - const input = trip.inputDetails[key]; - const inputIsConfirmed = trip.userInput[input.name]; - const inputIsInferred = trip.finalInference[input.name]; + {Object.keys(tripInputDetails).map((key, i) => { + const input = tripInputDetails[key]; + const inputIsConfirmed = timelineLabelMap[trip._id.$oid]?.[input.name]; + const inputIsInferred = inferFinalLabels(trip, timelineLabelMap[trip._id.$oid])[ + input.name + ]; let fillColor, textColor, borderColor; if (inputIsConfirmed) { fillColor = colors.primary; @@ -114,7 +128,7 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { ); })} - {trip.verifiability === 'can-verify' && ( + {verifiabilityForTrip(trip, timelineLabelMap[trip._id.$oid]) == 'can-verify' && ( = { key: string; }; }; -export type LabelOptions = { - [k in T]: { - value: string; - baseMode: string; - met?: { range: any[]; mets: number }; - met_equivalent?: string; - kgCo2PerKm: number; - text?: string; - }[]; +export type LabelOption = { + value: string; + baseMode: string; + met?: { range: any[]; mets: number }; + met_equivalent?: string; + kgCo2PerKm: number; + text?: string; +}; +export type MultilabelKey = 'MODE' | 'PURPOSE' | 'REPLACED_MODE'; +export type LabelOptions = { + [k in T]: LabelOption[]; } & { translations: { [lang: string]: { [translationKey: string]: string }; @@ -29,8 +31,8 @@ export type LabelOptions; -export let inputDetails: InputDetails<'MODE' | 'PURPOSE' | 'REPLACED_MODE'>; +export let labelOptions: LabelOptions; +export let inputDetails: InputDetails; export async function getLabelOptions(appConfigParam?) { if (appConfigParam) appConfig = appConfigParam; @@ -67,6 +69,9 @@ export async function getLabelOptions(appConfigParam?) { return labelOptions; } +export const labelOptionByValue = (value: string, labelType: string): LabelOption | undefined => + labelOptions[labelType]?.find((o) => o.value == value) || getFakeEntry(value); + export const baseLabelInputDetails = { MODE: { name: 'MODE', @@ -104,6 +109,30 @@ export function getLabelInputDetails(appConfigParam?) { return inputDetails; } +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 + + '. Needs REPLACED_MODE', + ); + return getLabelInputDetails(); + } else { + logDebug( + 'Found trip not labeled with mode of study ' + + appConfig.intro.mode_studied + + ". Doesn't need REPLACED_MODE", + ); + return baseLabelInputDetails; + } + } else { + logDebug('No mode of study, so there is no REPLACED_MODE label option'); + return getLabelInputDetails(); + } +} + export const getLabelInputs = () => Object.keys(getLabelInputDetails()); export const getBaseLabelInputs = () => Object.keys(baseLabelInputDetails); @@ -118,10 +147,87 @@ export const labelKeyToReadable = (otherValue: string) => { export const readableLabelToKey = (otherText: string) => otherText.trim().replace(/ /g, '_').toLowerCase(); -export const getFakeEntry = (otherValue) => ({ - text: labelKeyToReadable(otherValue), - value: otherValue, -}); +export const getFakeEntry = (otherValue): Partial => { + if (!otherValue) return undefined; + return { + text: labelKeyToReadable(otherValue), + value: otherValue, + }; +}; export const labelKeyToRichMode = (labelKey: string) => - labelOptions?.MODE?.find((m) => m.value == labelKey)?.text || labelKeyToReadable(labelKey); + labelOptionByValue(labelKey, 'MODE')?.text || labelKeyToReadable(labelKey); + +/* manual/mode_confirm becomes mode_confirm */ +export const inputType2retKey = (inputType) => getLabelInputDetails()[inputType].key.split('/')[1]; + +export function verifiabilityForTrip(trip, userInputForTrip) { + let allConfirmed = true; + let someInferred = false; + const inputsForTrip = Object.keys(labelInputDetailsForTrip(userInputForTrip)); + for (const inputType of inputsForTrip) { + const finalInference = inferFinalLabels(trip, userInputForTrip)[inputType]; + const confirmed = userInputForTrip[inputType]; + const inferred = finalInference && Object.values(finalInference).some((o) => o); + if (inferred && !confirmed) someInferred = true; + if (!confirmed) allConfirmed = false; + } + return someInferred ? 'can-verify' : allConfirmed ? 'already-verified' : 'cannot-verify'; +} + +export function inferFinalLabels(trip, userInputForTrip) { + // Deep copy the possibility tuples + let labelsList = []; + if (trip.inferred_labels) { + labelsList = JSON.parse(JSON.stringify(trip.inferred_labels)); + } + + // Capture the level of certainty so we can reconstruct it later + const totalCertainty = labelsList.map((item) => item.p).reduce((item, rest) => item + rest, 0); + + // Filter out the tuples that are inconsistent with existing green labels + for (const inputType of getLabelInputs()) { + const userInput = userInputForTrip?.[inputType]; + if (userInput) { + const retKey = inputType2retKey(inputType); + labelsList = labelsList.filter((item) => item.labels[retKey] == userInput.value); + } + } + + const finalInference: { [k in MultilabelKey]?: LabelOption } = {}; + + // Return early with (empty obj) if there are no possibilities left + if (labelsList.length == 0) { + return finalInference; + } else { + // Normalize probabilities to previous level of certainty + const certaintyScalar = + totalCertainty / labelsList.map((item) => item.p).reduce((item, rest) => item + rest); + labelsList.forEach((item) => (item.p *= certaintyScalar)); + + for (const inputType of getLabelInputs()) { + // For each label type, find the most probable value by binning by label value and summing + const retKey = inputType2retKey(inputType); + let valueProbs = new Map(); + for (const tuple of labelsList) { + const labelValue = tuple.labels[retKey]; + if (!valueProbs.has(labelValue)) valueProbs.set(labelValue, 0); + valueProbs.set(labelValue, valueProbs.get(labelValue) + tuple.p); + } + let max = { p: 0, labelValue: undefined }; + for (const [thisLabelValue, thisP] of valueProbs) { + // In the case of a tie, keep the label with earlier first appearance in the labelsList (we used a Map to preserve this order) + if (thisP > max.p) max = { p: thisP, labelValue: thisLabelValue }; + } + + // Display a label as red if its most probable inferred value has a probability less than or equal to the trip's confidence_threshold + // Fails safe if confidence_threshold doesn't exist + if (max.p <= trip.confidence_threshold) max.labelValue = undefined; + + if (max.labelValue) { + finalInference[inputType] = labelOptionByValue(max.labelValue, inputType); + } + } + return finalInference; + } +} diff --git a/www/js/survey/multilabel/infinite_scroll_filters.ts b/www/js/survey/multilabel/infinite_scroll_filters.ts index 28d91d48d..a13d0e48d 100644 --- a/www/js/survey/multilabel/infinite_scroll_filters.ts +++ b/www/js/survey/multilabel/infinite_scroll_filters.ts @@ -7,51 +7,32 @@ */ import i18next from 'i18next'; - -const unlabeledCheck = (t) => { - return t.INPUTS.map((inputType, index) => !t.userInput[inputType]).reduce( - (acc, val) => acc || val, - false, - ); -}; - -const invalidCheck = (t) => { - const retVal = - t.userInput['MODE'] && - t.userInput['MODE'].value === 'pilot_ebike' && - (!t.userInput['REPLACED_MODE'] || - t.userInput['REPLACED_MODE'].value === 'pilot_ebike' || - t.userInput['REPLACED_MODE'].value === 'same_mode'); - return retVal; +import { labelInputDetailsForTrip } from './confirmHelper'; +import { logDebug } from '../../plugin/logger'; + +const unlabeledCheck = (trip, userInputForTrip) => { + const tripInputDetails = labelInputDetailsForTrip(userInputForTrip); + return Object.keys(tripInputDetails) + .map((inputType) => !userInputForTrip?.[inputType]) + .reduce((acc, val) => acc || val, false); }; -const toLabelCheck = (trip) => { - if (trip.expectation) { - console.log(trip.expectation.to_label); - return trip.expectation.to_label && unlabeledCheck(trip); - } else { - return true; - } +const toLabelCheck = (trip, userInputForTrip) => { + logDebug('Expectation: ' + trip.expectation); + if (!trip.expectation) return true; + return trip.expectation.to_label && unlabeledCheck(trip, userInputForTrip); }; const UNLABELED = { key: 'unlabeled', text: i18next.t('diary.unlabeled'), filter: unlabeledCheck, - width: 'col-50', -}; - -const INVALID_EBIKE = { - key: 'invalid_ebike', - text: i18next.t('diary.invalid-ebike'), - filter: invalidCheck, }; const TO_LABEL = { key: 'to_label', text: i18next.t('diary.to-label'), filter: toLabelCheck, - width: 'col-50', }; export const configuredFilters = [TO_LABEL, UNLABELED]; diff --git a/www/js/survey/multilabel/multi-label-ui.js b/www/js/survey/multilabel/multi-label-ui.js deleted file mode 100644 index 8123272e4..000000000 --- a/www/js/survey/multilabel/multi-label-ui.js +++ /dev/null @@ -1,218 +0,0 @@ -import angular from 'angular'; -import { - baseLabelInputDetails, - getBaseLabelInputs, - getFakeEntry, - getLabelInputDetails, - getLabelInputs, - getLabelOptions, -} from './confirmHelper'; -import { getConfig } from '../../config/dynamicConfig'; -import { getUserInputForTrip } from '../inputMatcher'; - -angular - .module('emission.survey.multilabel.buttons', []) - - .factory('MultiLabelService', function ($rootScope, $timeout, $ionicPlatform, Logger) { - var mls = {}; - console.log('Creating MultiLabelService'); - mls.init = function (config) { - Logger.log('About to initialize the MultiLabelService'); - mls.ui_config = config; - getLabelOptions(config).then((inputParams) => (mls.inputParams = inputParams)); - mls.MANUAL_KEYS = Object.values(getLabelInputDetails(config)).map((inp) => inp.key); - Logger.log('finished initializing the MultiLabelService'); - }; - - $ionicPlatform.ready().then(function () { - Logger.log('UI_CONFIG: about to call configReady function in MultiLabelService'); - getConfig() - .then((newConfig) => { - mls.init(newConfig); - }) - .catch((err) => - Logger.displayError('Error while handling config in MultiLabelService', err), - ); - }); - - /** - * Embed 'inputType' to the trip. - */ - - mls.extractResult = (results) => results; - - mls.processManualInputs = function (manualResults, resultMap) { - var mrString = - 'unprocessed manual inputs ' + - manualResults.map(function (item, index) { - return ` ${item.length} ${getLabelInputs()[index]}`; - }); - console.log(mrString); - manualResults.forEach(function (mr, index) { - resultMap[getLabelInputs()[index]] = mr; - }); - }; - - /** - * Embed 'inputType' to the trip - * This is the version that is called from the list, which focuses only on - * manual inputs. It also sets some additional values - */ - mls.populateManualInputs = function (trip, nextTrip, inputType, inputList) { - // Check unprocessed labels first since they are more recent - const unprocessedLabelEntry = getUserInputForTrip(trip, nextTrip, inputList); - var userInputLabel = unprocessedLabelEntry ? unprocessedLabelEntry.data.label : undefined; - if (!angular.isDefined(userInputLabel)) { - userInputLabel = trip.user_input?.[mls.inputType2retKey(inputType)]; - } - mls.populateInput(trip.userInput, inputType, userInputLabel); - // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); - mls.editingTrip = angular.undefined; - }; - - /** - * Insert the given userInputLabel into the given inputType's slot in inputField - */ - mls.populateInput = function (tripField, inputType, userInputLabel) { - if (angular.isDefined(userInputLabel)) { - console.log( - 'populateInput: looking in map of ' + inputType + ' for userInputLabel' + userInputLabel, - ); - var userInputEntry = mls.inputParams[inputType].find((o) => o.value == userInputLabel); - if (!angular.isDefined(userInputEntry)) { - userInputEntry = getFakeEntry(userInputLabel); - mls.inputParams[inputType].push(userInputEntry); - } - console.log( - 'Mapped label ' + userInputLabel + ' to entry ' + JSON.stringify(userInputEntry), - ); - tripField[inputType] = userInputEntry; - } - }; - - /** - * Given the list of possible label tuples we've been sent and what the user has already input for the trip, choose the best labels to actually present to the user. - * The algorithm below operationalizes these principles: - * - Never consider label tuples that contradict a green label - * - Obey "conservation of uncertainty": the sum of probabilities after filtering by green labels must equal the sum of probabilities before - * - After filtering, predict the most likely choices at the level of individual labels, not label tuples - * - Never show user yellow labels that have a lower probability of being correct than confidenceThreshold - */ - mls.inferFinalLabels = function (trip) { - // Deep copy the possibility tuples - let labelsList = []; - if (angular.isDefined(trip.inferred_labels)) { - labelsList = JSON.parse(JSON.stringify(trip.inferred_labels)); - } - - // Capture the level of certainty so we can reconstruct it later - const totalCertainty = labelsList - .map((item) => item.p) - .reduce((item, rest) => item + rest, 0); - - // Filter out the tuples that are inconsistent with existing green labels - for (const inputType of getLabelInputs()) { - const userInput = trip.userInput[inputType]; - if (userInput) { - const retKey = mls.inputType2retKey(inputType); - labelsList = labelsList.filter((item) => item.labels[retKey] == userInput.value); - } - } - - // Red labels if we have no possibilities left - if (labelsList.length == 0) { - for (const inputType of getLabelInputs()) - mls.populateInput(trip.finalInference, inputType, undefined); - } else { - // Normalize probabilities to previous level of certainty - const certaintyScalar = - totalCertainty / labelsList.map((item) => item.p).reduce((item, rest) => item + rest); - labelsList.forEach((item) => (item.p *= certaintyScalar)); - - for (const inputType of getLabelInputs()) { - // For each label type, find the most probable value by binning by label value and summing - const retKey = mls.inputType2retKey(inputType); - let valueProbs = new Map(); - for (const tuple of labelsList) { - const labelValue = tuple.labels[retKey]; - if (!valueProbs.has(labelValue)) valueProbs.set(labelValue, 0); - valueProbs.set(labelValue, valueProbs.get(labelValue) + tuple.p); - } - let max = { p: 0, labelValue: undefined }; - for (const [thisLabelValue, thisP] of valueProbs) { - // In the case of a tie, keep the label with earlier first appearance in the labelsList (we used a Map to preserve this order) - if (thisP > max.p) max = { p: thisP, labelValue: thisLabelValue }; - } - - // Display a label as red if its most probable inferred value has a probability less than or equal to the trip's confidence_threshold - // Fails safe if confidence_threshold doesn't exist - if (max.p <= trip.confidence_threshold) max.labelValue = undefined; - - mls.populateInput(trip.finalInference, inputType, max.labelValue); - } - } - }; - - /* - * Uses either 2 or 3 labels depending on the type of install (program vs. study) - * and the primary mode. - * This used to be in the controller, where it really should be, but we had - * to move it to the service because we need to invoke it from the list view - * as part of filtering "To Label" entries. - * - * TODO: Move it back later after the diary vs. label unification - */ - mls.expandInputsIfNecessary = function (trip) { - console.log('Reading expanding inputs for ', trip); - const inputValue = trip.userInput['MODE'] ? trip.userInput['MODE'].value : undefined; - console.log('Experimenting with expanding inputs for mode ' + inputValue); - if (mls.ui_config.intro.mode_studied) { - if (inputValue == mls.ui_config.intro.mode_studied) { - Logger.log( - 'Found ' + - mls.ui_config.intro.mode_studied + - ' mode in a program, displaying full details', - ); - trip.inputDetails = getLabelInputDetails(); - trip.INPUTS = getLabelInputs(); - } else { - Logger.log( - 'Found non ' + - mls.ui_config.intro.mode_studied + - ' mode in a program, displaying base details', - ); - trip.inputDetails = baseLabelInputDetails; - trip.INPUTS = getBaseLabelInputs(); - } - } else { - Logger.log('study, not program, displaying full details'); - trip.INPUTS = getLabelInputs(); - trip.inputDetails = getLabelInputDetails(); - } - }; - - /** - * MODE (becomes manual/mode_confirm) becomes mode_confirm - */ - mls.inputType2retKey = function (inputType) { - return getLabelInputDetails()[inputType].key.split('/')[1]; - }; - - mls.updateVerifiability = function (trip) { - var allGreen = true; - var someYellow = false; - for (const inputType of trip.INPUTS) { - const green = trip.userInput[inputType]; - const yellow = trip.finalInference[inputType] && !green; - if (yellow) someYellow = true; - if (!green) allGreen = false; - } - trip.verifiability = someYellow - ? 'can-verify' - : allGreen - ? 'already-verified' - : 'cannot-verify'; - }; - - return mls; - }); diff --git a/www/js/survey/survey.ts b/www/js/survey/survey.ts deleted file mode 100644 index a12e65713..000000000 --- a/www/js/survey/survey.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { configuredFilters as multilabelConfiguredFilters } from './multilabel/infinite_scroll_filters'; -import { configuredFilters as enketoConfiguredFilters } from './enketo/infinite_scroll_filters'; - -type SurveyOption = { filter: Array; service: string; elementTag: string }; -export const SurveyOptions: { [key: string]: SurveyOption } = { - MULTILABEL: { - filter: multilabelConfiguredFilters, - service: 'MultiLabelService', - elementTag: 'multilabel', - }, - ENKETO: { - filter: enketoConfiguredFilters, - service: 'EnketoTripButtonService', - elementTag: 'enketo-trip-button', - }, -}; diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 1e71d1cd9..743d75b15 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -1,49 +1,81 @@ -/* These type definitions are a work in progress. The goal is to have a single source of truth for - the types of the trip / place / untracked objects and all properties they contain. - Since we are using TypeScript now, we should strive to enforce type safety and also benefit from - IntelliSense and other IDE features. */ +/* This file provides typings for use in '/diary', including timeline objects (trips and places) + and user input objects. + As much as possible, these types parallel the types used in the server code. */ -// Since it is WIP, these types are not used anywhere yet. +import { BaseModeKey, MotionTypeKey } from '../diary/diaryHelper'; -type ConfirmedPlace = any; // TODO +type ObjectId = { $oid: string }; +type ConfirmedPlace = { + _id: ObjectId; + additions: UserInputEntry[]; + cleaned_place: ObjectId; + ending_trip: ObjectId; + enter_fmt_time: string; // ISO string 2023-10-31T12:00:00.000-04:00 + enter_local_dt: LocalDt; + enter_ts: number; // Unix timestamp + key: string; + location: { type: string; coordinates: number[] }; + origin_key: string; + raw_places: ObjectId[]; + source: string; + user_input: { + /* for keys ending in 'user_input' (e.g. 'trip_user_input'), the server gives us the raw user + input object with 'data' and 'metadata' */ + [k: `${string}user_input`]: UserInputEntry; + /* for keys ending in 'confirm' (e.g. 'mode_confirm'), the server just gives us the user input value + as a string (e.g. 'walk', 'drove_alone') */ + [k: `${string}confirm`]: string; + }; +}; /* These are the properties received from the server (basically matches Python code) This should match what Timeline.readAllCompositeTrips returns (an array of these objects) */ export type CompositeTrip = { - _id: { $oid: string }; - additions: any[]; // TODO - cleaned_section_summary: any; // TODO - cleaned_trip: { $oid: string }; + _id: ObjectId; + additions: UserInputEntry[]; + cleaned_section_summary: SectionSummary; + cleaned_trip: ObjectId; confidence_threshold: number; - confirmed_trip: { $oid: string }; + confirmed_trip: ObjectId; distance: number; duration: number; end_confirmed_place: ConfirmedPlace; end_fmt_time: string; end_loc: { type: string; coordinates: number[] }; end_local_dt: LocalDt; - end_place: { $oid: string }; + end_place: ObjectId; end_ts: number; expectation: any; // TODO "{to_label: boolean}" - expected_trip: { $oid: string }; + expected_trip: ObjectId; inferred_labels: any[]; // TODO - inferred_section_summary: any; // TODO - inferred_trip: { $oid: string }; + inferred_section_summary: SectionSummary; + inferred_trip: ObjectId; key: string; locations: any[]; // TODO origin_key: string; - raw_trip: { $oid: string }; + raw_trip: ObjectId; sections: any[]; // TODO source: string; start_confirmed_place: ConfirmedPlace; start_fmt_time: string; start_loc: { type: string; coordinates: number[] }; start_local_dt: LocalDt; - start_place: { $oid: string }; + start_place: ObjectId; start_ts: number; - user_input: UserInput; + user_input: { + /* for keys ending in 'user_input' (e.g. 'trip_user_input'), the server gives us the raw user + input object with 'data' and 'metadata' */ + [k: `${string}user_input`]: UserInputEntry; + /* for keys ending in 'confirm' (e.g. 'mode_confirm'), the server just gives us the user input value + as a string (e.g. 'walk', 'drove_alone') */ + [k: `${string}confirm`]: string; + }; }; +/* The 'timeline' for a user is a list of their trips and places, + so a 'timeline entry' is either a trip or a place. */ +export type TimelineEntry = ConfirmedPlace | CompositeTrip; + /* 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 = { @@ -59,19 +91,13 @@ export type DerivedProperties = { detectedModes: { mode: string; icon: string; color: string; pct: number | string }[]; }; -/* These are the properties that are still filled in by some kind of 'populate' mechanism. - It would simplify the codebase to just compute them where they're needed - (using memoization when apt so performance is not impacted). */ -export type PopulatedTrip = CompositeTrip & { - additionsList?: any[]; // TODO - finalInference?: any; // TODO - geojson?: any; // TODO - getNextEntry?: () => PopulatedTrip | ConfirmedPlace; - userInput?: UserInput; - verifiability?: string; +export type SectionSummary = { + count: { [k: MotionTypeKey | BaseModeKey]: number }; + distance: { [k: MotionTypeKey | BaseModeKey]: number }; + duration: { [k: MotionTypeKey | BaseModeKey]: number }; }; -export type UserInput = { +export type UserInputEntry = { data: { end_ts: number; start_ts: number; @@ -102,19 +128,3 @@ export type LocalDt = { year: number; timezone: string; }; - -export type Trip = { - end_ts: number; - start_ts: number; -}; - -export type TlEntry = { - key: string; - origin_key: string; - start_ts: number; - end_ts: number; - enter_ts: number; - exit_ts: number; - duration: number; - getNextEntry?: () => PopulatedTrip | ConfirmedPlace; -};