From d4e14f5efca09831fb94cba2b6641c2f8b2670c8 Mon Sep 17 00:00:00 2001 From: vminojosa Date: Mon, 6 Jul 2026 13:58:56 -0400 Subject: [PATCH 01/13] added the tutorial from the google doc with minor adjustments --- docs/docs/extend/build-a-timeline.md | 319 +++++++++++++++++++++++++++ 1 file changed, 319 insertions(+) diff --git a/docs/docs/extend/build-a-timeline.md b/docs/docs/extend/build-a-timeline.md index e33042d..c9101a3 100644 --- a/docs/docs/extend/build-a-timeline.md +++ b/docs/docs/extend/build-a-timeline.md @@ -24,3 +24,322 @@ This guide still needs to be written. The structure below outlines what it shoul - [Build a plugin](plugins/plugin-tutorial.md) - [Publish & share](publish-and-share.md) - [The timeline](../learn/concepts/timeline.md) concept page + +# Timeline Development + +This tutorial will walk through translating the same basic reaction time task from the demo experiment into a package for jspsych-timelines. This simple demonstration will highlight key open-science principles behind what makes a distributable experimental task, including: + +- Setting up the developer environment with npm +- Blocking out timelineUnits and utils as exportable components +- Building the .createTimeline() export +- Designing parameters for configuring versions of the same task +- Testing builds +- Preparing documentation +- Finalizing a pull request with jspsych-timeline using GitHub + +[maybe include an npx CLI setup bit here] + +## Overview exports from index.ts + +`index.js` exports three principle kinds of components, all of which are functions. +- `.createTimeline()`: +- `timelineUnits`: +- `util`: + +## Setting up a timelineUnit + +To restate, `timelineUnits` are broken down, conceptual pieces of our experiment timeline. Each `timelineUnit` can be typed as an array of `TimelineNodes`. + +Let's look back our final code from the [Reaction Time Task](../learn/tutorials/rt-task.md#the-final-code) earlier. On initial review, we can split that timeline into three main chunks: +- An introduction, made up of the `welcome` and `instructions` nodes +- The `test_procedure`, alternating between `fixation` and `test` nodes +- The `debrief` presenting a digest of the participant's performance + +:::warning Needs hands-on review +Everything past this point must be implementationally verified by a few people willing to go through each step, noting build-breaking errors along the way. +::: + +The most straightforward way to block out our `timelineUnits` is by wrapping each of the above chunks in a function that returns that chunk. For the sake of including our jsPsych instance in each function's execution context, we'll take advantage of arrow functions. + +:::note Arrow Functions +The reason the above works as an arrow function, rather than a defined function, is because arrow functions are unbound to `this` and arguments. As a defined function, we would have to add `jsPsych` as its own argument to our `button_click_listener`, which makes are code less readable and flexible. For more on arrow functions, feel free to check out the [Mozilla docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions) +::: + +:::warning Include Pre-Load +Current unit breakdown below requires pre-load Node. Must decide if this will be a separate unit or factored into one defined below. +::: + +Here is the introduction as a `timelineIntro` unit: + +```javascript +const timelineIntro = () => { + var intro = []; + + var welcome = { + type: jsPsychHtmlKeyboardResponse, + stimulus: "Welcome to the experiment. Press any key to begin." + }; + + var instructions = { + type: jsPsychHtmlKeyboardResponse, + stimulus: ` +

In this experiment, a circle will appear in the center + of the screen.

If the circle is blue, + press the letter F on the keyboard as fast as you can.

+

If the circle is orange, press the letter J + as fast as you can.

+
+
+

Press the F key

+
+

Press the J key

+
+

Press any key to begin.

+ `, + post_trial_gap: 2000 + }; + + intro.push(welcome, instructions) + + return intro +} +``` + +Next, here's the `test_procedure` as a `timelineProcedure` unit: + +```javascript +const timelineProcedure = () => { + var test_stimuli = [ + { stimulus: "../blue.png", correct_response: 'f'}, + { stimulus: "../orange.png", correct_response: 'j'} + ]; + + var fixation = { + type: jsPsychHtmlKeyboardResponse, + stimulus: '
+
', + choices: "NO_KEYS", + trial_duration: function(){ + return jsPsych.randomization.sampleWithoutReplacement([250, 500, 750, 1000, 1250, 1500, 1750, 2000], 1)[0]; + }, + data: { + task: 'fixation' + } + }; + + var test = { + type: jsPsychImageKeyboardResponse, + stimulus: function() { return jsPsych.timelineVariable('stimulus'); }, + choices: ['f', 'j'], + data: { + task: 'response', + correct_response: function() { return jsPsych.timelineVariable('correct_response'); } + }, + on_finish: function(data){ + data.correct = jsPsych.pluginAPI.compareKeys(data.response, data.correct_response); + } + }; + + var test_procedure = { + timeline: [fixation, test], + timeline_variables: test_stimuli, + repetitions: 5, + randomize_order: true + }; + + return test_procedure; +} +``` + +Last, here's `debrief` as a `timelineDebrief` unit: + +```javascript +const timelineDebrief = () => { + var debrief = { + type: jsPsychHtmlKeyboardResponse, + stimulus: function() { + + var trials = jsPsych.data.get().filter({task: 'response'}); + var correct_trials = trials.filter({correct: true}); + var accuracy = Math.round(correct_trials.count() / trials.count() * 100); + var rt = Math.round(correct_trials.select('rt').mean()); + + return `

You responded correctly on ${accuracy}% of the trials.

+

Your average response time was ${rt}ms.

+

Press any key to complete the experiment. Thank you!

`; + + } + } + + return debrief +} +``` + +Now, let's add each of these as exports under timelineUnits: + +```javascript +export const timelineUnits = { + timelineIntro, + timelineTest, + timelineDebrief +} +``` + +After doing that, we can run `npm run build` in the commandline and call each timelineUnit separately from `examples/index.html`. + +:::note Introduce `examples/index.html` +Put something in the overview, under the first header, that explains `examples/index.html` +::: + +```html + +``` + +With our `timelineUnits` bracketed out and exported, anyone could isolate, rearrange, or reconfigure any one of the pieces of our original experiment. The next section will expand on that last point and go into parametrizing units for configurability. + +
+ The complete code so far + ```javascript + // paste whole index.ts in here + ``` +
+ + +## Building .createTimelines() + +## Designing and executing parameters + +Now that we have our initial experiment sectioned off into `timelineUnits`, we can now think about designing parameters, based on how we might want to modify the task for iterative deployments. + +Let's define our parameters as a Javascript object named `options`. Let's begin with this initial set of parameters: +- `repetitions`: +- `intro`: +- `debrief`: +**where repetitions takes a number while both intro and debrief take a Boolean. We can then feed this as an argument to .`createTimelines()`** + +In essence, we want to be able to run the following from `index.html`, assuming we've rewritten `createTimelines()` to take `options` as an argument. + +```javascript +const options = { + repetitions: 5, + instructions: true, + debrief: true +} + +const task = jsPsychTimelineReactionTimeDemo.createTimeline(jsPsych, options) +``` + +Now, let's write implementations for these parameters in each of their corresponding `timelineUnits`. We'll start with the simplest ones, `instructions` and `debrief`. + +For `instructions`, we can write a basic implementation by splitting our single `push` call into two—one for the `welcome` node and the other for the `instructions` node—then wrapping `push(instructions)` in an if-statement that depends on `options.instruction` as a condition. + +```javascript +const timelineIntro = () => { + var intro = []; + + var welcome = { + type: jsPsychHtmlKeyboardResponse, + stimulus: "Welcome to the experiment. Press any key to begin." + }; + + var instructions = { + type: jsPsychHtmlKeyboardResponse, + stimulus: ` +

In this experiment, a circle will appear in the center + of the screen.

If the circle is blue, + press the letter F on the keyboard as fast as you can.

+

If the circle is orange, press the letter J + as fast as you can.

+
+
+

Press the F key

+
+

Press the J key

+
+

Press any key to begin.

+ `, + post_trial_gap: 2000 + }; + + intro.push(welcome) + + if(options.instructions){ + intro.push(instructions) + } + + return intro +} +``` + +Then, we tweak each timelineUnit to take its relevant property in `options.` In this case, timelineTest will take repetitions, timelineIntro will take intro, and timelineDebrief will take debrief. Since these are all properties of options, they'll each be written using dot notation when actually called. + +```javascript +function timelineProcedure(jsPsych: jsPsych, repetitions: number) { + var test_stimuli = [ + { stimulus: "../blue.png", correct_response: 'f'}, + { stimulus: "../orange.png", correct_response: 'j'} + ]; + + var fixation = { + type: jsPsychHtmlKeyboardResponse, + stimulus: '
+
', + choices: "NO_KEYS", + trial_duration: function(){ + return jsPsych.randomization.sampleWithoutReplacement([250, 500, 750, 1000, 1250, 1500, 1750, 2000], 1)[0]; + }, + data: { + task: 'fixation' + } + }; + + var test = { + type: jsPsychImageKeyboardResponse, + stimulus: function() { return jsPsych.timelineVariable('stimulus'); }, + choices: ['f', 'j'], + data: { + task: 'response', + correct_response: function() { return jsPsych.timelineVariable('correct_response'); } + }, + on_finish: function(data){ + data.correct = jsPsych.pluginAPI.compareKeys(data.response, data.correct_response); + } + }; + + var test_procedure = { + timeline: [fixation, test], + timeline_variables: test_stimuli, + repetitions: repetitions, + randomize_order: true + }; + + return test_procedure; +} + +``` + +All that's left is to adjust `createTimeline()` to take a second `options` the second options argument + +```javascript +export function createTimeline(jsPsych: jsPsych, options){ + // fill this in with what the current function would look like at this stage +} +``` + +## Setting up a util +... + +## Testing exports +... + +## Writing documentation +... + +## Open a pull request! +... From bb80f5fe0443e38d3f9b1e69ce63094dafe93538 Mon Sep 17 00:00:00 2001 From: vminojosa Date: Wed, 8 Jul 2026 13:53:02 -0400 Subject: [PATCH 02/13] more or less completed a stable enough first pass at the parameters section of the tutorial. I also added a "complete code" details admonition to the timeline unit section and renamed a couple of variables to more closely resemble the original rt task. Also changed text accordingly to match the regular functions, since I will not be using arrow functions for timelineUnits --- docs/docs/extend/build-a-timeline.md | 219 ++++++++++++++++++++++----- 1 file changed, 181 insertions(+), 38 deletions(-) diff --git a/docs/docs/extend/build-a-timeline.md b/docs/docs/extend/build-a-timeline.md index c9101a3..3a28ef1 100644 --- a/docs/docs/extend/build-a-timeline.md +++ b/docs/docs/extend/build-a-timeline.md @@ -59,11 +59,7 @@ Let's look back our final code from the [Reaction Time Task](../learn/tutorials/ Everything past this point must be implementationally verified by a few people willing to go through each step, noting build-breaking errors along the way. ::: -The most straightforward way to block out our `timelineUnits` is by wrapping each of the above chunks in a function that returns that chunk. For the sake of including our jsPsych instance in each function's execution context, we'll take advantage of arrow functions. - -:::note Arrow Functions -The reason the above works as an arrow function, rather than a defined function, is because arrow functions are unbound to `this` and arguments. As a defined function, we would have to add `jsPsych` as its own argument to our `button_click_listener`, which makes are code less readable and flexible. For more on arrow functions, feel free to check out the [Mozilla docs](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions) -::: +The most straightforward way to block out our `timelineUnits` is by wrapping each of the above chunks in a function that returns that chunk. To start each, each of these functions should also take as an argument the jsPsych instance running our experiment, in case any of our trials' logic references core methods. :::warning Include Pre-Load Current unit breakdown below requires pre-load Node. Must decide if this will be a separate unit or factored into one defined below. @@ -72,14 +68,16 @@ Current unit breakdown below requires pre-load Node. Must decide if this will be Here is the introduction as a `timelineIntro` unit: ```javascript -const timelineIntro = () => { - var intro = []; +function timelineIntro(jsPsych: JsPsych) { + var intro_block = []; var welcome = { type: jsPsychHtmlKeyboardResponse, stimulus: "Welcome to the experiment. Press any key to begin." }; + intro_block.push(welcome) + var instructions = { type: jsPsychHtmlKeyboardResponse, stimulus: ` @@ -99,16 +97,16 @@ const timelineIntro = () => { post_trial_gap: 2000 }; - intro.push(welcome, instructions) + intro_block.push(instructions) - return intro + return intro_block } ``` Next, here's the `test_procedure` as a `timelineProcedure` unit: ```javascript -const timelineProcedure = () => { +function timelineProcedure(jsPsych: JsPsych) { var test_stimuli = [ { stimulus: "../blue.png", correct_response: 'f'}, { stimulus: "../orange.png", correct_response: 'j'} @@ -128,11 +126,11 @@ const timelineProcedure = () => { var test = { type: jsPsychImageKeyboardResponse, - stimulus: function() { return jsPsych.timelineVariable('stimulus'); }, + stimulus: jsPsych.timelineVariable('stimulus'), choices: ['f', 'j'], data: { task: 'response', - correct_response: function() { return jsPsych.timelineVariable('correct_response'); } + correct_response: jsPsych.timelineVariable('correct_response'); }, on_finish: function(data){ data.correct = jsPsych.pluginAPI.compareKeys(data.response, data.correct_response); @@ -153,8 +151,8 @@ const timelineProcedure = () => { Last, here's `debrief` as a `timelineDebrief` unit: ```javascript -const timelineDebrief = () => { - var debrief = { +function timelineDebrief(jsPsych: JsPsych) { + var debrief_block = { type: jsPsychHtmlKeyboardResponse, stimulus: function() { @@ -170,7 +168,7 @@ const timelineDebrief = () => { } } - return debrief + return debrief_block } ``` @@ -186,7 +184,7 @@ export const timelineUnits = { After doing that, we can run `npm run build` in the commandline and call each timelineUnit separately from `examples/index.html`. -:::note Introduce `examples/index.html` +:::warning Introduce `examples/index.html` Put something in the overview, under the first header, that explains `examples/index.html` ::: @@ -207,22 +205,133 @@ With our `timelineUnits` bracketed out and exported, anyone could isolate, rearr
The complete code so far ```javascript - // paste whole index.ts in here + import { JsPsych } from "jspsych"; + import jsPsychPreload from "@jspsych/plugin-preload"; + import jsPsychHtmlKeyboardResponse from "@jspsych/plugin-html-keyboard-response"; + import jsPsychImageKeyboardResponse from "@jspsych/plugin-image-keyboard-response"; + + function timelineIntro() { + var intro_block = []; + + var welcome = { + type: jsPsychHtmlKeyboardResponse, + stimulus: "Welcome to the experiment. Press any key to begin." + }; + + intro_block.push(welcome) + + var instructions = { + type: jsPsychHtmlKeyboardResponse, + stimulus: ` +

In this experiment, a circle will appear in the center + of the screen.

If the circle is blue, + press the letter F on the keyboard as fast as you can.

+

If the circle is orange, press the letter J + as fast as you can.

+
+
+

Press the F key

+
+

Press the J key

+
+

Press any key to begin.

+ `, + post_trial_gap: 2000 + }; + + intro_block.push(instructions) + + return intro_block + } + + function timelineTest(jsPsych: JsPsych) { + var test_stimuli = [ + { stimulus: "../assets/blue.png", correct_response: 'f'}, + { stimulus: "../assets/orange.png", correct_response: 'j'} + ]; + + var fixation = { + type: jsPsychHtmlKeyboardResponse, + stimulus: '
+
', + choices: "NO_KEYS", + trial_duration: function() { + return jsPsych.randomization.sampleWithoutReplacement([250, 500, 750, 1000, 1250, 1500, 1750, 2000], 1)[0]; + }, + data: { + task: 'fixation' + } + }; + + var test = { + type: jsPsychImageKeyboardResponse, + stimulus: jsPsych.timelineVariable('stimulus'), + choices: ['f', 'j'], + data: { + task: 'response', + correct_response: jsPsych.timelineVariable('correct_response') + }, + on_finish: function(data) { + data.correct = jsPsych.pluginAPI.compareKeys(data.response, data.correct_response); + } + }; + + var test_procedure = { + timeline: [fixation, test], + timeline_variables: test_stimuli, + repetitions: 5, + randomize_order: true + }; + + return [test_procedure]; + } + + function timelineDebrief(jsPsych: JsPsych) { + var debrief_block = { + type: jsPsychHtmlKeyboardResponse, + stimulus: function() { + var trials = jsPsych.data.get().filter({task: 'response'}); + var correct_trials = trials.filter({correct: true}); + var accuracy = Math.round(correct_trials.count() / trials.count() * 100); + var rt = Math.round(correct_trials.select('rt').mean()); + + return `

You responded correctly on ${accuracy}% of the trials.

+

Your average response time was ${rt}ms.

+

Press any key to complete the experiment. Thank you!

