Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Revisit and unify user input label handling #674

Closed
shankari opened this issue Sep 23, 2021 · 50 comments
Closed

Revisit and unify user input label handling #674

shankari opened this issue Sep 23, 2021 · 50 comments

Comments

@shankari
Copy link
Contributor

@asiripanich @atton16 I have now finished the force sync hack (e-mission/e-mission-phone#797) and will start work on the user label handling. If I have to go back and look at this anyway, I will probably refactor some of the current code around mode_confirm, purpose_confirm etc as well, including on the server.

@PatGendre I will provide a migration script in case you are using the existing manual/XXX labels in your database.

@shankari
Copy link
Contributor Author

I have been thinking for a while that, instead of having separate manual/mode_confirm, manual/purpose_confirm, manual/replaced_mode_confirm...

we may want to have a data structure with key manual/user_input and a more complex structure:

user_input: {
   mode: 
   purpose:
   replaced_mode:
}

I can see three possible advantages with this change:

  • the same data structure can support labels and surveys. surveys would just have a more complex structure
user_input: {
  name:
  address:
  ...
}
  • the user input code can be significantly simplified since it no longer needs to work with multiple inputs. The confirmed_trip code already takes existing manual labels and converts them into this structure. After the change, we could just copy over the entire structure instead of specifying the user input labels at
    https://github.com/e-mission/e-mission-server/blob/9ce6aacf328866c74ca5cdc754c0c9a03591aa3d/conf/analysis/debug.conf.json.sample#L11
    "userinput.keylist": ["manual/mode_confirm", "manual/purpose_confirm"] and change it every time

  • deployers who want to add new semantic data would no longer have to add a new data type, a non-trivial task. Instead, they could just store the user input in a different format, and the existing pipeline, including the autolabeling, would Just Work.

@shankari
Copy link
Contributor Author

I'm going to start with making the changes on the client side and refactoring the code accordingly. If it works, we can change the server and write the migration script. But let's defer that until we know that the client side changes look good.

Since the client-side changes for existing platforms may not be pushed for a while, we will have to maintain some backwards compat code on the server for a couple of months.

@PatGendre
Copy link
Contributor

@shankari : thanks for the force sync commit :-) we have at least one Android user obliged to force sync manually from time to time because the data doesn't seem to make it to the server in the background.

I will provide a migration script in case you are using the existing manual/XXX labels in your database.

thanks again! We send this manual labels to the personal cloud of the beta-testers and although not using this data yet, we intend to.

@atton16
Copy link

atton16 commented Sep 24, 2021

In our case, we have multiple modes and purposes. So we cannot use either of them.

Right now, we take the advantage of the userInput.data.label to display survey response shown in the pic below.

Screen Shot 2564-09-24 at 13 44 13

The fact that the proposed user_input allows arbitrary number of fields make it flexible support other input type. This is a great plus.

I would like to give you of the data structure we are using now. It might spark some idea for your new design.

Here is the data inside manual/survey_response:

const data = {
  start_ts: number,    // for trip reference
  end_ts: number,      // for trip reference
  uuid: string,        // for user reference

  timestamp: number,   // for sorting purpose

  label: string,       // for button label

  name: string,        // enketo survey name (for filtering)
  version: string,     // enketo survey version (for filtering and compatibility resolution)
  xmlResponse: string, // enketo survey response xml string
}

In our case, the thing we do not wanted to touch the most is adding new key to the server. So generalize user input into one data type bring us the great benefit.

@shankari
Copy link
Contributor Author

@PatGendre

thanks for the force sync commit :-) we have at least one Android user obliged to force sync manually from time to time because the data doesn't seem to make it to the server in the background.

Let me know the hack fixes the issue for the user. I can then merge it, and think about the longer-term native fix as well.

@shankari
Copy link
Contributor Author

@atton16 thanks for the example! You can, of course, use any fields within the object that you like. Having said that, you don't need the UUID, since all entries include the UUID by default - the entry structure is

{
    _id:
    uuid:
    metadata:
    data:
}

If the timestamp represents the time the the object was written, that is also captured in metadata.write_ts.
Having standard fields for all data structures (uuid, write_ts) but allowing the data to be more flexible gives us the best of both worlds.

The rest of the fields make sense. I would suggest that you should convert the xmlResponse to JSON before storing since it can then be the basis of mongodb queries.

@atton16
Copy link

atton16 commented Sep 24, 2021

@atton16 thanks for the example! You can, of course, use any fields within the object that you like. Having said that, you don't need the UUID, since all entries include the UUID by default - the entry structure is

{
    _id:
    uuid:
    metadata:
    data:
}

If the timestamp represents the time the the object was written, that is also captured in metadata.write_ts.
Having standard fields for all data structures (uuid, write_ts) but allowing the data to be more flexible gives us the best of both worlds.

The rest of the fields make sense. I would suggest that you should convert the xmlResponse to JSON before storing since it can then be the basis of mongodb queries.

I could convert xmlResponse to json but I also need it for internal enketo library to work. Best to add jsonResponse which is the json-converted version of xmlResponse.

Edit:
My concern on uuid and timestamp is that the metadata and the provided user_id field is not ready until it is pushed. That is why I also embed it in data object.

