diff --git a/package.cordovabuild.json b/package.cordovabuild.json index 1753e5646..7175c347f 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -106,6 +106,7 @@ "@react-navigation/native": "^6.1.7", "@react-navigation/stack": "^6.3.17", "@shopify/flash-list": "^1.3.1", + "@types/leaflet": "1.9.4", "angular": "1.6.7", "angular-animate": "1.6.7", "angular-local-storage": "^0.7.1", @@ -145,6 +146,7 @@ "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", "fs-extra": "^9.0.1", + "humanize-duration": "3.31.0", "i18next": "^22.5.0", "install": "^0.13.0", "ionic-datepicker": "1.2.1", diff --git a/package.serve.json b/package.serve.json index c66c32b1e..d35ab71de 100644 --- a/package.serve.json +++ b/package.serve.json @@ -58,6 +58,7 @@ "@react-navigation/stack": "^6.3.17", "@shopify/flash-list": "^1.3.1", "@types/jest": "^29.5.5", + "@types/leaflet": "1.9.4", "angular": "1.6.7", "angular-animate": "1.6.7", "angular-local-storage": "^0.7.1", @@ -76,6 +77,7 @@ "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", "fs-extra": "^9.0.1", + "humanize-duration": "3.31.0", "i18next": "^22.5.0", "install": "^0.13.0", "ionic-datepicker": "1.2.1", diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index e480fce33..60ea4e0c1 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -45,9 +45,11 @@ export const mockFile = () => { //for consent document const _storage = {}; +type MessageData = any; +type Message = { key: string; data: MessageData; metadata: { write_ts: number; [k: string]: any } }; export const mockBEMUserCache = (config?) => { const _cache = {}; - const messages = []; + const messages: Message[] = []; const mockBEMUserCache = { getLocalStorage: (key: string, isSecure: boolean) => { return new Promise((rs, rj) => @@ -98,18 +100,35 @@ export const mockBEMUserCache = (config?) => { putMessage: (key: string, value: any) => { return new Promise((rs, rj) => setTimeout(() => { - messages.push({ key, value }); + messages.push({ + key, + data: value, + // write_ts is epoch time in seconds + metadata: { write_ts: Math.floor(Date.now() / 1000) }, + }); rs(); }, 100), ); }, getAllMessages: (key: string, withMetadata?: boolean) => { - return new Promise((rs, rj) => + return new Promise((rs, rj) => setTimeout(() => { - rs(messages.filter((m) => m.key == key).map((m) => m.value)); + rs(messages.filter((m) => m.key == key).map((m) => (withMetadata ? m : m.data))); }, 100), ); }, + getMessagesForInterval: (key: string, tq, withMetadata?: boolean) => { + return new Promise((rs, rj) => + setTimeout(() => { + rs( + messages + .filter((m) => m.key == key) + .filter((m) => m.metadata[tq.key] >= tq.startTs && m.metadata.write_ts <= tq.endTs) + .map((m) => (withMetadata ? m : m.data)), + ); + }, 100), + ); + }, // Used for getUnifiedDataForInterval putRWDocument: (key: string, value: any) => { if (key == 'config/app_ui_config') { return new Promise((rs, rj) => diff --git a/www/__mocks__/timelineHelperMocks.ts b/www/__mocks__/timelineHelperMocks.ts new file mode 100644 index 000000000..014e892cb --- /dev/null +++ b/www/__mocks__/timelineHelperMocks.ts @@ -0,0 +1,256 @@ +import { MetaData, BEMData, ServerResponse } from '../js/types/serverData'; +import { + CompositeTrip, + ConfirmedPlace, + FilteredLocation, + TripTransition, + UnprocessedTrip, +} from '../js/types/diaryTypes'; +import { LabelOptions } from '../js/types/labelTypes'; +import { AppConfig } from '../js/types/appConfigTypes'; + +const mockMetaData: MetaData = { + write_ts: 1, + key: 'test/value/one', + platform: 'test', + time_zone: 'America/Los_Angeles', + write_fmt_time: '1969-07-16T07:01:49.000Z', + write_local_dt: null, + origin_key: '1', +}; + +export const mockLabelOptions: LabelOptions = { + MODE: null, + PURPOSE: null, + REPLACED_MODE: null, + translations: null, +}; + +const mockConfirmedPlaceData: ConfirmedPlace = { + source: 'DwellSegmentationTimeFilter', + key: null, + origin_key: null, + location: { + type: 'Point', + coordinates: [-122.0876886, 37.3887767], + }, + cleaned_place: null, + additions: [], + user_input: {}, + enter_fmt_time: '2015-07-22T08:14:53.881000-07:00', + exit_fmt_time: '2015-07-22T08:14:53.881000-07:00', + starting_trip: null, + ending_trip: null, + enter_local_dt: null, + exit_local_dt: null, + raw_places: [null, null], + enter_ts: 1437578093.881, + exit_ts: 1437578093.881, +}; +// using parse/stringify to deep copy & populate data +let tempMetaData = JSON.parse(JSON.stringify(mockMetaData)); +tempMetaData.write_ts = 2; +tempMetaData.origin_key = '2'; +export const mockMetaDataTwo = tempMetaData; + +export const mockUnprocessedTrip: UnprocessedTrip = { + _id: { $oid: 'mockUnprocessedTrip' }, + additions: [], + confidence_threshold: 0.0, + distance: 1.0, + duration: 3.0, + end_fmt_time: '', + end_loc: { type: 'Point', coordinates: [] }, + end_local_dt: null, + expectation: null, + inferred_labels: [], + key: 'mockUnprocessedTrip', + locations: [], + origin_key: '', + source: '', + start_local_dt: null, + start_ts: 0.1, + start_loc: { type: 'Point', coordinates: [] }, + starting_trip: null, + user_input: null, +}; + +export const mockCompData: ServerResponse = { + phone_data: [ + { + data: { + _id: { $oid: 'mockDataOne' }, + additions: [], + cleaned_section_summary: null, + cleaned_trip: null, + confidence_threshold: -1, + confirmed_trip: null, + distance: 777, + duration: 777, + end_confirmed_place: { + data: mockConfirmedPlaceData, + metadata: mockMetaData, + _id: { $oid: 'endConfirmedPlace' }, + }, + end_fmt_time: '2023-11-01T17:55:20.999397-07:00', + end_loc: { + type: 'Point', + coordinates: [-1, -1], + }, + end_local_dt: null, + end_place: null, + end_ts: -1, + expectation: null, + expected_trip: null, + inferred_labels: [], + inferred_section_summary: { + count: { + CAR: 1, + WALKING: 1, + }, + distance: { + CAR: 222, + WALKING: 222, + }, + duration: { + CAR: 333, + WALKING: 333, + }, + }, + inferred_trip: null, + key: '12345', + locations: [ + { + metadata: mockMetaData, + data: null, + }, + ], + origin_key: '', + raw_trip: null, + sections: [ + { + metadata: mockMetaData, + data: null, + }, + ], + source: 'DwellSegmentationDistFilter', + start_confirmed_place: { + data: mockConfirmedPlaceData, + metadata: mockMetaData, + _id: { $oid: 'startConfirmedPlace' }, + }, + start_fmt_time: '2023-11-01T17:55:20.999397-07:00', + start_loc: { + type: 'Point', + coordinates: [-1, -1], + }, + start_local_dt: null, + start_place: null, + start_ts: 1, + user_input: null, + }, + metadata: mockMetaData, + }, + ], +}; +// Setup for second mockData +let newPhoneData = JSON.parse(JSON.stringify(mockCompData.phone_data[0])); +newPhoneData.data._id.$oid = 'mockDataTwo'; +newPhoneData.metadata = mockMetaDataTwo; +newPhoneData.data.start_confirmed_place.metadata = mockMetaDataTwo; +newPhoneData.data.start_confirmed_place._id.$oid = 'startConfirmedPlaceTwo'; +newPhoneData.data.end_confirmed_place.metadata = mockMetaDataTwo; +newPhoneData.data.end_confirmed_place._id.$oid = 'endConfirmedPlaceTwo'; +export const mockCompDataTwo = { + phone_data: [mockCompData.phone_data[0], newPhoneData], +}; + +export const mockTransitions: Array> = [ + { + data: { + // mock of a startTransition + currstate: '', + transition: 'T_EXITED_GEOFENCE', + ts: 1, + }, + metadata: mockMetaData, + }, + { + data: { + // mock of an endTransition + currstate: '', + transition: 'T_TRIP_ENDED', + ts: 9999, + }, + metadata: mockMetaData, + }, +]; + +const mockFilterLocation: FilteredLocation = { + accuracy: 0.1, + altitude: 100, + elapsedRealtimeNanos: 10000, + filter: 'time', + fmt_time: '', + heading: 1.0, + latitude: 1.0, + loc: null, + local_dt: null, + longitude: -1.0, + sensed_speed: 0, + ts: 100, +}; +let mockFilterLocationTwo = JSON.parse(JSON.stringify(mockFilterLocation)); +mockFilterLocationTwo.ts = 900; +mockFilterLocationTwo.longitude = 200; +mockFilterLocationTwo.longitude = -200; + +export const mockFilterLocations: Array> = [ + { + data: mockFilterLocation, + metadata: mockMetaData, + }, + { + data: mockFilterLocationTwo, + metadata: mockMetaDataTwo, + }, +]; + +export const mockConfigModeOfStudy: AppConfig = { + server: null, + survey_info: { + 'trip-labels': 'MULTILABEL', + surveys: null, + }, + intro: { + mode_studied: 'sample_study', + }, +}; +export const mockConfigNoModeOfStudy: AppConfig = { + server: null, + survey_info: { + 'trip-labels': 'MULTILABEL', + surveys: null, + }, + intro: {}, +}; +export const mockConfigEnketo: AppConfig = { + server: null, + survey_info: { + 'trip-labels': 'ENKETO', + surveys: { + TripConfirmSurvey: { + compatibleWith: 1.2, + formPath: null, + labelTemplate: null, + version: null, + }, + }, + }, +}; + +// Used by jest.mocks() to return a various mocked objects. +export const fakeStartTsOne = -14576291; +export const fakeEndTsOne = -13885091; +export const fakeStartTsTwo = 1092844665; +export const fakeEndTsTwo = 1277049465; diff --git a/www/__tests__/diaryHelper.test.ts b/www/__tests__/diaryHelper.test.ts index 26ed03a8f..0eb4a8628 100644 --- a/www/__tests__/diaryHelper.test.ts +++ b/www/__tests__/diaryHelper.test.ts @@ -8,11 +8,14 @@ import { modeColors, } from '../js/diary/diaryHelper'; +import initializedI18next from '../js/i18nextInit'; +window['i18next'] = initializedI18next; + it('returns a formatted date', () => { - expect(getFormattedDate('2023-09-18T00:00:00-07:00')).toBe('Mon September 18, 2023'); + expect(getFormattedDate('2023-09-18T00:00:00-07:00')).toBe('Mon, September 18, 2023'); expect(getFormattedDate('')).toBeUndefined(); expect(getFormattedDate('2023-09-18T00:00:00-07:00', '2023-09-21T00:00:00-07:00')).toBe( - 'Mon September 18, 2023 - Thu September 21, 2023', + 'Mon, September 18, 2023 - Thu, September 21, 2023', ); }); diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts index ee5529199..3f5fb29a6 100644 --- a/www/__tests__/enketoHelper.test.ts +++ b/www/__tests__/enketoHelper.test.ts @@ -276,7 +276,7 @@ it('loads the previous response to a given survey', () => { */ it('filters the survey responses by their name and version', () => { //no response -> no filtered responses - expect(filterByNameAndVersion('TimeUseSurvey', [])).resolves.toStrictEqual([]); + expect(filterByNameAndVersion('TimeUseSurvey', [], fakeConfig)).toStrictEqual([]); const response = [ { @@ -294,7 +294,7 @@ it('filters the survey responses by their name and version', () => { ]; //one response -> that response - expect(filterByNameAndVersion('TimeUseSurvey', response)).resolves.toStrictEqual(response); + expect(filterByNameAndVersion('TimeUseSurvey', response, fakeConfig)).toStrictEqual(response); const responses = [ { @@ -336,5 +336,5 @@ it('filters the survey responses by their name and version', () => { ]; //several responses -> only the one that has a name match - expect(filterByNameAndVersion('TimeUseSurvey', responses)).resolves.toStrictEqual(response); + expect(filterByNameAndVersion('TimeUseSurvey', responses, fakeConfig)).toStrictEqual(response); }); diff --git a/www/__tests__/timelineHelper.test.ts b/www/__tests__/timelineHelper.test.ts new file mode 100644 index 000000000..83b25be01 --- /dev/null +++ b/www/__tests__/timelineHelper.test.ts @@ -0,0 +1,316 @@ +import { clearAlerts, mockAlert, mockLogger } from '../__mocks__/globalMocks'; +import { + useGeojsonForTrip, + readAllCompositeTrips, + readUnprocessedTrips, + compositeTrips2TimelineMap, + keysForLabelInputs, + updateAllUnprocessedInputs, + updateLocalUnprocessedInputs, + unprocessedLabels, + unprocessedNotes, +} from '../js/diary/timelineHelper'; +import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; +import * as mockTLH from '../__mocks__/timelineHelperMocks'; +import { GeoJSONData, GeoJSONStyledFeature, UserInputEntry } from '../js/types/diaryTypes'; + +mockLogger(); +mockAlert(); +mockBEMUserCache(); + +beforeEach(() => { + clearAlerts(); +}); + +afterAll(() => { + jest.restoreAllMocks(); +}); + +describe('useGeojsonForTrip', () => { + it('work with an empty input', () => { + const testVal = useGeojsonForTrip(null, null, null); + expect(testVal).toBeFalsy; + }); + + const checkGeojson = (geoObj: GeoJSONData) => { + expect(geoObj.data).toEqual( + expect.objectContaining({ + id: expect.any(String), + type: 'FeatureCollection', + features: expect.any(Array), + }), + ); + }; + + it('works without labelMode flag', () => { + const testValue = useGeojsonForTrip( + mockTLH.mockCompDataTwo.phone_data[1].data, + mockTLH.mockLabelOptions, + ); + checkGeojson(testValue); + expect(testValue.data.features.length).toBe(3); + }); +}); + +describe('compositeTrips2TimelineMap', () => { + const tripListOne = [mockTLH.mockCompData.phone_data[0].data]; + const tripListTwo = [ + mockTLH.mockCompDataTwo.phone_data[0].data, + mockTLH.mockCompDataTwo.phone_data[1].data, + ]; + const keyOne = mockTLH.mockCompData.phone_data[0].data._id.$oid; + const keyTwo = mockTLH.mockCompDataTwo.phone_data[1].data._id.$oid; + const keyThree = mockTLH.mockCompData.phone_data[0].data._id.$oid; + let testValue; + + it('Works with an empty list', () => { + expect(Object.keys(compositeTrips2TimelineMap([])).length).toBe(0); + }); + + it('Works with a list of len = 1, no flag', () => { + testValue = compositeTrips2TimelineMap(tripListOne); + expect(testValue.size).toBe(1); + expect(testValue.get(keyOne)).toEqual(tripListOne[0]); + }); + + it('Works with a list of len = 1, with flag', () => { + testValue = compositeTrips2TimelineMap(tripListOne, true); + expect(testValue.size).toBe(3); + expect(testValue.get(keyOne)).toEqual(tripListOne[0]); + expect(testValue.get('startConfirmedPlace')).toEqual(tripListOne[0].start_confirmed_place); + expect(testValue.get('endConfirmedPlace')).toEqual(tripListOne[0].end_confirmed_place); + }); + + it('Works with a list of len >= 1, no flag', () => { + testValue = compositeTrips2TimelineMap(tripListTwo); + expect(testValue.size).toBe(2); + expect(testValue.get(keyTwo)).toEqual(tripListTwo[1]); + expect(testValue.get(keyThree)).toEqual(tripListTwo[0]); + }); + + it('Works with a list of len >= 1, with flag', () => { + testValue = compositeTrips2TimelineMap(tripListTwo, true); + expect(testValue.size).toBe(6); + for (const [key, value] of Object.entries(testValue)) { + expect(value).toBe(tripListTwo[0][key] || tripListTwo[1][key]); + } + }); +}); + +it('use an appConfig to get labelInputKeys', () => { + expect(keysForLabelInputs(mockTLH.mockConfigEnketo)).toEqual(['manual/trip_user_input']); + expect(keysForLabelInputs(mockTLH.mockConfigModeOfStudy).length).toEqual(3); +}); + +// updateUnprocessedInputs Tests +jest.mock('../js/survey/multilabel/confirmHelper', () => ({ + ...jest.requireActual('../js/survey/multilabel/confirmHelper'), + getLabelInputs: jest.fn(() => ['MODE', 'PURPOSE', 'REPLACED_MODE']), +})); + +describe('unprocessedLabels, unprocessedNotes', () => { + it('has no labels or notes when nothing has been recorded', async () => { + await updateAllUnprocessedInputs({ start_ts: 0, end_ts: 99 }, mockTLH.mockConfigNoModeOfStudy); + Object.values(unprocessedLabels).forEach((value) => { + expect(value).toEqual([]); + }); + expect(unprocessedNotes).toEqual([]); + }); + + it('has some mode and purpose labels after they were just recorded', async () => { + // record some labels + await window['cordova'].plugins.BEMUserCache.putMessage('manual/mode_confirm', { + start_ts: 2, + end_ts: 3, + label: 'tricycle', + }); + await window['cordova'].plugins.BEMUserCache.putMessage('manual/purpose_confirm', { + start_ts: 2, + end_ts: 3, + label: 'shopping', + }); + + // update unprocessed inputs and check that the new labels show up in unprocessedLabels + await updateLocalUnprocessedInputs({ start_ts: 2, end_ts: 3 }, mockTLH.mockConfigNoModeOfStudy); + expect(unprocessedLabels['MODE'].length).toEqual(1); + expect(unprocessedLabels['MODE'][0].data.label).toEqual('tricycle'); + expect(unprocessedLabels['PURPOSE'].length).toEqual(1); + expect(unprocessedLabels['PURPOSE'][0].data.label).toEqual('shopping'); + }); + + it('has some trip- and place- survey responses after they were just recorded', async () => { + // record two survey responses, one for trip_user_input and one for place_user_input + const tripSurveyResponse = { + start_ts: 4, + end_ts: 5, + name: 'TripConfirmSurvey', // for now, the name of this survey must be hardcoded (see note in UserInputButton.tsx) + version: 1.2, + label: '1 foobar', + match_id: 'd263935e-9163-4072-9909-9d3e1edb31be', + key: 'manual/trip_user_input', + xmlResponse: ` 2023-12-04T12:12:38.968-05:00 2023-12-04T12:12:38.970-05:00 bar uuid:75dc7b18-2a9d-4356-b66e-d63dfa7568ca `, + }; + const placeSurveyResponse = { + ...tripSurveyResponse, + start_ts: 5, + end_ts: 6, + key: 'manual/place_user_input', + }; + await window['cordova'].plugins.BEMUserCache.putMessage( + 'manual/trip_user_input', + tripSurveyResponse, + ); + await window['cordova'].plugins.BEMUserCache.putMessage( + 'manual/place_user_input', + placeSurveyResponse, + ); + + // update unprocessed inputs and check that the trip survey response shows up in unprocessedLabels + await updateAllUnprocessedInputs({ start_ts: 4, end_ts: 6 }, mockTLH.mockConfigEnketo); + expect(unprocessedLabels['SURVEY'][0].data).toEqual(tripSurveyResponse); + // the second response is ignored for now because we haven't enabled place_user_input yet + // so the length is only 1 + expect(unprocessedLabels['SURVEY'].length).toEqual(1); + }); + + it('has some trip- and place- level additions after they were just recorded', async () => { + // record two additions, one for trip_addition_input and one for place_addition_input + const tripAdditionOne = { + start_ts: 6, + end_ts: 7, + key: 'manual/trip_addition_input', + data: { foo: 'bar' }, + }; + const tripAdditionTwo = { + ...tripAdditionOne, + data: { foo: 'baz' }, + }; + const placeAdditionOne = { + ...tripAdditionOne, + start_ts: 7, + end_ts: 8, + key: 'manual/place_addition_input', + }; + const placeAdditionTwo = { + ...placeAdditionOne, + data: { foo: 'baz' }, + }; + Promise.all([ + window['cordova'].plugins.BEMUserCache.putMessage( + 'manual/trip_addition_input', + tripAdditionOne, + ), + window['cordova'].plugins.BEMUserCache.putMessage( + 'manual/place_addition_input', + tripAdditionTwo, + ), + window['cordova'].plugins.BEMUserCache.putMessage( + 'manual/trip_addition_input', + placeAdditionOne, + ), + window['cordova'].plugins.BEMUserCache.putMessage( + 'manual/place_addition_input', + placeAdditionTwo, + ), + ]).then(() => { + // update unprocessed inputs and check that all additions show up in unprocessedNotes + updateAllUnprocessedInputs({ start_ts: 6, end_ts: 8 }, mockTLH.mockConfigEnketo); + expect(unprocessedLabels['NOTES'].length).toEqual(4); + expect(unprocessedLabels['NOTES'][0].data).toEqual(tripAdditionOne); + expect(unprocessedLabels['NOTES'][1].data).toEqual(tripAdditionTwo); + expect(unprocessedLabels['NOTES'][2].data).toEqual(placeAdditionOne); + expect(unprocessedLabels['NOTES'][3].data).toEqual(placeAdditionTwo); + }); + }); +}); + +// Tests for readAllCompositeTrips +// Once we have end-to-end testing, we could utilize getRawEnteries. +jest.mock('../js/services/commHelper', () => ({ + getRawEntries: jest.fn((key, startTs, endTs, valTwo) => { + if (startTs === mockTLH.fakeStartTsOne) return mockTLH.mockCompData; + if (startTs == mockTLH.fakeStartTsTwo) return mockTLH.mockCompDataTwo; + // the original implementation of `getRawEntries` for all other inputs + return jest + .requireActual('../js/services/commHelper') + .getRawEntries(key, startTs, endTs, valTwo); + }), +})); + +it('works when there are no composite trip objects fetched', async () => { + expect(readAllCompositeTrips(-1, -1)).resolves.toEqual([]); +}); + +// Checks that `readAllCompositeTrips` properly unpacks & flattens the confirmedPlaces +const checkTripIsUnpacked = (obj) => { + expect(obj.metadata).toBeUndefined(); + expect(obj).toEqual( + expect.objectContaining({ + key: expect.any(String), + origin_key: expect.any(String), + start_confirmed_place: expect.objectContaining({ + origin_key: expect.any(String), + }), + end_confirmed_place: expect.objectContaining({ + origin_key: expect.any(String), + }), + locations: expect.any(Array), + sections: expect.any(Array), + }), + ); +}; + +it('fetches a composite trip object and collapses it', async () => { + const testValue = await readAllCompositeTrips(mockTLH.fakeStartTsOne, mockTLH.fakeEndTsOne); + expect(testValue.length).toEqual(1); + checkTripIsUnpacked(testValue[0]); +}); + +it('Works with multiple trips', async () => { + const testValue = await readAllCompositeTrips(mockTLH.fakeStartTsTwo, mockTLH.fakeEndTsTwo); + expect(testValue.length).toEqual(2); + checkTripIsUnpacked(testValue[0]); + checkTripIsUnpacked(testValue[1]); + expect(testValue[0].origin_key).toBe('1'); + expect(testValue[1].origin_key).toBe('2'); +}); + +// Tests for `readUnprocessedTrips` +jest.mock('../js/services/unifiedDataLoader', () => ({ + getUnifiedDataForInterval: jest.fn((key, tq, combiner) => { + if (key === 'statemachine/transition') { + if (tq.startTs === mockTLH.fakeStartTsOne) return Promise.resolve(mockTLH.mockTransitions); + return Promise.resolve([]); + } + if (key === 'background/filtered_location') { + return Promise.resolve(mockTLH.mockFilterLocations); + } + // the original implementation of `getUnifiedDataForInterval` for other keys + return jest + .requireActual('../js/services/unifiedDataLoader') + .getUnifiedDataForInterval(key, tq, combiner); + }), +})); + +it('works when there are no unprocessed trips...', async () => { + expect(readUnprocessedTrips(-1, -1, null)).resolves.toEqual([]); +}); + +it('works when there are one or more unprocessed trips...', async () => { + const testValueOne = await readUnprocessedTrips( + mockTLH.fakeStartTsOne, + mockTLH.fakeEndTsOne, + null, + ); + expect(testValueOne.length).toEqual(1); + expect(testValueOne[0]).toEqual( + expect.objectContaining({ + origin_key: expect.any(String), + distance: expect.any(Number), + start_loc: expect.objectContaining({ + type: expect.any(String), + coordinates: expect.any(Array), + }), + }), + ); +}); diff --git a/www/__tests__/unifiedDataLoader.test.ts b/www/__tests__/unifiedDataLoader.test.ts index 57b1023da..b99f2a69e 100644 --- a/www/__tests__/unifiedDataLoader.test.ts +++ b/www/__tests__/unifiedDataLoader.test.ts @@ -1,10 +1,10 @@ import { mockLogger } from '../__mocks__/globalMocks'; import { removeDup, combinedPromises } from '../js/services/unifiedDataLoader'; -import { ServerData } from '../js/types/serverData'; +import { BEMData } from '../js/types/serverData'; mockLogger(); -const testOne: ServerData = { +const testOne: BEMData = { data: '', metadata: { key: '', @@ -42,7 +42,7 @@ describe('removeDup can', () => { }); // combinedPromises tests -const promiseGenerator = (values: Array>) => { +const promiseGenerator = (values: Array>) => { return Promise.resolve(values); }; const badPromiseGenerator = (input: string) => { diff --git a/www/index.js b/www/index.js index e087925b1..14ed7fd23 100644 --- a/www/index.js +++ b/www/index.js @@ -11,5 +11,4 @@ import './js/services.js'; import './js/i18n-utils.js'; import './js/main.js'; import './js/diary.js'; -import './js/diary/services.js'; import './js/plugin/logger.ts'; diff --git a/www/js/diary.js b/www/js/diary.js index 08909886d..729aa807c 100644 --- a/www/js/diary.js +++ b/www/js/diary.js @@ -2,7 +2,7 @@ import angular from 'angular'; import LabelTab from './diary/LabelTab'; angular - .module('emission.main.diary', ['emission.main.diary.services', 'emission.plugin.logger']) + .module('emission.main.diary', ['emission.plugin.logger']) .config(function ($stateProvider) { $stateProvider.state('root.main.inf_scroll', { diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index f8b222d6b..2e181859c 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -36,6 +36,7 @@ import LabelTabContext, { TimelineMap, TimelineNotesMap, } from './LabelTabContext'; +import { readAllCompositeTrips, readUnprocessedTrips } from './timelineHelper'; let showPlaces; const ONE_DAY = 24 * 60 * 60; // seconds @@ -57,8 +58,6 @@ const LabelTab = () => { const [refreshTime, setRefreshTime] = useState(null); const [isLoading, setIsLoading] = useState('replace'); - const Timeline = getAngularService('Timeline'); - // initialization, once the appConfig is loaded useEffect(() => { try { @@ -231,7 +230,7 @@ const LabelTab = () => { }); const readTimelineMap = compositeTrips2TimelineMap(tripsRead, showPlaces); logDebug(`LabelTab: after composite trips converted, - readTimelineMap = ${JSON.stringify(readTimelineMap)}`); + readTimelineMap = ${[...readTimelineMap.entries()]}`); if (mode == 'append') { setTimelineMap(new Map([...timelineMap, ...readTimelineMap])); } else if (mode == 'prepend') { @@ -246,7 +245,7 @@ const LabelTab = () => { async function fetchTripsInRange(startTs: number, endTs: number) { if (!pipelineRange.start_ts) return logWarn('No pipelineRange yet - early return'); logDebug('LabelTab: fetchTripsInRange from ' + startTs + ' to ' + endTs); - const readCompositePromise = Timeline.readAllCompositeTrips(startTs, endTs); + const readCompositePromise = readAllCompositeTrips(startTs, endTs); let readUnprocessedPromise; if (endTs >= pipelineRange.end_ts) { const nowTs = new Date().getTime() / 1000; @@ -255,11 +254,7 @@ const LabelTab = () => { [...timelineMap?.values()] .reverse() .find((trip) => trip.origin_key.includes('confirmed_trip')); - readUnprocessedPromise = Timeline.readUnprocessedTrips( - pipelineRange.end_ts, - nowTs, - lastProcessedTrip, - ); + readUnprocessedPromise = readUnprocessedTrips(pipelineRange.end_ts, nowTs, lastProcessedTrip); } else { readUnprocessedPromise = Promise.resolve([]); } diff --git a/www/js/diary/LabelTabContext.ts b/www/js/diary/LabelTabContext.ts index 24d7ade41..18e157234 100644 --- a/www/js/diary/LabelTabContext.ts +++ b/www/js/diary/LabelTabContext.ts @@ -1,8 +1,8 @@ import { createContext } from 'react'; import { TimelineEntry, UserInputEntry } from '../types/diaryTypes'; -import { LabelOption } from '../survey/multilabel/confirmHelper'; +import { LabelOption } from '../types/labelTypes'; -export type TimelineMap = Map; +export type TimelineMap = Map; // Todo: update to reflect unpacked trips (origin_Key, etc) export type TimelineLabelMap = { [k: string]: { /* if the key here is 'SURVEY', we are in the ENKETO configuration, meaning the user input diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts index 66ea67f0f..9ad17693e 100644 --- a/www/js/diary/diaryHelper.ts +++ b/www/js/diary/diaryHelper.ts @@ -1,11 +1,12 @@ // 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 i18next from 'i18next'; import { DateTime } from 'luxon'; -import { LabelOptions, readableLabelToKey } from '../survey/multilabel/confirmHelper'; import { CompositeTrip } from '../types/diaryTypes'; +import { LabelOptions } from '../types/labelTypes'; +import humanizeDuration from 'humanize-duration'; +import { AppConfig } from '../types/appConfigTypes'; export const modeColors = { pink: '#c32e85', // oklch(56% 0.2 350) // e-car @@ -84,7 +85,7 @@ export function getBaseModeByValue(value, labelOptions: LabelOptions) { return getBaseModeByKey(modeOption?.baseMode || 'OTHER'); } -export function getBaseModeByText(text, labelOptions: LabelOptions) { +export function getBaseModeByText(text: string, labelOptions: LabelOptions) { const modeOption = labelOptions?.MODE?.find((opt) => opt.text == text); return getBaseModeByKey(modeOption?.baseMode || 'OTHER'); } @@ -98,8 +99,8 @@ export function getBaseModeByText(text, labelOptions: LabelOptions) { export function isMultiDay(beginFmtTime: string, endFmtTime: string) { if (!beginFmtTime || !endFmtTime) return false; return ( - moment.parseZone(beginFmtTime).format('YYYYMMDD') != - moment.parseZone(endFmtTime).format('YYYYMMDD') + DateTime.fromISO(beginFmtTime, { setZone: true }).toFormat('YYYYMMDD') != + DateTime.fromISO(endFmtTime, { setZone: true }).toFormat('YYYYMMDD') ); } @@ -115,12 +116,15 @@ export function getFormattedDate(beginFmtTime: string, endFmtTime?: string) { return `${getFormattedDate(beginFmtTime)} - ${getFormattedDate(endFmtTime)}`; } // only one day given, or both are the same day - const t = moment.parseZone(beginFmtTime || endFmtTime); - // We use ddd LL to get Wed, May 3, 2023 or equivalent - // LL only has the date, month and year - // LLLL has the day of the week, but also the time - t.locale(i18next.language); - return t.format('ddd LL'); + const t = DateTime.fromISO(beginFmtTime || endFmtTime, { setZone: true }); + // We use toLocale to get Wed May 3, 2023 or equivalent, + const tConversion = t.toLocaleString({ + weekday: 'short', + month: 'long', + day: '2-digit', + year: 'numeric', + }); + return tConversion; } /** @@ -146,9 +150,14 @@ export function getFormattedDateAbbr(beginFmtTime: string, endFmtTime?: string) */ export function getFormattedTimeRange(beginFmtTime: string, endFmtTime: string) { if (!beginFmtTime || !endFmtTime) return; - const beginMoment = moment.parseZone(beginFmtTime); - const endMoment = moment.parseZone(endFmtTime); - return endMoment.to(beginMoment, true); + const beginTime = DateTime.fromISO(beginFmtTime, { setZone: true }); + const endTime = DateTime.fromISO(endFmtTime, { setZone: true }); + const range = endTime.diff(beginTime, ['hours', 'minutes']); + return humanizeDuration(range.as('milliseconds'), { + language: i18next.language, + largest: 1, + round: true, + }); } /** @@ -170,7 +179,7 @@ export function getDetectedModes(trip: CompositeTrip) { })); } -export function getFormattedSectionProperties(trip, ImperialConfig) { +export function getFormattedSectionProperties(trip: CompositeTrip, ImperialConfig: AppConfig) { return trip.sections?.map((s) => ({ startTime: getLocalTimeString(s.start_local_dt), duration: getFormattedTimeRange(s.start_fmt_time, s.end_fmt_time), @@ -181,10 +190,11 @@ export function getFormattedSectionProperties(trip, ImperialConfig) { })); } -export function getLocalTimeString(dt) { +export function getLocalTimeString(dt: DateTime) { if (!dt) return; - /* correcting the date of the processed trips knowing that local_dt months are from 1 -> 12 - and for the moment function they need to be between 0 -> 11 */ - const mdt = { ...dt, month: dt.month - 1 }; - return moment(mdt).format('LT'); + const dateTime = DateTime.fromObject({ + hour: dt.hour, + minute: dt.minute, + }); + return dateTime.toLocaleString(DateTime.TIME_SIMPLE); } diff --git a/www/js/diary/services.js b/www/js/diary/services.js deleted file mode 100644 index c9bcae7c7..000000000 --- a/www/js/diary/services.js +++ /dev/null @@ -1,428 +0,0 @@ -'use strict'; - -import angular from 'angular'; -import { getConfig } from '../config/dynamicConfig'; -import { getRawEntries } from '../services/commHelper'; -import { getUnifiedDataForInterval } from '../services/unifiedDataLoader'; - -angular - .module('emission.main.diary.services', ['emission.plugin.logger', 'emission.services']) - .factory( - 'Timeline', - function ($http, $ionicLoading, $ionicPlatform, $window, $rootScope, 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'), - }); - const readPromises = [ - getRawEntries(['analysis/composite_trip'], startTs, endTs, 'data.end_ts'), - ]; - return Promise.all(readPromises) - .then(([ctList]) => { - $ionicLoading.hide(); - return ctList.phone_data.map((ct) => { - const unpackedCt = unpack(ct); - return { - ...unpackedCt, - start_confirmed_place: unpack(unpackedCt.start_confirmed_place), - end_confirmed_place: unpack(unpackedCt.end_confirmed_place), - locations: unpackedCt.locations?.map(unpack), - sections: unpackedCt.sections?.map(unpack), - }; - }); - }) - .catch((err) => { - Logger.displayError('while reading confirmed trips', err); - $ionicLoading.hide(); - return []; - }); - }; - - /* - * This is going to be a bit tricky. As we can see from - * https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163, - * when we read local transitions, they have a string for the transition - * (e.g. `T_DATA_PUSHED`), while the remote transitions have an integer - * (e.g. `2`). - * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286338606 - * - * Also, at least on iOS, it is possible for trip end to be detected way - * after the end of the trip, so the trip end transition of a processed - * trip may actually show up as an unprocessed transition. - * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163 - * - * Let's abstract this out into our own minor state machine. - */ - var transitions2Trips = function (transitionList) { - var inTrip = false; - var tripList = []; - var currStartTransitionIndex = -1; - var currEndTransitionIndex = -1; - var processedUntil = 0; - - while (processedUntil < transitionList.length) { - // Logger.log("searching within list = "+JSON.stringify(transitionList.slice(processedUntil))); - if (inTrip == false) { - var foundStartTransitionIndex = transitionList - .slice(processedUntil) - .findIndex(isStartingTransition); - if (foundStartTransitionIndex == -1) { - Logger.log('No further unprocessed trips started, exiting loop'); - processedUntil = transitionList.length; - } else { - currStartTransitionIndex = processedUntil + foundStartTransitionIndex; - processedUntil = currStartTransitionIndex; - Logger.log( - 'Unprocessed trip started at ' + - JSON.stringify(transitionList[currStartTransitionIndex]), - ); - inTrip = true; - } - } else { - // Logger.log("searching within list = "+JSON.stringify(transitionList.slice(processedUntil))); - var foundEndTransitionIndex = transitionList - .slice(processedUntil) - .findIndex(isEndingTransition); - if (foundEndTransitionIndex == -1) { - Logger.log( - "Can't find end for trip starting at " + - JSON.stringify(transitionList[currStartTransitionIndex]) + - ' dropping it', - ); - processedUntil = transitionList.length; - } else { - currEndTransitionIndex = processedUntil + foundEndTransitionIndex; - processedUntil = currEndTransitionIndex; - Logger.log('currEndTransitionIndex = ' + currEndTransitionIndex); - Logger.log( - 'Unprocessed trip starting at ' + - JSON.stringify(transitionList[currStartTransitionIndex]) + - ' ends at ' + - JSON.stringify(transitionList[currEndTransitionIndex]), - ); - tripList.push([ - transitionList[currStartTransitionIndex], - transitionList[currEndTransitionIndex], - ]); - inTrip = false; - } - } - } - return tripList; - }; - - var isStartingTransition = function (transWrapper) { - // Logger.log("isStartingTransition: transWrapper.data.transition = "+transWrapper.data.transition); - if ( - transWrapper.data.transition == 'local.transition.exited_geofence' || - transWrapper.data.transition == 'T_EXITED_GEOFENCE' || - transWrapper.data.transition == 1 - ) { - // Logger.log("Returning true"); - return true; - } - // Logger.log("Returning false"); - return false; - }; - - var isEndingTransition = function (transWrapper) { - // Logger.log("isEndingTransition: transWrapper.data.transition = "+transWrapper.data.transition); - if ( - transWrapper.data.transition == 'T_TRIP_ENDED' || - transWrapper.data.transition == 'local.transition.stopped_moving' || - transWrapper.data.transition == 2 - ) { - // Logger.log("Returning true"); - return true; - } - // Logger.log("Returning false"); - return false; - }; - - /* - * Fill out place geojson after pulling trip location points. - * Place is only partially filled out because we haven't linked the timeline yet - */ - - var moment2localdate = function (currMoment, tz) { - return { - timezone: tz, - year: currMoment.year(), - //the months of the draft trips match the one format needed for - //moment function however now that is modified we need to also - //modify the months value here - month: currMoment.month() + 1, - day: currMoment.date(), - weekday: currMoment.weekday(), - hour: currMoment.hour(), - minute: currMoment.minute(), - second: currMoment.second(), - }; - }; - - var points2TripProps = function (locationPoints) { - var startPoint = locationPoints[0]; - var endPoint = locationPoints[locationPoints.length - 1]; - var tripAndSectionId = 'unprocessed_' + startPoint.data.ts + '_' + endPoint.data.ts; - var startMoment = moment.unix(startPoint.data.ts).tz(startPoint.metadata.time_zone); - var endMoment = moment.unix(endPoint.data.ts).tz(endPoint.metadata.time_zone); - - const speeds = [], - dists = []; - let loc, locLatLng; - locationPoints.forEach((pt) => { - const ptLatLng = L.latLng([pt.data.latitude, pt.data.longitude]); - if (loc) { - const dist = locLatLng.distanceTo(ptLatLng); - const timeDelta = pt.data.ts - loc.data.ts; - dists.push(dist); - speeds.push(dist / timeDelta); - } - loc = pt; - locLatLng = ptLatLng; - }); - - const locations = locationPoints.map((point, i) => ({ - loc: { - coordinates: [point.data.longitude, point.data.latitude], - }, - ts: point.data.ts, - speed: speeds[i], - })); - - return { - _id: { $oid: tripAndSectionId }, - key: 'UNPROCESSED_trip', - origin_key: 'UNPROCESSED_trip', - additions: [], - confidence_threshold: 0, - distance: dists.reduce((a, b) => a + b, 0), - duration: endPoint.data.ts - startPoint.data.ts, - end_fmt_time: endMoment.format(), - end_local_dt: moment2localdate(endMoment, endPoint.metadata.time_zone), - end_ts: endPoint.data.ts, - expectation: { to_label: true }, - inferred_labels: [], - locations: locations, - source: 'unprocessed', - start_fmt_time: startMoment.format(), - start_local_dt: moment2localdate(startMoment, startPoint.metadata.time_zone), - start_ts: startPoint.data.ts, - user_input: {}, - }; - }; - - var tsEntrySort = function (e1, e2) { - // compare timestamps - return e1.data.ts - e2.data.ts; - }; - - var transitionTrip2TripObj = function (trip) { - var tripStartTransition = trip[0]; - var tripEndTransition = trip[1]; - var tq = { - key: 'write_ts', - startTs: tripStartTransition.data.ts, - endTs: tripEndTransition.data.ts, - }; - Logger.log( - 'About to pull location data for range ' + - moment.unix(tripStartTransition.data.ts).toString() + - ' -> ' + - moment.unix(tripEndTransition.data.ts).toString(), - ); - const getMethod = window['cordova'].plugins.BEMUserCache.getSensorDataForInterval; - return getUnifiedDataForInterval('background/filtered_location', tq, getMethod).then( - function (locationList) { - if (locationList.length == 0) { - return undefined; - } - var sortedLocationList = locationList.sort(tsEntrySort); - var retainInRange = function (loc) { - return ( - tripStartTransition.data.ts <= loc.data.ts && - loc.data.ts <= tripEndTransition.data.ts - ); - }; - - var filteredLocationList = sortedLocationList.filter(retainInRange); - - // Fix for https://github.com/e-mission/e-mission-docs/issues/417 - if (filteredLocationList.length == 0) { - return undefined; - } - - var tripStartPoint = filteredLocationList[0]; - var tripEndPoint = filteredLocationList[filteredLocationList.length - 1]; - Logger.log( - 'tripStartPoint = ' + - JSON.stringify(tripStartPoint) + - 'tripEndPoint = ' + - JSON.stringify(tripEndPoint), - ); - // if we get a list but our start and end are undefined - // let's print out the complete original list to get a clue - // this should help with debugging - // https://github.com/e-mission/e-mission-docs/issues/417 - // if it ever occurs again - if (angular.isUndefined(tripStartPoint) || angular.isUndefined(tripEndPoint)) { - Logger.log('BUG 417 check: locationList = ' + JSON.stringify(locationList)); - Logger.log( - 'transitions: start = ' + - JSON.stringify(tripStartTransition.data) + - ' end = ' + - JSON.stringify(tripEndTransition.data.ts), - ); - } - - const tripProps = points2TripProps(filteredLocationList); - - return { - ...tripProps, - start_loc: { - type: 'Point', - coordinates: [tripStartPoint.data.longitude, tripStartPoint.data.latitude], - }, - end_loc: { - type: 'Point', - coordinates: [tripEndPoint.data.longitude, tripEndPoint.data.latitude], - }, - }; - }, - ); - }; - - var linkTrips = function (trip1, trip2) { - // complete trip1 - trip1.starting_trip = { $oid: trip2.id }; - trip1.exit_fmt_time = trip2.enter_fmt_time; - trip1.exit_local_dt = trip2.enter_local_dt; - trip1.exit_ts = trip2.enter_ts; - - // start trip2 - trip2.ending_trip = { $oid: trip1.id }; - trip2.enter_fmt_time = trip1.exit_fmt_time; - trip2.enter_local_dt = trip1.exit_local_dt; - trip2.enter_ts = trip1.exit_ts; - }; - - timeline.readUnprocessedTrips = function (startTs, endTs, lastProcessedTrip) { - $ionicLoading.show({ - template: i18next.t('service.reading-unprocessed-data'), - }); - - var tq = { key: 'write_ts', startTs, endTs }; - Logger.log( - 'about to query for unprocessed trips from ' + - moment.unix(tq.startTs).toString() + - ' -> ' + - moment.unix(tq.endTs).toString(), - ); - const getMethod = window['cordova'].plugins.BEMUserCache.getMessagesForInterval; - return getUnifiedDataForInterval('statemachine/transition', tq, getMethod).then( - function (transitionList) { - if (transitionList.length == 0) { - Logger.log('No unprocessed trips. yay!'); - $ionicLoading.hide(); - return []; - } else { - Logger.log('Found ' + transitionList.length + ' transitions. yay!'); - var sortedTransitionList = transitionList.sort(tsEntrySort); - /* - sortedTransitionList.forEach(function(transition) { - console.log(moment(transition.data.ts * 1000).format()+":" + JSON.stringify(transition.data)); - }); - */ - var tripsList = transitions2Trips(transitionList); - Logger.log('Mapped into' + tripsList.length + ' trips. yay!'); - tripsList.forEach(function (trip) { - console.log(JSON.stringify(trip)); - }); - var tripFillPromises = tripsList.map(transitionTrip2TripObj); - return Promise.all(tripFillPromises).then(function (raw_trip_gj_list) { - // Now we need to link up the trips. linking unprocessed trips - // to one another is fairly simple, but we need to link the - // first unprocessed trip to the last processed trip. - // This might be challenging if we don't have any processed - // trips for the day. I don't want to go back forever until - // I find a trip. So if this is the first trip, we will start a - // new chain for now, since this is with unprocessed data - // anyway. - - Logger.log('mapped trips to trip_gj_list of size ' + raw_trip_gj_list.length); - /* Filtering: we will keep trips that are 1) defined and 2) have a distance >= 100m or duration >= 5 minutes - https://github.com/e-mission/e-mission-docs/issues/966#issuecomment-1709112578 */ - const trip_gj_list = raw_trip_gj_list.filter( - (trip) => trip && (trip.distance >= 100 || trip.duration >= 300), - ); - Logger.log( - 'after filtering undefined and distance < 100, trip_gj_list size = ' + - raw_trip_gj_list.length, - ); - // Link 0th trip to first, first to second, ... - for (var i = 0; i < trip_gj_list.length - 1; i++) { - linkTrips(trip_gj_list[i], trip_gj_list[i + 1]); - } - Logger.log('finished linking trips for list of size ' + trip_gj_list.length); - if (lastProcessedTrip && trip_gj_list.length != 0) { - // Need to link the entire chain above to the processed data - Logger.log('linking unprocessed and processed trip chains'); - linkTrips(lastProcessedTrip, trip_gj_list[0]); - } - $ionicLoading.hide(); - Logger.log('Returning final list of size ' + trip_gj_list.length); - return trip_gj_list; - }); - } - }, - ); - }; - - var localCacheReadFn = timeline.updateFromDatabase; - - timeline.getTrip = function (tripId) { - return angular.isDefined(timeline.data.tripMap) ? timeline.data.tripMap[tripId] : undefined; - }; - - timeline.getTripWrapper = function (tripId) { - return angular.isDefined(timeline.data.tripWrapperMap) - ? timeline.data.tripWrapperMap[tripId] - : undefined; - }; - - timeline.getCompositeTrip = function (tripId) { - return angular.isDefined(timeline.data.infScrollCompositeTripMap) - ? timeline.data.infScrollCompositeTripMap[tripId] - : undefined; - }; - - timeline.setInfScrollCompositeTripList = function (compositeTripList) { - timeline.data.infScrollCompositeTripList = compositeTripList; - - timeline.data.infScrollCompositeTripMap = {}; - - timeline.data.infScrollCompositeTripList.forEach(function (trip, index, array) { - timeline.data.infScrollCompositeTripMap[trip._id.$oid] = trip; - }); - }; - - return timeline; - }, - ); diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 1a2c87462..5805fbe07 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -1,16 +1,35 @@ -import moment from 'moment'; -import { logDebug } from '../plugin/logger'; +import { displayError, logDebug } from '../plugin/logger'; import { getBaseModeByKey, getBaseModeByValue } from './diaryHelper'; import { getUnifiedDataForInterval } from '../services/unifiedDataLoader'; -import { UserInputEntry } from '../types/diaryTypes'; +import { getRawEntries } from '../services/commHelper'; +import { ServerResponse, BEMData } from '../types/serverData'; +import L from 'leaflet'; +import { DateTime } from 'luxon'; +import { + UserInputEntry, + TripTransition, + TimelineEntry, + GeoJSONData, + FilteredLocation, + TimestampRange, + CompositeTrip, +} from '../types/diaryTypes'; import { getLabelInputDetails, getLabelInputs } from '../survey/multilabel/confirmHelper'; +import { LabelOptions } from '../types/labelTypes'; import { filterByNameAndVersion } from '../survey/enketo/enketoHelper'; +import { AppConfig } from '../types/appConfigTypes'; +import { Point, Feature } from 'geojson'; + +const cachedGeojsons: Map = new Map(); -const cachedGeojsons = new Map(); /** * @description Gets a formatted GeoJSON object for a trip, including the start and end places and the trajectory. */ -export function useGeojsonForTrip(trip, labelOptions, labeledMode?) { +export function useGeojsonForTrip( + trip: CompositeTrip, + labelOptions: LabelOptions, + labeledMode?: Boolean, +) { if (!trip) return; const gjKey = `trip-${trip._id.$oid}-${labeledMode || 'detected'}`; if (cachedGeojsons.has(gjKey)) { @@ -29,7 +48,7 @@ export function useGeojsonForTrip(trip, labelOptions, labeledMode?) { ...locations2GeojsonTrajectory(trip, trip.locations, trajectoryColor), ]; - const gj = { + const gj: GeoJSONData = { data: { id: gjKey, type: 'FeatureCollection', @@ -52,7 +71,7 @@ export function useGeojsonForTrip(trip, labelOptions, labeledMode?) { * @param unpackPlaces whether to unpack the start and end places of each composite trip into the Map * @returns a Map() of timeline items, by id */ -export function compositeTrips2TimelineMap(ctList: any[], unpackPlaces?: boolean) { +export function compositeTrips2TimelineMap(ctList: Array, unpackPlaces?: boolean) { const timelineEntriesMap = new Map(); ctList.forEach((cTrip) => { if (unpackPlaces) { @@ -78,22 +97,29 @@ export let unprocessedLabels: { [key: string]: UserInputEntry[] } = {}; /* 'NOTES' are 1:n - each trip or place can have any number of notes */ export let unprocessedNotes: UserInputEntry[] = []; -const getUnprocessedInputQuery = (pipelineRange) => ({ +const getUnprocessedInputQuery = (pipelineRange: TimestampRange) => ({ key: 'write_ts', startTs: pipelineRange.end_ts - 10, - endTs: moment().unix() + 10, + endTs: DateTime.now().toUnixInteger() + 10, }); -function updateUnprocessedInputs(labelsPromises, notesPromises, appConfig) { - Promise.all([...labelsPromises, ...notesPromises]).then((comboResults) => { +/** + * updateUnprocessedInputs is a helper function for updateLocalUnprocessedInputs + * and updateAllUnprocessedInputs + */ +function updateUnprocessedInputs( + labelsPromises: Array>, + notesPromises: Array>, + appConfig: AppConfig, +) { + return Promise.all([...labelsPromises, ...notesPromises]).then((comboResults) => { const labelResults = comboResults.slice(0, labelsPromises.length); const notesResults = comboResults.slice(labelsPromises.length).flat(2); // fill in the unprocessedLabels object with the labels we just read labelResults.forEach((r, i) => { if (appConfig.survey_info?.['trip-labels'] == 'ENKETO') { - filterByNameAndVersion('TripConfirmSurvey', r).then((filtered) => { - unprocessedLabels['SURVEY'] = filtered; - }); + const filtered = filterByNameAndVersion('TripConfirmSurvey', r, appConfig); + unprocessedLabels['SURVEY'] = filtered as UserInputEntry[]; } else { unprocessedLabels[getLabelInputs()[i]] = r; } @@ -112,9 +138,11 @@ function updateUnprocessedInputs(labelsPromises, notesPromises, appConfig) { * @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 - * @returns Promise an array with 1) results for labels and 2) results for notes */ -export async function updateLocalUnprocessedInputs(pipelineRange, appConfig) { +export async function updateLocalUnprocessedInputs( + pipelineRange: TimestampRange, + appConfig: AppConfig, +) { const BEMUserCache = window['cordova'].plugins.BEMUserCache; const tq = getUnprocessedInputQuery(pipelineRange); const labelsPromises = keysForLabelInputs(appConfig).map((key) => @@ -132,9 +160,11 @@ export async function updateLocalUnprocessedInputs(pipelineRange, appConfig) { * @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 - * @returns Promise an array with 1) results for labels and 2) results for notes */ -export async function updateAllUnprocessedInputs(pipelineRange, appConfig) { +export async function updateAllUnprocessedInputs( + pipelineRange: TimestampRange, + appConfig: AppConfig, +) { const tq = getUnprocessedInputQuery(pipelineRange); const getMethod = window['cordova'].plugins.BEMUserCache.getMessagesForInterval; const labelsPromises = keysForLabelInputs(appConfig).map((key) => @@ -146,7 +176,7 @@ export async function updateAllUnprocessedInputs(pipelineRange, appConfig) { await updateUnprocessedInputs(labelsPromises, notesPromises, appConfig); } -export function keysForLabelInputs(appConfig) { +export function keysForLabelInputs(appConfig: AppConfig) { if (appConfig.survey_info?.['trip-labels'] == 'ENKETO') { return ['manual/trip_user_input']; } else { @@ -154,7 +184,7 @@ export function keysForLabelInputs(appConfig) { } } -function keysForNotesInputs(appConfig) { +function keysForNotesInputs(appConfig: AppConfig) { const notesKeys = []; if (appConfig.survey_info?.buttons?.['trip-notes']) notesKeys.push('manual/trip_addition_input'); if (appConfig.survey_info?.buttons?.['place-notes']) @@ -167,7 +197,7 @@ function keysForNotesInputs(appConfig) { * @param featureType a string describing the feature, e.g. "start_place" * @returns a GeoJSON feature with type "Point", the given location's coordinates and the given feature type */ -const location2GeojsonPoint = (locationPoint: any, featureType: string) => ({ +const location2GeojsonPoint = (locationPoint: Point, featureType: string): Feature => ({ type: 'Feature', geometry: { type: 'Point', @@ -184,7 +214,11 @@ const location2GeojsonPoint = (locationPoint: any, featureType: string) => ({ * @param trajectoryColor The color to use for the whole trajectory, if any. Otherwise, a color will be lookup up for the sensed mode of each section. * @returns for each section of the trip, a GeoJSON feature with type "LineString" and an array of coordinates. */ -const locations2GeojsonTrajectory = (trip, locationList, trajectoryColor?) => { +const locations2GeojsonTrajectory = ( + trip: CompositeTrip, + locationList: Array, + trajectoryColor?: string, +) => { let sectionsPoints; if (!trip.sections) { // this is a unimodal trip so we put all the locations in one section @@ -212,3 +246,350 @@ const locations2GeojsonTrajectory = (trip, locationList, trajectoryColor?) => { }; }); }; + +// DB entries retrieved from the server have '_id', 'metadata', and 'data' fields. +// This function returns a shallow copy of the obj, which flattens the +// 'data' field into the top level, while also including '_id' and 'metadata.key' +const unpackServerData = (obj: BEMData) => ({ + ...obj.data, + _id: obj._id, + key: obj.metadata.key, + origin_key: obj.metadata.origin_key || obj.metadata.key, +}); + +export function readAllCompositeTrips(startTs: number, endTs: number) { + const readPromises = [getRawEntries(['analysis/composite_trip'], startTs, endTs, 'data.end_ts')]; + return Promise.all(readPromises) + .then(([ctList]: [ServerResponse]) => { + return ctList.phone_data.map((ct) => { + const unpackedCt = unpackServerData(ct); + return { + ...unpackedCt, + start_confirmed_place: unpackServerData(unpackedCt.start_confirmed_place), + end_confirmed_place: unpackServerData(unpackedCt.end_confirmed_place), + locations: unpackedCt.locations?.map(unpackServerData), + sections: unpackedCt.sections?.map(unpackServerData), + }; + }); + }) + .catch((err) => { + displayError(err, 'while reading confirmed trips'); + return []; + }); +} +const dateTime2localdate = function (currtime: DateTime, tz: string) { + return { + timezone: tz, + year: currtime.year, + //the months of the draft trips match the one format needed for + //moment function however now that is modified we need to also + //modify the months value here + month: currtime.month, + day: currtime.day, + weekday: currtime.weekday, + hour: currtime.hour, + minute: currtime.minute, + second: currtime.second, + }; +}; + +const points2TripProps = function (locationPoints: Array>) { + const startPoint = locationPoints[0]; + const endPoint = locationPoints[locationPoints.length - 1]; + const tripAndSectionId = `unprocessed_${startPoint.data.ts}_${endPoint.data.ts}`; + const startTime = DateTime.fromSeconds(startPoint.data.ts).setZone(startPoint.metadata.time_zone); + const endTime = DateTime.fromSeconds(endPoint.data.ts).setZone(endPoint.metadata.time_zone); + + const speeds = []; + const dists = []; + let loc, locLatLng; + locationPoints.forEach((pt) => { + const ptLatLng = L.latLng([pt.data.latitude, pt.data.longitude]); + if (loc) { + const dist = locLatLng.distanceTo(ptLatLng); + const timeDelta = pt.data.ts - loc.data.ts; + dists.push(dist); + speeds.push(dist / timeDelta); + } + loc = pt; + locLatLng = ptLatLng; + }); + + const locations = locationPoints.map((point, i) => ({ + loc: { + coordinates: [point.data.longitude, point.data.latitude], + }, + ts: point.data.ts, + speed: speeds[i], + })); + + return { + _id: { $oid: tripAndSectionId }, + key: 'UNPROCESSED_trip', + origin_key: 'UNPROCESSED_trip', + additions: [], + confidence_threshold: 0, + distance: dists.reduce((a, b) => a + b, 0), + duration: endPoint.data.ts - startPoint.data.ts, + end_fmt_time: endTime.toISO(), + end_local_dt: dateTime2localdate(endTime, endPoint.metadata.time_zone), + end_ts: endPoint.data.ts, + expectation: { to_label: true }, + inferred_labels: [], + locations: locations, + source: 'unprocessed', + start_fmt_time: startTime.toISO(), + start_local_dt: dateTime2localdate(startTime, startPoint.metadata.time_zone), + start_ts: startPoint.data.ts, + user_input: {}, + }; +}; + +const tsEntrySort = function (e1: BEMData, e2: BEMData) { + // compare timestamps + return e1.data.ts - e2.data.ts; +}; + +const transitionTrip2TripObj = function (trip: Array) { + const tripStartTransition = trip[0]; + const tripEndTransition = trip[1]; + const tq = { + key: 'write_ts', + startTs: tripStartTransition.data.ts, + endTs: tripEndTransition.data.ts, + }; + logDebug( + 'About to pull location data for range ' + + DateTime.fromSeconds(tripStartTransition.data.ts).toLocaleString(DateTime.DATETIME_MED) + + ' to ' + + DateTime.fromSeconds(tripEndTransition.data.ts).toLocaleString(DateTime.DATETIME_MED), + ); + const getSensorData = window['cordova'].plugins.BEMUserCache.getSensorDataForInterval; + return getUnifiedDataForInterval('background/filtered_location', tq, getSensorData).then( + function (locationList: Array>) { + if (locationList.length == 0) { + return undefined; + } + const sortedLocationList = locationList.sort(tsEntrySort); + const retainInRange = function (loc) { + return ( + tripStartTransition.data.ts <= loc.data.ts && loc.data.ts <= tripEndTransition.data.ts + ); + }; + + const filteredLocationList = sortedLocationList.filter(retainInRange); + + // Fix for https://github.com/e-mission/e-mission-docs/issues/417 + if (filteredLocationList.length == 0) { + return undefined; + } + + const tripStartPoint = filteredLocationList[0]; + const tripEndPoint = filteredLocationList[filteredLocationList.length - 1]; + logDebug( + 'tripStartPoint = ' + + JSON.stringify(tripStartPoint) + + 'tripEndPoint = ' + + JSON.stringify(tripEndPoint), + ); + // if we get a list but our start and end are undefined + // let's print out the complete original list to get a clue + // this should help with debugging + // https://github.com/e-mission/e-mission-docs/issues/417 + // if it ever occurs again + if (tripStartPoint === undefined || tripEndPoint === undefined) { + logDebug('BUG 417 check: locationList = ' + JSON.stringify(locationList)); + logDebug( + 'transitions: start = ' + + JSON.stringify(tripStartTransition.data) + + ' end = ' + + JSON.stringify(tripEndTransition.data.ts), + ); + } + + const tripProps = points2TripProps(filteredLocationList); + + return { + ...tripProps, + start_loc: { + type: 'Point', + coordinates: [tripStartPoint.data.longitude, tripStartPoint.data.latitude], + }, + end_loc: { + type: 'Point', + coordinates: [tripEndPoint.data.longitude, tripEndPoint.data.latitude], + }, + }; + }, + ); +}; +const isStartingTransition = function (transWrapper: BEMData) { + if ( + transWrapper.data.transition == 'local.transition.exited_geofence' || + transWrapper.data.transition == 'T_EXITED_GEOFENCE' || + transWrapper.data.transition == 1 + ) { + return true; + } + return false; +}; + +const isEndingTransition = function (transWrapper: BEMData) { + // Logger.log("isEndingTransition: transWrapper.data.transition = "+transWrapper.data.transition); + if ( + transWrapper.data.transition == 'T_TRIP_ENDED' || + transWrapper.data.transition == 'local.transition.stopped_moving' || + transWrapper.data.transition == 2 + ) { + // Logger.log("Returning true"); + return true; + } + // Logger.log("Returning false"); + return false; +}; +/* + * This is going to be a bit tricky. As we can see from + * https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163, + * when we read local transitions, they have a string for the transition + * (e.g. `T_DATA_PUSHED`), while the remote transitions have an integer + * (e.g. `2`). + * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286338606 + * + * Also, at least on iOS, it is possible for trip end to be detected way + * after the end of the trip, so the trip end transition of a processed + * trip may actually show up as an unprocessed transition. + * See https://github.com/e-mission/e-mission-phone/issues/214#issuecomment-286279163 + * + * Let's abstract this out into our own minor state machine. + */ +const transitions2Trips = function (transitionList: Array>) { + let inTrip = false; + const tripList = []; + let currStartTransitionIndex = -1; + let currEndTransitionIndex = -1; + let processedUntil = 0; + + while (processedUntil < transitionList.length) { + // Logger.log("searching within list = "+JSON.stringify(transitionList.slice(processedUntil))); + if (inTrip == false) { + const foundStartTransitionIndex = transitionList + .slice(processedUntil) + .findIndex(isStartingTransition); + if (foundStartTransitionIndex == -1) { + logDebug('No further unprocessed trips started, exiting loop'); + processedUntil = transitionList.length; + } else { + currStartTransitionIndex = processedUntil + foundStartTransitionIndex; + processedUntil = currStartTransitionIndex; + logDebug( + 'Unprocessed trip started at ' + JSON.stringify(transitionList[currStartTransitionIndex]), + ); + inTrip = true; + } + } else { + const foundEndTransitionIndex = transitionList + .slice(processedUntil) + .findIndex(isEndingTransition); + if (foundEndTransitionIndex == -1) { + logDebug( + "Can't find end for trip starting at " + + JSON.stringify(transitionList[currStartTransitionIndex]) + + ' dropping it', + ); + processedUntil = transitionList.length; + } else { + currEndTransitionIndex = processedUntil + foundEndTransitionIndex; + processedUntil = currEndTransitionIndex; + logDebug(`currEndTransitionIndex ${currEndTransitionIndex}`); + logDebug( + 'Unprocessed trip starting at ' + + JSON.stringify(transitionList[currStartTransitionIndex]) + + ' ends at ' + + JSON.stringify(transitionList[currEndTransitionIndex]), + ); + tripList.push([ + transitionList[currStartTransitionIndex], + transitionList[currEndTransitionIndex], + ]); + inTrip = false; + } + } + } + return tripList; +}; + +const linkTrips = function (trip1, trip2) { + // complete trip1 + trip1.starting_trip = { $oid: trip2.id }; + trip1.exit_fmt_time = trip2.enter_fmt_time; + trip1.exit_local_dt = trip2.enter_local_dt; + trip1.exit_ts = trip2.enter_ts; + + // start trip2 + trip2.ending_trip = { $oid: trip1.id }; + trip2.enter_fmt_time = trip1.exit_fmt_time; + trip2.enter_local_dt = trip1.exit_local_dt; + trip2.enter_ts = trip1.exit_ts; +}; + +export function readUnprocessedTrips( + startTs: number, + endTs: number, + lastProcessedTrip: CompositeTrip, +) { + const tq = { key: 'write_ts', startTs, endTs }; + logDebug( + 'about to query for unprocessed trips from ' + + DateTime.fromSeconds(tq.startTs).toLocaleString(DateTime.DATETIME_MED) + + DateTime.fromSeconds(tq.endTs).toLocaleString(DateTime.DATETIME_MED), + ); + const getMessageMethod = window['cordova'].plugins.BEMUserCache.getMessagesForInterval; + return getUnifiedDataForInterval('statemachine/transition', tq, getMessageMethod).then(function ( + transitionList: Array>, + ) { + if (transitionList.length == 0) { + logDebug('No unprocessed trips. yay!'); + return []; + } else { + logDebug(`Found ${transitionList.length} transitions. yay!`); + const tripsList = transitions2Trips(transitionList); + logDebug(`Mapped into ${tripsList.length} trips. yay!`); + tripsList.forEach(function (trip) { + logDebug(JSON.stringify(trip, null, 2)); + }); + const tripFillPromises = tripsList.map(transitionTrip2TripObj); + return Promise.all(tripFillPromises).then(function (raw_trip_gj_list) { + // Now we need to link up the trips. linking unprocessed trips + // to one another is fairly simple, but we need to link the + // first unprocessed trip to the last processed trip. + // This might be challenging if we don't have any processed + // trips for the day. I don't want to go back forever until + // I find a trip. So if this is the first trip, we will start a + // new chain for now, since this is with unprocessed data + // anyway. + + logDebug(`mapped trips to trip_gj_list of size ${raw_trip_gj_list.length}`); + /* Filtering: we will keep trips that are 1) defined and 2) have a distance >= 100m or duration >= 5 minutes + https://github.com/e-mission/e-mission-docs/issues/966#issuecomment-1709112578 */ + const trip_gj_list = raw_trip_gj_list.filter( + (trip) => trip && (trip.distance >= 100 || trip.duration >= 300), + ); + logDebug( + `after filtering undefined and distance < 100, trip_gj_list size = ${trip_gj_list.length}`, + ); + // Link 0th trip to first, first to second, ... + for (let i = 0; i < trip_gj_list.length - 1; i++) { + linkTrips(trip_gj_list[i], trip_gj_list[i + 1]); + } + logDebug(`finished linking trips for list of size ${trip_gj_list.length}`); + if (lastProcessedTrip && trip_gj_list.length != 0) { + // Need to link the entire chain above to the processed data + logDebug('linking unprocessed and processed trip chains'); + linkTrips(lastProcessedTrip, trip_gj_list[0]); + } + logDebug(`Returning final list of size ${trip_gj_list.length}`); + return trip_gj_list; + }); + } + }); +} diff --git a/www/js/services.js b/www/js/services.js index 6ed060ed9..59eb56810 100644 --- a/www/js/services.js +++ b/www/js/services.js @@ -5,32 +5,6 @@ import { getRawEntries } from './services/commHelper'; angular .module('emission.services', ['emission.plugin.logger']) - - .service('ReferHelper', function ($http) { - this.habiticaRegister = function (groupid, successCallback, errorCallback) { - window.cordova.plugins.BEMServerComm.getUserPersonalData( - '/join.group/' + groupid, - successCallback, - errorCallback, - ); - }; - this.joinGroup = function (groupid, userid) { - // TODO: - return new Promise(function (resolve, reject) { - window.cordova.plugins.BEMServerComm.postUserPersonalData( - '/join.group/' + groupid, - 'inviter', - userid, - resolve, - reject, - ); - }); - - //function firstUpperCase(string) { - // return string[0].toUpperCase() + string.slice(1); - //}*/ - }; - }) .service('ControlHelper', function ($window, $ionicPopup, Logger) { this.writeFile = function (fileEntry, resultList) { // Create a FileWriter object for our FileEntry (log.txt). diff --git a/www/js/services/unifiedDataLoader.ts b/www/js/services/unifiedDataLoader.ts index 00f6e3027..5c06d33a5 100644 --- a/www/js/services/unifiedDataLoader.ts +++ b/www/js/services/unifiedDataLoader.ts @@ -1,12 +1,12 @@ import { getRawEntries } from './commHelper'; -import { ServerResponse, ServerData, TimeQuery } from '../types/serverData'; +import { ServerResponse, BEMData, TimeQuery } from '../types/serverData'; /** * removeDup is a helper function for combinedPromises * @param list An array of values from a BEMUserCache promise * @returns an array with duplicate values removed */ -export const removeDup = function (list: Array>) { +export const removeDup = function (list: Array>) { return list.filter(function (value, i, array) { const firstIndexOfValue = array.findIndex(function (element) { return element.metadata.write_ts == value.metadata.write_ts; diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index d7ecf0bb9..1cd88dd01 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -22,6 +22,8 @@ export type SurveyOptions = { }; type EnketoResponseData = { + start_ts?: number; //start timestamp (in seconds) + end_ts?: number; //end timestamp (in seconds) label: string; //display label (this value is use for displaying on the button) ts: string; //the timestamp at which the survey was filled out (in seconds) fmt_time: string; //the formatted timestamp at which the survey was filled out @@ -94,17 +96,15 @@ let _config: EnketoSurveyConfig; * @param {string} name survey name (defined in enketo survey config) * @param {EnketoResponse[]} responses An array of previously recorded responses to Enketo surveys * (presumably having been retrieved from unifiedDataLoader) + * @param {AppConfig} appConfig the dynamic config file for the app * @return {Promise} filtered survey responses */ -export function filterByNameAndVersion(name: string, responses: EnketoResponse[]) { - return getConfig().then((config) => - responses.filter( - (r) => - r.data.name === name && r.data.version >= config.survey_info.surveys[name].compatibleWith, - ), +export function filterByNameAndVersion(name: string, responses: EnketoResponse[], appConfig) { + return responses.filter( + (r) => + r.data.name === name && r.data.version >= appConfig.survey_info.surveys[name].compatibleWith, ); } - /** * resolve a label for the survey response * @param {string} name survey name @@ -272,7 +272,7 @@ const _getMostRecent = (responses) => { export function loadPreviousResponseForSurvey(dataKey: string) { const tq = window['cordova'].plugins.BEMUserCache.getAllTimeQuery(); logDebug('loadPreviousResponseForSurvey: dataKey = ' + dataKey + '; tq = ' + tq); - const getMethod = window['cordova'].plugins.BEMUserCache.getSensorDataForInterval; + const getMethod = window['cordova'].plugins.BEMUserCache.getMessagesForInterval; return getUnifiedDataForInterval(dataKey, tq, getMethod).then((responses) => _getMostRecent(responses), ); diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index f032f2f5a..6a91095ac 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -4,31 +4,7 @@ import { getAngularService } from '../../angular-react-helper'; import { fetchUrlCached } from '../../services/commHelper'; import i18next from 'i18next'; import { logDebug } from '../../plugin/logger'; - -type InputDetails = { - [k in T]?: { - name: string; - labeltext: string; - choosetext: string; - key: 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 }; - }; -}; +import { LabelOption, LabelOptions, MultilabelKey, InputDetails } from '../../types/labelTypes'; let appConfig; export let labelOptions: LabelOptions; diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts index f99000e90..27e03a458 100644 --- a/www/js/types/appConfigTypes.ts +++ b/www/js/types/appConfigTypes.ts @@ -6,6 +6,7 @@ export type AppConfig = { survey_info: { 'trip-labels': 'MULTILABEL' | 'ENKETO'; surveys: EnketoSurveyConfig; + buttons?: any; }; reminderSchemes?: ReminderSchemeConfig; [k: string]: any; // TODO fill in all the other fields diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts index 9a856b115..64cd87e3b 100644 --- a/www/js/types/diaryTypes.ts +++ b/www/js/types/diaryTypes.ts @@ -3,10 +3,21 @@ As much as possible, these types parallel the types used in the server code. */ import { BaseModeKey, MotionTypeKey } from '../diary/diaryHelper'; +import { BEMData, LocalDt } from './serverData'; +import { FeatureCollection, Feature, Geometry, Point } from 'geojson'; type ObjectId = { $oid: string }; + +type UserInput = { + /* 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; +}; + export type ConfirmedPlace = { - _id: ObjectId; additions: UserInputEntry[]; cleaned_place: ObjectId; ending_trip: ObjectId; @@ -17,18 +28,49 @@ export type ConfirmedPlace = { exit_local_dt: LocalDt; exit_ts: number; // Unix timestamp key: string; - location: { type: string; coordinates: number[] }; + location: Geometry; 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; + user_input: UserInput; + starting_trip: ObjectId; +}; + +export type TripTransition = { + currstate: string; + transition: string | number; + ts: number; +}; + +type CompTripLocations = { + loc: { + coordinates: [number, number]; // [1,2.3] }; + speed: number; + ts: number; +}; + +// Used for return type of readUnprocessedTrips +export type UnprocessedTrip = { + _id: ObjectId; + additions: UserInputEntry[]; + confidence_threshold: number; + distance: number; + duration: number; + end_fmt_time: string; + end_loc: Point; + end_local_dt: LocalDt; + expectation: any; // TODO "{to_label: boolean}" + inferred_labels: any[]; // TODO + key: string; + locations?: CompTripLocations[]; + origin_key: string; // e.x., UNPROCESSED_trip + source: string; + start_local_dt: LocalDt; + start_ts: number; + start_loc: Point; + starting_trip?: any; + user_input: UserInput; }; /* These are the properties received from the server (basically matches Python code) @@ -42,9 +84,9 @@ export type CompositeTrip = { confirmed_trip: ObjectId; distance: number; duration: number; - end_confirmed_place: ServerData; + end_confirmed_place: BEMData; end_fmt_time: string; - end_loc: { type: string; coordinates: number[] }; + end_loc: Point; end_local_dt: LocalDt; end_place: ObjectId; end_ts: number; @@ -59,26 +101,21 @@ export type CompositeTrip = { raw_trip: ObjectId; sections: any[]; // TODO source: string; - start_confirmed_place: ServerData; + start_confirmed_place: BEMData; start_fmt_time: string; - start_loc: { type: string; coordinates: number[] }; + start_loc: Point; 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' */ - [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; - }; + user_input: UserInput; }; /* 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; +export type TimestampRange = { start_ts: number; end_ts: number }; + /* Type guard to disambiguate timeline entries as either trips or places If it has a 'start_ts' and 'end_ts', it's a trip. Else, it's a place. */ export const isTrip = (entry: TimelineEntry): entry is CompositeTrip => @@ -105,34 +142,71 @@ export type SectionSummary = { 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; - }; - metadata: { - time_zone: string; - plugin: string; - write_ts: number; - platform: string; - read_ts: number; - key: string; - }; - key?: string; +type UserInputData = { + end_ts: number; + start_ts: number; + label: string; + start_local_dt?: LocalDt; + end_local_dt?: LocalDt; + status?: string; + match_id?: string; +}; + +export type UserInputEntry = BEMData; + +export type Location = { + speed: number; + heading: number; + local_dt: LocalDt; + idx: number; + section: ObjectId; + longitude: number; + latitude: number; + fmt_time: string; // ISO + mode: number; + loc: Geometry; + ts: number; // Unix + altitude: number; + distance: number; +}; + +// used in readAllCompositeTrips +export type SectionData = { + end_ts: number; // Unix time, e.x. 1696352498.804 + end_loc: Geometry; + start_fmt_time: string; // ISO time + end_fmt_time: string; + trip_id: ObjectId; + sensed_mode: number; + source: string; // e.x., "SmoothedHighConfidenceMotion" + start_ts: number; // Unix + start_loc: Geometry; + cleaned_section: ObjectId; + start_local_dt: LocalDt; + end_local_dt: LocalDt; + sensed_mode_str: string; //e.x., "CAR" + duration: number; + distance: number; }; -export type LocalDt = { - minute: number; - hour: number; - second: number; - day: number; - weekday: number; - month: number; - year: number; - timezone: string; +// used in timelineHelper's `transitionTrip2TripObj` +export type FilteredLocation = { + accuracy: number; + altitude: number; + elapsedRealtimeNanos: number; + filter: string; + fmt_time: string; + heading: number; + latitude: number; + loc: Geometry; + local_dt: LocalDt; + longitude: number; + sensed_speed: number; + ts: number; +}; + +export type GeoJSONStyledFeature = Feature & { style?: { color: string } }; + +export type GeoJSONData = { + data: FeatureCollection & { id: string; properties: { start_ts: number; end_ts: number } }; }; diff --git a/www/js/types/fileShareTypes.ts b/www/js/types/fileShareTypes.ts index 03b41a161..ee8d9a14e 100644 --- a/www/js/types/fileShareTypes.ts +++ b/www/js/types/fileShareTypes.ts @@ -1,6 +1,6 @@ -import { ServerData } from './serverData'; +import { BEMData } from './serverData'; -export type TimeStampData = ServerData; +export type TimeStampData = BEMData; export type RawTimelineData = { name: string; diff --git a/www/js/types/labelTypes.ts b/www/js/types/labelTypes.ts new file mode 100644 index 000000000..8ac720adc --- /dev/null +++ b/www/js/types/labelTypes.ts @@ -0,0 +1,24 @@ +export type InputDetails = { + [k in T]?: { + name: string; + labeltext: string; + choosetext: string; + key: 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 }; + }; +}; diff --git a/www/js/types/serverData.ts b/www/js/types/serverData.ts index 46fa9214b..b68078552 100644 --- a/www/js/types/serverData.ts +++ b/www/js/types/serverData.ts @@ -1,8 +1,8 @@ export type ServerResponse = { - phone_data: Array>; + phone_data: Array>; }; -export type ServerData = { +export type BEMData = { data: Type; metadata: MetaData; key?: string; @@ -17,6 +17,8 @@ export type MetaData = { time_zone: string; write_fmt_time: string; write_local_dt: LocalDt; + origin_key?: string; + read_ts?: number; }; export type LocalDt = {