From 2fcdea215d41029f43e67c90b780c368cbcc2f28 Mon Sep 17 00:00:00 2001 From: rivalee Date: Wed, 27 May 2026 17:02:48 +0100 Subject: [PATCH 01/15] Add accession no to worklist status --- app/views/_includes/appointment-status-bar.njk | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/_includes/appointment-status-bar.njk b/app/views/_includes/appointment-status-bar.njk index b454a757..0e553bad 100644 --- a/app/views/_includes/appointment-status-bar.njk +++ b/app/views/_includes/appointment-status-bar.njk @@ -16,12 +16,12 @@ {% if data.settings.screening.addedToWorklist == 'false' %} {{ appIcon("cross", { classes: "app-header-status__icon" }) }} - Not added to worklist (Retry) + Not added to worklist (ID: A123BC4) (Retry) {% else %} {{ appIcon("tick", { classes: "app-header-status__icon" }) }} - Added to worklist + Added to worklist (ID: A123BC4) {% endif %} {% endset %} From 0d21a2267cea8ad66a3e494b3b0c298c35494d4b Mon Sep 17 00:00:00 2001 From: rivalee Date: Thu, 28 May 2026 11:35:47 +0100 Subject: [PATCH 02/15] Add scenarios and success messages --- .../javascript/retry-worklist-connection.js | 54 +++++++ app/routes/events.js | 143 +++++++++++++++++- .../_includes/appointment-status-bar.njk | 8 +- .../events/retry-worklist-connection.html | 52 +++++++ 4 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 app/assets/javascript/retry-worklist-connection.js create mode 100644 app/views/events/retry-worklist-connection.html diff --git a/app/assets/javascript/retry-worklist-connection.js b/app/assets/javascript/retry-worklist-connection.js new file mode 100644 index 00000000..f4f103f0 --- /dev/null +++ b/app/assets/javascript/retry-worklist-connection.js @@ -0,0 +1,54 @@ +// app/assets/javascript/retry-worklist-connection.js +// +// Adds a brief "Attempting to reconnect" transient state to the Retry +// connection button so the user sees feedback before the page reloads. + +(function () { + const RECONNECT_DELAY_MS = 1500 + const button = document.querySelector('[data-retry-connection-button]') + if (!button) return + + const form = button.form + if (!form) return + + const originalText = button.textContent + + // Reset the button on initial load and when restored from bfcache, so it + // doesn't get stuck in the "Attempting to reconnect" disabled state after + // the post-redirect page load. + const resetButton = function () { + button.disabled = false + button.textContent = originalText + } + resetButton() + window.addEventListener('pageshow', resetButton) + + let isSubmitting = false + + form.addEventListener('submit', function (event) { + // Only intercept when the user clicked the Retry button (not the + // secondary "Switch to manual" button, which uses its own formaction). + if (event.submitter !== button) return + + // After the simulated reconnect delay we re-submit programmatically; + // let that submission through without re-intercepting it. + if (isSubmitting) return + + event.preventDefault() + + button.disabled = true + button.textContent = 'Attempting to reconnect' + + window.setTimeout(function () { + isSubmitting = true + // Re-enable so the value is submitted, then submit using the + // button's formaction. + button.disabled = false + if (typeof form.requestSubmit === 'function') { + form.requestSubmit(button) + } else { + form.submit() + } + }, RECONNECT_DELAY_MS) + }) +})() diff --git a/app/routes/events.js b/app/routes/events.js index 1903f4e5..642b45b1 100644 --- a/app/routes/events.js +++ b/app/routes/events.js @@ -5,6 +5,7 @@ const _ = require('lodash') const { getParticipant, getFullName, + getAge, saveTempParticipantToParticipant } = require('../lib/utils/participants') const { @@ -25,7 +26,7 @@ const { } = require('../lib/utils/referrers') const { createDynamicTemplateRoute } = require('../lib/utils/dynamic-routing') const { isAppointmentWorkflow } = require('../lib/utils/status') -const { sentenceCase } = require('../lib/utils/strings') +const { sentenceCase, formatNhsNumber } = require('../lib/utils/strings') const { getImageSetForEvent } = require('../lib/utils/mammogram-images') const { ensureSeedProfilesState, @@ -2228,10 +2229,133 @@ module.exports = (router) => { } ) + // Worklist connection retry routes + + // Helper: only allow same-origin app paths as return URLs. + const safeReturnUrl = (url) => { + if (typeof url !== 'string') return null + if (!url.startsWith('/')) return null + if (url.startsWith('//')) return null + return url + } + + // GET the retry page - capture where the user came from so we can send them + // back after a successful reconnect. + router.get('/clinics/:clinicId/events/:eventId/retry-worklist-connection', (req, res) => { + const data = req.session.data + + const fromQuery = safeReturnUrl(req.query.returnUrl) + if (fromQuery) { + data.worklistRetryReturnUrl = fromQuery + } + + res.render('events/retry-worklist-connection') + }) + + // Handle "Retry connection" button. + // The first attempt always fails (updates the "last retry attempt" time). + // The second (and any subsequent) attempt succeeds: marks the appointment as + // added to the worklist, flashes a success message, and returns the user to + // wherever they clicked Retry from. + router.post('/clinics/:clinicId/events/:eventId/retry-worklist-connection', (req, res) => { + const { clinicId, eventId } = req.params + const data = req.session.data + + data.settings = data.settings || {} + data.settings.screening = data.settings.screening || {} + + const attempts = (data.worklistRetryAttempts || 0) + 1 + + if (attempts >= 2) { + // Success: connection restored. + data.settings.screening.addedToWorklist = 'true' + delete data.settings.screening.worklistLastRetryAt + delete data.worklistRetryAttempts + + const returnUrl = + safeReturnUrl(data.worklistRetryReturnUrl) || + `/clinics/${clinicId}/events/${eventId}/take-images` + delete data.worklistRetryReturnUrl + + const participantName = getFullName(data.participant) + req.flash('success', { + html: `

${participantName} is now on the worklist

+

Image information will be sent automatically from the mammogram machine

` + }) + return res.redirect(returnUrl) + } + + // Failed attempt. + data.worklistRetryAttempts = attempts + data.settings.screening.worklistLastRetryAt = new Date().toISOString() + + res.redirect(`/clinics/${clinicId}/events/${eventId}/retry-worklist-connection`) + }) + + // Handle "Switch to manual image mode" button - enable manual mode, return + // the user to where they clicked Retry from, and flash a success banner + // explaining what they need to do on the mammogram machine. + router.post('/clinics/:clinicId/events/:eventId/switch-to-manual-image-mode', (req, res) => { + const { clinicId, eventId } = req.params + const data = req.session.data + + data.settings = data.settings || {} + data.settings.screening = data.settings.screening || {} + data.settings.screening.manualImageCollection = 'true' + + const returnUrl = + safeReturnUrl(data.worklistRetryReturnUrl) || + `/clinics/${clinicId}/events/${eventId}/take-images` + + delete data.worklistRetryAttempts + delete data.worklistRetryReturnUrl + delete data.settings.screening.worklistLastRetryAt + + const participant = data.participant || {} + const demographic = participant.demographicInformation || {} + const medical = participant.medicalInformation || {} + const participantName = getFullName(participant) + const nhsNumber = formatNhsNumber(medical.nhsNumber) + const dob = demographic.dateOfBirth + const dobFormatted = dob ? dayjs(dob).format('D MMMM YYYY') : '' + const age = getAge(participant) + const dobValue = dobFormatted + ? `${dobFormatted}${age ? ` (${age} years old)` : ''}` + : '' + + const html = ` +

Manual image mode enabled

+

Set up an unscheduled appointment for ${participantName} on the mammogram machine before taking images.

+

Add the following details so mammograms can be matched to the correct participant:

