How to Measure Duration with xAPI, Storyline, and JS Timers

Measure duration with xAPI and Storyline tutorial cover photo

One of the great benefits of xAPI is that you can collect detailed duration data. For example, you can use xAPI to answer questions such as the following:

  • How long did it take the user to complete the eLearning course?
  • How long did it take the user to answer a specific question in the eLearning course?
  • How long did the user spend on a given slide or learning resource before moving on?

When you have data such as this, you can draw valuable conclusions during analysis about your learning materials' effectiveness and your learners' level of effort.

Before continuing, ensure that you’ve completed the 3-part Getting Started with xAPI Tutorial Series. We will build upon what you’ve learned in those tutorials, so it’s important that you’re familiar with the basics.

So, specifically, this tutorial teaches you how to record the course and slide duration using JavaScript timers. You will also learn how to incorporate that duration data into your xAPI statements and manage it all from Storyline.

Let's get started!

Setting up your JavaScript Timers

This section will be fairly technical, but bear with me. You may encounter some new JavaScript terms, symbols, and concepts, but by the end of this section, you should have a decent idea of how your timers work. You will also be able to manage them very easily from Storyline once we're through the initial setup.

First thing's first: let's open up the xapi-statement.js file that you've been working on in previous tutorials. (If you've completed any of the intermediate xAPI tutorials, then your code will have more to it than what's linked above).

For this section, we're going to add code outside of our sendStatement() function. And, to keep things as simple as we can for this tutorial, we're going to define our new variables in the global scope.

Variables defined in the global scope can be accessed from any function in our code, which means that we will be able to use the variables in our sendStatement() function (which we will get to later in the tutorial). To define a global variable, you use the "var" keyword.

So, let's add two variables to our code: courseSeconds and slideSeconds. These variables will hold the number of seconds that a user has been on a given slide or in the course as a whole.

We'll add these variables right at the top of our code, outside of our sendStatement function, like so:

{% c-block language="js" %}
var courseSeconds = 0;
var slideSeconds = 0;

function sendStatement(...) {
 /* All of your sendStatement code will be here */
}
{% c-block-end %}

Next, we'll want to set up some variables that will tell our code whether or not our timers should increment. For example, if we want to pause our timers, we need a way to tell the code to pause them.

We can name these variables isCourseTimerActive and isSlideTimerActive. Let's also set these variables to false by default so that the timers do not start until we tell them to:

{% c-block language="js" %}
var courseSeconds = 0;
var slideSeconds = 0;

var isCourseTimerActive = false;
var isSlideTimerActive = false;
{% c-block-end %}

Setting the Interval

Now that we have our variables in place, we need to add the code responsible for the timers. (I will walk through how the code works first, but feel free to hold off on adding anything to your code until we reach the end of this section.)

In JavaScript, we can use something called the setInterval method. We pass this method a function and a number of milliseconds, X, and then it runs the function every X number of milliseconds. It looks something like this:

{% c-block language="js" %}
window.setInterval(function, milliseconds);
{% c-block-end %}

For the milliseconds parameter, we can pass in 1000 (since 1000 milliseconds make up one second).

For the function parameter, we need to pass a function that will add 1 to courseSeconds when isCourseTimerActive is true and add 1 to slideSeconds when isSlideTimerActive is true. That function will look like so:

{% c-block language="js" %}
() => {
 if(isCourseTimerActive === true) {
   courseSeconds += 1;
 }
 if (isSlideTimerActive === true) {
   slideSeconds += 1;
 }
}
{% c-block-end %}

First of all, the code above uses arrow syntax, which is a modern, simple way to create a function in JavaScript.

It also uses an if statement, which tells the following line(s) of code to execute only if the given condition is true. For example,

{% c-block language="js" %}
if(condition) {
 /* Then perform the code in these curly brackets! */
}
{% c-block-end %}

Finally, the "+=" operation tells the code to add what's on the right of the equation to the variable on the left. For example:

{% c-block language="js" %}
courseSeconds += 1;
{% c-block-end %}

