Skip to content

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_viewed clears it, so a remount or a new definition starts from zero.
  • step_viewed fires for the first step and for every later step change: Continue, Back, Skip, auto-advance, a jump to an error and a reset.
  • form_submitted ends the last question. form_submit_failed restarts its clock, because the visitor stays on it.
  • field_focused to field_blurred gives the time spent in each field. Those reports carry fieldKey.
  • watchPage pauses both clocks while the tab is hidden and reports the open question on pagehide, so abandoned questions are counted.
  • Single-page app navigation does not fire pagehide. Call timer.leave() when the route changes away from the form.
  • A question the visitor returns to is reported again. Sum the reports per stepId for the total.