`; + } + } + + return debrief_block + } + + export function createTimeline(jsPsych:JsPsych, options: Partial = {}) { + + } + + export const timelineUnits = { + timelineIntro, + timelineTest, + timelineDebrief + } + + export const utils = {} ```
## Building .createTimelines() +:::warning Rewrite transition in previous section +After writing how to use the timelineUnits to write `.creatTimelines()`, be sure to move the exit transition for the previous section here, as well as rewrite that transition. +::: + ## Designing and executing parameters Now that we have our initial experiment sectioned off into `timelineUnits`, we can now think about designing parameters, based on how we might want to modify the task for iterative deployments. Let's define our parameters as a Javascript object named `options`. Let's begin with this initial set of parameters: -- `repetitions`: -- `intro`: -- `debrief`: -**where repetitions takes a number while both intro and debrief take a Boolean. We can then feed this as an argument to .`createTimelines()`** +- `repetitions`: An integer that determines the amount of times the pair of "blue" and "orange" trials repeats +- `instructions`: A Boolean that allows the timeline to include the instructions trial if `True` +- `debrief`: A Boolean that allows the timeline to include the debrief trial if `True` In essence, we want to be able to run the following from `index.html`, assuming we've rewritten `createTimelines()` to take `options` as an argument. @@ -236,20 +345,27 @@ const options = { const task = jsPsychTimelineReactionTimeDemo.createTimeline(jsPsych, options) ``` -Now, let's write implementations for these parameters in each of their corresponding `timelineUnits`. We'll start with the simplest ones, `instructions` and `debrief`. +:::warning `options` and `defaultOptions` objects +Start by introducing these objects first in the `.createTimeline()` method. +::: + +Now, let's write implementations for these parameters in each of their corresponding `timelineUnits`. We'll start with `instructions` and `debrief`. -For `instructions`, we can write a basic implementation by splitting our single `push` call into two—one for the `welcome` node and the other for the `instructions` node—then wrapping `push(instructions)` in an if-statement that depends on `options.instruction` as a condition. +For `instructions`, we can write a basic implementation by wrapping our definition of `var instructions` in an `if`-statement. We'll define `instructions` as a trial object in the case that `optionInstructions` is true. Otherwise, we define `var instructions` as an empty array, since this implementation assumes an `instructions` variable will be pushed to the `intro_block` array either way. We'll also add `optionInstructions` as an argument for the `timelineIntro` function. ```javascript -const timelineIntro = () => { - var intro = []; +function timelineIntro(jsPsych: JsPsych, optionInstructions) { + var intro_block = []; var welcome = { type: jsPsychHtmlKeyboardResponse, stimulus: "Welcome to the experiment. Press any key to begin." }; - var instructions = { + intro.push(welcome) + + if(optionInstructions){ + var instructions = { type: jsPsychHtmlKeyboardResponse, stimulus: `