@shankari
Copy link
Contributor Author

shankari commented Sep 24, 2021

My concern on uuid and timestamp is that the metadata and the provided user_id field is not ready until it is pushed. That is why I also embed it in data object.

Until it is pushed, you don't need the UUID, since you are only dealing with data from one user on the phone.

And we do have metadata for the entries retrieved on the phone - that's why you have to use userInput.data to retrieve values; it is because there is also a userInput.metadata.

As another example, we actually use the metadata in the UnifiedDataLoader to find duplicate elements between the locally retrieved elements and the remotely retrieved elements.

https://github.com/e-mission/e-mission-phone/blob/5d964cbef72732fe4f2e9987d39c32c61d536433/www/js/services.js#L238

          return element.metadata.write_ts == value.metadata.write_ts;

@atton16
Copy link

atton16 commented Sep 27, 2021

My concern on uuid and timestamp is that the metadata and the provided user_id field is not ready until it is pushed. That is why I also embed it in data object.

Until it is pushed, you don't need the UUID, since you are only dealing with data from one user on the phone.

And we do have metadata for the entries retrieved on the phone - that's why you have to use userInput.data to retrieve values; it is because there is also a userInput.metadata.

As another example, we actually use the metadata in the UnifiedDataLoader to find duplicate elements between the locally retrieved elements and the remotely retrieved elements.

https://github.com/e-mission/e-mission-phone/blob/5d964cbef72732fe4f2e9987d39c32c61d536433/www/js/services.js#L238

          return element.metadata.write_ts == value.metadata.write_ts;

Thank you for the clarification. I might be able to get rid of it in our survey and using the provided fields.

@shankari
Copy link
Contributor Author

shankari commented Sep 27, 2021

At this point, we have the label code largely pulled out into its own modular element.

There are currently two main aspects that remain in list.js.

