ChildMindInstitute / MindLogger-bug-reports

GitHub repository for bug tracking and feature requesting for the MindLogger project
Other
0 stars 1 forks source link

✨📚📅 Define schedule formats #39

Closed shnizzedy closed 4 years ago

shnizzedy commented 4 years ago

Is your feature request related to a problem? Please describe. We don't

I don't have a good grasp of the format of stored scheduling data

https://github.com/ChildMindInstitute/mindlogger-app-backend/issues/204#issuecomment-548123955

The dayspan-vuetify calendar library doesn't support Vuetify 2. We should eventually find a new calendar component so we can update Vuetify.

https://github.com/ChildMindInstitute/mindlogger-admin/issues/19

Describe the solution you'd like As @henryrossiter suggested, we should define our requirements and choose and design components based on those requirements.

Describe alternatives you've considered To date, we've chosen components (here specifically dayspan-veutify@0.4.0) and worked around their formats.

Additional context Current relevant endpoints include:

GET /schedule

Get schedule Array for the logged-in user

Parameter Description Parameter Type Data Type
timezone The TZ database name of the timezone to return times in. Times returned in UTC if omitted. query string

Response Body

{
  "applet/5d6ee74a8f66701eb8166011": {
    "activity/5db9bac59f43a2fb26fdf425": {
      "lastResponse": "2019-10-28T16:46:09.281000"
    }
  },
  "applet/5db71b412611b7876bb7a220": {
    "activity/5db9bba49f43a2fb26fdf484": {
      "lastResponse": null
    },
    "activity/5db9bba59f43a2fb26fdf485": {
      "lastResponse": null
    }
  }
}

PUT /applet/{id}/constraints

Set or update schedule information for an activity.

Parameter Description Parameter Type Data Type
id The ID of the document. path string
activity Girder ID (or Array thereof) of the activity/activities to schedule. query string
schedule A JSON object containing schedule information for an activity formData string

Related

shnizzedy commented 4 years ago

I think rather than a freeform JSON Object as a single schedule parameter, it would be useful to constrain the shape and contents of the schedule, either by specifying how that Object should exist or by passing different keys separately.

binarybottle commented 4 years ago

Can anyone think of what the advantages would be of having a freeform json object as a schedule parameter?

curtpw commented 4 years ago

I can start prototyping an implementation that uses the Veutify v2 calendar component instead of dayspan-vuetify BUT I would simply be attempting as close to a one-for-one replacement as possible. I'm not in a position to make strategic schema or architectural decisions since I only starting looking at this a couple of days ago. It is worth mentioning that scheduling seems to be the most complex and fragile aspect of the ML Admin Panel web app. Rewriting it a week or two before launch is not ideal. I'm trying to take a measure twice cut once approach.

shnizzedy commented 4 years ago

@curtpw, in standup yesterday, @henryrossiter suggested we better define what we're trying to do, I added on that we should do so in writing, and @odziem added that our back-and-forth on https://github.com/ChildMindInstitute/mindlogger-app-backend/issues/204 was a useful iterative design-and-documentation thread. This issue is intended to be the measuring twice you mentioned. Updating the calendar component is a separate, related issue.

shnizzedy commented 4 years ago

Can anyone think of what the advantages would be of having a freeform json object as a schedule parameter?

@binarybottle ― advantages to whom? The question is a classic balancing act between structure and flexibility. In this case, as far as I can tell, the advantages to a freeform JSON Object are:

beneficiary advantage cost
developer, manager, coordinator, user time to launch stability, reliability, meeting expectations, control
manager, coordinator, user extreme flexibility nonfunctional / unimplemented functionality
developer, user support setting schedule should rarely throw exceptions silent failures, unknown / unknowable user requirements, pain points

To be clear, I think a freeform JSON Object (what we have now) is better than nothing (what we had before) but worse than a well-defined structure.

shnizzedy commented 4 years ago

related: https://github.com/ReproNim/reproschema/issues/46

shnizzedy commented 4 years ago

In this morning's standup, @odziem, @binarybottle, @henryrossiter, @hotavocado, and I discussed how to handle timezones.

shnizzedy commented 4 years ago

Here are some in-use-at-the-moment snippets to help until we document what we want:

https://github.com/ChildMindInstitute/mindlogger-admin/blob/97ed9fd8d9f41969c04934ee5314ae6bdbc1b256/src/Components/Utils/api/api.vue#L22-L29

const setSchedule = ({ apiHost, token, id, data }) => axios({
  method: 'put',
  url: `${apiHost}/${id}/constraints`,
  headers: {
    'Girder-Token': token,
  },
  data,
});