In this experiment, a circle will appear in the center @@ -266,22 +382,49 @@ const timelineIntro = () => {

Press any key to begin.

`, post_trial_gap: 2000 - }; - - intro.push(welcome) - - if(options.instructions){ - intro.push(instructions) + }; + } else { + var instructions = [] } + intro.push(instructions) + return intro } ``` -Then, we tweak each timelineUnit to take its relevant property in `options.` In this case, timelineTest will take repetitions, timelineIntro will take intro, and timelineDebrief will take debrief. Since these are all properties of options, they'll each be written using dot notation when actually called. +We can do much the same things with `debrief`. Let's define `var debrief_block` as the expected trial object if `optionsDebrief` is true, and define `debrief_block` as an empty array otherwise. Once again, we're also remembering to add `optionDebrief` as an argument. + +```javascript +function timelineDebrief(jsPsych: JsPsych, optionDebrief) { + if(optionDebrief){ + var debrief_block = { + type: jsPsychHtmlKeyboardResponse, + stimulus: function() { + + var trials = jsPsych.data.get().filter({task: 'response'}); + var correct_trials = trials.filter({correct: true}); + var accuracy = Math.round(correct_trials.count() / trials.count() * 100); + var rt = Math.round(correct_trials.select('rt').mean()); + + return `

You responded correctly on ${accuracy}% of the trials.

+

Your average response time was ${rt}ms.

+

Press any key to complete the experiment. Thank you!

`; + + } + } + } else { + var debrief_block = [] + } + + return debrief +} +``` + +Finally, we can implement `repetitions` in the `timelineProcedure` unit by simply adding `optionRepetitions` as an argument and reading it to the `repetitions` parameter in `test_procedure`. ```javascript -function timelineProcedure(jsPsych: jsPsych, repetitions: number) { +function timelineProcedure(jsPsych: jsPsych, optionRepetitions) { var test_stimuli = [ { stimulus: "../blue.png", correct_response: 'f'}, { stimulus: "../orange.png", correct_response: 'j'} @@ -301,11 +444,11 @@ function timelineProcedure(jsPsych: jsPsych, repetitions: number) { var test = { type: jsPsychImageKeyboardResponse, - stimulus: function() { return jsPsych.timelineVariable('stimulus'); }, + stimulus: jsPsych.timelineVariable('stimulus'), choices: ['f', 'j'], data: { task: 'response', - correct_response: function() { return jsPsych.timelineVariable('correct_response'); } + correct_response: jsPsych.timelineVariable('correct_response'), }, on_finish: function(data){ data.correct = jsPsych.pluginAPI.compareKeys(data.response, data.correct_response); @@ -315,7 +458,7 @@ function timelineProcedure(jsPsych: jsPsych, repetitions: number) { var test_procedure = { timeline: [fixation, test], timeline_variables: test_stimuli, - repetitions: repetitions, + repetitions: optionRepetitions, randomize_order: true }; @@ -324,7 +467,7 @@ function timelineProcedure(jsPsych: jsPsych, repetitions: number) { ``` -All that's left is to adjust `createTimeline()` to take a second `options` the second options argument +All that's left is to adjust `createTimeline()` so that each timelineUnit call takes their new arguments, respectively. ```javascript export function createTimeline(jsPsych: jsPsych, options){ From d15ccc1a50c93919c4f1a1c0215e49b3f1063857 Mon Sep 17 00:00:00 2001 From: vminojosa Date: Fri, 10 Jul 2026 14:18:38 -0400 Subject: [PATCH 03/13] wrote a complete pass of the utils section, and added a section to the timelineunits part describing how to call units from html post-build. Also made minor edits for readability and added admonitions to flag problem areas, like bugs --- docs/docs/extend/build-a-timeline.md | 166 +++++++++++++++++++++++++-- 1 file changed, 158 insertions(+), 8 deletions(-) diff --git a/docs/docs/extend/build-a-timeline.md b/docs/docs/extend/build-a-timeline.md index 3a28ef1..4f6950c 100644 --- a/docs/docs/extend/build-a-timeline.md +++ b/docs/docs/extend/build-a-timeline.md @@ -103,10 +103,10 @@ function timelineIntro(jsPsych: JsPsych) { } ``` -Next, here's the `test_procedure` as a `timelineProcedure` unit: +Next, here's the `test_procedure` as a `timelineTest` unit: ```javascript -function timelineProcedure(jsPsych: JsPsych) { +function timelineTest(jsPsych: JsPsych) { var test_stimuli = [ { stimulus: "../blue.png", correct_response: 'f'}, { stimulus: "../orange.png", correct_response: 'j'} @@ -193,7 +193,7 @@ Put something in the overview, under the first header, that explains `examples/i const jsPsych = initJsPsych(); const intro = jsPsychTimelineReactionTimeDemo.timelineUnit.timelineIntro(); - const test = jsPsychTimelineReactionTimeDemo.timelineUnit.timelineProcedure(); + const test = jsPsychTimelineReactionTimeDemo.timelineUnit.timelineTest(); const debrief = jsPsychTimelineReactionTimeDemo.timelineUnit.timelineDebrief(); jsPsych.run([intro, test, debrief]) @@ -329,7 +329,7 @@ After writing how to use the timelineUnits to write `.creatTimelines()`, be sure Now that we have our initial experiment sectioned off into `timelineUnits`, we can now think about designing parameters, based on how we might want to modify the task for iterative deployments. Let's define our parameters as a Javascript object named `options`. Let's begin with this initial set of parameters: -- `repetitions`: An integer that determines the amount of times the pair of "blue" and "orange" trials repeats +- `repetitions`: An integer that determines the amount of times the pair of "blue" and "orange" trials repeats. Basically a parametrized version of the `repetition` parameter that already exists in the `timeline_procedure` object! - `instructions`: A Boolean that allows the timeline to include the instructions trial if `True` - `debrief`: A Boolean that allows the timeline to include the debrief trial if `True` @@ -353,6 +353,9 @@ Now, let's write implementations for these parameters in each of their correspon For `instructions`, we can write a basic implementation by wrapping our definition of `var instructions` in an `if`-statement. We'll define `instructions` as a trial object in the case that `optionInstructions` is true. Otherwise, we define `var instructions` as an empty array, since this implementation assumes an `instructions` variable will be pushed to the `intro_block` array either way. We'll also add `optionInstructions` as an argument for the `timelineIntro` function. +:::warning Bug: Typing +Need to declare `instructions` as a variable with type array or object before you can run the parametrized unit. Same with `debrief`. +::: ```javascript function timelineIntro(jsPsych: JsPsych, optionInstructions) { var intro_block = []; @@ -393,7 +396,7 @@ function timelineIntro(jsPsych: JsPsych, optionInstructions) { } ``` -We can do much the same things with `debrief`. Let's define `var debrief_block` as the expected trial object if `optionsDebrief` is true, and define `debrief_block` as an empty array otherwise. Once again, we're also remembering to add `optionDebrief` as an argument. +We can do much the same thing with `debrief`. Let's define `var debrief_block` as the expected trial object if `optionsDebrief` is true, and define `debrief_block` as an empty array otherwise. Once again, we're also remembering to add `optionDebrief` as an argument. ```javascript function timelineDebrief(jsPsych: JsPsych, optionDebrief) { @@ -421,10 +424,10 @@ function timelineDebrief(jsPsych: JsPsych, optionDebrief) { } ``` -Finally, we can implement `repetitions` in the `timelineProcedure` unit by simply adding `optionRepetitions` as an argument and reading it to the `repetitions` parameter in `test_procedure`. +Finally, we can implement `repetitions` in the `timelineTest` unit by simply adding `optionRepetitions` as an argument and reading it to the `repetitions` parameter in `test_procedure`. ```javascript -function timelineProcedure(jsPsych: jsPsych, optionRepetitions) { +function timelineTest(jsPsych: jsPsych, optionRepetitions) { var test_stimuli = [ { stimulus: "../blue.png", correct_response: 'f'}, { stimulus: "../orange.png", correct_response: 'j'} @@ -475,8 +478,155 @@ export function createTimeline(jsPsych: jsPsych, options){ } ``` +Now, on next build, we'll be able to run these timelineUnits again from the `index.html`, this time altering each unit's behavior through each's new, second argument. + +```javascript title='examples/index.html' +const jsPsych = initJsPsych({ + on_finish: function() { + jsPsych.data.displayData(); +}}); + +const intro = jsPsychTimelineReactionTimeDemo.timelineUnits.timelineIntro(jsPsych, false); +const test = jsPsychTimelineReactionTimeDemo.timelineUnits.timelineTest(jsPsych, 5); +const debrief = jsPsychTimelineReactionTimeDemo.timelineUnits.timelineDebrief(jsPsych, true); + +jsPsych.run([intro, test, debrief]) +``` + +:::warning Signpost Other Ways to Factor Out Parameters +::: + ## Setting up a util -... + +With the bigger conceptual portions of the experiment factored out as parameterized `timelineUnits`, we can now think about factoring out `util`, or essential helper functions that support more sophisticated, customizable behaviors. + +To start thinking about `utils`, we're going to again default to the simplest case scenario, take what already exists in our code, and wrap it off into a separate function for export. A good place to start would be any of the functions returning a value to our trial objects. For example, let's look at the randomized fixation timing between stimuli. + +```javascript + var fixation = { + type: jsPsychHtmlKeyboardResponse, + stimulus: '
+
', + choices: "NO_KEYS", + trial_duration: function(){ + return jsPsych.randomization.sampleWithoutReplacement([250, 500, 750, 1000, 1250, 1500, 1750, 2000], 1)[0]; + }, + data: { + task: 'fixation' + } + }; +``` + +The logic in `trial_duration` samples 1 out of an array of integers, then sets that to the number of milliseconds passed before the fixation trial ends. As a `util`, however, we can factor this out into a separate function. For now, we'll have to give the function `jsPsych` as an argument, since the sampling logic is borrowed from a jsPsych module. + +```javascript +function fixationDuration(jsPsych: JsPsych) { + return jsPsych.randomization.sampleWithoutReplacement([250, 500, 750, 1000, 1250, 1500, 1750, 2000], 1)[0]; +} +``` + +Then, let's call it in the original trial definition. Remember to return it as the output to an arrow function. Otherwise, our new `fixationDuration` util will only be evaluated once when our trial object is created, as opposed to everytime the trial object is instantiated in our timeline. + +```javascript +var fixation = { + type: jsPsychHtmlKeyboardResponse, + stimulus: '
+
', + choices: "NO_KEYS", + trial_duration: () => fixationDuration(jsPsych) + data: { + task: 'fixation' + } +}; +``` + +Why would we want to do this? Doesn't this just get us the same result with extra steps? Well, aside from keeping our logic separated and a little more legible, this gives us an opportunity to scope out more control over this functional behavior of the experiment. As developers, we can have more control over the logic that determines time spent on a fixation point, by working within `fixationDuration` in isolation. We can even add new arguments that affect the util's behavior. As behavioral researchers, we could implement new parameters om our HTML, or call `fixation, in order to incorporate a new variable into our experiments. + +For instance, let's turn `fixationDuration` into a switch that, to start off, takes a second argument to distinguish between two cases: `"random"` and `"fixed"`. `"random"` cases can return the original randomization logic, while `"fixed"` returns a reliable 1000 milliseconds. We can also set the randomization logic as the default. + +```javascript +function fixationDuration(jsPsych: JsPsych, mode: "random" | "fixed" = "random") { + switch (mode) { + case "fixed": + return 1000; + case "random": + return jsPsych.randomization.sampleWithoutReplacement([250, 500, 750, 1000, 1250, 1500, 1750, 2000], 1)[0]; + default: + return jsPsych.randomization.sampleWithoutReplacement([250, 500, 750, 1000, 1250, 1500, 1750, 2000], 1)[0]; + } +} +``` + +Once we do this, we need to make sure the `fixationDuration` call in the `fixation` trial definition includes our new argument. Let's also make sure the timelineUnit `timelineTest` takes this new argument, and that the new argument is provided when `timelineTest` is called in `createTimeline`, since this value is inherited all throughout our `src` file. + +```javascript +var fixation = { + type: jsPsychHtmlKeyboardResponse, + stimulus: '
+
', + choices: "NO_KEYS", + trial_duration: () => fixationDuration(jsPsych, 'fixed') + data: { + task: 'fixation' + } +}; +``` +```javascript +function timelineTest(jsPsych: jsPsych, optionRepetitions, optionFixationDuration) { +``` +```javascript +timeline.push(timelineTest(jsPsych, options.repetitions, options.fixationDuration)); +``` +:::warning `options` Object and Interface +Once the `options` object and interface are described above, please incorporate that into this "inheritance chain". +::: + +Now, with our next build, we can go into `index.html` and use this new argument to adjust our `fixation` trial behavior on the fly, between `timelineTest` calls. + +```javascript title='examples/index.html' +const jsPsych = initJsPsych({ + on_finish: function() { + jsPsych.data.displayData(); +}}); + +const intro = jsPsychTimelineReactionTimeDemo.timelineUnits.timelineIntro(jsPsych, false); +const test1 = jsPsychTimelineReactionTimeDemo.timelineUnits.timelineTest(jsPsych, 5, "fixed"); +const test2 = jsPsychTimelineReactionTimeDemo.timelineUnits.timelineTest(jsPsych, 5, "random"); +const debrief = jsPsychTimelineReactionTimeDemo.timelineUnits.timelineDebrief(jsPsych, true); + +jsPsych.run([intro, test1, test2, debrief]) +``` + +We can also call `fixationDuration` separately. For example, maybe we, for whatever reason, define a trial object directly from the HTML that assigns the same functional output to `trial_duration`. We can call `fixationDuration` directly from the `utils` exports for this purpose. + +```javascript title='examples/index.html' +const jsPsych = initJsPsych({ + on_finish: function() { + jsPsych.data.displayData(); +}}); + +const XFixation = { + type: jsPsychHtmlKeyboardResponse, + stimulus: '
X
', + choices: "NO_KEYS", + trial_duration: () => jsPsychTimelineReactionTask.utils.fixationDuration(jsPsych, 'fixed'), + data: { + task: 'fixation' + } +} + +const intro = jsPsychTimelineReactionTask.timelineUnits.timelineIntro(jsPsych, false); +const test1 = jsPsychTimelineReactionTask.timelineUnits.timelineTest(jsPsych, 5, "fixed"); +const test2 = jsPsychTimelineReactionTask.timelineUnits.timelineTest(jsPsych, 5, "random"); +const debrief = jsPsychTimelineReactionTask.timelineUnits.timelineDebrief(jsPsych, true); + +jsPsych.run([intro, XFixation, test1, test2, debrief]) +``` + +:::warning Script Tag +Remember, for the above to work from the HTML, you need to add a script tag to the top of the HTML file that imports `jsPsychHtmlKeyboardReponse` via CDN. You're calling the plugin directly from the HTML now, so you need to have the type available in the HTML file. +::: + +Keep in mind that the point of these demonstrated `utils` is to convey the layer of abstraction permitted with a timeline package. `fixationDuration` may not be especially useful right now, at least not in the way we've implemented it so far, but it carves out new room for implementational flexibility. + +Researchers working primarily from HTML files, without digging into our source, might find new unanticipated uses for any of our `util` exports. Developers, meanwhile, can always modify the util itself and give it new functionality, or refactor it with respect to our `timelineUnits`. By designing jsPsych experiments with a layer of exposed, modular access in the form `timelineUnits` and `utils`, we introduce a new point of feedback in the jsPsych research ecosystem—one that ultimately helps us build a better tool for everyone. ## Testing exports ... From 2a72f3033162481065ee9d7881c175f08f177215 Mon Sep 17 00:00:00 2001 From: vminojosa Date: Mon, 13 Jul 2026 17:42:37 -0400 Subject: [PATCH 04/13] Added the opening section on createTimeline, a portion to the timelineUnits section for factoring createTimeline code out to units, example "code so far" detail nodes for both sections (which make it easier to check that code works between sections), and minor corrections like missing arguments. I also made minor edits to the designing and executing parameters section to introduce the options object --- docs/docs/extend/build-a-timeline.md | 508 +++++++++++++++++++++++---- 1 file changed, 439 insertions(+), 69 deletions(-) diff --git a/docs/docs/extend/build-a-timeline.md b/docs/docs/extend/build-a-timeline.md index 4f6950c..24acf41 100644 --- a/docs/docs/extend/build-a-timeline.md +++ b/docs/docs/extend/build-a-timeline.md @@ -46,20 +46,262 @@ This tutorial will walk through translating the same basic reaction time task fr - `timelineUnits`: - `util`: -## Setting up a timelineUnit +## Setting Up `createTimeline()` + +Since this is the primary export for our package, we can think of this as the "hub" where all of our exports come together to generate a complete, fully configured task. This single export will process any parameters exposed to users and referenced throughout our source code. `createTimeline` will also depend on any timelineUnits and utils we eventually factor out over the course of this tutorial. It's the glue holding our package together, so we'll start here. + +To get things going, we can just copy the original code for the [Reaction Time Task](../learn/tutorials/rt-task.md#the-final-code) right here in our function, treating the function as a wrapper. + +```javascript +export function createTimeline(jsPsych:JsPsych) { + var timeline = []; + + /* preload images */ + var preload = { + type: jsPsychPreload, + images: ['img/blue.png', 'img/orange.png'] + }; + timeline.push(preload); + + /* define welcome message trial */ + var welcome = { + type: jsPsychHtmlKeyboardResponse, + stimulus: "Welcome to the experiment. Press any key to begin." + }; + timeline.push(welcome); + + /* define instructions trial */ + var instructions = { + type: jsPsychHtmlKeyboardResponse, + stimulus: ` +

In this experiment, a circle will appear in the center + of the screen.

If the circle is blue, + press the letter F on the keyboard as fast as you can.

+

If the circle is orange, press the letter J + as fast as you can.

+
+
+

Press the F key

+
+

Press the J key

+
+

Press any key to begin.

+ `, + post_trial_gap: 2000 + }; + timeline.push(instructions); + + /* define trial stimuli array for timeline variables */ + var test_stimuli = [ + { stimulus: "img/blue.png", correct_response: 'f'}, + { stimulus: "img/orange.png", correct_response: 'j'} + ]; + + /* define fixation and test trials */ + var fixation = { + type: jsPsychHtmlKeyboardResponse, + stimulus: '
+
', + choices: "NO_KEYS", + trial_duration: function(){ + return jsPsych.randomization.sampleWithoutReplacement([250, 500, 750, 1000, 1250, 1500, 1750, 2000], 1)[0]; + }, + data: { + task: 'fixation' + } + }; + + var test = { + type: jsPsychImageKeyboardResponse, + stimulus: jsPsych.timelineVariable('stimulus'), + choices: ['f', 'j'], + data: { + task: 'response', + correct_response: jsPsych.timelineVariable('correct_response') + }, + on_finish: function(data){ + data.correct = jsPsych.pluginAPI.compareKeys(data.response, data.correct_response); + } + }; + + /* define test procedure */ + var test_procedure = { + timeline: [fixation, test], + timeline_variables: test_stimuli, + repetitions: 5, + randomize_order: true + }; + timeline.push(test_procedure); + + /* define debrief */ + var debrief_block = { + type: jsPsychHtmlKeyboardResponse, + stimulus: function() { + + var trials = jsPsych.data.get().filter({task: 'response'}); + var correct_trials = trials.filter({correct: true}); + var accuracy = Math.round(correct_trials.count() / trials.count() * 100); + var rt = Math.round(correct_trials.select('rt').mean()); + + return `

You responded correctly on ${accuracy}% of the trials.

+

Your average response time was ${rt}ms.

+

Press any key to complete the experiment. Thank you!

`; + + } + }; + timeline.push(debrief_block); + + return timeline +} +``` + +Notice how this code includes everything except `initjsPsych` and `jsPsych.run`. This is because `createTimeline` only outputs the complete timeline array and nothing else. It is not responsible for declaring the jsPsych instance that actually runs the experiment; that still happens in the HTML that imports and calls `createTimeline`! + +At the same time, `createTimeline` requires a jsPsych instance as an argument, since it still references core jsPsych methods to define trial parameters. + +Now, let's run `npm run build` in the commandline to create a first build of our package. After that, we can call `createTimeline` from `examples/index.html`, assign the output to a `timeline` constant, then run it to see the same familiar reaction time task in action. + +```javascript title="examples/index.html" +const jsPsych = initJsPsych({ + on_finish: function() { + jsPsych.data.displayData(); +}}); + +const timeline = jsPsychTimelineReactionTimeDemo.createTimeline(jsPsych); + +jsPsych.run(timeline) +``` + +
+ The complete code so far + ```javascript + import { JsPsych } from "jspsych"; + import jsPsychPreload from "@jspsych/plugin-preload"; + import jsPsychHtmlKeyboardResponse from "@jspsych/plugin-html-keyboard-response"; + import jsPsychImageKeyboardResponse from "@jspsych/plugin-image-keyboard-response"; + + export function createTimeline(jsPsych:JsPsych) { + var timeline = []; + + /* preload images */ + var preload = { + type: jsPsychPreload, + images: ['img/blue.png', 'img/orange.png'] + }; + timeline.push(preload); + + /* define welcome message trial */ + var welcome = { + type: jsPsychHtmlKeyboardResponse, + stimulus: "Welcome to the experiment. Press any key to begin." + }; + timeline.push(welcome); + + /* define instructions trial */ + var instructions = { + type: jsPsychHtmlKeyboardResponse, + stimulus: ` +

In this experiment, a circle will appear in the center + of the screen.

If the circle is blue, + press the letter F on the keyboard as fast as you can.

+

If the circle is orange, press the letter J + as fast as you can.

+
+
+

Press the F key

+
+

Press the J key

+
+

Press any key to begin.

+ `, + post_trial_gap: 2000 + }; + timeline.push(instructions); + + /* define trial stimuli array for timeline variables */ + var test_stimuli = [ + { stimulus: "img/blue.png", correct_response: 'f'}, + { stimulus: "img/orange.png", correct_response: 'j'} + ]; + + /* define fixation and test trials */ + var fixation = { + type: jsPsychHtmlKeyboardResponse, + stimulus: '
+
', + choices: "NO_KEYS", + trial_duration: function(){ + return jsPsych.randomization.sampleWithoutReplacement([250, 500, 750, 1000, 1250, 1500, 1750, 2000], 1)[0]; + }, + data: { + task: 'fixation' + } + }; + + var test = { + type: jsPsychImageKeyboardResponse, + stimulus: jsPsych.timelineVariable('stimulus'), + choices: ['f', 'j'], + data: { + task: 'response', + correct_response: jsPsych.timelineVariable('correct_response') + }, + on_finish: function(data){ + data.correct = jsPsych.pluginAPI.compareKeys(data.response, data.correct_response); + } + }; + + /* define test procedure */ + var test_procedure = { + timeline: [fixation, test], + timeline_variables: test_stimuli, + repetitions: 5, + randomize_order: true + }; + timeline.push(test_procedure); + + /* define debrief */ + var debrief_block = { + type: jsPsychHtmlKeyboardResponse, + stimulus: function() { + + var trials = jsPsych.data.get().filter({task: 'response'}); + var correct_trials = trials.filter({correct: true}); + var accuracy = Math.round(correct_trials.count() / trials.count() * 100); + var rt = Math.round(correct_trials.select('rt').mean()); + + return `

You responded correctly on ${accuracy}% of the trials.

+

Your average response time was ${rt}ms.

+

Press any key to complete the experiment. Thank you!

`; + + } + }; + timeline.push(debrief_block); + + return timeline; + } + + export const timelineUnits = { + } + + export const utils = {} + ``` +
-To restate, `timelineUnits` are broken down, conceptual pieces of our experiment timeline. Each `timelineUnit` can be typed as an array of `TimelineNodes`. +## Setting up a timelineUnit -Let's look back our final code from the [Reaction Time Task](../learn/tutorials/rt-task.md#the-final-code) earlier. On initial review, we can split that timeline into three main chunks: +Now that we understand `createTimeline` as a consistent end product of our source code, we can start to carve it up into `timelineUnits`. To restate, `timelineUnits` are broken down, conceptual pieces of our experiment timeline—that is, of the script executed in `createTimeline`. Each `timelineUnit` can be typed as an array of `TimelineNodes`. On first glance, we can split the script in `createTimeline` into three main chunks: - An introduction, made up of the `welcome` and `instructions` nodes - The `test_procedure`, alternating between `fixation` and `test` nodes -- The `debrief` presenting a digest of the participant's performance +- The `debrief` consisting of a single node, with a digest of the participant's performance :::warning Needs hands-on review Everything past this point must be implementationally verified by a few people willing to go through each step, noting build-breaking errors along the way. ::: -The most straightforward way to block out our `timelineUnits` is by wrapping each of the above chunks in a function that returns that chunk. To start each, each of these functions should also take as an argument the jsPsych instance running our experiment, in case any of our trials' logic references core methods. +The most straightforward way to block out our `timelineUnits` is by wrapping each chunk in a function that returns that chunk. + +:::tip jsPsych instance as argument +In the event that a `timelineUnit` or `util` references core jsPsych methods, each export should take the running jsPsych instance as an argument by default. Otherwise, those methods will not be appropriately defined. We explore other ways to factor out references to the jPsych instance in later sections of this tutorial (pending). +::: :::warning Include Pre-Load Current unit breakdown below requires pre-load Node. Must decide if this will be a separate unit or factored into one defined below. @@ -172,7 +414,26 @@ function timelineDebrief(jsPsych: JsPsych) { } ``` -Now, let's add each of these as exports under timelineUnits: +We've defined 3 timelineUnits in our source code. Great! Now we need to rewrite `createTimeline` so that it references these units. Instead of pushing each hardcoded trial node to `timeline`, we'll push what's returned from a single call for each timelineUnit. + +```javascript +export function createTimeline(jsPsych:JsPsych) { + var timeline = []; + + timeline.push(timelineIntro(jsPsych)) + timeline.push(timelineTest(jsPsych)) + timeline.push(timelineDebrief(jsPsych)) + + return timeline +} + +``` + +This keeps our code maintainable in two critical ways. First, with `createTimeline` rewritten and consolidated as `timelineUnits`, we can identify and debug errors more easily. There is never a world, for example, where one of our `timelineUnits` break but `createTimeline` works fine, and vice versa. Second, when we start adding parameters in the next section, we only need to write the logic for evaluating those parameters once. Otherwise, we might end up writing redundant or even incommensurate logic, once to evaluate a parameter in `createTimeline` and another to evaluate the same parameter in however many units depend on it. + +If we run another build, we can see that our `createTimeline` call in `examples/index.html` still works fine. + +Now, let's add each of these as exports under `timelineUnits`: ```javascript export const timelineUnits = { @@ -182,7 +443,7 @@ export const timelineUnits = { } ``` -After doing that, we can run `npm run build` in the commandline and call each timelineUnit separately from `examples/index.html`. +After doing so and running another build, we can now each `timelineUnit` as well from `examples/index.html`, separately. :::warning Introduce `examples/index.html` Put something in the overview, under the first header, that explains `examples/index.html` @@ -192,9 +453,9 @@ Put something in the overview, under the first header, that explains `examples/i @@ -210,7 +471,7 @@ With our `timelineUnits` bracketed out and exported, anyone could isolate, rearr import jsPsychHtmlKeyboardResponse from "@jspsych/plugin-html-keyboard-response"; import jsPsychImageKeyboardResponse from "@jspsych/plugin-image-keyboard-response"; - function timelineIntro() { + function timelineIntro(jsPsych: JsPsych) { var intro_block = []; var welcome = { @@ -303,8 +564,14 @@ With our `timelineUnits` bracketed out and exported, anyone could isolate, rearr return debrief_block } - export function createTimeline(jsPsych:JsPsych, options: Partial = {}) { + export function createTimeline(jsPsych:JsPsych) { + var timeline = []; + + timeline.push(timelineIntro(jsPsych)) + timeline.push(timelineTest(jsPsych)) + timeline.push(timelineDebrief(jsPsych)) + return timeline } export const timelineUnits = { @@ -317,13 +584,6 @@ With our `timelineUnits` bracketed out and exported, anyone could isolate, rearr ``` - -## Building .createTimelines() - -:::warning Rewrite transition in previous section -After writing how to use the timelineUnits to write `.creatTimelines()`, be sure to move the exit transition for the previous section here, as well as rewrite that transition. -::: - ## Designing and executing parameters Now that we have our initial experiment sectioned off into `timelineUnits`, we can now think about designing parameters, based on how we might want to modify the task for iterative deployments. @@ -333,7 +593,7 @@ Let's define our parameters as a Javascript object named `options`. Let's begin - `instructions`: A Boolean that allows the timeline to include the instructions trial if `True` - `debrief`: A Boolean that allows the timeline to include the debrief trial if `True` -In essence, we want to be able to run the following from `index.html`, assuming we've rewritten `createTimelines()` to take `options` as an argument. +In essence, we want to be able to run the following from `index.html`, assuming we've rewritten `createTimeline()` to take `options` as an argument. ```javascript const options = { @@ -345,11 +605,46 @@ const options = { const task = jsPsychTimelineReactionTimeDemo.createTimeline(jsPsych, options) ``` -:::warning `options` and `defaultOptions` objects -Start by introducing these objects first in the `.createTimeline()` method. +Let's start by scoping out these options in `createTimeline()`. For anyone who's developed a jsPsych plugin before, this process will look a little analogous to that one, albeit without any of the first-party formats provided with our plugin template. + +We'll start by defining an object at the start of `createTimeline()` that holds all of our default parameters. For example, we can include instructions, debrief, and 5 repetitions by default to match our original experiment. + +```javascript +export function createTimeline(jsPsych:JsPsych) { + + /* create timeline */ + var timeline = []; + + const defaultOptions = { + repetitions: 5, + welcome: true, + instructions: true, + debrief: true + }; +``` + +:::warning rewrite in the context of `createTimeline` +Assuming you move this section to before the timelineUnits section, consider rewriting what follows in the context of that main function. ::: -Now, let's write implementations for these parameters in each of their corresponding `timelineUnits`. We'll start with `instructions` and `debrief`. +Now, let's write implementations for these parameters in each of their corresponding `timelineUnits`. + +We'll start with `repetitions`, since its first implementation will be a simple matter of swapping a hardcoded value for the value stored in the `options` object, in this case by assigning `defaultOptions.repetitions` to the `repetitions` parameter in `test_procedure`. + +```javascript +var test_procedure = { + timeline: [fixation, test], + timeline_variables: test_stimuli, + repetitions: defaultOptions.repetitions, + randomize_order: true +}; +``` + +The other parameters will need a little new logic, since they affect whether whole trials are included on execution. + +:::danger Editing Checkpoint +Where you left off on last editing this section +::: For `instructions`, we can write a basic implementation by wrapping our definition of `var instructions` in an `if`-statement. We'll define `instructions` as a trial object in the case that `optionInstructions` is true. Otherwise, we define `var instructions` as an empty array, since this implementation assumes an `instructions` variable will be pushed to the `intro_block` array either way. We'll also add `optionInstructions` as an argument for the `timelineIntro` function. @@ -424,52 +719,6 @@ function timelineDebrief(jsPsych: JsPsych, optionDebrief) { } ``` -Finally, we can implement `repetitions` in the `timelineTest` unit by simply adding `optionRepetitions` as an argument and reading it to the `repetitions` parameter in `test_procedure`. - -```javascript -function timelineTest(jsPsych: jsPsych, optionRepetitions) { - var test_stimuli = [ - { stimulus: "../blue.png", correct_response: 'f'}, - { stimulus: "../orange.png", correct_response: 'j'} - ]; - - var fixation = { - type: jsPsychHtmlKeyboardResponse, - stimulus: '
+
', - choices: "NO_KEYS", - trial_duration: function(){ - return jsPsych.randomization.sampleWithoutReplacement([250, 500, 750, 1000, 1250, 1500, 1750, 2000], 1)[0]; - }, - data: { - task: 'fixation' - } - }; - - var test = { - type: jsPsychImageKeyboardResponse, - stimulus: jsPsych.timelineVariable('stimulus'), - choices: ['f', 'j'], - data: { - task: 'response', - correct_response: jsPsych.timelineVariable('correct_response'), - }, - on_finish: function(data){ - data.correct = jsPsych.pluginAPI.compareKeys(data.response, data.correct_response); - } - }; - - var test_procedure = { - timeline: [fixation, test], - timeline_variables: test_stimuli, - repetitions: optionRepetitions, - randomize_order: true - }; - - return test_procedure; -} - -``` - All that's left is to adjust `createTimeline()` so that each timelineUnit call takes their new arguments, respectively. ```javascript @@ -496,6 +745,127 @@ jsPsych.run([intro, test, debrief]) :::warning Signpost Other Ways to Factor Out Parameters ::: +
+ The complete code so far + ```javascript + import { JsPsych } from "jspsych"; + import jsPsychPreload from "@jspsych/plugin-preload"; + import jsPsychHtmlKeyboardResponse from "@jspsych/plugin-html-keyboard-response"; + import jsPsychImageKeyboardResponse from "@jspsych/plugin-image-keyboard-response"; + + function timelineIntro() { + var intro_block = []; + + var welcome = { + type: jsPsychHtmlKeyboardResponse, + stimulus: "Welcome to the experiment. Press any key to begin." + }; + + intro_block.push(welcome) + + var instructions = { + type: jsPsychHtmlKeyboardResponse, + stimulus: ` +

In this experiment, a circle will appear in the center + of the screen.

If the circle is blue, + press the letter F on the keyboard as fast as you can.

+

If the circle is orange, press the letter J + as fast as you can.

+
+
+

Press the F key

+
+

Press the J key

+
+

Press any key to begin.

+ `, + post_trial_gap: 2000 + }; + + intro_block.push(instructions) + + return intro_block + } + + function timelineTest(jsPsych: JsPsych) { + var test_stimuli = [ + { stimulus: "../assets/blue.png", correct_response: 'f'}, + { stimulus: "../assets/orange.png", correct_response: 'j'} + ]; + + var fixation = { + type: jsPsychHtmlKeyboardResponse, + stimulus: '
+
', + choices: "NO_KEYS", + trial_duration: function() { + return jsPsych.randomization.sampleWithoutReplacement([250, 500, 750, 1000, 1250, 1500, 1750, 2000], 1)[0]; + }, + data: { + task: 'fixation' + } + }; + + var test = { + type: jsPsychImageKeyboardResponse, + stimulus: jsPsych.timelineVariable('stimulus'), + choices: ['f', 'j'], + data: { + task: 'response', + correct_response: jsPsych.timelineVariable('correct_response') + }, + on_finish: function(data) { + data.correct = jsPsych.pluginAPI.compareKeys(data.response, data.correct_response); + } + }; + + var test_procedure = { + timeline: [fixation, test], + timeline_variables: test_stimuli, + repetitions: 5, + randomize_order: true + }; + + return [test_procedure]; + } + + function timelineDebrief(jsPsych: JsPsych) { + var debrief_block = { + type: jsPsychHtmlKeyboardResponse, + stimulus: function() { + var trials = jsPsych.data.get().filter({task: 'response'}); + var correct_trials = trials.filter({correct: true}); + var accuracy = Math.round(correct_trials.count() / trials.count() * 100); + var rt = Math.round(correct_trials.select('rt').mean()); + + return `

You responded correctly on ${accuracy}% of the trials.

+

Your average response time was ${rt}ms.

+

Press any key to complete the experiment. Thank you!

`; + } + } + + return debrief_block + } + + export function createTimeline(jsPsych:JsPsych) { + var timeline = []; + + timeline.push(timelineIntro(jsPsych)) + timeline.push(timelineTest(jsPsych)) + timeline.push(timelineDebrief(jsPsych)) + + return timeline + } + + export const timelineUnits = { + timelineIntro, + timelineTest, + timelineDebrief + } + + export const utils = {} + ``` +
+ ## Setting up a util With the bigger conceptual portions of the experiment factored out as parameterized `timelineUnits`, we can now think about factoring out `util`, or essential helper functions that support more sophisticated, customizable behaviors. From acb65b5eece3d743db4b12ec2d31456a1508fa37 Mon Sep 17 00:00:00 2001 From: vminojosa Date: Tue, 21 Jul 2026 15:03:33 -0400 Subject: [PATCH 05/13] completely rewrote the parameters section to accomodate some standard for fallback definitions and parameter object typing; the util section has also been meaningfully rewritten, though it needs further edits to accomodate the placement of the options typing explanation --- docs/docs/extend/build-a-timeline.md | 423 +++++++++++++++++++-------- 1 file changed, 295 insertions(+), 128 deletions(-) diff --git a/docs/docs/extend/build-a-timeline.md b/docs/docs/extend/build-a-timeline.md index 24acf41..5862045 100644 --- a/docs/docs/extend/build-a-timeline.md +++ b/docs/docs/extend/build-a-timeline.md @@ -426,10 +426,9 @@ export function createTimeline(jsPsych:JsPsych) { return timeline } - ``` -This keeps our code maintainable in two critical ways. First, with `createTimeline` rewritten and consolidated as `timelineUnits`, we can identify and debug errors more easily. There is never a world, for example, where one of our `timelineUnits` break but `createTimeline` works fine, and vice versa. Second, when we start adding parameters in the next section, we only need to write the logic for evaluating those parameters once. Otherwise, we might end up writing redundant or even incommensurate logic, once to evaluate a parameter in `createTimeline` and another to evaluate the same parameter in however many units depend on it. +This keeps our code maintainable in two critical ways. First, with `createTimeline` rewritten and consolidated as `timelineUnits`, we can identify and debug errors more easily. There is never a world, for example, where one of our `timelineUnits` break but `createTimeline` works fine. Second, when we start adding parameters in the next section, we only need to write the logic for evaluating those parameters once. Otherwise, we might end up writing redundant or even incommensurate logic, once to evaluate a parameter in `createTimeline` and another to evaluate the same parameter in however many units depend on it. If we run another build, we can see that our `createTimeline` call in `examples/index.html` still works fine. @@ -443,13 +442,13 @@ export const timelineUnits = { } ``` -After doing so and running another build, we can now each `timelineUnit` as well from `examples/index.html`, separately. +After running another build, we can now call each `timelineUnit` as well from `examples/index.html`, separately. :::warning Introduce `examples/index.html` Put something in the overview, under the first header, that explains `examples/index.html` ::: -```html +```html title='examples/index.html' +``` + + + +### Typing the parameters object in `createTimeline` + +There's one last thing to do to fully parameterize our package. While we can read parameters as arguments directly to each exported `timelineUnit`, our `createTimeline` export isn't yet written to take those parameters. To recap, `createTimeline` should work like a complete kit of every way `timelineUnits`—and later `utils`—can be configured. + +To help `createTimeline` handle this configurability, we need to add arguments to its functional signature. We could start with a single `options` argument, presume `options` is an object with a property for each parameter, then reference those properties in each `timelineUnit` call. + +```javascript +export function createTimeline(jsPsych:JsPsych, options ) { + + var timeline = []; + + timeline.push(timelineIntro(jsPsych, options.instructions)); + timeline.push(timelineTest(jsPsych, options.repetitions)); + timeline.push(timelineDebrief(jsPsych, options.debrief)); + + return timeline; } ``` -All that's left is to adjust `createTimeline()` so that each timelineUnit call takes their new arguments, respectively. +This would compile fine and is serviceable as a quick and dirty solution. However, what if our user doesn't want to configure every parameter? What if their `options` object contains some parameters, but not others? What about if their `options` argument includes typos, or isn't even an object at all? + +To weigh an alternative, we could define an argument for each timeline parameter. However, that could eventually get out of hand, for users and developers alike. Our code would become less readable as we implement new parameters or wrote in fallbacks. We'd also need to make sure any fallbacks in `createTimeline` matched those in our `timelineUnits`. Users' deployment scripts would also become less manageable since they'd lack the flexibility to implement a range of often counterbalanced configurations. + +This is where we can lean into the additional control Typescript affords us as developers. Let's start by typing `options` as an object with each of our parameters. ```javascript -export function createTimeline(jsPsych: jsPsych, options){ - // fill this in with what the current function would look like at this stage +export function createTimeline(jsPsych:JsPsych, options: { + repetitions: number, instructions: boolean, debrief: boolean +} ) { + + var timeline = []; + + timeline.push(timelineIntro(jsPsych, options.instructions)); + timeline.push(timelineTest(jsPsych, options.repetitions)); + timeline.push(timelineDebrief(jsPsych, options.debrief)); + + return timeline; } ``` -Now, on next build, we'll be able to run these timelineUnits again from the `index.html`, this time altering each unit's behavior through each's new, second argument. +Nice. Once we run a new build, we'll get a syntax error if `options` doesn't match the expected type, every time we call `createTimeline` in our HTML script. Unfortunately, we also can't call `createTimeline` without defining `options` at all. We could solve this be setting a fallback for `options` in the `createTimeline` functional signature, but this could end up redundant or inconsistent with our `timelineUnit` fallbacks. Even then, we wouldn't be able to handle cases where only some of the parameters are defined, but not others. -```javascript title='examples/index.html' -const jsPsych = initJsPsych({ - on_finish: function() { - jsPsych.data.displayData(); -}}); +To address these problems, let's take advantage of two other types available through Typescript: interfaces and Partials. -const intro = jsPsychTimelineReactionTimeDemo.timelineUnits.timelineIntro(jsPsych, false); -const test = jsPsychTimelineReactionTimeDemo.timelineUnits.timelineTest(jsPsych, 5); -const debrief = jsPsychTimelineReactionTimeDemo.timelineUnits.timelineDebrief(jsPsych, true); +An `interface` will let us set the `options` object type outside of the argument definition, then call it back in like so: -jsPsych.run([intro, test, debrief]) +```javascript +interface CreateTimelineOptions { + repetitions: number, + instructions: boolean, + debrief: boolean, +} + +export function createTimeline(jsPsych:JsPsych, options: CreateTimelineOptions ) { ``` +For anyone who's developed a jsPsych plugin before, this will look a little analogous to the [plugin info object](), albeit without any of the boilerplate syntax provided with our plugin template. -:::warning Signpost Other Ways to Factor Out Parameters -::: +In addition to cleaning up our code a little, we have set ourselves up to type `options` as a `Partial` of `interface CreateTimelineOptions`. A `Partial` will include any subset of the type defined in `createTimelineOptions`—even empty ones! At the same time, it will reject any objects with properties not included in `CreateTimelineOptions`. + +```javascript +interface CreateTimelineOptions { + repetitions: number, + instructions: boolean, + debrief: boolean, +} + +export function createTimeline(jsPsych:JsPsych, options: Partial = {} ) { +``` + +Now, on next build, we can run any range of complete or partial configurations through our `createTimeline` call in the example HTML. + +```javascript title="example/index.html" +``` + +We should keep this workflow in mind as we add new parameters. To restate the steps going forward, parameters are (1) introduced as arguments at the component scope, (2) implemented at that same scope, (3) provided a fallback value at scope's function signature, then (4) added to our `interface` type.
The complete code so far @@ -753,7 +849,7 @@ jsPsych.run([intro, test, debrief]) import jsPsychHtmlKeyboardResponse from "@jspsych/plugin-html-keyboard-response"; import jsPsychImageKeyboardResponse from "@jspsych/plugin-image-keyboard-response"; - function timelineIntro() { + function timelineIntro(jsPsych: JsPsych, optionInstructions: boolean = true) { var intro_block = []; var welcome = { @@ -766,28 +862,31 @@ jsPsych.run([intro, test, debrief]) var instructions = { type: jsPsychHtmlKeyboardResponse, stimulus: ` -

In this experiment, a circle will appear in the center - of the screen.

If the circle is blue, - press the letter F on the keyboard as fast as you can.

-

If the circle is orange, press the letter J - as fast as you can.

-
-
-

Press the F key

-
-

Press the J key

-
-

Press any key to begin.

+

In this experiment, a circle will appear in the center + of the screen.

If the circle is blue, + press the letter F on the keyboard as fast as you can.

+

If the circle is orange, press the letter J + as fast as you can.

+
+
+

Press the F key

+
+

Press the J key

+
+

Press any key to begin.

`, post_trial_gap: 2000 }; - - intro_block.push(instructions) + + if(optionInstructions){ + intro_block.push(instructions) + return intro_block + } return intro_block } - function timelineTest(jsPsych: JsPsych) { + function timelineTest(jsPsych: JsPsych, optionRepetitions: number = 5) { var test_stimuli = [ { stimulus: "../assets/blue.png", correct_response: 'f'}, { stimulus: "../assets/orange.png", correct_response: 'j'} @@ -821,17 +920,18 @@ jsPsych.run([intro, test, debrief]) var test_procedure = { timeline: [fixation, test], timeline_variables: test_stimuli, - repetitions: 5, + repetitions: optionRepetitions, randomize_order: true }; return [test_procedure]; } - function timelineDebrief(jsPsych: JsPsych) { + function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true) { var debrief_block = { type: jsPsychHtmlKeyboardResponse, stimulus: function() { + var trials = jsPsych.data.get().filter({task: 'response'}); var correct_trials = trials.filter({correct: true}); var accuracy = Math.round(correct_trials.count() / trials.count() * 100); @@ -840,18 +940,28 @@ jsPsych.run([intro, test, debrief]) return `

You responded correctly on ${accuracy}% of the trials.

Your average response time was ${rt}ms.

Press any key to complete the experiment. Thank you!

`; + } + } + + if(optionDebrief){ + return debrief_block + } else { + return [] } } - return debrief_block + interface CreateTimelineOptions { + repetitions: number, + instructions: boolean, + debrief: boolean, } - export function createTimeline(jsPsych:JsPsych) { + export function createTimeline(jsPsych:JsPsych, options: Partial = {} ) { var timeline = []; - timeline.push(timelineIntro(jsPsych)) - timeline.push(timelineTest(jsPsych)) - timeline.push(timelineDebrief(jsPsych)) + timeline.push(timelineIntro(jsPsych, options.instructions)) + timeline.push(timelineTest(jsPsych, options.repetitions)) + timeline.push(timelineDebrief(jsPsych, options.debrief)) return timeline } @@ -868,7 +978,7 @@ jsPsych.run([intro, test, debrief]) ## Setting up a util -With the bigger conceptual portions of the experiment factored out as parameterized `timelineUnits`, we can now think about factoring out `util`, or essential helper functions that support more sophisticated, customizable behaviors. +With the bigger conceptual portions of the experiment factored out as parameterized `timelineUnits`, we can now think about factoring out `utils`, or essential helper functions that support more sophisticated, customizable behaviors. To start thinking about `utils`, we're going to again default to the simplest case scenario, take what already exists in our code, and wrap it off into a separate function for export. A good place to start would be any of the functions returning a value to our trial objects. For example, let's look at the randomized fixation timing between stimuli. @@ -886,7 +996,9 @@ To start thinking about `utils`, we're going to again default to the simplest ca }; ``` -The logic in `trial_duration` samples 1 out of an array of integers, then sets that to the number of milliseconds passed before the fixation trial ends. As a `util`, however, we can factor this out into a separate function. For now, we'll have to give the function `jsPsych` as an argument, since the sampling logic is borrowed from a jsPsych module. +The logic in `trial_duration` samples 1 out of an array of integers, then sets that to the number of milliseconds passed before the fixation trial ends. + +As a `util`, we can factor this out into a separate function. We'll have to give the jsPsych instance as an initial argument, since the sampling logic is borrowed from our core modules. ```javascript function fixationDuration(jsPsych: JsPsych) { @@ -894,7 +1006,7 @@ function fixationDuration(jsPsych: JsPsych) { } ``` -Then, let's call it in the original trial definition. Remember to return it as the output to an arrow function. Otherwise, our new `fixationDuration` util will only be evaluated once when our trial object is created, as opposed to everytime the trial object is instantiated in our timeline. +Now, let's call it in the definition for `fixation` in `timelineTest`. Remember to return `fixationDuration` as the output to an arrow function. Otherwise, our new `fixationDuration` util will only be evaluated once when our trial object is created, as opposed to everytime the trial object is instantiated in our timeline. ```javascript var fixation = { @@ -908,9 +1020,19 @@ var fixation = { }; ``` -Why would we want to do this? Doesn't this just get us the same result with extra steps? Well, aside from keeping our logic separated and a little more legible, this gives us an opportunity to scope out more control over this functional behavior of the experiment. As developers, we can have more control over the logic that determines time spent on a fixation point, by working within `fixationDuration` in isolation. We can even add new arguments that affect the util's behavior. As behavioral researchers, we could implement new parameters om our HTML, or call `fixation, in order to incorporate a new variable into our experiments. +Last, we should remember to add `fixationDuration` to our exports, under `utils` + +```javascript +export const utils = { + fixationDuration +} +``` + +Why would we want to do this at all? Doesn't this just get us the same result with extra steps? + +Well, aside from keeping our logic separated and legible, this gives us an opportunity to scope out more control over experimental behavior. As developers, we can add more logic to affect time spent on a fixation point by working purely within `fixationDuration`. -For instance, let's turn `fixationDuration` into a switch that, to start off, takes a second argument to distinguish between two cases: `"random"` and `"fixed"`. `"random"` cases can return the original randomization logic, while `"fixed"` returns a reliable 1000 milliseconds. We can also set the randomization logic as the default. +For instance, let's turn `fixationDuration` into a switch that for starters takes a second argument to distinguish between two cases: `"random"` and `"fixed"`. `"random"` cases can return the original randomization logic, while `"fixed"` returns a reliable 1000 milliseconds. We can also set the randomization logic as the default. ```javascript function fixationDuration(jsPsych: JsPsych, mode: "random" | "fixed" = "random") { @@ -925,19 +1047,22 @@ function fixationDuration(jsPsych: JsPsych, mode: "random" | "fixed" = "random") } ``` -Once we do this, we need to make sure the `fixationDuration` call in the `fixation` trial definition includes our new argument. Let's also make sure the timelineUnit `timelineTest` takes this new argument, and that the new argument is provided when `timelineTest` is called in `createTimeline`, since this value is inherited all throughout our `src` file. +Once we do this, we need to make sure the `fixationDuration` call in the `fixation` trial definition includes our new argument. ```javascript var fixation = { type: jsPsychHtmlKeyboardResponse, stimulus: '
+
', choices: "NO_KEYS", - trial_duration: () => fixationDuration(jsPsych, 'fixed') + trial_duration: () => fixationDuration(jsPsych, optionFixationDuration) data: { task: 'fixation' } }; ``` + +Let's also make sure the timelineUnit `timelineTest` takes this new argument, and that the new argument is provided when `timelineTest` is called in `createTimeline`, since this value is inherited all throughout our `src` file. + ```javascript function timelineTest(jsPsych: jsPsych, optionRepetitions, optionFixationDuration) { ``` @@ -948,6 +1073,48 @@ timeline.push(timelineTest(jsPsych, options.repetitions, options.fixationDuratio Once the `options` object and interface are described above, please incorporate that into this "inheritance chain". ::: +Meanwhile, as behavioral researchers, we can reconfigure `fixationDuration` from our HTML, or even call `fixationDuration` separately, in order to incorporate a new variable into our experiments. + +```javascript title="examples/index.html" +const jsPsych = initJsPsych({ + on_finish: function() { + jsPsych.data.displayData(); + } +}); + +const x_fixation = { + type: jsPsychHtmlKeyboardResponse, + stimulus: '
X
', + choices: "NO_KEYS", + trial_duration: () => jsPsychTimelineReactionTimeDemo.utils.fixationDuration(jsPsych, "random"), + data: { + task: 'fixation' + } +} + +var test = { + type: jsPsychImageKeyboardResponse, + stimulus: jsPsych.timelineVariable('stimulus'), + choices: ['f', 'j'], + data: { + task: 'response', + correct_response: jsPsych.timelineVariable('correct_response') + }, + on_finish: function(data) { + data.correct = jsPsych.pluginAPI.compareKeys(data.response, data.correct_response); + } +}; + +var test_procedure = { + timeline: [x_fixation, test], + timeline_variables: test_stimuli, + repetitions: 5, + randomize_order: true +} + +jsPsych.run([test_procedure]) +``` + Now, with our next build, we can go into `index.html` and use this new argument to adjust our `fixation` trial behavior on the fly, between `timelineTest` calls. ```javascript title='examples/index.html' From d9b92ab9d7eddcd422aa238f44cb823b1e23bbae Mon Sep 17 00:00:00 2001 From: vminojosa Date: Wed, 22 Jul 2026 13:12:28 -0400 Subject: [PATCH 06/13] updated the utils section to match the structure of the example code until then; still some self-referential admonitions earlier in the tutorial signposting details worth possibly presenting to the reader; the testing and documentation sections still need writing, though the pull request section will be relegated to a separate page scoped out by Josh --- docs/docs/extend/build-a-timeline.md | 271 +++++++++++++++++++-------- 1 file changed, 198 insertions(+), 73 deletions(-) diff --git a/docs/docs/extend/build-a-timeline.md b/docs/docs/extend/build-a-timeline.md index 5862045..db23434 100644 --- a/docs/docs/extend/build-a-timeline.md +++ b/docs/docs/extend/build-a-timeline.md @@ -1032,7 +1032,7 @@ Why would we want to do this at all? Doesn't this just get us the same result wi Well, aside from keeping our logic separated and legible, this gives us an opportunity to scope out more control over experimental behavior. As developers, we can add more logic to affect time spent on a fixation point by working purely within `fixationDuration`. -For instance, let's turn `fixationDuration` into a switch that for starters takes a second argument to distinguish between two cases: `"random"` and `"fixed"`. `"random"` cases can return the original randomization logic, while `"fixed"` returns a reliable 1000 milliseconds. We can also set the randomization logic as the default. +For instance, let's turn `fixationDuration` into a switch that for starters takes a second argument distinguishing between two cases: `"random"` and `"fixed"`. `"random"` returns the original randomization logic, while `"fixed"` returns a reliable 1000 milliseconds. We can also set the randomization logic as the default switch case, and `"random"` as the fallback in the function signature. ```javascript function fixationDuration(jsPsych: JsPsych, mode: "random" | "fixed" = "random") { @@ -1061,110 +1061,235 @@ var fixation = { }; ``` -Let's also make sure the timelineUnit `timelineTest` takes this new argument, and that the new argument is provided when `timelineTest` is called in `createTimeline`, since this value is inherited all throughout our `src` file. +Of course, we also need to make sure, despite our fallback, a user defined parameter is able to be inherited throughout our source code and reach the `fixationDuration` call in the `fixation` definition above. We'll add a corresponding argument to our `timelineTest` function signature and the `timelineTest` call in `createTimeline` -```javascript -function timelineTest(jsPsych: jsPsych, optionRepetitions, optionFixationDuration) { +```javascript title='timelineTest function signature' +function timelineTest(jsPsych: jsPsych, optionRepetitions: number = 5, optionFixationDuration: "random" | "fixed" = "random") { ``` -```javascript +```javascript title='timelineTest call in createTimeline' timeline.push(timelineTest(jsPsych, options.repetitions, options.fixationDuration)); ``` -:::warning `options` Object and Interface -Once the `options` object and interface are described above, please incorporate that into this "inheritance chain". -::: -Meanwhile, as behavioral researchers, we can reconfigure `fixationDuration` from our HTML, or even call `fixationDuration` separately, in order to incorporate a new variable into our experiments. - -```javascript title="examples/index.html" -const jsPsych = initJsPsych({ - on_finish: function() { - jsPsych.data.displayData(); - } -}); +As indicated at the end of the previous section, lets also make sure the `fixationDuration` parameter included in our `createTimelineOptions` type interface, with possible values limited to the two cases. -const x_fixation = { - type: jsPsychHtmlKeyboardResponse, - stimulus: '
X
', - choices: "NO_KEYS", - trial_duration: () => jsPsychTimelineReactionTimeDemo.utils.fixationDuration(jsPsych, "random"), - data: { - task: 'fixation' - } +```javascript +interface CreateTimelineOptions { + repetitions: number, + instructions: boolean, + debrief: boolean, + fixationDuration: "random" | "fixed" } +``` -var test = { - type: jsPsychImageKeyboardResponse, - stimulus: jsPsych.timelineVariable('stimulus'), - choices: ['f', 'j'], - data: { - task: 'response', - correct_response: jsPsych.timelineVariable('correct_response') - }, - on_finish: function(data) { - data.correct = jsPsych.pluginAPI.compareKeys(data.response, data.correct_response); - } -}; +And as always, we should add the new `fixationDuration` util to our exports. -var test_procedure = { - timeline: [x_fixation, test], - timeline_variables: test_stimuli, - repetitions: 5, - randomize_order: true +```javascript +export const utils = { + fixationDuration } - -jsPsych.run([test_procedure]) -``` +``` Now, with our next build, we can go into `index.html` and use this new argument to adjust our `fixation` trial behavior on the fly, between `timelineTest` calls. -```javascript title='examples/index.html' -const jsPsych = initJsPsych({ - on_finish: function() { - jsPsych.data.displayData(); -}}); +```html title='examples/index.html' + ``` We can also call `fixationDuration` separately. For example, maybe we, for whatever reason, define a trial object directly from the HTML that assigns the same functional output to `trial_duration`. We can call `fixationDuration` directly from the `utils` exports for this purpose. -```javascript title='examples/index.html' -const jsPsych = initJsPsych({ - on_finish: function() { - jsPsych.data.displayData(); -}}); +```html title='examples/index.html' + ``` :::warning Script Tag -Remember, for the above to work from the HTML, you need to add a script tag to the top of the HTML file that imports `jsPsychHtmlKeyboardReponse` via CDN. You're calling the plugin directly from the HTML now, so you need to have the type available in the HTML file. +Remember, for the above to work from the HTML, you need to add a script tag to the HTML head that imports `jsPsychHtmlKeyboardReponse` via CDN. ::: Keep in mind that the point of these demonstrated `utils` is to convey the layer of abstraction permitted with a timeline package. `fixationDuration` may not be especially useful right now, at least not in the way we've implemented it so far, but it carves out new room for implementational flexibility. Researchers working primarily from HTML files, without digging into our source, might find new unanticipated uses for any of our `util` exports. Developers, meanwhile, can always modify the util itself and give it new functionality, or refactor it with respect to our `timelineUnits`. By designing jsPsych experiments with a layer of exposed, modular access in the form `timelineUnits` and `utils`, we introduce a new point of feedback in the jsPsych research ecosystem—one that ultimately helps us build a better tool for everyone. +
+ The complete code so far + ```javascript + import { JsPsych } from "jspsych"; + import jsPsychPreload from "@jspsych/plugin-preload"; + import jsPsychHtmlKeyboardResponse from "@jspsych/plugin-html-keyboard-response"; + import jsPsychImageKeyboardResponse from "@jspsych/plugin-image-keyboard-response"; + + function fixationDuration(jsPsych: JsPsych, mode: "random" | "fixed" = "random") { + switch (mode) { + case "fixed": + return 1000; + case "random": + return jsPsych.randomization.sampleWithoutReplacement([250, 500, 750, 1000, 1250, 1500, 1750, 2000], 1)[0]; + default: + return jsPsych.randomization.sampleWithoutReplacement([250, 500, 750, 1000, 1250, 1500, 1750, 2000], 1)[0]; + } + } + + function timelineIntro(jsPsych: JsPsych, optionInstructions: boolean = true, fixationDuration: boolean = "random") { + var intro_block = []; + + var welcome = { + type: jsPsychHtmlKeyboardResponse, + stimulus: "Welcome to the experiment. Press any key to begin." + }; + + intro_block.push(welcome) + + var instructions = { + type: jsPsychHtmlKeyboardResponse, + stimulus: ` +

In this experiment, a circle will appear in the center + of the screen.

If the circle is blue, + press the letter F on the keyboard as fast as you can.

+

If the circle is orange, press the letter J + as fast as you can.

+
+
+

Press the F key

+
+

Press the J key

+
+

Press any key to begin.

+ `, + post_trial_gap: 2000 + }; + + if(optionInstructions){ + intro_block.push(instructions) + return intro_block + } + + return intro_block + } + + function timelineTest(jsPsych: JsPsych, optionRepetitions: number = 5, optionFixationDuration: "random" | "fixed" = "random") { + var test_stimuli = [ + { stimulus: "../assets/blue.png", correct_response: 'f'}, + { stimulus: "../assets/orange.png", correct_response: 'j'} + ]; + + var fixation = { + type: jsPsychHtmlKeyboardResponse, + stimulus: '
+
', + choices: "NO_KEYS", + trial_duration: () => fixationDuration(optionFixationDuration), + data: { + task: 'fixation' + } + }; + + var test = { + type: jsPsychImageKeyboardResponse, + stimulus: jsPsych.timelineVariable('stimulus'), + choices: ['f', 'j'], + data: { + task: 'response', + correct_response: jsPsych.timelineVariable('correct_response') + }, + on_finish: function(data) { + data.correct = jsPsych.pluginAPI.compareKeys(data.response, data.correct_response); + } + }; + + var test_procedure = { + timeline: [fixation, test], + timeline_variables: test_stimuli, + repetitions: optionRepetitions, + randomize_order: true + }; + + return [test_procedure]; + } + + function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true) { + var debrief_block = { + type: jsPsychHtmlKeyboardResponse, + stimulus: function() { + + var trials = jsPsych.data.get().filter({task: 'response'}); + var correct_trials = trials.filter({correct: true}); + var accuracy = Math.round(correct_trials.count() / trials.count() * 100); + var rt = Math.round(correct_trials.select('rt').mean()); + + return `

You responded correctly on ${accuracy}% of the trials.

+

Your average response time was ${rt}ms.

+

Press any key to complete the experiment. Thank you!

`; + } + } + + if(optionDebrief){ + return debrief_block + } else { + return [] + } + } + + interface CreateTimelineOptions { + repetitions: number, + instructions: boolean, + debrief: boolean, + fixationDuration: "random" | "fixed" + } + + export function createTimeline(jsPsych:JsPsych, options: Partial = {} ) { + var timeline = []; + + timeline.push(timelineIntro(jsPsych, options.instructions, options.fixationDuration)) + timeline.push(timelineTest(jsPsych, options.repetitions)) + timeline.push(timelineDebrief(jsPsych, options.debrief)) + + return timeline + } + + export const timelineUnits = { + timelineIntro, + timelineTest, + timelineDebrief + } + + export const utils = { + fixationDuration + } + ``` +
+ ## Testing exports ... From 92cad9543f056812f26cfcffead6baf0486aa795 Mon Sep 17 00:00:00 2001 From: vminojosa Date: Wed, 29 Jul 2026 12:42:03 -0400 Subject: [PATCH 07/13] attempted to write out some actual definitions for timelineunits, createtimeline, and utils at the top, while including possible units and utils that arent functions; please let me know if anything I said isnt accurate; also some minor formatting adjustments --- docs/docs/extend/build-a-timeline.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/docs/extend/build-a-timeline.md b/docs/docs/extend/build-a-timeline.md index db23434..5de5caa 100644 --- a/docs/docs/extend/build-a-timeline.md +++ b/docs/docs/extend/build-a-timeline.md @@ -30,8 +30,8 @@ This guide still needs to be written. The structure below outlines what it shoul This tutorial will walk through translating the same basic reaction time task from the demo experiment into a package for jspsych-timelines. This simple demonstration will highlight key open-science principles behind what makes a distributable experimental task, including: - Setting up the developer environment with npm +- Setting up the 'createTimeline' export - Blocking out timelineUnits and utils as exportable components -- Building the .createTimeline() export - Designing parameters for configuring versions of the same task - Testing builds - Preparing documentation @@ -41,12 +41,12 @@ This tutorial will walk through translating the same basic reaction time task fr ## Overview exports from index.ts -`index.js` exports three principle kinds of components, all of which are functions. -- `.createTimeline()`: -- `timelineUnits`: -- `util`: +`index.js` exports three principle kinds of components. +- `createTimeline`: A function that takes each parameter, incorporates every export, and outputs a jsPsych timeline object. +- `timelineUnits`: An object that includes each part of the larger timeline, broken down into conceptual chunks, typically but not always written as functions. +- `util`: An object containing smaller logical components that support `createTimeline` or `timelineUnits`, like helper functions or type definitions. -## Setting Up `createTimeline()` +## Setting Up `createTimeline` Since this is the primary export for our package, we can think of this as the "hub" where all of our exports come together to generate a complete, fully configured task. This single export will process any parameters exposed to users and referenced throughout our source code. `createTimeline` will also depend on any timelineUnits and utils we eventually factor out over the course of this tutorial. It's the glue holding our package together, so we'll start here. From 44302da3580513646a1a388e0cb6eaa88376934f Mon Sep 17 00:00:00 2001 From: vminojosa Date: Wed, 29 Jul 2026 12:48:07 -0400 Subject: [PATCH 08/13] think I changed every return timeline array instance on the page to a return timeline object --- docs/docs/extend/build-a-timeline.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/docs/extend/build-a-timeline.md b/docs/docs/extend/build-a-timeline.md index 5de5caa..ab5a03c 100644 --- a/docs/docs/extend/build-a-timeline.md +++ b/docs/docs/extend/build-a-timeline.md @@ -150,11 +150,11 @@ export function createTimeline(jsPsych:JsPsych) { }; timeline.push(debrief_block); - return timeline + return { timeline: timeline } } ``` -Notice how this code includes everything except `initjsPsych` and `jsPsych.run`. This is because `createTimeline` only outputs the complete timeline array and nothing else. It is not responsible for declaring the jsPsych instance that actually runs the experiment; that still happens in the HTML that imports and calls `createTimeline`! +Notice how this code includes everything except `initjsPsych` and `jsPsych.run`. This is because `createTimeline` only outputs the complete `timeline` object and nothing else. It is not responsible for declaring the jsPsych instance that actually runs the experiment; that still happens in the HTML that imports and calls `createTimeline`! At the same time, `createTimeline` requires a jsPsych instance as an argument, since it still references core jsPsych methods to define trial parameters. @@ -276,7 +276,7 @@ jsPsych.run(timeline) }; timeline.push(debrief_block); - return timeline; + return { timeline: timeline }; } export const timelineUnits = { @@ -424,7 +424,7 @@ export function createTimeline(jsPsych:JsPsych) { timeline.push(timelineTest(jsPsych)) timeline.push(timelineDebrief(jsPsych)) - return timeline + return { timeline: timeline } } ``` @@ -570,7 +570,7 @@ With our `timelineUnits` bracketed out and exported, anyone could isolate, rearr timeline.push(timelineTest(jsPsych)) timeline.push(timelineDebrief(jsPsych)) - return timeline + return { timeline: timeline } } export const timelineUnits = { @@ -780,7 +780,7 @@ export function createTimeline(jsPsych:JsPsych, options ) { timeline.push(timelineTest(jsPsych, options.repetitions)); timeline.push(timelineDebrief(jsPsych, options.debrief)); - return timeline; + return { timeline: timeline }; } ``` @@ -801,7 +801,7 @@ export function createTimeline(jsPsych:JsPsych, options: { timeline.push(timelineTest(jsPsych, options.repetitions)); timeline.push(timelineDebrief(jsPsych, options.debrief)); - return timeline; + return { timeline: timeline }; } ``` @@ -963,7 +963,7 @@ We should keep this workflow in mind as we add new parameters. To restate the st timeline.push(timelineTest(jsPsych, options.repetitions)) timeline.push(timelineDebrief(jsPsych, options.debrief)) - return timeline + return { timeline: timeline } } export const timelineUnits = { @@ -1275,7 +1275,7 @@ Researchers working primarily from HTML files, without digging into our source, timeline.push(timelineTest(jsPsych, options.repetitions)) timeline.push(timelineDebrief(jsPsych, options.debrief)) - return timeline + return { timeline: timeline } } export const timelineUnits = { From 292aa2e4ab56e948489110e11525a4f7e363ee54 Mon Sep 17 00:00:00 2001 From: vminojosa Date: Wed, 29 Jul 2026 12:54:47 -0400 Subject: [PATCH 09/13] put "draft note" in the title of each admonition not intended for the final published page --- docs/docs/extend/build-a-timeline.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/docs/extend/build-a-timeline.md b/docs/docs/extend/build-a-timeline.md index ab5a03c..be7db59 100644 --- a/docs/docs/extend/build-a-timeline.md +++ b/docs/docs/extend/build-a-timeline.md @@ -288,12 +288,16 @@ jsPsych.run(timeline) ## Setting up a timelineUnit +:::warning Draft Note: Move this up top and rewrite +"With our `timelineUnits` bracketed out and exported, anyone could isolate, rearrange, or reconfigure any one of the pieces of our original experiment. The next section will expand on that last point and go into parametrizing units for configurability." +::: + Now that we understand `createTimeline` as a consistent end product of our source code, we can start to carve it up into `timelineUnits`. To restate, `timelineUnits` are broken down, conceptual pieces of our experiment timeline—that is, of the script executed in `createTimeline`. Each `timelineUnit` can be typed as an array of `TimelineNodes`. On first glance, we can split the script in `createTimeline` into three main chunks: - An introduction, made up of the `welcome` and `instructions` nodes - The `test_procedure`, alternating between `fixation` and `test` nodes - The `debrief` consisting of a single node, with a digest of the participant's performance -:::warning Needs hands-on review +:::warning Draft Note: Needs hands-on review Everything past this point must be implementationally verified by a few people willing to go through each step, noting build-breaking errors along the way. ::: @@ -303,7 +307,7 @@ The most straightforward way to block out our `timelineUnits` is by wrapping eac In the event that a `timelineUnit` or `util` references core jsPsych methods, each export should take the running jsPsych instance as an argument by default. Otherwise, those methods will not be appropriately defined. We explore other ways to factor out references to the jPsych instance in later sections of this tutorial (pending). ::: -:::warning Include Pre-Load +:::warning Draft Note: Include Pre-Load Current unit breakdown below requires pre-load Node. Must decide if this will be a separate unit or factored into one defined below. ::: @@ -444,7 +448,7 @@ export const timelineUnits = { After running another build, we can now call each `timelineUnit` as well from `examples/index.html`, separately. -:::warning Introduce `examples/index.html` +:::warning Draft Note: Introduce `examples/index.html` Put something in the overview, under the first header, that explains `examples/index.html` ::: From 7370c5df260816690e2357352ec37672588942ad Mon Sep 17 00:00:00 2001 From: vminojosa Date: Mon, 3 Aug 2026 14:24:02 -0400 Subject: [PATCH 10/13] just committing the newly rewritten portions of the util section, as a getPerformance function; keeping the old content at the bottom (and signposted with an admonition) in case there are concepts we want to rewrite within the context of the new example --- docs/docs/extend/build-a-timeline.md | 226 ++++++++++++++++++++------- 1 file changed, 172 insertions(+), 54 deletions(-) diff --git a/docs/docs/extend/build-a-timeline.md b/docs/docs/extend/build-a-timeline.md index be7db59..99b99b3 100644 --- a/docs/docs/extend/build-a-timeline.md +++ b/docs/docs/extend/build-a-timeline.md @@ -301,6 +301,10 @@ Now that we understand `createTimeline` as a consistent end product of our sourc Everything past this point must be implementationally verified by a few people willing to go through each step, noting build-breaking errors along the way. ::: +:::warning Draft Note: Nomenclatures +Make sure that all nomenclatures are consistent and descriptive throughout +::: + The most straightforward way to block out our `timelineUnits` is by wrapping each chunk in a function that returns that chunk. :::tip jsPsych instance as argument @@ -944,15 +948,15 @@ We should keep this workflow in mind as we add new parameters. To restate the st return `

You responded correctly on ${accuracy}% of the trials.

Your average response time was ${rt}ms.

Press any key to complete the experiment. Thank you!

`; - } } + } - if(optionDebrief){ - return debrief_block - } else { - return [] - } + if(optionDebrief){ + return debrief_block + } else { + return [] } + } interface CreateTimelineOptions { repetitions: number, @@ -984,86 +988,200 @@ We should keep this workflow in mind as we add new parameters. To restate the st With the bigger conceptual portions of the experiment factored out as parameterized `timelineUnits`, we can now think about factoring out `utils`, or essential helper functions that support more sophisticated, customizable behaviors. -To start thinking about `utils`, we're going to again default to the simplest case scenario, take what already exists in our code, and wrap it off into a separate function for export. A good place to start would be any of the functions returning a value to our trial objects. For example, let's look at the randomized fixation timing between stimuli. +To start thinking about `utils`, we're going to again default to the simplest case scenario, take what already exists in our code, and wrap it off into a separate function for export. A good place to start would be any of the functions returning a value to our trial objects. For example, let's look at the debrief trial at the end of the experiment. ```javascript - var fixation = { - type: jsPsychHtmlKeyboardResponse, - stimulus: '
+
', - choices: "NO_KEYS", - trial_duration: function(){ - return jsPsych.randomization.sampleWithoutReplacement([250, 500, 750, 1000, 1250, 1500, 1750, 2000], 1)[0]; - }, - data: { - task: 'fixation' - } - }; -``` +function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true) { + var debrief_block = { + type: jsPsychHtmlKeyboardResponse, + stimulus: function() { -The logic in `trial_duration` samples 1 out of an array of integers, then sets that to the number of milliseconds passed before the fixation trial ends. + var trials = jsPsych.data.get().filter({task: 'response'}); + var correct_trials = trials.filter({correct: true}); + var accuracy = Math.round(correct_trials.count() / trials.count() * 100); + var rt = Math.round(correct_trials.select('rt').mean()); -As a `util`, we can factor this out into a separate function. We'll have to give the jsPsych instance as an initial argument, since the sampling logic is borrowed from our core modules. + return `

You responded correctly on ${accuracy}% of the trials.

+

Your average response time was ${rt}ms.

+

Press any key to complete the experiment. Thank you!

`; + } + } + + if(optionDebrief){ + return debrief_block + } else { + return [] + } +} +``` + +Let's consider the functional logic in the `debrief_block.stimulus` definition and factor it out into its own `util` function, `getPerformance`. We'll have to give the jsPsych instance as an initial argument, since our logic calls on the `data` module. ```javascript -function fixationDuration(jsPsych: JsPsych) { - return jsPsych.randomization.sampleWithoutReplacement([250, 500, 750, 1000, 1250, 1500, 1750, 2000], 1)[0]; +function getPerformance(jsPsych: JsPsych) { + var trials = jsPsych.data.get().filter({task: 'response'}); + var correct_trials = trials.filter({correct: true}); + var accuracy = Math.round(correct_trials.count() / trials.count() * 100); + var rt = Math.round(correct_trials.select('rt').mean()); + + return {accuracy: accuracy, rt: rt}; } ``` -Now, let's call it in the definition for `fixation` in `timelineTest`. Remember to return `fixationDuration` as the output to an arrow function. Otherwise, our new `fixationDuration` util will only be evaluated once when our trial object is created, as opposed to everytime the trial object is instantiated in our timeline. +Then, let's call our new function in the original `stimulus` definition. ```javascript -var fixation = { - type: jsPsychHtmlKeyboardResponse, - stimulus: '
+
', - choices: "NO_KEYS", - trial_duration: () => fixationDuration(jsPsych) - data: { - task: 'fixation' +function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true) { + var debrief_block = { + type: jsPsychHtmlKeyboardResponse, + stimulus: function() { + var performance_data = getPerformance(jsPsych) + + return `

You responded correctly on ${performance_data.accuracy}% of the trials.

+

Your average response time was ${performance_data.rt}ms.

+

Press any key to complete the experiment. Thank you!

`; + } + } + + if(optionDebrief){ + return debrief_block + } else { + return [] } -}; +} ``` -Last, we should remember to add `fixationDuration` to our exports, under `utils` +On next build, both `createTimeline` and `timelineDebrief` should keep the same functionality when called from `examples/index.html`. Of course, we should also remember to add `getPerformance` to our exports, under `utils`, in case we want to users to reference `getPerformance` from their HTML as well. ```javascript export const utils = { - fixationDuration + getPerformance } ``` -Why would we want to do this at all? Doesn't this just get us the same result with extra steps? +By factoring out `getPerformance`, we open up some flexibility for users and developers alike. To start, we can now reference this `util` internally throughout our source code. For example, we might want to call `getPerformance` between timeline trials and write its outputs to the data object. -Well, aside from keeping our logic separated and legible, this gives us an opportunity to scope out more control over experimental behavior. As developers, we can add more logic to affect time spent on a fixation point by working purely within `fixationDuration`. +```javascript title="As defined in timelineTest" + var test = { + type: jsPsychImageKeyboardResponse, + stimulus: jsPsych.timelineVariable('stimulus'), + choices: ['f', 'j'], + data: { + task: 'response', + correct_response: jsPsych.timelineVariable('correct_response'); + }, + on_finish: function(data){ + data.correct = jsPsych.pluginAPI.compareKeys(data.response, data.correct_response); + + const performance_data = getPerformance(jsPsych); + data.total_accuracy = performance_data.accuracy; + data.total_rt = performance_data.rt; + } + }; +``` + +Users could also call `getPerformance` from their HTML like any other export, so long as it's called within the context of another jsPsych timeline. + +```javascript title="my cool example in examples/index.html" + +``` -For instance, let's turn `fixationDuration` into a switch that for starters takes a second argument distinguishing between two cases: `"random"` and `"fixed"`. `"random"` returns the original randomization logic, while `"fixed"` returns a reliable 1000 milliseconds. We can also set the randomization logic as the default switch case, and `"random"` as the fallback in the function signature. +Factoring out `getPerformance` as a `util` also helps us keep our code legible while we write more robust outputs. + +For instance, let's disaggregate our `accuracy` and `rt` metrics further based on stimulus color. . ```javascript -function fixationDuration(jsPsych: JsPsych, mode: "random" | "fixed" = "random") { - switch (mode) { - case "fixed": - return 1000; - case "random": - return jsPsych.randomization.sampleWithoutReplacement([250, 500, 750, 1000, 1250, 1500, 1750, 2000], 1)[0]; - default: - return jsPsych.randomization.sampleWithoutReplacement([250, 500, 750, 1000, 1250, 1500, 1750, 2000], 1)[0]; +function getPerformance(jsPsych: JsPsych) { + var trials = jsPsych.data.get().filter({task: 'response'}); + var correct_trials = trials.filter({correct: true}); + + var blue_trials = trials.filter({correct_response: 'f'}); + var correct_blue_trials = blue_trials.filter({correct: true}); + + var orange_trials = trials.filter({correct_response: 'j'}); + var correct_orange_trials = orange_trials.filter({correct: true}); + + var accuracy = Math.round(correct_trials.count() / trials.count() * 100); + var blue_accuracy = Math.round(correct_blue_trials.count() / blue_trials.count() * 100); + var orange_accuracy = Math.round(correct_orange_trials.count() / orange_trials.count() * 100); + + var rt = Math.round(correct_trials.select('rt').mean()); + var blue_rt = Math.round(correct_blue_trials.select('rt').mean()); + var orange_rt = Math.round(correct_orange_trials.select('rt').mean()); + + return { + accuracy: accuracy, + rt: rt, + blue_accuracy: blue_accuracy, + blue_rt: blue_rt, + orange_accuracy: orange_accuracy, + orange_rt: orange_rt, + }; +} +``` + +We could then reference these outputs again in the original `timelineDebrief` context, with more HTML strings written to interpolate those values. + +```javascript +function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true) { + var debrief_block = { + type: jsPsychHtmlKeyboardResponse, + stimulus: () => { + var performance_data = getPerformance(jsPsych) + + return `

You responded correctly on ${performance_data.accuracy}% of the trials.

+

Your average response time was ${performance_data.rt}ms.

+

You responded correctly on ${performance_data.blue_accuracy}% of the blue trials.

+

Your average response time for blue trials was ${performance_data.blue_rt}ms.

+

You responded correctly on ${performance_data.orange_accuracy}% of the orange trials.

+

Your average response time for orange trials was ${performance_data.orange_rt}ms.

+

Press any key to complete the experiment. Thank you!

`; + } + } + + if(optionDebrief){ + return debrief_block + } else { + return [] } } ``` -Once we do this, we need to make sure the `fixationDuration` call in the `fixation` trial definition includes our new argument. +Of course, now we're running into a different problem. The return value is handling a lot of hardcoded string interpolation. Here, we might then write out yet another util, `getPerformanceHTML`, to handle that. + +Let's set this util up so that an object contains an interpolating string for each metric, then appends each to the complete HTML string so long as it's present in the performance metrics object, `performance_data`. Presumably, `performance_data` will be whatever output comes from `getPerformance`. ```javascript -var fixation = { - type: jsPsychHtmlKeyboardResponse, - stimulus: '
+
', - choices: "NO_KEYS", - trial_duration: () => fixationDuration(jsPsych, optionFixationDuration) - data: { - task: 'fixation' +function getPerformanceHTML(performance_data){ + const performanceHTML = { + accuracy: `

You responded correctly on ${performance_data.accuracy}% of the trials.

`, + rt: `

Your average response time was ${performance_data.rt}ms.

`, + blue_accuracy: `

You responded correctly on ${performance_data.blue_accuracy}% of the blue trials.

`, + blue_rt: `

Your average response time for blue trials was ${performance_data.blue_rt}ms.

`, + orange_accuracy: `

You responded correctly on ${performance_data.orange_accuracy}% of the orange trials.

`, + orange_rt: `

Your average response time for orange trials was ${performance_data.orange_rt}ms.

` + } + + var html = "" + + for (const [key] of Object.entries(performance_data)) { + html += performanceHTML[key] || ""; } -}; + + const endHTML = `

Press any key to complete the experiment. Thank you!

` + html += endHTML + + return html +} ``` +:::tip Typing `performance_data` +We could, of course, take advantage of Typescript to make sure that `performance_data` is always structured like a `getPerformance` output. This would involve similar syntax to how we defined `options` first as the `CreateTimelineOptions` interface, then typed the `options` argument in `createTimeline` as `CreateTimelineOptions`. For the sake of simplicity, we won't go over this in the tutorial, but it's good to keep in mind. +::: + + +:::warning Draft Note: Old fixation util +Everything here and below is the old fixationDuration util draft +::: Of course, we also need to make sure, despite our fallback, a user defined parameter is able to be inherited throughout our source code and reach the `fixationDuration` call in the `fixation` definition above. We'll add a corresponding argument to our `timelineTest` function signature and the `timelineTest` call in `createTimeline` From 0d0e2de1e07908e62ffe37d9e8a94311ca36c99a Mon Sep 17 00:00:00 2001 From: vminojosa Date: Wed, 5 Aug 2026 14:18:23 -0400 Subject: [PATCH 11/13] the new getPerformance example is basically complete now (minus some miscellaneous example code blocks that need filling in; will do in the next commit); also planning to add an admonition signposting the unified first-party text object, since the customDebrief stuff segues perfectly; nothing left of the old fixationDuration first draft --- docs/docs/extend/build-a-timeline.md | 212 ++++++++++++++------------- 1 file changed, 110 insertions(+), 102 deletions(-) diff --git a/docs/docs/extend/build-a-timeline.md b/docs/docs/extend/build-a-timeline.md index 99b99b3..19aa619 100644 --- a/docs/docs/extend/build-a-timeline.md +++ b/docs/docs/extend/build-a-timeline.md @@ -456,7 +456,7 @@ After running another build, we can now call each `timelineUnit` as well from `e Put something in the overview, under the first header, that explains `examples/index.html` ::: -```html title='examples/index.html' +```html title="examples/index.html" ``` -As indicated at the end of the previous section, lets also make sure the `fixationDuration` parameter included in our `createTimelineOptions` type interface, with possible values limited to the two cases. +Of course, if we want to actually expose this argument to users calling `createTimeline`, we'll need to both update our `CreateTimelineOptions` interface and include the argument in the `timelineDebrief` call in `createTimeline`. ```javascript interface CreateTimelineOptions { repetitions: number, instructions: boolean, debrief: boolean, - fixationDuration: "random" | "fixed" + customDebrief: Function } ``` - -And as always, we should add the new `fixationDuration` util to our exports. - -```javascript -export const utils = { - fixationDuration -} +```javascript title="timelineDebrief call in createTimeline" +timeline.push(timelineDebrief(jsPsych, options.debrief, options.customDebrief)); ``` -Now, with our next build, we can go into `index.html` and use this new argument to adjust our `fixation` trial behavior on the fly, between `timelineTest` calls. +Now, on yet another build, we can go into `index.html` and use this new argument to customize our debrief by changing our `createTimeline` configuration. -```html title='examples/index.html' +```html title="examples/index.html" -``` - -We can also call `fixationDuration` separately. For example, maybe we, for whatever reason, define a trial object directly from the HTML that assigns the same functional output to `trial_duration`. We can call `fixationDuration` directly from the `utils` exports for this purpose. - -```html title='examples/index.html' - ``` -:::warning Script Tag -Remember, for the above to work from the HTML, you need to add a script tag to the HTML head that imports `jsPsychHtmlKeyboardReponse` via CDN. +:::warning Draft Note: Consider Rewriting +"Researchers working primarily from HTML files, without digging into our source, might find new unanticipated uses for any of our `util` exports. Developers, meanwhile, can always modify the util itself and give it new functionality, or refactor it with respect to our `timelineUnits`. By designing jsPsych experiments with a layer of exposed, modular access in the form `timelineUnits` and `utils`, we introduce a new point of feedback in the jsPsych research ecosystem—one that ultimately helps us build a better tool for everyone." ::: -Keep in mind that the point of these demonstrated `utils` is to convey the layer of abstraction permitted with a timeline package. `fixationDuration` may not be especially useful right now, at least not in the way we've implemented it so far, but it carves out new room for implementational flexibility. - -Researchers working primarily from HTML files, without digging into our source, might find new unanticipated uses for any of our `util` exports. Developers, meanwhile, can always modify the util itself and give it new functionality, or refactor it with respect to our `timelineUnits`. By designing jsPsych experiments with a layer of exposed, modular access in the form `timelineUnits` and `utils`, we introduce a new point of feedback in the jsPsych research ecosystem—one that ultimately helps us build a better tool for everyone. -
The complete code so far ```javascript @@ -1273,15 +1264,32 @@ Researchers working primarily from HTML files, without digging into our source, import jsPsychHtmlKeyboardResponse from "@jspsych/plugin-html-keyboard-response"; import jsPsychImageKeyboardResponse from "@jspsych/plugin-image-keyboard-response"; - function fixationDuration(jsPsych: JsPsych, mode: "random" | "fixed" = "random") { - switch (mode) { - case "fixed": - return 1000; - case "random": - return jsPsych.randomization.sampleWithoutReplacement([250, 500, 750, 1000, 1250, 1500, 1750, 2000], 1)[0]; - default: - return jsPsych.randomization.sampleWithoutReplacement([250, 500, 750, 1000, 1250, 1500, 1750, 2000], 1)[0]; - } + function getPerformance(jsPsych: JsPsych) { + var trials = jsPsych.data.get().filter({task: 'response'}); + var correct_trials = trials.filter({correct: true}); + + var blue_trials = trials.filter({correct_response: 'f'}); + var correct_blue_trials = blue_trials.filter({correct: true}); + + var orange_trials = trials.filter({correct_response: 'j'}); + var correct_orange_trials = orange_trials.filter({correct: true}); + + var accuracy = Math.round(correct_trials.count() / trials.count() * 100); + var blue_accuracy = Math.round(correct_blue_trials.count() / blue_trials.count() * 100); + var orange_accuracy = Math.round(correct_orange_trials.count() / orange_trials.count() * 100); + + var rt = Math.round(correct_trials.select('rt').mean()); + var blue_rt = Math.round(correct_blue_trials.select('rt').mean()); + var orange_rt = Math.round(correct_orange_trials.select('rt').mean()); + + return { + accuracy: accuracy, + rt: rt, + blue_accuracy: blue_accuracy, + blue_rt: blue_rt, + orange_accuracy: orange_accuracy, + orange_rt: orange_rt, + }; } function timelineIntro(jsPsych: JsPsych, optionInstructions: boolean = true, fixationDuration: boolean = "random") { @@ -1321,7 +1329,7 @@ Researchers working primarily from HTML files, without digging into our source, return intro_block } - function timelineTest(jsPsych: JsPsych, optionRepetitions: number = 5, optionFixationDuration: "random" | "fixed" = "random") { + function timelineTest(jsPsych: JsPsych, optionRepetitions: number = 5) { var test_stimuli = [ { stimulus: "../assets/blue.png", correct_response: 'f'}, { stimulus: "../assets/orange.png", correct_response: 'j'} @@ -1360,19 +1368,19 @@ Researchers working primarily from HTML files, without digging into our source, return [test_procedure]; } - function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true) { + function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true, + customDebrief: Function = function(performance_data) { + return `

You responded correctly on ${performance_data.accuracy}% of the trials.

+

Your average response time was ${performance_data.rt}ms.

+

Press any key to complete the experiment. Thank you!

` + } + ) { var debrief_block = { type: jsPsychHtmlKeyboardResponse, stimulus: function() { + const performance_data = getPerformance(jsPsych) - var trials = jsPsych.data.get().filter({task: 'response'}); - var correct_trials = trials.filter({correct: true}); - var accuracy = Math.round(correct_trials.count() / trials.count() * 100); - var rt = Math.round(correct_trials.select('rt').mean()); - - return `

You responded correctly on ${accuracy}% of the trials.

-

Your average response time was ${rt}ms.

-

Press any key to complete the experiment. Thank you!

`; + return customDebrief(performance_data); } } @@ -1387,15 +1395,15 @@ Researchers working primarily from HTML files, without digging into our source, repetitions: number, instructions: boolean, debrief: boolean, - fixationDuration: "random" | "fixed" + customDebrief: Function } export function createTimeline(jsPsych:JsPsych, options: Partial = {} ) { var timeline = []; - timeline.push(timelineIntro(jsPsych, options.instructions, options.fixationDuration)) + timeline.push(timelineIntro(jsPsych, options.instructions)) timeline.push(timelineTest(jsPsych, options.repetitions)) - timeline.push(timelineDebrief(jsPsych, options.debrief)) + timeline.push(timelineDebrief(jsPsych, options.debrief, options.customDebrief)) return { timeline: timeline } } @@ -1407,7 +1415,7 @@ Researchers working primarily from HTML files, without digging into our source, } export const utils = { - fixationDuration + getPerformance } ```
From 8bac18576aa975856123abebc365ae81b28a978d Mon Sep 17 00:00:00 2001 From: vminojosa Date: Thu, 6 Aug 2026 15:09:06 -0400 Subject: [PATCH 12/13] fixed some typos, rewrote the start of part 3, filled in the empty examples, and left an admonition for Alex to describe the unified first-party text object --- docs/docs/extend/build-a-timeline.md | 123 +++++++++++++++++++-------- 1 file changed, 89 insertions(+), 34 deletions(-) diff --git a/docs/docs/extend/build-a-timeline.md b/docs/docs/extend/build-a-timeline.md index 19aa619..6c805e8 100644 --- a/docs/docs/extend/build-a-timeline.md +++ b/docs/docs/extend/build-a-timeline.md @@ -39,14 +39,14 @@ This tutorial will walk through translating the same basic reaction time task fr [maybe include an npx CLI setup bit here] -## Overview exports from index.ts +## Part 1: Review of exports from `index.ts` `index.js` exports three principle kinds of components. - `createTimeline`: A function that takes each parameter, incorporates every export, and outputs a jsPsych timeline object. - `timelineUnits`: An object that includes each part of the larger timeline, broken down into conceptual chunks, typically but not always written as functions. - `util`: An object containing smaller logical components that support `createTimeline` or `timelineUnits`, like helper functions or type definitions. -## Setting Up `createTimeline` +## Part 2: Setting Up `createTimeline` Since this is the primary export for our package, we can think of this as the "hub" where all of our exports come together to generate a complete, fully configured task. This single export will process any parameters exposed to users and referenced throughout our source code. `createTimeline` will also depend on any timelineUnits and utils we eventually factor out over the course of this tutorial. It's the glue holding our package together, so we'll start here. @@ -286,28 +286,22 @@ jsPsych.run(timeline) ```
-## Setting up a timelineUnit +## Part 3: Setting up a `timelineUnit` -:::warning Draft Note: Move this up top and rewrite -"With our `timelineUnits` bracketed out and exported, anyone could isolate, rearrange, or reconfigure any one of the pieces of our original experiment. The next section will expand on that last point and go into parametrizing units for configurability." -::: +Now that we understand `createTimeline` as a consistent end product of our source code, we can start to carve it up into `timelineUnits`. To restate, `timelineUnits` are broken down, conceptual pieces of our experiment timeline—that is, of the script executed in `createTimeline`. Each `timelineUnit` can be typed as an array of `TimelineNodes`. By bracketing out and exporting `timelineUnits`, users could isolate, rearrange, or reconfigure any one of the pieces of our original experiment. -Now that we understand `createTimeline` as a consistent end product of our source code, we can start to carve it up into `timelineUnits`. To restate, `timelineUnits` are broken down, conceptual pieces of our experiment timeline—that is, of the script executed in `createTimeline`. Each `timelineUnit` can be typed as an array of `TimelineNodes`. On first glance, we can split the script in `createTimeline` into three main chunks: +On first glance, we can split the script in `createTimeline` into three main chunks: - An introduction, made up of the `welcome` and `instructions` nodes - The `test_procedure`, alternating between `fixation` and `test` nodes - The `debrief` consisting of a single node, with a digest of the participant's performance -:::warning Draft Note: Needs hands-on review -Everything past this point must be implementationally verified by a few people willing to go through each step, noting build-breaking errors along the way. -::: - :::warning Draft Note: Nomenclatures Make sure that all nomenclatures are consistent and descriptive throughout ::: The most straightforward way to block out our `timelineUnits` is by wrapping each chunk in a function that returns that chunk. -:::tip jsPsych instance as argument +:::info jsPsych instance as argument In the event that a `timelineUnit` or `util` references core jsPsych methods, each export should take the running jsPsych instance as an argument by default. Otherwise, those methods will not be appropriately defined. We explore other ways to factor out references to the jPsych instance in later sections of this tutorial (pending). ::: @@ -468,7 +462,7 @@ Put something in the overview, under the first header, that explains `examples/i ``` -With our `timelineUnits` bracketed out and exported, anyone could isolate, rearrange, or reconfigure any one of the pieces of our original experiment. The next section will expand on that last point and go into parametrizing units for configurability. +Now we have a clear sense, as potential contributors to the jsPsych library, how `timelineUnits` add welcome flexibility to how our package can be used. The next section will expand on that last point and go into parametrizing `timelineUnits` for configurability.
The complete code so far @@ -591,7 +585,7 @@ With our `timelineUnits` bracketed out and exported, anyone could isolate, rearr ```
-## Designing and implementing parameters +## Part 4: Designing and implementing parameters Now that we have our initial experiment sectioned off into `timelineUnits`, we can now think about designing parameters, based on how we might want to modify the task for iterative deployments. @@ -733,7 +727,7 @@ function timelineDebrief(jsPsych: JsPsych, optionDebrief) { } ``` -:::tip Alternative Implementation: Conditional Definition Instead Of Conditional Push +:::info Alternative Implementation: Conditional Definition Instead Of Conditional Push For `instructions`, we can write a basic implementation by wrapping our definition of `var instructions` in an `if`-statement. We'll define `instructions` as a trial object in the case that `optionInstructions` is true. Otherwise, we define `var instructions` as an empty array, since this implementation assumes an `instructions` variable will be pushed to the `intro_block` array either way. We'll also add `optionInstructions` as an argument for the `timelineIntro` function. :::warning Bug: Typing @@ -752,7 +746,7 @@ function timelineIntro(jsPsych: JsPsych, optionInstructions: boolean = true) function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true) ``` -These fallbacks allow use to successfully call each `timelineUnit` without defining the second argument. Instead, each `timelineUnit` will reference the fallback by default. This is how we set default parameters as if the timeline were a plugin. This is also how we keep our package build from breaking, insofar as none of the `timelineUnit` calls in `createTimeline` define these parameter arguments—at least not yet. +These fallbacks allow us to successfully call each `timelineUnit` without defining the second argument. Instead, each `timelineUnit` will reference the fallback by default. This is how we set default parameters as if the timeline were a plugin. This is also how we keep our package build from breaking, insofar as none of the `timelineUnit` calls in `createTimeline` define these parameter arguments—at least not yet. Now, on next build, we'll be able to run these `timelineUnits` from `index.html` while configuring each unit's behavior with the second argument. @@ -828,7 +822,7 @@ interface CreateTimelineOptions { export function createTimeline(jsPsych:JsPsych, options: CreateTimelineOptions ) { ``` -For anyone who's developed a jsPsych plugin before, this will look a little analogous to the [plugin info object](), albeit without any of the boilerplate syntax provided with our plugin template. +For anyone who's developed a jsPsych plugin before, this will look a little analogous to the [plugin info object](plugins/plugin-tutorial#part-1-review-of-indexts), albeit without any of the boilerplate syntax provided with our plugin template. In addition to cleaning up our code a little, we have set ourselves up to type `options` as a `Partial` of `interface CreateTimelineOptions`. A `Partial` will include any subset of the type defined in `createTimelineOptions`—even empty ones! At the same time, it will reject any objects with properties not included in `CreateTimelineOptions`. @@ -844,7 +838,59 @@ export function createTimeline(jsPsych:JsPsych, options: Partial + const jsPsych = initJsPsych({ + on_finish: function() { + jsPsych.data.displayData(); + }}); + + const options = { + repetitions: 5, + instructions: true, + debrief: true, + } + + const timeline = jsPsychTimelineReactionTimeDemo.createTimeline(jsPsych, options); + + jsPsych.run([timeline]) + +``` +```html title="Configuring the RT task without instructions and 10 more trials in examples/index.html" + +``` +```html title="Configuring the RT task without instructions or debrief, but with 14 total trials, in examples/index.html" + ``` We should keep this workflow in mind as we add new parameters. To restate the steps going forward, parameters are (1) introduced as arguments at the component scope, (2) implemented at that same scope, (3) provided a fallback value at scope's function signature, then (4) added to our `interface` type. @@ -984,7 +1030,7 @@ We should keep this workflow in mind as we add new parameters. To restate the st ``` -## Setting up a util +## Part 5: Setting up a util With the bigger conceptual portions of the experiment factored out as parameterized `timelineUnits`, we can now think about factoring out `utils`, or essential helper functions that support more sophisticated, customizable behaviors. @@ -1150,11 +1196,11 @@ function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true) { } ``` -Of course, our user may not always want to return the same stimulus HTML, with all of the metrics available from `getPerformance`. We can afford the user some flexibility when customizing their debrief by writing out a `customDebrief` argument. Let's type this argument as a function and set a fallback that takes `performance_data` and returns the original string. +Of course, our user may not always want to return the same stimulus HTML, with all of the metrics available from `getPerformance`. We can afford the user some flexibility when customizing their debrief by writing out a `formatDebrief` argument. Let's type this argument as a function and set a fallback that takes `performance_data` and returns the original string. ```javascript function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true, - customDebrief: Function = function(performance_data) { + formatDebrief: Function = function(performance_data) { return `

You responded correctly on ${performance_data.accuracy}% of the trials.

Your average response time was ${performance_data.rt}ms.

Press any key to complete the experiment. Thank you!

` @@ -1165,7 +1211,7 @@ function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true, stimulus: () => { var performance_data = getPerformance(jsPsych) - return customDebrief(performance_data); + return formatDebrief(performance_data); } } @@ -1177,11 +1223,11 @@ function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true, } ``` -:::tip Typing `performance_data` -We could, of course, take advantage of Typescript to make sure that `performance_data` is always structured like a `getPerformance` output. This would involve similar syntax to how we defined `options` first as the `CreateTimelineOptions` interface, then typed the `options` argument in `createTimeline` as `CreateTimelineOptions`. For the sake of simplicity, we won't go over this in the tutorial, but it's good to keep in mind. +:::info Typing `performance_data` +We could, of course, take advantage of Typescript to make sure that `performance_data` is always structured like a `getPerformance` output. This would involve similar syntax to how we first defined `options` as the `CreateTimelineOptions` interface, then typed the `options` argument in `createTimeline` as `CreateTimelineOptions`. For the sake of simplicity, we won't go over this in the tutorial, but it's good to keep in mind. ::: -Once again, we can confirm that `createTimeline` still works the same by running another build. Users can also influence the debrief HTML by reading their own `customDebrief` function as an argument to the debrief `timelineUnit`. +Once again, we can confirm that `createTimeline` still works the same by running another build. Users can also influence the debrief HTML by reading their own `formatDebrief` function as an argument to the debrief `timelineUnit`. ```html title="examples/index.html" ``` +:::tip Unified Text Object +The core jsPsych team is actually working at the moment on implementing a first-party unified text object for timelines. We plan for community developers to use this as a single consolidated variable where they can store strings referenced throughout the timeline—including functions that interpolate dynamic values, like the `formatDebrief` solution above. + +[leaving the rest here for Alex since they want to write this out more and have better info] + +```javascript +//leaving this codeblock for Alex too in case they want to use it. +``` +::: :::warning Draft Note: Consider Rewriting "Researchers working primarily from HTML files, without digging into our source, might find new unanticipated uses for any of our `util` exports. Developers, meanwhile, can always modify the util itself and give it new functionality, or refactor it with respect to our `timelineUnits`. By designing jsPsych experiments with a layer of exposed, modular access in the form `timelineUnits` and `utils`, we introduce a new point of feedback in the jsPsych research ecosystem—one that ultimately helps us build a better tool for everyone." @@ -1369,7 +1424,7 @@ Now, on yet another build, we can go into `index.html` and use this new argument } function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true, - customDebrief: Function = function(performance_data) { + formatDebrief: Function = function(performance_data) { return `

You responded correctly on ${performance_data.accuracy}% of the trials.

Your average response time was ${performance_data.rt}ms.

Press any key to complete the experiment. Thank you!

` @@ -1380,7 +1435,7 @@ Now, on yet another build, we can go into `index.html` and use this new argument stimulus: function() { const performance_data = getPerformance(jsPsych) - return customDebrief(performance_data); + return formatDebrief(performance_data); } } @@ -1395,7 +1450,7 @@ Now, on yet another build, we can go into `index.html` and use this new argument repetitions: number, instructions: boolean, debrief: boolean, - customDebrief: Function + formatDebrief: Function } export function createTimeline(jsPsych:JsPsych, options: Partial = {} ) { @@ -1403,7 +1458,7 @@ Now, on yet another build, we can go into `index.html` and use this new argument timeline.push(timelineIntro(jsPsych, options.instructions)) timeline.push(timelineTest(jsPsych, options.repetitions)) - timeline.push(timelineDebrief(jsPsych, options.debrief, options.customDebrief)) + timeline.push(timelineDebrief(jsPsych, options.debrief, options.formatDebrief)) return { timeline: timeline } } @@ -1420,10 +1475,10 @@ Now, on yet another build, we can go into `index.html` and use this new argument ``` -## Testing exports +## Part 6: Testing exports ... -## Writing documentation +## Part 7: Writing documentation ... ## Open a pull request! From 673be9e9d6be6e805a8bf04ed216c6dbf6b0ebbe Mon Sep 17 00:00:00 2001 From: vminojosa Date: Thu, 6 Aug 2026 16:15:56 -0400 Subject: [PATCH 13/13] rewrote the debrief timelineunit to not include the debrief parameter in its scope, per suggestion from Alex --- docs/docs/extend/build-a-timeline.md | 170 ++++++++++++--------------- 1 file changed, 76 insertions(+), 94 deletions(-) diff --git a/docs/docs/extend/build-a-timeline.md b/docs/docs/extend/build-a-timeline.md index 6c805e8..ee2b793 100644 --- a/docs/docs/extend/build-a-timeline.md +++ b/docs/docs/extend/build-a-timeline.md @@ -454,9 +454,9 @@ Put something in the overview, under the first header, that explains `examples/i @@ -612,10 +612,10 @@ We'll tackle parametrizing our timeline package in two steps: first by implement Let's first go through each `timelineUnit` and implement the parameters that pertain to them—that is, `repetitions` in `timelineTest`, `instructions` in `timelineIntro`, and `debrief` in `timelineDebrief`. -We'll start with `repetitions`, since its first implementation will be a simple matter of swapping out a hardcoded value. We can do this by adding a second argument for `optionRepetitions`, then reading it to the definition of `test_procedure`. +We'll start with `repetitions`, since its first implementation will be a simple matter of swapping out a hardcoded value. We can do this by adding a second argument for `option_repetitions`, then reading it to the definition of `test_procedure`. ```javascript -function timelineTest(jsPsych: JsPsych, optionRepetitions: number) { +function timelineTest(jsPsych: JsPsych, option_repetitions: number) { var test_stimuli = [ { stimulus: "../blue.png", correct_response: 'f'}, { stimulus: "../orange.png", correct_response: 'j'} @@ -649,7 +649,7 @@ function timelineTest(jsPsych: JsPsych, optionRepetitions: number) { var test_procedure = { timeline: [fixation, test], timeline_variables: test_stimuli, - repetitions: optionRepetitions, + repetitions: option_repetitions, randomize_order: true }; @@ -657,10 +657,10 @@ function timelineTest(jsPsych: JsPsych, optionRepetitions: number) { } ``` -The other parameters will need a little additional logic, since they affect whether whole trials are included on execution. For `instructions`, we can write an `if`-statement that depends on an `optionInstructions` argument, then wrap the `intro_block.push(instructions)` call. +The other parameters will need a little additional logic, since they affect whether whole trials are included on execution. For `instructions`, we can write an `if`-statement that depends on an `option_instructions` argument, then wrap the `intro_block.push(instructions)` call. ```javascript -function timelineIntro(jsPsych: JsPsych, optionInstructions) { +function timelineIntro(jsPsych: JsPsych, option_instructions) { var intro_block = []; var welcome = { @@ -689,7 +689,7 @@ function timelineIntro(jsPsych: JsPsych, optionInstructions) { post_trial_gap: 2000 }; - if(optionInstructions){ + if(option_instructions){ intro_block.push(instructions) return intro_block } @@ -698,37 +698,25 @@ function timelineIntro(jsPsych: JsPsych, optionInstructions) { } ``` -We can do much the same thing with `debrief`. Let's wrap `return debrief_block` in an `if`-statement that evaluates an `optionDebrief` argument. Otherwise, `timelineDebrief` will now return an empty array. +We can do much the same thing with `debrief`, albeit at the `createTimeline` scope since `timelineDebrief` only returns a single debrief trial anyway. Let's wrap `return debrief_block` in an `if`-statement that evaluates an `option_debrief` argument. Otherwise, `timelineDebrief` will now return an empty array. ```javascript -function timelineDebrief(jsPsych: JsPsych, optionDebrief) { - - var debrief_block = { - type: jsPsychHtmlKeyboardResponse, - stimulus: function() { +export function createTimeline(jsPsych:JsPsych) { + var timeline = []; - var trials = jsPsych.data.get().filter({task: 'response'}); - var correct_trials = trials.filter({correct: true}); - var accuracy = Math.round(correct_trials.count() / trials.count() * 100); - var rt = Math.round(correct_trials.select('rt').mean()); + timeline.push(timelineIntro(jsPsych)) + timeline.push(timelineTest(jsPsych)) - return `

You responded correctly on ${accuracy}% of the trials.

-

Your average response time was ${rt}ms.

-

Press any key to complete the experiment. Thank you!

`; + if(option_debrief){ + timeline.push(timelineDebrief(jsPsych)) + } + return { timeline: timeline } } - } - - if(optionDebrief){ - return debrief_block - } else { - return [] - } -} ``` :::info Alternative Implementation: Conditional Definition Instead Of Conditional Push -For `instructions`, we can write a basic implementation by wrapping our definition of `var instructions` in an `if`-statement. We'll define `instructions` as a trial object in the case that `optionInstructions` is true. Otherwise, we define `var instructions` as an empty array, since this implementation assumes an `instructions` variable will be pushed to the `intro_block` array either way. We'll also add `optionInstructions` as an argument for the `timelineIntro` function. +For `instructions`, we can write a basic implementation by wrapping our definition of `var instructions` in an `if`-statement. We'll define `instructions` as a trial object in the case that `option_instructions` is true. Otherwise, we define `var instructions` as an empty array, since this implementation assumes an `instructions` variable will be pushed to the `intro_block` array either way. We'll also add `option_instructions` as an argument for the `timelineIntro` function. :::warning Bug: Typing Need to declare `instructions` as a variable with type array or object before you can run the parametrized unit. Same with `debrief`. @@ -737,18 +725,18 @@ Need to declare `instructions` as a variable with type array or object before yo With our implementational logic figured out, we should set a fallback for each of our second arguments. We can accomplish that in the functional signature of each `timelineUnit`, like so: ```javascript -function timelineTest(jsPsych: JsPsych, optionRepetitions: number = 5) +function timelineTest(jsPsych: JsPsych, option_repetitions: number = 5) ``` ```javascript -function timelineIntro(jsPsych: JsPsych, optionInstructions: boolean = true) +function timelineIntro(jsPsych: JsPsych, option_instructions: boolean = true) ``` ```javascript -function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true) +export function createTimeline(jsPsych: JsPsych, option_debrief: boolean = true) ``` -These fallbacks allow us to successfully call each `timelineUnit` without defining the second argument. Instead, each `timelineUnit` will reference the fallback by default. This is how we set default parameters as if the timeline were a plugin. This is also how we keep our package build from breaking, insofar as none of the `timelineUnit` calls in `createTimeline` define these parameter arguments—at least not yet. +These fallbacks allow us to successfully call each `timelineTest`, `timelineIntro`, and `createTimeline` without defining the second argument. This is also a way for us to set default parameters as if the timeline were a plugin. This is also how we keep our package build from breaking, insofar as none of the `timelineUnit` calls in `createTimeline` define these parameter arguments—at least not yet. -Now, on next build, we'll be able to run these `timelineUnits` from `index.html` while configuring each unit's behavior with the second argument. +Now, on next build, we'll be able to run `timelineTest`, `timelineIntro`, and `createTimeline` from `index.html` while configuring each unit's behavior with the second argument. ```html title="examples/index.html" ``` - - ### Typing the parameters object in `createTimeline` There's one last thing to do to fully parameterize our package. While we can read parameters as arguments directly to each exported `timelineUnit`, our `createTimeline` export isn't yet written to take those parameters. To recap, `createTimeline` should work like a complete kit of every way `timelineUnits`—and later `utils`—can be configured. @@ -780,7 +766,9 @@ export function createTimeline(jsPsych:JsPsych, options ) { timeline.push(timelineIntro(jsPsych, options.instructions)); timeline.push(timelineTest(jsPsych, options.repetitions)); - timeline.push(timelineDebrief(jsPsych, options.debrief)); + if(options.debrief){ + timeline.push(timelineDebrief(jsPsych)) + } return { timeline: timeline }; } @@ -801,7 +789,9 @@ export function createTimeline(jsPsych:JsPsych, options: { timeline.push(timelineIntro(jsPsych, options.instructions)); timeline.push(timelineTest(jsPsych, options.repetitions)); - timeline.push(timelineDebrief(jsPsych, options.debrief)); + if(options.debrief){ + timeline.push(timelineDebrief(jsPsych)) + } return { timeline: timeline }; } @@ -903,7 +893,7 @@ We should keep this workflow in mind as we add new parameters. To restate the st import jsPsychHtmlKeyboardResponse from "@jspsych/plugin-html-keyboard-response"; import jsPsychImageKeyboardResponse from "@jspsych/plugin-image-keyboard-response"; - function timelineIntro(jsPsych: JsPsych, optionInstructions: boolean = true) { + function timelineIntro(jsPsych: JsPsych, option_instructions: boolean = true) { var intro_block = []; var welcome = { @@ -932,7 +922,7 @@ We should keep this workflow in mind as we add new parameters. To restate the st post_trial_gap: 2000 }; - if(optionInstructions){ + if(option_instructions){ intro_block.push(instructions) return intro_block } @@ -940,7 +930,7 @@ We should keep this workflow in mind as we add new parameters. To restate the st return intro_block } - function timelineTest(jsPsych: JsPsych, optionRepetitions: number = 5) { + function timelineTest(jsPsych: JsPsych, option_repetitions: number = 5) { var test_stimuli = [ { stimulus: "../assets/blue.png", correct_response: 'f'}, { stimulus: "../assets/orange.png", correct_response: 'j'} @@ -974,14 +964,14 @@ We should keep this workflow in mind as we add new parameters. To restate the st var test_procedure = { timeline: [fixation, test], timeline_variables: test_stimuli, - repetitions: optionRepetitions, + repetitions: option_repetitions, randomize_order: true }; return [test_procedure]; } - function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true) { + function timelineDebrief(jsPsych: JsPsych) { var debrief_block = { type: jsPsychHtmlKeyboardResponse, stimulus: function() { @@ -996,12 +986,6 @@ We should keep this workflow in mind as we add new parameters. To restate the st

Press any key to complete the experiment. Thank you!

`; } } - - if(optionDebrief){ - return debrief_block - } else { - return [] - } } interface CreateTimelineOptions { @@ -1015,7 +999,9 @@ We should keep this workflow in mind as we add new parameters. To restate the st timeline.push(timelineIntro(jsPsych, options.instructions)) timeline.push(timelineTest(jsPsych, options.repetitions)) - timeline.push(timelineDebrief(jsPsych, options.debrief)) + if(options.debrief){ + timeline.push(timelineDebrief(jsPsych)) + } return { timeline: timeline } } @@ -1037,7 +1023,7 @@ With the bigger conceptual portions of the experiment factored out as parameteri To start thinking about `utils`, we're going to again default to the simplest case scenario, take what already exists in our code, and wrap it off into a separate function for export. A good place to start would be any of the functions returning a value to our trial objects. For example, let's look at the debrief trial at the end of the experiment. ```javascript -function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true) { +function timelineDebrief(jsPsych: JsPsych) { var debrief_block = { type: jsPsychHtmlKeyboardResponse, stimulus: function() { @@ -1052,12 +1038,6 @@ function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true) {

Press any key to complete the experiment. Thank you!

`; } } - - if(optionDebrief){ - return debrief_block - } else { - return [] - } } ``` @@ -1077,7 +1057,7 @@ function getPerformance(jsPsych: JsPsych) { Then, let's call our new function in the original `stimulus` definition. ```javascript -function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true) { +function timelineDebrief(jsPsych: JsPsych) { var debrief_block = { type: jsPsychHtmlKeyboardResponse, stimulus: function() { @@ -1088,12 +1068,6 @@ function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true) {

Press any key to complete the experiment. Thank you!

`; } } - - if(optionDebrief){ - return debrief_block - } else { - return [] - } } ``` @@ -1128,8 +1102,32 @@ By factoring out `getPerformance`, we open up some flexibility for users and dev Users could also call `getPerformance` from their HTML like any other export, so long as it's called within the context of another jsPsych timeline. -```javascript title="my cool example in examples/index.html" +```html title="examples/index.html" + ``` :::warning Script Tag Remember, for the above to work from the HTML, you need to add a script tag to the HTML head that imports `jsPsychHtmlKeyboardReponse` via CDN. @@ -1172,7 +1170,7 @@ function getPerformance(jsPsych: JsPsych) { We could then reference these outputs again in the original `timelineDebrief` context, with the returned HTML string expanded to interpolate those values. ```javascript -function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true) { +function timelineDebrief(jsPsych: JsPsych) { var debrief_block = { type: jsPsychHtmlKeyboardResponse, stimulus: () => { @@ -1187,19 +1185,13 @@ function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true) {

Press any key to complete the experiment. Thank you!

`; } } - - if(optionDebrief){ - return debrief_block - } else { - return [] - } } ``` Of course, our user may not always want to return the same stimulus HTML, with all of the metrics available from `getPerformance`. We can afford the user some flexibility when customizing their debrief by writing out a `formatDebrief` argument. Let's type this argument as a function and set a fallback that takes `performance_data` and returns the original string. ```javascript -function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true, +function timelineDebrief(jsPsych: JsPsych, formatDebrief: Function = function(performance_data) { return `

You responded correctly on ${performance_data.accuracy}% of the trials.

Your average response time was ${performance_data.rt}ms.

@@ -1214,12 +1206,6 @@ function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true, return formatDebrief(performance_data); } } - - if(optionDebrief){ - return debrief_block - } else { - return [] - } } ``` @@ -1347,7 +1333,7 @@ The core jsPsych team is actually working at the moment on implementing a first- }; } - function timelineIntro(jsPsych: JsPsych, optionInstructions: boolean = true, fixationDuration: boolean = "random") { + function timelineIntro(jsPsych: JsPsych, option_instructions: boolean = true, fixationDuration: boolean = "random") { var intro_block = []; var welcome = { @@ -1376,7 +1362,7 @@ The core jsPsych team is actually working at the moment on implementing a first- post_trial_gap: 2000 }; - if(optionInstructions){ + if(option_instructions){ intro_block.push(instructions) return intro_block } @@ -1384,7 +1370,7 @@ The core jsPsych team is actually working at the moment on implementing a first- return intro_block } - function timelineTest(jsPsych: JsPsych, optionRepetitions: number = 5) { + function timelineTest(jsPsych: JsPsych, option_repetitions: number = 5) { var test_stimuli = [ { stimulus: "../assets/blue.png", correct_response: 'f'}, { stimulus: "../assets/orange.png", correct_response: 'j'} @@ -1416,14 +1402,14 @@ The core jsPsych team is actually working at the moment on implementing a first- var test_procedure = { timeline: [fixation, test], timeline_variables: test_stimuli, - repetitions: optionRepetitions, + repetitions: option_repetitions, randomize_order: true }; return [test_procedure]; } - function timelineDebrief(jsPsych: JsPsych, optionDebrief: boolean = true, + function timelineDebrief(jsPsych: JsPsych, formatDebrief: Function = function(performance_data) { return `

You responded correctly on ${performance_data.accuracy}% of the trials.

Your average response time was ${performance_data.rt}ms.

@@ -1438,12 +1424,6 @@ The core jsPsych team is actually working at the moment on implementing a first- return formatDebrief(performance_data); } } - - if(optionDebrief){ - return debrief_block - } else { - return [] - } } interface CreateTimelineOptions { @@ -1458,7 +1438,9 @@ The core jsPsych team is actually working at the moment on implementing a first- timeline.push(timelineIntro(jsPsych, options.instructions)) timeline.push(timelineTest(jsPsych, options.repetitions)) - timeline.push(timelineDebrief(jsPsych, options.debrief, options.formatDebrief)) + if(options.debrief){ + timeline.push(timelineDebrief(jsPsych, options.formatDebrief)) + } return { timeline: timeline } }