Is a more concise way of saying this:

{% c-block language="js" %}
courseSeconds = courseSeconds + 1;
{% c-block-end %}

However, either option will work with our code.

Now, as you can see, our function adds one to each second counter variable only if the corresponding isTimerActive variable is true.

All together, the code to set up our timers should look like this:

{% c-block language="js" %}
var courseSeconds = 0;
var slideSeconds = 0;

var isCourseTimerActive = false;
var isSlideTimerActive = false;

window.setInterval( () => {
   if (isCourseTimerActive === true) {
       courseSeconds += 1
   }
   if (isSlideTimerActive === true) {
       slideSeconds += 1
   }
}, 1000);
{% c-block-end %}

Managing your Timers

Now that our timer is in place, we need to make sure that you are able to control them easily from Storyline. Once we are done with this section, you will be able to start, stop, and reset each timer with a simple "Execute JavaScript" trigger in Storyline.

We can get this started by setting up a JavaScript object, called manageTimer, to hold the functions that we will need to manage the timer.

So, let's set up an object that holds two objects within it...one for the course timer and one for the slide timer. You can place this object right after the code that we just added:

{% c-block language="js" %}
const manageTimer = {
   "course": {

   },
   "slide": {

   }
}
{% c-block-end %}

From here, we can add a "start," "stop," and "reset" function for both our course and our slide timers. These functions should do the following:

  • "start" should make the timer start or continue incrementing
  • "stop" should make the timer stop incrementing
  • "reset" should reset the associated seconds variable back to 0

We can accomplish this by using arrow functions to change our variables. So, as discussed, let's add a "start," "stop," and "reset" key to each of our objects, then add the associated arrow functions as their values. The new code will look like this:

{% c-block language="js" %}
const manageTimer = {
   "course": {
       "start": () => {isCourseTimerActive = true},
       "stop": () => {isCourseTimerActive = false},
       "reset": () => {courseSeconds = 0}
   },
   "slide": {
       "start": () => {isSlideTimerActive = true},
       "stop": () => {isSlideTimerActive = false},
       "reset": () => {slideSeconds = 0}
   }
}
{% c-block-end %}

With this object in place, we can use dot notation to access the properties of the object. So, to call the function that starts the course timer, the JavaScript is as simple as this:

{% c-block language="js" %}
manageTimer.course.start();
{% c-block-end %}

In essence, this line of code first looks at the manageTimer object, then looks at the "course' property of that object, then looks at the "start" property of the course object. We add the parentheses at the end to signify that we are calling a function (in this case, the anonymous function associated with "start."

So, once we get our Storyline file set up and link the JavaScript file appropriately, we will be able to start, stop, and reset our timers using the following lines of code within an "Execute JavaScript" trigger:

{% c-block language="js" %}
manageTimer.course.start(); /* Starts the course timer */
manageTimer.course.stop(); /* Stops the course timer */
manageTimer.course.reset(); /* Resets the course timer */

manageTimer.slide.start(); /* Starts the slide timer */
manageTimer.slide.stop(); /* Stops the slide timer */
manageTimer.slide.reset(); /* Resets the slide timer */
{% c-block-end %}

All of the code that we've added so far should look like this:

{% c-block language="js" %}
var courseSeconds = 0;
var slideSeconds = 0;

var isCourseTimerActive = false;
var isSlideTimerActive = false;

window.setInterval( () => {
   if (isCourseTimerActive === true) {
       courseSeconds += 1
   }
   if (isSlideTimerActive === true) {
       slideSeconds += 1
   }
}, 1000);

const manageTimer = {
   "course": {
       "start": () => {isCourseTimerActive = true},
       "stop": () => {isCourseTimerActive = false},
       "reset": () => {courseSeconds = 0}
   },
   "slide": {
       "start": () => {isSlideTimerActive = true},
       "stop": () => {isSlideTimerActive = false},
       "reset": () => {slideSeconds = 0}
   }
}
{% c-block-end %}

