diff --git a/app/prototype_v4_2/content/accessibility.md b/app/prototype_v4_2/content/accessibility.md new file mode 100644 index 0000000..63dea60 --- /dev/null +++ b/app/prototype_v4_2/content/accessibility.md @@ -0,0 +1,105 @@ +--- +title: Accessibility statement for NHS {{ serviceName | lower }} +--- + +This statement was created in March 2026. + +This accessibility statement applies to the website {{ serviceUrl }} + +This website is run by NHS England. We want as many people as possible to be able to use this website. For example, that means you should be able to: + +- change colours, contrast levels and fonts using browser or device settings +- zoom in up to 400% without the text spilling off the screen +- navigate most of the website using a keyboard or speech recognition software +- listen to most of the website using a screen reader (including the most recent versions of JAWS, NVDA and VoiceOver) + +We've also made the website text as simple as possible to understand. + +[AbilityNet](#) has advice on making your device easier to use if you have a disability. + +## How accessible this website is + +We have used the NHS service standard and NHS design system to design and build this website which has been fully accessibility tested. + +Due to the short-term nature of the test of the online service and the delivery schedule we were unable to test the accessibility of this website with people with access needs. + +We have tested this website for accessibility issues with automated tools and through manual testing. We have a phone service for people who are unable to access this website. + +If you are unable to test the online service, please call us on {{ serviceTelephone | telephoneLink }} + +We know some parts of this website are not fully accessible: + +- There are some issues with accessibility of the content of our service, relating to error handling, text and headings and text fields and labelling + +Details of this can be found below in the 'Non-accessible content' section. + +## Feedback and contact information + +If you find any problems not listed on this page or think we're not meeting accessibility requirements, contact: {{ serviceEmail }} + +If you need information on this website in a different format like accessible PDF, large print, easy read, audio recording or braille: + +- email {{ serviceEmail }} + +We'll consider your request and get back to you in 7-14 days. + +## Enforcement procedure + +The Equality and Human Rights Commission (EHRC) is responsible for enforcing the Public Sector Bodies (Websites and Mobile Applications) (No. 2) Accessibility Regulations 2018 (the 'accessibility regulations'). If you're not happy with how we respond to your complaint, [contact the Equality Advisory and Support Service (EASS)](#). + +## Technical information about this website's accessibility + +NHS England is committed to making its website accessible, in accordance with the Public Sector Bodies (Websites and Mobile Applications) (No. 2) Accessibility Regulations 2018. + +## Compliance status + +This website is partially compliant with the [Web Content Accessibility Guidelines (WCAG) version 2.2](#) AA standard, due to the non-compliances listed below. + +## Non-accessible content + +The content listed below is non-accessible for the following reasons. + +#### Error handling + +- There is no error summary at the top of the page if there are errors + +#### Text and headings + +- Questions on pages are not marked as 'Heading level 2' + +#### Text fields and labelling + +- Text entry fields are editable but not announced as editable for screenreader users + +## What we're doing to improve accessibility + +We have an alternate phone service that increases accessibility for all users. To access this service, please call {{ serviceTelephone | telephoneLink }}. + +We have published tools and guidance on accessibility in the NHS digital service manual based on extensive testing. The service manual helps our teams build products and services to meet the same accessibility standards. + +At NHS England, creating an accessible service is a team effort. We want our teams to make accessible services by: + +- considering accessibility at the start of their project, and throughout +- making accessibility the whole team's responsibility +- researching with disabled users +- using a library of accessible components and patterns +- carrying out regular accessibility audits and testing +- designing and building to level AA of WCAG 2.2 – which is NHS England policy + +As part of this commitment, we have set up a cross-functional accessibility working group to make sure that accessibility remains at the core of everything we do. + +We are making sure that accessibility issues highlighted in this statement are being prioritised and fixed. Measures include: + +- ongoing improvements to the NHS design system with a focus on accessibility +- prioritising accessibility remedial work in all new development and improvement projects +- working with suppliers to improve the accessibility of their products + +## Preparation of this accessibility statement + +This statement was prepared on 19th March 2026. + +This website was last tested on 17th March 2026 against the WCAG 2.2 AA standard by the product team of NHS {{ serviceName | lower }}. + +Content was selected to make sure a good representation of different pages, templates and components were tested as well as key content and user journeys. + +This website's accessibility will be reviewed on a regular basis and this accessibility statement updated with any relevant changes. diff --git a/app/prototype_v4_2/content/contact.md b/app/prototype_v4_2/content/contact.md new file mode 100644 index 0000000..80ceb89 --- /dev/null +++ b/app/prototype_v4_2/content/contact.md @@ -0,0 +1,15 @@ +--- +title: Contact us +--- + +## To complete your lung cancer screening by phone + +Call us on: {{ serviceTelephone | telephoneLink }} + +**Phone lines are open:** +Monday to Friday 8am to 8pm +Saturdays 8am to 1pm + +## If you have questions about the online service + +Contact us by email: {{ serviceEmail }} diff --git a/app/prototype_v4_2/content/cookies.md b/app/prototype_v4_2/content/cookies.md new file mode 100644 index 0000000..ea64394 --- /dev/null +++ b/app/prototype_v4_2/content/cookies.md @@ -0,0 +1,55 @@ +--- +title: NHS {{ serviceName | lower }} cookies policy +--- + +Version 1.0, 11 March 2026 + +> First release of the service for pilot GP surgeries. + +This cookies policy relates to the service provided by NHS England. + +NHS England ("we" or "us") uses cookies to deliver the NHS {{ serviceName | lower }}. + +The information set out in this policy is provided in addition to the [NHS {{ serviceName | lower }} privacy policy](/prototype_v4/privacy-policy) and should be read alongside it. + +We put small files called cookies on your device (for example, your phone). + +Cookies are widely used to make websites and apps work, or work more efficiently, as well as to provide services and functionalities for users. + +We only put cookies on your device that are required for the NHS {{ serviceName | lower }} to work ("strictly necessary cookies"). + +We ask you to accept our cookies policy when you accept the NHS {{ serviceName | lower }} terms of use and privacy policy. In doing so, you agree to let us put the strictly necessary cookies on your device. + +The strictly necessary cookies we need to put on your device for the NHS {{ serviceName | lower }} to work are listed here. + +## Essential cookies + +| Name | Purpose | Expires | +| --- | --- | --- | +| sessionid | This cookie is used by Django to identify your session on the site. It allows the website to remember information as you navigate between pages. No personal data is stored in the cookie itself, it only contains a session identifier. | The end of the session. When you close the browser window, sign out or your session expires. | +| ASLBSA and ASLBSACORS | These cookies are created by Azure and help identify different users even if they share the same IP address, allowing for a more even distribution of traffic among origins. | The end of the session. When you close the browser window, sign out or your session expires. | +| csrftoken | This cookie is created by Django to protect the site from Cross-Site Request Forgery (CSRF) attacks. It ensures that form submissions and other actions come from you and not from a malicious third party. | Typically one year, unless your browser clears cookies. | + +## Cookies set by Qualtrics + +The NHS {{ serviceName | lower }} uses Qualtrics cookies for the purpose of capturing responses to feedback surveys. + +Details on how Qualtrics uses cookies can be found in the [Qualtrics cookies policy](#), which can be accessed from within the feedback survey. + +## Your cookie choices + +The majority of devices and browsers will allow you to alter the settings used for cookies and disable and enable them as you require. + +You can select your cookie preferences so that you do not get any cookies (except strictly necessary cookies) if you prefer not to receive them. + +You can also delete the cookies already on your device, and you can set your browser or device to prevent them being placed. + +Please be aware that if you set your preferences to not allow cookies, it could limit the functionality of the NHS {{ serviceName | lower }} as it needs the strictly necessary cookies to function. + +## Changes to this cookies policy + +The NHS {{ serviceName | lower }} [terms of use](/prototype_v4/terms-of-use), [privacy policy](/prototype_v4/privacy-policy) and cookies policy may change. If you use the NHS {{ serviceName | lower }} service again in the future you will be subject to the policies which exist at that time. + +## Version history + +Version 1, 11 March 2026 - first release of the service for pilot GP surgeries. diff --git a/app/prototype_v4_2/content/privacy.md b/app/prototype_v4_2/content/privacy.md new file mode 100644 index 0000000..d2bd402 --- /dev/null +++ b/app/prototype_v4_2/content/privacy.md @@ -0,0 +1,129 @@ +--- +title: NHS {{ serviceName | lower }} privacy policy +--- + +## Who we are + +NHS England is running a pilot of the _{{ serviceName | lower }}_ digital service. + +## Why we use your data + +We use your information to evaluate whether a digital questionnaire can safely and effectively support lung cancer screening alongside the existing telephone service. + +## What data we use + +This includes information you provide in the questionnaire, limited details from NHS login, and (if you complete both routes) responses from the telephone screening so they can be compared. + +## Your choice + +Taking part is voluntary. You can choose not to take part or withdraw at any time. This will not affect your NHS care. + +## How we protect your data + +Your data is kept secure, access is restricted, and personal identifiers are removed once analysis is complete. + +## More information + +Read the full privacy notice below to understand how your data is used, shared, stored, and what your rights are. + +### 1\. About this service + +NHS {{ serviceName | lower }} is a pilot by NHS England and allows you to complete a lung health check online before you complete your phone appointment. The pilot is designed to evaluate whether a digital questionnaire can operate as effectively as the existing telephone based lung cancer screening service. + +The pilot is for service evaluation purposes only and does not form part of routine clinical care. + +### 2\. Who we are + +**Data Controller:** NHS England + +NHS England is responsible for deciding how and why your personal data is used as part of this pilot. + +### 3\. What data we collect + +If you choose to take part, we may collect: + +- NHS number +- Name +- Email address + +This information is provided through NHS login to allow secure access. + +Information you provide in the questionnaire: + +- Date of birth +- Smoking history +- Height and weight +- Sex at birth and gender identity +- Ethnicity +- Education level +- Relevant health, lifestyle, and family history information + +When you complete the telephone screening, NHS England receives the telephone responses **so they can be compared with the digital responses** as part of the evaluation. + +On completion of both the digital service and phone screening appointment, your name and email address will be used to issue a participation voucher. + +### 4\. How we use your information + +We use your information to: + +- Evaluate whether the digital questionnaire works as safely and accurately as the telephone screening +- Compare digital and telephone screening responses +- Understand clarity, usability, and accessibility of the digital service +- Inform decisions about future service design + +We do not use your information to: + +- Provide diagnoses +- Make clinical decisions +- Replace existing screening pathways + +Participation in the pilot is voluntary. Where you choose to take part, we rely on your consent to: + +- participate in the digital pilot +- allow comparison of digital and telephone screening responses +- administer participation incentives where applicable + +You can withdraw from the pilot at any time without affecting your NHS care. + +### 5\. Who we share your information with + +Your information may be shared with: + +- **InHealth**, to enable comparison between digital and telephone screening responses +- **NHS England technical and analysis teams**, limited to staff who need access to support the pilot +- **Edenred**, where applicable, to issue participation vouchers (name and email address only) + +We do not sell your data or use it for marketing. + +### 6\. How long information is kept + +How long we keep your information: + +- Identifiable pilot data is kept only for the duration of the pilot and up to **three months afterwards** +- Personal identifiers are removed once analysis is complete +- An anonymised and aggregated dataset may be retained for up to **10 years** to support service evaluation and regulatory requirements +- All data is managed in line with the **NHS Records Management Code of Practice** + +### 7\. The law and your information + +The law allows the NHS to use information: + +- to invite people for screening +- to keep screening services safe and effective + +You can find out more [here](#). + +### 8\. Your rights and getting help + +You have rights over your personal information. + +These include the right to: + +- ask what information the NHS holds about you +- ask for a copy of your screening results + +If you want to access your screening results, contact your GP or local screening service. + +### 9\. Ask a question or find out more + +If you have a general question about using the NHS {{ serviceName | lower }}, you can contact us by email at: {{ serviceEmail }} diff --git a/app/prototype_v4_2/content/terms.md b/app/prototype_v4_2/content/terms.md new file mode 100644 index 0000000..3320353 --- /dev/null +++ b/app/prototype_v4_2/content/terms.md @@ -0,0 +1,113 @@ +--- +title: NHS {{ serviceName | lower }} terms of use +--- + +Version 4, 25 March 2026 + +## 1\. Introduction + +1.1. We (NHS England) have developed a digital service called NHS {{ serviceName | lower }}. This is a digital way to access [NHS lung cancer screening](#). This service is currently in pilot so is only available in certain areas and if your GP surgery is participating in the pilot. + +1.2. This service will not provide you with any results or information in relation to the lung cancer screening, you will need to complete your lung cancer screening by phone to receive a result. + +1.3. To find out more about who we are and our role, visit the [NHS England website](#). + +1.4. Contacting us: if you have any questions about the service, contact us by email: {{ serviceEmail }} + +## 2\. When these terms apply + +2.1. Please read these terms of use and our [privacy policy](#), [cookies policy](#) and [accessibility statement](#). By continuing to use the NHS {{ serviceName | lower }}, you agree to be bound by these terms. + +## 3\. How to use the NHS check if you need 2 lung scan + +3.1. To use NHS {{ serviceName | lower }}, your GP must be participating in the pilot. Your GP will send you a letter and an SMS with a link to access the NHS {{ serviceName | lower }} pilot service in a web browser. + +3.2. To use the NHS {{ serviceName | lower }} you need an NHS Login account with a medium level of identity verification. If you do not have an account or a medium level of identity verification, you will be able to set this up as part of your application. [Find out more about NHS login](#). + +3.3. You are responsible for making all arrangements necessary for you to access the NHS {{ serviceName | lower }}, including but not limited to: + +- a secure internet connection (see [Cyber Aware website](#)) +- an appropriate device, operating system and browser +- using your own virus protection software (and regularly updating it) when accessing and using the NHS {{ serviceName | lower }} + +## 4\. Details about the NHS check if you nee2 a lung scan + +4.1. If you download, print or export any of your submitted information, you are responsible for ensuring that this is held securely, and we will not be liable for any associated disclosure of sensitive and personal data. + +4.2. The NHS {{ serviceName | lower }}: + +- is not a substitute for seeking medical advice - always follow any medical advice given by your healthcare professionals +- does not provide medical or clinical diagnostic services +- does not arrange or guarantee further healthcare treatment. You remain responsible for booking further healthcare treatment via the phone service as directed by your GP. + +## 5\. Ending your use of the NHS check if yo2 need a lung scan + +5.1. You may stop using the NHS {{ serviceName | lower }} at any time. If you fail to complete your NHS {{ serviceName | lower }} within a set period of time your data will automatically be deleted. More information on how long your information is kept is in the [privacy policy](#). + +## 6\. Your right to use the NHS check if yo2 need a lung scan + +6.1. We own or have the right to use all intellectual property rights used for the provision of the NHS {{ serviceName | lower }}, including rights in copyright, patents, database rights, trademarks and other intellectual property rights, ("NHS IPR"). + +6.2. Unless permitted by law or under these terms, you will: + +- not copy the NHS {{ serviceName | lower }} or any NHS IPR, except where such copying is incidental to normal use +- not rent, lease, sub-license, loan, translate, merge, adapt or modify the NHS {{ serviceName | lower }} or any NHS IPR +- not combine or incorporate the NHS {{ serviceName | lower }} in any other programmes or services +- not disassemble, decompile, reverse-engineer or create derivative works based on the whole or any part of the NHS {{ serviceName | lower }} or other NHS IPR +- comply with all technology control or export laws that apply to the technology used by the NHS {{ serviceName | lower }} or any other NHS IPR + +## 7\. Prohibited uses + +7.1. You may not use the NHS {{ serviceName | lower }}: + +- to collect any data or attempt to decipher any transmissions to or from our servers +- in a way that could damage, disable, overburden, impair or compromise our systems or security +- to transmit any material that is insulting or offensive +- in a way that interferes with other users +- in any unlawful manner or for any unlawful purpose +- in a manner that is improper use or inconsistent with these terms +- to act fraudulently or maliciously by seeking to access or add data to another patient's GP record +- to transmit, send or upload any data that contains viruses, Trojan horses, worms, spyware or any other harmful programs designed to adversely affect the operation of computer software or hardware +- in connection with any kind of denial of service attack +- on any device or operating system that has been modified outside the mobile device or operating system vendor supported or warranted configurations. This includes devices that have been "jail-broken" or "rooted" +- with someone else's NHS login account + +7.2. If you do any of the above acts you may also be committing a criminal offence, and we will report any such activity to the relevant law enforcement authorities. We will co-operate with those authorities by disclosing your identity to them. + +## 8\. Our liability to you + +8.1. Although we make reasonable efforts to provide, maintain and update the NHS {{ serviceName | lower }} it is provided "as is" and, to the extent permitted by law, we make no representations, warranties or guarantees, whether express or implied (including but not limited to the implied warranties of satisfactory quality and fitness for a particular purpose), that the NHS {{ serviceName | lower }}: + +- is accurate, complete or up-to-date +- will meet your particular requirements or needs +- will always be available, error free, uninterrupted or free of viruses + +8.2. We are not responsible for external links to or from the NHS {{ serviceName | lower }} and cannot guarantee these will always work. + +8.3. Nothing in these terms excludes or limits our liability for: + +- death or personal injury arising from our negligence +- fraud or fraudulent misrepresentation +- any loss or damage to a device or digital content belonging to you, if you can show that a) this was caused by NHS {{ serviceName | lower }} and b) we failed use to use reasonable skill and care to prevent this +- any other liability that cannot be excluded or limited under English law + +8.4. Subject to clause 8.3 of these terms, we will not be liable or responsible to you or any other person for: + +- any harm, loss or damage suffered where this is not caused by i) our negligence or ii) our breach of these terms +- any loss or damage arising from an inability to access or use the NHS {{ serviceName | lower }} in whole or in part +- any business loss (including but not limited to loss of profits, revenue, contracts, anticipated savings, data, goodwill or wasted expenditure) +- any indirect or consequential losses that were not foreseeable to both you and us when you commenced using the NHS {{ serviceName | lower }} (loss or damage is "foreseeable" if it was an obvious consequence of our breach or if it was recognised by you and us at the time we entered into the contract created by your use of the NHS {{ serviceName | lower }}) + +8.5. This clause 8 does not affect any legal rights you may have as a consumer in relation to defective services or software. Advice about your legal rights is available from your local Citizen's Advice or Trading Standards Office. + +## 9\. General + +9.1. These terms, any instructions in the service, and any other terms or policies referenced, set out the entire agreement between you and us in respect of your use of the NHS {{ serviceName | lower }}. + +9.2. These terms do not give any rights to any third party to enforce any of these terms. + +9.3. Each of the clauses and sub-clauses of these terms operates separately. If any part is determined to be invalid or unenforceable it will be superseded by a valid and enforceable provision that most closely matches the intent of the original and all other terms shall continue in effect. + +9.4. Even if we delay in enforcing these terms, we can still enforce them later. + +9.5. The laws of England shall apply exclusively to these terms and all matters relating to use of the NHS {{ serviceName | lower }}, and any dispute shall be subject to the exclusive jurisdiction of the courts of England. diff --git a/app/prototype_v4_2/controllers/authentication.js b/app/prototype_v4_2/controllers/authentication.js new file mode 100644 index 0000000..1ec3f2c --- /dev/null +++ b/app/prototype_v4_2/controllers/authentication.js @@ -0,0 +1,88 @@ +const { path: prototypePath, view } = require('../lib/settings') + +exports.signIn_get = (req, res) => { + delete req.session.data + + res.render(view('authentication/sign-in'), { + actions: { + back: prototypePath, + next: `${prototypePath}/sign-in` + } + }) +} + +exports.signIn_post = (req, res) => { + const errors = [] + + if (errors.length) { + res.render(view('authentication/sign-in'), { + errors, + actions: { + back: prototypePath, + next: `${prototypePath}/sign-in` + } + }) + } else { + res.redirect(`${prototypePath}/security-code`) + } +} + +exports.securityCode_get = (req, res) => { + res.render(view('authentication/security-code'), { + actions: { + back: `${prototypePath}/sign-in`, + next: `${prototypePath}/security-code` + } + }) +} + +exports.securityCode_post = (req, res) => { + const errors = [] + + if (errors.length) { + res.render(view('authentication/security-code'), { + errors, + actions: { + back: `${prototypePath}/sign-in`, + next: `${prototypePath}/security-code` + } + }) + } else { + res.redirect(`${prototypePath}/sign-in-agreement`) + } +} + +exports.signInAgreement_get = (req, res) => { + res.render(view('authentication/sign-in-agreement'), { + actions: { + back: `${prototypePath}/security-code`, + accept: `${prototypePath}/sign-in-agreement`, + decline: `${prototypePath}/sign-in-agreement-declined` + } + }) +} + +exports.signInAgreement_post = (req, res) => { + const errors = [] + + if (errors.length) { + res.render(view('authentication/sign-in-agreement'), { + errors, + actions: { + back: `${prototypePath}/security-code`, + accept: `${prototypePath}/sign-in-agreement`, + decline: `${prototypePath}/sign-in-agreement-declined` + } + }) + } else { + res.redirect(`${prototypePath}/accept-terms`) + } +} + +exports.signInAgreementDeclined_get = (req, res) => { + res.render(view('authentication/sign-in-agreement-declined'), { + actions: { + back: prototypePath + } + }) +} diff --git a/app/prototype_v4_2/controllers/content.js b/app/prototype_v4_2/controllers/content.js new file mode 100644 index 0000000..2e0212d --- /dev/null +++ b/app/prototype_v4_2/controllers/content.js @@ -0,0 +1,73 @@ +const fs = require('fs') +const path = require('path') +const matter = require('gray-matter') +const { view } = require('../lib/settings') + +const contentDirectory = path.join(__dirname, '..', 'content') + +const renderNunjucksData = (value, nunjucks, context) => { + if (typeof value === 'string') { + return nunjucks.renderString(value, context) + } + + if (Array.isArray(value)) { + return value.map((item) => renderNunjucksData(item, nunjucks, context)) + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + renderNunjucksData(item, nunjucks, context) + ]) + ) + } + + return value +} + +const renderContent = (fileName) => (req, res, next) => { + const filePath = path.join(contentDirectory, `${fileName}.md`) + + fs.readFile(filePath, 'utf8', (error, file) => { + if (error) { + return next(error) + } + + let contentData + let renderedMarkdown + + try { + const parsed = matter(file) + const nunjucks = req.app.get('nunjucksEnv') + const renderedData = renderNunjucksData(parsed.data, nunjucks, res.locals) + + contentData = { + contents: { + items: [] + }, + ...renderedData + } + + const templateContext = { + ...res.locals, + contentData + } + + renderedMarkdown = nunjucks.renderString(parsed.content, templateContext) + } catch (error) { + return next(error) + } + + res.render(view('content/show'), { + content: renderedMarkdown, + contentData + }) + }) +} + +exports.accessibility = renderContent('accessibility') +exports.contact = renderContent('contact') +exports.cookies = renderContent('cookies') +exports.privacy = renderContent('privacy') +exports.terms = renderContent('terms') diff --git a/app/prototype_v4_2/controllers/error.js b/app/prototype_v4_2/controllers/error.js new file mode 100644 index 0000000..f8dd496 --- /dev/null +++ b/app/prototype_v4_2/controllers/error.js @@ -0,0 +1,13 @@ +const { view } = require('../lib/settings') + +exports.pageNotFound = (req, res) => { + res.status(404).render(view('errors/404')) +} + +exports.unexpectedError = (req, res) => { + res.status(500).render(view('errors/500')) +} + +exports.serviceUnavailable = (req, res) => { + res.status(503).render(view('errors/503')) +} diff --git a/app/prototype_v4_2/controllers/question.js b/app/prototype_v4_2/controllers/question.js new file mode 100644 index 0000000..f1cd34c --- /dev/null +++ b/app/prototype_v4_2/controllers/question.js @@ -0,0 +1,787 @@ +const { getDateOfBirth, isEligibleForScanAge } = require('../lib/eligibility') +const { getQuestionPage } = require('../lib/question-pages') +const { renderQuestion, renderQuestionPage, version, view } = require('../lib/question-renderer') +const { getCheckYourAnswers } = require('../lib/summary') +const { + deleteUnselectedSmokingQuantityOtherAnswer, + deleteUnselectedSmokingChangeAnswers, + deleteUnselectedSmokingTypeAnswers, + getFormerSmokerFallbackStep, + getSelectedSmokingTypes, + getSmokingTypeActions, + getSmokingTypeQuestionOverrides, + getSmokingTypeStep, + getSmokingTypeSteps, + getSmokingTypeStepUrl, + renderSmokingTypePage, + renderSmokingTypeQuestion, + validateSmokingTypePage, + validateSmokingTypeQuestion +} = require('../lib/tobacco-flow') +const { getHeightBack, getWeightBack, getWeightNext } = require('../lib/unit-navigation') +const { validateQuestion, validateQuestions } = require('../lib/question-validator') + +const getQuestionPageIds = (id, answers = {}) => { + return getQuestionPage(id, answers).questions.map((question) => question.id) +} + +/// ------------------------------------------------------------------------ /// +/// +/// ------------------------------------------------------------------------ /// + +exports.acceptTerms_get = (req, res) => { + renderQuestion(res, 'accept-terms', { + next: `/prototype_${version}/accept-terms`, + back: `/prototype_${version}/sign-in-agreement`, + cancel: `/prototype_${version}` + }) +} + +exports.acceptTerms_post = (req, res) => { + const { answers } = req.session.data + const errors = validateQuestion(answers, 'accept-terms') + + if (errors.length) { + renderQuestion(res, 'accept-terms', { + next: `/prototype_${version}/accept-terms`, + back: `/prototype_${version}/sign-in-agreement`, + cancel: `/prototype_${version}` + }, errors) + } else { + res.redirect(`/prototype_${version}/phone-questionnaire`) + } +} + +exports.phoneQuestionnaire_get = (req, res) => { + renderQuestion(res, 'phone-questionnaire', { + next: `/prototype_${version}/phone-questionnaire`, + back: `/prototype_${version}/accept-terms`, + cancel: `/prototype_${version}/` + }) +} + +exports.phoneQuestionnaire_post = (req, res) => { + const { answers } = req.session.data + const errors = validateQuestion(answers, 'phone-questionnaire') + + if (errors.length) { + renderQuestion(res, 'phone-questionnaire', { + next: `/prototype_${version}/phone-questionnaire`, + back: `/prototype_${version}/accept-terms`, + cancel: `/prototype_${version}/` + }, errors) + } else { + if (answers.phoneQuestionnaire === 'yes') { + res.redirect(`/prototype_${version}/phone-questionnaire-exit`) + } else { + res.redirect(`/prototype_${version}/smoker`) + } + } +} + +exports.phoneQuestionnaireExit_get = (req, res) => { + res.render(view('questions/phone-questionnaire-exit'), { + actions: { + back: `/prototype_${version}/phone-questionnaire` + } + }) +} + +/// ------------------------------------------------------------------------ /// +/// Eligibility +/// ------------------------------------------------------------------------ /// + +exports.smoker_get = (req, res) => { + renderQuestion(res, 'smoker', { + next: `/prototype_${version}/smoker`, + back: `/prototype_${version}/phone-questionnaire`, + cancel: `/prototype_${version}/` + }) +} + +exports.smoker_post = (req, res) => { + const { answers } = req.session.data + const errors = validateQuestion(answers, 'smoker') + + if (errors.length) { + renderQuestion(res, 'smoker', { + next: `/prototype_${version}/smoker`, + back: `/prototype_${version}/phone-questionnaire`, + cancel: `/prototype_${version}/` + }, errors) + } else { + if (['no', 'yes_fewer_than_100'].includes(answers.smoker)) { + res.redirect(`/prototype_${version}/not-eligible-for-screening`) + } else { + res.redirect(`/prototype_${version}/date-of-birth`) + } + } +} + +exports.dateOfBirth_get = (req, res) => { + renderQuestion(res, 'date-of-birth', { + next: `/prototype_${version}/date-of-birth`, + back: `/prototype_${version}/smoker`, + cancel: `/prototype_${version}/` + }) +} + +exports.dateOfBirth_post = (req, res) => { + const { answers } = req.session.data + const errors = validateQuestion(answers, 'date-of-birth') + const dateOfBirth = getDateOfBirth(answers) + + if (errors.length) { + renderQuestion(res, 'date-of-birth', { + next: `/prototype_${version}/date-of-birth`, + back: `/prototype_${version}/smoker`, + cancel: `/prototype_${version}/` + }, errors) + } else { + if (!isEligibleForScanAge(dateOfBirth)) { + res.redirect(`/prototype_${version}/not-eligible-for-scan`) + } else { + res.redirect(`/prototype_${version}/face-to-face-appointment`) + } + } +} + +exports.faceToFaceAppointment_get = (req, res) => { + renderQuestion(res, 'face-to-face-appointment', { + next: `/prototype_${version}/face-to-face-appointment`, + back: `/prototype_${version}/date-of-birth`, + cancel: `/prototype_${version}/` + }) +} + +exports.faceToFaceAppointment_post = (req, res) => { + const { answers } = req.session.data + const errors = validateQuestion(answers, 'face-to-face-appointment') + + if (errors.length) { + renderQuestion(res, 'face-to-face-appointment', { + next: `/prototype_${version}/face-to-face-appointment`, + back: `/prototype_${version}/date-of-birth`, + cancel: `/prototype_${version}/` + }, errors) + } else { + if (answers.faceToFaceAppointment === 'yes') { + res.redirect(`/prototype_${version}/book-appointment`) + } else { + res.redirect(`/prototype_${version}/height-metric`) + } + } +} + +exports.notEligibleForScreening_get = (req, res) => { + res.render(view('questions/not-eligible-for-screening'), { + actions: { + back: `/prototype_${version}/smoker`, + cancel: `/prototype_${version}/` + } + }) +} + +exports.notEligibleForScan_get = (req, res) => { + res.render(view('questions/not-eligible-for-scan'), { + actions: { + back: `/prototype_${version}/date-of-birth`, + cancel: `/prototype_${version}/` + } + }) +} + +exports.bookAppointment_get = (req, res) => { + res.render(view('questions/book-appointment'), { + actions: { + back: `/prototype_${version}/face-to-face-appointment`, + cancel: `/prototype_${version}/` + } + }) +} + +/// ------------------------------------------------------------------------ /// +/// About you +/// ------------------------------------------------------------------------ /// + +exports.heightMetric_get = (req, res) => { + renderQuestion(res, 'height-metric', { + next: `/prototype_${version}/height-metric`, + switchUnits: `/prototype_${version}/height-imperial`, + back: `/prototype_${version}/face-to-face-appointment`, + cancel: `/prototype_${version}/` + }) +} + +exports.heightMetric_post = (req, res) => { + const { answers } = req.session.data + const errors = validateQuestion(answers, 'height-metric') + + if (errors.length) { + renderQuestion(res, 'height-metric', { + next: `/prototype_${version}/height-metric`, + switchUnits: `/prototype_${version}/height-imperial`, + back: `/prototype_${version}/face-to-face-appointment`, + cancel: `/prototype_${version}/` + }, errors) + } else { + delete answers.height?.imperial + res.redirect(getWeightNext(req, 'metric')) + } +} + +exports.heightImperial_get = (req, res) => { + renderQuestion(res, 'height-imperial', { + next: `/prototype_${version}/height-imperial`, + switchUnits: `/prototype_${version}/height-metric`, + back: `/prototype_${version}/face-to-face-appointment`, + cancel: `/prototype_${version}/` + }) +} + +exports.heightImperial_post = (req, res) => { + const { answers } = req.session.data + const errors = validateQuestion(answers, 'height-imperial') + + if (errors.length) { + renderQuestion(res, 'height-imperial', { + next: `/prototype_${version}/height-imperial`, + switchUnits: `/prototype_${version}/height-metric`, + back: `/prototype_${version}/face-to-face-appointment`, + cancel: `/prototype_${version}/` + }, errors) + } else { + delete answers.height?.metric + res.redirect(getWeightNext(req, 'imperial')) + } +} + +exports.weightMetric_get = (req, res) => { + const back = getHeightBack(req) + + renderQuestion(res, 'weight-metric', { + next: `/prototype_${version}/weight-metric`, + switchUnits: `/prototype_${version}/weight-imperial`, + back, + cancel: `/prototype_${version}/` + }) +} + +exports.weightMetric_post = (req, res) => { + const { answers } = req.session.data + const back = getHeightBack(req) + const errors = validateQuestion(answers, 'weight-metric') + + if (errors.length) { + renderQuestion(res, 'weight-metric', { + next: `/prototype_${version}/weight-metric`, + switchUnits: `/prototype_${version}/weight-imperial`, + back, + cancel: `/prototype_${version}/` + }, errors) + } else { + delete answers.weight?.imperial + res.redirect(`/prototype_${version}/about-you`) + } +} + +exports.weightImperial_get = (req, res) => { + const back = getHeightBack(req) + + renderQuestion(res, 'weight-imperial', { + next: `/prototype_${version}/weight-imperial`, + switchUnits: `/prototype_${version}/weight-metric`, + back, + cancel: `/prototype_${version}/` + }) +} + +exports.weightImperial_post = (req, res) => { + const { answers } = req.session.data + const back = getHeightBack(req) + const errors = validateQuestion(answers, 'weight-imperial') + + if (errors.length) { + renderQuestion(res, 'weight-imperial', { + next: `/prototype_${version}/weight-imperial`, + switchUnits: `/prototype_${version}/weight-metric`, + back, + cancel: `/prototype_${version}/` + }, errors) + } else { + delete answers.weight?.metric + res.redirect(`/prototype_${version}/about-you`) + } +} + +exports.aboutYou_get = (req, res) => { + const back = getWeightBack(req) + const { answers } = req.session.data + + renderQuestionPage(res, 'about-you', { + next: `/prototype_${version}/about-you`, + back, + cancel: `/prototype_${version}/` + }, [], answers) +} + +exports.aboutYou_post = (req, res) => { + const { answers } = req.session.data + const back = getWeightBack(req) + const errors = validateQuestions(answers, getQuestionPageIds('about-you', answers)) + + if (errors.length) { + renderQuestionPage(res, 'about-you', { + next: `/prototype_${version}/about-you`, + back, + cancel: `/prototype_${version}/` + }, errors, answers) + } else { + res.redirect(`/prototype_${version}/respiratory-conditions`) + } +} + +// exports.gender_get = (req, res) => { +// const back = getWeightBack(req) + +// renderQuestion(res, 'gender', { +// next: `/prototype_${version}/gender`, +// back, +// cancel: `/prototype_${version}/` +// }) +// } + +// exports.gender_post = (req, res) => { +// const { answers } = req.session.data +// const back = getWeightBack(req) +// const errors = validateQuestion(answers, 'gender') + +// if (errors.length) { +// renderQuestion(res, 'gender', { +// next: `/prototype_${version}/gender`, +// back, +// cancel: `/prototype_${version}/` +// }, errors) +// } else { +// res.redirect(`/prototype_${version}/sex`) +// } +// } + +// exports.sex_get = (req, res) => { +// renderQuestion(res, 'sex', { +// next: `/prototype_${version}/sex`, +// back: `/prototype_${version}/gender`, +// cancel: `/prototype_${version}/` +// }) +// } + +// exports.sex_post = (req, res) => { +// const { answers } = req.session.data +// const errors = validateQuestion(answers, 'sex') + +// if (errors.length) { +// renderQuestion(res, 'sex', { +// next: `/prototype_${version}/sex`, +// back: `/prototype_${version}/gender`, +// cancel: `/prototype_${version}/` +// }, errors) +// } else { +// res.redirect(`/prototype_${version}/ethnicity`) +// } +// } + +// exports.ethnicity_get = (req, res) => { +// renderQuestion(res, 'ethnicity', { +// next: `/prototype_${version}/ethnicity`, +// back: `/prototype_${version}/sex`, +// cancel: `/prototype_${version}/` +// }) +// } + +// exports.ethnicity_post = (req, res) => { +// const { answers } = req.session.data +// const errors = validateQuestion(answers, 'ethnicity') + +// if (errors.length) { +// renderQuestion(res, 'ethnicity', { +// next: `/prototype_${version}/ethnicity`, +// back: `/prototype_${version}/sex`, +// cancel: `/prototype_${version}/` +// }, errors) +// } else { +// res.redirect(`/prototype_${version}/education`) +// } +// } + +// exports.education_get = (req, res) => { +// renderQuestion(res, 'education', { +// next: `/prototype_${version}/education`, +// back: `/prototype_${version}/ethnicity`, +// cancel: `/prototype_${version}/` +// }) +// } + +// exports.education_post = (req, res) => { +// const { answers } = req.session.data +// const errors = validateQuestion(answers, 'education') + +// if (errors.length) { +// renderQuestion(res, 'education', { +// next: `/prototype_${version}/education`, +// back: `/prototype_${version}/ethnicity`, +// cancel: `/prototype_${version}/` +// }, errors) +// } else { +// res.redirect(`/prototype_${version}/respiratory-conditions`) +// } +// } + +/// ------------------------------------------------------------------------ /// +/// Your health +/// ------------------------------------------------------------------------ /// + +exports.respiratoryConditions_get = (req, res) => { + renderQuestion(res, 'respiratory-conditions', { + next: `/prototype_${version}/respiratory-conditions`, + back: `/prototype_${version}/about-you`, + cancel: `/prototype_${version}/` + }) +} + +exports.respiratoryConditions_post = (req, res) => { + const { answers } = req.session.data + const errors = validateQuestion(answers, 'respiratory-conditions') + + if (errors.length) { + renderQuestion(res, 'respiratory-conditions', { + next: `/prototype_${version}/respiratory-conditions`, + back: `/prototype_${version}/about-you`, + cancel: `/prototype_${version}/` + }, errors) + } else { + res.redirect(`/prototype_${version}/asbestos`) + } +} + +exports.asbestos_get = (req, res) => { + const { answers } = req.session.data + + renderQuestionPage(res, 'asbestos', { + next: `/prototype_${version}/asbestos`, + back: `/prototype_${version}/respiratory-conditions`, + cancel: `/prototype_${version}/` + }, [], answers) +} + +exports.asbestos_post = (req, res) => { + const { answers } = req.session.data + const errors = validateQuestions(answers, getQuestionPageIds('asbestos', answers)) + + if (errors.length) { + renderQuestionPage(res, 'asbestos', { + next: `/prototype_${version}/asbestos`, + back: `/prototype_${version}/respiratory-conditions`, + cancel: `/prototype_${version}/` + }, errors, answers) + } else { + res.redirect(`/prototype_${version}/cancer-diagnosis`) + } +} + +exports.cancerDiagnosis_get = (req, res) => { + renderQuestion(res, 'cancer-diagnosis', { + next: `/prototype_${version}/cancer-diagnosis`, + back: `/prototype_${version}/asbestos`, + cancel: `/prototype_${version}/` + }) +} + +exports.cancerDiagnosis_post = (req, res) => { + const { answers } = req.session.data + const errors = validateQuestion(answers, 'cancer-diagnosis') + + if (errors.length) { + renderQuestion(res, 'cancer-diagnosis', { + next: `/prototype_${version}/cancer-diagnosis`, + back: `/prototype_${version}/asbestos`, + cancel: `/prototype_${version}/` + }, errors) + } else { + res.redirect(`/prototype_${version}/cancer-diagnosis-relatives`) + } +} + +/// ------------------------------------------------------------------------ /// +/// Family history +/// ------------------------------------------------------------------------ /// + +exports.cancerDiagnosisRelatives_get = (req, res) => { + renderQuestion(res, 'cancer-diagnosis-relatives', { + next: `/prototype_${version}/cancer-diagnosis-relatives`, + back: `/prototype_${version}/cancer-diagnosis`, + cancel: `/prototype_${version}/` + }) +} + +exports.cancerDiagnosisRelatives_post = (req, res) => { + const { answers } = req.session.data + const errors = validateQuestion(answers, 'cancer-diagnosis-relatives') + + if (errors.length) { + renderQuestion(res, 'cancer-diagnosis-relatives', { + next: `/prototype_${version}/cancer-diagnosis-relatives`, + back: `/prototype_${version}/cancer-diagnosis`, + cancel: `/prototype_${version}/` + }, errors) + } else { + if (answers.cancerDiagnosisRelatives === 'yes') { + res.redirect(`/prototype_${version}/cancer-diagnosis-relatives-age`) + } else { + delete answers.cancerDiagnosisRelativesAge + res.redirect(`/prototype_${version}/smoking-duration`) + } + } +} + +exports.cancerDiagnosisRelativesAge_get = (req, res) => { + renderQuestion(res, 'cancer-diagnosis-relatives-age', { + next: `/prototype_${version}/cancer-diagnosis-relatives-age`, + back: `/prototype_${version}/cancer-diagnosis-relatives`, + cancel: `/prototype_${version}/` + }) +} + +exports.cancerDiagnosisRelativesAge_post = (req, res) => { + const { answers } = req.session.data + const errors = validateQuestion(answers, 'cancer-diagnosis-relatives-age') + + if (errors.length) { + renderQuestion(res, 'cancer-diagnosis-relatives-age', { + next: `/prototype_${version}/cancer-diagnosis-relatives-age`, + back: `/prototype_${version}/cancer-diagnosis-relatives`, + cancel: `/prototype_${version}/` + }, errors) + } else { + res.redirect(`/prototype_${version}/smoking-duration`) + } +} + +/// ------------------------------------------------------------------------ /// +/// Smoking habits +/// ------------------------------------------------------------------------ /// + +exports.smokingDuration_get = (req, res) => { + const answers = req.session.data.answers || {} + const back = answers?.cancerDiagnosisRelativesAge ? `/prototype_${version}/cancer-diagnosis-relatives-age` : `/prototype_${version}/cancer-diagnosis-relatives` + + renderQuestionPage(res, 'smoking-duration', { + next: `/prototype_${version}/smoking-duration`, + back, + cancel: `/prototype_${version}/` + }, [], answers) +} + +exports.smokingDuration_post = (req, res) => { + const answers = req.session.data.answers || {} + const back = answers?.cancerDiagnosisRelativesAge ? `/prototype_${version}/cancer-diagnosis-relatives-age` : `/prototype_${version}/cancer-diagnosis-relatives` + const errors = validateQuestions(answers, getQuestionPageIds('smoking-duration', answers)) + + if (errors.length) { + renderQuestionPage(res, 'smoking-duration', { + next: `/prototype_${version}/smoking-duration`, + back, + cancel: `/prototype_${version}/` + }, errors, answers) + } else { + if (!getQuestionPageIds('smoking-duration', answers).includes('age-stopped-smoking')) { + delete answers.ageStoppedSmoking + } + + if (answers.periodsStoppedSmoking === 'no') { + delete answers.yearsStoppedSmoking + } + + res.redirect(`/prototype_${version}/smoking-type`) + } +} + +/// ------------------------------------------------------------------------ /// +/// Tobacco +/// ------------------------------------------------------------------------ /// + +exports.smokingType_get = (req, res) => { + const answers = req.session.data.answers || {} + + renderQuestion(res, 'smoking-type', { + next: `/prototype_${version}/smoking-type`, + back: `/prototype_${version}/smoking-duration`, + cancel: `/prototype_${version}/` + }, [], getSmokingTypeQuestionOverrides(answers)) +} + +exports.smokingType_post = (req, res) => { + const answers = req.session.data.answers || {} + const errors = validateQuestion(answers, 'smoking-type') + + if (errors.length) { + renderQuestion(res, 'smoking-type', { + next: `/prototype_${version}/smoking-type`, + back: `/prototype_${version}/smoking-duration`, + cancel: `/prototype_${version}/` + }, errors, getSmokingTypeQuestionOverrides(answers)) + } else { + const selectedTypes = Array.isArray(answers.smokingType) + ? answers.smokingType + : [answers.smokingType].filter(Boolean) + deleteUnselectedSmokingTypeAnswers(answers) + if (answers.smoker === 'yes_previous') { + getSelectedSmokingTypes(answers).forEach((type) => { + delete answers[type]?.smokingStatus + }) + } + const steps = getSmokingTypeSteps(answers) + + if (selectedTypes.includes('none')) { + res.redirect(`/prototype_${version}/smoking-type-exit`) + } else if (steps.length) { + res.redirect(getSmokingTypeStepUrl(steps[0])) + } else { + res.redirect(`/prototype_${version}/smoking-type`) + } + } +} + +exports.tobaccoSmoking_get = (req, res) => { + renderSmokingTypePage(req, res, 'tobacco-smoking') +} + +exports.tobaccoSmoking_post = (req, res) => { + const { step, steps } = getSmokingTypeStep(req, 'tobacco-smoking') + const { answers } = req.session.data + + if (!step) { + res.redirect(`/prototype_${version}/smoking-type`) + return + } + + deleteUnselectedSmokingQuantityOtherAnswer(answers, step) + + const errors = validateSmokingTypePage(req, 'tobacco-smoking', step) + + if (errors.length) { + renderSmokingTypePage(req, res, 'tobacco-smoking', errors) + } else { + res.redirect(getSmokingTypeActions(step, steps).onward) + } +} + +exports.smokingTypeExit_get = (req, res) => { + res.render(view('questions/smoking-type-exit'), { + actions: { + back: `/prototype_${version}/smoking-type` + } + }) +} + +exports.smokingStatus_get = (req, res) => { + renderSmokingTypeQuestion(req, res, 'smoking-status') +} + +exports.smokingStatus_post = (req, res) => { + const { step, steps } = getSmokingTypeStep(req, 'smoking-status') + const errors = step ? validateSmokingTypeQuestion(req, 'smoking-status', step) : [] + + if (!step) { + const fallbackStep = getFormerSmokerFallbackStep(req, 'smoking-status', steps) + + if (fallbackStep) { + res.redirect(getSmokingTypeStepUrl(fallbackStep)) + return + } + + res.redirect(`/prototype_${version}/smoking-type`) + return + } + + if (errors.length) { + renderSmokingTypeQuestion(req, res, 'smoking-status', errors) + } else { + res.redirect(getSmokingTypeActions(step, steps).onward) + } +} + +exports.smokingChange_get = (req, res) => { + renderSmokingTypeQuestion(req, res, 'smoking-change') +} + +exports.smokingChange_post = (req, res) => { + const { step, steps } = getSmokingTypeStep(req, 'smoking-change') + const { answers } = req.session.data + const errors = step ? validateSmokingTypeQuestion(req, 'smoking-change', step) : [] + + if (!step) { + res.redirect(`/prototype_${version}/smoking-type`) + return + } + + deleteUnselectedSmokingChangeAnswers(answers[step.type]) + + if (errors.length) { + renderSmokingTypeQuestion(req, res, 'smoking-change', errors) + } else { + res.redirect(getSmokingTypeActions(step, steps).onward) + } +} + +exports.tobaccoSmokingChange_get = (req, res) => { + renderSmokingTypePage(req, res, 'tobacco-smoking-change') +} + +exports.tobaccoSmokingChange_post = (req, res) => { + const { step, steps } = getSmokingTypeStep(req, 'tobacco-smoking-change') + + if (!step) { + res.redirect(`/prototype_${version}/smoking-type`) + return + } + + deleteUnselectedSmokingQuantityOtherAnswer(req.session.data.answers, step, 'quantity') + const errors = validateSmokingTypePage(req, 'tobacco-smoking-change', step) + + if (errors.length) { + renderSmokingTypePage(req, res, 'tobacco-smoking-change', errors) + } else { + res.redirect(getSmokingTypeActions(step, steps).onward) + } +} + +/// ------------------------------------------------------------------------ /// +/// Check your answers +/// ------------------------------------------------------------------------ /// + +exports.checkYourAnswers_get = (req, res) => { + const { answers } = req.session.data + const smokingSteps = getSmokingTypeSteps(answers) + const lastSmokingStep = smokingSteps[smokingSteps.length - 1] + + res.render(view('check-your-answers'), { + checkYourAnswers: getCheckYourAnswers(answers), + actions: { + next: `/prototype_${version}/check-your-answers`, + back: lastSmokingStep ? getSmokingTypeStepUrl(lastSmokingStep) : `/prototype_${version}/smoking-type`, + cancel: `/prototype_${version}/` + } + }) +} + +exports.checkYourAnswers_post = (req, res) => { + res.redirect(`/prototype_${version}/confirmation`) +} + +/// ------------------------------------------------------------------------ /// +/// Confirmation +/// ------------------------------------------------------------------------ /// + +exports.confirmation_get = (req, res) => { + res.render(view('confirmation')) +} diff --git a/app/prototype_v4_2/data/pages.yaml b/app/prototype_v4_2/data/pages.yaml new file mode 100644 index 0000000..6e020da --- /dev/null +++ b/app/prototype_v4_2/data/pages.yaml @@ -0,0 +1,238 @@ +--- +pages: + - id: accept-terms + questions: + - accept-terms + heading: + title: Terms of use + description: | + To continue, confirm that you have read and agree to the [NHS Check if you need a lung scan terms of use](terms-of-use). + - id: phone-questionnaire + questions: + - phone-questionnaire + heading: + title: Confirm if you have completed a lung cancer risk questionnaire by phone + description: | + If you have already completed a questionnaire about your lung health or lung cancer risk by phone you do not need to test the online service. This is because the online service asks the same questions. + + The questionnaire would have asked you questions about: + + - your height and weight + - your ethnicity + - your education + - if you have ever had a cancer diagnosis + - if your parents, siblings, or children have ever had a lung cancer diagnosis + - your smoking habits + - id: smoker + questions: + - smoker + heading: + title: Tobacco smoking + description: | + We will ask you questions about your smoking habits. This includes different types of tobacco smoking such as: + + - cigarettes from a packet + - rolling tobacco, or roll ups + - a pipe + - cigars + - cigarillos + - shisha + + It does not currently include vaping or e-cigarettes. + - id: date-of-birth + questions: + - date-of-birth + - id: face-to-face-appointment + questions: + - face-to-face-appointment + heading: + title: Check if you need a face-to-face appointment + description: | + This online service uses automatic calculations based on your height and weight. This means it is not suitable for everyone. + + You need to do your questionnaire in person, not online, if you: + + - have a condition that affects your height + - have had a limb amputation, or were born with a limb difference + - have been diagnosed with an eating disorder, or think you may have one + - are pregnant + - id: height-metric + questions: + - height-metric + heading: + title: Your height + caption: About you + description: | + An accurate measurement is important. + + You can measure your height at home with a measuring tape. Some pharmacies and gyms have machines to measure your height. + - id: height-imperial + questions: + - height-imperial + heading: + title: Your height + caption: About you + description: | + An accurate measurement is important. + + You can measure your height at home with a measuring tape. Some pharmacies and gyms have machines to measure your height. + - id: weight-metric + questions: + - weight-metric + heading: + title: Your weight + caption: About you + description: | + An accurate measurement is important. + + If you have digital scales, use these to check your weight. Some pharmacies and gyms have scales where you can check for free. + - id: weight-imperial + questions: + - weight-imperial + heading: + title: Your weight + caption: About you + description: | + An accurate measurement is important. + + If you have digital scales, use these to check your weight. Some pharmacies and gyms have scales where you can check for free. + - id: about-you + heading: + title: About you + questions: + - gender + - sex + - ethnicity + - education + description: The answers you submit will not be shared with your patient care advisor during your phone appointment, or with your GP. + - id: gender + questions: + - gender + heading: + title: Your gender identity + caption: About you + description: | + The answers you submit will not be shared with your patient care advisor during your phone appointment, or with your GP. + + In the future we plan to use information about someone's gender identity to help us plan their care in the NHS lung cancer screening programme. + - id: sex + questions: + - sex + heading: + title: Your sex at birth + caption: About you + description: | + Your sex may impact your chances of developing lung cancer. + - id: ethnicity + questions: + - ethnicity + heading: + title: Your ethnic background + caption: About you + description: | + We ask this question because your ethnicity may impact your chances of developing lung cancer. + - id: education + questions: + - education + heading: + title: Your education + caption: About you + description: | + We ask this question because education is linked to other factors that may impact your chances of developing lung cancer. + + If your qualification is not shown choose the closest level. + - id: respiratory-conditions + heading: + caption: Your health + questions: + - respiratory-conditions + - id: asbestos + heading: + title: Exposure to asbestos + caption: Your health + questions: + - asbestos-at-work + - asbestos-at-home + - id: cancer-diagnosis + questions: + - cancer-diagnosis + heading: + title: Tell us if you have ever been diagnosed with cancer + caption: Your health + description: | + If you have ever been diagnosed with any type of cancer it may impact your chances of developing lung cancer. + - id: cancer-diagnosis-relatives + questions: + - cancer-diagnosis-relatives + heading: + title: Tell us if your parents, siblings or children have ever been diagnosed with lung cancer + caption: Your family history + description: | + If any of your parents, siblings or children have ever been diagnosed with lung cancer it may impact your chances of developing lung cancer. + + This question is about lung cancer rather than any other type of cancer. It does not include your grandparents or grandchildren. + - id: cancer-diagnosis-relatives-age + questions: + - cancer-diagnosis-relatives-age + heading: + title: If your relatives were under 60 when they were diagnosed + caption: Your family history + description: | + We ask about their age when they were diagnosed with lung cancer because it may impact your chances of developing lung cancer. + + If you do not know or cannot remember, select 'I do not know'. + - id: smoking-duration + heading: + title: Smoking duration + caption: Your smoking habits + questions: + - age-started-smoking + - id: age-stopped-smoking + if: + any: + - question: smoker + is: yes_previous + - question: smoking-status + is: no + - periods-stopped-smoking + - id: smoking-type + questions: + - smoking-type + heading: + title: The type of tobacco you smoke or used to smoke + caption: Your smoking habits + description: | + Smoking affects your health in different ways depending on what you smoke. + + We need to know what you smoke, or used to smoke, frequently. For example, if you have smoked a pipe on a weekly basis for a year or longer. + + You do not need to tell us about less frequent forms of smoking. For example, a cigar on special occasions. + variants: + previous: + heading: + title: The type of tobacco you have smoked + description: | + Smoking affects your health in different ways depending on what you smoked. + + We need to know what you used to smoke frequently. For example, if you smoked a pipe on a weekly basis for a year or longer. + + You do not need to tell us about less frequent forms of smoking. For example, a cigar on special occasions. + - id: smoking-status + questions: + - smoking-status + - id: tobacco-smoking + heading: + title: Tobacco smoking + questions: + - smoking-frequency + - smoking-quantity + - id: tobacco-smoking-change + heading: + title: Tobacco smoking change + questions: + - smoking-frequency-change + - smoking-quantity-change + - smoking-years-change + - id: smoking-change + questions: + - smoking-change diff --git a/app/prototype_v4_2/data/questions.yaml b/app/prototype_v4_2/data/questions.yaml new file mode 100644 index 0000000..1edbcf0 --- /dev/null +++ b/app/prototype_v4_2/data/questions.yaml @@ -0,0 +1,881 @@ +--- +questions: + - id: accept-terms + type: multiple + answerKey: acceptTerms + input: + label: Confirm you agree to the terms of use + options: + - label: I agree + value: yes + validation: + required: true + errors: + required: + text: Confirm that you have read and agree to the terms of use + - id: phone-questionnaire + type: single + answerKey: phoneQuestionnaire + options: + - label: Yes + value: yes + - label: No + value: no + validation: + required: true + errors: + required: + text: Select whether you have previously completed a lung cancer risk questionnaire by phone + input: + label: Have you previously completed a lung cancer risk questionnaire by phone? + - id: smoker + type: single + answerKey: smoker + input: + label: Have you ever smoked tobacco? + hint: This includes social smoking + options: + - label: Yes, I currently smoke + value: yes_current + - label: Yes, I used to smoke + value: yes_previous + - label: Yes, but I have smoked fewer than 100 cigarettes in my lifetime + value: yes_fewer_than_100 + - label: No, I have never smoked + value: no + summary: + label: Have you ever smoked tobacco? + hiddenText: whether you have ever smoked tobacco + validation: + required: true + errors: + required: + text: Select whether you have ever smoked tobacco + - id: date-of-birth + type: date + answerKey: dateOfBirth + input: + label: What is your date of birth? + hint: For example, 15 3 1964 + items: + - id: dateOfBirth-day + name: answers[dateOfBirth][day] + label: Day + answerKey: day + - id: dateOfBirth-month + name: answers[dateOfBirth][month] + label: Month + answerKey: month + - id: dateOfBirth-year + name: answers[dateOfBirth][year] + label: Year + answerKey: year + summary: + label: Date of birth + validation: + required: true + type: date + errors: + required: + text: Enter your date of birth + href: "#dateOfBirth-day" + invalid: + text: Enter a real date of birth + href: "#dateOfBirth-day" + - id: face-to-face-appointment + type: single + answerKey: faceToFaceAppointment + input: + label: Do you need to leave the online service and ask for a face-to-face appointment? + options: + - label: Yes, one or more of these things apply to me, and I need a face-to-face appointment + value: yes + - label: No, I can continue online + value: no + summary: + label: Do you need to leave the online service and ask for a face-to-face appointment? + validation: + required: true + errors: + required: + text: Select whether you need to leave the online service and ask for a face-to-face appointment + - id: height-metric + type: text + answerKey: height + input: + id: height-metric + name: answers[height][metric] + label: What is your height in centimetres? + valueKey: metric + suffix: cm + inputmode: numeric + classes: nhsuk-input--width-4 + switchUnits: + text: Switch to feet and inches + summary: + label: Height + validation: + required: true + type: number + min: 100 + max: 250 + 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 + max: + text: Height in centimetres must be 250 or fewer + - id: height-imperial + type: text_group + answerKey: height + input: + valueKey: imperial + label: What is your height in feet and inches? + items: + - id: height-imperial-feet + name: answers[height][imperial][feet] + label: Feet + answerKey: feet + suffix: ft + inputmode: numeric + classes: nhsuk-input--width-4 + - id: height-imperial-inches + name: answers[height][imperial][inches] + label: Inches + answerKey: inches + suffix: in + inputmode: numeric + classes: nhsuk-input--width-4 + switchUnits: + text: Switch to centimetres + summary: + label: Height + validation: + items: + - answerKey: feet + required: true + type: number + min: 3 + max: 8 + - answerKey: inches + required: true + type: number + min: 0 + max: 11 + errors: + items: + feet: + required: + text: Enter your height in feet + href: "#height-imperial-feet" + invalid: + text: Enter your height in feet using numbers + href: "#height-imperial-feet" + min: + text: Height in feet must be 3 or more + href: "#height-imperial-feet" + max: + text: Height in feet must be 8 or fewer + href: "#height-imperial-feet" + inches: + required: + text: Enter your height in inches + href: "#height-imperial-inches" + invalid: + text: Enter your height in inches using numbers + href: "#height-imperial-inches" + min: + text: Height in inches must be 0 or more + href: "#height-imperial-inches" + max: + text: Height in inches must be 11 or fewer + href: "#height-imperial-inches" + - id: weight-metric + type: text + answerKey: weight + input: + id: weight-metric + name: answers[weight][metric] + valueKey: metric + label: What is your weight in kilograms? + suffix: kg + inputmode: numeric + classes: nhsuk-input--width-4 + switchUnits: + text: Switch to stones and pounds + summary: + label: Weight + validation: + required: true + type: number + min: 30 + max: 250 + errors: + required: + text: Enter your weight in kilograms + invalid: + text: Enter your weight in kilograms using numbers + min: + text: Weight in kilograms must be 30 or more + max: + text: Weight in kilograms must be 250 or fewer + - id: weight-imperial + type: text_group + answerKey: weight + input: + valueKey: imperial + items: + - id: weight-imperial-stones + name: answers[weight][imperial][stones] + label: Stones + answerKey: stones + suffix: st + inputmode: numeric + classes: nhsuk-input--width-4 + - id: weight-imperial-pounds + name: answers[weight][imperial][pounds] + label: Pounds + answerKey: pounds + suffix: lb + inputmode: numeric + classes: nhsuk-input--width-4 + label: What is your weight in stones and pounds? + switchUnits: + text: Switch to kilograms + summary: + label: Weight + validation: + items: + - answerKey: stones + required: true + type: number + min: 4 + max: 40 + - answerKey: pounds + required: true + type: number + min: 0 + max: 13 + errors: + items: + stones: + required: + text: Enter your weight in stones + href: "#weight-imperial-stones" + invalid: + text: Enter your weight in stones using numbers + href: "#weight-imperial-stones" + min: + text: Weight in stones must be 4 or more + href: "#weight-imperial-stones" + max: + text: Weight in stones must be 40 or fewer + href: "#weight-imperial-stones" + pounds: + required: + text: Enter your weight in pounds + href: "#weight-imperial-pounds" + invalid: + text: Enter your weight in pounds using numbers + href: "#weight-imperial-pounds" + min: + text: Weight in pounds must be 0 or more + href: "#weight-imperial-pounds" + max: + text: Weight in pounds must be 13 or fewer + href: "#weight-imperial-pounds" + - id: gender + type: single + answerKey: gender + input: + label: Which of these best describes your gender identity? + options: + - label: Female + value: female + - label: Male + value: male + - label: Non-binary + value: non_binary + - divider: or + - label: I prefer not to say + value: prefer_not_to_say + summary: + label: Gender identity + validation: + required: true + errors: + required: + text: Select which option best describes your gender identity + - id: sex + type: single + answerKey: sex + options: + - label: Female + value: female + - label: Male + value: male + summary: + label: Sex at birth + validation: + required: true + errors: + required: + text: Select your sex at birth + input: + label: What was your sex at birth? + - id: ethnicity + type: single + answerKey: ethnicity + input: + label: What is your ethnic background? + options: + - label: Asian or Asian British + value: asian_or_asian_british + - label: Black, African, Caribbean or Black British + value: black_african_caribbean_or_black_british + - label: Mixed or multiple ethnic groups + value: mixed_or_multiple_ethnic_groups + - label: White + value: white + - label: Other ethnic group + value: other_ethnic_group + - divider: or + - label: I prefer not to say + value: prefer_not_to_say + summary: + label: Ethnic background + validation: + required: true + errors: + required: + text: Select your ethnic background + - id: education + type: single + answerKey: education + input: + label: What is the highest level of education have you completed? + options: + - label: I finished school before the age of 15 + value: before_15 + - label: GCSEs + hint: Previously O-levels + value: gcse + - label: A-levels + hint: Previously Higher School Certificate (HSC) + value: a_level + - label: Further education + hint: For example, apprenticeships or Higher National Certificates (HNC) + value: further_education + - label: Undergraduate degree + hint: A university degree, also known as a bachelor’s degree + value: undergraduate_degree + - label: Postgraduate degree + hint: For example, a Masters or PhD + value: postgraduate_degree + - divider: or + - label: I prefer not to say + value: prefer_not_to_say + summary: + label: Education + validation: + required: true + errors: + required: + text: Select the highest level of education you have completed + - id: respiratory-conditions + type: multiple + answerKey: respiratoryConditions + input: + label: Have you ever been diagnosed with any of the following respiratory conditions? + hint: Select all that apply + options: + - label: Bronchitis + hint: An inflammation of the airways in the lungs that is usually caused by an infection + value: bronchitis + exclusiveGroup: conditions-list + - label: Chronic obstructive pulmonary disease (COPD) + hint: A group of lung conditions that cause breathing difficulties + value: chronic_obstructive_pulmonary_disease + exclusiveGroup: conditions-list + - label: Emphysema + hint: Damage to the air sacs in the lungs + value: emphysema + exclusiveGroup: conditions-list + - label: Pneumonia + hint: An infection of the lungs, usually diagnosed by a chest x-ray + value: pneumonia + exclusiveGroup: conditions-list + - label: Tuberculosis (TB) + hint: An infection that usually affects the lungs, but can affect any part of the body + value: tuberculosis + exclusiveGroup: conditions-list + - divider: or + - label: No, I have not had any of these respiratory conditions + value: no + exclusive: true + exclusiveGroup: conditions-list + summary: + label: Respiratory conditions + validation: + required: true + errors: + required: + text: Select if you have ever been diagnosed with any respiratory conditions + caption: Your health + - id: asbestos-at-work + type: single + answerKey: asbestosAtWork + input: + label: Have you ever worked in a job where you might have been exposed to asbestos? + options: + - label: Yes + value: yes + - label: No + value: no + summary: + label: Worked in a job where you might have been exposed to asbestos + validation: + required: true + errors: + required: + text: Select whether you have ever worked in a job where you might have been exposed to asbestos + - id: asbestos-at-home + type: single + answerKey: asbestosAtHome + input: + label: Have you ever lived with anyone who worked with asbestos? + options: + - label: Yes + value: yes + - label: No + value: no + summary: + label: Lived with anyone who worked with asbestos + validation: + required: true + errors: + required: + text: Select whether you have ever lived with anyone who worked with asbestos + - id: cancer-diagnosis + type: single + answerKey: cancerDiagnosis + input: + label: Have you ever been diagnosed with cancer? + options: + - label: Yes + value: yes + - label: No + value: no + summary: + label: Ever been diagnosed with cancer + validation: + required: true + errors: + required: + text: Select whether you have ever been diagnosed with cancer + - id: cancer-diagnosis-relatives + type: single + answerKey: cancerDiagnosisRelatives + input: + label: Have any of your parents, siblings or children ever been diagnosed with lung cancer? + options: + - label: Yes + value: yes + - label: No + value: no + - label: I do not know + value: do_not_know + summary: + label: Parents, siblings or children diagnosed with lung cancer + validation: + required: true + errors: + required: + text: Select whether any of your parents, siblings or children have ever been diagnosed with lung cancer + - id: cancer-diagnosis-relatives-age + type: single + answerKey: cancerDiagnosisRelativesAge + input: + label: Were any of your relatives younger than 60 years old when they were diagnosed with lung cancer? + options: + - label: Yes, they were younger than 60 + value: yes + - label: No, they were 60 or older + value: no + - label: I do not know + value: do_not_know + summary: + label: Relatives younger than 60 when diagnosed with lung cancer + validation: + required: true + errors: + required: + text: Select whether any of your relatives were younger than 60 when they were diagnosed with lung cancer + - id: age-started-smoking + type: text + answerKey: ageStartedSmoking + input: + label: How old were you when you started smoking? + hint: Give an estimate if you are not sure + prefix: Age + inputmode: numeric + classes: nhsuk-input--width-2 + summary: + label: Age you started smoking + validation: + required: true + type: number + min: 1 + max: 120 + errors: + required: + text: Enter the age you started smoking + invalid: + text: Enter the age you started smoking using numbers + min: + text: Age you started smoking must be 1 or older + max: + text: Age you started smoking must be 120 or younger + caption: Your smoking habits + - id: age-stopped-smoking + type: text + answerKey: ageStoppedSmoking + input: + label: How old were you when you stopped smoking? + hint: Give an estimate if you are not sure + prefix: Age + inputmode: numeric + classes: nhsuk-input--width-2 + summary: + label: Age you stopped smoking + validation: + required: true + type: number + min: 1 + max: 120 + errors: + required: + text: Enter the age you stopped smoking + invalid: + text: Enter the age you stopped smoking using numbers + min: + text: Age you stopped smoking must be 1 or older + max: + text: Age you stopped smoking must be 120 or younger + caption: Your smoking habits + - id: periods-stopped-smoking + type: single + answerKey: periodsStoppedSmoking + options: + - label: Yes + value: yes + conditionalInput: + id: years-stopped-smoking + name: answers[yearsStoppedSmoking] + answerKey: yearsStoppedSmoking + label: Enter the total number of years you stopped smoking + hint: Give an estimate if you are not sure + suffix: years + inputmode: numeric + classes: nhsuk-input--width-4 + - label: No + value: no + summary: + label: Stopped smoking for periods of 1 year or longer + validation: + required: true + conditional: + yes: + required: true + type: number + min: 1 + max: 80 + answerKey: yearsStoppedSmoking + href: "#years-stopped-smoking" + errors: + required: + text: Select whether you have ever stopped smoking for periods of 1 year or longer + conditional: + yes: + required: + text: Enter the total number of years you stopped smoking + href: "#years-stopped-smoking" + invalid: + text: Total number of years you stopped smoking must be a number + href: "#years-stopped-smoking" + min: + text: Total number of years you stopped smoking must be 1 or more + href: "#years-stopped-smoking" + max: + text: Total number of years you stopped smoking must be 80 or fewer + href: "#years-stopped-smoking" + input: + label: Have you ever stopped smoking for periods of 1 year or longer? + - id: smoking-type + type: multiple + answerKey: smokingType + input: + label: What do you or have you smoked? + hint: Select all that apply + options: + - label: Cigarettes + value: cigarettes + exclusiveGroup: types-list + - label: Rolling tobacco, or roll-ups + value: rolling_tobacco + exclusiveGroup: types-list + - label: Pipes + value: pipes + exclusiveGroup: types-list + - label: Small cigars + hint: Petit Corona or Short Panetela, usually 4 to 5 inches long + value: small_cigars + exclusiveGroup: types-list + - label: Medium cigars + hint: Robusto or Corona, usually 5 to 6 inches long + value: medium_cigars + exclusiveGroup: types-list + - label: Large cigars + hint: Churchill or Double Corona, usually 7 to 8 inches long + value: large_cigars + exclusiveGroup: types-list + - label: Cigarillos + hint: Cafe Creme or Signature cigars, roughly the size of a cigarette + value: cigarillos + exclusiveGroup: types-list + - label: Shisha + hint: Also known as hookah and water pipes + value: shisha + exclusiveGroup: types-list + - divider: or + - label: I have not smoked any of these types of tobacco + value: none + exclusive: true + exclusiveGroup: types-list + variants: + previous: + input: + label: What have you smoked? + summary: + label: Types of tobacco smoked + validation: + required: true + errors: + required: + text: Select the types of tobacco you have smoked + - id: smoking-status + type: single + input: + label: Do you currently smoke? + options: + - label: Yes + value: yes + - label: No + value: no + validation: + required: true + errors: + required: + text: Select whether you currently smoke this type of tobacco + - id: smoking-frequency + type: single + input: + label: How often do you smoke? + options: + - label: Daily + value: daily + - label: Weekly + hint: For example, on the weekend + value: weekly + - label: Monthly + hint: Select this option if you smoke at least once a month + value: monthly + - label: Yearly + hint: For example, 2 to 3 times a year or fewer + value: yearly + validation: + required: true + errors: + required: + text: Select how often you smoke this type of tobacco + - id: smoking-quantity + type: text + input: + id: smoking-quantity + label: How much do you smoke? + hint: Give an estimate if you are not sure + inputmode: numeric + classes: nhsuk-input--width-4 + variants: + rolling_tobacco: + type: single + input: + hint: A standard size pouch usually contains 30g of tobacco, a larger pouch is usually 50g + options: + - label: Less than 10g + value: less_than_10 + - label: 10g to 30g + value: 10_to_30 + - label: 31g to 50g + value: 31_to_50 + - label: 51g to 75g + value: 51_to_75 + - label: 76g to 100g + value: 76_to_100 + - label: More than 100g + value: more_than_100 + validation: + required: true + shisha: + type: single + input: + hint: null + options: + - label: Up to 30 minutes + value: up_to_30_minutes + - label: 30 minutes to 1 hour + value: 30_minutes_to_1_hour + - label: 1 to 2 hours + value: 1_to_2_hours + - label: More than 2 hours + value: more_than_2_hours + - divider: or + - label: Another amount + value: another_amount + conditionalInput: + id: smoking-quantity-other + name: answers[smokingQuantityOther] + answerKey: smokingQuantityOther + label: Enter the number of hours + hint: Give an estimate if you are not sure + suffix: hours + inputmode: numeric + classes: nhsuk-input--width-4 + validation: + required: true + validation: + required: true + type: number + min: 1 + max: 200 + errors: + required: + text: Enter how much you smoke + invalid: + text: Enter how much you smoke using numbers + min: + text: Amount smoked must be 1 or more + max: + text: Amount smoked must be 200 or fewer + - id: smoking-change + type: multiple + input: + label: Has the amount you normally smoke changed over time? + hint: Select all that apply + options: + - label: Yes, I used to smoke more + value: greater + exclusiveGroup: change-list + - label: Yes, I used to smoke fewer + value: fewer + exclusiveGroup: change-list + - divider: or + - label: No, it has not changed + value: no + exclusive: true + exclusiveGroup: change-list + validation: + required: true + errors: + required: + text: Select whether the amount you normally smoke has changed over time + - id: smoking-frequency-change + type: single + input: + label: How often did you smoke? + options: + - label: Daily + value: daily + - label: Weekly + hint: For example, on the weekend + value: weekly + - label: Monthly + hint: Select this option if you smoked at least once a month + value: monthly + - label: Yearly + hint: For example, 2 to 3 times a year or fewer + value: yearly + validation: + required: true + errors: + required: + text: Select how often you smoked this type of tobacco + - id: smoking-quantity-change + type: text + input: + id: smoking-quantity-change + label: How much did you smoke? + hint: Give an estimate if you are not sure + inputmode: numeric + classes: nhsuk-input--width-4 + variants: + rolling_tobacco: + type: single + input: + hint: A standard size pouch usually contains 30g of tobacco, a larger pouch is usually 50g + options: + - label: Less than 10g + value: less_than_10 + - label: 10g to 30g + value: 10_to_30 + - label: 31g to 50g + value: 31_to_50 + - label: 51g to 75g + value: 51_to_75 + - label: 76g to 100g + value: 76_to_100 + - label: More than 100g + value: more_than_100 + validation: + required: true + type: null + validation: + required: true + type: number + min: 1 + max: 200 + errors: + required: + text: Enter how much you smoked + invalid: + text: Enter how much you smoked using numbers + min: + text: Amount smoked must be 1 or more + max: + text: Amount smoked must be 200 or fewer + - id: smoking-years-change + type: text + input: + id: smoking-years-change + label: How many years did you smoke this amount? + hint: Give a rough estimate + suffix: years + inputmode: numeric + classes: nhsuk-input--width-4 + validation: + required: true + type: number + min: 1 + max: 80 + errors: + required: + text: Enter how many years you smoked this amount + invalid: + text: Enter how many years using numbers + min: + text: Number of years must be 1 or more + max: + text: Number of years must be 80 or fewer diff --git a/app/prototype_v4_2/data/tobacco.yaml b/app/prototype_v4_2/data/tobacco.yaml new file mode 100644 index 0000000..0e2d145 --- /dev/null +++ b/app/prototype_v4_2/data/tobacco.yaml @@ -0,0 +1,133 @@ +--- +tobaccoTypes: + cigarettes: + caption: Cigarette smoking + quantityUnit: cigarettes + singularSuffix: cigarette + suffix: cigarettes + headings: + current: + status: Do you currently smoke cigarettes? + frequency: How often do you smoke cigarettes? + quantity: How many cigarettes do you currently smoke in a normal day? + change: Has the number of cigarettes you normally smoke changed over time? + past: + frequency: How often did you smoke cigarettes? + quantity: How many cigarettes did you smoke in a normal day? + change: Did the number of cigarettes you normally smoked change over time? + + rolling_tobacco: + caption: Rolling tobacco smoking + quantityUnit: rolling tobacco + headings: + current: + status: Do you currently smoke rolling tobacco or roll-ups? + frequency: How often do you smoke rolling tobacco or roll-ups? + quantity: How much rolling tobacco do you currently smoke in a normal week? + change: Has the amount of rolling tobacco you normally smoke changed over time? + past: + frequency: How often did you smoke rolling tobacco or roll-ups? + quantity: How much rolling tobacco did you smoke in a normal week? + change: Did the amount of rolling tobacco you normally smoked change over time? + + pipes: + caption: Pipe smoking + quantityUnit: full pipe loads + singularSuffix: full pipe load + suffix: full pipe loads + headings: + current: + status: Do you currently smoke a pipe? + frequency: How often do you smoke a pipe? + quantity: How many full pipe loads do you currently smoke in a normal day? + change: Has the number of full pipe loads you normally smoke changed over time? + past: + frequency: How often did you smoke a pipe? + quantity: How many full pipe loads did you smoke in a normal day? + change: Did the number of full pipe loads you normally smoked change over time? + + small_cigars: + caption: Small cigar smoking + quantityUnit: small cigars + singularSuffix: small cigar + suffix: small cigars + headings: + current: + status: Do you currently smoke small cigars? + frequency: How often do you smoke small cigars? + quantity: How many small cigars do you currently smoke in a normal day? + change: Has the number of small cigars you normally smoke changed over time? + past: + frequency: How often did you smoke small cigars? + quantity: How many small cigars did you smoke in a normal day? + change: Did the number of small cigars you normally smoked change over time? + + medium_cigars: + caption: Medium cigar smoking + quantityUnit: medium cigars + singularSuffix: medium cigar + suffix: medium cigars + headings: + current: + status: Do you currently smoke medium cigars? + frequency: How often do you smoke medium cigars? + quantity: How many medium cigars do you currently smoke in a normal day? + change: Has the number of medium cigars you normally smoke changed over time? + past: + frequency: How often did you smoke medium cigars? + quantity: How many medium cigars did you smoke in a normal day? + change: Did the number of medium cigars you normally smoked change over time? + + large_cigars: + caption: Large cigar smoking + quantityUnit: large cigars + singularSuffix: large cigar + suffix: large cigars + headings: + current: + status: Do you currently smoke large cigars? + frequency: How often do you smoke large cigars? + quantity: How many large cigars do you currently smoke in a normal day? + change: Has the number of large cigars you normally smoke changed over time? + past: + frequency: How often did you smoke large cigars? + quantity: How many large cigars did you smoke in a normal day? + change: Did the number of large cigars you normally smoked change over time? + + cigarillos: + caption: Cigarillo smoking + quantityUnit: cigarillos + singularSuffix: cigarillo + suffix: cigarillos + headings: + current: + status: Do you currently smoke cigarillos? + frequency: How often do you smoke cigarillos? + quantity: How many cigarillos do you currently smoke in a normal day? + change: Has the number of cigarillos you normally smoke changed over time? + past: + frequency: How often did you smoke cigarillos? + quantity: How many cigarillos did you smoke in a normal day? + change: Did the number of cigarillos you normally smoked change over time? + + shisha: + caption: Shisha smoking + quantityUnit: hours + singularSuffix: hour + suffix: hours + headings: + current: + status: Do you currently smoke shisha? + frequency: How often do you smoke shisha? + quantity: How long do you currently smoke shisha in a normal day? + past: + frequency: How often did you smoke shisha? + quantity: How long did you smoke shisha in a normal day? + +smokingChangeTypes: + greater: + answerKey: smokingChangeIncrease + label: more + fewer: + answerKey: smokingChangeDecrease + label: fewer diff --git a/app/prototype_v4_2/docs/README.md b/app/prototype_v4_2/docs/README.md new file mode 100644 index 0000000..24cb176 --- /dev/null +++ b/app/prototype_v4_2/docs/README.md @@ -0,0 +1,42 @@ +# Prototype v4.1 documentation + +Prototype v4.1 uses YAML-backed question content for most standard question pages. + +## Main files + +| File | Purpose | +| --- | --- | +| `data/questions.yaml` | Standard question content, options, validation and error messages. | +| `data/tobacco.yaml` | Tobacco type content and tobacco sub-flow content variants. | +| `views/questions/_question.html` | Generic Nunjucks template for YAML-backed questions. | +| `lib/questions.js` | Loads, normalises and hot-reloads YAML content. | +| `lib/question-renderer.js` | Renders a YAML-backed question through the generic template. | +| `lib/question-validator.js` | Validates submitted answers using YAML validation rules. | +| `lib/tobacco-flow.js` | Builds and renders the repeated tobacco sub-flow. | +| `lib/summary.js` | Builds check your answers rows. | +| `controllers/question.js` | Handles route decisions and redirects. | + +## How the pieces fit together + +1. A route handler in `controllers/question.js` calls `renderQuestion`. +2. `renderQuestion` gets the question from `lib/questions.js`. +3. `lib/questions.js` loads content from `questions.yaml`, normalises it and returns the question by ID. +4. `_question.html` renders the right NHS component for the question `type`. +5. On POST, the controller calls `validateQuestion`. +6. `question-validator.js` validates the submitted answer using the question's YAML rules. +7. If there are errors, the same question is rendered with error messages. +8. If validation passes, the controller decides the next route. + +## Reference docs + +- [Question schema](question-schema.md) +- [Tobacco schema](tobacco-schema.md) +- [Content guide](content-guide.md) +- [Developer guide](developer-guide.md) +- [Question flow](question-flow.md) + +## Hot reload + +The YAML content is reloaded automatically when `questions.yaml` or `tobacco.yaml` changes. You should not need to restart the server after editing valid YAML. + +If YAML is invalid while it is being edited, the next request can fail until the file is valid again. diff --git a/app/prototype_v4_2/docs/content-guide.md b/app/prototype_v4_2/docs/content-guide.md new file mode 100644 index 0000000..57d3973 --- /dev/null +++ b/app/prototype_v4_2/docs/content-guide.md @@ -0,0 +1,38 @@ +# Content guide + +This guide is for people editing question content in `questions.yaml` and `tobacco.yaml`. + +## Question wording + +Use `heading.title` for the page heading. Use `input.label` when the input needs a separate label from the page heading. + +If there is no `input.label`, the page heading is used as the input label. + +## Descriptions + +Use `description` for content that should appear between the heading and the input. It supports Markdown. + +Use `details` for supporting content that can be hidden behind a details component. + +## Error messages + +Write error messages so they make sense without reading the page heading. + +For example: + +- Use `Select whether you have ever smoked tobacco` +- Avoid `Select an option` + +For dynamic tobacco questions, make sure the error message names the tobacco type or quantity being asked about where possible. + +## Error links + +Most questions do not need an explicit `href` in their error messages. The system links errors to the question input by default. + +Only add an explicit `href` for grouped fields, dates, conditional reveal inputs or dynamic tobacco fields. + +## Tobacco tense + +Do not rely on string replacement for current and past tense tobacco questions. + +Use explicit `current` and `past` wording in `tobacco.yaml`. diff --git a/app/prototype_v4_2/docs/developer-guide.md b/app/prototype_v4_2/docs/developer-guide.md new file mode 100644 index 0000000..fc9012b --- /dev/null +++ b/app/prototype_v4_2/docs/developer-guide.md @@ -0,0 +1,51 @@ +# Developer guide + +This guide explains the common development tasks for YAML-backed questions in prototype v4.1. + +## Add a standard question + +1. Add the question to `data/questions.yaml`. +2. Use a stable kebab-case `id`. +3. Add `answerKey` if the default camel-case key is not right. +4. Add validation and error messages if the question is required. +5. Add GET and POST handlers in `controllers/question.js`. +6. Use `renderQuestion` in the GET handler. +7. Use `validateQuestion` in the POST handler. +8. Update routes. +9. Update check your answers if the answer should appear there. + +## Add validation + +Use YAML validation where possible: + +- `required` +- `type: number` +- `type: date` +- `min` +- `max` +- `items` for `text_group` +- `conditional` for conditional reveal inputs + +Keep custom validation in code only when the rule depends on wider service logic. + +## Add a tobacco type + +1. Add an option to the `smoking-type` question in `questions.yaml`. +2. Add a matching key in `tobacco.yaml` under `tobaccoTypes`. +3. Add current and past headings. +4. Add quantity units and suffixes if answers need units. +5. Check the tobacco sub-flow and check your answers output. + +## Check changes + +Run Standard against the prototype files after JavaScript changes: + +```bash +npx standard app/prototype_v4_2/controllers/question.js app/prototype_v4_2/lib/*.js +``` + +You can also require the controller to catch syntax errors: + +```bash +node -e "require('./app/prototype_v4_2/controllers/question')" +``` diff --git a/app/prototype_v4_2/docs/question-flow.md b/app/prototype_v4_2/docs/question-flow.md new file mode 100644 index 0000000..a5546d6 --- /dev/null +++ b/app/prototype_v4_2/docs/question-flow.md @@ -0,0 +1,102 @@ +# Prototype v4.2 question flow + +This diagram is based on `app/prototype_v4_2/routes.js`, `app/prototype_v4_2/controllers/authentication.js`, and `app/prototype_v4_2/controllers/question.js`. + +```mermaid +flowchart TD + start["Start page
/prototype_v4_2/start-page"] --> signIn["Sign in"] + signIn --> securityCode["Security code"] + securityCode --> agreement{"Share NHS login
information?"} + agreement -- Accept --> terms["Accept terms"] + agreement -- Decline --> agreementDeclined["Sign-in agreement declined
End"] + + terms --> phoneQuestionnaire{"Completed the questionnaire
by phone?"} + phoneQuestionnaire -- Yes --> phoneExit["Phone questionnaire exit
End"] + phoneQuestionnaire -- No --> smoker{"Are you a current or
former smoker?"} + + smoker -- No or fewer than 100 cigarettes in lifetime --> notEligibleScreening["Not eligible for screening
End"] + smoker -- Yes --> dob{"Date of birth
Age 55 to 74?"} + + dob -- No --> notEligibleScan["Not eligible for scan
End"] + dob -- Yes --> faceToFace{"Need a face to face
appointment?"} + + faceToFace -- Yes --> bookAppointment["Book appointment
End"] + faceToFace -- No --> height{"Height"} + + height -- Metric --> heightMetric["Height - metric"] + height -- Imperial --> heightImperial["Height - imperial"] + heightMetric --> weight + heightImperial --> weight + + weight{"Weight"} -- Metric --> weightMetric["Weight - metric"] + weight -- Imperial --> weightImperial["Weight - imperial"] + weightMetric --> aboutYou["About you
Gender, sex, ethnicity and education"] + weightImperial --> aboutYou + + aboutYou --> respiratory["Respiratory conditions"] + respiratory --> asbestos["Asbestos
At work, at home"] + asbestos --> cancerDiagnosis["Cancer diagnosis"] + cancerDiagnosis --> relatives{"Close relative had
lung cancer?"} + + relatives -- Yes --> relativesAge["Relative diagnosed before 60?"] + relatives -- No --> smokingDuration["Smoking duration
Age started, age stopped if applicable,
periods stopped"] + relativesAge --> smokingDuration + + smokingDuration --> smokingType{"Smoking type"} + + smokingType -- None selected --> smokingTypeExit["Smoking type exit
End"] + smokingType -- One or more tobacco types --> tobaccoLoop["Repeat tobacco questions
for each selected type"] + + tobaccoLoop --> cya["Check your answers"] + cya --> confirmation["Confirmation
End"] +``` + +## Tobacco subflow + +The tobacco questions repeat for each selected tobacco type, in this order: + +1. Cigarettes +2. Rolling tobacco +3. Pipes +4. Small cigars +5. Medium cigars +6. Large cigars +7. Cigarillos +8. Shisha + +```mermaid +flowchart TD + selectedType["Next selected tobacco type"] --> formerSmoker{"Former smoker?"} + + formerSmoker -- No, currently smokes --> status["Smoking status"] + formerSmoker -- Yes --> tobaccoSmoking["Tobacco smoking
Frequency and quantity"] + status --> tobaccoSmoking + + tobaccoSmoking --> isShisha{"Is the selected type
shisha?"} + isShisha -- Yes --> nextTypeOrCya + isShisha -- No --> changed{"Smoking changed
over time?"} + + changed -- No change selected --> nextTypeOrCya + changed -- More selected --> moreChange["Tobacco smoking change
More: frequency, quantity and years"] + moreChange --> fewerSelected{"Fewer also selected?"} + + changed -- Only fewer selected --> fewerChange["Tobacco smoking change
Fewer: frequency, quantity and years"] + fewerSelected -- Yes --> fewerChange + fewerSelected -- No --> nextTypeOrCya + fewerChange --> nextTypeOrCya + + nextTypeOrCya["Next selected tobacco type
or Check your answers"] +``` + +## Notes + +- Height and weight unit pages can be switched manually using the unit-switch links. +- `Smoking duration` combines age started smoking, age stopped smoking and periods stopped smoking. +- `Age stopped smoking` is shown on `Smoking duration` when the `smoker` answer is `yes_previous`. It can also be shown again from check your answers if a tobacco-specific `Smoking status` answer is `no`. +- `Tobacco smoking` combines smoking frequency and smoking quantity. +- `Tobacco smoking change` combines changed-smoking frequency, quantity and years. +- The tobacco subflow uses query strings such as `/prototype_v4_2/smoking-status?type=cigarettes` and `/prototype_v4_2/tobacco-smoking-change?type=cigarettes&change=greater`. +- If the `smoker` answer is `yes_previous`, each tobacco type skips `Smoking status` and uses past-tense question text. +- Shisha follows the same tobacco-smoking flow as other tobacco types, but skips the smoking-change flow. +- If both `more` and `fewer` are selected for a tobacco type, the flow asks the `more` tobacco-smoking-change page first, then the `fewer` tobacco-smoking-change page. +- `Check your answers` links back to the last tobacco step that applies to the current set of answers. diff --git a/app/prototype_v4_2/docs/question-schema.md b/app/prototype_v4_2/docs/question-schema.md new file mode 100644 index 0000000..5e4cc0e --- /dev/null +++ b/app/prototype_v4_2/docs/question-schema.md @@ -0,0 +1,448 @@ +# Question schema + +Question form-control content for prototype v4.2 lives in `app/prototype_v4_2/data/questions.yaml`. + +The file should only contain reusable question controls under the top-level `questions` key. Tobacco-specific content, such as tobacco type names and current or past tense headings, lives in `app/prototype_v4_2/data/tobacco.yaml`. + +Question page composition and page-level content lives in `app/prototype_v4_2/data/pages.yaml`. Every question page should have an entry in this file, even when the page contains one question. + +## Basic structure + +Each item in `questions` represents one reusable question control. + +```yaml +questions: + - id: education + type: single + answerKey: education + input: + label: What is the highest level of education have you completed? + options: + - label: GCSEs + hint: Previously O-levels + value: gcse + - divider: or + - label: I prefer not to say + value: prefer_not_to_say + summary: + label: Education + validation: + required: true + errors: + required: + text: Select the highest level of education you have completed +``` + +The matching page content lives in `pages.yaml`: + +```yaml +pages: + - id: education + heading: + title: Your education + caption: About you + description: | + We ask this question because education is linked to other factors that may impact your chances of developing lung cancer. + questions: + - education +``` + +## Combining questions on one page + +Define each individual form control in `questions.yaml`, then compose one or more controls into a page in `pages.yaml`. + +```yaml +pages: + - id: about-you + heading: + title: About you + description: The answers you submit will not be shared with your patient care advisor during your phone appointment, or with your GP. + questions: + - gender + - sex + - ethnicity + - education +``` + +Grouped pages use the same question definitions, answer keys and validation rules as single-question pages. This means check-your-answers and later flow logic can keep reading answers from the same session keys, regardless of whether the answers came from one page or several pages. + +When `pages.yaml` defines a page `heading`, the heading is rendered as the page H1 and question labels are rendered as normal labels or legends with `isPageHeading: false`. This applies to one-question and grouped pages. + +To conditionally show a question on a grouped page, use an object instead of a string in the `questions` list. + +```yaml +pages: + - id: smoking-duration + heading: + title: Smoking duration + questions: + - age-started-smoking + - id: age-stopped-smoking + if: + question: smoker + is: yes_previous + - periods-stopped-smoking +``` + +The `question` value is a question ID from `questions.yaml`. The renderer looks up that question's `answerKey` and compares it with the saved answer. You can also use `answerKey` directly. + +Supported condition checks are `is`, `equals`, `includes`, `excludes` and `not`. Each check can use one value or a list of values. For checkbox answers, `is`, `equals` and `includes` all match when the submitted answer array contains any of the values. Use `excludes` when the question should be shown only if none of the values are selected. + +Use `any` when a question should show if one or more conditions match. + +```yaml +questions: + - age-started-smoking + - id: age-stopped-smoking + if: + any: + - question: smoker + is: yes_previous + - question: smoking-status + is: no + - periods-stopped-smoking +``` + +Use `all` when every condition must match. + +To add another page: + +1. Add the page to `pages.yaml`. +2. Add GET and POST routes in `routes.js`. +3. Use `renderQuestion(res, questionId, actions)` for one-question pages. +4. Use `renderQuestionPage(res, pageId, actions, errors, answers)` for grouped pages. +5. Validate grouped pages with `validateQuestions(answers, getQuestionPage(pageId, answers).questions.map((question) => question.id))`. + +## Common fields + +| Field | Required | Description | +| --- | --- | --- | +| `id` | Yes | Stable question ID used by the controller, renderer and default input ID. Use kebab case. | +| `type` | Yes | Question type rendered by `_question.html`. | +| `answerKey` | No | Key used in `req.session.data.answers`. If omitted, the question ID is converted to camel case. | +| `input` | Usually | Input, radios or checkboxes configuration. | +| `input.label` | Yes | Question label shown above the input, unless the page has no heading and the label is promoted to the page heading. | +| `options` | For choice questions | Options for radios or checkboxes. | +| `summary` | No | Check your answers label and hidden text. | +| `validation` | No | Validation rules for the question. | +| `errors` | When validating | Error messages for validation failures. | +| `variants` | No | Alternative content used by controller or flow overrides. | +| `switchUnits` | No | Link text for height and weight unit switching. | + +## Page Headings + +Put page headings, captions, descriptions and details in `pages.yaml`. + +For example, a one-question page with separate page content: + +```yaml +pages: + - id: phone-questionnaire + heading: + title: Confirm if you have completed a lung cancer risk questionnaire by phone + description: | + If you have already completed a questionnaire about your lung health or lung cancer risk by phone you do not need to test the online service. + questions: + - phone-questionnaire +``` + +The question label stays in `questions.yaml`: + +```yaml +questions: + - id: phone-questionnaire + type: single + answerKey: phoneQuestionnaire + input: + label: Have you previously completed a lung cancer risk questionnaire by phone? + options: + - label: Yes + value: yes + - label: No + value: no +``` + +If a page has no `heading.title`, the renderer falls back to the first question label as the page heading and sets `isPageHeading: true`. You can still set a caption without duplicating the title: + +```yaml +pages: + - id: respiratory-conditions + heading: + caption: Your health + questions: + - respiratory-conditions +``` + +## Descriptions and details + +Page `description` supports Markdown and is rendered between the page heading and the input. + +Use `details` for expandable supporting content: + +```yaml +details: + summary: What is asbestos? + text: | + Asbestos was used in a number of building materials and products. +``` + +## Question types + +### `single` + +Renders NHS radios. + +```yaml +- id: smoker + type: single + answerKey: smoker + input: + label: Have you ever smoked tobacco? + hint: This includes social smoking + options: + - label: Yes, I currently smoke + value: yes_current + - label: No, I have never smoked + value: no + validation: + required: true + errors: + required: + text: Select whether you have ever smoked tobacco +``` + +### `multiple` + +Renders NHS checkboxes. + +```yaml +- id: respiratory-conditions + type: multiple + answerKey: respiratoryConditions + input: + label: Have you ever been diagnosed with any of the following respiratory conditions? + hint: Select all that apply + options: + - label: Bronchitis + value: bronchitis + exclusiveGroup: conditions-list + - divider: or + - label: No, I have not had any of these respiratory conditions + value: no + exclusive: true + exclusiveGroup: conditions-list +``` + +Use `exclusive: true` for a checkbox option that should clear the other options in the same group. + +### `text` + +Renders a single NHS input. + +```yaml +- id: height-metric + type: text + answerKey: height + input: + label: What is your height in centimetres? + id: height-metric + name: answers[height][metric] + valueKey: metric + suffix: cm + inputmode: numeric + classes: nhsuk-input--width-4 +``` + +Use `valueKey` when the answer is stored under a nested key, such as `answers.height.metric`. + +### `text_group` + +Renders a group of related text inputs in one fieldset, for example feet and inches. + +```yaml +- id: height-imperial + type: text_group + answerKey: height + input: + label: What is your height in feet and inches? + valueKey: imperial + items: + - id: height-imperial-feet + name: answers[height][imperial][feet] + label: Feet + answerKey: feet + suffix: ft + - id: height-imperial-inches + name: answers[height][imperial][inches] + label: Inches + answerKey: inches + suffix: in +``` + +Each item needs a matching validation rule and error messages when validation is required. + +### `date` + +Renders an NHS date input. + +```yaml +- id: date-of-birth + type: date + answerKey: dateOfBirth + input: + label: What is your date of birth? + hint: For example, 15 3 1964 + items: + - id: dateOfBirth-day + name: answers[dateOfBirth][day] + label: Day + answerKey: day + - id: dateOfBirth-month + name: answers[dateOfBirth][month] + label: Month + answerKey: month + - id: dateOfBirth-year + name: answers[dateOfBirth][year] + label: Year + answerKey: year + validation: + required: true + type: date + errors: + required: + text: Enter your date of birth + href: "#dateOfBirth-day" + invalid: + text: Enter a real date of birth + href: "#dateOfBirth-day" +``` + +Date errors should usually link to the day input. + +## Options + +Option fields map to NHS radios or checkboxes items. + +| Field | Description | +| --- | --- | +| `label` | Visible option text. | +| `hint` | Optional hint under the option label. | +| `value` | Submitted value stored in the session. | +| `divider` | Divider text, for example `or`. | +| `exclusive` | Marks a checkbox option as exclusive. | +| `exclusiveGroup` | Shared group name for exclusive checkbox behaviour. | +| `conditionalInput` | Input shown when the option is selected. | + +## Conditional reveal inputs + +Use `conditionalInput` on an option to show an input when that option is selected. + +The validation rule for the conditional input lives under `validation.conditional`. + +```yaml +options: + - label: Yes + value: yes + conditionalInput: + id: years-stopped-smoking + name: answers[yearsStoppedSmoking] + answerKey: yearsStoppedSmoking + label: Enter the total number of years you stopped smoking + suffix: Years + inputmode: numeric + classes: nhsuk-input--width-4 + - label: No + value: no +validation: + required: true + conditional: + yes: + required: true + type: number + min: 1 + max: 80 + answerKey: yearsStoppedSmoking + href: "#years-stopped-smoking" +errors: + required: + text: Select whether you have ever stopped smoking for periods of 1 year or longer + conditional: + yes: + required: + text: Enter the total number of years you stopped smoking + href: "#years-stopped-smoking" + invalid: + text: Total number of years you stopped smoking must be a number + href: "#years-stopped-smoking" + min: + text: Total number of years you stopped smoking must be 1 or more + href: "#years-stopped-smoking" + max: + text: Total number of years you stopped smoking must be 80 or fewer + href: "#years-stopped-smoking" +``` + +## Validation + +Supported validation fields are: + +| Field | Description | +| --- | --- | +| `required` | Requires a non-empty answer. | +| `type: number` | Requires a numeric answer. | +| `type: date` | Requires day, month and year to form a real date. | +| `min` | Minimum numeric value. | +| `max` | Maximum numeric value. | +| `items` | Per-field validation for `text_group`. | +| `conditional` | Validation for conditional reveal inputs. | + +For numeric questions, provide all relevant messages: + +```yaml +validation: + required: true + type: number + min: 1 + max: 120 +errors: + required: + text: Enter the age you started smoking + invalid: + text: Enter the age you started smoking using numbers + min: + text: Age you started smoking must be 1 or older + max: + text: Age you started smoking must be 120 or younger +``` + +## Error hrefs + +For simple questions, do not set `href`. The validator defaults it to `#${question.input.id}`. + +Because `input.id` also defaults to the question ID, this means a question with `id: education` links to `#education`. + +Only set `href` explicitly for exceptions: + +- date inputs, usually `#dateOfBirth-day` +- `text_group` inputs, such as `#height-imperial-feet` +- conditional reveal inputs, such as `#years-stopped-smoking` +- tobacco flow questions where the input ID is intentionally dynamic or shared + +## Variants + +Use `variants` when the same question needs alternative content that is selected by the controller or flow logic. + +```yaml +variants: + previous: + input: + label: What have you smoked? +``` + +Keep variants small. Put variant page headings and descriptions in `pages.yaml`. + +## Hot reload + +`questions.yaml` is reloaded automatically when it changes. You should not need to restart the server after editing valid YAML. + +If the YAML file is invalid while you are editing it, the next request can fail until the file is valid again. diff --git a/app/prototype_v4_2/docs/tobacco-schema.md b/app/prototype_v4_2/docs/tobacco-schema.md new file mode 100644 index 0000000..c2532a5 --- /dev/null +++ b/app/prototype_v4_2/docs/tobacco-schema.md @@ -0,0 +1,57 @@ +# Tobacco schema + +Tobacco-specific content lives in `app/prototype_v4_2/data/tobacco.yaml`. + +Use this file for content that is shared across the repeated tobacco sub-flow, including tobacco type names, quantity units and current or past tense headings. + +## Top-level keys + +| Key | Purpose | +| --- | --- | +| `tobaccoTypes` | Content for each tobacco type. | +| `smokingChangeTypes` | The ordered set of changed-smoking branches. | + +## Tobacco types + +Each tobacco type key must match the value used by the `smoking-type` question in `questions.yaml`. + +```yaml +tobaccoTypes: + cigarettes: + caption: Cigarette smoking + quantityUnit: cigarettes + singularSuffix: cigarette + suffix: cigarettes + headings: + current: + status: Do you currently smoke cigarettes? + frequency: How often do you smoke cigarettes? + quantity: How many cigarettes do you currently smoke in a normal day? + change: Has the number of cigarettes you normally smoke changed over time? + past: + frequency: How often did you smoke cigarettes? + quantity: How many cigarettes did you smoke in a normal day? + change: Did the number of cigarettes you normally smoked change over time? +``` + +The tobacco flow uses `headings.current` when someone currently smokes and `headings.past` when someone used to smoke. + +`caption` is shown above the tobacco question heading. + +`quantityUnit`, `singularSuffix` and `suffix` are used when answers are formatted in check your answers and when contextual change questions are built. + +## Smoking change types + +`smokingChangeTypes` controls the changed-smoking branches and their order. + +```yaml +smokingChangeTypes: + greater: + answerKey: smokingChangeIncrease + label: more + fewer: + answerKey: smokingChangeDecrease + label: fewer +``` + +`answerKey` is the nested key used to store answers for that branch. diff --git a/app/prototype_v4_2/lib/eligibility.js b/app/prototype_v4_2/lib/eligibility.js new file mode 100644 index 0000000..ffc0050 --- /dev/null +++ b/app/prototype_v4_2/lib/eligibility.js @@ -0,0 +1,63 @@ +/** + * Convert date-of-birth answer parts into a Date object. + * + * @param {Object} answers - Session answers object. + * @returns {Date|boolean} Date of birth, or false when the parts are invalid. + */ +const getDateOfBirth = (answers) => { + const day = Number(answers?.dateOfBirth?.day) + const month = Number(answers?.dateOfBirth?.month) + const year = Number(answers?.dateOfBirth?.year) + + if (!Number.isInteger(day) || !Number.isInteger(month) || !Number.isInteger(year)) { + return false + } + + const date = new Date(year, month - 1, day) + + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return false + } + + return date +} + +/** + * Calculate an age in years from a Date. + * + * @param {Date} dateOfBirth - Date of birth. + * @returns {number} Age in years. + */ +const getAge = (dateOfBirth) => { + const today = new Date() + let age = today.getFullYear() - dateOfBirth.getFullYear() + const monthDiff = today.getMonth() - dateOfBirth.getMonth() + + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dateOfBirth.getDate())) { + age-- + } + + return age +} + +/** + * Check whether a person is in the scan-eligible age range. + * + * @param {Date} dateOfBirth - Date of birth. + * @returns {boolean} True when age is between 55 and 74 inclusive. + */ +const isEligibleForScanAge = (dateOfBirth) => { + const age = getAge(dateOfBirth) + + return age >= 55 && age <= 74 +} + +module.exports = { + getAge, + getDateOfBirth, + isEligibleForScanAge +} diff --git a/app/prototype_v4_2/lib/page-index.js b/app/prototype_v4_2/lib/page-index.js new file mode 100644 index 0000000..83568f9 --- /dev/null +++ b/app/prototype_v4_2/lib/page-index.js @@ -0,0 +1,91 @@ +const defaultAnswers = { + acceptTerms: ['yes'], + phoneQuestionnaire: 'no', + smoker: 'yes_current', + dateOfBirth: { + day: '15', + month: '3', + year: '1964' + }, + faceToFaceAppointment: 'no', + height: { + metric: '170' + }, + weight: { + metric: '70' + }, + gender: 'female', + sex: 'female', + ethnicity: 'white', + education: 'further_education', + respiratoryConditions: ['no'], + asbestosAtWork: 'no', + asbestosAtHome: 'no', + cancerDiagnosis: 'no', + cancerDiagnosisRelatives: 'yes', + cancerDiagnosisRelativesAge: 'no', + ageStartedSmoking: '18', + periodsStoppedSmoking: 'no', + smokingType: ['cigarettes'], + cigarettes: { + smokingStatus: 'yes', + smokingFrequency: 'daily', + smokingQuantity: '10', + smokingChange: ['greater'], + smokingChangeIncrease: { + frequency: 'weekly', + quantity: '5', + years: '10' + } + } +} + +const cloneAnswers = () => JSON.parse(JSON.stringify(defaultAnswers)) + +const getDefaultAnswerProfile = (profile) => { + const answers = cloneAnswers() + + if (profile === 'former') { + answers.smoker = 'yes_previous' + answers.ageStoppedSmoking = '55' + answers.cigarettes = { + smokingFrequency: 'daily', + smokingQuantity: '10', + smokingChange: ['greater'], + smokingChangeIncrease: { + frequency: 'weekly', + quantity: '5', + years: '10' + } + } + } + + if (profile === 'shisha') { + answers.smokingType = ['shisha'] + delete answers.cigarettes + answers.shisha = { + smokingStatus: 'yes', + smokingFrequency: 'weekly', + smokingQuantity: '30_minutes_to_1_hour' + } + } + + return answers +} + +const getIndexRedirect = (returnUrl, prototypePath) => { + if (!returnUrl || typeof returnUrl !== 'string') { + return `${prototypePath}/check-your-answers` + } + + if (!returnUrl.startsWith(`${prototypePath}/`) || returnUrl.includes('//')) { + return `${prototypePath}/page-index` + } + + return returnUrl +} + +module.exports = { + getDefaultAnswerProfile, + getIndexRedirect +} diff --git a/app/prototype_v4_2/lib/question-pages.js b/app/prototype_v4_2/lib/question-pages.js new file mode 100644 index 0000000..728e104 --- /dev/null +++ b/app/prototype_v4_2/lib/question-pages.js @@ -0,0 +1,232 @@ +const fs = require('fs') +const path = require('path') +const yaml = require('js-yaml') +const { getQuestion } = require('./questions') + +const pagesPath = path.join(__dirname, '../data/pages.yaml') +const pages = {} +let loadedAt + +const loadYaml = (filePath) => { + const file = fs.readFileSync(filePath, 'utf8') + return yaml.load(file) || {} +} + +const refreshPages = (force = false) => { + const mtime = fs.statSync(pagesPath).mtimeMs + + if (!force && mtime === loadedAt) { + return + } + + Object.keys(pages).forEach((key) => { + delete pages[key] + }) + + ;(loadYaml(pagesPath).pages || []).forEach((page) => { + pages[page.id] = page + }) + + loadedAt = mtime +} + +refreshPages(true) + +const getQuestionHeading = (question) => { + if (!question.input?.label) { + return undefined + } + + return { + title: question.input.label, + caption: question.input.caption + } +} + +const normaliseQuestionRef = (item) => { + return typeof item === 'string' ? { id: item } : item +} + +const getCondition = (questionRef) => { + return questionRef.if || questionRef.showIf || questionRef.condition +} + +const getNestedAnswerValues = (answers = {}, answerKey) => { + return Object.values(answers).flatMap((answer) => { + if (!answer || typeof answer !== 'object' || Array.isArray(answer)) { + return [] + } + + return answer[answerKey] === undefined ? [] : [answer[answerKey]] + }) +} + +const getAnswer = (answers = {}, answerKey) => { + if (answers[answerKey] !== undefined) { + return answers[answerKey] + } + + const nestedValues = getNestedAnswerValues(answers, answerKey) + + return nestedValues.length ? nestedValues : undefined +} + +const getConditionAnswer = (condition = {}, answers = {}) => { + if (condition.answerKey) { + return getAnswer(answers, condition.answerKey) + } + + if (condition.answer) { + return getAnswer(answers, condition.answer) + } + + if (condition.question) { + return getAnswer(answers, getQuestion(condition.question).answerKey) + } + + return undefined +} + +const hasValue = (actual, expected) => { + if (Array.isArray(expected)) { + return expected.some((value) => hasValue(actual, value)) + } + + if (Array.isArray(actual)) { + return actual.includes(expected) + } + + return actual === expected +} + +const matchesConditionRule = (condition, answers = {}) => { + if (!condition) { + return true + } + + if (Array.isArray(condition.any)) { + return condition.any.some((rule) => matchesConditionRule(rule, answers)) + } + + if (Array.isArray(condition.all)) { + return condition.all.every((rule) => matchesConditionRule(rule, answers)) + } + + const actual = getConditionAnswer(condition, answers) + + if (condition.is !== undefined) { + return hasValue(actual, condition.is) + } + + if (condition.equals !== undefined) { + return hasValue(actual, condition.equals) + } + + if (condition.includes !== undefined) { + return hasValue(actual, condition.includes) + } + + if (condition.not !== undefined) { + return !hasValue(actual, condition.not) + } + + if (condition.excludes !== undefined) { + return !hasValue(actual, condition.excludes) + } + + return true +} + +const matchesCondition = (questionRef, answers = {}) => { + return matchesConditionRule(getCondition(questionRef), answers) +} + +const mergeQuestionWithPageContent = (question, pageContent = {}, options = {}) => { + const isSingleQuestionPage = options.isSingleQuestionPage === true + const hasPageHeading = Boolean(options.pageHeading?.title) + const heading = pageContent.heading + ? { + ...getQuestionHeading(question), + ...pageContent.heading + } + : getQuestionHeading(question) + const input = { + ...question.input + } + + if (isSingleQuestionPage && !hasPageHeading && heading?.title) { + input.label = heading.title + input.isPageHeading = true + } else if (heading?.title && !input.label) { + input.label = heading.title + input.isPageHeading = false + } else if (!isSingleQuestionPage) { + input.isPageHeading = false + } + + return { + ...question, + heading, + description: pageContent.description !== undefined ? pageContent.description : question.description, + details: pageContent.details !== undefined ? pageContent.details : question.details, + input, + page: { + heading, + description: pageContent.description !== undefined ? pageContent.description : question.description, + details: pageContent.details !== undefined ? pageContent.details : question.details + } + } +} + +const getQuestionPage = (id, answers = {}) => { + refreshPages() + + const page = pages[id] + + if (!page) { + throw new Error(`Question page not found: ${id}`) + } + + const pageQuestions = page.questions || [] + const visiblePageQuestions = pageQuestions + .map(normaliseQuestionRef) + .filter((questionRef) => matchesCondition(questionRef, answers)) + const questions = visiblePageQuestions.map((questionRef) => { + const question = getQuestion(questionRef.id) + const isSingleQuestionPage = visiblePageQuestions.length === 1 + const pageContent = visiblePageQuestions.length === 1 + ? { + ...questionRef + } + : questionRef + + return mergeQuestionWithPageContent(question, pageContent, { + isSingleQuestionPage, + pageHeading: page.heading + }) + }) + const firstQuestion = questions[0] + const heading = page.heading + ? { + ...firstQuestion?.page?.heading, + ...page.heading + } + : firstQuestion?.page?.heading + + return { + ...page, + heading, + description: page.description !== undefined + ? page.description + : visiblePageQuestions.length === 1 ? firstQuestion?.page?.description : undefined, + details: page.details !== undefined + ? page.details + : visiblePageQuestions.length === 1 ? firstQuestion?.page?.details : undefined, + questions + } +} + +module.exports = { + getQuestionPage, + refreshPages +} diff --git a/app/prototype_v4_2/lib/question-renderer.js b/app/prototype_v4_2/lib/question-renderer.js new file mode 100644 index 0000000..144f05b --- /dev/null +++ b/app/prototype_v4_2/lib/question-renderer.js @@ -0,0 +1,126 @@ +const { getQuestion } = require('./questions') +const { getQuestionPage } = require('./question-pages') +const settings = require('./settings') + +const { version, view } = settings + +/** + * Render a YAML-backed question using optional runtime overrides. + * + * @param {Object} res - Express response object. + * @param {string} id - Question id from questions.yaml. + * @param {Object} actions - URLs used by the generic question template. + * @param {Object[]} [errors] - Validation errors for the question. + * @param {Object} [overrides] - Runtime overrides for dynamic question content. + */ +const renderQuestion = (res, id, actions, errors = [], overrides = {}) => { + const page = getQuestionPage(id) + const question = page.questions[0] || getQuestion(id) + const heading = { + ...page.heading, + ...overrides.heading + } + const input = { + ...question.input, + ...overrides.input + } + + if (overrides.input?.label) { + if (!heading.title) { + heading.title = overrides.input.label + } + + if (input.isPageHeading) { + input.label = overrides.input.label + } + } + + res.render(view('questions/_question'), { + question: { + ...question, + ...overrides, + heading, + description: overrides.description !== undefined ? overrides.description : page.description, + details: overrides.details !== undefined ? overrides.details : page.details, + input + }, + errorMap: getErrorMap(errors), + errors, + actions + }) +} + +const getErrorMap = (errors = []) => { + return errors.reduce((map, error) => { + if (error.href) { + map[error.href.replace('#', '')] = error + } + + return map + }, {}) +} + +const mergeQuestionOverrides = (question, overrides = {}) => { + const heading = { + ...question.heading, + ...overrides.heading + } + const input = { + ...question.input, + ...overrides.input + } + + if (overrides.heading?.title && !overrides.input?.label) { + input.label = overrides.heading.title + } + + return { + ...question, + ...overrides, + heading, + input, + errors: { + ...question.errors, + ...overrides.errors + }, + validation: { + ...question.validation, + ...overrides.validation + } + } +} + +/** + * Render a YAML-backed page containing one or more questions. + * + * @param {Object} res - Express response object. + * @param {string} id - Page id from pages.yaml. + * @param {Object} actions - URLs used by the grouped question template. + * @param {Object[]} [errors] - Validation errors for all questions on the page. + */ +const renderQuestionPage = (res, id, actions, errors = [], answers = {}, overrides = {}) => { + const page = getQuestionPage(id, answers) + const questionOverrides = overrides.questions || {} + + res.render(view('questions/_question-page'), { + page: { + ...page, + ...overrides, + heading: { + ...page.heading, + ...overrides.heading + }, + questions: page.questions.map((question) => mergeQuestionOverrides(question, questionOverrides[question.id])) + }, + errorMap: getErrorMap(errors), + errors, + actions + }) +} + +module.exports = { + renderQuestion, + renderQuestionPage, + version, + view +} diff --git a/app/prototype_v4_2/lib/question-validator.js b/app/prototype_v4_2/lib/question-validator.js new file mode 100644 index 0000000..db2953f --- /dev/null +++ b/app/prototype_v4_2/lib/question-validator.js @@ -0,0 +1,317 @@ +const { getQuestion } = require('./questions') + +/** + * @typedef {Object} ValidationError + * @property {string} text - Error message shown in the summary and field. + * @property {string} href - Fragment link to the invalid input. + */ + +/** + * @typedef {Object} QuestionValidation + * @property {boolean} [required] - Whether the answer must be present. + * @property {string} [type] - Type-specific validation, for example number or date. + * @property {number} [min] - Minimum numeric value. + * @property {number} [max] - Maximum numeric value. + * @property {Object} [conditional] - Conditional reveal validation rules. + * @property {Object[]} [items] - Validation rules for grouped inputs. + */ + +/** + * Check whether a submitted answer should be treated as empty. + * + * @param {*} value - Submitted answer value. + * @returns {boolean} True when the value is missing or blank. + */ +const isBlank = (value) => { + if (Array.isArray(value)) { + return value.length === 0 + } + + return value === undefined || value === null || String(value).trim() === '' +} + +/** + * Resolve the submitted value for a question, including overridden values. + * + * @param {Object} answers - Session answers object. + * @param {Object} question - Normalised question config. + * @returns {*} Submitted value for validation. + */ +const getAnswerValue = (answers = {}, question) => { + if (question.values !== undefined) { + return question.values + } + + if (question.value !== undefined) { + return question.value + } + + const value = answers[question.answerKey] + + if (question.input?.valueKey) { + return value?.[question.input.valueKey] + } + + return value +} + +/** + * Resolve a conditional reveal value from a runtime override or answer key. + * + * @param {Object} answers - Session answers object. + * @param {Object} rule - Conditional validation rule. + * @returns {*} Submitted conditional value. + */ +const getConditionalValue = (answers = {}, rule = {}) => { + if (rule.value !== undefined) { + return rule.value + } + + return answers[rule.answerKey] +} + +/** + * Resolve a date input value from the answers object. + * + * @param {Object} answers - Session answers object. + * @param {Object} question - Normalised date question config. + * @returns {Object} Date parts keyed by day, month and year. + */ +const getDateValue = (answers = {}, question) => { + return answers[question.answerKey] || {} +} + +/** + * Check whether day, month and year represent a real calendar date. + * + * @param {Object} value - Date parts keyed by day, month and year. + * @returns {boolean} True when the date parts form a valid date. + */ +const isRealDate = (value = {}) => { + const day = Number(value.day) + const month = Number(value.month) + const year = Number(value.year) + + if (!Number.isInteger(day) || !Number.isInteger(month) || !Number.isInteger(year)) { + return false + } + + const date = new Date(year, month - 1, day) + + return date.getFullYear() === year && + date.getMonth() === month - 1 && + date.getDate() === day +} + +/** + * Get the default input href for a question-level error. + * + * @param {Object} question - Normalised question config. + * @returns {string} Fragment link for the first invalid input. + */ +const getDefaultErrorHref = (question) => { + return `#${question.input?.id || question.id}` +} + +/** + * Build a validation error, filling in a default href when needed. + * + * @param {Object} error - Error content from YAML or overrides. + * @param {string} defaultHref - Fallback fragment link. + * @returns {ValidationError} Normalised validation error. + */ +const makeError = (error, defaultHref) => { + return { + text: error.text, + href: error.href || defaultHref + } +} + +/** + * Validate conditional reveal inputs for selected trigger options. + * + * @param {Object} answers - Session answers object. + * @param {Object} question - Normalised question config. + * @param {ValidationError[]} errors - Mutable error collection. + */ +const validateConditional = (answers, question, errors) => { + const conditionalRules = question.validation?.conditional || {} + const value = getAnswerValue(answers, question) + + Object.entries(conditionalRules).forEach(([triggerValue, rule]) => { + if (value !== triggerValue || !rule.required) { + return + } + + const conditionalValue = getConditionalValue(answers, rule) + + if (isBlank(conditionalValue)) { + const error = question.errors?.conditional?.[triggerValue]?.required || { + text: 'Enter an answer', + href: rule.href + } + + errors.push(makeError(error, rule.href)) + return + } + + if (rule.type === 'number') { + validateNumber(conditionalValue, rule, question.errors, errors, rule.href) + } + }) +} + +/** + * Merge runtime overrides, such as tobacco-specific headings, into a question. + * + * @param {Object} question - Normalised question config. + * @param {Object} overrides - Runtime question overrides. + * @returns {Object} Merged question config. + */ +const mergeQuestion = (question, overrides = {}) => { + return { + ...question, + ...overrides, + heading: { + ...question.heading, + ...overrides.heading + }, + input: { + ...question.input, + ...overrides.input + }, + errors: { + ...question.errors, + ...overrides.errors + }, + validation: { + ...question.validation, + ...overrides.validation + } + } +} + +/** + * Validate a numeric value against invalid, min and max rules. + * + * @param {*} value - Submitted value. + * @param {QuestionValidation} validation - Numeric validation config. + * @param {Object} errorsConfig - Error messages keyed by validation rule. + * @param {ValidationError[]} errors - Mutable error collection. + * @param {string} defaultHref - Fallback fragment link. + */ +const validateNumber = (value, validation, errorsConfig, errors, defaultHref) => { + const number = Number(value) + + if (Number.isNaN(number)) { + errors.push(makeError(errorsConfig.invalid, defaultHref)) + return + } + + if (validation.min !== undefined && number < validation.min) { + errors.push(makeError(errorsConfig.min, defaultHref)) + } + + if (validation.max !== undefined && number > validation.max) { + errors.push(makeError(errorsConfig.max, defaultHref)) + } +} + +/** + * Validate grouped inputs, such as imperial height or weight fields. + * + * @param {Object} answers - Session answers object. + * @param {Object} question - Normalised text_group question config. + * @returns {ValidationError[]} Validation errors. + */ +const validateInputGroup = (answers, question) => { + const errors = [] + const groupValue = answers[question.answerKey]?.[question.input.valueKey] || {} + + ;(question.validation?.items || []).forEach((itemValidation) => { + 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)) + return + } + + if (itemValidation.type === 'number' && !isBlank(value)) { + validateNumber(value, itemValidation, itemErrors, errors, defaultHref) + } + }) + + return errors +} + +/** + * Validate a question using its YAML validation rules and runtime overrides. + * + * @param {Object} answers - Session answers object. + * @param {string} id - Question id. + * @param {Object} [overrides] - Runtime question overrides. + * @returns {ValidationError[]} Validation errors. + */ +const validateQuestion = (answers = {}, id, overrides = {}) => { + const question = mergeQuestion(getQuestion(id), overrides) + const validation = question.validation || {} + const errors = [] + const defaultHref = getDefaultErrorHref(question) + + if (!validation.required && !validation.type && !validation.conditional && !validation.items) { + return errors + } + + if (question.type === 'text_group') { + return validateInputGroup(answers, question) + } + + if (validation.type === 'date') { + const value = getDateValue(answers, question) + const hasAnyDatePart = [value.day, value.month, value.year].some((item) => !isBlank(item)) + + if (validation.required && !hasAnyDatePart) { + errors.push(makeError(question.errors.required, defaultHref)) + return errors + } + + if (hasAnyDatePart && !isRealDate(value)) { + errors.push(makeError(question.errors.invalid, defaultHref)) + } + + return errors + } + + const value = getAnswerValue(answers, question) + + if (validation.required && isBlank(value)) { + errors.push(makeError(question.errors.required, defaultHref)) + return errors + } + + if (question.type === 'text' && validation.type === 'number' && !isBlank(value)) { + validateNumber(value, validation, question.errors, errors, defaultHref) + } + + validateConditional(answers, question, errors) + + return errors +} + +/** + * Validate several questions using the same rules as individual pages. + * + * @param {Object} answers - Session answers object. + * @param {string[]} ids - Question ids to validate. + * @returns {ValidationError[]} Validation errors for all questions. + */ +const validateQuestions = (answers = {}, ids = []) => { + return ids.flatMap((id) => validateQuestion(answers, id)) +} + +module.exports = { + validateQuestion, + validateQuestions +} diff --git a/app/prototype_v4_2/lib/questions.js b/app/prototype_v4_2/lib/questions.js new file mode 100644 index 0000000..fbf6210 --- /dev/null +++ b/app/prototype_v4_2/lib/questions.js @@ -0,0 +1,224 @@ +const fs = require('fs') +const path = require('path') +const yaml = require('js-yaml') + +const questionsPath = path.join(__dirname, '../data/questions.yaml') +const tobaccoPath = path.join(__dirname, '../data/tobacco.yaml') +const data = { + questions: {}, + smokingChangeTypes: {}, + tobaccoTypes: {} +} +let loadedAt = {} + +/** + * @typedef {Object} QuestionOption + * @property {string} [label] - Option label shown to the user. + * @property {string} [hint] - Optional hint text shown under the label. + * @property {string} [value] - Submitted option value. + * @property {string} [divider] - Divider text, for example "or". + * @property {boolean} [exclusive] - Whether the option clears other checkboxes. + * @property {string} [exclusiveGroup] - Checkbox exclusive group name. + * @property {Object} [conditionalInput] - Conditional reveal input config. + */ + +/** + * @typedef {Object} Question + * @property {string} id - Stable question id used by routes and content lookup. + * @property {string} type - Renderer type, for example single, multiple, text, date or text_group. + * @property {string} answerKey - Key used in `req.session.data.answers`. + * @property {Object} input - Normalised input config for NHS components. + * @property {QuestionOption[]} [options] - Raw YAML options. + * @property {Object[]} items - Options converted to NHS component items. + */ + +/** + * Load and parse a YAML file. + * + * @param {string} filePath - Absolute path to the YAML file. + * @returns {Object} Parsed YAML object, or an empty object for blank files. + */ +const loadYaml = (filePath) => { + const file = fs.readFileSync(filePath, 'utf8') + return yaml.load(file) || {} +} + +/** + * Get a file's modification timestamp. + * + * @param {string} filePath - Absolute path to the file. + * @returns {number} Modification timestamp in milliseconds. + */ +const getFileMtime = (filePath) => { + return fs.statSync(filePath).mtimeMs +} + +/** + * Replace an object's keys without replacing the object reference. + * + * This keeps destructured imports of exported data objects up to date. + * + * @param {Object} target - Object to mutate. + * @param {Object} source - Replacement key/value pairs. + */ +const replaceObject = (target, source = {}) => { + Object.keys(target).forEach((key) => { + delete target[key] + }) + + Object.assign(target, source) +} + +/** + * Convert a kebab-case question id into the default camelCase answer key. + * + * @param {string} id - Question id, for example `date-of-birth`. + * @returns {string} Answer key, for example `dateOfBirth`. + */ +const toAnswerName = (id) => { + return id.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) +} + +/** + * Convert a YAML option into the shape expected by NHS radios/checkboxes. + * + * @param {QuestionOption} option - YAML option definition. + * @returns {Object} NHS component item. + */ +const toComponentItem = (option) => { + if (option.divider) { + return { + divider: option.divider + } + } + + return { + text: option.label, + hint: option.hint ? { text: option.hint } : undefined, + value: option.value, + exclusive: option.exclusive, + exclusiveGroup: option.exclusiveGroup, + conditionalInput: option.conditionalInput + } +} + +/** + * Add derived fields used by the generic question renderer. + * + * @param {Question} question - Raw question loaded from YAML. + * @returns {Question} Normalised question. + */ +const normaliseQuestion = (question) => { + const answerKey = question.answerKey || toAnswerName(question.id) + const input = question.input || {} + + return { + ...question, + answerKey, + input: { + ...input, + id: input.id || question.id, + label: input.label, + name: input.name || `answers[${answerKey}]`, + hintParam: input.hint ? { text: input.hint } : undefined, + isPageHeading: !input.label + }, + items: (question.options || []).map(toComponentItem) + } +} + +/** + * Load all question and tobacco content files. + * + * @returns {Object} Indexed questions and tobacco content config. + */ +const loadData = () => { + const questionsData = loadYaml(questionsPath) + const tobaccoData = loadYaml(tobaccoPath) + const questions = (questionsData.questions || []).map(normaliseQuestion) + + return { + questions: questions.reduce((index, question) => { + index[question.id] = question + return index + }, {}), + tobaccoTypes: tobaccoData.tobaccoTypes || {}, + smokingChangeTypes: tobaccoData.smokingChangeTypes || {} + } +} + +/** + * Reload YAML content when either data file has changed. + * + * @param {boolean} [force] - Reload even when mtimes are unchanged. + */ +const refreshData = (force = false) => { + const mtimes = { + questions: getFileMtime(questionsPath), + tobacco: getFileMtime(tobaccoPath) + } + const hasChanged = force || + mtimes.questions !== loadedAt.questions || + mtimes.tobacco !== loadedAt.tobacco + + if (!hasChanged) { + return + } + + const freshData = loadData() + + replaceObject(data.questions, freshData.questions) + replaceObject(data.tobaccoTypes, freshData.tobaccoTypes) + replaceObject(data.smokingChangeTypes, freshData.smokingChangeTypes) + + loadedAt = mtimes +} + +refreshData(true) + +/** + * Get a normalised question by id. + * + * @param {string} id - Question id. + * @returns {Question} Normalised question. + * @throws {Error} When the question id is not defined in questions.yaml. + */ +const getQuestion = (id) => { + refreshData() + + const question = data.questions[id] + + if (!question) { + throw new Error(`Question not found: ${id}`) + } + + return question +} + +/** + * Build a value-to-label lookup from a question's options. + * + * @param {string} id - Question id. + * @returns {Object.} Map of submitted values to display labels. + */ +const getQuestionValueLabels = (id) => { + refreshData() + + const question = getQuestion(id) + + return (question.options || []).reduce((labels, option) => { + if (option.value && option.label) { + labels[option.value] = option.label + } + + return labels + }, {}) +} + +module.exports = { + getQuestion, + getQuestionValueLabels, + refreshData, + smokingChangeTypes: data.smokingChangeTypes, + tobaccoTypes: data.tobaccoTypes +} diff --git a/app/prototype_v4_2/lib/settings.js b/app/prototype_v4_2/lib/settings.js new file mode 100644 index 0000000..25f41f2 --- /dev/null +++ b/app/prototype_v4_2/lib/settings.js @@ -0,0 +1,24 @@ +const version = 'v4_2' +const name = `prototype_${version}` +const path = `/${name}` +const viewPath = `${name}/views` + +const view = (template) => { + return `${viewPath}/${template}` +} + +const locals = { + version, + name, + path, + viewPath +} + +module.exports = { + locals, + name, + path, + version, + view, + viewPath +} diff --git a/app/prototype_v4_2/lib/summary.js b/app/prototype_v4_2/lib/summary.js new file mode 100644 index 0000000..61caa87 --- /dev/null +++ b/app/prototype_v4_2/lib/summary.js @@ -0,0 +1,404 @@ +const { nhsukDate } = require('../../filters/dates') +const { getQuestionValueLabels } = require('./questions') +const { version } = require('./question-renderer') +const { + formatQuantity, + getSelectedSmokingChanges, + getSelectedSmokingTypes, + getSmokingChangeAnswer, + getSmokingChangeHeading, + getSmokingChangeLabels, + getSmokingQuantity, + getSmokingStepHeading, + getSmokingTypeHeadings, + getSmokingTypeStepUrl, + isPastSmokingType, + getValueLabels: getTobaccoValueLabels +} = require('./tobacco-flow') + +const getValueLabels = () => { + return { + asbestosAtHome: getQuestionValueLabels('asbestos-at-home'), + asbestosAtWork: getQuestionValueLabels('asbestos-at-work'), + cancerDiagnosis: getQuestionValueLabels('cancer-diagnosis'), + cancerDiagnosisRelatives: getQuestionValueLabels('cancer-diagnosis-relatives'), + cancerDiagnosisRelativesAge: getQuestionValueLabels('cancer-diagnosis-relatives-age'), + education: getQuestionValueLabels('education'), + ethnicity: getQuestionValueLabels('ethnicity'), + faceToFaceAppointment: getQuestionValueLabels('face-to-face-appointment'), + gender: getQuestionValueLabels('gender'), + periodsStoppedSmoking: { + yes: 'Yes', + no: 'No' + }, + respiratoryConditions: getQuestionValueLabels('respiratory-conditions'), + sex: getQuestionValueLabels('sex'), + smoker: getQuestionValueLabels('smoker'), + ...getTobaccoValueLabels() + } +} + +/** + * Format a date of birth for check-your-answers. + * + * @param {Object} dateOfBirth - Date parts keyed by day, month and year. + * @returns {string} Formatted date, or an empty string when incomplete. + */ +const formatDateOfBirth = (dateOfBirth = {}) => { + if (!dateOfBirth.day || !dateOfBirth.month || !dateOfBirth.year) { + return '' + } + + return nhsukDate( + `${dateOfBirth.year}-${String(dateOfBirth.month).padStart(2, '0')}-${String(dateOfBirth.day).padStart(2, '0')}` + ) +} + +/** + * Format a height answer for check-your-answers. + * + * @param {Object} height - Height answer object. + * @returns {string} Formatted height. + */ +const formatHeight = (height = {}) => { + if (height.metric) { + return `${height.metric} cm` + } + + if (height.imperial?.feet || height.imperial?.inches) { + return `${height.imperial.feet || 0} feet ${height.imperial.inches || 0} inches` + } + + return '' +} + +/** + * Format a weight answer for check-your-answers. + * + * @param {Object} weight - Weight answer object. + * @returns {string} Formatted weight. + */ +const formatWeight = (weight = {}) => { + if (weight.metric) { + return `${weight.metric} kg` + } + + if (weight.imperial?.stones || weight.imperial?.pounds) { + return `${weight.imperial.stones || 0} stone ${weight.imperial.pounds || 0} pounds` + } + + return '' +} + +/** + * Format a smoking quantity, including conditional reveal "another amount" answers. + * + * @param {string} type - Tobacco type key. + * @param {Object} answer - Answer object containing quantity fields. + * @param {string} quantityKey - Quantity answer key. + * @returns {string} Formatted quantity answer. + */ +const formatSmokingQuantityAnswer = (type, answer = {}, quantityKey = 'smokingQuantity') => { + if (answer[quantityKey] === 'another_amount') { + return answer.smokingQuantityOther + ? formatQuantity(answer.smokingQuantityOther, 'hour', 'hours') + : '' + } + + return getSmokingQuantity(type, answer[quantityKey]) +} + +/** + * Format one or more stored values using display labels. + * + * @param {string|string[]} value - Submitted value or values. + * @param {Object.} labels - Value-to-label map. + * @returns {string} Comma-separated display labels. + */ +const formatValue = (value, labels) => { + if (!value) { + return '' + } + + const values = Array.isArray(value) ? value : [value] + + return values.map((item) => labels?.[item] || item).join(', ') +} + +/** + * @typedef {Object} SummaryRow + * @property {Object} key - NHS summary-list key config. + * @property {Object} [value] - NHS summary-list value config. + * @property {Object} actions - NHS summary-list actions config. + */ + +/** + * Build an NHS summary-list row. + * + * @param {Object} row - Row config. + * @param {string} row.key - Question text. + * @param {string} [row.value] - Plain text answer value. + * @param {string} [row.html] - HTML answer value. + * @param {string} row.href - Change link URL. + * @param {string} [row.visuallyHiddenText] - Custom visually hidden action text. + * @returns {SummaryRow|boolean} Summary row, or false when there is no value. + */ +const makeSummaryRow = ({ key, value, html, href, visuallyHiddenText }) => { + if (!value && !html) { + return false + } + + return { + key: { + text: key + }, + value: html + ? { html } + : { text: value }, + actions: { + items: [ + { + href, + text: 'Change', + visuallyHiddenText: visuallyHiddenText || key + } + ] + } + } +} + +/** + * Remove empty rows from a summary-list row collection. + * + * @param {Array} rows - Summary rows. + * @returns {SummaryRow[]} Visible rows. + */ +const makeSummaryRows = (rows) => rows.filter(Boolean) + +/** + * Escape HTML before manually building a summary-list HTML value. + * + * @param {*} value - Value to escape. + * @returns {string} Escaped HTML string. + */ +const escapeHtml = (value) => { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +/** + * Format a multi-value answer as either text or bullet-list HTML. + * + * @param {string|string[]} value - Submitted value or values. + * @param {Object.} labels - Value-to-label map. + * @returns {Object} Summary-list value config. + */ +const formatListValue = (value, labels) => { + if (!value) { + return {} + } + + const values = Array.isArray(value) ? value : [value] + const labelValues = values.map((item) => labels[item] || item) + + if (labelValues.length > 1) { + return { + html: `
    ${labelValues.map((label) => `
  • ${escapeHtml(label)}
  • `).join('')}
` + } + } + + return { + value: labelValues[0] + } +} + +/** + * Build all check-your-answers summary-list sections. + * + * @param {Object} answers - Session answers object. + * @returns {Object} Summary-list rows grouped by section. + */ +const getCheckYourAnswers = (answers = {}) => { + const valueLabels = getValueLabels() + const selectedSmokingTypes = getSelectedSmokingTypes(answers) + const isFormerSmoker = answers.smoker === 'yes_previous' + + const tobaccoRows = selectedSmokingTypes.map((type) => { + const answer = answers[type] || {} + const isPast = isPastSmokingType(answers, answer) + const smokingType = getSmokingTypeHeadings(type, isPast) + const smokingChangeRows = getSelectedSmokingChanges(answer).flatMap((change) => { + const changeAnswer = getSmokingChangeAnswer(answer, change) + + return [ + makeSummaryRow({ + key: getSmokingChangeHeading('smoking-frequency-change', type, change, changeAnswer, answer), + value: formatValue(changeAnswer.frequency, valueLabels.smokingFrequency), + href: getSmokingTypeStepUrl({ page: 'tobacco-smoking-change', type, change }) + }), + makeSummaryRow({ + key: getSmokingChangeHeading('smoking-quantity-change', type, change, changeAnswer, answer), + value: getSmokingQuantity(type, changeAnswer.quantity), + href: getSmokingTypeStepUrl({ page: 'tobacco-smoking-change', type, change }) + }), + makeSummaryRow({ + key: getSmokingChangeHeading('smoking-years-change', type, change, changeAnswer, answer), + value: changeAnswer.years && formatQuantity(changeAnswer.years, 'year', 'years'), + href: getSmokingTypeStepUrl({ page: 'tobacco-smoking-change', type, change }) + }) + ] + }) + const rows = makeSummaryRows([ + !isFormerSmoker && makeSummaryRow({ + key: smokingType.statusHeading, + value: formatValue(answer.smokingStatus, valueLabels.smokingStatus), + href: getSmokingTypeStepUrl({ page: 'smoking-status', type }) + }), + makeSummaryRow({ + key: getSmokingStepHeading('smoking-frequency', type, isPast, answer), + value: formatValue(answer.smokingFrequency, valueLabels.smokingFrequency), + href: getSmokingTypeStepUrl({ page: 'tobacco-smoking', type }) + }), + makeSummaryRow({ + key: getSmokingStepHeading('smoking-quantity', type, isPast, answer), + value: formatSmokingQuantityAnswer(type, answer), + href: getSmokingTypeStepUrl({ page: 'tobacco-smoking', type }) + }), + type !== 'shisha' && makeSummaryRow({ + key: smokingType.changeHeading, + ...formatListValue(answer.smokingChange, getSmokingChangeLabels(type, answer, isPast)), + href: getSmokingTypeStepUrl({ page: 'smoking-change', type }) + }), + ...smokingChangeRows + ]) + + return { + heading: valueLabels.smokingType[type], + rows + } + }).filter((section) => section.rows.length) + + return { + eligibility: makeSummaryRows([ + makeSummaryRow({ + key: 'Have you ever smoked tobacco?', + value: formatValue(answers.smoker, valueLabels.smoker), + href: `/prototype_${version}/smoker`, + visuallyHiddenText: 'whether you have ever smoked tobacco' + }), + makeSummaryRow({ + key: 'Date of birth', + value: formatDateOfBirth(answers.dateOfBirth), + href: `/prototype_${version}/date-of-birth` + }), + makeSummaryRow({ + key: 'Do you need to leave the online service and ask for a face-to-face appointment?', + value: formatValue(answers.faceToFaceAppointment, valueLabels.faceToFaceAppointment), + href: `/prototype_${version}/face-to-face-appointment` + }) + ]), + aboutYou: makeSummaryRows([ + makeSummaryRow({ + key: 'Height', + value: formatHeight(answers.height), + href: answers.height?.imperial ? `/prototype_${version}/height-imperial` : `/prototype_${version}/height-metric` + }), + makeSummaryRow({ + key: 'Weight', + value: formatWeight(answers.weight), + href: answers.weight?.imperial ? `/prototype_${version}/weight-imperial` : `/prototype_${version}/weight-metric` + }), + makeSummaryRow({ + key: 'Gender identity', + value: formatValue(answers.gender, valueLabels.gender), + href: `/prototype_${version}/about-you` + }), + makeSummaryRow({ + key: 'Sex at birth', + value: formatValue(answers.sex, valueLabels.sex), + href: `/prototype_${version}/about-you` + }), + makeSummaryRow({ + key: 'Ethnic background', + value: formatValue(answers.ethnicity, valueLabels.ethnicity), + href: `/prototype_${version}/about-you` + }), + makeSummaryRow({ + key: 'Education', + value: formatValue(answers.education, valueLabels.education), + href: `/prototype_${version}/about-you` + }) + ]), + health: makeSummaryRows([ + makeSummaryRow({ + key: 'Respiratory conditions', + ...formatListValue(answers.respiratoryConditions, valueLabels.respiratoryConditions), + href: `/prototype_${version}/respiratory-conditions` + }), + makeSummaryRow({ + key: 'Worked in a job where you might have been exposed to asbestos', + value: formatValue(answers.asbestosAtWork, valueLabels.asbestosAtWork), + href: `/prototype_${version}/asbestos` + }), + makeSummaryRow({ + key: 'Lived with anyone who worked with asbestos', + value: formatValue(answers.asbestosAtHome, valueLabels.asbestosAtHome), + href: `/prototype_${version}/asbestos` + }), + makeSummaryRow({ + key: 'Ever been diagnosed with cancer', + value: formatValue(answers.cancerDiagnosis, valueLabels.cancerDiagnosis), + href: `/prototype_${version}/cancer-diagnosis` + }) + ]), + familyHistory: makeSummaryRows([ + makeSummaryRow({ + key: 'Parents, siblings or children diagnosed with lung cancer', + value: formatValue(answers.cancerDiagnosisRelatives, valueLabels.cancerDiagnosisRelatives), + href: `/prototype_${version}/cancer-diagnosis-relatives` + }), + answers.cancerDiagnosisRelatives === 'yes' && makeSummaryRow({ + key: 'Relatives younger than 60 when diagnosed with lung cancer', + value: formatValue(answers.cancerDiagnosisRelativesAge, valueLabels.cancerDiagnosisRelativesAge), + href: `/prototype_${version}/cancer-diagnosis-relatives-age` + }) + ]), + smokingHabits: makeSummaryRows([ + makeSummaryRow({ + key: 'Age you started smoking', + value: answers.ageStartedSmoking && `Age ${answers.ageStartedSmoking}`, + href: `/prototype_${version}/smoking-duration` + }), + isFormerSmoker && makeSummaryRow({ + key: 'Age you stopped smoking', + value: answers.ageStoppedSmoking && `Age ${answers.ageStoppedSmoking}`, + href: `/prototype_${version}/smoking-duration` + }), + makeSummaryRow({ + key: 'Stopped smoking for periods of 1 year or longer', + value: formatValue(answers.periodsStoppedSmoking, valueLabels.periodsStoppedSmoking), + href: `/prototype_${version}/smoking-duration` + }), + answers.periodsStoppedSmoking === 'yes' && makeSummaryRow({ + key: 'Total number of years you stopped smoking', + value: answers.yearsStoppedSmoking && formatQuantity(answers.yearsStoppedSmoking, 'year', 'years'), + href: `/prototype_${version}/smoking-duration` + }), + makeSummaryRow({ + key: 'Types of tobacco smoked', + ...formatListValue(answers.smokingType, valueLabels.smokingType), + href: `/prototype_${version}/smoking-type` + }) + ]), + tobaccoRows + } +} + +module.exports = { + getCheckYourAnswers, + getValueLabels +} diff --git a/app/prototype_v4_2/lib/tobacco-flow.js b/app/prototype_v4_2/lib/tobacco-flow.js new file mode 100644 index 0000000..8d98a48 --- /dev/null +++ b/app/prototype_v4_2/lib/tobacco-flow.js @@ -0,0 +1,1121 @@ +const { + getQuestion, + getQuestionValueLabels, + refreshData, + smokingChangeTypes, + tobaccoTypes: smokingTypes +} = require('./questions') +const { getQuestionPage } = require('./question-pages') +const { renderQuestion, renderQuestionPage, version } = require('./question-renderer') +const { validateQuestion } = require('./question-validator') + +const nextStepAfterSmokingTypes = `/prototype_${version}/check-your-answers` + +const getValueLabels = () => { + return { + smokingChange: getQuestionValueLabels('smoking-change'), + smokingFrequency: getQuestionValueLabels('smoking-frequency'), + smokingStatus: getQuestionValueLabels('smoking-status'), + smokingType: getQuestionValueLabels('smoking-type') + } +} + +/** + * @typedef {Object} SmokingTypeStep + * @property {string} page - Route/page id for the step. + * @property {string} type - Tobacco type key, for example cigarettes. + * @property {string} [change] - Smoking change key. + */ + +/** + * Format a numeric quantity with singular or plural unit text. + * + * @param {string|number} value - Numeric value. + * @param {string} singular - Singular unit. + * @param {string} plural - Plural unit. + * @returns {string} Formatted quantity. + */ +const formatQuantity = (value, singular, plural) => { + return `${value} ${Number(value) === 1 ? singular : plural}` +} + +/** + * Convert a YAML option into the shape expected by NHS radios/checkboxes. + * + * @param {Object} option - YAML option definition. + * @returns {Object} NHS component item. + */ +const toComponentItem = (option) => { + if (option.divider) { + return { + divider: option.divider + } + } + + return { + text: option.label, + hint: option.hint ? { text: option.hint } : undefined, + value: option.value, + exclusive: option.exclusive, + exclusiveGroup: option.exclusiveGroup, + conditionalInput: option.conditionalInput + } +} + +/** + * Get a question variant for a tobacco type. + * + * @param {string} id - Question id. + * @param {string} type - Tobacco type key. + * @returns {Object} Variant config. + */ +const getQuestionVariant = (id, type) => { + return getQuestion(id).variants?.[type] || {} +} + +/** + * Build value-to-label mappings for variant quantity options. + * + * @param {string} id - Quantity question id. + * @param {string} type - Tobacco type key. + * @returns {Object.} Map of submitted values to labels. + */ +const getQuestionVariantValueLabels = (id, type) => { + const question = getQuestion(id) + const variant = getQuestionVariant(id, type) + + return (variant.options || question.options || []).reduce((labels, option) => { + if (option.value && option.label) { + labels[option.value] = option.label + } + + return labels + }, {}) +} + +/** + * Get selected tobacco types in tobacco.yaml order. + * + * @param {Object} answers - Session answers object. + * @returns {string[]} Selected tobacco type keys. + */ +const getSelectedSmokingTypes = (answers = {}) => { + refreshData() + + const selectedTypes = Array.isArray(answers.smokingType) + ? answers.smokingType + : [answers.smokingType].filter(Boolean) + + return Object.keys(smokingTypes).filter((type) => selectedTypes.includes(type)) +} + +/** + * Remove nested answers for tobacco types the user has unselected. + * + * @param {Object} answers - Session answers object, mutated in place. + */ +const deleteUnselectedSmokingTypeAnswers = (answers = {}) => { + const selectedTypes = getSelectedSmokingTypes(answers) + + Object.keys(smokingTypes).forEach((type) => { + if (!selectedTypes.includes(type)) { + delete answers[type] + } + }) +} + +/** + * Get selected smoking change options in tobacco.yaml order. + * + * @param {Object} answer - Answer object for one tobacco type. + * @returns {string[]} Selected smoking change keys. + */ +const getSelectedSmokingChanges = (answer = {}) => { + refreshData() + + const selectedChanges = Array.isArray(answer.smokingChange) + ? answer.smokingChange + : [answer.smokingChange].filter(Boolean) + + return Object.keys(smokingChangeTypes).filter((change) => selectedChanges.includes(change)) +} + +/** + * Remove nested changed-smoking answers for unselected change options. + * + * @param {Object} answer - Answer object for one tobacco type, mutated in place. + */ +const deleteUnselectedSmokingChangeAnswers = (answer = {}) => { + const selectedChanges = getSelectedSmokingChanges(answer) + + Object.entries(smokingChangeTypes).forEach(([change, changeType]) => { + if (!selectedChanges.includes(change)) { + delete answer[changeType.answerKey] + } + }) +} + +/** + * Build the tobacco sub-flow steps from selected tobacco answers. + * + * @param {Object} answers - Session answers object. + * @returns {SmokingTypeStep[]} Ordered tobacco sub-flow steps. + */ +const getSmokingTypeSteps = (answers = {}) => { + const includeSmokingStatus = answers.smoker !== 'yes_previous' + + return getSelectedSmokingTypes(answers).flatMap((type) => { + const steps = [] + const answer = answers[type] || {} + + if (includeSmokingStatus) { + steps.push({ page: 'smoking-status', type }) + } + + steps.push({ page: 'tobacco-smoking', type }) + + if (type !== 'shisha') { + steps.push({ page: 'smoking-change', type }) + getSelectedSmokingChanges(answer).forEach((change) => { + steps.push({ page: 'tobacco-smoking-change', type, change }) + }) + } + + return steps + }) +} + +/** + * Build a URL for a tobacco sub-flow step. + * + * @param {SmokingTypeStep} step - Tobacco sub-flow step. + * @returns {string} Step URL including query parameters. + */ +const getSmokingTypeStepUrl = (step) => { + const searchParams = new URLSearchParams({ type: step.type }) + + if (step.change) { + searchParams.set('change', step.change) + } + + return `/prototype_${version}/${step.page}?${searchParams}` +} + +/** + * Format a tobacco quantity answer with the correct unit label. + * + * @param {string} type - Tobacco type key. + * @param {string} answer - Submitted quantity answer. + * @returns {string} Display quantity. + */ +const getSmokingQuantity = (type, answer) => { + refreshData() + + if (!answer) { + return '' + } + + const optionLabel = getQuestionVariantValueLabels('smoking-quantity', type)[answer] + + if (optionLabel) { + return optionLabel + } + + const smokingType = smokingTypes[type] + return smokingType?.suffix + ? formatQuantity(answer, smokingType.singularSuffix || smokingType.suffix, smokingType.suffix) + : answer +} + +/** + * Format rolling tobacco boundary values for "more/fewer than" comparisons. + * + * @param {string} answer - Submitted rolling tobacco quantity. + * @returns {string} Quantity text for comparison labels. + */ +const getRollingTobaccoComparisonQuantity = (answer) => { + const comparisonQuantities = { + less_than_10: '10g', + more_than_100: '100g' + } + + return comparisonQuantities[answer] || getSmokingQuantity('rolling_tobacco', answer) +} + +/** + * Format a tobacco quantity for changed-smoking comparison text. + * + * @param {string} type - Tobacco type key. + * @param {string} answer - Submitted quantity answer. + * @returns {string} Quantity text for comparison labels. + */ +const getSmokingComparisonQuantity = (type, answer) => { + if (type === 'rolling_tobacco') { + return getRollingTobaccoComparisonQuantity(answer) + } + + return getSmokingQuantity(type, answer) +} + +const smokingFrequencyPeriods = { + daily: 'a day', + weekly: 'a week', + monthly: 'a month', + yearly: 'a year' +} + +/** + * Convert a smoking frequency value into a period phrase. + * + * @param {string} frequency - Frequency value. + * @returns {string} Period phrase, for example `a day`. + */ +const getSmokingFrequencyPeriod = (frequency) => { + return smokingFrequencyPeriods[frequency] || '' +} + +/** + * Replace the default "normal day/week/month/year" phrase in a heading. + * + * @param {string} heading - Heading text from YAML. + * @param {string} frequency - Selected smoking frequency. + * @returns {string} Heading with the selected frequency period. + */ +const applySmokingFrequencyPeriod = (heading = '', frequency) => { + const period = getSmokingFrequencyPeriod(frequency) + + if (!period) { + return heading + } + + return heading.replace(/in a normal (day|week|month|year)/, `in a normal ${period.replace(/^a /, '')}`) +} + +/** + * Build the current amount phrase used in changed-smoking labels. + * + * @param {string} type - Tobacco type key. + * @param {Object} answer - Answer object for one tobacco type. + * @returns {string} Amount phrase, for example `10 cigarettes a day`. + */ +const getSmokingCurrentAmount = (type, answer = {}) => { + const quantity = getSmokingComparisonQuantity(type, answer.smokingQuantity) + const period = getSmokingFrequencyPeriod(answer.smokingFrequency) + + if (!quantity) { + return '' + } + + return [quantity, period].filter(Boolean).join(' ') +} + +/** + * Decide whether a tobacco type should use past-tense content. + * + * @param {Object} answers - Session answers object. + * @param {Object} answer - Answer object for one tobacco type. + * @returns {boolean} True when past-tense headings should be used. + */ +const isPastSmokingType = (answers = {}, answer = {}) => { + return answers.smoker === 'yes_previous' || answer.smokingStatus === 'no' +} + +/** + * Build contextual labels for the smoking-change checkbox options. + * + * @param {string} type - Tobacco type key. + * @param {Object} answer - Answer object for one tobacco type. + * @param {boolean} isPast - Whether past-tense labels should be used. + * @returns {Object.} Value-to-label map. + */ +const getSmokingChangeLabels = (type, answer = {}, isPast = false) => { + const amount = getSmokingCurrentAmount(type, answer) + const fewerLabel = type === 'rolling_tobacco' ? 'less' : 'fewer' + const defaultLabels = isPast + ? { + greater: 'Yes, I smoked more', + fewer: `Yes, I smoked ${fewerLabel}`, + no: 'No, it did not change' + } + : getValueLabels().smokingChange + + if (!amount) { + return defaultLabels + } + + return { + ...defaultLabels, + greater: `Yes, I ${isPast ? 'smoked' : 'used to smoke'} more than ${amount}`, + fewer: `Yes, I ${isPast ? 'smoked' : 'used to smoke'} ${fewerLabel} than ${amount}` + } +} + +/** + * Get tobacco type content with the right tense-specific heading aliases. + * + * @param {string} type - Tobacco type key. + * @param {boolean} isPast - Whether past-tense headings should be used. + * @returns {Object} Tobacco type content. + */ +const getSmokingTypeHeadings = (type, isPast = false) => { + refreshData() + + const smokingType = smokingTypes[type] + + if (!smokingType) { + return {} + } + + const currentHeadings = smokingType.headings?.current || {} + const tenseHeadings = smokingType.headings?.[isPast ? 'past' : 'current'] || currentHeadings + + return { + ...smokingType, + statusHeading: currentHeadings.status, + frequencyHeading: tenseHeadings.frequency, + quantityHeading: tenseHeadings.quantity, + changeHeading: tenseHeadings.change + } +} + +/** + * Get the heading for a tobacco sub-flow step. + * + * @param {string} page - Step page id. + * @param {string} type - Tobacco type key. + * @param {boolean} isPast - Whether past-tense headings should be used. + * @param {Object} answer - Answer object used for frequency-specific periods. + * @returns {string} Step heading. + */ +const getSmokingStepHeading = (page, type, isPast = false, answer = {}) => { + const frequency = answer.smokingFrequency + + return applySmokingFrequencyPeriod(getSmokingTypeHeadings(type, isPast)[`${page.replace('smoking-', '')}Heading`] || '', frequency) +} + +/** + * Get the nested answer object for a changed-smoking option. + * + * @param {Object} answer - Answer object for one tobacco type. + * @param {string} change - Smoking change key. + * @returns {Object} Change-specific answer object. + */ +const getSmokingChangeAnswer = (answer = {}, change) => { + refreshData() + + const answerKey = smokingChangeTypes[change]?.answerKey + + return answerKey ? answer[answerKey] || {} : {} +} + +/** + * Get the object that stores a smoking quantity answer for the current step. + * + * @param {Object} answers - Session answers object. + * @param {SmokingTypeStep} step - Current tobacco step. + * @returns {Object} Answer object containing the quantity answer. + */ +const getSmokingQuantityAnswer = (answers = {}, step = {}) => { + const answer = answers[step.type] || {} + + if (step.change) { + return getSmokingChangeAnswer(answer, step.change) + } + + return answer +} + +/** + * Remove a stale conditional reveal quantity when "another amount" is not selected. + * + * @param {Object} answers - Session answers object, mutated in place. + * @param {SmokingTypeStep} step - Current tobacco step. + * @param {string} quantityKey - Quantity answer key. + */ +const deleteUnselectedSmokingQuantityOtherAnswer = (answers = {}, step, quantityKey = 'smokingQuantity') => { + delete answers.smokingQuantityOther + + if (!step) { + return + } + + const answer = getSmokingQuantityAnswer(answers, step) + + if (answer[quantityKey] !== 'another_amount') { + delete answer.smokingQuantityOther + } +} + +/** + * Build contextual comparison text for changed-smoking questions. + * + * @param {string} type - Tobacco type key. + * @param {string} change - Smoking change key. + * @param {Object} answer - Answer object for one tobacco type. + * @returns {string} Comparison text. + */ +const getSmokingChangeComparisonText = (type, change, answer = {}) => { + refreshData() + + const smokingChange = smokingChangeTypes[change] + const amount = getSmokingCurrentAmount(type, answer) + const changeLabel = type === 'rolling_tobacco' && change === 'fewer' + ? 'less' + : smokingChange?.label + + if (!smokingChange) { + return '' + } + + if (!amount) { + return `when you smoked ${changeLabel}` + } + + return `when you smoked ${changeLabel} than ${amount}` +} + +/** + * Build the heading for changed-smoking frequency, quantity and years pages. + * + * @param {string} page - Changed-smoking page id. + * @param {string} type - Tobacco type key. + * @param {string} change - Smoking change key. + * @param {Object} changeAnswer - Change-specific answer object. + * @param {Object} answer - Answer object for one tobacco type. + * @returns {string} Contextual page heading. + */ +const getSmokingChangeHeading = (page, type, change, changeAnswer = {}, answer = {}) => { + const smokingType = smokingTypes[type] + const smokingChange = smokingChangeTypes[change] + + if (!smokingType || !smokingChange) { + return '' + } + + const comparisonText = getSmokingChangeComparisonText(type, change, answer) + + if (page === 'smoking-frequency-change' || page === 'smoking-quantity-change') { + const headingType = page === 'smoking-frequency-change' ? 'frequencyHeading' : 'quantityHeading' + const baseHeading = applySmokingFrequencyPeriod(getSmokingTypeHeadings(type, true)[headingType], answer.smokingFrequency) + + return baseHeading ? `${baseHeading.replace('?', '')} ${comparisonText}?` : '' + } + + 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 '' +} + +/** + * Resolve the active tobacco step from the current request query. + * + * @param {Object} req - Express request object. + * @param {string} page - Current tobacco page id. + * @returns {{step: SmokingTypeStep|undefined, steps: SmokingTypeStep[]}} Active step and all steps. + */ +const getSmokingTypeStep = (req, page) => { + const { answers } = req.session.data + const steps = getSmokingTypeSteps(answers) + const queryType = req.query?.type + const queryChange = req.query?.change + const step = steps.find((step) => step.page === page && step.type === queryType && step.change === queryChange) || + steps.find((step) => step.page === page && step.type === queryType && !step.change) || + steps.find((step) => step.page === page) + + return { step, steps } +} + +/** + * Skip smoking-status for former smokers by falling back to their first type step. + * + * @param {Object} req - Express request object. + * @param {string} page - Current tobacco page id. + * @param {SmokingTypeStep[]} steps - Ordered tobacco steps. + * @returns {SmokingTypeStep|boolean} Fallback step, or false when not applicable. + */ +const getFormerSmokerFallbackStep = (req, page, steps) => { + if (page !== 'smoking-status' || req.session.data.answers?.smoker !== 'yes_previous') { + return false + } + + return steps.find((step) => step.type === req.query?.type) || steps[0] +} + +/** + * Get smoking-type page variants based on the smoker answer. + * + * @param {Object} answers - Session answers object. + * @returns {Object} Runtime question overrides. + */ +const getSmokingTypeQuestionOverrides = (answers = {}) => { + const question = getQuestion('smoking-type') + const page = getQuestionPage('smoking-type') + + if (answers.smoker === 'yes_previous') { + return { + ...question.variants.previous, + ...page.variants?.previous, + input: { + ...question.variants.previous?.input, + ...page.variants?.previous?.input + } + } + } + + return {} +} + +/** + * Clone a question's component items with contextual labels or hints. + * + * @param {string} id - Question id. + * @param {Object.} labels - Value-to-label overrides. + * @param {Object.} hintOverrides - Value-to-hint overrides. + * @returns {Object[]} NHS component items. + */ +const getQuestionItemsWithLabels = (id, labels = {}, hintOverrides = {}) => { + return getQuestion(id).items.map((item) => { + if (!item.value) { + return item + } + + return { + ...item, + text: labels[item.value] || item.text, + hint: hintOverrides[item.value] + ? { text: hintOverrides[item.value] } + : item.hint + } + }) +} + +/** + * Build runtime overrides for a quantity question. + * + * @param {Object} params - Quantity override inputs. + * @returns {Object} Runtime question overrides. + */ +const getSmokingQuantityQuestionOverrides = ({ + page, + step, + heading, + caption, + name, + value, + conditionalValue, + smokingType +}) => { + const question = getQuestion(page) + const variant = getQuestionVariant(page, step.type) + const variantInput = variant.input || {} + const hasHintOverride = Object.prototype.hasOwnProperty.call(variantInput, 'hint') + const hint = hasHintOverride ? variantInput.hint : question.input.hint + const questionType = variant.type || question.type + const conditionalHref = '#smoking-quantity-other' + const items = variant.options + ? variant.options.map((option) => { + const item = toComponentItem(option) + + if (!item.conditionalInput) { + return item + } + + const conditionalName = `answers[${step.type}][${item.conditionalInput.answerKey}]` + + return { + ...item, + conditionalInput: { + ...item.conditionalInput, + name: conditionalName, + value: conditionalValue + } + } + }) + : question.items + + return { + type: questionType, + heading: { + title: heading, + caption + }, + input: { + id: question.input.id, + name, + hint, + hintParam: hint ? { text: hint } : undefined, + suffix: questionType === 'text' ? smokingType.suffix : undefined + }, + validation: { + ...variant.validation, + conditional: { + another_amount: { + required: true, + type: 'number', + min: 0.5, + max: 24, + answerKey: 'smokingQuantityOther', + value: conditionalValue, + href: conditionalHref + } + } + }, + errors: { + conditional: { + another_amount: { + required: { + text: 'Enter the number of hours', + href: conditionalHref + } + } + }, + invalid: { + text: 'Number of hours must be a number', + href: conditionalHref + }, + min: { + text: 'Number of hours must be 0.5 or more', + href: conditionalHref + }, + max: { + text: 'Number of hours must be 24 or fewer', + href: conditionalHref + } + }, + items, + value + } +} + +const removeQuestionMark = (value = '') => value.replace(/\?$/, '') + +const lowerFirst = (value = '') => { + return value ? `${value.charAt(0).toLowerCase()}${value.slice(1)}` : '' +} + +/** + * Convert a question heading into an error-message answer phrase. + * + * @param {string} heading - Question heading. + * @returns {string} Error-message phrase. + */ +const getAnswerPhraseFromHeading = (heading = '') => { + return lowerFirst(removeQuestionMark(heading)) + .replace(/^how often did you smoke /, 'how often you smoked ') + .replace(/^how often do you smoke /, 'how often you smoke ') + .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 ') +} + +/** + * Convert a yes/no style heading into a "Select whether..." error. + * + * @param {string} heading - Question heading. + * @returns {string} Required error text. + */ +const getSelectWhetherText = (heading = '') => { + const text = removeQuestionMark(heading) + const hasChangedMatch = heading.match(/^Has (.+) changed over time\?$/) + const didChangeMatch = heading.match(/^Did (.+) change over time\?$/) + + if (hasChangedMatch) { + return `Select whether ${hasChangedMatch[1]} has changed over time` + } + + if (didChangeMatch) { + return `Select whether ${didChangeMatch[1]} changed over time` + } + + return `Select whether ${lowerFirst(text) + .replace(/^do you /, 'you ') + .replace(/^did you usually smoke /, 'you usually smoked ') + .replace(/^did you /, 'you ') + .replace(/^have you /, 'you have ') + .replace(/^have any /, 'any ') + .replace(/^were any /, 'any ')}` +} + +/** + * Build contextual required error text from the current heading. + * + * @param {Object} question - Base question config. + * @param {Object} overrides - Runtime question overrides. + * @returns {string} Required error text. + */ +const getContextualRequiredErrorText = (question, overrides) => { + const heading = overrides.heading?.title || question.heading?.title || '' + const questionType = overrides.type || question.type + + if (heading.startsWith('How often')) { + return `Select ${getAnswerPhraseFromHeading(heading)}` + } + + if (heading.startsWith('How much') || heading.startsWith('How many') || heading.startsWith('How long')) { + return `${questionType === 'single' ? 'Select' : 'Enter'} ${getAnswerPhraseFromHeading(heading)}` + } + + return getSelectWhetherText(heading) +} + +/** + * Build contextual invalid-number error text from the current heading. + * + * @param {Object} question - Base question config. + * @param {Object} overrides - Runtime question overrides. + * @returns {string|undefined} Invalid error text when applicable. + */ +const getContextualInvalidErrorText = (question, overrides) => { + const heading = overrides.heading?.title || question.heading?.title || '' + + if (heading.startsWith('How much') || heading.startsWith('How many') || heading.startsWith('How long')) { + return `Enter ${getAnswerPhraseFromHeading(heading)} using numbers` + } + + return undefined +} + +/** + * Build runtime question overrides for the tobacco sub-flow. + * + * @param {Object} params - Tobacco override inputs. + * @returns {Object} Runtime question overrides. + */ +const getSmokingContentQuestionOverrides = ({ + page, + step, + answer, + changeAnswer, + smokingType, + smokingChange, + smokingChangeLabels, + isPastSmokingType +}) => { + if (page === 'smoking-status') { + return { + heading: { + title: smokingType.statusHeading, + caption: smokingType.caption + }, + input: { + name: `answers[${step.type}][smokingStatus]` + }, + value: answer.smokingStatus + } + } + + if (page === 'smoking-frequency') { + return { + heading: { + title: getSmokingStepHeading(page, step.type, isPastSmokingType, answer), + caption: smokingType.caption + }, + input: { + name: `answers[${step.type}][smokingFrequency]` + }, + value: answer.smokingFrequency, + items: getQuestionItemsWithLabels('smoking-frequency', {}, { + monthly: `Select this option if you ${isPastSmokingType ? 'smoked' : 'smoke'} at least once a month` + }) + } + } + + if (page === 'smoking-quantity') { + return getSmokingQuantityQuestionOverrides({ + page, + step, + heading: getSmokingStepHeading(page, step.type, isPastSmokingType, answer), + caption: smokingType.caption, + name: `answers[${step.type}][smokingQuantity]`, + value: answer.smokingQuantity, + conditionalValue: answer.smokingQuantityOther, + smokingType + }) + } + + if (page === 'smoking-change') { + return { + heading: { + title: smokingType.changeHeading, + caption: smokingType.caption + }, + input: { + name: `answers[${step.type}][smokingChange]` + }, + values: answer.smokingChange, + items: getQuestionItemsWithLabels('smoking-change', smokingChangeLabels) + } + } + + if (page === 'smoking-frequency-change') { + return { + heading: { + title: getSmokingChangeHeading(page, step.type, step.change, changeAnswer, answer), + caption: smokingType.caption + }, + input: { + name: `answers[${step.type}][${smokingChange.answerKey}][frequency]` + }, + value: changeAnswer.frequency, + items: getQuestion('smoking-frequency-change').items + } + } + + if (page === 'smoking-quantity-change') { + return getSmokingQuantityQuestionOverrides({ + page, + step, + heading: getSmokingChangeHeading(page, step.type, step.change, changeAnswer, answer), + caption: smokingType.caption, + name: `answers[${step.type}][${smokingChange.answerKey}][quantity]`, + value: changeAnswer.quantity, + conditionalValue: changeAnswer.smokingQuantityOther, + smokingType + }) + } + + if (page === 'smoking-years-change') { + return { + heading: { + title: getSmokingChangeHeading(page, step.type, step.change, changeAnswer, answer), + caption: smokingType.caption + }, + input: { + id: 'smoking-years-change', + name: `answers[${step.type}][${smokingChange.answerKey}][years]` + }, + value: changeAnswer.years + } + } + + return {} +} + +const getSmokingTypeStepContext = (req, step) => { + const answer = req.session.data.answers[step.type] || {} + const isPast = isPastSmokingType(req.session.data.answers, answer) + const changeAnswer = getSmokingChangeAnswer(answer, step.change) + const smokingType = getSmokingTypeHeadings(step.type, isPast) + const smokingChange = smokingChangeTypes[step.change] + const smokingChangeLabels = getSmokingChangeLabels(step.type, answer, isPast) + + return { + answer, + changeAnswer, + smokingType, + smokingChange, + smokingChangeLabels, + isPastSmokingType: isPast + } +} + +const getSmokingTypePageAnswers = (step) => { + return { + smokingType: [step.type] + } +} + +const getSmokingTypePageHeading = (page, smokingType = {}) => { + if (page === 'tobacco-smoking-change') { + return { + title: `${smokingType.caption} change` + } + } + + return { + title: smokingType.caption + } +} + +const getSmokingTypePageQuestionIds = (page, step) => { + return getQuestionPage(page, getSmokingTypePageAnswers(step)).questions.map((question) => question.id) +} + +const getSmokingContentPageOverrides = (req, page, step) => { + const context = getSmokingTypeStepContext(req, step) + const questions = getSmokingTypePageQuestionIds(page, step).reduce((overrides, questionId) => { + overrides[questionId] = getSmokingContentQuestionOverrides({ + page: questionId, + step, + ...context + }) + + return overrides + }, {}) + + return { + heading: getSmokingTypePageHeading(page, context.smokingType), + questions + } +} + +/** + * Build action URLs for a tobacco sub-flow step. + * + * @param {SmokingTypeStep} step - Current tobacco step. + * @param {SmokingTypeStep[]} steps - Ordered tobacco steps. + * @returns {Object} Action URLs. + */ +const getSmokingTypeActions = (step, steps) => { + const index = steps.findIndex((item) => item.page === step.page && item.type === step.type && item.change === step.change) + const previousStep = steps[index - 1] + const nextStep = steps[index + 1] + + return { + next: getSmokingTypeStepUrl(step), + back: previousStep ? getSmokingTypeStepUrl(previousStep) : `/prototype_${version}/smoking-type`, + onward: nextStep ? getSmokingTypeStepUrl(nextStep) : nextStepAfterSmokingTypes, + cancel: `/prototype_${version}/` + } +} + +/** + * Render a tobacco sub-flow page, resolving the active step and context. + * + * @param {Object} req - Express request object. + * @param {Object} res - Express response object. + * @param {string} page - Current tobacco page id. + * @param {Object[]} [errors] - Validation errors. + */ +const renderSmokingTypeQuestion = (req, res, page, errors = []) => { + const { step, steps } = getSmokingTypeStep(req, page) + + if (!step) { + const fallbackStep = getFormerSmokerFallbackStep(req, page, steps) + + if (fallbackStep) { + res.redirect(getSmokingTypeStepUrl(fallbackStep)) + return + } + + res.redirect(`/prototype_${version}/smoking-type`) + return + } + + const context = getSmokingTypeStepContext(req, step) + + renderQuestion(res, page, getSmokingTypeActions(step, steps), errors, getSmokingContentQuestionOverrides({ + page, + step, + ...context + })) +} + +/** + * Render a grouped tobacco sub-flow page, resolving the active step and context. + * + * @param {Object} req - Express request object. + * @param {Object} res - Express response object. + * @param {string} page - Current tobacco page id. + * @param {Object[]} [errors] - Validation errors. + */ +const renderSmokingTypePage = (req, res, page, errors = []) => { + const { step, steps } = getSmokingTypeStep(req, page) + + if (!step) { + res.redirect(`/prototype_${version}/smoking-type`) + return + } + + renderQuestionPage( + res, + page, + getSmokingTypeActions(step, steps), + errors, + getSmokingTypePageAnswers(step), + getSmokingContentPageOverrides(req, page, step) + ) +} + +/** + * Validate a tobacco sub-flow page with contextual headings and errors. + * + * @param {Object} req - Express request object. + * @param {string} page - Current tobacco page id. + * @param {SmokingTypeStep} step - Current tobacco step. + * @returns {Object[]} Validation errors. + */ +const validateSmokingTypeQuestion = (req, page, step) => { + const context = getSmokingTypeStepContext(req, step) + const overrides = getSmokingContentQuestionOverrides({ + page, + step, + ...context + }) + const questionType = overrides.type || getQuestion(page).type + const question = getQuestion(page) + const errorHref = overrides.input.id || question.input.id + const errors = { + ...overrides.errors, + required: { + ...question.errors?.required, + ...overrides.errors?.required, + text: getContextualRequiredErrorText(question, overrides), + href: `#${errorHref}` + } + } + + if (questionType === 'text' && question.errors?.invalid) { + errors.invalid = { + ...question.errors.invalid, + ...overrides.errors?.invalid, + text: getContextualInvalidErrorText(question, overrides) || question.errors.invalid.text, + href: `#${errorHref}` + } + } + + return validateQuestion(req.session.data.answers, page, { + ...overrides, + errors + }) +} + +/** + * Validate a grouped tobacco sub-flow page. + * + * @param {Object} req - Express request object. + * @param {string} page - Current tobacco page id. + * @param {SmokingTypeStep} step - Current tobacco step. + * @returns {Object[]} Validation errors. + */ +const validateSmokingTypePage = (req, page, step) => { + return getSmokingTypePageQuestionIds(page, step).flatMap((questionId) => validateSmokingTypeQuestion(req, questionId, step)) +} + +module.exports = { + deleteUnselectedSmokingQuantityOtherAnswer, + deleteUnselectedSmokingChangeAnswers, + deleteUnselectedSmokingTypeAnswers, + formatQuantity, + getSelectedSmokingChanges, + getSelectedSmokingTypes, + getFormerSmokerFallbackStep, + getSmokingChangeAnswer, + getSmokingChangeHeading, + getSmokingChangeLabels, + getSmokingQuantity, + getSmokingStepHeading, + getSmokingTypeActions, + getSmokingTypeHeadings, + getSmokingTypeQuestionOverrides, + getSmokingTypeStep, + getSmokingTypeSteps, + getSmokingTypeStepUrl, + isPastSmokingType, + renderSmokingTypePage, + renderSmokingTypeQuestion, + validateSmokingTypePage, + validateSmokingTypeQuestion, + getValueLabels +} diff --git a/app/prototype_v4_2/lib/unit-navigation.js b/app/prototype_v4_2/lib/unit-navigation.js new file mode 100644 index 0000000..4bfa8e5 --- /dev/null +++ b/app/prototype_v4_2/lib/unit-navigation.js @@ -0,0 +1,52 @@ +const { version } = require('./question-renderer') + +/** + * Resolve the back link for weight pages based on the height unit answered. + * + * @param {Object} req - Express request object. + * @returns {string} Back link URL. + */ +const getHeightBack = (req) => { + const { answers } = req.session.data + + return answers?.height?.imperial ? `/prototype_${version}/height-imperial` : `/prototype_${version}/height-metric` +} + +/** + * Resolve the back link after weight pages based on the weight unit answered. + * + * @param {Object} req - Express request object. + * @returns {string} Back link URL. + */ +const getWeightBack = (req) => { + const { answers } = req.session.data + + return answers?.weight?.imperial ? `/prototype_${version}/weight-imperial` : `/prototype_${version}/weight-metric` +} + +/** + * Resolve the next weight page after height, preserving an existing unit choice. + * + * @param {Object} req - Express request object. + * @param {string} defaultUnit - Unit to use when no weight unit has been chosen. + * @returns {string} Next weight page URL. + */ +const getWeightNext = (req, defaultUnit) => { + const { answers } = req.session.data + + if (answers?.weight?.imperial) { + return `/prototype_${version}/weight-imperial` + } + + if (answers?.weight?.metric) { + return `/prototype_${version}/weight-metric` + } + + return `/prototype_${version}/weight-${defaultUnit}` +} + +module.exports = { + getHeightBack, + getWeightBack, + getWeightNext +} diff --git a/app/prototype_v4_2/routes.js b/app/prototype_v4_2/routes.js new file mode 100644 index 0000000..878eea6 --- /dev/null +++ b/app/prototype_v4_2/routes.js @@ -0,0 +1,247 @@ +const express = require('express') +const fs = require('fs') +const path = require('path') +const router = express.Router() +const settings = require('./lib/settings') + +const { path: prototypePath, version, view } = settings +const viewsDirectory = path.join(__dirname, 'views') + +const hasView = (template) => { + if (!template || template.includes('..')) { + return false + } + + const templatePath = path.join(viewsDirectory, `${template}.html`) + return templatePath.startsWith(viewsDirectory) && fs.existsSync(templatePath) +} + +/// ------------------------------------------------------------------------ /// +/// Controller modules - used for routing +/// ------------------------------------------------------------------------ /// + +const authenticationController = require('./controllers/authentication') +const contentController = require('./controllers/content') +const errorController = require('./controllers/error') +const { getDefaultAnswerProfile, getIndexRedirect } = require('./lib/page-index') +const questionController = require('./controllers/question') + +router.use((req, res, next) => { + res.locals.prototype = settings.locals + next() +}) + +/// ------------------------------------------------------------------------ /// +/// Start page +/// ------------------------------------------------------------------------ /// + +router.get(prototypePath, (req, res) => { + res.redirect(`${prototypePath}/start-page`) +}) + +router.get(`${prototypePath}/start-page`, (req, res) => { + res.render(view('start'), { + actions: { + start: `${prototypePath}/sign-in` + } + }) +}) + +/// ------------------------------------------------------------------------ /// +/// Sign-in pages +/// ------------------------------------------------------------------------ /// + +router.get(`/prototype_${version}/sign-in`, authenticationController.signIn_get) +router.post(`/prototype_${version}/sign-in`, authenticationController.signIn_post) + +router.get(`/prototype_${version}/security-code`, authenticationController.securityCode_get) +router.post(`/prototype_${version}/security-code`, authenticationController.securityCode_post) + +router.get(`/prototype_${version}/sign-in-agreement`, authenticationController.signInAgreement_get) +router.post(`/prototype_${version}/sign-in-agreement`, authenticationController.signInAgreement_post) + +router.get(`/prototype_${version}/sign-in-agreement-declined`, authenticationController.signInAgreementDeclined_get) + +/// ------------------------------------------------------------------------ /// +/// Terms and conditions page +/// ------------------------------------------------------------------------ /// + +router.get(`/prototype_${version}/accept-terms`, questionController.acceptTerms_get) +router.post(`/prototype_${version}/accept-terms`, questionController.acceptTerms_post) + +/// ------------------------------------------------------------------------ /// +/// Question pages +/// ------------------------------------------------------------------------ /// + +router.get(`/prototype_${version}/phone-questionnaire`, questionController.phoneQuestionnaire_get) +router.post(`/prototype_${version}/phone-questionnaire`, questionController.phoneQuestionnaire_post) + +router.get(`/prototype_${version}/phone-questionnaire-exit`, questionController.phoneQuestionnaireExit_get) + +/// Eligibility ------------------------------------------------------------ /// + +router.get(`/prototype_${version}/smoker`, questionController.smoker_get) +router.post(`/prototype_${version}/smoker`, questionController.smoker_post) + +router.get(`/prototype_${version}/date-of-birth`, questionController.dateOfBirth_get) +router.post(`/prototype_${version}/date-of-birth`, questionController.dateOfBirth_post) + +router.get(`/prototype_${version}/face-to-face-appointment`, questionController.faceToFaceAppointment_get) +router.post(`/prototype_${version}/face-to-face-appointment`, questionController.faceToFaceAppointment_post) + +router.get(`/prototype_${version}/not-eligible-for-screening`, questionController.notEligibleForScreening_get) + +router.get(`/prototype_${version}/not-eligible-for-scan`, questionController.notEligibleForScan_get) + +router.get(`/prototype_${version}/book-appointment`, questionController.bookAppointment_get) + +/// About you -------------------------------------------------------------- /// + +router.get(`/prototype_${version}/height-metric`, questionController.heightMetric_get) +router.post(`/prototype_${version}/height-metric`, questionController.heightMetric_post) + +router.get(`/prototype_${version}/height-imperial`, questionController.heightImperial_get) +router.post(`/prototype_${version}/height-imperial`, questionController.heightImperial_post) + +router.get(`/prototype_${version}/weight-metric`, questionController.weightMetric_get) +router.post(`/prototype_${version}/weight-metric`, questionController.weightMetric_post) + +router.get(`/prototype_${version}/weight-imperial`, questionController.weightImperial_get) +router.post(`/prototype_${version}/weight-imperial`, questionController.weightImperial_post) + +router.get(`/prototype_${version}/about-you`, questionController.aboutYou_get) +router.post(`/prototype_${version}/about-you`, questionController.aboutYou_post) + +// router.get(`/prototype_${version}/sex`, questionController.sex_get) +// router.post(`/prototype_${version}/sex`, questionController.sex_post) + +// router.get(`/prototype_${version}/gender`, questionController.gender_get) +// router.post(`/prototype_${version}/gender`, questionController.gender_post) + +// router.get(`/prototype_${version}/ethnicity`, questionController.ethnicity_get) +// router.post(`/prototype_${version}/ethnicity`, questionController.ethnicity_post) + +// router.get(`/prototype_${version}/education`, questionController.education_get) +// router.post(`/prototype_${version}/education`, questionController.education_post) + +/// Your health ------------------------------------------------------------ /// + +router.get(`/prototype_${version}/respiratory-conditions`, questionController.respiratoryConditions_get) +router.post(`/prototype_${version}/respiratory-conditions`, questionController.respiratoryConditions_post) + +router.get(`/prototype_${version}/asbestos`, questionController.asbestos_get) +router.post(`/prototype_${version}/asbestos`, questionController.asbestos_post) + +router.get(`/prototype_${version}/cancer-diagnosis`, questionController.cancerDiagnosis_get) +router.post(`/prototype_${version}/cancer-diagnosis`, questionController.cancerDiagnosis_post) + +/// Family history --------------------------------------------------------- /// + +router.get(`/prototype_${version}/cancer-diagnosis-relatives`, questionController.cancerDiagnosisRelatives_get) +router.post(`/prototype_${version}/cancer-diagnosis-relatives`, questionController.cancerDiagnosisRelatives_post) + +router.get(`/prototype_${version}/cancer-diagnosis-relatives-age`, questionController.cancerDiagnosisRelativesAge_get) +router.post(`/prototype_${version}/cancer-diagnosis-relatives-age`, questionController.cancerDiagnosisRelativesAge_post) + +/// Smoking habits --------------------------------------------------------- /// + +router.get(`/prototype_${version}/smoking-duration`, questionController.smokingDuration_get) +router.post(`/prototype_${version}/smoking-duration`, questionController.smokingDuration_post) + +/// Tobacco --------------------------------------------------------------- /// + +router.get(`/prototype_${version}/smoking-type`, questionController.smokingType_get) +router.post(`/prototype_${version}/smoking-type`, questionController.smokingType_post) + +router.get(`/prototype_${version}/tobacco-smoking`, questionController.tobaccoSmoking_get) +router.post(`/prototype_${version}/tobacco-smoking`, questionController.tobaccoSmoking_post) + +router.get(`/prototype_${version}/smoking-type-exit`, questionController.smokingTypeExit_get) + +router.get(`/prototype_${version}/smoking-status`, questionController.smokingStatus_get) +router.post(`/prototype_${version}/smoking-status`, questionController.smokingStatus_post) + +router.get(`/prototype_${version}/smoking-change`, questionController.smokingChange_get) +router.post(`/prototype_${version}/smoking-change`, questionController.smokingChange_post) + +router.get(`/prototype_${version}/tobacco-smoking-change`, questionController.tobaccoSmokingChange_get) +router.post(`/prototype_${version}/tobacco-smoking-change`, questionController.tobaccoSmokingChange_post) + +/// Check your answers ----------------------------------------------------- /// + +router.get(`/prototype_${version}/check-your-answers`, questionController.checkYourAnswers_get) +router.post(`/prototype_${version}/check-your-answers`, questionController.checkYourAnswers_post) + +/// Confirmation ----------------------------------------------------------- /// + +router.get(`/prototype_${version}/confirmation`, questionController.confirmation_get) + +/// ------------------------------------------------------------------------ /// +/// Static pages +/// ------------------------------------------------------------------------ /// + +router.get(`/prototype_${version}/accessibility-statement`, contentController.accessibility) + +router.get(`/prototype_${version}/contact-us`, contentController.contact) + +router.get(`/prototype_${version}/cookies`, contentController.cookies) + +router.get(`/prototype_${version}/privacy-policy`, contentController.privacy) + +router.get(`/prototype_${version}/terms-of-use`, contentController.terms) + +/// ------------------------------------------------------------------------ /// +/// Error pages +/// ------------------------------------------------------------------------ /// + +router.get(`/prototype_${version}/404`, errorController.pageNotFound) +router.get(`/prototype_${version}/page-not-found`, errorController.pageNotFound) + +router.get(`/prototype_${version}/500`, errorController.unexpectedError) +router.get(`/prototype_${version}/server-error`, errorController.unexpectedError) + +router.get(`/prototype_${version}/503`, errorController.serviceUnavailable) +router.get(`/prototype_${version}/service-unavailable`, errorController.serviceUnavailable) + +/// ------------------------------------------------------------------------ /// +/// Page index +/// ------------------------------------------------------------------------ /// + +router.get(`/prototype_${version}/set-default-answers`, (req, res) => { + req.session.data = req.session.data || {} + req.session.data.answers = getDefaultAnswerProfile(req.query.profile) + + res.redirect(getIndexRedirect(req.query.returnUrl, prototypePath)) +}) + +router.get(`/prototype_${version}/page-index`, (req, res) => { + res.render(view('index'), { + actions: { + setDefaultAnswers: `/prototype_${version}/set-default-answers` + } + }) +}) + +router.get(`/prototype_${version}/index`, (req, res) => { + res.redirect(`/prototype_${version}/page-index`) +}) + +router.get(`/prototype_${version}/index-allpages`, (req, res) => { + res.redirect(`/prototype_${version}/page-index`) +}) + +/// ------------------------------------------------------------------------ /// +/// Add your routes above +/// ------------------------------------------------------------------------ /// + +router.get(new RegExp(`^\\/prototype_${version}\\/(.+)$`), (req, res, next) => { + const template = req.params[0] + + if (!hasView(template)) { + return errorController.pageNotFound(req, res) + } + + res.render(view(template)) +}) + +module.exports = router diff --git a/app/prototype_v4_2/views/authentication/security-code.html b/app/prototype_v4_2/views/authentication/security-code.html new file mode 100644 index 0000000..bc6062e --- /dev/null +++ b/app/prototype_v4_2/views/authentication/security-code.html @@ -0,0 +1,92 @@ +{% extends prototype.viewPath + "/layouts/main.html" %} + +{% set title = "Enter the security code" %} + +{% block content %} + {% include prototype.viewPath + "/includes/error-summary.html" %} + +
+
+ + {% include prototype.viewPath + "/includes/page-heading.html" %} + +

+ We have sent a 6-digit security code to your phone number ending in 0887. +

+ +

+ It may take a few minutes to arrive. +

+ + {% set securityCodeHtml %} +

Make sure your device:

+
    +
  • is not in flight mode and has a strong signal
  • +
  • can receive text messages from other senders
  • +
+

Wait 30 seconds before you send the security code again.

+ {% endset %} + + {{ details({ + summaryText: "Not received a security code?", + html: securityCodeHtml + }) }} + +
+ + {{ input({ + id: "security-code", + name: "authentication[securityCode]", + label: { + text: "Security code", + isPageHeading: false, + classes: "nhsuk-label--s" + }, + hint: { + text: "The code is 6 digits" + }, + inputmode: "numeric", + value: data.authentication.securityCode, + classes: "nhsuk-input--width-10" + }) }} + + {{ checkboxes({ + name: "authentication[rememberDevice]", + values: data.authentication.rememberDevice, + items: [ + { + value: "yes", + text: "Remember this device and stop sending security codes" + } + ] + }) }} + + {{ button({ + text: "Continue" + }) }} + +
+ + {% set rememberDeviceHtml %} +

We can remember the device you are using now, so you will not need to enter a security code when you log in with this device in the future.

+

To keep your NHS login secure, you should only do this on your own personal or trusted devices.

+

We may ask if you still want us to remember this device in the future.

+ {% endset %} + + {{ details({ + summaryText: "What does remember this device mean?", + html: rememberDeviceHtml + }) }} + + {% set mobileDeviceHtml %} +

If you no longer have access to it, you can change the mobile phone number you use to log in.

+ {% endset %} + + {{ details({ + summaryText: "I cannot log in using my mobile phone", + html: mobileDeviceHtml + }) }} + +
+
+{% endblock %} diff --git a/app/prototype_v4_2/views/authentication/sign-in-agreement-declined.html b/app/prototype_v4_2/views/authentication/sign-in-agreement-declined.html new file mode 100644 index 0000000..2ab24b7 --- /dev/null +++ b/app/prototype_v4_2/views/authentication/sign-in-agreement-declined.html @@ -0,0 +1,32 @@ +{% extends prototype.viewPath + "/layouts/main.html" %} + +{% set title = "Sorry, you cannot test the online service" %} + +{% set bodyTextMarkdown %} +To test the online service, we need to confirm who you are. To do this, you need to agree to let NHS login share your information with us. + +If you do not agree to share your NHS login information, you should complete your NHS lung cancer screening by phone. + +Call us on {{ serviceTelephone | telephoneLink }} + +**Phone lines are open:** +Monday to Friday 8am to 8pm +Saturdays 8am to 1pm +{% endset %} + +{% block content %} +
+
+ + {% include prototype.viewPath + "/includes/page-heading.html" %} + + {{ bodyTextMarkdown | markdownToHtml | safe }} + + {{ button({ + text: "Back to start page", + href: actions.back + }) }} + +
+
+{% endblock %} diff --git a/app/prototype_v4_2/views/authentication/sign-in-agreement.html b/app/prototype_v4_2/views/authentication/sign-in-agreement.html new file mode 100644 index 0000000..ce92baa --- /dev/null +++ b/app/prototype_v4_2/views/authentication/sign-in-agreement.html @@ -0,0 +1,42 @@ +{% extends prototype.viewPath + "/layouts/main.html" %} + +{% set title = "Agree to share your NHS login information" %} + +{% set bodyTextMarkdown %} +To continue, you need to agree to share your NHS login information with **NHS {{ serviceName | lower }}**. + +**NHS {{ serviceName | lower }}** will use your: + +- first names +- last name +- date of birth +- email address +- NHS number + +Read the terms of use and privacy policy for **NHS {{ serviceName | lower }}** to check how your information will be used. + +If you do not agree to share this information you will not be able to use NHS login with **NHS {{ serviceName | lower }}**. +{% endset %} + +{% block content %} +
+
+ + {% include prototype.viewPath + "/includes/page-heading.html" %} + +
+ + {{ bodyTextMarkdown | markdownToHtml | safe }} + + {{ button({ + text: "I agree" + }) }} + +

+ I do not agree to share this information +

+
+ +
+
+{% endblock %} diff --git a/app/prototype_v4_2/views/authentication/sign-in.html b/app/prototype_v4_2/views/authentication/sign-in.html new file mode 100644 index 0000000..dfc5d48 --- /dev/null +++ b/app/prototype_v4_2/views/authentication/sign-in.html @@ -0,0 +1,96 @@ +{% extends prototype.viewPath + "/layouts/main.html" %} + +{% set title = "Log in to your NHS account" %} + +{% block content %} + {% include prototype.viewPath + "/includes/error-summary.html" %} + +
+
+ + {% include prototype.viewPath + "/includes/page-heading.html" %} + +
+ + {{ input({ + id: "email", + name: "authentication[email]", + label: { + text: "Email address", + isPageHeading: false, + classes: "nhsuk-label--s" + }, + type: "email", + autocomplete: "email", + spellcheck: false, + value: data.authentication.email, + classes: "nhsuk-input--width-20" + }) }} + + {{ input({ + id: "password", + name: "authentication[password]", + label: { + text: "Password", + isPageHeading: false, + classes: "nhsuk-label--s" + }, + type: "password", + autocomplete: "current-password", + value: data.authentication.password, + classes: "nhsuk-input--width-20" + }) }} + +

+ + Forgotten your password? + +

+ + {{ button({ + text: "Continue" + }) }} + +

+ + Log in with a passkey + +

+ +
+ +

+ or +

+ + {{ button({ + text: "Create NHS account", + href: "#", + classes: "nhsuk-button--secondary" + }) }} + +

+ An NHS account allows you to access a range of online health services with one set of login details. +

+ +

+ Learn more about NHS account. +

+ + {% set cardDescriptionHtml %} + + {% endset %} + + {{ card({ + heading: "Trouble with logging in?", + headingSize: "m", + descriptionHtml: cardDescriptionHtml + }) }} + +
+
+{% endblock %} diff --git a/app/prototype_v4_2/views/check-your-answers.html b/app/prototype_v4_2/views/check-your-answers.html new file mode 100644 index 0000000..f541116 --- /dev/null +++ b/app/prototype_v4_2/views/check-your-answers.html @@ -0,0 +1,88 @@ +{% extends prototype.viewPath + "/layouts/main.html" %} + +{% set title = "Check your answers" %} + +{% block beforeContent %} +{{ backLink({ + text: "Back", + href: actions.back +}) }} +{% endblock %} + +{% block content %} + {% include prototype.viewPath + "/includes/error-summary.html" %} + +
+
+ + {% include prototype.viewPath + "/includes/page-heading.html" %} + +

+ Check the information you have given us. If any of the details are wrong, you can change them. +

+ +
+ + {% if checkYourAnswers.eligibility.length %} +

Eligibility

+ + {{ summaryList({ + rows: checkYourAnswers.eligibility + }) }} + {% endif %} + + {% if checkYourAnswers.aboutYou.length %} +

About you

+ + {{ summaryList({ + rows: checkYourAnswers.aboutYou + }) }} + {% endif %} + + {% if checkYourAnswers.health.length %} +

Your health

+ + {{ summaryList({ + rows: checkYourAnswers.health + }) }} + {% endif %} + + {% if checkYourAnswers.familyHistory.length %} +

Your family history

+ + {{ summaryList({ + rows: checkYourAnswers.familyHistory + }) }} + {% endif %} + + {% if checkYourAnswers.smokingHabits.length or checkYourAnswers.tobaccoRows.length %} +

Your smoking habits

+ + {% if checkYourAnswers.smokingHabits.length %} + {{ summaryList({ + rows: checkYourAnswers.smokingHabits + }) }} + {% endif %} + + {% for tobacco in checkYourAnswers.tobaccoRows %} +

{{ tobacco.heading }}

+ + {{ summaryList({ + rows: tobacco.rows + }) }} + {% endfor %} + {% endif %} + + {{ button({ + text: "Submit" + }) }} + +
+ +

+ Cancel +

+ +
+
+{% endblock %} diff --git a/app/prototype_v4_2/views/confirmation.html b/app/prototype_v4_2/views/confirmation.html new file mode 100644 index 0000000..8daeff5 --- /dev/null +++ b/app/prototype_v4_2/views/confirmation.html @@ -0,0 +1,89 @@ +{% extends prototype.viewPath + "/layouts/main.html" %} + +{% set title = "Thank you for testing the online service" %} + +{% set whatHappensNextMarkdown %} +## What happens next + +You will need to complete your NHS lung cancer screening by phone. + +Call us on {{ serviceTelephone | telephoneLink }} + +An advisor will ask you similar questions about your medical history and lifestyle. They will not have access to the answers you have submitted online. This is because we are testing the online service. + +**Phone lines are open:** +Monday to Friday 8am to 8pm +Saturdays 8am to 1pm + +If we do not hear from you in 14 days we will call you to complete the questionnaire by phone. If you are unable to answer the phone we will call you back. + +As a thank you for testing the online service we will offer you a £10 voucher after you have completed your phone appointment. +{% endset %} + +{% block content %} +
+
+ + {% if query.risk and query.risk == "high" %} + +{% set highRiskContentMarkdown %} +{{ insetText({ + text: "Based on the information you have provided we recommend that you call to book an appointment to see a healthcare professional" +}) }} + +## About the appointment + +The appointment will be in person and offered within 2 weeks of your call. A healthcare professional will review your responses and answer any questions you may have. + +Your appointment will take place at your local hospital or a mobile unit in a convenient location such as a supermarket car park. + +## You may be offered a CT scan + +If the healthcare professional recommends a CT scan, a machine will take detailed pictures of the inside of your chest. It is non-invasive and pain free. The scan is used to detect any abnormalities in your lungs. + +For every 100 people who are scanned 2 people are diagnosed with lung cancer. The aim is to find lung cancer early. Early diagnosis can make lung cancer more treatable and make treatment more successful. + +## Book a CT scan + +Call {{ serviceTelephone | telephoneLink }} to book an appointment. +{% endset %} + + {{ card({ + heading: "You may benefit from further tests", + headingSize: "l", + descriptionHtml: highRiskContentMarkdown | markdownToHtml | safe + }) }} + + {% else %} + + {% set title = "Thank you for testing the online service" %} + + {{ panel({ + titleText: title + }) }} + + {{ whatHappensNextMarkdown | markdownToHtml | safe }} + + {% endif %} + + {% include prototype.viewPath + "/includes/speak-to-a-gp.html" %} + + {% include prototype.viewPath + "/includes/find-your-local-stop-smoking-service.html" %} + + {% include prototype.viewPath + "/includes/benefits-of-stopping-smoking.html" %} + + {% include prototype.viewPath + "/includes/help-stop-smoking.html" %} + + {% if query.risk and query.risk == "high" %} + {% include prototype.viewPath + "/includes/vaping-to-quit-smoking.html" %} + {% endif %} + + {% include prototype.viewPath + "/includes/smoking-anxiety-and-mood.html" %} + + {% include prototype.viewPath + "/includes/help-and-support.html" %} + + {% include prototype.viewPath + "/includes/give-feedback.html" %} + +
+
+{% endblock %} diff --git a/app/prototype_v4_2/views/content/show.html b/app/prototype_v4_2/views/content/show.html new file mode 100644 index 0000000..7adb370 --- /dev/null +++ b/app/prototype_v4_2/views/content/show.html @@ -0,0 +1,29 @@ +{% extends prototype.viewPath + "/layouts/main.html" %} + +{% set title = contentData.title %} + +{% block content %} + +
+
+ +

{{ title }}

+ + {% if contentData.contents.items and contentData.contents.items.length %} +

Contents

+ +
    + {% for item in contentData.contents.items %} +
  • {{ item.text }}
  • + {% endfor %} +
+ {% endif %} + +
+ {{ content | markdownToHtml | safe }} +
+ +
+
+ +{% endblock %} diff --git a/app/prototype_v4_2/views/errors/404.html b/app/prototype_v4_2/views/errors/404.html new file mode 100644 index 0000000..749dffa --- /dev/null +++ b/app/prototype_v4_2/views/errors/404.html @@ -0,0 +1,14 @@ +{% extends prototype.viewPath + "/layouts/main.html" %} + +{% set title = "Page not found" %} + +{% block content %} +
+
+

{{ title }}

+

If you typed a web address, check it is correct.

+

If you pasted the web address, check you copied the entire address.

+

If you have any questions, email {{ serviceEmail }}.

+
+
+{% endblock %} diff --git a/app/prototype_v4_2/views/errors/500.html b/app/prototype_v4_2/views/errors/500.html new file mode 100644 index 0000000..324cc38 --- /dev/null +++ b/app/prototype_v4_2/views/errors/500.html @@ -0,0 +1,17 @@ +{% extends prototype.viewPath + "/layouts/main.html" %} + +{% set hidePhaseBanner = true %} +{% set hideFooterLinks = true %} + +{% set title = "Sorry, there’s a problem with the service" %} + +{% block content %} +
+
+

{{ title }}

+

Try again later.

+

If you reached this page after submitting information then it has not been saved. You will need to enter it again when the service is available.

+

If you have any questions, email {{ serviceEmail }}.

+
+
+{% endblock %} diff --git a/app/prototype_v4_2/views/errors/503.html b/app/prototype_v4_2/views/errors/503.html new file mode 100644 index 0000000..6f8b22d --- /dev/null +++ b/app/prototype_v4_2/views/errors/503.html @@ -0,0 +1,17 @@ +{% extends prototype.viewPath + "/layouts/main.html" %} + +{% set hidePhaseBanner = true %} +{% set hideFooterLinks = true %} + +{% set title = "Sorry, the service is unavailable" %} + +{% block content %} +
+
+

{{ title }}

+

You will be able to use the service from 3pm on Tuesday, 5 May 2026.

+

If you reached this page after submitting information then it has not been saved. You will need to enter it again when the service is available.

+

If you have any questions, email {{ serviceEmail }}.

+
+
+{% endblock %} diff --git a/app/prototype_v4_2/views/includes/benefits-of-stopping-smoking.html b/app/prototype_v4_2/views/includes/benefits-of-stopping-smoking.html new file mode 100644 index 0000000..f835847 --- /dev/null +++ b/app/prototype_v4_2/views/includes/benefits-of-stopping-smoking.html @@ -0,0 +1,32 @@ +

+ Benefits of stopping smoking +

+ +

+ When you stop smoking, you lower your risk of cancer, protect the health of those around you, and enjoy a better quality of life. +

+ +{{ list({ + type: "tick", + title: "When you quit, you:", + items: [ + { + text: "lower your risk of cancer, lung disease, heart disease, stroke and dementia" + }, + { + text: "protect your loved ones and pets from secondhand smoke" + }, + { + text: "feel better mentally in as little as 6 weeks" + }, + { + text: "save money - around £38 a week for the average smoker" + }, + { + text: "increase energy levels" + }, + { + text: "look better and have healthier skin" + } + ] +}) }} diff --git a/app/prototype_v4_2/views/includes/error-summary.html b/app/prototype_v4_2/views/includes/error-summary.html new file mode 100644 index 0000000..14689aa --- /dev/null +++ b/app/prototype_v4_2/views/includes/error-summary.html @@ -0,0 +1,10 @@ +{% if errors | length %} +
+
+ {{ errorSummary({ + titleText: "There is a problem", + errorList: errors + }) if errors | length }} +
+
+{% endif %} diff --git a/app/prototype_v4_2/views/includes/find-your-local-stop-smoking-service.html b/app/prototype_v4_2/views/includes/find-your-local-stop-smoking-service.html new file mode 100644 index 0000000..5ceb4f8 --- /dev/null +++ b/app/prototype_v4_2/views/includes/find-your-local-stop-smoking-service.html @@ -0,0 +1,7 @@ +

+ Find your local Stop Smoking Service +

+ +

+ Stopping smoking is the single biggest change you can make for your health. Find local services to help you stop smoking (opens in new tab). +

diff --git a/app/prototype_v4_2/views/includes/give-feedback.html b/app/prototype_v4_2/views/includes/give-feedback.html new file mode 100644 index 0000000..68bfa1e --- /dev/null +++ b/app/prototype_v4_2/views/includes/give-feedback.html @@ -0,0 +1,13 @@ +

+ Help us improve this service +

+ +

+ Tell us about your experience using this service today. +

+ +{{ button({ + text: "Give feedback", + href: "#", + classes: "nhsuk-button--secondary" +}) }} diff --git a/app/prototype_v4_2/views/includes/help-and-support.html b/app/prototype_v4_2/views/includes/help-and-support.html new file mode 100644 index 0000000..4242e9c --- /dev/null +++ b/app/prototype_v4_2/views/includes/help-and-support.html @@ -0,0 +1,24 @@ +{{ list({ + type: "tick", + title: "For free help and support:", + items: [ + { + html: 'find your nearest local Stop Smoking Service' + }, + { + html: 'get a free personal quit plan' + }, + { + html: 'join the Quit Smoking Group on Facebook' + }, + { + html: 'read NHS guidance on smoking' + }, + { + html: 'call the free National Smokefree Helpline on 0300 123 1044 (England only)' + }, + { + html: 'download the NHS Quit Smoking app from the Apple App Store or Google Play Store' + } + ] +}) }} diff --git a/app/prototype_v4_2/views/includes/help-stop-smoking.html b/app/prototype_v4_2/views/includes/help-stop-smoking.html new file mode 100644 index 0000000..7498ae5 --- /dev/null +++ b/app/prototype_v4_2/views/includes/help-stop-smoking.html @@ -0,0 +1,39 @@ +

+ It’s never too late to stop +

+ +

+ When you stop smoking, your body can repair itself sooner than you might think. It does not matter how old you are, or how long you have smoked. +

+ +{% set insetTextHtml %} +

When you stop smoking, in:

+
    +
  • 2 days, all harmful carbon monoxide leaves your body
  • +
  • 2 to 12 weeks, your circulation improves
  • +
  • 3 to 9 months, your lung function and breathing improves
  • +
  • 1 year, your risk of a heart attack halves compared to a smoker
  • +
+{% endset %} + +{{ insetText({ + html: insetTextHtml, + classes: "nhsuk-u-margin-top-0" +}) }} + +{{ list({ + type: "tick", + title: "To help stop smoking:", + items: [ + { + text: "talk to your GP or pharmacy about stop smoking aids, medication and support services +" + }, + { + text: "set small goals - you're 5 times more likely to quit for good if you stop smoking for 28 days" + }, + { + text: "reflect on any past attempts to quit and try new methods" + } + ] +}) }} diff --git a/app/prototype_v4_2/views/includes/page-heading-legend.html b/app/prototype_v4_2/views/includes/page-heading-legend.html new file mode 100644 index 0000000..c3bf181 --- /dev/null +++ b/app/prototype_v4_2/views/includes/page-heading-legend.html @@ -0,0 +1,6 @@ +{% if caption.length %} + + {{- caption -}} + +{% endif %} +{{ title }} diff --git a/app/prototype_v4_2/views/includes/page-heading.html b/app/prototype_v4_2/views/includes/page-heading.html new file mode 100644 index 0000000..0a57514 --- /dev/null +++ b/app/prototype_v4_2/views/includes/page-heading.html @@ -0,0 +1,8 @@ +

+ {% if caption.length %} + + {{ caption }} + + {% endif %} + {{ title }} +

diff --git a/app/prototype_v4_2/views/includes/smoking-anxiety-and-mood.html b/app/prototype_v4_2/views/includes/smoking-anxiety-and-mood.html new file mode 100644 index 0000000..12d66da --- /dev/null +++ b/app/prototype_v4_2/views/includes/smoking-anxiety-and-mood.html @@ -0,0 +1,32 @@ +{% set expanderHtml %} +

+ Quitting smoking can lift your mood, and ease stress, anxiety and depression. +

+ +

+ Smokers often think that smoking helps them relax. But in reality, nicotine cravings increase anxiety and tension. +

+ +

To manage cravings, try to:

+ +
    +
  • keep busy with hobbies and activities
  • +
  • relax with breathing techniques or meditation
  • +
  • seek support from friends, family, or support groups
  • +
  • use nicotine replacement therapy or medications
  • +
+ +

+ The following links open in a new tab.

Read NHS guidance on smoking and mental health (opens in new tab) +

+ +

+ Get your free Mind Plan (opens in new tab) +

+{% endset %} + +{{ details({ + summaryText: "Smoking, anxiety and mood", + html: expanderHtml, + classes: "nhsuk-expander" +}) }} diff --git a/app/prototype_v4_2/views/includes/speak-to-a-gp.html b/app/prototype_v4_2/views/includes/speak-to-a-gp.html new file mode 100644 index 0000000..2aba529 --- /dev/null +++ b/app/prototype_v4_2/views/includes/speak-to-a-gp.html @@ -0,0 +1,22 @@ +{% set speakToGpMarkdown %} +You have any of the following symptoms: + +- a cough that does not go away after 3 weeks +- a long-standing cough that gets worse +- [chest infections](https://www.nhs.uk/conditions/chest-infection/) that keep coming back +- [coughing up blood](https://www.nhs.uk/conditions/coughing-up-blood/) +- an ache or pain when breathing or coughing +- persistent [breathlessness](https://www.nhs.uk/conditions/shortness-of-breath/) +- persistent tiredness or lack of energy +- loss of appetite or unexplained weight loss + +They could be [symptoms of lung cancer](https://www.nhs.uk/conditions/lung-cancer/symptoms/). Your GP will ask about your general health and your symptoms. + +You may be asked to have a [blood test](https://www.nhs.uk/conditions/blood-tests/) to rule out some of the possible causes of your symptoms, such as a [chest infection](https://www.nhs.uk/conditions/chest-infection/). +{% endset %} + +{{ card({ + type: "non-urgent", + heading: "Speak to a GP if:", + descriptionHtml: speakToGpMarkdown | markdownToHtml | safe +}) }} diff --git a/app/prototype_v4_2/views/includes/vaping-to-quit-smoking.html b/app/prototype_v4_2/views/includes/vaping-to-quit-smoking.html new file mode 100644 index 0000000..676bb93 --- /dev/null +++ b/app/prototype_v4_2/views/includes/vaping-to-quit-smoking.html @@ -0,0 +1,45 @@ +{% set expanderHtml %} +

+ Nearly two-thirds of people who use vapes with Stop Smoking Services quit smoking. +

+ +

+ Nicotine vapes are less harmful than cigarettes. They don't produce tar or carbon monoxide, which cause cancer, lung disease, and heart disease. +

+ +

+ Vaping mimics smoking with hand-to-mouth movement and helps manage nicotine cravings. +

+ +

+ Vaping also costs about a third as much as smoking, once you have the kit. +

+ +

+ Vaping is not risk-free. It is not recommended for non smokers or those under 18 years old. +

+ +

Useful resources

+ +

The following links open in a new tab.

+ +

+ Visit a vape shop or local Stop Smoking Service (opens in new tab) for advice +

+ +

+ Read NHS guidance on vaping to quit smoking +

+ +

Want to quit vaping?

+ +

+ To quit vaping, gradually reduce nicotine strength or usage. Seek advice from a vape shop or local Stop Smoking Service. +

+{% endset %} + +{{ details({ + summaryText: "Vaping to quit smoking", + html: expanderHtml, + classes: "nhsuk-expander" +}) }} diff --git a/app/prototype_v4_2/views/index.html b/app/prototype_v4_2/views/index.html new file mode 100644 index 0000000..fca59fa --- /dev/null +++ b/app/prototype_v4_2/views/index.html @@ -0,0 +1,119 @@ +{% extends prototype.viewPath + "/layouts/main.html" %} + +{% set title = "Page index" %} +{% set prototypePath = prototype.path %} + +{% macro pageLink(href, text) %} +
  • + {{ text }} +
  • +{% endmacro %} + +{% macro seededPageLink(returnUrl, text) %} +
  • + {{ text }} +
  • +{% endmacro %} + +{% macro seededProfilePageLink(returnUrl, profile, text) %} +
  • + {{ text }} +
  • +{% endmacro %} + +{% block content %} +
    +
    +

    {{ title }}

    +

    Quick navigation to all pages in prototype v4.1.

    +

    Some links set default answers before opening the page, so conditional pages and check your answers have enough data to render.

    + +

    Start and sign in

    +
      + {{ pageLink("/start-page", "Help us test a new online service") }} + {{ pageLink("/sign-in", "Log in to your NHS account") }} + {{ pageLink("/security-code", "Enter the security code") }} + {{ pageLink("/sign-in-agreement", "Agree to share NHS login information") }} + {{ pageLink("/sign-in-agreement-declined", "You cannot use this service") }} + {{ pageLink("/accept-terms", "Terms of use") }} + {{ pageLink("/phone-questionnaire", "Confirm if you have completed a lung cancer risk questionnaire by phone") }} + {{ pageLink("/phone-questionnaire-exit", "You do not need to test the online service") }} +
    + +

    Eligibility

    +
      + {{ pageLink("/smoker", "Tobacco smoking") }} + {{ pageLink("/date-of-birth", "What is your date of birth?") }} + {{ pageLink("/face-to-face-appointment", "Check if you need a face-to-face appointment") }} + {{ pageLink("/not-eligible-for-screening", "You are not eligible for lung cancer screening") }} + {{ pageLink("/not-eligible-for-scan", "You are not eligible for an NHS lung scan") }} + {{ pageLink("/book-appointment", "Call us to book an appointment") }} +
    + +

    About you

    +
      + {{ pageLink("/height-metric", "Your height - metric") }} + {{ pageLink("/height-imperial", "Your height - imperial") }} + {{ pageLink("/weight-metric", "Your weight - metric") }} + {{ pageLink("/weight-imperial", "Your weight - imperial") }} + {{ pageLink("/about-you", "About you") }} + {# {{ pageLink("/gender", "Your gender identity") }} + {{ pageLink("/sex", "Your sex at birth") }} + {{ pageLink("/ethnicity", "Your ethnic background") }} + {{ pageLink("/education", "Your education") }} #} +
    + +

    Your health

    +
      + {{ pageLink("/respiratory-conditions", "Respiratory conditions") }} + {{ pageLink("/asbestos", "Asbestos") }} + {{ pageLink("/cancer-diagnosis", "Cancer diagnosis") }} +
    + +

    Your family history

    +
      + {{ pageLink("/cancer-diagnosis-relatives", "Relatives with lung cancer") }} + {{ seededPageLink("/cancer-diagnosis-relatives-age", "Relatives under 60 when diagnosed") }} +
    + +

    Your smoking habits

    +
      + {{ pageLink("/smoking-duration", "Smoking duration") }} + {{ pageLink("/smoking-type", "The type of tobacco you smoke or used to smoke") }} + {{ pageLink("/smoking-type-exit", "You are not eligible for lung cancer screening") }} +
    + +

    Tobacco smoking

    +
      + {{ seededPageLink("/tobacco-smoking", "Tobacco smoking") }} + {{ seededPageLink("/smoking-status?type=cigarettes", "Cigarettes - currently smoke") }} + {{ seededPageLink("/smoking-change?type=cigarettes", "Cigarettes - has quantity changed") }} + {{ seededPageLink("/tobacco-smoking-change?type=cigarettes%26change=greater", "Cigarettes - more smoking change") }} + {{ seededProfilePageLink("/tobacco-smoking?type=shisha", "shisha", "Shisha smoking") }} +
    + +

    Check and confirmation

    +
      + {{ seededPageLink("/check-your-answers", "Check your answers - with default answers") }} + {{ pageLink("/confirmation", "Confirmation") }} + {{ pageLink("/confirmation?risk=high", "Confirmation - High risk") }} +
    + +

    Static pages

    +
      + {{ pageLink("/accessibility-statement", "Accessibility statement") }} + {{ pageLink("/contact-us", "Contact us") }} + {{ pageLink("/cookies", "Cookies") }} + {{ pageLink("/privacy-policy", "Privacy policy") }} + {{ pageLink("/terms-of-use", "Terms of use") }} +
    + +

    Error pages

    +
      + {{ pageLink("/404", "Page not found") }} + {{ pageLink("/500", "Unexpected error") }} + {{ pageLink("/503", "Service unavailable") }} +
    +
    +
    +{% endblock %} diff --git a/app/prototype_v4_2/views/layouts/main.html b/app/prototype_v4_2/views/layouts/main.html new file mode 100755 index 0000000..37d40ff --- /dev/null +++ b/app/prototype_v4_2/views/layouts/main.html @@ -0,0 +1,116 @@ + + +{% extends "prototype-kit-template.njk" %} + +{% block head %} + + +{% endblock %} + + +{% block pageTitle %} +{{ "Error: " if errors | length }} {{- title + " - " if title | length }} {{- serviceName }} - NHS +{% endblock %} + + + +{% block header %} +{{ header({ + logo: { + href: "/", + ariaLabel: "Check if you need a lung scan" + }, + service: { + text: "Check if you need a lung scan", + href: "/" + }, + account: { + items: [ + { + text: "Alex Smith", + href: "#", + icon: true + }, + { + text: "Log out", + href: prototype.path + "/" + } + ] + } if data['logged-in'] +}) }} + + +{% if not hidePhaseBanner %} +
    +
    +
    + {{ tag({ + text: "Pilot", + classes: "nhsuk-tag--blue" + }) }} +

    + We are testing a new service – your feedback will help us to improve it. +

    +
    +
    +
    +{% endif %} +{% endblock %} + + + +{% block footer %} +{{ footer({ + meta: { + items: [ + { + href: prototype.path + "/accessibility-statement", + text: "Accessibility statement" + }, + { + href: prototype.path + "/contact-us", + text: "Contact us" + }, + { + href: prototype.path + "/cookies", + text: "Cookies" + }, + { + href: prototype.path + "/privacy-policy", + text: "Privacy policy" + }, + { + href: prototype.path + "/terms-of-use", + text: "Terms of use" + } + ] + } +}) if not hideFooterLinks }} + + +{% endblock %} + +{% block bodyEnd %} + {{ super() }} + + {% block pageScripts %}{% endblock %} +{% endblock %} diff --git a/app/prototype_v4_2/views/questions/_question-input.html b/app/prototype_v4_2/views/questions/_question-input.html new file mode 100644 index 0000000..f12d569 --- /dev/null +++ b/app/prototype_v4_2/views/questions/_question-input.html @@ -0,0 +1,204 @@ +{% set isGroupedPage = isGroupedPage | default(false) %} + +{% if question.input.isPageHeading and not isGroupedPage %} + {% set headingHtml %} + {% include prototype.viewPath + "/includes/page-heading-legend.html" %} + {% endset %} + {% set legend = { + html: headingHtml, + isPageHeading: true, + classes: "nhsuk-fieldset__legend--l" + } %} +{% elif question.input.label %} + {% set legend = { + text: question.input.label, + isPageHeading: false, + classes: "nhsuk-fieldset__legend--m" + } %} +{% else %} + {% set legend = { + text: question.heading.title, + isPageHeading: false, + classes: "nhsuk-fieldset__legend--m" + } %} +{% endif %} + +{% set componentItems = [] %} +{% for item in question.items %} + {% if item.conditionalInput %} + {% set conditionalHtml %} + {{ input({ + id: item.conditionalInput.id, + name: item.conditionalInput.name, + label: { + text: item.conditionalInput.label, + isPageHeading: false, + classes: "nhsuk-label--s" + }, + hint: { + text: item.conditionalInput.hint + } if item.conditionalInput.hint, + suffix: item.conditionalInput.suffix, + prefix: item.conditionalInput.prefix, + inputmode: item.conditionalInput.inputmode, + value: item.conditionalInput.value if item.conditionalInput.value is defined else data.answers[item.conditionalInput.answerKey], + classes: item.conditionalInput.classes, + errorMessage: { + text: errorMap[item.conditionalInput.id].text + } if errorMap[item.conditionalInput.id] + }) }} + {% endset %} + {% set _ = componentItems.push({ + text: item.text, + hint: item.hint, + value: item.value, + exclusive: item.exclusive, + exclusiveGroup: item.exclusiveGroup, + conditional: { + html: conditionalHtml + } + }) %} + {% else %} + {% set _ = componentItems.push(item) %} + {% endif %} +{% endfor %} + +{% if question.type == "single" %} + {{ radios({ + name: question.input.name, + idPrefix: question.input.id, + fieldset: { + legend: legend + }, + hint: question.input.hintParam, + errorMessage: { + text: errorMap[question.input.id].text + } if errorMap[question.input.id], + value: question.value if question.value is defined else data.answers[question.answerKey], + items: componentItems + }) }} +{% elif question.type == "multiple" %} + {{ checkboxes({ + name: question.input.name, + idPrefix: question.input.id, + fieldset: { + legend: legend + }, + hint: question.input.hintParam, + errorMessage: { + text: errorMap[question.input.id].text + } if errorMap[question.input.id], + values: question.values if question.values is defined else data.answers[question.answerKey], + items: componentItems + }) }} +{% elif question.type == "text" %} + {% if question.input.isPageHeading and not isGroupedPage %} + {% set label = { + html: headingHtml, + isPageHeading: true, + classes: "nhsuk-label--l" + } %} + {% elif question.input.label %} + {% set label = { + text: question.input.label, + isPageHeading: false, + classes: "nhsuk-label--m" + } %} + {% else %} + {% set label = { + text: question.heading.title, + isPageHeading: false, + classes: "nhsuk-label--m" + } %} + {% endif %} + + {% if question.value is defined %} + {% set inputValue = question.value %} + {% elif question.input.valueKey %} + {% set inputValue = data.answers[question.answerKey][question.input.valueKey] %} + {% else %} + {% set inputValue = data.answers[question.answerKey] %} + {% endif %} + + {{ input({ + id: question.input.id, + name: question.input.name, + label: label, + hint: question.input.hintParam, + prefix: question.input.prefix, + suffix: question.input.suffix, + inputmode: question.input.inputmode, + value: inputValue, + classes: question.input.classes, + errorMessage: { + text: errorMap[question.input.id].text + } if errorMap[question.input.id] + }) }} +{% elif question.type == "date" %} + {% set dateError = errorMap[question.input.items[0].id] or errorMap[question.input.id] %} + {{ dateInput({ + id: question.answerKey, + fieldset: { + legend: legend + }, + hint: question.input.hintParam, + errorMessage: { + text: dateError.text + } if dateError, + items: [ + { + id: question.input.items[0].id, + name: question.input.items[0].name, + label: question.input.items[0].label, + value: data.answers[question.answerKey][question.input.items[0].answerKey], + classes: "nhsuk-input--width-2" + }, + { + id: question.input.items[1].id, + name: question.input.items[1].name, + label: question.input.items[1].label, + value: data.answers[question.answerKey][question.input.items[1].answerKey], + classes: "nhsuk-input--width-2" + }, + { + id: question.input.items[2].id, + name: question.input.items[2].name, + label: question.input.items[2].label, + value: data.answers[question.answerKey][question.input.items[2].answerKey], + classes: "nhsuk-input--width-4" + } + ] + }) }} +{% elif question.type == "text_group" %} + {% call fieldset({ + legend: legend + }) %} +
    + {% for item in question.input.items %} +
    + {{ input({ + id: item.id, + name: item.name, + label: { + text: item.label + }, + suffix: item.suffix, + prefix: item.prefix, + inputmode: item.inputmode, + value: data.answers[question.answerKey][question.input.valueKey][item.answerKey], + classes: item.classes, + errorMessage: { + text: errorMap[item.id].text + } if errorMap[item.id] + }) }} +
    + {% endfor %} +
    + {% endcall %} +{% endif %} + +{% if question.switchUnits %} +

    + {{ question.switchUnits.text }} +

    +{% endif %} diff --git a/app/prototype_v4_2/views/questions/_question-page.html b/app/prototype_v4_2/views/questions/_question-page.html new file mode 100644 index 0000000..4412e53 --- /dev/null +++ b/app/prototype_v4_2/views/questions/_question-page.html @@ -0,0 +1,44 @@ +{% extends prototype.viewPath + "/layouts/main.html" %} + +{% set title = page.heading.title %} +{% set caption = page.heading.caption %} + +{% block beforeContent %} +{{ backLink({ + text: "Back", + href: actions.back +}) }} +{% endblock %} + +{% block content %} + {% include prototype.viewPath + "/includes/error-summary.html" %} + +
    +
    + + {% include prototype.viewPath + "/includes/page-heading.html" %} + + {% if page.description %} + {{ page.description | markdownToHtml | safe }} + {% endif %} + +
    + + {% set isGroupedPage = true %} + {% for question in page.questions %} + {% include prototype.viewPath + "/questions/_question-input.html" %} + {% endfor %} + + {{ button({ + text: "Continue" + }) }} + +
    + +

    + Cancel +

    + +
    +
    +{% endblock %} diff --git a/app/prototype_v4_2/views/questions/_question.html b/app/prototype_v4_2/views/questions/_question.html new file mode 100644 index 0000000..fa4fc85 --- /dev/null +++ b/app/prototype_v4_2/views/questions/_question.html @@ -0,0 +1,50 @@ +{% extends prototype.viewPath + "/layouts/main.html" %} + +{% set title = question.heading.title %} +{% set caption = question.heading.caption %} + +{% block beforeContent %} +{{ backLink({ + text: "Back", + href: actions.back +}) }} +{% endblock %} + +{% block content %} + {% include prototype.viewPath + "/includes/error-summary.html" %} + +
    +
    + + {% if not question.input.isPageHeading %} + {% include prototype.viewPath + "/includes/page-heading.html" %} + {% endif %} + + {% if question.description %} + {{ question.description | markdownToHtml | safe }} + {% endif %} + + {% if question.details %} + {{ details({ + summaryText: question.details.summary, + html: question.details.text | markdownToHtml | safe + }) }} + {% endif %} + +
    + + {% include prototype.viewPath + "/questions/_question-input.html" %} + + {{ button({ + text: "Continue" + }) }} + +
    + +

    + Cancel +

    + +
    +
    +{% endblock %} diff --git a/app/prototype_v4_2/views/questions/book-appointment.html b/app/prototype_v4_2/views/questions/book-appointment.html new file mode 100644 index 0000000..3ee553c --- /dev/null +++ b/app/prototype_v4_2/views/questions/book-appointment.html @@ -0,0 +1,34 @@ +{% extends prototype.viewPath + "/layouts/main.html" %} + +{% set title = "Call us to book an appointment" %} + +{% set bodyTextMarkdown %} +Call us on {{ serviceTelephone | telephoneLink }} to book an appointment. If we do not hear from you in 2 weeks we will call you. + +At your face to face appointment a health professional will measure your height and weight. They will also ask you questions to check if you need a lung scan. + +**Phone lines are open:** +Monday to Friday 8am to 8pm +Saturdays 8am to 1pm +{% endset %} + +{% block beforeContent %} +{{ backLink({ + text: "Back", + href: actions.back +}) }} +{% endblock %} + +{% block content %} +
    +
    + + {% include prototype.viewPath + "/includes/page-heading.html" %} + + {{ bodyTextMarkdown | markdownToHtml | safe }} + + {% include prototype.viewPath + "/includes/speak-to-a-gp.html" %} + +
    +
    +{% endblock %} diff --git a/app/prototype_v4_2/views/questions/not-eligible-for-scan.html b/app/prototype_v4_2/views/questions/not-eligible-for-scan.html new file mode 100644 index 0000000..699fb2a --- /dev/null +++ b/app/prototype_v4_2/views/questions/not-eligible-for-scan.html @@ -0,0 +1,30 @@ +{% extends prototype.viewPath + "/layouts/main.html" %} + +{% set title = "You are not eligible for an NHS lung scan" %} + +{% set bodyTextMarkdown %} +The NHS lung scan is for people between the ages of 55 and 74. + +According to your date of birth you are not in this age range. +{% endset %} + +{% block beforeContent %} +{{ backLink({ + text: "Back", + href: actions.back +}) }} +{% endblock %} + +{% block content %} +
    +
    + + {% include prototype.viewPath + "/includes/page-heading.html" %} + + {{ bodyTextMarkdown | markdownToHtml | safe }} + + {% include prototype.viewPath + "/includes/speak-to-a-gp.html" %} + +
    +
    +{% endblock %} diff --git a/app/prototype_v4_2/views/questions/not-eligible-for-screening.html b/app/prototype_v4_2/views/questions/not-eligible-for-screening.html new file mode 100644 index 0000000..ad4947b --- /dev/null +++ b/app/prototype_v4_2/views/questions/not-eligible-for-screening.html @@ -0,0 +1,28 @@ +{% extends prototype.viewPath + "/layouts/main.html" %} + +{% set title = "You are not eligible for lung cancer screening" %} + +{% set bodyTextMarkdown %} +You told us you have never smoked or have smoked fewer than 100 cigarettes in your lifetime. This means you are not eligible for NHS lung cancer screening. +{% endset %} + +{% block beforeContent %} +{{ backLink({ + text: "Back", + href: actions.back +}) }} +{% endblock %} + +{% block content %} +
    +
    + + {% include prototype.viewPath + "/includes/page-heading.html" %} + + {{ bodyTextMarkdown | markdownToHtml | safe }} + + {% include prototype.viewPath + "/includes/speak-to-a-gp.html" %} + +
    +
    +{% endblock %} diff --git a/app/prototype_v4_2/views/questions/phone-questionnaire-exit.html b/app/prototype_v4_2/views/questions/phone-questionnaire-exit.html new file mode 100644 index 0000000..5c3a77a --- /dev/null +++ b/app/prototype_v4_2/views/questions/phone-questionnaire-exit.html @@ -0,0 +1,32 @@ +{% extends prototype.viewPath + "/layouts/main.html" %} + +{% set title = "You do not need to test the online service" %} + +{% set bodyTextMarkdown %} +Because you have completed the questionnaire by phone you do not need to test the online service. This is because the online service asks the same questions. + +Find out more about NHS lung cancer screening in [North West and South West London](https://www.healthylondon.org/our-work/lung-health-check/). +{% endset %} + +{% block beforeContent %} +{{ backLink({ + text: "Back", + href: actions.back +}) }} +{% endblock %} + +{% block content %} + {% include prototype.viewPath + "/includes/error-summary.html" %} + +
    +
    + + {% include prototype.viewPath + "/includes/page-heading.html" %} + + {{ bodyTextMarkdown | markdownToHtml | safe }} + + {% include prototype.viewPath + "/includes/speak-to-a-gp.html" %} + +
    +
    +{% endblock %} diff --git a/app/prototype_v4_2/views/questions/smoking-type-exit.html b/app/prototype_v4_2/views/questions/smoking-type-exit.html new file mode 100644 index 0000000..924cbcf --- /dev/null +++ b/app/prototype_v4_2/views/questions/smoking-type-exit.html @@ -0,0 +1,28 @@ +{% extends prototype.viewPath + "/layouts/main.html" %} + +{% set title = "You are not eligible for lung cancer screening" %} + +{% set bodyTextMarkdown %} +You told us you have never smoked the types of tobacco listed. This means you are not eligible for NHS lung cancer screening. +{% endset %} + +{% block beforeContent %} +{{ backLink({ + text: "Back", + href: actions.back +}) }} +{% endblock %} + +{% block content %} +
    +
    + + {% include prototype.viewPath + "/includes/page-heading.html" %} + + {{ bodyTextMarkdown | markdownToHtml | safe }} + + {% include prototype.viewPath + "/includes/speak-to-a-gp.html" %} + +
    +
    +{% endblock %} diff --git a/app/prototype_v4_2/views/start.html b/app/prototype_v4_2/views/start.html new file mode 100644 index 0000000..95b3248 --- /dev/null +++ b/app/prototype_v4_2/views/start.html @@ -0,0 +1,56 @@ +{% extends prototype.viewPath + "/layouts/main.html" %} + +{% set title = "Help us test a new online service" %} + +{% block content %} +
    +
    + + {% include prototype.viewPath + "/includes/page-heading.html" %} + +

    We are testing a new online questionnaire for lung cancer screening. This service will ask you some questions about your medical history and lifestyle.

    + +

    This online service cannot currently recommend if you need a lung scan. Once you have completed the online service you will need to call us to repeat the questionnaire by phone.

    + +

    The answers you submit will not be shared with your patient care advisor during your phone appointment, or with your GP.

    + +

    As a thank you for testing the online service, we will offer you a £10 voucher once you have completed both the online service and your phone appointment.

    + +

    The benefits of lung cancer screening

    + +

    Your risk of lung cancer increases as you get older. Screening aims to find lung cancer early, sometimes before you have symptoms. Early diagnosis can make lung cancer more treatable and make treatment more successful.

    + +

    You are eligible for lung cancer screening if you are:

    + +
      +
    • aged between 55 and 74
    • +
    • registered with a GP surgery
    • +
    • someone who smokes or used to smoke
    • +
    + +

    What to expect in the online service

    + +

    We will ask you questions about:

    + +
      +
    • your height and weight
    • +
    • your ethnicity
    • +
    • your education
    • +
    • if you have ever had a cancer diagnosis
    • +
    • if your parents, siblings, or children have ever had a lung cancer diagnosis
    • +
    • your smoking habits
    • +
    + + {{ button({ + text: "Continue", + href: actions.start, + classes: "nhsuk-button--login" + }) }} + +

    If you do not want to test the online service

    + +

    You should call us on {{ serviceTelephone }} to complete the questionnaire by phone.

    + +
    +
    +{% endblock %} diff --git a/app/routes.js b/app/routes.js index 99fb8ca..b3b6fa3 100644 --- a/app/routes.js +++ b/app/routes.js @@ -8,6 +8,7 @@ router.use(require('./prototype_v2/routes')) router.use(require('./prototype_v3/routes')) router.use(require('./prototype_v4/routes')) router.use(require('./prototype_v4_1/routes')) +router.use(require('./prototype_v4_2/routes')) // Add your routes here - above the module.exports line diff --git a/app/views/index.html b/app/views/index.html index 0af5909..35d3250 100755 --- a/app/views/index.html +++ b/app/views/index.html @@ -27,10 +27,21 @@

    {{ serviceName }} online prototypes

    - -

    Prototype version 4.1 - pilot

    + +

    Prototype version 4.2

    +

    Last updated: 20 May 2026

    +

    Multiple changes have been made in v4.2, including major revisions to the question flow.

    + + Open prototype + + + +
    + + +

    Prototype version 4.1

    Last updated: 14 May 2026

    -

    Multiple changes have been made to v4, including major revisions to the tobacco smoking screens.

    +

    Multiple changes have been made in v4.1, including major revisions to the tobacco smoking screens and validation.

    Open prototype