-
-
- Special appointment 3 slots
+
+ Special appointment
+
+ 3 slots
|
From 93cf51e7a901be792d6f801dbaaa619a1d67ad25 Mon Sep 17 00:00:00 2001
From: danjohnstonUX <21033686+danjohnstonUX@users.noreply.github.com>
Date: Tue, 29 Sep 2026 15:39:19 +0100
Subject: [PATCH 23/27] added participant view to batches and added search and
filter functionality
---
app/assets/sass/components/_card.scss | 20 +
app/assets/sass/main.scss | 1 +
app/data/participants.js | 59 ++-
app/routes.js | 118 +++++-
.../participant-breakdown/standard.html | 6 +-
app/views/batches/participants/detail.html | 199 +++++++++
app/views/batches/participants/index.html | 166 ++++++++
app/views/participants/detail.html | 146 +++++--
app/views/participants/index.html | 381 +++++++++++++-----
9 files changed, 940 insertions(+), 156 deletions(-)
create mode 100644 app/assets/sass/components/_card.scss
create mode 100644 app/views/batches/participants/detail.html
create mode 100644 app/views/batches/participants/index.html
diff --git a/app/assets/sass/components/_card.scss b/app/assets/sass/components/_card.scss
new file mode 100644
index 00000000..3a39888b
--- /dev/null
+++ b/app/assets/sass/components/_card.scss
@@ -0,0 +1,20 @@
+@use "sass:color";
+@use "nhsuk-frontend/dist/nhsuk/core" as *;
+
+.nhsuk-card--feature.app-card--feature-grey {
+ background-color: color.scale(nhsuk-colour("grey-4"), $alpha: -50%) !important;
+}
+.nhsuk-card--feature.app-card--feature-grey .nhsuk-card__heading {
+ background-color: nhsuk-colour("grey-1");
+ color: nhsuk-colour("white");
+}
+
+.nhsuk-card--feature.app-card--feature-orange .nhsuk-card__heading {
+ background-color: nhsuk-colour("orange");
+ color: nhsuk-colour("white");
+}
+
+.nhsuk-card--feature.app-card--feature-green .nhsuk-card__heading {
+ background-color: nhsuk-colour("green");
+ color: nhsuk-colour("white");
+}
diff --git a/app/assets/sass/main.scss b/app/assets/sass/main.scss
index 359adaa8..a3c43b4f 100755
--- a/app/assets/sass/main.scss
+++ b/app/assets/sass/main.scss
@@ -85,6 +85,7 @@
// https://github.com/NHSDigital/manage-breast-screening-prototype
@forward "components/calendar-table";
+@forward "components/card";
@forward "components/clinic-level-breadcrumbs";
@forward "components/grid-extensions";
@forward "components/inline-detailed-search";
diff --git a/app/data/participants.js b/app/data/participants.js
index e71e5398..32d46d07 100644
--- a/app/data/participants.js
+++ b/app/data/participants.js
@@ -64,6 +64,12 @@ const towns = [
{ name: 'Westcombe', postcode: 'WC6 9LM' }
]
+const appointmentUnits = ['West Sussex Breast Care Centre', 'Alpha Van', 'Beta Van', 'Gamma Van', 'Delta Van']
+const appointmentLocations = [
+ 'Bognor Regis', 'Burgess Hill', 'Chichester', 'Crawley', 'Haywards Heath',
+ 'Horsham', 'Littlehampton', 'Shoreham-by-Sea', 'Storrington'
+]
+
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
@@ -71,16 +77,36 @@ const months = [
const pad = (value, width = 2) => String(value).padStart(width, '0')
const formatDate = (date) => `${pad(date.getDate())} ${months[date.getMonth()]} ${date.getFullYear()}`
+const postcodeFor = (index, postcode) => {
+ const outwardCode = postcode.split(' ')[0]
+ const letters = 'ABCDEFGHJKLMNPRSTUWXYZ'
+ const suffixIndex = Math.floor(index / 9)
+ const firstLetter = letters[Math.floor(suffixIndex / letters.length)]
+ const secondLetter = letters[suffixIndex % letters.length]
+ return `${outwardCode} ${(index % 9) + 1}${firstLetter}${secondLetter}`
+}
+
+const gpPractices = [
+ { practice_code: 'DEMO001', name: 'Ash Grove Medical Centre', phone: '01632 960101', address: { houseNumber: '12', street: 'Ash Grove', town: 'Brightmere', postcode: 'BM1 4AC' } },
+ { practice_code: 'DEMO002', name: 'Cedarford Family Practice', phone: '01632 960102', address: { houseNumber: '24', street: 'Cedar Road', town: 'Cedarford', postcode: 'CF2 7CE' } },
+ { practice_code: 'DEMO003', name: 'Fairview Health Centre', phone: '01632 960103', address: { houseNumber: '8', street: 'Fairview Drive', town: 'Fairwick', postcode: 'FW3 5EG' } },
+ { practice_code: 'DEMO004', name: 'Greystone Medical Practice', phone: '01632 960104', address: { houseNumber: '31', street: 'Church Street', town: 'Greystone', postcode: 'GS4 8GJ' } },
+ { practice_code: 'DEMO005', name: 'Oakminster Surgery', phone: '01632 960105', address: { houseNumber: '5', street: 'Oak Street', town: 'Oakminster', postcode: 'OM5 2JL' } },
+ { practice_code: 'DEMO006', name: 'Westcombe Medical Centre', phone: '01632 960106', address: { houseNumber: '17', street: 'Station Road', town: 'Westcombe', postcode: 'WC6 9LN' } },
+ { practice_code: 'DEMO007', name: 'Meadow Lane Health Centre', phone: '01632 960107', address: { houseNumber: '42', street: 'Meadow Lane', town: 'Brightmere', postcode: 'BM1 4AD' } },
+ { practice_code: 'DEMO008', name: 'Willow Drive Family Practice', phone: '01632 960108', address: { houseNumber: '3', street: 'Willow Drive', town: 'Cedarford', postcode: 'CF2 7CF' } }
+]
+
const addDays = (date, days) => {
const result = new Date(date)
result.setDate(result.getDate() + days)
return result
}
-const formatRelativeDate = (date, today) => {
+const formatRelativeDate = (date, today, daysThreshold = 7) => {
const days = Math.max(0, Math.round((date - today) / 86400000))
- if (days < 7) {
+ if (days < daysThreshold) {
return `In ${days} day${days === 1 ? '' : 's'}`
}
@@ -93,6 +119,13 @@ const formatRelativeDate = (date, today) => {
return `In ${months} month${months === 1 ? '' : 's'}`
}
+const lastScreenedDaysAgoFor = (index) => {
+ if (index % 30 === 0) return null
+ if (index % 20 < 4) return 365 + ((index * 37) % 1095)
+ if (index % 37 === 0) return 1826 + ((index * 11) % 1095)
+ return 1095 + ((index * 13) % 181)
+}
+
const calculateAge = (dateOfBirth, today) => {
let age = today.getFullYear() - dateOfBirth.getFullYear()
const birthdayThisYear = new Date(today.getFullYear(), dateOfBirth.getMonth(), dateOfBirth.getDate())
@@ -123,6 +156,8 @@ const createParticipant = (index, today) => {
const dateOfBirth = dateOfBirthFor(index, today)
const nextTestDueDays = 7 + ((index * 11) % 84)
const dueDate = addDays(today, nextTestDueDays)
+ const lastScreenedDaysAgo = lastScreenedDaysAgoFor(index)
+ const lastScreenedDate = lastScreenedDaysAgo === null ? null : addDays(today, -lastScreenedDaysAgo)
const isBreaching = index === 7 || index === 83
const hasScheduledAppointment = isBreaching || (index % 4 === 0 && index !== 4 && index !== 8)
const nextAppointmentDays = !hasScheduledAppointment
@@ -131,8 +166,15 @@ const createParticipant = (index, today) => {
? nextTestDueDays + 5 + (index % 4)
: 3 + ((index * 9) % Math.max(4, nextTestDueDays - 2))
const appointmentDate = nextAppointmentDays === null ? null : addDays(today, nextAppointmentDays)
+ const appointmentUnit = nextAppointmentDays === null
+ ? null
+ : appointmentUnits[Math.floor(Math.random() * appointmentUnits.length)]
+ const appointmentLocation = nextAppointmentDays === null
+ ? null
+ : appointmentUnit === 'West Sussex Breast Care Centre'
+ ? 'Worthing Hospital'
+ : appointmentLocations[Math.floor(Math.random() * appointmentLocations.length)]
const specialAppointmentRequired = index % 17 === 0
- const episodeStages = ['scheduled', 'scheduled', 'scheduled', 'scheduled', 'mammograms', 'reading', 'assessment', 'closed']
const mobile = `07700 9${pad(index % 100)}${pad((index * 13) % 1000, 3)}`
const home = `01632 960${pad((index * 17) % 1000, 3)}`
@@ -149,20 +191,25 @@ const createParticipant = (index, today) => {
houseNumber: String(1 + ((index * 13) % 98)),
street: streets[index % streets.length],
town: town.name,
- postcode: town.postcode
+ postcode: postcodeFor(index, town.postcode)
},
+ gp_practice: gpPractices[index % gpPractices.length],
phone_numbers: { mobile, home: index % 4 === 0 ? home : '' },
email: `${firstName.toLowerCase()}.${surname.toLowerCase()}${index + 1}@example.com`,
sx_number: `ECX${String((index * 7919 + 104729) % 1000000).padStart(6, '0')}`,
+ last_screened_days_ago: lastScreenedDaysAgo,
+ last_screened_date: lastScreenedDate ? formatDate(lastScreenedDate) : 'Never screened',
next_test_due_days: nextTestDueDays,
next_test_due_date: formatDate(dueDate),
next_test_due_date_value: dueDate.getTime(),
- next_test_due_date_relative: formatRelativeDate(dueDate, today),
- episode_stage: episodeStages[index % episodeStages.length],
+ next_test_due_date_relative: formatRelativeDate(dueDate, today, 28),
+ episode_stage: 'scheduled',
next_appointment_days: nextAppointmentDays,
next_appointment_date: appointmentDate ? formatDate(appointmentDate) : 'Not known',
next_appointment_date_value: appointmentDate ? appointmentDate.getTime() : null,
next_appointment_date_relative: appointmentDate ? formatRelativeDate(appointmentDate, today) : 'Not known',
+ next_appointment_unit: appointmentUnit,
+ next_appointment_location: appointmentLocation,
special_appointment_required: specialAppointmentRequired ? 'Yes' : 'No',
special_appointment_information: '',
further_information: '',
diff --git a/app/routes.js b/app/routes.js
index 6764f7d6..965a0818 100644
--- a/app/routes.js
+++ b/app/routes.js
@@ -4,6 +4,11 @@ const express = require('express')
const router = express.Router()
const participantMonths = [
+ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
+]
+
+const participantFullMonths = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
]
@@ -15,11 +20,12 @@ const participantDateFromToday = (today, days) => {
}
const formatParticipantDate = (date) => `${String(date.getDate()).padStart(2, '0')} ${participantMonths[date.getMonth()]} ${date.getFullYear()}`
+const formatParticipantFullDate = (date) => `${String(date.getDate()).padStart(2, '0')} ${participantFullMonths[date.getMonth()]} ${date.getFullYear()}`
-const formatParticipantRelativeDate = (date, today) => {
+const formatParticipantRelativeDate = (date, today, daysThreshold = 7) => {
const days = Math.max(0, Math.round((date - today) / 86400000))
- if (days < 7) {
+ if (days < daysThreshold) {
return `In ${days} day${days === 1 ? '' : 's'}`
}
@@ -32,18 +38,22 @@ const formatParticipantRelativeDate = (date, today) => {
return `In ${months} month${months === 1 ? '' : 's'}`
}
-const refreshParticipantDates = (participant, today) => {
+const refreshParticipantDates = (participant, today, dateFormatter = formatParticipantDate) => {
const nextTestDueDate = participantDateFromToday(today, participant.next_test_due_days)
const nextAppointmentDate = participant.next_appointment_days === null
? null
: participantDateFromToday(today, participant.next_appointment_days)
+ const lastScreenedDate = participant.last_screened_days_ago === null
+ ? null
+ : participantDateFromToday(today, -participant.last_screened_days_ago)
return {
...participant,
- next_test_due_date: formatParticipantDate(nextTestDueDate),
+ last_screened_date: lastScreenedDate ? dateFormatter(lastScreenedDate) : 'Never screened',
+ next_test_due_date: dateFormatter(nextTestDueDate),
next_test_due_date_value: nextTestDueDate.getTime(),
- next_test_due_date_relative: formatParticipantRelativeDate(nextTestDueDate, today),
- next_appointment_date: nextAppointmentDate ? formatParticipantDate(nextAppointmentDate) : 'Not known',
+ next_test_due_date_relative: formatParticipantRelativeDate(nextTestDueDate, today, 28),
+ next_appointment_date: nextAppointmentDate ? dateFormatter(nextAppointmentDate) : 'Not known',
next_appointment_date_value: nextAppointmentDate ? nextAppointmentDate.getTime() : null,
next_appointment_date_relative: nextAppointmentDate ? formatParticipantRelativeDate(nextAppointmentDate, today) : 'Not known'
}
@@ -60,6 +70,38 @@ const compareAppointmentDates = (a, b, direction) => {
return direction * (a.next_appointment_date_value - b.next_appointment_date_value)
}
+const queryValues = value => (Array.isArray(value) ? value : value ? [value] : [])
+ .filter(item => item !== '_unchecked')
+
+const matchesDateRange = (days, range) => {
+ if (typeof days !== 'number') return false
+ if (range === 'within_week') return days <= 7
+ if (range === 'within_month') return days <= 30
+ if (range === 'within_3_months') return days <= 90
+ if (range === 'within_6_months') return days <= 180
+ return true
+}
+
+const matchesLastScreenedFilter = (participant, filter) => {
+ const days = participant.last_screened_days_ago
+ if (filter === 'never') return days === null
+ if (typeof days !== 'number') return false
+ if (filter === 'within_3_years') return days <= 1095
+ if (filter === '3_to_5_years') return days > 1095 && days <= 1825
+ if (filter === 'over_5_years') return days > 1825
+ return true
+}
+
+const participantFilterQuery = (search, nextTestDue, nextScreening, lastScreened, breachRisk) => {
+ const params = new URLSearchParams()
+ if (search) params.set('search', search)
+ nextTestDue.forEach(value => params.append('nextTestDue', value))
+ nextScreening.forEach(value => params.append('nextScreening', value))
+ lastScreened.forEach(value => params.append('lastScreened', value))
+ breachRisk.forEach(value => params.append('breachRisk', value))
+ return params.toString()
+}
+
//======= September test specific for now
// Utility function to generate calendar month data
@@ -781,12 +823,50 @@ router.post('/sessions/02-organise-slots', function (req, res) {
// Isolated September Test (Mission 1) routes
router.use(require('./routes/mission-1'));
-router.get('/participants', function (req, res) {
+router.get(['/participants', '/batches/participants'], function (req, res) {
+ const participantsPath = req.path.startsWith('/batches/') ? '/batches/participants' : '/participants'
const sort = req.query.sort || 'due-soonest'
+ const search = String(req.query.search || '').trim()
+ const nextTestDue = queryValues(req.query.nextTestDue)
+ const nextScreening = queryValues(req.query.nextScreening)
+ const lastScreened = queryValues(req.query.lastScreened)
+ const breachRisk = queryValues(req.query.breachRisk)
const today = new Date()
today.setHours(0, 0, 0, 0)
- const allParticipants = ((req.session.data.participants && req.session.data.participants.default) || [])
+ let allParticipants = ((req.session.data.participants && req.session.data.participants.default) || [])
.map(participant => refreshParticipantDates(participant, today))
+
+ if (search) {
+ const searchTerm = search.toLowerCase().replace(/\s+/g, ' ')
+ allParticipants = allParticipants.filter(participant => [
+ participant.full_name,
+ participant.surname_sort_value,
+ participant.address.postcode,
+ participant.nhs_number,
+ participant.sx_number
+ ].some(value => String(value || '').toLowerCase().replace(/\s+/g, ' ').includes(searchTerm)))
+ }
+
+ if (nextTestDue.length) {
+ allParticipants = allParticipants.filter(participant =>
+ nextTestDue.some(range => matchesDateRange(participant.next_test_due_days, range)))
+ }
+
+ if (nextScreening.length) {
+ allParticipants = allParticipants.filter(participant => nextScreening.some(range => range === 'not_known'
+ ? participant.next_appointment_days === null
+ : matchesDateRange(participant.next_appointment_days, range)))
+ }
+
+ if (lastScreened.length) {
+ allParticipants = allParticipants.filter(participant =>
+ lastScreened.some(filter => matchesLastScreenedFilter(participant, filter)))
+ }
+
+ if (breachRisk.length) {
+ allParticipants = allParticipants.filter(participant => breachRisk.some(filter =>
+ filter === 'at_risk' ? participant.is_breaching : !participant.is_breaching))
+ }
const sortComparators = {
'name-asc': (a, b) => a.surname_sort_value.localeCompare(b.surname_sort_value),
'name-desc': (a, b) => b.surname_sort_value.localeCompare(a.surname_sort_value),
@@ -809,19 +889,30 @@ router.get('/participants', function (req, res) {
const lastRecord = Math.min(currentPage * pageSize, totalParticipants)
const participants = allParticipants.slice(firstRecord - 1, lastRecord)
- res.render('participants/index', {
+ res.render(participantsPath === '/batches/participants' ? 'batches/participants/index' : 'participants/index', {
participants,
+ participantsPath,
+ batchName: participantsPath === '/batches/participants' ? 'Chichester - Parklands' : null,
+ participantListTitle: participantsPath === '/batches/participants' ? 'Standard participants' : 'Participants',
selectedSort: sort,
currentPage,
totalPages,
firstRecord,
lastRecord,
totalParticipants,
- paginationPages: Array.from({ length: totalPages }, (_, index) => index + 1)
+ paginationPages: Array.from({ length: totalPages }, (_, index) => index + 1),
+ search,
+ selectedNextTestDue: nextTestDue,
+ selectedNextScreening: nextScreening,
+ selectedLastScreened: lastScreened,
+ selectedBreachRisk: breachRisk,
+ participantFilterQuery: participantFilterQuery(search, nextTestDue, nextScreening, lastScreened, breachRisk),
+ clearSearchQuery: participantFilterQuery('', nextTestDue, nextScreening, lastScreened, breachRisk)
})
})
-router.get('/participants/:participantId', function (req, res) {
+router.get(['/participants/:participantId', '/batches/participants/:participantId'], function (req, res) {
+ const participantsPath = req.path.startsWith('/batches/') ? '/batches/participants' : '/participants'
const participants = (req.session.data.participants && req.session.data.participants.default) || []
const today = new Date()
today.setHours(0, 0, 0, 0)
@@ -831,7 +922,10 @@ router.get('/participants/:participantId', function (req, res) {
return res.status(404).send('Participant not found')
}
- res.render('participants/detail', { participant: refreshParticipantDates(participant, today) })
+ res.render(participantsPath === '/batches/participants' ? 'batches/participants/detail' : 'participants/detail', {
+ participant: refreshParticipantDates(participant, today, formatParticipantFullDate),
+ participantsPath
+ })
})
// Isolated create capacity from zero routes
diff --git a/app/views/batches/participant-breakdown/standard.html b/app/views/batches/participant-breakdown/standard.html
index a30a1bfb..94761a8d 100644
--- a/app/views/batches/participant-breakdown/standard.html
+++ b/app/views/batches/participant-breakdown/standard.html
@@ -43,7 +43,7 @@
}) }}
- View all participants
+ View all participants
@@ -66,7 +66,7 @@
}) }}
- View all participants
+ View all participants
@@ -89,7 +89,7 @@
}) }}
- View all participants
+ View all participants
diff --git a/app/views/batches/participants/detail.html b/app/views/batches/participants/detail.html
new file mode 100644
index 00000000..5af807a9
--- /dev/null
+++ b/app/views/batches/participants/detail.html
@@ -0,0 +1,199 @@
+{% extends 'layout.html' %}
+
+{% set pageName = 'Participant details' %}
+
+{% from "_includes/primary-navigation.html" import primaryNavigation %}
+{% block header %}
+{{ primaryNavigation("Batches", serviceName) }}
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+ Participant details
+ {{ participant.full_name }}
+
+
+
+
+
+ {% if participant.is_breaching %}At risk of
+ breaching{% endif %}
+
+
+
+{% set stageText = {
+'scheduled': 'Scheduled',
+'mammograms': 'Mammograms',
+'reading': 'Waiting for reading',
+'assessment': 'At assessment',
+'closed': 'Closed'
+}[participant.episode_stage] %}
+{% set stageColour = {
+'scheduled': 'blue',
+'mammograms': 'purple',
+'reading': 'yellow',
+'assessment': 'orange',
+'closed': 'grey'
+}[participant.episode_stage] %}
+
+
+
+
+
+ Screening details
+
+
+ - Next test due date
+ - {{ participant.next_test_due_date }}
{{ participant.next_test_due_date_relative }}
+
+
+ - Next screening appointment
+ -
+ {% if participant.next_appointment_date != 'Not known' %}{{
+ participant.next_appointment_date }}
{% endif %}
+ {{ participant.next_appointment_date_relative }}
+ {% if participant.next_appointment_unit %}
+ {{ participant.next_appointment_unit }}, {{
+ participant.next_appointment_location }}
+ Appointment taking place at {{
+ participant.next_appointment_unit }} in {{ participant.next_appointment_location
+ }}{% endif %}
+
+
+
+ - Last screened
+ - {{ participant.last_screened_date }}
+
+
+
+
+
+
+
+ Personal details
+
+
+ - Full name
+ - {{ participant.full_name }}
+
+
+ - NHS Number
+ - {{ participant.nhs_number }}
+
+
+ - Date of birth
+ - {{ participant.date_of_birth }}
({{ participant.age }} years old)
+
+
+ - Gender
+ - {{ participant.gender }}
+
+
+ - Ethnicity
+ - {{ participant.ethnicity }}
+
+
+
+
+
+
+
+ Contact details
+
+
+ - Address
+ -
+ {{ participant.address.houseNumber }} {{ participant.address.street }}
+ {{ participant.address.town }}
+ {{ participant.address.postcode }}
+
+
+
+ - {% if participant.phone_numbers.mobile and
+ participant.phone_numbers.home %}Phone numbers{% else %}Phone number{% endif %}
+ - {% if participant.phone_numbers.mobile %}{{
+ participant.phone_numbers.mobile }}{% endif %}{% if participant.phone_numbers.mobile and
+ participant.phone_numbers.home %}
{% endif %}{% if participant.phone_numbers.home %}{{
+ participant.phone_numbers.home }}{% endif %}
+
+
+ - Email address
+ - {{ participant.email }}
+
+
+
+
+
+
+
+ GP details
+
+
+ - Name
+ - {{ participant.gp_practice.name }}
+
+
+ - Phone number
+ - {{ participant.gp_practice.phone }}
+
+
+ - Address
+ -
+ {{ participant.gp_practice.address.houseNumber }} {{ participant.gp_practice.address.street
+ }}
+ {{ participant.gp_practice.address.town }}
+ {{ participant.gp_practice.address.postcode }}
+
+
+
+
+
+
+
+
+ Screening episodes
+ Current episode
+
+
+ - Stage
+ - {{
+ stageText }}
+
+
+ - Next screening appointment
+ -
+ {% if participant.next_appointment_date != 'Not known' %}{{
+ participant.next_appointment_date }}
{% endif %}
+ {{ participant.next_appointment_date_relative }}
+ {% if participant.next_appointment_unit %} {{
+ participant.next_appointment_unit }}, {{ participant.next_appointment_location
+ }}Appointment taking place at {{
+ participant.next_appointment_unit }} in {{ participant.next_appointment_location
+ }}{% endif %}
+
+
+
+ Previous episodes
+
+
+ - Last screened
+ - {{ participant.last_screened_date or '-' }}
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/app/views/batches/participants/index.html b/app/views/batches/participants/index.html
new file mode 100644
index 00000000..1ee942bd
--- /dev/null
+++ b/app/views/batches/participants/index.html
@@ -0,0 +1,166 @@
+{% extends 'layout.html' %}
+
+{% set pageName = participantListTitle %}
+
+{% from "_includes/primary-navigation.html" import primaryNavigation %}
+{% block header %}
+{{ primaryNavigation("Batches", serviceName) }}
+{% endblock %}
+
+{% block beforeContent %}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+ {{ batchName }}
+ {{ pageName }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% for participant in participants %}
+
+ {{ participant.full_name }} {{ participant.age }} years old |
+ {{ participant.address.postcode }} |
+ {{ participant.nhs_number }} |
+ {{ participant.next_test_due_date }} {{ participant.next_test_due_date_relative }} |
+ {% if participant.next_appointment_date != 'Not known' %}{{ participant.next_appointment_date }} {% endif %}{{ participant.next_appointment_date_relative }} |
+
+ {% endfor %}
+
+
+ {% if totalPages > 1 %}
+
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/app/views/participants/detail.html b/app/views/participants/detail.html
index fb50fadf..0d053758 100644
--- a/app/views/participants/detail.html
+++ b/app/views/participants/detail.html
@@ -4,45 +4,36 @@
{% from "_includes/primary-navigation.html" import primaryNavigation %}
{% block header %}
-{{ primaryNavigation("Participants", serviceName) }}
+{{ primaryNavigation("Batches" if participantsPath == "/batches/participants" else "Participants", serviceName) }}
{% endblock %}
{% block content %}
+
+
+
+
+
+
Participant details
{{ participant.full_name }}
+
-
-
-
-
-
- {% if participant.is_breaching %}
- At risk of breaching
- {% endif %}
-
-
-
-
- Next test due date
- {{ participant.next_test_due_date }}
- {{ participant.next_test_due_date_relative }}
-
-
- Next screening date
- {% if participant.next_appointment_date != 'Not known' %}
- {{ participant.next_appointment_date }}
- {% endif %}
- {{ participant.next_appointment_date_relative }}
-
-
-
-
+
+ {% if participant.is_breaching %}
+ At risk of breaching
+ {% endif %}
@@ -63,27 +54,65 @@
+
- Personal details
+ Screening details
- - NHS Number
- - {{ participant.nhs_number }}
+ - Next test due date
+ - {{ participant.next_test_due_date }}
+ {{ participant.next_test_due_date_relative }}
+
+
- - SX Number
- - {{ participant.sx_number }}
+ - Next screening appointment
+ - {% if participant.next_appointment_date != 'Not known'
+ %}{{ participant.next_appointment_date }}
{% endif
+ %}
+ {{ participant.next_appointment_date_relative }}
+ {% if participant.next_appointment_unit %}
+ {{ participant.next_appointment_unit }}, {{
+ participant.next_appointment_location }}
+ Appointment taking place at {{
+ participant.next_appointment_unit }} in {{ participant.next_appointment_location
+ }}{% endif %}
+
+
+
+ - Last screened
+ - {{ participant.last_screened_date }}
+
+
+
+
+
+
+ Personal details
+
- Full name
- {{ participant.full_name }}
+
+ - NHS Number
+ - {{ participant.nhs_number }}
+
+
- Date of birth
- {{ participant.date_of_birth }}
({{ participant.age }} years old)
+
+ - Gender
+ - {{ participant.gender }}
+
- Ethnicity
- {{ participant.ethnicity }}
@@ -94,7 +123,7 @@ Personal details
- Contact detail
+ Contact details
- Address
@@ -122,24 +151,39 @@ Contact detail
- Email address
- {{ participant.email }}
+
+
+
+
+
+
+ GP details
+
+
+ - Name
+ - {{ participant.gp_practice.name }}
+
- - Special appointment required
- - {{ participant.special_appointment_required }}
+ - Phone number
+ - {{ participant.gp_practice.phone }}
- {% if participant.special_appointment_required == 'Yes' %}
- - Special appointment information
- - {{ participant.special_appointment_information or '-' }}
+
- Address
+ -
+ {{ participant.gp_practice.address.houseNumber }} {{ participant.gp_practice.address.street
+ }}
+ {{ participant.gp_practice.address.town }}
+ {{ participant.gp_practice.address.postcode }}
- {% endif %}
- Current screening episode
+ Screening episodes
+ Current episode
- Stage
@@ -150,13 +194,31 @@ Current screening episode
- Next screening appointment
-
- {% if participant.next_appointment_date != 'Not known' %}{{ participant.next_appointment_date }}
{% endif %}
+ {% if participant.next_appointment_date != 'Not known' %}{{
+ participant.next_appointment_date }} {% endif %}
{{ participant.next_appointment_date_relative }}
+ {% if participant.next_appointment_unit %}
+ {{ participant.next_appointment_unit }}, {{
+ participant.next_appointment_location }}
+ Appointment taking place at {{
+ participant.next_appointment_unit }} in {{ participant.next_appointment_location
+ }}{% endif %}
+
+
+
+ Previous episodes
+
+
+ - Last screened
+ -
+ {{ participant.last_screened_date or '-' }}
+
+
{% endblock %}
\ No newline at end of file
diff --git a/app/views/participants/index.html b/app/views/participants/index.html
index 06e2c3d0..d840ef11 100644
--- a/app/views/participants/index.html
+++ b/app/views/participants/index.html
@@ -4,112 +4,307 @@
{% from "_includes/primary-navigation.html" import primaryNavigation %}
{% block header %}
- {{ primaryNavigation("Participants", serviceName) }}
+{{ primaryNavigation("Batches" if participantsPath == "/batches/participants" else "Participants", serviceName) }}
{% endblock %}
{% block beforeContent %}{% endblock %}
{% block content %}
-
-
-
- {{ pageName }}
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {% for participant in participants %}
-
-
- {{ participant.full_name }}
- {{ participant.age }} years old
- |
- {{ participant.nhs_number }} |
-
- {{ participant.next_test_due_date }}
- {{ participant.next_test_due_date_relative }}
- |
-
- {% if participant.next_appointment_date != 'Not known' %}{{ participant.next_appointment_date }} {% endif %}
- {{ participant.next_appointment_date_relative }}
- |
-
- {% endfor %}
-
-
+
+
+
+
+
+
+
+
+
+
+
- {% if totalPages > 1 %}
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% for participant in participants %}
+
+
+ {{ participant.full_name }}
+ {{ participant.age }} years old
+ |
+ {{ participant.address.postcode }} |
+ {{ participant.nhs_number }} |
+
+ {{ participant.next_test_due_date }}
+ {{ participant.next_test_due_date_relative }}
+ |
+
+ {% if participant.next_appointment_date != 'Not known' %}{{ participant.next_appointment_date }} {% endif
+ %}
+ {{ participant.next_appointment_date_relative }}
+ |
+
+ {% endfor %}
+
+
+
+ {% if totalPages > 1 %}
+
+ {% endif %}
-
+
-{% endblock %}
+{% endblock %}
\ No newline at end of file
From 81c8b338a7545ab3f9506819cf10d19f89eeabf1 Mon Sep 17 00:00:00 2001
From: danjohnstonUX <21033686+danjohnstonUX@users.noreply.github.com>
Date: Mon, 5 Oct 2026 09:53:00 +0100
Subject: [PATCH 24/27] updated index
---
app/views/index.html | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/app/views/index.html b/app/views/index.html
index 1e4e76ad..422c3b43 100755
--- a/app/views/index.html
+++ b/app/views/index.html
@@ -49,7 +49,10 @@ September 2026
-
- View participant details
+ View standalone participant details
+
+ -
+ View participant details within a batch
From 7d001d781a82f0873aaea655f3f7a73952fd5b90 Mon Sep 17 00:00:00 2001
From: danjohnstonUX <21033686+danjohnstonUX@users.noreply.github.com>
Date: Fri, 11 Sep 2026 15:10:27 +0100
Subject: [PATCH 25/27] Add participant list and details pages and add
participant database
---
app/assets/sass/main.scss | 30 ++
app/data/participants.js | 328 +++++++++++---------
app/routes.js | 110 +++++++
app/views/_includes/primary-navigation.html | 2 +-
app/views/index.html | 9 +
app/views/participants/detail.html | 162 ++++++++++
app/views/participants/index.html | 115 +++++++
7 files changed, 603 insertions(+), 153 deletions(-)
create mode 100644 app/views/participants/detail.html
create mode 100644 app/views/participants/index.html
diff --git a/app/assets/sass/main.scss b/app/assets/sass/main.scss
index a3b1d1be..deb48bb3 100755
--- a/app/assets/sass/main.scss
+++ b/app/assets/sass/main.scss
@@ -189,6 +189,36 @@ a {
width: 100%;
}
+.app-participants-toolbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 24px;
+ margin-bottom: 24px;
+
+ > p {
+ margin-bottom: 0;
+ }
+}
+
+.app-participants-sort-form {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ margin-bottom: 0;
+
+ .nhsuk-label {
+ margin-bottom: 0;
+ white-space: nowrap;
+ }
+}
+
+@include nhsuk-media-query($from: tablet) {
+ .app-participants-sort-form {
+ flex-shrink: 0;
+ }
+}
+
.nhsuk-grid-column-four-fifths {
@include nhsuk-grid-column(four-fifths);
diff --git a/app/data/participants.js b/app/data/participants.js
index 64cf0904..e71e5398 100644
--- a/app/data/participants.js
+++ b/app/data/participants.js
@@ -1,152 +1,176 @@
-export default [
- { participantId: 'p0001', nhs_number: "999 054 3666", full_name: "Billie Joanne Marquardt", date_of_birth: "15 March 1960", status: "unstaged" },
- { participantId: 'p0002', nhs_number: "999 582 7164", full_name: "Alice Marie Gregory", date_of_birth: "8 November 1964", status: "unstaged" },
- { participantId: 'p0003', nhs_number: "999 374 0952", full_name: "Daphne Hart", date_of_birth: "22 July 1958", status: "unstaged" },
- { participantId: 'p0004', nhs_number: "999 195 4836", full_name: "Marta O'Neill", date_of_birth: "3 May 1971", status: "unstaged" },
- { participantId: 'p0005', nhs_number: "999 287 9305", full_name: "Susan Carter", date_of_birth: "19 September 1967", status: "unstaged" },
- { participantId: 'p0006', nhs_number: "999 463 1129", full_name: "Olivia Barnes", date_of_birth: "12 January 1975", status: "unstaged" },
- { participantId: 'p0007', nhs_number: "999 774 2201", full_name: "Rachel Price", date_of_birth: "27 April 1962", status: "unstaged" },
- { participantId: 'p0008', nhs_number: "999 546 8870", full_name: "Hannah Wallace", date_of_birth: "6 October 1959", status: "unstaged" },
- { participantId: 'p0009', nhs_number: "999 559 3322", full_name: "Fiona Lambert", date_of_birth: "14 December 1973", status: "unstaged" },
- { participantId: 'p0010', nhs_number: "999 804 7753", full_name: "Georgia Mills", date_of_birth: "30 June 1968", status: "unstaged" },
- { participantId: 'p0011', nhs_number: "999 290 4601", full_name: "Elaine Fox", date_of_birth: "11 February 1976", status: "unstaged" },
- { participantId: 'p0012', nhs_number: "999 613 2084", full_name: "Tessa King", date_of_birth: "25 August 1963", status: "unstaged" },
- { participantId: 'p0013', nhs_number: "999 118 9756", full_name: "Nadia Singh", date_of_birth: "7 April 1957", status: "unstaged" },
- { participantId: 'p0014', nhs_number: "999 039 6617", full_name: "Jane Reed", date_of_birth: "18 November 1970", status: "unstaged" },
- { participantId: 'p0015', nhs_number: "999 259 1148", full_name: "Laura White", date_of_birth: "29 May 1965", status: "unstaged" },
- { participantId: 'p0016', nhs_number: "999 880 3320", full_name: "Marissa Young", date_of_birth: "9 March 1961", status: "unstaged" },
- { participantId: 'p0017', nhs_number: "999 901 7732", full_name: "Naomi Bell", date_of_birth: "21 September 1974", status: "unstaged" },
- { participantId: 'p0018', nhs_number: "999 216 5087", full_name: "Paige Long", date_of_birth: "4 July 1969", status: "unstaged" },
- { participantId: 'p0019', nhs_number: "999 487 9901", full_name: "Catherine Wood", date_of_birth: "16 January 1956", status: "unstaged" },
- { participantId: 'p0020', nhs_number: "999 603 1774", full_name: "Victoria Hale", date_of_birth: "13 October 1966", status: "unstaged" },
- { participantId: 'p0021', nhs_number: "999 905 6423", full_name: "Imogen Frost", date_of_birth: "31 December 1972", status: "unstaged" },
- { participantId: 'p0022', nhs_number: "999 332 1108", full_name: "Dara Moss", date_of_birth: "24 June 1960", status: "unstaged" },
- { participantId: 'p0023', nhs_number: "999 427 5930", full_name: "Sophie Hartley", date_of_birth: "2 August 1962", status: "unstaged" },
- { participantId: 'p0024', nhs_number: "999 218 3446", full_name: "Emma Cole", date_of_birth: "17 April 1975", status: "unstaged" },
- { participantId: 'p0025', nhs_number: "999 109 8875", full_name: "Megan Price", date_of_birth: "5 February 1961", status: "unstaged" },
- { participantId: 'p0026', nhs_number: "999 678 2209", full_name: "Mia Ford", date_of_birth: "19 May 1972", status: "unstaged" },
- { participantId: 'p0027', nhs_number: "999 502 6683", full_name: "Zoe Grant", date_of_birth: "11 March 1965", status: "unstaged" },
- { participantId: 'p0028', nhs_number: "999 244 1196", full_name: "Ariana Miles", date_of_birth: "28 November 1959", status: "unstaged" },
- { participantId: 'p0029', nhs_number: "999 883 4455", full_name: "Bethany Cross", date_of_birth: "7 July 1975", status: "unstaged" },
- { participantId: 'p0030', nhs_number: "999 771 9002", full_name: "Callie Morris", date_of_birth: "14 September 1968", status: "unstaged" },
- { participantId: 'p0031', nhs_number: "999 439 3321", full_name: "Helena Rowe", date_of_birth: "2 April 1963", status: "unstaged" },
- { participantId: 'p0032', nhs_number: "999 210 5587", full_name: "Danielle Nash", date_of_birth: "23 June 1970", status: "unstaged" },
- { participantId: 'p0033', nhs_number: "999 619 4470", full_name: "Faye Turner", date_of_birth: "8 January 1957", status: "unstaged" },
- { participantId: 'p0034', nhs_number: "999 908 2233", full_name: "Leona Grant", date_of_birth: "15 October 1974", status: "unstaged" },
- { participantId: 'p0035', nhs_number: "999 776 9904", full_name: "Nora Blake", date_of_birth: "30 December 1966", status: "unstaged" },
- { participantId: 'p0036', nhs_number: "999 665 8805", full_name: "Olivia Lee", date_of_birth: "19 August 1960", status: "unstaged" },
- { participantId: 'p0037', nhs_number: "999 554 7706", full_name: "Priya Desai", date_of_birth: "10 May 1973", status: "unstaged" },
- { participantId: 'p0038', nhs_number: "999 443 6607", full_name: "Quinn Park", date_of_birth: "26 March 1964", status: "unstaged" },
- { participantId: 'p0039', nhs_number: "999 332 5508", full_name: "Rosa Kirk", date_of_birth: "13 July 1958", status: "unstaged" },
- { participantId: 'p0040', nhs_number: "999 221 4409", full_name: "Shannon Boyd", date_of_birth: "22 November 1971", status: "unstaged" },
- { participantId: 'p0041', nhs_number: "999 110 3310", full_name: "Tara Voss", date_of_birth: "9 February 1969", status: "unstaged" },
- { participantId: 'p0042', nhs_number: "999 009 2201", full_name: "Uma Jha", date_of_birth: "17 September 1962", status: "unstaged" },
- { participantId: 'p0043', nhs_number: "999 898 1192", full_name: "Victoria Dean", date_of_birth: "4 April 1976", status: "unstaged" },
- { participantId: 'p0044', nhs_number: "999 787 0083", full_name: "Wendy Fox", date_of_birth: "21 June 1967", status: "unstaged" },
- { participantId: 'p0045', nhs_number: "999 676 9974", full_name: "Xena Holt", date_of_birth: "11 December 1960", status: "unstaged" },
- { participantId: 'p0046', nhs_number: "999 565 8865", full_name: "Yasmin Noor", date_of_birth: "28 August 1961", status: "unstaged" },
- { participantId: 'p0047', nhs_number: "999 454 7756", full_name: "Zara Moon", date_of_birth: "6 May 1973", status: "unstaged" },
- { participantId: 'p0048', nhs_number: "999 343 6647", full_name: "Abigail Stone", date_of_birth: "14 October 1959", status: "unstaged" },
- { participantId: 'p0049', nhs_number: "999 000 0001", full_name: "Olivia Smith", date_of_birth: "12 March 1968", status: "unstaged" },
- { participantId: 'p0050', nhs_number: "999 000 0002", full_name: "Emily Johnson", date_of_birth: "5 June 1974", status: "unstaged" },
- { participantId: 'p0051', nhs_number: "999 000 0003", full_name: "Charlotte Taylor", date_of_birth: "23 September 1961", status: "unstaged" },
- { participantId: 'p0052', nhs_number: "999 000 0004", full_name: "Sophie Brown", date_of_birth: "18 January 1959", status: "unstaged" },
- { participantId: 'p0053', nhs_number: "999 000 0005", full_name: "Chloe Williams", date_of_birth: "7 July 1970", status: "unstaged" },
- { participantId: 'p0054', nhs_number: "999 000 0006", full_name: "Amelia Davies", date_of_birth: "30 November 1965", status: "unstaged" },
- { participantId: 'p0055', nhs_number: "999 000 0007", full_name: "Isla Evans", date_of_birth: "2 April 1963", status: "unstaged" },
- { participantId: 'p0056', nhs_number: "999 000 0008", full_name: "Mia Wilson", date_of_birth: "14 February 1967", status: "unstaged" },
- { participantId: 'p0057', nhs_number: "999 000 0009", full_name: "Ella Thomas", date_of_birth: "29 May 1976", status: "unstaged" },
- { participantId: 'p0058', nhs_number: "999 000 0010", full_name: "Grace Roberts", date_of_birth: "11 August 1962", status: "unstaged" },
- { participantId: 'p0059', nhs_number: "999 000 0011", full_name: "Lily Johnson", date_of_birth: "4 October 1958", status: "unstaged" },
- { participantId: 'p0060', nhs_number: "999 000 0012", full_name: "Hannah Lewis", date_of_birth: "25 March 1964", status: "unstaged" },
- { participantId: 'p0061', nhs_number: "999 000 0013", full_name: "Ruby Walker", date_of_birth: "9 December 1960", status: "unstaged" },
- { participantId: 'p0062', nhs_number: "999 000 0014", full_name: "Evie Wright", date_of_birth: "16 May 1969", status: "unstaged" },
- { participantId: 'p0063', nhs_number: "999 000 0015", full_name: "Isabelle Robinson", date_of_birth: "21 November 1957", status: "unstaged" },
- { participantId: 'p0064', nhs_number: "999 000 0016", full_name: "Alice Wood", date_of_birth: "6 July 1971", status: "unstaged" },
- { participantId: 'p0065', nhs_number: "999 000 0017", full_name: "Matilda Thompson", date_of_birth: "19 April 1966", status: "unstaged" },
- { participantId: 'p0066', nhs_number: "999 000 0018", full_name: "Poppy White", date_of_birth: "27 February 1963", status: "unstaged" },
- { participantId: 'p0067', nhs_number: "999 000 0019", full_name: "Jessica Watson", date_of_birth: "1 January 1957", status: "unstaged" },
- { participantId: 'p0068', nhs_number: "999 000 0020", full_name: "Sarah Jackson", date_of_birth: "8 September 1968", status: "unstaged" },
- { participantId: 'p0069', nhs_number: "999 000 0021", full_name: "Laura Clarke", date_of_birth: "13 June 1962", status: "unstaged" },
- { participantId: 'p0070', nhs_number: "999 000 0022", full_name: "Megan Harrison", date_of_birth: "22 August 1959", status: "unstaged" },
- { participantId: 'p0071', nhs_number: "999 000 0023", full_name: "Victoria Green", date_of_birth: "3 March 1973", status: "unstaged" },
- { participantId: 'p0072', nhs_number: "999 000 0024", full_name: "Natalie Hall", date_of_birth: "15 October 1965", status: "unstaged" },
- { participantId: 'p0073', nhs_number: "999 000 0025", full_name: "Lauren Cooper", date_of_birth: "28 November 1961", status: "unstaged" },
- { participantId: 'p0074', nhs_number: "999 000 0026", full_name: "Zoe Mitchell", date_of_birth: "10 May 1960", status: "unstaged" },
- { participantId: 'p0075', nhs_number: "999 000 0027", full_name: "Amber Morgan", date_of_birth: "17 July 1969", status: "unstaged" },
- { participantId: 'p0076', nhs_number: "999 000 0028", full_name: "Lydia Hill", date_of_birth: "2 December 1975", status: "unstaged" },
- { participantId: 'p0077', nhs_number: "999 000 0029", full_name: "Bethany Moore", date_of_birth: "24 April 1958", status: "unstaged" },
- { participantId: 'p0078', nhs_number: "999 000 0030", full_name: "Natalie Ward", date_of_birth: "30 September 1964", status: "unstaged" },
- { participantId: 'p0079', nhs_number: "999 000 0031", full_name: "Rebecca Edwards", date_of_birth: "5 February 1970", status: "unstaged" },
- { participantId: 'p0080', nhs_number: "999 000 0032", full_name: "Danielle Hughes", date_of_birth: "14 June 1967", status: "unstaged" },
- { participantId: 'p0081', nhs_number: "999 000 0033", full_name: "Fiona Turner", date_of_birth: "9 October 1963", status: "unstaged" },
- { participantId: 'p0082', nhs_number: "999 000 0034", full_name: "Rachel Parker", date_of_birth: "26 January 1956", status: "unstaged" },
- { participantId: 'p0083', nhs_number: "999 000 0035", full_name: "Nicola Collins", date_of_birth: "7 August 1966", status: "unstaged" },
- { participantId: 'p0084', nhs_number: "999 000 0036", full_name: "Claire Stewart", date_of_birth: "12 November 1971", status: "unstaged" },
- { participantId: 'p0085', nhs_number: "999 000 0037", full_name: "Joanne Morris", date_of_birth: "19 March 1962", status: "unstaged" },
- { participantId: 'p0086', nhs_number: "999 000 0038", full_name: "Lorraine Rogers", date_of_birth: "3 May 1974", status: "unstaged" },
- { participantId: 'p0087', nhs_number: "999 000 0039", full_name: "Angela Cook", date_of_birth: "22 July 1958", status: "unstaged" },
- { participantId: 'p0088', nhs_number: "999 000 0040", full_name: "Emma Murphy", date_of_birth: "27 September 1969", status: "unstaged" },
- { participantId: 'p0089', nhs_number: "999 000 0041", full_name: "Jill Richardson", date_of_birth: "6 April 1965", status: "unstaged" },
- { participantId: 'p0090', nhs_number: "999 000 0042", full_name: "Sandra Cox", date_of_birth: "15 December 1957", status: "unstaged" },
- { participantId: 'p0091', nhs_number: "999 000 0043", full_name: "Yasmin Baker", date_of_birth: "23 June 1961", status: "unstaged" },
- { participantId: 'p0092', nhs_number: "999 000 0044", full_name: "Paige Bell", date_of_birth: "11 October 1970", status: "unstaged" },
- { participantId: 'p0093', nhs_number: "999 000 0045", full_name: "Harriet Butler", date_of_birth: "2 February 1968", status: "unstaged" },
- { participantId: 'p0094', nhs_number: "999 000 0046", full_name: "Imogen Gray", date_of_birth: "29 May 1964", status: "unstaged" },
- { participantId: 'p0095', nhs_number: "999 000 0047", full_name: "Tessa Chapman", date_of_birth: "18 November 1962", status: "unstaged" },
- { participantId: 'p0096', nhs_number: "999 000 0048", full_name: "Darcey Ellis", date_of_birth: "4 July 1973", status: "unstaged" },
- { participantId: 'p0097', nhs_number: "999 000 0049", full_name: "Casey Kelly", date_of_birth: "30 March 1960", status: "unstaged" },
- { participantId: 'p0098', nhs_number: "999 000 0050", full_name: "Morgan Porter", date_of_birth: "21 August 1959", status: "unstaged" },
- { participantId: 'p0099', nhs_number: "999 000 0051", full_name: "Alex Pearson", date_of_birth: "8 January 1966", status: "unstaged" },
- { participantId: 'p0100', nhs_number: "999 000 0052", full_name: "Jamie Barker", date_of_birth: "14 October 1963", status: "unstaged" },
- { participantId: 'p0101', nhs_number: "999 000 0053", full_name: "Rowan Hudson", date_of_birth: "25 May 1967", status: "unstaged" },
- { participantId: 'p0102', nhs_number: "999 000 0054", full_name: "Riley Harvey", date_of_birth: "6 April 1958", status: "unstaged" },
- { participantId: 'p0103', nhs_number: "999 000 0055", full_name: "Avery Powell", date_of_birth: "13 September 1975", status: "unstaged" },
- { participantId: 'p0104', nhs_number: "999 000 0056", full_name: "Quinn Jenkins", date_of_birth: "28 February 1961", status: "unstaged" },
- { participantId: 'p0105', nhs_number: "999 000 0057", full_name: "Jordan Simpson", date_of_birth: "17 June 1969", status: "unstaged" },
- { participantId: 'p0106', nhs_number: "999 000 0058", full_name: "Taylor Matthews", date_of_birth: "3 November 1960", status: "unstaged" },
- { participantId: 'p0107', nhs_number: "999 000 0059", full_name: "Sam Davidson", date_of_birth: "12 July 1964", status: "unstaged" },
- { participantId: 'p0108', nhs_number: "999 000 0060", full_name: "Pat Palmer", date_of_birth: "20 March 1956", status: "unstaged" },
- { participantId: 'p0109', nhs_number: "999 000 0061", full_name: "Blair Grant", date_of_birth: "9 September 1968", status: "unstaged" },
- { participantId: 'p0110', nhs_number: "999 000 0062", full_name: "Darcy Price", date_of_birth: "1 December 1965", status: "unstaged" },
- { participantId: 'p0111', nhs_number: "999 000 0063", full_name: "Skylar Grant", date_of_birth: "14 August 1971", status: "unstaged" },
- { participantId: 'p0112', nhs_number: "999 000 0064", full_name: "Noel King", date_of_birth: "5 May 1959", status: "unstaged" },
- { participantId: 'p0113', nhs_number: "999 000 0065", full_name: "Ariel Lee", date_of_birth: "23 January 1962", status: "unstaged" },
- { participantId: 'p0114', nhs_number: "999 000 0066", full_name: "Shannon Baker", date_of_birth: "11 November 1967", status: "unstaged" },
- { participantId: 'p0115', nhs_number: "999 000 0067", full_name: "Kerry Webb", date_of_birth: "4 April 1963", status: "unstaged" },
- { participantId: 'p0116', nhs_number: "999 000 0068", full_name: "Shannon Reid", date_of_birth: "29 June 1974", status: "unstaged" },
- { participantId: 'p0117', nhs_number: "999 000 0069", full_name: "Kelly Fox", date_of_birth: "16 October 1969", status: "unstaged" },
- { participantId: 'p0118', nhs_number: "999 000 0070", full_name: "Casey Ward", date_of_birth: "7 March 1966", status: "unstaged" },
- { participantId: 'p0119', nhs_number: "999 000 0071", full_name: "Rowan Stone", date_of_birth: "30 August 1961", status: "unstaged" },
- { participantId: 'p0120', nhs_number: "999 000 0072", full_name: "Skye Doyle", date_of_birth: "19 May 1958", status: "unstaged" },
- { participantId: 'p0121', nhs_number: "999 000 0073", full_name: "Morgan Heath", date_of_birth: "2 September 1960", status: "unstaged" },
- { participantId: 'p0122', nhs_number: "999 000 0074", full_name: "Alex Murray", date_of_birth: "25 December 1964", status: "unstaged" },
- { participantId: 'p0123', nhs_number: "999 000 0075", full_name: "Taylor Hughes", date_of_birth: "10 February 1967", status: "unstaged" },
- { participantId: 'p0124', nhs_number: "999 000 0076", full_name: "Jamie Arnold", date_of_birth: "18 July 1972", status: "unstaged" },
- { participantId: 'p0125', nhs_number: "999 000 0077", full_name: "Riley Bates", date_of_birth: "6 November 1957", status: "unstaged" },
- { participantId: 'p0126', nhs_number: "999 000 0078", full_name: "Avery Gill", date_of_birth: "22 April 1965", status: "unstaged" },
- { participantId: 'p0127', nhs_number: "999 000 0079", full_name: "Quinn Ellis", date_of_birth: "13 June 1963", status: "unstaged" },
- { participantId: 'p0128', nhs_number: "999 000 0080", full_name: "Jordan Neal", date_of_birth: "27 September 1969", status: "unstaged" },
- { participantId: 'p0129', nhs_number: "999 000 0081", full_name: "Casey Walton", date_of_birth: "8 December 1958", status: "unstaged" },
- { participantId: 'p0130', nhs_number: "999 000 0082", full_name: "Sam Nash", date_of_birth: "15 January 1966", status: "unstaged" },
- { participantId: 'p0131', nhs_number: "999 000 0083", full_name: "Robin Blake", date_of_birth: "4 October 1970", status: "unstaged" },
- { participantId: 'p0132', nhs_number: "999 000 0084", full_name: "Frances Potter", date_of_birth: "21 March 1959", status: "unstaged" },
- { participantId: 'p0133', nhs_number: "999 000 0085", full_name: "Marlow Bishop", date_of_birth: "11 July 1968", status: "unstaged" },
- { participantId: 'p0134', nhs_number: "999 000 0086", full_name: "Ellis Finch", date_of_birth: "29 November 1962", status: "unstaged" },
- { participantId: 'p0135', nhs_number: "999 000 0087", full_name: "Darcy Lane", date_of_birth: "6 May 1961", status: "unstaged" },
- { participantId: 'p0136', nhs_number: "999 000 0088", full_name: "Sky Morgan", date_of_birth: "17 August 1964", status: "unstaged" },
- { participantId: 'p0137', nhs_number: "999 000 0089", full_name: "Rowan Finch", date_of_birth: "3 June 1971", status: "unstaged" },
- { participantId: 'p0138', nhs_number: "999 000 0090", full_name: "Taylor Fields", date_of_birth: "24 February 1967", status: "unstaged" },
- { participantId: 'p0139', nhs_number: "999 000 0091", full_name: "Alex Rivers", date_of_birth: "13 October 1956", status: "unstaged" },
- { participantId: 'p0140', nhs_number: "999 000 0092", full_name: "Morgan Heath", date_of_birth: "2 January 1963", status: "unstaged" },
- { participantId: 'p0141', nhs_number: "999 000 0093", full_name: "Jamie Park", date_of_birth: "30 April 1969", status: "unstaged" },
- { participantId: 'p0142', nhs_number: "999 000 0094", full_name: "Riley Fox", date_of_birth: "9 September 1965", status: "unstaged" },
- { participantId: 'p0143', nhs_number: "999 000 0095", full_name: "Avery Cross", date_of_birth: "5 June 1975", status: "unstaged" },
- { participantId: 'p0144', nhs_number: "999 000 0096", full_name: "Quinn Haynes", date_of_birth: "18 August 1958", status: "unstaged" },
- { participantId: 'p0145', nhs_number: "999 000 0097", full_name: "Jordan Vance", date_of_birth: "27 November 1966", status: "unstaged" },
- { participantId: 'p0146', nhs_number: "999 000 0098", full_name: "Taylor Webb", date_of_birth: "14 February 1970", status: "unstaged" },
- { participantId: 'p0147', nhs_number: "999 000 0099", full_name: "Sam Rector", date_of_birth: "1 October 1962", status: "unstaged" },
- { participantId: 'p0148', nhs_number: "999 000 0100", full_name: "Casey Milton", date_of_birth: "20 July 1968", status: "unstaged" },
- { participantId: 'p0149', nhs_number: "999 000 0101", full_name: "Alex Harper", date_of_birth: "11 March 1960", status: "unstaged" },
- { participantId: 'p0150', nhs_number: "999 000 0102", full_name: "Morgan Kane", date_of_birth: "22 December 1957", status: "unstaged" }
-];
+const firstNames = [
+ 'Amelia', 'Aisha', 'Alice', 'Angela', 'Anita', 'Charlotte', 'Claire',
+ 'Diane', 'Elizabeth', 'Emma', 'Fiona', 'Grace', 'Hannah', 'Helen',
+ 'Imogen', 'Janet', 'Joanne', 'Julia', 'Karen', 'Laura', 'Linda',
+ 'Louise', 'Maria', 'Megan', 'Michelle', 'Natalie', 'Nicola', 'Olivia',
+ 'Patricia', 'Rachel', 'Rebecca', 'Ruth', 'Sarah', 'Sharon', 'Sophie',
+ 'Susan', 'Teresa', 'Tracey', 'Victoria', 'Wendy', 'Yasmin'
+]
+
+const middleNames = [
+ 'Anne', 'Beatrice', 'Catherine', 'Dawn', 'Elaine', 'Jane', 'Jean',
+ 'Louise', 'Marie', 'May', 'Rose', 'Victoria'
+]
+
+const surnames = [
+ 'Adams', 'Baker', 'Bennett', 'Bishop', 'Brown', 'Campbell', 'Carter',
+ 'Chapman', 'Clarke', 'Collins', 'Cooper', 'Davies', 'Dawson', 'Dixon',
+ 'Edwards', 'Ellis', 'Evans', 'Fisher', 'Foster', 'Fox', 'Graham',
+ 'Grant', 'Green', 'Griffiths', 'Hall', 'Harris', 'Harrison', 'Hart',
+ 'Harvey', 'Henderson', 'Hill', 'Hughes', 'Jackson', 'James', 'Jenkins',
+ 'Johnson', 'Jones', 'Kelly', 'King', 'Knight', 'Lane', 'Lawrence',
+ 'Lee', 'Lewis', 'Lloyd', 'Marshall', 'Martin', 'Mason', 'Matthews',
+ 'Miller', 'Mills', 'Mitchell', 'Moore', 'Morgan', 'Morris', 'Moss',
+ 'Murphy', 'Murray', 'Nash', 'Nelson', 'Nicholson', 'Parker', 'Patel',
+ 'Pearson', 'Perry', 'Phillips', 'Price', 'Reed', 'Rees', 'Reid',
+ 'Richards', 'Roberts', 'Robinson', 'Rogers', 'Rose', 'Russell', 'Scott',
+ 'Shaw', 'Simpson', 'Smith', 'Stevens', 'Stewart', 'Stone', 'Taylor',
+ 'Thomas', 'Thompson', 'Turner', 'Walker', 'Wallace', 'Walsh', 'Ward',
+ 'Watson', 'Webb', 'West', 'White', 'Wilkinson', 'Williams', 'Wilson',
+ 'Wood', 'Wright', 'Young'
+]
+
+const internationalNames = [
+ { firstName: 'Amina', surname: 'Rahman' },
+ { firstName: 'Ananya', surname: 'Sharma' },
+ { firstName: 'Beatriz', surname: 'Gomes' },
+ { firstName: 'Chiamaka', surname: 'Okafor' },
+ { firstName: 'Fatima', surname: 'Khan' },
+ { firstName: 'Hana', surname: 'Sato' },
+ { firstName: 'Leila', surname: 'Haddad' },
+ { firstName: 'Mei', surname: 'Wong' },
+ { firstName: 'Nadia', surname: 'Petrova' },
+ { firstName: 'Priyanka', surname: 'Patel' },
+ { firstName: 'Samira', surname: 'Hassan' },
+ { firstName: 'Thandiwe', surname: 'Ndlovu' },
+ { firstName: 'Valentina', surname: 'Rossi' },
+ { firstName: 'Wei', surname: 'Chen' },
+ { firstName: 'Zeinab', surname: 'Ali' }
+]
+
+const streets = [
+ 'Ash Grove', 'Beech Avenue', 'Brook Lane', 'Cedar Road', 'Church Street',
+ 'Elm Close', 'Fairview Drive', 'Garden Way', 'Hawthorn Road',
+ 'Highfield Avenue', 'Meadow Lane', 'Oak Street', 'Orchard Close',
+ 'Park View', 'Station Road', 'The Crescent', 'Willow Drive'
+]
+
+const towns = [
+ { name: 'Brightmere', postcode: 'BM1 4AB' },
+ { name: 'Cedarford', postcode: 'CF2 7CD' },
+ { name: 'Fairwick', postcode: 'FW3 5EF' },
+ { name: 'Greystone', postcode: 'GS4 8GH' },
+ { name: 'Oakminster', postcode: 'OM5 2JK' },
+ { name: 'Westcombe', postcode: 'WC6 9LM' }
+]
+
+const months = [
+ 'January', 'February', 'March', 'April', 'May', 'June',
+ 'July', 'August', 'September', 'October', 'November', 'December'
+]
+
+const pad = (value, width = 2) => String(value).padStart(width, '0')
+const formatDate = (date) => `${pad(date.getDate())} ${months[date.getMonth()]} ${date.getFullYear()}`
+const addDays = (date, days) => {
+ const result = new Date(date)
+ result.setDate(result.getDate() + days)
+ return result
+}
+
+const formatRelativeDate = (date, today) => {
+ const days = Math.max(0, Math.round((date - today) / 86400000))
+
+ if (days < 7) {
+ return `In ${days} day${days === 1 ? '' : 's'}`
+ }
+
+ const weeks = Math.round(days / 7)
+ if (weeks < 8) {
+ return `In ${weeks} week${weeks === 1 ? '' : 's'}`
+ }
+
+ const months = Math.max(1, Math.round(days / 30))
+ return `In ${months} month${months === 1 ? '' : 's'}`
+}
+
+const calculateAge = (dateOfBirth, today) => {
+ let age = today.getFullYear() - dateOfBirth.getFullYear()
+ const birthdayThisYear = new Date(today.getFullYear(), dateOfBirth.getMonth(), dateOfBirth.getDate())
+
+ if (today < birthdayThisYear) {
+ age -= 1
+ }
+
+ return age
+}
+
+const dateOfBirthFor = (index, today) => {
+ const age = 50 + (index % 22)
+ const date = new Date(today.getFullYear() - age, (index * 5) % 12, 1 + ((index * 7) % 27))
+
+ if (date > new Date(today.getFullYear() - 50, today.getMonth(), today.getDate())) {
+ date.setFullYear(date.getFullYear() - 1)
+ }
+ return date
+}
+
+const createParticipant = (index, today) => {
+ const internationalName = index % 10 === 0 ? internationalNames[Math.floor(index / 10) % internationalNames.length] : null
+ const firstName = internationalName ? internationalName.firstName : firstNames[index % firstNames.length]
+ const surname = internationalName ? internationalName.surname : surnames[(index * 7) % surnames.length]
+ const middleName = index % 3 === 0 ? ` ${middleNames[(index * 3) % middleNames.length]}` : ''
+ const town = towns[index % towns.length]
+ const dateOfBirth = dateOfBirthFor(index, today)
+ const nextTestDueDays = 7 + ((index * 11) % 84)
+ const dueDate = addDays(today, nextTestDueDays)
+ const isBreaching = index === 7 || index === 83
+ const hasScheduledAppointment = isBreaching || (index % 4 === 0 && index !== 4 && index !== 8)
+ const nextAppointmentDays = !hasScheduledAppointment
+ ? null
+ : isBreaching
+ ? nextTestDueDays + 5 + (index % 4)
+ : 3 + ((index * 9) % Math.max(4, nextTestDueDays - 2))
+ const appointmentDate = nextAppointmentDays === null ? null : addDays(today, nextAppointmentDays)
+ const specialAppointmentRequired = index % 17 === 0
+ const episodeStages = ['scheduled', 'scheduled', 'scheduled', 'scheduled', 'mammograms', 'reading', 'assessment', 'closed']
+ const mobile = `07700 9${pad(index % 100)}${pad((index * 13) % 1000, 3)}`
+ const home = `01632 960${pad((index * 17) % 1000, 3)}`
+
+ return {
+ participantId: `p${pad(index + 1)}`,
+ full_name: `${surname.toUpperCase()}, ${firstName}${middleName}`,
+ surname_sort_value: surname.toUpperCase(),
+ nhs_number: `999 ${pad((index * 37) % 1000, 3)} ${pad((index * 97 + 1) % 10000, 4)}`,
+ date_of_birth: formatDate(dateOfBirth),
+ age: calculateAge(dateOfBirth, today),
+ gender: 'Female',
+ ethnicity: 'Not recorded',
+ address: {
+ houseNumber: String(1 + ((index * 13) % 98)),
+ street: streets[index % streets.length],
+ town: town.name,
+ postcode: town.postcode
+ },
+ phone_numbers: { mobile, home: index % 4 === 0 ? home : '' },
+ email: `${firstName.toLowerCase()}.${surname.toLowerCase()}${index + 1}@example.com`,
+ sx_number: `ECX${String((index * 7919 + 104729) % 1000000).padStart(6, '0')}`,
+ next_test_due_days: nextTestDueDays,
+ next_test_due_date: formatDate(dueDate),
+ next_test_due_date_value: dueDate.getTime(),
+ next_test_due_date_relative: formatRelativeDate(dueDate, today),
+ episode_stage: episodeStages[index % episodeStages.length],
+ next_appointment_days: nextAppointmentDays,
+ next_appointment_date: appointmentDate ? formatDate(appointmentDate) : 'Not known',
+ next_appointment_date_value: appointmentDate ? appointmentDate.getTime() : null,
+ next_appointment_date_relative: appointmentDate ? formatRelativeDate(appointmentDate, today) : 'Not known',
+ special_appointment_required: specialAppointmentRequired ? 'Yes' : 'No',
+ special_appointment_information: '',
+ further_information: '',
+ status: 'unstaged',
+ is_breaching: isBreaching
+ }
+}
+
+const today = new Date()
+
+export default Array.from({ length: 150 }, (_, index) => createParticipant(index, today))
diff --git a/app/routes.js b/app/routes.js
index 562634cd..6764f7d6 100644
--- a/app/routes.js
+++ b/app/routes.js
@@ -3,6 +3,63 @@ const express = require('express')
const router = express.Router()
+const participantMonths = [
+ 'January', 'February', 'March', 'April', 'May', 'June',
+ 'July', 'August', 'September', 'October', 'November', 'December'
+]
+
+const participantDateFromToday = (today, days) => {
+ const date = new Date(today)
+ date.setDate(date.getDate() + days)
+ return date
+}
+
+const formatParticipantDate = (date) => `${String(date.getDate()).padStart(2, '0')} ${participantMonths[date.getMonth()]} ${date.getFullYear()}`
+
+const formatParticipantRelativeDate = (date, today) => {
+ const days = Math.max(0, Math.round((date - today) / 86400000))
+
+ if (days < 7) {
+ return `In ${days} day${days === 1 ? '' : 's'}`
+ }
+
+ const weeks = Math.round(days / 7)
+ if (weeks < 8) {
+ return `In ${weeks} week${weeks === 1 ? '' : 's'}`
+ }
+
+ const months = Math.max(1, Math.round(days / 30))
+ return `In ${months} month${months === 1 ? '' : 's'}`
+}
+
+const refreshParticipantDates = (participant, today) => {
+ const nextTestDueDate = participantDateFromToday(today, participant.next_test_due_days)
+ const nextAppointmentDate = participant.next_appointment_days === null
+ ? null
+ : participantDateFromToday(today, participant.next_appointment_days)
+
+ return {
+ ...participant,
+ next_test_due_date: formatParticipantDate(nextTestDueDate),
+ next_test_due_date_value: nextTestDueDate.getTime(),
+ next_test_due_date_relative: formatParticipantRelativeDate(nextTestDueDate, today),
+ next_appointment_date: nextAppointmentDate ? formatParticipantDate(nextAppointmentDate) : 'Not known',
+ next_appointment_date_value: nextAppointmentDate ? nextAppointmentDate.getTime() : null,
+ next_appointment_date_relative: nextAppointmentDate ? formatParticipantRelativeDate(nextAppointmentDate, today) : 'Not known'
+ }
+}
+
+const compareAppointmentDates = (a, b, direction) => {
+ const aUnknown = a.next_appointment_date_value === null
+ const bUnknown = b.next_appointment_date_value === null
+
+ if (aUnknown && bUnknown) return 0
+ if (aUnknown) return 1
+ if (bUnknown) return -1
+
+ return direction * (a.next_appointment_date_value - b.next_appointment_date_value)
+}
+
//======= September test specific for now
// Utility function to generate calendar month data
@@ -724,6 +781,59 @@ router.post('/sessions/02-organise-slots', function (req, res) {
// Isolated September Test (Mission 1) routes
router.use(require('./routes/mission-1'));
+router.get('/participants', function (req, res) {
+ const sort = req.query.sort || 'due-soonest'
+ const today = new Date()
+ today.setHours(0, 0, 0, 0)
+ const allParticipants = ((req.session.data.participants && req.session.data.participants.default) || [])
+ .map(participant => refreshParticipantDates(participant, today))
+ const sortComparators = {
+ 'name-asc': (a, b) => a.surname_sort_value.localeCompare(b.surname_sort_value),
+ 'name-desc': (a, b) => b.surname_sort_value.localeCompare(a.surname_sort_value),
+ 'due-soonest': (a, b) => a.next_test_due_date_value - b.next_test_due_date_value,
+ 'due-latest': (a, b) => b.next_test_due_date_value - a.next_test_due_date_value,
+ 'screening-soonest': (a, b) => compareAppointmentDates(a, b, 1),
+ 'screening-latest': (a, b) => compareAppointmentDates(a, b, -1),
+ 'age-oldest': (a, b) => b.age - a.age,
+ 'age-youngest': (a, b) => a.age - b.age
+ }
+
+ allParticipants.sort(sortComparators[sort] || sortComparators['name-asc'])
+
+ const pageSize = 100
+ const totalParticipants = allParticipants.length
+ const totalPages = Math.max(1, Math.ceil(totalParticipants / pageSize))
+ const requestedPage = parseInt(req.query.page, 10) || 1
+ const currentPage = Math.min(Math.max(requestedPage, 1), totalPages)
+ const firstRecord = totalParticipants ? ((currentPage - 1) * pageSize) + 1 : 0
+ const lastRecord = Math.min(currentPage * pageSize, totalParticipants)
+ const participants = allParticipants.slice(firstRecord - 1, lastRecord)
+
+ res.render('participants/index', {
+ participants,
+ selectedSort: sort,
+ currentPage,
+ totalPages,
+ firstRecord,
+ lastRecord,
+ totalParticipants,
+ paginationPages: Array.from({ length: totalPages }, (_, index) => index + 1)
+ })
+})
+
+router.get('/participants/:participantId', function (req, res) {
+ const participants = (req.session.data.participants && req.session.data.participants.default) || []
+ const today = new Date()
+ today.setHours(0, 0, 0, 0)
+ const participant = participants.find(item => item.participantId === req.params.participantId)
+
+ if (!participant) {
+ return res.status(404).send('Participant not found')
+ }
+
+ res.render('participants/detail', { participant: refreshParticipantDates(participant, today) })
+})
+
// Isolated create capacity from zero routes
router.use(require('./routes/create-capacity-from-zero'));
diff --git a/app/views/_includes/primary-navigation.html b/app/views/_includes/primary-navigation.html
index 4dfff69f..6afab2cb 100644
--- a/app/views/_includes/primary-navigation.html
+++ b/app/views/_includes/primary-navigation.html
@@ -40,7 +40,7 @@
active: activeItem === "Clinics"
},
{
- href: "#",
+ href: "/participants/",
text: "Participants",
active: activeItem === "Participants"
},
diff --git a/app/views/index.html b/app/views/index.html
index 75b3f174..bf4da108 100755
--- a/app/views/index.html
+++ b/app/views/index.html
@@ -44,6 +44,15 @@
href: "/prototype-admin/reset?returnPage=" + (currentPage | urlencode)
}) }}
+ September 2026
+ Participant view
+
+
+ -
+ View participant details
+
+
+
August – September 2026
Clinics with schedules ideation
diff --git a/app/views/participants/detail.html b/app/views/participants/detail.html
new file mode 100644
index 00000000..fb50fadf
--- /dev/null
+++ b/app/views/participants/detail.html
@@ -0,0 +1,162 @@
+{% extends 'layout.html' %}
+
+{% set pageName = 'Participant details' %}
+
+{% from "_includes/primary-navigation.html" import primaryNavigation %}
+{% block header %}
+{{ primaryNavigation("Participants", serviceName) }}
+{% endblock %}
+
+{% block content %}
+
+
+ Participant details
+ {{ participant.full_name }}
+
+
+
+
+
+
+
+
+
+ {% if participant.is_breaching %}
+ At risk of breaching
+ {% endif %}
+
+
+
+
+ Next test due date
+ {{ participant.next_test_due_date }}
+ {{ participant.next_test_due_date_relative }}
+
+
+ Next screening date
+ {% if participant.next_appointment_date != 'Not known' %}
+ {{ participant.next_appointment_date }}
+ {% endif %}
+ {{ participant.next_appointment_date_relative }}
+
+
+
+
+
+
+
+{% set stageText = {
+'scheduled': 'Scheduled',
+'mammograms': 'Mammograms',
+'reading': 'Waiting for reading',
+'assessment': 'At assessment',
+'closed': 'Closed'
+}[participant.episode_stage] %}
+{% set stageColour = {
+'scheduled': 'blue',
+'mammograms': 'purple',
+'reading': 'yellow',
+'assessment': 'orange',
+'closed': 'grey'
+}[participant.episode_stage] %}
+
+
+
+
+
+ Personal details
+
+
+ - NHS Number
+ - {{ participant.nhs_number }}
+
+
+ - SX Number
+ - {{ participant.sx_number }}
+
+
+ - Full name
+ - {{ participant.full_name }}
+
+
+ - Date of birth
+ - {{ participant.date_of_birth }}
({{ participant.age }} years old)
+
+
+ - Ethnicity
+ - {{ participant.ethnicity }}
+
+
+
+
+
+
+
+ Contact detail
+
+
+ - Address
+ -
+ {{ participant.address.houseNumber }} {{ participant.address.street }}
+ {{ participant.address.town }}
+ {{ participant.address.postcode }}
+
+
+
+ -
+ {% if participant.phone_numbers.mobile and participant.phone_numbers.home %}
+ Phone numbers
+ {% else %}
+ Phone number
+ {% endif %}
+
+ -
+ {% if participant.phone_numbers.mobile %}{{ participant.phone_numbers.mobile }}{% endif %}
+ {% if participant.phone_numbers.mobile and participant.phone_numbers.home %}
{% endif %}
+ {% if participant.phone_numbers.home %}{{ participant.phone_numbers.home }}{% endif %}
+
+
+
+ - Email address
+ - {{ participant.email }}
+
+
+ - Special appointment required
+ - {{ participant.special_appointment_required }}
+
+ {% if participant.special_appointment_required == 'Yes' %}
+
+ - Special appointment information
+ - {{ participant.special_appointment_information or '-' }}
+
+
+ {% endif %}
+
+
+
+
+
+
+ Current screening episode
+
+
+ - Stage
+ -
+ {{ stageText }}
+
+
+
+ - Next screening appointment
+ -
+ {% if participant.next_appointment_date != 'Not known' %}{{ participant.next_appointment_date }}
{% endif %}
+ {{ participant.next_appointment_date_relative }}
+
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/app/views/participants/index.html b/app/views/participants/index.html
new file mode 100644
index 00000000..06e2c3d0
--- /dev/null
+++ b/app/views/participants/index.html
@@ -0,0 +1,115 @@
+{% extends 'layout.html' %}
+
+{% set pageName = "Participants" %}
+
+{% from "_includes/primary-navigation.html" import primaryNavigation %}
+{% block header %}
+ {{ primaryNavigation("Participants", serviceName) }}
+{% endblock %}
+
+{% block beforeContent %}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% for participant in participants %}
+
+
+ {{ participant.full_name }}
+ {{ participant.age }} years old
+ |
+ {{ participant.nhs_number }} |
+
+ {{ participant.next_test_due_date }}
+ {{ participant.next_test_due_date_relative }}
+ |
+
+ {% if participant.next_appointment_date != 'Not known' %}{{ participant.next_appointment_date }} {% endif %}
+ {{ participant.next_appointment_date_relative }}
+ |
+
+ {% endfor %}
+
+
+
+ {% if totalPages > 1 %}
+
+ {% endif %}
+
+
+
+
+{% endblock %}
From 317c845fa31d4abb23637dd1f799353adc00cac8 Mon Sep 17 00:00:00 2001
From: danjohnstonUX <21033686+danjohnstonUX@users.noreply.github.com>
Date: Tue, 29 Sep 2026 15:39:19 +0100
Subject: [PATCH 26/27] added participant view to batches and added search and
filter functionality
Rebase participant view branch
---
app/assets/sass/components/_card.scss | 20 +
app/assets/sass/main.scss | 1 +
app/data/participants.js | 59 ++-
app/routes.js | 118 +++++-
.../participant-breakdown/standard.html | 6 +-
app/views/batches/participants/detail.html | 199 +++++++++
app/views/batches/participants/index.html | 166 ++++++++
app/views/participants/detail.html | 146 +++++--
app/views/participants/index.html | 381 +++++++++++++-----
9 files changed, 940 insertions(+), 156 deletions(-)
create mode 100644 app/assets/sass/components/_card.scss
create mode 100644 app/views/batches/participants/detail.html
create mode 100644 app/views/batches/participants/index.html
diff --git a/app/assets/sass/components/_card.scss b/app/assets/sass/components/_card.scss
new file mode 100644
index 00000000..3a39888b
--- /dev/null
+++ b/app/assets/sass/components/_card.scss
@@ -0,0 +1,20 @@
+@use "sass:color";
+@use "nhsuk-frontend/dist/nhsuk/core" as *;
+
+.nhsuk-card--feature.app-card--feature-grey {
+ background-color: color.scale(nhsuk-colour("grey-4"), $alpha: -50%) !important;
+}
+.nhsuk-card--feature.app-card--feature-grey .nhsuk-card__heading {
+ background-color: nhsuk-colour("grey-1");
+ color: nhsuk-colour("white");
+}
+
+.nhsuk-card--feature.app-card--feature-orange .nhsuk-card__heading {
+ background-color: nhsuk-colour("orange");
+ color: nhsuk-colour("white");
+}
+
+.nhsuk-card--feature.app-card--feature-green .nhsuk-card__heading {
+ background-color: nhsuk-colour("green");
+ color: nhsuk-colour("white");
+}
diff --git a/app/assets/sass/main.scss b/app/assets/sass/main.scss
index deb48bb3..cdf5f741 100755
--- a/app/assets/sass/main.scss
+++ b/app/assets/sass/main.scss
@@ -86,6 +86,7 @@
@forward "components/calendar-table";
@forward "components/clinic-capacity-summary";
+@forward "components/card";
@forward "components/clinic-level-breadcrumbs";
@forward "components/grid-extensions";
@forward "components/inline-detailed-search";
diff --git a/app/data/participants.js b/app/data/participants.js
index e71e5398..32d46d07 100644
--- a/app/data/participants.js
+++ b/app/data/participants.js
@@ -64,6 +64,12 @@ const towns = [
{ name: 'Westcombe', postcode: 'WC6 9LM' }
]
+const appointmentUnits = ['West Sussex Breast Care Centre', 'Alpha Van', 'Beta Van', 'Gamma Van', 'Delta Van']
+const appointmentLocations = [
+ 'Bognor Regis', 'Burgess Hill', 'Chichester', 'Crawley', 'Haywards Heath',
+ 'Horsham', 'Littlehampton', 'Shoreham-by-Sea', 'Storrington'
+]
+
const months = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
@@ -71,16 +77,36 @@ const months = [
const pad = (value, width = 2) => String(value).padStart(width, '0')
const formatDate = (date) => `${pad(date.getDate())} ${months[date.getMonth()]} ${date.getFullYear()}`
+const postcodeFor = (index, postcode) => {
+ const outwardCode = postcode.split(' ')[0]
+ const letters = 'ABCDEFGHJKLMNPRSTUWXYZ'
+ const suffixIndex = Math.floor(index / 9)
+ const firstLetter = letters[Math.floor(suffixIndex / letters.length)]
+ const secondLetter = letters[suffixIndex % letters.length]
+ return `${outwardCode} ${(index % 9) + 1}${firstLetter}${secondLetter}`
+}
+
+const gpPractices = [
+ { practice_code: 'DEMO001', name: 'Ash Grove Medical Centre', phone: '01632 960101', address: { houseNumber: '12', street: 'Ash Grove', town: 'Brightmere', postcode: 'BM1 4AC' } },
+ { practice_code: 'DEMO002', name: 'Cedarford Family Practice', phone: '01632 960102', address: { houseNumber: '24', street: 'Cedar Road', town: 'Cedarford', postcode: 'CF2 7CE' } },
+ { practice_code: 'DEMO003', name: 'Fairview Health Centre', phone: '01632 960103', address: { houseNumber: '8', street: 'Fairview Drive', town: 'Fairwick', postcode: 'FW3 5EG' } },
+ { practice_code: 'DEMO004', name: 'Greystone Medical Practice', phone: '01632 960104', address: { houseNumber: '31', street: 'Church Street', town: 'Greystone', postcode: 'GS4 8GJ' } },
+ { practice_code: 'DEMO005', name: 'Oakminster Surgery', phone: '01632 960105', address: { houseNumber: '5', street: 'Oak Street', town: 'Oakminster', postcode: 'OM5 2JL' } },
+ { practice_code: 'DEMO006', name: 'Westcombe Medical Centre', phone: '01632 960106', address: { houseNumber: '17', street: 'Station Road', town: 'Westcombe', postcode: 'WC6 9LN' } },
+ { practice_code: 'DEMO007', name: 'Meadow Lane Health Centre', phone: '01632 960107', address: { houseNumber: '42', street: 'Meadow Lane', town: 'Brightmere', postcode: 'BM1 4AD' } },
+ { practice_code: 'DEMO008', name: 'Willow Drive Family Practice', phone: '01632 960108', address: { houseNumber: '3', street: 'Willow Drive', town: 'Cedarford', postcode: 'CF2 7CF' } }
+]
+
const addDays = (date, days) => {
const result = new Date(date)
result.setDate(result.getDate() + days)
return result
}
-const formatRelativeDate = (date, today) => {
+const formatRelativeDate = (date, today, daysThreshold = 7) => {
const days = Math.max(0, Math.round((date - today) / 86400000))
- if (days < 7) {
+ if (days < daysThreshold) {
return `In ${days} day${days === 1 ? '' : 's'}`
}
@@ -93,6 +119,13 @@ const formatRelativeDate = (date, today) => {
return `In ${months} month${months === 1 ? '' : 's'}`
}
+const lastScreenedDaysAgoFor = (index) => {
+ if (index % 30 === 0) return null
+ if (index % 20 < 4) return 365 + ((index * 37) % 1095)
+ if (index % 37 === 0) return 1826 + ((index * 11) % 1095)
+ return 1095 + ((index * 13) % 181)
+}
+
const calculateAge = (dateOfBirth, today) => {
let age = today.getFullYear() - dateOfBirth.getFullYear()
const birthdayThisYear = new Date(today.getFullYear(), dateOfBirth.getMonth(), dateOfBirth.getDate())
@@ -123,6 +156,8 @@ const createParticipant = (index, today) => {
const dateOfBirth = dateOfBirthFor(index, today)
const nextTestDueDays = 7 + ((index * 11) % 84)
const dueDate = addDays(today, nextTestDueDays)
+ const lastScreenedDaysAgo = lastScreenedDaysAgoFor(index)
+ const lastScreenedDate = lastScreenedDaysAgo === null ? null : addDays(today, -lastScreenedDaysAgo)
const isBreaching = index === 7 || index === 83
const hasScheduledAppointment = isBreaching || (index % 4 === 0 && index !== 4 && index !== 8)
const nextAppointmentDays = !hasScheduledAppointment
@@ -131,8 +166,15 @@ const createParticipant = (index, today) => {
? nextTestDueDays + 5 + (index % 4)
: 3 + ((index * 9) % Math.max(4, nextTestDueDays - 2))
const appointmentDate = nextAppointmentDays === null ? null : addDays(today, nextAppointmentDays)
+ const appointmentUnit = nextAppointmentDays === null
+ ? null
+ : appointmentUnits[Math.floor(Math.random() * appointmentUnits.length)]
+ const appointmentLocation = nextAppointmentDays === null
+ ? null
+ : appointmentUnit === 'West Sussex Breast Care Centre'
+ ? 'Worthing Hospital'
+ : appointmentLocations[Math.floor(Math.random() * appointmentLocations.length)]
const specialAppointmentRequired = index % 17 === 0
- const episodeStages = ['scheduled', 'scheduled', 'scheduled', 'scheduled', 'mammograms', 'reading', 'assessment', 'closed']
const mobile = `07700 9${pad(index % 100)}${pad((index * 13) % 1000, 3)}`
const home = `01632 960${pad((index * 17) % 1000, 3)}`
@@ -149,20 +191,25 @@ const createParticipant = (index, today) => {
houseNumber: String(1 + ((index * 13) % 98)),
street: streets[index % streets.length],
town: town.name,
- postcode: town.postcode
+ postcode: postcodeFor(index, town.postcode)
},
+ gp_practice: gpPractices[index % gpPractices.length],
phone_numbers: { mobile, home: index % 4 === 0 ? home : '' },
email: `${firstName.toLowerCase()}.${surname.toLowerCase()}${index + 1}@example.com`,
sx_number: `ECX${String((index * 7919 + 104729) % 1000000).padStart(6, '0')}`,
+ last_screened_days_ago: lastScreenedDaysAgo,
+ last_screened_date: lastScreenedDate ? formatDate(lastScreenedDate) : 'Never screened',
next_test_due_days: nextTestDueDays,
next_test_due_date: formatDate(dueDate),
next_test_due_date_value: dueDate.getTime(),
- next_test_due_date_relative: formatRelativeDate(dueDate, today),
- episode_stage: episodeStages[index % episodeStages.length],
+ next_test_due_date_relative: formatRelativeDate(dueDate, today, 28),
+ episode_stage: 'scheduled',
next_appointment_days: nextAppointmentDays,
next_appointment_date: appointmentDate ? formatDate(appointmentDate) : 'Not known',
next_appointment_date_value: appointmentDate ? appointmentDate.getTime() : null,
next_appointment_date_relative: appointmentDate ? formatRelativeDate(appointmentDate, today) : 'Not known',
+ next_appointment_unit: appointmentUnit,
+ next_appointment_location: appointmentLocation,
special_appointment_required: specialAppointmentRequired ? 'Yes' : 'No',
special_appointment_information: '',
further_information: '',
diff --git a/app/routes.js b/app/routes.js
index 6764f7d6..965a0818 100644
--- a/app/routes.js
+++ b/app/routes.js
@@ -4,6 +4,11 @@ const express = require('express')
const router = express.Router()
const participantMonths = [
+ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
+]
+
+const participantFullMonths = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
]
@@ -15,11 +20,12 @@ const participantDateFromToday = (today, days) => {
}
const formatParticipantDate = (date) => `${String(date.getDate()).padStart(2, '0')} ${participantMonths[date.getMonth()]} ${date.getFullYear()}`
+const formatParticipantFullDate = (date) => `${String(date.getDate()).padStart(2, '0')} ${participantFullMonths[date.getMonth()]} ${date.getFullYear()}`
-const formatParticipantRelativeDate = (date, today) => {
+const formatParticipantRelativeDate = (date, today, daysThreshold = 7) => {
const days = Math.max(0, Math.round((date - today) / 86400000))
- if (days < 7) {
+ if (days < daysThreshold) {
return `In ${days} day${days === 1 ? '' : 's'}`
}
@@ -32,18 +38,22 @@ const formatParticipantRelativeDate = (date, today) => {
return `In ${months} month${months === 1 ? '' : 's'}`
}
-const refreshParticipantDates = (participant, today) => {
+const refreshParticipantDates = (participant, today, dateFormatter = formatParticipantDate) => {
const nextTestDueDate = participantDateFromToday(today, participant.next_test_due_days)
const nextAppointmentDate = participant.next_appointment_days === null
? null
: participantDateFromToday(today, participant.next_appointment_days)
+ const lastScreenedDate = participant.last_screened_days_ago === null
+ ? null
+ : participantDateFromToday(today, -participant.last_screened_days_ago)
return {
...participant,
- next_test_due_date: formatParticipantDate(nextTestDueDate),
+ last_screened_date: lastScreenedDate ? dateFormatter(lastScreenedDate) : 'Never screened',
+ next_test_due_date: dateFormatter(nextTestDueDate),
next_test_due_date_value: nextTestDueDate.getTime(),
- next_test_due_date_relative: formatParticipantRelativeDate(nextTestDueDate, today),
- next_appointment_date: nextAppointmentDate ? formatParticipantDate(nextAppointmentDate) : 'Not known',
+ next_test_due_date_relative: formatParticipantRelativeDate(nextTestDueDate, today, 28),
+ next_appointment_date: nextAppointmentDate ? dateFormatter(nextAppointmentDate) : 'Not known',
next_appointment_date_value: nextAppointmentDate ? nextAppointmentDate.getTime() : null,
next_appointment_date_relative: nextAppointmentDate ? formatParticipantRelativeDate(nextAppointmentDate, today) : 'Not known'
}
@@ -60,6 +70,38 @@ const compareAppointmentDates = (a, b, direction) => {
return direction * (a.next_appointment_date_value - b.next_appointment_date_value)
}
+const queryValues = value => (Array.isArray(value) ? value : value ? [value] : [])
+ .filter(item => item !== '_unchecked')
+
+const matchesDateRange = (days, range) => {
+ if (typeof days !== 'number') return false
+ if (range === 'within_week') return days <= 7
+ if (range === 'within_month') return days <= 30
+ if (range === 'within_3_months') return days <= 90
+ if (range === 'within_6_months') return days <= 180
+ return true
+}
+
+const matchesLastScreenedFilter = (participant, filter) => {
+ const days = participant.last_screened_days_ago
+ if (filter === 'never') return days === null
+ if (typeof days !== 'number') return false
+ if (filter === 'within_3_years') return days <= 1095
+ if (filter === '3_to_5_years') return days > 1095 && days <= 1825
+ if (filter === 'over_5_years') return days > 1825
+ return true
+}
+
+const participantFilterQuery = (search, nextTestDue, nextScreening, lastScreened, breachRisk) => {
+ const params = new URLSearchParams()
+ if (search) params.set('search', search)
+ nextTestDue.forEach(value => params.append('nextTestDue', value))
+ nextScreening.forEach(value => params.append('nextScreening', value))
+ lastScreened.forEach(value => params.append('lastScreened', value))
+ breachRisk.forEach(value => params.append('breachRisk', value))
+ return params.toString()
+}
+
//======= September test specific for now
// Utility function to generate calendar month data
@@ -781,12 +823,50 @@ router.post('/sessions/02-organise-slots', function (req, res) {
// Isolated September Test (Mission 1) routes
router.use(require('./routes/mission-1'));
-router.get('/participants', function (req, res) {
+router.get(['/participants', '/batches/participants'], function (req, res) {
+ const participantsPath = req.path.startsWith('/batches/') ? '/batches/participants' : '/participants'
const sort = req.query.sort || 'due-soonest'
+ const search = String(req.query.search || '').trim()
+ const nextTestDue = queryValues(req.query.nextTestDue)
+ const nextScreening = queryValues(req.query.nextScreening)
+ const lastScreened = queryValues(req.query.lastScreened)
+ const breachRisk = queryValues(req.query.breachRisk)
const today = new Date()
today.setHours(0, 0, 0, 0)
- const allParticipants = ((req.session.data.participants && req.session.data.participants.default) || [])
+ let allParticipants = ((req.session.data.participants && req.session.data.participants.default) || [])
.map(participant => refreshParticipantDates(participant, today))
+
+ if (search) {
+ const searchTerm = search.toLowerCase().replace(/\s+/g, ' ')
+ allParticipants = allParticipants.filter(participant => [
+ participant.full_name,
+ participant.surname_sort_value,
+ participant.address.postcode,
+ participant.nhs_number,
+ participant.sx_number
+ ].some(value => String(value || '').toLowerCase().replace(/\s+/g, ' ').includes(searchTerm)))
+ }
+
+ if (nextTestDue.length) {
+ allParticipants = allParticipants.filter(participant =>
+ nextTestDue.some(range => matchesDateRange(participant.next_test_due_days, range)))
+ }
+
+ if (nextScreening.length) {
+ allParticipants = allParticipants.filter(participant => nextScreening.some(range => range === 'not_known'
+ ? participant.next_appointment_days === null
+ : matchesDateRange(participant.next_appointment_days, range)))
+ }
+
+ if (lastScreened.length) {
+ allParticipants = allParticipants.filter(participant =>
+ lastScreened.some(filter => matchesLastScreenedFilter(participant, filter)))
+ }
+
+ if (breachRisk.length) {
+ allParticipants = allParticipants.filter(participant => breachRisk.some(filter =>
+ filter === 'at_risk' ? participant.is_breaching : !participant.is_breaching))
+ }
const sortComparators = {
'name-asc': (a, b) => a.surname_sort_value.localeCompare(b.surname_sort_value),
'name-desc': (a, b) => b.surname_sort_value.localeCompare(a.surname_sort_value),
@@ -809,19 +889,30 @@ router.get('/participants', function (req, res) {
const lastRecord = Math.min(currentPage * pageSize, totalParticipants)
const participants = allParticipants.slice(firstRecord - 1, lastRecord)
- res.render('participants/index', {
+ res.render(participantsPath === '/batches/participants' ? 'batches/participants/index' : 'participants/index', {
participants,
+ participantsPath,
+ batchName: participantsPath === '/batches/participants' ? 'Chichester - Parklands' : null,
+ participantListTitle: participantsPath === '/batches/participants' ? 'Standard participants' : 'Participants',
selectedSort: sort,
currentPage,
totalPages,
firstRecord,
lastRecord,
totalParticipants,
- paginationPages: Array.from({ length: totalPages }, (_, index) => index + 1)
+ paginationPages: Array.from({ length: totalPages }, (_, index) => index + 1),
+ search,
+ selectedNextTestDue: nextTestDue,
+ selectedNextScreening: nextScreening,
+ selectedLastScreened: lastScreened,
+ selectedBreachRisk: breachRisk,
+ participantFilterQuery: participantFilterQuery(search, nextTestDue, nextScreening, lastScreened, breachRisk),
+ clearSearchQuery: participantFilterQuery('', nextTestDue, nextScreening, lastScreened, breachRisk)
})
})
-router.get('/participants/:participantId', function (req, res) {
+router.get(['/participants/:participantId', '/batches/participants/:participantId'], function (req, res) {
+ const participantsPath = req.path.startsWith('/batches/') ? '/batches/participants' : '/participants'
const participants = (req.session.data.participants && req.session.data.participants.default) || []
const today = new Date()
today.setHours(0, 0, 0, 0)
@@ -831,7 +922,10 @@ router.get('/participants/:participantId', function (req, res) {
return res.status(404).send('Participant not found')
}
- res.render('participants/detail', { participant: refreshParticipantDates(participant, today) })
+ res.render(participantsPath === '/batches/participants' ? 'batches/participants/detail' : 'participants/detail', {
+ participant: refreshParticipantDates(participant, today, formatParticipantFullDate),
+ participantsPath
+ })
})
// Isolated create capacity from zero routes
diff --git a/app/views/batches/participant-breakdown/standard.html b/app/views/batches/participant-breakdown/standard.html
index fdce28be..4b294a32 100644
--- a/app/views/batches/participant-breakdown/standard.html
+++ b/app/views/batches/participant-breakdown/standard.html
@@ -43,7 +43,7 @@
}) }}
- View all participants
+ View all participants
@@ -66,7 +66,7 @@
}) }}
- View all participants
+ View all participants
@@ -89,7 +89,7 @@
}) }}
- View all participants
+ View all participants
diff --git a/app/views/batches/participants/detail.html b/app/views/batches/participants/detail.html
new file mode 100644
index 00000000..5af807a9
--- /dev/null
+++ b/app/views/batches/participants/detail.html
@@ -0,0 +1,199 @@
+{% extends 'layout.html' %}
+
+{% set pageName = 'Participant details' %}
+
+{% from "_includes/primary-navigation.html" import primaryNavigation %}
+{% block header %}
+{{ primaryNavigation("Batches", serviceName) }}
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+ Participant details
+ {{ participant.full_name }}
+
+
+
+
+
+ {% if participant.is_breaching %}At risk of
+ breaching{% endif %}
+
+
+
+{% set stageText = {
+'scheduled': 'Scheduled',
+'mammograms': 'Mammograms',
+'reading': 'Waiting for reading',
+'assessment': 'At assessment',
+'closed': 'Closed'
+}[participant.episode_stage] %}
+{% set stageColour = {
+'scheduled': 'blue',
+'mammograms': 'purple',
+'reading': 'yellow',
+'assessment': 'orange',
+'closed': 'grey'
+}[participant.episode_stage] %}
+
+
+
+
+
+ Screening details
+
+
+ - Next test due date
+ - {{ participant.next_test_due_date }}
{{ participant.next_test_due_date_relative }}
+
+
+ - Next screening appointment
+ -
+ {% if participant.next_appointment_date != 'Not known' %}{{
+ participant.next_appointment_date }}
{% endif %}
+ {{ participant.next_appointment_date_relative }}
+ {% if participant.next_appointment_unit %}
+ {{ participant.next_appointment_unit }}, {{
+ participant.next_appointment_location }}
+ Appointment taking place at {{
+ participant.next_appointment_unit }} in {{ participant.next_appointment_location
+ }}{% endif %}
+
+
+
+ - Last screened
+ - {{ participant.last_screened_date }}
+
+
+
+
+
+
+
+ Personal details
+
+
+ - Full name
+ - {{ participant.full_name }}
+
+
+ - NHS Number
+ - {{ participant.nhs_number }}
+
+
+ - Date of birth
+ - {{ participant.date_of_birth }}
({{ participant.age }} years old)
+
+
+ - Gender
+ - {{ participant.gender }}
+
+
+ - Ethnicity
+ - {{ participant.ethnicity }}
+
+
+
+
+
+
+
+ Contact details
+
+
+ - Address
+ -
+ {{ participant.address.houseNumber }} {{ participant.address.street }}
+ {{ participant.address.town }}
+ {{ participant.address.postcode }}
+
+
+
+ - {% if participant.phone_numbers.mobile and
+ participant.phone_numbers.home %}Phone numbers{% else %}Phone number{% endif %}
+ - {% if participant.phone_numbers.mobile %}{{
+ participant.phone_numbers.mobile }}{% endif %}{% if participant.phone_numbers.mobile and
+ participant.phone_numbers.home %}
{% endif %}{% if participant.phone_numbers.home %}{{
+ participant.phone_numbers.home }}{% endif %}
+
+
+ - Email address
+ - {{ participant.email }}
+
+
+
+
+
+
+
+ GP details
+
+
+ - Name
+ - {{ participant.gp_practice.name }}
+
+
+ - Phone number
+ - {{ participant.gp_practice.phone }}
+
+
+ - Address
+ -
+ {{ participant.gp_practice.address.houseNumber }} {{ participant.gp_practice.address.street
+ }}
+ {{ participant.gp_practice.address.town }}
+ {{ participant.gp_practice.address.postcode }}
+
+
+
+
+
+
+
+
+ Screening episodes
+ Current episode
+
+
+ - Stage
+ - {{
+ stageText }}
+
+
+ - Next screening appointment
+ -
+ {% if participant.next_appointment_date != 'Not known' %}{{
+ participant.next_appointment_date }}
{% endif %}
+ {{ participant.next_appointment_date_relative }}
+ {% if participant.next_appointment_unit %} {{
+ participant.next_appointment_unit }}, {{ participant.next_appointment_location
+ }}Appointment taking place at {{
+ participant.next_appointment_unit }} in {{ participant.next_appointment_location
+ }}{% endif %}
+
+
+
+ Previous episodes
+
+
+ - Last screened
+ - {{ participant.last_screened_date or '-' }}
+
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/app/views/batches/participants/index.html b/app/views/batches/participants/index.html
new file mode 100644
index 00000000..1ee942bd
--- /dev/null
+++ b/app/views/batches/participants/index.html
@@ -0,0 +1,166 @@
+{% extends 'layout.html' %}
+
+{% set pageName = participantListTitle %}
+
+{% from "_includes/primary-navigation.html" import primaryNavigation %}
+{% block header %}
+{{ primaryNavigation("Batches", serviceName) }}
+{% endblock %}
+
+{% block beforeContent %}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+ {{ batchName }}
+ {{ pageName }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% for participant in participants %}
+
+ {{ participant.full_name }} {{ participant.age }} years old |
+ {{ participant.address.postcode }} |
+ {{ participant.nhs_number }} |
+ {{ participant.next_test_due_date }} {{ participant.next_test_due_date_relative }} |
+ {% if participant.next_appointment_date != 'Not known' %}{{ participant.next_appointment_date }} {% endif %}{{ participant.next_appointment_date_relative }} |
+
+ {% endfor %}
+
+
+ {% if totalPages > 1 %}
+
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/app/views/participants/detail.html b/app/views/participants/detail.html
index fb50fadf..0d053758 100644
--- a/app/views/participants/detail.html
+++ b/app/views/participants/detail.html
@@ -4,45 +4,36 @@
{% from "_includes/primary-navigation.html" import primaryNavigation %}
{% block header %}
-{{ primaryNavigation("Participants", serviceName) }}
+{{ primaryNavigation("Batches" if participantsPath == "/batches/participants" else "Participants", serviceName) }}
{% endblock %}
{% block content %}
+
+
+
+
+
+
Participant details
{{ participant.full_name }}
+
-
-
-
-
-
- {% if participant.is_breaching %}
- At risk of breaching
- {% endif %}
-
-
-
-
- Next test due date
- {{ participant.next_test_due_date }}
- {{ participant.next_test_due_date_relative }}
-
-
- Next screening date
- {% if participant.next_appointment_date != 'Not known' %}
- {{ participant.next_appointment_date }}
- {% endif %}
- {{ participant.next_appointment_date_relative }}
-
-
-
-
+
+ {% if participant.is_breaching %}
+ At risk of breaching
+ {% endif %}
@@ -63,27 +54,65 @@
+
- Personal details
+ Screening details
- - NHS Number
- - {{ participant.nhs_number }}
+ - Next test due date
+ - {{ participant.next_test_due_date }}
+ {{ participant.next_test_due_date_relative }}
+
+
- - SX Number
- - {{ participant.sx_number }}
+ - Next screening appointment
+ - {% if participant.next_appointment_date != 'Not known'
+ %}{{ participant.next_appointment_date }}
{% endif
+ %}
+ {{ participant.next_appointment_date_relative }}
+ {% if participant.next_appointment_unit %}
+ {{ participant.next_appointment_unit }}, {{
+ participant.next_appointment_location }}
+ Appointment taking place at {{
+ participant.next_appointment_unit }} in {{ participant.next_appointment_location
+ }}{% endif %}
+
+
+
+ - Last screened
+ - {{ participant.last_screened_date }}
+
+
+
+
+
+
+ Personal details
+
- Full name
- {{ participant.full_name }}
+
+ - NHS Number
+ - {{ participant.nhs_number }}
+
+
- Date of birth
- {{ participant.date_of_birth }}
({{ participant.age }} years old)
+
+ - Gender
+ - {{ participant.gender }}
+
- Ethnicity
- {{ participant.ethnicity }}
@@ -94,7 +123,7 @@ Personal details
- Contact detail
+ Contact details
- Address
@@ -122,24 +151,39 @@ Contact detail
- Email address
- {{ participant.email }}
+
+
+
+
+
+
+ GP details
+
+
+ - Name
+ - {{ participant.gp_practice.name }}
+
- - Special appointment required
- - {{ participant.special_appointment_required }}
+ - Phone number
+ - {{ participant.gp_practice.phone }}
- {% if participant.special_appointment_required == 'Yes' %}
- - Special appointment information
- - {{ participant.special_appointment_information or '-' }}
+
- Address
+ -
+ {{ participant.gp_practice.address.houseNumber }} {{ participant.gp_practice.address.street
+ }}
+ {{ participant.gp_practice.address.town }}
+ {{ participant.gp_practice.address.postcode }}
- {% endif %}
- Current screening episode
+ Screening episodes
+ Current episode
- Stage
@@ -150,13 +194,31 @@ Current screening episode
- Next screening appointment
-
- {% if participant.next_appointment_date != 'Not known' %}{{ participant.next_appointment_date }}
{% endif %}
+ {% if participant.next_appointment_date != 'Not known' %}{{
+ participant.next_appointment_date }} {% endif %}
{{ participant.next_appointment_date_relative }}
+ {% if participant.next_appointment_unit %}
+ {{ participant.next_appointment_unit }}, {{
+ participant.next_appointment_location }}
+ Appointment taking place at {{
+ participant.next_appointment_unit }} in {{ participant.next_appointment_location
+ }}{% endif %}
+
+
+
+ Previous episodes
+
+
+ - Last screened
+ -
+ {{ participant.last_screened_date or '-' }}
+
+
{% endblock %}
\ No newline at end of file
diff --git a/app/views/participants/index.html b/app/views/participants/index.html
index 06e2c3d0..d840ef11 100644
--- a/app/views/participants/index.html
+++ b/app/views/participants/index.html
@@ -4,112 +4,307 @@
{% from "_includes/primary-navigation.html" import primaryNavigation %}
{% block header %}
- {{ primaryNavigation("Participants", serviceName) }}
+{{ primaryNavigation("Batches" if participantsPath == "/batches/participants" else "Participants", serviceName) }}
{% endblock %}
{% block beforeContent %}{% endblock %}
{% block content %}
-
-
-
- {{ pageName }}
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {% for participant in participants %}
-
-
- {{ participant.full_name }}
- {{ participant.age }} years old
- |
- {{ participant.nhs_number }} |
-
- {{ participant.next_test_due_date }}
- {{ participant.next_test_due_date_relative }}
- |
-
- {% if participant.next_appointment_date != 'Not known' %}{{ participant.next_appointment_date }} {% endif %}
- {{ participant.next_appointment_date_relative }}
- |
-
- {% endfor %}
-
-
+
+
+
+
+
+
+
+
+
+
+
- {% if totalPages > 1 %}
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% for participant in participants %}
+
+
+ {{ participant.full_name }}
+ {{ participant.age }} years old
+ |
+ {{ participant.address.postcode }} |
+ {{ participant.nhs_number }} |
+
+ {{ participant.next_test_due_date }}
+ {{ participant.next_test_due_date_relative }}
+ |
+
+ {% if participant.next_appointment_date != 'Not known' %}{{ participant.next_appointment_date }} {% endif
+ %}
+ {{ participant.next_appointment_date_relative }}
+ |
+
+ {% endfor %}
+
+
+
+ {% if totalPages > 1 %}
+
+ {% endif %}
-
+
-{% endblock %}
+{% endblock %}
\ No newline at end of file
From 348bd8bfd9b28dd78299e37955b1a1324c98e61e Mon Sep 17 00:00:00 2001
From: danjohnstonUX <21033686+danjohnstonUX@users.noreply.github.com>
Date: Mon, 5 Oct 2026 09:53:00 +0100
Subject: [PATCH 27/27] updated index
---
app/views/index.html | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/app/views/index.html b/app/views/index.html
index bf4da108..091f5dee 100755
--- a/app/views/index.html
+++ b/app/views/index.html
@@ -49,7 +49,10 @@ September 2026
-
- View participant details
+ View standalone participant details
+
+ -
+ View participant details within a batch
|