Remember, the course timer is held in the courseSeconds variable, and the slide timer is held in the slideSeconds variable. As you can imagine, we are going to use these variables to set the duration for our xAPI statement.

Let's get to it!

Editing the xAPI sendStatement Function

We're finally ready to dive back into the xAPI statement code. First, let's add a "result" object to the statement. We'll add it after the "object" object, like so:

{% c-block language="js" %}
const statement = {
 "actor": {
   "name": uNamejs,
   "mbox": "mailto:" + uEmailjs
 },
 "verb": {
   "id": verbId,
   "display": { "en-US": verb }
 },
 "object": {
   "id": objectId,
   "definition": {
     "name": { "en-US": object }
   }
 },
 "result": {

 }
};
{% c-block-end %}

Important note: Don't forget to add a comma after the "object" object. Also, you may already have the result object in your code if you've completed the other intermediate tutorials.

The result object can hold plenty of important information about a statement, such as score information, open text responses, and more. For now, though, we're going to focus on the result object's "duration" property. Let's add the "duration" object now:

{% c-block language="js" %}
"result": {
 "duration":
}
{% c-block-end %}

We'll leave a placeholder for the duration property's value. You might think that we can include the courseSeconds or slideSeconds JavaScript variable that we created earlier, but the the duration property's value must be in a very specific format: ISO 8601.

Converting Duration to ISO 8601 Format

Since the xAPI specification only accepts the duration in ISO 8601 format, our duration value must look something like this: PT1H22M17S. This reads: "This period of time consists of one hour, twenty two minutes, and seventeen seconds."

As you may recall, our current timer variables only hold seconds. Let's create a function within our sendStatement() function to convert these seconds into the ISO 8601 format.

{% c-block language="js" %}
function convertToIso(secondsVar) {
 let seconds = secondsVar;
 if (seconds > 60) {
   if (seconds > 3600) {
     const hours = Math.floor(seconds / 3600);
     const minutes = Math.floor((seconds % 3600) / 60);
     seconds = (seconds % 3600) % 60;
     return `PT${hours}H${minutes}M${seconds}S`;
   } else {
     const minutes = Math.floor(seconds / 60);
     seconds %= 60;
     return `PT${minutes}M${seconds}S`;
   }
 } else {
   return `PT${seconds}S`;
 }
}
{% c-block-end %}

While it's not important that you understand exactly how this function works, here are some additional details in case you would like to develop a deeper understanding:

  • We pass the function a parameter called secondsVar. When it comes time to call this function, we will pass it the name of the seconds variable that we want to convert. For example: convertToIso(courseSeconds) or convertToIso(slideSeconds).
  • We create a new variable called "seconds" that will hold the seconds we pass to it.
  • If the duration is greater than one minute, then we perform a check of whether or not the duration is greater than one hour.
  • If the duration is greater than one hour, then we separate the duration into hours, minutes, and seconds.
  • If the duration is greater than one minute but less than one hour, then we separate the duration into minutes and seconds.
  • If the duration is less than one minute, then we just return the seconds variable with "PT" before it and "S" after it.
  • We calculate the hours, minutes, and seconds, using the % operator and the Math.floor() function. The % operator returns the remainder, and the Math.floor() function rounds a number down to the nearest whole number.
  • The function returns the desired string in ISO 8601 format. We define the strings using template literals. Template literals use backticks (rather than single or double quotes), and they allow you to pull variables into the strings like this: ${variableName}.

The important thing to know is that this function converts the number of seconds that we give it into the ISO 8601 format.

For example, imagine that a user has spent 4000 seconds in your course, which would run the courseSeconds timer up to 4000. We can write the following line of code to convert it into ISO 8601 format and assign it to a new variable:

{% c-block language="js" %}
const totalCourseDuration = convertToIso(courseSeconds);
{% c-block-end %}

Now, if we were to log the totalCourseDuration value, it would equal: "PT1H6M40S". This is the value that we would need to use for our xAPI statement's duration value. (Note: you do not need to add the example line above to your code at this time.)

Adding the sendStatement function parameter

