diff --git a/projects/packages/forms/changelog/add-forms-submit-timer b/projects/packages/forms/changelog/add-forms-submit-timer
new file mode 100644
index 0000000000000..5d9fa9be8c51e
--- /dev/null
+++ b/projects/packages/forms/changelog/add-forms-submit-timer
@@ -0,0 +1,4 @@
+Significance: minor
+Type: added
+
+Forms: add form fill duration to form entries
diff --git a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php
index 20f1eca85e5f5..041aa392c973d 100644
--- a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php
+++ b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php
@@ -549,6 +549,16 @@ public function get_item_schema() {
'readonly' => true,
);
+ $schema['properties']['form_fill_duration'] = array(
+ 'description' => __( 'The duration in seconds from first user interaction to form submission. Returns null for submissions before this feature was added.', 'jetpack-forms' ),
+ 'type' => array( 'integer', 'null' ),
+ 'context' => array( 'view', 'edit', 'embed' ),
+ 'arg_options' => array(
+ 'sanitize_callback' => 'absint',
+ ),
+ 'readonly' => true,
+ );
+
$schema['properties']['browser'] = array(
'description' => __( 'The browser and platform used to submit the form.', 'jetpack-forms' ),
'type' => 'string',
@@ -809,6 +819,9 @@ public function prepare_item_for_response( $item, $request ) {
if ( rest_is_field_included( 'country_code', $fields ) ) {
$data['country_code'] = $feedback_response->get_country_code();
}
+ if ( rest_is_field_included( 'form_fill_duration', $fields ) ) {
+ $data['form_fill_duration'] = $feedback_response->get_form_fill_duration();
+ }
if ( rest_is_field_included( 'browser', $fields ) ) {
$data['browser'] = $feedback_response->get_browser();
}
diff --git a/projects/packages/forms/src/contact-form/class-contact-form.php b/projects/packages/forms/src/contact-form/class-contact-form.php
index d9a4d8e1f81f8..719b8f3beafa2 100644
--- a/projects/packages/forms/src/contact-form/class-contact-form.php
+++ b/projects/packages/forms/src/contact-form/class-contact-form.php
@@ -829,6 +829,7 @@ public static function parse( $attributes, $content, $context = array() ) {
id='contact-form-$id'
class='{$container_classes_string}'
data-wp-interactive='jetpack/form' " . wp_interactivity_data_wp_context( $context ) . "
+ data-wp-on--focusin=\"callbacks.trackFirstInteraction\"
data-wp-watch--scroll-to-wrapper=\"callbacks.scrollToWrapper\"
>\n";
@@ -914,6 +915,7 @@ class='" . esc_attr( $form_classes ) . "' $form_aria_label
$r .= '';
}
$r .= "\n";
+ $r .= "\n";
$r .= $form->body;
if ( $is_multistep ) {
diff --git a/projects/packages/forms/src/contact-form/class-feedback.php b/projects/packages/forms/src/contact-form/class-feedback.php
index 060e992854ee1..4d7763ed8ffe4 100644
--- a/projects/packages/forms/src/contact-form/class-feedback.php
+++ b/projects/packages/forms/src/contact-form/class-feedback.php
@@ -90,6 +90,15 @@ class Feedback {
*/
protected $country_code = null;
+ /**
+ * The form fill duration in seconds.
+ *
+ * Tracks how long the user spent filling out the form (from first interaction to submission).
+ *
+ * @var int|null
+ */
+ protected $form_fill_duration = null;
+
/**
* The subject of the feedback entry.
*
@@ -234,10 +243,11 @@ private function load_from_post( WP_Post $feedback_post ) {
$parsed_content['request_url'] ?? ''
);
- $this->ip_address = $parsed_content['ip'] ?? $this->get_first_field_of_type( 'ip' );
- $this->country_code = $parsed_content['country_code'] ?? null;
- $this->user_agent = $parsed_content['user_agent'] ?? null;
- $this->subject = $parsed_content['subject'] ?? $this->get_first_field_of_type( 'subject' );
+ $this->ip_address = $parsed_content['ip'] ?? $this->get_first_field_of_type( 'ip' );
+ $this->country_code = $parsed_content['country_code'] ?? null;
+ $this->user_agent = $parsed_content['user_agent'] ?? null;
+ $this->form_fill_duration = $parsed_content['form_fill_duration'] ?? null;
+ $this->subject = $parsed_content['subject'] ?? $this->get_first_field_of_type( 'subject' );
$this->notification_recipients = $parsed_content['notification_recipients'] ?? array();
@@ -290,14 +300,15 @@ private function load_from_submission( $post_data, $form, $current_post = null,
$this->source = Feedback_Source::from_submission( $current_post, $current_page_number );
// If post_data is provided, use it to populate fields.
- $this->fields = $this->get_computed_fields( $post_data, $form );
- $this->ip_address = Contact_Form_Plugin::get_ip_address();
- $this->country_code = $this->get_country_code_from_ip( $this->ip_address );
- $this->user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? filter_var( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : null;
- $this->subject = $this->get_computed_subject( $post_data, $form );
- $this->author_data = Feedback_Author::from_submission( $post_data, $form );
- $this->comment_content = $this->get_computed_comment_content( $post_data, $form );
- $this->has_consent = $this->get_computed_consent( $post_data, $form );
+ $this->fields = $this->get_computed_fields( $post_data, $form );
+ $this->ip_address = Contact_Form_Plugin::get_ip_address();
+ $this->country_code = $this->get_country_code_from_ip( $this->ip_address );
+ $this->user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? filter_var( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : null;
+ $this->form_fill_duration = isset( $post_data['form_fill_duration'] ) ? absint( $post_data['form_fill_duration'] ) : null;
+ $this->subject = $this->get_computed_subject( $post_data, $form );
+ $this->author_data = Feedback_Author::from_submission( $post_data, $form );
+ $this->comment_content = $this->get_computed_comment_content( $post_data, $form );
+ $this->has_consent = $this->get_computed_consent( $post_data, $form );
$this->notification_recipients = $this->get_computed_notification_recipients( $post_data, $form );
@@ -785,6 +796,17 @@ public function get_country_code() {
return $this->country_code;
}
+ /**
+ * Get the form fill duration in seconds.
+ *
+ * Represents the time from first user interaction to form submission.
+ *
+ * @return int|null
+ */
+ public function get_form_fill_duration() {
+ return $this->form_fill_duration;
+ }
+
/**
* Get the emoji flag for the country.
*
@@ -1213,6 +1235,7 @@ public function serialize() {
'ip' => $this->ip_address,
'country_code' => $this->country_code,
'user_agent' => $this->user_agent,
+ 'form_fill_duration' => $this->form_fill_duration,
'notification_recipients' => $this->notification_recipients,
),
$this->source->serialize()
diff --git a/projects/packages/forms/src/modules/form/view.js b/projects/packages/forms/src/modules/form/view.js
index 30f5976797dc9..2862ada5c42d1 100644
--- a/projects/packages/forms/src/modules/form/view.js
+++ b/projects/packages/forms/src/modules/form/view.js
@@ -466,6 +466,18 @@ const { state, actions } = store( NAMESPACE, {
event.stopPropagation();
context.submissionError = null;
+ // Calculate and set the form fill duration before submission
+ if ( context.formFirstInteractionTime ) {
+ const duration = Math.round( ( Date.now() - context.formFirstInteractionTime ) / 1000 ); // Duration in seconds
+ const form = document.getElementById( 'jp-form-' + context.formHash );
+ if ( form ) {
+ const durationField = form.querySelector( 'input[name="form_fill_duration"]' );
+ if ( durationField ) {
+ durationField.value = duration;
+ }
+ }
+ }
+
const { success, error, data, refreshArgs } = yield submitForm( context.formHash );
if ( success ) {
@@ -558,6 +570,14 @@ const { state, actions } = store( NAMESPACE, {
registerField( fieldId, fieldType, fieldLabel, fieldValue, fieldIsRequired, fieldExtra );
},
+ trackFirstInteraction() {
+ const context = getContext();
+ // Store the first interaction time when user focuses on any form field
+ if ( ! context.formFirstInteractionTime ) {
+ context.formFirstInteractionTime = Date.now();
+ }
+ },
+
scrollToWrapper() {
const context = getContext();
diff --git a/projects/packages/forms/tests/php/contact-form/Feedback_Test.php b/projects/packages/forms/tests/php/contact-form/Feedback_Test.php
index e429c109913c1..eedd288b03203 100644
--- a/projects/packages/forms/tests/php/contact-form/Feedback_Test.php
+++ b/projects/packages/forms/tests/php/contact-form/Feedback_Test.php
@@ -360,6 +360,44 @@ public function test_country_code_included_in_serialized_response() {
remove_filter( 'jetpack_get_country_from_ip', $filter_callback, 10 );
}
+ /**
+ * Test that form_fill_duration is included in serialized response and persists after save/load.
+ */
+ public function test_form_fill_duration_persists_after_save() {
+
+ $form_id = Utility::get_form_id();
+ // Create a form submission with form_fill_duration
+ $_post_data = Utility::get_post_request(
+ array(
+ 'name' => 'John Doe',
+ 'email' => 'john@example.com',
+ 'message' => 'Test message',
+ 'form_fill_duration' => '45', // 45 seconds
+ ),
+ 'g' . $form_id
+ );
+
+ $form = new Contact_Form(
+ array(
+ 'title' => 'Test Form',
+ 'description' => 'This is a test form.',
+ ),
+ "[contact-field label='Name' type='name' required='1'/][contact-field label='Email' type='email' required='1'/][contact-field label='Message' type='textarea' required='1'/]"
+ );
+
+ // Create a contact form response
+ $response = Feedback::from_submission( $_post_data, $form );
+ $feedback_post_id = $response->save();
+ $saved_response = Feedback::get( $feedback_post_id );
+
+ // The form_fill_duration should be present and match the test value.
+ $this->assertNotEmpty( $response->get_form_fill_duration(), 'Form fill duration should not be empty' );
+ $this->assertNotEmpty( $saved_response->get_form_fill_duration(), 'Form fill duration should not be empty after save/load' );
+ $this->assertEquals( $response->get_form_fill_duration(), $saved_response->get_form_fill_duration(), 'Form fill duration should match after save/load' );
+ $this->assertEquals( 45, $saved_response->get_form_fill_duration(), 'Form fill duration should be 45 seconds' );
+ $this->assertIsInt( $saved_response->get_form_fill_duration(), 'Form fill duration should be an integer' );
+ }
+
/**
* Test the browser information is parsed correctly from user agent.
*/
diff --git a/projects/packages/forms/tests/php/contact-form/class-utility.php b/projects/packages/forms/tests/php/contact-form/class-utility.php
index e757dd8978610..d8dd6cbb229fa 100644
--- a/projects/packages/forms/tests/php/contact-form/class-utility.php
+++ b/projects/packages/forms/tests/php/contact-form/class-utility.php
@@ -175,7 +175,7 @@ public static function get_post_request( $values, $form_id = null, $post_id = 0
$prefix = $form_id ? $form_id : 'g' . $post_id;
$post_data = array();
foreach ( $values as $key => $val ) {
- if ( strpos( $key, 'contact-form' ) === 0 || strpos( $key, 'action' ) === 0 ) {
+ if ( strpos( $key, 'contact-form' ) === 0 || strpos( $key, 'action' ) === 0 || strpos( $key, 'form_fill_duration' ) === 0 ) {
$post_data[ $key ] = $val;
} else {
$post_data[ $prefix . '-' . $key ] = $val;
diff --git a/projects/plugins/jetpack/changelog/add-forms-submit-timer b/projects/plugins/jetpack/changelog/add-forms-submit-timer
new file mode 100644
index 0000000000000..c86ed6d1ca1ec
--- /dev/null
+++ b/projects/plugins/jetpack/changelog/add-forms-submit-timer
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Forms: Add form fill duration to feedback entries