https://github.com/ChildMindInstitute/mindlogger-admin/blob/1d136a8b4ee0448a23862b6085d638493af41c35/src/Steps/SetSchedule.vue#L143-L168

    saveSchedule() {
      const scheduleForm = new FormData();
      if (this.currentApplet && this.currentApplet.applet && this.currentApplet.applet.schedule) {
          this.dialog = true;
          this.saveSuccess = false;
          this.saveError = false;
          this.loading = true;
          const schedule = this.currentApplet.applet.schedule;
          scheduleForm.set('schedule', JSON.stringify(schedule || {}));
          api.setSchedule({
            apiHost: this.$store.state.backend,
            id: this.currentApplet.applet._id,
            token: this.$store.state.auth.authToken.token,
            data: scheduleForm,
          }).then(() => {
            console.log('success');
            this.loading = false;
            this.saveSuccess = true;
          }).catch((e) => {
            this.errorMessage = `Save Unsuccessful. ${e}`;
            console.log('fail');
            this.loading = false;
            this.saveError = true;
          });
        }
    },

https://github.com/ChildMindInstitute/mindlogger-admin/blob/f0681f7319db526d317324962def6b4547ab8907/src/State/state.js#L35-L47

  setSchedule(state, schedule) {
    if (!_.isEmpty(state.currentApplet)) {
      // TODO: this sucks.
      const idx = _.findIndex(state.allApplets,
        a => a.applet['skos:prefLabel'] == state.currentApplet.applet['skos:prefLabel']);
      if (idx > -1) {
        state.allApplets[idx].applet.schedule = schedule;
        state.currentApplet = state.allApplets[idx];
      }
      // update this in the copy too.
      //state.currentApplet = {...state.currentApplet, schedule };
    }
  },

https://github.com/ChildMindInstitute/mindlogger-app/blob/33e5511d750eed23bc9fd77ee6cf86b85dcf2e85/app/state/applets/applets.selectors.js#L12-L104

export const dateParser = (schedule) => {
  const output = {};

  schedule.events.forEach((e) => {
    const uri = e.data.URI;

    if (!output[uri]) {
      output[uri] = {
        notificationDateTimes: [],
      };
    }

    const eventSchedule = Parse.schedule(e.schedule);
    const now = Day.fromDate(new Date());

    const lastScheduled = getLastScheduled(eventSchedule, now);
    const nextScheduled = getNextScheduled(eventSchedule, now);

    const notifications = R.pathOr([], ['data', 'notifications'], e);
    const dateTimes = getScheduledNotifications(eventSchedule, now, notifications);

    let lastScheduledResponse = lastScheduled;
    if (output[uri].lastScheduledResponse && lastScheduled) {
      lastScheduledResponse = moment.max(
        moment(output[uri].lastScheduledResponse),
        moment(lastScheduled),
      );
    }

    let nextScheduledResponse = nextScheduled;
    if (output[uri].nextScheduledResponse && nextScheduled) {
      nextScheduledResponse = moment.min(
        moment(output[uri].nextScheduledResponse),
        moment(nextScheduled),
      );
    }

    output[uri] = {
      lastScheduledResponse,
      nextScheduledResponse,

      // TODO: only append unique datetimes when multiple events scheduled for same activity/URI
      notificationDateTimes: output[uri].notificationDateTimes.concat(dateTimes),
    };
  });

  return output;
};

// Attach some info to each activity
export const appletsSelector = createSelector(
  R.path(['applets', 'applets']),
  responseScheduleSelector,
  (applets, responseSchedule) => applets.map((applet) => {
    let scheduledDateTimesByActivity = {};

    // applet.schedule, if defined, has an events key.
    // events is a list of objects.
    // the events[idx].data.URI points to the specific activity's schema.
    if (applet.schedule) {
      scheduledDateTimesByActivity = dateParser(applet.schedule);
    }

    const extraInfoActivities = applet.activities.map((act) => {
      const scheduledDateTimes = scheduledDateTimesByActivity[act.schema];

      const nextScheduled = R.pathOr(null, ['nextScheduledResponse'], scheduledDateTimes);
      const lastScheduled = R.pathOr(null, ['lastScheduledResponse'], scheduledDateTimes);
      const lastResponse = R.path([applet.id, act.id, 'lastResponse'], responseSchedule);

      return {
        ...act,
        appletId: applet.id,
        appletShortName: applet.name,
        appletName: applet.name,
        appletSchema: applet.schema,
        appletSchemaVersion: applet.schemaVersion,
        lastScheduledTimestamp: lastScheduled,
        lastResponseTimestamp: lastResponse,
        nextScheduledTimestamp: nextScheduled,
        isOverdue: lastScheduled && moment(lastResponse) < moment(lastScheduled),

        // also add in our parsed notifications...
        notification: R.prop('notificationDateTimes', scheduledDateTimes),
      };
    });

    return {
      ...applet,
      activities: extraInfoActivities,
    };
  }),
);

https://github.com/ChildMindInstitute/mindlogger-app/blob/26061c7fa06e747125e24f0020f9333b447c8002/app/services/pushNotifications.js#L39-L98

