From 070a3616ab70afacdcc90c8c7af211291e9e857f Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Wed, 20 May 2026 11:41:06 +0100 Subject: [PATCH 01/18] Setup prototype v4.2 --- app/prototype_v4_2/content/accessibility.md | 105 ++ app/prototype_v4_2/content/contact.md | 15 + app/prototype_v4_2/content/cookies.md | 55 + app/prototype_v4_2/content/privacy.md | 129 ++ app/prototype_v4_2/content/terms.md | 113 ++ .../controllers/authentication.js | 88 ++ app/prototype_v4_2/controllers/content.js | 73 ++ app/prototype_v4_2/controllers/error.js | 13 + app/prototype_v4_2/controllers/question.js | 928 ++++++++++++++ app/prototype_v4_2/data/questions.yaml | 1123 ++++++++++++++++ app/prototype_v4_2/data/tobacco.yaml | 158 +++ app/prototype_v4_2/docs/README.md | 42 + app/prototype_v4_2/docs/content-guide.md | 38 + app/prototype_v4_2/docs/developer-guide.md | 51 + app/prototype_v4_2/docs/question-flow.md | 122 ++ app/prototype_v4_2/docs/question-schema.md | 371 ++++++ app/prototype_v4_2/docs/tobacco-schema.md | 89 ++ app/prototype_v4_2/lib/eligibility.js | 63 + app/prototype_v4_2/lib/page-index.js | 94 ++ app/prototype_v4_2/lib/question-renderer.js | 48 + app/prototype_v4_2/lib/question-validator.js | 305 +++++ app/prototype_v4_2/lib/questions.js | 230 ++++ app/prototype_v4_2/lib/settings.js | 24 + app/prototype_v4_2/lib/summary.js | 428 ++++++ app/prototype_v4_2/lib/tobacco-flow.js | 1142 +++++++++++++++++ app/prototype_v4_2/lib/unit-navigation.js | 52 + app/prototype_v4_2/routes.js | 265 ++++ .../views/authentication/security-code.html | 92 ++ .../sign-in-agreement-declined.html | 32 + .../authentication/sign-in-agreement.html | 42 + .../views/authentication/sign-in.html | 96 ++ .../views/check-your-answers.html | 88 ++ app/prototype_v4_2/views/confirmation.html | 89 ++ app/prototype_v4_2/views/content/show.html | 29 + app/prototype_v4_2/views/errors/404.html | 14 + app/prototype_v4_2/views/errors/500.html | 17 + app/prototype_v4_2/views/errors/503.html | 17 + .../benefits-of-stopping-smoking.html | 32 + .../views/includes/error-summary.html | 10 + .../find-your-local-stop-smoking-service.html | 7 + .../views/includes/give-feedback.html | 13 + .../views/includes/help-and-support.html | 24 + .../views/includes/help-stop-smoking.html | 39 + .../views/includes/page-heading-legend.html | 6 + .../views/includes/page-heading.html | 8 + .../includes/smoking-anxiety-and-mood.html | 32 + .../views/includes/speak-to-a-gp.html | 22 + .../includes/vaping-to-quit-smoking.html | 45 + app/prototype_v4_2/views/index.html | 126 ++ app/prototype_v4_2/views/layouts/main.html | 116 ++ .../views/questions/_question.html | 238 ++++ .../views/questions/book-appointment.html | 34 + .../questions/not-eligible-for-scan.html | 30 + .../questions/not-eligible-for-screening.html | 28 + .../questions/phone-questionnaire-exit.html | 32 + .../views/questions/smoking-type-exit.html | 28 + app/prototype_v4_2/views/start.html | 56 + app/routes.js | 1 + app/views/index.html | 17 +- 59 files changed, 7621 insertions(+), 3 deletions(-) create mode 100644 app/prototype_v4_2/content/accessibility.md create mode 100644 app/prototype_v4_2/content/contact.md create mode 100644 app/prototype_v4_2/content/cookies.md create mode 100644 app/prototype_v4_2/content/privacy.md create mode 100644 app/prototype_v4_2/content/terms.md create mode 100644 app/prototype_v4_2/controllers/authentication.js create mode 100644 app/prototype_v4_2/controllers/content.js create mode 100644 app/prototype_v4_2/controllers/error.js create mode 100644 app/prototype_v4_2/controllers/question.js create mode 100644 app/prototype_v4_2/data/questions.yaml create mode 100644 app/prototype_v4_2/data/tobacco.yaml create mode 100644 app/prototype_v4_2/docs/README.md create mode 100644 app/prototype_v4_2/docs/content-guide.md create mode 100644 app/prototype_v4_2/docs/developer-guide.md create mode 100644 app/prototype_v4_2/docs/question-flow.md create mode 100644 app/prototype_v4_2/docs/question-schema.md create mode 100644 app/prototype_v4_2/docs/tobacco-schema.md create mode 100644 app/prototype_v4_2/lib/eligibility.js create mode 100644 app/prototype_v4_2/lib/page-index.js create mode 100644 app/prototype_v4_2/lib/question-renderer.js create mode 100644 app/prototype_v4_2/lib/question-validator.js create mode 100644 app/prototype_v4_2/lib/questions.js create mode 100644 app/prototype_v4_2/lib/settings.js create mode 100644 app/prototype_v4_2/lib/summary.js create mode 100644 app/prototype_v4_2/lib/tobacco-flow.js create mode 100644 app/prototype_v4_2/lib/unit-navigation.js create mode 100644 app/prototype_v4_2/routes.js create mode 100644 app/prototype_v4_2/views/authentication/security-code.html create mode 100644 app/prototype_v4_2/views/authentication/sign-in-agreement-declined.html create mode 100644 app/prototype_v4_2/views/authentication/sign-in-agreement.html create mode 100644 app/prototype_v4_2/views/authentication/sign-in.html create mode 100644 app/prototype_v4_2/views/check-your-answers.html create mode 100644 app/prototype_v4_2/views/confirmation.html create mode 100644 app/prototype_v4_2/views/content/show.html create mode 100644 app/prototype_v4_2/views/errors/404.html create mode 100644 app/prototype_v4_2/views/errors/500.html create mode 100644 app/prototype_v4_2/views/errors/503.html create mode 100644 app/prototype_v4_2/views/includes/benefits-of-stopping-smoking.html create mode 100644 app/prototype_v4_2/views/includes/error-summary.html create mode 100644 app/prototype_v4_2/views/includes/find-your-local-stop-smoking-service.html create mode 100644 app/prototype_v4_2/views/includes/give-feedback.html create mode 100644 app/prototype_v4_2/views/includes/help-and-support.html create mode 100644 app/prototype_v4_2/views/includes/help-stop-smoking.html create mode 100644 app/prototype_v4_2/views/includes/page-heading-legend.html create mode 100644 app/prototype_v4_2/views/includes/page-heading.html create mode 100644 app/prototype_v4_2/views/includes/smoking-anxiety-and-mood.html create mode 100644 app/prototype_v4_2/views/includes/speak-to-a-gp.html create mode 100644 app/prototype_v4_2/views/includes/vaping-to-quit-smoking.html create mode 100644 app/prototype_v4_2/views/index.html create mode 100755 app/prototype_v4_2/views/layouts/main.html create mode 100644 app/prototype_v4_2/views/questions/_question.html create mode 100644 app/prototype_v4_2/views/questions/book-appointment.html create mode 100644 app/prototype_v4_2/views/questions/not-eligible-for-scan.html create mode 100644 app/prototype_v4_2/views/questions/not-eligible-for-screening.html create mode 100644 app/prototype_v4_2/views/questions/phone-questionnaire-exit.html create mode 100644 app/prototype_v4_2/views/questions/smoking-type-exit.html create mode 100644 app/prototype_v4_2/views/start.html 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..06c3efa --- /dev/null +++ b/app/prototype_v4_2/controllers/question.js @@ -0,0 +1,928 @@ +const { getDateOfBirth, isEligibleForScanAge } = require('../lib/eligibility') +const { renderQuestion, version, view } = require('../lib/question-renderer') +const { getCheckYourAnswers } = require('../lib/summary') +const { + deleteUnselectedShishaSettingAnswers, + deleteUnselectedSmokingQuantityOtherAnswer, + deleteUnselectedSmokingChangeAnswers, + deleteUnselectedSmokingTypeAnswers, + getFormerSmokerFallbackStep, + getSelectedSmokingTypes, + getSmokingTypeActions, + getSmokingTypeQuestionOverrides, + getSmokingTypeStep, + getSmokingTypeSteps, + getSmokingTypeStepUrl, + renderSmokingTypeQuestion, + validateSmokingTypeQuestion +} = require('../lib/tobacco-flow') +const { getHeightBack, getWeightBack, getWeightNext } = require('../lib/unit-navigation') +const { validateQuestion } = require('../lib/question-validator') + +/// ------------------------------------------------------------------------ /// +/// +/// ------------------------------------------------------------------------ /// + +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}/gender`) + } +} + +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}/gender`) + } +} + +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}/education`, + 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}/education`, + cancel: `/prototype_${version}/` + }, errors) + } else { + res.redirect(`/prototype_${version}/asbestos-at-work`) + } +} + +exports.asbestosAtWork_get = (req, res) => { + renderQuestion(res, 'asbestos-at-work', { + next: `/prototype_${version}/asbestos-at-work`, + back: `/prototype_${version}/respiratory-conditions`, + cancel: `/prototype_${version}/` + }) +} + +exports.asbestosAtWork_post = (req, res) => { + const { answers } = req.session.data + const errors = validateQuestion(answers, 'asbestos-at-work') + + if (errors.length) { + renderQuestion(res, 'asbestos-at-work', { + next: `/prototype_${version}/asbestos-at-work`, + back: `/prototype_${version}/respiratory-conditions`, + cancel: `/prototype_${version}/` + }, errors) + } else { + res.redirect(`/prototype_${version}/asbestos-at-home`) + } +} + +exports.asbestosAtHome_get = (req, res) => { + renderQuestion(res, 'asbestos-at-home', { + next: `/prototype_${version}/asbestos-at-home`, + back: `/prototype_${version}/asbestos-at-work`, + cancel: `/prototype_${version}/` + }) +} + +exports.asbestosAtHome_post = (req, res) => { + const { answers } = req.session.data + const errors = validateQuestion(answers, 'asbestos-at-home') + + if (errors.length) { + renderQuestion(res, 'asbestos-at-home', { + next: `/prototype_${version}/asbestos-at-home`, + back: `/prototype_${version}/asbestos-at-work`, + cancel: `/prototype_${version}/` + }, errors) + } 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-at-work`, + 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-at-work`, + 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}/age-started-smoking`) + } + } +} + +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}/age-started-smoking`) + } +} + +/// ------------------------------------------------------------------------ /// +/// Smoking habits +/// ------------------------------------------------------------------------ /// + +exports.ageStartedSmoking_get = (req, res) => { + const { answers } = req.session.data + const back = answers?.cancerDiagnosisRelativesAge ? `/prototype_${version}/cancer-diagnosis-relatives-age` : `/prototype_${version}/cancer-diagnosis-relatives` + + renderQuestion(res, 'age-started-smoking', { + next: `/prototype_${version}/age-started-smoking`, + back, + cancel: `/prototype_${version}/` + }) +} + +exports.ageStartedSmoking_post = (req, res) => { + const { answers } = req.session.data + const back = answers?.cancerDiagnosisRelativesAge ? `/prototype_${version}/cancer-diagnosis-relatives-age` : `/prototype_${version}/cancer-diagnosis-relatives` + + const errors = validateQuestion(answers, 'age-started-smoking') + + // TODO: + // If not answered, throw error + // If the age started smoking is older than person's age + // based on date of birth, throw error + + if (errors.length) { + renderQuestion(res, 'age-started-smoking', { + next: `/prototype_${version}/age-started-smoking`, + back, + cancel: `/prototype_${version}/` + }, errors) + } else { + if (answers.smoker === 'yes_previous') { + res.redirect(`/prototype_${version}/age-stopped-smoking`) + } else { + delete answers.ageStoppedSmoking + res.redirect(`/prototype_${version}/periods-stopped-smoking`) + } + } +} + +exports.ageStoppedSmoking_get = (req, res) => { + const { answers } = req.session.data + + if (answers.smoker !== 'yes_previous') { + res.redirect(`/prototype_${version}/periods-stopped-smoking`) + return + } + + renderQuestion(res, 'age-stopped-smoking', { + next: `/prototype_${version}/age-stopped-smoking`, + back: `/prototype_${version}/age-started-smoking`, + cancel: `/prototype_${version}/` + }) +} + +exports.ageStoppedSmoking_post = (req, res) => { + const { answers } = req.session.data + const errors = validateQuestion(answers, 'age-stopped-smoking') + + if (answers.smoker !== 'yes_previous') { + delete answers.ageStoppedSmoking + res.redirect(`/prototype_${version}/periods-stopped-smoking`) + return + } + + // TODO: + // If not answered, throw error + // If the age stopped smoking is older than person's age + // based on date of birth, throw error + + if (errors.length) { + renderQuestion(res, 'age-stopped-smoking', { + next: `/prototype_${version}/age-stopped-smoking`, + back: `/prototype_${version}/age-started-smoking`, + cancel: `/prototype_${version}/` + }, errors) + } else { + res.redirect(`/prototype_${version}/periods-stopped-smoking`) + } +} + +exports.periodsStoppedSmoking_get = (req, res) => { + const answers = req.session.data.answers || {} + const back = answers.smoker === 'yes_previous' ? `/prototype_${version}/age-stopped-smoking` : `/prototype_${version}/age-started-smoking` + + renderQuestion(res, 'periods-stopped-smoking', { + next: `/prototype_${version}/periods-stopped-smoking`, + back, + cancel: `/prototype_${version}/` + }) +} + +exports.periodsStoppedSmoking_post = (req, res) => { + const answers = req.session.data.answers || {} + const back = answers.smoker === 'yes_previous' ? `/prototype_${version}/age-stopped-smoking` : `/prototype_${version}/age-started-smoking` + const errors = validateQuestion(answers, 'periods-stopped-smoking') + + if (errors.length) { + renderQuestion(res, 'periods-stopped-smoking', { + next: `/prototype_${version}/periods-stopped-smoking`, + back, + cancel: `/prototype_${version}/` + }, errors) + } else { + 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}/periods-stopped-smoking`, + 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}/periods-stopped-smoking`, + 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.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.smokingFrequency_get = (req, res) => { + renderSmokingTypeQuestion(req, res, 'smoking-frequency') +} + +exports.smokingFrequency_post = (req, res) => { + const { step, steps } = getSmokingTypeStep(req, 'smoking-frequency') + const errors = step ? validateSmokingTypeQuestion(req, 'smoking-frequency', step) : [] + + if (!step) { + res.redirect(`/prototype_${version}/smoking-type`) + return + } + + if (errors.length) { + renderSmokingTypeQuestion(req, res, 'smoking-frequency', errors) + } else { + res.redirect(getSmokingTypeActions(step, steps).onward) + } +} + +exports.smokingQuantity_get = (req, res) => { + renderSmokingTypeQuestion(req, res, 'smoking-quantity') +} + +exports.smokingQuantity_post = (req, res) => { + const { step, steps } = getSmokingTypeStep(req, 'smoking-quantity') + deleteUnselectedSmokingQuantityOtherAnswer(req.session.data.answers, step) + const errors = step ? validateSmokingTypeQuestion(req, 'smoking-quantity', step) : [] + + if (!step) { + res.redirect(`/prototype_${version}/smoking-type`) + return + } + + if (errors.length) { + renderSmokingTypeQuestion(req, res, 'smoking-quantity', errors) + } else { + res.redirect(getSmokingTypeActions(step, steps).onward) + } +} + +exports.smokingSetting_get = (req, res) => { + renderSmokingTypeQuestion(req, res, 'smoking-setting') +} + +exports.smokingSetting_post = (req, res) => { + const { step, steps } = getSmokingTypeStep(req, 'smoking-setting') + const { answers } = req.session.data + const errors = step ? validateSmokingTypeQuestion(req, 'smoking-setting', step) : [] + + if (!step) { + res.redirect(`/prototype_${version}/smoking-type`) + return + } + + deleteUnselectedShishaSettingAnswers(answers[step.type]) + + if (errors.length) { + renderSmokingTypeQuestion(req, res, 'smoking-setting', 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.smokingFrequencyChange_get = (req, res) => { + renderSmokingTypeQuestion(req, res, 'smoking-frequency-change') +} + +exports.smokingFrequencyChange_post = (req, res) => { + const { step, steps } = getSmokingTypeStep(req, 'smoking-frequency-change') + const errors = step ? validateSmokingTypeQuestion(req, 'smoking-frequency-change', step) : [] + + if (!step) { + res.redirect(`/prototype_${version}/smoking-type`) + return + } + + if (errors.length) { + renderSmokingTypeQuestion(req, res, 'smoking-frequency-change', errors) + } else { + res.redirect(getSmokingTypeActions(step, steps).onward) + } +} + +exports.smokingQuantityChange_get = (req, res) => { + renderSmokingTypeQuestion(req, res, 'smoking-quantity-change') +} + +exports.smokingQuantityChange_post = (req, res) => { + const { step, steps } = getSmokingTypeStep(req, 'smoking-quantity-change') + deleteUnselectedSmokingQuantityOtherAnswer(req.session.data.answers, step, 'quantity') + const errors = step ? validateSmokingTypeQuestion(req, 'smoking-quantity-change', step) : [] + + if (!step) { + res.redirect(`/prototype_${version}/smoking-type`) + return + } + + if (errors.length) { + renderSmokingTypeQuestion(req, res, 'smoking-quantity-change', errors) + } else { + res.redirect(getSmokingTypeActions(step, steps).onward) + } +} + +exports.smokingYearsChange_get = (req, res) => { + renderSmokingTypeQuestion(req, res, 'smoking-years-change') +} + +exports.smokingYearsChange_post = (req, res) => { + const { step, steps } = getSmokingTypeStep(req, 'smoking-years-change') + const errors = step ? validateSmokingTypeQuestion(req, 'smoking-years-change', step) : [] + + if (!step) { + res.redirect(`/prototype_${version}/smoking-type`) + return + } + + if (errors.length) { + renderSmokingTypeQuestion(req, res, 'smoking-years-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/questions.yaml b/app/prototype_v4_2/data/questions.yaml new file mode 100644 index 0000000..e94fbbe --- /dev/null +++ b/app/prototype_v4_2/data/questions.yaml @@ -0,0 +1,1123 @@ +--- +questions: + - id: accept-terms + type: multiple + answerKey: acceptTerms + 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). + 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 + 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 + input: + label: Have you previously completed a lung cancer risk questionnaire by phone? + 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 + + - id: smoker + type: single + answerKey: 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. + 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 + heading: + title: What is your date of birth? + input: + 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 + 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 + 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 + 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. + input: + id: height-metric + name: answers[height][metric] + valueKey: metric + label: What is your height in centimetres? + 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 + 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. + 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 + 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 + 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. + 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 + 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. + input: + label: What is your weight in stones and pounds? + 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 + 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 + 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. + input: + label: Which of these best describes you? + 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 you + + - id: sex + type: single + answerKey: sex + heading: + title: Your sex at birth + caption: About you + description: | + Your sex may impact your chances of developing lung cancer. + input: + label: What was your sex at birth? + 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 + + - id: ethnicity + type: single + answerKey: 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. + 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 + 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. + 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 + heading: + title: Have you ever been diagnosed with any of the following respiratory conditions? + caption: Your health + input: + 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 + + - id: asbestos-at-work + type: single + answerKey: asbestosAtWork + heading: + title: Tell us if you might have been exposed to asbestos at work + caption: Your health + description: | + You may have been exposed to asbestos if you worked in an industry such as building or construction, particularly from the 1950s to the 1990s. + + You could be exposed to asbestos today if your job involves working in certain roles in old buildings. + + Examples include: + + - heating and ventilation engineers + - demolition workers + - plumbers + - construction workers + - electricians + + ## If you did not work with asbestos but think you might have been exposed + + You might know there was asbestos in buildings you have spent time in, or products you have used. But if the asbestos was not damaged, the risk to your health is very low. + + If you lived with someone who worked with asbestos you may have been exposed to asbestos yourself. For example, if you washed the clothes they worked in. The question on the next page will ask if you lived with someone who might have been exposed to asbestos at work. + details: + summary: What is asbestos? + text: | + Asbestos was used in a number of building materials and products. For example: + + - boilers and pipes + - car brakes + - cement for roofing sheets + - floor tiles + - insulating board to protect buildings and ships against fire + + If you worked in an industry such as building or construction, you are more likely to have come into contact with damaged asbestos. + 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 + heading: + title: Tell us if you ever lived with anyone who worked with asbestos + caption: Your health + description: | + If you lived with someone who worked with asbestos you may have been exposed to asbestos. For example, if you washed the clothes they worked in. + + Someone might have worked with asbestos if they worked in an industry such as building or construction, particularly from the 1950s to the 1990s. + details: + summary: What is asbestos? + text: | + Asbestos was used in a number of building materials and products. For example: + + - boilers and pipes + - car brakes + - cement for roofing sheets + - floor tiles + - insulating board to protect buildings and ships against fire + + If you worked in an industry such as building or construction, you are more likely to have come into contact with damaged asbestos. + 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 + 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. + 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 + 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. + 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 + 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'. + 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 + heading: + title: How old were you when you started smoking? + caption: Your smoking habits + input: + 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 + + - id: age-stopped-smoking + type: text + answerKey: ageStoppedSmoking + heading: + title: How old were you when you stopped smoking? + caption: Your smoking habits + input: + 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 + + - id: periods-stopped-smoking + type: single + answerKey: periodsStoppedSmoking + heading: + title: Periods when you stopped smoking + caption: Your smoking habits + description: | + There may have been periods when you stopped or quit smoking. + + If you stopped smoking for periods of 1 year or longer, tell us the total number of years you stopped smoking. + input: + label: Have you ever stopped smoking for periods of 1 year or longer? + 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" + + - id: smoking-type + type: multiple + answerKey: smokingType + 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. + 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: + 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. + 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 + heading: + title: 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 + heading: + title: 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 + heading: + title: How much do you smoke? + input: + id: smoking-quantity + 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: + 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-setting + type: multiple + heading: + title: Do you usually smoke shisha in a group or on your own? + input: + hint: Select all that apply + options: + - label: In a group + value: group + - label: By myself + value: individual + validation: + required: true + errors: + required: + text: Select whether you usually smoke shisha in a group or on your own + + - id: smoking-change + type: multiple + heading: + title: Has the amount you normally smoke changed over time? + input: + 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 + heading: + title: 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 + heading: + title: How much did you smoke? + input: + id: smoking-quantity-change + 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: + 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 + heading: + title: How many years did you smoke this amount? + input: + id: smoking-years-change + 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..e6d5bce --- /dev/null +++ b/app/prototype_v4_2/data/tobacco.yaml @@ -0,0 +1,158 @@ +--- +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? + setting: Do you usually smoke shisha in a group or on your own? + frequency: How often do you smoke shisha? + quantity: How long do you currently smoke shisha in a normal day? + past: + setting: Did you usually smoke shisha in a group or on your own? + frequency: How often did you smoke shisha? + quantity: How long did you smoke shisha in a normal day? + settingHeadings: + group: + current: + frequency: How often do you smoke shisha in a group? + quantity: How long do you currently smoke shisha in a group in a normal day? + past: + frequency: How often did you smoke shisha in a group? + quantity: How long did you smoke shisha in a group in a normal day? + individual: + current: + frequency: How often do you smoke shisha by yourself? + quantity: How long do you currently smoke shisha by yourself in a normal day? + past: + frequency: How often did you smoke shisha by yourself? + quantity: How long did you smoke shisha by yourself in a normal day? + +smokingChangeTypes: + greater: + answerKey: smokingChangeIncrease + label: more + fewer: + answerKey: smokingChangeDecrease + label: fewer + +shishaSmokingSettings: + group: + label: In a group + headingText: in a group + individual: + label: By myself + headingText: by yourself 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..33b20de --- /dev/null +++ b/app/prototype_v4_2/docs/question-flow.md @@ -0,0 +1,122 @@ +# Prototype v4.1 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 --> gender["Gender"] + weightImperial --> gender + + gender --> sex["Sex"] + sex --> ethnicity["Ethnicity"] + ethnicity --> education["Education"] + education --> respiratory["Respiratory conditions"] + respiratory --> asbestosWork["Asbestos at work"] + asbestosWork --> asbestosHome["Asbestos at home"] + asbestosHome --> cancerDiagnosis["Cancer diagnosis"] + cancerDiagnosis --> relatives{"Close relative had
lung cancer?"} + + relatives -- Yes --> relativesAge["Relative diagnosed before 60?"] + relatives -- No --> ageStarted["Age started smoking"] + relativesAge --> ageStarted + + ageStarted --> previousSmoker{"Used to smoke?"} + previousSmoker -- Yes --> ageStopped["Age stopped smoking"] + previousSmoker -- No, currently smokes --> stoppedSmoking["Periods stopped smoking"] + ageStopped --> stoppedSmoking + stoppedSmoking --> 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"] --> isShisha{"Is the selected type
shisha?"} + + isShisha -- Yes --> shishaPast{"Used to smoke?"} + shishaPast -- No, currently smokes --> shishaStatus["Smoking status"] + shishaPast -- Yes --> setting["Smoking setting"] + shishaStatus --> setting + setting --> selectedSetting["Next selected shisha setting"] + selectedSetting --> shishaFrequency["Smoking frequency"] + shishaFrequency --> shishaQuantity["Smoking quantity"] + shishaQuantity --> moreSettings{"More selected
shisha settings?"} + moreSettings -- Yes --> selectedSetting + moreSettings -- No --> nextTypeOrCya + + isShisha -- No --> past{"Used to smoke?"} + past -- No, currently smokes --> status["Smoking status"] + past -- Yes --> frequency["Smoking frequency"] + status --> frequency["Smoking frequency"] + frequency --> quantity["Smoking quantity"] + quantity --> changed{"Smoking changed
over time?"} + + changed -- No change selected --> nextTypeOrCya + changed -- Increased selected --> increasedFrequency["Increased: frequency before change"] + increasedFrequency --> increasedQuantity["Increased: quantity before change"] + increasedQuantity --> increasedYears["Increased: years before change"] + increasedYears --> decreasedSelected{"Decreased also selected?"} + + changed -- Decreased selected --> decreasedFrequency["Decreased: frequency before change"] + decreasedSelected -- Yes --> decreasedFrequency + decreasedSelected -- No --> nextTypeOrCya + + decreasedFrequency --> decreasedQuantity["Decreased: quantity before change"] + decreasedQuantity --> decreasedYears["Decreased: years before change"] + decreasedYears --> 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. +- `Age stopped smoking` is only asked when the `smoker` answer is `yes_previous`. +- The tobacco subflow uses query strings such as `/prototype_v4_2/smoking-status?type=cigarettes`. +- If the `smoker` answer is `yes_previous`, each tobacco type skips `Smoking status` and uses past-tense question text. +- Shisha asks for `Smoking setting`, then repeats frequency and quantity for each selected setting. The shisha setting-specific pages include the setting in the query string, for example `/prototype_v4_2/smoking-frequency?type=shisha&setting=group`. +- If both `increased` and `decreased` are selected for a tobacco type, the flow asks the three "increased" change questions first, then the three "decreased" change questions. +- `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..fd06773 --- /dev/null +++ b/app/prototype_v4_2/docs/question-schema.md @@ -0,0 +1,371 @@ +# Question schema + +Question content for prototype v4.1 lives in `app/prototype_v4_2/data/questions.yaml`. + +The file should only contain standard question content 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`. + +## Basic structure + +Each item in `questions` represents one reusable question. + +```yaml +questions: + - id: education + type: single + answerKey: 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. + 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 +``` + +## 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. | +| `heading.title` | Yes | Page heading or fieldset legend. | +| `heading.caption` | No | Caption shown above the heading. | +| `description` | No | Markdown content shown between the heading and input label. | +| `details` | No | NHS details component content. | +| `input` | Usually | Input, radios or checkboxes configuration. | +| `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. | + +## Heading and input labels + +If `input.label` is not set, the renderer uses `heading.title` as the input label and sets `isPageHeading` to `true`. + +Use this when the visible question text should be the page heading and the input label, for example: + +```yaml +- id: age-started-smoking + type: text + heading: + title: How old were you when you started smoking? + caption: Your smoking habits + input: + hint: Give an estimate if you are not sure +``` + +Use `input.label` when the page needs separate introductory heading content and a specific fieldset or input label: + +```yaml +- id: smoker + type: single + heading: + title: Tobacco smoking + input: + label: Have you ever smoked tobacco? + hint: This includes social smoking +``` + +## Descriptions and details + +`description` supports Markdown and is rendered between the page heading and the input label. + +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 + heading: + title: Tobacco smoking + 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 + heading: + title: Have you ever been diagnosed with any of the following respiratory conditions? + caption: Your health + input: + 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 + heading: + title: Your height + caption: About you + input: + id: height-metric + name: answers[height][metric] + valueKey: metric + label: What is your height in centimetres? + 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 + heading: + title: Your height + caption: About you + 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 + heading: + title: What is your date of birth? + input: + 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: + heading: + title: The type of tobacco you have smoked + input: + label: What have you smoked? +``` + +Keep variants small. If a question becomes difficult to understand because it has too many variants, consider using separate question IDs instead. + +## 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..e388f66 --- /dev/null +++ b/app/prototype_v4_2/docs/tobacco-schema.md @@ -0,0 +1,89 @@ +# 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. | +| `shishaSmokingSettings` | Shisha setting labels and heading fragments. | + +## 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. + +## Shisha + +Shisha has setting-specific headings because the flow asks whether someone smoked shisha in a group, by themselves, or both. + +```yaml +settingHeadings: + group: + current: + frequency: How often do you smoke shisha in a group? + quantity: How many hours do you currently smoke shisha in a group in a normal day? + past: + frequency: How often did you smoke shisha in a group? + quantity: How many hours did you smoke shisha in a group in a normal day? +``` + +The setting keys must match `shishaSmokingSettings`. + +## 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. + +## Shisha smoking settings + +```yaml +shishaSmokingSettings: + group: + label: In a group + headingText: in a group + individual: + label: By myself + headingText: by yourself +``` + +`label` is used for the checkbox option. `headingText` is used when building contextual summary and heading text. 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..c37a28c --- /dev/null +++ b/app/prototype_v4_2/lib/page-index.js @@ -0,0 +1,94 @@ +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', + smokingSetting: ['group'], + group: { + 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-renderer.js b/app/prototype_v4_2/lib/question-renderer.js new file mode 100644 index 0000000..6f079a7 --- /dev/null +++ b/app/prototype_v4_2/lib/question-renderer.js @@ -0,0 +1,48 @@ +const { getQuestion } = require('./questions') +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 question = getQuestion(id) + const errorMap = errors.reduce((map, error) => { + if (error.href) { + map[error.href.replace('#', '')] = error + } + + return map + }, {}) + + res.render(view('questions/_question'), { + question: { + ...question, + ...overrides, + heading: { + ...question.heading, + ...overrides.heading + }, + input: { + ...question.input, + ...overrides.input + } + }, + errorMap, + errors, + actions + }) +} + +module.exports = { + renderQuestion, + 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..35d5ecf --- /dev/null +++ b/app/prototype_v4_2/lib/question-validator.js @@ -0,0 +1,305 @@ +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 +} + +module.exports = { + validateQuestion +} diff --git a/app/prototype_v4_2/lib/questions.js b/app/prototype_v4_2/lib/questions.js new file mode 100644 index 0000000..466aae1 --- /dev/null +++ b/app/prototype_v4_2/lib/questions.js @@ -0,0 +1,230 @@ +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: {}, + shishaSmokingSettings: {}, + 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} heading - Heading content. + * @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 || {} + const label = input.label || question.heading?.title + + return { + ...question, + answerKey, + input: { + ...input, + id: input.id || question.id, + 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 || {}, + shishaSmokingSettings: tobaccoData.shishaSmokingSettings || {} + } +} + +/** + * 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) + replaceObject(data.shishaSmokingSettings, freshData.shishaSmokingSettings) + + 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, + shishaSmokingSettings: data.shishaSmokingSettings, + 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..3004166 --- /dev/null +++ b/app/prototype_v4_2/lib/summary.js @@ -0,0 +1,428 @@ +const { nhsukDate } = require('../../filters/dates') +const { getQuestionValueLabels } = require('./questions') +const { version } = require('./question-renderer') +const { + formatQuantity, + getSelectedShishaSettings, + getSelectedSmokingChanges, + getSelectedSmokingTypes, + getShishaSettingAnswer, + 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 shishaSettingRows = getSelectedShishaSettings(answer).flatMap((setting) => { + const settingAnswer = getShishaSettingAnswer(answer, setting) + + return [ + makeSummaryRow({ + key: getSmokingStepHeading('smoking-frequency', type, setting, isPast, settingAnswer), + value: formatValue(settingAnswer.smokingFrequency, valueLabels.smokingFrequency), + href: getSmokingTypeStepUrl({ page: 'smoking-frequency', type, setting }) + }), + makeSummaryRow({ + key: getSmokingStepHeading('smoking-quantity', type, setting, isPast, settingAnswer), + value: formatSmokingQuantityAnswer(type, settingAnswer), + href: getSmokingTypeStepUrl({ page: 'smoking-quantity', type, setting }) + }) + ] + }) + 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: 'smoking-frequency-change', type, change }) + }), + makeSummaryRow({ + key: getSmokingChangeHeading('smoking-quantity-change', type, change, changeAnswer, answer), + value: getSmokingQuantity(type, changeAnswer.quantity), + href: getSmokingTypeStepUrl({ page: 'smoking-quantity-change', type, change }) + }), + makeSummaryRow({ + key: getSmokingChangeHeading('smoking-years-change', type, change, changeAnswer, answer), + value: changeAnswer.years && formatQuantity(changeAnswer.years, 'year', 'years'), + href: getSmokingTypeStepUrl({ page: 'smoking-years-change', type, change }) + }) + ] + }) + const rows = makeSummaryRows([ + !isFormerSmoker && makeSummaryRow({ + key: smokingType.statusHeading, + value: formatValue(answer.smokingStatus, valueLabels.smokingStatus), + href: getSmokingTypeStepUrl({ page: 'smoking-status', type }) + }), + type === 'shisha' && makeSummaryRow({ + key: smokingType.settingHeading, + ...formatListValue(answer.smokingSetting, valueLabels.smokingSetting), + href: getSmokingTypeStepUrl({ page: 'smoking-setting', type }) + }), + type !== 'shisha' && makeSummaryRow({ + key: getSmokingStepHeading('smoking-frequency', type, undefined, isPast, answer), + value: formatValue(answer.smokingFrequency, valueLabels.smokingFrequency), + href: getSmokingTypeStepUrl({ page: 'smoking-frequency', type }) + }), + type !== 'shisha' && makeSummaryRow({ + key: getSmokingStepHeading('smoking-quantity', type, undefined, isPast, answer), + value: formatSmokingQuantityAnswer(type, answer), + href: getSmokingTypeStepUrl({ page: 'smoking-quantity', type }) + }), + type !== 'shisha' && makeSummaryRow({ + key: smokingType.changeHeading, + ...formatListValue(answer.smokingChange, getSmokingChangeLabels(type, answer, isPast)), + href: getSmokingTypeStepUrl({ page: 'smoking-change', type }) + }), + ...shishaSettingRows, + ...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}/gender` + }), + makeSummaryRow({ + key: 'Sex at birth', + value: formatValue(answers.sex, valueLabels.sex), + href: `/prototype_${version}/sex` + }), + makeSummaryRow({ + key: 'Ethnic background', + value: formatValue(answers.ethnicity, valueLabels.ethnicity), + href: `/prototype_${version}/ethnicity` + }), + makeSummaryRow({ + key: 'Education', + value: formatValue(answers.education, valueLabels.education), + href: `/prototype_${version}/education` + }) + ]), + 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-at-work` + }), + makeSummaryRow({ + key: 'Lived with anyone who worked with asbestos', + value: formatValue(answers.asbestosAtHome, valueLabels.asbestosAtHome), + href: `/prototype_${version}/asbestos-at-home` + }), + 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}/age-started-smoking` + }), + isFormerSmoker && makeSummaryRow({ + key: 'Age you stopped smoking', + value: answers.ageStoppedSmoking && `Age ${answers.ageStoppedSmoking}`, + href: `/prototype_${version}/age-stopped-smoking` + }), + makeSummaryRow({ + key: 'Stopped smoking for periods of 1 year or longer', + value: formatValue(answers.periodsStoppedSmoking, valueLabels.periodsStoppedSmoking), + href: `/prototype_${version}/periods-stopped-smoking` + }), + answers.periodsStoppedSmoking === 'yes' && makeSummaryRow({ + key: 'Total number of years you stopped smoking', + value: answers.yearsStoppedSmoking && formatQuantity(answers.yearsStoppedSmoking, 'year', 'years'), + href: `/prototype_${version}/periods-stopped-smoking` + }), + 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..490b9b3 --- /dev/null +++ b/app/prototype_v4_2/lib/tobacco-flow.js @@ -0,0 +1,1142 @@ +const { + getQuestion, + getQuestionValueLabels, + refreshData, + smokingChangeTypes, + shishaSmokingSettings, + tobaccoTypes: smokingTypes +} = require('./questions') +const { renderQuestion, 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'), + smokingSetting: getQuestionValueLabels('smoking-setting'), + 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} [setting] - Shisha setting key. + * @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] + } + }) +} + +/** + * Get selected shisha settings in tobacco.yaml order. + * + * @param {Object} answer - Shisha answer object. + * @returns {string[]} Selected shisha setting keys. + */ +const getSelectedShishaSettings = (answer = {}) => { + refreshData() + + const selectedSettings = Array.isArray(answer.smokingSetting) + ? answer.smokingSetting + : [answer.smokingSetting].filter(Boolean) + + return Object.keys(shishaSmokingSettings).filter((setting) => selectedSettings.includes(setting)) +} + +/** + * Remove nested shisha answers for unselected settings. + * + * @param {Object} answer - Shisha answer object, mutated in place. + */ +const deleteUnselectedShishaSettingAnswers = (answer = {}) => { + const selectedSettings = getSelectedShishaSettings(answer) + + Object.keys(shishaSmokingSettings).forEach((setting) => { + if (!selectedSettings.includes(setting)) { + delete answer[setting] + } + }) +} + +/** + * 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 }) + } + + if (type === 'shisha') { + steps.push({ page: 'smoking-setting', type }) + getSelectedShishaSettings(answer).forEach((setting) => { + steps.push({ page: 'smoking-frequency', type, setting }) + steps.push({ page: 'smoking-quantity', type, setting }) + }) + } else { + steps.push({ page: 'smoking-frequency', type }) + steps.push({ page: 'smoking-quantity', type }) + steps.push({ page: 'smoking-change', type }) + getSelectedSmokingChanges(answer).forEach((change) => { + steps.push({ page: 'smoking-frequency-change', type, change }) + steps.push({ page: 'smoking-quantity-change', type, change }) + steps.push({ page: 'smoking-years-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) + } + + if (step.setting) { + searchParams.set('setting', step.setting) + } + + 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 the nested answer object for a shisha setting. + * + * @param {Object} answer - Shisha answer object. + * @param {string} setting - Shisha setting key. + * @returns {Object} Setting-specific answer object. + */ +const getShishaSettingAnswer = (answer = {}, setting) => { + return setting ? answer[setting] || {} : {} +} + +/** + * 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, + settingHeading: tenseHeadings.setting + } +} + +/** + * Get the heading for a tobacco sub-flow step. + * + * @param {string} page - Step page id. + * @param {string} type - Tobacco type key. + * @param {string} setting - Optional shisha setting 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, setting, isPast = false, answer = {}) => { + const smokingType = smokingTypes[type] + const frequency = answer.smokingFrequency + + if (!smokingType) { + return '' + } + + if (type === 'shisha' && setting) { + const tense = isPast ? 'past' : 'current' + const settingHeadings = smokingType.settingHeadings?.[setting]?.[tense] + + if (settingHeadings) { + return applySmokingFrequencyPeriod(settingHeadings[page.replace('smoking-', '')] || '', frequency) + } + } + + 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) + } + + if (step.setting) { + return getShishaSettingAnswer(answer, step.setting) + } + + 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').heading.title + } + + 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 querySetting = req.query?.setting + const step = steps.find((step) => step.page === page && step.type === queryType && step.change === queryChange && step.setting === querySetting) || + steps.find((step) => step.page === page && step.type === queryType && !step.change && !step.setting) || + 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') + + if (answers.smoker === 'yes_previous') { + return question.variants.previous + } + + 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 = step.setting + ? `answers[${step.type}][${step.setting}][${item.conditionalInput.answerKey}]` + : `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, + settingAnswer, + 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-setting') { + return { + heading: { + title: smokingType.settingHeading, + caption: smokingType.caption + }, + input: { + name: `answers[${step.type}][smokingSetting]` + }, + values: answer.smokingSetting + } + } + + if (page === 'smoking-frequency') { + const isSettingSpecific = Boolean(step.setting) + + return { + heading: { + title: getSmokingStepHeading(page, step.type, step.setting, isPastSmokingType, isSettingSpecific ? settingAnswer : answer), + caption: smokingType.caption + }, + input: { + name: isSettingSpecific + ? `answers[${step.type}][${step.setting}][smokingFrequency]` + : `answers[${step.type}][smokingFrequency]` + }, + value: isSettingSpecific ? settingAnswer.smokingFrequency : answer.smokingFrequency, + items: getQuestionItemsWithLabels('smoking-frequency', {}, { + monthly: `Select this option if you ${isPastSmokingType ? 'smoked' : 'smoke'} at least once a month` + }) + } + } + + if (page === 'smoking-quantity') { + const isSettingSpecific = Boolean(step.setting) + + return getSmokingQuantityQuestionOverrides({ + page, + step, + heading: getSmokingStepHeading(page, step.type, step.setting, isPastSmokingType, isSettingSpecific ? settingAnswer : answer), + caption: smokingType.caption, + name: isSettingSpecific + ? `answers[${step.type}][${step.setting}][smokingQuantity]` + : `answers[${step.type}][smokingQuantity]`, + value: isSettingSpecific ? settingAnswer.smokingQuantity : answer.smokingQuantity, + conditionalValue: isSettingSpecific ? settingAnswer.smokingQuantityOther : 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 {} +} + +/** + * 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 && item.setting === step.setting) + 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 answer = req.session.data.answers[step.type] || {} + const isPast = isPastSmokingType(req.session.data.answers, answer) + const changeAnswer = getSmokingChangeAnswer(answer, step.change) + const settingAnswer = getShishaSettingAnswer(answer, step.setting) + const smokingType = getSmokingTypeHeadings(step.type, isPast) + const smokingChange = smokingChangeTypes[step.change] + const smokingChangeLabels = getSmokingChangeLabels(step.type, answer, isPast) + renderQuestion(res, page, getSmokingTypeActions(step, steps), errors, getSmokingContentQuestionOverrides({ + page, + step, + answer, + changeAnswer, + settingAnswer, + smokingType, + smokingChange, + smokingChangeLabels, + isPastSmokingType: isPast + })) +} + +/** + * 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 answer = req.session.data.answers[step.type] || {} + const isPast = isPastSmokingType(req.session.data.answers, answer) + const changeAnswer = getSmokingChangeAnswer(answer, step.change) + const settingAnswer = getShishaSettingAnswer(answer, step.setting) + const smokingType = getSmokingTypeHeadings(step.type, isPast) + const smokingChange = smokingChangeTypes[step.change] + const smokingChangeLabels = getSmokingChangeLabels(step.type, answer, isPast) + const overrides = getSmokingContentQuestionOverrides({ + page, + step, + answer, + settingAnswer, + changeAnswer, + smokingType, + smokingChange, + smokingChangeLabels, + isPastSmokingType: isPast + }) + 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 + }) +} + +module.exports = { + deleteUnselectedShishaSettingAnswers, + deleteUnselectedSmokingQuantityOtherAnswer, + deleteUnselectedSmokingChangeAnswers, + deleteUnselectedSmokingTypeAnswers, + formatQuantity, + getSelectedShishaSettings, + getSelectedSmokingChanges, + getSelectedSmokingTypes, + getShishaSettingAnswer, + getFormerSmokerFallbackStep, + getSmokingChangeAnswer, + getSmokingChangeHeading, + getSmokingChangeLabels, + getSmokingQuantity, + getSmokingStepHeading, + getSmokingTypeActions, + getSmokingTypeHeadings, + getSmokingTypeQuestionOverrides, + getSmokingTypeStep, + getSmokingTypeSteps, + getSmokingTypeStepUrl, + isPastSmokingType, + renderSmokingTypeQuestion, + 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..7a74075 --- /dev/null +++ b/app/prototype_v4_2/routes.js @@ -0,0 +1,265 @@ +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}/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-at-work`, questionController.asbestosAtWork_get) +router.post(`/prototype_${version}/asbestos-at-work`, questionController.asbestosAtWork_post) + +router.get(`/prototype_${version}/asbestos-at-home`, questionController.asbestosAtHome_get) +router.post(`/prototype_${version}/asbestos-at-home`, questionController.asbestosAtHome_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}/age-started-smoking`, questionController.ageStartedSmoking_get) +router.post(`/prototype_${version}/age-started-smoking`, questionController.ageStartedSmoking_post) + +router.get(`/prototype_${version}/age-stopped-smoking`, questionController.ageStoppedSmoking_get) +router.post(`/prototype_${version}/age-stopped-smoking`, questionController.ageStoppedSmoking_post) + +router.get(`/prototype_${version}/periods-stopped-smoking`, questionController.periodsStoppedSmoking_get) +router.post(`/prototype_${version}/periods-stopped-smoking`, questionController.periodsStoppedSmoking_post) + +/// Tobacco --------------------------------------------------------------- /// + +router.get(`/prototype_${version}/smoking-type`, questionController.smokingType_get) +router.post(`/prototype_${version}/smoking-type`, questionController.smokingType_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-frequency`, questionController.smokingFrequency_get) +router.post(`/prototype_${version}/smoking-frequency`, questionController.smokingFrequency_post) + +router.get(`/prototype_${version}/smoking-quantity`, questionController.smokingQuantity_get) +router.post(`/prototype_${version}/smoking-quantity`, questionController.smokingQuantity_post) + +router.get(`/prototype_${version}/smoking-setting`, questionController.smokingSetting_get) +router.post(`/prototype_${version}/smoking-setting`, questionController.smokingSetting_post) + +router.get(`/prototype_${version}/smoking-change`, questionController.smokingChange_get) +router.post(`/prototype_${version}/smoking-change`, questionController.smokingChange_post) + +router.get(`/prototype_${version}/smoking-frequency-change`, questionController.smokingFrequencyChange_get) +router.post(`/prototype_${version}/smoking-frequency-change`, questionController.smokingFrequencyChange_post) + +router.get(`/prototype_${version}/smoking-quantity-change`, questionController.smokingQuantityChange_get) +router.post(`/prototype_${version}/smoking-quantity-change`, questionController.smokingQuantityChange_post) + +router.get(`/prototype_${version}/smoking-years-change`, questionController.smokingYearsChange_get) +router.post(`/prototype_${version}/smoking-years-change`, questionController.smokingYearsChange_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..fbac5e9 --- /dev/null +++ b/app/prototype_v4_2/views/index.html @@ -0,0 +1,126 @@ +{% 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("/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-at-work", "Asbestos at work") }} + {{ pageLink("/asbestos-at-home", "Asbestos at home") }} + {{ 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("/age-started-smoking", "Age you started smoking") }} + {{ seededProfilePageLink("/age-stopped-smoking", "former", "Former smoker - age you stopped smoking") }} + {{ pageLink("/periods-stopped-smoking", "Periods when you stopped smoking") }} + {{ 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("/smoking-status?type=cigarettes", "Cigarettes - currently smoke") }} + {{ seededPageLink("/smoking-frequency?type=cigarettes", "Cigarettes - frequency") }} + {{ seededPageLink("/smoking-quantity?type=cigarettes", "Cigarettes - quantity") }} + {{ seededPageLink("/smoking-change?type=cigarettes", "Cigarettes - has quantity changed") }} + {{ seededPageLink("/smoking-frequency-change?type=cigarettes%26change=greater", "Cigarettes - more frequency") }} + {{ seededPageLink("/smoking-quantity-change?type=cigarettes%26change=greater", "Cigarettes - more quantity") }} + {{ seededPageLink("/smoking-years-change?type=cigarettes%26change=greater", "Cigarettes - more duration") }} + {{ seededProfilePageLink("/smoking-setting?type=shisha", "shisha", "Shisha - smoking setting") }} + {{ seededProfilePageLink("/smoking-frequency?type=shisha%26setting=group", "shisha", "Shisha in a group - frequency") }} + {{ seededProfilePageLink("/smoking-quantity?type=shisha%26setting=group", "shisha", "Shisha in a group - quantity") }} +
    + +

    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.html b/app/prototype_v4_2/views/questions/_question.html new file mode 100644 index 0000000..29f4327 --- /dev/null +++ b/app/prototype_v4_2/views/questions/_question.html @@ -0,0 +1,238 @@ +{% 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 %} + +
    + + {% if question.input.isPageHeading %} + {% set headingHtml %} + {% include prototype.viewPath + "/includes/page-heading-legend.html" %} + {% endset %} + {% set legend = { + html: headingHtml, + isPageHeading: true, + classes: "nhsuk-fieldset__legend--l" + } %} + {% else %} + {% set legend = { + text: question.input.label, + 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 %} + {% set label = { + html: headingHtml, + isPageHeading: true, + classes: "nhsuk-label--l" + } %} + {% else %} + {% set label = { + text: question.input.label, + 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" %} + {{ dateInput({ + id: question.answerKey, + fieldset: { + legend: legend + }, + hint: question.input.hintParam, + errorMessage: { + text: errors[0].text + } if errors | length, + 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 %} + + {{ 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 From 24231803a572bd70b8e74e2d47f1dfdf88246fa5 Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Wed, 20 May 2026 12:58:57 +0100 Subject: [PATCH 02/18] Reorganise prototype to allow multiple questions per page --- app/prototype_v4_2/controllers/question.js | 43 ++- app/prototype_v4_2/data/pages.yaml | 294 ++++++++++++++++ app/prototype_v4_2/data/questions.yaml | 318 ++---------------- app/prototype_v4_2/docs/question-flow.md | 9 +- app/prototype_v4_2/docs/question-schema.md | 133 +++++--- app/prototype_v4_2/lib/question-pages.js | 124 +++++++ app/prototype_v4_2/lib/question-renderer.js | 71 +++- app/prototype_v4_2/lib/question-validator.js | 14 +- app/prototype_v4_2/lib/questions.js | 4 +- app/prototype_v4_2/lib/summary.js | 8 +- app/prototype_v4_2/lib/tobacco-flow.js | 13 +- app/prototype_v4_2/routes.js | 3 + app/prototype_v4_2/views/index.html | 1 + .../views/questions/_question-input.html | 204 +++++++++++ .../views/questions/_question-page.html | 44 +++ .../views/questions/_question.html | 190 +---------- 16 files changed, 914 insertions(+), 559 deletions(-) create mode 100644 app/prototype_v4_2/data/pages.yaml create mode 100644 app/prototype_v4_2/lib/question-pages.js create mode 100644 app/prototype_v4_2/views/questions/_question-input.html create mode 100644 app/prototype_v4_2/views/questions/_question-page.html diff --git a/app/prototype_v4_2/controllers/question.js b/app/prototype_v4_2/controllers/question.js index 06c3efa..621e456 100644 --- a/app/prototype_v4_2/controllers/question.js +++ b/app/prototype_v4_2/controllers/question.js @@ -1,5 +1,6 @@ const { getDateOfBirth, isEligibleForScanAge } = require('../lib/eligibility') -const { renderQuestion, version, view } = require('../lib/question-renderer') +const { getQuestionPage } = require('../lib/question-pages') +const { renderQuestion, renderQuestionPage, version, view } = require('../lib/question-renderer') const { getCheckYourAnswers } = require('../lib/summary') const { deleteUnselectedShishaSettingAnswers, @@ -17,7 +18,11 @@ const { validateSmokingTypeQuestion } = require('../lib/tobacco-flow') const { getHeightBack, getWeightBack, getWeightNext } = require('../lib/unit-navigation') -const { validateQuestion } = require('../lib/question-validator') +const { validateQuestion, validateQuestions } = require('../lib/question-validator') + +const getQuestionPageIds = (id) => { + return getQuestionPage(id).questions.map((question) => question.id) +} /// ------------------------------------------------------------------------ /// /// @@ -275,7 +280,7 @@ exports.weightMetric_post = (req, res) => { }, errors) } else { delete answers.weight?.imperial - res.redirect(`/prototype_${version}/gender`) + res.redirect(`/prototype_${version}/about-you`) } } @@ -304,7 +309,33 @@ exports.weightImperial_post = (req, res) => { }, errors) } else { delete answers.weight?.metric - res.redirect(`/prototype_${version}/gender`) + res.redirect(`/prototype_${version}/about-you`) + } +} + +exports.aboutYou_get = (req, res) => { + const back = getWeightBack(req) + + renderQuestionPage(res, 'about-you', { + next: `/prototype_${version}/about-you`, + back, + cancel: `/prototype_${version}/` + }) +} + +exports.aboutYou_post = (req, res) => { + const { answers } = req.session.data + const back = getWeightBack(req) + const errors = validateQuestions(answers, getQuestionPageIds('about-you')) + + if (errors.length) { + renderQuestionPage(res, 'about-you', { + next: `/prototype_${version}/about-you`, + back, + cancel: `/prototype_${version}/` + }, errors) + } else { + res.redirect(`/prototype_${version}/respiratory-conditions`) } } @@ -410,7 +441,7 @@ exports.education_post = (req, res) => { exports.respiratoryConditions_get = (req, res) => { renderQuestion(res, 'respiratory-conditions', { next: `/prototype_${version}/respiratory-conditions`, - back: `/prototype_${version}/education`, + back: `/prototype_${version}/about-you`, cancel: `/prototype_${version}/` }) } @@ -422,7 +453,7 @@ exports.respiratoryConditions_post = (req, res) => { if (errors.length) { renderQuestion(res, 'respiratory-conditions', { next: `/prototype_${version}/respiratory-conditions`, - back: `/prototype_${version}/education`, + back: `/prototype_${version}/about-you`, cancel: `/prototype_${version}/` }, errors) } else { diff --git a/app/prototype_v4_2/data/pages.yaml b/app/prototype_v4_2/data/pages.yaml new file mode 100644 index 0000000..2647d3f --- /dev/null +++ b/app/prototype_v4_2/data/pages.yaml @@ -0,0 +1,294 @@ +--- +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 + questions: + - respiratory-conditions + - id: asbestos-at-work + questions: + - asbestos-at-work + heading: + title: Tell us if you might have been exposed to asbestos at work + caption: Your health + description: | + You may have been exposed to asbestos if you worked in an industry such as building or construction, particularly from the 1950s to the 1990s. + + You could be exposed to asbestos today if your job involves working in certain roles in old buildings. + + Examples include: + + - heating and ventilation engineers + - demolition workers + - plumbers + - construction workers + - electricians + + ## If you did not work with asbestos but think you might have been exposed + + You might know there was asbestos in buildings you have spent time in, or products you have used. But if the asbestos was not damaged, the risk to your health is very low. + + If you lived with someone who worked with asbestos you may have been exposed to asbestos yourself. For example, if you washed the clothes they worked in. The question on the next page will ask if you lived with someone who might have been exposed to asbestos at work. + details: + summary: What is asbestos? + text: | + Asbestos was used in a number of building materials and products. For example: + + - boilers and pipes + - car brakes + - cement for roofing sheets + - floor tiles + - insulating board to protect buildings and ships against fire + + If you worked in an industry such as building or construction, you are more likely to have come into contact with damaged asbestos. + - id: asbestos-at-home + questions: + - asbestos-at-home + heading: + title: Tell us if you ever lived with anyone who worked with asbestos + caption: Your health + description: | + If you lived with someone who worked with asbestos you may have been exposed to asbestos. For example, if you washed the clothes they worked in. + + Someone might have worked with asbestos if they worked in an industry such as building or construction, particularly from the 1950s to the 1990s. + details: + summary: What is asbestos? + text: | + Asbestos was used in a number of building materials and products. For example: + + - boilers and pipes + - car brakes + - cement for roofing sheets + - floor tiles + - insulating board to protect buildings and ships against fire + + If you worked in an industry such as building or construction, you are more likely to have come into contact with damaged asbestos. + - 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: age-started-smoking + questions: + - age-started-smoking + - id: age-stopped-smoking + questions: + - age-stopped-smoking + - id: periods-stopped-smoking + questions: + - periods-stopped-smoking + heading: + title: Periods when you stopped smoking + caption: Your smoking habits + description: | + There may have been periods when you stopped or quit smoking. + + If you stopped smoking for periods of 1 year or longer, tell us the total number of years you 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: smoking-frequency + questions: + - smoking-frequency + - id: smoking-quantity + questions: + - smoking-quantity + - id: smoking-setting + questions: + - smoking-setting + - id: smoking-change + questions: + - smoking-change + - id: smoking-frequency-change + questions: + - smoking-frequency-change + - id: smoking-quantity-change + questions: + - smoking-quantity-change + - id: smoking-years-change + questions: + - smoking-years-change diff --git a/app/prototype_v4_2/data/questions.yaml b/app/prototype_v4_2/data/questions.yaml index e94fbbe..99ba01c 100644 --- a/app/prototype_v4_2/data/questions.yaml +++ b/app/prototype_v4_2/data/questions.yaml @@ -3,12 +3,6 @@ questions: - id: accept-terms type: multiple answerKey: acceptTerms - 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). - input: - label: Confirm you agree to the terms of use options: - label: I agree value: yes @@ -17,25 +11,10 @@ questions: errors: required: text: Confirm that you have read and agree to the terms of use - + label: Confirm you agree to the terms of use - id: phone-questionnaire type: single answerKey: phoneQuestionnaire - 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 - input: - label: Have you previously completed a lung cancer risk questionnaire by phone? options: - label: Yes value: yes @@ -46,25 +25,11 @@ questions: errors: required: text: Select whether you have previously completed a lung cancer risk questionnaire by phone - + label: Have you previously completed a lung cancer risk questionnaire by phone? - id: smoker type: single answerKey: 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. input: - label: Have you ever smoked tobacco? hint: This includes social smoking options: - label: Yes, I currently smoke @@ -83,12 +48,10 @@ questions: errors: required: text: Select whether you have ever smoked tobacco - + label: Have you ever smoked tobacco? - id: date-of-birth type: date answerKey: dateOfBirth - heading: - title: What is your date of birth? input: hint: For example, 15 3 1964 items: @@ -116,23 +79,10 @@ questions: invalid: text: Enter a real date of birth href: "#dateOfBirth-day" - + label: What is your date of birth? - id: face-to-face-appointment type: single answerKey: faceToFaceAppointment - 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 - 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 @@ -145,22 +95,14 @@ questions: errors: required: text: Select whether you need to leave the online service and ask for a face-to-face appointment - + label: Do you need to leave the online service and ask for a face-to-face appointment? - id: height-metric type: text answerKey: height - 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. input: id: height-metric name: answers[height][metric] valueKey: metric - label: What is your height in centimetres? suffix: cm inputmode: numeric classes: nhsuk-input--width-4 @@ -182,19 +124,11 @@ questions: text: Height in centimetres must be 100 or more max: text: Height in centimetres must be 250 or fewer - + label: What is your height in centimetres? - id: height-imperial type: text_group answerKey: height - 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. input: - label: What is your height in feet and inches? valueKey: imperial items: - id: height-imperial-feet @@ -255,22 +189,14 @@ questions: max: text: Height in inches must be 11 or fewer href: "#height-imperial-inches" - + label: What is your height in feet and inches? - id: weight-metric type: text answerKey: weight - 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. 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 @@ -292,19 +218,11 @@ questions: text: Weight in kilograms must be 30 or more max: text: Weight in kilograms must be 250 or fewer - + label: What is your weight in kilograms? - id: weight-imperial type: text_group answerKey: weight - 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. input: - label: What is your weight in stones and pounds? valueKey: imperial items: - id: weight-imperial-stones @@ -365,19 +283,10 @@ questions: max: text: Weight in pounds must be 13 or fewer href: "#weight-imperial-pounds" - + label: What is your weight in stones and pounds? - id: gender type: single answerKey: 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. - input: - label: Which of these best describes you? options: - label: Female value: female @@ -395,17 +304,10 @@ questions: errors: required: text: Select which option best describes you - + label: Which of these best describes your gender identity? - id: sex type: single answerKey: sex - heading: - title: Your sex at birth - caption: About you - description: | - Your sex may impact your chances of developing lung cancer. - input: - label: What was your sex at birth? options: - label: Female value: female @@ -418,17 +320,10 @@ questions: errors: required: text: Select your sex at birth - + label: What was your sex at birth? - id: ethnicity type: single answerKey: 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. - input: - label: What is your ethnic background? options: - label: Asian or Asian British value: asian_or_asian_british @@ -450,19 +345,10 @@ questions: errors: required: text: Select your ethnic background - + label: What is your ethnic background? - id: education type: single answerKey: 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. - 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 @@ -491,13 +377,10 @@ questions: errors: required: text: Select the highest level of education you have completed - + label: What is the highest level of education have you completed? - id: respiratory-conditions type: multiple answerKey: respiratoryConditions - heading: - title: Have you ever been diagnosed with any of the following respiratory conditions? - caption: Your health input: hint: Select all that apply options: @@ -533,45 +416,11 @@ questions: errors: required: text: Select if you have ever been diagnosed with any respiratory conditions - + label: Have you ever been diagnosed with any of the following respiratory conditions? + caption: Your health - id: asbestos-at-work type: single answerKey: asbestosAtWork - heading: - title: Tell us if you might have been exposed to asbestos at work - caption: Your health - description: | - You may have been exposed to asbestos if you worked in an industry such as building or construction, particularly from the 1950s to the 1990s. - - You could be exposed to asbestos today if your job involves working in certain roles in old buildings. - - Examples include: - - - heating and ventilation engineers - - demolition workers - - plumbers - - construction workers - - electricians - - ## If you did not work with asbestos but think you might have been exposed - - You might know there was asbestos in buildings you have spent time in, or products you have used. But if the asbestos was not damaged, the risk to your health is very low. - - If you lived with someone who worked with asbestos you may have been exposed to asbestos yourself. For example, if you washed the clothes they worked in. The question on the next page will ask if you lived with someone who might have been exposed to asbestos at work. - details: - summary: What is asbestos? - text: | - Asbestos was used in a number of building materials and products. For example: - - - boilers and pipes - - car brakes - - cement for roofing sheets - - floor tiles - - insulating board to protect buildings and ships against fire - - If you worked in an industry such as building or construction, you are more likely to have come into contact with damaged asbestos. - input: - label: Have you ever worked in a job where you might have been exposed to asbestos? options: - label: Yes value: yes @@ -584,31 +433,10 @@ questions: errors: required: text: Select whether you have ever worked in a job where you might have been exposed to asbestos - + label: Have you ever worked in a job where you might have been exposed to asbestos? - id: asbestos-at-home type: single answerKey: asbestosAtHome - heading: - title: Tell us if you ever lived with anyone who worked with asbestos - caption: Your health - description: | - If you lived with someone who worked with asbestos you may have been exposed to asbestos. For example, if you washed the clothes they worked in. - - Someone might have worked with asbestos if they worked in an industry such as building or construction, particularly from the 1950s to the 1990s. - details: - summary: What is asbestos? - text: | - Asbestos was used in a number of building materials and products. For example: - - - boilers and pipes - - car brakes - - cement for roofing sheets - - floor tiles - - insulating board to protect buildings and ships against fire - - If you worked in an industry such as building or construction, you are more likely to have come into contact with damaged asbestos. - input: - label: Have you ever lived with anyone who worked with asbestos? options: - label: Yes value: yes @@ -621,17 +449,10 @@ questions: errors: required: text: Select whether you have ever lived with anyone who worked with asbestos - + label: Have you ever lived with anyone who worked with asbestos? - id: cancer-diagnosis type: single answerKey: cancerDiagnosis - 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. - input: - label: Have you ever been diagnosed with cancer? options: - label: Yes value: yes @@ -644,19 +465,10 @@ questions: errors: required: text: Select whether you have ever been diagnosed with cancer - + label: Have you ever been diagnosed with cancer? - id: cancer-diagnosis-relatives type: single answerKey: cancerDiagnosisRelatives - 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. - input: - label: Have any of your parents, siblings or children ever been diagnosed with lung cancer? options: - label: Yes value: yes @@ -671,19 +483,10 @@ questions: errors: required: text: Select whether any of your parents, siblings or children have ever been diagnosed with lung cancer - + label: Have any of your parents, siblings or children ever been diagnosed with lung cancer? - id: cancer-diagnosis-relatives-age type: single answerKey: cancerDiagnosisRelativesAge - 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'. - 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 @@ -698,13 +501,10 @@ questions: errors: required: text: Select whether any of your relatives were younger than 60 when they were diagnosed with lung cancer - + label: Were any of your relatives younger than 60 years old when they were diagnosed with lung cancer? - id: age-started-smoking type: text answerKey: ageStartedSmoking - heading: - title: How old were you when you started smoking? - caption: Your smoking habits input: hint: Give an estimate if you are not sure prefix: Age @@ -726,13 +526,11 @@ questions: text: Age you started smoking must be 1 or older max: text: Age you started smoking must be 120 or younger - + label: How old were you when you started smoking? + caption: Your smoking habits - id: age-stopped-smoking type: text answerKey: ageStoppedSmoking - heading: - title: How old were you when you stopped smoking? - caption: Your smoking habits input: hint: Give an estimate if you are not sure prefix: Age @@ -754,19 +552,11 @@ questions: text: Age you stopped smoking must be 1 or older max: text: Age you stopped smoking must be 120 or younger - + label: How old were you when you stopped smoking? + caption: Your smoking habits - id: periods-stopped-smoking type: single answerKey: periodsStoppedSmoking - heading: - title: Periods when you stopped smoking - caption: Your smoking habits - description: | - There may have been periods when you stopped or quit smoking. - - If you stopped smoking for periods of 1 year or longer, tell us the total number of years you stopped smoking. - input: - label: Have you ever stopped smoking for periods of 1 year or longer? options: - label: Yes value: yes @@ -810,21 +600,11 @@ questions: max: text: Total number of years you stopped smoking must be 80 or fewer href: "#years-stopped-smoking" - + label: Have you ever stopped smoking for periods of 1 year or longer? - id: smoking-type type: multiple answerKey: smokingType - 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. input: - label: What do you or have you smoked? hint: Select all that apply options: - label: Cigarettes @@ -863,16 +643,7 @@ questions: exclusiveGroup: types-list 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. - input: - label: What have you smoked? + label: What have you smoked? summary: label: Types of tobacco smoked validation: @@ -880,11 +651,9 @@ questions: errors: required: text: Select the types of tobacco you have smoked - + label: What do you or have you smoked? - id: smoking-status type: single - heading: - title: Do you currently smoke? options: - label: Yes value: yes @@ -895,11 +664,9 @@ questions: errors: required: text: Select whether you currently smoke this type of tobacco - + label: Do you currently smoke? - id: smoking-frequency type: single - heading: - title: How often do you smoke? options: - label: Daily value: daily @@ -917,11 +684,9 @@ questions: errors: required: text: Select how often you smoke this type of tobacco - + label: How often do you smoke? - id: smoking-quantity type: text - heading: - title: How much do you smoke? input: id: smoking-quantity hint: Give an estimate if you are not sure @@ -950,7 +715,7 @@ questions: shisha: type: single input: - hint: + hint: null options: - label: Up to 30 minutes value: up_to_30_minutes @@ -988,11 +753,9 @@ questions: text: Amount smoked must be 1 or more max: text: Amount smoked must be 200 or fewer - + label: How much do you smoke? - id: smoking-setting type: multiple - heading: - title: Do you usually smoke shisha in a group or on your own? input: hint: Select all that apply options: @@ -1005,11 +768,9 @@ questions: errors: required: text: Select whether you usually smoke shisha in a group or on your own - + label: Do you usually smoke shisha in a group or on your own? - id: smoking-change type: multiple - heading: - title: Has the amount you normally smoke changed over time? input: hint: Select all that apply options: @@ -1029,11 +790,9 @@ questions: errors: required: text: Select whether the amount you normally smoke has changed over time - + label: Has the amount you normally smoke changed over time? - id: smoking-frequency-change type: single - heading: - title: How often did you smoke? options: - label: Daily value: daily @@ -1051,11 +810,9 @@ questions: errors: required: text: Select how often you smoked this type of tobacco - + label: How often did you smoke? - id: smoking-quantity-change type: text - heading: - title: How much did you smoke? input: id: smoking-quantity-change hint: Give an estimate if you are not sure @@ -1081,7 +838,7 @@ questions: value: more_than_100 validation: required: true - type: + type: null validation: required: true type: number @@ -1096,11 +853,9 @@ questions: text: Amount smoked must be 1 or more max: text: Amount smoked must be 200 or fewer - + label: How much did you smoke? - id: smoking-years-change type: text - heading: - title: How many years did you smoke this amount? input: id: smoking-years-change hint: Give a rough estimate @@ -1121,3 +876,4 @@ questions: text: Number of years must be 1 or more max: text: Number of years must be 80 or fewer + label: How many years did you smoke this amount? diff --git a/app/prototype_v4_2/docs/question-flow.md b/app/prototype_v4_2/docs/question-flow.md index 33b20de..61862a9 100644 --- a/app/prototype_v4_2/docs/question-flow.md +++ b/app/prototype_v4_2/docs/question-flow.md @@ -30,13 +30,10 @@ flowchart TD weight{"Weight"} -- Metric --> weightMetric["Weight - metric"] weight -- Imperial --> weightImperial["Weight - imperial"] - weightMetric --> gender["Gender"] - weightImperial --> gender + weightMetric --> aboutYou["About you
    Gender, sex, ethnicity and education"] + weightImperial --> aboutYou - gender --> sex["Sex"] - sex --> ethnicity["Ethnicity"] - ethnicity --> education["Education"] - education --> respiratory["Respiratory conditions"] + aboutYou --> respiratory["Respiratory conditions"] respiratory --> asbestosWork["Asbestos at work"] asbestosWork --> asbestosHome["Asbestos at home"] asbestosHome --> cancerDiagnosis["Cancer diagnosis"] diff --git a/app/prototype_v4_2/docs/question-schema.md b/app/prototype_v4_2/docs/question-schema.md index fd06773..e6f7bb4 100644 --- a/app/prototype_v4_2/docs/question-schema.md +++ b/app/prototype_v4_2/docs/question-schema.md @@ -1,25 +1,21 @@ # Question schema -Question content for prototype v4.1 lives in `app/prototype_v4_2/data/questions.yaml`. +Question form-control content for prototype v4.2 lives in `app/prototype_v4_2/data/questions.yaml`. -The file should only contain standard question content 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`. +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. +Each item in `questions` represents one reusable question control. ```yaml questions: - id: education type: single answerKey: 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. - input: - label: What is the highest level of education have you completed? + label: What is the highest level of education have you completed? options: - label: GCSEs hint: Previously O-levels @@ -36,6 +32,49 @@ questions: 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 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)` for grouped pages. +5. Validate grouped pages with `validateQuestions(answers, questionIds)`. + ## Common fields | Field | Required | Description | @@ -43,10 +82,7 @@ questions: | `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. | -| `heading.title` | Yes | Page heading or fieldset legend. | -| `heading.caption` | No | Caption shown above the heading. | -| `description` | No | Markdown content shown between the heading and input label. | -| `details` | No | NHS details component content. | +| `label` | Yes | Question label shown above the input, unless the page has no heading and the label is promoted to the page heading. | | `input` | Usually | Input, radios or checkboxes configuration. | | `options` | For choice questions | Options for radios or checkboxes. | | `summary` | No | Check your answers label and hidden text. | @@ -55,37 +91,43 @@ questions: | `variants` | No | Alternative content used by controller or flow overrides. | | `switchUnits` | No | Link text for height and weight unit switching. | -## Heading and input labels +## Page Headings -If `input.label` is not set, the renderer uses `heading.title` as the input label and sets `isPageHeading` to `true`. +Put page headings, captions, descriptions and details in `pages.yaml`. -Use this when the visible question text should be the page heading and the input label, for example: +For example, a one-question page with separate page content: ```yaml -- id: age-started-smoking - type: text - heading: - title: How old were you when you started smoking? - caption: Your smoking habits - input: - hint: Give an estimate if you are not sure +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 ``` -Use `input.label` when the page needs separate introductory heading content and a specific fieldset or input label: +The question label stays in `questions.yaml`: ```yaml -- id: smoker - type: single - heading: - title: Tobacco smoking - input: - label: Have you ever smoked tobacco? - hint: This includes social smoking +questions: + - id: phone-questionnaire + type: single + answerKey: phoneQuestionnaire + 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`, the renderer falls back to the first question label as the page heading and sets `isPageHeading: true`. + ## Descriptions and details -`description` supports Markdown and is rendered between the page heading and the input label. +Page `description` supports Markdown and is rendered between the page heading and the input. Use `details` for expandable supporting content: @@ -106,10 +148,8 @@ Renders NHS radios. - id: smoker type: single answerKey: smoker - heading: - title: Tobacco smoking + label: Have you ever smoked tobacco? input: - label: Have you ever smoked tobacco? hint: This includes social smoking options: - label: Yes, I currently smoke @@ -131,9 +171,7 @@ Renders NHS checkboxes. - id: respiratory-conditions type: multiple answerKey: respiratoryConditions - heading: - title: Have you ever been diagnosed with any of the following respiratory conditions? - caption: Your health + label: Have you ever been diagnosed with any of the following respiratory conditions? input: hint: Select all that apply options: @@ -157,14 +195,11 @@ Renders a single NHS input. - id: height-metric type: text answerKey: height - heading: - title: Your height - caption: About you + label: What is your height in centimetres? input: id: height-metric name: answers[height][metric] valueKey: metric - label: What is your height in centimetres? suffix: cm inputmode: numeric classes: nhsuk-input--width-4 @@ -180,11 +215,8 @@ Renders a group of related text inputs in one fieldset, for example feet and inc - id: height-imperial type: text_group answerKey: height - heading: - title: Your height - caption: About you + label: What is your height in feet and inches? input: - label: What is your height in feet and inches? valueKey: imperial items: - id: height-imperial-feet @@ -209,8 +241,7 @@ Renders an NHS date input. - id: date-of-birth type: date answerKey: dateOfBirth - heading: - title: What is your date of birth? + label: What is your date of birth? input: hint: For example, 15 3 1964 items: @@ -356,13 +387,11 @@ Use `variants` when the same question needs alternative content that is selected ```yaml variants: previous: - heading: - title: The type of tobacco you have smoked input: label: What have you smoked? ``` -Keep variants small. If a question becomes difficult to understand because it has too many variants, consider using separate question IDs instead. +Keep variants small. Put variant page headings and descriptions in `pages.yaml`. ## Hot reload 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..9e0632b --- /dev/null +++ b/app/prototype_v4_2/lib/question-pages.js @@ -0,0 +1,124 @@ +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.label) { + return undefined + } + + return { + title: question.label, + caption: question.caption + } +} + +const mergeQuestionWithPageContent = (question, pageContent = {}, options = {}) => { + const isSingleQuestionPage = options.isSingleQuestionPage === true + const hasPageHeading = Boolean(options.pageHeading?.title) + const heading = pageContent.heading || getQuestionHeading(question) + const input = { + ...question.input + } + + if (isSingleQuestionPage && !hasPageHeading && heading?.title) { + input.label = heading.title + input.isPageHeading = true + } else if (question.label && !input.label) { + input.label = question.label + input.isPageHeading = false + } 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) => { + refreshPages() + + const page = pages[id] + + if (!page) { + throw new Error(`Question page not found: ${id}`) + } + + const pageQuestions = page.questions || [] + const questions = pageQuestions.map((item) => { + const questionRef = typeof item === 'string' ? { id: item } : item + const question = getQuestion(questionRef.id) + const isSingleQuestionPage = pageQuestions.length === 1 + const pageContent = pageQuestions.length === 1 + ? { + ...questionRef + } + : questionRef + + return mergeQuestionWithPageContent(question, pageContent, { + isSingleQuestionPage, + pageHeading: page.heading + }) + }) + const firstQuestion = questions[0] + + return { + ...page, + heading: page.heading || firstQuestion?.page?.heading, + description: page.description !== undefined + ? page.description + : pageQuestions.length === 1 ? firstQuestion?.page?.description : undefined, + details: page.details !== undefined + ? page.details + : pageQuestions.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 index 6f079a7..efc87dc 100644 --- a/app/prototype_v4_2/lib/question-renderer.js +++ b/app/prototype_v4_2/lib/question-renderer.js @@ -1,4 +1,5 @@ const { getQuestion } = require('./questions') +const { getQuestionPage } = require('./question-pages') const settings = require('./settings') const { version, view } = settings @@ -13,29 +14,68 @@ const { version, view } = settings * @param {Object} [overrides] - Runtime overrides for dynamic question content. */ const renderQuestion = (res, id, actions, errors = [], overrides = {}) => { - const question = getQuestion(id) - const errorMap = errors.reduce((map, error) => { - if (error.href) { - map[error.href.replace('#', '')] = error + const page = getQuestionPage(id) + const question = page.questions[0] || getQuestion(id) + const label = overrides.label || question.label + const heading = { + ...page.heading, + ...overrides.heading + } + const input = { + ...question.input, + ...overrides.input + } + + if (overrides.label) { + if (!heading.title) { + heading.title = overrides.label } - return map - }, {}) + input.label = overrides.label + + if (input.isPageHeading) { + input.label = overrides.label + } + } res.render(view('questions/_question'), { question: { ...question, ...overrides, - heading: { - ...question.heading, - ...overrides.heading - }, - input: { - ...question.input, - ...overrides.input - } + label, + heading, + description: overrides.description !== undefined ? overrides.description : page.description, + details: overrides.details !== undefined ? overrides.details : page.details, + input }, - errorMap, + errorMap: getErrorMap(errors), + errors, + actions + }) +} + +const getErrorMap = (errors = []) => { + return errors.reduce((map, error) => { + if (error.href) { + map[error.href.replace('#', '')] = error + } + + return map + }, {}) +} + +/** + * 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 = []) => { + res.render(view('questions/_question-page'), { + page: getQuestionPage(id), + errorMap: getErrorMap(errors), errors, actions }) @@ -43,6 +83,7 @@ const renderQuestion = (res, id, actions, errors = [], overrides = {}) => { 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 index 35d5ecf..db2953f 100644 --- a/app/prototype_v4_2/lib/question-validator.js +++ b/app/prototype_v4_2/lib/question-validator.js @@ -300,6 +300,18 @@ const validateQuestion = (answers = {}, id, overrides = {}) => { 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 + validateQuestion, + validateQuestions } diff --git a/app/prototype_v4_2/lib/questions.js b/app/prototype_v4_2/lib/questions.js index 466aae1..90c174e 100644 --- a/app/prototype_v4_2/lib/questions.js +++ b/app/prototype_v4_2/lib/questions.js @@ -28,7 +28,6 @@ let loadedAt = {} * @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} heading - Heading content. * @property {Object} input - Normalised input config for NHS components. * @property {QuestionOption[]} [options] - Raw YAML options. * @property {Object[]} items - Options converted to NHS component items. @@ -113,7 +112,6 @@ const toComponentItem = (option) => { const normaliseQuestion = (question) => { const answerKey = question.answerKey || toAnswerName(question.id) const input = question.input || {} - const label = input.label || question.heading?.title return { ...question, @@ -121,7 +119,7 @@ const normaliseQuestion = (question) => { input: { ...input, id: input.id || question.id, - label, + label: input.label, name: input.name || `answers[${answerKey}]`, hintParam: input.hint ? { text: input.hint } : undefined, isPageHeading: !input.label diff --git a/app/prototype_v4_2/lib/summary.js b/app/prototype_v4_2/lib/summary.js index 3004166..84e846a 100644 --- a/app/prototype_v4_2/lib/summary.js +++ b/app/prototype_v4_2/lib/summary.js @@ -339,22 +339,22 @@ const getCheckYourAnswers = (answers = {}) => { makeSummaryRow({ key: 'Gender identity', value: formatValue(answers.gender, valueLabels.gender), - href: `/prototype_${version}/gender` + href: `/prototype_${version}/about-you` }), makeSummaryRow({ key: 'Sex at birth', value: formatValue(answers.sex, valueLabels.sex), - href: `/prototype_${version}/sex` + href: `/prototype_${version}/about-you` }), makeSummaryRow({ key: 'Ethnic background', value: formatValue(answers.ethnicity, valueLabels.ethnicity), - href: `/prototype_${version}/ethnicity` + href: `/prototype_${version}/about-you` }), makeSummaryRow({ key: 'Education', value: formatValue(answers.education, valueLabels.education), - href: `/prototype_${version}/education` + href: `/prototype_${version}/about-you` }) ]), health: makeSummaryRows([ diff --git a/app/prototype_v4_2/lib/tobacco-flow.js b/app/prototype_v4_2/lib/tobacco-flow.js index 490b9b3..081ab2e 100644 --- a/app/prototype_v4_2/lib/tobacco-flow.js +++ b/app/prototype_v4_2/lib/tobacco-flow.js @@ -6,6 +6,7 @@ const { shishaSmokingSettings, tobaccoTypes: smokingTypes } = require('./questions') +const { getQuestionPage } = require('./question-pages') const { renderQuestion, version } = require('./question-renderer') const { validateQuestion } = require('./question-validator') @@ -581,7 +582,7 @@ const getSmokingChangeHeading = (page, type, change, changeAnswer = {}, answer = const quantity = getSmokingQuantity(type, changeAnswer.quantity) if (!quantity) { - return getQuestion('smoking-years-change').heading.title + return getQuestionPage('smoking-years-change').heading.title } return `How many years did you smoke ${[quantity, getSmokingFrequencyPeriod(answer.smokingFrequency)].filter(Boolean).join(' ')}?` @@ -634,9 +635,17 @@ const getFormerSmokerFallbackStep = (req, page, steps) => { */ const getSmokingTypeQuestionOverrides = (answers = {}) => { const question = getQuestion('smoking-type') + const page = getQuestionPage('smoking-type') if (answers.smoker === 'yes_previous') { - return question.variants.previous + return { + ...question.variants.previous, + ...page.variants?.previous, + input: { + ...question.variants.previous?.input, + ...page.variants?.previous?.input + } + } } return {} diff --git a/app/prototype_v4_2/routes.js b/app/prototype_v4_2/routes.js index 7a74075..559aa18 100644 --- a/app/prototype_v4_2/routes.js +++ b/app/prototype_v4_2/routes.js @@ -109,6 +109,9 @@ router.post(`/prototype_${version}/weight-metric`, questionController.weightMetr 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) diff --git a/app/prototype_v4_2/views/index.html b/app/prototype_v4_2/views/index.html index fbac5e9..708ee33 100644 --- a/app/prototype_v4_2/views/index.html +++ b/app/prototype_v4_2/views/index.html @@ -56,6 +56,7 @@

    About you

    {{ 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") }} 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 index 29f4327..fa4fc85 100644 --- a/app/prototype_v4_2/views/questions/_question.html +++ b/app/prototype_v4_2/views/questions/_question.html @@ -33,195 +33,7 @@
    - {% if question.input.isPageHeading %} - {% set headingHtml %} - {% include prototype.viewPath + "/includes/page-heading-legend.html" %} - {% endset %} - {% set legend = { - html: headingHtml, - isPageHeading: true, - classes: "nhsuk-fieldset__legend--l" - } %} - {% else %} - {% set legend = { - text: question.input.label, - 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 %} - {% set label = { - html: headingHtml, - isPageHeading: true, - classes: "nhsuk-label--l" - } %} - {% else %} - {% set label = { - text: question.input.label, - 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" %} - {{ dateInput({ - id: question.answerKey, - fieldset: { - legend: legend - }, - hint: question.input.hintParam, - errorMessage: { - text: errors[0].text - } if errors | length, - 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 %} + {% include prototype.viewPath + "/questions/_question-input.html" %} {{ button({ text: "Continue" From 10c36f53361f45f45a90cf2242e1b1ebf716be22 Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Wed, 20 May 2026 14:33:22 +0100 Subject: [PATCH 03/18] Update question label --- app/prototype_v4_2/data/questions.yaml | 85 ++++++++++++--------- app/prototype_v4_2/docs/question-schema.md | 18 +++-- app/prototype_v4_2/lib/question-pages.js | 8 +- app/prototype_v4_2/lib/question-renderer.js | 10 +-- 4 files changed, 66 insertions(+), 55 deletions(-) diff --git a/app/prototype_v4_2/data/questions.yaml b/app/prototype_v4_2/data/questions.yaml index 99ba01c..b0206fd 100644 --- a/app/prototype_v4_2/data/questions.yaml +++ b/app/prototype_v4_2/data/questions.yaml @@ -3,6 +3,8 @@ questions: - id: accept-terms type: multiple answerKey: acceptTerms + input: + label: Confirm you agree to the terms of use options: - label: I agree value: yes @@ -11,7 +13,6 @@ questions: errors: required: text: Confirm that you have read and agree to the terms of use - label: Confirm you agree to the terms of use - id: phone-questionnaire type: single answerKey: phoneQuestionnaire @@ -25,11 +26,13 @@ questions: errors: required: text: Select whether you have previously completed a lung cancer risk questionnaire by phone - label: Have you 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 @@ -48,11 +51,11 @@ questions: errors: required: text: Select whether you have ever smoked tobacco - label: Have you 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 @@ -79,10 +82,11 @@ questions: invalid: text: Enter a real date of birth href: "#dateOfBirth-day" - label: What is your date of birth? - 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 @@ -95,13 +99,13 @@ questions: errors: required: text: Select whether you need to leave the online service and ask for a face-to-face appointment - label: Do 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 @@ -124,12 +128,12 @@ questions: text: Height in centimetres must be 100 or more max: text: Height in centimetres must be 250 or fewer - label: What is your height in centimetres? - 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] @@ -189,7 +193,6 @@ questions: max: text: Height in inches must be 11 or fewer href: "#height-imperial-inches" - label: What is your height in feet and inches? - id: weight-metric type: text answerKey: weight @@ -197,6 +200,7 @@ questions: 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 @@ -218,7 +222,6 @@ questions: text: Weight in kilograms must be 30 or more max: text: Weight in kilograms must be 250 or fewer - label: What is your weight in kilograms? - id: weight-imperial type: text_group answerKey: weight @@ -239,6 +242,7 @@ questions: suffix: lb inputmode: numeric classes: nhsuk-input--width-4 + label: What is your weight in stones and pounds? switchUnits: text: Switch to kilograms summary: @@ -283,10 +287,11 @@ questions: max: text: Weight in pounds must be 13 or fewer href: "#weight-imperial-pounds" - label: What is your weight in stones and pounds? - id: gender type: single answerKey: gender + input: + label: Which of these best describes your gender identity? options: - label: Female value: female @@ -303,8 +308,7 @@ questions: required: true errors: required: - text: Select which option best describes you - label: Which of these best describes your gender identity? + text: Select which option best describes your gender identity - id: sex type: single answerKey: sex @@ -320,10 +324,13 @@ questions: errors: required: text: Select your sex at birth - label: What was 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 @@ -345,10 +352,11 @@ questions: errors: required: text: Select your ethnic background - label: What is 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 @@ -377,12 +385,12 @@ questions: errors: required: text: Select the highest level of education you have completed - label: What is the highest level of education have you completed? - id: respiratory-conditions type: multiple answerKey: respiratoryConditions input: hint: Select all that apply + label: Have you ever been diagnosed with any of the following respiratory conditions? options: - label: Bronchitis hint: An inflammation of the airways in the lungs that is usually caused by an infection @@ -416,7 +424,6 @@ questions: errors: required: text: Select if you have ever been diagnosed with any respiratory conditions - label: Have you ever been diagnosed with any of the following respiratory conditions? caption: Your health - id: asbestos-at-work type: single @@ -433,7 +440,8 @@ questions: errors: required: text: Select whether you have ever worked in a job where you might have been exposed to asbestos - label: Have you ever worked in a job where you might have been exposed to asbestos? + input: + label: Have you ever worked in a job where you might have been exposed to asbestos? - id: asbestos-at-home type: single answerKey: asbestosAtHome @@ -449,10 +457,13 @@ questions: errors: required: text: Select whether you have ever lived with anyone who worked with asbestos - label: Have you ever lived with anyone who worked with asbestos? + input: + label: Have you 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 @@ -465,10 +476,11 @@ questions: errors: required: text: Select whether you have ever been diagnosed with cancer - label: Have you 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 @@ -483,10 +495,11 @@ questions: errors: required: text: Select whether any of your parents, siblings or children have ever been diagnosed with lung cancer - label: Have any of your parents, siblings or children 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 @@ -501,11 +514,11 @@ questions: errors: required: text: Select whether any of your relatives were younger than 60 when they were diagnosed with lung cancer - label: Were any of your relatives younger than 60 years old 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 @@ -526,12 +539,12 @@ questions: text: Age you started smoking must be 1 or older max: text: Age you started smoking must be 120 or younger - label: How old were you when you started smoking? 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 @@ -552,7 +565,6 @@ questions: text: Age you stopped smoking must be 1 or older max: text: Age you stopped smoking must be 120 or younger - label: How old were you when you stopped smoking? caption: Your smoking habits - id: periods-stopped-smoking type: single @@ -566,7 +578,7 @@ questions: answerKey: yearsStoppedSmoking label: Enter the total number of years you stopped smoking hint: Give an estimate if you are not sure - suffix: Years + suffix: years inputmode: numeric classes: nhsuk-input--width-4 - label: No @@ -600,11 +612,13 @@ questions: max: text: Total number of years you stopped smoking must be 80 or fewer href: "#years-stopped-smoking" - label: Have you ever stopped smoking for periods of 1 year or longer? + 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 @@ -643,7 +657,8 @@ questions: exclusiveGroup: types-list variants: previous: - label: What have you smoked? + input: + label: What have you smoked? summary: label: Types of tobacco smoked validation: @@ -651,9 +666,10 @@ questions: errors: required: text: Select the types of tobacco you have smoked - label: What do you or have you smoked? - id: smoking-status type: single + input: + label: Do you currently smoke? options: - label: Yes value: yes @@ -664,9 +680,10 @@ questions: errors: required: text: Select whether you currently smoke this type of tobacco - label: Do you currently smoke? - id: smoking-frequency type: single + input: + label: How often do you smoke? options: - label: Daily value: daily @@ -684,11 +701,11 @@ questions: errors: required: text: Select how often you smoke this type of tobacco - label: How often do you smoke? - 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 @@ -753,10 +770,10 @@ questions: text: Amount smoked must be 1 or more max: text: Amount smoked must be 200 or fewer - label: How much do you smoke? - id: smoking-setting type: multiple input: + label: Do you usually smoke shisha in a group or on your own? hint: Select all that apply options: - label: In a group @@ -768,10 +785,10 @@ questions: errors: required: text: Select whether you usually smoke shisha in a group or on your own - label: Do you usually smoke shisha in a group or on your own? - 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 @@ -790,9 +807,10 @@ questions: errors: required: text: Select whether the amount you normally smoke has changed over time - label: Has the amount you normally smoke changed over time? - id: smoking-frequency-change type: single + input: + label: How often did you smoke? options: - label: Daily value: daily @@ -810,11 +828,11 @@ questions: errors: required: text: Select how often you smoked this type of tobacco - label: How often did you smoke? - 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 @@ -853,11 +871,11 @@ questions: text: Amount smoked must be 1 or more max: text: Amount smoked must be 200 or fewer - label: How much did you smoke? - 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 @@ -876,4 +894,3 @@ questions: text: Number of years must be 1 or more max: text: Number of years must be 80 or fewer - label: How many years did you smoke this amount? diff --git a/app/prototype_v4_2/docs/question-schema.md b/app/prototype_v4_2/docs/question-schema.md index e6f7bb4..4a4f24b 100644 --- a/app/prototype_v4_2/docs/question-schema.md +++ b/app/prototype_v4_2/docs/question-schema.md @@ -15,7 +15,8 @@ questions: - id: education type: single answerKey: education - label: What is the highest level of education have you completed? + input: + label: What is the highest level of education have you completed? options: - label: GCSEs hint: Previously O-levels @@ -82,8 +83,8 @@ To add another page: | `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. | -| `label` | Yes | Question label shown above the input, unless the page has no heading and the label is promoted to the page heading. | | `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. | @@ -115,7 +116,8 @@ questions: - id: phone-questionnaire type: single answerKey: phoneQuestionnaire - label: Have you previously completed a lung cancer risk questionnaire by phone? + input: + label: Have you previously completed a lung cancer risk questionnaire by phone? options: - label: Yes value: yes @@ -148,8 +150,8 @@ Renders NHS radios. - id: smoker type: single answerKey: smoker - label: Have you ever smoked tobacco? input: + label: Have you ever smoked tobacco? hint: This includes social smoking options: - label: Yes, I currently smoke @@ -171,8 +173,8 @@ Renders NHS checkboxes. - id: respiratory-conditions type: multiple answerKey: respiratoryConditions - label: Have you ever been diagnosed with any of the following respiratory conditions? input: + label: Have you ever been diagnosed with any of the following respiratory conditions? hint: Select all that apply options: - label: Bronchitis @@ -195,8 +197,8 @@ Renders a single NHS input. - id: height-metric type: text answerKey: height - label: What is your height in centimetres? input: + label: What is your height in centimetres? id: height-metric name: answers[height][metric] valueKey: metric @@ -215,8 +217,8 @@ Renders a group of related text inputs in one fieldset, for example feet and inc - id: height-imperial type: text_group answerKey: height - label: What is your height in feet and inches? input: + label: What is your height in feet and inches? valueKey: imperial items: - id: height-imperial-feet @@ -241,8 +243,8 @@ Renders an NHS date input. - id: date-of-birth type: date answerKey: dateOfBirth - label: What is your date of birth? input: + label: What is your date of birth? hint: For example, 15 3 1964 items: - id: dateOfBirth-day diff --git a/app/prototype_v4_2/lib/question-pages.js b/app/prototype_v4_2/lib/question-pages.js index 9e0632b..f35a779 100644 --- a/app/prototype_v4_2/lib/question-pages.js +++ b/app/prototype_v4_2/lib/question-pages.js @@ -33,13 +33,12 @@ const refreshPages = (force = false) => { refreshPages(true) const getQuestionHeading = (question) => { - if (!question.label) { + if (!question.input?.label) { return undefined } return { - title: question.label, - caption: question.caption + title: question.input.label } } @@ -54,9 +53,6 @@ const mergeQuestionWithPageContent = (question, pageContent = {}, options = {}) if (isSingleQuestionPage && !hasPageHeading && heading?.title) { input.label = heading.title input.isPageHeading = true - } else if (question.label && !input.label) { - input.label = question.label - input.isPageHeading = false } else if (heading?.title && !input.label) { input.label = heading.title input.isPageHeading = false diff --git a/app/prototype_v4_2/lib/question-renderer.js b/app/prototype_v4_2/lib/question-renderer.js index efc87dc..b9e1d19 100644 --- a/app/prototype_v4_2/lib/question-renderer.js +++ b/app/prototype_v4_2/lib/question-renderer.js @@ -16,7 +16,6 @@ const { version, view } = settings const renderQuestion = (res, id, actions, errors = [], overrides = {}) => { const page = getQuestionPage(id) const question = page.questions[0] || getQuestion(id) - const label = overrides.label || question.label const heading = { ...page.heading, ...overrides.heading @@ -26,15 +25,13 @@ const renderQuestion = (res, id, actions, errors = [], overrides = {}) => { ...overrides.input } - if (overrides.label) { + if (overrides.input?.label) { if (!heading.title) { - heading.title = overrides.label + heading.title = overrides.input.label } - input.label = overrides.label - if (input.isPageHeading) { - input.label = overrides.label + input.label = overrides.input.label } } @@ -42,7 +39,6 @@ const renderQuestion = (res, id, actions, errors = [], overrides = {}) => { question: { ...question, ...overrides, - label, heading, description: overrides.description !== undefined ? overrides.description : page.description, details: overrides.details !== undefined ? overrides.details : page.details, From aafeb95edaf3b0c6ca769c866789f286f3f062e3 Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Wed, 20 May 2026 18:18:37 +0100 Subject: [PATCH 04/18] Combine asbestos questions on to one page --- app/prototype_v4_2/controllers/question.js | 215 ++++++++++++--------- app/prototype_v4_2/data/pages.yaml | 7 + app/prototype_v4_2/data/questions.yaml | 10 +- app/prototype_v4_2/routes.js | 27 +-- app/prototype_v4_2/views/index.html | 1 + 5 files changed, 147 insertions(+), 113 deletions(-) diff --git a/app/prototype_v4_2/controllers/question.js b/app/prototype_v4_2/controllers/question.js index 621e456..a83a0ee 100644 --- a/app/prototype_v4_2/controllers/question.js +++ b/app/prototype_v4_2/controllers/question.js @@ -339,100 +339,100 @@ exports.aboutYou_post = (req, res) => { } } -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`) - } -} +// 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 @@ -461,6 +461,29 @@ exports.respiratoryConditions_post = (req, res) => { } } +exports.asbestos_get = (req, res) => { + renderQuestionPage(res, 'asbestos', { + next: `/prototype_${version}/asbestos`, + back: `/prototype_${version}/respiratory-conditions`, + cancel: `/prototype_${version}/` + }) +} + +exports.asbestos_post = (req, res) => { + const { answers } = req.session.data + const errors = validateQuestions(answers, getQuestionPageIds('asbestos')) + + if (errors.length) { + renderQuestionPage(res, 'asbestos', { + next: `/prototype_${version}/asbestos`, + back: `/prototype_${version}/respiratory-conditions`, + cancel: `/prototype_${version}/` + }, errors) + } else { + res.redirect(`/prototype_${version}/cancer-diagnosis`) + } +} + exports.asbestosAtWork_get = (req, res) => { renderQuestion(res, 'asbestos-at-work', { next: `/prototype_${version}/asbestos-at-work`, @@ -510,7 +533,7 @@ exports.asbestosAtHome_post = (req, res) => { exports.cancerDiagnosis_get = (req, res) => { renderQuestion(res, 'cancer-diagnosis', { next: `/prototype_${version}/cancer-diagnosis`, - back: `/prototype_${version}/asbestos-at-work`, + back: `/prototype_${version}/asbestos`, cancel: `/prototype_${version}/` }) } @@ -522,7 +545,7 @@ exports.cancerDiagnosis_post = (req, res) => { if (errors.length) { renderQuestion(res, 'cancer-diagnosis', { next: `/prototype_${version}/cancer-diagnosis`, - back: `/prototype_${version}/asbestos-at-work`, + back: `/prototype_${version}/asbestos`, cancel: `/prototype_${version}/` }, errors) } else { diff --git a/app/prototype_v4_2/data/pages.yaml b/app/prototype_v4_2/data/pages.yaml index 2647d3f..0838909 100644 --- a/app/prototype_v4_2/data/pages.yaml +++ b/app/prototype_v4_2/data/pages.yaml @@ -144,6 +144,13 @@ pages: - id: respiratory-conditions questions: - respiratory-conditions + - id: asbestos + questions: + - asbestos-at-work + - asbestos-at-home + heading: + title: Exposure to asbestos + caption: Your health - id: asbestos-at-work questions: - asbestos-at-work diff --git a/app/prototype_v4_2/data/questions.yaml b/app/prototype_v4_2/data/questions.yaml index b0206fd..3883ccf 100644 --- a/app/prototype_v4_2/data/questions.yaml +++ b/app/prototype_v4_2/data/questions.yaml @@ -389,8 +389,8 @@ questions: type: multiple answerKey: respiratoryConditions input: - hint: Select all that apply 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 @@ -428,6 +428,8 @@ questions: - 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 @@ -440,11 +442,11 @@ questions: errors: required: text: Select whether you have ever worked in a job where you might have been exposed to asbestos - input: - label: Have you 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 @@ -457,8 +459,6 @@ questions: errors: required: text: Select whether you have ever lived with anyone who worked with asbestos - input: - label: Have you ever lived with anyone who worked with asbestos? - id: cancer-diagnosis type: single answerKey: cancerDiagnosis diff --git a/app/prototype_v4_2/routes.js b/app/prototype_v4_2/routes.js index 559aa18..d3a9912 100644 --- a/app/prototype_v4_2/routes.js +++ b/app/prototype_v4_2/routes.js @@ -112,28 +112,31 @@ router.post(`/prototype_${version}/weight-imperial`, questionController.weightIm 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}/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}/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}/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) +// 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-at-work`, questionController.asbestosAtWork_get) -router.post(`/prototype_${version}/asbestos-at-work`, questionController.asbestosAtWork_post) +router.get(`/prototype_${version}/asbestos`, questionController.asbestos_get) +router.post(`/prototype_${version}/asbestos`, questionController.asbestos_post) -router.get(`/prototype_${version}/asbestos-at-home`, questionController.asbestosAtHome_get) -router.post(`/prototype_${version}/asbestos-at-home`, questionController.asbestosAtHome_post) +// router.get(`/prototype_${version}/asbestos-at-work`, questionController.asbestosAtWork_get) +// router.post(`/prototype_${version}/asbestos-at-work`, questionController.asbestosAtWork_post) + +// router.get(`/prototype_${version}/asbestos-at-home`, questionController.asbestosAtHome_get) +// router.post(`/prototype_${version}/asbestos-at-home`, questionController.asbestosAtHome_post) router.get(`/prototype_${version}/cancer-diagnosis`, questionController.cancerDiagnosis_get) router.post(`/prototype_${version}/cancer-diagnosis`, questionController.cancerDiagnosis_post) diff --git a/app/prototype_v4_2/views/index.html b/app/prototype_v4_2/views/index.html index 708ee33..6afd805 100644 --- a/app/prototype_v4_2/views/index.html +++ b/app/prototype_v4_2/views/index.html @@ -66,6 +66,7 @@

    About you

    Your health

      {{ pageLink("/respiratory-conditions", "Respiratory conditions") }} + {{ pageLink("/asbestos", "Asbestos") }} {{ pageLink("/asbestos-at-work", "Asbestos at work") }} {{ pageLink("/asbestos-at-home", "Asbestos at home") }} {{ pageLink("/cancer-diagnosis", "Cancer diagnosis") }} From ac181e14c733d940c1bd8dc95d4df71c2477aab8 Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Thu, 21 May 2026 12:09:10 +0100 Subject: [PATCH 05/18] Combine cancer questions on to one page --- app/prototype_v4_2/controllers/question.js | 182 ++++++++++++--------- app/prototype_v4_2/data/pages.yaml | 7 +- app/prototype_v4_2/routes.js | 11 +- app/prototype_v4_2/views/index.html | 1 + 4 files changed, 119 insertions(+), 82 deletions(-) diff --git a/app/prototype_v4_2/controllers/question.js b/app/prototype_v4_2/controllers/question.js index a83a0ee..ede3ceb 100644 --- a/app/prototype_v4_2/controllers/question.js +++ b/app/prototype_v4_2/controllers/question.js @@ -457,7 +457,7 @@ exports.respiratoryConditions_post = (req, res) => { cancel: `/prototype_${version}/` }, errors) } else { - res.redirect(`/prototype_${version}/asbestos-at-work`) + res.redirect(`/prototype_${version}/asbestos`) } } @@ -480,115 +480,143 @@ exports.asbestos_post = (req, res) => { cancel: `/prototype_${version}/` }, errors) } else { - res.redirect(`/prototype_${version}/cancer-diagnosis`) + res.redirect(`/prototype_${version}/cancer-history`) } } -exports.asbestosAtWork_get = (req, res) => { - renderQuestion(res, 'asbestos-at-work', { - next: `/prototype_${version}/asbestos-at-work`, - back: `/prototype_${version}/respiratory-conditions`, - cancel: `/prototype_${version}/` - }) -} +// exports.asbestosAtWork_get = (req, res) => { +// renderQuestion(res, 'asbestos-at-work', { +// next: `/prototype_${version}/asbestos-at-work`, +// back: `/prototype_${version}/respiratory-conditions`, +// cancel: `/prototype_${version}/` +// }) +// } -exports.asbestosAtWork_post = (req, res) => { - const { answers } = req.session.data - const errors = validateQuestion(answers, 'asbestos-at-work') +// exports.asbestosAtWork_post = (req, res) => { +// const { answers } = req.session.data +// const errors = validateQuestion(answers, 'asbestos-at-work') - if (errors.length) { - renderQuestion(res, 'asbestos-at-work', { - next: `/prototype_${version}/asbestos-at-work`, - back: `/prototype_${version}/respiratory-conditions`, - cancel: `/prototype_${version}/` - }, errors) - } else { - res.redirect(`/prototype_${version}/asbestos-at-home`) - } -} +// if (errors.length) { +// renderQuestion(res, 'asbestos-at-work', { +// next: `/prototype_${version}/asbestos-at-work`, +// back: `/prototype_${version}/respiratory-conditions`, +// cancel: `/prototype_${version}/` +// }, errors) +// } else { +// res.redirect(`/prototype_${version}/asbestos-at-home`) +// } +// } -exports.asbestosAtHome_get = (req, res) => { - renderQuestion(res, 'asbestos-at-home', { - next: `/prototype_${version}/asbestos-at-home`, - back: `/prototype_${version}/asbestos-at-work`, - cancel: `/prototype_${version}/` - }) -} +// exports.asbestosAtHome_get = (req, res) => { +// renderQuestion(res, 'asbestos-at-home', { +// next: `/prototype_${version}/asbestos-at-home`, +// back: `/prototype_${version}/asbestos-at-work`, +// cancel: `/prototype_${version}/` +// }) +// } -exports.asbestosAtHome_post = (req, res) => { - const { answers } = req.session.data - const errors = validateQuestion(answers, 'asbestos-at-home') +// exports.asbestosAtHome_post = (req, res) => { +// const { answers } = req.session.data +// const errors = validateQuestion(answers, 'asbestos-at-home') - if (errors.length) { - renderQuestion(res, 'asbestos-at-home', { - next: `/prototype_${version}/asbestos-at-home`, - back: `/prototype_${version}/asbestos-at-work`, - cancel: `/prototype_${version}/` - }, errors) - } else { - res.redirect(`/prototype_${version}/cancer-diagnosis`) - } -} +// if (errors.length) { +// renderQuestion(res, 'asbestos-at-home', { +// next: `/prototype_${version}/asbestos-at-home`, +// back: `/prototype_${version}/asbestos-at-work`, +// cancel: `/prototype_${version}/` +// }, errors) +// } else { +// res.redirect(`/prototype_${version}/cancer-diagnosis`) +// } +// } -exports.cancerDiagnosis_get = (req, res) => { - renderQuestion(res, 'cancer-diagnosis', { - next: `/prototype_${version}/cancer-diagnosis`, +exports.cancerHistory_get = (req, res) => { + renderQuestionPage(res, 'cancer-history', { + next: `/prototype_${version}/cancer-history`, back: `/prototype_${version}/asbestos`, cancel: `/prototype_${version}/` }) } -exports.cancerDiagnosis_post = (req, res) => { +exports.cancerHistory_post = (req, res) => { const { answers } = req.session.data - const errors = validateQuestion(answers, 'cancer-diagnosis') + const errors = validateQuestions(answers, getQuestionPageIds('cancer-history')) if (errors.length) { - renderQuestion(res, 'cancer-diagnosis', { - next: `/prototype_${version}/cancer-diagnosis`, + renderQuestionPage(res, 'cancer-history', { + next: `/prototype_${version}/cancer-history`, back: `/prototype_${version}/asbestos`, cancel: `/prototype_${version}/` }, errors) } else { - res.redirect(`/prototype_${version}/cancer-diagnosis-relatives`) + if (answers.cancerDiagnosisRelatives === 'yes') { + res.redirect(`/prototype_${version}/cancer-diagnosis-relatives-age`) + } else { + delete answers.cancerDiagnosisRelativesAge + res.redirect(`/prototype_${version}/age-started-smoking`) + } } } +// 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_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') +// 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}/age-started-smoking`) - } - } -} +// 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}/age-started-smoking`) +// } +// } +// } exports.cancerDiagnosisRelativesAge_get = (req, res) => { renderQuestion(res, 'cancer-diagnosis-relatives-age', { next: `/prototype_${version}/cancer-diagnosis-relatives-age`, - back: `/prototype_${version}/cancer-diagnosis-relatives`, + back: `/prototype_${version}/cancer-history`, cancel: `/prototype_${version}/` }) } @@ -600,7 +628,7 @@ exports.cancerDiagnosisRelativesAge_post = (req, res) => { if (errors.length) { renderQuestion(res, 'cancer-diagnosis-relatives-age', { next: `/prototype_${version}/cancer-diagnosis-relatives-age`, - back: `/prototype_${version}/cancer-diagnosis-relatives`, + back: `/prototype_${version}/cancer-history`, cancel: `/prototype_${version}/` }, errors) } else { diff --git a/app/prototype_v4_2/data/pages.yaml b/app/prototype_v4_2/data/pages.yaml index 0838909..df974e8 100644 --- a/app/prototype_v4_2/data/pages.yaml +++ b/app/prototype_v4_2/data/pages.yaml @@ -150,7 +150,6 @@ pages: - asbestos-at-home heading: title: Exposure to asbestos - caption: Your health - id: asbestos-at-work questions: - asbestos-at-work @@ -209,6 +208,12 @@ pages: - insulating board to protect buildings and ships against fire If you worked in an industry such as building or construction, you are more likely to have come into contact with damaged asbestos. + - id: cancer-history + heading: + title: History of cancer + questions: + - cancer-diagnosis + - cancer-diagnosis-relatives - id: cancer-diagnosis questions: - cancer-diagnosis diff --git a/app/prototype_v4_2/routes.js b/app/prototype_v4_2/routes.js index d3a9912..07ef923 100644 --- a/app/prototype_v4_2/routes.js +++ b/app/prototype_v4_2/routes.js @@ -138,13 +138,16 @@ router.post(`/prototype_${version}/asbestos`, questionController.asbestos_post) // router.get(`/prototype_${version}/asbestos-at-home`, questionController.asbestosAtHome_get) // router.post(`/prototype_${version}/asbestos-at-home`, questionController.asbestosAtHome_post) -router.get(`/prototype_${version}/cancer-diagnosis`, questionController.cancerDiagnosis_get) -router.post(`/prototype_${version}/cancer-diagnosis`, questionController.cancerDiagnosis_post) +router.get(`/prototype_${version}/cancer-history`, questionController.cancerHistory_get) +router.post(`/prototype_${version}/cancer-history`, questionController.cancerHistory_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`, 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) diff --git a/app/prototype_v4_2/views/index.html b/app/prototype_v4_2/views/index.html index 6afd805..7644b2c 100644 --- a/app/prototype_v4_2/views/index.html +++ b/app/prototype_v4_2/views/index.html @@ -69,6 +69,7 @@

      Your health

      {{ pageLink("/asbestos", "Asbestos") }} {{ pageLink("/asbestos-at-work", "Asbestos at work") }} {{ pageLink("/asbestos-at-home", "Asbestos at home") }} + {{ pageLink("/cancer-history", "Cancer history") }} {{ pageLink("/cancer-diagnosis", "Cancer diagnosis") }}
    From 4064d0b4d1c7e38e1d8ec9dcea73d15248d434e1 Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Thu, 21 May 2026 12:38:54 +0100 Subject: [PATCH 06/18] Rollback cancer history and keep questions separate --- app/prototype_v4_2/controllers/question.js | 128 ++++++++++----------- app/prototype_v4_2/routes.js | 12 +- app/prototype_v4_2/views/index.html | 10 +- 3 files changed, 75 insertions(+), 75 deletions(-) diff --git a/app/prototype_v4_2/controllers/question.js b/app/prototype_v4_2/controllers/question.js index ede3ceb..80d39fa 100644 --- a/app/prototype_v4_2/controllers/question.js +++ b/app/prototype_v4_2/controllers/question.js @@ -480,7 +480,7 @@ exports.asbestos_post = (req, res) => { cancel: `/prototype_${version}/` }, errors) } else { - res.redirect(`/prototype_${version}/cancer-history`) + res.redirect(`/prototype_${version}/cancer-diagnosis`) } } @@ -530,93 +530,93 @@ exports.asbestos_post = (req, res) => { // } // } -exports.cancerHistory_get = (req, res) => { - renderQuestionPage(res, 'cancer-history', { - next: `/prototype_${version}/cancer-history`, - back: `/prototype_${version}/asbestos`, - cancel: `/prototype_${version}/` - }) -} - -exports.cancerHistory_post = (req, res) => { - const { answers } = req.session.data - const errors = validateQuestions(answers, getQuestionPageIds('cancer-history')) - - if (errors.length) { - renderQuestionPage(res, 'cancer-history', { - next: `/prototype_${version}/cancer-history`, - back: `/prototype_${version}/asbestos`, - 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}/age-started-smoking`) - } - } -} - -// exports.cancerDiagnosis_get = (req, res) => { -// renderQuestion(res, 'cancer-diagnosis', { -// next: `/prototype_${version}/cancer-diagnosis`, +// exports.cancerHistory_get = (req, res) => { +// renderQuestionPage(res, 'cancer-history', { +// next: `/prototype_${version}/cancer-history`, // back: `/prototype_${version}/asbestos`, // cancel: `/prototype_${version}/` // }) // } -// exports.cancerDiagnosis_post = (req, res) => { +// exports.cancerHistory_post = (req, res) => { // const { answers } = req.session.data -// const errors = validateQuestion(answers, 'cancer-diagnosis') +// const errors = validateQuestions(answers, getQuestionPageIds('cancer-history')) // if (errors.length) { -// renderQuestion(res, 'cancer-diagnosis', { -// next: `/prototype_${version}/cancer-diagnosis`, +// renderQuestionPage(res, 'cancer-history', { +// next: `/prototype_${version}/cancer-history`, // back: `/prototype_${version}/asbestos`, // cancel: `/prototype_${version}/` // }, errors) // } else { -// res.redirect(`/prototype_${version}/cancer-diagnosis-relatives`) +// if (answers.cancerDiagnosisRelatives === 'yes') { +// res.redirect(`/prototype_${version}/cancer-diagnosis-relatives-age`) +// } else { +// delete answers.cancerDiagnosisRelativesAge +// res.redirect(`/prototype_${version}/age-started-smoking`) +// } // } // } +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_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') +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}/age-started-smoking`) -// } -// } -// } + 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}/age-started-smoking`) + } + } +} exports.cancerDiagnosisRelativesAge_get = (req, res) => { renderQuestion(res, 'cancer-diagnosis-relatives-age', { next: `/prototype_${version}/cancer-diagnosis-relatives-age`, - back: `/prototype_${version}/cancer-history`, + back: `/prototype_${version}/cancer-diagnosis-relatives`, cancel: `/prototype_${version}/` }) } @@ -628,7 +628,7 @@ exports.cancerDiagnosisRelativesAge_post = (req, res) => { if (errors.length) { renderQuestion(res, 'cancer-diagnosis-relatives-age', { next: `/prototype_${version}/cancer-diagnosis-relatives-age`, - back: `/prototype_${version}/cancer-history`, + back: `/prototype_${version}/cancer-diagnosis-relatives`, cancel: `/prototype_${version}/` }, errors) } else { diff --git a/app/prototype_v4_2/routes.js b/app/prototype_v4_2/routes.js index 07ef923..ff5586a 100644 --- a/app/prototype_v4_2/routes.js +++ b/app/prototype_v4_2/routes.js @@ -138,16 +138,16 @@ router.post(`/prototype_${version}/asbestos`, questionController.asbestos_post) // router.get(`/prototype_${version}/asbestos-at-home`, questionController.asbestosAtHome_get) // router.post(`/prototype_${version}/asbestos-at-home`, questionController.asbestosAtHome_post) -router.get(`/prototype_${version}/cancer-history`, questionController.cancerHistory_get) -router.post(`/prototype_${version}/cancer-history`, questionController.cancerHistory_post) +// router.get(`/prototype_${version}/cancer-history`, questionController.cancerHistory_get) +// router.post(`/prototype_${version}/cancer-history`, questionController.cancerHistory_post) -// router.get(`/prototype_${version}/cancer-diagnosis`, questionController.cancerDiagnosis_get) -// router.post(`/prototype_${version}/cancer-diagnosis`, questionController.cancerDiagnosis_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`, 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) diff --git a/app/prototype_v4_2/views/index.html b/app/prototype_v4_2/views/index.html index 7644b2c..cad4230 100644 --- a/app/prototype_v4_2/views/index.html +++ b/app/prototype_v4_2/views/index.html @@ -57,19 +57,19 @@

    About you

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

    Your health

      {{ pageLink("/respiratory-conditions", "Respiratory conditions") }} {{ pageLink("/asbestos", "Asbestos") }} - {{ pageLink("/asbestos-at-work", "Asbestos at work") }} - {{ pageLink("/asbestos-at-home", "Asbestos at home") }} - {{ pageLink("/cancer-history", "Cancer history") }} + {# {{ pageLink("/asbestos-at-work", "Asbestos at work") }} + {{ pageLink("/asbestos-at-home", "Asbestos at home") }} #} + {# {{ pageLink("/cancer-history", "Cancer history") }} #} {{ pageLink("/cancer-diagnosis", "Cancer diagnosis") }}
    From 9e3ea4db397b8b1ceec833f6d84d041faa2cf9df Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Thu, 21 May 2026 14:21:16 +0100 Subject: [PATCH 07/18] Add tobacco-smoking page --- app/prototype_v4_2/controllers/question.js | 59 +++++++++++++-- app/prototype_v4_2/data/pages.yaml | 10 +++ app/prototype_v4_2/docs/question-schema.md | 24 +++++- app/prototype_v4_2/lib/question-pages.js | 84 +++++++++++++++++++-- app/prototype_v4_2/lib/question-renderer.js | 4 +- app/prototype_v4_2/routes.js | 3 + app/prototype_v4_2/views/index.html | 1 + 7 files changed, 166 insertions(+), 19 deletions(-) diff --git a/app/prototype_v4_2/controllers/question.js b/app/prototype_v4_2/controllers/question.js index 80d39fa..ecb6531 100644 --- a/app/prototype_v4_2/controllers/question.js +++ b/app/prototype_v4_2/controllers/question.js @@ -20,8 +20,8 @@ const { const { getHeightBack, getWeightBack, getWeightNext } = require('../lib/unit-navigation') const { validateQuestion, validateQuestions } = require('../lib/question-validator') -const getQuestionPageIds = (id) => { - return getQuestionPage(id).questions.map((question) => question.id) +const getQuestionPageIds = (id, answers = {}) => { + return getQuestionPage(id, answers).questions.map((question) => question.id) } /// ------------------------------------------------------------------------ /// @@ -315,25 +315,26 @@ exports.weightImperial_post = (req, res) => { 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')) + 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) + }, errors, answers) } else { res.redirect(`/prototype_${version}/respiratory-conditions`) } @@ -462,23 +463,25 @@ exports.respiratoryConditions_post = (req, res) => { } 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')) + 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) + }, errors, answers) } else { res.redirect(`/prototype_${version}/cancer-diagnosis`) } @@ -795,6 +798,46 @@ exports.smokingType_post = (req, res) => { } } +exports.tobaccoSmoking_get = (req, res) => { + // renderQuestionPage(res, 'tobacco-smoking', { + // next: `/prototype_${version}/tobacco-smoking`, + // back: `/prototype_${version}/smoking-type`, + // cancel: `/prototype_${version}/` + // }) +} + +exports.tobaccoSmoking_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}/periods-stopped-smoking`, + // 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.smokingTypeExit_get = (req, res) => { res.render(view('questions/smoking-type-exit'), { actions: { diff --git a/app/prototype_v4_2/data/pages.yaml b/app/prototype_v4_2/data/pages.yaml index df974e8..5ebbc8d 100644 --- a/app/prototype_v4_2/data/pages.yaml +++ b/app/prototype_v4_2/data/pages.yaml @@ -283,6 +283,16 @@ pages: - id: smoking-status questions: - smoking-status + - id: tobacco-smoking + heading: + title: Tobacco smoking + questions: + - id: smoking-setting + if: + question: smoking-type + includes: shisha + - smoking-frequency + - smoking-quantity - id: smoking-frequency questions: - smoking-frequency diff --git a/app/prototype_v4_2/docs/question-schema.md b/app/prototype_v4_2/docs/question-schema.md index 4a4f24b..1ae67c1 100644 --- a/app/prototype_v4_2/docs/question-schema.md +++ b/app/prototype_v4_2/docs/question-schema.md @@ -68,13 +68,33 @@ Grouped pages use the same question definitions, answer keys and validation rule 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: tobacco-smoking + heading: + title: Tobacco smoking + questions: + - id: smoking-setting + if: + question: smoking-type + includes: shisha + - smoking-frequency + - smoking-quantity +``` + +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`, `not` and `notIncludes`. For checkbox answers, `is`, `equals` and `includes` all match when the submitted answer array contains the value. + 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)` for grouped pages. -5. Validate grouped pages with `validateQuestions(answers, questionIds)`. +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 diff --git a/app/prototype_v4_2/lib/question-pages.js b/app/prototype_v4_2/lib/question-pages.js index f35a779..89e2b5e 100644 --- a/app/prototype_v4_2/lib/question-pages.js +++ b/app/prototype_v4_2/lib/question-pages.js @@ -42,6 +42,74 @@ const getQuestionHeading = (question) => { } } +const normaliseQuestionRef = (item) => { + return typeof item === 'string' ? { id: item } : item +} + +const getCondition = (questionRef) => { + return questionRef.if || questionRef.showIf || questionRef.condition +} + +const getConditionAnswer = (condition = {}, answers = {}) => { + if (condition.answerKey) { + return answers[condition.answerKey] + } + + if (condition.answer) { + return answers[condition.answer] + } + + if (condition.question) { + return 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 matchesCondition = (questionRef, answers = {}) => { + const condition = getCondition(questionRef) + + if (!condition) { + return true + } + + 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.notIncludes !== undefined) { + return !hasValue(actual, condition.notIncludes) + } + + return true +} + const mergeQuestionWithPageContent = (question, pageContent = {}, options = {}) => { const isSingleQuestionPage = options.isSingleQuestionPage === true const hasPageHeading = Boolean(options.pageHeading?.title) @@ -74,7 +142,7 @@ const mergeQuestionWithPageContent = (question, pageContent = {}, options = {}) } } -const getQuestionPage = (id) => { +const getQuestionPage = (id, answers = {}) => { refreshPages() const page = pages[id] @@ -84,11 +152,13 @@ const getQuestionPage = (id) => { } const pageQuestions = page.questions || [] - const questions = pageQuestions.map((item) => { - const questionRef = typeof item === 'string' ? { id: item } : item + const visiblePageQuestions = pageQuestions + .map(normaliseQuestionRef) + .filter((questionRef) => matchesCondition(questionRef, answers)) + const questions = visiblePageQuestions.map((questionRef) => { const question = getQuestion(questionRef.id) - const isSingleQuestionPage = pageQuestions.length === 1 - const pageContent = pageQuestions.length === 1 + const isSingleQuestionPage = visiblePageQuestions.length === 1 + const pageContent = visiblePageQuestions.length === 1 ? { ...questionRef } @@ -106,10 +176,10 @@ const getQuestionPage = (id) => { heading: page.heading || firstQuestion?.page?.heading, description: page.description !== undefined ? page.description - : pageQuestions.length === 1 ? firstQuestion?.page?.description : undefined, + : visiblePageQuestions.length === 1 ? firstQuestion?.page?.description : undefined, details: page.details !== undefined ? page.details - : pageQuestions.length === 1 ? firstQuestion?.page?.details : undefined, + : visiblePageQuestions.length === 1 ? firstQuestion?.page?.details : undefined, questions } } diff --git a/app/prototype_v4_2/lib/question-renderer.js b/app/prototype_v4_2/lib/question-renderer.js index b9e1d19..3746910 100644 --- a/app/prototype_v4_2/lib/question-renderer.js +++ b/app/prototype_v4_2/lib/question-renderer.js @@ -68,9 +68,9 @@ const getErrorMap = (errors = []) => { * @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 = []) => { +const renderQuestionPage = (res, id, actions, errors = [], answers = {}) => { res.render(view('questions/_question-page'), { - page: getQuestionPage(id), + page: getQuestionPage(id, answers), errorMap: getErrorMap(errors), errors, actions diff --git a/app/prototype_v4_2/routes.js b/app/prototype_v4_2/routes.js index ff5586a..ed8b356 100644 --- a/app/prototype_v4_2/routes.js +++ b/app/prototype_v4_2/routes.js @@ -168,6 +168,9 @@ router.post(`/prototype_${version}/periods-stopped-smoking`, questionController. 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) diff --git a/app/prototype_v4_2/views/index.html b/app/prototype_v4_2/views/index.html index cad4230..8a5d722 100644 --- a/app/prototype_v4_2/views/index.html +++ b/app/prototype_v4_2/views/index.html @@ -90,6 +90,7 @@

    Your smoking habits

    Tobacco smoking

      + {{ seededPageLink("/tobacco-smoking", "Tobacco smoking") }} {{ seededPageLink("/smoking-status?type=cigarettes", "Cigarettes - currently smoke") }} {{ seededPageLink("/smoking-frequency?type=cigarettes", "Cigarettes - frequency") }} {{ seededPageLink("/smoking-quantity?type=cigarettes", "Cigarettes - quantity") }} From 6929080bd87f21ad54598f78e6c6eebeee4dd603 Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Thu, 21 May 2026 14:27:08 +0100 Subject: [PATCH 08/18] Update page schema conditional logic --- app/prototype_v4_2/data/pages.yaml | 3 ++- app/prototype_v4_2/docs/question-schema.md | 5 +++-- app/prototype_v4_2/lib/question-pages.js | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/prototype_v4_2/data/pages.yaml b/app/prototype_v4_2/data/pages.yaml index 5ebbc8d..4543d30 100644 --- a/app/prototype_v4_2/data/pages.yaml +++ b/app/prototype_v4_2/data/pages.yaml @@ -290,7 +290,8 @@ pages: - id: smoking-setting if: question: smoking-type - includes: shisha + includes: + - shisha - smoking-frequency - smoking-quantity - id: smoking-frequency diff --git a/app/prototype_v4_2/docs/question-schema.md b/app/prototype_v4_2/docs/question-schema.md index 1ae67c1..7983f73 100644 --- a/app/prototype_v4_2/docs/question-schema.md +++ b/app/prototype_v4_2/docs/question-schema.md @@ -79,14 +79,15 @@ pages: - id: smoking-setting if: question: smoking-type - includes: shisha + includes: + - shisha - smoking-frequency - smoking-quantity ``` 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`, `not` and `notIncludes`. For checkbox answers, `is`, `equals` and `includes` all match when the submitted answer array contains the value. +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. To add another page: diff --git a/app/prototype_v4_2/lib/question-pages.js b/app/prototype_v4_2/lib/question-pages.js index 89e2b5e..b2e0186 100644 --- a/app/prototype_v4_2/lib/question-pages.js +++ b/app/prototype_v4_2/lib/question-pages.js @@ -103,8 +103,8 @@ const matchesCondition = (questionRef, answers = {}) => { return !hasValue(actual, condition.not) } - if (condition.notIncludes !== undefined) { - return !hasValue(actual, condition.notIncludes) + if (condition.excludes !== undefined) { + return !hasValue(actual, condition.excludes) } return true From de2b5c05d9ee2b6371a15d7205b8321ed2d0b772 Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Thu, 21 May 2026 14:33:47 +0100 Subject: [PATCH 09/18] Update page schema conditional logic to include any and all --- app/prototype_v4_2/data/pages.yaml | 13 +++++++++++++ app/prototype_v4_2/docs/question-schema.md | 17 +++++++++++++++++ app/prototype_v4_2/lib/question-pages.js | 16 +++++++++++++--- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/app/prototype_v4_2/data/pages.yaml b/app/prototype_v4_2/data/pages.yaml index 4543d30..b488355 100644 --- a/app/prototype_v4_2/data/pages.yaml +++ b/app/prototype_v4_2/data/pages.yaml @@ -258,6 +258,19 @@ pages: There may have been periods when you stopped or quit smoking. If you stopped smoking for periods of 1 year or longer, tell us the total number of years you stopped smoking. + - id: smoking-duration + heading: + title: Smoking duration + 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 diff --git a/app/prototype_v4_2/docs/question-schema.md b/app/prototype_v4_2/docs/question-schema.md index 7983f73..2025b30 100644 --- a/app/prototype_v4_2/docs/question-schema.md +++ b/app/prototype_v4_2/docs/question-schema.md @@ -89,6 +89,23 @@ The `question` value is a question ID from `questions.yaml`. The renderer looks 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`. diff --git a/app/prototype_v4_2/lib/question-pages.js b/app/prototype_v4_2/lib/question-pages.js index b2e0186..409b3f2 100644 --- a/app/prototype_v4_2/lib/question-pages.js +++ b/app/prototype_v4_2/lib/question-pages.js @@ -78,13 +78,19 @@ const hasValue = (actual, expected) => { return actual === expected } -const matchesCondition = (questionRef, answers = {}) => { - const condition = getCondition(questionRef) - +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) { @@ -110,6 +116,10 @@ const matchesCondition = (questionRef, answers = {}) => { 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) From ddcd25e6db754932b162ed4399e64e5d2cf47a8c Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Thu, 21 May 2026 14:38:30 +0100 Subject: [PATCH 10/18] Add tobacco smoking change page --- app/prototype_v4_2/data/pages.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/prototype_v4_2/data/pages.yaml b/app/prototype_v4_2/data/pages.yaml index b488355..fbcf09c 100644 --- a/app/prototype_v4_2/data/pages.yaml +++ b/app/prototype_v4_2/data/pages.yaml @@ -307,6 +307,13 @@ pages: - shisha - 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-frequency questions: - smoking-frequency From 052cc7f896d361545df2f679b547b2469dffa37c Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Thu, 21 May 2026 15:41:53 +0100 Subject: [PATCH 11/18] Update question flow --- app/prototype_v4_2/controllers/question.js | 127 +++++---- app/prototype_v4_2/data/pages.yaml | 8 - app/prototype_v4_2/data/questions.yaml | 15 -- app/prototype_v4_2/data/tobacco.yaml | 25 -- app/prototype_v4_2/docs/question-schema.md | 15 +- app/prototype_v4_2/docs/tobacco-schema.md | 32 --- app/prototype_v4_2/lib/page-index.js | 7 +- app/prototype_v4_2/lib/question-pages.js | 26 +- app/prototype_v4_2/lib/question-renderer.js | 45 +++- app/prototype_v4_2/lib/questions.js | 6 +- app/prototype_v4_2/lib/summary.js | 42 +-- app/prototype_v4_2/lib/tobacco-flow.js | 272 +++++++++----------- app/prototype_v4_2/routes.js | 9 +- app/prototype_v4_2/views/index.html | 4 +- 14 files changed, 286 insertions(+), 347 deletions(-) diff --git a/app/prototype_v4_2/controllers/question.js b/app/prototype_v4_2/controllers/question.js index ecb6531..31ee231 100644 --- a/app/prototype_v4_2/controllers/question.js +++ b/app/prototype_v4_2/controllers/question.js @@ -3,7 +3,6 @@ const { getQuestionPage } = require('../lib/question-pages') const { renderQuestion, renderQuestionPage, version, view } = require('../lib/question-renderer') const { getCheckYourAnswers } = require('../lib/summary') const { - deleteUnselectedShishaSettingAnswers, deleteUnselectedSmokingQuantityOtherAnswer, deleteUnselectedSmokingChangeAnswers, deleteUnselectedSmokingTypeAnswers, @@ -14,7 +13,9 @@ const { getSmokingTypeStep, getSmokingTypeSteps, getSmokingTypeStepUrl, + renderSmokingTypePage, renderSmokingTypeQuestion, + validateSmokingTypePage, validateSmokingTypeQuestion } = require('../lib/tobacco-flow') const { getHeightBack, getWeightBack, getWeightNext } = require('../lib/unit-navigation') @@ -611,7 +612,7 @@ exports.cancerDiagnosisRelatives_post = (req, res) => { res.redirect(`/prototype_${version}/cancer-diagnosis-relatives-age`) } else { delete answers.cancerDiagnosisRelativesAge - res.redirect(`/prototype_${version}/age-started-smoking`) + res.redirect(`/prototype_${version}/smoking-duration`) } } } @@ -635,7 +636,7 @@ exports.cancerDiagnosisRelativesAge_post = (req, res) => { cancel: `/prototype_${version}/` }, errors) } else { - res.redirect(`/prototype_${version}/age-started-smoking`) + res.redirect(`/prototype_${version}/smoking-duration`) } } @@ -643,6 +644,41 @@ exports.cancerDiagnosisRelativesAge_post = (req, res) => { /// 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`) + } +} + exports.ageStartedSmoking_get = (req, res) => { const { answers } = req.session.data const back = answers?.cancerDiagnosisRelativesAge ? `/prototype_${version}/cancer-diagnosis-relatives-age` : `/prototype_${version}/cancer-diagnosis-relatives` @@ -761,7 +797,7 @@ exports.smokingType_get = (req, res) => { renderQuestion(res, 'smoking-type', { next: `/prototype_${version}/smoking-type`, - back: `/prototype_${version}/periods-stopped-smoking`, + back: `/prototype_${version}/smoking-duration`, cancel: `/prototype_${version}/` }, [], getSmokingTypeQuestionOverrides(answers)) } @@ -773,7 +809,7 @@ exports.smokingType_post = (req, res) => { if (errors.length) { renderQuestion(res, 'smoking-type', { next: `/prototype_${version}/smoking-type`, - back: `/prototype_${version}/periods-stopped-smoking`, + back: `/prototype_${version}/smoking-duration`, cancel: `/prototype_${version}/` }, errors, getSmokingTypeQuestionOverrides(answers)) } else { @@ -799,43 +835,27 @@ exports.smokingType_post = (req, res) => { } exports.tobaccoSmoking_get = (req, res) => { - // renderQuestionPage(res, 'tobacco-smoking', { - // next: `/prototype_${version}/tobacco-smoking`, - // back: `/prototype_${version}/smoking-type`, - // cancel: `/prototype_${version}/` - // }) + renderSmokingTypePage(req, res, 'tobacco-smoking') } exports.tobaccoSmoking_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}/periods-stopped-smoking`, - // 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`) - // } - // } + 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) => { @@ -914,47 +934,46 @@ exports.smokingQuantity_post = (req, res) => { } } -exports.smokingSetting_get = (req, res) => { - renderSmokingTypeQuestion(req, res, 'smoking-setting') +exports.smokingChange_get = (req, res) => { + renderSmokingTypeQuestion(req, res, 'smoking-change') } -exports.smokingSetting_post = (req, res) => { - const { step, steps } = getSmokingTypeStep(req, 'smoking-setting') +exports.smokingChange_post = (req, res) => { + const { step, steps } = getSmokingTypeStep(req, 'smoking-change') const { answers } = req.session.data - const errors = step ? validateSmokingTypeQuestion(req, 'smoking-setting', step) : [] + const errors = step ? validateSmokingTypeQuestion(req, 'smoking-change', step) : [] if (!step) { res.redirect(`/prototype_${version}/smoking-type`) return } - deleteUnselectedShishaSettingAnswers(answers[step.type]) + deleteUnselectedSmokingChangeAnswers(answers[step.type]) if (errors.length) { - renderSmokingTypeQuestion(req, res, 'smoking-setting', errors) + renderSmokingTypeQuestion(req, res, 'smoking-change', errors) } else { res.redirect(getSmokingTypeActions(step, steps).onward) } } -exports.smokingChange_get = (req, res) => { - renderSmokingTypeQuestion(req, res, 'smoking-change') +exports.tobaccoSmokingChange_get = (req, res) => { + renderSmokingTypePage(req, res, 'tobacco-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) : [] +exports.tobaccoSmokingChange_post = (req, res) => { + const { step, steps } = getSmokingTypeStep(req, 'tobacco-smoking-change') if (!step) { res.redirect(`/prototype_${version}/smoking-type`) return } - deleteUnselectedSmokingChangeAnswers(answers[step.type]) + deleteUnselectedSmokingQuantityOtherAnswer(req.session.data.answers, step, 'quantity') + const errors = validateSmokingTypePage(req, 'tobacco-smoking-change', step) if (errors.length) { - renderSmokingTypeQuestion(req, res, 'smoking-change', errors) + renderSmokingTypePage(req, res, 'tobacco-smoking-change', errors) } else { res.redirect(getSmokingTypeActions(step, steps).onward) } diff --git a/app/prototype_v4_2/data/pages.yaml b/app/prototype_v4_2/data/pages.yaml index fbcf09c..addb0f3 100644 --- a/app/prototype_v4_2/data/pages.yaml +++ b/app/prototype_v4_2/data/pages.yaml @@ -300,11 +300,6 @@ pages: heading: title: Tobacco smoking questions: - - id: smoking-setting - if: - question: smoking-type - includes: - - shisha - smoking-frequency - smoking-quantity - id: tobacco-smoking-change @@ -320,9 +315,6 @@ pages: - id: smoking-quantity questions: - smoking-quantity - - id: smoking-setting - questions: - - smoking-setting - id: smoking-change questions: - smoking-change diff --git a/app/prototype_v4_2/data/questions.yaml b/app/prototype_v4_2/data/questions.yaml index 3883ccf..1edbcf0 100644 --- a/app/prototype_v4_2/data/questions.yaml +++ b/app/prototype_v4_2/data/questions.yaml @@ -770,21 +770,6 @@ questions: text: Amount smoked must be 1 or more max: text: Amount smoked must be 200 or fewer - - id: smoking-setting - type: multiple - input: - label: Do you usually smoke shisha in a group or on your own? - hint: Select all that apply - options: - - label: In a group - value: group - - label: By myself - value: individual - validation: - required: true - errors: - required: - text: Select whether you usually smoke shisha in a group or on your own - id: smoking-change type: multiple input: diff --git a/app/prototype_v4_2/data/tobacco.yaml b/app/prototype_v4_2/data/tobacco.yaml index e6d5bce..0e2d145 100644 --- a/app/prototype_v4_2/data/tobacco.yaml +++ b/app/prototype_v4_2/data/tobacco.yaml @@ -118,28 +118,11 @@ tobaccoTypes: headings: current: status: Do you currently smoke shisha? - setting: Do you usually smoke shisha in a group or on your own? frequency: How often do you smoke shisha? quantity: How long do you currently smoke shisha in a normal day? past: - setting: Did you usually smoke shisha in a group or on your own? frequency: How often did you smoke shisha? quantity: How long did you smoke shisha in a normal day? - settingHeadings: - group: - current: - frequency: How often do you smoke shisha in a group? - quantity: How long do you currently smoke shisha in a group in a normal day? - past: - frequency: How often did you smoke shisha in a group? - quantity: How long did you smoke shisha in a group in a normal day? - individual: - current: - frequency: How often do you smoke shisha by yourself? - quantity: How long do you currently smoke shisha by yourself in a normal day? - past: - frequency: How often did you smoke shisha by yourself? - quantity: How long did you smoke shisha by yourself in a normal day? smokingChangeTypes: greater: @@ -148,11 +131,3 @@ smokingChangeTypes: fewer: answerKey: smokingChangeDecrease label: fewer - -shishaSmokingSettings: - group: - label: In a group - headingText: in a group - individual: - label: By myself - headingText: by yourself diff --git a/app/prototype_v4_2/docs/question-schema.md b/app/prototype_v4_2/docs/question-schema.md index 2025b30..1d1e506 100644 --- a/app/prototype_v4_2/docs/question-schema.md +++ b/app/prototype_v4_2/docs/question-schema.md @@ -72,17 +72,16 @@ To conditionally show a question on a grouped page, use an object instead of a s ```yaml pages: - - id: tobacco-smoking + - id: smoking-duration heading: - title: Tobacco smoking + title: Smoking duration questions: - - id: smoking-setting + - age-started-smoking + - id: age-stopped-smoking if: - question: smoking-type - includes: - - shisha - - smoking-frequency - - smoking-quantity + 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. diff --git a/app/prototype_v4_2/docs/tobacco-schema.md b/app/prototype_v4_2/docs/tobacco-schema.md index e388f66..c2532a5 100644 --- a/app/prototype_v4_2/docs/tobacco-schema.md +++ b/app/prototype_v4_2/docs/tobacco-schema.md @@ -10,7 +10,6 @@ Use this file for content that is shared across the repeated tobacco sub-flow, i | --- | --- | | `tobaccoTypes` | Content for each tobacco type. | | `smokingChangeTypes` | The ordered set of changed-smoking branches. | -| `shishaSmokingSettings` | Shisha setting labels and heading fragments. | ## Tobacco types @@ -41,23 +40,6 @@ The tobacco flow uses `headings.current` when someone currently smokes and `head `quantityUnit`, `singularSuffix` and `suffix` are used when answers are formatted in check your answers and when contextual change questions are built. -## Shisha - -Shisha has setting-specific headings because the flow asks whether someone smoked shisha in a group, by themselves, or both. - -```yaml -settingHeadings: - group: - current: - frequency: How often do you smoke shisha in a group? - quantity: How many hours do you currently smoke shisha in a group in a normal day? - past: - frequency: How often did you smoke shisha in a group? - quantity: How many hours did you smoke shisha in a group in a normal day? -``` - -The setting keys must match `shishaSmokingSettings`. - ## Smoking change types `smokingChangeTypes` controls the changed-smoking branches and their order. @@ -73,17 +55,3 @@ smokingChangeTypes: ``` `answerKey` is the nested key used to store answers for that branch. - -## Shisha smoking settings - -```yaml -shishaSmokingSettings: - group: - label: In a group - headingText: in a group - individual: - label: By myself - headingText: by yourself -``` - -`label` is used for the checkbox option. `headingText` is used when building contextual summary and heading text. diff --git a/app/prototype_v4_2/lib/page-index.js b/app/prototype_v4_2/lib/page-index.js index c37a28c..83568f9 100644 --- a/app/prototype_v4_2/lib/page-index.js +++ b/app/prototype_v4_2/lib/page-index.js @@ -65,11 +65,8 @@ const getDefaultAnswerProfile = (profile) => { delete answers.cigarettes answers.shisha = { smokingStatus: 'yes', - smokingSetting: ['group'], - group: { - smokingFrequency: 'weekly', - smokingQuantity: '30_minutes_to_1_hour' - } + smokingFrequency: 'weekly', + smokingQuantity: '30_minutes_to_1_hour' } } diff --git a/app/prototype_v4_2/lib/question-pages.js b/app/prototype_v4_2/lib/question-pages.js index 409b3f2..7dfcf60 100644 --- a/app/prototype_v4_2/lib/question-pages.js +++ b/app/prototype_v4_2/lib/question-pages.js @@ -50,17 +50,37 @@ 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 answers[condition.answerKey] + return getAnswer(answers, condition.answerKey) } if (condition.answer) { - return answers[condition.answer] + return getAnswer(answers, condition.answer) } if (condition.question) { - return answers[getQuestion(condition.question).answerKey] + return getAnswer(answers, getQuestion(condition.question).answerKey) } return undefined diff --git a/app/prototype_v4_2/lib/question-renderer.js b/app/prototype_v4_2/lib/question-renderer.js index 3746910..144f05b 100644 --- a/app/prototype_v4_2/lib/question-renderer.js +++ b/app/prototype_v4_2/lib/question-renderer.js @@ -60,6 +60,36 @@ const getErrorMap = (errors = []) => { }, {}) } +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. * @@ -68,9 +98,20 @@ const getErrorMap = (errors = []) => { * @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 = {}) => { +const renderQuestionPage = (res, id, actions, errors = [], answers = {}, overrides = {}) => { + const page = getQuestionPage(id, answers) + const questionOverrides = overrides.questions || {} + res.render(view('questions/_question-page'), { - page: getQuestionPage(id, answers), + page: { + ...page, + ...overrides, + heading: { + ...page.heading, + ...overrides.heading + }, + questions: page.questions.map((question) => mergeQuestionOverrides(question, questionOverrides[question.id])) + }, errorMap: getErrorMap(errors), errors, actions diff --git a/app/prototype_v4_2/lib/questions.js b/app/prototype_v4_2/lib/questions.js index 90c174e..fbf6210 100644 --- a/app/prototype_v4_2/lib/questions.js +++ b/app/prototype_v4_2/lib/questions.js @@ -7,7 +7,6 @@ const tobaccoPath = path.join(__dirname, '../data/tobacco.yaml') const data = { questions: {}, smokingChangeTypes: {}, - shishaSmokingSettings: {}, tobaccoTypes: {} } let loadedAt = {} @@ -144,8 +143,7 @@ const loadData = () => { return index }, {}), tobaccoTypes: tobaccoData.tobaccoTypes || {}, - smokingChangeTypes: tobaccoData.smokingChangeTypes || {}, - shishaSmokingSettings: tobaccoData.shishaSmokingSettings || {} + smokingChangeTypes: tobaccoData.smokingChangeTypes || {} } } @@ -172,7 +170,6 @@ const refreshData = (force = false) => { replaceObject(data.questions, freshData.questions) replaceObject(data.tobaccoTypes, freshData.tobaccoTypes) replaceObject(data.smokingChangeTypes, freshData.smokingChangeTypes) - replaceObject(data.shishaSmokingSettings, freshData.shishaSmokingSettings) loadedAt = mtimes } @@ -223,6 +220,5 @@ module.exports = { getQuestionValueLabels, refreshData, smokingChangeTypes: data.smokingChangeTypes, - shishaSmokingSettings: data.shishaSmokingSettings, tobaccoTypes: data.tobaccoTypes } diff --git a/app/prototype_v4_2/lib/summary.js b/app/prototype_v4_2/lib/summary.js index 84e846a..5324f32 100644 --- a/app/prototype_v4_2/lib/summary.js +++ b/app/prototype_v4_2/lib/summary.js @@ -3,10 +3,8 @@ const { getQuestionValueLabels } = require('./questions') const { version } = require('./question-renderer') const { formatQuantity, - getSelectedShishaSettings, getSelectedSmokingChanges, getSelectedSmokingTypes, - getShishaSettingAnswer, getSmokingChangeAnswer, getSmokingChangeHeading, getSmokingChangeLabels, @@ -233,22 +231,6 @@ const getCheckYourAnswers = (answers = {}) => { const answer = answers[type] || {} const isPast = isPastSmokingType(answers, answer) const smokingType = getSmokingTypeHeadings(type, isPast) - const shishaSettingRows = getSelectedShishaSettings(answer).flatMap((setting) => { - const settingAnswer = getShishaSettingAnswer(answer, setting) - - return [ - makeSummaryRow({ - key: getSmokingStepHeading('smoking-frequency', type, setting, isPast, settingAnswer), - value: formatValue(settingAnswer.smokingFrequency, valueLabels.smokingFrequency), - href: getSmokingTypeStepUrl({ page: 'smoking-frequency', type, setting }) - }), - makeSummaryRow({ - key: getSmokingStepHeading('smoking-quantity', type, setting, isPast, settingAnswer), - value: formatSmokingQuantityAnswer(type, settingAnswer), - href: getSmokingTypeStepUrl({ page: 'smoking-quantity', type, setting }) - }) - ] - }) const smokingChangeRows = getSelectedSmokingChanges(answer).flatMap((change) => { const changeAnswer = getSmokingChangeAnswer(answer, change) @@ -256,17 +238,17 @@ const getCheckYourAnswers = (answers = {}) => { makeSummaryRow({ key: getSmokingChangeHeading('smoking-frequency-change', type, change, changeAnswer, answer), value: formatValue(changeAnswer.frequency, valueLabels.smokingFrequency), - href: getSmokingTypeStepUrl({ page: 'smoking-frequency-change', type, change }) + 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: 'smoking-quantity-change', type, change }) + 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: 'smoking-years-change', type, change }) + href: getSmokingTypeStepUrl({ page: 'tobacco-smoking-change', type, change }) }) ] }) @@ -276,27 +258,21 @@ const getCheckYourAnswers = (answers = {}) => { value: formatValue(answer.smokingStatus, valueLabels.smokingStatus), href: getSmokingTypeStepUrl({ page: 'smoking-status', type }) }), - type === 'shisha' && makeSummaryRow({ - key: smokingType.settingHeading, - ...formatListValue(answer.smokingSetting, valueLabels.smokingSetting), - href: getSmokingTypeStepUrl({ page: 'smoking-setting', type }) - }), - type !== 'shisha' && makeSummaryRow({ - key: getSmokingStepHeading('smoking-frequency', type, undefined, isPast, answer), + makeSummaryRow({ + key: getSmokingStepHeading('smoking-frequency', type, isPast, answer), value: formatValue(answer.smokingFrequency, valueLabels.smokingFrequency), - href: getSmokingTypeStepUrl({ page: 'smoking-frequency', type }) + href: getSmokingTypeStepUrl({ page: 'tobacco-smoking', type }) }), - type !== 'shisha' && makeSummaryRow({ - key: getSmokingStepHeading('smoking-quantity', type, undefined, isPast, answer), + makeSummaryRow({ + key: getSmokingStepHeading('smoking-quantity', type, isPast, answer), value: formatSmokingQuantityAnswer(type, answer), - href: getSmokingTypeStepUrl({ page: 'smoking-quantity', type }) + 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 }) }), - ...shishaSettingRows, ...smokingChangeRows ]) diff --git a/app/prototype_v4_2/lib/tobacco-flow.js b/app/prototype_v4_2/lib/tobacco-flow.js index 081ab2e..c89f3ab 100644 --- a/app/prototype_v4_2/lib/tobacco-flow.js +++ b/app/prototype_v4_2/lib/tobacco-flow.js @@ -3,11 +3,10 @@ const { getQuestionValueLabels, refreshData, smokingChangeTypes, - shishaSmokingSettings, tobaccoTypes: smokingTypes } = require('./questions') const { getQuestionPage } = require('./question-pages') -const { renderQuestion, version } = require('./question-renderer') +const { renderQuestion, renderQuestionPage, version } = require('./question-renderer') const { validateQuestion } = require('./question-validator') const nextStepAfterSmokingTypes = `/prototype_${version}/check-your-answers` @@ -16,7 +15,6 @@ const getValueLabels = () => { return { smokingChange: getQuestionValueLabels('smoking-change'), smokingFrequency: getQuestionValueLabels('smoking-frequency'), - smokingSetting: getQuestionValueLabels('smoking-setting'), smokingStatus: getQuestionValueLabels('smoking-status'), smokingType: getQuestionValueLabels('smoking-type') } @@ -26,7 +24,6 @@ const getValueLabels = () => { * @typedef {Object} SmokingTypeStep * @property {string} page - Route/page id for the step. * @property {string} type - Tobacco type key, for example cigarettes. - * @property {string} [setting] - Shisha setting key. * @property {string} [change] - Smoking change key. */ @@ -158,37 +155,6 @@ const deleteUnselectedSmokingChangeAnswers = (answer = {}) => { }) } -/** - * Get selected shisha settings in tobacco.yaml order. - * - * @param {Object} answer - Shisha answer object. - * @returns {string[]} Selected shisha setting keys. - */ -const getSelectedShishaSettings = (answer = {}) => { - refreshData() - - const selectedSettings = Array.isArray(answer.smokingSetting) - ? answer.smokingSetting - : [answer.smokingSetting].filter(Boolean) - - return Object.keys(shishaSmokingSettings).filter((setting) => selectedSettings.includes(setting)) -} - -/** - * Remove nested shisha answers for unselected settings. - * - * @param {Object} answer - Shisha answer object, mutated in place. - */ -const deleteUnselectedShishaSettingAnswers = (answer = {}) => { - const selectedSettings = getSelectedShishaSettings(answer) - - Object.keys(shishaSmokingSettings).forEach((setting) => { - if (!selectedSettings.includes(setting)) { - delete answer[setting] - } - }) -} - /** * Build the tobacco sub-flow steps from selected tobacco answers. * @@ -206,20 +172,12 @@ const getSmokingTypeSteps = (answers = {}) => { steps.push({ page: 'smoking-status', type }) } - if (type === 'shisha') { - steps.push({ page: 'smoking-setting', type }) - getSelectedShishaSettings(answer).forEach((setting) => { - steps.push({ page: 'smoking-frequency', type, setting }) - steps.push({ page: 'smoking-quantity', type, setting }) - }) - } else { - steps.push({ page: 'smoking-frequency', type }) - steps.push({ page: 'smoking-quantity', type }) + steps.push({ page: 'tobacco-smoking', type }) + + if (type !== 'shisha') { steps.push({ page: 'smoking-change', type }) getSelectedSmokingChanges(answer).forEach((change) => { - steps.push({ page: 'smoking-frequency-change', type, change }) - steps.push({ page: 'smoking-quantity-change', type, change }) - steps.push({ page: 'smoking-years-change', type, change }) + steps.push({ page: 'tobacco-smoking-change', type, change }) }) } @@ -240,10 +198,6 @@ const getSmokingTypeStepUrl = (step) => { searchParams.set('change', step.change) } - if (step.setting) { - searchParams.set('setting', step.setting) - } - return `/prototype_${version}/${step.page}?${searchParams}` } @@ -396,17 +350,6 @@ const getSmokingChangeLabels = (type, answer = {}, isPast = false) => { } } -/** - * Get the nested answer object for a shisha setting. - * - * @param {Object} answer - Shisha answer object. - * @param {string} setting - Shisha setting key. - * @returns {Object} Setting-specific answer object. - */ -const getShishaSettingAnswer = (answer = {}, setting) => { - return setting ? answer[setting] || {} : {} -} - /** * Get tobacco type content with the right tense-specific heading aliases. * @@ -431,8 +374,7 @@ const getSmokingTypeHeadings = (type, isPast = false) => { statusHeading: currentHeadings.status, frequencyHeading: tenseHeadings.frequency, quantityHeading: tenseHeadings.quantity, - changeHeading: tenseHeadings.change, - settingHeading: tenseHeadings.setting + changeHeading: tenseHeadings.change } } @@ -441,28 +383,13 @@ const getSmokingTypeHeadings = (type, isPast = false) => { * * @param {string} page - Step page id. * @param {string} type - Tobacco type key. - * @param {string} setting - Optional shisha setting 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, setting, isPast = false, answer = {}) => { - const smokingType = smokingTypes[type] +const getSmokingStepHeading = (page, type, isPast = false, answer = {}) => { const frequency = answer.smokingFrequency - if (!smokingType) { - return '' - } - - if (type === 'shisha' && setting) { - const tense = isPast ? 'past' : 'current' - const settingHeadings = smokingType.settingHeadings?.[setting]?.[tense] - - if (settingHeadings) { - return applySmokingFrequencyPeriod(settingHeadings[page.replace('smoking-', '')] || '', frequency) - } - } - return applySmokingFrequencyPeriod(getSmokingTypeHeadings(type, isPast)[`${page.replace('smoking-', '')}Heading`] || '', frequency) } @@ -495,10 +422,6 @@ const getSmokingQuantityAnswer = (answers = {}, step = {}) => { return getSmokingChangeAnswer(answer, step.change) } - if (step.setting) { - return getShishaSettingAnswer(answer, step.setting) - } - return answer } @@ -603,9 +526,8 @@ const getSmokingTypeStep = (req, page) => { const steps = getSmokingTypeSteps(answers) const queryType = req.query?.type const queryChange = req.query?.change - const querySetting = req.query?.setting - const step = steps.find((step) => step.page === page && step.type === queryType && step.change === queryChange && step.setting === querySetting) || - steps.find((step) => step.page === page && step.type === queryType && !step.change && !step.setting) || + 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 } @@ -706,9 +628,7 @@ const getSmokingQuantityQuestionOverrides = ({ return item } - const conditionalName = step.setting - ? `answers[${step.type}][${step.setting}][${item.conditionalInput.answerKey}]` - : `answers[${step.type}][${item.conditionalInput.answerKey}]` + const conditionalName = `answers[${step.type}][${item.conditionalInput.answerKey}]` return { ...item, @@ -876,7 +796,6 @@ const getSmokingContentQuestionOverrides = ({ page, step, answer, - settingAnswer, changeAnswer, smokingType, smokingChange, @@ -896,33 +815,16 @@ const getSmokingContentQuestionOverrides = ({ } } - if (page === 'smoking-setting') { - return { - heading: { - title: smokingType.settingHeading, - caption: smokingType.caption - }, - input: { - name: `answers[${step.type}][smokingSetting]` - }, - values: answer.smokingSetting - } - } - if (page === 'smoking-frequency') { - const isSettingSpecific = Boolean(step.setting) - return { heading: { - title: getSmokingStepHeading(page, step.type, step.setting, isPastSmokingType, isSettingSpecific ? settingAnswer : answer), + title: getSmokingStepHeading(page, step.type, isPastSmokingType, answer), caption: smokingType.caption }, input: { - name: isSettingSpecific - ? `answers[${step.type}][${step.setting}][smokingFrequency]` - : `answers[${step.type}][smokingFrequency]` + name: `answers[${step.type}][smokingFrequency]` }, - value: isSettingSpecific ? settingAnswer.smokingFrequency : answer.smokingFrequency, + value: answer.smokingFrequency, items: getQuestionItemsWithLabels('smoking-frequency', {}, { monthly: `Select this option if you ${isPastSmokingType ? 'smoked' : 'smoke'} at least once a month` }) @@ -930,18 +832,14 @@ const getSmokingContentQuestionOverrides = ({ } if (page === 'smoking-quantity') { - const isSettingSpecific = Boolean(step.setting) - return getSmokingQuantityQuestionOverrides({ page, step, - heading: getSmokingStepHeading(page, step.type, step.setting, isPastSmokingType, isSettingSpecific ? settingAnswer : answer), + heading: getSmokingStepHeading(page, step.type, isPastSmokingType, answer), caption: smokingType.caption, - name: isSettingSpecific - ? `answers[${step.type}][${step.setting}][smokingQuantity]` - : `answers[${step.type}][smokingQuantity]`, - value: isSettingSpecific ? settingAnswer.smokingQuantity : answer.smokingQuantity, - conditionalValue: isSettingSpecific ? settingAnswer.smokingQuantityOther : answer.smokingQuantityOther, + name: `answers[${step.type}][smokingQuantity]`, + value: answer.smokingQuantity, + conditionalValue: answer.smokingQuantityOther, smokingType }) } @@ -1004,6 +902,64 @@ const getSmokingContentQuestionOverrides = ({ 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. * @@ -1012,7 +968,7 @@ const getSmokingContentQuestionOverrides = ({ * @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 && item.setting === step.setting) + 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] @@ -1047,26 +1003,41 @@ const renderSmokingTypeQuestion = (req, res, page, errors = []) => { return } - const answer = req.session.data.answers[step.type] || {} - const isPast = isPastSmokingType(req.session.data.answers, answer) - const changeAnswer = getSmokingChangeAnswer(answer, step.change) - const settingAnswer = getShishaSettingAnswer(answer, step.setting) - const smokingType = getSmokingTypeHeadings(step.type, isPast) - const smokingChange = smokingChangeTypes[step.change] - const smokingChangeLabels = getSmokingChangeLabels(step.type, answer, isPast) + const context = getSmokingTypeStepContext(req, step) + renderQuestion(res, page, getSmokingTypeActions(step, steps), errors, getSmokingContentQuestionOverrides({ page, step, - answer, - changeAnswer, - settingAnswer, - smokingType, - smokingChange, - smokingChangeLabels, - isPastSmokingType: isPast + ...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. * @@ -1076,23 +1047,11 @@ const renderSmokingTypeQuestion = (req, res, page, errors = []) => { * @returns {Object[]} Validation errors. */ const validateSmokingTypeQuestion = (req, page, step) => { - const answer = req.session.data.answers[step.type] || {} - const isPast = isPastSmokingType(req.session.data.answers, answer) - const changeAnswer = getSmokingChangeAnswer(answer, step.change) - const settingAnswer = getShishaSettingAnswer(answer, step.setting) - const smokingType = getSmokingTypeHeadings(step.type, isPast) - const smokingChange = smokingChangeTypes[step.change] - const smokingChangeLabels = getSmokingChangeLabels(step.type, answer, isPast) + const context = getSmokingTypeStepContext(req, step) const overrides = getSmokingContentQuestionOverrides({ page, step, - answer, - settingAnswer, - changeAnswer, - smokingType, - smokingChange, - smokingChangeLabels, - isPastSmokingType: isPast + ...context }) const questionType = overrides.type || getQuestion(page).type const question = getQuestion(page) @@ -1122,16 +1081,25 @@ const validateSmokingTypeQuestion = (req, page, step) => { }) } +/** + * 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 = { - deleteUnselectedShishaSettingAnswers, deleteUnselectedSmokingQuantityOtherAnswer, deleteUnselectedSmokingChangeAnswers, deleteUnselectedSmokingTypeAnswers, formatQuantity, - getSelectedShishaSettings, getSelectedSmokingChanges, getSelectedSmokingTypes, - getShishaSettingAnswer, getFormerSmokerFallbackStep, getSmokingChangeAnswer, getSmokingChangeHeading, @@ -1145,7 +1113,9 @@ module.exports = { getSmokingTypeSteps, getSmokingTypeStepUrl, isPastSmokingType, + renderSmokingTypePage, renderSmokingTypeQuestion, + validateSmokingTypePage, validateSmokingTypeQuestion, getValueLabels } diff --git a/app/prototype_v4_2/routes.js b/app/prototype_v4_2/routes.js index ed8b356..79c5c83 100644 --- a/app/prototype_v4_2/routes.js +++ b/app/prototype_v4_2/routes.js @@ -154,6 +154,9 @@ router.post(`/prototype_${version}/cancer-diagnosis-relatives-age`, questionCont /// Smoking habits --------------------------------------------------------- /// +router.get(`/prototype_${version}/smoking-duration`, questionController.smokingDuration_get) +router.post(`/prototype_${version}/smoking-duration`, questionController.smokingDuration_post) + router.get(`/prototype_${version}/age-started-smoking`, questionController.ageStartedSmoking_get) router.post(`/prototype_${version}/age-started-smoking`, questionController.ageStartedSmoking_post) @@ -182,12 +185,12 @@ router.post(`/prototype_${version}/smoking-frequency`, questionController.smokin router.get(`/prototype_${version}/smoking-quantity`, questionController.smokingQuantity_get) router.post(`/prototype_${version}/smoking-quantity`, questionController.smokingQuantity_post) -router.get(`/prototype_${version}/smoking-setting`, questionController.smokingSetting_get) -router.post(`/prototype_${version}/smoking-setting`, questionController.smokingSetting_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) + router.get(`/prototype_${version}/smoking-frequency-change`, questionController.smokingFrequencyChange_get) router.post(`/prototype_${version}/smoking-frequency-change`, questionController.smokingFrequencyChange_post) diff --git a/app/prototype_v4_2/views/index.html b/app/prototype_v4_2/views/index.html index 8a5d722..22ace09 100644 --- a/app/prototype_v4_2/views/index.html +++ b/app/prototype_v4_2/views/index.html @@ -98,9 +98,7 @@

      Tobacco smoking

      {{ seededPageLink("/smoking-frequency-change?type=cigarettes%26change=greater", "Cigarettes - more frequency") }} {{ seededPageLink("/smoking-quantity-change?type=cigarettes%26change=greater", "Cigarettes - more quantity") }} {{ seededPageLink("/smoking-years-change?type=cigarettes%26change=greater", "Cigarettes - more duration") }} - {{ seededProfilePageLink("/smoking-setting?type=shisha", "shisha", "Shisha - smoking setting") }} - {{ seededProfilePageLink("/smoking-frequency?type=shisha%26setting=group", "shisha", "Shisha in a group - frequency") }} - {{ seededProfilePageLink("/smoking-quantity?type=shisha%26setting=group", "shisha", "Shisha in a group - quantity") }} + {{ seededProfilePageLink("/tobacco-smoking?type=shisha", "shisha", "Shisha smoking") }}

    Check and confirmation

    From 8f4f177a3aa19a548e33607c0075213daeb4d9f4 Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Thu, 21 May 2026 15:52:17 +0100 Subject: [PATCH 12/18] Tidy up unused questions and pages --- app/prototype_v4_2/controllers/question.js | 213 +-------------------- app/prototype_v4_2/data/pages.yaml | 31 --- app/prototype_v4_2/docs/question-flow.md | 2 +- app/prototype_v4_2/lib/summary.js | 8 +- app/prototype_v4_2/routes.js | 24 --- app/prototype_v4_2/views/index.html | 10 +- 6 files changed, 8 insertions(+), 280 deletions(-) diff --git a/app/prototype_v4_2/controllers/question.js b/app/prototype_v4_2/controllers/question.js index 31ee231..93e0941 100644 --- a/app/prototype_v4_2/controllers/question.js +++ b/app/prototype_v4_2/controllers/question.js @@ -557,7 +557,7 @@ exports.asbestos_post = (req, res) => { // res.redirect(`/prototype_${version}/cancer-diagnosis-relatives-age`) // } else { // delete answers.cancerDiagnosisRelativesAge -// res.redirect(`/prototype_${version}/age-started-smoking`) +// res.redirect(`/prototype_${version}/smoking-duration`) // } // } // } @@ -679,115 +679,6 @@ exports.smokingDuration_post = (req, res) => { } } -exports.ageStartedSmoking_get = (req, res) => { - const { answers } = req.session.data - const back = answers?.cancerDiagnosisRelativesAge ? `/prototype_${version}/cancer-diagnosis-relatives-age` : `/prototype_${version}/cancer-diagnosis-relatives` - - renderQuestion(res, 'age-started-smoking', { - next: `/prototype_${version}/age-started-smoking`, - back, - cancel: `/prototype_${version}/` - }) -} - -exports.ageStartedSmoking_post = (req, res) => { - const { answers } = req.session.data - const back = answers?.cancerDiagnosisRelativesAge ? `/prototype_${version}/cancer-diagnosis-relatives-age` : `/prototype_${version}/cancer-diagnosis-relatives` - - const errors = validateQuestion(answers, 'age-started-smoking') - - // TODO: - // If not answered, throw error - // If the age started smoking is older than person's age - // based on date of birth, throw error - - if (errors.length) { - renderQuestion(res, 'age-started-smoking', { - next: `/prototype_${version}/age-started-smoking`, - back, - cancel: `/prototype_${version}/` - }, errors) - } else { - if (answers.smoker === 'yes_previous') { - res.redirect(`/prototype_${version}/age-stopped-smoking`) - } else { - delete answers.ageStoppedSmoking - res.redirect(`/prototype_${version}/periods-stopped-smoking`) - } - } -} - -exports.ageStoppedSmoking_get = (req, res) => { - const { answers } = req.session.data - - if (answers.smoker !== 'yes_previous') { - res.redirect(`/prototype_${version}/periods-stopped-smoking`) - return - } - - renderQuestion(res, 'age-stopped-smoking', { - next: `/prototype_${version}/age-stopped-smoking`, - back: `/prototype_${version}/age-started-smoking`, - cancel: `/prototype_${version}/` - }) -} - -exports.ageStoppedSmoking_post = (req, res) => { - const { answers } = req.session.data - const errors = validateQuestion(answers, 'age-stopped-smoking') - - if (answers.smoker !== 'yes_previous') { - delete answers.ageStoppedSmoking - res.redirect(`/prototype_${version}/periods-stopped-smoking`) - return - } - - // TODO: - // If not answered, throw error - // If the age stopped smoking is older than person's age - // based on date of birth, throw error - - if (errors.length) { - renderQuestion(res, 'age-stopped-smoking', { - next: `/prototype_${version}/age-stopped-smoking`, - back: `/prototype_${version}/age-started-smoking`, - cancel: `/prototype_${version}/` - }, errors) - } else { - res.redirect(`/prototype_${version}/periods-stopped-smoking`) - } -} - -exports.periodsStoppedSmoking_get = (req, res) => { - const answers = req.session.data.answers || {} - const back = answers.smoker === 'yes_previous' ? `/prototype_${version}/age-stopped-smoking` : `/prototype_${version}/age-started-smoking` - - renderQuestion(res, 'periods-stopped-smoking', { - next: `/prototype_${version}/periods-stopped-smoking`, - back, - cancel: `/prototype_${version}/` - }) -} - -exports.periodsStoppedSmoking_post = (req, res) => { - const answers = req.session.data.answers || {} - const back = answers.smoker === 'yes_previous' ? `/prototype_${version}/age-stopped-smoking` : `/prototype_${version}/age-started-smoking` - const errors = validateQuestion(answers, 'periods-stopped-smoking') - - if (errors.length) { - renderQuestion(res, 'periods-stopped-smoking', { - next: `/prototype_${version}/periods-stopped-smoking`, - back, - cancel: `/prototype_${version}/` - }, errors) - } else { - if (answers.periodsStoppedSmoking === 'no') { - delete answers.yearsStoppedSmoking - } - res.redirect(`/prototype_${version}/smoking-type`) - } -} - /// ------------------------------------------------------------------------ /// /// Tobacco /// ------------------------------------------------------------------------ /// @@ -893,47 +784,6 @@ exports.smokingStatus_post = (req, res) => { } } -exports.smokingFrequency_get = (req, res) => { - renderSmokingTypeQuestion(req, res, 'smoking-frequency') -} - -exports.smokingFrequency_post = (req, res) => { - const { step, steps } = getSmokingTypeStep(req, 'smoking-frequency') - const errors = step ? validateSmokingTypeQuestion(req, 'smoking-frequency', step) : [] - - if (!step) { - res.redirect(`/prototype_${version}/smoking-type`) - return - } - - if (errors.length) { - renderSmokingTypeQuestion(req, res, 'smoking-frequency', errors) - } else { - res.redirect(getSmokingTypeActions(step, steps).onward) - } -} - -exports.smokingQuantity_get = (req, res) => { - renderSmokingTypeQuestion(req, res, 'smoking-quantity') -} - -exports.smokingQuantity_post = (req, res) => { - const { step, steps } = getSmokingTypeStep(req, 'smoking-quantity') - deleteUnselectedSmokingQuantityOtherAnswer(req.session.data.answers, step) - const errors = step ? validateSmokingTypeQuestion(req, 'smoking-quantity', step) : [] - - if (!step) { - res.redirect(`/prototype_${version}/smoking-type`) - return - } - - if (errors.length) { - renderSmokingTypeQuestion(req, res, 'smoking-quantity', errors) - } else { - res.redirect(getSmokingTypeActions(step, steps).onward) - } -} - exports.smokingChange_get = (req, res) => { renderSmokingTypeQuestion(req, res, 'smoking-change') } @@ -979,67 +829,6 @@ exports.tobaccoSmokingChange_post = (req, res) => { } } -exports.smokingFrequencyChange_get = (req, res) => { - renderSmokingTypeQuestion(req, res, 'smoking-frequency-change') -} - -exports.smokingFrequencyChange_post = (req, res) => { - const { step, steps } = getSmokingTypeStep(req, 'smoking-frequency-change') - const errors = step ? validateSmokingTypeQuestion(req, 'smoking-frequency-change', step) : [] - - if (!step) { - res.redirect(`/prototype_${version}/smoking-type`) - return - } - - if (errors.length) { - renderSmokingTypeQuestion(req, res, 'smoking-frequency-change', errors) - } else { - res.redirect(getSmokingTypeActions(step, steps).onward) - } -} - -exports.smokingQuantityChange_get = (req, res) => { - renderSmokingTypeQuestion(req, res, 'smoking-quantity-change') -} - -exports.smokingQuantityChange_post = (req, res) => { - const { step, steps } = getSmokingTypeStep(req, 'smoking-quantity-change') - deleteUnselectedSmokingQuantityOtherAnswer(req.session.data.answers, step, 'quantity') - const errors = step ? validateSmokingTypeQuestion(req, 'smoking-quantity-change', step) : [] - - if (!step) { - res.redirect(`/prototype_${version}/smoking-type`) - return - } - - if (errors.length) { - renderSmokingTypeQuestion(req, res, 'smoking-quantity-change', errors) - } else { - res.redirect(getSmokingTypeActions(step, steps).onward) - } -} - -exports.smokingYearsChange_get = (req, res) => { - renderSmokingTypeQuestion(req, res, 'smoking-years-change') -} - -exports.smokingYearsChange_post = (req, res) => { - const { step, steps } = getSmokingTypeStep(req, 'smoking-years-change') - const errors = step ? validateSmokingTypeQuestion(req, 'smoking-years-change', step) : [] - - if (!step) { - res.redirect(`/prototype_${version}/smoking-type`) - return - } - - if (errors.length) { - renderSmokingTypeQuestion(req, res, 'smoking-years-change', errors) - } else { - res.redirect(getSmokingTypeActions(step, steps).onward) - } -} - /// ------------------------------------------------------------------------ /// /// Check your answers /// ------------------------------------------------------------------------ /// diff --git a/app/prototype_v4_2/data/pages.yaml b/app/prototype_v4_2/data/pages.yaml index addb0f3..f191b6a 100644 --- a/app/prototype_v4_2/data/pages.yaml +++ b/app/prototype_v4_2/data/pages.yaml @@ -242,22 +242,6 @@ pages: 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: age-started-smoking - questions: - - age-started-smoking - - id: age-stopped-smoking - questions: - - age-stopped-smoking - - id: periods-stopped-smoking - questions: - - periods-stopped-smoking - heading: - title: Periods when you stopped smoking - caption: Your smoking habits - description: | - There may have been periods when you stopped or quit smoking. - - If you stopped smoking for periods of 1 year or longer, tell us the total number of years you stopped smoking. - id: smoking-duration heading: title: Smoking duration @@ -309,21 +293,6 @@ pages: - smoking-frequency-change - smoking-quantity-change - smoking-years-change - - id: smoking-frequency - questions: - - smoking-frequency - - id: smoking-quantity - questions: - - smoking-quantity - id: smoking-change questions: - smoking-change - - id: smoking-frequency-change - questions: - - smoking-frequency-change - - id: smoking-quantity-change - questions: - - smoking-quantity-change - - id: smoking-years-change - questions: - - smoking-years-change diff --git a/app/prototype_v4_2/docs/question-flow.md b/app/prototype_v4_2/docs/question-flow.md index 61862a9..dcc06fa 100644 --- a/app/prototype_v4_2/docs/question-flow.md +++ b/app/prototype_v4_2/docs/question-flow.md @@ -114,6 +114,6 @@ flowchart TD - `Age stopped smoking` is only asked when the `smoker` answer is `yes_previous`. - The tobacco subflow uses query strings such as `/prototype_v4_2/smoking-status?type=cigarettes`. - If the `smoker` answer is `yes_previous`, each tobacco type skips `Smoking status` and uses past-tense question text. -- Shisha asks for `Smoking setting`, then repeats frequency and quantity for each selected setting. The shisha setting-specific pages include the setting in the query string, for example `/prototype_v4_2/smoking-frequency?type=shisha&setting=group`. +- Shisha follows the same tobacco-smoking flow as other tobacco types, but skips the smoking-change flow. - If both `increased` and `decreased` are selected for a tobacco type, the flow asks the three "increased" change questions first, then the three "decreased" change questions. - `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/lib/summary.js b/app/prototype_v4_2/lib/summary.js index 5324f32..05b0939 100644 --- a/app/prototype_v4_2/lib/summary.js +++ b/app/prototype_v4_2/lib/summary.js @@ -371,22 +371,22 @@ const getCheckYourAnswers = (answers = {}) => { makeSummaryRow({ key: 'Age you started smoking', value: answers.ageStartedSmoking && `Age ${answers.ageStartedSmoking}`, - href: `/prototype_${version}/age-started-smoking` + href: `/prototype_${version}/smoking-duration` }), isFormerSmoker && makeSummaryRow({ key: 'Age you stopped smoking', value: answers.ageStoppedSmoking && `Age ${answers.ageStoppedSmoking}`, - href: `/prototype_${version}/age-stopped-smoking` + 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}/periods-stopped-smoking` + 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}/periods-stopped-smoking` + href: `/prototype_${version}/smoking-duration` }), makeSummaryRow({ key: 'Types of tobacco smoked', diff --git a/app/prototype_v4_2/routes.js b/app/prototype_v4_2/routes.js index 79c5c83..8be8944 100644 --- a/app/prototype_v4_2/routes.js +++ b/app/prototype_v4_2/routes.js @@ -157,15 +157,6 @@ router.post(`/prototype_${version}/cancer-diagnosis-relatives-age`, questionCont router.get(`/prototype_${version}/smoking-duration`, questionController.smokingDuration_get) router.post(`/prototype_${version}/smoking-duration`, questionController.smokingDuration_post) -router.get(`/prototype_${version}/age-started-smoking`, questionController.ageStartedSmoking_get) -router.post(`/prototype_${version}/age-started-smoking`, questionController.ageStartedSmoking_post) - -router.get(`/prototype_${version}/age-stopped-smoking`, questionController.ageStoppedSmoking_get) -router.post(`/prototype_${version}/age-stopped-smoking`, questionController.ageStoppedSmoking_post) - -router.get(`/prototype_${version}/periods-stopped-smoking`, questionController.periodsStoppedSmoking_get) -router.post(`/prototype_${version}/periods-stopped-smoking`, questionController.periodsStoppedSmoking_post) - /// Tobacco --------------------------------------------------------------- /// router.get(`/prototype_${version}/smoking-type`, questionController.smokingType_get) @@ -179,27 +170,12 @@ router.get(`/prototype_${version}/smoking-type-exit`, questionController.smoking router.get(`/prototype_${version}/smoking-status`, questionController.smokingStatus_get) router.post(`/prototype_${version}/smoking-status`, questionController.smokingStatus_post) -router.get(`/prototype_${version}/smoking-frequency`, questionController.smokingFrequency_get) -router.post(`/prototype_${version}/smoking-frequency`, questionController.smokingFrequency_post) - -router.get(`/prototype_${version}/smoking-quantity`, questionController.smokingQuantity_get) -router.post(`/prototype_${version}/smoking-quantity`, questionController.smokingQuantity_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) -router.get(`/prototype_${version}/smoking-frequency-change`, questionController.smokingFrequencyChange_get) -router.post(`/prototype_${version}/smoking-frequency-change`, questionController.smokingFrequencyChange_post) - -router.get(`/prototype_${version}/smoking-quantity-change`, questionController.smokingQuantityChange_get) -router.post(`/prototype_${version}/smoking-quantity-change`, questionController.smokingQuantityChange_post) - -router.get(`/prototype_${version}/smoking-years-change`, questionController.smokingYearsChange_get) -router.post(`/prototype_${version}/smoking-years-change`, questionController.smokingYearsChange_post) - /// Check your answers ----------------------------------------------------- /// router.get(`/prototype_${version}/check-your-answers`, questionController.checkYourAnswers_get) diff --git a/app/prototype_v4_2/views/index.html b/app/prototype_v4_2/views/index.html index 22ace09..1e4c9c3 100644 --- a/app/prototype_v4_2/views/index.html +++ b/app/prototype_v4_2/views/index.html @@ -81,9 +81,7 @@

    Your family history

    Your smoking habits

      - {{ pageLink("/age-started-smoking", "Age you started smoking") }} - {{ seededProfilePageLink("/age-stopped-smoking", "former", "Former smoker - age you stopped smoking") }} - {{ pageLink("/periods-stopped-smoking", "Periods when you stopped smoking") }} + {{ 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") }}
    @@ -92,12 +90,8 @@

    Tobacco smoking

      {{ seededPageLink("/tobacco-smoking", "Tobacco smoking") }} {{ seededPageLink("/smoking-status?type=cigarettes", "Cigarettes - currently smoke") }} - {{ seededPageLink("/smoking-frequency?type=cigarettes", "Cigarettes - frequency") }} - {{ seededPageLink("/smoking-quantity?type=cigarettes", "Cigarettes - quantity") }} {{ seededPageLink("/smoking-change?type=cigarettes", "Cigarettes - has quantity changed") }} - {{ seededPageLink("/smoking-frequency-change?type=cigarettes%26change=greater", "Cigarettes - more frequency") }} - {{ seededPageLink("/smoking-quantity-change?type=cigarettes%26change=greater", "Cigarettes - more quantity") }} - {{ seededPageLink("/smoking-years-change?type=cigarettes%26change=greater", "Cigarettes - more duration") }} + {{ seededPageLink("/tobacco-smoking-change?type=cigarettes%26change=greater", "Cigarettes - more smoking change") }} {{ seededProfilePageLink("/tobacco-smoking?type=shisha", "shisha", "Shisha smoking") }}
    From 0aabc420ce7a3e77fdb72481755c359f8010a947 Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Thu, 21 May 2026 16:04:22 +0100 Subject: [PATCH 13/18] Tidy up unused questions and pages --- app/prototype_v4_2/controllers/question.js | 46 ----------------- app/prototype_v4_2/data/pages.yaml | 60 +--------------------- app/prototype_v4_2/docs/question-flow.md | 5 +- app/prototype_v4_2/lib/summary.js | 4 +- app/prototype_v4_2/routes.js | 6 --- app/prototype_v4_2/views/index.html | 2 - 6 files changed, 6 insertions(+), 117 deletions(-) diff --git a/app/prototype_v4_2/controllers/question.js b/app/prototype_v4_2/controllers/question.js index 93e0941..24b0847 100644 --- a/app/prototype_v4_2/controllers/question.js +++ b/app/prototype_v4_2/controllers/question.js @@ -488,52 +488,6 @@ exports.asbestos_post = (req, res) => { } } -// exports.asbestosAtWork_get = (req, res) => { -// renderQuestion(res, 'asbestos-at-work', { -// next: `/prototype_${version}/asbestos-at-work`, -// back: `/prototype_${version}/respiratory-conditions`, -// cancel: `/prototype_${version}/` -// }) -// } - -// exports.asbestosAtWork_post = (req, res) => { -// const { answers } = req.session.data -// const errors = validateQuestion(answers, 'asbestos-at-work') - -// if (errors.length) { -// renderQuestion(res, 'asbestos-at-work', { -// next: `/prototype_${version}/asbestos-at-work`, -// back: `/prototype_${version}/respiratory-conditions`, -// cancel: `/prototype_${version}/` -// }, errors) -// } else { -// res.redirect(`/prototype_${version}/asbestos-at-home`) -// } -// } - -// exports.asbestosAtHome_get = (req, res) => { -// renderQuestion(res, 'asbestos-at-home', { -// next: `/prototype_${version}/asbestos-at-home`, -// back: `/prototype_${version}/asbestos-at-work`, -// cancel: `/prototype_${version}/` -// }) -// } - -// exports.asbestosAtHome_post = (req, res) => { -// const { answers } = req.session.data -// const errors = validateQuestion(answers, 'asbestos-at-home') - -// if (errors.length) { -// renderQuestion(res, 'asbestos-at-home', { -// next: `/prototype_${version}/asbestos-at-home`, -// back: `/prototype_${version}/asbestos-at-work`, -// cancel: `/prototype_${version}/` -// }, errors) -// } else { -// res.redirect(`/prototype_${version}/cancer-diagnosis`) -// } -// } - // exports.cancerHistory_get = (req, res) => { // renderQuestionPage(res, 'cancer-history', { // next: `/prototype_${version}/cancer-history`, diff --git a/app/prototype_v4_2/data/pages.yaml b/app/prototype_v4_2/data/pages.yaml index f191b6a..3329d5c 100644 --- a/app/prototype_v4_2/data/pages.yaml +++ b/app/prototype_v4_2/data/pages.yaml @@ -145,69 +145,12 @@ pages: questions: - respiratory-conditions - id: asbestos - questions: - - asbestos-at-work - - asbestos-at-home heading: title: Exposure to asbestos - - id: asbestos-at-work - questions: - - asbestos-at-work - heading: - title: Tell us if you might have been exposed to asbestos at work caption: Your health - description: | - You may have been exposed to asbestos if you worked in an industry such as building or construction, particularly from the 1950s to the 1990s. - - You could be exposed to asbestos today if your job involves working in certain roles in old buildings. - - Examples include: - - - heating and ventilation engineers - - demolition workers - - plumbers - - construction workers - - electricians - - ## If you did not work with asbestos but think you might have been exposed - - You might know there was asbestos in buildings you have spent time in, or products you have used. But if the asbestos was not damaged, the risk to your health is very low. - - If you lived with someone who worked with asbestos you may have been exposed to asbestos yourself. For example, if you washed the clothes they worked in. The question on the next page will ask if you lived with someone who might have been exposed to asbestos at work. - details: - summary: What is asbestos? - text: | - Asbestos was used in a number of building materials and products. For example: - - - boilers and pipes - - car brakes - - cement for roofing sheets - - floor tiles - - insulating board to protect buildings and ships against fire - - If you worked in an industry such as building or construction, you are more likely to have come into contact with damaged asbestos. - - id: asbestos-at-home questions: + - asbestos-at-work - asbestos-at-home - heading: - title: Tell us if you ever lived with anyone who worked with asbestos - caption: Your health - description: | - If you lived with someone who worked with asbestos you may have been exposed to asbestos. For example, if you washed the clothes they worked in. - - Someone might have worked with asbestos if they worked in an industry such as building or construction, particularly from the 1950s to the 1990s. - details: - summary: What is asbestos? - text: | - Asbestos was used in a number of building materials and products. For example: - - - boilers and pipes - - car brakes - - cement for roofing sheets - - floor tiles - - insulating board to protect buildings and ships against fire - - If you worked in an industry such as building or construction, you are more likely to have come into contact with damaged asbestos. - id: cancer-history heading: title: History of cancer @@ -245,6 +188,7 @@ pages: - id: smoking-duration heading: title: Smoking duration + caption: Your smoking habits questions: - age-started-smoking - id: age-stopped-smoking diff --git a/app/prototype_v4_2/docs/question-flow.md b/app/prototype_v4_2/docs/question-flow.md index dcc06fa..874044b 100644 --- a/app/prototype_v4_2/docs/question-flow.md +++ b/app/prototype_v4_2/docs/question-flow.md @@ -34,9 +34,8 @@ flowchart TD weightImperial --> aboutYou aboutYou --> respiratory["Respiratory conditions"] - respiratory --> asbestosWork["Asbestos at work"] - asbestosWork --> asbestosHome["Asbestos at home"] - asbestosHome --> cancerDiagnosis["Cancer diagnosis"] + respiratory --> asbestos["Asbestos"] + asbestos --> cancerDiagnosis["Cancer diagnosis"] cancerDiagnosis --> relatives{"Close relative had
    lung cancer?"} relatives -- Yes --> relativesAge["Relative diagnosed before 60?"] diff --git a/app/prototype_v4_2/lib/summary.js b/app/prototype_v4_2/lib/summary.js index 05b0939..61caa87 100644 --- a/app/prototype_v4_2/lib/summary.js +++ b/app/prototype_v4_2/lib/summary.js @@ -342,12 +342,12 @@ const getCheckYourAnswers = (answers = {}) => { makeSummaryRow({ key: 'Worked in a job where you might have been exposed to asbestos', value: formatValue(answers.asbestosAtWork, valueLabels.asbestosAtWork), - href: `/prototype_${version}/asbestos-at-work` + href: `/prototype_${version}/asbestos` }), makeSummaryRow({ key: 'Lived with anyone who worked with asbestos', value: formatValue(answers.asbestosAtHome, valueLabels.asbestosAtHome), - href: `/prototype_${version}/asbestos-at-home` + href: `/prototype_${version}/asbestos` }), makeSummaryRow({ key: 'Ever been diagnosed with cancer', diff --git a/app/prototype_v4_2/routes.js b/app/prototype_v4_2/routes.js index 8be8944..8a0bd3c 100644 --- a/app/prototype_v4_2/routes.js +++ b/app/prototype_v4_2/routes.js @@ -132,12 +132,6 @@ router.post(`/prototype_${version}/respiratory-conditions`, questionController.r router.get(`/prototype_${version}/asbestos`, questionController.asbestos_get) router.post(`/prototype_${version}/asbestos`, questionController.asbestos_post) -// router.get(`/prototype_${version}/asbestos-at-work`, questionController.asbestosAtWork_get) -// router.post(`/prototype_${version}/asbestos-at-work`, questionController.asbestosAtWork_post) - -// router.get(`/prototype_${version}/asbestos-at-home`, questionController.asbestosAtHome_get) -// router.post(`/prototype_${version}/asbestos-at-home`, questionController.asbestosAtHome_post) - // router.get(`/prototype_${version}/cancer-history`, questionController.cancerHistory_get) // router.post(`/prototype_${version}/cancer-history`, questionController.cancerHistory_post) diff --git a/app/prototype_v4_2/views/index.html b/app/prototype_v4_2/views/index.html index 1e4c9c3..c0a9758 100644 --- a/app/prototype_v4_2/views/index.html +++ b/app/prototype_v4_2/views/index.html @@ -67,8 +67,6 @@

    Your health

      {{ pageLink("/respiratory-conditions", "Respiratory conditions") }} {{ pageLink("/asbestos", "Asbestos") }} - {# {{ pageLink("/asbestos-at-work", "Asbestos at work") }} - {{ pageLink("/asbestos-at-home", "Asbestos at home") }} #} {# {{ pageLink("/cancer-history", "Cancer history") }} #} {{ pageLink("/cancer-diagnosis", "Cancer diagnosis") }}
    From 0efd304a101587b452aa1d542e8bec8def86c243 Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Thu, 21 May 2026 16:06:51 +0100 Subject: [PATCH 14/18] Tidy up unused questions and pages --- app/prototype_v4_2/controllers/question.js | 28 ---------------------- app/prototype_v4_2/data/pages.yaml | 6 ----- app/prototype_v4_2/routes.js | 3 --- app/prototype_v4_2/views/index.html | 1 - 4 files changed, 38 deletions(-) diff --git a/app/prototype_v4_2/controllers/question.js b/app/prototype_v4_2/controllers/question.js index 24b0847..f1cd34c 100644 --- a/app/prototype_v4_2/controllers/question.js +++ b/app/prototype_v4_2/controllers/question.js @@ -488,34 +488,6 @@ exports.asbestos_post = (req, res) => { } } -// exports.cancerHistory_get = (req, res) => { -// renderQuestionPage(res, 'cancer-history', { -// next: `/prototype_${version}/cancer-history`, -// back: `/prototype_${version}/asbestos`, -// cancel: `/prototype_${version}/` -// }) -// } - -// exports.cancerHistory_post = (req, res) => { -// const { answers } = req.session.data -// const errors = validateQuestions(answers, getQuestionPageIds('cancer-history')) - -// if (errors.length) { -// renderQuestionPage(res, 'cancer-history', { -// next: `/prototype_${version}/cancer-history`, -// back: `/prototype_${version}/asbestos`, -// 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.cancerDiagnosis_get = (req, res) => { renderQuestion(res, 'cancer-diagnosis', { next: `/prototype_${version}/cancer-diagnosis`, diff --git a/app/prototype_v4_2/data/pages.yaml b/app/prototype_v4_2/data/pages.yaml index 3329d5c..974a4ec 100644 --- a/app/prototype_v4_2/data/pages.yaml +++ b/app/prototype_v4_2/data/pages.yaml @@ -151,12 +151,6 @@ pages: questions: - asbestos-at-work - asbestos-at-home - - id: cancer-history - heading: - title: History of cancer - questions: - - cancer-diagnosis - - cancer-diagnosis-relatives - id: cancer-diagnosis questions: - cancer-diagnosis diff --git a/app/prototype_v4_2/routes.js b/app/prototype_v4_2/routes.js index 8a0bd3c..878eea6 100644 --- a/app/prototype_v4_2/routes.js +++ b/app/prototype_v4_2/routes.js @@ -132,9 +132,6 @@ router.post(`/prototype_${version}/respiratory-conditions`, questionController.r router.get(`/prototype_${version}/asbestos`, questionController.asbestos_get) router.post(`/prototype_${version}/asbestos`, questionController.asbestos_post) -// router.get(`/prototype_${version}/cancer-history`, questionController.cancerHistory_get) -// router.post(`/prototype_${version}/cancer-history`, questionController.cancerHistory_post) - router.get(`/prototype_${version}/cancer-diagnosis`, questionController.cancerDiagnosis_get) router.post(`/prototype_${version}/cancer-diagnosis`, questionController.cancerDiagnosis_post) diff --git a/app/prototype_v4_2/views/index.html b/app/prototype_v4_2/views/index.html index c0a9758..fca59fa 100644 --- a/app/prototype_v4_2/views/index.html +++ b/app/prototype_v4_2/views/index.html @@ -67,7 +67,6 @@

    Your health

      {{ pageLink("/respiratory-conditions", "Respiratory conditions") }} {{ pageLink("/asbestos", "Asbestos") }} - {# {{ pageLink("/cancer-history", "Cancer history") }} #} {{ pageLink("/cancer-diagnosis", "Cancer diagnosis") }}
    From 22e71627956ab66d070ee489d566841ac95132a4 Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Thu, 21 May 2026 16:26:05 +0100 Subject: [PATCH 15/18] Render captions nicely --- app/prototype_v4_2/data/pages.yaml | 2 ++ app/prototype_v4_2/docs/question-schema.md | 11 ++++++++++- app/prototype_v4_2/lib/question-pages.js | 18 +++++++++++++++--- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/app/prototype_v4_2/data/pages.yaml b/app/prototype_v4_2/data/pages.yaml index 974a4ec..6e020da 100644 --- a/app/prototype_v4_2/data/pages.yaml +++ b/app/prototype_v4_2/data/pages.yaml @@ -142,6 +142,8 @@ pages: If your qualification is not shown choose the closest level. - id: respiratory-conditions + heading: + caption: Your health questions: - respiratory-conditions - id: asbestos diff --git a/app/prototype_v4_2/docs/question-schema.md b/app/prototype_v4_2/docs/question-schema.md index 1d1e506..5e4cc0e 100644 --- a/app/prototype_v4_2/docs/question-schema.md +++ b/app/prototype_v4_2/docs/question-schema.md @@ -162,7 +162,16 @@ questions: value: no ``` -If a page has no `heading`, the renderer falls back to the first question label as the page heading and sets `isPageHeading: true`. +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 diff --git a/app/prototype_v4_2/lib/question-pages.js b/app/prototype_v4_2/lib/question-pages.js index 7dfcf60..728e104 100644 --- a/app/prototype_v4_2/lib/question-pages.js +++ b/app/prototype_v4_2/lib/question-pages.js @@ -38,7 +38,8 @@ const getQuestionHeading = (question) => { } return { - title: question.input.label + title: question.input.label, + caption: question.input.caption } } @@ -143,7 +144,12 @@ const matchesCondition = (questionRef, answers = {}) => { const mergeQuestionWithPageContent = (question, pageContent = {}, options = {}) => { const isSingleQuestionPage = options.isSingleQuestionPage === true const hasPageHeading = Boolean(options.pageHeading?.title) - const heading = pageContent.heading || getQuestionHeading(question) + const heading = pageContent.heading + ? { + ...getQuestionHeading(question), + ...pageContent.heading + } + : getQuestionHeading(question) const input = { ...question.input } @@ -200,10 +206,16 @@ const getQuestionPage = (id, answers = {}) => { }) }) const firstQuestion = questions[0] + const heading = page.heading + ? { + ...firstQuestion?.page?.heading, + ...page.heading + } + : firstQuestion?.page?.heading return { ...page, - heading: page.heading || firstQuestion?.page?.heading, + heading, description: page.description !== undefined ? page.description : visiblePageQuestions.length === 1 ? firstQuestion?.page?.description : undefined, From 2c7b7ec94afbc33b719bf8611d448bee11bc91c6 Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Thu, 21 May 2026 17:26:29 +0100 Subject: [PATCH 16/18] Update question-flow.md --- app/prototype_v4_2/docs/question-flow.md | 66 +++++++++--------------- 1 file changed, 25 insertions(+), 41 deletions(-) diff --git a/app/prototype_v4_2/docs/question-flow.md b/app/prototype_v4_2/docs/question-flow.md index 874044b..d1f5e12 100644 --- a/app/prototype_v4_2/docs/question-flow.md +++ b/app/prototype_v4_2/docs/question-flow.md @@ -1,4 +1,4 @@ -# Prototype v4.1 question flow +# 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`. @@ -39,14 +39,10 @@ flowchart TD cancerDiagnosis --> relatives{"Close relative had
    lung cancer?"} relatives -- Yes --> relativesAge["Relative diagnosed before 60?"] - relatives -- No --> ageStarted["Age started smoking"] - relativesAge --> ageStarted + relatives -- No --> smokingDuration["Smoking duration
    Age started, age stopped if applicable,
    periods stopped"] + relativesAge --> smokingDuration - ageStarted --> previousSmoker{"Used to smoke?"} - previousSmoker -- Yes --> ageStopped["Age stopped smoking"] - previousSmoker -- No, currently smokes --> stoppedSmoking["Periods stopped smoking"] - ageStopped --> stoppedSmoking - stoppedSmoking --> smokingType{"Smoking type"} + 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"] @@ -70,39 +66,24 @@ The tobacco questions repeat for each selected tobacco type, in this order: ```mermaid flowchart TD - selectedType["Next selected tobacco type"] --> isShisha{"Is the selected type
    shisha?"} - - isShisha -- Yes --> shishaPast{"Used to smoke?"} - shishaPast -- No, currently smokes --> shishaStatus["Smoking status"] - shishaPast -- Yes --> setting["Smoking setting"] - shishaStatus --> setting - setting --> selectedSetting["Next selected shisha setting"] - selectedSetting --> shishaFrequency["Smoking frequency"] - shishaFrequency --> shishaQuantity["Smoking quantity"] - shishaQuantity --> moreSettings{"More selected
    shisha settings?"} - moreSettings -- Yes --> selectedSetting - moreSettings -- No --> nextTypeOrCya - - isShisha -- No --> past{"Used to smoke?"} - past -- No, currently smokes --> status["Smoking status"] - past -- Yes --> frequency["Smoking frequency"] - status --> frequency["Smoking frequency"] - frequency --> quantity["Smoking quantity"] - quantity --> changed{"Smoking changed
    over time?"} + selectedType["Next selected tobacco type"] --> formerSmoker{"Former smoker?"} - changed -- No change selected --> nextTypeOrCya - changed -- Increased selected --> increasedFrequency["Increased: frequency before change"] - increasedFrequency --> increasedQuantity["Increased: quantity before change"] - increasedQuantity --> increasedYears["Increased: years before change"] - increasedYears --> decreasedSelected{"Decreased also selected?"} + 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 -- Decreased selected --> decreasedFrequency["Decreased: frequency before change"] - decreasedSelected -- Yes --> decreasedFrequency - decreasedSelected -- No --> nextTypeOrCya + changed -- No change selected --> nextTypeOrCya + changed -- More selected --> moreChange["Tobacco smoking change
    More: frequency, quantity and years"] + moreChange --> fewerSelected{"Fewer also selected?"} - decreasedFrequency --> decreasedQuantity["Decreased: quantity before change"] - decreasedQuantity --> decreasedYears["Decreased: years before change"] - decreasedYears --> nextTypeOrCya + 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"] ``` @@ -110,9 +91,12 @@ flowchart TD ## Notes - Height and weight unit pages can be switched manually using the unit-switch links. -- `Age stopped smoking` is only asked when the `smoker` answer is `yes_previous`. -- The tobacco subflow uses query strings such as `/prototype_v4_2/smoking-status?type=cigarettes`. +- `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 `increased` and `decreased` are selected for a tobacco type, the flow asks the three "increased" change questions first, then the three "decreased" change questions. +- 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. From 2080146656934345ef6dcfd40acd6c74e9cf608b Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Thu, 21 May 2026 17:28:15 +0100 Subject: [PATCH 17/18] Update question-flow.md --- app/prototype_v4_2/docs/question-flow.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/prototype_v4_2/docs/question-flow.md b/app/prototype_v4_2/docs/question-flow.md index d1f5e12..a5546d6 100644 --- a/app/prototype_v4_2/docs/question-flow.md +++ b/app/prototype_v4_2/docs/question-flow.md @@ -34,7 +34,7 @@ flowchart TD weightImperial --> aboutYou aboutYou --> respiratory["Respiratory conditions"] - respiratory --> asbestos["Asbestos"] + respiratory --> asbestos["Asbestos
    At work, at home"] asbestos --> cancerDiagnosis["Cancer diagnosis"] cancerDiagnosis --> relatives{"Close relative had
    lung cancer?"} From 1a68aa849a5edeccf84ad1ffeca32e16d3d81b53 Mon Sep 17 00:00:00 2001 From: Simon Whatley Date: Fri, 22 May 2026 09:58:34 +0100 Subject: [PATCH 18/18] Fix question reference --- app/prototype_v4_2/lib/tobacco-flow.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/prototype_v4_2/lib/tobacco-flow.js b/app/prototype_v4_2/lib/tobacco-flow.js index c89f3ab..8d98a48 100644 --- a/app/prototype_v4_2/lib/tobacco-flow.js +++ b/app/prototype_v4_2/lib/tobacco-flow.js @@ -505,7 +505,7 @@ const getSmokingChangeHeading = (page, type, change, changeAnswer = {}, answer = const quantity = getSmokingQuantity(type, changeAnswer.quantity) if (!quantity) { - return getQuestionPage('smoking-years-change').heading.title + return getQuestion('smoking-years-change').input.label } return `How many years did you smoke ${[quantity, getSmokingFrequencyPeriod(answer.smokingFrequency)].filter(Boolean).join(' ')}?`