Our code is looking great so far. We have the timers in place and a solid function to convert seconds into ISO 8601 format. However, we are still missing one key piece of code. Consider this:

How do we tell our code whether or not we want to include a duration with the xAPI statement, and taking that a step further, how do we tell it which timer to draw on (the course timer or the slide timer)?

We may only want to report the slide duration on question slides, and we also need a control in place to tell the code when we want to report the overall course timer.

The best way to do this is by adding a simple parameter to our sendStatement function. Let's add a new parameter called "timer." (See Part 3 of the Getting Started series if you are unfamiliar with parameters).

{% c-block language="js" %}
function sendStatement(verb, verbId, object, objectId, timer) {
 /* The rest of the sendStatement function code is here */
}
{% c-block-end %}

Using this parameter, we can gain increased control over our function when we call it in a Storyline "Execute JavaScript" trigger.

We need to set up our code so that when we execute the following code from Storyline, it reports the course duration along with the xAPI statement:

{% c-block language="js" %}
sendStatement("passed", "http://example.com/passed", "test","http://example.com/test", "course");
{% c-block-end %}

We also want to be able to call the following line of code to send a statement with the slide duration attached:

{% c-block language="js" %}
sendStatement("selected", "http://example.com/selected", "next","http://example.com/next", "slide");
{% c-block-end %}

And, finally, we want our function to include no duration information with the xAPI statement if we do not pass anything to the "timer" parameter. For example:

{% c-block language="js" %}
sendStatement("selected", "http://example.com/selected", "next","http://example.com/next");
{% c-block-end %}

You do not need to do anything with the previous three code examples at this time. We will cover how to use this code in Storyline in a later section.

Let's look at how to do this in the next section using conditions.

Setting Up Parameter Conditions

To ensure that our code responds appropriately to the "timer" argument that we pass to it, we need to set up if statements within the function.

Let's set up an if statement that can handle all three of our potential arguments: "course", "slide", and no argument. We'll add this code right before we define our statement, like so:

{% c-block language="js" %}
function sendStatement(verb, verbId, object, objectId, timer) {
 const player = GetPlayer();
 const uNamejs = player.GetVar("uName");
 const uEmailjs = player.GetVar("uEmail");
 const conf = {
   "endpoint": "https://trial-lrs.yetanalytics.io/xapi/",
   "auth": "Basic " + toBase64("1212:3434")
 };
 ADL.XAPIWrapper.changeConfig(conf);
/* New code begins */
 if (timer == "course") {

 } else if (timer == "slide") {

 } else {

 }
/* New code ends */
 const statement = {
   "actor": {
     "name": uNamejs,
     "mbox": "mailto:" + uEmailjs
   },
   "verb": {
     "id": verbId,
     "display": { "en-US": verb }
   },
   "object": {
     "id": objectId,
     "definition": {
       "name": { "en-US": object }
     }
   },
   "result": {
     "duration":
   }
 };
 const result = ADL.XAPIWrapper.sendStatement(statement);
 function convertToIso(secondsVar) {
   let seconds = secondsVar;
   if (seconds > 60) {
     if (seconds > 3600) {
       const hours = Math.floor(seconds / 3600);
       const minutes = Math.floor((seconds % 3600) / 60);
       seconds = (seconds % 3600) % 60;
       return `PT${hours}H${minutes}M${seconds}S`;
     } else {
       const minutes = Math.floor(seconds / 60);
       seconds %= 60;
       return `PT${minutes}M${seconds}S`;
     }
   } else {
     return `PT${seconds}S`;
   }
 }
}
{% c-block-end %}

These next steps will tie all of the code together.

First, define a new variable in the function that will hold the final duration value. We'll define this using the "let" keyword. This tells our code that the variable can and will change (as opposed to the "const" keyword, which tells the code that the variable will remain constant).

We can add this code right above the if statement that we created:

{% c-block language="js" %}
let finalDuration;
{% c-block-end %}

Now we've defined a variable that we can use within the function, but we have not yet given it a value.

