How do I measure time per question?
Listen to form events and time each question from its step_viewed to the next step, the submit or the visitor leaving. The form library only reports events. The timer below is your code.
Definition
One question per step:
json
{
"id": "survey",
"title": "Quick survey",
"submit": { "label": "Send" },
"steps": [
{
"id": "role",
"title": "What is your role?",
"fields": [
{
"key": "role",
"type": "radio",
"label": "Role",
"autoAdvance": true,
"validation": { "required": true },
"options": [
{ "value": "engineer", "label": "Engineer" },
{ "value": "designer", "label": "Designer" },
{ "value": "other", "label": "Other" }
]
}
]
},
{
"id": "team",
"title": "How big is your team?",
"fields": [{ "key": "team", "type": "number", "label": "People", "validation": { "required": true, "min": 1 } }]
},
{
"id": "feedback",
"title": "Anything else?",
"fields": [{ "key": "feedback", "type": "textarea", "label": "Feedback" }]
}
]
}Timer
Save as question-timing.ts:
ts
import type { FormEvent } from '@formhaus/core';
export interface QuestionTime {
stepId: string;
fieldKey?: string;
ms: number;
}
function stopwatch(now: () => number) {
let spent = 0;
let since: number | undefined;
return {
start() {
since ??= now();
},
pause() {
if (since !== undefined) spent += now() - since;
since = undefined;
},
take() {
this.pause();
const ms = spent;
spent = 0;
return ms;
},
};
}
export function questionTimer(report: (time: QuestionTime) => void, now = () => performance.now()) {
const step = stopwatch(now);
const field = stopwatch(now);
let stepId: string | undefined;
let fieldKey: string | undefined;
let active = false;
let hidden = false;
const finishField = () => {
const ms = field.take();
if (stepId && fieldKey) report({ stepId, fieldKey, ms });
fieldKey = undefined;
};
const finishStep = () => {
if (fieldKey) finishField();
const ms = step.take();
if (stepId && ms > 0) report({ stepId, ms });
active = false;
};
const resume = () => {
active = true;
if (!hidden) step.start();
};
return {
onEvent(event: FormEvent) {
if (event.type === 'form_viewed') {
step.take();
field.take();
stepId = fieldKey = undefined;
active = false;
} else if (event.type === 'step_viewed') {
finishStep();
stepId = event.stepId;
resume();
} else if (event.type === 'field_focused') {
fieldKey = event.fieldKey;
if (!hidden) field.start();
} else if (event.type === 'field_blurred' && event.fieldKey === fieldKey) {
finishField();
} else if (event.type === 'form_submitted' || event.type === 'form_reset') {
finishStep();
} else if (event.type === 'form_submit_failed') {
resume();
}
},
hide() {
hidden = true;
step.pause();
field.pause();
},
show() {
hidden = false;
if (active) step.start();
if (fieldKey) field.start();
},
leave() {
finishStep();
stepId = undefined;
},
};
}
export type QuestionTimer = ReturnType<typeof questionTimer>;
export function watchPage(timer: QuestionTimer, page: Document = document, view: Window = window) {
const visibility = () => (page.visibilityState === 'hidden' ? timer.hide() : timer.show());
const leave = () => timer.leave();
visibility();
page.addEventListener('visibilitychange', visibility);
view.addEventListener('pagehide', leave);
return () => {
page.removeEventListener('visibilitychange', visibility);
view.removeEventListener('pagehide', leave);
};
}React
tsx
import { FormRenderer } from '@formhaus/react';
import { parseDefinition } from '@formhaus/core';
import { useEffect, useMemo } from 'react';
import survey from './survey.json';
import { questionTimer, watchPage } from './question-timing';
import { track } from './analytics';
import { save } from './api';
const definition = parseDefinition(survey);
export function Survey() {
const timer = useMemo(() => questionTimer((time) => track('question_time', time)), []);
useEffect(() => watchPage(timer), [timer]);
return <FormRenderer definition={definition} onSubmit={save} onFormEvent={timer.onEvent} />;
}Vue
vue
<script setup lang="ts">
import { FormRenderer } from '@formhaus/vue';
import { parseDefinition } from '@formhaus/core';
import { onBeforeUnmount, onMounted } from 'vue';
import survey from './survey.json';
import { questionTimer, watchPage } from './question-timing';
import { track } from './analytics';
import { save } from './api';
const definition = parseDefinition(survey);
const timer = questionTimer((time) => track('question_time', time));
let stop = () => {};
onMounted(() => (stop = watchPage(timer)));
onBeforeUnmount(() => stop());
</script>
<template>
<FormRenderer :definition="definition" @submit="save" @form-event="timer.onEvent" />
</template>Without a renderer
ts
import { FormEngine } from '@formhaus/core';
const engine = new FormEngine(definition, {}, { onFormEvent: timer.onEvent });
engine.start();Call start() when the form is on screen. It reports form_viewed and the first step_viewed once per engine.
How it works
- Each mounted form gets its own timer.
form_viewedclears it, so a remount or a new definition starts from zero. step_viewedfires for the first step and for every later step change: Continue, Back, Skip, auto-advance, a jump to an error and a reset.form_submittedends the last question.form_submit_failedrestarts its clock, because the visitor stays on it.field_focusedtofield_blurredgives the time spent in each field. Those reports carryfieldKey.watchPagepauses both clocks while the tab is hidden and reports the open question onpagehide, so abandoned questions are counted.- Single-page app navigation does not fire
pagehide. Calltimer.leave()when the route changes away from the form. - A question the visitor returns to is reported again. Sum the reports per
stepIdfor the total.
Related
- Form events: every event and its payload
- Quiz funnel: one-click questions with
autoAdvance - Headless engine:
FormEnginewithout React or Vue