+
+
+
NHS number
+
${nhsNumber}
+
+
+
Full name
+
${participantName}
+
+
+
Date of birth
+
${dobValue}
+
+
` + + req.flash('success', { + title: 'Success', + html + }) + + res.redirect(returnUrl) + }) + // Manual imaging routes - // Handle take-images route - redirect to appropriate page based on state - router.get('/clinics/:clinicId/events/:eventId/take-images', (req, res) => { + // Handle take-images route - redirect to appropriate page based on state. + // Use `all` so the gate applies to the POST from review-medical-information + // as well as direct GET navigation. + router.all('/clinics/:clinicId/events/:eventId/take-images', (req, res) => { const { clinicId, eventId } = req.params const data = req.session.data @@ -2240,6 +2364,19 @@ module.exports = (router) => { const imagesStageCompleted = data.event?.workflowStatus?.['take-images'] === 'completed' + // Gate: if the appointment was not added to the worklist and the user + // hasn't yet switched to manual image mode, divert to the retry page + // before letting them into the image-taking step. + const isAddedToWorklist = + data.settings?.screening?.addedToWorklist !== 'false' + + if (!isAddedToWorklist && !isManualImageCollection) { + return res.redirect( + `/clinics/${clinicId}/events/${eventId}/retry-worklist-connection?returnUrl=` + + encodeURIComponent(`/clinics/${clinicId}/events/${eventId}/take-images`) + ) + } + // If manual flow and images already completed, redirect to details page for editing if ( isManualImageCollection && diff --git a/app/views/_includes/appointment-status-bar.njk b/app/views/_includes/appointment-status-bar.njk index 0e553bad..c1b6e668 100644 --- a/app/views/_includes/appointment-status-bar.njk +++ b/app/views/_includes/appointment-status-bar.njk @@ -16,12 +16,16 @@ {% if data.settings.screening.addedToWorklist == 'false' %} {{ appIcon("cross", { classes: "app-header-status__icon" }) }} - Not added to worklist (ID: A123BC4) (Retry) + {% if data.settings.screening.manualImageCollection == 'true' %} + Not added to worklist (manual image mode enabled) + {% else %} + Not added to worklist (ID: KOX20260527A1246) Retry + {% endif %} {% else %} {{ appIcon("tick", { classes: "app-header-status__icon" }) }} - Added to worklist (ID: A123BC4) + Added to worklist (ID: KOX20260527A1246) {% endif %} {% endset %} diff --git a/app/views/events/retry-worklist-connection.html b/app/views/events/retry-worklist-connection.html new file mode 100644 index 00000000..8f8c6ad5 --- /dev/null +++ b/app/views/events/retry-worklist-connection.html @@ -0,0 +1,52 @@ +{# app/views/events/retry-worklist-connection.html #} + +{% extends 'layout-appointment.html' %} + +{% set hideBackLink = true %} +{% set activeWorkflowStep = 'take-images' %} +{% set activeTab = 'images' %} + +{% set pageHeading = (participant | getFullName) + " has not been added to the worklist" %} + +{% set gridColumn = "nhsuk-grid-column-two-thirds" %} + +{% block pageContent %} + + {{ backLink({ + href: "javascript:history.back();", + text: "Back", + classes: "nhsuk-u-margin-top-0 nhsuk-u-margin-bottom-4" + }) }} + +

{{ pageHeading }}

+ +

Due to a connection issue, image information for this appointment cannot be transferred automatically from the mammogram machine.

+ +
+
+ {{ button({ + text: "Retry connection", + attributes: { + formaction: "./retry-worklist-connection", + "data-retry-connection-button": "" + } + }) }} + + {{ button({ + text: "Switch to manual image mode", + classes: "nhsuk-button--secondary", + attributes: { + formaction: "./switch-to-manual-image-mode" + } + }) }} +
+
+ + {% set lastRetryAt = data.settings.screening.worklistLastRetryAt %} + {% if lastRetryAt %} +

Connection failed on retry. Last retry attempt at {{ lastRetryAt | formatTime('HH:mm:ss') }}

+ {% endif %} + + + +{% endblock %} From 054fdcc422b2445c5ccafc893ca772cc7bbdc13f Mon Sep 17 00:00:00 2001 From: rivalee Date: Thu, 28 May 2026 13:50:30 +0100 Subject: [PATCH 03/15] Update manual image page with content --- app/routes/events.js | 36 ++--------------------------- app/views/events/images-manual.html | 30 +++++++++++++++++++++++- 2 files changed, 31 insertions(+), 35 deletions(-) diff --git a/app/routes/events.js b/app/routes/events.js index 642b45b1..45456fe7 100644 --- a/app/routes/events.js +++ b/app/routes/events.js @@ -5,7 +5,6 @@ const _ = require('lodash') const { getParticipant, getFullName, - getAge, saveTempParticipantToParticipant } = require('../lib/utils/participants') const { @@ -26,7 +25,7 @@ const { } = require('../lib/utils/referrers') const { createDynamicTemplateRoute } = require('../lib/utils/dynamic-routing') const { isAppointmentWorkflow } = require('../lib/utils/status') -const { sentenceCase, formatNhsNumber } = require('../lib/utils/strings') +const { sentenceCase } = require('../lib/utils/strings') const { getImageSetForEvent } = require('../lib/utils/mammogram-images') const { ensureSeedProfilesState, @@ -2311,40 +2310,9 @@ module.exports = (router) => { delete data.worklistRetryReturnUrl delete data.settings.screening.worklistLastRetryAt - const participant = data.participant || {} - const demographic = participant.demographicInformation || {} - const medical = participant.medicalInformation || {} - const participantName = getFullName(participant) - const nhsNumber = formatNhsNumber(medical.nhsNumber) - const dob = demographic.dateOfBirth - const dobFormatted = dob ? dayjs(dob).format('D MMMM YYYY') : '' - const age = getAge(participant) - const dobValue = dobFormatted - ? `${dobFormatted}${age ? ` (${age} years old)` : ''}` - : '' - - const html = ` -

Manual image mode enabled

-

Set up an unscheduled appointment for ${participantName} on the mammogram machine before taking images.

-

Add the following details so mammograms can be matched to the correct participant:

