From 8712380a29af1d748fd4e7bb284353b7393a7e98 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Wed, 25 Oct 2023 15:05:34 -0400 Subject: [PATCH 01/20] use section summary for getDetectedModes https://github.com/e-mission/e-mission-phone/pull/1014#discussion_r1315241966 Confirmed and composite trips have section summaries that are computed on the server. We can use these here instead of using the raw 'sections'. Let's also add type definitions for the section summaries. --- www/js/diary/diaryHelper.ts | 49 ++++++++++++++----------------------- www/js/types/diaryTypes.ts | 12 +++++++-- 2 files changed, 29 insertions(+), 32 deletions(-) diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts index 0b834a485..b12c89738 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 = 'IN_VEHICLE' | 'BICYCLING' | 'ON_FOOT' | 'STILL' | 'UNKNOWN' | 'TILTING' +export type MotionTypeKey = 'IN_VEHICLE' | 'BICYCLING' | 'ON_FOOT' | 'STILL' | 'UNKNOWN' | 'TILTING' | 'WALKING' | 'RUNNING' | 'NONE' | 'STOPPED_WHILE_IN_VEHICLE' | 'AIR_OR_HSR'; const BaseModes: {[k: string]: BaseMode} = { @@ -54,7 +55,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 @@ -138,37 +139,25 @@ export function getFormattedTimeRange(beginFmtTime: string, endFmtTime: string) const beginMoment = moment.parseZone(beginFmtTime); const endMoment = moment.parseZone(endFmtTime); 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/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index b12e58543..f4d53a4a9 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -3,6 +3,8 @@ Since we are using TypeScript now, we should strive to enforce type safety and also benefit from IntelliSense and other IDE features. */ +import { BaseModeKey, MotionTypeKey } from "../diary/diaryHelper"; + // Since it is WIP, these types are not used anywhere yet. type ConfirmedPlace = any; // TODO @@ -12,7 +14,7 @@ type ConfirmedPlace = any; // TODO export type CompositeTrip = { _id: {$oid: string}, additions: any[], // TODO - cleaned_section_summary: any, // TODO + cleaned_section_summary: SectionSummary, cleaned_trip: {$oid: string}, confidence_threshold: number, confirmed_trip: {$oid: string}, @@ -27,7 +29,7 @@ export type CompositeTrip = { expectation: any, // TODO "{to_label: boolean}" expected_trip: {$oid: string}, inferred_labels: any[], // TODO - inferred_section_summary: any, // TODO + inferred_section_summary: SectionSummary, inferred_trip: {$oid: string}, key: string, locations: any[], // TODO @@ -71,6 +73,12 @@ export type PopulatedTrip = CompositeTrip & { verifiability?: string, } +export type SectionSummary = { + count: {[k: MotionTypeKey | BaseModeKey]: number}, + distance: {[k: MotionTypeKey | BaseModeKey]: number}, + duration: {[k: MotionTypeKey | BaseModeKey]: number}, +} + export type UserInput = { data: { end_ts: number, From f7716cf791fbec89625cdb7d168ab8b148ab3f38 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Sun, 29 Oct 2023 19:49:32 -0400 Subject: [PATCH 02/20] refactor filters: prep to remove buttons services Preparing to remove the buttons services (https://github.com/e-mission/e-mission-phone/pull/1086), the filter functions need to be tweaked. `user_input` will no longer be stored on the trip object, it will be accessible in the LabelTabContext, not from these functions, so it will be passed in as a second param to these functions as userInputForTrip. Also, we'll remove the surveyOpt variable from LabelTabContext since (i) it can just be read directly from appConfig and (ii) we don't want to keep more variables than necessary in LabelTabContext to keep it from getting too cluttered. - the INVALID_EBIKE filter (along with invalidCheck) was removed as it is not used - logic for toLabelCheck was written more concisely - 'width' is no longer needed on the configuredFilters - this was for the old UI --- www/js/diary/LabelTab.tsx | 14 ++--- www/js/diary/cards/TripCard.tsx | 11 ++-- www/js/diary/details/LabelDetailsScreen.tsx | 13 ++-- .../survey/enketo/infinite_scroll_filters.ts | 29 +++------ .../multilabel/infinite_scroll_filters.ts | 59 +++++++------------ 5 files changed, 48 insertions(+), 78 deletions(-) diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index f4677766d..b67dec542 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -18,7 +18,6 @@ import LabelScreenDetails from "./details/LabelDetailsScreen"; import { NavigationContainer } from "@react-navigation/native"; import { compositeTrips2TimelineMap, getAllUnprocessedInputs, getLocalUnprocessedInputs, populateCompositeTrips } from "./timelineHelper"; import { fillLocationNamesOfTrip, resetNominatimLimiter } from "./addressNamesHelper"; -import { SurveyOptions } from "../survey/survey"; import { getLabelOptions } from "../survey/multilabel/confirmHelper"; import { displayError } from "../plugin/logger"; import { useTheme } from "react-native-paper"; @@ -34,7 +33,6 @@ const LabelTab = () => { 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); @@ -54,9 +52,6 @@ const LabelTab = () => { // initialization, once the appConfig is loaded useEffect(() => { 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); @@ -68,8 +63,11 @@ const LabelTab = () => { // 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 })); setFilterInputs(allFalseFilters); @@ -86,7 +84,7 @@ const LabelTab = () => { let entriesToDisplay = allEntries; if (activeFilter) { const entriesAfterFilter = allEntries.filter( - t => t.justRepopulated || activeFilter?.filter(t) + t => t.justRepopulated || activeFilter?.filter(t, t.user_input) ); /* next, filter out any untracked time if the trips that came before and after it are no longer displayed */ diff --git a/www/js/diary/cards/TripCard.tsx b/www/js/diary/cards/TripCard.tsx index 08e02bca4..9aeb4a61e 100644 --- a/www/js/diary/cards/TripCard.tsx +++ b/www/js/diary/cards/TripCard.tsx @@ -34,7 +34,7 @@ const TripCard = ({ trip }: Props) => { distanceSuffix, displayTime, detectedModes } = useDerivedProperties(trip); let [ tripStartDisplayName, tripEndDisplayName ] = useAddressNames(trip); const navigation = useNavigation(); - const { surveyOpt, labelOptions } = useContext(LabelTabContext); + const { labelOptions } = useContext(LabelTabContext); const tripGeojson = useGeojsonForTrip(trip, labelOptions, trip?.userInput?.MODE?.value); const isDraft = trip.key.includes('UNPROCESSED'); @@ -70,10 +70,11 @@ const TripCard = ({ trip }: Props) => { displayEndName={tripEndDisplayName} /> {/* mode and purpose buttons / survey button */} - {surveyOpt?.elementTag == 'multilabel' && - } - {surveyOpt?.elementTag == 'enketo-trip-button' - && } + {appConfig?.survey_info?.['trip-labels'] == 'ENKETO' ? ( + + ) : ( + + )} {/* left panel */} diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx index ffed9a300..b25c8e50c 100644 --- a/www/js/diary/details/LabelDetailsScreen.tsx +++ b/www/js/diary/details/LabelDetailsScreen.tsx @@ -18,12 +18,14 @@ 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 } = 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(); @@ -51,10 +53,11 @@ const LabelScreenDetails = ({ route, navigation }) => { style={{margin: 10, paddingHorizontal: 10, rowGap: 12, borderRadius: 15 }}> {/* MultiLabel or UserInput button, inline on one row */} - {surveyOpt?.elementTag == 'multilabel' && - } - {surveyOpt?.elementTag == 'enketo-trip-button' - && } + {appConfig?.survey_info?.['trip-labels'] == 'ENKETO' ? ( + + ) : ( + + )} {/* Full-size Leaflet map, with zoom controls */} diff --git a/www/js/survey/enketo/infinite_scroll_filters.ts b/www/js/survey/enketo/infinite_scroll_filters.ts index 98eba65db..363cfaa85 100644 --- a/www/js/survey/enketo/infinite_scroll_filters.ts +++ b/www/js/survey/enketo/infinite_scroll_filters.ts @@ -7,32 +7,17 @@ */ 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 = { - key: "to_label", - text: i18next.t("diary.to-label"), - filter: unlabeledCheck + key: "to_label", + text: i18next.t("diary.to-label"), + filter: unlabeledCheck, } export const configuredFilters = [ - TO_LABEL, - UNLABELED -]; \ No newline at end of file + TO_LABEL, +]; diff --git a/www/js/survey/multilabel/infinite_scroll_filters.ts b/www/js/survey/multilabel/infinite_scroll_filters.ts index 8d71266d9..62fe2cd20 100644 --- a/www/js/survey/multilabel/infinite_scroll_filters.ts +++ b/www/js/survey/multilabel/infinite_scroll_filters.ts @@ -7,52 +7,35 @@ */ 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 + key: "unlabeled", + text: i18next.t("diary.unlabeled"), + filter: unlabeledCheck, } const TO_LABEL = { - key: "to_label", - text: i18next.t("diary.to-label"), - filter: toLabelCheck, - width: "col-50" + key: "to_label", + text: i18next.t("diary.to-label"), + filter: toLabelCheck, } export const configuredFilters = [ - TO_LABEL, - UNLABELED -]; \ No newline at end of file + TO_LABEL, + UNLABELED, +]; From 4d48aeebd2b1734e3d08dc5de6726f1faf4499bc Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Sun, 29 Oct 2023 20:11:58 -0400 Subject: [PATCH 03/20] map user inputs instead of populating tlEntry objs This is a major refactor of how we handle user inputs that removes the need for having separate button services (https://github.com/e-mission/e-mission-phone/pull/1086) Instead of populating fields called "userInput" and "additionsList" on trip/place objects, we will store all inputs of each kind in a single object mapping trip/place IDs to inputs. These will be the single source of truth for what inputs are matched to what trips/places and will remove the need for populating/repopulating trips. These maps will be accessible in LabelTabContext and read downstream wherever user inputs need to be read. Specific notes on the changes: - In LabelTab, matching inputs now happens not as a part of 'populating' trips/places, but in a useEffect so that whenever timelineMap is updated, timelineLabelMap and timelineNotesMap are also updated. - in timelineHelper, we read unprocessedInputs from local and/or server and instead of returning them from 'get' methods, we'll cache them here in timelineHelper and update those caches with 'update' methods. - Type definition for UserInput renamed to UnprocessedUserInput to avoid confusion - this only applies to unprocessed inputs and is not necessarily the structure that processed user inputs will have - in diaryHelper, getBaseModeOfLabeledTrip was removed since it was basically just a shortcut to getBaseModeByValue with a trip as param instead of its userInput. All usages replaced with getBaseModeByValue --- www/__tests__/inputMatcher.test.ts | 8 +- www/js/diary.js | 6 +- www/js/diary/LabelTab.tsx | 52 +++++---- www/js/diary/cards/ModesIndicator.tsx | 11 +- www/js/diary/cards/PlaceCard.tsx | 6 +- www/js/diary/cards/TripCard.tsx | 8 +- www/js/diary/details/LabelDetailsScreen.tsx | 6 +- .../details/TripSectionsDescriptives.tsx | 13 +-- www/js/diary/diaryHelper.ts | 7 -- www/js/diary/services.js | 12 --- www/js/diary/timelineHelper.ts | 92 ++++++++++------ www/js/survey/enketo/AddNoteButton.tsx | 6 +- www/js/survey/enketo/UserInputButton.tsx | 9 +- www/js/survey/inputMatcher.ts | 68 ++++++++++-- .../multilabel/MultiLabelButtonGroup.tsx | 24 +++-- www/js/survey/multilabel/confirmHelper.ts | 101 ++++++++++++++++-- www/js/types/diaryTypes.ts | 7 +- 17 files changed, 294 insertions(+), 142 deletions(-) diff --git a/www/__tests__/inputMatcher.test.ts b/www/__tests__/inputMatcher.test.ts index ac14a506b..3179c9700 100644 --- a/www/__tests__/inputMatcher.test.ts +++ b/www/__tests__/inputMatcher.test.ts @@ -8,10 +8,10 @@ import { getAdditionsForTimelineEntry, getUniqueEntries } from '../js/survey/inputMatcher'; -import { TlEntry, UserInput } from '../js/types/diaryTypes'; +import { TlEntry, UnprocessedUserInput } from '../js/types/diaryTypes'; describe('input-matcher', () => { - let userTrip: UserInput; + let userTrip: UnprocessedUserInput; let trip: TlEntry; beforeEach(() => { @@ -212,13 +212,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 = getUserInputForTrip(trip, userInputList); expect(mostRecentEntry).toMatchObject(userInputWriteThird); }); it('tests getUserInputForTrip with invalid userInputList', () => { const userInputList = undefined; - const mostRecentEntry = getUserInputForTrip(trip, {}, userInputList); + const mostRecentEntry = getUserInputForTrip(trip, userInputList); expect(mostRecentEntry).toBe(undefined); }); diff --git a/www/js/diary.js b/www/js/diary.js index c0b7bce35..cc996f811 100644 --- a/www/js/diary.js +++ b/www/js/diary.js @@ -2,10 +2,8 @@ import angular from 'angular'; 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.plugin.logger', + 'emission.survey.enketo.answer']) .config(function($stateProvider) { $stateProvider diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index b67dec542..b794a8257 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -16,14 +16,18 @@ import LabelListScreen from "./list/LabelListScreen"; import { createStackNavigator } from "@react-navigation/stack"; import LabelScreenDetails from "./details/LabelDetailsScreen"; import { NavigationContainer } from "@react-navigation/native"; -import { compositeTrips2TimelineMap, getAllUnprocessedInputs, getLocalUnprocessedInputs, populateCompositeTrips } from "./timelineHelper"; +import { compositeTrips2TimelineMap, updateAllUnprocessedInputs, updateLocalUnprocessedInputs, populateCompositeTrips, unprocessedLabels, unprocessedNotes } from "./timelineHelper"; import { fillLocationNamesOfTrip, resetNominatimLimiter } from "./addressNamesHelper"; import { getLabelOptions } from "../survey/multilabel/confirmHelper"; -import { displayError } from "../plugin/logger"; +import { displayError, logDebug } from "../plugin/logger"; import { useTheme } from "react-native-paper"; import { getPipelineRangeTs } from "../commHelper"; +import { UnprocessedUserInput } from "../types/diaryTypes"; +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"; -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); @@ -37,7 +41,9 @@ const LabelTab = () => { 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<{[k: string]: {[k: string]: UnprocessedUserInput}}>(null); + const [timelineNotesMap, setTimelineNotesMap] = useState<{[k: string]: UnprocessedUserInput[]}>(null); const [displayedEntries, setDisplayedEntries] = useState(null); const [refreshTime, setRefreshTime] = useState(null); const [isLoading, setIsLoading] = useState('replace'); @@ -47,17 +53,12 @@ const LabelTab = () => { const $ionicPopup = getAngularService('$ionicPopup'); const Logger = getAngularService('Logger'); const Timeline = getAngularService('Timeline'); - const enbs = getAngularService('EnketoNotesButtonService'); // initialization, once the appConfig is loaded useEffect(() => { if (!appConfig) return; 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 @@ -75,16 +76,24 @@ const LabelTab = () => { loadTimelineEntries(); }, [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(() => { if (!timelineMap) return setDisplayedEntries(null); 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.user_input) + 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 */ @@ -104,9 +113,13 @@ const LabelTab = () => { async function loadTimelineEntries() { try { const pipelineRange = await getPipelineRangeTs(); - [labelsResultMap, notesResultMap] = await getAllUnprocessedInputs(pipelineRange, labelPopulateFactory, enbs); - Logger.log("After reading unprocessedInputs, labelsResultMap =" + JSON.stringify(labelsResultMap) - + "; notesResultMap = " + JSON.stringify(notesResultMap)); + await updateAllUnprocessedInputs(pipelineRange, appConfig); + logDebug( + 'After updating unprocessed inputs, unprocessedLabels = ' + + JSON.stringify(unprocessedLabels) + + '; unprocessedNotes = ' + + JSON.stringify(unprocessedNotes), + ); setPipelineRange(pipelineRange); } catch (error) { Logger.displayError("Error while loading pipeline range", error); @@ -177,7 +190,7 @@ const LabelTab = () => { function handleFetchedTrips(ctList, utList, mode: 'prepend' | 'append' | 'replace') { const tripsRead = ctList.concat(utList); - populateCompositeTrips(tripsRead, showPlaces, labelPopulateFactory, labelsResultMap, enbs, notesResultMap); + populateCompositeTrips(tripsRead, showPlaces); // Fill place names on a reversed copy of the list so we fill from the bottom up tripsRead.slice().reverse().forEach(function (trip, index) { fillLocationNamesOfTrip(trip); @@ -224,11 +237,9 @@ const LabelTab = () => { const timelineMapRef = useRef(timelineMap); async function repopulateTimelineEntry(oid: string) { if (!timelineMap.has(oid)) return console.error("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(); const newEntry = {...timelineMap.get(oid), justRepopulated: repopTime}; - labelPopulateFactory.populateInputsAndInferences(newEntry, newLabels); - enbs.populateInputsAndInferences(newEntry, newNotes); const newTimelineMap = new Map(timelineMap).set(oid, newEntry); setTimelineMap(newTimelineMap); @@ -246,9 +257,10 @@ const LabelTab = () => { } const contextVals = { - surveyOpt, labelOptions, timelineMap, + timelineLabelMap, + timelineNotesMap, displayedEntries, filterInputs, setFilterInputs, diff --git a/www/js/diary/cards/ModesIndicator.tsx b/www/js/diary/cards/ModesIndicator.tsx index 5211f7ed4..db873a71e 100644 --- a/www/js/diary/cards/ModesIndicator.tsx +++ b/www/js/diary/cards/ModesIndicator.tsx @@ -3,7 +3,7 @@ import { View, StyleSheet } from 'react-native'; import color from "color"; import { LabelTabContext } from '../LabelTab'; 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'; @@ -11,15 +11,16 @@ 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(.8).rgb().string(); let indicatorBorderColor = color('black').alpha(.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 = ( @@ -27,7 +28,7 @@ const ModesIndicator = ({ trip, detectedModes, }) => { - {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 cd1d9c10e..31ea5c789 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,11 +17,13 @@ import { DiaryCard, cardStyles } from "./DiaryCard"; import { useAddressNames } from "../addressNamesHelper"; import useDerivedProperties from "../useDerivedProperties"; import StartEndLocations from "../components/StartEndLocations"; +import { LabelTabContext } from "../LabelTab"; 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); @@ -49,7 +51,7 @@ const PlaceCard = ({ place }: Props) => { - + ); diff --git a/www/js/diary/cards/TripCard.tsx b/www/js/diary/cards/TripCard.tsx index 9aeb4a61e..dc1e44ce8 100644 --- a/www/js/diary/cards/TripCard.tsx +++ b/www/js/diary/cards/TripCard.tsx @@ -34,8 +34,8 @@ const TripCard = ({ trip }: Props) => { distanceSuffix, displayTime, detectedModes } = useDerivedProperties(trip); let [ tripStartDisplayName, tripEndDisplayName ] = useAddressNames(trip); const navigation = useNavigation(); - const { 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); @@ -92,9 +92,9 @@ const TripCard = ({ trip }: Props) => { } - {trip.additionsList?.length != 0 && + {timelineNotesMap[trip._id.$oid]?.length != 0 && - + } diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx index b25c8e50c..11875545a 100644 --- a/www/js/diary/details/LabelDetailsScreen.tsx +++ b/www/js/diary/details/LabelDetailsScreen.tsx @@ -22,7 +22,7 @@ import useAppConfig from "../../useAppConfig"; const LabelScreenDetails = ({ route, navigation }) => { - const { timelineMap, labelOptions } = useContext(LabelTabContext); + const { timelineMap, labelOptions, timelineLabelMap } = useContext(LabelTabContext); const { t } = useTranslation(); const { height: windowHeight } = useWindowDimensions(); const appConfig = useAppConfig(); @@ -33,7 +33,7 @@ const LabelScreenDetails = ({ route, navigation }) => { const [ tripStartDisplayName, tripEndDisplayName ] = useAddressNames(trip); const [ modesShown, setModesShown ] = useState<'labeled'|'detected'>('labeled'); - const tripGeojson = useGeojsonForTrip(trip, labelOptions, modesShown=='labeled' && trip?.userInput?.MODE?.value); + const tripGeojson = useGeojsonForTrip(trip, labelOptions, modesShown=='labeled' && timelineLabelMap[trip._id.$oid]?.MODE?.value); const mapOpts = {minZoom: 3, maxZoom: 17}; const modal = ( @@ -65,7 +65,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} density='medium' buttons={[{label: t('diary.labeled-mode'), value: 'labeled'}, {label: t('diary.detected-modes'), value: 'detected'}]} /> : diff --git a/www/js/diary/details/TripSectionsDescriptives.tsx b/www/js/diary/details/TripSectionsDescriptives.tsx index 6d172fed4..c89481f44 100644 --- a/www/js/diary/details/TripSectionsDescriptives.tsx +++ b/www/js/diary/details/TripSectionsDescriptives.tsx @@ -3,24 +3,25 @@ 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 { getBaseModeByKey, getBaseModeByValue } from '../diaryHelper'; import { LabelTabContext } from '../LabelTab'; const TripSectionsDescriptives = ({ trip, showLabeledMode=false }) => { - const { labelOptions } = useContext(LabelTabContext); + const { labelOptions, timelineLabelMap } = useContext(LabelTabContext); const { displayStartTime, displayTime, formattedDistance, distanceSuffix, formattedSectionProperties } = useDerivedProperties(trip); 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'); } @@ -30,7 +31,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 }]; } return ( diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts index b12c89738..e042531d7 100644 --- a/www/js/diary/diaryHelper.ts +++ b/www/js/diary/diaryHelper.ts @@ -66,13 +66,6 @@ export function getBaseModeByKey(motionName: BaseModeKey | MotionTypeKey | `Moti 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"); diff --git a/www/js/diary/services.js b/www/js/diary/services.js index 774273fa2..383d8d0bc 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'; @@ -17,16 +15,6 @@ angular.module('emission.main.diary.services', ['emission.plugin.logger', 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 579e3ac4f..14f003178 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 { UnprocessedUserInput } 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,47 +73,53 @@ export function compositeTrips2TimelineMap(ctList: any[], unpackPlaces?: boolean return timelineEntriesMap; } -export function populateCompositeTrips(ctList, showPlaces, labelsFactory, labelsResultMap, notesFactory, notesResultMap) { +export function populateCompositeTrips(ctList, showPlaces) { 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]: UnprocessedUserInput[] } = {}; +/* 'NOTES' are 1:n - each trip or place can have any number of notes */ +export let unprocessedNotes: UnprocessedUserInput[] = []; + const getUnprocessedInputQuery = (pipelineRange) => ({ key: "write_ts", startTs: pipelineRange.end_ts - 10, 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 = getUniqueEntries(getNotDeletedCandidates(combinedNotes)); }); } @@ -119,21 +128,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); } /** @@ -141,21 +148,36 @@ 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); +} + +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 1b85c728e..08ca239cc 100644 --- a/www/js/survey/enketo/AddNoteButton.tsx +++ b/www/js/survey/enketo/AddNoteButton.tsx @@ -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 { @@ -44,7 +44,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/UserInputButton.tsx b/www/js/survey/enketo/UserInputButton.tsx index 68d0ae944..acb129e6a 100644 --- a/www/js/survey/enketo/UserInputButton.tsx +++ b/www/js/survey/enketo/UserInputButton.tsx @@ -26,19 +26,16 @@ 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/inputMatcher.ts b/www/js/survey/inputMatcher.ts index c6c8ed61c..6fdc01d90 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -1,15 +1,17 @@ import { logDebug, displayErrorMsg } from "../plugin/logger" import { DateTime } from "luxon"; -import { UserInput, Trip, TlEntry } from "../types/diaryTypes"; +import { UnprocessedUserInput, Trip, TlEntry } from "../types/diaryTypes"; +import { unprocessedLabels, unprocessedNotes } from "../diary/timelineHelper"; +import { LabelOption, MultilabelKey, getLabelInputDetails, getLabelInputs, getLabelOptions, inputType2retKey, labelKeyToRichMode, labelOptions } from "./multilabel/confirmHelper"; 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(ui.data.start_ts, ui.metadata.time_zone)} (${ui.data.start_ts}) -> +export const printUserInput = (ui: UnprocessedUserInput): string => `${fmtTs(ui.data.start_ts, ui.metadata.time_zone)} (${ui.data.start_ts}) -> ${fmtTs(ui.data.end_ts, ui.metadata.time_zone)} (${ui.data.end_ts}) ${ui.data.label} logged at ${ui.metadata.write_ts}`; -export const validUserInputForDraftTrip = (trip: Trip, userInput: UserInput, logsEnabled: boolean): boolean => { +export const validUserInputForDraftTrip = (trip: Trip, userInput: UnprocessedUserInput, logsEnabled: boolean): boolean => { if(logsEnabled) { logDebug(`Draft trip: comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} @@ -27,7 +29,7 @@ export const validUserInputForDraftTrip = (trip: Trip, userInput: UserInput, log || -(userInput.data.start_ts - trip.start_ts) <= 15 * 60) && userInput.data.end_ts <= trip.end_ts; } -export const validUserInputForTimelineEntry = (tlEntry: TlEntry, userInput: UserInput, logsEnabled: boolean): boolean => { +export const validUserInputForTimelineEntry = (tlEntry: TlEntry, userInput: UnprocessedUserInput, logsEnabled: boolean): boolean => { if (!tlEntry.origin_key) return false; if (tlEntry.origin_key.includes('UNPROCESSED')) return validUserInputForDraftTrip(tlEntry, userInput, logsEnabled); @@ -101,7 +103,7 @@ export const validUserInputForTimelineEntry = (tlEntry: TlEntry, userInput: User } // parallels get_not_deleted_candidates() in trip_queries.py -export const getNotDeletedCandidates = (candidates: UserInput[]): UserInput[] => { +export const getNotDeletedCandidates = (candidates: UnprocessedUserInput[]): UnprocessedUserInput[] => { console.log('getNotDeletedCandidates called with ' + candidates.length + ' candidates'); // We want to retain all ACTIVE entries that have not been DELETED @@ -115,7 +117,7 @@ export const getNotDeletedCandidates = (candidates: UserInput[]): UserInput[] => return notDeletedActive; } -export const getUserInputForTrip = (trip: TlEntry, nextTrip: any, userInputList: UserInput[]): undefined | UserInput => { +export const getUserInputForTrip = (trip: TlEntry, userInputList: UnprocessedUserInput[]): undefined | UnprocessedUserInput => { const logsEnabled = userInputList?.length < 20; if (userInputList === undefined) { logDebug("In getUserInputForTrip, no user input, returning undefined"); @@ -147,7 +149,7 @@ export const getUserInputForTrip = (trip: TlEntry, nextTrip: any, userInputList } // return array of matching additions for a trip or place -export const getAdditionsForTimelineEntry = (entry: TlEntry, additionsList: UserInput[]): UserInput[] => { +export const getAdditionsForTimelineEntry = (entry: TlEntry, additionsList: UnprocessedUserInput[]): UnprocessedUserInput[] => { const logsEnabled = additionsList?.length < 20; if (additionsList === undefined) { @@ -193,3 +195,55 @@ 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: TlEntry[], appConfig): [{ [k: string]: { [k: string]: UnprocessedUserInput | LabelOption } }, { [k: string]: UnprocessedUserInput[] }] { + const timelineLabelMap: { [k: string]: { [k: string]: UnprocessedUserInput | LabelOption } } = {}; + const timelineNotesMap: { [k: string]: UnprocessedUserInput[] } = {}; + + allEntries.forEach((tlEntry, i) => { + if (appConfig?.survey_info?.['trip-labels'] == 'ENKETO') { + // ENKETO configuration: just look for the 'SURVEY' key in the unprocessedInputs + const userInputForTrip = getUserInputForTrip(tlEntry, unprocessedLabels['SURVEY']); + if (userInputForTrip) { + timelineLabelMap[tlEntry._id.$oid] = { SURVEY: userInputForTrip }; + } + } 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 = getUserInputForTrip(tlEntry, unprocessedLabels[label]); + if (userInputForTrip) { + labelsForTrip[label] = labelOptions[label].find((opt: LabelOption) => opt.value == userInputForTrip.data.label); + } else { + const processedLabelValue = tlEntry.user_input?.[inputType2retKey(label)]; + labelsForTrip[label] = labelOptions[label].find((opt: LabelOption) => opt.value == processedLabelValue); + } + }); + 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) => { + const additionsForTrip = getAdditionsForTimelineEntry(tlEntry, unprocessedNotes); + if (additionsForTrip?.length) { + timelineNotesMap[tlEntry._id.$oid] = additionsForTrip; + } + }); + } + + return [timelineLabelMap, timelineNotesMap]; +} diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index ca71721a7..1021039a5 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -10,12 +10,14 @@ import DiaryButton from "../../components/DiaryButton"; import { useTranslation } from "react-i18next"; import { LabelTabContext } from "../../diary/LabelTab"; import { displayErrorMsg, logDebug } from "../../plugin/logger"; -import { getLabelInputDetails, getLabelInputs, readableLabelToKey } from "./confirmHelper"; +import { getLabelInputDetails, getLabelInputs, inferFinalLabels, labelInputDetailsForTrip, labelKeyToRichMode, readableLabelToKey } 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) @@ -23,14 +25,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); } } @@ -71,14 +75,14 @@ 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; diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index b668669bf..5bb092c6b 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -13,22 +13,24 @@ type InputDetails = { 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 } }}; let appConfig; -export let labelOptions: LabelOptions<'MODE'|'PURPOSE'|'REPLACED_MODE'>; -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; @@ -94,6 +96,22 @@ 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); @@ -117,3 +135,64 @@ export const getFakeEntry = (otherValue) => ({ export const labelKeyToRichMode = (labelKey: string) => labelOptions?.MODE?.find(m => m.value == labelKey)?.text || labelKeyToReadable(labelKey); + +/* manual/mode_confirm becomes mode_confirm */ +export const inputType2retKey = (inputType) => getLabelInputDetails()[inputType].key.split('/')[1]; + +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 = {}; + + // Red labels if we have no possibilities left + if (labelsList.length == 0) { + for (const inputType of getLabelInputs()) { + finalInference[inputType] = undefined; + } + 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; + + finalInference[inputType] = labelOptions[inputType].find((opt) => opt.value == max.labelValue); + } + return finalInference; + } +} diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index f4d53a4a9..150f829a3 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -43,7 +43,7 @@ export type CompositeTrip = { start_local_dt: LocalDt, start_place: {$oid: string}, start_ts: number, - user_input: UserInput, + user_input: UnprocessedUserInput, } /* These properties aren't received from the server, but are derived from the above properties. @@ -69,7 +69,7 @@ export type PopulatedTrip = CompositeTrip & { finalInference?: any, // TODO geojson?: any, // TODO getNextEntry?: () => PopulatedTrip | ConfirmedPlace, - userInput?: UserInput, + userInput?: UnprocessedUserInput, verifiability?: string, } @@ -79,7 +79,7 @@ export type SectionSummary = { duration: {[k: MotionTypeKey | BaseModeKey]: number}, } -export type UserInput = { +export type UnprocessedUserInput = { data: { end_ts: number, start_ts: number @@ -117,6 +117,7 @@ export type Trip = { } export type TlEntry = { + _id: { $oid: string }, key: string, origin_key: string, start_ts: number, From 3855a039c5ed2ba9f090455afc1a6b9090bb33ea Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Sun, 29 Oct 2023 20:14:22 -0400 Subject: [PATCH 04/20] remove btn services and survey.ts These are not needed since f7716cf791fbec89625cdb7d168ab8b148ab3f38 and 4d48aeebd2b1734e3d08dc5de6726f1faf4499bc --- www/index.js | 3 - .../survey/enketo/enketo-add-note-button.js | 105 --------- www/js/survey/enketo/enketo-trip-button.js | 110 ---------- www/js/survey/multilabel/multi-label-ui.js | 204 ------------------ www/js/survey/survey.ts | 16 -- 5 files changed, 438 deletions(-) delete mode 100644 www/js/survey/enketo/enketo-add-note-button.js delete mode 100644 www/js/survey/enketo/enketo-trip-button.js delete mode 100644 www/js/survey/multilabel/multi-label-ui.js delete mode 100644 www/js/survey/survey.ts diff --git a/www/index.js b/www/index.js index 1e90692f1..67c6d34bb 100644 --- a/www/index.js +++ b/www/index.js @@ -15,12 +15,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/survey/enketo/enketo-add-note-button.js b/www/js/survey/enketo/enketo-add-note-button.js deleted file mode 100644 index 2585638c9..000000000 --- a/www/js/survey/enketo/enketo-add-note-button.js +++ /dev/null @@ -1,105 +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 536ce6c34..000000000 --- a/www/js/survey/enketo/enketo-trip-button.js +++ /dev/null @@ -1,110 +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/multilabel/multi-label-ui.js b/www/js/survey/multilabel/multi-label-ui.js deleted file mode 100644 index 28ab5ee12..000000000 --- a/www/js/survey/multilabel/multi-label-ui.js +++ /dev/null @@ -1,204 +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; - }); - } - - mls.populateInputsAndInferences = function(trip, 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 = {}; - getLabelInputs().forEach(function(item, index) { - mls.populateManualInputs(trip, trip.nextTrip, item, - manualResultMap[item]); - }); - trip.finalInference = {}; - mls.inferFinalLabels(trip); - mls.expandInputsIfNecessary(trip); - mls.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 - */ - 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 66f662082..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" - } -} From 40d3f75392a24653dee78d9fddb7adc6c020fe18 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Sun, 29 Oct 2023 20:33:50 -0400 Subject: [PATCH 05/20] add back verifiability with verifiabilityForTrip() As a result of 4d48aeebd2b1734e3d08dc5de6726f1faf4499bc, trips are not populated with the 'verifiability' property. We can handle this with a function call --- www/js/survey/multilabel/MultiLabelButtonGroup.tsx | 4 ++-- www/js/survey/multilabel/confirmHelper.ts | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index 1021039a5..54c9be531 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -10,7 +10,7 @@ import DiaryButton from "../../components/DiaryButton"; import { useTranslation } from "react-i18next"; import { LabelTabContext } from "../../diary/LabelTab"; import { displayErrorMsg, logDebug } from "../../plugin/logger"; -import { getLabelInputDetails, getLabelInputs, inferFinalLabels, labelInputDetailsForTrip, labelKeyToRichMode, readableLabelToKey } from "./confirmHelper"; +import { getLabelInputDetails, getLabelInputs, inferFinalLabels, labelInputDetailsForTrip, labelKeyToRichMode, readableLabelToKey, verifiabilityForTrip } from "./confirmHelper"; import useAppConfig from "../../useAppConfig"; const MultilabelButtonGroup = ({ trip, buttonsInline=false }) => { @@ -104,7 +104,7 @@ const MultilabelButtonGroup = ({ trip, buttonsInline=false }) => { ) })} - {trip.verifiability === 'can-verify' && ( + {verifiabilityForTrip(trip, timelineLabelMap[trip._id.$oid]) == 'can-verify' && ( /* 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 confirmed = userInputForTrip[inputType]; + const inferred = inferFinalLabels(trip, userInputForTrip)[inputType] && !confirmed; + if (inferred) 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 = []; From 49ff3853d7580cd5a728d0e2d9de8a80e5ccca03 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Sun, 29 Oct 2023 21:30:02 -0400 Subject: [PATCH 06/20] remove populateCompositeTrips; update types The last property on the trip/place objects that differs between phone and server is 'getNextEntry' called from the input matching function. We can instead pass 'nextEntry' as a second parameter to the input matching functions. This allows us to significantly simplify our typings and ensure that 'CompositeTrip' and 'ConfirmedPlace' here will be the same as they are on the server. Hooray! -- While adjusting inputMatcher, I also renamed 'getUserInputForTrip' to 'getUserInputForTimelineEntry' (i) to be more consistent and (ii) in case we support a 'place user input' in the future --- www/__tests__/inputMatcher.test.ts | 42 +++++++------ www/js/diary/LabelTab.tsx | 7 +-- www/js/diary/timelineHelper.ts | 20 ------- www/js/survey/inputMatcher.ts | 94 +++++++++++++++++++----------- www/js/types/diaryTypes.ts | 42 +++---------- 5 files changed, 95 insertions(+), 110 deletions(-) diff --git a/www/__tests__/inputMatcher.test.ts b/www/__tests__/inputMatcher.test.ts index 3179c9700..566df0cd7 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, UnprocessedUserInput } from '../js/types/diaryTypes'; +import { CompositeTrip, TimelineEntry, UnprocessedUserInput } from '../js/types/diaryTypes'; describe('input-matcher', () => { let userTrip: UnprocessedUserInput; - let trip: TlEntry; + let trip: TimelineEntry; + let nextTrip: TimelineEntry; beforeEach(() => { /* @@ -37,7 +38,7 @@ describe('input-matcher', () => { key: 'manual/mode_confirm' }, key: 'manual/place' - } + }, trip = { key: 'FOO', origin_key: 'FOO', @@ -46,8 +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 +87,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 +96,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 +105,18 @@ 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 +124,8 @@ 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(); }); @@ -212,13 +220,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); }); @@ -228,13 +236,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/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index b794a8257..60b462139 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -16,9 +16,9 @@ import LabelListScreen from "./list/LabelListScreen"; import { createStackNavigator } from "@react-navigation/stack"; import LabelScreenDetails from "./details/LabelDetailsScreen"; import { NavigationContainer } from "@react-navigation/native"; -import { compositeTrips2TimelineMap, updateAllUnprocessedInputs, updateLocalUnprocessedInputs, populateCompositeTrips, unprocessedLabels, unprocessedNotes } from "./timelineHelper"; +import { compositeTrips2TimelineMap, updateAllUnprocessedInputs, updateLocalUnprocessedInputs, unprocessedLabels, unprocessedNotes } from "./timelineHelper"; import { fillLocationNamesOfTrip, resetNominatimLimiter } from "./addressNamesHelper"; -import { getLabelOptions } from "../survey/multilabel/confirmHelper"; +import { LabelOption, getLabelOptions } from "../survey/multilabel/confirmHelper"; import { displayError, logDebug } from "../plugin/logger"; import { useTheme } from "react-native-paper"; import { getPipelineRangeTs } from "../commHelper"; @@ -42,7 +42,7 @@ const LabelTab = () => { const [pipelineRange, setPipelineRange] = useState(null); const [queriedRange, setQueriedRange] = useState(null); const [timelineMap, setTimelineMap] = useState>(null); - const [timelineLabelMap, setTimelineLabelMap] = useState<{[k: string]: {[k: string]: UnprocessedUserInput}}>(null); + const [timelineLabelMap, setTimelineLabelMap] = useState<{[k: string]: {[k: string]: UnprocessedUserInput | LabelOption}}>(null); const [timelineNotesMap, setTimelineNotesMap] = useState<{[k: string]: UnprocessedUserInput[]}>(null); const [displayedEntries, setDisplayedEntries] = useState(null); const [refreshTime, setRefreshTime] = useState(null); @@ -190,7 +190,6 @@ const LabelTab = () => { function handleFetchedTrips(ctList, utList, mode: 'prepend' | 'append' | 'replace') { const tripsRead = ctList.concat(utList); - populateCompositeTrips(tripsRead, showPlaces); // Fill place names on a reversed copy of the list so we fill from the bottom up tripsRead.slice().reverse().forEach(function (trip, index) { fillLocationNamesOfTrip(trip); diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 14f003178..08f5a734d 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -73,26 +73,6 @@ export function compositeTrips2TimelineMap(ctList: any[], unpackPlaces?: boolean return timelineEntriesMap; } -export function populateCompositeTrips(ctList, showPlaces) { - try { - ctList.forEach((ct, i) => { - if (showPlaces && ct.start_confirmed_place) { - const cp = ct.start_confirmed_place; - cp.getNextEntry = () => ctList[i]; - } - if (showPlaces && ct.end_confirmed_place) { - const cp = ct.end_confirmed_place; - cp.getNextEntry = () => ctList[i + 1]; - ct.getNextEntry = () => cp; - } else { - ct.getNextEntry = () => ctList[i + 1]; - } - }); - } 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]: UnprocessedUserInput[] } = {}; diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index 6fdc01d90..8745b4385 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -1,6 +1,6 @@ import { logDebug, displayErrorMsg } from "../plugin/logger" import { DateTime } from "luxon"; -import { UnprocessedUserInput, Trip, TlEntry } from "../types/diaryTypes"; +import { CompositeTrip, TimelineEntry, UnprocessedUserInput } from "../types/diaryTypes"; import { unprocessedLabels, unprocessedNotes } from "../diary/timelineHelper"; import { LabelOption, MultilabelKey, getLabelInputDetails, getLabelInputs, getLabelOptions, inputType2retKey, labelKeyToRichMode, labelOptions } from "./multilabel/confirmHelper"; @@ -11,7 +11,7 @@ export const fmtTs = (ts_in_secs: number, tz: string): string | null => DateTime export const printUserInput = (ui: UnprocessedUserInput): string => `${fmtTs(ui.data.start_ts, ui.metadata.time_zone)} (${ui.data.start_ts}) -> ${fmtTs(ui.data.end_ts, ui.metadata.time_zone)} (${ui.data.end_ts}) ${ui.data.label} logged at ${ui.metadata.write_ts}`; -export const validUserInputForDraftTrip = (trip: Trip, userInput: UnprocessedUserInput, logsEnabled: boolean): boolean => { +export const validUserInputForDraftTrip = (trip: CompositeTrip, userInput: UnprocessedUserInput, logsEnabled: boolean): boolean => { if(logsEnabled) { logDebug(`Draft trip: comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} @@ -29,7 +29,12 @@ export const validUserInputForDraftTrip = (trip: Trip, userInput: UnprocessedUse || -(userInput.data.start_ts - trip.start_ts) <= 15 * 60) && userInput.data.end_ts <= trip.end_ts; } -export const validUserInputForTimelineEntry = (tlEntry: TlEntry, userInput: UnprocessedUserInput, logsEnabled: boolean): boolean => { +export const validUserInputForTimelineEntry = ( + tlEntry: TimelineEntry, + nextEntry: TimelineEntry | null, + userInput: UnprocessedUserInput, + logsEnabled: boolean, +): boolean => { if (!tlEntry.origin_key) return false; if (tlEntry.origin_key.includes('UNPROCESSED')) return validUserInputForDraftTrip(tlEntry, userInput, logsEnabled); @@ -77,29 +82,28 @@ export const validUserInputForTimelineEntry = (tlEntry: TlEntry, userInput: Unpr 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 (!nextEntryEnd) { // the last place will not have an exit_ts - endChecks = true; // so we will just skip the end check - } else { - endChecks = userInput.data.end_ts <= nextEntryEnd; - logDebug(`Second level of end checks when the next trip is defined(${userInput.data.end_ts} <= ${nextEntryEnd}) ${endChecks}`); - } + 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 } else { - // next trip is not defined, last trip - endChecks = (userInput.data.end_local_dt.day == userInput.data.start_local_dt.day) - logDebug("Second level of end checks for the last trip of the day"); - logDebug(`compare ${userInput.data.end_local_dt.day} with ${userInput.data.start_local_dt.day} ${endChecks}`); - } - if (endChecks) { - // If we have flipped the values, check to see that there is sufficient overlap - const overlapDuration = Math.min(userInput.data.end_ts, entryEnd) - Math.max(userInput.data.start_ts, entryStart) - logDebug(`Flipped endCheck, overlap(${overlapDuration})/trip(${tlEntry.duration} (${overlapDuration} / ${tlEntry.duration})`); - endChecks = (overlapDuration/tlEntry.duration) > 0.5; + endChecks = userInput.data.end_ts <= nextEntryEnd; + logDebug(`Second level of end checks when the next trip is defined(${userInput.data.end_ts} <= ${nextEntryEnd}) ${endChecks}`); } + } else { + // next trip is not defined, last trip + endChecks = (userInput.data.end_local_dt.day == userInput.data.start_local_dt.day) + logDebug("Second level of end checks for the last trip of the day"); + logDebug(`compare ${userInput.data.end_local_dt.day} with ${userInput.data.start_local_dt.day} ${endChecks}`); + } + if (endChecks) { + // If we have flipped the values, check to see that there is sufficient overlap + const overlapDuration = Math.min(userInput.data.end_ts, entryEnd) - Math.max(userInput.data.start_ts, entryStart) + logDebug(`Flipped endCheck, overlap(${overlapDuration})/trip(${tlEntry.duration} (${overlapDuration} / ${tlEntry.duration})`); + endChecks = (overlapDuration/tlEntry.duration) > 0.5; } - return startChecks && endChecks; + } + return startChecks && endChecks; } // parallels get_not_deleted_candidates() in trip_queries.py @@ -117,25 +121,31 @@ export const getNotDeletedCandidates = (candidates: UnprocessedUserInput[]): Unp return notDeletedActive; } -export const getUserInputForTrip = (trip: TlEntry, userInputList: UnprocessedUserInput[]): undefined | UnprocessedUserInput => { +export const getUserInputForTimelineEntry = ( + entry: TimelineEntry, + nextEntry: TimelineEntry | null, + userInputList: UnprocessedUserInput[], +): undefined | UnprocessedUserInput => { 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; } if (logsEnabled) console.log(`Input list = ${userInputList.map(printUserInput)}`); // undefined !== true, so this covers the label view case as well - const potentialCandidates = userInputList.filter((ui) => validUserInputForTimelineEntry(trip, ui, logsEnabled)); + const potentialCandidates = userInputList.filter((ui) => + validUserInputForTimelineEntry(entry, nextEntry, ui, logsEnabled), + ); if (potentialCandidates.length === 0) { - if (logsEnabled) logDebug("In getUserInputForTripStartEnd, no potential candidates, returning []"); + if (logsEnabled) logDebug("In getUserInputForTimelineEntry, no potential candidates, returning []"); return undefined; } if (potentialCandidates.length === 1) { - logDebug(`In getUserInputForTripStartEnd, one potential candidate, returning ${printUserInput(potentialCandidates[0])}`); + logDebug(`In getUserInputForTimelineEntry, one potential candidate, returning ${printUserInput(potentialCandidates[0])}`); return potentialCandidates[0]; } @@ -149,7 +159,11 @@ export const getUserInputForTrip = (trip: TlEntry, userInputList: UnprocessedUs } // return array of matching additions for a trip or place -export const getAdditionsForTimelineEntry = (entry: TlEntry, additionsList: UnprocessedUserInput[]): UnprocessedUserInput[] => { +export const getAdditionsForTimelineEntry = ( + entry: TimelineEntry, + nextEntry: TimelineEntry | null, + additionsList: UnprocessedUserInput[], +): UnprocessedUserInput[] => { const logsEnabled = additionsList?.length < 20; if (additionsList === undefined) { @@ -159,7 +173,9 @@ export const getAdditionsForTimelineEntry = (entry: TlEntry, additionsList: Unpr // 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)); + const matchingAdditions = notDeleted.filter((ui) => + validUserInputForTimelineEntry(entry, nextEntry, ui, logsEnabled), + ); if (logsEnabled) console.log(`Matching Addition list ${matchingAdditions.map(printUserInput)}`); @@ -201,14 +217,19 @@ export const getUniqueEntries = (combinedList) => { * @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: TlEntry[], appConfig): [{ [k: string]: { [k: string]: UnprocessedUserInput | LabelOption } }, { [k: string]: UnprocessedUserInput[] }] { +export function mapInputsToTimelineEntries(allEntries: TimelineEntry[], appConfig): [{ [k: string]: { [k: string]: UnprocessedUserInput | LabelOption } }, { [k: string]: UnprocessedUserInput[] }] { const timelineLabelMap: { [k: string]: { [k: string]: UnprocessedUserInput | LabelOption } } = {}; const timelineNotesMap: { [k: string]: UnprocessedUserInput[] } = {}; 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 = getUserInputForTrip(tlEntry, unprocessedLabels['SURVEY']); + const userInputForTrip = getUserInputForTimelineEntry( + tlEntry, + nextEntry, + unprocessedLabels['SURVEY'], + ); if (userInputForTrip) { timelineLabelMap[tlEntry._id.$oid] = { SURVEY: userInputForTrip }; } @@ -218,7 +239,11 @@ export function mapInputsToTimelineEntries(allEntries: TlEntry[], appConfig): [{ const labelsForTrip: { [k: string]: LabelOption } = {}; Object.keys(getLabelInputDetails()).forEach((label: MultilabelKey) => { // Check unprocessed labels first since they are more recent - const userInputForTrip = getUserInputForTrip(tlEntry, unprocessedLabels[label]); + const userInputForTrip = getUserInputForTimelineEntry( + tlEntry, + nextEntry, + unprocessedLabels[label], + ); if (userInputForTrip) { labelsForTrip[label] = labelOptions[label].find((opt: LabelOption) => opt.value == userInputForTrip.data.label); } else { @@ -238,7 +263,8 @@ export function mapInputsToTimelineEntries(allEntries: TlEntry[], appConfig): [{ ) { // trip-level or place-level notes are configured, so we need to match additions too allEntries.forEach((tlEntry, i) => { - const additionsForTrip = getAdditionsForTimelineEntry(tlEntry, unprocessedNotes); + const nextEntry = i + 1 < allEntries.length ? allEntries[i + 1] : null; + const additionsForTrip = getAdditionsForTimelineEntry(tlEntry, nextEntry, unprocessedNotes); if (additionsForTrip?.length) { timelineNotesMap[tlEntry._id.$oid] = additionsForTrip; } diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 150f829a3..cdb42d64d 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -1,12 +1,9 @@ -/* 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. */ import { BaseModeKey, MotionTypeKey } from "../diary/diaryHelper"; -// Since it is WIP, these types are not used anywhere yet. - type ConfirmedPlace = any; // TODO /* These are the properties received from the server (basically matches Python code) @@ -46,6 +43,10 @@ export type CompositeTrip = { user_input: UnprocessedUserInput, } +/* 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 = { @@ -61,18 +62,6 @@ 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?: UnprocessedUserInput, - verifiability?: string, -} - export type SectionSummary = { count: {[k: MotionTypeKey | BaseModeKey]: number}, distance: {[k: MotionTypeKey | BaseModeKey]: number}, @@ -110,20 +99,3 @@ export type LocalDt = { year: number, timezone: string, } - -export type Trip = { - end_ts: number, - start_ts: number, -} - -export type TlEntry = { - _id: { $oid: string }, - key: string, - origin_key: string, - start_ts: number, - end_ts: number, - enter_ts: number, - exit_ts: number, - duration: number, - getNextEntry?: () => PopulatedTrip | ConfirmedPlace, -} From a34c219a04909b3143acd52db064aca0b16d044b Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Sun, 29 Oct 2023 21:37:18 -0400 Subject: [PATCH 07/20] TripCard: don't show footer if no notes On trips with no notes/additions, an empty was being rendered (causing a 10px gap due to padding) If there were no notes, `timelineNotesMap[trip._id.$oid]` would be `undefined`, which does not equal 0. We should only show notes if it is defined AND the length is not 0, so we can just say `timelineNotesMap[trip._id.$oid]?.length`. --- www/js/diary/cards/TripCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/js/diary/cards/TripCard.tsx b/www/js/diary/cards/TripCard.tsx index dc1e44ce8..e87da3adc 100644 --- a/www/js/diary/cards/TripCard.tsx +++ b/www/js/diary/cards/TripCard.tsx @@ -92,7 +92,7 @@ const TripCard = ({ trip }: Props) => { } - {timelineNotesMap[trip._id.$oid]?.length != 0 && + {timelineNotesMap[trip._id.$oid]?.length && From c262f6af33efcea367244b5fb501b98e745d9ad9 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Sun, 29 Oct 2023 21:49:14 -0400 Subject: [PATCH 08/20] remove unused vars in LabelTab Replaced one use of `Logger` with `displayError`. Now we don't need to use these Angular services here anymore. The only Angular service still used in this file is TimelineHelper. --- www/i18n/en.json | 1 + www/js/diary/LabelTab.tsx | 8 ++------ 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/www/i18n/en.json b/www/i18n/en.json index 9217339f7..00490aa3e 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -396,6 +396,7 @@ "errors": { "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-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/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 60b462139..669150009 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -48,10 +48,6 @@ const LabelTab = () => { 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'); // initialization, once the appConfig is loaded @@ -121,8 +117,8 @@ const LabelTab = () => { JSON.stringify(unprocessedNotes), ); setPipelineRange(pipelineRange); - } catch (error) { - Logger.displayError("Error while loading pipeline range", error); + } catch (e) { + displayError(e, t('errors.while-loading-pipeline-range')); setIsLoading(false); } } From 90095174fc0ff99ba5cedf37f455d68087ffb58a Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 31 Oct 2023 11:29:02 -0400 Subject: [PATCH 09/20] fix label button showing before appConfig loaded Before appConfig is loaded, we we are unsure whether MultilabelButtonGroup or UserInputButton should be shown neither. As long as appConfig is undefined we want to show neither. So a simple if/else is not sufficient here, we should specifically check for the presence of either MULTILABEL or ENKETO --- www/js/diary/cards/TripCard.tsx | 7 ++++--- www/js/diary/details/LabelDetailsScreen.tsx | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/www/js/diary/cards/TripCard.tsx b/www/js/diary/cards/TripCard.tsx index e87da3adc..ad35d48a8 100644 --- a/www/js/diary/cards/TripCard.tsx +++ b/www/js/diary/cards/TripCard.tsx @@ -70,10 +70,11 @@ const TripCard = ({ trip }: Props) => { displayEndName={tripEndDisplayName} /> {/* mode and purpose buttons / survey button */} - {appConfig?.survey_info?.['trip-labels'] == 'ENKETO' ? ( + {appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL' && ( + + )} + {appConfig?.survey_info?.['trip-labels'] == 'ENKETO' && ( - ) : ( - )} diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx index 11875545a..c0f1cc6bd 100644 --- a/www/js/diary/details/LabelDetailsScreen.tsx +++ b/www/js/diary/details/LabelDetailsScreen.tsx @@ -53,10 +53,11 @@ const LabelScreenDetails = ({ route, navigation }) => { style={{margin: 10, paddingHorizontal: 10, rowGap: 12, borderRadius: 15 }}> {/* MultiLabel or UserInput button, inline on one row */} - {appConfig?.survey_info?.['trip-labels'] == 'ENKETO' ? ( + {appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL' && ( + + )} + {appConfig?.survey_info?.['trip-labels'] == 'ENKETO' && ( - ) : ( - )} From fb46586925a92252bf45f763544fcae2f00ce86d Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 31 Oct 2023 11:37:12 -0400 Subject: [PATCH 10/20] combine processed+unprocessed survey responses In inputMatcher -> mapInputsToTimelineEntries, adds back the logic for handling processed survey response inputs that are already matched to the trip/place on the server. For user_input, we only use it if there is no unprocessed user input (since unprocessed entries will be newer). For additions, we have to merge processed+unprocessed and then from this, get the non-deleted, unique entries. in timelineHelper, when storing unprocessedNotes we should remove duplicates according to their write_ts, not by getUniqueEntries(getNotDeletedCandidates(...)), because we need to retain the "DELETED" entries so they can be matched to any processed entries that they may refer to --- www/js/diary/timelineHelper.ts | 6 ++++-- www/js/survey/inputMatcher.ts | 25 +++++++++++++++++++++---- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 08f5a734d..ba5dd9a7b 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -99,7 +99,9 @@ function updateUnprocessedInputs(labelsPromises, notesPromises, appConfig) { }); // merge the notes we just read into the existing unprocessedNotes, removing duplicates const combinedNotes = [...unprocessedNotes, ...notesResults]; - unprocessedNotes = getUniqueEntries(getNotDeletedCandidates(combinedNotes)); + unprocessedNotes = combinedNotes.filter((note, i, self) => + self.findIndex(n => n.metadata.write_ts == note.metadata.write_ts) == i + ); }); } @@ -143,7 +145,7 @@ export async function updateAllUnprocessedInputs(pipelineRange, appConfig) { await updateUnprocessedInputs(labelsPromises, notesPromises, appConfig); } -function keysForLabelInputs(appConfig) { +export function keysForLabelInputs(appConfig) { if (appConfig.survey_info?.['trip-labels'] == 'ENKETO') { return ['manual/trip_user_input']; } else { diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index 8745b4385..2dc937949 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -1,7 +1,7 @@ import { logDebug, displayErrorMsg } from "../plugin/logger" import { DateTime } from "luxon"; import { CompositeTrip, TimelineEntry, UnprocessedUserInput } from "../types/diaryTypes"; -import { unprocessedLabels, unprocessedNotes } from "../diary/timelineHelper"; +import { keysForLabelInputs, unprocessedLabels, unprocessedNotes } from "../diary/timelineHelper"; import { LabelOption, MultilabelKey, getLabelInputDetails, getLabelInputs, getLabelOptions, inputType2retKey, labelKeyToRichMode, labelOptions } from "./multilabel/confirmHelper"; const EPOCH_MAXIMUM = 2**31 - 1; @@ -232,6 +232,15 @@ export function mapInputsToTimelineEntries(allEntries: TimelineEntry[], appConfi ); 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 @@ -263,10 +272,18 @@ export function mapInputsToTimelineEntries(allEntries: TimelineEntry[], appConfi ) { // 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 additionsForTrip = getAdditionsForTimelineEntry(tlEntry, nextEntry, unprocessedNotes); - if (additionsForTrip?.length) { - timelineNotesMap[tlEntry._id.$oid] = additionsForTrip; + const unprocessedAdditions = getAdditionsForTimelineEntry(tlEntry, nextEntry, unprocessedNotes); + const processedAdditions = tlEntry.additions || []; + + const mergedAdditions = getUniqueEntries( + getNotDeletedCandidates([...unprocessedAdditions, ...processedAdditions]), + ); + if (mergedAdditions?.length) { + timelineNotesMap[tlEntry._id.$oid] = mergedAdditions; } }); } From af5948bb8ea111e5750b7f678ffe2229e1bd870e Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 31 Oct 2023 12:46:27 -0400 Subject: [PATCH 11/20] move LabelTabContext to own file to expand types --- www/js/diary/LabelTab.tsx | 2 +- www/js/diary/LabelTabContext.ts | 38 +++++++++++++++++++ www/js/diary/cards/ModesIndicator.tsx | 2 +- www/js/diary/cards/PlaceCard.tsx | 2 +- www/js/diary/cards/TripCard.tsx | 2 +- www/js/diary/details/LabelDetailsScreen.tsx | 2 +- .../details/TripSectionsDescriptives.tsx | 2 +- www/js/diary/list/DateSelect.tsx | 2 +- www/js/diary/list/LabelListScreen.tsx | 2 +- www/js/survey/enketo/AddNoteButton.tsx | 2 +- www/js/survey/enketo/AddedNotesList.tsx | 2 +- www/js/survey/enketo/UserInputButton.tsx | 2 +- .../multilabel/MultiLabelButtonGroup.tsx | 2 +- 13 files changed, 50 insertions(+), 12 deletions(-) create mode 100644 www/js/diary/LabelTabContext.ts diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 669150009..1908411f5 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -26,11 +26,11 @@ import { UnprocessedUserInput } from "../types/diaryTypes"; 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 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(); diff --git a/www/js/diary/LabelTabContext.ts b/www/js/diary/LabelTabContext.ts new file mode 100644 index 000000000..e5715bf52 --- /dev/null +++ b/www/js/diary/LabelTabContext.ts @@ -0,0 +1,38 @@ +import { createContext } from 'react'; +import { TimelineEntry, UnprocessedUserInput } 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?: UnprocessedUserInput; + } & { + /* all other keys, (e.g. 'MODE', 'PURPOSE') are from the MULTILABEL configuration + and use a LabelOption for the user input value */ + [k: string]: LabelOption; + }; +}; +export type TimelineNotesMap = { + [k: string]: UnprocessedUserInput[]; +}; + +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 db873a71e..00967b43e 100644 --- a/www/js/diary/cards/ModesIndicator.tsx +++ b/www/js/diary/cards/ModesIndicator.tsx @@ -1,7 +1,7 @@ import React, { useContext } from 'react'; import { View, StyleSheet } from 'react-native'; import color from "color"; -import { LabelTabContext } from '../LabelTab'; +import LabelTabContext from '../LabelTabContext'; import { logDebug } from '../../plugin/logger'; import { getBaseModeByValue } from '../diaryHelper'; import { Icon } from '../../components/Icon'; diff --git a/www/js/diary/cards/PlaceCard.tsx b/www/js/diary/cards/PlaceCard.tsx index 31ea5c789..009cc22cf 100644 --- a/www/js/diary/cards/PlaceCard.tsx +++ b/www/js/diary/cards/PlaceCard.tsx @@ -17,7 +17,7 @@ import { DiaryCard, cardStyles } from "./DiaryCard"; import { useAddressNames } from "../addressNamesHelper"; import useDerivedProperties from "../useDerivedProperties"; import StartEndLocations from "../components/StartEndLocations"; -import { LabelTabContext } from "../LabelTab"; +import LabelTabContext from '../LabelTabContext'; type Props = { place: {[key: string]: any} }; const PlaceCard = ({ place }: Props) => { diff --git a/www/js/diary/cards/TripCard.tsx b/www/js/diary/cards/TripCard.tsx index ad35d48a8..69abbfb29 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"; diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx index c0f1cc6bd..48fc67430 100644 --- a/www/js/diary/details/LabelDetailsScreen.tsx +++ b/www/js/diary/details/LabelDetailsScreen.tsx @@ -5,7 +5,7 @@ import React, { useContext, useState } from "react"; import { View, Modal, ScrollView, useWindowDimensions } from "react-native"; import { PaperProvider, Appbar, SegmentedButtons, Button, Surface, 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"; diff --git a/www/js/diary/details/TripSectionsDescriptives.tsx b/www/js/diary/details/TripSectionsDescriptives.tsx index c89481f44..db9efb3ca 100644 --- a/www/js/diary/details/TripSectionsDescriptives.tsx +++ b/www/js/diary/details/TripSectionsDescriptives.tsx @@ -4,7 +4,7 @@ import { Text, useTheme } from 'react-native-paper' import { Icon } from '../../components/Icon'; import useDerivedProperties from '../useDerivedProperties'; import { getBaseModeByKey, getBaseModeByValue } from '../diaryHelper'; -import { LabelTabContext } from '../LabelTab'; +import LabelTabContext from '../LabelTabContext'; const TripSectionsDescriptives = ({ trip, showLabeledMode=false }) => { diff --git a/www/js/diary/list/DateSelect.tsx b/www/js/diary/list/DateSelect.tsx index 1c28cdc2c..210de20be 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 4fb1702b2..ae3cefd5f 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 = () => { diff --git a/www/js/survey/enketo/AddNoteButton.tsx b/www/js/survey/enketo/AddNoteButton.tsx index 08ca239cc..8efd8901e 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"; diff --git a/www/js/survey/enketo/AddedNotesList.tsx b/www/js/survey/enketo/AddedNotesList.tsx index e29278cca..1a3899baf 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 acb129e6a..6693e5cf7 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, diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index 54c9be531..d47ef3f17 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -8,7 +8,7 @@ import { View, Modal, ScrollView, Pressable, useWindowDimensions } from "react-n import { IconButton, Text, Dialog, useTheme, RadioButton, Button, TextInput } 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, inferFinalLabels, labelInputDetailsForTrip, labelKeyToRichMode, readableLabelToKey, verifiabilityForTrip } from "./confirmHelper"; import useAppConfig from "../../useAppConfig"; From d96ff8d0d7c785f6914b2d080885df9030f6d033 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 31 Oct 2023 13:16:29 -0400 Subject: [PATCH 12/20] expand types -added type def for ConfirmedPlace -create ObjectId instead of repeating `{ $oid: string }` -expanded user input typings: -- this was tricky because the structure of the user input received from the server differs by key. For labels, we just get a string value for the label chosen. For survey responses, we get a actual raw data entry. For the purpose of typing these, I am differentiating them based on whether the key ends in 'user_input' (used for surveys) or if it ends in 'confirm' (used for labels) -use these new typings in LabelTab and inputMatcher --- www/__tests__/inputMatcher.test.ts | 4 +-- www/js/diary/LabelTab.tsx | 11 +++---- www/js/diary/LabelTabContext.ts | 11 ++++--- www/js/diary/timelineHelper.ts | 6 ++-- www/js/survey/inputMatcher.ts | 25 +++++++------- www/js/types/diaryTypes.ts | 53 +++++++++++++++++++++++------- 6 files changed, 70 insertions(+), 40 deletions(-) diff --git a/www/__tests__/inputMatcher.test.ts b/www/__tests__/inputMatcher.test.ts index 566df0cd7..1550686eb 100644 --- a/www/__tests__/inputMatcher.test.ts +++ b/www/__tests__/inputMatcher.test.ts @@ -8,10 +8,10 @@ import { getAdditionsForTimelineEntry, getUniqueEntries } from '../js/survey/inputMatcher'; -import { CompositeTrip, TimelineEntry, UnprocessedUserInput } from '../js/types/diaryTypes'; +import { CompositeTrip, TimelineEntry, UserInputEntry } from '../js/types/diaryTypes'; describe('input-matcher', () => { - let userTrip: UnprocessedUserInput; + let userTrip: UserInputEntry; let trip: TimelineEntry; let nextTrip: TimelineEntry; diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 1908411f5..3c214dbee 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -18,11 +18,10 @@ import LabelScreenDetails from "./details/LabelDetailsScreen"; import { NavigationContainer } from "@react-navigation/native"; import { compositeTrips2TimelineMap, updateAllUnprocessedInputs, updateLocalUnprocessedInputs, unprocessedLabels, unprocessedNotes } from "./timelineHelper"; import { fillLocationNamesOfTrip, resetNominatimLimiter } from "./addressNamesHelper"; -import { LabelOption, getLabelOptions } from "../survey/multilabel/confirmHelper"; +import { getLabelOptions } from "../survey/multilabel/confirmHelper"; import { displayError, logDebug } from "../plugin/logger"; import { useTheme } from "react-native-paper"; import { getPipelineRangeTs } from "../commHelper"; -import { UnprocessedUserInput } from "../types/diaryTypes"; 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"; @@ -41,9 +40,9 @@ const LabelTab = () => { const [filterInputs, setFilterInputs] = useState([]); const [pipelineRange, setPipelineRange] = useState(null); const [queriedRange, setQueriedRange] = useState(null); - const [timelineMap, setTimelineMap] = useState>(null); - const [timelineLabelMap, setTimelineLabelMap] = useState<{[k: string]: {[k: string]: UnprocessedUserInput | LabelOption}}>(null); - const [timelineNotesMap, setTimelineNotesMap] = useState<{[k: string]: UnprocessedUserInput[]}>(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'); @@ -76,7 +75,7 @@ const LabelTab = () => { // update the displayedEntries according to the active filter useEffect(() => { if (!timelineMap) return setDisplayedEntries(null); - const allEntries = Array.from(timelineMap.values()); + const allEntries = Array.from(timelineMap.values()); const [newTimelineLabelMap, newTimelineNotesMap] = mapInputsToTimelineEntries( allEntries, appConfig, diff --git a/www/js/diary/LabelTabContext.ts b/www/js/diary/LabelTabContext.ts index e5715bf52..944ba19df 100644 --- a/www/js/diary/LabelTabContext.ts +++ b/www/js/diary/LabelTabContext.ts @@ -1,5 +1,5 @@ import { createContext } from 'react'; -import { TimelineEntry, UnprocessedUserInput } from '../types/diaryTypes'; +import { TimelineEntry, UserInputEntry } from '../types/diaryTypes'; import { LabelOption } from '../survey/multilabel/confirmHelper'; export type TimelineMap = Map; @@ -7,15 +7,16 @@ 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?: UnprocessedUserInput; - } & { + SURVEY?: UserInputEntry; /* all other keys, (e.g. 'MODE', 'PURPOSE') are from the MULTILABEL configuration and use a LabelOption for the user input value */ - [k: string]: LabelOption; + MODE?: LabelOption; + PURPOSE?: LabelOption; + REPLACED_MODE?: LabelOption; }; }; export type TimelineNotesMap = { - [k: string]: UnprocessedUserInput[]; + [k: string]: UserInputEntry[]; }; type ContextProps = { diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index ba5dd9a7b..faa123803 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -3,7 +3,7 @@ import { getAngularService } from "../angular-react-helper"; import { displayError, logDebug } from "../plugin/logger"; import { getBaseModeByKey, getBaseModeByValue } from "./diaryHelper"; import i18next from "i18next"; -import { UnprocessedUserInput } from "../types/diaryTypes"; +import { UserInputEntry } from "../types/diaryTypes"; import { getLabelInputDetails, getLabelInputs } from "../survey/multilabel/confirmHelper"; import { getNotDeletedCandidates, getUniqueEntries } from "../survey/inputMatcher"; @@ -75,9 +75,9 @@ export function compositeTrips2TimelineMap(ctList: any[], unpackPlaces?: boolean /* '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]: UnprocessedUserInput[] } = {}; +export let unprocessedLabels: { [key: string]: UserInputEntry[] } = {}; /* 'NOTES' are 1:n - each trip or place can have any number of notes */ -export let unprocessedNotes: UnprocessedUserInput[] = []; +export let unprocessedNotes: UserInputEntry[] = []; const getUnprocessedInputQuery = (pipelineRange) => ({ key: "write_ts", diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index 2dc937949..929fb269a 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -1,17 +1,18 @@ import { logDebug, displayErrorMsg } from "../plugin/logger" import { DateTime } from "luxon"; -import { CompositeTrip, TimelineEntry, UnprocessedUserInput } from "../types/diaryTypes"; +import { CompositeTrip, TimelineEntry, UserInputEntry } from "../types/diaryTypes"; import { keysForLabelInputs, unprocessedLabels, unprocessedNotes } from "../diary/timelineHelper"; import { LabelOption, MultilabelKey, getLabelInputDetails, getLabelInputs, getLabelOptions, inputType2retKey, labelKeyToRichMode, labelOptions } 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: UnprocessedUserInput): string => `${fmtTs(ui.data.start_ts, ui.metadata.time_zone)} (${ui.data.start_ts}) -> +export const printUserInput = (ui: UserInputEntry): string => `${fmtTs(ui.data.start_ts, ui.metadata.time_zone)} (${ui.data.start_ts}) -> ${fmtTs(ui.data.end_ts, ui.metadata.time_zone)} (${ui.data.end_ts}) ${ui.data.label} logged at ${ui.metadata.write_ts}`; -export const validUserInputForDraftTrip = (trip: CompositeTrip, userInput: UnprocessedUserInput, logsEnabled: boolean): boolean => { +export const validUserInputForDraftTrip = (trip: CompositeTrip, userInput: UserInputEntry, logsEnabled: boolean): boolean => { if(logsEnabled) { logDebug(`Draft trip: comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} @@ -32,7 +33,7 @@ export const validUserInputForDraftTrip = (trip: CompositeTrip, userInput: Unpro export const validUserInputForTimelineEntry = ( tlEntry: TimelineEntry, nextEntry: TimelineEntry | null, - userInput: UnprocessedUserInput, + userInput: UserInputEntry, logsEnabled: boolean, ): boolean => { if (!tlEntry.origin_key) return false; @@ -107,7 +108,7 @@ export const validUserInputForTimelineEntry = ( } // parallels get_not_deleted_candidates() in trip_queries.py -export const getNotDeletedCandidates = (candidates: UnprocessedUserInput[]): UnprocessedUserInput[] => { +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 @@ -124,8 +125,8 @@ export const getNotDeletedCandidates = (candidates: UnprocessedUserInput[]): Unp export const getUserInputForTimelineEntry = ( entry: TimelineEntry, nextEntry: TimelineEntry | null, - userInputList: UnprocessedUserInput[], -): undefined | UnprocessedUserInput => { + userInputList: UserInputEntry[], +): undefined | UserInputEntry => { const logsEnabled = userInputList?.length < 20; if (userInputList === undefined) { logDebug("In getUserInputForTimelineEntry, no user input, returning undefined"); @@ -162,8 +163,8 @@ export const getUserInputForTimelineEntry = ( export const getAdditionsForTimelineEntry = ( entry: TimelineEntry, nextEntry: TimelineEntry | null, - additionsList: UnprocessedUserInput[], -): UnprocessedUserInput[] => { + additionsList: UserInputEntry[], +): UserInputEntry[] => { const logsEnabled = additionsList?.length < 20; if (additionsList === undefined) { @@ -217,9 +218,9 @@ export const getUniqueEntries = (combinedList) => { * @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): [{ [k: string]: { [k: string]: UnprocessedUserInput | LabelOption } }, { [k: string]: UnprocessedUserInput[] }] { - const timelineLabelMap: { [k: string]: { [k: string]: UnprocessedUserInput | LabelOption } } = {}; - const timelineNotesMap: { [k: string]: UnprocessedUserInput[] } = {}; +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; diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index cdb42d64d..cd3f8cd8a 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -4,43 +4,72 @@ 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 + _id: ObjectId, + additions: UserInputEntry[], cleaned_section_summary: SectionSummary, - cleaned_trip: {$oid: string}, + 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: SectionSummary, - inferred_trip: {$oid: string}, + 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: UnprocessedUserInput, + 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, @@ -68,7 +97,7 @@ export type SectionSummary = { duration: {[k: MotionTypeKey | BaseModeKey]: number}, } -export type UnprocessedUserInput = { +export type UserInputEntry = { data: { end_ts: number, start_ts: number From 22b80f97e508fe89857fac1d8b6f850c8161ba9d Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Tue, 31 Oct 2023 13:44:18 -0400 Subject: [PATCH 13/20] fix diaryHelper.test.ts Since 8712380a29af1d748fd4e7bb284353b7393a7e98, the getDetectedModes function no longer goes through the sections of a trip and sums up distances to get percentages. We now use cleaned_section_summary and inferred_section_summary which are computed on the server for us. Updating the test to reflect this causes it to pass again. --- www/__tests__/diaryHelper.test.ts | 35 ++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/www/__tests__/diaryHelper.test.ts b/www/__tests__/diaryHelper.test.ts index 822b19bba..6f49c2720 100644 --- a/www/__tests__/diaryHelper.test.ts +++ b/www/__tests__/diaryHelper.test.ts @@ -30,15 +30,30 @@ 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 -let myFakeTrip = {sections: [ - { "sensed_mode_str": "BICYCLING", "distance": 6013.73657416706 }, - { "sensed_mode_str": "WALKING", "distance": 715.3078629361006 } -]}; -let myFakeTrip2 = {sections: [ - { "sensed_mode_str": "BICYCLING", "distance": 6013.73657416706 }, - { "sensed_mode_str": "BICYCLING", "distance": 715.3078629361006 } -]}; +/* fake trips with 'distance' in their section summaries + ('count' and 'duration' are not used bygetDetectedModes) */ +let myFakeTrip = { + distance: 6729.0444371031606, + cleaned_section_summary: { + // count: {...} + // duration: {...} + distance: { + BICYCLING: 6013.73657416706, + WALKING: 715.3078629361006, + }, + }, +} as any; + +let myFakeTrip2 = { + ...myFakeTrip, + inferred_section_summary: { + // count: {...} + // duration: {...} + distance: { + BICYCLING: 6729.0444371031606, + }, + }, +}; let myFakeDetectedModes = [ { mode: "BICYCLING", @@ -59,5 +74,5 @@ let myFakeDetectedModes2 = [ 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 }) From 51108457028fa6c15006b50eb88eec8f98e8139d Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Thu, 2 Nov 2023 16:54:36 -0400 Subject: [PATCH 14/20] apply prettier to PR #1086 --- locales | 1 + www/__tests__/diaryHelper.test.ts | 2 +- www/__tests__/inputMatcher.test.ts | 465 +++++++++--------- www/i18n/en.json | 2 +- www/js/diary.js | 9 +- www/js/diary/LabelTab.tsx | 60 ++- www/js/diary/LabelTabContext.ts | 28 +- www/js/diary/cards/ModesIndicator.tsx | 12 +- www/js/diary/cards/PlaceCard.tsx | 18 +- .../details/TripSectionsDescriptives.tsx | 32 +- www/js/diary/diaryHelper.ts | 24 +- www/js/diary/list/DateSelect.tsx | 16 +- www/js/diary/list/LabelListScreen.tsx | 12 +- www/js/diary/timelineHelper.ts | 33 +- www/js/survey/enketo/AddNoteButton.tsx | 21 +- www/js/survey/enketo/AddedNotesList.tsx | 18 +- www/js/survey/enketo/UserInputButton.tsx | 23 +- .../survey/enketo/infinite_scroll_filters.ts | 14 +- www/js/survey/inputMatcher.ts | 342 +++++++------ www/js/survey/multilabel/confirmHelper.ts | 46 +- .../multilabel/infinite_scroll_filters.ts | 29 +- www/js/types/diaryTypes.ts | 148 +++--- 22 files changed, 733 insertions(+), 622 deletions(-) create mode 160000 locales diff --git a/locales b/locales new file mode 160000 index 000000000..7a62b7866 --- /dev/null +++ b/locales @@ -0,0 +1 @@ +Subproject commit 7a62b7866e549fad40217f2229f80e500bc61494 diff --git a/www/__tests__/diaryHelper.test.ts b/www/__tests__/diaryHelper.test.ts index ebcd7e5cf..26ed03a8f 100644 --- a/www/__tests__/diaryHelper.test.ts +++ b/www/__tests__/diaryHelper.test.ts @@ -94,4 +94,4 @@ it('returns the detected modes, with percentages, for a trip', () => { expect(getDetectedModes(myFakeTrip)).toEqual(myFakeDetectedModes); expect(getDetectedModes(myFakeTrip2)).toEqual(myFakeDetectedModes2); 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 1550686eb..b4082fb33 100644 --- a/www/__tests__/inputMatcher.test.ts +++ b/www/__tests__/inputMatcher.test.ts @@ -1,261 +1,268 @@ -import { - fmtTs, - printUserInput, - validUserInputForDraftTrip, - validUserInputForTimelineEntry, - getNotDeletedCandidates, - getUserInputForTimelineEntry, - getAdditionsForTimelineEntry, - getUniqueEntries +import { + fmtTs, + printUserInput, + validUserInputForDraftTrip, + validUserInputForTimelineEntry, + getNotDeletedCandidates, + getUserInputForTimelineEntry, + getAdditionsForTimelineEntry, + getUniqueEntries, } from '../js/survey/inputMatcher'; import { CompositeTrip, TimelineEntry, UserInputEntry } from '../js/types/diaryTypes'; describe('input-matcher', () => { - let userTrip: UserInputEntry; - let trip: TimelineEntry; - let nextTrip: TimelineEntry; + let userTrip: UserInputEntry; + let trip: TimelineEntry; + let nextTrip: TimelineEntry; - beforeEach(() => { - /* + beforeEach(() => { + /* Create a valid userTrip and trip object before each test case. The trip data is from the 'real_examples' data (shankari_2015-07-22) on the server. For some test cases, I need to generate fake data, such as labels, keys, and origin_keys. In such cases, I referred to 'TestUserInputFakeData.py' on the server. */ - userTrip = { - data: { - end_ts: 1437604764, - start_ts: 1437601247, - label: 'FOO', - status: 'ACTIVE' - }, - metadata: { - time_zone: 'America/Los_Angeles', - plugin: 'none', - write_ts: 1695921991, - platform: 'ios', - read_ts: 0, - key: 'manual/mode_confirm' - }, - key: 'manual/place' - }, - trip = { - key: 'FOO', - origin_key: 'FOO', - start_ts: 1437601000, - end_ts: 1437605000, - enter_ts: 1437605000, - exit_ts: 1437605000, - duration: 100, - }, - nextTrip = { - key: 'BAR', - origin_key: 'BAR', - start_ts: 1437606000, - end_ts: 1437607000, - enter_ts: 1437607000, - exit_ts: 1437607000, - duration: 100, - }, + (userTrip = { + data: { + end_ts: 1437604764, + start_ts: 1437601247, + label: 'FOO', + status: 'ACTIVE', + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695921991, + platform: 'ios', + read_ts: 0, + key: 'manual/mode_confirm', + }, + key: 'manual/place', + }), + (trip = { + key: 'FOO', + origin_key: 'FOO', + start_ts: 1437601000, + end_ts: 1437605000, + enter_ts: 1437605000, + exit_ts: 1437605000, + duration: 100, + }), + (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 }); + }); - // mock Logger - window['Logger'] = { log: console.log }; - }); + it('tests fmtTs with valid input', () => { + const pstTime = fmtTs(1437601247.8459613, 'America/Los_Angeles'); + const estTime = fmtTs(1437601247.8459613, 'America/New_York'); - it('tests fmtTs with valid input', () => { - const pstTime = fmtTs(1437601247.8459613, 'America/Los_Angeles'); - const estTime = fmtTs(1437601247.8459613, 'America/New_York'); - - // Check if it contains correct year-mm-dd hr:mm - expect(pstTime).toContain('2015-07-22T14:40'); - expect(estTime).toContain('2015-07-22T17:40'); - }); + // Check if it contains correct year-mm-dd hr:mm + expect(pstTime).toContain('2015-07-22T14:40'); + expect(estTime).toContain('2015-07-22T17:40'); + }); - it('tests fmtTs with invalid input', () => { - const formattedTime = fmtTs(0, ''); - expect(formattedTime).toBeFalsy(); - }); + it('tests fmtTs with invalid input', () => { + const formattedTime = fmtTs(0, ''); + expect(formattedTime).toBeFalsy(); + }); - it('tests printUserInput prints the trip log correctly', () => { - const userTripLog = printUserInput(userTrip); - expect(userTripLog).toContain('1437604764'); - expect(userTripLog).toContain('1437601247'); - expect(userTripLog).toContain('FOO'); - }); + it('tests printUserInput prints the trip log correctly', () => { + const userTripLog = printUserInput(userTrip); + expect(userTripLog).toContain('1437604764'); + expect(userTripLog).toContain('1437601247'); + expect(userTripLog).toContain('FOO'); + }); - it('tests validUserInputForDraftTrip with valid trip input', () => { - const validTrp = { - end_ts: 1437604764, - start_ts: 1437601247 - } as CompositeTrip; - const validUserInput = validUserInputForDraftTrip(validTrp, userTrip, false); - expect(validUserInput).toBeTruthy(); - }); + it('tests validUserInputForDraftTrip with valid trip input', () => { + const validTrp = { + end_ts: 1437604764, + start_ts: 1437601247, + } as CompositeTrip; + const validUserInput = validUserInputForDraftTrip(validTrp, userTrip, false); + expect(validUserInput).toBeTruthy(); + }); - it('tests validUserInputForDraftTrip with invalid trip input', () => { - const invalidTrip = { - end_ts: 0, - start_ts: 0 - } as CompositeTrip; - const invalidUserInput = validUserInputForDraftTrip(invalidTrip, userTrip, false); - expect(invalidUserInput).toBeFalsy(); - }); + it('tests validUserInputForDraftTrip with invalid trip input', () => { + const invalidTrip = { + end_ts: 0, + start_ts: 0, + } as CompositeTrip; + const invalidUserInput = validUserInputForDraftTrip(invalidTrip, userTrip, false); + expect(invalidUserInput).toBeFalsy(); + }); - it('tests validUserInputForTimelineEntry with valid trip object', () => { - // 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, nextTrip, userTrip, false); - expect(validTimelineEntry).toBeTruthy(); - }); + it('tests validUserInputForTimelineEntry with valid trip object', () => { + // 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, nextTrip, userTrip, false); + expect(validTimelineEntry).toBeTruthy(); + }); - it('tests validUserInputForTimelineEntry with tlEntry with invalid key and origin_key', () => { - const invalidTlEntry = trip; - const invalidTimelineEntry = validUserInputForTimelineEntry(invalidTlEntry, null, userTrip, false); - expect(invalidTimelineEntry).toBeFalsy(); - }); + it('tests validUserInputForTimelineEntry with tlEntry with invalid key and origin_key', () => { + const invalidTlEntry = trip; + const invalidTimelineEntry = validUserInputForTimelineEntry( + invalidTlEntry, + null, + userTrip, + false, + ); + expect(invalidTimelineEntry).toBeFalsy(); + }); - it('tests validUserInputForTimelineEntry with tlEntry with invalie start & end time', () => { - const invalidTlEntry: TimelineEntry = { - key: 'analysis/confirmed_place', - origin_key: 'analysis/confirmed_place', - start_ts: 1, - end_ts: 1, - enter_ts: 1, - exit_ts: 1, - duration: 1, - } - const invalidTimelineEntry = validUserInputForTimelineEntry(invalidTlEntry, null, userTrip, false); - expect(invalidTimelineEntry).toBeFalsy(); - }); + it('tests validUserInputForTimelineEntry with tlEntry with invalie start & end time', () => { + const invalidTlEntry: TimelineEntry = { + key: 'analysis/confirmed_place', + origin_key: 'analysis/confirmed_place', + start_ts: 1, + end_ts: 1, + enter_ts: 1, + exit_ts: 1, + duration: 1, + }; + const invalidTimelineEntry = validUserInputForTimelineEntry( + invalidTlEntry, + null, + userTrip, + false, + ); + expect(invalidTimelineEntry).toBeFalsy(); + }); - it('tests getNotDeletedCandidates called with 0 candidates', () => { - jest.spyOn(console, 'log'); - const candidates = getNotDeletedCandidates([]); - - // check if the log printed collectly with - expect(console.log).toHaveBeenCalledWith('getNotDeletedCandidates called with 0 candidates'); - expect(candidates).toStrictEqual([]); + it('tests getNotDeletedCandidates called with 0 candidates', () => { + jest.spyOn(console, 'log'); + const candidates = getNotDeletedCandidates([]); - }); + // check if the log printed collectly with + expect(console.log).toHaveBeenCalledWith('getNotDeletedCandidates called with 0 candidates'); + expect(candidates).toStrictEqual([]); + }); - it('tests getNotDeletedCandidates called with multiple candidates', () => { - const activeTrip = userTrip; - const deletedTrip = { - data: { - end_ts: 1437604764, - start_ts: 1437601247, - label: 'FOO', - status: 'DELETED', - match_id: 'FOO' - }, - metadata: { - time_zone: 'America/Los_Angeles', - plugin: 'none', - write_ts: 1695921991, - platform: 'ios', - read_ts: 0, - key: 'manual/mode_confirm' - }, - key: 'manual/place' - } - const candidates = [ activeTrip, deletedTrip ]; - const validCandidates = getNotDeletedCandidates(candidates); + it('tests getNotDeletedCandidates called with multiple candidates', () => { + const activeTrip = userTrip; + const deletedTrip = { + data: { + end_ts: 1437604764, + start_ts: 1437601247, + label: 'FOO', + status: 'DELETED', + match_id: 'FOO', + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695921991, + platform: 'ios', + read_ts: 0, + key: 'manual/mode_confirm', + }, + key: 'manual/place', + }; + const candidates = [activeTrip, deletedTrip]; + const validCandidates = getNotDeletedCandidates(candidates); - // check if the result has only 'ACTIVE' data - expect(validCandidates).toHaveLength(1); - expect(validCandidates[0]).toMatchObject(userTrip); + // check if the result has only 'ACTIVE' data + expect(validCandidates).toHaveLength(1); + expect(validCandidates[0]).toMatchObject(userTrip); + }); - }); + it('tests getUserInputForTrip with valid userInputList', () => { + const userInputWriteFirst = { + data: { + end_ts: 1437607732, + label: 'bus', + start_ts: 1437606026, + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695830232, + platform: 'ios', + read_ts: 0, + key: 'manual/mode_confirm', + type: 'message', + }, + }; + const userInputWriteSecond = { + data: { + end_ts: 1437598393, + label: 'e-bike', + start_ts: 1437596745, + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695838268, + platform: 'ios', + read_ts: 0, + key: 'manual/mode_confirm', + type: 'message', + }, + }; + const userInputWriteThird = { + data: { + end_ts: 1437604764, + label: 'e-bike', + start_ts: 1437601247, + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695921991, + platform: 'ios', + read_ts: 0, + key: 'manual/mode_confirm', + type: 'message', + }, + }; - it('tests getUserInputForTrip with valid userInputList', () => { - const userInputWriteFirst = { - data: { - end_ts: 1437607732, - label: 'bus', - start_ts: 1437606026 - }, - metadata: { - time_zone: 'America/Los_Angeles', - plugin: 'none', - write_ts: 1695830232, - platform: 'ios', - read_ts: 0, - key:'manual/mode_confirm', - type:'message' - } - } - const userInputWriteSecond = { - data: { - end_ts: 1437598393, - label: 'e-bike', - start_ts: 1437596745 - }, - metadata: { - time_zone: 'America/Los_Angeles', - plugin: 'none', - write_ts: 1695838268, - platform: 'ios', - read_ts: 0, - key:'manual/mode_confirm', - type:'message' - } - } - const userInputWriteThird = { - data: { - end_ts: 1437604764, - label: 'e-bike', - start_ts: 1437601247 - }, - metadata: { - time_zone: 'America/Los_Angeles', - plugin: 'none', - write_ts: 1695921991, - platform: 'ios', - read_ts: 0, - key:'manual/mode_confirm', - type:'message' - } - } + // make the linst unsorted and then check if userInputWriteThird(latest one) is return output + const userInputList = [userInputWriteSecond, userInputWriteThird, userInputWriteFirst]; + const mostRecentEntry = getUserInputForTimelineEntry(trip, nextTrip, userInputList); + expect(mostRecentEntry).toMatchObject(userInputWriteThird); + }); - // make the linst unsorted and then check if userInputWriteThird(latest one) is return output - const userInputList = [userInputWriteSecond, userInputWriteThird, userInputWriteFirst]; - const mostRecentEntry = getUserInputForTimelineEntry(trip, nextTrip, userInputList); - expect(mostRecentEntry).toMatchObject(userInputWriteThird); - }); + it('tests getUserInputForTrip with invalid userInputList', () => { + const userInputList = undefined; + const mostRecentEntry = getUserInputForTimelineEntry(trip, nextTrip, userInputList); + expect(mostRecentEntry).toBe(undefined); + }); - it('tests getUserInputForTrip with invalid userInputList', () => { - const userInputList = undefined; - const mostRecentEntry = getUserInputForTimelineEntry(trip, nextTrip, userInputList); - expect(mostRecentEntry).toBe(undefined); - }); + it('tests getAdditionsForTimelineEntry with valid additionsList', () => { + const additionsList = new Array(5).fill(userTrip); + trip['key'] = 'analysis/confirmed_place'; + trip['origin_key'] = 'analysis/confirmed_place'; - it('tests getAdditionsForTimelineEntry with valid additionsList', () => { - const additionsList = new Array(5).fill(userTrip); - trip['key'] = 'analysis/confirmed_place'; - trip['origin_key'] = 'analysis/confirmed_place'; + // check if the result keep the all valid userTrip items + const matchingAdditions = getAdditionsForTimelineEntry(trip, nextTrip, additionsList); + expect(matchingAdditions).toHaveLength(5); + }); - // check if the result keep the all valid userTrip items - const matchingAdditions = getAdditionsForTimelineEntry(trip, nextTrip, additionsList); - expect(matchingAdditions).toHaveLength(5); - }); + it('tests getAdditionsForTimelineEntry with invalid additionsList', () => { + const additionsList = undefined; + const matchingAdditions = getAdditionsForTimelineEntry(trip, nextTrip, additionsList); + expect(matchingAdditions).toMatchObject([]); + }); - it('tests getAdditionsForTimelineEntry with invalid additionsList', () => { - const additionsList = undefined; - const matchingAdditions = getAdditionsForTimelineEntry(trip, nextTrip, additionsList); - expect(matchingAdditions).toMatchObject([]); - }); + it('tests getUniqueEntries with valid combinedList', () => { + const combinedList = new Array(5).fill(userTrip); - it('tests getUniqueEntries with valid combinedList', () => { - const combinedList = new Array(5).fill(userTrip); + // check if the result keeps only unique userTrip items + const uniqueEntires = getUniqueEntries(combinedList); + expect(uniqueEntires).toHaveLength(1); + }); - // check if the result keeps only unique userTrip items - const uniqueEntires = getUniqueEntries(combinedList); - expect(uniqueEntires).toHaveLength(1); - }); - - it('tests getUniqueEntries with empty combinedList', () => { - const uniqueEntires = getUniqueEntries([]); - expect(uniqueEntires).toMatchObject([]); - }); -}) + it('tests getUniqueEntries with empty combinedList', () => { + const uniqueEntires = getUniqueEntries([]); + expect(uniqueEntires).toMatchObject([]); + }); +}); diff --git a/www/i18n/en.json b/www/i18n/en.json index 3278340f6..5238021dc 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -460,7 +460,7 @@ "while-loading-another-week": "Error while loading travel of {{when}} week", "while-loading-specific-week": "Error while loading travel for the week of {{day}}", "while-log-messages": "While getting messages from the log ", - "while-max-index" : "While getting max index " + "while-max-index": "While getting max index " }, "consent": { "header": "Consent", diff --git a/www/js/diary.js b/www/js/diary.js index c1fd1963c..c580ad8f2 100644 --- a/www/js/diary.js +++ b/www/js/diary.js @@ -1,9 +1,12 @@ import angular from 'angular'; import LabelTab from './diary/LabelTab'; -angular.module('emission.main.diary',['emission.main.diary.services', - 'emission.plugin.logger', - 'emission.survey.enketo.answer']) +angular + .module('emission.main.diary', [ + 'emission.main.diary.services', + 'emission.plugin.logger', + 'emission.survey.enketo.answer', + ]) .config(function ($stateProvider) { $stateProvider.state('root.main.inf_scroll', { diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index a9fd0d921..882a708b2 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -6,26 +6,36 @@ share the data that has been loaded and interacted with. */ -import React, { useEffect, useState, useRef } from "react"; -import { getAngularService } from "../angular-react-helper"; -import useAppConfig from "../useAppConfig"; -import { useTranslation } from "react-i18next"; -import { invalidateMaps } from "../components/LeafletView"; -import moment from "moment"; -import LabelListScreen from "./list/LabelListScreen"; -import { createStackNavigator } from "@react-navigation/stack"; -import LabelScreenDetails from "./details/LabelDetailsScreen"; -import { NavigationContainer } from "@react-navigation/native"; -import { compositeTrips2TimelineMap, updateAllUnprocessedInputs, updateLocalUnprocessedInputs, unprocessedLabels, unprocessedNotes } from "./timelineHelper"; -import { fillLocationNamesOfTrip, resetNominatimLimiter } from "./addressNamesHelper"; -import { getLabelOptions } from "../survey/multilabel/confirmHelper"; -import { displayError, logDebug } 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"; +import React, { useEffect, useState, useRef } from 'react'; +import { getAngularService } from '../angular-react-helper'; +import useAppConfig from '../useAppConfig'; +import { useTranslation } from 'react-i18next'; +import { invalidateMaps } from '../components/LeafletView'; +import moment from 'moment'; +import LabelListScreen from './list/LabelListScreen'; +import { createStackNavigator } from '@react-navigation/stack'; +import LabelScreenDetails from './details/LabelDetailsScreen'; +import { NavigationContainer } from '@react-navigation/native'; +import { + compositeTrips2TimelineMap, + updateAllUnprocessedInputs, + updateLocalUnprocessedInputs, + unprocessedLabels, + unprocessedNotes, +} from './timelineHelper'; +import { fillLocationNamesOfTrip, resetNominatimLimiter } from './addressNamesHelper'; +import { getLabelOptions } from '../survey/multilabel/confirmHelper'; +import { displayError, logDebug } 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 showPlaces; const ONE_DAY = 24 * 60 * 60; // seconds @@ -64,7 +74,8 @@ const LabelTab = () => { ? enketoConfiguredFilters : multilabelConfiguredFilters; const allFalseFilters = tripFilters.map((f, i) => ({ - ...f, state: (i == 0 ? true : false) // only the first filter will have state true on init + ...f, + state: i == 0 ? true : false, // only the first filter will have state true on init })); setFilterInputs(allFalseFilters); } @@ -88,7 +99,7 @@ const LabelTab = () => { let entriesToDisplay = allEntries; if (activeFilter) { const entriesAfterFilter = allEntries.filter( - t => t.justRepopulated || activeFilter?.filter(t, newTimelineLabelMap[t._id.$oid]) + (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 */ @@ -244,10 +255,11 @@ const LabelTab = () => { const timelineMapRef = useRef(timelineMap); async function repopulateTimelineEntry(oid: string) { - if (!timelineMap.has(oid)) return console.error("Item with oid: " + oid + " not found in timeline"); + if (!timelineMap.has(oid)) + return console.error('Item with oid: ' + oid + ' not found in timeline'); await updateLocalUnprocessedInputs(pipelineRange, appConfig); const repopTime = new Date().getTime(); - const newEntry = {...timelineMap.get(oid), justRepopulated: repopTime}; + const newEntry = { ...timelineMap.get(oid), justRepopulated: repopTime }; const newTimelineMap = new Map(timelineMap).set(oid, newEntry); setTimelineMap(newTimelineMap); diff --git a/www/js/diary/LabelTabContext.ts b/www/js/diary/LabelTabContext.ts index 944ba19df..24d7ade41 100644 --- a/www/js/diary/LabelTabContext.ts +++ b/www/js/diary/LabelTabContext.ts @@ -20,20 +20,20 @@ export type TimelineNotesMap = { }; 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 + 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 15e300175..89366630d 100644 --- a/www/js/diary/cards/ModesIndicator.tsx +++ b/www/js/diary/cards/ModesIndicator.tsx @@ -1,6 +1,6 @@ import React, { useContext } from 'react'; import { View, StyleSheet } from 'react-native'; -import color from "color"; +import color from 'color'; import LabelTabContext from '../LabelTabContext'; import { logDebug } from '../../plugin/logger'; import { getBaseModeByValue } from '../diaryHelper'; @@ -25,8 +25,14 @@ const ModesIndicator = ({ trip, detectedModes }) => { modeViews = ( - + {timelineLabelMap[trip._id.$oid]?.MODE.text} diff --git a/www/js/diary/cards/PlaceCard.tsx b/www/js/diary/cards/PlaceCard.tsx index e5db2644c..52ad37c44 100644 --- a/www/js/diary/cards/PlaceCard.tsx +++ b/www/js/diary/cards/PlaceCard.tsx @@ -6,17 +6,17 @@ PlaceCards use the blueish 'place' theme flavor. */ -import React, { useContext } from "react"; +import React, { useContext } from 'react'; import { View, StyleSheet } from 'react-native'; import { Text } from 'react-native-paper'; -import useAppConfig from "../../useAppConfig"; -import AddNoteButton from "../../survey/enketo/AddNoteButton"; -import AddedNotesList from "../../survey/enketo/AddedNotesList"; -import { getTheme } from "../../appTheme"; -import { DiaryCard, cardStyles } from "./DiaryCard"; -import { useAddressNames } from "../addressNamesHelper"; -import useDerivedProperties from "../useDerivedProperties"; -import StartEndLocations from "../components/StartEndLocations"; +import useAppConfig from '../../useAppConfig'; +import AddNoteButton from '../../survey/enketo/AddNoteButton'; +import AddedNotesList from '../../survey/enketo/AddedNotesList'; +import { getTheme } from '../../appTheme'; +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 } }; diff --git a/www/js/diary/details/TripSectionsDescriptives.tsx b/www/js/diary/details/TripSectionsDescriptives.tsx index 579e684dc..53aad9d34 100644 --- a/www/js/diary/details/TripSectionsDescriptives.tsx +++ b/www/js/diary/details/TripSectionsDescriptives.tsx @@ -6,11 +6,15 @@ import useDerivedProperties from '../useDerivedProperties'; import { getBaseModeByKey, getBaseModeByValue } from '../diaryHelper'; import LabelTabContext from '../LabelTabContext'; -const TripSectionsDescriptives = ({ trip, showLabeledMode=false }) => { - +const TripSectionsDescriptives = ({ trip, showLabeledMode = false }) => { const { labelOptions, timelineLabelMap } = useContext(LabelTabContext); - const { displayStartTime, displayTime, formattedDistance, - distanceSuffix, formattedSectionProperties } = useDerivedProperties(trip); + const { + displayStartTime, + displayTime, + formattedDistance, + distanceSuffix, + formattedSectionProperties, + } = useDerivedProperties(trip); const { colors } = useTheme(); @@ -18,21 +22,23 @@ const TripSectionsDescriptives = ({ trip, showLabeledMode=false }) => { let sections = formattedSectionProperties; /* if we're only showing the labeled mode, or there are no sections (i.e. unprocessed trip), we treat this as unimodal and use trip-level attributes to construct a single section */ - if (showLabeledMode && labeledModeForTrip || !trip.sections?.length) { + if ((showLabeledMode && labeledModeForTrip) || !trip.sections?.length) { let baseMode; if (showLabeledMode && labeledModeForTrip) { baseMode = getBaseModeByValue(labeledModeForTrip.value, labelOptions); } else { baseMode = getBaseModeByKey('UNPROCESSED'); } - sections = [{ - startTime: displayStartTime, - duration: displayTime, - distance: formattedDistance, - color: baseMode.color, - icon: baseMode.icon, - text: showLabeledMode && labeledModeForTrip?.text, // label text only shown for labeled trips - }]; + sections = [ + { + startTime: displayStartTime, + duration: displayTime, + distance: formattedDistance, + color: baseMode.color, + icon: baseMode.icon, + text: showLabeledMode && labeledModeForTrip?.text, // label text only shown for labeled trips + }, + ]; } return ( diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts index 4d83f4281..616974b7b 100644 --- a/www/js/diary/diaryHelper.ts +++ b/www/js/diary/diaryHelper.ts @@ -1,9 +1,9 @@ // here we have some helper functions used throughout the label tab // these functions are being gradually migrated out of services.js -import moment from "moment"; -import { DateTime } from "luxon"; -import { LabelOptions, readableLabelToKey } from "../survey/multilabel/confirmHelper"; +import moment from 'moment'; +import { DateTime } from 'luxon'; +import { LabelOptions, readableLabelToKey } from '../survey/multilabel/confirmHelper'; import { CompositeTrip } from '../types/diaryTypes'; export const modeColors = { @@ -25,10 +25,20 @@ type BaseMode = { }; // parallels the server-side MotionTypes enum: https://github.com/e-mission/e-mission-server/blob/94e7478e627fa8c171323662f951c611c0993031/emission/core/wrapper/motionactivity.py#L12 -export type MotionTypeKey = 'IN_VEHICLE' | 'BICYCLING' | 'ON_FOOT' | 'STILL' | 'UNKNOWN' | 'TILTING' - | 'WALKING' | 'RUNNING' | 'NONE' | 'STOPPED_WHILE_IN_VEHICLE' | 'AIR_OR_HSR'; - -const BaseModes: {[k: string]: BaseMode} = { +export type MotionTypeKey = + | 'IN_VEHICLE' + | 'BICYCLING' + | 'ON_FOOT' + | 'STILL' + | 'UNKNOWN' + | 'TILTING' + | 'WALKING' + | 'RUNNING' + | 'NONE' + | 'STOPPED_WHILE_IN_VEHICLE' + | 'AIR_OR_HSR'; + +const BaseModes: { [k: string]: BaseMode } = { // BEGIN MotionTypes IN_VEHICLE: { name: 'IN_VEHICLE', icon: 'speedometer', color: modeColors.red }, BICYCLING: { name: 'BICYCLING', icon: 'bike', color: modeColors.green }, diff --git a/www/js/diary/list/DateSelect.tsx b/www/js/diary/list/DateSelect.tsx index 0aa79f05e..91f2f4fb5 100644 --- a/www/js/diary/list/DateSelect.tsx +++ b/www/js/diary/list/DateSelect.tsx @@ -6,15 +6,15 @@ and allows the user to select a date. */ -import React, { useEffect, useState, useMemo, useContext } from "react"; -import { StyleSheet } from "react-native"; -import moment from "moment"; +import React, { useEffect, useState, useMemo, useContext } from 'react'; +import { StyleSheet } from 'react-native'; +import moment from 'moment'; import LabelTabContext from '../LabelTabContext'; -import { DatePickerModal } from "react-native-paper-dates"; -import { Text, Divider, useTheme } from "react-native-paper"; -import i18next from "i18next"; -import { useTranslation } from "react-i18next"; -import NavBarButton from "../../components/NavBarButton"; +import { DatePickerModal } from 'react-native-paper-dates'; +import { Text, Divider, useTheme } from 'react-native-paper'; +import i18next from 'i18next'; +import { useTranslation } from 'react-i18next'; +import NavBarButton from '../../components/NavBarButton'; const DateSelect = ({ tsRange, loadSpecificWeekFn }) => { const { pipelineRange } = useContext(LabelTabContext); diff --git a/www/js/diary/list/LabelListScreen.tsx b/www/js/diary/list/LabelListScreen.tsx index 897cfbfc1..eb50c05a0 100644 --- a/www/js/diary/list/LabelListScreen.tsx +++ b/www/js/diary/list/LabelListScreen.tsx @@ -1,9 +1,9 @@ -import React, { useContext } from "react"; -import { View } from "react-native"; -import { Appbar, useTheme } from "react-native-paper"; -import DateSelect from "./DateSelect"; -import FilterSelect from "./FilterSelect"; -import TimelineScrollList from "./TimelineScrollList"; +import React, { useContext } from 'react'; +import { View } from 'react-native'; +import { Appbar, useTheme } from 'react-native-paper'; +import DateSelect from './DateSelect'; +import FilterSelect from './FilterSelect'; +import TimelineScrollList from './TimelineScrollList'; import LabelTabContext from '../LabelTabContext'; const LabelListScreen = () => { diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 5ad5dc103..d6e36c397 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -1,11 +1,11 @@ -import moment from "moment"; -import { getAngularService } from "../angular-react-helper"; -import { displayError, logDebug } from "../plugin/logger"; -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"; +import moment from 'moment'; +import { getAngularService } from '../angular-react-helper'; +import { displayError, logDebug } from '../plugin/logger'; +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(); /** @@ -99,8 +99,8 @@ function updateUnprocessedInputs(labelsPromises, notesPromises, appConfig) { }); // 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 + unprocessedNotes = combinedNotes.filter( + (note, i, self) => self.findIndex((n) => n.metadata.write_ts == note.metadata.write_ts) == i, ); }); } @@ -110,17 +110,17 @@ function updateUnprocessedInputs(labelsPromises, notesPromises, appConfig) { * 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 appConfig the app configuration + * @param appConfig the app configuration * @returns Promise an array with 1) results for labels and 2) results for notes */ export async function updateLocalUnprocessedInputs(pipelineRange, appConfig) { const BEMUserCache = window['cordova'].plugins.BEMUserCache; const tq = getUnprocessedInputQuery(pipelineRange); const labelsPromises = keysForLabelInputs(appConfig).map((key) => - BEMUserCache.getMessagesForInterval(key, tq, true) + BEMUserCache.getMessagesForInterval(key, tq, true), ); const notesPromises = keysForNotesInputs(appConfig).map((key) => - BEMUserCache.getMessagesForInterval(key, tq, true) + BEMUserCache.getMessagesForInterval(key, tq, true), ); await updateUnprocessedInputs(labelsPromises, notesPromises, appConfig); } @@ -137,10 +137,10 @@ export async function updateAllUnprocessedInputs(pipelineRange, appConfig) { const UnifiedDataLoader = getAngularService('UnifiedDataLoader'); const tq = getUnprocessedInputQuery(pipelineRange); const labelsPromises = keysForLabelInputs(appConfig).map((key) => - UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true) + UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true), ); const notesPromises = keysForNotesInputs(appConfig).map((key) => - UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true) + UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true), ); await updateUnprocessedInputs(labelsPromises, notesPromises, appConfig); } @@ -155,8 +155,7 @@ export function keysForLabelInputs(appConfig) { function keysForNotesInputs(appConfig) { const notesKeys = []; - if (appConfig.survey_info?.buttons?.['trip-notes']) - notesKeys.push('manual/trip_addition_input'); + 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 57f85d062..c4bbcdade 100644 --- a/www/js/survey/enketo/AddNoteButton.tsx +++ b/www/js/survey/enketo/AddNoteButton.tsx @@ -7,13 +7,13 @@ The start and end times of the addition are determined by the survey response. */ -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/LabelTabContext"; -import EnketoModal from "./EnketoModal"; -import { displayErrorMsg, logDebug } from "../../plugin/logger"; +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/LabelTabContext'; +import EnketoModal from './EnketoModal'; +import { displayErrorMsg, logDebug } from '../../plugin/logger'; type Props = { timelineEntry: any; @@ -23,7 +23,7 @@ type Props = { const AddNoteButton = ({ timelineEntry, notesConfig, storeKey }: Props) => { const { t, i18n } = useTranslation(); const [displayLabel, setDisplayLabel] = useState(''); - const { repopulateTimelineEntry, timelineNotesMap } = useContext(LabelTabContext) + const { repopulateTimelineEntry, timelineNotesMap } = useContext(LabelTabContext); useEffect(() => { let newLabel: string; @@ -43,9 +43,8 @@ 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 - timelineNotesMap[timelineEntry._id.$oid]?.forEach(a => { - if (a.data.end_ts > (begin || 0) && a.data.end_ts != stop) - begin = a.data.end_ts; + timelineNotesMap[timelineEntry._id.$oid]?.forEach((a) => { + if (a.data.end_ts > (begin || 0) && a.data.end_ts != stop) begin = a.data.end_ts; }); const timezone = diff --git a/www/js/survey/enketo/AddedNotesList.tsx b/www/js/survey/enketo/AddedNotesList.tsx index cab23d4a7..7cc161779 100644 --- a/www/js/survey/enketo/AddedNotesList.tsx +++ b/www/js/survey/enketo/AddedNotesList.tsx @@ -2,15 +2,15 @@ Notes are added from the AddNoteButton and are derived from survey responses. */ -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/LabelTabContext"; -import { getFormattedDateAbbr, isMultiDay } from "../../diary/diaryHelper"; -import { Icon } from "../../components/Icon"; -import EnketoModal from "./EnketoModal"; -import { useTranslation } from "react-i18next"; +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/LabelTabContext'; +import { getFormattedDateAbbr, isMultiDay } from '../../diary/diaryHelper'; +import { Icon } from '../../components/Icon'; +import EnketoModal from './EnketoModal'; +import { useTranslation } from 'react-i18next'; type Props = { timelineEntry: any; diff --git a/www/js/survey/enketo/UserInputButton.tsx b/www/js/survey/enketo/UserInputButton.tsx index 39f75384b..f2ed4c6e7 100644 --- a/www/js/survey/enketo/UserInputButton.tsx +++ b/www/js/survey/enketo/UserInputButton.tsx @@ -8,14 +8,14 @@ The start and end times of the addition are the same as the trip or place. */ -import React, { useContext, useMemo, useState } from "react"; -import { getAngularService } from "../../angular-react-helper"; -import DiaryButton from "../../components/DiaryButton"; -import { useTranslation } from "react-i18next"; -import { useTheme } from "react-native-paper"; -import { displayErrorMsg, logDebug } from "../../plugin/logger"; -import EnketoModal from "./EnketoModal"; -import LabelTabContext from "../../diary/LabelTabContext"; +import React, { useContext, useMemo, useState } from 'react'; +import { getAngularService } from '../../angular-react-helper'; +import DiaryButton from '../../components/DiaryButton'; +import { useTranslation } from 'react-i18next'; +import { useTheme } from 'react-native-paper'; +import { displayErrorMsg, logDebug } from '../../plugin/logger'; +import EnketoModal from './EnketoModal'; +import LabelTabContext from '../../diary/LabelTabContext'; type Props = { timelineEntry: any; @@ -29,9 +29,10 @@ const UserInputButton = ({ timelineEntry }: Props) => { const { repopulateTimelineEntry, timelineLabelMap } = useContext(LabelTabContext); // the label resolved from the survey response, or null if there is no response yet - const responseLabel = useMemo(() => ( - timelineLabelMap[timelineEntry._id.$oid]?.['SURVEY']?.data?.label || null - ), [timelineEntry]); + const responseLabel = useMemo( + () => timelineLabelMap[timelineEntry._id.$oid]?.['SURVEY']?.data?.label || null, + [timelineEntry], + ); function launchUserInputSurvey() { logDebug('UserInputButton: About to launch survey'); diff --git a/www/js/survey/enketo/infinite_scroll_filters.ts b/www/js/survey/enketo/infinite_scroll_filters.ts index 363cfaa85..5d17b600e 100644 --- a/www/js/survey/enketo/infinite_scroll_filters.ts +++ b/www/js/survey/enketo/infinite_scroll_filters.ts @@ -6,18 +6,16 @@ * All UI elements should only use $scope variables. */ -import i18next from "i18next"; +import i18next from 'i18next'; const unlabeledCheck = (trip, userInputForTrip) => { return !userInputForTrip?.['SURVEY']; -} +}; const TO_LABEL = { - key: "to_label", - text: i18next.t("diary.to-label"), + key: 'to_label', + text: i18next.t('diary.to-label'), filter: unlabeledCheck, -} +}; -export const configuredFilters = [ - TO_LABEL, -]; +export const configuredFilters = [TO_LABEL]; diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index 929fb269a..0fc7bd89a 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -1,20 +1,39 @@ -import { logDebug, displayErrorMsg } from "../plugin/logger" -import { DateTime } from "luxon"; -import { CompositeTrip, TimelineEntry, UserInputEntry } from "../types/diaryTypes"; -import { keysForLabelInputs, unprocessedLabels, unprocessedNotes } from "../diary/timelineHelper"; -import { LabelOption, MultilabelKey, getLabelInputDetails, getLabelInputs, getLabelOptions, inputType2retKey, labelKeyToRichMode, labelOptions } from "./multilabel/confirmHelper"; -import { TimelineLabelMap, TimelineNotesMap } from "../diary/LabelTabContext"; +import { logDebug, displayErrorMsg } from '../plugin/logger'; +import { DateTime } from 'luxon'; +import { CompositeTrip, TimelineEntry, UserInputEntry } from '../types/diaryTypes'; +import { keysForLabelInputs, unprocessedLabels, unprocessedNotes } from '../diary/timelineHelper'; +import { + LabelOption, + MultilabelKey, + getLabelInputDetails, + getLabelInputs, + getLabelOptions, + inputType2retKey, + labelKeyToRichMode, + labelOptions, +} from './multilabel/confirmHelper'; +import { TimelineLabelMap, TimelineNotesMap } from '../diary/LabelTabContext'; -const EPOCH_MAXIMUM = 2**31 - 1; +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 fmtTs = (ts_in_secs: number, tz: string): string | null => + DateTime.fromSeconds(ts_in_secs, { zone: tz }).toISO(); -export const printUserInput = (ui: UserInputEntry): string => `${fmtTs(ui.data.start_ts, ui.metadata.time_zone)} (${ui.data.start_ts}) -> -${fmtTs(ui.data.end_ts, ui.metadata.time_zone)} (${ui.data.end_ts}) ${ui.data.label} logged at ${ui.metadata.write_ts}`; +export const printUserInput = (ui: UserInputEntry): string => `${fmtTs( + ui.data.start_ts, + ui.metadata.time_zone, +)} (${ui.data.start_ts}) -> +${fmtTs(ui.data.end_ts, ui.metadata.time_zone)} (${ui.data.end_ts}) ${ui.data.label} logged at ${ + ui.metadata.write_ts +}`; -export const validUserInputForDraftTrip = (trip: CompositeTrip, userInput: UserInputEntry, logsEnabled: boolean): boolean => { - if(logsEnabled) { - logDebug(`Draft trip: +export const validUserInputForDraftTrip = ( + trip: CompositeTrip, + userInput: UserInputEntry, + logsEnabled: boolean, +): boolean => { + if (logsEnabled) { + logDebug(`Draft trip: comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} -> ${fmtTs(userInput.data.end_ts, userInput.metadata.time_zone)} trip = ${fmtTs(trip.start_ts, userInput.metadata.time_zone)} @@ -24,11 +43,14 @@ export const validUserInputForDraftTrip = (trip: CompositeTrip, userInput: UserI || ${-(userInput.data.start_ts - trip.start_ts) <= 15 * 60}) && ${userInput.data.end_ts <= trip.end_ts} `); - } + } - return (userInput.data.start_ts >= trip.start_ts && userInput.data.start_ts < trip.end_ts - || -(userInput.data.start_ts - trip.start_ts) <= 15 * 60) && userInput.data.end_ts <= trip.end_ts; -} + return ( + ((userInput.data.start_ts >= trip.start_ts && userInput.data.start_ts < trip.end_ts) || + -(userInput.data.start_ts - trip.start_ts) <= 15 * 60) && + userInput.data.end_ts <= trip.end_ts + ); +}; export const validUserInputForTimelineEntry = ( tlEntry: TimelineEntry, @@ -36,34 +58,35 @@ export const validUserInputForTimelineEntry = ( userInput: UserInputEntry, logsEnabled: boolean, ): boolean => { - if (!tlEntry.origin_key) return false; - if (tlEntry.origin_key.includes('UNPROCESSED')) return validUserInputForDraftTrip(tlEntry, userInput, logsEnabled); + if (!tlEntry.origin_key) return false; + if (tlEntry.origin_key.includes('UNPROCESSED')) + return validUserInputForDraftTrip(tlEntry, userInput, logsEnabled); - /* Place-level inputs always have a key starting with 'manual/place', and + /* Place-level inputs always have a key starting with 'manual/place', and trip-level inputs never have a key starting with 'manual/place' So if these don't match, we can immediately return false */ - const entryIsPlace = tlEntry.origin_key === 'analysis/confirmed_place'; - const isPlaceInput = (userInput.key || userInput.metadata.key).startsWith('manual/place'); - - if (entryIsPlace !== isPlaceInput) return false; - - let entryStart = tlEntry.start_ts || tlEntry.enter_ts; - let entryEnd = tlEntry.end_ts || tlEntry.exit_ts; - - if (!entryStart && entryEnd) { - /* if a place has no enter time, this is the first start_place of the first composite trip object + const entryIsPlace = tlEntry.origin_key === 'analysis/confirmed_place'; + const isPlaceInput = (userInput.key || userInput.metadata.key).startsWith('manual/place'); + + if (entryIsPlace !== isPlaceInput) return false; + + let entryStart = tlEntry.start_ts || tlEntry.enter_ts; + let entryEnd = tlEntry.end_ts || tlEntry.exit_ts; + + if (!entryStart && entryEnd) { + /* if a place has no enter time, this is the first start_place of the first composite trip object so we will set the start time to the start of the day of the end time for the purpose of comparison */ - entryStart = DateTime.fromSeconds(entryEnd).startOf('day').toUnixInteger(); - } + entryStart = DateTime.fromSeconds(entryEnd).startOf('day').toUnixInteger(); + } - if (!entryEnd) { - /* if a place has no exit time, the user hasn't left there yet + if (!entryEnd) { + /* if a place has no exit time, the user hasn't left there yet so we will set the end time as high as possible for the purpose of comparison */ - entryEnd = EPOCH_MAXIMUM; - } - - if (logsEnabled) { - logDebug(`Cleaned trip: + entryEnd = EPOCH_MAXIMUM; + } + + if (logsEnabled) { + logDebug(`Cleaned trip: comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} -> ${fmtTs(userInput.data.end_ts, userInput.metadata.time_zone)} trip = ${fmtTs(entryStart, userInput.metadata.time_zone)} @@ -73,91 +96,108 @@ export const validUserInputForTimelineEntry = ( end checks are ${userInput.data.end_ts <= entryEnd} || ${userInput.data.end_ts - entryEnd <= 15 * 60}) `); - } + } - /* For this input to match, it must begin after the start of the timelineEntry (inclusive) + /* For this input to match, it must begin after the start of the timelineEntry (inclusive) but before the end of the timelineEntry (exclusive) */ - const startChecks = userInput.data.start_ts >= entryStart && userInput.data.start_ts < entryEnd; - /* A matching user input must also finish before the end of the timelineEntry, + const startChecks = userInput.data.start_ts >= entryStart && userInput.data.start_ts < entryEnd; + /* A matching user input must also finish before the end of the timelineEntry, or within 15 minutes. */ - let endChecks = (userInput.data.end_ts <= entryEnd || (userInput.data.end_ts - entryEnd) <= 15 * 60); + let endChecks = userInput.data.end_ts <= entryEnd || userInput.data.end_ts - entryEnd <= 15 * 60; - if (startChecks && !endChecks) { - 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 - } else { - endChecks = userInput.data.end_ts <= nextEntryEnd; - logDebug(`Second level of end checks when the next trip is defined(${userInput.data.end_ts} <= ${nextEntryEnd}) ${endChecks}`); - } + if (startChecks && !endChecks) { + 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 + } else { + endChecks = userInput.data.end_ts <= nextEntryEnd; + logDebug( + `Second level of end checks when the next trip is defined(${userInput.data.end_ts} <= ${nextEntryEnd}) ${endChecks}`, + ); + } } else { - // next trip is not defined, last trip - endChecks = (userInput.data.end_local_dt.day == userInput.data.start_local_dt.day) - logDebug("Second level of end checks for the last trip of the day"); - logDebug(`compare ${userInput.data.end_local_dt.day} with ${userInput.data.start_local_dt.day} ${endChecks}`); + // next trip is not defined, last trip + endChecks = userInput.data.end_local_dt.day == userInput.data.start_local_dt.day; + logDebug('Second level of end checks for the last trip of the day'); + logDebug( + `compare ${userInput.data.end_local_dt.day} with ${userInput.data.start_local_dt.day} ${endChecks}`, + ); } if (endChecks) { - // If we have flipped the values, check to see that there is sufficient overlap - const overlapDuration = Math.min(userInput.data.end_ts, entryEnd) - Math.max(userInput.data.start_ts, entryStart) - logDebug(`Flipped endCheck, overlap(${overlapDuration})/trip(${tlEntry.duration} (${overlapDuration} / ${tlEntry.duration})`); - endChecks = (overlapDuration/tlEntry.duration) > 0.5; + // If we have flipped the values, check to see that there is sufficient overlap + const overlapDuration = + Math.min(userInput.data.end_ts, entryEnd) - Math.max(userInput.data.start_ts, entryStart); + logDebug( + `Flipped endCheck, overlap(${overlapDuration})/trip(${tlEntry.duration} (${overlapDuration} / ${tlEntry.duration})`, + ); + endChecks = overlapDuration / tlEntry.duration > 0.5; } } return startChecks && endChecks; -} +}; // parallels get_not_deleted_candidates() in trip_queries.py 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 - const allActiveList = candidates.filter(c => !c.data.status || c.data.status == 'ACTIVE'); - const allDeletedIds = candidates.filter(c => c.data.status && c.data.status == 'DELETED').map(c => c.data['match_id']); - const notDeletedActive = allActiveList.filter(c => !allDeletedIds.includes(c.data['match_id'])); - - console.log(`Found ${allActiveList.length} active entries, ${allDeletedIds.length} deleted entries -> + console.log('getNotDeletedCandidates called with ' + candidates.length + ' candidates'); + + // We want to retain all ACTIVE entries that have not been DELETED + const allActiveList = candidates.filter((c) => !c.data.status || c.data.status == 'ACTIVE'); + const allDeletedIds = candidates + .filter((c) => c.data.status && c.data.status == 'DELETED') + .map((c) => c.data['match_id']); + const notDeletedActive = allActiveList.filter((c) => !allDeletedIds.includes(c.data['match_id'])); + + console.log(`Found ${allActiveList.length} active entries, ${allDeletedIds.length} deleted entries -> ${notDeletedActive.length} non deleted active entries`); - - return notDeletedActive; -} + + return notDeletedActive; +}; export const getUserInputForTimelineEntry = ( entry: TimelineEntry, nextEntry: TimelineEntry | null, userInputList: UserInputEntry[], ): undefined | UserInputEntry => { - const logsEnabled = userInputList?.length < 20; - if (userInputList === undefined) { - logDebug("In getUserInputForTimelineEntry, no user input, returning undefined"); - return undefined; - } + const logsEnabled = userInputList?.length < 20; + if (userInputList === undefined) { + logDebug('In getUserInputForTimelineEntry, no user input, returning undefined'); + return undefined; + } - if (logsEnabled) console.log(`Input list = ${userInputList.map(printUserInput)}`); + if (logsEnabled) console.log(`Input list = ${userInputList.map(printUserInput)}`); - // undefined !== true, so this covers the label view case as well - const potentialCandidates = userInputList.filter((ui) => - validUserInputForTimelineEntry(entry, nextEntry, ui, logsEnabled), + // undefined !== true, so this covers the label view case as well + const potentialCandidates = userInputList.filter((ui) => + validUserInputForTimelineEntry(entry, nextEntry, ui, logsEnabled), + ); + + if (potentialCandidates.length === 0) { + if (logsEnabled) + logDebug('In getUserInputForTimelineEntry, no potential candidates, returning []'); + return undefined; + } + + if (potentialCandidates.length === 1) { + logDebug( + `In getUserInputForTimelineEntry, one potential candidate, returning ${printUserInput( + potentialCandidates[0], + )}`, ); - - if (potentialCandidates.length === 0) { - if (logsEnabled) logDebug("In getUserInputForTimelineEntry, no potential candidates, returning []"); - return undefined; - } + return potentialCandidates[0]; + } - if (potentialCandidates.length === 1) { - logDebug(`In getUserInputForTimelineEntry, one potential candidate, returning ${printUserInput(potentialCandidates[0])}`); - return potentialCandidates[0]; - } + logDebug(`potentialCandidates are ${potentialCandidates.map(printUserInput)}`); - logDebug(`potentialCandidates are ${potentialCandidates.map(printUserInput)}`); + const sortedPC = potentialCandidates.sort( + (pc1, pc2) => pc2.metadata.write_ts - pc1.metadata.write_ts, + ); + const mostRecentEntry = sortedPC[0]; + logDebug('Returning mostRecentEntry ' + printUserInput(mostRecentEntry)); - const sortedPC = potentialCandidates.sort((pc1, pc2) => pc2.metadata.write_ts - pc1.metadata.write_ts); - const mostRecentEntry = sortedPC[0]; - logDebug("Returning mostRecentEntry "+printUserInput(mostRecentEntry)); - - return mostRecentEntry; -} + return mostRecentEntry; +}; // return array of matching additions for a trip or place export const getAdditionsForTimelineEntry = ( @@ -165,60 +205,72 @@ export const getAdditionsForTimelineEntry = ( nextEntry: TimelineEntry | null, additionsList: UserInputEntry[], ): UserInputEntry[] => { - const logsEnabled = additionsList?.length < 20; + const logsEnabled = additionsList?.length < 20; - if (additionsList === undefined) { - logDebug("In getAdditionsForTimelineEntry, no addition input, returning []"); - return []; - } + if (additionsList === undefined) { + logDebug('In getAdditionsForTimelineEntry, no addition input, returning []'); + return []; + } - // get additions that have not been deleted and filter out additions that do not start within the bounds of the timeline entry - const notDeleted = getNotDeletedCandidates(additionsList); - const matchingAdditions = notDeleted.filter((ui) => - validUserInputForTimelineEntry(entry, nextEntry, ui, logsEnabled), - ); + // 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, nextEntry, ui, logsEnabled), + ); - if (logsEnabled) console.log(`Matching Addition list ${matchingAdditions.map(printUserInput)}`); + if (logsEnabled) console.log(`Matching Addition list ${matchingAdditions.map(printUserInput)}`); - return matchingAdditions; -} + return matchingAdditions; +}; export const getUniqueEntries = (combinedList) => { - /* we should not get any non-ACTIVE entries here + /* we should not get any non-ACTIVE entries here since we have run filtering algorithms on both the phone and the server */ - const allDeleted = combinedList.filter(c => c.data.status && c.data.status == 'DELETED'); - - if (allDeleted.length > 0) { - displayErrorMsg("Found "+allDeleted.length +" non-ACTIVE addition entries while trying to dedup entries", JSON.stringify(allDeleted)); - } - - const uniqueMap = new Map(); - combinedList.forEach((e) => { - const existingVal = uniqueMap.get(e.data.match_id); - /* if the existing entry and the input entry don't match and they are both active, we have an error + const allDeleted = combinedList.filter((c) => c.data.status && c.data.status == 'DELETED'); + + if (allDeleted.length > 0) { + displayErrorMsg( + 'Found ' + allDeleted.length + ' non-ACTIVE addition entries while trying to dedup entries', + JSON.stringify(allDeleted), + ); + } + + const uniqueMap = new Map(); + combinedList.forEach((e) => { + const existingVal = uniqueMap.get(e.data.match_id); + /* if the existing entry and the input entry don't match and they are both active, we have an error let's notify the user for now */ - if (existingVal) { - if ((existingVal.data.start_ts != e.data.start_ts) || - (existingVal.data.end_ts != e.data.end_ts) || - (existingVal.data.write_ts != e.data.write_ts)) { - displayErrorMsg(`Found two ACTIVE entries with the same match ID but different timestamps ${existingVal.data.match_id}` - , `${JSON.stringify(existingVal)} vs ${JSON.stringify(e)}`); - } else { - console.log(`Found two entries with match_id ${existingVal.data.match_id} but they are identical`); - } - } else { - uniqueMap.set(e.data.match_id, e); - } - }); - return Array.from(uniqueMap.values()); -} + if (existingVal) { + if ( + existingVal.data.start_ts != e.data.start_ts || + existingVal.data.end_ts != e.data.end_ts || + existingVal.data.write_ts != e.data.write_ts + ) { + displayErrorMsg( + `Found two ACTIVE entries with the same match ID but different timestamps ${existingVal.data.match_id}`, + `${JSON.stringify(existingVal)} vs ${JSON.stringify(e)}`, + ); + } else { + console.log( + `Found two entries with match_id ${existingVal.data.match_id} but they are identical`, + ); + } + } else { + uniqueMap.set(e.data.match_id, e); + } + }); + 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] { +export function mapInputsToTimelineEntries( + allEntries: TimelineEntry[], + appConfig, +): [TimelineLabelMap, TimelineNotesMap] { const timelineLabelMap: TimelineLabelMap = {}; const timelineNotesMap: TimelineNotesMap = {}; @@ -255,10 +307,14 @@ export function mapInputsToTimelineEntries(allEntries: TimelineEntry[], appConfi unprocessedLabels[label], ); if (userInputForTrip) { - labelsForTrip[label] = labelOptions[label].find((opt: LabelOption) => opt.value == userInputForTrip.data.label); + labelsForTrip[label] = labelOptions[label].find( + (opt: LabelOption) => opt.value == userInputForTrip.data.label, + ); } else { const processedLabelValue = tlEntry.user_input?.[inputType2retKey(label)]; - labelsForTrip[label] = labelOptions[label].find((opt: LabelOption) => opt.value == processedLabelValue); + labelsForTrip[label] = labelOptions[label].find( + (opt: LabelOption) => opt.value == processedLabelValue, + ); } }); if (Object.keys(labelsForTrip).length) { @@ -277,7 +333,11 @@ export function mapInputsToTimelineEntries(allEntries: TimelineEntry[], appConfi 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 unprocessedAdditions = getAdditionsForTimelineEntry( + tlEntry, + nextEntry, + unprocessedNotes, + ); const processedAdditions = tlEntry.additions || []; const mergedAdditions = getUniqueEntries( diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index 26b2d4fbd..fa339323d 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -14,19 +14,21 @@ type InputDetails = { }; }; export type LabelOption = { - value: string, - baseMode: string, - met?: {range: any[], mets: number} - met_equivalent?: string, - kgCo2PerKm: number, - text?: string, + 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 MultilabelKey = 'MODE' | 'PURPOSE' | 'REPLACED_MODE'; export type LabelOptions = { - [k in T]: LabelOption[] -} & { translations: { - [lang: string]: { [translationKey: string]: string } -}}; + [k in T]: LabelOption[]; +} & { + translations: { + [lang: string]: { [translationKey: string]: string }; + }; +}; let appConfig; export let labelOptions: LabelOptions; @@ -108,14 +110,22 @@ 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"); + 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"); + 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"); + logDebug('No mode of study, so there is no REPLACED_MODE label option'); return getLabelInputDetails(); } } @@ -140,7 +150,7 @@ export const getFakeEntry = (otherValue) => ({ }); export const labelKeyToRichMode = (labelKey: string) => - labelOptions?.MODE?.find(m => m.value == labelKey)?.text || labelKeyToReadable(labelKey); + labelOptions?.MODE?.find((m) => m.value == labelKey)?.text || labelKeyToReadable(labelKey); /* manual/mode_confirm becomes mode_confirm */ export const inputType2retKey = (inputType) => getLabelInputDetails()[inputType].key.split('/')[1]; @@ -157,7 +167,7 @@ export function verifiabilityForTrip(trip, userInputForTrip) { } return someInferred ? 'can-verify' : allConfirmed ? 'already-verified' : 'cannot-verify'; } - + export function inferFinalLabels(trip, userInputForTrip) { // Deep copy the possibility tuples let labelsList = []; @@ -210,7 +220,9 @@ export function inferFinalLabels(trip, userInputForTrip) { // Fails safe if confidence_threshold doesn't exist if (max.p <= trip.confidence_threshold) max.labelValue = undefined; - finalInference[inputType] = labelOptions[inputType].find((opt) => opt.value == max.labelValue); + finalInference[inputType] = labelOptions[inputType].find( + (opt) => opt.value == max.labelValue, + ); } return finalInference; } diff --git a/www/js/survey/multilabel/infinite_scroll_filters.ts b/www/js/survey/multilabel/infinite_scroll_filters.ts index 62fe2cd20..a13d0e48d 100644 --- a/www/js/survey/multilabel/infinite_scroll_filters.ts +++ b/www/js/survey/multilabel/infinite_scroll_filters.ts @@ -6,36 +6,33 @@ * All UI elements should only use $scope variables. */ -import i18next from "i18next"; -import { labelInputDetailsForTrip } from "./confirmHelper"; -import { logDebug } from "../../plugin/logger"; +import i18next from 'i18next'; +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, userInputForTrip) => { - logDebug('Expectation: '+trip.expectation); + 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"), + key: 'unlabeled', + text: i18next.t('diary.unlabeled'), filter: unlabeledCheck, -} +}; const TO_LABEL = { - key: "to_label", - text: i18next.t("diary.to-label"), + key: 'to_label', + text: i18next.t('diary.to-label'), filter: toLabelCheck, -} +}; -export const configuredFilters = [ - TO_LABEL, - UNLABELED, -]; +export const configuredFilters = [TO_LABEL, UNLABELED]; diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index cd3f8cd8a..743d75b15 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -2,7 +2,7 @@ and user input objects. As much as possible, these types parallel the types used in the server code. */ -import { BaseModeKey, MotionTypeKey } from "../diary/diaryHelper"; +import { BaseModeKey, MotionTypeKey } from '../diary/diaryHelper'; type ObjectId = { $oid: string }; type ConfirmedPlace = { @@ -31,37 +31,37 @@ type ConfirmedPlace = { /* 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: ObjectId, - additions: UserInputEntry[], - cleaned_section_summary: SectionSummary, - cleaned_trip: ObjectId, - confidence_threshold: number, - 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: ObjectId, - end_ts: number, - expectation: any, // TODO "{to_label: boolean}" - expected_trip: ObjectId, - inferred_labels: any[], // TODO - inferred_section_summary: SectionSummary, - inferred_trip: ObjectId, - key: string, - locations: any[], // TODO - origin_key: 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: ObjectId, - start_ts: number, + _id: ObjectId; + additions: UserInputEntry[]; + cleaned_section_summary: SectionSummary; + cleaned_trip: ObjectId; + confidence_threshold: number; + 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: ObjectId; + end_ts: number; + expectation: any; // TODO "{to_label: boolean}" + expected_trip: ObjectId; + inferred_labels: any[]; // TODO + inferred_section_summary: SectionSummary; + inferred_trip: ObjectId; + key: string; + locations: any[]; // TODO + origin_key: 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: ObjectId; + start_ts: number; 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' */ @@ -70,7 +70,7 @@ export type CompositeTrip = { 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. */ @@ -79,52 +79,52 @@ 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 = { - displayDate: string, - displayStartTime: string, - displayEndTime: string, - displayTime: string, - displayStartDateAbbr: string, - displayEndDateAbbr: string, - formattedDistance: string, - formattedSectionProperties: any[], // TODO - distanceSuffix: string, - detectedModes: { mode: string, icon: string, color: string, pct: number|string }[], -} + displayDate: string; + displayStartTime: string; + displayEndTime: string; + displayTime: string; + displayStartDateAbbr: string; + displayEndDateAbbr: string; + formattedDistance: string; + formattedSectionProperties: any[]; // TODO + distanceSuffix: string; + detectedModes: { mode: string; icon: string; color: string; pct: number | string }[]; +}; export type SectionSummary = { - count: {[k: MotionTypeKey | BaseModeKey]: number}, - distance: {[k: MotionTypeKey | BaseModeKey]: number}, - duration: {[k: MotionTypeKey | BaseModeKey]: number}, -} + count: { [k: MotionTypeKey | BaseModeKey]: number }; + distance: { [k: MotionTypeKey | BaseModeKey]: number }; + duration: { [k: MotionTypeKey | BaseModeKey]: number }; +}; export type UserInputEntry = { data: { - end_ts: number, - start_ts: number - label: string, - start_local_dt?: LocalDt - end_local_dt?: LocalDt - status?: string, - match_id?: string, - }, + end_ts: number; + start_ts: number; + label: string; + start_local_dt?: LocalDt; + end_local_dt?: LocalDt; + status?: string; + match_id?: string; + }; metadata: { - time_zone: string, - plugin: string, - write_ts: number, - platform: string, - read_ts: number, - key: string, - }, - key?: string -} + time_zone: string; + plugin: string; + write_ts: number; + platform: string; + read_ts: number; + key: string; + }; + key?: string; +}; export type LocalDt = { - minute: number, - hour: number, - second: number, - day: number, - weekday: number, - month: number, - year: number, - timezone: string, -} + minute: number; + hour: number; + second: number; + day: number; + weekday: number; + month: number; + year: number; + timezone: string; +}; From 1f952f75626fd1d75687614c0aa37d880c2b93f9 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 3 Nov 2023 02:33:17 -0400 Subject: [PATCH 15/20] remove 'locales' submodule This was committed by mistake in 51108457028fa6c15006b50eb88eec8f98e8139d --- locales | 1 - 1 file changed, 1 deletion(-) delete mode 160000 locales diff --git a/locales b/locales deleted file mode 160000 index 7a62b7866..000000000 --- a/locales +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 7a62b7866e549fad40217f2229f80e500bc61494 From 1b0169208dccdf913ad2f2bee0aa72a89aa09f8d Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 3 Nov 2023 02:47:21 -0400 Subject: [PATCH 16/20] fix syntax errors and apply prettier Prettier was not running on these files because they had syntax errors from bad merge conflicts. Fixes syntax on these and runs Prettier to clean up --- www/js/diary/cards/TripCard.tsx | 41 ++++--- www/js/diary/details/LabelDetailsScreen.tsx | 76 +++++++----- www/js/diary/services.js | 53 +++++---- .../multilabel/MultiLabelButtonGroup.tsx | 112 +++++++++++------- 4 files changed, 171 insertions(+), 111 deletions(-) diff --git a/www/js/diary/cards/TripCard.tsx b/www/js/diary/cards/TripCard.tsx index 7bcda9c36..5c598f886 100644 --- a/www/js/diary/cards/TripCard.tsx +++ b/www/js/diary/cards/TripCard.tsx @@ -7,22 +7,22 @@ import React, { useContext } from 'react'; import { View, useWindowDimensions, StyleSheet } from 'react-native'; import { Text, IconButton } from 'react-native-paper'; -import LeafletView from "../../components/LeafletView"; -import { useTranslation } from "react-i18next"; -import MultilabelButtonGroup from "../../survey/multilabel/MultiLabelButtonGroup"; -import UserInputButton from "../../survey/enketo/UserInputButton"; -import useAppConfig from "../../useAppConfig"; -import AddNoteButton from "../../survey/enketo/AddNoteButton"; -import AddedNotesList from "../../survey/enketo/AddedNotesList"; -import { getTheme } from "../../appTheme"; -import { DiaryCard, cardStyles } from "./DiaryCard"; -import { useNavigation } from "@react-navigation/native"; -import { useAddressNames } from "../addressNamesHelper"; +import LeafletView from '../../components/LeafletView'; +import { useTranslation } from 'react-i18next'; +import MultilabelButtonGroup from '../../survey/multilabel/MultiLabelButtonGroup'; +import UserInputButton from '../../survey/enketo/UserInputButton'; +import useAppConfig from '../../useAppConfig'; +import AddNoteButton from '../../survey/enketo/AddNoteButton'; +import AddedNotesList from '../../survey/enketo/AddedNotesList'; +import { getTheme } from '../../appTheme'; +import { DiaryCard, cardStyles } from './DiaryCard'; +import { useNavigation } from '@react-navigation/native'; +import { useAddressNames } from '../addressNamesHelper'; import LabelTabContext from '../LabelTabContext'; -import useDerivedProperties from "../useDerivedProperties"; -import StartEndLocations from "../components/StartEndLocations"; -import ModesIndicator from "./ModesIndicator"; -import { useGeojsonForTrip } from "../timelineHelper"; +import useDerivedProperties from '../useDerivedProperties'; +import StartEndLocations from '../components/StartEndLocations'; +import ModesIndicator from './ModesIndicator'; +import { useGeojsonForTrip } from '../timelineHelper'; type Props = { trip: { [key: string]: any } }; const TripCard = ({ trip }: Props) => { @@ -41,7 +41,11 @@ const TripCard = ({ trip }: Props) => { let [tripStartDisplayName, tripEndDisplayName] = useAddressNames(trip); const navigation = useNavigation(); const { labelOptions, timelineLabelMap, timelineNotesMap } = useContext(LabelTabContext); - const tripGeojson = useGeojsonForTrip(trip, labelOptions, timelineLabelMap[trip._id.$oid]?.MODE?.value); + const tripGeojson = useGeojsonForTrip( + trip, + labelOptions, + timelineLabelMap[trip._id.$oid]?.MODE?.value, + ); const isDraft = trip.key.includes('UNPROCESSED'); const flavoredTheme = getTheme(isDraft ? 'draft' : undefined); @@ -98,7 +102,8 @@ const TripCard = ({ trip }: Props) => { displayEndName={tripEndDisplayName} /> - {/* mode and purpose buttons / survey button */} + + {/* mode and purpose buttons / survey button */} {appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL' && ( )} @@ -128,7 +133,7 @@ const TripCard = ({ trip }: Props) => { )} - {timelineNotesMap[trip._id.$oid]?.length && + {timelineNotesMap[trip._id.$oid]?.length && ( diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx index 0ad9852d0..89ff822d4 100644 --- a/www/js/diary/details/LabelDetailsScreen.tsx +++ b/www/js/diary/details/LabelDetailsScreen.tsx @@ -2,26 +2,33 @@ listed sections of the trip, and a graph of speed during the trip. Navigated to from the main LabelListScreen by clicking a trip card. */ -import React, { useContext, useState } from "react"; -import { View, Modal, ScrollView, useWindowDimensions } from "react-native"; -import { PaperProvider, Appbar, SegmentedButtons, Button, Surface, Text, useTheme } from "react-native-paper"; +import React, { useContext, useState } from 'react'; +import { View, Modal, ScrollView, useWindowDimensions } from 'react-native'; +import { + PaperProvider, + Appbar, + SegmentedButtons, + Button, + Surface, + Text, + useTheme, +} from 'react-native-paper'; import LabelTabContext from '../LabelTabContext'; -import LeafletView from "../../components/LeafletView"; -import { useTranslation } from "react-i18next"; -import MultilabelButtonGroup from "../../survey/multilabel/MultiLabelButtonGroup"; -import UserInputButton from "../../survey/enketo/UserInputButton"; -import { useAddressNames } from "../addressNamesHelper"; -import { SafeAreaView } from "react-native-safe-area-context"; -import useDerivedProperties from "../useDerivedProperties"; -import StartEndLocations from "../components/StartEndLocations"; -import { useGeojsonForTrip } from "../timelineHelper"; -import TripSectionsDescriptives from "./TripSectionsDescriptives"; -import OverallTripDescriptives from "./OverallTripDescriptives"; -import ToggleSwitch from "../../components/ToggleSwitch"; -import useAppConfig from "../../useAppConfig"; +import LeafletView from '../../components/LeafletView'; +import { useTranslation } from 'react-i18next'; +import MultilabelButtonGroup from '../../survey/multilabel/MultiLabelButtonGroup'; +import UserInputButton from '../../survey/enketo/UserInputButton'; +import { useAddressNames } from '../addressNamesHelper'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import useDerivedProperties from '../useDerivedProperties'; +import StartEndLocations from '../components/StartEndLocations'; +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 { timelineMap, labelOptions, timelineLabelMap } = useContext(LabelTabContext); const { t } = useTranslation(); const { height: windowHeight } = useWindowDimensions(); @@ -32,9 +39,13 @@ const LabelScreenDetails = ({ route, navigation }) => { const { displayDate, displayStartTime, displayEndTime } = useDerivedProperties(trip); const [tripStartDisplayName, tripEndDisplayName] = useAddressNames(trip); - const [ modesShown, setModesShown ] = useState<'labeled'|'detected'>('labeled'); - const tripGeojson = useGeojsonForTrip(trip, labelOptions, modesShown=='labeled' && timelineLabelMap[trip._id.$oid]?.MODE?.value); - const mapOpts = {minZoom: 3, maxZoom: 17}; + const [modesShown, setModesShown] = useState<'labeled' | 'detected'>('labeled'); + const tripGeojson = useGeojsonForTrip( + trip, + labelOptions, + modesShown == 'labeled' && timelineLabelMap[trip._id.$oid]?.MODE?.value, + ); + const mapOpts = { minZoom: 3, maxZoom: 17 }; const modal = ( @@ -82,13 +93,24 @@ const LabelScreenDetails = ({ route, navigation }) => { {/* If trip is labeled, show a toggle to switch between "Labeled Mode" and "Detected Modes" otherwise, just show "Detected" */} - {timelineLabelMap[trip._id.$oid]?.MODE?.value ? - setModesShown(v)} value={modesShown} density='medium' - buttons={[{label: t('diary.labeled-mode'), value: 'labeled'}, {label: t('diary.detected-modes'), value: 'detected'}]} /> - : - )} diff --git a/www/js/diary/services.js b/www/js/diary/services.js index 197125b52..a1b238ef8 100644 --- a/www/js/diary/services.js +++ b/www/js/diary/services.js @@ -4,30 +4,35 @@ import angular from 'angular'; import { getConfig } from '../config/dynamicConfig'; import { getRawEntries } from '../commHelper'; -angular.module('emission.main.diary.services', ['emission.plugin.logger', - 'emission.services']) -.factory('Timeline', function($http, $ionicLoading, $ionicPlatform, $window, - $rootScope, UnifiedDataLoader, Logger, $injector) { - var timeline = {}; - // corresponds to the old $scope.data. Contains all state for the current - // day, including the indication of the current day - timeline.data = {}; - timeline.data.unifiedConfirmsResults = null; - timeline.UPDATE_DONE = "TIMELINE_UPDATE_DONE"; - - // DB entries retrieved from the server have '_id', 'metadata', and 'data' fields. - // This function returns a shallow copy of the obj, which flattens the - // 'data' field into the top level, while also including '_id' and 'metadata.key' - const unpack = (obj) => ({ - ...obj.data, - _id: obj._id, - key: obj.metadata.key, - origin_key: obj.metadata.origin_key || obj.metadata.key, - }); - - timeline.readAllCompositeTrips = function(startTs, endTs) { - $ionicLoading.show({ - template: i18next.t('service.reading-server') +angular + .module('emission.main.diary.services', ['emission.plugin.logger', 'emission.services']) + .factory( + 'Timeline', + function ( + $http, + $ionicLoading, + $ionicPlatform, + $window, + $rootScope, + UnifiedDataLoader, + Logger, + $injector, + ) { + var timeline = {}; + // corresponds to the old $scope.data. Contains all state for the current + // day, including the indication of the current day + timeline.data = {}; + timeline.data.unifiedConfirmsResults = null; + timeline.UPDATE_DONE = 'TIMELINE_UPDATE_DONE'; + + // DB entries retrieved from the server have '_id', 'metadata', and 'data' fields. + // This function returns a shallow copy of the obj, which flattens the + // 'data' field into the top level, while also including '_id' and 'metadata.key' + const unpack = (obj) => ({ + ...obj.data, + _id: obj._id, + key: obj.metadata.key, + origin_key: obj.metadata.origin_key || obj.metadata.key, }); timeline.readAllCompositeTrips = function (startTs, endTs) { diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index 548ce6caa..797961843 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -2,16 +2,32 @@ In the default configuration, these are the "Mode" and "Purpose" buttons. Next to the buttons is a small checkmark icon, which marks inferrel labels as confirmed */ -import React, { useContext, useEffect, useState, useMemo } from "react"; -import { getAngularService } from "../../angular-react-helper"; -import { View, Modal, ScrollView, Pressable, useWindowDimensions } from "react-native"; -import { IconButton, Text, Dialog, useTheme, RadioButton, Button, TextInput } from "react-native-paper"; -import DiaryButton from "../../components/DiaryButton"; -import { useTranslation } from "react-i18next"; -import LabelTabContext from "../../diary/LabelTabContext"; -import { displayErrorMsg, logDebug } from "../../plugin/logger"; -import { getLabelInputDetails, getLabelInputs, inferFinalLabels, labelInputDetailsForTrip, labelKeyToRichMode, readableLabelToKey, verifiabilityForTrip } from "./confirmHelper"; -import useAppConfig from "../../useAppConfig"; +import React, { useContext, useEffect, useState, useMemo } from 'react'; +import { getAngularService } from '../../angular-react-helper'; +import { View, Modal, ScrollView, Pressable, useWindowDimensions } from 'react-native'; +import { + IconButton, + Text, + Dialog, + useTheme, + RadioButton, + Button, + TextInput, +} from 'react-native-paper'; +import DiaryButton from '../../components/DiaryButton'; +import { useTranslation } from 'react-i18next'; +import LabelTabContext from '../../diary/LabelTabContext'; +import { displayErrorMsg, logDebug } from '../../plugin/logger'; +import { + getLabelInputDetails, + getLabelInputs, + inferFinalLabels, + labelInputDetailsForTrip, + labelKeyToRichMode, + readableLabelToKey, + verifiabilityForTrip, +} from './confirmHelper'; +import useAppConfig from '../../useAppConfig'; const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { const { colors } = useTheme(); @@ -78,40 +94,52 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { } const tripInputDetails = labelInputDetailsForTrip(timelineLabelMap[trip._id.$oid], appConfig); - return (<> - - - {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; - } else if (inputIsInferred) { - fillColor = colors.secondaryContainer; - borderColor = colors.secondary; - textColor = colors.onSecondaryContainer; - } - const btnText = inputIsConfirmed?.text || inputIsInferred?.text || input.choosetext; + return ( + <> + + + {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; + } else if (inputIsInferred) { + fillColor = colors.secondaryContainer; + borderColor = colors.secondary; + textColor = colors.onSecondaryContainer; + } + const btnText = inputIsConfirmed?.text || inputIsInferred?.text || input.choosetext; - return ( - - {t(input.labeltext)} - setModalVisibleFor(input.name)}> - { t(btnText) } - - - ) - })} - - {verifiabilityForTrip(trip, timelineLabelMap[trip._id.$oid]) == 'can-verify' && ( - - + return ( + + {t(input.labeltext)} + setModalVisibleFor(input.name)}> + {t(btnText)} + + + ); + })} + {verifiabilityForTrip(trip, timelineLabelMap[trip._id.$oid]) == 'can-verify' && ( + + + + )} {trip.verifiability === 'can-verify' && ( Date: Fri, 3 Nov 2023 15:11:41 -0400 Subject: [PATCH 17/20] clean up confirmHelper -add labelOptionByValue function to make it easier to lookup a labelOption by a label value; use it in other functions that need to perform this lookup -type getFakeEntry, return undefined if falsy input -verifiabilityForTrip should only consider 'inferred' true if it has any truthy values; not if all values are undefined -inferFinalLabels: if no usable inferences, return empty object rather than object with undefined values --- www/js/survey/inputMatcher.ts | 13 ++------- www/js/survey/multilabel/confirmHelper.ts | 34 +++++++++++++---------- 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts index 0fc7bd89a..8f05f9639 100644 --- a/www/js/survey/inputMatcher.ts +++ b/www/js/survey/inputMatcher.ts @@ -6,11 +6,8 @@ import { LabelOption, MultilabelKey, getLabelInputDetails, - getLabelInputs, - getLabelOptions, inputType2retKey, - labelKeyToRichMode, - labelOptions, + labelOptionByValue, } from './multilabel/confirmHelper'; import { TimelineLabelMap, TimelineNotesMap } from '../diary/LabelTabContext'; @@ -307,14 +304,10 @@ export function mapInputsToTimelineEntries( unprocessedLabels[label], ); if (userInputForTrip) { - labelsForTrip[label] = labelOptions[label].find( - (opt: LabelOption) => opt.value == userInputForTrip.data.label, - ); + labelsForTrip[label] = labelOptionByValue(userInputForTrip.data.label, label); } else { const processedLabelValue = tlEntry.user_input?.[inputType2retKey(label)]; - labelsForTrip[label] = labelOptions[label].find( - (opt: LabelOption) => opt.value == processedLabelValue, - ); + labelsForTrip[label] = labelOptionByValue(processedLabelValue, label); } }); if (Object.keys(labelsForTrip).length) { diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index fa339323d..632023313 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -69,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', @@ -144,13 +147,16 @@ 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]; @@ -160,9 +166,10 @@ export function verifiabilityForTrip(trip, userInputForTrip) { 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 = inferFinalLabels(trip, userInputForTrip)[inputType] && !confirmed; - if (inferred) someInferred = true; + 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'; @@ -187,13 +194,10 @@ export function inferFinalLabels(trip, userInputForTrip) { } } - const finalInference = {}; + const finalInference: { [k in MultilabelKey]?: LabelOption } = {}; - // Red labels if we have no possibilities left + // Return early with (empty obj) if there are no possibilities left if (labelsList.length == 0) { - for (const inputType of getLabelInputs()) { - finalInference[inputType] = undefined; - } return finalInference; } else { // Normalize probabilities to previous level of certainty @@ -220,9 +224,9 @@ export function inferFinalLabels(trip, userInputForTrip) { // Fails safe if confidence_threshold doesn't exist if (max.p <= trip.confidence_threshold) max.labelValue = undefined; - finalInference[inputType] = labelOptions[inputType].find( - (opt) => opt.value == max.labelValue, - ); + if (max.labelValue) { + finalInference[inputType] = labelOptionByValue(max.labelValue, inputType); + } } return finalInference; } From 55c313d6d4e1b7649c9ab3ac6bc1134265faceff Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 3 Nov 2023 15:11:52 -0400 Subject: [PATCH 18/20] add tests for confirmHelper --- www/__tests__/confirmHelper.test.ts | 170 ++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 www/__tests__/confirmHelper.test.ts 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'); + }); +}); From d3e6af2f7bd999afd19f7790b3d90b1c5893ed1c Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 10 Nov 2023 00:38:07 -0500 Subject: [PATCH 19/20] fix en.json -> "questions" Something got jumbled when pulling in upstream changes + resolving conflicts --- www/i18n/en.json | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/www/i18n/en.json b/www/i18n/en.json index 4fa7447f3..aa41988f3 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -458,15 +458,9 @@ "destroy-data-pt1": "If you would like to have your data destroyed, please contact K. Shankari ", "destroy-data-pt2": " requesting deletion. You must include your token in the request for deletion. Because we do not connect your identity with your token, we cannot delete your information without obtaining the token as part of the deletion request. We will then destroy all data associated with that deletion request, both in the online and archived datasets." }, - "errors": { - "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-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}}", - "while-log-messages": "While getting messages from the log ", - "while-max-index": "While getting max index " + "questions": { + "header": "Questions", + "for-questions": "If you have any questions about the data collection goals and results, please contact the primary point of contact for the study, {{program_admin_contact}}. If you have any technical questions about app operation, please contact NREL’s K. Shankari (k.shankari@nrel.gov)." }, "consent": { "header": "Consent", From 69b18cb557c1d37e409bfe817c7b8a57825dfe61 Mon Sep 17 00:00:00 2001 From: Jack Greenlee Date: Fri, 10 Nov 2023 00:53:48 -0500 Subject: [PATCH 20/20] remove duplicate 'verifiability' check This was also likely due to bad merging from upstream changes --- www/js/survey/multilabel/MultiLabelButtonGroup.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index 797961843..a6023f1f4 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -140,18 +140,6 @@ const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { /> )} - {trip.verifiability === 'can-verify' && ( - - - - )} dismiss()}> dismiss()}>