Since we want the final duration value to change depending on which argument we pass to the function ("course", "slide" or none), we need to change this variable within the conditions that we've set up.

And, since this variable will hold the final duration string that we must include with our xAPI statement, we will need to use the convertToIso() function that we created in the last section, like so:

{% c-block language="js" %}
if (timer == "course") {
 finalDuration = convertToIso(courseSeconds);
} else if (timer == "slide") {
 finalDuration = convertToIso(slideSeconds);
} else {
 finalDuration = null;
}
{% c-block-end %}

As you can see, the code above does the following:

  • If the user passes the "course" argument to the function, then the courseSeconds variable is passed to the convertToIso function, and then the finalDuration value is set to the resulting string.
  • If the user passes the "slide" argument to the function, then the slideSeconds variable is passed to the convertToIso function, and then the finalDuration value is set to that resulting string.
  • If the user does not pass an argument to satisfy the "timer" parameter, then the finalDuration is set to null, which is a keyword developers use to tell that variable to stay empty.

All that's left to do is add the finalDuration variable to the xAPI statement.

Final Modification to the xAPI Statement

Now that our finalDuration variable holds the ISO 8601 string that we must send with our xAPI statement, we can just add that variable as the value for the "duration" property we created earlier.

The final result object should look like this:

{% c-block language="js" %}
"result": {
 "duration": finalDuration
}
{% c-block-end %}

Your final xapi-statement.js code should look like this:

{% c-block language="js" %}
var courseSeconds = 0;
var slideSeconds = 0;

var isCourseTimerActive = false;
var isSlideTimerActive = false;

window.setInterval( () => {
   if (isCourseTimerActive === true) {
       courseSeconds += 1
   }
   if (isSlideTimerActive === true) {
       slideSeconds += 1
   }
}, 1000);

const manageTimer = {
   "course": {
       "start": () => {isCourseTimerActive = true},
       "stop": () => {isCourseTimerActive = false},
       "reset": () => {courseSeconds = 0}
   },
   "slide": {
       "start": () => {isSlideTimerActive = true},
       "stop": () => {isSlideTimerActive = false},
       "reset": () => {slideSeconds = 0}
   }
}

function sendStatement(verb, verbId, object, objectId, timer) {
 const player = GetPlayer();
 const uNamejs = player.GetVar("uName");
 const uEmailjs = player.GetVar("uEmail");
 const conf = {
   "endpoint": "https://trial-lrs.yetanalytics.io/xapi/",
   "auth": "Basic " + toBase64("1212:3434")
 };
 ADL.XAPIWrapper.changeConfig(conf);

 let finalDuration;
 if (timer == "course") {
   finalDuration = convertToIso(courseSeconds);
 } else if (timer == "slide") {
   finalDuration = convertToIso(slideSeconds);
 } else {
   finalDuration = null;
 }

 const statement = {
   "actor": {
     "name": uNamejs,
     "mbox": "mailto:" + uEmailjs
   },
   "verb": {
     "id": verbId,
     "display": { "en-US": verb }
   },
   "object": {
     "id": objectId,
     "definition": {
       "name": { "en-US": object }
     }
   },
   "result": {
     "duration": finalDuration
   }
 };
 const result = ADL.XAPIWrapper.sendStatement(statement);
 function convertToIso(secondsVar) {
   let seconds = secondsVar;
   if (seconds > 60) {
     if (seconds > 3600) {
       const hours = Math.floor(seconds / 3600);
       const minutes = Math.floor((seconds % 3600) / 60);
       seconds = (seconds % 3600) % 60;
       return `PT${hours}H${minutes}M${seconds}S`;
     } else {
       const minutes = Math.floor(seconds / 60);
       seconds %= 60;
       return `PT${minutes}M${seconds}S`;
     }
   } else {
     return `PT${seconds}S`;
   }
 }
}
{% c-block-end %}

Setting up the Storyline File

Now that we're finished with the JavaScript file, we can hop over to Storyline. Open whatever project you're working on or create a new project for testing purposes.