-
-
-
NHS number
-
${nhsNumber}
-
-
-
Full name
-
${participantName}
-
-
-
Date of birth
-
${dobValue}
-
-
` - req.flash('success', { title: 'Success', - html + html: '

Manual image mode enabled

' }) res.redirect(returnUrl) diff --git a/app/views/events/images-manual.html b/app/views/events/images-manual.html index c7351ec6..a8bfd6e1 100644 --- a/app/views/events/images-manual.html +++ b/app/views/events/images-manual.html @@ -4,7 +4,7 @@ {% set hideBackLink = true %} {% set activeWorkflowStep = 'take-images' %} -{% set pageHeading = "Record images taken" %} +{% set pageHeading = "Manually record image information" %} {% set gridColumn = "nhsuk-grid-column-two-thirds" %} @@ -79,6 +79,34 @@

{{ pageHeading }}

] }) }} #} + {# When the worklist connection failed and the user chose manual mode, + show the details they need to enter on the mammogram machine to set up + an unscheduled appointment. #} + {% if data.settings.screening.addedToWorklist == 'false' and data.settings.screening.manualImageCollection == 'true' %} +

Set up an unscheduled appointment on the mammogram machine before taking images.

+

Add the following details so your admin team can match mammograms to the correct participant once the appointment has been completed:

+ {{ summaryList({ + rows: [ + { + key: { text: "ID" }, + value: { text: "KOX20260527A1246" } + }, + { + key: { text: "NHS number" }, + value: { text: participant.medicalInformation.nhsNumber | formatNhsNumber } + }, + { + key: { text: "Full name" }, + value: { text: participant | getFullName } + }, + { + key: { text: "Date of birth" }, + value: { text: participant.demographicInformation.dateOfBirth | formatDate } + } + ] + }) }} + {% endif %} + {{ radios({ idPrefix: "imagingComplete", name: "event[mammogramDataTemp][isStandardSet]", From b1a17ca4180e524b810ea85c8a07eba4db3c0549 Mon Sep 17 00:00:00 2001 From: rivalee Date: Thu, 28 May 2026 15:43:48 +0100 Subject: [PATCH 04/15] Add reason capture when the user switches from troubleshooting content --- app/routes/events.js | 7 +++++++ app/views/events/images-manual.html | 28 ++++++++++++++++++++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/app/routes/events.js b/app/routes/events.js index 45456fe7..3ea3be03 100644 --- a/app/routes/events.js +++ b/app/routes/events.js @@ -2310,6 +2310,13 @@ module.exports = (router) => { delete data.worklistRetryReturnUrl delete data.settings.screening.worklistLastRetryAt + // Clear any failover flag from a prior automatic→manual switch so we + // don't incorrectly show the "Reason for switching" input here (the + // reason is implicit when arriving via the retry-connection flow). + if (data.event?.mammogramDataTemp) { + delete data.event.mammogramDataTemp.isManualFailover + } + req.flash('success', { title: 'Success', html: '

Manual image mode enabled

' diff --git a/app/views/events/images-manual.html b/app/views/events/images-manual.html index a8bfd6e1..ebb18c51 100644 --- a/app/views/events/images-manual.html +++ b/app/views/events/images-manual.html @@ -79,10 +79,13 @@

{{ pageHeading }}

] }) }} #} - {# When the worklist connection failed and the user chose manual mode, - show the details they need to enter on the mammogram machine to set up - an unscheduled appointment. #} - {% if data.settings.screening.addedToWorklist == 'false' and data.settings.screening.manualImageCollection == 'true' %} + {# Show unscheduled-appointment setup details whenever the appointment is + being handled in manual image collection mode. The reason-for-switching + input is only shown when the user manually switched mid-appointment via + the troubleshooting link on the automatic images page (failover). #} + {% set isManualFailover = event.mammogramDataTemp.isManualFailover %} + + {% if data.settings.screening.manualImageCollection == 'true' or isManualFailover %}

Set up an unscheduled appointment on the mammogram machine before taking images.

Add the following details so your admin team can match mammograms to the correct participant once the appointment has been completed:

{{ summaryList({ @@ -105,6 +108,23 @@

{{ pageHeading }}

} ] }) }} + + {% if isManualFailover %} + {% call details({ + summaryText: "Add reason for switching to manual" + }) %} + {{ textarea({ + label: { + text: "Reason for switching to manual", + classes: "nhsuk-label--s" + }, + id: "manualImageModeReason", + name: "event[mammogramDataTemp][manualImageModeReason]", + rows: 3, + value: mammogramSource.manualImageModeReason + }) }} + {% endcall %} + {% endif %} {% endif %} {{ radios({ From 5d98b4aae1335b6534be83810337451224d9fc6f Mon Sep 17 00:00:00 2001 From: rivalee Date: Wed, 3 Jun 2026 11:55:21 +0100 Subject: [PATCH 05/15] Update troubleshooting content links and content variants for different issues --- app/routes/events.js | 17 +++++++++++++++++ .../_includes/images/image-troubleshooting.njk | 6 +++--- app/views/events/images-manual.html | 12 ++++++++---- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/app/routes/events.js b/app/routes/events.js index 3ea3be03..59645204 100644 --- a/app/routes/events.js +++ b/app/routes/events.js @@ -2382,6 +2382,12 @@ module.exports = (router) => { router.get('/clinics/:clinicId/events/:eventId/images-manual', (req, res) => { const { clinicId, eventId } = req.params const data = req.session.data + const validTroubleshootingIssues = [ + 'worklist-participant', + 'wrong-image-count', + 'incorrect-image-labels' + ] + const troubleshootingIssue = req.query.issue // If mammogramData exists and is manual entry, prepopulate temp for editing if (data.event?.mammogramData?.isManualEntry) { @@ -2410,6 +2416,17 @@ module.exports = (router) => { } } + // Persist troubleshooting issue context when navigating from troubleshooting links + if (validTroubleshootingIssues.includes(troubleshootingIssue)) { + if (!data.event.mammogramDataTemp) { + data.event.mammogramDataTemp = {} + } + data.event.mammogramDataTemp.troubleshootingIssue = troubleshootingIssue + } else if (data.event?.mammogramDataTemp?.troubleshootingIssue) { + // Clear stale issue context for non-troubleshooting entry points + delete data.event.mammogramDataTemp.troubleshootingIssue + } + // Let the dynamic routing handle the actual rendering res.render('events/images-manual') }) diff --git a/app/views/_includes/images/image-troubleshooting.njk b/app/views/_includes/images/image-troubleshooting.njk index 562b8cbd..2db4334e 100644 --- a/app/views/_includes/images/image-troubleshooting.njk +++ b/app/views/_includes/images/image-troubleshooting.njk @@ -19,7 +19,7 @@

If this needs to be changed, update the mammogram machine and wait a few seconds for the image details to refresh.

-

If the issue is not resolved, enable manual image mode.

+

If the issue is not resolved, enable manual image mode.

Images details will be reconciled after the appointment.

@@ -29,7 +29,7 @@ summaryText: "The wrong number of images are displayed" }) %} -

Enable manual image mode.

+

Enable manual image mode.

Images details will be reconciled after the appointment.

@@ -41,7 +41,7 @@

Update image information on the mammogram machine and wait a few seconds for the image details to refresh.

-

If the issue is not resolved, enable manual image mode.

+

If the issue is not resolved, enable manual image mode.

Images details will be reconciled after the appointment.

diff --git a/app/views/events/images-manual.html b/app/views/events/images-manual.html index ebb18c51..2f1df6e6 100644 --- a/app/views/events/images-manual.html +++ b/app/views/events/images-manual.html @@ -84,10 +84,14 @@

{{ pageHeading }}

input is only shown when the user manually switched mid-appointment via the troubleshooting link on the automatic images page (failover). #} {% set isManualFailover = event.mammogramDataTemp.isManualFailover %} - - {% if data.settings.screening.manualImageCollection == 'true' or isManualFailover %} -

Set up an unscheduled appointment on the mammogram machine before taking images.

-

Add the following details so your admin team can match mammograms to the correct participant once the appointment has been completed:

+ {% set troubleshootingIssue = event.mammogramDataTemp.troubleshootingIssue %} + {% set hasTroubleshootingIssue = troubleshootingIssue in ['worklist-participant', 'wrong-image-count', 'incorrect-image-labels'] %} + {% set showWorklistMatchingContent = troubleshootingIssue == 'worklist-participant' %} + {% set showDefaultManualContent = data.settings.screening.manualImageCollection == 'true' or isManualFailover %} + + {% if (hasTroubleshootingIssue and showWorklistMatchingContent) or (not hasTroubleshootingIssue and showDefaultManualContent) %} +

Manually add participant details

+

Set up an unscheduled appointment using the following information so mammograms can be assigned correctly:

{{ summaryList({ rows: [ { From 46a3533ded5401247a52a3222e330f68e0eb999b Mon Sep 17 00:00:00 2001 From: rivalee Date: Thu, 4 Jun 2026 10:24:42 +0100 Subject: [PATCH 06/15] Refine workflow status info chunks --- app/assets/sass/_utils.scss | 4 +++ .../_includes/appointment-status-bar.njk | 26 ++++++++++++++----- app/views/events/images-manual.html | 6 ++--- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/app/assets/sass/_utils.scss b/app/assets/sass/_utils.scss index 00cef5bc..fa027ce2 100644 --- a/app/assets/sass/_utils.scss +++ b/app/assets/sass/_utils.scss @@ -17,3 +17,7 @@ .app-display-none { display: none; } + +.app-code-font { + font-family: $nhsuk-code-font; +} diff --git a/app/views/_includes/appointment-status-bar.njk b/app/views/_includes/appointment-status-bar.njk index c1b6e668..87753b9a 100644 --- a/app/views/_includes/appointment-status-bar.njk +++ b/app/views/_includes/appointment-status-bar.njk @@ -4,7 +4,7 @@ {% set appointmentRowItems = [] %} {# Date and time #} -{% set appointmentHtml %} +{% set appointmentDateTimeHtml %} {{ clinic.date | formatDate }} at {{ event.timing.startTime | formatTimeString }} {% if event | isSpecialAppointment %} {{ tag({ @@ -13,25 +13,39 @@ classes: "nhsuk-u-margin-left-1" })}} {% endif %} +{% endset %} +{% set appointmentRowItems = appointmentRowItems | push({ + key: 'Appt:', + value: appointmentDateTimeHtml +}) %} + +{# Worklist accession number #} +{% set appointmentRowItems = appointmentRowItems | push({ + key: 'Accn:', + value: 'KOX 20260527 A1246' +}) %} + +{# Worklist status #} +{% set worklistStatusHtml %} {% if data.settings.screening.addedToWorklist == 'false' %} {{ appIcon("cross", { classes: "app-header-status__icon" }) }} {% if data.settings.screening.manualImageCollection == 'true' %} Not added to worklist (manual image mode enabled) {% else %} - Not added to worklist (ID: KOX20260527A1246) Retry + Not added to worklist Retry {% endif %} {% else %} {{ appIcon("tick", { classes: "app-header-status__icon" }) }} - Added to worklist (ID: KOX20260527A1246) + Added to worklist {% endif %} {% endset %} {% set appointmentRowItems = appointmentRowItems | push({ - key: "Appointment:", - value: appointmentHtml + key: 'Worklist status', + value: worklistStatusHtml }) %} {# Appointment type #} @@ -90,7 +104,7 @@ {# NHS Number #} {% set participantRowItems = participantRowItems | push({ key: "NHS:", - value: participant.medicalInformation.nhsNumber | formatNhsNumber + value: '' + (participant.medicalInformation.nhsNumber | formatNhsNumber | replace(' ', ' ')) + '' }) %} {{ appStatusBar({ diff --git a/app/views/events/images-manual.html b/app/views/events/images-manual.html index 2f1df6e6..e4426e49 100644 --- a/app/views/events/images-manual.html +++ b/app/views/events/images-manual.html @@ -95,12 +95,12 @@

Manually add participant details

{{ summaryList({ rows: [ { - key: { text: "ID" }, - value: { text: "KOX20260527A1246" } + key: { html: 'Accn' }, + value: { html: 'KOX 20260527 A1246' } }, { key: { text: "NHS number" }, - value: { text: participant.medicalInformation.nhsNumber | formatNhsNumber } + value: { html: '' + (participant.medicalInformation.nhsNumber | formatNhsNumber | replace(' ', ' ')) + '' } }, { key: { text: "Full name" }, From 3351a4ee88e2b4ade6d0f3803a25108bbc771f92 Mon Sep 17 00:00:00 2001 From: rivalee Date: Thu, 4 Jun 2026 14:02:18 +0100 Subject: [PATCH 07/15] Fix status messages and spacing --- app/assets/sass/components/_status-bar.scss | 8 ++++++++ app/routes/events.js | 1 + app/views/_components/status-bar/template.njk | 2 +- .../_includes/appointment-status-bar.njk | 20 ++++++++----------- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/app/assets/sass/components/_status-bar.scss b/app/assets/sass/components/_status-bar.scss index 480c19c8..19512280 100644 --- a/app/assets/sass/components/_status-bar.scss +++ b/app/assets/sass/components/_status-bar.scss @@ -28,6 +28,10 @@ align-items: center; } +.app-status-bar__row--compact { + gap: 10px; +} + .app-status-bar__row + .app-status-bar__row { margin-top: 8px; padding-top: 8px; @@ -45,6 +49,10 @@ opacity: 0.9; } +.app-status-bar__worklist-status { + margin-left: 4px; +} + .app-status-bar__viewer-link { margin-left: auto; } diff --git a/app/routes/events.js b/app/routes/events.js index 59645204..dfabd5da 100644 --- a/app/routes/events.js +++ b/app/routes/events.js @@ -2301,6 +2301,7 @@ module.exports = (router) => { data.settings = data.settings || {} data.settings.screening = data.settings.screening || {} data.settings.screening.manualImageCollection = 'true' + data.settings.screening.manualImageModeEnabledByUser = 'true' const returnUrl = safeReturnUrl(data.worklistRetryReturnUrl) || diff --git a/app/views/_components/status-bar/template.njk b/app/views/_components/status-bar/template.njk index b31f0a1a..b771bb61 100644 --- a/app/views/_components/status-bar/template.njk +++ b/app/views/_components/status-bar/template.njk @@ -6,7 +6,7 @@
{% for row in params.rows %} -
+
{% for item in row.items %}
{% if item.key and item.value %} diff --git a/app/views/_includes/appointment-status-bar.njk b/app/views/_includes/appointment-status-bar.njk index 87753b9a..f655a89d 100644 --- a/app/views/_includes/appointment-status-bar.njk +++ b/app/views/_includes/appointment-status-bar.njk @@ -19,33 +19,29 @@ value: appointmentDateTimeHtml }) %} -{# Worklist accession number #} -{% set appointmentRowItems = appointmentRowItems | push({ - key: 'Accn:', - value: 'KOX 20260527 A1246' -}) %} - {# Worklist status #} {% set worklistStatusHtml %} {% if data.settings.screening.addedToWorklist == 'false' %} {{ appIcon("cross", { classes: "app-header-status__icon" }) }} - {% if data.settings.screening.manualImageCollection == 'true' %} - Not added to worklist (manual image mode enabled) + {% if data.settings.screening.manualImageCollection == 'true' and data.settings.screening.manualImageModeEnabledByUser == 'true' %} + Worklist issue, manual mode enabled {% else %} - Not added to worklist Retry + Not on worklist (Retry) {% endif %} {% else %} {{ appIcon("tick", { classes: "app-header-status__icon" }) }} - Added to worklist + On worklist {% endif %} {% endset %} + +{# Worklist accession number with inline worklist status #} {% set appointmentRowItems = appointmentRowItems | push({ - key: 'Worklist status', - value: worklistStatusHtml + key: 'Accn:', + value: 'KOX 20260527 A1246' + worklistStatusHtml + '' }) %} {# Appointment type #} From 43921f3aa1d5930b3f97ee261fa7004dfd3e59b0 Mon Sep 17 00:00:00 2001 From: rivalee Date: Thu, 4 Jun 2026 15:23:38 +0100 Subject: [PATCH 08/15] Fix bits and bobs --- app/routes/events.js | 14 +++++++++++-- app/routes/settings.js | 32 +++++++++++++++++++++++++++++ app/views/events/appointment.html | 11 ++++++++++ app/views/events/images-manual.html | 2 +- 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/app/routes/events.js b/app/routes/events.js index dfabd5da..5dbb5ecb 100644 --- a/app/routes/events.js +++ b/app/routes/events.js @@ -2335,6 +2335,18 @@ module.exports = (router) => { const { clinicId, eventId } = req.params const data = req.session.data + const isAddedToWorklist = + data.settings?.screening?.addedToWorklist !== 'false' + + // When a participant is on the worklist, image transfer should use the + // automatic flow. Clear any stale manual-mode state from prior actions. + if (isAddedToWorklist) { + data.settings = data.settings || {} + data.settings.screening = data.settings.screening || {} + data.settings.screening.manualImageCollection = 'false' + delete data.settings.screening.manualImageModeEnabledByUser + } + const isManualImageCollection = data.settings?.screening?.manualImageCollection === 'true' const imagesStageCompleted = @@ -2343,8 +2355,6 @@ module.exports = (router) => { // Gate: if the appointment was not added to the worklist and the user // hasn't yet switched to manual image mode, divert to the retry page // before letting them into the image-taking step. - const isAddedToWorklist = - data.settings?.screening?.addedToWorklist !== 'false' if (!isAddedToWorklist && !isManualImageCollection) { return res.redirect( diff --git a/app/routes/settings.js b/app/routes/settings.js index 4ea599dd..e78fd8c2 100644 --- a/app/routes/settings.js +++ b/app/routes/settings.js @@ -64,6 +64,38 @@ const getCustomOverridesFromBody = (body = {}, fallbackProfile = {}) => { } module.exports = (router) => { + // Keep worklist simulation settings consistent with image collection mode: + // - "Not successful": clear any stale "manual mode enabled by user" marker + // so retry messaging is shown + // - "Successful": return to automatic image collection mode + router.get('/settings', (req, res, next) => { + const addedToWorklistFromQuery = + req.query?.settings?.screening?.addedToWorklist + + if (addedToWorklistFromQuery === 'false') { + if (!req.session.data.settings) { + req.session.data.settings = {} + } + if (!req.session.data.settings.screening) { + req.session.data.settings.screening = {} + } + + delete req.session.data.settings.screening.manualImageModeEnabledByUser + } else if (addedToWorklistFromQuery === 'true') { + if (!req.session.data.settings) { + req.session.data.settings = {} + } + if (!req.session.data.settings.screening) { + req.session.data.settings.screening = {} + } + + req.session.data.settings.screening.manualImageCollection = 'false' + delete req.session.data.settings.screening.manualImageModeEnabledByUser + } + + next() + }) + // Ensure any POST to settings resolves to a GET render router.post('/settings', (req, res) => { return res.redirect(303, '/settings') diff --git a/app/views/events/appointment.html b/app/views/events/appointment.html index 565df501..b74e0761 100644 --- a/app/views/events/appointment.html +++ b/app/views/events/appointment.html @@ -252,6 +252,17 @@ } | openInModal ] if event.status != "event_complete" else [] } + }, + { + key: { + text: "Accession number" + }, + value: { + html: 'KOX 20260527 A1246' + }, + actions: { + items: [] + } } ] } | handleSummaryListMissingInformation) }} diff --git a/app/views/events/images-manual.html b/app/views/events/images-manual.html index e4426e49..ccada7ff 100644 --- a/app/views/events/images-manual.html +++ b/app/views/events/images-manual.html @@ -95,7 +95,7 @@

Manually add participant details

{{ summaryList({ rows: [ { - key: { html: 'Accn' }, + key: { text: "Accession number" }, value: { html: 'KOX 20260527 A1246' } }, { From 9e3712541fdcbfddf4ec4e2f47a352179e5d3edd Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Mon, 8 Jun 2026 12:23:15 +0100 Subject: [PATCH 09/15] Generate accession numbers and format them --- app/assets/sass/_utils.scss | 1 + app/assets/sass/components/_status-bar.scss | 6 ++--- app/lib/generators/clinic-generator.js | 1 + app/lib/generators/event-generator.js | 11 +++++++++ app/lib/generators/mammogram-generator.js | 6 +++-- app/lib/utils/strings.js | 24 +++++++++++++++++++ .../_includes/appointment-status-bar.njk | 3 ++- app/views/events/appointment.html | 2 +- 8 files changed, 47 insertions(+), 7 deletions(-) diff --git a/app/assets/sass/_utils.scss b/app/assets/sass/_utils.scss index fa027ce2..caf09aba 100644 --- a/app/assets/sass/_utils.scss +++ b/app/assets/sass/_utils.scss @@ -20,4 +20,5 @@ .app-code-font { font-family: $nhsuk-code-font; + word-spacing: -0.5ch; } diff --git a/app/assets/sass/components/_status-bar.scss b/app/assets/sass/components/_status-bar.scss index 19512280..292e2345 100644 --- a/app/assets/sass/components/_status-bar.scss +++ b/app/assets/sass/components/_status-bar.scss @@ -28,9 +28,9 @@ align-items: center; } -.app-status-bar__row--compact { - gap: 10px; -} +// .app-status-bar__row--compact { +// gap: 10px; +// } .app-status-bar__row + .app-status-bar__row { margin-top: 8px; diff --git a/app/lib/generators/clinic-generator.js b/app/lib/generators/clinic-generator.js index d80efc5f..b9f1b3d8 100644 --- a/app/lib/generators/clinic-generator.js +++ b/app/lib/generators/clinic-generator.js @@ -183,6 +183,7 @@ const generateClinic = ( clinicCode: generateClinicCode(), date: clinicDate.format('YYYY-MM-DD'), breastScreeningUnitId: breastScreeningUnit.id, + bsuAbbreviation: breastScreeningUnit.abbreviation, locationType: location.type, clinicType, riskLevels, diff --git a/app/lib/generators/event-generator.js b/app/lib/generators/event-generator.js index 1b6fa167..3867dc49 100644 --- a/app/lib/generators/event-generator.js +++ b/app/lib/generators/event-generator.js @@ -143,10 +143,19 @@ const generateEvent = ({ eventStatus = 'event_in_progress' } + // Generate accession number for this appointment - format: ABCYYYYMMDD##### + // ABC = BSU abbreviation, YYYYMMDD = clinic date, ##### = random 5-digit sequence + const accessionNumber = [ + clinic.bsuAbbreviation || 'BSU', + dayjs(clinic.date).format('YYYYMMDD'), + faker.number.int({ min: 10000, max: 99999 }) + ].join('') + const eventBase = { id: id || generateId(), participantId: participant.id, clinicId: clinic.id, + accessionNumber, slotId: slot.id, type: clinic.clinicType, timing: { @@ -271,6 +280,7 @@ const generateEvent = ({ // Add mammogram images for completed events event.mammogramData = generateMammogramImages({ startTime: actualStartTime, + accessionNumber: eventBase.accessionNumber, isSeedData: true, config: participant.config, scenarioWeights: seedDataProfile?.mammogram?.scenarioWeights, @@ -347,6 +357,7 @@ const generateEvent = ({ if (event.workflowStatus?.['take-images'] === 'completed') { event.mammogramData = generateMammogramImages({ startTime: dayjs(event.sessionDetails.startedAt), + accessionNumber: event.accessionNumber, isSeedData: true, config: participant.config, scenarioWeights: seedDataProfile?.mammogram?.scenarioWeights, diff --git a/app/lib/generators/mammogram-generator.js b/app/lib/generators/mammogram-generator.js index b0318d16..a9f19626 100644 --- a/app/lib/generators/mammogram-generator.js +++ b/app/lib/generators/mammogram-generator.js @@ -145,6 +145,7 @@ const generateViewImages = ({ * * @param {object} [options] - Generation options * @param {Date|string} [options.startTime] - Starting timestamp (defaults to now) + * @param {string} [options.accessionNumber] - Accession number for this study (from the event) * @param {boolean} [options.isSeedData] - Whether generating seed data * @param {object} [options.config] - Optional configuration for specific scenarios * @param {string} [options.config.scenario] - Force a specific scenario ('standard', 'extraImages', 'technicalRepeat', 'incomplete', 'incompleteImperfect') @@ -155,13 +156,15 @@ const generateViewImages = ({ */ const generateMammogramImages = ({ startTime = new Date(), + accessionNumber = null, isSeedData = false, config = {}, scenarioWeights = null, imperfectChanceForTechnicalOrIncomplete = 0.15, notesForReaderChanceWithoutImperfect = 0.05 } = {}) => { - const accessionBase = faker.number + // Use the provided accession number as base, or fall back to a random number + const accessionBase = accessionNumber || faker.number .int({ min: 100000000, max: 999999999 }) .toString() let currentIndex = 1 @@ -359,7 +362,6 @@ const generateMammogramImages = ({ } return { - accessionBase, views, ...incompleteMammographyData, ...imperfectData, diff --git a/app/lib/utils/strings.js b/app/lib/utils/strings.js index 01e2d329..5e58181c 100644 --- a/app/lib/utils/strings.js +++ b/app/lib/utils/strings.js @@ -356,6 +356,29 @@ const formatNhsNumber = (input) => { // formatNhsNumber(4857773456) // returns '485 777 3456' // formatNhsNumber('485 777 3456') // returns '485 777 3456' +/** + * Format an accession number for display with spaces (ABC YYYYMMDD ##### format) + * + * @param {string} input - Raw accession number, e.g. 'KOX2026052712345' + * @returns {string} Formatted accession number, e.g. 'KOX 20260527 12345' + * @example + * formatAccessionNumber('KOX2026052712345') // 'KOX 20260527 12345' + */ +const formatAccessionNumber = (input) => { + if (!input) return '' + const str = input.toString().replace(/\s/g, '') + + // Expect 3 letters + 8 digits (date) + remaining digits + if (!/^[A-Z]{3}\d{13,}$/.test(str)) { + return input + } + + const bsu = str.slice(0, 3) + const date = str.slice(3, 11) + const sequence = str.slice(11) + return `${bsu} ${date} ${sequence}` +} + /** * Make a word plural based on a count * @@ -394,6 +417,7 @@ const formatMammogramViewCode = (code) => { module.exports = { addIndefiniteArticle, + formatAccessionNumber, formatCurrency, formatCurrencyForCsv, formatNhsNumber, diff --git a/app/views/_includes/appointment-status-bar.njk b/app/views/_includes/appointment-status-bar.njk index f655a89d..a9380294 100644 --- a/app/views/_includes/appointment-status-bar.njk +++ b/app/views/_includes/appointment-status-bar.njk @@ -39,9 +39,10 @@ {% endset %} {# Worklist accession number with inline worklist status #} +{% set accessionNumberFormatted = event.accessionNumber | formatAccessionNumber %} {% set appointmentRowItems = appointmentRowItems | push({ key: 'Accn:', - value: 'KOX 20260527 A1246' + worklistStatusHtml + '' + value: '' + accessionNumberFormatted + '' + worklistStatusHtml + '' }) %} {# Appointment type #} diff --git a/app/views/events/appointment.html b/app/views/events/appointment.html index b74e0761..8a16138e 100644 --- a/app/views/events/appointment.html +++ b/app/views/events/appointment.html @@ -258,7 +258,7 @@ text: "Accession number" }, value: { - html: 'KOX 20260527 A1246' + html: '' + (event.accessionNumber | formatAccessionNumber) + '' }, actions: { items: [] From 0cec40b9790b74c4aa2e86d97174082645f36070 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Mon, 8 Jun 2026 12:30:42 +0100 Subject: [PATCH 10/15] Clean up template --- app/views/events/retry-worklist-connection.html | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/app/views/events/retry-worklist-connection.html b/app/views/events/retry-worklist-connection.html index 8f8c6ad5..64b81c21 100644 --- a/app/views/events/retry-worklist-connection.html +++ b/app/views/events/retry-worklist-connection.html @@ -2,9 +2,9 @@ {% extends 'layout-appointment.html' %} -{% set hideBackLink = true %} {% set activeWorkflowStep = 'take-images' %} {% set activeTab = 'images' %} +{% set isForm = true %} {% set pageHeading = (participant | getFullName) + " has not been added to the worklist" %} @@ -12,18 +12,11 @@ {% block pageContent %} - {{ backLink({ - href: "javascript:history.back();", - text: "Back", - classes: "nhsuk-u-margin-top-0 nhsuk-u-margin-bottom-4" - }) }} -

{{ pageHeading }}

Due to a connection issue, image information for this appointment cannot be transferred automatically from the mammogram machine.

-
-
+
{{ button({ text: "Retry connection", attributes: { @@ -39,8 +32,7 @@

{{ pageHeading }}

formaction: "./switch-to-manual-image-mode" } }) }} -
- +
{% set lastRetryAt = data.settings.screening.worklistLastRetryAt %} {% if lastRetryAt %} From eb621a36dd0e241845895a001e826606eafd3430 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Mon, 8 Jun 2026 12:30:52 +0100 Subject: [PATCH 11/15] Better formatting --- app/views/_includes/appointment-status-bar.njk | 2 +- app/views/events/images-manual.html | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/views/_includes/appointment-status-bar.njk b/app/views/_includes/appointment-status-bar.njk index a9380294..9d8780a1 100644 --- a/app/views/_includes/appointment-status-bar.njk +++ b/app/views/_includes/appointment-status-bar.njk @@ -101,7 +101,7 @@ {# NHS Number #} {% set participantRowItems = participantRowItems | push({ key: "NHS:", - value: '' + (participant.medicalInformation.nhsNumber | formatNhsNumber | replace(' ', ' ')) + '' + value: '' + (participant.medicalInformation.nhsNumber | formatNhsNumber) + '' }) %} {{ appStatusBar({ diff --git a/app/views/events/images-manual.html b/app/views/events/images-manual.html index ccada7ff..22f24546 100644 --- a/app/views/events/images-manual.html +++ b/app/views/events/images-manual.html @@ -96,11 +96,11 @@

Manually add participant details

rows: [ { key: { text: "Accession number" }, - value: { html: 'KOX 20260527 A1246' } + value: { html: '' + (event.accessionNumber | formatAccessionNumber) + '' } }, { key: { text: "NHS number" }, - value: { html: '' + (participant.medicalInformation.nhsNumber | formatNhsNumber | replace(' ', ' ')) + '' } + value: { html: '' + (participant.medicalInformation.nhsNumber | formatNhsNumber) + '' } }, { key: { text: "Full name" }, From 002863c16b9773670c2546f3c5ba69a4b057b18f Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Mon, 8 Jun 2026 13:17:25 +0100 Subject: [PATCH 12/15] Move row --- .../mammogram-image-data.njk | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/app/views/_includes/summary-lists/medical-information/mammogram-image-data.njk b/app/views/_includes/summary-lists/medical-information/mammogram-image-data.njk index 6b8e3cf7..44ac02a6 100644 --- a/app/views/_includes/summary-lists/medical-information/mammogram-image-data.njk +++ b/app/views/_includes/summary-lists/medical-information/mammogram-image-data.njk @@ -117,6 +117,25 @@ {% set defaultHref = "./images-automatic" %} +{# Image recording method - only show if manual entry #} +{% if isManualEntry %} + {% set imageRecordingValueHtml %} +

Manual

+ {% if event.mammogramData.isManualFailover %} +

Add a reason for using manual data collection

+ {% endif %} + {% endset %} + + {% set summaryRows = summaryRows | push({ + key: { + text: "Image recording method" + }, + value: { + html: imageRecordingValueHtml + } + }) %} +{% endif %} + {# Machine room #} {% if event.mammogramData.machineRoom %} {# Check if this is a mobile clinic #} @@ -146,25 +165,6 @@ }) %} {% endif %} -{# Image recording method - only show if manual entry #} -{% if isManualEntry %} - {% set imageRecordingValueHtml %} -

Manual

- {% if event.mammogramData.isManualFailover %} -

Add a reason for using manual data collection

- {% endif %} - {% endset %} - - {% set summaryRows = summaryRows | push({ - key: { - text: "Image recording method" - }, - value: { - html: imageRecordingValueHtml - } - }) %} -{% endif %} - {# Combined views taken (showing all view information in one row) #} {# Sort views by side and standard order before displaying #} {% set sortedViews = [] %} From 3e6cab9e0e6eca6fab1292f012ec0b74be3a351a Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Mon, 8 Jun 2026 15:33:52 +0100 Subject: [PATCH 13/15] Rework worklist routes --- .../javascript/retry-worklist-connection.js | 61 ++++--- app/routes/events.js | 158 +++++++----------- app/routes/settings.js | 32 ---- .../_includes/appointment-status-bar.njk | 6 +- app/views/events/images-manual.html | 2 +- .../events/retry-worklist-connection.html | 12 +- app/views/events/take-images.html | 2 +- 7 files changed, 110 insertions(+), 163 deletions(-) diff --git a/app/assets/javascript/retry-worklist-connection.js b/app/assets/javascript/retry-worklist-connection.js index f4f103f0..925f2f7d 100644 --- a/app/assets/javascript/retry-worklist-connection.js +++ b/app/assets/javascript/retry-worklist-connection.js @@ -1,9 +1,11 @@ // app/assets/javascript/retry-worklist-connection.js // -// Adds a brief "Attempting to reconnect" transient state to the Retry -// connection button so the user sees feedback before the page reloads. +// Handles retry connection simulation entirely client-side. +// First click: shows "Attempting to reconnect" then shows a failure message. +// Second click: shows "Attempting to reconnect" then submits the form (server +// marks worklist as connected and redirects via referrerChain). -(function () { +;(function () { const RECONNECT_DELAY_MS = 1500 const button = document.querySelector('[data-retry-connection-button]') if (!button) return @@ -11,12 +13,13 @@ const form = button.form if (!form) return + const failureMessage = document.querySelector('[data-retry-failure-message]') + const retryTimeSpan = document.querySelector('[data-retry-time]') const originalText = button.textContent + let attempts = 0 - // Reset the button on initial load and when restored from bfcache, so it - // doesn't get stuck in the "Attempting to reconnect" disabled state after - // the post-redirect page load. - const resetButton = function () { + // Reset button state on page load and bfcache restore + const resetButton = () => { button.disabled = false button.textContent = originalText } @@ -25,13 +28,11 @@ let isSubmitting = false - form.addEventListener('submit', function (event) { - // Only intercept when the user clicked the Retry button (not the - // secondary "Switch to manual" button, which uses its own formaction). + form.addEventListener('submit', (event) => { + // Only intercept the Retry button (not the secondary Switch to manual button) if (event.submitter !== button) return - // After the simulated reconnect delay we re-submit programmatically; - // let that submission through without re-intercepting it. + // After a successful retry we re-submit programmatically — let it through if (isSubmitting) return event.preventDefault() @@ -39,16 +40,34 @@ button.disabled = true button.textContent = 'Attempting to reconnect' - window.setTimeout(function () { - isSubmitting = true - // Re-enable so the value is submitted, then submit using the - // button's formaction. - button.disabled = false - if (typeof form.requestSubmit === 'function') { - form.requestSubmit(button) - } else { - form.submit() + setTimeout(() => { + attempts++ + + if (attempts >= 2) { + // Second attempt: simulate success — submit form to server + isSubmitting = true + button.disabled = false + if (typeof form.requestSubmit === 'function') { + form.requestSubmit(button) + } else { + form.submit() + } + return + } + + // First attempt: simulate failure — show message and re-enable button + if (failureMessage && retryTimeSpan) { + const now = new Date() + const timeString = now.toLocaleTimeString('en-GB', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + retryTimeSpan.textContent = timeString + failureMessage.style.display = '' } + + resetButton() }, RECONNECT_DELAY_MS) }) })() diff --git a/app/routes/events.js b/app/routes/events.js index 5dbb5ecb..1f5e5bd2 100644 --- a/app/routes/events.js +++ b/app/routes/events.js @@ -2229,102 +2229,63 @@ module.exports = (router) => { ) // Worklist connection retry routes + // + // The retry page is rendered as a simple GET (auto-routed or via this route). + // Retry counting is handled entirely client-side — the JS fakes a failed + // first attempt and a successful second attempt, then submits the form. + // + // POST retry-worklist-connection: marks worklist as connected and redirects + // back using the standard referrerChain system. + // + // POST switch-to-manual-image-mode: stores manual mode on the EVENT (not + // globally) and redirects back via referrerChain. + + // Handle successful "Retry connection" — client-side JS only submits after + // simulating a successful reconnect. + router.post( + '/clinics/:clinicId/events/:eventId/retry-worklist-connection', + (req, res) => { + const { clinicId, eventId } = req.params + const data = req.session.data - // Helper: only allow same-origin app paths as return URLs. - const safeReturnUrl = (url) => { - if (typeof url !== 'string') return null - if (!url.startsWith('/')) return null - if (url.startsWith('//')) return null - return url - } - - // GET the retry page - capture where the user came from so we can send them - // back after a successful reconnect. - router.get('/clinics/:clinicId/events/:eventId/retry-worklist-connection', (req, res) => { - const data = req.session.data - - const fromQuery = safeReturnUrl(req.query.returnUrl) - if (fromQuery) { - data.worklistRetryReturnUrl = fromQuery - } - - res.render('events/retry-worklist-connection') - }) - - // Handle "Retry connection" button. - // The first attempt always fails (updates the "last retry attempt" time). - // The second (and any subsequent) attempt succeeds: marks the appointment as - // added to the worklist, flashes a success message, and returns the user to - // wherever they clicked Retry from. - router.post('/clinics/:clinicId/events/:eventId/retry-worklist-connection', (req, res) => { - const { clinicId, eventId } = req.params - const data = req.session.data - - data.settings = data.settings || {} - data.settings.screening = data.settings.screening || {} - - const attempts = (data.worklistRetryAttempts || 0) + 1 - - if (attempts >= 2) { - // Success: connection restored. - data.settings.screening.addedToWorklist = 'true' - delete data.settings.screening.worklistLastRetryAt - delete data.worklistRetryAttempts - - const returnUrl = - safeReturnUrl(data.worklistRetryReturnUrl) || - `/clinics/${clinicId}/events/${eventId}/take-images` - delete data.worklistRetryReturnUrl + // Mark this event as reconnected (per-event, doesn't change the global setting) + data.event.isOnWorklist = true const participantName = getFullName(data.participant) req.flash('success', { html: `

