diff --git a/public/demo-image/config.json b/public/demo-image/config.json index 00327058..b7b70a78 100644 --- a/public/demo-image/config.json +++ b/public/demo-image/config.json @@ -1,5 +1,5 @@ { - "$schema": "https://raw.githubusercontent.com/revisit-studies/study/v1.0.5/src/parser/StudyConfigSchema.json", + "$schema": "https://raw.githubusercontent.com/revisit-studies/study/time-to-next/src/parser/StudyConfigSchema.json", "studyMetadata": { "title": "Simple Images as Stimuli: Decision-Making with Uncertainty Visualizations", "version": "pilot", @@ -54,7 +54,9 @@ "No" ] } - ] + ], + "nextButtonEnableTime": 3000, + "nextButtonDisableTime": 15000 }, "dotplot-medium": { "meta": { diff --git a/src/analysis/individualStudy/table/TableView.tsx b/src/analysis/individualStudy/table/TableView.tsx index 062ee0c5..dbbfccf7 100644 --- a/src/analysis/individualStudy/table/TableView.tsx +++ b/src/analysis/individualStudy/table/TableView.tsx @@ -20,17 +20,19 @@ function AnswerCell({ cellData }: { cellData: StoredAnswer }) { return Number.isFinite(cellData.endTime) && Number.isFinite(cellData.startTime) ? ( - {Object.entries(cellData.answer).map(([key, storedAnswer]) => ( - - - {' '} - {`${key}: `} - - - {`${storedAnswer}`} - - - ))} + {cellData.timedOut + ? Timed out + : Object.entries(cellData.answer).map(([key, storedAnswer]) => ( + + + {' '} + {`${key}: `} + + + {`${storedAnswer}`} + + + ))} ) : ( @@ -237,6 +239,7 @@ export function TableView({ endTime: Math.max(...Object.values(record.answers).filter((a) => a.endTime !== -1 && a.endTime !== undefined).map((a) => a.endTime)), answer: {}, windowEvents: Object.values(record.answers).flatMap((a) => a.windowEvents), + timedOut: false, // not used }} key={`cell-${record.participantId}-total-duration`} /> diff --git a/src/components/TimedOut.tsx b/src/components/TimedOut.tsx new file mode 100644 index 00000000..61dec58b --- /dev/null +++ b/src/components/TimedOut.tsx @@ -0,0 +1,9 @@ +import { Text } from '@mantine/core'; + +export function TimedOut() { + return ( + + Thank you for participating. Unfortunately, you have not answered the questions within the given time limit. Because of this, you are no longer eligible to participate in the study. You may close this window now. + + ); +} diff --git a/src/components/response/ResponseBlock.tsx b/src/components/response/ResponseBlock.tsx index 94efff81..fe982c05 100644 --- a/src/components/response/ResponseBlock.tsx +++ b/src/components/response/ResponseBlock.tsx @@ -5,6 +5,7 @@ import { import React, { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { IconAlertTriangle, IconInfoCircle } from '@tabler/icons-react'; import { IndividualComponent, ResponseBlockLocation, @@ -20,6 +21,8 @@ import { useAnswerField } from './utils'; import ResponseSwitcher from './ResponseSwitcher'; import { StoredAnswer, TrrackedProvenance } from '../../store/types'; import { useStorageEngine } from '../../storage/storageEngineHooks'; +import { useStudyConfig } from '../../store/hooks/useStudyConfig'; +import { useNextStep } from '../../store/hooks/useNextStep'; type Props = { status?: StoredAnswer; @@ -42,6 +45,7 @@ export default function ResponseBlock({ const storedAnswer = status?.answer; const studyId = useStudyId(); + const studyConfig = useStudyConfig(); const navigate = useNavigate(); @@ -62,6 +66,26 @@ export default function ResponseBlock({ const showNextBtn = location === (configInUse?.nextButtonLocation || 'belowStimulus'); + const nextButtonDisableTime = configInUse?.nextButtonDisableTime; + const nextButtonEnableTime = configInUse?.nextButtonEnableTime || 0; + const [timer, setTimer] = useState(undefined); + // Start a timer on first render, update timer every 100ms + useEffect(() => { + let time = 0; + const interval = setInterval(() => { + time += 100; + setTimer(time); + }, 500); + return () => { + clearInterval(interval); + }; + }, []); + useEffect(() => { + if (timer && nextButtonDisableTime && timer >= nextButtonDisableTime && studyConfig.uiConfig.timeoutReject) { + navigate('./__timeout'); + } + }, [nextButtonDisableTime, timer, navigate, studyConfig.uiConfig.timeoutReject]); + useEffect(() => { const iframeResponse = responses.find((r) => r.type === 'iframe'); if (iframeAnswers && iframeResponse) { @@ -156,10 +180,28 @@ export default function ResponseBlock({ } }); - setEnableNextButton((allowFailedTraining && newAttemptsUsed >= trainingAttempts) || (Object.values(correctAnswers).every((isCorrect) => isCorrect) && newAttemptsUsed <= trainingAttempts)); + setEnableNextButton( + ( + allowFailedTraining && newAttemptsUsed >= trainingAttempts + ) || ( + Object.values(correctAnswers).every((isCorrect) => isCorrect) + && newAttemptsUsed <= trainingAttempts + ), + ); } }; + const buttonTimerSatisfied = useMemo( + () => { + const nextButtonDisableSatisfied = nextButtonDisableTime && timer ? timer <= nextButtonDisableTime : true; + const nextButtonEnableSatisfied = timer ? timer >= nextButtonEnableTime : true; + return nextButtonDisableSatisfied && nextButtonEnableSatisfied; + }, + [nextButtonDisableTime, nextButtonEnableTime, timer], + ); + + const { goToNextStep } = useNextStep(); + return (
{responses.map((response, index) => { @@ -216,11 +258,38 @@ export default function ResponseBlock({ )} {showNextBtn && ( )} + {showNextBtn && nextButtonEnableTime > 0 && timer && timer < nextButtonEnableTime && ( + }> + The next button will be enabled in + {' '} + {Math.ceil((nextButtonEnableTime - timer) / 1000)} + {' '} + seconds. + + )} + {showNextBtn && nextButtonDisableTime && timer && (nextButtonDisableTime - timer) < 10000 && ( + (nextButtonDisableTime - timer) > 0 + ? ( + }> + The next button disables in + {' '} + {Math.ceil((nextButtonDisableTime - timer) / 1000)} + {' '} + seconds. + + ) : !studyConfig.uiConfig.timeoutReject && ( + }> + The next button has timed out and is now disabled. + + + + + ))}
); } diff --git a/src/controllers/ComponentController.tsx b/src/controllers/ComponentController.tsx index 2d5d0508..2cd93707 100644 --- a/src/controllers/ComponentController.tsx +++ b/src/controllers/ComponentController.tsx @@ -17,6 +17,7 @@ import { useStoreActions, useStoreDispatch } from '../store/store'; import { StudyEnd } from '../components/StudyEnd'; import { TrainingFailed } from '../components/TrainingFailed'; import ResourceNotFound from '../ResourceNotFound'; +import { TimedOut } from '../components/TimedOut'; // current active stimuli presented to the user export default function ComponentController() { @@ -56,6 +57,11 @@ export default function ComponentController() { return ; } + // Handle timed out participants + if (currentComponent === '__timedOut') { + return ; + } + if (currentComponent === 'Notfound') { return ; } diff --git a/src/parser/LibraryConfigSchema.json b/src/parser/LibraryConfigSchema.json index 65b9c466..e7f0a1aa 100644 --- a/src/parser/LibraryConfigSchema.json +++ b/src/parser/LibraryConfigSchema.json @@ -60,6 +60,14 @@ "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.", "type": "object" }, + "nextButtonDisableTime": { + "description": "A timeout (in ms) after which the next button will be disabled.", + "type": "number" + }, + "nextButtonEnableTime": { + "description": "A timer (in ms) after which the next button will be enabled.", + "type": "number" + }, "nextButtonLocation": { "$ref": "#/definitions/ResponseBlockLocation", "description": "The location of the next button." @@ -486,6 +494,14 @@ "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.", "type": "object" }, + "nextButtonDisableTime": { + "description": "A timeout (in ms) after which the next button will be disabled.", + "type": "number" + }, + "nextButtonEnableTime": { + "description": "A timer (in ms) after which the next button will be enabled.", + "type": "number" + }, "nextButtonLocation": { "$ref": "#/definitions/ResponseBlockLocation", "description": "The location of the next button." @@ -658,6 +674,14 @@ "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.", "type": "object" }, + "nextButtonDisableTime": { + "description": "A timeout (in ms) after which the next button will be disabled.", + "type": "number" + }, + "nextButtonEnableTime": { + "description": "A timer (in ms) after which the next button will be enabled.", + "type": "number" + }, "nextButtonLocation": { "$ref": "#/definitions/ResponseBlockLocation", "description": "The location of the next button." @@ -949,6 +973,14 @@ "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.", "type": "object" }, + "nextButtonDisableTime": { + "description": "A timeout (in ms) after which the next button will be disabled.", + "type": "number" + }, + "nextButtonEnableTime": { + "description": "A timer (in ms) after which the next button will be enabled.", + "type": "number" + }, "nextButtonLocation": { "$ref": "#/definitions/ResponseBlockLocation", "description": "The location of the next button." @@ -1103,6 +1135,14 @@ "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.", "type": "object" }, + "nextButtonDisableTime": { + "description": "A timeout (in ms) after which the next button will be disabled.", + "type": "number" + }, + "nextButtonEnableTime": { + "description": "A timer (in ms) after which the next button will be enabled.", + "type": "number" + }, "nextButtonLocation": { "$ref": "#/definitions/ResponseBlockLocation", "description": "The location of the next button." @@ -1272,6 +1312,14 @@ "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.", "type": "object" }, + "nextButtonDisableTime": { + "description": "A timeout (in ms) after which the next button will be disabled.", + "type": "number" + }, + "nextButtonEnableTime": { + "description": "A timer (in ms) after which the next button will be enabled.", + "type": "number" + }, "nextButtonLocation": { "$ref": "#/definitions/ResponseBlockLocation", "description": "The location of the next button." @@ -1581,6 +1629,14 @@ "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.", "type": "object" }, + "nextButtonDisableTime": { + "description": "A timeout (in ms) after which the next button will be disabled.", + "type": "number" + }, + "nextButtonEnableTime": { + "description": "A timer (in ms) after which the next button will be enabled.", + "type": "number" + }, "nextButtonLocation": { "$ref": "#/definitions/ResponseBlockLocation", "description": "The location of the next button." diff --git a/src/parser/StudyConfigSchema.json b/src/parser/StudyConfigSchema.json index 76020b52..2d18ed03 100644 --- a/src/parser/StudyConfigSchema.json +++ b/src/parser/StudyConfigSchema.json @@ -60,6 +60,14 @@ "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.", "type": "object" }, + "nextButtonDisableTime": { + "description": "A timeout (in ms) after which the next button will be disabled.", + "type": "number" + }, + "nextButtonEnableTime": { + "description": "A timer (in ms) after which the next button will be enabled.", + "type": "number" + }, "nextButtonLocation": { "$ref": "#/definitions/ResponseBlockLocation", "description": "The location of the next button." @@ -486,6 +494,14 @@ "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.", "type": "object" }, + "nextButtonDisableTime": { + "description": "A timeout (in ms) after which the next button will be disabled.", + "type": "number" + }, + "nextButtonEnableTime": { + "description": "A timer (in ms) after which the next button will be enabled.", + "type": "number" + }, "nextButtonLocation": { "$ref": "#/definitions/ResponseBlockLocation", "description": "The location of the next button." @@ -658,6 +674,14 @@ "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.", "type": "object" }, + "nextButtonDisableTime": { + "description": "A timeout (in ms) after which the next button will be disabled.", + "type": "number" + }, + "nextButtonEnableTime": { + "description": "A timer (in ms) after which the next button will be enabled.", + "type": "number" + }, "nextButtonLocation": { "$ref": "#/definitions/ResponseBlockLocation", "description": "The location of the next button." @@ -908,6 +932,14 @@ "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.", "type": "object" }, + "nextButtonDisableTime": { + "description": "A timeout (in ms) after which the next button will be disabled.", + "type": "number" + }, + "nextButtonEnableTime": { + "description": "A timer (in ms) after which the next button will be enabled.", + "type": "number" + }, "nextButtonLocation": { "$ref": "#/definitions/ResponseBlockLocation", "description": "The location of the next button." @@ -1062,6 +1094,14 @@ "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.", "type": "object" }, + "nextButtonDisableTime": { + "description": "A timeout (in ms) after which the next button will be disabled.", + "type": "number" + }, + "nextButtonEnableTime": { + "description": "A timer (in ms) after which the next button will be enabled.", + "type": "number" + }, "nextButtonLocation": { "$ref": "#/definitions/ResponseBlockLocation", "description": "The location of the next button." @@ -1231,6 +1271,14 @@ "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.", "type": "object" }, + "nextButtonDisableTime": { + "description": "A timeout (in ms) after which the next button will be disabled.", + "type": "number" + }, + "nextButtonEnableTime": { + "description": "A timer (in ms) after which the next button will be enabled.", + "type": "number" + }, "nextButtonLocation": { "$ref": "#/definitions/ResponseBlockLocation", "description": "The location of the next button." @@ -1652,6 +1700,10 @@ "description": "The message to display when the study ends.", "type": "string" }, + "timeoutReject": { + "description": "Whether to redirect a timed out participant to a rejection page. This only works for components where the `nextButtonDisableTime` field is set.", + "type": "boolean" + }, "urlParticipantIdParam": { "description": "If the participant ID is passed in the URL, this is the name of the querystring parameter that is used to capture the participant ID (e.g. PROLIFIC_ID). This will allow a user to continue a study on different devices and browsers.", "type": "string" @@ -1705,6 +1757,14 @@ "description": "The meta data for the component. This is used to identify and provide additional information for the component in the admin panel.", "type": "object" }, + "nextButtonDisableTime": { + "description": "A timeout (in ms) after which the next button will be disabled.", + "type": "number" + }, + "nextButtonEnableTime": { + "description": "A timer (in ms) after which the next button will be enabled.", + "type": "number" + }, "nextButtonLocation": { "$ref": "#/definitions/ResponseBlockLocation", "description": "The location of the next button." diff --git a/src/parser/types.ts b/src/parser/types.ts index 5ccc8309..ef77b814 100644 --- a/src/parser/types.ts +++ b/src/parser/types.ts @@ -114,6 +114,8 @@ export interface UIConfig { * Whether to prepend questions with their index (+ 1). This should only be used when all questions are in the same location, e.g. all are in the side bar. */ enumerateQuestions?: boolean; + /** Whether to redirect a timed out participant to a rejection page. This only works for components where the `nextButtonDisableTime` field is set. */ + timeoutReject?: boolean; } /** @@ -480,6 +482,10 @@ export interface BaseIndividualComponent { description?: string; /** The instruction of the component. This is used to identify and provide additional information for the component in the admin panel. */ instruction?: string; + /** A timeout (in ms) after which the next button will be disabled. */ + nextButtonDisableTime?: number; + /** A timer (in ms) after which the next button will be enabled. */ + nextButtonEnableTime?: number; } /** diff --git a/src/store/hooks/useNextStep.ts b/src/store/hooks/useNextStep.ts index 197d3405..c6e36cfb 100644 --- a/src/store/hooks/useNextStep.ts +++ b/src/store/hooks/useNextStep.ts @@ -82,7 +82,7 @@ export function useNextStep() { const startTime = useMemo(() => Date.now(), []); const windowEvents = useWindowEvents(); - const goToNextStep = useCallback(() => { + const goToNextStep = useCallback((collectData = true) => { if (typeof currentStep !== 'number') { return; } @@ -101,14 +101,18 @@ export function useNextStep() { const currentWindowEvents = windowEvents && 'current' in windowEvents && windowEvents.current ? windowEvents.current.splice(0, windowEvents.current.length) : []; if (dataCollectionEnabled && storedAnswer.endTime === -1) { // === -1 means the answer has not been saved yet + const toSave = { + answer: collectData ? answer : {}, + startTime, + endTime, + provenanceGraph, + windowEvents: currentWindowEvents, + timedOut: !collectData, + }; storeDispatch( saveTrialAnswer({ identifier, - answer, - startTime, - endTime, - provenanceGraph, - windowEvents: currentWindowEvents, + ...toSave, }), ); // Update database @@ -116,9 +120,7 @@ export function useNextStep() { storageEngine.saveAnswers( { ...answers, - [identifier]: { - answer, startTime, endTime, provenanceGraph, windowEvents: currentWindowEvents, - }, + [identifier]: toSave, }, ); } diff --git a/src/store/store.tsx b/src/store/store.tsx index 12c02d44..bee31bb4 100644 --- a/src/store/store.tsx +++ b/src/store/store.tsx @@ -22,7 +22,7 @@ export async function studyStoreCreator( .map((id, idx) => [ `${id}_${idx}`, { - answer: {}, startTime: 0, endTime: -1, provenanceGraph: undefined, windowEvents: [], + answer: {}, startTime: 0, endTime: -1, provenanceGraph: undefined, windowEvents: [], timedOut: false, }, ])); const emptyValidation: TrialValidation = Object.assign( @@ -107,7 +107,7 @@ export async function studyStoreCreator( }: PayloadAction<{ identifier: string } & StoredAnswer>, ) { const { - identifier, answer, startTime, endTime, provenanceGraph, windowEvents, + identifier, answer, startTime, endTime, provenanceGraph, windowEvents, timedOut, } = payload; state.answers[identifier] = { answer, @@ -115,6 +115,7 @@ export async function studyStoreCreator( endTime, provenanceGraph, windowEvents, + timedOut, }; }, }, diff --git a/src/store/types.ts b/src/store/types.ts index f6c46b12..10e677f3 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -102,6 +102,8 @@ export interface StoredAnswer { ``` */ windowEvents: EventType[]; + /** A boolean value that indicates whether the participant timed out on this question. */ + timedOut: boolean; } export interface StimulusParams {