Case Study - JITAI Notifications
You can use MyDataHelps APIs to provide Just-in-Time Adaptive Interventions (JITAI), such as notifications, customized for each participant.
Consider that your objective is to implement an interventional study designed to nudge participants with messages that assist in blood pressure management. These notifications are scheduled to be sent at four specific intervals: morning, lunch, afternoon, and evening. Participants have the autonomy to choose their preferred times for these intervals. The goal is to maintain an average delivery rate of 1.5 messages per participant per day. To tailor the timing and content of these messages, you use variables such as the time of day, the participant’s recent blood pressure readings, number of steps taken daily, and a medication adherence score.
Prerequisites
Before you get started, you’ll need a few things.
Project Setup
You will need to make sure that your MyDataHelps project has the following configured:
- We are going to be using
Omron
to collect blood pressure information from participants, and Fitbit
to record their daily steps, so make sure you enable these data types for your project.
- Create 4 custom fields to store a participant’s preferred notification times:
MorningTime
, LunchTime
, AfternoonTime
and EveningTime
. These will hold values such as 8 AM
or 6 PM
and will be set from a survey that you will send to your participants to ask for their preferred notification times.
- Design your notification bank. We recommend using prefixes in your notification identifiers to distinguish between different categories of messages such as
myproj-morning-meds-4
or myproj-afternoon-activity-5
. The notification bank will consist of all of the messages that you might want to send, as well as the criteria you are going to want to use to determine when to send which message, such as time of day, a participant’s past week’s data or how they’ve answered specific recent surveys. Once your notification bank is ready you may load it into your MyDataHelps project.
- Create a service account.
- In MyDataHelpsDesigner, create a segment for all of the participants that are ready to receive notifications (for example everyone that is in the phase
active
and has the 4 times - morning, lunch, afternoon and evening - set and is in the Intervention
cohort).
NPM Packages
To be able to use all the code in this tutorial, you will need to install the following NPM packages:
npm install uuid jsonwebtoken luxon
Project Variables
The code examples below reference several project-specific variables:
projectID
can be found in your project settings. See Project Identifiers.
segmentID
is visible when editing a segment. See Editing or Deleting a Segment.
baseUrl
is the base URL for all MyDataHelps API endpoints: https://designer.mydatahelps.org
Implementation
In this example we are going to implement a cloud function (for instance AWS Lambda in Node.js
) that is going to interact with the MyDataHelps APIs to dispatch a diverse array of notifications determined by complex criteria. This function is going to run every minute during the day.
Step 1 - Get a service access token
Get a service access token to authenticate with the MyDataHelps APIs. You will need to do this every time the cloud function runs.
This token be referenced in the code examples as ${serviceAccessToken}
.
Step 2 - Query the relevant participants
Use the MyDataHelps Participant API to query for all of the participants in the segment you configured.
let serviceHeaders = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${serviceAccessToken}`
};
const projectId = '1cd23328-5964-46f4-a918-9f9331e09810';
const segmentId = 'a22bffc3-66c0-44bc-92b7-504e09b16bf0';
let allParticipants = [];
let participantPage;
let pageNum = 0;
do {
participantPage = await fetch(`${baseUrl}/api/v1/administration/projects/${projectId}/participants?segmentId=${segmentId}&pageNumber=${pageNum}&pageSize=100`, {
method: 'get',
headers: serviceHeaders
}).then(res => res.json());
allParticipants = allParticipants.concat(participantPage.participants);
pageNum += 1;
} while (allParticipants.length < participantPage.totalParticipants);
console.log(allParticipants);
Expected response format
[
{
id: 'e5d6b29f-1319-4faf-9573-1bc00060d1d4',
enrolled: true,
invitationStatus: null,
insertedDate: '2024-01-10T20:05:14.96+00:00',
enrollmentDate: '2024-01-10T20:05:15.392+00:00',
viewParticipantRecordLink: null,
accountEmail: 'participant1@example.com',
customFields: {
AfternoonTime: '4:00 PM',
Cohort: 'Intervention',
DinnerTime: '6:00 PM',
LunchTime: '12:00 PM',
MorningTime: '8:00 AM',
Phase: 'active',
// ...
},
...
},
{
id: '22f1476b-a186-4aea-92e9-b30c45794a6b',
enrolled: true,
invitationStatus: null,
insertedDate: '2024-01-10T20:02:32.317+00:00',
enrollmentDate: '2024-01-11T14:23:58.924+00:00',
viewParticipantRecordLink: null,
accountEmail: 'participant2@example.com',
customFields: {
AfternoonTime: '3:00 PM',
Cohort: 'Intervention',
DinnerTime: '6:00 PM',
LunchTime: '12:00 PM',
MorningTime: '8:00 AM',
Phase: 'active',
// ...
},
...
},
// ...
]
Next, you can filter down the participants to only those with a notification preference time in the previous minute (notification preferences are stored in the MorningTime
, LunchTime
, AfternoonTime
, and DinnerTime
custom fields).
allParticipants = allParticipants.filter(p => isTimeWithinLastMinute(p.customFields.MorningTime, p.demographics.timeZone)
|| isTimeWithinLastMinute(p.customFields.LunchTime, p.demographics.timeZone)
|| isTimeWithinLastMinute(p.customFields.AfternoonTime, p.demographics.timeZone)
|| isTimeWithinLastMinute(p.customFields.DinnerTime, p.demographics.timeZone));
// ...
function isTimeWithinLastMinute(timeStr, timeZone) {
const inputTime = DateTime.fromFormat(timeStr, 'h:mm a', { zone: timeZone }).set({
year: DateTime.local().year,
month: DateTime.local().month,
day: DateTime.local().day
});
const currentTime = DateTime.local().setZone(timeZone);
const diff = Math.abs(currentTime.diff(inputTime, 'minutes').minutes);
return diff <= 1;
}
At this point we already have the participants and all of their custom fields. We will then need to query each participant’s recent blood pressures (Omron) and daily steps (Fitbit) to determine their intervention scenario. We iterate through all of the participants, and for each participant, we query for the Omron blood pressure data and Fitbit daily steps for the past week.
const weekAgo = DateTime.local().setZone(participant.demographics.timeZone).minus({ days: 7 });
let bloodPressureQueryResponse = await fetch(`${baseUrl}/api/v1/administration/projects/${projectId}/devicedatapoints?participantIdentifier=${participant.participantIdentifier}&namespace=Omron&type=BloodPressureDiastolic,BloodPressureSystolic&observedAfter=${weekAgo}`, {
method: 'get',
headers: serviceHeaders
}).then(res => res.json());
let bloodPressureDeviceDataPoints = bloodPressureQueryResponse.deviceDataPoints;
Expected response format
[{
id: 'b1d20fd5-f437-ee11-aacb-0afb9334277d',
namespace: 'Omron',
deviceDataContextID: '95d20fd5-f437-ee11-aacb-0afb9334277d',
insertedDate: '2023-08-11T03:12:00.973Z',
modifiedDate: '2023-08-11T03:12:00.973Z',
participantID: 'e5d6b29f-1319-4faf-9573-1bc00060d1d4',
participantIdentifier: '77080fc6-fefa-436c-8660-a192fedcc83c',
identifier: '1691718936000',
type: 'BloodPressureDiastolic',
value: '64',
units: 'mmHg',
properties: {},
source: null,
startDate: '2023-08-10T21:55:36-04:00',
observationDate: '2023-08-10T21:55:36-04:00'
},
{
id: 'b0d20fd5-f437-ee11-aacb-0afb9334277d',
namespace: 'Omron',
deviceDataContextID: '95d20fd5-f437-ee11-aacb-0afb9334277d',
insertedDate: '2023-08-11T03:12:00.97Z',
modifiedDate: '2023-08-11T03:12:00.97Z',
participantID: 'e5d6b29f-1319-4faf-9573-1bc00060d1d4',
participantIdentifier: '77080fc6-fefa-436c-8660-a192fedcc83c',
identifier: '1691718936000',
type: 'BloodPressureSystolic',
value: '103',
units: 'mmHg',
properties: {},
source: null,
startDate: '2023-08-10T21:55:36-04:00',
observationDate: '2023-08-10T21:55:36-04:00'
},
// ...
]
let dailyStepsQueryResponse = await fetch(`${baseUrl}/api/v1/administration/projects/${projectId}/devicedatapoints?participantIdentifier=${p.participantIdentifier}&namespace=Fitbit&type=Steps&observedAfter=${weekAgo}`, {
method: 'get',
headers: serviceHeaders
}).then(res => res.json());
let dailySteps = dailyStepsQueryResponse.deviceDataPoints;
Expected response format
[{
id: '12b7e011-b590-ee11-aad0-0afb9334277d',
namespace: 'Fitbit',
deviceDataContextID: null,
insertedDate: '2023-12-02T01:49:50.17Z',
modifiedDate: '2023-12-02T01:49:50.17Z',
participantID: 'e5d6b29f-1319-4faf-9573-1bc00060d1d4',
participantIdentifier: '77080fc6-fefa-436c-8660-a192fedcc83c',
identifier: null,
type: 'Steps',
value: '4487',
units: null,
properties: {
DataLakeUrl: 'https://pep-device-data-prod.s3.amazonaws.com/fitbit-activities-day/155babd3-2551-ec11-aab5-0afb9334277d/activities_date_2023-12-01.json'
},
source: null,
startDate: '2023-12-01T00:00:00+00:00',
observationDate: '2023-12-02T00:00:00+00:00'
},
{
id: 'a69d6951-e98f-ee11-aace-0afb9334277d',
namespace: 'Fitbit',
deviceDataContextID: null,
insertedDate: '2023-12-01T01:31:18.04Z',
modifiedDate: '2023-12-01T01:31:18.04Z',
participantID: 'e5d6b29f-1319-4faf-9573-1bc00060d1d4',
participantIdentifier: '77080fc6-fefa-436c-8660-a192fedcc83c',
identifier: null,
type: 'Steps',
value: '7462',
units: null,
properties: {
DataLakeUrl: 'https://pep-device-data-prod.s3.amazonaws.com/fitbit-activities-day/155babd3-2551-ec11-aab5-0afb9334277d/activities_date_2023-11-30.json'
},
source: null,
startDate: '2023-11-30T00:00:00+00:00',
observationDate: '2023-12-01T00:00:00+00:00'
},
// ...
]
The collection of notifications you possess may involve intricate configurations. Some notifications might be designed to be delivered to participants exclusively under precise conditions. During this step, we will append detailed context to each notification to delineate the conditions under which each should be transmitted, for example, if a particular notification should only be sent in the mornings, or if it is related to a physical activity nudge.
let notificationBank = [{
notificationIdentifier: "project-meds-user-morning-6",
morning: true,
afternoon: false,
lunch: true,
dinner: false,
user: true,
standard: false,
activity: false,
planning: false
}, {
notificationIdentifier: "project-physical-planning-7",
morning: false,
afternoon: false,
lunch: false,
dinner: true,
user: false,
standard: false,
activity: false,
planning: true
},
// ...
];
Step 5 - Implement the business logic.
Below, we implement a specific condition under which to send a participant notifications. Your real use case scenario might contain a few or many of such conditions.
In the condition we’ve selected, you want to send notifications to participants with a blood pressure measurement of over 130/80 in the past week, with a medication adherence score of less than 21 on the H-SCALE and that have not met their step goal during the previous week. For these participants, you want to send a message with a 37.5% probability (1.5 avg messages per day divided by 4 decision points). Half the time you want these messages to be medication adherence messages and half the time you want them to be physical activity messages. For medication adherence messages, you want these to be user generated 67% of the time and standard 33% of the time. For the physical activity messages, you want these to be activity suggestions 33% of the time and planning suggestions 67% of the time.
This condition and accompanying probabilities are summarized in the table below:
Example: Condition 7
Condition Criteria
* BP is above 130/80
* Medication adherence for last week is <21 on the H-SCALE
* Step goal for last week was not met
|
Total message frequency: avg. 1.5 msg/day
Message Send Probabilities
* No message (.625)
* Send Message (.375)
* Medication adherence messages (.375 * .67 = .2513)
* User generated (.2513 * .67 = .1684)
* Standard (.2513 * .33 = .0829)
* Physical activity messages (.375 * .33 = .1238)
* Activity suggestions (.1238 * .33 = .0409)
* Planning suggestions (.1238 * .67 = .0829)
|
let timeOfDay = isTimeWithinLastMinute(participant.customFields.MorningTime, participant.demographics.timeZone) ? "morning" :
isTimeWithinLastMinute(participant.customFields.LunchTime, participant.demographics.timeZone) ? "lunch" :
isTimeWithinLastMinute(participant.customFields.AfternoonTime, participant.demographics.timeZone) ? "afternoon" :
isTimeWithinLastMinute(participant.customFields.DinnerTime, participant.demographics.timeZone) ? "dinner" : "error";
if (
bloodPressureDeviceDataPoints.some(d => (d.type === "BloodPressureSystolic" && parseInt(d.value) > 130) || (d.type === "BloodPressureDiastolic" && parseInt(d.value) > 80))
&& parseInt(participant.customFields.MedicationAdherenceLastWeek) < 21
&& dailyStepsDeviceDataPoints.map(ddp => parseInt(ddp.value)).reduce((a, b) => a + b, 0) / dailyStepsDeviceDataPoints.length < parseInt(participant.customFields.StepsGoalLastWeek)
) {
// 1st dice roll - 66% meds 33% physical activity
let firstDiceRoll = Math.random() <= 0.666666 ? "meds" : "physical";
let secondDiceRoll;
if (firstDiceRoll === "meds") {
// 2nd dice roll for meds - 66% user 33% standard
secondDiceRoll = Math.random() <= 0.666666 ? "user" : "standard";
}
else {
// 2nd dice roll for physical activity - 66% planning 33% activity
secondDiceRoll = Math.random() <= 0.666666 ? "planning" : "activity";
}
// 3rd dice roll - randomization - send or don't send
let thirdDiceRoll = Math.random() <= 0.375 ? "1" : "0";
// Select which notification to send from the ones that qualify
let qualifyingNotifications = notificationBank.filter(n => n[timeOfDay] && n[firstDiceRoll] && n[secondDiceRoll]);
let selectedNotification = qualifyingNotifications[Math.floor(Math.random() * qualifyingNotifications.length)];
let diceRoll = `${firstDiceRoll}-${secondDiceRoll}-${thirdDiceRoll}`;
deviceDataPointsToPersist.push({
participantIdentifier: participant.participantIdentifier,
type: `${timeOfDay}NotificationDiceRoll`,
observationDate: DateTime.local().toISO(),
value: diceRoll
});
if (thirdDiceRoll === "1") {
notificationsToSend.push({
participantIdentifier: participant.participantIdentifier,
notificationIdentifier: selectedNotification.notificationIdentifier
});
}
}
Step 6 - Record the dice rolls and send the notifications
The final step left to do is to persist the dice rolls via the Persist Device Data API and send the notifications via the Bulk Send Notifications API.
let persistDiceRollsResponse = await fetch(`${baseUrl}/api/v1/administration/projects/${projectId}/devicedatapoints`, {
method: 'post',
headers: serviceHeaders,
body: JSON.stringify(deviceDataPointsToPersist)
}).then(res => res.json());
let sendNotificationsResponse = await fetch(`${baseUrl}/api/v1/administration/projects/${projectId}/notifications`, {
method: 'post',
headers: serviceHeaders,
body: JSON.stringify(notificationsToSend)
}).then(res => res.json());