From d94605a19f21332e7de10d36ab724814b7c19771 Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Fri, 12 Jun 2026 11:35:06 +0100 Subject: [PATCH 1/9] Update v4.1 validation rules --- app/prototype_v4_1/data/questions.yaml | 91 ++++++++++++++------ app/prototype_v4_1/docs/question-schema.md | 3 + app/prototype_v4_1/lib/question-validator.js | 83 +++++++++++++++++- 3 files changed, 150 insertions(+), 27 deletions(-) diff --git a/app/prototype_v4_1/data/questions.yaml b/app/prototype_v4_1/data/questions.yaml index 981d7db2..552f3f7b 100644 --- a/app/prototype_v4_1/data/questions.yaml +++ b/app/prototype_v4_1/data/questions.yaml @@ -162,7 +162,7 @@ questions: valueKey: metric label: What is your height in centimetres? suffix: cm - inputmode: numeric + inputmode: decimal classes: nhsuk-input--width-4 switchUnits: text: Switch to feet and inches @@ -171,17 +171,17 @@ questions: validation: required: true type: number - min: 100 - max: 250 + min: 139.7 + max: 243.8 errors: required: text: Enter your height in centimetres invalid: text: Enter your height in centimetres using numbers min: - text: Height in centimetres must be 100 or more + text: Height in centimetres must be 139.7cm or more max: - text: Height in centimetres must be 250 or fewer + text: Height in centimetres must be 243.8cm or fewer - id: height-imperial type: text_group @@ -220,13 +220,21 @@ questions: - answerKey: feet required: true type: number - min: 3 - max: 8 + integer: true + min: 0 - answerKey: inches required: true type: number + integer: true min: 0 max: 11 + total: + min: 55 + max: 96 + items: + - answerKey: feet + multiplier: 12 + - answerKey: inches errors: items: feet: @@ -236,11 +244,11 @@ questions: invalid: text: Enter your height in feet using numbers href: "#height-imperial-feet" - min: - text: Height in feet must be 3 or more + integer: + text: Height in feet must be a whole number href: "#height-imperial-feet" - max: - text: Height in feet must be 8 or fewer + min: + text: Height in feet must be 0ft or more href: "#height-imperial-feet" inches: required: @@ -249,12 +257,22 @@ questions: invalid: text: Enter your height in inches using numbers href: "#height-imperial-inches" + integer: + text: Height in inches must be a whole number + href: "#height-imperial-inches" min: - text: Height in inches must be 0 or more + text: Height in inches must be 0in or more href: "#height-imperial-inches" max: - text: Height in inches must be 11 or fewer + text: Height in inches must be 11in or fewer href: "#height-imperial-inches" + total: + min: + text: Height must be between 4 feet 7 inches and 8 feet + href: "#height-imperial-feet" + max: + text: Height must be between 4 feet 7 inches and 8 feet + href: "#height-imperial-feet" - id: weight-metric type: text @@ -272,7 +290,7 @@ questions: valueKey: metric label: What is your weight in kilograms? suffix: kg - inputmode: numeric + inputmode: decimal classes: nhsuk-input--width-4 switchUnits: text: Switch to stones and pounds @@ -281,17 +299,20 @@ questions: validation: required: true type: number - min: 30 - max: 250 + decimalPlaces: 2 + min: 25.4 + max: 317.5 errors: required: text: Enter your weight in kilograms invalid: text: Enter your weight in kilograms using numbers + decimalPlaces: + text: Weight in kilograms must have 2 decimal places or fewer min: - text: Weight in kilograms must be 30 or more + text: Weight in kilograms must be 25.4kg or more max: - text: Weight in kilograms must be 250 or fewer + text: Weight in kilograms must be 317.5kg or fewer - id: weight-imperial type: text_group @@ -330,13 +351,21 @@ questions: - answerKey: stones required: true type: number - min: 4 - max: 40 + integer: true + min: 0 - answerKey: pounds required: true type: number + integer: true min: 0 max: 13 + total: + min: 56 + max: 700 + items: + - answerKey: stones + multiplier: 14 + - answerKey: pounds errors: items: stones: @@ -346,11 +375,11 @@ questions: invalid: text: Enter your weight in stones using numbers href: "#weight-imperial-stones" - min: - text: Weight in stones must be 4 or more + integer: + text: Weight in stones must be a whole number href: "#weight-imperial-stones" - max: - text: Weight in stones must be 40 or fewer + min: + text: Weight in stones must be 0st or more href: "#weight-imperial-stones" pounds: required: @@ -359,12 +388,22 @@ questions: invalid: text: Enter your weight in pounds using numbers href: "#weight-imperial-pounds" + integer: + text: Weight in pounds must be a whole number + href: "#weight-imperial-pounds" min: - text: Weight in pounds must be 0 or more + text: Weight in pounds must be 0lb or more href: "#weight-imperial-pounds" max: - text: Weight in pounds must be 13 or fewer + text: Weight in pounds must be 13lb or fewer href: "#weight-imperial-pounds" + total: + min: + text: Weight must be between 4 stone and 50 stone + href: "#weight-imperial-stones" + max: + text: Weight must be between 4 stone and 50 stone + href: "#weight-imperial-stones" - id: gender type: single diff --git a/app/prototype_v4_1/docs/question-schema.md b/app/prototype_v4_1/docs/question-schema.md index 85db6b46..513e5736 100644 --- a/app/prototype_v4_1/docs/question-schema.md +++ b/app/prototype_v4_1/docs/question-schema.md @@ -314,7 +314,10 @@ Supported validation fields are: | `type: date` | Requires day, month and year to form a real date. | | `min` | Minimum numeric value. | | `max` | Maximum numeric value. | +| `integer` | Requires a numeric answer to be a whole number. | +| `decimalPlaces` | Maximum decimal places for a numeric answer. | | `items` | Per-field validation for `text_group`. | +| `total` | Combined numeric validation for `text_group` fields. | | `conditional` | Validation for conditional reveal inputs. | For numeric questions, provide all relevant messages: diff --git a/app/prototype_v4_1/lib/question-validator.js b/app/prototype_v4_1/lib/question-validator.js index 35d5ecf7..8904474a 100644 --- a/app/prototype_v4_1/lib/question-validator.js +++ b/app/prototype_v4_1/lib/question-validator.js @@ -12,8 +12,11 @@ const { getQuestion } = require('./questions') * @property {string} [type] - Type-specific validation, for example number or date. * @property {number} [min] - Minimum numeric value. * @property {number} [max] - Maximum numeric value. + * @property {boolean} [integer] - Whether a numeric value must be a whole number. + * @property {number} [decimalPlaces] - Maximum number of decimal places allowed. * @property {Object} [conditional] - Conditional reveal validation rules. * @property {Object[]} [items] - Validation rules for grouped inputs. + * @property {Object} [total] - Total validation rules for grouped numeric inputs. */ /** @@ -191,6 +194,30 @@ const mergeQuestion = (question, overrides = {}) => { } } +/** + * Check whether a submitted numeric value is written as a whole number. + * + * @param {*} value - Submitted value. + * @returns {boolean} True when the submitted value is an integer string. + */ +const isIntegerValue = (value) => { + return /^-?\d+$/.test(String(value).trim()) +} + +/** + * Check whether a submitted numeric value has no more than the allowed number + * of decimal places. + * + * @param {*} value - Submitted value. + * @param {number} decimalPlaces - Maximum decimal places allowed. + * @returns {boolean} True when the value has an allowed decimal precision. + */ +const hasMaximumDecimalPlaces = (value, decimalPlaces) => { + const match = String(value).trim().match(/^-?\d+(?:\.(\d+))?$/) + + return Boolean(match) && (!match[1] || match[1].length <= decimalPlaces) +} + /** * Validate a numeric value against invalid, min and max rules. * @@ -203,11 +230,24 @@ const mergeQuestion = (question, overrides = {}) => { const validateNumber = (value, validation, errorsConfig, errors, defaultHref) => { const number = Number(value) - if (Number.isNaN(number)) { + if (Number.isNaN(number) || !Number.isFinite(number)) { errors.push(makeError(errorsConfig.invalid, defaultHref)) return } + if (validation.integer && !isIntegerValue(value)) { + errors.push(makeError(errorsConfig.integer || errorsConfig.invalid, defaultHref)) + return + } + + if ( + validation.decimalPlaces !== undefined && + !hasMaximumDecimalPlaces(value, validation.decimalPlaces) + ) { + errors.push(makeError(errorsConfig.decimalPlaces || errorsConfig.invalid, defaultHref)) + return + } + if (validation.min !== undefined && number < validation.min) { errors.push(makeError(errorsConfig.min, defaultHref)) } @@ -217,6 +257,37 @@ const validateNumber = (value, validation, errorsConfig, errors, defaultHref) => } } +/** + * Validate the total value of grouped numeric inputs. + * + * @param {Object} groupValue - Submitted group values keyed by answer key. + * @param {Object} question - Normalised text_group question config. + * @param {ValidationError[]} errors - Mutable error collection. + */ +const validateGroupTotal = (groupValue, question, errors) => { + const totalValidation = question.validation?.total + + if (!totalValidation) { + return + } + + const total = (totalValidation.items || []).reduce((sum, item) => { + const multiplier = item.multiplier || 1 + + return sum + (Number(groupValue[item.answerKey]) * multiplier) + }, 0) + const totalErrors = question.errors?.total || {} + const defaultHref = `#${question.input?.items?.[0]?.id || question.id}` + + if (totalValidation.min !== undefined && total < totalValidation.min) { + errors.push(makeError(totalErrors.min, defaultHref)) + } + + if (totalValidation.max !== undefined && total > totalValidation.max) { + errors.push(makeError(totalErrors.max, defaultHref)) + } +} + /** * Validate grouped inputs, such as imperial height or weight fields. * @@ -227,8 +298,10 @@ const validateNumber = (value, validation, errorsConfig, errors, defaultHref) => const validateInputGroup = (answers, question) => { const errors = [] const groupValue = answers[question.answerKey]?.[question.input.valueKey] || {} + let hasItemErrors = false ;(question.validation?.items || []).forEach((itemValidation) => { + const errorCount = errors.length const value = groupValue[itemValidation.answerKey] const itemErrors = question.errors?.items?.[itemValidation.answerKey] || {} const defaultHref = `#${itemValidation.id || itemValidation.answerKey}` @@ -241,8 +314,16 @@ const validateInputGroup = (answers, question) => { if (itemValidation.type === 'number' && !isBlank(value)) { validateNumber(value, itemValidation, itemErrors, errors, defaultHref) } + + if (errors.length > errorCount) { + hasItemErrors = true + } }) + if (!hasItemErrors) { + validateGroupTotal(groupValue, question, errors) + } + return errors } From 1359a97edb71c658ede605fdd458c67f2e6634ac Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Fri, 12 Jun 2026 11:35:18 +0100 Subject: [PATCH 2/9] Update v4.2 validation rules --- app/prototype_v4_2/data/questions.yaml | 91 ++++++++++++++------ app/prototype_v4_2/docs/question-schema.md | 3 + app/prototype_v4_2/lib/question-validator.js | 83 +++++++++++++++++- 3 files changed, 150 insertions(+), 27 deletions(-) diff --git a/app/prototype_v4_2/data/questions.yaml b/app/prototype_v4_2/data/questions.yaml index 9f278b67..779e114d 100644 --- a/app/prototype_v4_2/data/questions.yaml +++ b/app/prototype_v4_2/data/questions.yaml @@ -108,7 +108,7 @@ questions: label: What is your height in centimetres? valueKey: metric suffix: cm - inputmode: numeric + inputmode: decimal classes: nhsuk-input--width-4 switchUnits: text: Switch to feet and inches @@ -117,17 +117,17 @@ questions: validation: required: true type: number - min: 100 - max: 250 + min: 139.7 + max: 243.8 errors: required: text: Enter your height in centimetres invalid: text: Enter your height in centimetres using numbers min: - text: Height in centimetres must be 100 or more + text: Height in centimetres must be 139.7cm or more max: - text: Height in centimetres must be 250 or fewer + text: Height in centimetres must be 243.8cm or fewer - id: height-imperial type: text_group answerKey: height @@ -158,13 +158,21 @@ questions: - answerKey: feet required: true type: number - min: 3 - max: 8 + integer: true + min: 0 - answerKey: inches required: true type: number + integer: true min: 0 max: 11 + total: + min: 55 + max: 96 + items: + - answerKey: feet + multiplier: 12 + - answerKey: inches errors: items: feet: @@ -174,11 +182,11 @@ questions: invalid: text: Enter your height in feet using numbers href: "#height-imperial-feet" - min: - text: Height in feet must be 3 or more + integer: + text: Height in feet must be a whole number href: "#height-imperial-feet" - max: - text: Height in feet must be 8 or fewer + min: + text: Height in feet must be 0ft or more href: "#height-imperial-feet" inches: required: @@ -187,12 +195,22 @@ questions: invalid: text: Enter your height in inches using numbers href: "#height-imperial-inches" + integer: + text: Height in inches must be a whole number + href: "#height-imperial-inches" min: - text: Height in inches must be 0 or more + text: Height in inches must be 0in or more href: "#height-imperial-inches" max: - text: Height in inches must be 11 or fewer + text: Height in inches must be 11in or fewer href: "#height-imperial-inches" + total: + min: + text: Height must be between 4 feet 7 inches and 8 feet + href: "#height-imperial-feet" + max: + text: Height must be between 4 feet 7 inches and 8 feet + href: "#height-imperial-feet" - id: weight-metric type: text answerKey: weight @@ -202,7 +220,7 @@ questions: valueKey: metric label: What is your weight in kilograms? suffix: kg - inputmode: numeric + inputmode: decimal classes: nhsuk-input--width-4 switchUnits: text: Switch to stones and pounds @@ -211,17 +229,20 @@ questions: validation: required: true type: number - min: 30 - max: 250 + decimalPlaces: 2 + min: 25.4 + max: 317.5 errors: required: text: Enter your weight in kilograms invalid: text: Enter your weight in kilograms using numbers + decimalPlaces: + text: Weight in kilograms must have 2 decimal places or fewer min: - text: Weight in kilograms must be 30 or more + text: Weight in kilograms must be 25.4kg or more max: - text: Weight in kilograms must be 250 or fewer + text: Weight in kilograms must be 317.5kg or fewer - id: weight-imperial type: text_group answerKey: weight @@ -252,13 +273,21 @@ questions: - answerKey: stones required: true type: number - min: 4 - max: 40 + integer: true + min: 0 - answerKey: pounds required: true type: number + integer: true min: 0 max: 13 + total: + min: 56 + max: 700 + items: + - answerKey: stones + multiplier: 14 + - answerKey: pounds errors: items: stones: @@ -268,11 +297,11 @@ questions: invalid: text: Enter your weight in stones using numbers href: "#weight-imperial-stones" - min: - text: Weight in stones must be 4 or more + integer: + text: Weight in stones must be a whole number href: "#weight-imperial-stones" - max: - text: Weight in stones must be 40 or fewer + min: + text: Weight in stones must be 0st or more href: "#weight-imperial-stones" pounds: required: @@ -281,12 +310,22 @@ questions: invalid: text: Enter your weight in pounds using numbers href: "#weight-imperial-pounds" + integer: + text: Weight in pounds must be a whole number + href: "#weight-imperial-pounds" min: - text: Weight in pounds must be 0 or more + text: Weight in pounds must be 0lb or more href: "#weight-imperial-pounds" max: - text: Weight in pounds must be 13 or fewer + text: Weight in pounds must be 13lb or fewer href: "#weight-imperial-pounds" + total: + min: + text: Weight must be between 4 stone and 50 stone + href: "#weight-imperial-stones" + max: + text: Weight must be between 4 stone and 50 stone + href: "#weight-imperial-stones" - id: gender type: single answerKey: gender diff --git a/app/prototype_v4_2/docs/question-schema.md b/app/prototype_v4_2/docs/question-schema.md index ac82bc78..d080daab 100644 --- a/app/prototype_v4_2/docs/question-schema.md +++ b/app/prototype_v4_2/docs/question-schema.md @@ -391,7 +391,10 @@ Supported validation fields are: | `type: date` | Requires day, month and year to form a real date. | | `min` | Minimum numeric value. | | `max` | Maximum numeric value. | +| `integer` | Requires a numeric answer to be a whole number. | +| `decimalPlaces` | Maximum decimal places for a numeric answer. | | `items` | Per-field validation for `text_group`. | +| `total` | Combined numeric validation for `text_group` fields. | | `conditional` | Validation for conditional reveal inputs. | For numeric questions, provide all relevant messages: diff --git a/app/prototype_v4_2/lib/question-validator.js b/app/prototype_v4_2/lib/question-validator.js index db2953f0..fbfdef5a 100644 --- a/app/prototype_v4_2/lib/question-validator.js +++ b/app/prototype_v4_2/lib/question-validator.js @@ -12,8 +12,11 @@ const { getQuestion } = require('./questions') * @property {string} [type] - Type-specific validation, for example number or date. * @property {number} [min] - Minimum numeric value. * @property {number} [max] - Maximum numeric value. + * @property {boolean} [integer] - Whether a numeric value must be a whole number. + * @property {number} [decimalPlaces] - Maximum number of decimal places allowed. * @property {Object} [conditional] - Conditional reveal validation rules. * @property {Object[]} [items] - Validation rules for grouped inputs. + * @property {Object} [total] - Total validation rules for grouped numeric inputs. */ /** @@ -191,6 +194,30 @@ const mergeQuestion = (question, overrides = {}) => { } } +/** + * Check whether a submitted numeric value is written as a whole number. + * + * @param {*} value - Submitted value. + * @returns {boolean} True when the submitted value is an integer string. + */ +const isIntegerValue = (value) => { + return /^-?\d+$/.test(String(value).trim()) +} + +/** + * Check whether a submitted numeric value has no more than the allowed number + * of decimal places. + * + * @param {*} value - Submitted value. + * @param {number} decimalPlaces - Maximum decimal places allowed. + * @returns {boolean} True when the value has an allowed decimal precision. + */ +const hasMaximumDecimalPlaces = (value, decimalPlaces) => { + const match = String(value).trim().match(/^-?\d+(?:\.(\d+))?$/) + + return Boolean(match) && (!match[1] || match[1].length <= decimalPlaces) +} + /** * Validate a numeric value against invalid, min and max rules. * @@ -203,11 +230,24 @@ const mergeQuestion = (question, overrides = {}) => { const validateNumber = (value, validation, errorsConfig, errors, defaultHref) => { const number = Number(value) - if (Number.isNaN(number)) { + if (Number.isNaN(number) || !Number.isFinite(number)) { errors.push(makeError(errorsConfig.invalid, defaultHref)) return } + if (validation.integer && !isIntegerValue(value)) { + errors.push(makeError(errorsConfig.integer || errorsConfig.invalid, defaultHref)) + return + } + + if ( + validation.decimalPlaces !== undefined && + !hasMaximumDecimalPlaces(value, validation.decimalPlaces) + ) { + errors.push(makeError(errorsConfig.decimalPlaces || errorsConfig.invalid, defaultHref)) + return + } + if (validation.min !== undefined && number < validation.min) { errors.push(makeError(errorsConfig.min, defaultHref)) } @@ -217,6 +257,37 @@ const validateNumber = (value, validation, errorsConfig, errors, defaultHref) => } } +/** + * Validate the total value of grouped numeric inputs. + * + * @param {Object} groupValue - Submitted group values keyed by answer key. + * @param {Object} question - Normalised text_group question config. + * @param {ValidationError[]} errors - Mutable error collection. + */ +const validateGroupTotal = (groupValue, question, errors) => { + const totalValidation = question.validation?.total + + if (!totalValidation) { + return + } + + const total = (totalValidation.items || []).reduce((sum, item) => { + const multiplier = item.multiplier || 1 + + return sum + (Number(groupValue[item.answerKey]) * multiplier) + }, 0) + const totalErrors = question.errors?.total || {} + const defaultHref = `#${question.input?.items?.[0]?.id || question.id}` + + if (totalValidation.min !== undefined && total < totalValidation.min) { + errors.push(makeError(totalErrors.min, defaultHref)) + } + + if (totalValidation.max !== undefined && total > totalValidation.max) { + errors.push(makeError(totalErrors.max, defaultHref)) + } +} + /** * Validate grouped inputs, such as imperial height or weight fields. * @@ -227,8 +298,10 @@ const validateNumber = (value, validation, errorsConfig, errors, defaultHref) => const validateInputGroup = (answers, question) => { const errors = [] const groupValue = answers[question.answerKey]?.[question.input.valueKey] || {} + let hasItemErrors = false ;(question.validation?.items || []).forEach((itemValidation) => { + const errorCount = errors.length const value = groupValue[itemValidation.answerKey] const itemErrors = question.errors?.items?.[itemValidation.answerKey] || {} const defaultHref = `#${itemValidation.id || itemValidation.answerKey}` @@ -241,8 +314,16 @@ const validateInputGroup = (answers, question) => { if (itemValidation.type === 'number' && !isBlank(value)) { validateNumber(value, itemValidation, itemErrors, errors, defaultHref) } + + if (errors.length > errorCount) { + hasItemErrors = true + } }) + if (!hasItemErrors) { + validateGroupTotal(groupValue, question, errors) + } + return errors } From 3d8ee3304b5ce28cd2c32dec4460b10f5cd6d831 Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Fri, 12 Jun 2026 11:35:30 +0100 Subject: [PATCH 3/9] Update v4.3 validation rules --- app/prototype_v4_3/data/questions.yaml | 91 ++++++++++++++------ app/prototype_v4_3/docs/question-schema.md | 3 + app/prototype_v4_3/lib/question-validator.js | 83 +++++++++++++++++- 3 files changed, 150 insertions(+), 27 deletions(-) diff --git a/app/prototype_v4_3/data/questions.yaml b/app/prototype_v4_3/data/questions.yaml index 581d4235..5318e502 100644 --- a/app/prototype_v4_3/data/questions.yaml +++ b/app/prototype_v4_3/data/questions.yaml @@ -108,7 +108,7 @@ questions: label: What is your height in centimetres? valueKey: metric suffix: cm - inputmode: numeric + inputmode: decimal classes: nhsuk-input--width-4 switchUnits: text: Switch to feet and inches @@ -117,17 +117,17 @@ questions: validation: required: true type: number - min: 100 - max: 250 + min: 139.7 + max: 243.8 errors: required: text: Enter your height in centimetres invalid: text: Enter your height in centimetres using numbers min: - text: Height in centimetres must be 100 or more + text: Height in centimetres must be 139.7cm or more max: - text: Height in centimetres must be 250 or fewer + text: Height in centimetres must be 243.8cm or fewer - id: height-imperial type: text_group answerKey: height @@ -158,13 +158,21 @@ questions: - answerKey: feet required: true type: number - min: 3 - max: 8 + integer: true + min: 0 - answerKey: inches required: true type: number + integer: true min: 0 max: 11 + total: + min: 55 + max: 96 + items: + - answerKey: feet + multiplier: 12 + - answerKey: inches errors: items: feet: @@ -174,11 +182,11 @@ questions: invalid: text: Enter your height in feet using numbers href: "#height-imperial-feet" - min: - text: Height in feet must be 3 or more + integer: + text: Height in feet must be a whole number href: "#height-imperial-feet" - max: - text: Height in feet must be 8 or fewer + min: + text: Height in feet must be 0ft or more href: "#height-imperial-feet" inches: required: @@ -187,12 +195,22 @@ questions: invalid: text: Enter your height in inches using numbers href: "#height-imperial-inches" + integer: + text: Height in inches must be a whole number + href: "#height-imperial-inches" min: - text: Height in inches must be 0 or more + text: Height in inches must be 0in or more href: "#height-imperial-inches" max: - text: Height in inches must be 11 or fewer + text: Height in inches must be 11in or fewer href: "#height-imperial-inches" + total: + min: + text: Height must be between 4 feet 7 inches and 8 feet + href: "#height-imperial-feet" + max: + text: Height must be between 4 feet 7 inches and 8 feet + href: "#height-imperial-feet" - id: weight-metric type: text answerKey: weight @@ -202,7 +220,7 @@ questions: valueKey: metric label: What is your weight in kilograms? suffix: kg - inputmode: numeric + inputmode: decimal classes: nhsuk-input--width-4 switchUnits: text: Switch to stones and pounds @@ -211,17 +229,20 @@ questions: validation: required: true type: number - min: 30 - max: 250 + decimalPlaces: 2 + min: 25.4 + max: 317.5 errors: required: text: Enter your weight in kilograms invalid: text: Enter your weight in kilograms using numbers + decimalPlaces: + text: Weight in kilograms must have 2 decimal places or fewer min: - text: Weight in kilograms must be 30 or more + text: Weight in kilograms must be 25.4kg or more max: - text: Weight in kilograms must be 250 or fewer + text: Weight in kilograms must be 317.5kg or fewer - id: weight-imperial type: text_group answerKey: weight @@ -252,13 +273,21 @@ questions: - answerKey: stones required: true type: number - min: 4 - max: 40 + integer: true + min: 0 - answerKey: pounds required: true type: number + integer: true min: 0 max: 13 + total: + min: 56 + max: 700 + items: + - answerKey: stones + multiplier: 14 + - answerKey: pounds errors: items: stones: @@ -268,11 +297,11 @@ questions: invalid: text: Enter your weight in stones using numbers href: "#weight-imperial-stones" - min: - text: Weight in stones must be 4 or more + integer: + text: Weight in stones must be a whole number href: "#weight-imperial-stones" - max: - text: Weight in stones must be 40 or fewer + min: + text: Weight in stones must be 0st or more href: "#weight-imperial-stones" pounds: required: @@ -281,12 +310,22 @@ questions: invalid: text: Enter your weight in pounds using numbers href: "#weight-imperial-pounds" + integer: + text: Weight in pounds must be a whole number + href: "#weight-imperial-pounds" min: - text: Weight in pounds must be 0 or more + text: Weight in pounds must be 0lb or more href: "#weight-imperial-pounds" max: - text: Weight in pounds must be 13 or fewer + text: Weight in pounds must be 13lb or fewer href: "#weight-imperial-pounds" + total: + min: + text: Weight must be between 4 stone and 50 stone + href: "#weight-imperial-stones" + max: + text: Weight must be between 4 stone and 50 stone + href: "#weight-imperial-stones" - id: gender type: single answerKey: gender diff --git a/app/prototype_v4_3/docs/question-schema.md b/app/prototype_v4_3/docs/question-schema.md index f4aaf43e..5f972112 100644 --- a/app/prototype_v4_3/docs/question-schema.md +++ b/app/prototype_v4_3/docs/question-schema.md @@ -393,7 +393,10 @@ Supported validation fields are: | `type: date` | Requires day, month and year to form a real date. | | `min` | Minimum numeric value. | | `max` | Maximum numeric value. | +| `integer` | Requires a numeric answer to be a whole number. | +| `decimalPlaces` | Maximum decimal places for a numeric answer. | | `items` | Per-field validation for `text_group`. | +| `total` | Combined numeric validation for `text_group` fields. | | `conditional` | Validation for conditional reveal inputs. | For numeric questions, provide all relevant messages: diff --git a/app/prototype_v4_3/lib/question-validator.js b/app/prototype_v4_3/lib/question-validator.js index db2953f0..fbfdef5a 100644 --- a/app/prototype_v4_3/lib/question-validator.js +++ b/app/prototype_v4_3/lib/question-validator.js @@ -12,8 +12,11 @@ const { getQuestion } = require('./questions') * @property {string} [type] - Type-specific validation, for example number or date. * @property {number} [min] - Minimum numeric value. * @property {number} [max] - Maximum numeric value. + * @property {boolean} [integer] - Whether a numeric value must be a whole number. + * @property {number} [decimalPlaces] - Maximum number of decimal places allowed. * @property {Object} [conditional] - Conditional reveal validation rules. * @property {Object[]} [items] - Validation rules for grouped inputs. + * @property {Object} [total] - Total validation rules for grouped numeric inputs. */ /** @@ -191,6 +194,30 @@ const mergeQuestion = (question, overrides = {}) => { } } +/** + * Check whether a submitted numeric value is written as a whole number. + * + * @param {*} value - Submitted value. + * @returns {boolean} True when the submitted value is an integer string. + */ +const isIntegerValue = (value) => { + return /^-?\d+$/.test(String(value).trim()) +} + +/** + * Check whether a submitted numeric value has no more than the allowed number + * of decimal places. + * + * @param {*} value - Submitted value. + * @param {number} decimalPlaces - Maximum decimal places allowed. + * @returns {boolean} True when the value has an allowed decimal precision. + */ +const hasMaximumDecimalPlaces = (value, decimalPlaces) => { + const match = String(value).trim().match(/^-?\d+(?:\.(\d+))?$/) + + return Boolean(match) && (!match[1] || match[1].length <= decimalPlaces) +} + /** * Validate a numeric value against invalid, min and max rules. * @@ -203,11 +230,24 @@ const mergeQuestion = (question, overrides = {}) => { const validateNumber = (value, validation, errorsConfig, errors, defaultHref) => { const number = Number(value) - if (Number.isNaN(number)) { + if (Number.isNaN(number) || !Number.isFinite(number)) { errors.push(makeError(errorsConfig.invalid, defaultHref)) return } + if (validation.integer && !isIntegerValue(value)) { + errors.push(makeError(errorsConfig.integer || errorsConfig.invalid, defaultHref)) + return + } + + if ( + validation.decimalPlaces !== undefined && + !hasMaximumDecimalPlaces(value, validation.decimalPlaces) + ) { + errors.push(makeError(errorsConfig.decimalPlaces || errorsConfig.invalid, defaultHref)) + return + } + if (validation.min !== undefined && number < validation.min) { errors.push(makeError(errorsConfig.min, defaultHref)) } @@ -217,6 +257,37 @@ const validateNumber = (value, validation, errorsConfig, errors, defaultHref) => } } +/** + * Validate the total value of grouped numeric inputs. + * + * @param {Object} groupValue - Submitted group values keyed by answer key. + * @param {Object} question - Normalised text_group question config. + * @param {ValidationError[]} errors - Mutable error collection. + */ +const validateGroupTotal = (groupValue, question, errors) => { + const totalValidation = question.validation?.total + + if (!totalValidation) { + return + } + + const total = (totalValidation.items || []).reduce((sum, item) => { + const multiplier = item.multiplier || 1 + + return sum + (Number(groupValue[item.answerKey]) * multiplier) + }, 0) + const totalErrors = question.errors?.total || {} + const defaultHref = `#${question.input?.items?.[0]?.id || question.id}` + + if (totalValidation.min !== undefined && total < totalValidation.min) { + errors.push(makeError(totalErrors.min, defaultHref)) + } + + if (totalValidation.max !== undefined && total > totalValidation.max) { + errors.push(makeError(totalErrors.max, defaultHref)) + } +} + /** * Validate grouped inputs, such as imperial height or weight fields. * @@ -227,8 +298,10 @@ const validateNumber = (value, validation, errorsConfig, errors, defaultHref) => const validateInputGroup = (answers, question) => { const errors = [] const groupValue = answers[question.answerKey]?.[question.input.valueKey] || {} + let hasItemErrors = false ;(question.validation?.items || []).forEach((itemValidation) => { + const errorCount = errors.length const value = groupValue[itemValidation.answerKey] const itemErrors = question.errors?.items?.[itemValidation.answerKey] || {} const defaultHref = `#${itemValidation.id || itemValidation.answerKey}` @@ -241,8 +314,16 @@ const validateInputGroup = (answers, question) => { if (itemValidation.type === 'number' && !isBlank(value)) { validateNumber(value, itemValidation, itemErrors, errors, defaultHref) } + + if (errors.length > errorCount) { + hasItemErrors = true + } }) + if (!hasItemErrors) { + validateGroupTotal(groupValue, question, errors) + } + return errors } From 8f047bd712627a9f7d8bd40f439dcdc227849bbe Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Fri, 12 Jun 2026 11:50:55 +0100 Subject: [PATCH 4/9] Update imperial validation rules --- app/prototype_v4_1/lib/question-validator.js | 46 ++++++++++++++++---- app/prototype_v4_2/lib/question-validator.js | 46 ++++++++++++++++---- app/prototype_v4_3/lib/question-validator.js | 46 ++++++++++++++++---- 3 files changed, 114 insertions(+), 24 deletions(-) diff --git a/app/prototype_v4_1/lib/question-validator.js b/app/prototype_v4_1/lib/question-validator.js index 8904474a..ef162476 100644 --- a/app/prototype_v4_1/lib/question-validator.js +++ b/app/prototype_v4_1/lib/question-validator.js @@ -288,6 +288,30 @@ const validateGroupTotal = (groupValue, question, errors) => { } } +/** + * Order grouped input errors by the visual input order. + * + * @param {ValidationError[]} errors - Validation errors for a text_group. + * @param {Object} question - Normalised text_group question config. + * @returns {ValidationError[]} Errors ordered by grouped input position. + */ +const orderGroupErrors = (errors, question) => { + const inputOrder = (question.input?.items || []).reduce((map, item, index) => { + map[`#${item.id}`] = index + return map + }, {}) + + return errors + .map((error, index) => ({ error, index })) + .sort((a, b) => { + const aOrder = inputOrder[a.error.href] ?? Number.MAX_SAFE_INTEGER + const bOrder = inputOrder[b.error.href] ?? Number.MAX_SAFE_INTEGER + + return aOrder - bOrder || a.index - b.index + }) + .map(({ error }) => error) +} + /** * Validate grouped inputs, such as imperial height or weight fields. * @@ -298,33 +322,39 @@ const validateGroupTotal = (groupValue, question, errors) => { const validateInputGroup = (answers, question) => { const errors = [] const groupValue = answers[question.answerKey]?.[question.input.valueKey] || {} - let hasItemErrors = false + let canValidateTotal = Boolean(question.validation?.total) ;(question.validation?.items || []).forEach((itemValidation) => { - const errorCount = errors.length const value = groupValue[itemValidation.answerKey] const itemErrors = question.errors?.items?.[itemValidation.answerKey] || {} const defaultHref = `#${itemValidation.id || itemValidation.answerKey}` if (itemValidation.required && isBlank(value)) { errors.push(makeError(itemErrors.required, defaultHref)) + canValidateTotal = false return } if (itemValidation.type === 'number' && !isBlank(value)) { - validateNumber(value, itemValidation, itemErrors, errors, defaultHref) - } + const number = Number(value) + + if ( + Number.isNaN(number) || + !Number.isFinite(number) || + (itemValidation.integer && !isIntegerValue(value)) + ) { + canValidateTotal = false + } - if (errors.length > errorCount) { - hasItemErrors = true + validateNumber(value, itemValidation, itemErrors, errors, defaultHref) } }) - if (!hasItemErrors) { + if (canValidateTotal) { validateGroupTotal(groupValue, question, errors) } - return errors + return orderGroupErrors(errors, question) } /** diff --git a/app/prototype_v4_2/lib/question-validator.js b/app/prototype_v4_2/lib/question-validator.js index fbfdef5a..9b970ee2 100644 --- a/app/prototype_v4_2/lib/question-validator.js +++ b/app/prototype_v4_2/lib/question-validator.js @@ -288,6 +288,30 @@ const validateGroupTotal = (groupValue, question, errors) => { } } +/** + * Order grouped input errors by the visual input order. + * + * @param {ValidationError[]} errors - Validation errors for a text_group. + * @param {Object} question - Normalised text_group question config. + * @returns {ValidationError[]} Errors ordered by grouped input position. + */ +const orderGroupErrors = (errors, question) => { + const inputOrder = (question.input?.items || []).reduce((map, item, index) => { + map[`#${item.id}`] = index + return map + }, {}) + + return errors + .map((error, index) => ({ error, index })) + .sort((a, b) => { + const aOrder = inputOrder[a.error.href] ?? Number.MAX_SAFE_INTEGER + const bOrder = inputOrder[b.error.href] ?? Number.MAX_SAFE_INTEGER + + return aOrder - bOrder || a.index - b.index + }) + .map(({ error }) => error) +} + /** * Validate grouped inputs, such as imperial height or weight fields. * @@ -298,33 +322,39 @@ const validateGroupTotal = (groupValue, question, errors) => { const validateInputGroup = (answers, question) => { const errors = [] const groupValue = answers[question.answerKey]?.[question.input.valueKey] || {} - let hasItemErrors = false + let canValidateTotal = Boolean(question.validation?.total) ;(question.validation?.items || []).forEach((itemValidation) => { - const errorCount = errors.length const value = groupValue[itemValidation.answerKey] const itemErrors = question.errors?.items?.[itemValidation.answerKey] || {} const defaultHref = `#${itemValidation.id || itemValidation.answerKey}` if (itemValidation.required && isBlank(value)) { errors.push(makeError(itemErrors.required, defaultHref)) + canValidateTotal = false return } if (itemValidation.type === 'number' && !isBlank(value)) { - validateNumber(value, itemValidation, itemErrors, errors, defaultHref) - } + const number = Number(value) + + if ( + Number.isNaN(number) || + !Number.isFinite(number) || + (itemValidation.integer && !isIntegerValue(value)) + ) { + canValidateTotal = false + } - if (errors.length > errorCount) { - hasItemErrors = true + validateNumber(value, itemValidation, itemErrors, errors, defaultHref) } }) - if (!hasItemErrors) { + if (canValidateTotal) { validateGroupTotal(groupValue, question, errors) } - return errors + return orderGroupErrors(errors, question) } /** diff --git a/app/prototype_v4_3/lib/question-validator.js b/app/prototype_v4_3/lib/question-validator.js index fbfdef5a..9b970ee2 100644 --- a/app/prototype_v4_3/lib/question-validator.js +++ b/app/prototype_v4_3/lib/question-validator.js @@ -288,6 +288,30 @@ const validateGroupTotal = (groupValue, question, errors) => { } } +/** + * Order grouped input errors by the visual input order. + * + * @param {ValidationError[]} errors - Validation errors for a text_group. + * @param {Object} question - Normalised text_group question config. + * @returns {ValidationError[]} Errors ordered by grouped input position. + */ +const orderGroupErrors = (errors, question) => { + const inputOrder = (question.input?.items || []).reduce((map, item, index) => { + map[`#${item.id}`] = index + return map + }, {}) + + return errors + .map((error, index) => ({ error, index })) + .sort((a, b) => { + const aOrder = inputOrder[a.error.href] ?? Number.MAX_SAFE_INTEGER + const bOrder = inputOrder[b.error.href] ?? Number.MAX_SAFE_INTEGER + + return aOrder - bOrder || a.index - b.index + }) + .map(({ error }) => error) +} + /** * Validate grouped inputs, such as imperial height or weight fields. * @@ -298,33 +322,39 @@ const validateGroupTotal = (groupValue, question, errors) => { const validateInputGroup = (answers, question) => { const errors = [] const groupValue = answers[question.answerKey]?.[question.input.valueKey] || {} - let hasItemErrors = false + let canValidateTotal = Boolean(question.validation?.total) ;(question.validation?.items || []).forEach((itemValidation) => { - const errorCount = errors.length const value = groupValue[itemValidation.answerKey] const itemErrors = question.errors?.items?.[itemValidation.answerKey] || {} const defaultHref = `#${itemValidation.id || itemValidation.answerKey}` if (itemValidation.required && isBlank(value)) { errors.push(makeError(itemErrors.required, defaultHref)) + canValidateTotal = false return } if (itemValidation.type === 'number' && !isBlank(value)) { - validateNumber(value, itemValidation, itemErrors, errors, defaultHref) - } + const number = Number(value) + + if ( + Number.isNaN(number) || + !Number.isFinite(number) || + (itemValidation.integer && !isIntegerValue(value)) + ) { + canValidateTotal = false + } - if (errors.length > errorCount) { - hasItemErrors = true + validateNumber(value, itemValidation, itemErrors, errors, defaultHref) } }) - if (!hasItemErrors) { + if (canValidateTotal) { validateGroupTotal(groupValue, question, errors) } - return errors + return orderGroupErrors(errors, question) } /** From a16347a9e195e273603dda19ad02bad8e100f5bf Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Tue, 16 Jun 2026 14:46:15 +0100 Subject: [PATCH 5/9] Validate number of hours is fewer than the frequency maximum --- app/prototype_v4_1/lib/tobacco-flow.js | 30 ++++++++++++++++++++++++-- app/prototype_v4_2/lib/tobacco-flow.js | 30 ++++++++++++++++++++++++-- app/prototype_v4_3/lib/tobacco-flow.js | 30 ++++++++++++++++++++++++-- 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/app/prototype_v4_1/lib/tobacco-flow.js b/app/prototype_v4_1/lib/tobacco-flow.js index 490b9b3a..b81586fb 100644 --- a/app/prototype_v4_1/lib/tobacco-flow.js +++ b/app/prototype_v4_1/lib/tobacco-flow.js @@ -309,6 +309,13 @@ const smokingFrequencyPeriods = { yearly: 'a year' } +const smokingFrequencyMaxHours = { + daily: 24, + weekly: 168, + monthly: 744, + yearly: 8760 +} + /** * Convert a smoking frequency value into a period phrase. * @@ -319,6 +326,21 @@ const getSmokingFrequencyPeriod = (frequency) => { return smokingFrequencyPeriods[frequency] || '' } +/** + * Get the maximum number of hours allowed for an "another amount" shisha answer. + * + * @param {string} type - Tobacco type key. + * @param {string} frequency - Selected smoking frequency. + * @returns {number} Maximum number of hours. + */ +const getSmokingQuantityOtherMaxHours = (type, frequency) => { + if (type !== 'shisha') { + return 24 + } + + return smokingFrequencyMaxHours[frequency] || 24 +} + /** * Replace the default "normal day/week/month/year" phrase in a heading. * @@ -680,6 +702,7 @@ const getSmokingQuantityQuestionOverrides = ({ name, value, conditionalValue, + frequency, smokingType }) => { const question = getQuestion(page) @@ -689,6 +712,7 @@ const getSmokingQuantityQuestionOverrides = ({ const hint = hasHintOverride ? variantInput.hint : question.input.hint const questionType = variant.type || question.type const conditionalHref = '#smoking-quantity-other' + const maxHours = getSmokingQuantityOtherMaxHours(step.type, frequency) const items = variant.options ? variant.options.map((option) => { const item = toComponentItem(option) @@ -732,7 +756,7 @@ const getSmokingQuantityQuestionOverrides = ({ required: true, type: 'number', min: 0.5, - max: 24, + max: maxHours, answerKey: 'smokingQuantityOther', value: conditionalValue, href: conditionalHref @@ -757,7 +781,7 @@ const getSmokingQuantityQuestionOverrides = ({ href: conditionalHref }, max: { - text: 'Number of hours must be 24 or fewer', + text: `Number of hours must be ${maxHours} or fewer`, href: conditionalHref } }, @@ -933,6 +957,7 @@ const getSmokingContentQuestionOverrides = ({ : `answers[${step.type}][smokingQuantity]`, value: isSettingSpecific ? settingAnswer.smokingQuantity : answer.smokingQuantity, conditionalValue: isSettingSpecific ? settingAnswer.smokingQuantityOther : answer.smokingQuantityOther, + frequency: isSettingSpecific ? settingAnswer.smokingFrequency : answer.smokingFrequency, smokingType }) } @@ -974,6 +999,7 @@ const getSmokingContentQuestionOverrides = ({ name: `answers[${step.type}][${smokingChange.answerKey}][quantity]`, value: changeAnswer.quantity, conditionalValue: changeAnswer.smokingQuantityOther, + frequency: answer.smokingFrequency, smokingType }) } diff --git a/app/prototype_v4_2/lib/tobacco-flow.js b/app/prototype_v4_2/lib/tobacco-flow.js index e96f1a96..06117276 100644 --- a/app/prototype_v4_2/lib/tobacco-flow.js +++ b/app/prototype_v4_2/lib/tobacco-flow.js @@ -274,6 +274,13 @@ const smokingFrequencyPeriods = { yearly: 'a year' } +const smokingFrequencyMaxHours = { + daily: 24, + weekly: 168, + monthly: 744, + yearly: 8760 +} + /** * Convert a smoking frequency value into a period phrase. * @@ -284,6 +291,21 @@ const getSmokingFrequencyPeriod = (frequency) => { return smokingFrequencyPeriods[frequency] || '' } +/** + * Get the maximum number of hours allowed for an "another amount" shisha answer. + * + * @param {string} type - Tobacco type key. + * @param {string} frequency - Selected smoking frequency. + * @returns {number} Maximum number of hours. + */ +const getSmokingQuantityOtherMaxHours = (type, frequency) => { + if (type !== 'shisha') { + return 24 + } + + return smokingFrequencyMaxHours[frequency] || 24 +} + /** * Replace the default "normal day/week/month/year" phrase in a heading. * @@ -646,6 +668,7 @@ const getSmokingQuantityQuestionOverrides = ({ name, value, conditionalValue, + frequency, smokingType }) => { const question = getQuestion(page) @@ -655,6 +678,7 @@ const getSmokingQuantityQuestionOverrides = ({ const hint = hasHintOverride ? variantInput.hint : question.input.hint const questionType = variant.type || question.type const conditionalHref = '#smoking-quantity-other' + const maxHours = getSmokingQuantityOtherMaxHours(step.type, frequency) const items = variant.options ? variant.options.map((option) => { const item = toComponentItem(option) @@ -696,7 +720,7 @@ const getSmokingQuantityQuestionOverrides = ({ required: true, type: 'number', min: 0.5, - max: 24, + max: maxHours, answerKey: 'smokingQuantityOther', value: conditionalValue, href: conditionalHref @@ -721,7 +745,7 @@ const getSmokingQuantityQuestionOverrides = ({ href: conditionalHref }, max: { - text: 'Number of hours must be 24 or fewer', + text: `Number of hours must be ${maxHours} or fewer`, href: conditionalHref } }, @@ -878,6 +902,7 @@ const getSmokingContentQuestionOverrides = ({ name: `answers[${step.type}][smokingQuantity]`, value: answer.smokingQuantity, conditionalValue: answer.smokingQuantityOther, + frequency: answer.smokingFrequency, smokingType }) } @@ -919,6 +944,7 @@ const getSmokingContentQuestionOverrides = ({ name: `answers[${step.type}][${smokingChange.answerKey}][quantity]`, value: changeAnswer.quantity, conditionalValue: changeAnswer.smokingQuantityOther, + frequency: answer.smokingFrequency, smokingType }) } diff --git a/app/prototype_v4_3/lib/tobacco-flow.js b/app/prototype_v4_3/lib/tobacco-flow.js index 9c317640..3dd4e3b5 100644 --- a/app/prototype_v4_3/lib/tobacco-flow.js +++ b/app/prototype_v4_3/lib/tobacco-flow.js @@ -319,6 +319,13 @@ const smokingFrequencyPeriods = { yearly: 'a year' } +const smokingFrequencyMaxHours = { + daily: 24, + weekly: 168, + monthly: 744, + yearly: 8760 +} + /** * Convert a smoking frequency value into a period phrase. * @@ -329,6 +336,21 @@ const getSmokingFrequencyPeriod = (frequency) => { return smokingFrequencyPeriods[frequency] || '' } +/** + * Get the maximum number of hours allowed for an "another amount" shisha answer. + * + * @param {string} type - Tobacco type key. + * @param {string} frequency - Selected smoking frequency. + * @returns {number} Maximum number of hours. + */ +const getSmokingQuantityOtherMaxHours = (type, frequency) => { + if (type !== 'shisha') { + return 24 + } + + return smokingFrequencyMaxHours[frequency] || 24 +} + /** * Replace the default "normal day/week/month/year" phrase in a heading. * @@ -733,6 +755,7 @@ const getSmokingQuantityQuestionOverrides = ({ name, value, conditionalValue, + frequency, smokingType }) => { const question = getQuestion(page) @@ -742,6 +765,7 @@ const getSmokingQuantityQuestionOverrides = ({ const hint = hasHintOverride ? variantInput.hint : question.input.hint const questionType = variant.type || question.type const conditionalHref = '#smoking-quantity-other' + const maxHours = getSmokingQuantityOtherMaxHours(step.type, frequency) const items = variant.options ? variant.options.map((option) => { const item = toComponentItem(option) @@ -783,7 +807,7 @@ const getSmokingQuantityQuestionOverrides = ({ required: true, type: 'number', min: 0.5, - max: 24, + max: maxHours, answerKey: 'smokingQuantityOther', value: conditionalValue, href: conditionalHref @@ -808,7 +832,7 @@ const getSmokingQuantityQuestionOverrides = ({ href: conditionalHref }, max: { - text: 'Number of hours must be 24 or fewer', + text: `Number of hours must be ${maxHours} or fewer`, href: conditionalHref } }, @@ -1048,6 +1072,7 @@ const getSmokingContentQuestionOverrides = ({ name: `answers[${step.type}][smokingQuantity]`, value: answer.smokingQuantity, conditionalValue: answer.smokingQuantityOther, + frequency: answer.smokingFrequency, smokingType }) } @@ -1089,6 +1114,7 @@ const getSmokingContentQuestionOverrides = ({ name: `answers[${step.type}][${smokingChange.answerKey}][quantity]`, value: changeAnswer.quantity, conditionalValue: changeAnswer.smokingQuantityOther, + frequency: answer.smokingFrequency, smokingType }) } From baa88d4b06246ad5d3fa42e51e9ab4be3eba15a9 Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Tue, 16 Jun 2026 15:01:40 +0100 Subject: [PATCH 6/9] Throw an error if frequency/quantity is wrong --- app/prototype_v4_1/lib/tobacco-flow.js | 113 ++++++++++++++++++++++++- app/prototype_v4_2/lib/tobacco-flow.js | 113 ++++++++++++++++++++++++- app/prototype_v4_3/lib/tobacco-flow.js | 113 ++++++++++++++++++++++++- 3 files changed, 336 insertions(+), 3 deletions(-) diff --git a/app/prototype_v4_1/lib/tobacco-flow.js b/app/prototype_v4_1/lib/tobacco-flow.js index b81586fb..ed127e56 100644 --- a/app/prototype_v4_1/lib/tobacco-flow.js +++ b/app/prototype_v4_1/lib/tobacco-flow.js @@ -302,6 +302,104 @@ const getSmokingComparisonQuantity = (type, answer) => { return getSmokingQuantity(type, answer) } +const rollingTobaccoQuantityValues = { + less_than_10: 10, + '10_to_30': 20, + '31_to_50': 40.5, + '51_to_75': 63, + '76_to_100': 88, + more_than_100: 100 +} + +const smokingFrequencyRateMultipliers = { + daily: 365, + weekly: 52, + monthly: 12, + yearly: 1 +} + +/** + * Check whether a value can be compared as a submitted numeric answer. + * + * @param {*} value - Submitted value. + * @returns {boolean} True when value is numeric. + */ +const isComparableNumber = (value) => { + if (value === undefined || value === null || String(value).trim() === '') { + return false + } + + const number = Number(value) + + return Number.isFinite(number) +} + +/** + * Get an annualised quantity value for cross-frequency comparisons. + * + * @param {string} type - Tobacco type key. + * @param {*} quantity - Submitted quantity answer. + * @param {string} frequency - Submitted frequency answer. + * @returns {number|undefined} Annualised quantity, when comparable. + */ +const getSmokingQuantityAnnualRate = (type, quantity, frequency) => { + const multiplier = smokingFrequencyRateMultipliers[frequency] + + if (!multiplier) { + return undefined + } + + if (type === 'rolling_tobacco') { + const value = rollingTobaccoQuantityValues[quantity] + + return value ? value * multiplier : undefined + } + + return isComparableNumber(quantity) ? Number(quantity) * multiplier : undefined +} + +/** + * Check whether a changed-smoking quantity contradicts the selected change direction. + * + * @param {Object} params - Comparison inputs. + * @returns {Object|undefined} Validation error when the quantities contradict. + */ +const getSmokingQuantityChangeComparisonError = ({ + page, + step, + answer, + changeAnswer, + href +}) => { + if (page !== 'smoking-quantity-change' || !step.change || !answer.smokingQuantity || !answer.smokingFrequency || !changeAnswer.quantity || !changeAnswer.frequency) { + return undefined + } + + const currentRate = getSmokingQuantityAnnualRate(step.type, answer.smokingQuantity, answer.smokingFrequency) + const changedRate = getSmokingQuantityAnnualRate(step.type, changeAnswer.quantity, changeAnswer.frequency) + + if (currentRate === undefined || changedRate === undefined) { + return undefined + } + + const contradictsChange = step.change === 'greater' + ? changedRate <= currentRate + : changedRate >= currentRate + + if (!contradictsChange) { + return undefined + } + + const comparisonText = step.type === 'rolling_tobacco' && step.change === 'fewer' + ? 'less' + : smokingChangeTypes[step.change]?.label + + return { + text: `Amount smoked must be ${comparisonText} than ${[getSmokingComparisonQuantity(step.type, answer.smokingQuantity), getSmokingFrequencyPeriod(answer.smokingFrequency)].filter(Boolean).join(' ')}`, + href + } +} + const smokingFrequencyPeriods = { daily: 'a day', weekly: 'a week', @@ -1133,10 +1231,23 @@ const validateSmokingTypeQuestion = (req, page, step) => { } } - return validateQuestion(req.session.data.answers, page, { + const validationErrors = validateQuestion(req.session.data.answers, page, { ...overrides, errors }) + const comparisonError = getSmokingQuantityChangeComparisonError({ + page, + step, + answer, + changeAnswer, + href: `#${errorHref}` + }) + + if (comparisonError && !validationErrors.some((error) => error.href === comparisonError.href)) { + validationErrors.push(comparisonError) + } + + return validationErrors } module.exports = { diff --git a/app/prototype_v4_2/lib/tobacco-flow.js b/app/prototype_v4_2/lib/tobacco-flow.js index 06117276..7ec2d6ac 100644 --- a/app/prototype_v4_2/lib/tobacco-flow.js +++ b/app/prototype_v4_2/lib/tobacco-flow.js @@ -267,6 +267,104 @@ const getSmokingComparisonQuantity = (type, answer) => { return getSmokingQuantity(type, answer) } +const rollingTobaccoQuantityValues = { + less_than_10: 10, + '10_to_30': 20, + '31_to_50': 40.5, + '51_to_75': 63, + '76_to_100': 88, + more_than_100: 100 +} + +const smokingFrequencyRateMultipliers = { + daily: 365, + weekly: 52, + monthly: 12, + yearly: 1 +} + +/** + * Check whether a value can be compared as a submitted numeric answer. + * + * @param {*} value - Submitted value. + * @returns {boolean} True when value is numeric. + */ +const isComparableNumber = (value) => { + if (value === undefined || value === null || String(value).trim() === '') { + return false + } + + const number = Number(value) + + return Number.isFinite(number) +} + +/** + * Get an annualised quantity value for cross-frequency comparisons. + * + * @param {string} type - Tobacco type key. + * @param {*} quantity - Submitted quantity answer. + * @param {string} frequency - Submitted frequency answer. + * @returns {number|undefined} Annualised quantity, when comparable. + */ +const getSmokingQuantityAnnualRate = (type, quantity, frequency) => { + const multiplier = smokingFrequencyRateMultipliers[frequency] + + if (!multiplier) { + return undefined + } + + if (type === 'rolling_tobacco') { + const value = rollingTobaccoQuantityValues[quantity] + + return value ? value * multiplier : undefined + } + + return isComparableNumber(quantity) ? Number(quantity) * multiplier : undefined +} + +/** + * Check whether a changed-smoking quantity contradicts the selected change direction. + * + * @param {Object} params - Comparison inputs. + * @returns {Object|undefined} Validation error when the quantities contradict. + */ +const getSmokingQuantityChangeComparisonError = ({ + page, + step, + answer, + changeAnswer, + href +}) => { + if (page !== 'smoking-quantity-change' || !step.change || !answer.smokingQuantity || !answer.smokingFrequency || !changeAnswer.quantity || !changeAnswer.frequency) { + return undefined + } + + const currentRate = getSmokingQuantityAnnualRate(step.type, answer.smokingQuantity, answer.smokingFrequency) + const changedRate = getSmokingQuantityAnnualRate(step.type, changeAnswer.quantity, changeAnswer.frequency) + + if (currentRate === undefined || changedRate === undefined) { + return undefined + } + + const contradictsChange = step.change === 'greater' + ? changedRate <= currentRate + : changedRate >= currentRate + + if (!contradictsChange) { + return undefined + } + + const comparisonText = step.type === 'rolling_tobacco' && step.change === 'fewer' + ? 'less' + : smokingChangeTypes[step.change]?.label + + return { + text: `Amount smoked must be ${comparisonText} than ${[getSmokingComparisonQuantity(step.type, answer.smokingQuantity), getSmokingFrequencyPeriod(answer.smokingFrequency)].filter(Boolean).join(' ')}`, + href + } +} + const smokingFrequencyPeriods = { daily: 'a day', weekly: 'a week', @@ -1146,10 +1244,23 @@ const validateSmokingTypeQuestion = (req, page, step) => { } } - return validateQuestion(req.session.data.answers, page, { + const validationErrors = validateQuestion(req.session.data.answers, page, { ...overrides, errors }) + const comparisonError = getSmokingQuantityChangeComparisonError({ + page, + step, + answer: context.answer, + changeAnswer: context.changeAnswer, + href: `#${errorHref}` + }) + + if (comparisonError && !validationErrors.some((error) => error.href === comparisonError.href)) { + validationErrors.push(comparisonError) + } + + return validationErrors } /** diff --git a/app/prototype_v4_3/lib/tobacco-flow.js b/app/prototype_v4_3/lib/tobacco-flow.js index 3dd4e3b5..5808d114 100644 --- a/app/prototype_v4_3/lib/tobacco-flow.js +++ b/app/prototype_v4_3/lib/tobacco-flow.js @@ -312,6 +312,104 @@ const getSmokingComparisonQuantity = (type, answer) => { return getSmokingQuantity(type, answer) } +const rollingTobaccoQuantityValues = { + less_than_10: 10, + '10_to_30': 20, + '31_to_50': 40.5, + '51_to_75': 63, + '76_to_100': 88, + more_than_100: 100 +} + +const smokingFrequencyRateMultipliers = { + daily: 365, + weekly: 52, + monthly: 12, + yearly: 1 +} + +/** + * Check whether a value can be compared as a submitted numeric answer. + * + * @param {*} value - Submitted value. + * @returns {boolean} True when value is numeric. + */ +const isComparableNumber = (value) => { + if (value === undefined || value === null || String(value).trim() === '') { + return false + } + + const number = Number(value) + + return Number.isFinite(number) +} + +/** + * Get an annualised quantity value for cross-frequency comparisons. + * + * @param {string} type - Tobacco type key. + * @param {*} quantity - Submitted quantity answer. + * @param {string} frequency - Submitted frequency answer. + * @returns {number|undefined} Annualised quantity, when comparable. + */ +const getSmokingQuantityAnnualRate = (type, quantity, frequency) => { + const multiplier = smokingFrequencyRateMultipliers[frequency] + + if (!multiplier) { + return undefined + } + + if (type === 'rolling_tobacco') { + const value = rollingTobaccoQuantityValues[quantity] + + return value ? value * multiplier : undefined + } + + return isComparableNumber(quantity) ? Number(quantity) * multiplier : undefined +} + +/** + * Check whether a changed-smoking quantity contradicts the selected change direction. + * + * @param {Object} params - Comparison inputs. + * @returns {Object|undefined} Validation error when the quantities contradict. + */ +const getSmokingQuantityChangeComparisonError = ({ + page, + step, + answer, + changeAnswer, + href +}) => { + if (page !== 'smoking-quantity-change' || !step.change || !answer.smokingQuantity || !answer.smokingFrequency || !changeAnswer.quantity || !changeAnswer.frequency) { + return undefined + } + + const currentRate = getSmokingQuantityAnnualRate(step.type, answer.smokingQuantity, answer.smokingFrequency) + const changedRate = getSmokingQuantityAnnualRate(step.type, changeAnswer.quantity, changeAnswer.frequency) + + if (currentRate === undefined || changedRate === undefined) { + return undefined + } + + const contradictsChange = step.change === 'greater' + ? changedRate <= currentRate + : changedRate >= currentRate + + if (!contradictsChange) { + return undefined + } + + const comparisonText = step.type === 'rolling_tobacco' && step.change === 'fewer' + ? 'less' + : smokingChangeTypes[step.change]?.label + + return { + text: `Amount smoked must be ${comparisonText} than ${[getSmokingComparisonQuantity(step.type, answer.smokingQuantity), getSmokingFrequencyPeriod(answer.smokingFrequency)].filter(Boolean).join(' ')}`, + href + } +} + const smokingFrequencyPeriods = { daily: 'a day', weekly: 'a week', @@ -1349,10 +1447,23 @@ const validateSmokingTypeQuestion = (req, page, step) => { } } - return validateQuestion(req.session.data.answers, page, { + const validationErrors = validateQuestion(req.session.data.answers, page, { ...overrides, errors }) + const comparisonError = getSmokingQuantityChangeComparisonError({ + page, + step, + answer: context.answer, + changeAnswer: context.changeAnswer, + href: `#${errorHref}` + }) + + if (comparisonError && !validationErrors.some((error) => error.href === comparisonError.href)) { + validationErrors.push(comparisonError) + } + + return validationErrors } /** From c00328a51388f5e103cbf85cc0c0313412efb03c Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Tue, 16 Jun 2026 16:24:57 +0100 Subject: [PATCH 7/9] Fix tobacco smoking change error messages --- app/prototype_v4_1/lib/tobacco-flow.js | 87 +++++++++++++++++++++----- app/prototype_v4_2/lib/tobacco-flow.js | 84 ++++++++++++++++++++----- app/prototype_v4_3/lib/tobacco-flow.js | 83 +++++++++++++++++++----- 3 files changed, 211 insertions(+), 43 deletions(-) diff --git a/app/prototype_v4_1/lib/tobacco-flow.js b/app/prototype_v4_1/lib/tobacco-flow.js index ed127e56..7786f4f2 100644 --- a/app/prototype_v4_1/lib/tobacco-flow.js +++ b/app/prototype_v4_1/lib/tobacco-flow.js @@ -312,10 +312,10 @@ const rollingTobaccoQuantityValues = { } const smokingFrequencyRateMultipliers = { - daily: 365, - weekly: 52, - monthly: 12, - yearly: 1 + daily: 7, + weekly: 1, + monthly: 0.25, + yearly: 1 / 52 } /** @@ -335,14 +335,14 @@ const isComparableNumber = (value) => { } /** - * Get an annualised quantity value for cross-frequency comparisons. + * Get a normalised quantity value for cross-frequency comparisons. * * @param {string} type - Tobacco type key. * @param {*} quantity - Submitted quantity answer. * @param {string} frequency - Submitted frequency answer. - * @returns {number|undefined} Annualised quantity, when comparable. + * @returns {number|undefined} Normalised quantity, when comparable. */ -const getSmokingQuantityAnnualRate = (type, quantity, frequency) => { +const getSmokingQuantityComparisonRate = (type, quantity, frequency) => { const multiplier = smokingFrequencyRateMultipliers[frequency] if (!multiplier) { @@ -358,6 +358,21 @@ const getSmokingQuantityAnnualRate = (type, quantity, frequency) => { return isComparableNumber(quantity) ? Number(quantity) * multiplier : undefined } +/** + * Build the left side of a changed-quantity comparison error. + * + * @param {string} type - Tobacco type key. + * @returns {string} Error phrase. + */ +const getSmokingQuantityChangeErrorPhrase = (type) => { + const heading = removeQuestionMark(getSmokingTypeHeadings(type, true).quantityHeading || '') + .replace(/ in a normal (day|week|month|year)$/, '') + + return upperFirst(lowerFirst(heading) + .replace(/^(how (?:much|many) .+?) did you normally smoke$/, '$1 you normally smoked') + .replace(/^(how (?:much|many) .+?) did you smoke$/, '$1 you normally smoked')) +} + /** * Check whether a changed-smoking quantity contradicts the selected change direction. * @@ -375,8 +390,8 @@ const getSmokingQuantityChangeComparisonError = ({ return undefined } - const currentRate = getSmokingQuantityAnnualRate(step.type, answer.smokingQuantity, answer.smokingFrequency) - const changedRate = getSmokingQuantityAnnualRate(step.type, changeAnswer.quantity, changeAnswer.frequency) + const currentRate = getSmokingQuantityComparisonRate(step.type, answer.smokingQuantity, answer.smokingFrequency) + const changedRate = getSmokingQuantityComparisonRate(step.type, changeAnswer.quantity, changeAnswer.frequency) if (currentRate === undefined || changedRate === undefined) { return undefined @@ -395,7 +410,7 @@ const getSmokingQuantityChangeComparisonError = ({ : smokingChangeTypes[step.change]?.label return { - text: `Amount smoked must be ${comparisonText} than ${[getSmokingComparisonQuantity(step.type, answer.smokingQuantity), getSmokingFrequencyPeriod(answer.smokingFrequency)].filter(Boolean).join(' ')}`, + text: `${getSmokingQuantityChangeErrorPhrase(step.type)} must be ${comparisonText} than ${[getSmokingComparisonQuantity(step.type, answer.smokingQuantity), getSmokingFrequencyComparisonPeriod(answer.smokingFrequency)].filter(Boolean).join(' ')}`, href } } @@ -407,6 +422,13 @@ const smokingFrequencyPeriods = { yearly: 'a year' } +const smokingFrequencyComparisonPeriods = { + daily: 'per day', + weekly: 'per week', + monthly: 'per month', + yearly: 'per year' +} + const smokingFrequencyMaxHours = { daily: 24, weekly: 168, @@ -424,6 +446,16 @@ const getSmokingFrequencyPeriod = (frequency) => { return smokingFrequencyPeriods[frequency] || '' } +/** + * Convert a smoking frequency value into a comparison phrase. + * + * @param {string} frequency - Frequency value. + * @returns {string} Comparison phrase, for example `per day`. + */ +const getSmokingFrequencyComparisonPeriod = (frequency) => { + return smokingFrequencyComparisonPeriods[frequency] || '' +} + /** * Get the maximum number of hours allowed for an "another amount" shisha answer. * @@ -786,6 +818,16 @@ const getQuestionItemsWithLabels = (id, labels = {}, hintOverrides = {}) => { }) } +/** + * Check whether a smoking quantity must be a whole number. + * + * @param {string} type - Tobacco type key. + * @returns {boolean} True when decimal quantities should be rejected. + */ +const requiresWholeNumberSmokingQuantity = (type) => { + return !['rolling_tobacco', 'shisha'].includes(type) +} + /** * Build runtime overrides for a quantity question. * @@ -811,6 +853,14 @@ const getSmokingQuantityQuestionOverrides = ({ const questionType = variant.type || question.type const conditionalHref = '#smoking-quantity-other' const maxHours = getSmokingQuantityOtherMaxHours(step.type, frequency) + const validation = { + ...variant.validation + } + + if (requiresWholeNumberSmokingQuantity(step.type)) { + validation.integer = true + } + const items = variant.options ? variant.options.map((option) => { const item = toComponentItem(option) @@ -848,7 +898,7 @@ const getSmokingQuantityQuestionOverrides = ({ suffix: questionType === 'text' ? smokingType.suffix : undefined }, validation: { - ...variant.validation, + ...validation, conditional: { another_amount: { required: true, @@ -874,6 +924,10 @@ const getSmokingQuantityQuestionOverrides = ({ text: 'Number of hours must be a number', href: conditionalHref }, + integer: { + text: `${upperFirst(getAnswerPhraseFromHeading(heading))} must be a whole number`, + href: `#${question.input.id}` + }, min: { text: 'Number of hours must be 0.5 or more', href: conditionalHref @@ -894,6 +948,10 @@ const lowerFirst = (value = '') => { return value ? `${value.charAt(0).toLowerCase()}${value.slice(1)}` : '' } +const upperFirst = (value = '') => { + return value ? `${value.charAt(0).toUpperCase()}${value.slice(1)}` : '' +} + /** * Convert a question heading into an error-message answer phrase. * @@ -907,9 +965,10 @@ const getAnswerPhraseFromHeading = (heading = '') => { .replace(/^how long did you smoke /, 'how long you smoked ') .replace(/^how long do you currently smoke /, 'how long you currently smoke ') .replace(/^how long do you smoke /, 'how long you smoke ') - .replace(/^(how (?:much|many|long) .+?) did you smoke /, '$1 you smoked ') - .replace(/^(how (?:much|many|long) .+?) do you currently smoke /, '$1 you currently smoke ') - .replace(/^(how (?:much|many|long) .+?) do you smoke /, '$1 you smoke ') + .replace(/^(how (?:much|many|long) .+?) did you normally smoke(?= |$)/, '$1 you normally smoked') + .replace(/^(how (?:much|many|long) .+?) did you smoke(?= |$)/, '$1 you smoked') + .replace(/^(how (?:much|many|long) .+?) do you currently smoke(?= |$)/, '$1 you currently smoke') + .replace(/^(how (?:much|many|long) .+?) do you smoke(?= |$)/, '$1 you smoke') } /** diff --git a/app/prototype_v4_2/lib/tobacco-flow.js b/app/prototype_v4_2/lib/tobacco-flow.js index 7ec2d6ac..98743654 100644 --- a/app/prototype_v4_2/lib/tobacco-flow.js +++ b/app/prototype_v4_2/lib/tobacco-flow.js @@ -277,10 +277,10 @@ const rollingTobaccoQuantityValues = { } const smokingFrequencyRateMultipliers = { - daily: 365, - weekly: 52, - monthly: 12, - yearly: 1 + daily: 7, + weekly: 1, + monthly: 0.25, + yearly: 1 / 52 } /** @@ -300,14 +300,14 @@ const isComparableNumber = (value) => { } /** - * Get an annualised quantity value for cross-frequency comparisons. + * Get a normalised quantity value for cross-frequency comparisons. * * @param {string} type - Tobacco type key. * @param {*} quantity - Submitted quantity answer. * @param {string} frequency - Submitted frequency answer. - * @returns {number|undefined} Annualised quantity, when comparable. + * @returns {number|undefined} Normalised quantity, when comparable. */ -const getSmokingQuantityAnnualRate = (type, quantity, frequency) => { +const getSmokingQuantityComparisonRate = (type, quantity, frequency) => { const multiplier = smokingFrequencyRateMultipliers[frequency] if (!multiplier) { @@ -323,6 +323,21 @@ const getSmokingQuantityAnnualRate = (type, quantity, frequency) => { return isComparableNumber(quantity) ? Number(quantity) * multiplier : undefined } +/** + * Build the left side of a changed-quantity comparison error. + * + * @param {string} type - Tobacco type key. + * @returns {string} Error phrase. + */ +const getSmokingQuantityChangeErrorPhrase = (type) => { + const heading = removeQuestionMark(getSmokingTypeHeadings(type, true).quantityHeading || '') + .replace(/ in a normal (day|week|month|year)$/, '') + + return upperFirst(lowerFirst(heading) + .replace(/^(how (?:much|many) .+?) did you normally smoke$/, '$1 you normally smoked') + .replace(/^(how (?:much|many) .+?) did you smoke$/, '$1 you normally smoked')) +} + /** * Check whether a changed-smoking quantity contradicts the selected change direction. * @@ -340,8 +355,8 @@ const getSmokingQuantityChangeComparisonError = ({ return undefined } - const currentRate = getSmokingQuantityAnnualRate(step.type, answer.smokingQuantity, answer.smokingFrequency) - const changedRate = getSmokingQuantityAnnualRate(step.type, changeAnswer.quantity, changeAnswer.frequency) + const currentRate = getSmokingQuantityComparisonRate(step.type, answer.smokingQuantity, answer.smokingFrequency) + const changedRate = getSmokingQuantityComparisonRate(step.type, changeAnswer.quantity, changeAnswer.frequency) if (currentRate === undefined || changedRate === undefined) { return undefined @@ -360,7 +375,7 @@ const getSmokingQuantityChangeComparisonError = ({ : smokingChangeTypes[step.change]?.label return { - text: `Amount smoked must be ${comparisonText} than ${[getSmokingComparisonQuantity(step.type, answer.smokingQuantity), getSmokingFrequencyPeriod(answer.smokingFrequency)].filter(Boolean).join(' ')}`, + text: `${getSmokingQuantityChangeErrorPhrase(step.type)} must be ${comparisonText} than ${[getSmokingComparisonQuantity(step.type, answer.smokingQuantity), getSmokingFrequencyComparisonPeriod(answer.smokingFrequency)].filter(Boolean).join(' ')}`, href } } @@ -372,6 +387,13 @@ const smokingFrequencyPeriods = { yearly: 'a year' } +const smokingFrequencyComparisonPeriods = { + daily: 'per day', + weekly: 'per week', + monthly: 'per month', + yearly: 'per year' +} + const smokingFrequencyMaxHours = { daily: 24, weekly: 168, @@ -389,6 +411,16 @@ const getSmokingFrequencyPeriod = (frequency) => { return smokingFrequencyPeriods[frequency] || '' } +/** + * Convert a smoking frequency value into a comparison phrase. + * + * @param {string} frequency - Frequency value. + * @returns {string} Comparison phrase, for example `per day`. + */ +const getSmokingFrequencyComparisonPeriod = (frequency) => { + return smokingFrequencyComparisonPeriods[frequency] || '' +} + /** * Get the maximum number of hours allowed for an "another amount" shisha answer. * @@ -752,6 +784,16 @@ const getQuestionItemsWithLabels = (id, labels = {}, hintOverrides = {}) => { }) } +/** + * Check whether a smoking quantity must be a whole number. + * + * @param {string} type - Tobacco type key. + * @returns {boolean} True when decimal quantities should be rejected. + */ +const requiresWholeNumberSmokingQuantity = (type) => { + return !['rolling_tobacco', 'shisha'].includes(type) +} + /** * Build runtime overrides for a quantity question. * @@ -777,6 +819,14 @@ const getSmokingQuantityQuestionOverrides = ({ const questionType = variant.type || question.type const conditionalHref = '#smoking-quantity-other' const maxHours = getSmokingQuantityOtherMaxHours(step.type, frequency) + const validation = { + ...variant.validation + } + + if (requiresWholeNumberSmokingQuantity(step.type)) { + validation.integer = true + } + const items = variant.options ? variant.options.map((option) => { const item = toComponentItem(option) @@ -812,7 +862,7 @@ const getSmokingQuantityQuestionOverrides = ({ suffix: questionType === 'text' ? smokingType.suffix : undefined }, validation: { - ...variant.validation, + ...validation, conditional: { another_amount: { required: true, @@ -838,6 +888,10 @@ const getSmokingQuantityQuestionOverrides = ({ text: 'Number of hours must be a number', href: conditionalHref }, + integer: { + text: `${upperFirst(getAnswerPhraseFromHeading(heading))} must be a whole number`, + href: `#${question.input.id}` + }, min: { text: 'Number of hours must be 0.5 or more', href: conditionalHref @@ -875,10 +929,10 @@ const getAnswerPhraseFromHeading = (heading = '') => { .replace(/^how long did you smoke /, 'how long you smoked ') .replace(/^how long do you currently smoke /, 'how long you currently smoke ') .replace(/^how long do you smoke /, 'how long you smoke ') - .replace(/^(how (?:much|many|long) .+?) did you normally smoke/, '$1 you normally smoked') - .replace(/^(how (?:much|many|long) .+?) did you smoke /, '$1 you smoked ') - .replace(/^(how (?:much|many|long) .+?) do you currently smoke /, '$1 you currently smoke ') - .replace(/^(how (?:much|many|long) .+?) do you smoke /, '$1 you smoke ') + .replace(/^(how (?:much|many|long) .+?) did you normally smoke(?= |$)/, '$1 you normally smoked') + .replace(/^(how (?:much|many|long) .+?) did you smoke(?= |$)/, '$1 you smoked') + .replace(/^(how (?:much|many|long) .+?) do you currently smoke(?= |$)/, '$1 you currently smoke') + .replace(/^(how (?:much|many|long) .+?) do you smoke(?= |$)/, '$1 you smoke') } /** diff --git a/app/prototype_v4_3/lib/tobacco-flow.js b/app/prototype_v4_3/lib/tobacco-flow.js index 5808d114..72120af6 100644 --- a/app/prototype_v4_3/lib/tobacco-flow.js +++ b/app/prototype_v4_3/lib/tobacco-flow.js @@ -322,10 +322,10 @@ const rollingTobaccoQuantityValues = { } const smokingFrequencyRateMultipliers = { - daily: 365, - weekly: 52, - monthly: 12, - yearly: 1 + daily: 7, + weekly: 1, + monthly: 0.25, + yearly: 1 / 52 } /** @@ -345,14 +345,14 @@ const isComparableNumber = (value) => { } /** - * Get an annualised quantity value for cross-frequency comparisons. + * Get a normalised quantity value for cross-frequency comparisons. * * @param {string} type - Tobacco type key. * @param {*} quantity - Submitted quantity answer. * @param {string} frequency - Submitted frequency answer. - * @returns {number|undefined} Annualised quantity, when comparable. + * @returns {number|undefined} Normalised quantity, when comparable. */ -const getSmokingQuantityAnnualRate = (type, quantity, frequency) => { +const getSmokingQuantityComparisonRate = (type, quantity, frequency) => { const multiplier = smokingFrequencyRateMultipliers[frequency] if (!multiplier) { @@ -368,6 +368,21 @@ const getSmokingQuantityAnnualRate = (type, quantity, frequency) => { return isComparableNumber(quantity) ? Number(quantity) * multiplier : undefined } +/** + * Build the left side of a changed-quantity comparison error. + * + * @param {string} type - Tobacco type key. + * @returns {string} Error phrase. + */ +const getSmokingQuantityChangeErrorPhrase = (type) => { + const heading = removeQuestionMark(getSmokingTypeHeadings(type, true).quantityHeading || '') + .replace(/ in a normal (day|week|month|year)$/, '') + + return upperFirst(lowerFirst(heading) + .replace(/^(how (?:much|many) .+?) did you normally smoke$/, '$1 you normally smoked') + .replace(/^(how (?:much|many) .+?) did you smoke$/, '$1 you normally smoked')) +} + /** * Check whether a changed-smoking quantity contradicts the selected change direction. * @@ -385,8 +400,8 @@ const getSmokingQuantityChangeComparisonError = ({ return undefined } - const currentRate = getSmokingQuantityAnnualRate(step.type, answer.smokingQuantity, answer.smokingFrequency) - const changedRate = getSmokingQuantityAnnualRate(step.type, changeAnswer.quantity, changeAnswer.frequency) + const currentRate = getSmokingQuantityComparisonRate(step.type, answer.smokingQuantity, answer.smokingFrequency) + const changedRate = getSmokingQuantityComparisonRate(step.type, changeAnswer.quantity, changeAnswer.frequency) if (currentRate === undefined || changedRate === undefined) { return undefined @@ -405,7 +420,7 @@ const getSmokingQuantityChangeComparisonError = ({ : smokingChangeTypes[step.change]?.label return { - text: `Amount smoked must be ${comparisonText} than ${[getSmokingComparisonQuantity(step.type, answer.smokingQuantity), getSmokingFrequencyPeriod(answer.smokingFrequency)].filter(Boolean).join(' ')}`, + text: `${getSmokingQuantityChangeErrorPhrase(step.type)} must be ${comparisonText} than ${[getSmokingComparisonQuantity(step.type, answer.smokingQuantity), getSmokingFrequencyComparisonPeriod(answer.smokingFrequency)].filter(Boolean).join(' ')}`, href } } @@ -417,6 +432,13 @@ const smokingFrequencyPeriods = { yearly: 'a year' } +const smokingFrequencyComparisonPeriods = { + daily: 'per day', + weekly: 'per week', + monthly: 'per month', + yearly: 'per year' +} + const smokingFrequencyMaxHours = { daily: 24, weekly: 168, @@ -434,6 +456,16 @@ const getSmokingFrequencyPeriod = (frequency) => { return smokingFrequencyPeriods[frequency] || '' } +/** + * Convert a smoking frequency value into a comparison phrase. + * + * @param {string} frequency - Frequency value. + * @returns {string} Comparison phrase, for example `per day`. + */ +const getSmokingFrequencyComparisonPeriod = (frequency) => { + return smokingFrequencyComparisonPeriods[frequency] || '' +} + /** * Get the maximum number of hours allowed for an "another amount" shisha answer. * @@ -839,6 +871,16 @@ const getQuestionItemsWithLabels = (id, labels = {}, hintOverrides = {}) => { }) } +/** + * Check whether a smoking quantity must be a whole number. + * + * @param {string} type - Tobacco type key. + * @returns {boolean} True when decimal quantities should be rejected. + */ +const requiresWholeNumberSmokingQuantity = (type) => { + return !['rolling_tobacco', 'shisha'].includes(type) +} + /** * Build runtime overrides for a quantity question. * @@ -864,6 +906,14 @@ const getSmokingQuantityQuestionOverrides = ({ const questionType = variant.type || question.type const conditionalHref = '#smoking-quantity-other' const maxHours = getSmokingQuantityOtherMaxHours(step.type, frequency) + const validation = { + ...variant.validation + } + + if (requiresWholeNumberSmokingQuantity(step.type)) { + validation.integer = true + } + const items = variant.options ? variant.options.map((option) => { const item = toComponentItem(option) @@ -899,7 +949,7 @@ const getSmokingQuantityQuestionOverrides = ({ suffix: questionType === 'text' ? smokingType.suffix : undefined }, validation: { - ...variant.validation, + ...validation, conditional: { another_amount: { required: true, @@ -925,6 +975,10 @@ const getSmokingQuantityQuestionOverrides = ({ text: 'Number of hours must be a number', href: conditionalHref }, + integer: { + text: `${upperFirst(getAnswerPhraseFromHeading(heading))} must be a whole number`, + href: `#${question.input.id}` + }, min: { text: 'Number of hours must be 0.5 or more', href: conditionalHref @@ -963,9 +1017,10 @@ const getAnswerPhraseFromHeading = (heading = '') => { .replace(/^how long did you smoke /, 'how long you smoked ') .replace(/^how long do you currently smoke /, 'how long you currently smoke ') .replace(/^how long do you smoke /, 'how long you smoke ') - .replace(/^(how (?:much|many|long) .+?) did you smoke /, '$1 you smoked ') - .replace(/^(how (?:much|many|long) .+?) do you currently smoke /, '$1 you currently smoke ') - .replace(/^(how (?:much|many|long) .+?) do you smoke /, '$1 you smoke ') + .replace(/^(how (?:much|many|long) .+?) did you normally smoke(?= |$)/, '$1 you normally smoked') + .replace(/^(how (?:much|many|long) .+?) did you smoke(?= |$)/, '$1 you smoked') + .replace(/^(how (?:much|many|long) .+?) do you currently smoke(?= |$)/, '$1 you currently smoke') + .replace(/^(how (?:much|many|long) .+?) do you smoke(?= |$)/, '$1 you smoke') } /** From df9dcb104e30ea1cacd8dd4af0a7b5893cfb3926 Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Tue, 16 Jun 2026 16:37:51 +0100 Subject: [PATCH 8/9] Prevent dynamic heading in v4.2 and v4.3 --- app/prototype_v4_2/lib/tobacco-flow.js | 8 +------- app/prototype_v4_3/lib/tobacco-flow.js | 8 +------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/app/prototype_v4_2/lib/tobacco-flow.js b/app/prototype_v4_2/lib/tobacco-flow.js index 98743654..074bca58 100644 --- a/app/prototype_v4_2/lib/tobacco-flow.js +++ b/app/prototype_v4_2/lib/tobacco-flow.js @@ -689,13 +689,7 @@ const getSmokingChangeHeading = (page, type, change, changeAnswer = {}, answer = } if (page === 'smoking-years-change') { - const quantity = getSmokingQuantity(type, changeAnswer.quantity) - - if (!quantity) { - return getQuestion('smoking-years-change').input.label - } - - return `How many years did you smoke ${[quantity, getSmokingFrequencyPeriod(answer.smokingFrequency)].filter(Boolean).join(' ')}?` + return getQuestion('smoking-years-change').input.label } return '' diff --git a/app/prototype_v4_3/lib/tobacco-flow.js b/app/prototype_v4_3/lib/tobacco-flow.js index 72120af6..71da00f7 100644 --- a/app/prototype_v4_3/lib/tobacco-flow.js +++ b/app/prototype_v4_3/lib/tobacco-flow.js @@ -737,13 +737,7 @@ const getSmokingChangeHeading = (page, type, change, changeAnswer = {}, answer = } if (page === 'smoking-years-change') { - const quantity = getSmokingQuantity(type, changeAnswer.quantity) - - if (!quantity) { - return getQuestion('smoking-years-change').input.label - } - - return `How many years did you smoke ${[quantity, getSmokingFrequencyPeriod(answer.smokingFrequency)].filter(Boolean).join(' ')}?` + return getQuestion('smoking-years-change').input.label } return '' From 4870402a58cc879f1374506ce31d9722e6a507e8 Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Fri, 19 Jun 2026 12:03:22 +0100 Subject: [PATCH 9/9] Add error message docs --- app/prototype_v4_1/docs/error-messages.md | 533 ++++++++++++++++++ app/prototype_v4_2/docs/error-messages.md | 609 +++++++++++++++++++++ app/prototype_v4_3/docs/error-messages.md | 627 ++++++++++++++++++++++ 3 files changed, 1769 insertions(+) create mode 100644 app/prototype_v4_1/docs/error-messages.md create mode 100644 app/prototype_v4_2/docs/error-messages.md create mode 100644 app/prototype_v4_3/docs/error-messages.md diff --git a/app/prototype_v4_1/docs/error-messages.md b/app/prototype_v4_1/docs/error-messages.md new file mode 100644 index 00000000..e88bebd7 --- /dev/null +++ b/app/prototype_v4_1/docs/error-messages.md @@ -0,0 +1,533 @@ +# Error messages + +This document summarises validation logic and error messages for `prototype_v4_1`. + +Sources: + +- `data/questions.yaml` for base question content, validation rules and error text +- `lib/question-validator.js` for shared validation behaviour +- `lib/tobacco-flow.js` for runtime tobacco question overrides +- `controllers/question.js` for route branching and answer-clearing logic + +## Shared validation logic + +- Blank values are `undefined`, `null`, an empty string after trimming, or an empty array. +- `required` errors stop further validation for that question or field. +- Date validation only accepts real calendar dates. If no date part is supplied, the `required` message is used; if any date part is supplied but the date is not real, the `invalid` message is used. +- Number validation rejects non-finite numbers, then checks whole-number, decimal-place, minimum and maximum rules in that order. +- Grouped text inputs validate each item first. Total minimum and maximum checks only run when the grouped values are present and numerically valid. +- Conditional reveal validation only runs for the selected trigger value. If the conditional value is blank, the conditional `required` message is used; number rules then use the question-level number error messages unless a runtime override replaces them. +- Error links default to `#${question.input.id || question.id}`. Date, grouped and conditional inputs usually set explicit `href` values. + +## Flow logic + +- `phone-questionnaire`: `yes` goes to `phone-questionnaire-exit`; `no` continues to `smoker`. +- `smoker`: `no` or `yes_fewer_than_100` goes to `not-eligible-for-screening`; current or previous smokers continue to `date-of-birth`. +- `date-of-birth`: valid dates outside the eligible scan age range go to `not-eligible-for-scan`; eligible users continue to `face-to-face-appointment`. +- `face-to-face-appointment`: `yes` goes to `book-appointment`; `no` continues to height and weight. +- `height-*` and `weight-*`: metric and imperial pages are alternatives. Submitting one unit clears the other unit answer. +- `cancer-diagnosis-relatives`: `yes` continues to `cancer-diagnosis-relatives-age`; `no` clears that answer and continues to smoking history. +- `age-stopped-smoking`: only shown when `smoker` is `yes_previous`; otherwise the answer is cleared and the flow continues to `periods-stopped-smoking`. +- `periods-stopped-smoking`: if `no`, `yearsStoppedSmoking` is cleared. +- `smoking-type`: `none` goes to `smoking-type-exit`; otherwise the selected tobacco types create the tobacco sub-flow in tobacco.yaml order. Former smokers skip each type-specific `smoking-status` question. +- Non-shisha tobacco types ask `smoking-frequency`, `smoking-quantity`, then `smoking-change`. Shisha asks `smoking-setting`, then asks `smoking-frequency` and `smoking-quantity` for each selected setting. +- `smoking-change`: selected `greater` and `fewer` options create corresponding changed-smoking follow-up steps. Unselected changed-smoking answer groups are cleared. +- `smoking-quantity` and `smoking-quantity-change`: if `another_amount` is not selected, the related `smokingQuantityOther` answer is cleared. + +## Runtime tobacco messages + +- Tobacco sub-flow pages use `getSmokingContentQuestionOverrides()` before validation. The validator sees the runtime heading, input name, selected value and tobacco-specific variant. +- `smoking-status`, `smoking-frequency`, `smoking-setting`, `smoking-quantity`, `smoking-change`, `smoking-frequency-change`, `smoking-quantity-change`, and `smoking-years-change` can replace their base `required` text with contextual text generated from the runtime heading. +- For headings beginning `How often`, the required message is `Select ...`; for `How much`, `How many`, or `How long`, the message is `Enter ...` for text inputs and `Select ...` for single-choice inputs; yes/no headings become `Select whether ...`. +- For numeric tobacco quantity text inputs, non-rolling-tobacco and non-shisha types require whole numbers. The integer message is generated as `[answer phrase] must be a whole number`. +- For shisha `another_amount`, the conditional input messages are `Enter the number of hours`, `Number of hours must be a number`, `Number of hours must be 0.5 or more`, and `Number of hours must be [maxHours] or fewer`. `maxHours` is 24 for daily/non-shisha, 168 weekly, 744 monthly, and 8760 yearly. +- `smoking-quantity-change` adds a comparison error when a `greater` answer is not greater than the original amount, or a `fewer` answer is not fewer than the original amount after normalising by frequency. The message is generated as `[quantity phrase] must be more/fewer/less than [original quantity] [original frequency]`. + +## Question messages + +### accept-terms + +Question: Terms of use + +Type: `multiple` + +Answer key: `acceptTerms` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Confirm that you have read and agree to the terms of use | `#accept-terms` | + +### phone-questionnaire + +Question: Confirm if you have completed a lung cancer risk questionnaire by phone + +Type: `single` + +Answer key: `phoneQuestionnaire` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have previously completed a lung cancer risk questionnaire by phone | `#phone-questionnaire` | + +### smoker + +Question: Tobacco smoking + +Type: `single` + +Answer key: `smoker` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever smoked tobacco | `#smoker` | + +### date-of-birth + +Question: What is your date of birth? + +Type: `date` + +Answer key: `dateOfBirth` + +Validation: required; type: date + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter your date of birth | `#dateOfBirth-day` | +| `invalid` | Enter a real date of birth | `#dateOfBirth-day` | + +### face-to-face-appointment + +Question: Check if you need a face-to-face appointment + +Type: `single` + +Answer key: `faceToFaceAppointment` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you need to leave the online service and ask for a face-to-face appointment | `#face-to-face-appointment` | + +### height-metric + +Question: Your height + +Type: `text` + +Answer key: `height` + +Validation: required; type: number; minimum 139.7; maximum 243.8 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter your height in centimetres | `#height-metric` | +| `invalid` | Enter your height in centimetres using numbers | `#height-metric` | +| `min` | Height in centimetres must be 139.7cm or more | `#height-metric` | +| `max` | Height in centimetres must be 243.8cm or fewer | `#height-metric` | + +### height-imperial + +Question: Your height + +Type: `text_group` + +Answer key: `height` + +Validation: grouped input items: feet (required, number, whole number, min 0); inches (required, number, whole number, min 0, max 11); group total: min 55, max 96 + +| Rule | Message | Link | +| --- | --- | --- | +| `feet.required` | Enter your height in feet | `#height-imperial-feet` | +| `feet.invalid` | Enter your height in feet using numbers | `#height-imperial-feet` | +| `feet.integer` | Height in feet must be a whole number | `#height-imperial-feet` | +| `feet.min` | Height in feet must be 0ft or more | `#height-imperial-feet` | +| `inches.required` | Enter your height in inches | `#height-imperial-inches` | +| `inches.invalid` | Enter your height in inches using numbers | `#height-imperial-inches` | +| `inches.integer` | Height in inches must be a whole number | `#height-imperial-inches` | +| `inches.min` | Height in inches must be 0in or more | `#height-imperial-inches` | +| `inches.max` | Height in inches must be 11in or fewer | `#height-imperial-inches` | +| `total.min` | Height must be between 4 feet 7 inches and 8 feet | `#height-imperial-feet` | +| `total.max` | Height must be between 4 feet 7 inches and 8 feet | `#height-imperial-feet` | + +### weight-metric + +Question: Your weight + +Type: `text` + +Answer key: `weight` + +Validation: required; type: number; maximum 2 decimal places; minimum 25.4; maximum 317.5 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter your weight in kilograms | `#weight-metric` | +| `invalid` | Enter your weight in kilograms using numbers | `#weight-metric` | +| `decimalPlaces` | Weight in kilograms must have 2 decimal places or fewer | `#weight-metric` | +| `min` | Weight in kilograms must be 25.4kg or more | `#weight-metric` | +| `max` | Weight in kilograms must be 317.5kg or fewer | `#weight-metric` | + +### weight-imperial + +Question: Your weight + +Type: `text_group` + +Answer key: `weight` + +Validation: grouped input items: stones (required, number, whole number, min 0); pounds (required, number, whole number, min 0, max 13); group total: min 56, max 700 + +| Rule | Message | Link | +| --- | --- | --- | +| `stones.required` | Enter your weight in stones | `#weight-imperial-stones` | +| `stones.invalid` | Enter your weight in stones using numbers | `#weight-imperial-stones` | +| `stones.integer` | Weight in stones must be a whole number | `#weight-imperial-stones` | +| `stones.min` | Weight in stones must be 0st or more | `#weight-imperial-stones` | +| `pounds.required` | Enter your weight in pounds | `#weight-imperial-pounds` | +| `pounds.invalid` | Enter your weight in pounds using numbers | `#weight-imperial-pounds` | +| `pounds.integer` | Weight in pounds must be a whole number | `#weight-imperial-pounds` | +| `pounds.min` | Weight in pounds must be 0lb or more | `#weight-imperial-pounds` | +| `pounds.max` | Weight in pounds must be 13lb or fewer | `#weight-imperial-pounds` | +| `total.min` | Weight must be between 4 stone and 50 stone | `#weight-imperial-stones` | +| `total.max` | Weight must be between 4 stone and 50 stone | `#weight-imperial-stones` | + +### gender + +Question: Your gender identity + +Type: `single` + +Answer key: `gender` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select which option best describes you | `#gender` | + +### sex + +Question: Your sex at birth + +Type: `single` + +Answer key: `sex` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select your sex at birth | `#sex` | + +### ethnicity + +Question: Your ethnic background + +Type: `single` + +Answer key: `ethnicity` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select your ethnic background | `#ethnicity` | + +### education + +Question: Your education + +Type: `single` + +Answer key: `education` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select the highest level of education you have completed | `#education` | + +### respiratory-conditions + +Question: Have you ever been diagnosed with any of the following respiratory conditions? + +Type: `multiple` + +Answer key: `respiratoryConditions` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select if you have ever been diagnosed with any respiratory conditions | `#respiratory-conditions` | + +### asbestos-at-work + +Question: Tell us if you might have been exposed to asbestos at work + +Type: `single` + +Answer key: `asbestosAtWork` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever worked in a job where you might have been exposed to asbestos | `#asbestos-at-work` | + +### asbestos-at-home + +Question: Tell us if you ever lived with anyone who worked with asbestos + +Type: `single` + +Answer key: `asbestosAtHome` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever lived with anyone who worked with asbestos | `#asbestos-at-home` | + +### cancer-diagnosis + +Question: Tell us if you have ever been diagnosed with cancer + +Type: `single` + +Answer key: `cancerDiagnosis` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever been diagnosed with cancer | `#cancer-diagnosis` | + +### cancer-diagnosis-relatives + +Question: Tell us if your parents, siblings or children have ever been diagnosed with lung cancer + +Type: `single` + +Answer key: `cancerDiagnosisRelatives` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether any of your parents, siblings or children have ever been diagnosed with lung cancer | `#cancer-diagnosis-relatives` | + +### cancer-diagnosis-relatives-age + +Question: If your relatives were under 60 when they were diagnosed + +Type: `single` + +Answer key: `cancerDiagnosisRelativesAge` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether any of your relatives were younger than 60 when they were diagnosed with lung cancer | `#cancer-diagnosis-relatives-age` | + +### age-started-smoking + +Question: How old were you when you started smoking? + +Type: `text` + +Answer key: `ageStartedSmoking` + +Validation: required; type: number; minimum 1; maximum 120 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter the age you started smoking | `#age-started-smoking` | +| `invalid` | Enter the age you started smoking using numbers | `#age-started-smoking` | +| `min` | Age you started smoking must be 1 or older | `#age-started-smoking` | +| `max` | Age you started smoking must be 120 or younger | `#age-started-smoking` | + +### age-stopped-smoking + +Question: How old were you when you stopped smoking? + +Type: `text` + +Answer key: `ageStoppedSmoking` + +Validation: required; type: number; minimum 1; maximum 120 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter the age you stopped smoking | `#age-stopped-smoking` | +| `invalid` | Enter the age you stopped smoking using numbers | `#age-stopped-smoking` | +| `min` | Age you stopped smoking must be 1 or older | `#age-stopped-smoking` | +| `max` | Age you stopped smoking must be 120 or younger | `#age-stopped-smoking` | + +### periods-stopped-smoking + +Question: Periods when you stopped smoking + +Type: `single` + +Answer key: `periodsStoppedSmoking` + +Validation: required; conditional: yes (required, number, min 1, max 80) + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever stopped smoking for periods of 1 year or longer | `#periods-stopped-smoking` | +| `invalid` | Total number of years you stopped smoking must be a number | `#years-stopped-smoking` | +| `min` | Total number of years you stopped smoking must be 1 or more | `#years-stopped-smoking` | +| `max` | Total number of years you stopped smoking must be 80 or fewer | `#years-stopped-smoking` | +| `conditional.yes.required` | Enter the total number of years you stopped smoking | `#years-stopped-smoking` | + +### smoking-type + +Question: The type of tobacco you smoke or used to smoke + +Type: `multiple` + +Answer key: `smokingType` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select the types of tobacco you have smoked | `#smoking-type` | + +Variants: `previous`. Variants can change type, options, hints or validation before the same validator runs. + +### smoking-status + +Question: Do you currently smoke? + +Type: `single` + +Answer key: `smokingStatus` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you currently smoke this type of tobacco | `#smoking-status` | + +### smoking-frequency + +Question: How often do you smoke? + +Type: `single` + +Answer key: `smokingFrequency` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select how often you smoke this type of tobacco | `#smoking-frequency` | + +### smoking-quantity + +Question: How much do you smoke? + +Type: `text` + +Answer key: `smokingQuantity` + +Validation: required; type: number; minimum 1; maximum 200 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter how much you smoke | `#smoking-quantity` | +| `invalid` | Enter how much you smoke using numbers | `#smoking-quantity` | +| `min` | Amount smoked must be 1 or more | `#smoking-quantity` | +| `max` | Amount smoked must be 200 or fewer | `#smoking-quantity` | + +Variants: `rolling_tobacco`, `shisha`. Variants can change type, options, hints or validation before the same validator runs. + +### smoking-setting + +Question: Do you usually smoke shisha in a group or on your own? + +Type: `multiple` + +Answer key: `smokingSetting` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you usually smoke shisha in a group or on your own | `#smoking-setting` | + +### smoking-change + +Question: Has the amount you normally smoke changed over time? + +Type: `multiple` + +Answer key: `smokingChange` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether the amount you normally smoke has changed over time | `#smoking-change` | + +### smoking-frequency-change + +Question: How often did you smoke? + +Type: `single` + +Answer key: `smokingFrequencyChange` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select how often you smoked this type of tobacco | `#smoking-frequency-change` | + +### smoking-quantity-change + +Question: How much did you smoke? + +Type: `text` + +Answer key: `smokingQuantityChange` + +Validation: required; type: number; minimum 1; maximum 200 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter how much you smoked | `#smoking-quantity-change` | +| `invalid` | Enter how much you smoked using numbers | `#smoking-quantity-change` | +| `min` | Amount smoked must be 1 or more | `#smoking-quantity-change` | +| `max` | Amount smoked must be 200 or fewer | `#smoking-quantity-change` | + +Variants: `rolling_tobacco`. Variants can change type, options, hints or validation before the same validator runs. + +### smoking-years-change + +Question: How many years did you smoke this amount? + +Type: `text` + +Answer key: `smokingYearsChange` + +Validation: required; type: number; minimum 1; maximum 80 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter how many years you smoked this amount | `#smoking-years-change` | +| `invalid` | Enter how many years using numbers | `#smoking-years-change` | +| `min` | Number of years must be 1 or more | `#smoking-years-change` | +| `max` | Number of years must be 80 or fewer | `#smoking-years-change` | diff --git a/app/prototype_v4_2/docs/error-messages.md b/app/prototype_v4_2/docs/error-messages.md new file mode 100644 index 00000000..8f8e08be --- /dev/null +++ b/app/prototype_v4_2/docs/error-messages.md @@ -0,0 +1,609 @@ +# Error messages + +This document summarises validation logic and error messages for `prototype_v4_2`. + +Sources: + +- `data/questions.yaml` for base question content, validation rules and error text +- `data/pages.yaml` for grouped-page composition and conditional question display +- `lib/question-validator.js` for shared validation behaviour +- `lib/tobacco-flow.js` for runtime tobacco question overrides +- `controllers/question.js` for route branching and answer-clearing logic + +## Shared validation logic + +- Blank values are `undefined`, `null`, an empty string after trimming, or an empty array. +- `required` errors stop further validation for that question or field. +- Date validation only accepts real calendar dates. If no date part is supplied, the `required` message is used; if any date part is supplied but the date is not real, the `invalid` message is used. +- Number validation rejects non-finite numbers, then checks whole-number, decimal-place, minimum and maximum rules in that order. +- Grouped text inputs validate each item first. Total minimum and maximum checks only run when the grouped values are present and numerically valid. +- Conditional reveal validation only runs for the selected trigger value. If the conditional value is blank, the conditional `required` message is used; number rules then use the question-level number error messages unless a runtime override replaces them. +- Error links default to `#${question.input.id || question.id}`. Date, grouped and conditional inputs usually set explicit `href` values. + +## Flow logic + +- `phone-questionnaire`: `yes` goes to `phone-questionnaire-exit`; `no` continues to `smoker`. +- `smoker`: `no` or `yes_fewer_than_100` goes to `not-eligible-for-screening`; current or previous smokers continue to `date-of-birth`. +- `date-of-birth`: valid dates outside the eligible scan age range go to `not-eligible-for-scan`; eligible users continue to `face-to-face-appointment`. +- `face-to-face-appointment`: `yes` goes to `book-appointment`; `no` continues to height and weight. +- `height-*` and `weight-*`: metric and imperial pages are alternatives. Submitting one unit clears the other unit answer. +- `asbestos`: grouped page that validates `asbestos-at-work` and `asbestos-at-home` together. +- `cancer-diagnosis-relatives`: `yes` continues to `cancer-diagnosis-relatives-age`; `no` clears that answer and goes to `check-your-answers`. +- `smoking-duration`: grouped page. `age-stopped-smoking` is visible only when `smoker` is `yes_previous` or type-specific `smoking-status` is `no`; otherwise `ageStoppedSmoking` is cleared. If `periods-stopped-smoking` is `no`, `yearsStoppedSmoking` is cleared. +- `smoking-type`: `none` goes to `smoking-type-exit`; otherwise selected tobacco types create the tobacco sub-flow in tobacco.yaml order. Former smokers skip each type-specific `smoking-status` question. +- `tobacco-smoking`: grouped tobacco page that validates `smoking-frequency` and `smoking-quantity` together for the active tobacco type. +- `smoking-change`: selected `greater` and `fewer` options create corresponding changed-smoking follow-up steps. Unselected changed-smoking answer groups are cleared. +- `tobacco-smoking-change`: grouped tobacco page that validates `smoking-frequency-change`, `smoking-quantity-change`, and `smoking-years-change` together for the active tobacco type and change direction. +- `smoking-quantity` and `smoking-quantity-change`: if `another_amount` is not selected, the related `smokingQuantityOther` answer is cleared. + +## Page composition + +- `accept-terms`: `accept-terms` +- `phone-questionnaire`: `phone-questionnaire` +- `smoker`: `smoker` +- `date-of-birth`: `date-of-birth` +- `face-to-face-appointment`: `face-to-face-appointment` +- `height-metric`: `height-metric` +- `height-imperial`: `height-imperial` +- `weight-metric`: `weight-metric` +- `weight-imperial`: `weight-imperial` +- `gender`: `gender` +- `sex`: `sex` +- `ethnicity`: `ethnicity` +- `education`: `education` +- `respiratory-conditions`: `respiratory-conditions` +- `asbestos`: `asbestos-at-work`, `asbestos-at-home` +- `cancer-diagnosis`: `cancer-diagnosis` +- `cancer-diagnosis-relatives`: `cancer-diagnosis-relatives` +- `cancer-diagnosis-relatives-age`: `cancer-diagnosis-relatives-age` +- `smoking-duration`: `age-started-smoking`, `age-stopped-smoking` (shown when any configured condition matches), `periods-stopped-smoking` +- `smoking-type`: `smoking-type` +- `smoking-status`: `smoking-status` +- `tobacco-smoking`: `smoking-frequency`, `smoking-quantity` +- `tobacco-smoking-change`: `smoking-frequency-change`, `smoking-quantity-change`, `smoking-years-change` +- `smoking-change`: `smoking-change` + +## Runtime tobacco messages + +- Tobacco sub-flow pages use `getSmokingContentQuestionOverrides()` before validation. The validator sees the runtime heading, input name, selected value and tobacco-specific variant. +- `smoking-status`, `smoking-frequency`, `smoking-quantity`, `smoking-change`, `smoking-frequency-change`, `smoking-quantity-change`, and `smoking-years-change` can replace their base `required` text with contextual text generated from the runtime heading. +- For headings beginning `How often`, the required message is `Select ...`; for `How much`, `How many`, or `How long`, the message is `Enter ...` for text inputs and `Select ...` for single-choice inputs; yes/no headings become `Select whether ...`. +- For numeric tobacco quantity text inputs, non-rolling-tobacco and non-shisha types require whole numbers. The integer message is generated as `[answer phrase] must be a whole number`. +- For shisha `another_amount`, the conditional input messages are `Enter the number of hours`, `Number of hours must be a number`, `Number of hours must be 0.5 or more`, and `Number of hours must be [maxHours] or fewer`. `maxHours` is 24 for daily/non-shisha, 168 weekly, 744 monthly, and 8760 yearly. +- `smoking-quantity-change` adds a comparison error when a `greater` answer is not greater than the original amount, or a `fewer` answer is not fewer than the original amount after normalising by frequency. The message is generated as `[quantity phrase] must be more/fewer/less than [original quantity] [original frequency]`. + +## Question messages + +### accept-terms + +Question: Confirm you agree to the terms of use + +Type: `multiple` + +Answer key: `acceptTerms` + +Page: `accept-terms` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Confirm that you have read and agree to the terms of use | `#accept-terms` | + +### phone-questionnaire + +Question: Have you previously completed a lung cancer risk questionnaire by phone in the last 12 months? + +Type: `single` + +Answer key: `phoneQuestionnaire` + +Page: `phone-questionnaire` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have previously completed a lung cancer risk questionnaire by phone | `#phone-questionnaire` | + +### smoker + +Question: Have you ever smoked tobacco? + +Type: `single` + +Answer key: `smoker` + +Page: `smoker` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever smoked tobacco | `#smoker` | + +### date-of-birth + +Question: What is your date of birth? + +Type: `date` + +Answer key: `dateOfBirth` + +Page: `date-of-birth` + +Validation: required; type: date + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter your date of birth | `#dateOfBirth-day` | +| `invalid` | Enter a real date of birth | `#dateOfBirth-day` | + +### face-to-face-appointment + +Question: Do you need to leave the online service and ask for a face-to-face appointment? + +Type: `single` + +Answer key: `faceToFaceAppointment` + +Page: `face-to-face-appointment` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you need to leave the online service and ask for a face-to-face appointment | `#face-to-face-appointment` | + +### height-metric + +Question: What is your height in centimetres? + +Type: `text` + +Answer key: `height` + +Page: `height-metric` + +Validation: required; type: number; minimum 139.7; maximum 243.8 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter your height in centimetres | `#height-metric` | +| `invalid` | Enter your height in centimetres using numbers | `#height-metric` | +| `min` | Height in centimetres must be 139.7cm or more | `#height-metric` | +| `max` | Height in centimetres must be 243.8cm or fewer | `#height-metric` | + +### height-imperial + +Question: What is your height in feet and inches? + +Type: `text_group` + +Answer key: `height` + +Page: `height-imperial` + +Validation: grouped input items: feet (required, number, whole number, min 0); inches (required, number, whole number, min 0, max 11); group total: min 55, max 96 + +| Rule | Message | Link | +| --- | --- | --- | +| `feet.required` | Enter your height in feet | `#height-imperial-feet` | +| `feet.invalid` | Enter your height in feet using numbers | `#height-imperial-feet` | +| `feet.integer` | Height in feet must be a whole number | `#height-imperial-feet` | +| `feet.min` | Height in feet must be 0ft or more | `#height-imperial-feet` | +| `inches.required` | Enter your height in inches | `#height-imperial-inches` | +| `inches.invalid` | Enter your height in inches using numbers | `#height-imperial-inches` | +| `inches.integer` | Height in inches must be a whole number | `#height-imperial-inches` | +| `inches.min` | Height in inches must be 0in or more | `#height-imperial-inches` | +| `inches.max` | Height in inches must be 11in or fewer | `#height-imperial-inches` | +| `total.min` | Height must be between 4 feet 7 inches and 8 feet | `#height-imperial-feet` | +| `total.max` | Height must be between 4 feet 7 inches and 8 feet | `#height-imperial-feet` | + +### weight-metric + +Question: What is your weight in kilograms? + +Type: `text` + +Answer key: `weight` + +Page: `weight-metric` + +Validation: required; type: number; maximum 2 decimal places; minimum 25.4; maximum 317.5 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter your weight in kilograms | `#weight-metric` | +| `invalid` | Enter your weight in kilograms using numbers | `#weight-metric` | +| `decimalPlaces` | Weight in kilograms must have 2 decimal places or fewer | `#weight-metric` | +| `min` | Weight in kilograms must be 25.4kg or more | `#weight-metric` | +| `max` | Weight in kilograms must be 317.5kg or fewer | `#weight-metric` | + +### weight-imperial + +Question: What is your weight in stones and pounds? + +Type: `text_group` + +Answer key: `weight` + +Page: `weight-imperial` + +Validation: grouped input items: stones (required, number, whole number, min 0); pounds (required, number, whole number, min 0, max 13); group total: min 56, max 700 + +| Rule | Message | Link | +| --- | --- | --- | +| `stones.required` | Enter your weight in stones | `#weight-imperial-stones` | +| `stones.invalid` | Enter your weight in stones using numbers | `#weight-imperial-stones` | +| `stones.integer` | Weight in stones must be a whole number | `#weight-imperial-stones` | +| `stones.min` | Weight in stones must be 0st or more | `#weight-imperial-stones` | +| `pounds.required` | Enter your weight in pounds | `#weight-imperial-pounds` | +| `pounds.invalid` | Enter your weight in pounds using numbers | `#weight-imperial-pounds` | +| `pounds.integer` | Weight in pounds must be a whole number | `#weight-imperial-pounds` | +| `pounds.min` | Weight in pounds must be 0lb or more | `#weight-imperial-pounds` | +| `pounds.max` | Weight in pounds must be 13lb or fewer | `#weight-imperial-pounds` | +| `total.min` | Weight must be between 4 stone and 50 stone | `#weight-imperial-stones` | +| `total.max` | Weight must be between 4 stone and 50 stone | `#weight-imperial-stones` | + +### gender + +Question: Which of these best describes your gender identity? + +Type: `single` + +Answer key: `gender` + +Page: `gender` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select which option best describes your gender identity | `#gender` | + +### sex + +Question: What was your sex at birth? + +Type: `single` + +Answer key: `sex` + +Page: `sex` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select your sex at birth | `#sex` | + +### ethnicity + +Question: What is your ethnic background? + +Type: `single` + +Answer key: `ethnicity` + +Page: `ethnicity` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select your ethnic background | `#ethnicity` | + +### education + +Question: What is the highest level of education you have completed? + +Type: `single` + +Answer key: `education` + +Page: `education` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select the highest level of education you have completed | `#education` | + +### respiratory-conditions + +Question: Have you ever been diagnosed with any of the following respiratory conditions? + +Type: `multiple` + +Answer key: `respiratoryConditions` + +Page: `respiratory-conditions` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select if you have ever been diagnosed with any respiratory conditions | `#respiratory-conditions` | + +### asbestos-at-work + +Question: Have you ever worked in a job where you might have been exposed to asbestos? + +Type: `single` + +Answer key: `asbestosAtWork` + +Page: `asbestos` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever worked in a job where you might have been exposed to asbestos | `#asbestos-at-work` | + +### asbestos-at-home + +Question: Have you ever lived with anyone who worked with asbestos? + +Type: `single` + +Answer key: `asbestosAtHome` + +Page: `asbestos` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever lived with anyone who worked with asbestos | `#asbestos-at-home` | + +### cancer-diagnosis + +Question: Have you ever been diagnosed with cancer? + +Type: `single` + +Answer key: `cancerDiagnosis` + +Page: `cancer-diagnosis` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever been diagnosed with cancer | `#cancer-diagnosis` | + +### cancer-diagnosis-relatives + +Question: Have any of your parents, siblings or children ever been diagnosed with lung cancer? + +Type: `single` + +Answer key: `cancerDiagnosisRelatives` + +Page: `cancer-diagnosis-relatives` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether any of your parents, siblings or children have ever been diagnosed with lung cancer | `#cancer-diagnosis-relatives` | + +### cancer-diagnosis-relatives-age + +Question: Were any of your relatives younger than 60 years old when they were diagnosed with lung cancer? + +Type: `single` + +Answer key: `cancerDiagnosisRelativesAge` + +Page: `cancer-diagnosis-relatives-age` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether any of your relatives were younger than 60 when they were diagnosed with lung cancer | `#cancer-diagnosis-relatives-age` | + +### age-started-smoking + +Question: How old were you when you started smoking? + +Type: `text` + +Answer key: `ageStartedSmoking` + +Page: `smoking-duration` + +Validation: required; type: number; minimum 1; maximum 120 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter the age you started smoking | `#age-started-smoking` | +| `invalid` | Enter the age you started smoking using numbers | `#age-started-smoking` | +| `min` | Age you started smoking must be 1 or older | `#age-started-smoking` | +| `max` | Age you started smoking must be 120 or younger | `#age-started-smoking` | + +### age-stopped-smoking + +Question: How old were you when you quit smoking? + +Type: `text` + +Answer key: `ageStoppedSmoking` + +Page: `smoking-duration` + +Validation: required; type: number; minimum 1; maximum 120 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter the age you quit smoking | `#age-stopped-smoking` | +| `invalid` | Enter the age you quit smoking using numbers | `#age-stopped-smoking` | +| `min` | Age you quit smoking must be 1 or older | `#age-stopped-smoking` | +| `max` | Age you quit smoking must be 120 or younger | `#age-stopped-smoking` | + +### periods-stopped-smoking + +Question: Have you ever stopped smoking for periods of 1 year or longer? + +Type: `single` + +Answer key: `periodsStoppedSmoking` + +Page: `smoking-duration` + +Validation: required; conditional: yes (required, number, min 1, max 80) + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever stopped smoking for periods of 1 year or longer | `#periods-stopped-smoking` | +| `invalid` | Total number of years you stopped smoking must be a number | `#years-stopped-smoking` | +| `min` | Total number of years you stopped smoking must be 1 or more | `#years-stopped-smoking` | +| `max` | Total number of years you stopped smoking must be 80 or fewer | `#years-stopped-smoking` | +| `conditional.yes.required` | Enter the total number of years you stopped smoking | `#years-stopped-smoking` | + +### smoking-type + +Question: Have you ever smoked any of the following types of tobacco? + +Type: `multiple` + +Answer key: `smokingType` + +Page: `smoking-type` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select the types of tobacco you have smoked | `#smoking-type` | + +Variants: `previous`. Variants can change type, options, hints or validation before the same validator runs. + +### smoking-status + +Question: Do you currently smoke? + +Type: `single` + +Answer key: `smokingStatus` + +Page: `smoking-status` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you currently smoke this type of tobacco | `#smoking-status` | + +### smoking-frequency + +Question: How often do you smoke? + +Type: `single` + +Answer key: `smokingFrequency` + +Page: `tobacco-smoking` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select how often you smoke this type of tobacco | `#smoking-frequency` | + +### smoking-quantity + +Question: How much do you smoke? + +Type: `text` + +Answer key: `smokingQuantity` + +Page: `tobacco-smoking` + +Validation: required; type: number; minimum 1; maximum 200 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter how much you smoke | `#smoking-quantity` | +| `invalid` | Enter how much you smoke using numbers | `#smoking-quantity` | +| `min` | Amount smoked must be 1 or more | `#smoking-quantity` | +| `max` | Amount smoked must be 200 or fewer | `#smoking-quantity` | + +Variants: `rolling_tobacco`, `shisha`. Variants can change type, options, hints or validation before the same validator runs. + +### smoking-change + +Question: Has the amount you normally smoke changed over time? + +Type: `multiple` + +Answer key: `smokingChange` + +Page: `smoking-change` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether the amount you normally smoke has changed over time | `#smoking-change` | + +### smoking-frequency-change + +Question: How often did you smoke? + +Type: `single` + +Answer key: `smokingFrequencyChange` + +Page: `tobacco-smoking-change` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select how often you smoked this type of tobacco | `#smoking-frequency-change` | + +### smoking-quantity-change + +Question: How much did you smoke? + +Type: `text` + +Answer key: `smokingQuantityChange` + +Page: `tobacco-smoking-change` + +Validation: required; type: number; minimum 1; maximum 200 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter how much you smoked | `#smoking-quantity-change` | +| `invalid` | Enter how much you smoked using numbers | `#smoking-quantity-change` | +| `min` | Amount smoked must be 1 or more | `#smoking-quantity-change` | +| `max` | Amount smoked must be 200 or fewer | `#smoking-quantity-change` | + +Variants: `rolling_tobacco`. Variants can change type, options, hints or validation before the same validator runs. + +### smoking-years-change + +Question: How many years did you smoke this amount? + +Type: `text` + +Answer key: `smokingYearsChange` + +Page: `tobacco-smoking-change` + +Validation: required; type: number; minimum 1; maximum 80 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter how many years you smoked this amount | `#smoking-years-change` | +| `invalid` | Enter how many years using numbers | `#smoking-years-change` | +| `min` | Number of years must be 1 or more | `#smoking-years-change` | +| `max` | Number of years must be 80 or fewer | `#smoking-years-change` | + diff --git a/app/prototype_v4_3/docs/error-messages.md b/app/prototype_v4_3/docs/error-messages.md new file mode 100644 index 00000000..df970ddf --- /dev/null +++ b/app/prototype_v4_3/docs/error-messages.md @@ -0,0 +1,627 @@ +# Error messages + +This document summarises validation logic and error messages for `prototype_v4_3`. + +Sources: + +- `data/questions.yaml` for base question content, validation rules and error text +- `data/pages.yaml` for grouped-page composition and conditional question display +- `lib/question-validator.js` for shared validation behaviour +- `lib/tobacco-flow.js` for runtime tobacco question overrides +- `controllers/question.js` for route branching and answer-clearing logic + +## Shared validation logic + +- Blank values are `undefined`, `null`, an empty string after trimming, or an empty array. +- `required` errors stop further validation for that question or field. +- Date validation only accepts real calendar dates. If no date part is supplied, the `required` message is used; if any date part is supplied but the date is not real, the `invalid` message is used. +- Number validation rejects non-finite numbers, then checks whole-number, decimal-place, minimum and maximum rules in that order. +- Grouped text inputs validate each item first. Total minimum and maximum checks only run when the grouped values are present and numerically valid. +- Conditional reveal validation only runs for the selected trigger value. If the conditional value is blank, the conditional `required` message is used; number rules then use the question-level number error messages unless a runtime override replaces them. +- Error links default to `#${question.input.id || question.id}`. Date, grouped and conditional inputs usually set explicit `href` values. + +## Flow logic + +- `phone-questionnaire`: `yes` goes to `phone-questionnaire-exit`; `no` continues to `smoking-type`. +- `smoking-type`: `none` goes to `smoking-type-exit`. If multiple tobacco types are selected, the flow asks `smoking-status-current`; if one type is selected, it asks that type-specific `smoking-status`. +- `smoking-status-current`: only shown when more than one tobacco type was selected; it records which selected types are currently smoked and then continues to `date-of-birth`. +- `smoking-status`: if the answer is `less_than_lifetime_threshold`, the flow goes to `not-eligible-for-screening`; otherwise it continues through that tobacco type sub-flow. +- `date-of-birth`: valid dates outside the eligible scan age range go to `not-eligible-for-scan`; eligible users continue to `face-to-face-appointment`. +- `face-to-face-appointment`: `yes` goes to `book-appointment`; `no` continues to height and weight. +- `height-*` and `weight-*`: metric and imperial pages are alternatives. Submitting one unit clears the other unit answer. +- `asbestos`: grouped page that validates `asbestos-at-work` and `asbestos-at-home` together. +- `cancer-diagnosis-relatives`: `yes` continues to `cancer-diagnosis-relatives-age`; `no` clears that answer and goes to `check-your-answers`. +- `smoking-duration`: rendered inside the active tobacco sub-flow. It validates `age-started-smoking`, conditionally `age-stopped-smoking`, and `periods-stopped-smoking` for the active tobacco type. Current tobacco types clear `ageStoppedSmoking`; `periods-stopped-smoking` of `no` clears `yearsStoppedSmoking`. +- `tobacco-smoking`: grouped tobacco page that validates `smoking-frequency` and `smoking-quantity` together for the active tobacco type. +- `smoking-change`: selected `greater` and `fewer` options create corresponding changed-smoking follow-up steps. Unselected changed-smoking answer groups are cleared. +- `tobacco-smoking-change`: grouped tobacco page that validates `smoking-frequency-change`, `smoking-quantity-change`, and `smoking-years-change` together for the active tobacco type and change direction. +- `smoking-quantity` and `smoking-quantity-change`: if `another_amount` is not selected, the related `smokingQuantityOther` answer is cleared. + +## Page composition + +- `accept-terms`: `accept-terms` +- `phone-questionnaire`: `phone-questionnaire` +- `smoker`: `smoker` +- `date-of-birth`: `date-of-birth` +- `face-to-face-appointment`: `face-to-face-appointment` +- `height-metric`: `height-metric` +- `height-imperial`: `height-imperial` +- `weight-metric`: `weight-metric` +- `weight-imperial`: `weight-imperial` +- `gender`: `gender` +- `sex`: `sex` +- `ethnicity`: `ethnicity` +- `education`: `education` +- `respiratory-conditions`: `respiratory-conditions` +- `asbestos`: `asbestos-at-work`, `asbestos-at-home` +- `cancer-diagnosis`: `cancer-diagnosis` +- `cancer-diagnosis-relatives`: `cancer-diagnosis-relatives` +- `cancer-diagnosis-relatives-age`: `cancer-diagnosis-relatives-age` +- `smoking-duration`: `age-started-smoking`, `age-stopped-smoking` (shown when any configured condition matches), `periods-stopped-smoking` +- `smoking-type`: `smoking-type` +- `smoking-status`: `smoking-status` +- `smoking-status-current`: `smoking-status-current` +- `tobacco-smoking`: `smoking-frequency`, `smoking-quantity` +- `tobacco-smoking-change`: `smoking-frequency-change`, `smoking-quantity-change`, `smoking-years-change` +- `smoking-change`: `smoking-change` + +## Runtime tobacco messages + +- Tobacco sub-flow pages use `getSmokingContentQuestionOverrides()` before validation. The validator sees the runtime heading, input name, selected value and tobacco-specific variant. +- `age-started-smoking`, `age-stopped-smoking`, `periods-stopped-smoking`, `smoking-status`, `smoking-frequency`, `smoking-quantity`, `smoking-change`, `smoking-frequency-change`, `smoking-quantity-change`, and `smoking-years-change` can replace their base `required` text with contextual text generated from the runtime heading. +- For headings beginning `How often`, the required message is `Select ...`; for `How old`, `How much`, `How many`, or `How long`, the message is `Enter ...` for text inputs and `Select ...` for single-choice inputs; yes/no headings become `Select whether ...`. +- For numeric tobacco quantity text inputs, non-rolling-tobacco and non-shisha types require whole numbers. The integer message is generated as `[answer phrase] must be a whole number`. +- For shisha `another_amount`, the conditional input messages are `Enter the number of hours`, `Number of hours must be a number`, `Number of hours must be 0.5 or more`, and `Number of hours must be [maxHours] or fewer`. `maxHours` is 24 for daily/non-shisha, 168 weekly, 744 monthly, and 8760 yearly. +- `smoking-quantity-change` adds a comparison error when a `greater` answer is not greater than the original amount, or a `fewer` answer is not fewer than the original amount after normalising by frequency. The message is generated as `[quantity phrase] must be more/fewer/less than [original quantity] [original frequency]`. + +## Question messages + +### accept-terms + +Question: Confirm you agree to the terms of use + +Type: `multiple` + +Answer key: `acceptTerms` + +Page: `accept-terms` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Confirm that you have read and agree to the terms of use | `#accept-terms` | + +### phone-questionnaire + +Question: Have you previously completed a lung cancer risk questionnaire by phone in the last 12 months? + +Type: `single` + +Answer key: `phoneQuestionnaire` + +Page: `phone-questionnaire` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have previously completed a lung cancer risk questionnaire by phone | `#phone-questionnaire` | + +### smoker + +Question: Have you ever smoked tobacco? + +Type: `single` + +Answer key: `smoker` + +Page: `smoker` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever smoked tobacco | `#smoker` | + +### date-of-birth + +Question: What is your date of birth? + +Type: `date` + +Answer key: `dateOfBirth` + +Page: `date-of-birth` + +Validation: required; type: date + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter your date of birth | `#dateOfBirth-day` | +| `invalid` | Enter a real date of birth | `#dateOfBirth-day` | + +### face-to-face-appointment + +Question: Do you need to leave the online service and ask for a face-to-face appointment? + +Type: `single` + +Answer key: `faceToFaceAppointment` + +Page: `face-to-face-appointment` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you need to leave the online service and ask for a face-to-face appointment | `#face-to-face-appointment` | + +### height-metric + +Question: What is your height in centimetres? + +Type: `text` + +Answer key: `height` + +Page: `height-metric` + +Validation: required; type: number; minimum 139.7; maximum 243.8 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter your height in centimetres | `#height-metric` | +| `invalid` | Enter your height in centimetres using numbers | `#height-metric` | +| `min` | Height in centimetres must be 139.7cm or more | `#height-metric` | +| `max` | Height in centimetres must be 243.8cm or fewer | `#height-metric` | + +### height-imperial + +Question: What is your height in feet and inches? + +Type: `text_group` + +Answer key: `height` + +Page: `height-imperial` + +Validation: grouped input items: feet (required, number, whole number, min 0); inches (required, number, whole number, min 0, max 11); group total: min 55, max 96 + +| Rule | Message | Link | +| --- | --- | --- | +| `feet.required` | Enter your height in feet | `#height-imperial-feet` | +| `feet.invalid` | Enter your height in feet using numbers | `#height-imperial-feet` | +| `feet.integer` | Height in feet must be a whole number | `#height-imperial-feet` | +| `feet.min` | Height in feet must be 0ft or more | `#height-imperial-feet` | +| `inches.required` | Enter your height in inches | `#height-imperial-inches` | +| `inches.invalid` | Enter your height in inches using numbers | `#height-imperial-inches` | +| `inches.integer` | Height in inches must be a whole number | `#height-imperial-inches` | +| `inches.min` | Height in inches must be 0in or more | `#height-imperial-inches` | +| `inches.max` | Height in inches must be 11in or fewer | `#height-imperial-inches` | +| `total.min` | Height must be between 4 feet 7 inches and 8 feet | `#height-imperial-feet` | +| `total.max` | Height must be between 4 feet 7 inches and 8 feet | `#height-imperial-feet` | + +### weight-metric + +Question: What is your weight in kilograms? + +Type: `text` + +Answer key: `weight` + +Page: `weight-metric` + +Validation: required; type: number; maximum 2 decimal places; minimum 25.4; maximum 317.5 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter your weight in kilograms | `#weight-metric` | +| `invalid` | Enter your weight in kilograms using numbers | `#weight-metric` | +| `decimalPlaces` | Weight in kilograms must have 2 decimal places or fewer | `#weight-metric` | +| `min` | Weight in kilograms must be 25.4kg or more | `#weight-metric` | +| `max` | Weight in kilograms must be 317.5kg or fewer | `#weight-metric` | + +### weight-imperial + +Question: What is your weight in stones and pounds? + +Type: `text_group` + +Answer key: `weight` + +Page: `weight-imperial` + +Validation: grouped input items: stones (required, number, whole number, min 0); pounds (required, number, whole number, min 0, max 13); group total: min 56, max 700 + +| Rule | Message | Link | +| --- | --- | --- | +| `stones.required` | Enter your weight in stones | `#weight-imperial-stones` | +| `stones.invalid` | Enter your weight in stones using numbers | `#weight-imperial-stones` | +| `stones.integer` | Weight in stones must be a whole number | `#weight-imperial-stones` | +| `stones.min` | Weight in stones must be 0st or more | `#weight-imperial-stones` | +| `pounds.required` | Enter your weight in pounds | `#weight-imperial-pounds` | +| `pounds.invalid` | Enter your weight in pounds using numbers | `#weight-imperial-pounds` | +| `pounds.integer` | Weight in pounds must be a whole number | `#weight-imperial-pounds` | +| `pounds.min` | Weight in pounds must be 0lb or more | `#weight-imperial-pounds` | +| `pounds.max` | Weight in pounds must be 13lb or fewer | `#weight-imperial-pounds` | +| `total.min` | Weight must be between 4 stone and 50 stone | `#weight-imperial-stones` | +| `total.max` | Weight must be between 4 stone and 50 stone | `#weight-imperial-stones` | + +### gender + +Question: Which of these best describes your gender identity? + +Type: `single` + +Answer key: `gender` + +Page: `gender` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select which option best describes your gender identity | `#gender` | + +### sex + +Question: What was your sex at birth? + +Type: `single` + +Answer key: `sex` + +Page: `sex` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select your sex at birth | `#sex` | + +### ethnicity + +Question: What is your ethnic background? + +Type: `single` + +Answer key: `ethnicity` + +Page: `ethnicity` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select your ethnic background | `#ethnicity` | + +### education + +Question: What level of education have you completed? + +Type: `multiple` + +Answer key: `education` + +Page: `education` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select what level of education you have completed | `#education` | + +### respiratory-conditions + +Question: Have you ever been diagnosed with any of the following respiratory conditions? + +Type: `multiple` + +Answer key: `respiratoryConditions` + +Page: `respiratory-conditions` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select if you have ever been diagnosed with any respiratory conditions | `#respiratory-conditions` | + +### asbestos-at-work + +Question: Have you ever worked in a job where you might have been exposed to asbestos? + +Type: `single` + +Answer key: `asbestosAtWork` + +Page: `asbestos` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever worked in a job where you might have been exposed to asbestos | `#asbestos-at-work` | + +### asbestos-at-home + +Question: Have you ever lived with anyone who worked with asbestos? + +Type: `single` + +Answer key: `asbestosAtHome` + +Page: `asbestos` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever lived with anyone who worked with asbestos | `#asbestos-at-home` | + +### cancer-diagnosis + +Question: Have you ever been diagnosed with cancer? + +Type: `single` + +Answer key: `cancerDiagnosis` + +Page: `cancer-diagnosis` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever been diagnosed with cancer | `#cancer-diagnosis` | + +### cancer-diagnosis-relatives + +Question: Have any of your parents, siblings or children ever been diagnosed with lung cancer? + +Type: `single` + +Answer key: `cancerDiagnosisRelatives` + +Page: `cancer-diagnosis-relatives` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether any of your parents, siblings or children have ever been diagnosed with lung cancer | `#cancer-diagnosis-relatives` | + +### cancer-diagnosis-relatives-age + +Question: Were any of your relatives younger than 60 years old when they were diagnosed with lung cancer? + +Type: `single` + +Answer key: `cancerDiagnosisRelativesAge` + +Page: `cancer-diagnosis-relatives-age` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether any of your relatives were younger than 60 when they were diagnosed with lung cancer | `#cancer-diagnosis-relatives-age` | + +### age-started-smoking + +Question: How old were you when you started smoking? + +Type: `text` + +Answer key: `ageStartedSmoking` + +Page: `smoking-duration` + +Validation: required; type: number; minimum 1; maximum 120 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter the age you started smoking | `#age-started-smoking` | +| `invalid` | Enter the age you started smoking using numbers | `#age-started-smoking` | +| `min` | Age you started smoking must be 1 or older | `#age-started-smoking` | +| `max` | Age you started smoking must be 120 or younger | `#age-started-smoking` | + +### age-stopped-smoking + +Question: How old were you when you quit smoking? + +Type: `text` + +Answer key: `ageStoppedSmoking` + +Page: `smoking-duration` + +Validation: required; type: number; minimum 1; maximum 120 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter the age you quit smoking | `#age-stopped-smoking` | +| `invalid` | Enter the age you quit smoking using numbers | `#age-stopped-smoking` | +| `min` | Age you quit smoking must be 1 or older | `#age-stopped-smoking` | +| `max` | Age you quit smoking must be 120 or younger | `#age-stopped-smoking` | + +### periods-stopped-smoking + +Question: Have you ever stopped smoking for periods of 1 year or longer? + +Type: `single` + +Answer key: `periodsStoppedSmoking` + +Page: `smoking-duration` + +Validation: required; conditional: yes (required, number, min 1, max 80) + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you have ever stopped smoking for periods of 1 year or longer | `#periods-stopped-smoking` | +| `invalid` | Total number of years you stopped smoking must be a number | `#years-stopped-smoking` | +| `min` | Total number of years you stopped smoking must be 1 or more | `#years-stopped-smoking` | +| `max` | Total number of years you stopped smoking must be 80 or fewer | `#years-stopped-smoking` | +| `conditional.yes.required` | Enter the total number of years you stopped smoking | `#years-stopped-smoking` | + +### smoking-type + +Question: Have you ever smoked any of the following types of tobacco for a period of 1 year or longer? + +Type: `multiple` + +Answer key: `smokingType` + +Page: `smoking-type` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select the types of tobacco you have smoked | `#smoking-type` | + +Variants: `previous`. Variants can change type, options, hints or validation before the same validator runs. + +### smoking-status-current + +Question: Do you currently smoke any of the following types of tobacco? + +Type: `multiple` + +Answer key: `smokingStatusCurrent` + +Page: `smoking-status-current` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select the types of tobacco you currently smoke | `#smoking-status-current` | + +### smoking-status + +Question: Do you currently smoke? + +Type: `single` + +Answer key: `smokingStatus` + +Page: `smoking-status` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether you currently smoke this type of tobacco | `#smoking-status` | + +### smoking-frequency + +Question: How often do you smoke? + +Type: `single` + +Answer key: `smokingFrequency` + +Page: `tobacco-smoking` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select how often you smoke this type of tobacco | `#smoking-frequency` | + +### smoking-quantity + +Question: How much do you smoke? + +Type: `text` + +Answer key: `smokingQuantity` + +Page: `tobacco-smoking` + +Validation: required; type: number; minimum 1; maximum 200 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter how much you smoke | `#smoking-quantity` | +| `invalid` | Enter how much you smoke using numbers | `#smoking-quantity` | +| `min` | Amount smoked must be 1 or more | `#smoking-quantity` | +| `max` | Amount smoked must be 200 or fewer | `#smoking-quantity` | + +Variants: `rolling_tobacco`, `shisha`. Variants can change type, options, hints or validation before the same validator runs. + +### smoking-change + +Question: Has the amount you normally smoke changed over time? + +Type: `multiple` + +Answer key: `smokingChange` + +Page: `smoking-change` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select whether the amount you normally smoke has changed over time | `#smoking-change` | + +### smoking-frequency-change + +Question: How often did you smoke? + +Type: `single` + +Answer key: `smokingFrequencyChange` + +Page: `tobacco-smoking-change` + +Validation: required + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Select how often you smoked this type of tobacco | `#smoking-frequency-change` | + +### smoking-quantity-change + +Question: How much did you smoke? + +Type: `text` + +Answer key: `smokingQuantityChange` + +Page: `tobacco-smoking-change` + +Validation: required; type: number; minimum 1; maximum 200 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter how much you smoked | `#smoking-quantity-change` | +| `invalid` | Enter how much you smoked using numbers | `#smoking-quantity-change` | +| `min` | Amount smoked must be 1 or more | `#smoking-quantity-change` | +| `max` | Amount smoked must be 200 or fewer | `#smoking-quantity-change` | + +Variants: `rolling_tobacco`. Variants can change type, options, hints or validation before the same validator runs. + +### smoking-years-change + +Question: How many years did you smoke this amount? + +Type: `text` + +Answer key: `smokingYearsChange` + +Page: `tobacco-smoking-change` + +Validation: required; type: number; minimum 1; maximum 80 + +| Rule | Message | Link | +| --- | --- | --- | +| `required` | Enter how many years you smoked this amount | `#smoking-years-change` | +| `invalid` | Enter how many years using numbers | `#smoking-years-change` | +| `min` | Number of years must be 1 or more | `#smoking-years-change` | +| `max` | Number of years must be 80 or fewer | `#smoking-years-change` | +