export const scheduleNotifications = (activities) => {
  PushNotificationIOS.setApplicationIconBadgeNumber(1);
  PushNotification.cancelAllLocalNotifications();
  // const now = moment();
  // const lookaheadDate = moment().add(1, 'month');

  const notifications = [];

  for (let i = 0; i < activities.length; i += 1) {
    const activity = activities[i];

    const scheduleDateTimes = activity.notification || [];

    // /* below is for easy debugging.
    //    every 30 seconds a notification will appear for an applet.
    // */
    // const scheduleDateTimes = [];

    // for (i = 0; i < 50; i += 1) {
    //   const foo = new Date();
    //   foo.setSeconds(foo.getSeconds() + i * 30);
    //   scheduleDateTimes.push(moment(foo));
    // }
    // console.log('activity', activity);
    // /* end easy debugging section */

    scheduleDateTimes.forEach((dateTime) => {
      const ugctime = new Date(dateTime.valueOf());
      notifications.push({
        timestamp: ugctime,
        niceTime: dateTime.format(),
        activityId: activity.id,
        activityName: activity.name.en,
        appletName: activity.appletName,
        activity: JSON.stringify(activity),
      });
    });
  }

  // Sort notifications by timestamp
  notifications.sort((a, b) => a.timestamp - b.timestamp);

  // Schedule the notifications
  notifications.forEach((notification) => {
    PushNotification.localNotificationSchedule({
      message: `Please perform activity: ${notification.activityName}`,
      date: new Date(notification.timestamp),
      group: notification.activityName,
      vibrate: true,
      userInfo: {
        id: notification.activityId,
        activity: notification.activity,
      },
      // android only (notification.activity is already stringified)
      data: notification.activity,
    });
  });

  return notifications;
};

https://github.com/ChildMindInstitute/mindlogger-app-backend/blob/76abb94e8214449e1bf19e6fd3718e603631b15f/girderformindlogger/api/v1/applet.py#L425-L438

    def setConstraints(self, folder, activity, schedule, **kwargs):
        thisUser = self.getCurrentUser()
        applet = jsonld_expander.formatLdObject(
            _setConstraints(folder, activity, schedule, thisUser),
            'applet',
            thisUser,
            refreshCache=True
        )
        thread = threading.Thread(
            target=AppletModel().updateUserCacheAllUsersAllRoles,
            args=(applet, thisUser)
        )
        thread.start()
        return(applet)
shnizzedy commented 4 years ago

For applet 5e1f4f47720011229c1a15d2 on https://api.mindlogger.org/api/v1, my attempt to manually set a schedule ⅓ worked. I realized https://github.com/ChildMindInstitute/mindlogger-admin/issues/71 may have been the issue with it working; I think the schedule page on admin was saving the incorrect activity keys. I've updated to match the URLs of the activities actually contained in the applet. For the sake of documentation, here's what is currently set, which I intend to reflect the following schedule:

activity scheduled time notified if not completed time
EMA Assessment (Morning) daily, 8:00 AM 9:00 AM
EMA Assessment (Mid Day) daily, 2:00 PM 3:00 PM
EMA Assessment (Night) daily, 7:00 PM 8:00 PM
{
    "around": 1578805200000,
    "events": [{
            "data": {
                "URI": "https://raw.githubusercontent.com/hotavocado/HBN_EMA_NIMH2/master/activities/morning_set/morning_set_schema",
                "busy": true,
                "calendar": "",
                "color": "#F44336",
                "description": "",
                "forecolor": "#ffffff",
                "icon": "",
                "location": "",
                "notifications": [{
                    "end": null,
                    "notifyIfIncomplete": true,
                    "random": false,
                    "start": "09:00"
                }],
                "title": "EMA Assessment (Morning)",
                "useNotifications": true
            },
            "schedule": {
                "times": [
                    "08"
                ]
            }
        },
        {
            "data": {
                "URI": "https://raw.githubusercontent.com/hotavocado/HBN_EMA_NIMH2/master/activities/day_set/day_set_schema",
                "busy": true,
                "calendar": "",
                "color": "#1976d2",
                "description": "",
                "forecolor": "#ffffff",
                "icon": "",
                "location": "",
                "notifications": [{
                    "end": null,
                    "notifyIfIncomplete": true,
                    "random": false,
                    "start": "15:00"
                }],
                "title": "EMA Assessment (Mid Day)",
                "useNotifications": true
            },
            "schedule": {
                "times": [
                    "14"
                ]
            }
        },
        {
            "data": {
                "URI": "https://raw.githubusercontent.com/hotavocado/HBN_EMA_NIMH2/master/activities/evening_set/evening_set_schema",
                "busy": true,
                "calendar": "",
                "color": "#1976d2",
                "description": "",
                "forecolor": "#ffffff",
                "icon": "",
                "location": "",
                "notifications": [{
                    "end": null,
                    "notifyIfIncomplete": true,
                    "random": false,
                    "start": "20:00"
                }],
                "title": "EMA Assessment (Night)",
                "useNotifications": true
            },
            "schedule": {
                "times": [
                    "19"
                ]
            }
        }
    ],
    "eventsOutside": true,
    "fill": false,
    "listTimes": true,
    "minimumSize": 0,
    "repeatCovers": true,
    "size": 1,
    "type": 1,
    "updateColumns": true,
    "updateRows": true
}
WorldImpex commented 4 years ago

Moved to #737