${participantName} is now on the worklist

Image information will be sent automatically from the mammogram machine

` }) + + const returnUrl = getReturnUrl( + `/clinics/${clinicId}/events/${eventId}/take-images`, + req.query.referrerChain + ) return res.redirect(returnUrl) } + ) - // Failed attempt. - data.worklistRetryAttempts = attempts - data.settings.screening.worklistLastRetryAt = new Date().toISOString() - - res.redirect(`/clinics/${clinicId}/events/${eventId}/retry-worklist-connection`) - }) - - // Handle "Switch to manual image mode" button - enable manual mode, return - // the user to where they clicked Retry from, and flash a success banner - // explaining what they need to do on the mammogram machine. - router.post('/clinics/:clinicId/events/:eventId/switch-to-manual-image-mode', (req, res) => { - const { clinicId, eventId } = req.params - const data = req.session.data - - data.settings = data.settings || {} - data.settings.screening = data.settings.screening || {} - data.settings.screening.manualImageCollection = 'true' - data.settings.screening.manualImageModeEnabledByUser = 'true' + // Handle "Switch to manual image mode" — stores override on the event only. + router.post( + '/clinics/:clinicId/events/:eventId/switch-to-manual-image-mode', + (req, res) => { + const { clinicId, eventId } = req.params + const data = req.session.data - const returnUrl = - safeReturnUrl(data.worklistRetryReturnUrl) || - `/clinics/${clinicId}/events/${eventId}/take-images` + // Store manual mode on the event itself, not globally + data.event.isManualImageCollection = true - delete data.worklistRetryAttempts - delete data.worklistRetryReturnUrl - delete data.settings.screening.worklistLastRetryAt + req.flash('success', { + html: '

Manual image mode enabled

' + }) - // Clear any failover flag from a prior automatic→manual switch so we - // don't incorrectly show the "Reason for switching" input here (the - // reason is implicit when arriving via the retry-connection flow). - if (data.event?.mammogramDataTemp) { - delete data.event.mammogramDataTemp.isManualFailover + const returnUrl = getReturnUrl( + `/clinics/${clinicId}/events/${eventId}/take-images`, + req.query.referrerChain + ) + res.redirect(returnUrl) } - - req.flash('success', { - title: 'Success', - html: '

Manual image mode enabled

' - }) - - res.redirect(returnUrl) - }) + ) // Manual imaging routes @@ -2336,30 +2297,27 @@ module.exports = (router) => { const data = req.session.data const isAddedToWorklist = - data.settings?.screening?.addedToWorklist !== 'false' - - // When a participant is on the worklist, image transfer should use the - // automatic flow. Clear any stale manual-mode state from prior actions. - if (isAddedToWorklist) { - data.settings = data.settings || {} - data.settings.screening = data.settings.screening || {} - data.settings.screening.manualImageCollection = 'false' - delete data.settings.screening.manualImageModeEnabledByUser - } + data.settings?.screening?.addedToWorklist !== 'false' || + data.event?.isOnWorklist === true + // Manual mode is true if the global setting says so, OR this specific + // event was switched to manual (e.g. via the retry-connection page). const isManualImageCollection = - data.settings?.screening?.manualImageCollection === 'true' + data.settings?.screening?.manualImageCollection === 'true' || + data.event?.isManualImageCollection === true + const imagesStageCompleted = data.event?.workflowStatus?.['take-images'] === 'completed' // Gate: if the appointment was not added to the worklist and the user // hasn't yet switched to manual image mode, divert to the retry page // before letting them into the image-taking step. - if (!isAddedToWorklist && !isManualImageCollection) { return res.redirect( - `/clinics/${clinicId}/events/${eventId}/retry-worklist-connection?returnUrl=` + - encodeURIComponent(`/clinics/${clinicId}/events/${eventId}/take-images`) + urlWithReferrer( + `/clinics/${clinicId}/events/${eventId}/retry-worklist-connection`, + `/clinics/${clinicId}/events/${eventId}/take-images` + ) ) } @@ -2412,14 +2370,16 @@ module.exports = (router) => { // Clear any existing temp data for fresh start delete data.event.mammogramDataTemp - // Check if this is a failover from automatic mode - const isManualImageCollection = + // Check if this is a failover from automatic mode (event was switched + // to manual via the retry-connection page, or user navigated here from + // the troubleshooting link on the automatic images page). + const isGlobalManualSetting = data.settings?.screening?.manualImageCollection === 'true' const hadAutomaticData = !!data.event?.mammogramData && !data.event?.mammogramData?.isManualEntry // Set failover flag if switching from automatic to manual - if (!isManualImageCollection || hadAutomaticData) { + if (!isGlobalManualSetting || hadAutomaticData) { if (!data.event.mammogramDataTemp) { data.event.mammogramDataTemp = {} } diff --git a/app/routes/settings.js b/app/routes/settings.js index e78fd8c2..4ea599dd 100644 --- a/app/routes/settings.js +++ b/app/routes/settings.js @@ -64,38 +64,6 @@ const getCustomOverridesFromBody = (body = {}, fallbackProfile = {}) => { } module.exports = (router) => { - // Keep worklist simulation settings consistent with image collection mode: - // - "Not successful": clear any stale "manual mode enabled by user" marker - // so retry messaging is shown - // - "Successful": return to automatic image collection mode - router.get('/settings', (req, res, next) => { - const addedToWorklistFromQuery = - req.query?.settings?.screening?.addedToWorklist - - if (addedToWorklistFromQuery === 'false') { - if (!req.session.data.settings) { - req.session.data.settings = {} - } - if (!req.session.data.settings.screening) { - req.session.data.settings.screening = {} - } - - delete req.session.data.settings.screening.manualImageModeEnabledByUser - } else if (addedToWorklistFromQuery === 'true') { - if (!req.session.data.settings) { - req.session.data.settings = {} - } - if (!req.session.data.settings.screening) { - req.session.data.settings.screening = {} - } - - req.session.data.settings.screening.manualImageCollection = 'false' - delete req.session.data.settings.screening.manualImageModeEnabledByUser - } - - next() - }) - // Ensure any POST to settings resolves to a GET render router.post('/settings', (req, res) => { return res.redirect(303, '/settings') diff --git a/app/views/_includes/appointment-status-bar.njk b/app/views/_includes/appointment-status-bar.njk index 9d8780a1..b7e4e8ac 100644 --- a/app/views/_includes/appointment-status-bar.njk +++ b/app/views/_includes/appointment-status-bar.njk @@ -21,13 +21,13 @@ {# Worklist status #} {% set worklistStatusHtml %} - {% if data.settings.screening.addedToWorklist == 'false' %} + {% if data.settings.screening.addedToWorklist == 'false' and not data.event.isOnWorklist %} {{ appIcon("cross", { classes: "app-header-status__icon" }) }} - {% if data.settings.screening.manualImageCollection == 'true' and data.settings.screening.manualImageModeEnabledByUser == 'true' %} + {% if data.event.isManualImageCollection %} Worklist issue, manual mode enabled {% else %} - Not on worklist (Retry) + Not on worklist (Retry) {% endif %} {% else %} diff --git a/app/views/events/images-manual.html b/app/views/events/images-manual.html index 22f24546..b6c0bb7c 100644 --- a/app/views/events/images-manual.html +++ b/app/views/events/images-manual.html @@ -87,7 +87,7 @@

{{ pageHeading }}

{% set troubleshootingIssue = event.mammogramDataTemp.troubleshootingIssue %} {% set hasTroubleshootingIssue = troubleshootingIssue in ['worklist-participant', 'wrong-image-count', 'incorrect-image-labels'] %} {% set showWorklistMatchingContent = troubleshootingIssue == 'worklist-participant' %} - {% set showDefaultManualContent = data.settings.screening.manualImageCollection == 'true' or isManualFailover %} + {% set showDefaultManualContent = data.settings.screening.manualImageCollection == 'true' or data.event.isManualImageCollection or isManualFailover %} {% if (hasTroubleshootingIssue and showWorklistMatchingContent) or (not hasTroubleshootingIssue and showDefaultManualContent) %}

Manually add participant details

diff --git a/app/views/events/retry-worklist-connection.html b/app/views/events/retry-worklist-connection.html index 64b81c21..62a46edc 100644 --- a/app/views/events/retry-worklist-connection.html +++ b/app/views/events/retry-worklist-connection.html @@ -20,7 +20,7 @@

{{ pageHeading }}

{{ button({ text: "Retry connection", attributes: { - formaction: "./retry-worklist-connection", + formaction: "./retry-worklist-connection" | urlWithReferrer(referrerChain), "data-retry-connection-button": "" } }) }} @@ -29,15 +29,15 @@

{{ pageHeading }}

text: "Switch to manual image mode", classes: "nhsuk-button--secondary", attributes: { - formaction: "./switch-to-manual-image-mode" + formaction: "./switch-to-manual-image-mode" | urlWithReferrer(referrerChain) } }) }}
- {% set lastRetryAt = data.settings.screening.worklistLastRetryAt %} - {% if lastRetryAt %} -

Connection failed on retry. Last retry attempt at {{ lastRetryAt | formatTime('HH:mm:ss') }}

- {% endif %} + {# Failure message rendered by client-side JS after first retry attempt #} + diff --git a/app/views/events/take-images.html b/app/views/events/take-images.html index f132bb22..deff8a81 100644 --- a/app/views/events/take-images.html +++ b/app/views/events/take-images.html @@ -10,7 +10,7 @@ {# Used in automatic flow to show holding page #} {# {% set isAwaitingImages = event.workflowStatus['awaiting-images'] != 'completed' %} #} -{% set isManualImageCollection = data.settings.screening.manualImageCollection | falsify %} +{% set isManualImageCollection = (data.settings.screening.manualImageCollection | falsify) or data.event.isManualImageCollection %} {# {% set imagesStageCompleted = true if data.event.workflowStatus['take-images'] == 'completed' else false %} #} From 1a6fe41ac3564f280004f17ba9961dfea77e4580 Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Mon, 8 Jun 2026 16:38:12 +0100 Subject: [PATCH 14/15] Comment out collecting reason for manual failover --- .../medical-information/mammogram-image-data.njk | 5 +++-- app/views/events/images-manual.html | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/views/_includes/summary-lists/medical-information/mammogram-image-data.njk b/app/views/_includes/summary-lists/medical-information/mammogram-image-data.njk index 44ac02a6..31ee0a73 100644 --- a/app/views/_includes/summary-lists/medical-information/mammogram-image-data.njk +++ b/app/views/_includes/summary-lists/medical-information/mammogram-image-data.njk @@ -121,9 +121,10 @@ {% if isManualEntry %} {% set imageRecordingValueHtml %}

Manual

- {% if event.mammogramData.isManualFailover %} + {# Commented out until we decide how to collect a reason #} + {# {% if event.mammogramData.isManualFailover %}

Add a reason for using manual data collection

- {% endif %} + {% endif %} #} {% endset %} {% set summaryRows = summaryRows | push({ diff --git a/app/views/events/images-manual.html b/app/views/events/images-manual.html index b6c0bb7c..8fd46740 100644 --- a/app/views/events/images-manual.html +++ b/app/views/events/images-manual.html @@ -113,7 +113,7 @@

Manually add participant details

] }) }} - {% if isManualFailover %} + {# {% if isManualFailover %} {% call details({ summaryText: "Add reason for switching to manual" }) %} @@ -128,7 +128,7 @@

Manually add participant details

value: mammogramSource.manualImageModeReason }) }} {% endcall %} - {% endif %} + {% endif %} #} {% endif %} {{ radios({ From 5c0c5e0e1f608cb2a2d328a48f97e07fe770e91a Mon Sep 17 00:00:00 2001 From: Ed Horsford Date: Mon, 8 Jun 2026 17:07:40 +0100 Subject: [PATCH 15/15] Use new code modifier --- .../_includes/appointment-status-bar.njk | 4 +- app/views/events/appointment.html | 2 +- app/views/events/images-manual.html | 4 +- package-lock.json | 80 ++++++++++++++----- 4 files changed, 65 insertions(+), 25 deletions(-) diff --git a/app/views/_includes/appointment-status-bar.njk b/app/views/_includes/appointment-status-bar.njk index b7e4e8ac..e246564b 100644 --- a/app/views/_includes/appointment-status-bar.njk +++ b/app/views/_includes/appointment-status-bar.njk @@ -42,7 +42,7 @@ {% set accessionNumberFormatted = event.accessionNumber | formatAccessionNumber %} {% set appointmentRowItems = appointmentRowItems | push({ key: 'Accn:', - value: '' + accessionNumberFormatted + '' + worklistStatusHtml + '' + value: '' + accessionNumberFormatted + '' + worklistStatusHtml + '' }) %} {# Appointment type #} @@ -101,7 +101,7 @@ {# NHS Number #} {% set participantRowItems = participantRowItems | push({ key: "NHS:", - value: '' + (participant.medicalInformation.nhsNumber | formatNhsNumber) + '' + value: '' + (participant.medicalInformation.nhsNumber | formatNhsNumber) + '' }) %} {{ appStatusBar({ diff --git a/app/views/events/appointment.html b/app/views/events/appointment.html index 8a16138e..0832f24a 100644 --- a/app/views/events/appointment.html +++ b/app/views/events/appointment.html @@ -258,7 +258,7 @@ text: "Accession number" }, value: { - html: '' + (event.accessionNumber | formatAccessionNumber) + '' + html: '' + (event.accessionNumber | formatAccessionNumber) + '' }, actions: { items: [] diff --git a/app/views/events/images-manual.html b/app/views/events/images-manual.html index 8fd46740..f43289d7 100644 --- a/app/views/events/images-manual.html +++ b/app/views/events/images-manual.html @@ -96,11 +96,11 @@

Manually add participant details

rows: [ { key: { text: "Accession number" }, - value: { html: '' + (event.accessionNumber | formatAccessionNumber) + '' } + value: { html: '' + (event.accessionNumber | formatAccessionNumber) + '' } }, { key: { text: "NHS number" }, - value: { html: '' + (participant.medicalInformation.nhsNumber | formatNhsNumber) + '' } + value: { html: '' + (participant.medicalInformation.nhsNumber | formatNhsNumber) + '' } }, { key: { text: "Full name" }, diff --git a/package-lock.json b/package-lock.json index 30b92a55..c187cdf0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1010,6 +1010,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1030,6 +1033,9 @@ "cpu": [ "arm" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1050,6 +1056,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1070,6 +1079,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1090,6 +1102,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1110,6 +1125,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1230,11 +1248,10 @@ } }, "node_modules/@types/node": { - "version": "25.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "version": "25.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", + "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } @@ -1252,13 +1269,15 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", + "peer": true, "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" @@ -1340,7 +1359,8 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/async": { "version": "2.6.4", @@ -1672,7 +1692,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1792,7 +1811,6 @@ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "license": "MIT", - "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -1876,6 +1894,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 6" } @@ -1986,6 +2005,7 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2584,7 +2604,6 @@ "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -2684,6 +2703,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -2782,6 +2802,7 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.6.0" } @@ -2852,6 +2873,7 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", @@ -2930,6 +2952,7 @@ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -2939,6 +2962,7 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -3233,6 +3257,7 @@ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.10" } @@ -3340,7 +3365,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/is-wsl": { "version": "1.1.0", @@ -3504,6 +3530,7 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -3639,16 +3666,16 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } }, "node_modules/nhsuk-frontend": { - "version": "10.5.1", - "resolved": "https://registry.npmjs.org/nhsuk-frontend/-/nhsuk-frontend-10.5.1.tgz", - "integrity": "sha512-O6GqxHZ6UBePTc7uiItNttZoi8+J3dTC5vUN6bMs0cAoL8TgvNOiYwppFMrMB/nHVk+PtFtf+F8x2tz7OdNS1Q==", + "version": "10.5.2", + "resolved": "https://registry.npmjs.org/nhsuk-frontend/-/nhsuk-frontend-10.5.2.tgz", + "integrity": "sha512-lTOmzSDJkEn8uhuEuj3NKAW5MYuG/5tqMRsp15An2oLKlpGEoEAoREp+tHYJ7DLOPDRp9Z/zmp6/pLea75ae1g==", "license": "MIT", - "peer": true, "engines": { "node": "^20.9.0 || ^22.11.0 || >= 24.11.0" }, @@ -3873,6 +3900,7 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", + "peer": true, "dependencies": { "wrappy": "1" } @@ -3921,6 +3949,7 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" @@ -4000,7 +4029,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -4504,6 +4532,7 @@ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "license": "MIT", + "peer": true, "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" @@ -4748,6 +4777,7 @@ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", @@ -4848,7 +4878,6 @@ "resolved": "https://registry.npmjs.org/sass-embedded/-/sass-embedded-1.100.0.tgz", "integrity": "sha512-Ut8wlQSk19tm7jMK6mz6cF1+e+E7tUnW2tM02zQDPnOTcVbV8qCQG8UWxZkkNlY50+hV3hqP24OOkUlMz8xBpw==", "license": "MIT", - "peer": true, "dependencies": { "@bufbuild/protobuf": "^2.5.0", "colorjs.io": "^0.5.0", @@ -5004,6 +5033,7 @@ "cpu": [ "arm" ], + "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -5020,6 +5050,7 @@ "cpu": [ "arm64" ], + "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -5036,6 +5067,7 @@ "cpu": [ "arm" ], + "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -5052,6 +5084,7 @@ "cpu": [ "arm64" ], + "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -5068,6 +5101,7 @@ "cpu": [ "riscv64" ], + "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -5084,6 +5118,7 @@ "cpu": [ "x64" ], + "libc": "musl", "license": "MIT", "optional": true, "os": [ @@ -5100,6 +5135,7 @@ "cpu": [ "riscv64" ], + "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -5116,6 +5152,7 @@ "cpu": [ "x64" ], + "libc": "glibc", "license": "MIT", "optional": true, "os": [ @@ -5239,9 +5276,9 @@ } }, "node_modules/semver": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", - "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz", + "integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5255,6 +5292,7 @@ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", @@ -5395,6 +5433,7 @@ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", + "peer": true, "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", @@ -6053,7 +6092,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ws": { "version": "8.20.1",