Working with Course Duration

Let's start with the course duration. There are two elements to keep in mind:

  1. Starting and stopping the course timer
  2. Reporting the course duration with an xAPI statement

Starting and stopping the course timer happens independently of sending an xAPI statement.

If you remember from the section where we set up the timers, you can start the course timer by adding the following code to an "Execute JavaScript" trigger in Storyline:

{% c-block language="js" %}
manageTimer.course.start();
{% c-block-end %}

I recommend executing this trigger as soon as the first slide's timeline starts in your Storyline course.

However, what if you give the user the option to restart your Storyline course using a "Restart course" trigger on a custom button? You can restart the course timer by adding an "Execute JavaScript" trigger that fires whenever the user clicks the Restart button:

{% c-block language="js" %}
manageTimer.course.reset();
{% c-block-end %}

And, if needed, you can stop your course timer with the following code in an "Execute JavaScript" trigger:

{% c-block language="js" %}
manageTimer.course.stop();
{% c-block-end %}

Sending the Course Duration

When you're ready to send an xAPI statement that has the course duration attached, you need to execute the sendStatement() function (with all parameters included).

For example, imagine that you want to send a "completed" statement whenever the user reaches the last slide in your course. You can include the following line of JavaScript in an "Execute JavaScript" trigger that fires whenever the timeline starts:

{% c-block language="js" %}
sendStatement("completed", "http://example.com/completed", "example course", "http://example.com/course", "course")
{% c-block-end %}

Since we're passing "course" as the final argument, this tells the statement to include the course duration along with it.

Working with Slide Duration

Just as we can manage the course duration with concise JavaScript, we can start the slide timer by adding the following line of code to a Storyline "Execute JavaScript" trigger:

{% c-block language="js" %}
manageTimer.slide.start();
{% c-block-end %}

I'd recommend executing this code as soon as the timeline starts on the first Storyline slide whose duration you would like to measure.

Once the timer has been initiated, you only need to reset it every time the timeline starts on an ensuing slide. You can do this like so:

{% c-block language="js" %}
manageTimer.slide.reset();
{% c-block-end %}

Finally, if you'd like to pause your slide duration because you intend to continue the timer or send the statement at a later time, you can use this code in an "Execute JavaScript" trigger:

{% c-block language="js" %}
manageTimer.slide.stop();
{% c-block-end %}

Sending the Slide Duration

When it comes time to send the slide's duration, you can call the sendStatement() function in an "Execute JavaScript" Storyline trigger.

However, this time, you must ensure that you pass the "slide" string as the final argument to the function. The JavaScript you use will look like this:

{% c-block language="js" %}
sendStatement("completed", "http://example.com/completed", "example slide", "http://example.com/slide", "slide")
{% c-block-end %}

And there we have it! You can control your timers and send your statements with ease by using the simple JavaScript included above. You do not need to touch the xapi-statement.js file after setting it up initially.

Publishing Your Course

Once all of your "Execute JavaScript" triggers are in place, you're ready to publish your course! After you publish the course and modify the output folder accordingly, your course will communicate to the timers and send the xAPI statements as desired. You will need to add the xapi-statements.js and xapiwrapper.min.js files every time you publish your Storyline course.

You will also need to ensure that you're collecting the user's name and email address from the course if you want the code to work as shown.

If you'd rather use a filler name and email address to test your code, you can replace those variables with strings, like so:

{% c-block language="js" %}
const uNamejs = "testname";
const uEmailjs = "testemail@test.com";
{% c-block-end %}

Finally, if you haven't configured your "conf" object to work with your LRS, then your statements will not send successfully. See here if you need additional help setting the "conf" object.

Conclusion

If you've made it this far, then congratulations! Capturing duration data can offer deep insights during analysis, so adding this technical ability to your toolkit is a big win.

On the other hand, if you've had any issues sending your statements, then you should refer to the common xAPI and Storyline troubleshooting steps.

Return to the Full Guide to xAPI and Storyline.

Featured Tutorials

View all tutorials