Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/assets/images/nhsuk-icon-arrow-down.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions app/assets/images/nhsuk-icon-arrow-up-down.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions app/assets/images/nhsuk-icon-arrow-up.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions app/assets/sass/main.scss
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ a {
}
}

.app-search-criteria dd {
margin-inline-start: 0;
}

@include nhsuk-media-query($from: tablet) {
.app-participants-sort-form {
flex-shrink: 0;
Expand Down
3 changes: 2 additions & 1 deletion app/data/participants.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ const createParticipant = (index, today) => {

return {
participantId: `p${pad(index + 1)}`,
full_name: `${surname.toUpperCase()}, ${firstName}${middleName}`,
full_name: `${firstName} ${middleName} ${surname}`,
display_name: `${surname.toUpperCase()}, ${firstName}`,
surname_sort_value: surname.toUpperCase(),
nhs_number: `999 ${pad((index * 37) % 1000, 3)} ${pad((index * 97 + 1) % 10000, 4)}`,
date_of_birth: formatDate(dateOfBirth),
Expand Down
63 changes: 62 additions & 1 deletion app/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const participantDateFromToday = (today, days) => {
}

const formatParticipantDate = (date) => `${String(date.getDate()).padStart(2, '0')} ${participantMonths[date.getMonth()]} ${date.getFullYear()}`
const formatParticipantIsoDate = (date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
const formatParticipantFullDate = (date) => `${String(date.getDate()).padStart(2, '0')} ${participantFullMonths[date.getMonth()]} ${date.getFullYear()}`

const formatParticipantRelativeDate = (date, today, daysThreshold = 7) => {
Expand Down Expand Up @@ -59,9 +60,11 @@ const refreshParticipantDates = (participant, today, dateFormatter = formatParti
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_iso: formatParticipantIsoDate(nextTestDueDate),
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_iso: nextAppointmentDate ? formatParticipantIsoDate(nextAppointmentDate) : '9999-12-31',
next_appointment_date_relative: nextAppointmentDate ? formatParticipantRelativeDate(nextAppointmentDate, today) : 'Not known'
}
}
Expand Down Expand Up @@ -914,7 +917,65 @@ router.get(['/participants', '/batches/participants'], function (req, res) {
})
})

router.get(['/participants/:participantId', '/batches/participants/:participantId'], function (req, res) {
const participantSearchFields = ['firstName', 'lastName', 'nhsNumber', 'sxNumber', 'dobDay', 'dobMonth', 'dobYear', 'postcode', 'emailAddress']

const participantSearchCriteria = query => Object.fromEntries(participantSearchFields
.map(field => [field, String(query[field] || '').trim()]))

const normaliseSearchValue = value => String(value || '').toLowerCase().replace(/\s+/g, '')

const matchesParticipantSearch = (participant, criteria) => {
const includes = (value, term) => !term || normaliseSearchValue(value).includes(normaliseSearchValue(term))
const [dobDay, dobMonthName, dobYear] = participant.date_of_birth.split(' ')
const dobMonth = participantFullMonths.indexOf(dobMonthName) + 1
const matchesDatePart = (actual, term) => !term || parseInt(term, 10) === parseInt(actual, 10)
const matchesYear = !criteria.dobYear || (criteria.dobYear.length === 2
? dobYear.endsWith(criteria.dobYear)
: parseInt(criteria.dobYear, 10) === parseInt(dobYear, 10))

return includes(participant.full_name.split(', ')[1], criteria.firstName) &&
includes(participant.surname_sort_value, criteria.lastName) &&
includes(participant.nhs_number, criteria.nhsNumber) &&
includes(participant.sx_number, criteria.sxNumber) &&
includes(participant.address.postcode, criteria.postcode) &&
includes(participant.email, criteria.emailAddress) &&
matchesDatePart(dobDay, criteria.dobDay) &&
matchesDatePart(dobMonth, criteria.dobMonth) &&
matchesYear
}

router.get('/participants/search', function (req, res) {
res.render('participants/search', {
participantsPath: '/participants',
criteria: participantSearchCriteria(req.query)
})
})

router.get('/participants/search-results', function (req, res) {
const criteria = participantSearchCriteria(req.query)
const today = new Date()
today.setHours(0, 0, 0, 0)
const participants = ((req.session.data.participants && req.session.data.participants.default) || [])
.filter(participant => matchesParticipantSearch(participant, criteria))
.map(participant => refreshParticipantDates(participant, today))
.sort((a, b) => a.full_name.localeCompare(b.full_name))
const searchQuery = new URLSearchParams(Object.entries(criteria).filter(([, value]) => value)).toString()

res.render('participants/search-results', {
participants,
participantsPath: '/participants',
criteria,
hasCriteria: Boolean(searchQuery),
searchQuery
})
})

router.get(['/participants/:participantId', '/batches/participants/:participantId'], function (req, res, next) {
// Let static pages such as /participants/search fall through to auto-routing
if (!/^p\d+$/.test(req.params.participantId)) {
return next()
}

const participantsPath = req.path.startsWith('/batches/') ? '/batches/participants' : '/participants'
const participants = (req.session.data.participants && req.session.data.participants.default) || []
const today = new Date()
Expand Down
2 changes: 1 addition & 1 deletion app/views/batches/participants/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<div class="nhsuk-grid-row">
<div class="nhsuk-grid-column-full">
<span class="nhsuk-caption-l">Participant details</span>
<h1 class="nhsuk-heading-l">{{ participant.full_name }}</h1>
<h1 class="nhsuk-heading-l">{{ participant.display_name }}</h1>
</div>
</div>

Expand Down
72 changes: 42 additions & 30 deletions app/views/batches/participants/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -99,53 +99,60 @@ <h2 class="nhsuk-card__heading nhsuk-heading-s">Search and filter</h2>
</form>
</div>
<div class="nhsuk-grid-column-three-quarters">
<div class="app-participants-toolbar">
<div class="app-participants-toolbar nhsuk-u-margin-bottom-0">
<p class="nhsuk-hint">Showing <strong>{{ firstRecord }}</strong> to <strong>{{ lastRecord }}</strong> of <strong>{{ totalParticipants }}</strong> participants</p>
<form method="get" action="/batches/participants" class="app-participants-sort-form">
<input type="hidden" name="search" value="{{ search }}">
{% for filter in selectedNextTestDue %}<input type="hidden" name="nextTestDue" value="{{ filter }}">{% endfor %}
{% for filter in selectedNextScreening %}<input type="hidden" name="nextScreening" value="{{ filter }}">{% endfor %}
{% for filter in selectedLastScreened %}<input type="hidden" name="lastScreened" value="{{ filter }}">{% endfor %}
{% for filter in selectedBreachRisk %}<input type="hidden" name="breachRisk" value="{{ filter }}">{% endfor %}
<label class="nhsuk-label" for="participant-sort">Sort by</label>
<select class="nhsuk-select" id="participant-sort" name="sort" onchange="this.form.submit()">
<option value="name-asc"{% if selectedSort == 'name-asc' %} selected{% endif %}>Name (A to Z)</option>
<option value="name-desc"{% if selectedSort == 'name-desc' %} selected{% endif %}>Name (Z to A)</option>
<option value="due-soonest"{% if selectedSort == 'due-soonest' %} selected{% endif %}>Next test due date (soonest)</option>
<option value="due-latest"{% if selectedSort == 'due-latest' %} selected{% endif %}>Next test due date (latest)</option>
<option value="screening-soonest"{% if selectedSort == 'screening-soonest' %} selected{% endif %}>Next screening (soonest)</option>
<option value="screening-latest"{% if selectedSort == 'screening-latest' %} selected{% endif %}>Next screening (latest)</option>
<option value="age-oldest"{% if selectedSort == 'age-oldest' %} selected{% endif %}>Age (oldest)</option>
<option value="age-youngest"{% if selectedSort == 'age-youngest' %} selected{% endif %}>Age (youngest)</option>
</select>
</form>
</div>
<table class="nhsuk-table nhsuk-table-responsive">
<table class="nhsuk-table nhsuk-table-responsive nhsuk-table--striped nhsuk-table--sortable" data-module="nhsuk-table">
<thead class="nhsuk-table__head">
<tr class="nhsuk-table__row">
<th scope="col" class="nhsuk-table__header">Name</th>
<th scope="col" class="nhsuk-table__header">Postcode</th>
<th scope="col" class="nhsuk-table__header">NHS number</th>
<th scope="col" class="nhsuk-table__header">Next test due date</th>
<th scope="col" class="nhsuk-table__header">Next screening</th>
<th scope="col" class="nhsuk-table__header" scope="col" data-sort>
Name
</th>
<th scope="col" class="nhsuk-table__header" scope="col" data-sort>
Postcode
</th>
<th scope="col" class="nhsuk-table__header nhsuk-u-nowrap" scope="col" data-sort>
NHS number
</th>
<th scope="col" class="nhsuk-table__header nhsuk-u-nowrap" aria-sort="ascending" scope="col" data-sort>
Next test due date
</th>
<th scope="col" class="nhsuk-table__header nhsuk-u-nowrap" scope="col" data-sort>
Next screening
</th>
</tr>
</thead>
<tbody class="nhsuk-table__body">
{% for participant in participants %}
<tr class="nhsuk-table__row">
<td class="nhsuk-table__cell"><a href="/batches/participants/{{ participant.participantId }}">{{ participant.full_name }}</a><br><span class="nhsuk-hint">{{ participant.age }} years old</span></td>
<td class="nhsuk-table__cell">
<p class="nhsuk-u-margin-bottom-1 nhsuk-u-font-weight-bold"><a href="{{ participantsPath }}/{{ participant.participantId }}">{{ participant.display_name }}</a></p>
<p class="nhsuk-hint nhsuk-u-margin-bottom-0">{{ participant.age }} years old</p>
</td>
<td class="nhsuk-table__cell">{{ participant.address.postcode }}</td>
<td class="nhsuk-table__cell">{{ participant.nhs_number }}</td>
<td class="nhsuk-table__cell">{{ participant.next_test_due_date }}<br><span class="nhsuk-hint">{{ participant.next_test_due_date_relative }}</span></td>
<td class="nhsuk-table__cell">{% if participant.next_appointment_date != 'Not known' %}{{ participant.next_appointment_date }}<br>{% endif %}<span class="nhsuk-hint">{{ participant.next_appointment_date_relative }}</span></td>
<td class="nhsuk-table__cell" data-sort-value="{{ participant.next_test_due_date_iso }}">
{{ participant.next_test_due_date }}<br>
<span class="nhsuk-hint">{{ participant.next_test_due_date_relative }}</span>
</td>
<td class="nhsuk-table__cell" data-sort-value="{{ participant.next_appointment_date_iso }}">
{% if participant.next_appointment_date != 'Not known' %}{{ participant.next_appointment_date }}<br>{% endif
%}
<span class="nhsuk-hint">{{ participant.next_appointment_date_relative }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if totalPages > 1 %}
<nav class="nhsuk-pagination nhsuk-pagination--numbered" role="navigation" aria-label="Pagination">
{% if currentPage > 1 %}
<a href="/batches/participants?page={{ currentPage - 1 }}&sort={{ selectedSort }}{% if participantFilterQuery %}&{{ participantFilterQuery }}{% endif %}" class="nhsuk-pagination__previous" rel="prev">Previous</a>
<a href="/batches/participants?page={{ currentPage - 1 }}&sort={{ selectedSort }}{% if participantFilterQuery %}&{{ participantFilterQuery }}{% endif %}" class="nhsuk-pagination__previous" rel="prev">
<svg class="nhsuk-icon nhsuk-icon--arrow-left" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" focusable="false" aria-hidden="true">
<path d="M10.7 6.3c.4.4.4 1 0 1.4L7.4 11H19a1 1 0 0 1 0 2H7.4l3.3 3.3c.4.4.4 1 0 1.4a1 1 0 0 1-1.4 0l-5-5A1 1 0 0 1 4 12c0-.3.1-.5.3-.7l5-5a1 1 0 0 1 1.4 0Z" />
</svg>
<span class="nhsuk-pagination__title">Previous<span class="nhsuk-u-visually-hidden"> page</span></span>
</a>
{% endif %}
<ul class="nhsuk-pagination__list">
{% for pageNumber in paginationPages %}
Expand All @@ -156,7 +163,12 @@ <h2 class="nhsuk-card__heading nhsuk-heading-s">Search and filter</h2>
{% endfor %}
</ul>
{% if currentPage < totalPages %}
<a href="/batches/participants?page={{ currentPage + 1 }}&sort={{ selectedSort }}{% if participantFilterQuery %}&{{ participantFilterQuery }}{% endif %}" class="nhsuk-pagination__next" rel="next" aria-label="Next page">Next</a>
<a href="/batches/participants?page={{ currentPage + 1 }}&sort={{ selectedSort }}{% if participantFilterQuery %}&{{ participantFilterQuery }}{% endif %}" class="nhsuk-pagination__next" rel="next" aria-label="Next page">
<span class="nhsuk-pagination__title">Next<span class="nhsuk-u-visually-hidden"> page</span></span>
<svg class="nhsuk-icon nhsuk-icon--arrow-right" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" focusable="false" aria-hidden="true">
<path d="m14.7 6.3 5 5c.2.2.3.4.3.7 0 .3-.1.5-.3.7l-5 5a1 1 0 0 1-1.4-1.4l3.3-3.3H5a1 1 0 0 1 0-2h11.6l-3.3-3.3a1 1 0 1 1 1.4-1.4Z" />
</svg>
</a>
{% endif %}
</nav>
{% endif %}
Expand Down
3 changes: 3 additions & 0 deletions app/views/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ <h2 class="nhsuk-heading-m">September 2026</h2>
<li>
<a href="/batches/participant-breakdown/standard">View participant details within a batch</a>
</li>
<li>
<a href="/participants/search">Search for a participant</a>
</li>
</ol>

<h2 class="nhsuk-heading-m">August &ndash; September 2026</h2>
Expand Down
4 changes: 2 additions & 2 deletions app/views/participants/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

{% from "_includes/primary-navigation.html" import primaryNavigation %}
{% block header %}
{{ primaryNavigation("Batches" if participantsPath == "/batches/participants" else "Participants", serviceName) }}
{{ primaryNavigation("Batches" if participantsPath == "/batches/participants" else "Participants", serviceName, bsoName) }}
{% endblock %}

{% block content %}
Expand All @@ -24,7 +24,7 @@
<div class="nhsuk-grid-column-full">

<span class="nhsuk-caption-l">Participant details</span>
<h1 class="nhsuk-heading-l">{{ participant.full_name }}</h1>
<h1 class="nhsuk-heading-l">{{ participant.display_name }}</h1>
</div>

</div>
Expand Down
Loading
Loading