These are:

  1. $scope.populateInputFromTimeline, which is called when post-processing the loaded trips for the day to match the inputs
  2. $scope.$on('$ionicView.loaded', function() { which fills in the default input parameters and maps

@shankari
Copy link
Contributor Author

The obvious design decision for the first choice is to populate the value in the directive.
This has a couple of nice advantages:

  • we can keep all the user input stuff in the directive instead of having it pollute the list module
  • as a corollary, if the platform is being used for purely passive data collection without user input, the user input element will also be skipped, which will exclude all that logic and complexity.

However $scope.populateInputFromTimeline requires us to have both the current trip and the next trip. This is because it calls DiaryHelper.getUserInputForTrip, which checks that the end of the matched user input is before the start of the next trip. e-mission/e-mission-phone@a70569e

And since the directive works on the current trip by default, this is a problem.

Some solutions we can rule out:

  • I don't think we should revert the improved checking, it is going to be really annoying for participants to enter in their values and then have them disappear
  • I don't think we should support only labeling confirmed trips because of the potential delay in uploading and confirming. If users are inspired to fill in labels, we should be happy to take their data. We can revisit this if we change the upload/pipeline process to have a lower lag time.

So we have to pass in the next trip as well (for backward compatibility).

We are currently iterating through the trips using tripgj in data.currDayTripWrappers. So we can:

  1. iterate through the indices instead
  2. make currDayTripWrappers be a linked list
  3. have each entry in the wrapper be a tuple instead of a trip

Options (2) and (3) are very similar, and are the closest to the current codebase. Let's go ahead and use (2) because it feels a little nicer, and there are less of an input to pass in.

shankari added a commit to shankari/e-mission-phone that referenced this issue Sep 27, 2021
+ To allow us to move user input labeling into the directive as well
(e-mission/e-mission-docs#674 (comment))

This was a little tricky because it turns out that when the directive is
created, `tripgj` is undefined. It then gets updated through the digest
process. The really weird thing is that when we log the `scope` object
directly, it looks like it also gets updated with the digest.

So we ended up with logs like

```
Scope
    isolateBindings: {inputs: {…}, tripgj: {…}, unifiedConfirmsResults: {…}, inputParams: {…}}
    inputParams: {MODE: {…}, PURPOSE: {…}}
    inputs: (2) ["MODE", "PURPOSE"]
    tripgj: {data: {…}, start_place: {…}, style: ƒ, onEachFeature: ƒ, pointToLayer: ƒ, …}
    unifiedConfirmsResults: {MODE: Array(60), PURPOSE: Array(63)}
    userInputDetails: (2) [{…}, {…}]__proto__: Object

unifiedresults [object Object]
scope trip information is undefined
```

which was driving me crazy.
After adding the `$watch` callback, we can now fill the inputs correctly

``
the trip binding has changed from [object Object] to new value
Checking to fill user inputs for [object Object]
potentialCandidates are 2016-08-04T13:03:51-07:00(1470341031.235) -> 2016-08-04T13:35:12-07:00(1470342912) shared_ride logged at 1632745829.985,2016-08-04T13:03:51-07:00(1470341031.235) -> 2016-08-04T13:35:12-07:00(1470342912) drove_alone logged at 1632745826.26,2016-08-04T13:03:51-07:00(1470341031.235) -> 2016-08-04T13:35:12-07:00(1470342912) shared_ride logged at 1602636485.509,2016-08-04T13:03:51-07:00(1470341031.235) -> 2016-08-04T13:35:12-07:00(1470342912) shared_ride logged at 1602636496.041:undefined
```

+ Check in the multi-label-ui for the first time. Better late than never!
@shankari
Copy link
Contributor Author

The design for the second choice is more complicated. I re-added the listener to the element passed in to the controller, and it doesn't work - there are no matches for "after loading in directive".

I'm also a bit confused about what we use this for, and how it works because, even in the original controller, the output is just

after loading, inputParams = {}

@shankari
Copy link
Contributor Author

So the initial load doesn't initialize anything, but we do have entries before we start matching inputs

index.html:145 after loading, inputParams = {}
index.html:145 While populating inputs, inputParams MODE: options: (9) [{…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}] PURPOSE: {options: Array(15), text2entry: {…}, value2entry: {…}}
index.html:145 While populating inputs, inputParams MODE: options: (9) [{…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}]text2entry: value2entry: PURPOSE: {options: Array(15), text2entry: {…}, value2entry: {…}}
index.html:145 While populating inputs, inputParams Object
index.html:145 While populating inputs, inputParams Object
index.html:145 While populating inputs, inputParams Object
index.html:145 While populating inputs, inputParams Object
index.html:145 While populating inputs, inputParams Object
index.html:145 While populating inputs, inputParams Object
index.html:145 While populating inputs, inputParams Object
index.html:145 While populating inputs, inputParams Object
index.html:145 While populating inputs, inputParams Object
index.html:145 While populating inputs, inputParams Object

Where are these changed and if we don't rely on the callback to populate them, can we remove it?

@shankari
Copy link
Contributor Author

if the callback is removed, we don't have any labels so it is clearly needed.

index.html:145 TypeError: Cannot read property 'value2entry' of undefined
    at Scope.$scope.populateInputFromTimeline (multi-label-ui.js:29)
    at multi-label-ui.js:48
    at Array.forEach (<anonymous>)
    at Scope.$scope.fillUserInputs (multi-label-ui.js:47)
    at multi-label-ui.js:58
    at Scope.$digest (ionic.bundle.js:30239)
    at render (ionic.bundle.js:62720)
    at forceRerender (ionic.bundle.js:62618)
    at RepeatController.refreshLayout (ionic.bundle.js:62589)
    at refreshDimensions (ionic.bundle.js:62344)

@shankari
Copy link
Contributor Author

ah, we do load the data, we just do so asynchronously. ConfirmHelper.getOptionsAndMaps returns a promise.
Next step: can we get the event in the directive so we can move the code there?

Note also that it is a bit sub-optimal to read the options for each directive separately. But we don't want to keep this code in this list since it is not relevant for the purely passive case, or in the case of other labels such as surveys.

What we really need is a shared datastructure across the directives, but outside the list. Shared datastructures in angular are (IIRC) stored in services. We have a service (or rather, a factory, which is similar). Can we move this code into (ConfirmHelper)?

@shankari
Copy link
Contributor Author

shankari commented Sep 28, 2021

I think we can, but we have to think carefully about the timing. Concretely, we have two independent threads:

  • loading the JSON options
  • filling in the tripgj, which in turn, triggers filling in the user input and requires the JSON options to have loaded first

This was fairly wonky even before, because we launched the JSON options load on the view loaded event, and populated the inputs while handling the trip geojson. The first operation is short and the second is long, so the timing almost always worked. But if the first operation slows down for some reason, the code will break.

Verified this by adding a 60 sec timeout to the load function

diff --git a/www/js/tripconfirm/trip-confirm-services.js b/www/js/tripconfirm/trip-confirm-services.js
index 5094f057..24be3163 100644
--- a/www/js/tripconfirm/trip-confirm-services.js
+++ b/www/js/tripconfirm/trip-confirm-services.js
@@ -1,5 +1,5 @@
 angular.module('emission.tripconfirm.services', ['ionic', 'emission.i18n.utils', "emission.plugin.logger"])
-.factory("ConfirmHelper", function($http, $ionicPopup, $translate, i18nUtils, Logger) {
+.factory("ConfirmHelper", function($http, $ionicPopup, $translate, i18nUtils, Logger, $timeout) {
     var ch = {};
     ch.INPUTS = ["MODE", "PURPOSE"]
     ch.inputDetails = {
@@ -82,12 +82,12 @@ angular.module('emission.tripconfirm.services', ['ionic', 'emission
.i18n.utils',
     ch.getOptions = function(inputType) {
         if (!angular.isDefined(ch.inputDetails[inputType].options)) {
             var lang = $translate.use();
-            return loadAndPopulateOptions()
+            return $timeout(() => loadAndPopulateOptions()
                 .then(function () {
                     return ch.inputDetails[inputType].options;
-                });
+                }), 60000);
         } else {
-            return Promise.resolve(ch.inputDetails[inputType].options);
+            return $timeout(() => Promise.resolve(ch.inputDetails[inputType].options),
 10000);
         }
     }

We got the following output. Clearly, due to the 60 sec delay in loading the trip options, we error out while filling them in, and we don't see the user inputs.

ionic view loaded event invoked
in loaded callback, inputParams for MODE =  undefined
in loaded callback, inputParams for PURPOSE =  undefined
registering loaded callback in directive
registering loaded callback in directive


TypeError: Cannot read property 'value2entry' of undefined
TypeError: Cannot read property 'value2entry' of undefined


GET http://localhost/_app_file_/data/user/0/edu.berkeley.eecs.emission.devapp/files/phonegapdevapp/www/json/trip_confirm_options.json 404 (OK)
In loaded callback, processing {options: Array(9), text2entry: {…}, value2entry: {…}}
In loaded callback, processing {options: Array(14), text2entry: {…}, value2entry: {…}}
Waiting for trip confirm options load Successful load, then manual refresh
Screenshot_1632799183 Screenshot_1632799192

@shankari
Copy link
Contributor Author

It is not too terrible to keep the timing as-is, since the first call involves a local read, and the second call involves a network read, so the chances of out-of-order execution are vanishingly small. And when they do occur, they can easily be fixed by reloading. But since we are here anyway, let's see if we can fix this small race condition as well.

@shankari
Copy link
Contributor Author

what I really need is a singleton design pattern.
According to https://stackoverflow.com/questions/21496331/are-angularjs-services-singleton
all angular services (and factories and providers) are essentially singletons, so this should be simple.
We just have to make sure that we return an object instead of a function that returns an object.

With that, the ConfirmHelper interface becomes super simple - it just exports the inputParams. Everything else about reading the data is internal to it. But it will have to return it as a promise; will that still work?

@shankari
Copy link
Contributor Author

After adding some logs:

        let c1 = ConfirmHelper;
        let c2 = ConfirmHelper;
        console.log("Is this a singleton?" + (c1 == c2), c1, c2);
        let p1 = ConfirmHelper.getOptionsAndMaps("MODE");
        let p2 = ConfirmHelper.getOptionsAndMaps("MODE");
        console.log("Are the promises identical?" + (p1 == p2), p1, p2);
        Promise.all([p1, p2]).then((r1, r2) => {
            console.log("Are the results identical?" + (r1 == r2), r1, r2);
        });

We get the following logs:

Is this a singleton?true 
{INPUTS: Array(2), inputDetails: {…}, getOptionsAndMaps: ƒ, getOptions: ƒ, checkOtherOption: ƒ, …} 
{INPUTS: Array(2), inputDetails: {…}, getOptionsAndMaps: ƒ, getOptions: ƒ, checkOtherOption: ƒ, …}


Are the promises identical?false Promise {<pending>} Promise {<pending>}
index.html:145 

Are the results identical?false 
(2) [{…}, {…}]

options: (9) [{…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}]
text2entry: {Walk: {…}, Bike: {…}, Drove Alone: {…}, Shared Ride: {…}, Taxi/Uber/Lyft: {…}, …}
value2entry: {walk: {…}, bike: {…}, drove_alone: {…}, shared_ride: {…}, taxi: {…}, …}

options: (9) [{…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}]
text2entry: {Walk: {…}, Bike: {…}, Drove Alone: {…}, Shared Ride: {…}, Taxi/Uber/Lyft: {…}, …}
value2entry: {walk: {…}, bike: {…}, drove_alone: {…}, shared_ride: {…}, taxi: {…}, …}

@shankari
Copy link
Contributor Author

Since the service is a singleton, we should be able to have a single inputParams object it in. We should be able to populate it directly as the object is created instead of having the calling location invoke a function. However, it needs to be populated with a promise, and we need to know when the promise is complete.

We need to know that the data is present before we call populateInputFromTimeline

shankari added a commit to shankari/e-mission-phone that referenced this issue Sep 28, 2021
Fortunately, all factories and services are already singletons.
e-mission/e-mission-docs#674 (comment)

The property is read asynchronously, though, so we have the data that we want
to read stored as a promise. The first time we access the field, the async
operation completes and the promise completes. In subsequent calls, the promise
is fulfilled and will return immediately.

This allows us to retain the desired singleton behavior even for an
asynchronously read data source.

Testing done:

Promise executes first and only time

```
Starting promise execution with  {}
Promise list  (2) [Promise, Promise]
Read all inputParams, resolving with  {}
controller DiaryListCtrl called
```

It is then available later but without re-reading or delay

```
While populating inputs, inputParams {MODE: {…}, PURPOSE: {…}}
While populating inputs, inputParams {MODE: {…}, PURPOSE: {…}}
```

Introduced a timeout while reading the config

```
diff --git a/www/js/tripconfirm/trip-confirm-services.js b/www/js/tripconfirm/trip-confirm-services.js
index 8089362e..291184fa 100644
--- a/www/js/tripconfirm/trip-confirm-services.js
+++ b/www/js/tripconfirm/trip-confirm-services.js
@@ -1,5 +1,5 @@
 angular.module('emission.tripconfirm.services', ['ionic', 'emission.i18n.utils', "emission.plugin.logger"])
-.factory("ConfirmHelper", function($http, $ionicPopup, $translate, i18nUtils, Logger) {
+.factory("ConfirmHelper", function($http, $ionicPopup, $translate, i18nUtils, Logger, $timeout) {
     var ch = {};
     ch.INPUTS = ["MODE", "PURPOSE"]
     ch.inputDetails = {
@@ -139,8 +139,10 @@ angular.module('emission.tripconfirm.services', ['ionic', 'emission.i18n.utils',
           ch.INPUTS.forEach(function(item, index) {
               inputParams[item] = omObjList[index];
           }));
-          console.log("Read all inputParams, resolving with ", inputParams);
-          resolve(inputParams);
+          $timeout(() => {
+            console.log("Read all inputParams, resolving with ", inputParams);
+            resolve(inputParams)
+          }, 60000);
     });
```

The promise reading started first

```
Starting promise execution with  {}
Promise list  (2) [Promise, Promise]
controller DiaryListCtrl called
```

Then, everything was filled in

```
DEBUG:section 2016-08-04T14:18:35.840464-10:00 -> 2016-08-04T14:34:12.571000-10:00 bound incident addition
```

Several minutes after that, the promise resolved

```
Read all inputParams, resolving with  {MODE: {…}, PURPOSE: {…}}
Checking to fill user inputs for 10:03 AM -> 10:35 AM
While populating inputs, inputParams {MODE: {…}, PURPOSE: {…}}
Set MODE {"text":"Shared Ride","value":"shared_ride"} for trip id "5f8646cde070a9164325d554"
```

And the labels were automagically filled in and green.

yay for promises!
@shankari
Copy link
Contributor Author

At this point (with e-mission/e-mission-phone@c9621c3), we have removed all vestiges of the ConfirmHelper from list.js

grep ConfirmHelper www/js/diary/list.js  | wc -l
       0

Now let's try to use the same or a similar directive in the infinite scroll list. This should have the side effect that any labeling improvements to the list view will also show up in the diary.

@shankari
Copy link
Contributor Author

The changes between the new directive and the infinite scroll list are minor.
Screen Shot 2021-09-28 at 6 31 30 PM

The primary changes and their resolution are:

Change Resolution
padding style for the enclosing div Combine all of them
style v/s class for the enclosing div Retain the class; it was designed for use with the walkthrough and may be used for it again
renaming tripgj v/s trip change name in the directive to trip
Use of finalInference Incorporate into the directive

wrt the input-confirm-row, it was added in e-mission/e-mission-phone@8113fcc to make it easier to walk through the label screen. However, in
e-mission/e-mission-phone@62da14c I changed the walkthrough to use '.diary-entry' instead of input-confirm-row because of #669

So it is not currently used anywhere.

$ grep -r input-confirm-row www/
Binary file www//templates/diary/.infinite_scroll_list.html.swo matches
www//templates/diary/infinite_scroll_list.html:                    <div class="row input-confirm-row">

But if we do ever get to the point where we fix the walkthrough "the right way", we will probably want to restore the pointers, so we will keep the class around.

@shankari
Copy link
Contributor Author

shankari commented Sep 29, 2021

The padding style for the enclosing div was changed in
e-mission/e-mission-phone@9cf8258

There isn't much of an explanation of why we needed to remove the right/left padding. @GabrielKS, do you remember?

Looks like the top margin was always zero from when we copied the information over to create the infinite scroll list (e-mission/e-mission-phone@85a56ba)

I really don't like tweaking CSS values, but it looks like @GabrielKS intentionally removed the padding, so let's just keep that version for now.

shankari added a commit to shankari/e-mission-phone that referenced this issue Sep 29, 2021
And using the new directive in both places

Concretely:
- unify the HTML code as per
e-mission/e-mission-docs#674 (comment)
and
e-mission/e-mission-docs#674 (comment)

- replace the buttons in the label screen with the new directive
- modify the directive slightly to skip `populateInputFromTimeline` if no input
  label list is passed in.

Testing done:
- Diary screen shows the trips with green labels
- Label screen with the "All Trips" filter shows the trips with green labels
@GabrielKS
Copy link

Which lines are you referring to? I messed with margins and padding a fair amount; sometimes it was to streamline unnecessary or out-of-place code, sometimes it was for greater cross-platform reliability, sometimes it was just to make things look better.

@shankari
Copy link
Contributor Author

As we integrate the label screen functionality into the directive, one fairly major question relates to the "one-click" labeling of trips.

Should that be within the directive or not?
Pro: It is part of the labeling
Cons:

  • It is not within the same bounding box as the label buttons
  • We plan to have separate directives for the multilabel and survey cases, and the "one-click" functionality should be the same in both cases

Options:

  • We can solve (1) above through absolute positioning, although I am not sure that is a great idea
  • We can solve (2) through requires controllers, although I am not sure if it works for sibling controllers.

If we can figure out a way to link the two controllers, it seems like two separate directives would really be the best option. It is more modular, and it also addresses the absolute positioning issue.

shankari added a commit to shankari/e-mission-phone that referenced this issue Oct 4, 2021
Move all the labeling code from the infinite scroll list to the directive
Concretely:
- remove the input detail initialization
- move out the code which initializes the inputs and replace it with the
  nextTrip link
- move the manual input population code as well. this is now a separate
  implementation from the diary initialization code; we will need to unify it later
- move related functions to the directive:
    - `populateInput`,
    - `populateManualInputs`,
    - `updateVisibilityAfterDelay`,
    - `updateTripProperties`,
    - `inferFinalLabels`,
    - `updateVerifiability`
- delete functions that were already copied from the diary, with minor
  modifications as necessary:
    - create `$scope.popovers`,
    - `$scope.openPopover`
    - `$scope.choose`
    - `closePopover`
    - `$scope.verifyTrip`

also copied `trip.properties.*_ts` into `trip.*_ts` so we can have the same
implementation for both data structures (e-mission/e-mission-docs#674 (comment))

In addition, in the directive, we do the following:
- add functions to find the view element, its state and scope.
    - We will use this state to indicate which view we are working with, given
      that we plan to support labeling assist in the diary view as well (e-mission/e-mission-docs#674 (comment))
    - We will use the scope to callback to the view and filter the trips accordingly (e-mission/e-mission-docs#674 (comment))

This almost works.

The one pending issue is that of filtering to set `$scope.displayTrips`. In
`$scope.setupInfScroll`, we read entries from the server
(`$scope.readDataFromServer`), which sets `$scope.data.allTrips`. It then calls
`$scope.recomputeDisplayTrip` which uses the userInputs to decide which trips
should be in `$scope.displayTrips`.

The problem is that we set the userInputs in the directive. However, the
directives are not executed until they are displayed. So until we compute which
trips go into `displayTrips`, we will not execute the directives, which means
that we will not have the fields we need to compute the directives. We need to
resolve this circular dependency.
@shankari
Copy link
Contributor Author

shankari commented Oct 4, 2021

One final issue:

The problem is that we set the userInputs in the directive. However, the directives are not executed until they are displayed. So until we compute which trips go into displayTrips, we will not execute the directives, which means that we will not have the fields we need to compute the directives. We need to resolve this circular dependency.

This is however, really hard to fix. We cannot rely on any directive-level code because the directives will not be initialized unless we set $scope.data.displayTrips. So the obvious fix is to pull out the filtering code in the directive into a service and invoke it from both the directive and the view controller.

In other words MultiLabelCtrl calls ConfirmHelper.fillUserInputsObjects; InfiniteDiaryListCtrl also calls ConfirmHelper.fillUserInputsObjects. So we can fill in all the userInput fields. But this reintroduces the dependency between InfiniteDiaryListCtrl and ConfirmHelper instead of keeping the list control generic and independent of the labels.

@shankari
Copy link
Contributor Author

shankari commented Oct 4, 2021

Another issue is that there is already a dependency from the view controller on the infinite scroll filter, and the filter depends on the structure of the labels. While fixing this, we should fix that dependency if possible as well.

shankari added a commit to shankari/e-mission-phone that referenced this issue Oct 4, 2021
Inject the filtering factory dynamically
Remove the static dependency
Initialize the selected filters from the factory

This addresses
e-mission/e-mission-docs#674 (comment)
shankari added a commit to shankari/e-mission-phone that referenced this issue Oct 4, 2021
Pull out all the populateXXX and recalculateXXX functionality into a separate
service so it can be invoked from multiple controllers.
This is consistent with e-mission/e-mission-docs#674 (comment)

We load the service dynamically to reduce the hardcoded dependency issue.

We retain a couple of simple wrappers e.g. `fillUserInputsGeojson` in the
controller so that we can invoke it from `$watch` more easily and we can call
`$scope.$apply` to update the visualization.

Remove references to `scope.manualResultMap` from the controller since the
expanded values will be pre-populated.

Pass the viewScope around as needed to support the callback after the update.
Add a new NOP callback to the diary view to match the desired external interface.
Add blank inferred label datastructures for the diary pending a bigger
restructuring that will send inferred labels to as part of the geojson.
@shankari
Copy link
Contributor Author

shankari commented Oct 4, 2021

Three final cleanup issues:

  • Performance
  • Further unification
  • renaming and code movement

I will do the last in a separate PR that does not involve any code changes.

@shankari
Copy link
Contributor Author

shankari commented Oct 4, 2021

It turns out that the directive $watch trigger is invoked as the user scrolls through the list. Note that the oldVal == newVal.

the trip binding has changed from  {display_end_time: "2:34 PM" display_start_time: "2:18 PM"} bo new value  {display_end_time: "4:18 PM" display_start_time: "2:39 PM"}
the trip binding has changed from  {data: {…}, start_place: {…}, style: ƒ, onEachFeature: ƒ, pointToLayer: ƒ, …}  bo new value  {data: {…}, start_place: {…}, style: ƒ, onEachFeature: ƒ, pointToLayer: ƒ, …}
...

This means that actually invoking code within the $watch can seriously affect scrolling performance. So populating labels, which involves iterating over the manual labels and includes some complex matching, is not suitable for the $watch. It is also redundant, since we only need to populate them once, not every time as we scroll.

And we need to initialize the service and precompute values for the label screen anyway.

So we remove the $watch callback and call the service directly from the diary screen as well.

shankari added a commit to shankari/e-mission-phone that referenced this issue Oct 4, 2021
…e `$watch`

Add a simple wrapper (`populateInputsDummyInferences`) to the service to
iterate over all the inputs and populate them, similar to the existing
`populateInputsAndInferences`.

This is consistent with
e-mission/e-mission-docs#674 (comment)

Dynamically inject the service in the diary as well
Call the new method while filling in other fields
Move the code to try and unify the trip structure to the new function as well

Testing done:
Scrolled through the diary and list views, do not see any performance degradation
@shankari
Copy link
Contributor Author

shankari commented Oct 5, 2021

At this point, there is little duplication, and we have cleaned up a bunch of the old code. Next step is to actually send the inferred labels over as part of the geojson for a particular day. This requires a server-side change.

@shankari
Copy link
Contributor Author

shankari commented Oct 5, 2021

wrt e-mission/e-mission-phone#799 (comment), we should be done. Users can simply change from

<multilabel class="col" trip="trip"></multilabel>

to

<enketosurvey class="col" trip="trip"></enketosurvey>

and everything will work.

But we are not a pure framework, we are also a "platform" that has examples of using all these modules. Changing the implementation by changing the code also results in multiple incompatible branches, which are a pain to maintain.

So ideally, we would have several of these changes "configurable" through a config file instead of requiring HTML/JS changes. Technical partners would, of course, be open to making whatever changes they wanted.

So I experimented briefly with creating config options

    surveyoptions.MULTILABEL = {
        filter: "InfScrollFilters",
        service: "MultiLabelService",
        elementTag: "multilabel"
    }

configuring the controller

$scope.surveyOpt = SurveyOptions.MULTILABEL;

and then changing the tag to

                        <{{surveyOpts.elementTag}} class="col" trip="trip"></{{surveyOpts.elementTag}}>

Unfortunately, that doesn't work since the element tag is treated as a string.

Screen Shot 2021-10-05 at 8 59 21 AM

@shankari
Copy link
Contributor Author

shankari commented Oct 5, 2021

I tried some other dynamic matching options:

Attribute matching

Didn't work because it wasn't expanded properly

Screen Shot 2021-10-05 at 9 23 27 AM

Class matching

Didn't work because of lack of matching, even though I changed the directive config to restrict: 'EAC',
Screen Shot 2021-10-05 at 9 24 58 AM

Class matching with hardcoded value

WORKS!!
Screen Shot 2021-10-05 at 9 31 09 AM

@shankari
Copy link
Contributor Author

shankari commented Oct 5, 2021

Double checking this once more because this is so crazy:

<div class="col multilabel" trip="tripgj" data-foo="{{surveyOpt.elementTag}}"></div>
Screen Shot 2021-10-05 at 9 34 48 AM

<div class="col {{surveyOpt.elementTag}}" trip="tripgj" data-foo="{{surveyOpt.elementTag}}"></div>
Screen Shot 2021-10-05 at 9 37 53 AM

The only explanatation is that the binding happens before the scope expansion. I will have to probably create a template for the survey and use ng-if for the selection. That is outside the scope at this point, but I will probably return to it after the enketo survey is in place.

shankari added a commit to shankari/e-mission-phone that referenced this issue Oct 5, 2021
- Create a "Config" in the top-level survey module for the multi-label survey
- Switch the list views to use the configurations to figure the service and filters
- Switch the HTML to use the configurations to the extent possible. This extent
  is the linkedtag attribute of the `verifycheck` element. Alas, we can't
  appear to set the element directive tag name directly (see )
    e-mission/e-mission-docs#674 (comment)
    to
    e-mission/e-mission-docs#674 (comment)

+ move the one-click `verifycheck` button outside of `multilabel` since it is explicitly designed for use with other kinds of surveys as well
@shankari
Copy link
Contributor Author

shankari commented Oct 5, 2021

For the record, I also tried to create a new directive linkedsurvey and pass in the element tag via a scope so it could be used in an inner template.

The hope was to use

<linkedsurvey element-tag="{{surveyOpt.elementTag}}" trip="tripgj" class="row"></linkedsurvey>

I was not able to get it to work:

  • the templateURL function doesn't take in the scope, so trying to load a dynamic template aka https://docs.angularjs.org/guide/directive#template-expanding-directive just ends up trying to load templates/survey/{{surveyOpt.elementTag}}-wrapper.html
  • the compile function also doesn't take the scope and will run into a similar issue. The link function does take the scope into account, but it also involves working with weird issues around replaceWith which did not seem to actually replace anything for me. We also need to ensure that we copy the attributes over, otherwise, we will end with the tripgj missing, and will not display anything.

@shankari
Copy link
Contributor Author

shankari commented Oct 6, 2021

Finally, $compile just seems to be completely broken when I try to use it

        link: function(scope, elem, attr) {
           let template = "<div>{{elementTag}}</div>"; 
           console.log("Compiled functon ", $compile(template));
           let newElem = $compile(template)(scope);
           console.log("after manual compile with ", scope.elementTag, template, newElem.contents());
           elem.append(newElem);
        }

Outputs

after manual compile with  multilabel <div>{{elementTag}}</div> 
S.fn.init(1)
0: text
data: "{{elementTag}}"
length: 14
nodeName: "#text"
nodeType: 3
nodeValue: "{{elementTag}}"
ownerDocument: document
parentElement: div.ng-binding
parentNode: div.ng-binding
previousElementSibling: null
previousSibling: null
textContent: "{{elementTag}}"
wholeText: "{{elementTag}}"

I don't know what $compile is supposed to do if it doesn't fill in a template!!

@shankari
Copy link
Contributor Author

shankari commented Oct 6, 2021

Ah but hardcoded replaceWith works.

.directive("linkedsurvey", function($compile) {
    return {
        scope: {
            elementTag:"@",
        },
        link: function(scope, elem, attr) {
           let newHTML = "<"+scope.elementTag+"></"+scope.elementTag+">"; 
           elem.replaceWith(newHTML);
           console.log("after manual compile with ", scope.elementTag, elem);
        }
    };
});

Doesn't seem to work in the logs

after manual compile with  multilabel S.fn.init [linkedsurvey.col]

but does work in the DOM

Screen Shot 2021-10-05 at 5 37 10 PM

@shankari
Copy link
Contributor Author

shankari commented Oct 6, 2021

That doesn't display anything because there's no trip attribute, so let's copy over the attributes and HTML, similar to https://stackoverflow.com/a/38410626/4040267

Through dint of unremitting effort and some gnarly inquiries into DOM attributes, I finally figured out

.directive("linkedsurvey", function($compile) {
    return {
        scope: {
            elementTag:"@",
        },
        link: function(scope, elem, attr) {
           console.log("before manual compile with ", scope.elementTag, elem, attr);
           let newHTML = "<"+scope.elementTag+"></"+scope.elementTag+">"; 
           let newElem = angular.element(newHTML);
           for (let normalizedKey in attr.$attr) {
              const domKey = attr.$attr[normalizedKey];
              const domVal = attr[normalizedKey];
              newElem.attr(domKey, domVal);
           }
           console.log("after manual compile with ", scope.elementTag, elem, newElem, attr);
           elem.replaceWith(newElem);
        }
    };

This actually does generate the correct HTML
Screen Shot 2021-10-05 at 7 46 37 PM

HOWEVER, the directive expansion is apparently not recursive, because the <multilabel> directive is not expanded further. I give up.

shankari added a commit to shankari/e-mission-phone that referenced this issue Oct 6, 2021
This is a bit hacky because it assumes that the only input is the trip object.
There are likely ways to get around it through clever dom manipulation by using
the `link` method but I am not going to overengineer here.

This is basically the `ng-if` idea outlined in:
e-mission/e-mission-docs#674 (comment)
shankari added a commit to shankari/e-mission-server that referenced this issue Oct 6, 2021
…ned trips

The changes were actually fairly minor
- add new `get_confirmed_timeline` and `get_confirmed_timeline_from_dt` which
  reads cleaned places but confirmed trips
- fortunately, the timeline linkage code focuses on trips, so it works with
  confirmed trips instead of cleaned trips
- change the code which looks up the sections and stops to use the cleaned_trip
  id instead of the trip id if it is a confirmed trip

TODO: change this if/when we have confirmed sections.

We now get back a geojson with the inferred label fields.

This completes the server changes outlined in
e-mission/e-mission-docs#674 (comment)
@shankari
Copy link
Contributor Author

shankari commented Oct 6, 2021

One more pending change is to only retrieve non-processed user inputs from the server for the diary code as well.
This should be a large performance improvement for long-term data collection.

Right now, we are retrieving all user inputs.

1120       var tq = $window.cordova.plugins.BEMUserCache.getAllTimeQuery();

This list can grow without bound, which is generally a Bad Idea for system design.

with this change, we will only read inputs from the timeline run point to now, which is bounded, and should be short depending on how frequently the timeline runs.

shankari added a commit to shankari/e-mission-phone that referenced this issue Oct 7, 2021
Simplify the retrieval code by:
- pulling out duplicated code from the original call and the local fallback
    into a separate function (`readTripsAndUnprocessedInputs`)
- pulling out the processing of the trips and the manual input handling for
  future unification with the labeling code
- in the common case, only read inputs *after* the pipeline is complete.
    Due to e-mission/e-mission-server#837, the geojson
    trips should also now have any prior user inputs included in the trip.
    This plugs a huge performance issue for longitudinal data collection, because
    otherwise, we were reading the entire list of user inputs from the
    beginning of the install, which grows unboundedly.

    with this change, we will only read inputs from the timeline run point to
    now, which is bounded, and should be short depending on how frequently the
    timeline runs.

This addresses:
e-mission/e-mission-docs#674 (comment)
@shankari
Copy link
Contributor Author

shankari commented Oct 8, 2021

Last change done!
Related phone PR: e-mission/e-mission-phone#799
Related server PR: e-mission/e-mission-server#837

Will close this after merging.

@shankari
Copy link
Contributor Author

Closing this for now. Will open other issues if we encounter bugs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants