diff --git a/www/__tests__/inputMatcher.test.ts b/www/__tests__/inputMatcher.test.ts new file mode 100644 index 000000000..6033df444 --- /dev/null +++ b/www/__tests__/inputMatcher.test.ts @@ -0,0 +1,251 @@ +import { + fmtTs, + printUserInput, + validUserInputForDraftTrip, + validUserInputForTimelineEntry, + getNotDeletedCandidates, + getUserInputForTrip, + getAdditionsForTimelineEntry, + getUniqueEntries, +} from '../js/survey/inputMatcher'; +import { TlEntry, UserInput } from '../js/types/diaryTypes'; + +describe('input-matcher', () => { + let userTrip: UserInput; + let trip: TlEntry; + + 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, + getNextEntry: jest.fn(), + }; + + // 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'); + + // 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 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, + }; + const validUserInput = validUserInputForDraftTrip(validTrp, userTrip, false); + expect(validUserInput).toBeTruthy(); + }); + + it('tests validUserInputForDraftTrip with invalid trip input', () => { + const invalidTrip = { + end_ts: 0, + start_ts: 0, + }; + 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, 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); + expect(invalidTimelineEntry).toBeFalsy(); + }); + + it('tests validUserInputForTimelineEntry with tlEntry with invalie start & end time', () => { + const invalidTlEntry: TlEntry = { + key: 'analysis/confirmed_place', + origin_key: 'analysis/confirmed_place', + start_ts: 1, + end_ts: 1, + enter_ts: 1, + exit_ts: 1, + duration: 1, + getNextEntry: jest.fn(), + }; + const invalidTimelineEntry = validUserInputForTimelineEntry(invalidTlEntry, 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 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); + }); + + 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 = getUserInputForTrip(trip, {}, userInputList); + expect(mostRecentEntry).toMatchObject(userInputWriteThird); + }); + + it('tests getUserInputForTrip with invalid userInputList', () => { + const userInputList = undefined; + const mostRecentEntry = getUserInputForTrip(trip, {}, 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'; + + // check if the result keep the all valid userTrip items + const matchingAdditions = getAdditionsForTimelineEntry(trip, additionsList); + expect(matchingAdditions).toHaveLength(5); + }); + + it('tests getAdditionsForTimelineEntry with invalid additionsList', () => { + const additionsList = undefined; + const matchingAdditions = getAdditionsForTimelineEntry(trip, additionsList); + expect(matchingAdditions).toMatchObject([]); + }); + + 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); + }); + + it('tests getUniqueEntries with empty combinedList', () => { + const uniqueEntires = getUniqueEntries([]); + expect(uniqueEntires).toMatchObject([]); + }); +}); diff --git a/www/index.js b/www/index.js index 78d29cf7a..e5a69ca58 100644 --- a/www/index.js +++ b/www/index.js @@ -14,7 +14,6 @@ import './js/controllers.js'; import './js/services.js'; import './js/i18n-utils.js'; import './js/main.js'; -import './js/survey/input-matcher.js'; import './js/survey/multilabel/multi-label-ui.js'; import './js/diary.js'; import './js/diary/services.js'; diff --git a/www/js/survey/enketo/enketo-add-note-button.js b/www/js/survey/enketo/enketo-add-note-button.js index a5bb7edd2..56a41cb04 100644 --- a/www/js/survey/enketo/enketo-add-note-button.js +++ b/www/js/survey/enketo/enketo-add-note-button.js @@ -3,131 +3,121 @@ */ import angular from 'angular'; +import { getAdditionsForTimelineEntry, getUniqueEntries } from '../inputMatcher'; angular .module('emission.survey.enketo.add-note-button', [ 'emission.services', 'emission.survey.enketo.answer', - 'emission.survey.inputmatcher', ]) - .factory( - 'EnketoNotesButtonService', - function (InputMatcher, EnketoSurveyAnswer, Logger, $timeout) { - var enbs = {}; - console.log('Creating EnketoNotesButtonService'); - enbs.SINGLE_KEY = 'NOTES'; - enbs.MANUAL_KEYS = []; + .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'); - } - }; + /** + * 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); - }; + /** + * 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.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'); - } - }; + 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 = InputMatcher.getAdditionsForTimelineEntry( - timelineEntry, - inputList, - ); - const combinedPotentialAdditionList = timelineEntry.additions.concat(unprocessedAdditions); - const dedupedList = InputMatcher.getUniqueEntries(combinedPotentialAdditionList); - Logger.log( - 'After combining unprocessed (' + - unprocessedAdditions.length + - ') with server (' + - timelineEntry.additions.length + - ') for a combined (' + - combinedPotentialAdditionList.length + - '), deduped entries are (' + - dedupedList.length + - ')', - ); + /** + * 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; - }; + 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); - }); - } - }; + /** + * 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; - }, - ); + return enbs; + }); diff --git a/www/js/survey/enketo/enketo-trip-button.js b/www/js/survey/enketo/enketo-trip-button.js index 66cf82cd7..89ae9dc29 100644 --- a/www/js/survey/enketo/enketo-trip-button.js +++ b/www/js/survey/enketo/enketo-trip-button.js @@ -12,112 +12,104 @@ */ import angular from 'angular'; +import { getUserInputForTrip } from '../inputMatcher'; angular - .module('emission.survey.enketo.trip.button', [ - 'emission.survey.enketo.answer', - 'emission.survey.inputmatcher', - ]) - .factory( - 'EnketoTripButtonService', - function (InputMatcher, EnketoSurveyAnswer, Logger, $timeout) { - var etbs = {}; - console.log('Creating EnketoTripButtonService'); - etbs.key = 'manual/trip_user_input'; - etbs.SINGLE_KEY = 'SURVEY'; - etbs.MANUAL_KEYS = [etbs.key]; + .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); + /** + * 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.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'); - } - }; + 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 = InputMatcher.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; - }; + /** + * 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; - } - }; + /** + * 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; - }; + /** + * 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]; - }; + /** + * 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; - }; + etbs.updateVerifiability = function (trip) { + // currently a NOP since we don't have any other trip properties + trip.verifiability = 'cannot-verify'; + return; + }; - return etbs; - }, - ); + return etbs; + }); diff --git a/www/js/survey/input-matcher.js b/www/js/survey/input-matcher.js deleted file mode 100644 index 6fc3178df..000000000 --- a/www/js/survey/input-matcher.js +++ /dev/null @@ -1,273 +0,0 @@ -'use strict'; - -import angular from 'angular'; - -angular - .module('emission.survey.inputmatcher', ['emission.plugin.logger']) - .factory('InputMatcher', function (Logger) { - var im = {}; - - const EPOCH_MAXIMUM = 2 ** 31 - 1; - const fmtTs = function (ts_in_secs, tz) { - return moment(ts_in_secs * 1000) - .tz(tz) - .format(); - }; - - var printUserInput = function (ui) { - return ( - 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 - ); - }; - - im.validUserInputForDraftTrip = function (trip, userInput, logsEnabled) { - if (logsEnabled) { - Logger.log(`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)} - -> ${fmtTs(trip.end_ts, userInput.metadata.time_zone)} - checks are (${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 - ); - }; - - im.validUserInputForTimelineEntry = function (tlEntry, userInput, logsEnabled) { - if (!tlEntry.origin_key) return false; - if (tlEntry.origin_key.includes('UNPROCESSED') == true) - return im.validUserInputForDraftTrip(tlEntry, userInput, logsEnabled); - - /* 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 - // so we will set the start time to the start of the day of the end time for the purpose of comparison - entryStart = moment.unix(entryEnd).startOf('day').unix(); - } - 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) { - Logger.log(`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)} - -> ${fmtTs(entryStart, userInput.metadata.time_zone)} - start checks are ${userInput.data.start_ts >= entryStart} - && ${userInput.data.start_ts < entryEnd} - 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) - 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, - or within 15 minutes. */ - var 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; - Logger.log( - '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; - Logger.log('Second level of end checks for the last trip of the day'); - Logger.log( - '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); - Logger.log( - '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 - const getNotDeletedCandidates = function (candidates) { - 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; - }; - - im.getUserInputForTrip = function (trip, nextTrip, userInputList) { - const logsEnabled = userInputList.length < 20; - - if (userInputList === undefined) { - Logger.log('In getUserInputForTrip, 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 - var potentialCandidates = userInputList.filter((ui) => - im.validUserInputForTimelineEntry(trip, ui, logsEnabled), - ); - if (potentialCandidates.length === 0) { - if (logsEnabled) { - Logger.log('In getUserInputForTripStartEnd, no potential candidates, returning []'); - } - return undefined; - } - - if (potentialCandidates.length === 1) { - Logger.log( - 'In getUserInputForTripStartEnd, one potential candidate, returning ' + - printUserInput(potentialCandidates[0]), - ); - return potentialCandidates[0]; - } - - Logger.log('potentialCandidates are ' + potentialCandidates.map(printUserInput)); - var sortedPC = potentialCandidates.sort(function (pc1, pc2) { - return pc2.metadata.write_ts - pc1.metadata.write_ts; - }); - var mostRecentEntry = sortedPC[0]; - Logger.log('Returning mostRecentEntry ' + printUserInput(mostRecentEntry)); - return mostRecentEntry; - }; - - // return array of matching additions for a trip or place - im.getAdditionsForTimelineEntry = function (entry, additionsList) { - const logsEnabled = additionsList.length < 20; - - if (additionsList === undefined) { - Logger.log('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) => - im.validUserInputForTimelineEntry(entry, ui, logsEnabled), - ); - - if (logsEnabled) { - console.log('Matching Addition list = ' + matchingAdditions.map(printUserInput)); - } - return matchingAdditions; - }; - - im.getUniqueEntries = function (combinedList) { - // 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) { - Logger.displayError( - 'Found ' + - allDeletedEntries.length + - ' non-ACTIVE addition entries while trying to dedup entries', - allDeletedEntries, - ); - } - 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 - ) { - Logger.displayError( - '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()); - }; - - return im; - }); diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts new file mode 100644 index 000000000..6203b5f27 --- /dev/null +++ b/www/js/survey/inputMatcher.ts @@ -0,0 +1,250 @@ +import { logDebug, displayErrorMsg } from '../plugin/logger'; +import { DateTime } from 'luxon'; +import { UserInput, Trip, TlEntry } from '../types/diaryTypes'; + +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}) -> +${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 => { + 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)} + -> ${fmtTs(trip.end_ts, userInput.metadata.time_zone)} + checks are (${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: TlEntry, + userInput: UserInput, + logsEnabled: boolean, +): boolean => { + 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 + 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 + so we will set the start time to the start of the day of the end time for the purpose of comparison */ + entryStart = DateTime.fromSeconds(entryEnd).startOf('day').toUnixInteger(); + } + + if (!entryEnd) { + /* if a place has no exit time, the user hasn't left there yet + so we will set the end time as high as possible for the purpose of comparison */ + entryEnd = EPOCH_MAXIMUM; + } + + 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)} + -> ${fmtTs(entryStart, userInput.metadata.time_zone)} + start checks are ${userInput.data.start_ts >= entryStart} + && ${userInput.data.start_ts < entryEnd} + 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) + 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, + or within 15 minutes. */ + 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}`, + ); + } + } 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; +}; + +// parallels get_not_deleted_candidates() in trip_queries.py +export const getNotDeletedCandidates = (candidates: UserInput[]): UserInput[] => { + 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; +}; + +export const getUserInputForTrip = ( + trip: TlEntry, + nextTrip: any, + userInputList: UserInput[], +): undefined | UserInput => { + const logsEnabled = userInputList?.length < 20; + if (userInputList === undefined) { + logDebug('In getUserInputForTrip, 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), + ); + + if (potentialCandidates.length === 0) { + if (logsEnabled) + logDebug('In getUserInputForTripStartEnd, no potential candidates, returning []'); + return undefined; + } + + if (potentialCandidates.length === 1) { + logDebug( + `In getUserInputForTripStartEnd, one potential candidate, returning ${printUserInput( + potentialCandidates[0], + )}`, + ); + return potentialCandidates[0]; + } + + 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)); + + return mostRecentEntry; +}; + +// return array of matching additions for a trip or place +export const getAdditionsForTimelineEntry = ( + entry: TlEntry, + additionsList: UserInput[], +): UserInput[] => { + const logsEnabled = additionsList?.length < 20; + + 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, ui, logsEnabled), + ); + + if (logsEnabled) console.log(`Matching Addition list ${matchingAdditions.map(printUserInput)}`); + + return matchingAdditions; +}; + +export const getUniqueEntries = (combinedList) => { + /* 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 + 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()); +}; diff --git a/www/js/survey/multilabel/multi-label-ui.js b/www/js/survey/multilabel/multi-label-ui.js index c4a8c732c..8123272e4 100644 --- a/www/js/survey/multilabel/multi-label-ui.js +++ b/www/js/survey/multilabel/multi-label-ui.js @@ -8,233 +8,211 @@ import { getLabelOptions, } from './confirmHelper'; import { getConfig } from '../../config/dynamicConfig'; +import { getUserInputForTrip } from '../inputMatcher'; angular - .module('emission.survey.multilabel.buttons', ['emission.survey.inputmatcher']) - - .factory( - 'MultiLabelService', - function ($rootScope, InputMatcher, $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; + .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]}`; }); - }; - - 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 = InputMatcher.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; + console.log(mrString); + manualResults.forEach(function (mr, index) { + resultMap[getLabelInputs()[index]] = mr; + }); + }; + + /** + * Embed 'inputType' to the trip + * This is the version that is called from the list, which focuses only on + * manual inputs. It also sets some additional values + */ + mls.populateManualInputs = function (trip, nextTrip, inputType, inputList) { + // Check unprocessed labels first since they are more recent + const unprocessedLabelEntry = getUserInputForTrip(trip, nextTrip, inputList); + var userInputLabel = unprocessedLabelEntry ? unprocessedLabelEntry.data.label : undefined; + if (!angular.isDefined(userInputLabel)) { + userInputLabel = trip.user_input?.[mls.inputType2retKey(inputType)]; + } + mls.populateInput(trip.userInput, inputType, userInputLabel); + // Logger.log("Set "+ inputType + " " + JSON.stringify(userInputEntry) + " for trip starting at " + JSON.stringify(trip.start_fmt_time)); + mls.editingTrip = angular.undefined; + }; + + /** + * Insert the given userInputLabel into the given inputType's slot in inputField + */ + mls.populateInput = function (tripField, inputType, userInputLabel) { + if (angular.isDefined(userInputLabel)) { + console.log( + 'populateInput: looking in map of ' + inputType + ' for userInputLabel' + userInputLabel, + ); + var userInputEntry = mls.inputParams[inputType].find((o) => o.value == userInputLabel); + if (!angular.isDefined(userInputEntry)) { + userInputEntry = getFakeEntry(userInputLabel); + mls.inputParams[inputType].push(userInputEntry); } - }; - - /** - * 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)); + 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)); - // 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); + // 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); } - } - - // 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); + 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(); + } + }; + + /* + * 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(); } - }; - - /** - * 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; - }, - ); + } 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/diary/diaryTypes.ts b/www/js/types/diaryTypes.ts similarity index 73% rename from www/js/diary/diaryTypes.ts rename to www/js/types/diaryTypes.ts index 5755c91ab..1e71d1cd9 100644 --- a/www/js/diary/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -21,7 +21,7 @@ export type CompositeTrip = { end_confirmed_place: ConfirmedPlace; end_fmt_time: string; end_loc: { type: string; coordinates: number[] }; - end_local_dt: any; // TODO + end_local_dt: LocalDt; end_place: { $oid: string }; end_ts: number; expectation: any; // TODO "{to_label: boolean}" @@ -38,10 +38,10 @@ export type CompositeTrip = { start_confirmed_place: ConfirmedPlace; start_fmt_time: string; start_loc: { type: string; coordinates: number[] }; - start_local_dt: any; // TODO + start_local_dt: LocalDt; start_place: { $oid: string }; start_ts: number; - user_input: any; // TODO + user_input: UserInput; }; /* These properties aren't received from the server, but are derived from the above properties. @@ -67,6 +67,54 @@ export type PopulatedTrip = CompositeTrip & { finalInference?: any; // TODO geojson?: any; // TODO getNextEntry?: () => PopulatedTrip | ConfirmedPlace; - userInput?: any; // TODO + userInput?: UserInput; verifiability?: string; }; + +export type UserInput = { + data: { + 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; +}; + +export type LocalDt = { + minute: number; + hour: number; + second: number; + day: number; + weekday: number; + month: number; + year: number; + timezone: string; +}; + +export type Trip = { + end_ts: number; + start_ts: number; +}; + +export type TlEntry = { + key: string; + origin_key: string; + start_ts: number; + end_ts: number; + enter_ts: number; + exit_ts: number; + duration: number; + getNextEntry?: () => PopulatedTrip | ConfirmedPlace; +};