From 432c8a9295b749db69275d527c1f4fc5b313d73c Mon Sep 17 00:00:00 2001
From: Mat Johnson
Date: Mon, 12 Oct 2026 16:11:38 +0100
Subject: [PATCH 01/22] Sort older work into folder
---
app/routes.js | 10 ++--
app/views/index.html | 48 ++++++++++++-------
.../01-set-timings.html | 0
.../02-organise-slots.html | 0
.../03-prototype-end.html | 0
.../templating-01-listing.html | 0
.../templating-03-name-and-description.html | 0
.../templating-04-check-answers.html | 0
8 files changed, 37 insertions(+), 21 deletions(-)
rename app/views/sessions/{ => z-older-versions}/01-set-timings.html (100%)
rename app/views/sessions/{ => z-older-versions}/02-organise-slots.html (100%)
rename app/views/sessions/{ => z-older-versions}/03-prototype-end.html (100%)
rename app/views/sessions/{ => z-older-versions}/templating-01-listing.html (100%)
rename app/views/sessions/{ => z-older-versions}/templating-03-name-and-description.html (100%)
rename app/views/sessions/{ => z-older-versions}/templating-04-check-answers.html (100%)
diff --git a/app/routes.js b/app/routes.js
index c6b389b..e36dae1 100644
--- a/app/routes.js
+++ b/app/routes.js
@@ -457,7 +457,7 @@ router.get(['/clinics-schedules/06-apply-session', OLDER_VERSIONS_PREFIX + '06-a
const isWholeNumber = str => /^\d+$/.test(str)
-router.post('/sessions/01-set-timings', function (req, res) {
+router.post('/sessions/z-older-versions/01-set-timings', function (req, res) {
const session = (req.body && req.body.newSession) || {}
const startTime = session.startTime || {}
const endTime = session.endTime || {}
@@ -512,7 +512,7 @@ router.post('/sessions/01-set-timings', function (req, res) {
errors
})
}
- res.redirect('/sessions/02-organise-slots')
+ res.redirect('/sessions/z-older-versions/02-organise-slots')
})
router.post(['/clinics-schedules/04-organise-slots', OLDER_VERSIONS_PREFIX + '04-organise-slots'], function (req, res) {
@@ -821,11 +821,11 @@ router.get(['/clinics-schedules/09-prototype-end', OLDER_VERSIONS_PREFIX + '09-p
res.render(clinicsScheduleView(req, '09-prototype-end'))
})
-router.post('/sessions/02-organise-slots', function (req, res) {
+router.post('/sessions/z-older-versions/02-organise-slots', function (req, res) {
if (req.session.data.sessionCreationType === 'new session template') {
- res.redirect('/sessions/templating-03-name-and-description')
+ res.redirect('/sessions/z-older-versions/templating-03-name-and-description')
} else {
- res.redirect('/sessions/03-prototype-end')
+ res.redirect('/sessions/z-older-versions/03-prototype-end')
}
})
diff --git a/app/views/index.html b/app/views/index.html
index 362da9a..199cb39 100755
--- a/app/views/index.html
+++ b/app/views/index.html
@@ -59,8 +59,9 @@ September 2026
- August – September 2026
- Clinics with schedules ideation
+ Clinics and schedules
+ August – September 2026
+ Clinics with schedules
Create a new clinic (containers)
@@ -90,37 +91,50 @@ August – September 2026
-
- July 2026
- Sessions: first run
+
+ May–June 2026
+
+ superceded
+
+
+ Clinics: first run
- Set up session
+ Clinic listing
+
+
+ Clinic detail
- Session templates: iteration 1
+
+
+ Clinic sessions (days)
+ October 2026
+ Session templates: iteration 2
- Template listing
+ Create a new session template
-
- May–June 2026
+ July 2026
+
+ Sessions: first run
superceded
-
-
Clinics: first run
+
- Clinic listing
+ Set up session
- Clinic detail
+ Template listing
- April 2026
+
+ Batches
+ April 2026
Batches: first run
@@ -140,9 +154,11 @@ April 2026
+
+ BSO management
March 2026
- BSO-level building blocks
+ BSO-level building blocks: units, locations, etc
Paused
diff --git a/app/views/sessions/01-set-timings.html b/app/views/sessions/z-older-versions/01-set-timings.html
similarity index 100%
rename from app/views/sessions/01-set-timings.html
rename to app/views/sessions/z-older-versions/01-set-timings.html
diff --git a/app/views/sessions/02-organise-slots.html b/app/views/sessions/z-older-versions/02-organise-slots.html
similarity index 100%
rename from app/views/sessions/02-organise-slots.html
rename to app/views/sessions/z-older-versions/02-organise-slots.html
diff --git a/app/views/sessions/03-prototype-end.html b/app/views/sessions/z-older-versions/03-prototype-end.html
similarity index 100%
rename from app/views/sessions/03-prototype-end.html
rename to app/views/sessions/z-older-versions/03-prototype-end.html
diff --git a/app/views/sessions/templating-01-listing.html b/app/views/sessions/z-older-versions/templating-01-listing.html
similarity index 100%
rename from app/views/sessions/templating-01-listing.html
rename to app/views/sessions/z-older-versions/templating-01-listing.html
diff --git a/app/views/sessions/templating-03-name-and-description.html b/app/views/sessions/z-older-versions/templating-03-name-and-description.html
similarity index 100%
rename from app/views/sessions/templating-03-name-and-description.html
rename to app/views/sessions/z-older-versions/templating-03-name-and-description.html
diff --git a/app/views/sessions/templating-04-check-answers.html b/app/views/sessions/z-older-versions/templating-04-check-answers.html
similarity index 100%
rename from app/views/sessions/templating-04-check-answers.html
rename to app/views/sessions/z-older-versions/templating-04-check-answers.html
From 99c3a2f419fdbca41a26abdd28245bf7ae7adfa7 Mon Sep 17 00:00:00 2001
From: Mat Johnson
Date: Mon, 12 Oct 2026 17:19:37 +0100
Subject: [PATCH 02/22] Sketching in
---
.../create-capacity-from-zero/index.html | 2 +-
app/views/index.html | 2 +-
app/views/sessions/00-template-listing.html | 258 ++++++++++++++++++
app/views/sessions/01-template-name.html | 99 +++++++
app/views/sessions/02-session-times.html | 184 +++++++++++++
app/views/sessions/03-slot-structure.html | 128 +++++++++
6 files changed, 671 insertions(+), 2 deletions(-)
create mode 100644 app/views/sessions/00-template-listing.html
create mode 100644 app/views/sessions/01-template-name.html
create mode 100644 app/views/sessions/02-session-times.html
create mode 100644 app/views/sessions/03-slot-structure.html
diff --git a/app/views/create-capacity-from-zero/index.html b/app/views/create-capacity-from-zero/index.html
index 03ec45e..79cbb64 100644
--- a/app/views/create-capacity-from-zero/index.html
+++ b/app/views/create-capacity-from-zero/index.html
@@ -16,7 +16,7 @@
{% for item in [
{ href: '/create-capacity-from-zero/', label: 'Active clinics', selected: 'true' },
{ href: '#', label: 'Archived clinics' },
- { href: '#', label: 'Manage session templates' }
+ { href: '/sessions/00-template-listing', label: 'Manage session templates' }
] %}
{% set secondaryNavItems = secondaryNavItems | push({
text: item.label | safe,
diff --git a/app/views/index.html b/app/views/index.html
index 199cb39..516a302 100755
--- a/app/views/index.html
+++ b/app/views/index.html
@@ -113,7 +113,7 @@ October 2026
Session templates: iteration 2
- Create a new session template
+ Create a new session template
July 2026
diff --git a/app/views/sessions/00-template-listing.html b/app/views/sessions/00-template-listing.html
new file mode 100644
index 0000000..2985d7a
--- /dev/null
+++ b/app/views/sessions/00-template-listing.html
@@ -0,0 +1,258 @@
+{% extends 'layout.html' %}
+
+{% set pageName = "" %}
+
+{% from "_includes/primary-navigation.html" import primaryNavigation %}
+{% block header %}
+ {{ primaryNavigation("Clinics", serviceName, bsoName) }}
+{% endblock %}
+
+{% block beforeContent %}{% endblock %}
+
+{% block content %}
+
+{% set secondaryNavItems = [] %}
+
+{% for item in [
+ { href: '/clinics/', label: 'Active clinics' },
+ { href: '#', label: 'Archived clinics' },
+ { href: '/sessions/00-template-listing', label: 'Manage session templates', selected: 'true' }
+] %}
+ {% set secondaryNavItems = secondaryNavItems | push({
+ text: item.label | safe,
+ href: item.href | trim,
+ current: true if item.selected == 'true'
+ }) %}
+{% endfor %}
+
+{{ appSecondaryNavigation({
+ visuallyHiddenTitle: "Secondary menu",
+ items: secondaryNavItems
+}) }}
+
+
+
+
+
+
+
+
+ Standard
+
+
+
+
+
+
+
+
+
+ Total capacity
+
+
+ 42 slots
+
+
+
+
+ Time
+
+
+ 09:00 to 17:00
+
+
+
+
+
+
+
+
+ Slot length
+
+
+ 10 minutes
+
+
+
+
+ Total held
+
+
+ 8 slots
+
+
+
+
+ Total cancelled
+
+
+ 6 slots
+
+
+
+
+
+
+
+
+
+
+ Special appointments
+
+
+
+
+
+
+
+
+
+ Total capacity
+
+
+ 42 slots
+
+
+
+
+ Time
+
+
+ 09:00 to 17:00
+
+
+
+
+ Show full template preview
+
+
+
+
+
+
+ Slot length
+
+
+ 10 minutes
+
+
+
+
+ Total held
+
+
+ 42 slots
+
+
+
+
+ Total cancelled
+
+
+ 6 slots
+
+
+
+
+
+
+
+
+
+
+ Rubie default
+
+
+
+
+
+
+
+
+
+ Total capacity
+
+
+ 42 slots
+
+
+
+
+ Time
+
+
+ 09:00 to 17:00
+
+
+
+
+ Show full template preview
+
+
+
+
+
+
+ Slot length
+
+
+ 10 minutes
+
+
+
+
+ Total held
+
+
+ 0 slots
+
+
+
+
+ Total cancelled
+
+
+ 6 slots
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block pageScripts %}{% endblock %}
\ No newline at end of file
diff --git a/app/views/sessions/01-template-name.html b/app/views/sessions/01-template-name.html
new file mode 100644
index 0000000..3bb684b
--- /dev/null
+++ b/app/views/sessions/01-template-name.html
@@ -0,0 +1,99 @@
+{% extends 'layout.html' %}
+
+{% set pageName = "Session templates" %}
+
+{% from "_includes/primary-navigation.html" import primaryNavigation %}
+{% block header %}
+ {{ primaryNavigation("Clinics", serviceName, bsoName) }}
+{% endblock %}
+
+{% block beforeContent %}{% endblock %}
+
+{% block content %}
+
+{% set tickSvg %}
+
+
+
+{% endset %}
+
+
+
+
+
+
+
+
+ Workflow steps
+
+ Create session template
+
+
+
+
+ 1
+ Template name
+
+
+
+
+ 2
+ Session times
+
+
+
+
+ 3
+ Slot structure
+
+
+
+
+ 4
+ Check and confirm
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block pageScripts %}{% endblock %}
\ No newline at end of file
diff --git a/app/views/sessions/02-session-times.html b/app/views/sessions/02-session-times.html
new file mode 100644
index 0000000..ce6c0d3
--- /dev/null
+++ b/app/views/sessions/02-session-times.html
@@ -0,0 +1,184 @@
+{% extends 'layout.html' %}
+
+{% set pageName = "Session templates" %}
+
+{% from "_includes/primary-navigation.html" import primaryNavigation %}
+{% block header %}
+ {{ primaryNavigation("Clinics", serviceName, bsoName) }}
+{% endblock %}
+
+{% block beforeContent %}{% endblock %}
+
+{% block content %}
+
+{% set tickSvg %}
+
+
+
+{% endset %}
+
+
+
+
+
+
+
+
+ Workflow steps
+
+ Create session template
+
+
+
+
+ 1
+ Template name
+
+
+
+
+ 2
+ Session times
+
+
+
+
+ 3
+ Slot structure
+
+
+
+
+ 4
+ Check and confirm
+
+
+
+
+
+
+
+
+
+
+ {% if errorState === "true" %}
+ {% set errorList = [] %}
+ {% if errors.startTime %}
+ {% set errorList = errorList.concat([{ text: errors.startTime, href: "#start-time-hour" }]) %}
+ {% endif %}
+ {% if errors.endTime %}
+ {% set errorList = errorList.concat([{ text: errors.endTime, href: "#end-time-hour" }]) %}
+ {% endif %}
+ {% if errors.duration %}
+ {% set errorList = errorList.concat([{ text: errors.duration, href: "#duration" }]) %}
+ {% endif %}
+ {{ errorSummary({
+ titleText: "There is a problem",
+ errorList: errorList
+ }) }}
+ {% endif %}
+
+
Session times
+
+
+
+
+
+
+
+
+
+ Continue
+
+
+
+
+
+{% endblock %}
+
+{% block pageScripts %}{% endblock %}
\ No newline at end of file
diff --git a/app/views/sessions/03-slot-structure.html b/app/views/sessions/03-slot-structure.html
new file mode 100644
index 0000000..c208796
--- /dev/null
+++ b/app/views/sessions/03-slot-structure.html
@@ -0,0 +1,128 @@
+{% extends 'layout.html' %}
+
+{% set pageName = "Session templates" %}
+
+{% from "_includes/primary-navigation.html" import primaryNavigation %}
+{% block header %}
+ {{ primaryNavigation("Clinics", serviceName, bsoName) }}
+{% endblock %}
+
+{% block beforeContent %}{% endblock %}
+
+{% block content %}
+
+{% set tickSvg %}
+
+
+
+{% endset %}
+
+
+
+
+
+
+
+
+ {% if errorState === "true" %}
+ {% set errorList = [] %}
+ {% if errors.slots %}
+ {% set errorList = errorList.concat([{ text: errors.slots, href: "#computed-slot-0" }]) %}
+ {% endif %}
+ {{ errorSummary({
+ titleText: "There is a problem",
+ errorList: errorList
+ }) }}
+ {% endif %}
+
Slot structure
+
+ Session time
+ {{ sessionStartLabel }} to {{ sessionEndLabel }}
+
+
+
+
+
+
+
+ {% for slot in slots %}
+
+
+
+
+ {{ slot.startLabel }}
+ to {{ slot.endLabel }}
+ {% if slot.isStaffBreak %}
+
+ Staff break
+
+ {% endif %}
+
+
+
+ {% endfor %}
+
+
+
+
+
+
Set selected slots as:
+ Staff break
+
+ Clear slot type
+
+
+
+ {{ bookableSlotCount }} bookable slots
+
+
+ Continue
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block pageScripts %}{% endblock %}
\ No newline at end of file
From 7b7b8470a7aa17b05d40166df7b7b56afe6898dd Mon Sep 17 00:00:00 2001
From: Mat Johnson
Date: Tue, 13 Oct 2026 13:24:24 +0100
Subject: [PATCH 03/22] stuff! including extracting route logic into handlers
to help use the same logic in multiple places
---
.../sass/components/_template-card.scss | 15 +-
app/data/session-data-defaults.js | 3 +
app/routes.js | 6 +-
app/routes/clinic-sessions.js | 65 ++++++
app/routes/mission-1.js | 95 +--------
app/routes/session-times-handlers.js | 51 +++++
app/routes/slot-structure-handlers.js | 48 +++++
app/views/clinics/clinic-detail.html | 74 -------
app/views/clinics/index.html | 7 +-
.../select-session-template.html | 172 ++++++----------
app/views/sessions/00-template-listing.html | 192 +++++++-----------
app/views/sessions/01-template-name.html | 24 ++-
app/views/sessions/02-session-times.html | 13 +-
app/views/sessions/03-slot-structure.html | 1 +
14 files changed, 340 insertions(+), 426 deletions(-)
create mode 100644 app/routes/clinic-sessions.js
create mode 100644 app/routes/session-times-handlers.js
create mode 100644 app/routes/slot-structure-handlers.js
diff --git a/app/assets/sass/components/_template-card.scss b/app/assets/sass/components/_template-card.scss
index 4fdcf44..6af5133 100644
--- a/app/assets/sass/components/_template-card.scss
+++ b/app/assets/sass/components/_template-card.scss
@@ -1,12 +1,17 @@
// Import NHS.UK frontend library
@use "nhsuk-frontend/dist/nhsuk/core" as *;
-.app-template-card {
+.app-session-template-card {
display: flex;
- gap: 2rem;
+ gap: 1.75rem;
+}
- .preview-image {
- width: 100px;
- outline: 1px solid $nhsuk-border-colour;
+.app-session-template-preview-image {
+ min-width: 10%;
+ & > img {
+ display: block;
+ width: 100%;
+ outline: 1px solid $nhsuk-input-border-colour;
+ margin-bottom: nhsuk-spacing(3);
}
}
\ No newline at end of file
diff --git a/app/data/session-data-defaults.js b/app/data/session-data-defaults.js
index 45ef5a4..4559344 100644
--- a/app/data/session-data-defaults.js
+++ b/app/data/session-data-defaults.js
@@ -19,5 +19,8 @@ module.exports = {
schedules: [],
selectedDays: [],
dayTemplates: {}
+ },
+ createSessionTemplate: {
+
}
}
\ No newline at end of file
diff --git a/app/routes.js b/app/routes.js
index e36dae1..2759188 100644
--- a/app/routes.js
+++ b/app/routes.js
@@ -5,10 +5,10 @@ const router = express.Router()
// Isolated September Test (Mission 1) routes
router.use(require('./routes/mission-1'));
-// Isolated create capacity from zero routes
-router.use(require('./routes/create-capacity-from-zero'));
-// Isolated BSO management routes
+//
router.use(require('./routes/bso-management'));
+router.use(require('./routes/create-capacity-from-zero'));
+router.use(require('./routes/clinic-sessions'));
const participantMonths = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
diff --git a/app/routes/clinic-sessions.js b/app/routes/clinic-sessions.js
new file mode 100644
index 0000000..c084767
--- /dev/null
+++ b/app/routes/clinic-sessions.js
@@ -0,0 +1,65 @@
+const express = require('express')
+const router = express.Router()
+const { validateSessionTimes } = require('./session-times-handlers')
+const { buildSessionSlots } = require('./slot-structure-handlers')
+/*
+
+/app/views/sessions/
+
+*/
+
+router.post('/sessions/01-template-name', function (req, res) {
+ const sessionTemplateName = (req.body.sessionTemplateName || '').trim()
+ const sessionTemplateNote = (req.body.sessionTemplateNote || '').trim()
+
+ if (!req.session.data.createSessionTemplate) {
+ req.session.data.createSessionTemplate = {}
+ }
+
+ req.session.data.createSessionTemplate.sessionTemplateName = sessionTemplateName
+ req.session.data.createSessionTemplate.sessionTemplateNote = sessionTemplateNote
+
+ const errors = {}
+
+ if (!sessionTemplateName) {
+ errors.sessionTemplateName = 'Template must be given a name'
+ }
+
+ if (Object.keys(errors).length > 0) {
+ return res.render('sessions/01-template-name', {
+ errors
+ })
+ }
+
+ res.redirect('/sessions/02-session-times')
+})
+
+router.post('/sessions/02-session-times', function (req, res) {
+ const { startTime, endTime, duration, errors } = validateSessionTimes(req.body.newSession)
+
+ if (!req.session.data.createSessionTemplate) {
+ req.session.data.createSessionTemplate = {}
+ }
+
+ req.session.data.createSessionTemplate.startTime = startTime
+ req.session.data.createSessionTemplate.endTime = endTime
+ req.session.data.createSessionTemplate.duration = duration
+
+ if (Object.keys(errors).length > 0) {
+ return res.render('sessions/02-session-times', {
+ errors
+ })
+ }
+
+ res.redirect('/sessions/03-slot-structure')
+})
+
+router.get('/sessions/03-slot-structure', function (req, res) {
+ if (!req.session.data.createSessionTemplate) {
+ req.session.data.createSessionTemplate = {}
+ }
+
+ res.render('sessions/03-slot-structure', buildSessionSlots(req.session.data.createSessionTemplate))
+})
+
+module.exports = router
\ No newline at end of file
diff --git a/app/routes/mission-1.js b/app/routes/mission-1.js
index dcedaa4..8bee93a 100644
--- a/app/routes/mission-1.js
+++ b/app/routes/mission-1.js
@@ -1,5 +1,7 @@
const express = require('express')
const router = express.Router()
+const { validateSessionTimes } = require('./session-times-handlers')
+const { buildSessionSlots } = require('./slot-structure-handlers')
// Routes and functions specific to Mission 1 prototypes
/*
@@ -168,56 +170,14 @@ router.post('/mission-1/current-iteration/create-clinic-rev-1-schedule', functio
// validating clinic session times for 1 day clinic creation
router.post('/mission-1/current-iteration/create-clinic-rev-1-set-timings', function (req, res) {
- const newSession = req.body.newSession || {}
- const startTime = newSession.startTime || {}
- const endTime = newSession.endTime || {}
-
- const startHourStr = (startTime.hour || '').trim()
- const startMinuteStr = (startTime.minute || '').trim()
- const endHourStr = (endTime.hour || '').trim()
- const endMinuteStr = (endTime.minute || '').trim()
- const durationStr = (newSession.duration || '').trim()
-
- const startHour = parseInt(startHourStr, 10)
- const startMinute = parseInt(startMinuteStr, 10)
- const endHour = parseInt(endHourStr, 10)
- const endMinute = parseInt(endMinuteStr, 10)
- const duration = parseInt(durationStr, 10)
-
- const validStartHour = isWholeNumber(startHourStr) && startHour >= 0 && startHour <= 23
- const validStartMinute = isWholeNumber(startMinuteStr) && startMinute >= 0 && startMinute <= 59
- const validEndHour = isWholeNumber(endHourStr) && endHour >= 0 && endHour <= 23
- const validEndMinute = isWholeNumber(endMinuteStr) && endMinute >= 0 && endMinute <= 59
-
- const errors = {}
-
- if (!validStartHour || !validStartMinute) {
- errors.startTime = 'Start time must be entered, in 24 hour format'
- }
-
- if (!validEndHour || !validEndMinute) {
- errors.endTime = 'End time must be entered, in 24 hour format'
- }
-
- if (!isWholeNumber(durationStr) || duration <= 0) {
- errors.duration = 'Slot length must be entered, in minutes'
- }
-
- if (!errors.startTime && !errors.endTime) {
- const startTotalMinutes = startHour * 60 + startMinute
- const endTotalMinutes = endHour * 60 + endMinute
- if (endTotalMinutes <= startTotalMinutes) {
- errors.startTime = 'Start time must be earlier than end time'
- errors.endTime = 'End time must be later than start time'
- }
- }
+ const { startTime, endTime, duration, errors } = validateSessionTimes(req.body.newSession)
if (typeof req.session.data.missionOne !== 'object') {
req.session.data.missionOne = {}
}
req.session.data.missionOne.startTime = startTime
req.session.data.missionOne.endTime = endTime
- req.session.data.missionOne.duration = durationStr
+ req.session.data.missionOne.duration = duration
if (Object.keys(errors).length > 0) {
return res.render('mission-1/current-iteration/create-clinic-rev-1-set-timings', {
@@ -228,53 +188,6 @@ router.post('/mission-1/current-iteration/create-clinic-rev-1-set-timings', func
res.redirect('/mission-1/current-iteration/create-clinic-rev-1-slot-structure')
})
-// Formats a total minutes-from-midnight value as e.g. "10:08"
-const formatClock = (totalMinutes) => {
- const hour = Math.floor(totalMinutes / 60) % 24
- const minute = totalMinutes % 60
- return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
-}
-
-// Builds the slot checkbox data for a clinic session, flagging any slots marked as staff breaks
-const buildSessionSlots = (missionOne) => {
- const startTime = missionOne.startTime || {}
- const endTime = missionOne.endTime || {}
-
- const startMinutes = (parseInt(startTime.hour, 10) || 0) * 60 + (parseInt(startTime.minute, 10) || 0)
- const endMinutes = (parseInt(endTime.hour, 10) || 0) * 60 + (parseInt(endTime.minute, 10) || 0)
- const duration = parseInt(missionOne.duration, 10) || 0
- const staffBreakSlots = (missionOne.staffBreakSlots || []).map(Number).filter(Number.isInteger)
-
- const slots = []
-
- if (duration > 0 && endMinutes > startMinutes) {
- const slotCount = Math.floor((endMinutes - startMinutes) / duration)
-
- for (let i = 0; i < slotCount; i++) {
- const slotStartMinutes = startMinutes + (i * duration)
- slots.push({
- index: i,
- startLabel: formatClock(slotStartMinutes),
- endLabel: formatClock(slotStartMinutes + duration),
- isStaffBreak: staffBreakSlots.includes(i)
- })
- }
- }
-
- const bookableSlotCount = slots.filter((slot) => !slot.isStaffBreak).length
- const staffBreakSlotCount = slots.filter((slot) => slot.isStaffBreak).length
- missionOne.bookableSlotCount = bookableSlotCount
- missionOne.staffBreakSlotCount = staffBreakSlotCount
-
- return {
- slots,
- bookableSlotCount,
- staffBreakSlotCount,
- sessionStartLabel: formatClock(startMinutes),
- sessionEndLabel: formatClock(endMinutes)
- }
-}
-
// computing the real slot structure from the submitted session times for 1 day clinic creation
router.get('/mission-1/current-iteration/create-clinic-rev-1-slot-structure', function (req, res) {
const missionOne = req.session.data.missionOne || {}
diff --git a/app/routes/session-times-handlers.js b/app/routes/session-times-handlers.js
new file mode 100644
index 0000000..8b185f2
--- /dev/null
+++ b/app/routes/session-times-handlers.js
@@ -0,0 +1,51 @@
+const isWholeNumber = str => /^\d+$/.test(str)
+
+// Validates the `newSession` body shared by the session times forms
+const validateSessionTimes = (newSession = {}) => {
+ const startTime = newSession.startTime || {}
+ const endTime = newSession.endTime || {}
+
+ const startHourStr = (startTime.hour || '').trim()
+ const startMinuteStr = (startTime.minute || '').trim()
+ const endHourStr = (endTime.hour || '').trim()
+ const endMinuteStr = (endTime.minute || '').trim()
+ const durationStr = (newSession.duration || '').trim()
+
+ const startHour = parseInt(startHourStr, 10)
+ const startMinute = parseInt(startMinuteStr, 10)
+ const endHour = parseInt(endHourStr, 10)
+ const endMinute = parseInt(endMinuteStr, 10)
+ const duration = parseInt(durationStr, 10)
+
+ const validStartHour = isWholeNumber(startHourStr) && startHour >= 0 && startHour <= 23
+ const validStartMinute = isWholeNumber(startMinuteStr) && startMinute >= 0 && startMinute <= 59
+ const validEndHour = isWholeNumber(endHourStr) && endHour >= 0 && endHour <= 23
+ const validEndMinute = isWholeNumber(endMinuteStr) && endMinute >= 0 && endMinute <= 59
+
+ const errors = {}
+
+ if (!validStartHour || !validStartMinute) {
+ errors.startTime = 'Start time must be entered, in 24 hour format'
+ }
+
+ if (!validEndHour || !validEndMinute) {
+ errors.endTime = 'End time must be entered, in 24 hour format'
+ }
+
+ if (!isWholeNumber(durationStr) || duration <= 0) {
+ errors.duration = 'Slot length must be entered, in minutes'
+ }
+
+ if (!errors.startTime && !errors.endTime) {
+ const startTotalMinutes = startHour * 60 + startMinute
+ const endTotalMinutes = endHour * 60 + endMinute
+ if (endTotalMinutes <= startTotalMinutes) {
+ errors.startTime = 'Start time must be earlier than end time'
+ errors.endTime = 'End time must be later than start time'
+ }
+ }
+
+ return { startTime, endTime, duration: durationStr, errors }
+}
+
+module.exports = { validateSessionTimes }
diff --git a/app/routes/slot-structure-handlers.js b/app/routes/slot-structure-handlers.js
new file mode 100644
index 0000000..2e9140c
--- /dev/null
+++ b/app/routes/slot-structure-handlers.js
@@ -0,0 +1,48 @@
+// Formats a total minutes-from-midnight value as e.g. "10:08"
+const formatClock = (totalMinutes) => {
+ const hour = Math.floor(totalMinutes / 60) % 24
+ const minute = totalMinutes % 60
+ return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
+}
+
+// Builds the slot checkbox data for a session, flagging any slots marked as staff breaks
+const buildSessionSlots = (sessionData) => {
+ const startTime = sessionData.startTime || {}
+ const endTime = sessionData.endTime || {}
+
+ const startMinutes = (parseInt(startTime.hour, 10) || 0) * 60 + (parseInt(startTime.minute, 10) || 0)
+ const endMinutes = (parseInt(endTime.hour, 10) || 0) * 60 + (parseInt(endTime.minute, 10) || 0)
+ const duration = parseInt(sessionData.duration, 10) || 0
+ const staffBreakSlots = (sessionData.staffBreakSlots || []).map(Number).filter(Number.isInteger)
+
+ const slots = []
+
+ if (duration > 0 && endMinutes > startMinutes) {
+ const slotCount = Math.floor((endMinutes - startMinutes) / duration)
+
+ for (let i = 0; i < slotCount; i++) {
+ const slotStartMinutes = startMinutes + (i * duration)
+ slots.push({
+ index: i,
+ startLabel: formatClock(slotStartMinutes),
+ endLabel: formatClock(slotStartMinutes + duration),
+ isStaffBreak: staffBreakSlots.includes(i)
+ })
+ }
+ }
+
+ const bookableSlotCount = slots.filter((slot) => !slot.isStaffBreak).length
+ const staffBreakSlotCount = slots.filter((slot) => slot.isStaffBreak).length
+ sessionData.bookableSlotCount = bookableSlotCount
+ sessionData.staffBreakSlotCount = staffBreakSlotCount
+
+ return {
+ slots,
+ bookableSlotCount,
+ staffBreakSlotCount,
+ sessionStartLabel: formatClock(startMinutes),
+ sessionEndLabel: formatClock(endMinutes)
+ }
+}
+
+module.exports = { formatClock, buildSessionSlots }
diff --git a/app/views/clinics/clinic-detail.html b/app/views/clinics/clinic-detail.html
index cfcf291..da4b7e7 100644
--- a/app/views/clinics/clinic-detail.html
+++ b/app/views/clinics/clinic-detail.html
@@ -61,80 +61,6 @@ Schedules
-
-
-
-
-
-
-
-
-
-
-
-
- Chichester - St Richards
- HWO-NNN-standard-20260501
-
-
- Alpha van
- St Richard’s Hospital, Chichester
-
-
- 31 August 2026 to 9 October 2026
-
-
- 1500 slots available
- of which 150 held
-
-
-
-
-
- Chichester - St Richards
- HWO-NNN-standard-20260501
-
-
- Alpha van
- St Richard’s Hospital, Chichester
-
-
- No active schedules
-
-
- 1500 slots available
- of which 150 held
-
-
-
-
-
-
-
diff --git a/app/views/clinics/index.html b/app/views/clinics/index.html
index ad8231f..155c633 100644
--- a/app/views/clinics/index.html
+++ b/app/views/clinics/index.html
@@ -16,7 +16,7 @@
{% for item in [
{ href: '/clinics/', label: 'Active clinics', selected: 'true' },
{ href: '#', label: 'Archived clinics' },
- { href: '/sessions/templating-01-listing', label: 'Manage session templates' }
+ { href: '/sessions/00-template-listing', label: 'Manage session templates' }
] %}
{% set secondaryNavItems = secondaryNavItems | push({
text: item.label | safe,
@@ -46,7 +46,7 @@
Active clinics
-
+
- 1500 slots available
- of which 150 held
+ 0 slots available
diff --git a/app/views/create-capacity-from-zero/select-session-template.html b/app/views/create-capacity-from-zero/select-session-template.html
index a419a9a..b449ca7 100644
--- a/app/views/create-capacity-from-zero/select-session-template.html
+++ b/app/views/create-capacity-from-zero/select-session-template.html
@@ -14,15 +14,15 @@
{% endblock %}
{% block content %}
-
-
-
Choose session template
+
+
-
+
-
- Standard
+
+ Standard day
+ This is where a short note would go
@@ -31,9 +31,10 @@
-
-
-
+
+
+
+
Full preview
@@ -41,47 +42,53 @@
Total capacity
-
+
42 slots
-
- Time
+
+ Session times
-
+
09:00 to 17:00
+
+
+ Slot length
+
+
+ 10 minutes
+
+
-
- Show full template preview
-
-
+ Slot breakdown
+
- Slot length
+ Open slots
-
- 10 minutes
+
+ 32 total
-
- Total held
+
+ Held slots
-
- 8 slots
+
+ 4 total
-
- Total cancelled
+
+ Unavailable to book
-
- 6 slots
+
+ 6 total
@@ -89,10 +96,11 @@
-
+
-
+
Special appointments
+ 18 double appointment slots
@@ -101,9 +109,10 @@
-
-
-
+
+
+
+
Full preview
@@ -111,117 +120,53 @@
Total capacity
-
+
42 slots
-
- Time
+
+ Session times
-
+
09:00 to 17:00
-
-
- Show full template preview
-
-
-
-
Slot length
-
+
10 minutes
-
-
- Total held
-
-
- 42 slots
-
-
-
-
- Total cancelled
-
-
- 6 slots
-
-
-
-
-
-
-
-
- Rubie default
-
-
-
-
- Select this template
-
-
-
-
-
-
-
+
Slot breakdown
- Total capacity
-
-
- 42 slots
-
-
-
-
- Time
+ Open slots
-
- 09:00 to 17:00
+
+ 0
-
-
- Show full template preview
-
-
-
-
- Slot length
+ Held slots
-
- 10 minutes
+
+ 36 total
-
- Total held
-
-
- 0 slots
-
-
-
-
- Total cancelled
+
+ Unavailable to book
-
- 6 slots
+
+ 6 total
@@ -229,7 +174,6 @@
-
Create new session template
diff --git a/app/views/sessions/00-template-listing.html b/app/views/sessions/00-template-listing.html
index 2985d7a..068c222 100644
--- a/app/views/sessions/00-template-listing.html
+++ b/app/views/sessions/00-template-listing.html
@@ -38,13 +38,20 @@
Session templates
-
-
- Standard
-
-
-
-
+
+
+
+ Standard day
+ This is where a short note would go
+
+
+ Edit this template
+
+
+
+
+
+
Full preview
@@ -52,134 +59,74 @@
Total capacity
-
+
42 slots
-
- Time
+
+ Session times
-
+
09:00 to 17:00
-
-
-
-
Slot length
-
+
10 minutes
-
-
- Total held
-
-
- 8 slots
-
-
-
-
- Total cancelled
-
-
- 6 slots
-
-
-
-
-
-
-
- Special appointments
-
-
-
-
-
-
+
Slot breakdown
- Total capacity
+ Open slots
-
- 42 slots
+
+ 32 total
-
-
- Time
-
-
- 09:00 to 17:00
-
-
-
-
- Show full template preview
-
-
-
-
- Slot length
+ Held slots
-
- 10 minutes
+
+ 4 total
-
- Total held
-
-
- 42 slots
-
-
-
-
- Total cancelled
+
+ Unavailable to book
-
- 6 slots
+
+ 6 total
-
-
-
- Rubie default
-
-
-
-
+
+
+
+ Special appointments
+ 18 double appointment slots
+
+
+ Edit this template
+
+
+
+
+
+
Full preview
@@ -187,58 +134,57 @@
Total capacity
-
+
42 slots
-
- Time
+
+ Session times
-
+
09:00 to 17:00
+
+
+ Slot length
+
+
+ 10 minutes
+
+
-
- Show full template preview
-
-
+ Slot breakdown
+
- Slot length
+ Open slots
-
- 10 minutes
+
+ 0
-
- Total held
+
+ Held slots
-
- 0 slots
+
+ 36 total
-
- Total cancelled
+
+ Unavailable to book
-
- 6 slots
+
+ 6 total
-
diff --git a/app/views/sessions/01-template-name.html b/app/views/sessions/01-template-name.html
index 3bb684b..ce1e315 100644
--- a/app/views/sessions/01-template-name.html
+++ b/app/views/sessions/01-template-name.html
@@ -1,6 +1,7 @@
{% extends 'layout.html' %}
{% set pageName = "Session templates" %}
+{% set errorState = "true" if errors else "false" %}
{% from "_includes/primary-navigation.html" import primaryNavigation %}
{% block header %}
@@ -61,7 +62,18 @@
-
+ {% if errorState === "true" %}
+ {% set errorList = [] %}
+ {% if errors.sessionTemplateName %}
+ {% set errorList = errorList.concat([{ text: errors.sessionTemplateName, href: "#session-template-name" }]) %}
+ {% endif %}
+ {{ errorSummary({
+ titleText: "There is a problem",
+ errorList: errorList
+ }) }}
+ {% endif %}
+
+
@@ -71,20 +83,20 @@
{% if errorState === "true" %}
- Error: Template must be given a name
+ Error: {{ errors.sessionTemplateName }}
{% endif %}
-
+
Continue
diff --git a/app/views/sessions/02-session-times.html b/app/views/sessions/02-session-times.html
index ce6c0d3..a70d96b 100644
--- a/app/views/sessions/02-session-times.html
+++ b/app/views/sessions/02-session-times.html
@@ -1,6 +1,7 @@
{% extends 'layout.html' %}
{% set pageName = "Session templates" %}
+{% set errorState = "true" if errors else "false" %}
{% from "_includes/primary-navigation.html" import primaryNavigation %}
{% block header %}
@@ -30,7 +31,7 @@
-
+
1
Template name
@@ -103,7 +104,7 @@ Session times
Hour
+ name="newSession[startTime][hour]" type="text" inputmode="numeric" value="{{ data.createSessionTemplate.startTime.hour }}">
@@ -113,7 +114,7 @@ Session times
Minute
+ name="newSession[startTime][minute]" type="text" inputmode="numeric" value="{{ data.createSessionTemplate.startTime.minute }}">
@@ -141,7 +142,7 @@
Session times
Hour
+ name="newSession[endTime][hour]" type="text" inputmode="numeric" value="{{ data.createSessionTemplate.endTime.hour }}">
@@ -150,7 +151,7 @@
Session times
Minute
+ name="newSession[endTime][minute]" type="text" inputmode="numeric" value="{{ data.createSessionTemplate.endTime.minute }}">
@@ -168,7 +169,7 @@
Session times
{% endif %}
diff --git a/app/views/sessions/03-slot-structure.html b/app/views/sessions/03-slot-structure.html
index c208796..970bf5e 100644
--- a/app/views/sessions/03-slot-structure.html
+++ b/app/views/sessions/03-slot-structure.html
@@ -1,6 +1,7 @@
{% extends 'layout.html' %}
{% set pageName = "Session templates" %}
+{% set errorState = "true" if errors else "false" %}
{% from "_includes/primary-navigation.html" import primaryNavigation %}
{% block header %}
From 1865875e49e7e25ab695c84ea6699deee9442cf3 Mon Sep 17 00:00:00 2001
From: Mat Johnson
Date: Tue, 13 Oct 2026 16:22:26 +0100
Subject: [PATCH 04/22] First whole run at session templates
---
.../sass/components/_session-slots.scss | 5 +
app/data/session-data-defaults.js | 3 +-
app/routes/clinic-sessions.js | 66 +++++-
app/routes/mission-1.js | 49 +----
app/routes/slot-structure-handlers.js | 193 +++++++++++++++++-
.../clinics-schedules/04-organise-slots.html | 2 +-
.../z-older-versions/04-organise-slots.html | 2 +-
.../z-older-versions/02-organise-slots.html | 2 +-
.../clinic-summary-in-progress.html | 2 +-
.../clinic-summary.html | 2 +-
.../new-schedule-empty.html | 2 +-
.../new-schedule-ready.html | 2 +-
.../create-clinic-rev-1-slot-structure.html | 2 +-
app/views/sessions/00-template-listing.html | 134 ++++++++++--
app/views/sessions/01-template-name.html | 4 +-
app/views/sessions/02-session-times.html | 4 +-
app/views/sessions/03-slot-structure.html | 72 +++++--
app/views/sessions/04-check-template.html | 123 +++++++++++
app/views/sessions/day-slot-view.html | 2 +-
.../z-older-versions/02-organise-slots.html | 2 +-
app/views/z-sketching/calc-2.html | 2 +-
21 files changed, 572 insertions(+), 103 deletions(-)
create mode 100644 app/views/sessions/04-check-template.html
diff --git a/app/assets/sass/components/_session-slots.scss b/app/assets/sass/components/_session-slots.scss
index 9434a72..239ae99 100644
--- a/app/assets/sass/components/_session-slots.scss
+++ b/app/assets/sass/components/_session-slots.scss
@@ -1,5 +1,10 @@
@use "nhsuk-frontend/dist/nhsuk/core" as *;
+.app-side-panel {
+ position: sticky;
+ top: 1em;
+}
+
.slot {
background-color: nhsuk-colour("white");
border: 1px solid $nhsuk-input-border-colour;
diff --git a/app/data/session-data-defaults.js b/app/data/session-data-defaults.js
index 4559344..9cb2596 100644
--- a/app/data/session-data-defaults.js
+++ b/app/data/session-data-defaults.js
@@ -22,5 +22,6 @@ module.exports = {
},
createSessionTemplate: {
- }
+ },
+ sessionTemplates: []
}
\ No newline at end of file
diff --git a/app/routes/clinic-sessions.js b/app/routes/clinic-sessions.js
index c084767..0b8e624 100644
--- a/app/routes/clinic-sessions.js
+++ b/app/routes/clinic-sessions.js
@@ -1,7 +1,14 @@
const express = require('express')
const router = express.Router()
const { validateSessionTimes } = require('./session-times-handlers')
-const { buildSessionSlots } = require('./slot-structure-handlers')
+const {
+ buildSessionSlots,
+ staffBreakHandler,
+ holdHandler,
+ specialAppointmentHandler,
+ combineSlotsHandler,
+ divideSlotsHandler
+} = require('./slot-structure-handlers')
/*
/app/views/sessions/
@@ -62,4 +69,61 @@ router.get('/sessions/03-slot-structure', function (req, res) {
res.render('sessions/03-slot-structure', buildSessionSlots(req.session.data.createSessionTemplate))
})
+router.post('/sessions/03-slot-structure', function (req, res) {
+ if (!req.session.data.createSessionTemplate) {
+ req.session.data.createSessionTemplate = {}
+ }
+
+ // Refreshes the stored slot counts for the check page
+ buildSessionSlots(req.session.data.createSessionTemplate)
+
+ res.redirect('/sessions/04-check-template')
+})
+
+router.post('/sessions/save-template', function (req, res) {
+ const template = req.session.data.createSessionTemplate || {}
+ const { bookableSlotCount, staffBreakSlotCount, sessionStartLabel, sessionEndLabel } = buildSessionSlots(template)
+ const standard = (template.bookableBreakdown || []).find((item) => item.label === 'Standard')
+ const openSlotCount = standard ? standard.count : 0
+
+ if (!Array.isArray(req.session.data.sessionTemplates)) {
+ req.session.data.sessionTemplates = []
+ }
+
+ req.session.data.sessionTemplates.push({
+ name: template.sessionTemplateName,
+ note: template.sessionTemplateNote,
+ sessionStartLabel,
+ sessionEndLabel,
+ duration: template.duration,
+ totalSlotCount: bookableSlotCount + staffBreakSlotCount,
+ openSlotCount,
+ heldSlotCount: bookableSlotCount - openSlotCount,
+ unavailableSlotCount: staffBreakSlotCount
+ })
+
+ req.session.data.createSessionTemplate = {}
+ req.session.data.sessionTemplateCreated = true
+
+ res.redirect('/sessions/00-template-listing')
+})
+
+// Shows the success banner once, straight after a template is saved
+router.get('/sessions/00-template-listing', function (req, res) {
+ const templateCreated = Boolean(req.session.data.sessionTemplateCreated)
+ delete req.session.data.sessionTemplateCreated
+
+ res.render('sessions/00-template-listing', { templateCreated })
+})
+
+const sessionsSlotStructure = { dataKey: 'createSessionTemplate', view: 'sessions/03-slot-structure' }
+router.post('/sessions/mark-staff-break', staffBreakHandler({ action: 'mark', ...sessionsSlotStructure }))
+router.post('/sessions/clear-slot-type', staffBreakHandler({ action: 'clear', ...sessionsSlotStructure }))
+router.post('/sessions/mark-hold-rebooking', holdHandler({ reason: 'rebooking', ...sessionsSlotStructure }))
+router.post('/sessions/mark-hold-web-rebooking', holdHandler({ reason: 'web rebooking', ...sessionsSlotStructure }))
+router.post('/sessions/mark-hold-technical-recall', holdHandler({ reason: 'technical recall', ...sessionsSlotStructure }))
+router.post('/sessions/mark-special-appointment', specialAppointmentHandler(sessionsSlotStructure))
+router.post('/sessions/combine-slots', combineSlotsHandler(sessionsSlotStructure))
+router.post('/sessions/divide-slots', divideSlotsHandler(sessionsSlotStructure))
+
module.exports = router
\ No newline at end of file
diff --git a/app/routes/mission-1.js b/app/routes/mission-1.js
index 8bee93a..53b5362 100644
--- a/app/routes/mission-1.js
+++ b/app/routes/mission-1.js
@@ -1,7 +1,7 @@
const express = require('express')
const router = express.Router()
const { validateSessionTimes } = require('./session-times-handlers')
-const { buildSessionSlots } = require('./slot-structure-handlers')
+const { buildSessionSlots, staffBreakHandler } = require('./slot-structure-handlers')
// Routes and functions specific to Mission 1 prototypes
/*
@@ -195,50 +195,9 @@ router.get('/mission-1/current-iteration/create-clinic-rev-1-slot-structure', fu
res.render('mission-1/current-iteration/create-clinic-rev-1-slot-structure', buildSessionSlots(missionOne))
})
-// marking the selected slots as staff breaks for 1 day clinic creation
-router.post('/mission-1/current-iteration/mark-staff-break', function (req, res) {
- const missionOne = req.session.data.missionOne || {}
- // nhsuk-frontend's checkboxes component submits an "_unchecked" sentinel for every untoggled box, so filter down to real indices
- const selectedSlots = [].concat(req.body.computedSlots || []).map(Number).filter(Number.isInteger)
-
- const errors = {}
- if (selectedSlots.length === 0) {
- errors.slots = 'Select at least one slot'
- }
-
- if (Object.keys(errors).length > 0) {
- return res.render('mission-1/current-iteration/create-clinic-rev-1-slot-structure', Object.assign({ errors }, buildSessionSlots(missionOne)))
- }
-
- const staffBreakSlots = new Set((missionOne.staffBreakSlots || []).map(Number).filter(Number.isInteger))
- selectedSlots.forEach((index) => staffBreakSlots.add(index))
- missionOne.staffBreakSlots = Array.from(staffBreakSlots)
- req.session.data.missionOne = missionOne
-
- res.redirect('/mission-1/current-iteration/create-clinic-rev-1-slot-structure')
-})
-
-// clearing staff break status from the selected slots for 1 day clinic creation
-router.post('/mission-1/current-iteration/clear-slot-type', function (req, res) {
- const missionOne = req.session.data.missionOne || {}
- // nhsuk-frontend's checkboxes component submits an "_unchecked" sentinel for every untoggled box, so filter down to real indices
- const selectedSlots = [].concat(req.body.computedSlots || []).map(Number).filter(Number.isInteger)
-
- const errors = {}
- if (selectedSlots.length === 0) {
- errors.slots = 'Select at least one slot'
- }
-
- if (Object.keys(errors).length > 0) {
- return res.render('mission-1/current-iteration/create-clinic-rev-1-slot-structure', Object.assign({ errors }, buildSessionSlots(missionOne)))
- }
-
- const selectedSet = new Set(selectedSlots)
- missionOne.staffBreakSlots = (missionOne.staffBreakSlots || []).map(Number).filter(Number.isInteger).filter((index) => !selectedSet.has(index))
- req.session.data.missionOne = missionOne
-
- res.redirect('/mission-1/current-iteration/create-clinic-rev-1-slot-structure')
-})
+const missionOneSlotStructure = { dataKey: 'missionOne', view: 'mission-1/current-iteration/create-clinic-rev-1-slot-structure' }
+router.post('/mission-1/current-iteration/mark-staff-break', staffBreakHandler({ action: 'mark', ...missionOneSlotStructure }))
+router.post('/mission-1/current-iteration/clear-slot-type', staffBreakHandler({ action: 'clear', ...missionOneSlotStructure }))
// refreshing slot counts before the check and confirm step for 1 day clinic creation
router.post('/mission-1/current-iteration/create-clinic-rev-1-publish-check', function (req, res) {
diff --git a/app/routes/slot-structure-handlers.js b/app/routes/slot-structure-handlers.js
index 2e9140c..b9e94c7 100644
--- a/app/routes/slot-structure-handlers.js
+++ b/app/routes/slot-structure-handlers.js
@@ -14,28 +14,61 @@ const buildSessionSlots = (sessionData) => {
const endMinutes = (parseInt(endTime.hour, 10) || 0) * 60 + (parseInt(endTime.minute, 10) || 0)
const duration = parseInt(sessionData.duration, 10) || 0
const staffBreakSlots = (sessionData.staffBreakSlots || []).map(Number).filter(Number.isInteger)
+ const heldSlots = sessionData.heldSlots || {}
+ const specialAppointmentSlots = (sessionData.specialAppointmentSlots || []).map(Number).filter(Number.isInteger)
+ const combinedSlots = sessionData.combinedSlots || []
const slots = []
if (duration > 0 && endMinutes > startMinutes) {
const slotCount = Math.floor((endMinutes - startMinutes) / duration)
- for (let i = 0; i < slotCount; i++) {
- const slotStartMinutes = startMinutes + (i * duration)
+ // Combined slots are stored as inclusive { start, end } ranges of base slot indices and render as one slot keyed by `start`
+ for (let i = 0; i < slotCount;) {
+ const range = combinedSlots.find((r) => r.start === i && r.end < slotCount)
+ const lastIndex = range ? range.end : i
slots.push({
index: i,
- startLabel: formatClock(slotStartMinutes),
- endLabel: formatClock(slotStartMinutes + duration),
- isStaffBreak: staffBreakSlots.includes(i)
+ startLabel: formatClock(startMinutes + (i * duration)),
+ endLabel: formatClock(startMinutes + ((lastIndex + 1) * duration)),
+ isCombined: Boolean(range),
+ span: lastIndex - i + 1,
+ isStaffBreak: staffBreakSlots.includes(i),
+ isHold: Boolean(heldSlots[i]),
+ holdReason: heldSlots[i],
+ isSpecialAppointment: specialAppointmentSlots.includes(i)
})
+ i = lastIndex + 1
}
}
- const bookableSlotCount = slots.filter((slot) => !slot.isStaffBreak).length
- const staffBreakSlotCount = slots.filter((slot) => slot.isStaffBreak).length
+ // Counts are in base slots, so combining never changes them
+ const sumSpans = (list) => list.reduce((total, slot) => total + slot.span, 0)
+ const bookableSlotCount = sumSpans(slots.filter((slot) => !slot.isStaffBreak))
+ const staffBreakSlotCount = sumSpans(slots.filter((slot) => slot.isStaffBreak))
sessionData.bookableSlotCount = bookableSlotCount
sessionData.staffBreakSlotCount = staffBreakSlotCount
+ const slotTypeLabel = (slot) => {
+ if (slot.isStaffBreak) return 'Staff break'
+ if (slot.isHold) return `Held for ${slot.holdReason}`
+ if (slot.isSpecialAppointment) return 'Special appointment'
+ return 'Standard'
+ }
+ // Returns [{ label, count }] in first-seen order, counted in base slots
+ const breakdown = (list) => list.reduce((items, slot) => {
+ const label = slotTypeLabel(slot)
+ const item = items.find((existing) => existing.label === label)
+ if (item) {
+ item.count += slot.span
+ } else {
+ items.push({ label, count: slot.span })
+ }
+ return items
+ }, [])
+ sessionData.bookableBreakdown = breakdown(slots.filter((slot) => !slot.isStaffBreak))
+ sessionData.nonBookableBreakdown = breakdown(slots.filter((slot) => slot.isStaffBreak))
+
return {
slots,
bookableSlotCount,
@@ -45,4 +78,148 @@ const buildSessionSlots = (sessionData) => {
}
}
-module.exports = { formatClock, buildSessionSlots }
+// Builds a POST handler that validates the selected slots, applies `update` to them, then returns to the slot structure view
+const slotUpdateHandler = ({ dataKey, view, update }) => function (req, res) {
+ if (typeof req.session.data[dataKey] !== 'object') {
+ req.session.data[dataKey] = {}
+ }
+ const sessionData = req.session.data[dataKey]
+ // nhsuk-frontend's checkboxes component submits an "_unchecked" sentinel for every untoggled box, so filter down to real indices
+ const selectedSlots = [].concat(req.body.computedSlots || []).map(Number).filter(Number.isInteger)
+
+ if (selectedSlots.length === 0) {
+ const errors = { slots: 'Select at least one slot' }
+ return res.render(view, Object.assign({ errors }, buildSessionSlots(sessionData)))
+ }
+
+ const tags = {
+ staffBreakSlots: new Set((sessionData.staffBreakSlots || []).map(Number).filter(Number.isInteger)),
+ heldSlots: Object.assign({}, sessionData.heldSlots),
+ specialAppointmentSlots: new Set((sessionData.specialAppointmentSlots || []).map(Number).filter(Number.isInteger))
+ }
+ selectedSlots.forEach((index) => {
+ clearTags(index, tags)
+ update(index, tags)
+ })
+ sessionData.staffBreakSlots = Array.from(tags.staffBreakSlots)
+ sessionData.heldSlots = tags.heldSlots
+ sessionData.specialAppointmentSlots = Array.from(tags.specialAppointmentSlots)
+
+ res.redirect('/' + view)
+}
+
+// A slot carries at most one tag, so every update starts by clearing existing ones
+const clearTags = (index, tags) => {
+ tags.staffBreakSlots.delete(index)
+ delete tags.heldSlots[index]
+ tags.specialAppointmentSlots.delete(index)
+}
+
+// 'mark' tags the selected slots as staff breaks; 'clear' removes all tags from them
+const staffBreakHandler = ({ action, dataKey, view }) => slotUpdateHandler({
+ dataKey,
+ view,
+ update: (index, tags) => {
+ if (action === 'mark') {
+ tags.staffBreakSlots.add(index)
+ }
+ }
+})
+
+// Holds the selected slots for `reason`
+const holdHandler = ({ reason, dataKey, view }) => slotUpdateHandler({
+ dataKey,
+ view,
+ update: (index, tags) => {
+ tags.heldSlots[index] = reason
+ }
+})
+
+const specialAppointmentHandler = ({ dataKey, view }) => slotUpdateHandler({
+ dataKey,
+ view,
+ update: (index, tags) => {
+ tags.specialAppointmentSlots.add(index)
+ }
+})
+
+const getSessionData = (req, dataKey) => {
+ if (typeof req.session.data[dataKey] !== 'object') {
+ req.session.data[dataKey] = {}
+ }
+ return req.session.data[dataKey]
+}
+
+// nhsuk-frontend's checkboxes component submits an "_unchecked" sentinel for every untoggled box, so filter down to real indices
+const getSelectedSlots = (req) =>
+ Array.from(new Set([].concat(req.body.computedSlots || []).map(Number).filter(Number.isInteger))).sort((a, b) => a - b)
+
+const renderWithError = (res, view, sessionData, message) =>
+ res.render(view, Object.assign({ errors: { slots: message } }, buildSessionSlots(sessionData)))
+
+// Merges adjacent selected slots into one; the combined slot keeps the first selected slot's tag
+const combineSlotsHandler = ({ dataKey, view }) => function (req, res) {
+ const sessionData = getSessionData(req, dataKey)
+ const selectedSlots = getSelectedSlots(req)
+
+ if (selectedSlots.length < 2) {
+ return renderWithError(res, view, sessionData, 'Select at least 2 adjacent slots to combine')
+ }
+
+ const displayedSlots = buildSessionSlots(sessionData).slots
+ const positions = selectedSlots.map((index) => displayedSlots.findIndex((slot) => slot.index === index))
+ const isAdjacent = positions.every((position, i) => position !== -1 && (i === 0 || position === positions[i - 1] + 1))
+
+ if (!isAdjacent) {
+ return renderWithError(res, view, sessionData, 'Slots to combine must be next to each other')
+ }
+
+ const existingRanges = sessionData.combinedSlots || []
+ const start = selectedSlots[0]
+ const lastSelected = selectedSlots[selectedSlots.length - 1]
+ const lastRange = existingRanges.find((r) => r.start === lastSelected)
+ const end = lastRange ? lastRange.end : lastSelected
+
+ sessionData.combinedSlots = existingRanges
+ .filter((r) => r.start < start || r.start > end)
+ .concat({ start, end })
+ .sort((a, b) => a.start - b.start)
+
+ const isAbsorbed = (index) => index > start && index <= end
+ sessionData.staffBreakSlots = (sessionData.staffBreakSlots || []).map(Number).filter((index) => !isAbsorbed(index))
+ sessionData.specialAppointmentSlots = (sessionData.specialAppointmentSlots || []).map(Number).filter((index) => !isAbsorbed(index))
+ sessionData.heldSlots = Object.fromEntries(Object.entries(sessionData.heldSlots || {}).filter(([index]) => !isAbsorbed(Number(index))))
+
+ res.redirect('/' + view)
+}
+
+// Splits selected combined slots back into their original slots; any tag stays on the first slot
+const divideSlotsHandler = ({ dataKey, view }) => function (req, res) {
+ const sessionData = getSessionData(req, dataKey)
+ const selectedSlots = getSelectedSlots(req)
+
+ if (selectedSlots.length === 0) {
+ return renderWithError(res, view, sessionData, 'Select at least one slot')
+ }
+
+ const existingRanges = sessionData.combinedSlots || []
+ const remainingRanges = existingRanges.filter((r) => !selectedSlots.includes(r.start))
+
+ if (remainingRanges.length === existingRanges.length) {
+ return renderWithError(res, view, sessionData, 'Select at least one combined slot to divide')
+ }
+
+ sessionData.combinedSlots = remainingRanges
+
+ res.redirect('/' + view)
+}
+
+module.exports = {
+ formatClock,
+ buildSessionSlots,
+ staffBreakHandler,
+ holdHandler,
+ specialAppointmentHandler,
+ combineSlotsHandler,
+ divideSlotsHandler
+}
diff --git a/app/views/clinics-schedules/04-organise-slots.html b/app/views/clinics-schedules/04-organise-slots.html
index 11d5ec1..de5f5cf 100644
--- a/app/views/clinics-schedules/04-organise-slots.html
+++ b/app/views/clinics-schedules/04-organise-slots.html
@@ -150,7 +150,7 @@
-
+
With selected:
Hold
diff --git a/app/views/clinics-schedules/z-older-versions/04-organise-slots.html b/app/views/clinics-schedules/z-older-versions/04-organise-slots.html
index 11d5ec1..de5f5cf 100644
--- a/app/views/clinics-schedules/z-older-versions/04-organise-slots.html
+++ b/app/views/clinics-schedules/z-older-versions/04-organise-slots.html
@@ -150,7 +150,7 @@
-
+
With selected:
Hold
diff --git a/app/views/clinics/z-older-versions/02-organise-slots.html b/app/views/clinics/z-older-versions/02-organise-slots.html
index bc6642d..f2bae8b 100644
--- a/app/views/clinics/z-older-versions/02-organise-slots.html
+++ b/app/views/clinics/z-older-versions/02-organise-slots.html
@@ -147,7 +147,7 @@
-
+
With selected:
Hold
diff --git a/app/views/create-capacity-from-zero/clinic-summary-in-progress.html b/app/views/create-capacity-from-zero/clinic-summary-in-progress.html
index 4bcfa9f..e444b5f 100644
--- a/app/views/create-capacity-from-zero/clinic-summary-in-progress.html
+++ b/app/views/create-capacity-from-zero/clinic-summary-in-progress.html
@@ -724,7 +724,7 @@
Add a new schedule
-
+
With selected days:
Assign session template
diff --git a/app/views/create-capacity-from-zero/clinic-summary.html b/app/views/create-capacity-from-zero/clinic-summary.html
index 54ec6a5..5724c3a 100644
--- a/app/views/create-capacity-from-zero/clinic-summary.html
+++ b/app/views/create-capacity-from-zero/clinic-summary.html
@@ -80,7 +80,7 @@
-
+
With selected days:
diff --git a/app/views/create-capacity-from-zero/new-schedule-empty.html b/app/views/create-capacity-from-zero/new-schedule-empty.html
index 076707e..ab18b87 100644
--- a/app/views/create-capacity-from-zero/new-schedule-empty.html
+++ b/app/views/create-capacity-from-zero/new-schedule-empty.html
@@ -286,7 +286,7 @@
-
+
With selected days:
Assign session template
diff --git a/app/views/create-capacity-from-zero/new-schedule-ready.html b/app/views/create-capacity-from-zero/new-schedule-ready.html
index c466927..d09cdc0 100644
--- a/app/views/create-capacity-from-zero/new-schedule-ready.html
+++ b/app/views/create-capacity-from-zero/new-schedule-ready.html
@@ -448,7 +448,7 @@
-
+
With selected days:
Assign session template
diff --git a/app/views/mission-1/current-iteration/create-clinic-rev-1-slot-structure.html b/app/views/mission-1/current-iteration/create-clinic-rev-1-slot-structure.html
index e0914e0..54b1530 100644
--- a/app/views/mission-1/current-iteration/create-clinic-rev-1-slot-structure.html
+++ b/app/views/mission-1/current-iteration/create-clinic-rev-1-slot-structure.html
@@ -108,7 +108,7 @@
-
+
Set selected slots as:
Staff break
diff --git a/app/views/sessions/00-template-listing.html b/app/views/sessions/00-template-listing.html
index 068c222..6e2f3e5 100644
--- a/app/views/sessions/00-template-listing.html
+++ b/app/views/sessions/00-template-listing.html
@@ -11,6 +11,22 @@
{% block content %}
+{% if templateCreated %}
+
+
+
+
+ Session template created
+
+
+
+{% endif %}
+
+
{% set secondaryNavItems = [] %}
{% for item in [
@@ -38,11 +54,15 @@
Session templates
+ {% for template in data.sessionTemplates %}
- Standard day
- This is where a short note would go
+ {{ template.name }}
+ {% if template.note %}
+ {{ template.note }}
+ {% endif %}
+ {% if templateCreated %}New {% endif %}
Edit this template
@@ -57,12 +77,80 @@
- Total capacity
+ Session times
- 42 slots
+ {{ template.sessionStartLabel }} to {{ template.sessionEndLabel }}
+
+
+ Slot length
+
+
+ {{ template.duration }} minutes
+
+
+
+
+ Total slots
+
+
+ {{ template.totalSlotCount }}
+
+
+
+
+
+
Slot breakdown
+
+
+
+ Standard slots
+
+
+ {{ template.openSlotCount if template.openSlotCount else 0 }}
+
+
+
+
+ Held
+
+
+ {{ template.heldSlotCount if template.heldSlotCount else 0 }}
+
+
+
+
+ Unavailable to book
+
+
+ {{ template.unavailableSlotCount if template.unavailableSlotCount else 0 }}
+
+
+
+
+
+
+ {% endfor %}
+
+
+
+
+ Standard day
+ This is where a short note would go
+
+
+ Edit this template
+
+
+
+
+
+
Full preview
+
+
+
Session times
@@ -79,6 +167,14 @@
10 minutes
+
+
+ Total slots
+
+
+ 42
+
+
@@ -86,18 +182,18 @@
Slot breakdown
- Open slots
+ Standard slots
- 32 total
+ 32
- Held slots
+ Held
- 4 total
+ 4
@@ -105,7 +201,7 @@
Slot breakdown
Unavailable to book
- 6 total
+ 6
@@ -132,26 +228,26 @@
- Total capacity
+ Session times
- 42 slots
+ 09:00 to 17:00
- Session times
+ Slot length
- 09:00 to 17:00
+ 10 minutes
- Slot length
+ Total slots
- 10 minutes
+ 42
@@ -161,7 +257,7 @@ Slot breakdown
- Open slots
+ Standard slots
0
@@ -169,10 +265,10 @@ Slot breakdown
- Held slots
+ Held
- 36 total
+ 36
@@ -180,7 +276,7 @@
Slot breakdown
Unavailable to book
- 6 total
+ 6
diff --git a/app/views/sessions/01-template-name.html b/app/views/sessions/01-template-name.html
index ce1e315..43d8ed0 100644
--- a/app/views/sessions/01-template-name.html
+++ b/app/views/sessions/01-template-name.html
@@ -19,7 +19,7 @@
{% endset %}
-
+
-
+
{% if errorState === "true" %}
diff --git a/app/views/sessions/02-session-times.html b/app/views/sessions/02-session-times.html
index a70d96b..576d31e 100644
--- a/app/views/sessions/02-session-times.html
+++ b/app/views/sessions/02-session-times.html
@@ -19,7 +19,7 @@
{% endset %}
-
+
-
+
{% if errorState === "true" %}
diff --git a/app/views/sessions/03-slot-structure.html b/app/views/sessions/03-slot-structure.html
index 970bf5e..3ed3802 100644
--- a/app/views/sessions/03-slot-structure.html
+++ b/app/views/sessions/03-slot-structure.html
@@ -19,7 +19,7 @@
{% endset %}
-
+
-
+
@@ -92,11 +92,18 @@
{{ slot.startLabel }}
to {{ slot.endLabel }}
- {% if slot.isStaffBreak %}
- Staff break
+ {% if slot.isStaffBreak %}
+ Staff break
+ {% elseif slot.isSpecialAppointment %}
+ Special appointment
+ {% elseif slot.isHold %}
+ Held {{ slot.holdReason }}
+ {% endif %}
+ {% if slot.isCombined %}
+ {{ slot.span }} slots
+ {% endif %}
- {% endif %}
@@ -104,19 +111,56 @@
-
-
-
Set selected slots as:
-
Staff break
-
-
Clear slot type
+
+
+
+
+
+ Tag slots
+
+
Hold as:
+
+
+ Rebooking capacity
+
+
+ Web rebooking
+
+
+ Special appointment
+
+
+ Technical recall
+
+
+
Make non-bookable:
+
+
+
Remove tags from selected
+
+
+
+
+
+ Combine slots
+
+ Combine selected
+
+ Divide selected
+
+
+
{{ bookableSlotCount }} bookable slots
- Continue
+ Continue
diff --git a/app/views/sessions/04-check-template.html b/app/views/sessions/04-check-template.html
new file mode 100644
index 0000000..bb05330
--- /dev/null
+++ b/app/views/sessions/04-check-template.html
@@ -0,0 +1,123 @@
+{% extends 'layout.html' %}
+
+{% from "mission-1/current-iteration/includes/primary-navigation.html" import primaryNavigation %}
+{% block header %}
+ {{ primaryNavigation("Manage clinics" if data.missionOne and data.missionOne.returnTo == "clinics" else "Batches", serviceName, bsoName) }}
+{% endblock %}
+
+{% block content %}
+
+{% set tickSvg %}
+
+
+
+{% endset %}
+
+
+
+
+
+
Check and confirm
+
+
+
+
+ Template name
+
+
+ {{ data.createSessionTemplate.sessionTemplateName }}
+ {% if data.createSessionTemplate.sessionTemplateNote !== '' %}
+ {{ data.createSessionTemplate.sessionTemplateNote }}
+ {% endif %}
+
+
+ Change clinic name
+
+
+
+
+ Session times
+
+
+ {{ data.createSessionTemplate.startTime.hour | zeroPad }}:{{ data.createSessionTemplate.startTime.minute | zeroPad }} to {{ data.createSessionTemplate.endTime.hour | zeroPad }}:{{ data.createSessionTemplate.endTime.minute | zeroPad }}
+ {{ data.createSessionTemplate.duration }} minute slot length
+
+
+ Change session times
+
+
+
+
+ Slot structure
+
+
+ {{ data.createSessionTemplate.bookableSlotCount }} bookable slots
+ {% if data.createSessionTemplate.bookableBreakdown | length %}
+
+ {% for item in data.createSessionTemplate.bookableBreakdown %}
+ {{ item.count }} {{ item.label | lower }}
+ {% endfor %}
+
+ {% endif %}
+ {{ data.createSessionTemplate.staffBreakSlotCount }} non-bookable slots
+ {% if data.createSessionTemplate.nonBookableBreakdown | length %}
+
+ {% for item in data.createSessionTemplate.nonBookableBreakdown %}
+ {{ item.count }} {{ item.label | lower }}
+ {% endfor %}
+
+ {% endif %}
+
+
+ Change slot structure
+
+
+
+
+
+ Save session template
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/app/views/sessions/day-slot-view.html b/app/views/sessions/day-slot-view.html
index 1fdc9c4..6271257 100644
--- a/app/views/sessions/day-slot-view.html
+++ b/app/views/sessions/day-slot-view.html
@@ -948,7 +948,7 @@
-
+
Change slots
With selected:
diff --git a/app/views/sessions/z-older-versions/02-organise-slots.html b/app/views/sessions/z-older-versions/02-organise-slots.html
index d1dbb4d..f79ee29 100644
--- a/app/views/sessions/z-older-versions/02-organise-slots.html
+++ b/app/views/sessions/z-older-versions/02-organise-slots.html
@@ -169,7 +169,7 @@
-
+
With selected:
Hold
diff --git a/app/views/z-sketching/calc-2.html b/app/views/z-sketching/calc-2.html
index 5700f4c..231d4f0 100644
--- a/app/views/z-sketching/calc-2.html
+++ b/app/views/z-sketching/calc-2.html
@@ -93,7 +93,7 @@ Session setup
0 available to book of 0
-
+
With selected:
Hold
From 4c3f5fdd5b8ea22de1a083d311ccb8cb1318513e Mon Sep 17 00:00:00 2001
From: Mat Johnson
Date: Tue, 13 Oct 2026 16:39:02 +0100
Subject: [PATCH 05/22] Reverse loop through new session templates
---
app/views/sessions/00-template-listing.html | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/app/views/sessions/00-template-listing.html b/app/views/sessions/00-template-listing.html
index 6e2f3e5..47a7100 100644
--- a/app/views/sessions/00-template-listing.html
+++ b/app/views/sessions/00-template-listing.html
@@ -30,7 +30,7 @@
{% set secondaryNavItems = [] %}
{% for item in [
- { href: '/clinics/', label: 'Active clinics' },
+ { href: '/create-capacity-from-zero/', label: 'Active clinics' },
{ href: '#', label: 'Archived clinics' },
{ href: '/sessions/00-template-listing', label: 'Manage session templates', selected: 'true' }
] %}
@@ -54,7 +54,7 @@ Session templates
- {% for template in data.sessionTemplates %}
+ {% for template in data.sessionTemplates | reverse %}
@@ -62,7 +62,7 @@
{% if template.note %}
{{ template.note }}
{% endif %}
- {% if templateCreated %}New {% endif %}
+ {% if templateCreated and loop.first %}New {% endif %}
Edit this template
From 1c42df193093ed165b1e80d4438f822e80920bf2 Mon Sep 17 00:00:00 2001
From: Mat Johnson
Date: Tue, 13 Oct 2026 16:40:40 +0100
Subject: [PATCH 06/22] Progressive enhancement to slot pages
---
.../create-clinic-rev-1-slot-structure.html | 60 ++++++++++++++++++-
1 file changed, 59 insertions(+), 1 deletion(-)
diff --git a/app/views/mission-1/current-iteration/create-clinic-rev-1-slot-structure.html b/app/views/mission-1/current-iteration/create-clinic-rev-1-slot-structure.html
index 54b1530..3166691 100644
--- a/app/views/mission-1/current-iteration/create-clinic-rev-1-slot-structure.html
+++ b/app/views/mission-1/current-iteration/create-clinic-rev-1-slot-structure.html
@@ -67,6 +67,7 @@
+
{% if errorState === "true" %}
{% set errorList = [] %}
{% if errors.slots %}
@@ -77,6 +78,7 @@
errorList: errorList
}) }}
{% endif %}
+
Slot structure
Session time
@@ -115,7 +117,7 @@ Set selected slots as:
Clear slot type
-
+
{{ bookableSlotCount }} bookable slots
@@ -128,4 +130,60 @@
Set selected slots as:
+{% endblock %}
+
+{% block pageScripts %}
+
{% endblock %}
\ No newline at end of file
From 6479d2efb2ffd763d2b33fe3de9d8d33f111eef2 Mon Sep 17 00:00:00 2001
From: Mat Johnson
Date: Tue, 13 Oct 2026 16:46:42 +0100
Subject: [PATCH 07/22] Progressive emhancement to slots in session templates
too
---
app/views/sessions/03-slot-structure.html | 69 ++++++++++++++++++++++-
1 file changed, 67 insertions(+), 2 deletions(-)
diff --git a/app/views/sessions/03-slot-structure.html b/app/views/sessions/03-slot-structure.html
index 3ed3802..d155746 100644
--- a/app/views/sessions/03-slot-structure.html
+++ b/app/views/sessions/03-slot-structure.html
@@ -64,6 +64,7 @@
+
{% if errorState === "true" %}
{% set errorList = [] %}
{% if errors.slots %}
@@ -74,6 +75,7 @@
errorList: errorList
}) }}
{% endif %}
+
Slot structure
Session time
@@ -156,7 +158,7 @@
-
+
{{ bookableSlotCount }} bookable slots
@@ -170,4 +172,67 @@
{% endblock %}
-{% block pageScripts %}{% endblock %}
\ No newline at end of file
+{% block pageScripts %}
+
+{% endblock %}
\ No newline at end of file
From ed36351e352cf9ebd407fc8d97c998acafa2ee2f Mon Sep 17 00:00:00 2001
From: Mat Johnson
Date: Wed, 14 Oct 2026 11:47:58 +0100
Subject: [PATCH 08/22] Work in generic slot types
---
app/routes/clinic-sessions.js | 15 +++--
app/routes/mission-1.js | 6 +-
app/routes/slot-structure-handlers.js | 67 ++++++++++++-------
.../create-clinic-rev-1-publish-check.html | 2 +-
.../create-clinic-rev-1-slot-structure.html | 6 +-
app/views/sessions/03-slot-structure.html | 20 ++++--
app/views/sessions/04-check-template.html | 2 +-
7 files changed, 72 insertions(+), 46 deletions(-)
diff --git a/app/routes/clinic-sessions.js b/app/routes/clinic-sessions.js
index 0b8e624..25af203 100644
--- a/app/routes/clinic-sessions.js
+++ b/app/routes/clinic-sessions.js
@@ -3,7 +3,8 @@ const router = express.Router()
const { validateSessionTimes } = require('./session-times-handlers')
const {
buildSessionSlots,
- staffBreakHandler,
+ clearTagsHandler,
+ nonBookableHandler,
holdHandler,
specialAppointmentHandler,
combineSlotsHandler,
@@ -82,7 +83,7 @@ router.post('/sessions/03-slot-structure', function (req, res) {
router.post('/sessions/save-template', function (req, res) {
const template = req.session.data.createSessionTemplate || {}
- const { bookableSlotCount, staffBreakSlotCount, sessionStartLabel, sessionEndLabel } = buildSessionSlots(template)
+ const { bookableSlotCount, nonBookableSlotCount, sessionStartLabel, sessionEndLabel } = buildSessionSlots(template)
const standard = (template.bookableBreakdown || []).find((item) => item.label === 'Standard')
const openSlotCount = standard ? standard.count : 0
@@ -96,10 +97,10 @@ router.post('/sessions/save-template', function (req, res) {
sessionStartLabel,
sessionEndLabel,
duration: template.duration,
- totalSlotCount: bookableSlotCount + staffBreakSlotCount,
+ totalSlotCount: bookableSlotCount + nonBookableSlotCount,
openSlotCount,
heldSlotCount: bookableSlotCount - openSlotCount,
- unavailableSlotCount: staffBreakSlotCount
+ unavailableSlotCount: nonBookableSlotCount
})
req.session.data.createSessionTemplate = {}
@@ -117,12 +118,14 @@ router.get('/sessions/00-template-listing', function (req, res) {
})
const sessionsSlotStructure = { dataKey: 'createSessionTemplate', view: 'sessions/03-slot-structure' }
-router.post('/sessions/mark-staff-break', staffBreakHandler({ action: 'mark', ...sessionsSlotStructure }))
-router.post('/sessions/clear-slot-type', staffBreakHandler({ action: 'clear', ...sessionsSlotStructure }))
+router.post('/sessions/mark-staff-break', nonBookableHandler({ reason: 'staff break', ...sessionsSlotStructure }))
+router.post('/sessions/clear-slot-type', clearTagsHandler(sessionsSlotStructure))
router.post('/sessions/mark-hold-rebooking', holdHandler({ reason: 'rebooking', ...sessionsSlotStructure }))
router.post('/sessions/mark-hold-web-rebooking', holdHandler({ reason: 'web rebooking', ...sessionsSlotStructure }))
router.post('/sessions/mark-hold-technical-recall', holdHandler({ reason: 'technical recall', ...sessionsSlotStructure }))
+router.post('/sessions/mark-hold-generic', holdHandler(sessionsSlotStructure))
router.post('/sessions/mark-special-appointment', specialAppointmentHandler(sessionsSlotStructure))
+router.post('/sessions/mark-non-bookable-generic', nonBookableHandler(sessionsSlotStructure))
router.post('/sessions/combine-slots', combineSlotsHandler(sessionsSlotStructure))
router.post('/sessions/divide-slots', divideSlotsHandler(sessionsSlotStructure))
diff --git a/app/routes/mission-1.js b/app/routes/mission-1.js
index 53b5362..c81f78e 100644
--- a/app/routes/mission-1.js
+++ b/app/routes/mission-1.js
@@ -1,7 +1,7 @@
const express = require('express')
const router = express.Router()
const { validateSessionTimes } = require('./session-times-handlers')
-const { buildSessionSlots, staffBreakHandler } = require('./slot-structure-handlers')
+const { buildSessionSlots, nonBookableHandler, clearTagsHandler } = require('./slot-structure-handlers')
// Routes and functions specific to Mission 1 prototypes
/*
@@ -196,8 +196,8 @@ router.get('/mission-1/current-iteration/create-clinic-rev-1-slot-structure', fu
})
const missionOneSlotStructure = { dataKey: 'missionOne', view: 'mission-1/current-iteration/create-clinic-rev-1-slot-structure' }
-router.post('/mission-1/current-iteration/mark-staff-break', staffBreakHandler({ action: 'mark', ...missionOneSlotStructure }))
-router.post('/mission-1/current-iteration/clear-slot-type', staffBreakHandler({ action: 'clear', ...missionOneSlotStructure }))
+router.post('/mission-1/current-iteration/mark-staff-break', nonBookableHandler({ reason: 'staff break', ...missionOneSlotStructure }))
+router.post('/mission-1/current-iteration/clear-slot-type', clearTagsHandler(missionOneSlotStructure))
// refreshing slot counts before the check and confirm step for 1 day clinic creation
router.post('/mission-1/current-iteration/create-clinic-rev-1-publish-check', function (req, res) {
diff --git a/app/routes/slot-structure-handlers.js b/app/routes/slot-structure-handlers.js
index b9e94c7..7ddab74 100644
--- a/app/routes/slot-structure-handlers.js
+++ b/app/routes/slot-structure-handlers.js
@@ -5,7 +5,12 @@ const formatClock = (totalMinutes) => {
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
}
-// Builds the slot checkbox data for a session, flagging any slots marked as staff breaks
+// Reason-tagged slots are stored as { [index]: reason }, with `true` for no reason; ignores data saved in older shapes
+const asTagMap = (value) => (value && typeof value === 'object' && !Array.isArray(value)) ? value : {}
+
+const tagReason = (value) => typeof value === 'string' ? value : null
+
+// Builds the slot checkbox data for a session, flagging each slot's tag
const buildSessionSlots = (sessionData) => {
const startTime = sessionData.startTime || {}
const endTime = sessionData.endTime || {}
@@ -13,8 +18,8 @@ const buildSessionSlots = (sessionData) => {
const startMinutes = (parseInt(startTime.hour, 10) || 0) * 60 + (parseInt(startTime.minute, 10) || 0)
const endMinutes = (parseInt(endTime.hour, 10) || 0) * 60 + (parseInt(endTime.minute, 10) || 0)
const duration = parseInt(sessionData.duration, 10) || 0
- const staffBreakSlots = (sessionData.staffBreakSlots || []).map(Number).filter(Number.isInteger)
- const heldSlots = sessionData.heldSlots || {}
+ const heldSlots = asTagMap(sessionData.heldSlots)
+ const nonBookableSlots = asTagMap(sessionData.nonBookableSlots)
const specialAppointmentSlots = (sessionData.specialAppointmentSlots || []).map(Number).filter(Number.isInteger)
const combinedSlots = sessionData.combinedSlots || []
@@ -33,9 +38,10 @@ const buildSessionSlots = (sessionData) => {
endLabel: formatClock(startMinutes + ((lastIndex + 1) * duration)),
isCombined: Boolean(range),
span: lastIndex - i + 1,
- isStaffBreak: staffBreakSlots.includes(i),
+ isNonBookable: Boolean(nonBookableSlots[i]),
+ nonBookableReason: tagReason(nonBookableSlots[i]),
isHold: Boolean(heldSlots[i]),
- holdReason: heldSlots[i],
+ holdReason: tagReason(heldSlots[i]),
isSpecialAppointment: specialAppointmentSlots.includes(i)
})
i = lastIndex + 1
@@ -44,14 +50,15 @@ const buildSessionSlots = (sessionData) => {
// Counts are in base slots, so combining never changes them
const sumSpans = (list) => list.reduce((total, slot) => total + slot.span, 0)
- const bookableSlotCount = sumSpans(slots.filter((slot) => !slot.isStaffBreak))
- const staffBreakSlotCount = sumSpans(slots.filter((slot) => slot.isStaffBreak))
+ const bookableSlotCount = sumSpans(slots.filter((slot) => !slot.isNonBookable))
+ const nonBookableSlotCount = sumSpans(slots.filter((slot) => slot.isNonBookable))
sessionData.bookableSlotCount = bookableSlotCount
- sessionData.staffBreakSlotCount = staffBreakSlotCount
+ sessionData.nonBookableSlotCount = nonBookableSlotCount
+ const capitalise = (text) => text.charAt(0).toUpperCase() + text.slice(1)
const slotTypeLabel = (slot) => {
- if (slot.isStaffBreak) return 'Staff break'
- if (slot.isHold) return `Held for ${slot.holdReason}`
+ if (slot.isNonBookable) return slot.nonBookableReason ? capitalise(slot.nonBookableReason) : 'Non-bookable'
+ if (slot.isHold) return slot.holdReason ? `Held for ${slot.holdReason}` : 'Held'
if (slot.isSpecialAppointment) return 'Special appointment'
return 'Standard'
}
@@ -66,13 +73,13 @@ const buildSessionSlots = (sessionData) => {
}
return items
}, [])
- sessionData.bookableBreakdown = breakdown(slots.filter((slot) => !slot.isStaffBreak))
- sessionData.nonBookableBreakdown = breakdown(slots.filter((slot) => slot.isStaffBreak))
+ sessionData.bookableBreakdown = breakdown(slots.filter((slot) => !slot.isNonBookable))
+ sessionData.nonBookableBreakdown = breakdown(slots.filter((slot) => slot.isNonBookable))
return {
slots,
bookableSlotCount,
- staffBreakSlotCount,
+ nonBookableSlotCount,
sessionStartLabel: formatClock(startMinutes),
sessionEndLabel: formatClock(endMinutes)
}
@@ -93,16 +100,16 @@ const slotUpdateHandler = ({ dataKey, view, update }) => function (req, res) {
}
const tags = {
- staffBreakSlots: new Set((sessionData.staffBreakSlots || []).map(Number).filter(Number.isInteger)),
- heldSlots: Object.assign({}, sessionData.heldSlots),
+ heldSlots: Object.assign({}, asTagMap(sessionData.heldSlots)),
+ nonBookableSlots: Object.assign({}, asTagMap(sessionData.nonBookableSlots)),
specialAppointmentSlots: new Set((sessionData.specialAppointmentSlots || []).map(Number).filter(Number.isInteger))
}
selectedSlots.forEach((index) => {
clearTags(index, tags)
update(index, tags)
})
- sessionData.staffBreakSlots = Array.from(tags.staffBreakSlots)
sessionData.heldSlots = tags.heldSlots
+ sessionData.nonBookableSlots = tags.nonBookableSlots
sessionData.specialAppointmentSlots = Array.from(tags.specialAppointmentSlots)
res.redirect('/' + view)
@@ -110,19 +117,24 @@ const slotUpdateHandler = ({ dataKey, view, update }) => function (req, res) {
// A slot carries at most one tag, so every update starts by clearing existing ones
const clearTags = (index, tags) => {
- tags.staffBreakSlots.delete(index)
delete tags.heldSlots[index]
+ delete tags.nonBookableSlots[index]
tags.specialAppointmentSlots.delete(index)
}
-// 'mark' tags the selected slots as staff breaks; 'clear' removes all tags from them
-const staffBreakHandler = ({ action, dataKey, view }) => slotUpdateHandler({
+// Removes all tags from the selected slots
+const clearTagsHandler = ({ dataKey, view }) => slotUpdateHandler({
+ dataKey,
+ view,
+ update: () => {}
+})
+
+// Makes the selected slots non-bookable for `reason`
+const nonBookableHandler = ({ reason, dataKey, view }) => slotUpdateHandler({
dataKey,
view,
update: (index, tags) => {
- if (action === 'mark') {
- tags.staffBreakSlots.add(index)
- }
+ tags.nonBookableSlots[index] = reason || true
}
})
@@ -131,7 +143,8 @@ const holdHandler = ({ reason, dataKey, view }) => slotUpdateHandler({
dataKey,
view,
update: (index, tags) => {
- tags.heldSlots[index] = reason
+ // Holds without a reason are stored as `true`
+ tags.heldSlots[index] = reason || true
}
})
@@ -186,9 +199,10 @@ const combineSlotsHandler = ({ dataKey, view }) => function (req, res) {
.sort((a, b) => a.start - b.start)
const isAbsorbed = (index) => index > start && index <= end
- sessionData.staffBreakSlots = (sessionData.staffBreakSlots || []).map(Number).filter((index) => !isAbsorbed(index))
+ const withoutAbsorbed = (tagMap) => Object.fromEntries(Object.entries(asTagMap(tagMap)).filter(([index]) => !isAbsorbed(Number(index))))
sessionData.specialAppointmentSlots = (sessionData.specialAppointmentSlots || []).map(Number).filter((index) => !isAbsorbed(index))
- sessionData.heldSlots = Object.fromEntries(Object.entries(sessionData.heldSlots || {}).filter(([index]) => !isAbsorbed(Number(index))))
+ sessionData.heldSlots = withoutAbsorbed(sessionData.heldSlots)
+ sessionData.nonBookableSlots = withoutAbsorbed(sessionData.nonBookableSlots)
res.redirect('/' + view)
}
@@ -217,7 +231,8 @@ const divideSlotsHandler = ({ dataKey, view }) => function (req, res) {
module.exports = {
formatClock,
buildSessionSlots,
- staffBreakHandler,
+ clearTagsHandler,
+ nonBookableHandler,
holdHandler,
specialAppointmentHandler,
combineSlotsHandler,
diff --git a/app/views/mission-1/current-iteration/create-clinic-rev-1-publish-check.html b/app/views/mission-1/current-iteration/create-clinic-rev-1-publish-check.html
index 1940b5f..c2d91d0 100644
--- a/app/views/mission-1/current-iteration/create-clinic-rev-1-publish-check.html
+++ b/app/views/mission-1/current-iteration/create-clinic-rev-1-publish-check.html
@@ -106,7 +106,7 @@ Check and confirm
{{ data.missionOne.bookableSlotCount }} bookable slots
- {{ data.missionOne.staffBreakSlotCount }} slots set as staff break
+ {{ data.missionOne.nonBookableSlotCount }} slots set as staff break
Change slot structure
diff --git a/app/views/mission-1/current-iteration/create-clinic-rev-1-slot-structure.html b/app/views/mission-1/current-iteration/create-clinic-rev-1-slot-structure.html
index 3166691..fb5eadb 100644
--- a/app/views/mission-1/current-iteration/create-clinic-rev-1-slot-structure.html
+++ b/app/views/mission-1/current-iteration/create-clinic-rev-1-slot-structure.html
@@ -91,15 +91,15 @@
{% for slot in slots %}
-
+
{{ slot.startLabel }}
to {{ slot.endLabel }}
- {% if slot.isStaffBreak %}
+ {% if slot.isNonBookable %}
- Staff break
+ {{ slot.nonBookableReason | capitalize if slot.nonBookableReason else "Non-bookable" }}
{% endif %}
diff --git a/app/views/sessions/03-slot-structure.html b/app/views/sessions/03-slot-structure.html
index d155746..1ea11eb 100644
--- a/app/views/sessions/03-slot-structure.html
+++ b/app/views/sessions/03-slot-structure.html
@@ -88,19 +88,19 @@
{% for slot in slots %}
-
+
{{ slot.startLabel }}
to {{ slot.endLabel }}
- {% if slot.isStaffBreak %}
- Staff break
+ {% if slot.isNonBookable %}
+ Non-bookable {% if slot.nonBookableReason %}{{ slot.nonBookableReason }} {% endif %}
{% elseif slot.isSpecialAppointment %}
Special appointment
{% elseif slot.isHold %}
- Held {{ slot.holdReason }}
+ Held {% if slot.holdReason %}{{ slot.holdReason }} {% endif %}
{% endif %}
{% if slot.isCombined %}
{{ slot.span }} slots
@@ -120,7 +120,7 @@
Tag slots
- Hold as:
+ Hold:
Rebooking capacity
@@ -128,11 +128,14 @@ Hold a
Web rebooking
+
+ Technical recall
+
Special appointment
- Technical recall
+ Generic
Make non-bookable:
@@ -140,6 +143,9 @@ Make n
Staff break
+
+ Generic
+
Remove tags from selected
@@ -179,10 +185,12 @@
var form = document.querySelector('#slots-container').closest('form')
var enhancedActions = [
'mark-staff-break',
+ 'mark-non-bookable-generic',
'clear-slot-type',
'mark-hold-rebooking',
'mark-hold-web-rebooking',
'mark-hold-technical-recall',
+ 'mark-hold-generic',
'mark-special-appointment',
'combine-slots',
'divide-slots'
diff --git a/app/views/sessions/04-check-template.html b/app/views/sessions/04-check-template.html
index bb05330..c8b3725 100644
--- a/app/views/sessions/04-check-template.html
+++ b/app/views/sessions/04-check-template.html
@@ -98,7 +98,7 @@ Check and confirm
{% endfor %}
{% endif %}
- {{ data.createSessionTemplate.staffBreakSlotCount }} non-bookable slots
+ {{ data.createSessionTemplate.nonBookableSlotCount }} non-bookable slots
{% if data.createSessionTemplate.nonBookableBreakdown | length %}
{% for item in data.createSessionTemplate.nonBookableBreakdown %}
From cb2b2a62df5ec6aabef9a742af0b28df0bdae209 Mon Sep 17 00:00:00 2001
From: Mat Johnson
Date: Wed, 14 Oct 2026 13:20:56 +0100
Subject: [PATCH 09/22] Some tidy up and thinking about how to dispay summary
---
app/routes/slot-structure-handlers.js | 18 +++++---
app/views/sessions/01-template-name.html | 2 +-
app/views/sessions/02-session-times.html | 2 +-
app/views/sessions/03-slot-structure.html | 2 +-
app/views/sessions/04-check-template.html | 50 ++++++++++++++++-------
5 files changed, 52 insertions(+), 22 deletions(-)
diff --git a/app/routes/slot-structure-handlers.js b/app/routes/slot-structure-handlers.js
index 7ddab74..2e41c92 100644
--- a/app/routes/slot-structure-handlers.js
+++ b/app/routes/slot-structure-handlers.js
@@ -62,8 +62,7 @@ const buildSessionSlots = (sessionData) => {
if (slot.isSpecialAppointment) return 'Special appointment'
return 'Standard'
}
- // Returns [{ label, count }] in first-seen order, counted in base slots
- const breakdown = (list) => list.reduce((items, slot) => {
+ const breakdown = (list, order) => list.reduce((items, slot) => {
const label = slotTypeLabel(slot)
const item = items.find((existing) => existing.label === label)
if (item) {
@@ -72,9 +71,18 @@ const buildSessionSlots = (sessionData) => {
items.push({ label, count: slot.span })
}
return items
- }, [])
- sessionData.bookableBreakdown = breakdown(slots.filter((slot) => !slot.isNonBookable))
- sessionData.nonBookableBreakdown = breakdown(slots.filter((slot) => slot.isNonBookable))
+ }, []).sort((first, second) => {
+ const firstIndex = order.indexOf(first.label)
+ const secondIndex = order.indexOf(second.label)
+ return (firstIndex === -1 ? order.length : firstIndex) - (secondIndex === -1 ? order.length : secondIndex)
+ })
+ sessionData.bookableBreakdown = breakdown(slots.filter((slot) => !slot.isNonBookable), [
+ 'Held for rebooking', 'Held for web rebooking', 'Held for technical recall',
+ 'Special appointment', 'Held', 'Standard'
+ ])
+ sessionData.nonBookableBreakdown = breakdown(slots.filter((slot) => slot.isNonBookable), [
+ 'Staff break', 'Non-bookable'
+ ])
return {
slots,
diff --git a/app/views/sessions/01-template-name.html b/app/views/sessions/01-template-name.html
index 43d8ed0..97ce8a6 100644
--- a/app/views/sessions/01-template-name.html
+++ b/app/views/sessions/01-template-name.html
@@ -1,6 +1,6 @@
{% extends 'layout.html' %}
-{% set pageName = "Session templates" %}
+{% set pageName = "" %}
{% set errorState = "true" if errors else "false" %}
{% from "_includes/primary-navigation.html" import primaryNavigation %}
diff --git a/app/views/sessions/02-session-times.html b/app/views/sessions/02-session-times.html
index 576d31e..361de68 100644
--- a/app/views/sessions/02-session-times.html
+++ b/app/views/sessions/02-session-times.html
@@ -1,6 +1,6 @@
{% extends 'layout.html' %}
-{% set pageName = "Session templates" %}
+{% set pageName = "" %}
{% set errorState = "true" if errors else "false" %}
{% from "_includes/primary-navigation.html" import primaryNavigation %}
diff --git a/app/views/sessions/03-slot-structure.html b/app/views/sessions/03-slot-structure.html
index 1ea11eb..39dd562 100644
--- a/app/views/sessions/03-slot-structure.html
+++ b/app/views/sessions/03-slot-structure.html
@@ -1,6 +1,6 @@
{% extends 'layout.html' %}
-{% set pageName = "Session templates" %}
+{% set pageName = "" %}
{% set errorState = "true" if errors else "false" %}
{% from "_includes/primary-navigation.html" import primaryNavigation %}
diff --git a/app/views/sessions/04-check-template.html b/app/views/sessions/04-check-template.html
index c8b3725..bf9020f 100644
--- a/app/views/sessions/04-check-template.html
+++ b/app/views/sessions/04-check-template.html
@@ -1,8 +1,10 @@
{% extends 'layout.html' %}
-{% from "mission-1/current-iteration/includes/primary-navigation.html" import primaryNavigation %}
+{% set pageName = "" %}
+
+{% from "_includes/primary-navigation.html" import primaryNavigation %}
{% block header %}
- {{ primaryNavigation("Manage clinics" if data.missionOne and data.missionOne.returnTo == "clinics" else "Batches", serviceName, bsoName) }}
+ {{ primaryNavigation("Clinics", serviceName, bsoName) }}
{% endblock %}
{% block content %}
@@ -90,21 +92,41 @@ Check and confirm
Slot structure
- {{ data.createSessionTemplate.bookableSlotCount }} bookable slots
{% if data.createSessionTemplate.bookableBreakdown | length %}
-
- {% for item in data.createSessionTemplate.bookableBreakdown %}
- {{ item.count }} {{ item.label | lower }}
- {% endfor %}
-
+
+
+
+ {{ data.createSessionTemplate.bookableSlotCount }} bookable slots
+
+
+
+
+ {% for item in data.createSessionTemplate.bookableBreakdown %}
+ {{ item.count }} {{ item.label | lower }}
+ {% endfor %}
+
+
+
+ {% else %}
+ {{ data.createSessionTemplate.bookableSlotCount }} bookable slots
{% endif %}
- {{ data.createSessionTemplate.nonBookableSlotCount }} non-bookable slots
{% if data.createSessionTemplate.nonBookableBreakdown | length %}
-
- {% for item in data.createSessionTemplate.nonBookableBreakdown %}
- {{ item.count }} {{ item.label | lower }}
- {% endfor %}
-
+
+
+
+ {{ data.createSessionTemplate.nonBookableSlotCount }} non-bookable slots
+
+
+
+
+ {% for item in data.createSessionTemplate.nonBookableBreakdown %}
+ {{ item.count }} {{ item.label | lower }}
+ {% endfor %}
+
+
+
+ {% else %}
+ {{ data.createSessionTemplate.nonBookableSlotCount }} non-bookable slots
{% endif %}
From 618b92ef9100a7b5bbc19eaf621b42f300243263 Mon Sep 17 00:00:00 2001
From: Mat Johnson
Date: Wed, 14 Oct 2026 15:31:05 +0100
Subject: [PATCH 10/22] Build out dynamic image generation for session
templates
---
.gitignore | 1 +
app.js | 3 +
.../session-template-map-specials-day.svg | 1 +
.../session-template-map-standard-day.svg | 1 +
app/assets/images/session-templates/.gitkeep | 0
app/lib/session-map-svg.js | 108 ++++++++++++++++++
app/routes/clinic-sessions.js | 16 ++-
.../select-session-template.html | 4 +-
app/views/sessions/00-template-listing.html | 8 +-
9 files changed, 136 insertions(+), 6 deletions(-)
create mode 100644 app/assets/images/session-template-map-specials-day.svg
create mode 100644 app/assets/images/session-template-map-standard-day.svg
create mode 100644 app/assets/images/session-templates/.gitkeep
create mode 100644 app/lib/session-map-svg.js
diff --git a/.gitignore b/.gitignore
index 0fe1248..dda066c 100755
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ pids
*.pid
*.seed
*.pid.lock
+app/assets/images/session-templates/*.svg
# node-waf configuration
.lock-wscript
diff --git a/app.js b/app.js
index 17fa984..8910dcf 100755
--- a/app.js
+++ b/app.js
@@ -6,6 +6,7 @@ const sessionDataDefaults = require('./app/data/session-data-defaults')
const filters = require('./app/filters')
const locals = require('./app/locals')
const routes = require('./app/routes')
+const { clearSessionMapImages } = require('./app/lib/session-map-svg')
const viewsPath = [
'app/views/'
@@ -17,6 +18,8 @@ const entryPoints = [
]
async function init() {
+ clearSessionMapImages()
+
const prototype = await NHSPrototypeKit.init({
serviceName: config.serviceName,
buildOptions: {
diff --git a/app/assets/images/session-template-map-specials-day.svg b/app/assets/images/session-template-map-specials-day.svg
new file mode 100644
index 0000000..ef2001f
--- /dev/null
+++ b/app/assets/images/session-template-map-specials-day.svg
@@ -0,0 +1 @@
+42 slots from 10:00 to 17:00. Special appointments 10:00 to 13:00. Non-bookable 13:00 to 14:00. Special appointments 14:00 to 17:00.
diff --git a/app/assets/images/session-template-map-standard-day.svg b/app/assets/images/session-template-map-standard-day.svg
new file mode 100644
index 0000000..781b36a
--- /dev/null
+++ b/app/assets/images/session-template-map-standard-day.svg
@@ -0,0 +1 @@
+42 slots from 10:00 to 17:00. Special appointments 10:00 to 10:40. Non-bookable 13:00 to 14:00. Held 16:20 to 17:00.
diff --git a/app/assets/images/session-templates/.gitkeep b/app/assets/images/session-templates/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/app/lib/session-map-svg.js b/app/lib/session-map-svg.js
new file mode 100644
index 0000000..7e1ddd8
--- /dev/null
+++ b/app/lib/session-map-svg.js
@@ -0,0 +1,108 @@
+const fs = require('fs')
+const path = require('path')
+const { randomUUID } = require('crypto')
+
+const imageDir = path.join(__dirname, '../assets/images/session-templates')
+const imageUrlBase = '/assets/images/session-templates'
+
+// Matches the kit's session cookie maxAge, after which no session can still reference the image
+const maxImageAge = 1000 * 60 * 60 * 4
+
+const fills = {
+ 'non-bookable': '#aeb7bd',
+ held: '#f6c580',
+ special: '#fff9c4',
+ standard: '#ffffff'
+}
+
+const typeLabels = {
+ 'non-bookable': 'Non-bookable',
+ held: 'Held',
+ special: 'Special appointments'
+}
+
+// Precedence matches slotTypeLabel in slot-structure-handlers
+const slotType = (slot) => {
+ if (slot.isNonBookable) return 'non-bookable'
+ if (slot.isHold) return 'held'
+ if (slot.isSpecialAppointment) return 'special'
+ return 'standard'
+}
+
+const buildSegments = (slots) => slots.reduce((segments, slot) => {
+ const type = slotType(slot)
+ const last = segments[segments.length - 1]
+ if (last && last.type === type) {
+ last.span += slot.span
+ last.endLabel = slot.endLabel
+ } else {
+ segments.push({ type, startIndex: slot.index, span: slot.span, startLabel: slot.startLabel, endLabel: slot.endLabel })
+ }
+ return segments
+}, [])
+
+// Takes the output of buildSessionSlots and returns { svg, description }
+const buildSessionMapSvg = ({ slots, sessionStartLabel, sessionEndLabel }) => {
+ const segments = buildSegments(slots)
+ const totalSlots = segments.reduce((total, segment) => total + segment.span, 0) || 1
+
+ const description = [`${totalSlots} slots from ${sessionStartLabel} to ${sessionEndLabel}.`]
+ .concat(segments
+ .filter((segment) => segment.type !== 'standard')
+ .map((segment) => `${typeLabels[segment.type]} ${segment.startLabel} to ${segment.endLabel}.`))
+ .join(' ')
+
+ const shapes = segments.map((segment, i) => [
+ segment.type === 'standard' ? '' : ` `,
+ i === 0 ? '' : ` `
+ ].join(''))
+
+ // width/height give a portrait intrinsic ratio; the viewBox stretches to fill it
+ const svg = `` +
+ `${description} ` +
+ ` ` +
+ shapes.join('') +
+ ' \n'
+
+ return { svg, description }
+}
+
+const listImages = () => {
+ if (!fs.existsSync(imageDir)) return []
+ return fs.readdirSync(imageDir)
+ .filter((file) => path.extname(file) === '.svg')
+ .map((file) => path.join(imageDir, file))
+}
+
+// Sessions live in memory, so none survive a restart and every existing image is orphaned
+const clearSessionMapImages = () => {
+ listImages().forEach((file) => fs.rmSync(file, { force: true }))
+}
+
+const pruneOldImages = () => {
+ const cutoff = Date.now() - maxImageAge
+ listImages()
+ .filter((file) => fs.statSync(file).mtimeMs < cutoff)
+ .forEach((file) => fs.rmSync(file, { force: true }))
+}
+
+// Writes the map for a session and returns { imagePath, imageAlt }, or {} if the write fails
+const saveSessionMapImage = (sessionSlots) => {
+ const { svg, description } = buildSessionMapSvg(sessionSlots)
+ const fileName = `${Date.now()}-${randomUUID().slice(0, 8)}.svg`
+
+ try {
+ fs.mkdirSync(imageDir, { recursive: true })
+ pruneOldImages()
+ fs.writeFileSync(path.join(imageDir, fileName), svg)
+ return { imagePath: `${imageUrlBase}/${fileName}`, imageAlt: description }
+ } catch (error) {
+ console.error('Could not save session map image:', error)
+ return {}
+ }
+}
+
+const sessionMapImageExists = (imagePath) =>
+ typeof imagePath === 'string' && fs.existsSync(path.join(imageDir, path.basename(imagePath)))
+
+module.exports = { buildSessionMapSvg, clearSessionMapImages, saveSessionMapImage, sessionMapImageExists }
diff --git a/app/routes/clinic-sessions.js b/app/routes/clinic-sessions.js
index 25af203..38c147f 100644
--- a/app/routes/clinic-sessions.js
+++ b/app/routes/clinic-sessions.js
@@ -10,6 +10,7 @@ const {
combineSlotsHandler,
divideSlotsHandler
} = require('./slot-structure-handlers')
+const { saveSessionMapImage, sessionMapImageExists } = require('../lib/session-map-svg')
/*
/app/views/sessions/
@@ -83,7 +84,8 @@ router.post('/sessions/03-slot-structure', function (req, res) {
router.post('/sessions/save-template', function (req, res) {
const template = req.session.data.createSessionTemplate || {}
- const { bookableSlotCount, nonBookableSlotCount, sessionStartLabel, sessionEndLabel } = buildSessionSlots(template)
+ const sessionSlots = buildSessionSlots(template)
+ const { bookableSlotCount, nonBookableSlotCount, sessionStartLabel, sessionEndLabel } = sessionSlots
const standard = (template.bookableBreakdown || []).find((item) => item.label === 'Standard')
const openSlotCount = standard ? standard.count : 0
@@ -100,7 +102,8 @@ router.post('/sessions/save-template', function (req, res) {
totalSlotCount: bookableSlotCount + nonBookableSlotCount,
openSlotCount,
heldSlotCount: bookableSlotCount - openSlotCount,
- unavailableSlotCount: nonBookableSlotCount
+ unavailableSlotCount: nonBookableSlotCount,
+ ...saveSessionMapImage(sessionSlots)
})
req.session.data.createSessionTemplate = {}
@@ -114,6 +117,15 @@ router.get('/sessions/00-template-listing', function (req, res) {
const templateCreated = Boolean(req.session.data.sessionTemplateCreated)
delete req.session.data.sessionTemplateCreated
+ // Images can be pruned or lost on restart while a template still references them
+ const templates = req.session.data.sessionTemplates || []
+ templates.forEach((template) => {
+ if (template.imagePath && !sessionMapImageExists(template.imagePath)) {
+ delete template.imagePath
+ delete template.imageAlt
+ }
+ })
+
res.render('sessions/00-template-listing', { templateCreated })
})
diff --git a/app/views/create-capacity-from-zero/select-session-template.html b/app/views/create-capacity-from-zero/select-session-template.html
index b449ca7..886476e 100644
--- a/app/views/create-capacity-from-zero/select-session-template.html
+++ b/app/views/create-capacity-from-zero/select-session-template.html
@@ -33,7 +33,7 @@
-
+
Full preview
@@ -111,7 +111,7 @@
-
+
Full preview
diff --git a/app/views/sessions/00-template-listing.html b/app/views/sessions/00-template-listing.html
index 47a7100..1118499 100644
--- a/app/views/sessions/00-template-listing.html
+++ b/app/views/sessions/00-template-listing.html
@@ -70,7 +70,11 @@
+ {% if template.imagePath %}
+
+ {% else %}
+ {% endif %}
Full preview
@@ -146,7 +150,7 @@
-
+
Full preview
@@ -221,7 +225,7 @@
-
+
Full preview
From d2d4a12f4a3cbff1258a8913e95c5dc30bb78f45 Mon Sep 17 00:00:00 2001
From: Mat Johnson
Date: Wed, 14 Oct 2026 16:04:37 +0100
Subject: [PATCH 11/22] Edit functionality
---
app/lib/session-map-svg.js | 7 ++-
app/routes/clinic-sessions.js | 54 ++++++++++++++++++---
app/routes/slot-structure-handlers.js | 2 +-
app/views/sessions/00-template-listing.html | 14 +++---
app/views/sessions/01-template-name.html | 2 +-
app/views/sessions/02-session-times.html | 2 +-
app/views/sessions/03-slot-structure.html | 6 ++-
app/views/sessions/04-check-template.html | 27 ++++++++---
8 files changed, 90 insertions(+), 24 deletions(-)
diff --git a/app/lib/session-map-svg.js b/app/lib/session-map-svg.js
index 7e1ddd8..01b1a25 100644
--- a/app/lib/session-map-svg.js
+++ b/app/lib/session-map-svg.js
@@ -105,4 +105,9 @@ const saveSessionMapImage = (sessionSlots) => {
const sessionMapImageExists = (imagePath) =>
typeof imagePath === 'string' && fs.existsSync(path.join(imageDir, path.basename(imagePath)))
-module.exports = { buildSessionMapSvg, clearSessionMapImages, saveSessionMapImage, sessionMapImageExists }
+const deleteSessionMapImage = (imagePath) => {
+ if (typeof imagePath !== 'string') return
+ fs.rmSync(path.join(imageDir, path.basename(imagePath)), { force: true })
+}
+
+module.exports = { buildSessionMapSvg, clearSessionMapImages, deleteSessionMapImage, saveSessionMapImage, sessionMapImageExists }
diff --git a/app/routes/clinic-sessions.js b/app/routes/clinic-sessions.js
index 38c147f..4c70d74 100644
--- a/app/routes/clinic-sessions.js
+++ b/app/routes/clinic-sessions.js
@@ -1,4 +1,5 @@
const express = require('express')
+const { randomUUID } = require('crypto')
const router = express.Router()
const { validateSessionTimes } = require('./session-times-handlers')
const {
@@ -10,7 +11,7 @@ const {
combineSlotsHandler,
divideSlotsHandler
} = require('./slot-structure-handlers')
-const { saveSessionMapImage, sessionMapImageExists } = require('../lib/session-map-svg')
+const { deleteSessionMapImage, saveSessionMapImage, sessionMapImageExists } = require('../lib/session-map-svg')
/*
/app/views/sessions/
@@ -82,6 +83,7 @@ router.post('/sessions/03-slot-structure', function (req, res) {
res.redirect('/sessions/04-check-template')
})
+// Saves the draft as a template, overwriting the template being edited if there is one
router.post('/sessions/save-template', function (req, res) {
const template = req.session.data.createSessionTemplate || {}
const sessionSlots = buildSessionSlots(template)
@@ -92,8 +94,18 @@ router.post('/sessions/save-template', function (req, res) {
if (!Array.isArray(req.session.data.sessionTemplates)) {
req.session.data.sessionTemplates = []
}
+ const templates = req.session.data.sessionTemplates
- req.session.data.sessionTemplates.push({
+ const existingIndex = templates.findIndex((saved) => saved.id === req.session.data.editingTemplateId)
+ const existing = templates[existingIndex]
+ if (existing) {
+ deleteSessionMapImage(existing.imagePath)
+ templates.splice(existingIndex, 1)
+ }
+
+ // Pushed to the end so it lists first, as the listing is reversed
+ templates.push({
+ id: existing ? existing.id : randomUUID(),
name: template.sessionTemplateName,
note: template.sessionTemplateNote,
sessionStartLabel,
@@ -103,19 +115,48 @@ router.post('/sessions/save-template', function (req, res) {
openSlotCount,
heldSlotCount: bookableSlotCount - openSlotCount,
unavailableSlotCount: nonBookableSlotCount,
+ source: JSON.parse(JSON.stringify(template)),
...saveSessionMapImage(sessionSlots)
})
req.session.data.createSessionTemplate = {}
- req.session.data.sessionTemplateCreated = true
+ delete req.session.data.editingTemplateId
+ req.session.data.sessionTemplateSaved = existing ? 'updated' : 'created'
+
+ res.redirect('/sessions/00-template-listing')
+})
+
+router.get('/sessions/new-template', function (req, res) {
+ req.session.data.createSessionTemplate = {}
+ delete req.session.data.editingTemplateId
+
+ res.redirect('/sessions/01-template-name')
+})
+
+router.get('/sessions/edit-template/:id', function (req, res) {
+ const template = (req.session.data.sessionTemplates || []).find((saved) => saved.id === req.params.id)
+
+ if (!template || !template.source) {
+ return res.redirect('/sessions/00-template-listing')
+ }
+
+ req.session.data.createSessionTemplate = JSON.parse(JSON.stringify(template.source))
+ req.session.data.editingTemplateId = template.id
+
+ res.redirect('/sessions/04-check-template')
+})
+
+router.get('/sessions/cancel-edit', function (req, res) {
+ req.session.data.createSessionTemplate = {}
+ delete req.session.data.editingTemplateId
res.redirect('/sessions/00-template-listing')
})
// Shows the success banner once, straight after a template is saved
router.get('/sessions/00-template-listing', function (req, res) {
- const templateCreated = Boolean(req.session.data.sessionTemplateCreated)
- delete req.session.data.sessionTemplateCreated
+ const templateSaved = req.session.data.sessionTemplateSaved
+ delete req.session.data.sessionTemplateSaved
// Images can be pruned or lost on restart while a template still references them
const templates = req.session.data.sessionTemplates || []
@@ -126,11 +167,12 @@ router.get('/sessions/00-template-listing', function (req, res) {
}
})
- res.render('sessions/00-template-listing', { templateCreated })
+ res.render('sessions/00-template-listing', { templateSaved })
})
const sessionsSlotStructure = { dataKey: 'createSessionTemplate', view: 'sessions/03-slot-structure' }
router.post('/sessions/mark-staff-break', nonBookableHandler({ reason: 'staff break', ...sessionsSlotStructure }))
+router.post('/sessions/mark-staff-meeting', nonBookableHandler({ reason: 'staff meeting', ...sessionsSlotStructure }))
router.post('/sessions/clear-slot-type', clearTagsHandler(sessionsSlotStructure))
router.post('/sessions/mark-hold-rebooking', holdHandler({ reason: 'rebooking', ...sessionsSlotStructure }))
router.post('/sessions/mark-hold-web-rebooking', holdHandler({ reason: 'web rebooking', ...sessionsSlotStructure }))
diff --git a/app/routes/slot-structure-handlers.js b/app/routes/slot-structure-handlers.js
index 2e41c92..95839ca 100644
--- a/app/routes/slot-structure-handlers.js
+++ b/app/routes/slot-structure-handlers.js
@@ -81,7 +81,7 @@ const buildSessionSlots = (sessionData) => {
'Special appointment', 'Held', 'Standard'
])
sessionData.nonBookableBreakdown = breakdown(slots.filter((slot) => slot.isNonBookable), [
- 'Staff break', 'Non-bookable'
+ 'Staff break', 'Staff meeting', 'Non-bookable'
])
return {
diff --git a/app/views/sessions/00-template-listing.html b/app/views/sessions/00-template-listing.html
index 1118499..7007077 100644
--- a/app/views/sessions/00-template-listing.html
+++ b/app/views/sessions/00-template-listing.html
@@ -11,7 +11,7 @@
{% block content %}
-{% if templateCreated %}
+{% if templateSaved %}
@@ -48,7 +48,7 @@
@@ -62,11 +62,13 @@
{% if template.note %}
{{ template.note }}
{% endif %}
- {% if templateCreated and loop.first %}New {% endif %}
+ {% if templateSaved and loop.first %}{{ "Updated" if templateSaved == "updated" else "New" }} {% endif %}
-
+ {% if template.id and template.source %}
+
Edit this template
-
+
+ {% endif %}
diff --git a/app/views/sessions/01-template-name.html b/app/views/sessions/01-template-name.html
index 97ce8a6..797a165 100644
--- a/app/views/sessions/01-template-name.html
+++ b/app/views/sessions/01-template-name.html
@@ -27,7 +27,7 @@
Workflow steps
- Create session template
+ {{ "Edit" if data.editingTemplateId else "Create" }} session template
diff --git a/app/views/sessions/02-session-times.html b/app/views/sessions/02-session-times.html
index 361de68..275c86d 100644
--- a/app/views/sessions/02-session-times.html
+++ b/app/views/sessions/02-session-times.html
@@ -27,7 +27,7 @@
Workflow steps
- Create session template
+ {{ "Edit" if data.editingTemplateId else "Create" }} session template
diff --git a/app/views/sessions/03-slot-structure.html b/app/views/sessions/03-slot-structure.html
index 39dd562..acf0aa8 100644
--- a/app/views/sessions/03-slot-structure.html
+++ b/app/views/sessions/03-slot-structure.html
@@ -27,7 +27,7 @@
Workflow steps
- Create session template
+ {{ "Edit" if data.editingTemplateId else "Create" }} session template
@@ -143,6 +143,9 @@ Make n
Staff break
+
+ Staff meeting
+
Generic
@@ -185,6 +188,7 @@
var form = document.querySelector('#slots-container').closest('form')
var enhancedActions = [
'mark-staff-break',
+ 'mark-staff-meeting',
'mark-non-bookable-generic',
'clear-slot-type',
'mark-hold-rebooking',
diff --git a/app/views/sessions/04-check-template.html b/app/views/sessions/04-check-template.html
index bf9020f..5227aa2 100644
--- a/app/views/sessions/04-check-template.html
+++ b/app/views/sessions/04-check-template.html
@@ -24,7 +24,7 @@
Workflow steps
- Create session template
+ {{ "Edit" if data.editingTemplateId else "Create" }} session template
@@ -100,11 +100,14 @@ Check and confirm
{% else %}
@@ -118,11 +121,14 @@ Check and confirm
{% else %}
@@ -136,7 +142,14 @@ Check and confirm
+ {% if data.editingTemplateId %}
+
+ {% else %}
Save session template
+ {% endif %}
From 09945afd8e67e8eb88fcc7ef0017c935201aa7da Mon Sep 17 00:00:00 2001
From: Mat Johnson
Date: Thu, 15 Oct 2026 11:51:44 +0100
Subject: [PATCH 12/22] fingers crossed
---
package-lock.json | 8 --------
1 file changed, 8 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index abb3a9c..9b0d6d1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3953,7 +3953,6 @@
"cpu": [
"arm"
],
- "libc": "glibc",
"license": "MIT",
"optional": true,
"os": [
@@ -3970,7 +3969,6 @@
"cpu": [
"arm64"
],
- "libc": "glibc",
"license": "MIT",
"optional": true,
"os": [
@@ -3987,7 +3985,6 @@
"cpu": [
"arm"
],
- "libc": "musl",
"license": "MIT",
"optional": true,
"os": [
@@ -4004,7 +4001,6 @@
"cpu": [
"arm64"
],
- "libc": "musl",
"license": "MIT",
"optional": true,
"os": [
@@ -4021,7 +4017,6 @@
"cpu": [
"riscv64"
],
- "libc": "musl",
"license": "MIT",
"optional": true,
"os": [
@@ -4038,7 +4033,6 @@
"cpu": [
"x64"
],
- "libc": "musl",
"license": "MIT",
"optional": true,
"os": [
@@ -4055,7 +4049,6 @@
"cpu": [
"riscv64"
],
- "libc": "glibc",
"license": "MIT",
"optional": true,
"os": [
@@ -4072,7 +4065,6 @@
"cpu": [
"x64"
],
- "libc": "glibc",
"license": "MIT",
"optional": true,
"os": [
From 7e2fdd912dec38b544bd0c5d55087d7c83cc5bc8 Mon Sep 17 00:00:00 2001
From: Mat Johnson
Date: Thu, 15 Oct 2026 14:23:25 +0100
Subject: [PATCH 13/22] Fiddle with layout on template cards, start to draw up
a hub page for clinics
---
.../sass/components/_template-card.scss | 7 +-
app/views/clinics/index.html | 156 ++++++------------
app/views/index.html | 27 ++-
app/views/sessions/00-template-listing.html | 69 +++++---
4 files changed, 123 insertions(+), 136 deletions(-)
diff --git a/app/assets/sass/components/_template-card.scss b/app/assets/sass/components/_template-card.scss
index 6af5133..e299dc3 100644
--- a/app/assets/sass/components/_template-card.scss
+++ b/app/assets/sass/components/_template-card.scss
@@ -3,7 +3,7 @@
.app-session-template-card {
display: flex;
- gap: 1.75rem;
+ gap: 2rem;
}
.app-session-template-preview-image {
@@ -14,4 +14,9 @@
outline: 1px solid $nhsuk-input-border-colour;
margin-bottom: nhsuk-spacing(3);
}
+}
+
+.app-session-template-controls {
+ flex-shrink: 0;
+ width: max-content;
}
\ No newline at end of file
diff --git a/app/views/clinics/index.html b/app/views/clinics/index.html
index 155c633..37b0dd7 100644
--- a/app/views/clinics/index.html
+++ b/app/views/clinics/index.html
@@ -1,6 +1,6 @@
{% extends 'layout.html' %}
-{% set pageName = "Clinics" %}
+{% set pageName = "" %}
{% from "_includes/primary-navigation.html" import primaryNavigation %}
{% block header %}
@@ -11,115 +11,65 @@
{% block content %}
- {% set secondaryNavItems = [] %}
-
- {% for item in [
- { href: '/clinics/', label: 'Active clinics', selected: 'true' },
- { href: '#', label: 'Archived clinics' },
- { href: '/sessions/00-template-listing', label: 'Manage session templates' }
- ] %}
- {% set secondaryNavItems = secondaryNavItems | push({
- text: item.label | safe,
- href: item.href | trim,
- current: true if item.selected == 'true'
- }) %}
- {% endfor %}
-
- {{ appSecondaryNavigation({
- visuallyHiddenTitle: "Secondary menu",
- items: secondaryNavItems
- }) }}
-
-
-
Active clinics
- {{ button({
- text: "Create a new clinic",
- href: "00-create-day-clinic",
- classes: "nhsuk-button--secondary"
- }) }}
-
+
Clinics
-
-
+
+
+
+
+
+
+
+ Run screening sessions
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
- Chichester - St Richards
- HWO-NNN-standard-20260501
-
-
- Alpha van
- St Richard’s Hospital, Chichester
-
-
- 31 August 2026 to 9 October 2026
-
-
- 1500 slots available
- of which 150 held
-
-
-
-
-
- Chichester - St Richards
- HWO-NNN-standard-20260501
-
-
- Alpha van
- St Richard’s Hospital, Chichester
-
-
- No active schedules
-
-
- 0 slots available
-
-
-
-
-
-
-
+
+
+
+
+
+ Set up clinics, book participants
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/index.html b/app/views/index.html
index 516a302..e90c20b 100755
--- a/app/views/index.html
+++ b/app/views/index.html
@@ -97,15 +97,24 @@
superceded
-
Clinics: first run
-
-
- Clinic listing
-
-
- Clinic detail
-
-
+
+
+
+ Older work (may be broken, not maintained)
+
+
+
+
Clinics: first run
+
+
+ Clinic listing
+
+
+ Clinic detail
+
+
+
+
Clinic sessions (days)
diff --git a/app/views/sessions/00-template-listing.html b/app/views/sessions/00-template-listing.html
index 7007077..32cf203 100644
--- a/app/views/sessions/00-template-listing.html
+++ b/app/views/sessions/00-template-listing.html
@@ -64,11 +64,6 @@
{% endif %}
{% if templateSaved and loop.first %}{{ "Updated" if templateSaved == "updated" else "New" }} {% endif %}
- {% if template.id and template.source %}
-
- Edit this template
-
- {% endif %}
@@ -136,20 +131,29 @@
Slot breakdown
+
{% endfor %}
-
-
- Standard day
- This is where a short note would go
-
-
- Edit this template
-
-
+
+ Standard day
+ This is where a short note would go
+
@@ -212,19 +216,26 @@
Slot breakdown
+
+
+
+ Edit this template
+
+
+
+
+ Archive this template
+
+
+
-
-
- Special appointments
- 18 double appointment slots
-
-
- Edit this template
-
-
+
+ Special appointments
+ 18 double appointment slots
+
@@ -287,6 +298,18 @@
Slot breakdown
+
+
+
+ Edit this template
+
+
+
+
+ Archive this template
+
+
+
From b4dc58d6fdbb19ad07215259b2e6f4baf5ae7f37 Mon Sep 17 00:00:00 2001
From: Mat Johnson
Date: Mon, 19 Oct 2026 16:19:24 +0100
Subject: [PATCH 14/22] Working on template preview and display of combined
slots
---
.../sass/components/_session-slots.scss | 35 +-
.../sass/components/_template-card.scss | 11 +-
.../components/_template-preview-dialog.scss | 28 +-
app/routes/clinic-sessions.js | 6 +-
app/routes/slot-structure-handlers.js | 2 +
app/views/sessions/00-template-listing.html | 318 +++++-------------
app/views/sessions/03-slot-structure.html | 5 +-
7 files changed, 133 insertions(+), 272 deletions(-)
diff --git a/app/assets/sass/components/_session-slots.scss b/app/assets/sass/components/_session-slots.scss
index 239ae99..b4d6c08 100644
--- a/app/assets/sass/components/_session-slots.scss
+++ b/app/assets/sass/components/_session-slots.scss
@@ -18,11 +18,14 @@
border-right: 0;
}
+.slot .nhsuk-checkboxes__label {
+ width: 100%;
+}
+
.slot-end {
@include nhsuk-font-size(16);
margin-right: 1rem;
}
-//.slot-tag { margin-left: 1rem; }
.slot.is-hold {}
@@ -32,19 +35,18 @@
border-color: $nhsuk-border-colour;
}
-.slot.is-special-appointment {}
-
.slot.is-merged {
- border-bottom: none;
margin-bottom: 0;
}
-.slot.is-merged-child {
- border-top: 1px dashed #212b32;
- height: 42px;
-}
-.slot.is-merged-child > .nhsuk-checkboxes__item,
-.slot.is-merged-child > div {
- display: none;
+.slot.is-merged-child {
+ --stack-offset: calc(var(--stack-index, 1) * #{nhsuk-spacing(1)});
+ background-color: nhsuk-colour("grey-5");
+ border-top: 0;
+ height: 6px;
+ min-height: 0;
+ padding: 0;
+ margin-left: var(--stack-offset);
+ margin-right: var(--stack-offset);
}
.slot.is-merged-last-child {
border-bottom: 1px solid #212b32;
@@ -52,16 +54,7 @@
}
.slot.is-staff-break.is-merged-last-child {
- border-bottom-style: dashed;
-}
-
-.slot.is-merged-child > .slot-time,
-.slot.is-merged-child > .slot-end {
- display: none;
-}
-
-.slot .nhsuk-checkboxes__label {
- width: 100%;
+ border-color: $nhsuk-border-colour;
}
.panel-item { display: block; cursor: pointer; }
diff --git a/app/assets/sass/components/_template-card.scss b/app/assets/sass/components/_template-card.scss
index e299dc3..596a66d 100644
--- a/app/assets/sass/components/_template-card.scss
+++ b/app/assets/sass/components/_template-card.scss
@@ -3,20 +3,15 @@
.app-session-template-card {
display: flex;
- gap: 2rem;
+ gap: 1.5rem;
}
.app-session-template-preview-image {
- min-width: 10%;
+ min-width: 22.5%;
& > img {
display: block;
width: 100%;
outline: 1px solid $nhsuk-input-border-colour;
- margin-bottom: nhsuk-spacing(3);
+ margin-bottom: nhsuk-spacing(2);
}
}
-
-.app-session-template-controls {
- flex-shrink: 0;
- width: max-content;
-}
\ No newline at end of file
diff --git a/app/assets/sass/components/_template-preview-dialog.scss b/app/assets/sass/components/_template-preview-dialog.scss
index 9d9b9a9..8b90201 100644
--- a/app/assets/sass/components/_template-preview-dialog.scss
+++ b/app/assets/sass/components/_template-preview-dialog.scss
@@ -9,19 +9,26 @@
height: 90vh;
margin: 5vh auto;
padding: 0;
- border: none;
+ border: 2px solid nhsuk-colour("black");
+ background-color: nhsuk-colour("grey-5");
&::backdrop {
background: rgba(0, 0, 0, 0.5);
}
+}
- &__content {
- position: relative;
- }
+.app-template-preview-dialog--header {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ padding: nhsuk-spacing(5);
+ padding-bottom: nhsuk-spacing(3);
+ background-color: nhsuk-colour("grey-5");
+ border-bottom: 1px solid $nhsuk-border-colour;
+}
- &__close {
+.app-template-preview-dialog--close {
position: absolute;
- z-index: 1;
right: 0;
top: 0;
width: nhsuk-spacing(7);
@@ -43,11 +50,8 @@
outline: $nhsuk-border-width solid $nhsuk-focus-text-colour;
outline-offset: 0;
}
- }
+}
- &__frame {
- width: 100%;
- height: 90vh;
- border: none;
- }
+.app-template-preview-dialog--content {
+ padding: nhsuk-spacing(5);
}
diff --git a/app/routes/clinic-sessions.js b/app/routes/clinic-sessions.js
index 4c70d74..2f00ef9 100644
--- a/app/routes/clinic-sessions.js
+++ b/app/routes/clinic-sessions.js
@@ -88,8 +88,6 @@ router.post('/sessions/save-template', function (req, res) {
const template = req.session.data.createSessionTemplate || {}
const sessionSlots = buildSessionSlots(template)
const { bookableSlotCount, nonBookableSlotCount, sessionStartLabel, sessionEndLabel } = sessionSlots
- const standard = (template.bookableBreakdown || []).find((item) => item.label === 'Standard')
- const openSlotCount = standard ? standard.count : 0
if (!Array.isArray(req.session.data.sessionTemplates)) {
req.session.data.sessionTemplates = []
@@ -112,9 +110,7 @@ router.post('/sessions/save-template', function (req, res) {
sessionEndLabel,
duration: template.duration,
totalSlotCount: bookableSlotCount + nonBookableSlotCount,
- openSlotCount,
- heldSlotCount: bookableSlotCount - openSlotCount,
- unavailableSlotCount: nonBookableSlotCount,
+ slots: sessionSlots.slots,
source: JSON.parse(JSON.stringify(template)),
...saveSessionMapImage(sessionSlots)
})
diff --git a/app/routes/slot-structure-handlers.js b/app/routes/slot-structure-handlers.js
index 95839ca..1f90766 100644
--- a/app/routes/slot-structure-handlers.js
+++ b/app/routes/slot-structure-handlers.js
@@ -38,6 +38,8 @@ const buildSessionSlots = (sessionData) => {
endLabel: formatClock(startMinutes + ((lastIndex + 1) * duration)),
isCombined: Boolean(range),
span: lastIndex - i + 1,
+ // One placeholder per absorbed slot, rendered as empty divs to give the stacked card effect
+ mergedChildren: Array.from({ length: lastIndex - i }, (_, n) => n + 1),
isNonBookable: Boolean(nonBookableSlots[i]),
nonBookableReason: tagReason(nonBookableSlots[i]),
isHold: Boolean(heldSlots[i]),
diff --git a/app/views/sessions/00-template-listing.html b/app/views/sessions/00-template-listing.html
index 32cf203..c2cf554 100644
--- a/app/views/sessions/00-template-listing.html
+++ b/app/views/sessions/00-template-listing.html
@@ -51,19 +51,57 @@ Session templates
Create a new session template
-
-
+{% set exampleTemplates = [
+ {
+ example: true,
+ name: "Standard day with a ludicrous name that's probably really long",
+ note: "Pattern for our static site",
+ imagePath: "/assets/images/session-template-map-standard-day.svg",
+ sessionStartLabel: "09:00",
+ sessionEndLabel: "17:00",
+ duration: 10,
+ totalSlotCount: 42,
+ totalLabel: "Total slots"
+ },
+ {
+ example: true,
+ name: "Special appointments",
+ note: "This is where a short note would go is where a short note would go is where a short note would go",
+ imagePath: "/assets/images/session-template-map-specials-day.svg",
+ sessionStartLabel: "09:00",
+ sessionEndLabel: "17:00",
+ duration: 10,
+ totalSlotCount: 42,
+ totalLabel: "Total slots"
+ }
+] %}
+
+{# Session templates first (newest first), then the examples; a new grid row wraps every two cards #}
+{% set allTemplates = (data.sessionTemplates | reverse).concat(exampleTemplates) %}
- {% for template in data.sessionTemplates | reverse %}
+{% for template in allTemplates %}
+{% if loop.index0 % 2 == 0 %}
{% endif %}
+
-
- {{ template.name }}
+
+
+ {{ template.name }}
+ {% if templateSaved and loop.first and not template.example %}{{ "Updated" if templateSaved == "updated" else "New" }} {% endif %}
+
{% if template.note %}
-
{{ template.note }}
+
{{ template.note }}
{% endif %}
- {% if templateSaved and loop.first %}
{{ "Updated" if templateSaved == "updated" else "New" }} {% endif %}
-
+
+ {% if template.example %}
+
+ Edit template
+
+ {% elif template.id and template.source %}
+
+ Edit template
+
+ {% endif %}
@@ -72,7 +110,6 @@
{% else %}
{% endif %}
- Full preview
- Slot length
+ Base slot length
{{ template.duration }} minutes
@@ -94,236 +131,67 @@
- Total slots
+ {{ template.totalLabel or "Total slots" }}
{{ template.totalSlotCount }}
+
Full details
-
-
Slot breakdown
-
-
-
- Standard slots
-
-
- {{ template.openSlotCount if template.openSlotCount else 0 }}
-
-
-
-
- Held
-
-
- {{ template.heldSlotCount if template.heldSlotCount else 0 }}
-
-
-
-
- Unavailable to book
-
-
- {{ template.unavailableSlotCount if template.unavailableSlotCount else 0 }}
-
-
-
-
-
-
-
- {% endfor %}
-
-
-
- Standard day
- This is where a short note would go
-
-
-
-
-
Full preview
-
-
-
-
-
- Session times
-
-
- 09:00 to 17:00
-
-
-
-
- Slot length
-
-
- 10 minutes
-
-
-
-
- Total slots
-
-
- 42
-
-
-
-
-
-
Slot breakdown
-
-
-
- Standard slots
-
-
- 32
-
-
-
-
- Held
-
-
- 4
-
-
-
-
- Unavailable to book
-
-
- 6
-
-
-
-
-
-
-
- Edit this template
-
-
-
-
- Archive this template
-
-
-
+
+{% if loop.index0 % 2 == 1 or loop.last %}
{% endif %}
+{% endfor %}
-
-
- Special appointments
- 18 double appointment slots
-
-
-
-
-
Full preview
-
-
-
-
-
- Session times
-
-
- 09:00 to 17:00
-
-
-
-
- Slot length
-
-
- 10 minutes
-
-
-
-
- Total slots
-
-
- 42
-
-
-
-
-
-
Slot breakdown
-
-
-
- Standard slots
-
-
- 0
-
-
-
-
- Held
-
-
- 36
-
-
-
-
- Unavailable to book
-
-
- 6
-
-
-
-
-
-
-
- Edit this template
-
-
-
-
- Archive this template
-
-
-
+{% for template in data.sessionTemplates %}
+
+
+
+
+ {% for slot in template.slots %}
+
+ {{ slot.startLabel }}
+ to {{ slot.endLabel }}
+
+ {% if slot.isNonBookable %}
+ Non-bookable {% if slot.nonBookableReason %}{{ slot.nonBookableReason }} {% endif %}
+ {% elseif slot.isSpecialAppointment %}
+ Special appointment
+ {% elseif slot.isHold %}
+ Held {% if slot.holdReason %}{{ slot.holdReason }} {% endif %}
+ {% endif %}
+ {% if slot.isCombined %}
+ {{ slot.span }} slots
+ {% endif %}
+
+ {% for position in slot.mergedChildren %}
+
+ {% endfor %}
+ {% endfor %}
-
+{% endfor %}
-
{% endblock %}
{% block pageScripts %}{% endblock %}
\ No newline at end of file
diff --git a/app/views/sessions/03-slot-structure.html b/app/views/sessions/03-slot-structure.html
index acf0aa8..ebe2df3 100644
--- a/app/views/sessions/03-slot-structure.html
+++ b/app/views/sessions/03-slot-structure.html
@@ -88,7 +88,7 @@
{% for slot in slots %}
-
+
+ {% for position in slot.mergedChildren %}
+
+ {% endfor %}
{% endfor %}
From 40995ee644658a5466508d1a04caa682dfca8a75 Mon Sep 17 00:00:00 2001
From: Mat Johnson
Date: Tue, 20 Oct 2026 11:55:19 +0100
Subject: [PATCH 15/22] Fixing up layouts etc
---
.../sass/components/_session-slots.scss | 18 +++++--
app/assets/sass/main.scss | 4 +-
app/views/sessions/00-template-listing.html | 52 +++++++++----------
app/views/sessions/03-slot-structure.html | 7 ++-
app/views/sessions/day-slot-view.html | 4 +-
5 files changed, 46 insertions(+), 39 deletions(-)
diff --git a/app/assets/sass/components/_session-slots.scss b/app/assets/sass/components/_session-slots.scss
index b4d6c08..9a22507 100644
--- a/app/assets/sass/components/_session-slots.scss
+++ b/app/assets/sass/components/_session-slots.scss
@@ -22,15 +22,25 @@
width: 100%;
}
+.slot-time {
+ margin-right: nhsuk-px-to-rem(6px);
+}
.slot-end {
@include nhsuk-font-size(16);
margin-right: 1rem;
}
+// For slots whose time and tags are direct children, rather than inside a checkbox label
+.slot.slot--centred {
+ display: flex;
+ align-items: first baseline;
+ padding-top: nhsuk-px-to-rem(8px);
+}
+
.slot.is-hold {}
.slot.is-staff-break {
- background-color: transparent !important;
+ background-color: nhsuk-colour("grey-5") !important;
background-image: repeating-linear-gradient(130deg, nhsuk-colour("grey-4"), nhsuk-colour("grey-4") 1px, transparent 1px, transparent 10px);
border-color: $nhsuk-border-colour;
}
@@ -40,16 +50,16 @@
}
.slot.is-merged-child {
--stack-offset: calc(var(--stack-index, 1) * #{nhsuk-spacing(1)});
- background-color: nhsuk-colour("grey-5");
+ background-color: nhsuk-colour("white");
border-top: 0;
- height: 6px;
+ height: 9px;
min-height: 0;
padding: 0;
margin-left: var(--stack-offset);
margin-right: var(--stack-offset);
}
.slot.is-merged-last-child {
- border-bottom: 1px solid #212b32;
+ border-bottom: 1px solid $nhsuk-input-border-colour;
margin-bottom: 12px;
}
diff --git a/app/assets/sass/main.scss b/app/assets/sass/main.scss
index edeba8d..e30654d 100755
--- a/app/assets/sass/main.scss
+++ b/app/assets/sass/main.scss
@@ -210,8 +210,8 @@ a {
.nhsuk-grid-column-one-fifth {
@include nhsuk-grid-column(one-fifth);
}
-.app-tag--attached {
- margin-right: -1px;
+.app-tag--stacked {
+ margin-left: -1px;
}
.app-nowrap {
white-space: nowrap;
diff --git a/app/views/sessions/00-template-listing.html b/app/views/sessions/00-template-listing.html
index c2cf554..4493862 100644
--- a/app/views/sessions/00-template-listing.html
+++ b/app/views/sessions/00-template-listing.html
@@ -94,11 +94,11 @@
{% endif %}
{% if template.example %}
-
+
Edit template
{% elif template.id and template.source %}
-
+
Edit template
{% endif %}
@@ -138,7 +138,7 @@
-
Full details
+
Full details
@@ -147,7 +147,7 @@
{% endfor %}
{% for template in data.sessionTemplates %}
-
+
-
- {% for slot in template.slots %}
-
- {{ slot.startLabel }}
- to {{ slot.endLabel }}
-
- {% if slot.isNonBookable %}
- Non-bookable {% if slot.nonBookableReason %}{{ slot.nonBookableReason }} {% endif %}
- {% elseif slot.isSpecialAppointment %}
- Special appointment
- {% elseif slot.isHold %}
- Held {% if slot.holdReason %}{{ slot.holdReason }} {% endif %}
- {% endif %}
- {% if slot.isCombined %}
- {{ slot.span }} slots
- {% endif %}
-
-
- {% for position in slot.mergedChildren %}
-
- {% endfor %}
- {% endfor %}
+ {% for slot in template.slots %}
+
+ {{ slot.startLabel }}
+ to {{ slot.endLabel }}
+
+ {% if slot.isNonBookable %}
+ Non-bookable {% if slot.nonBookableReason %}{{ slot.nonBookableReason }} {% endif %}
+ {% elseif slot.isSpecialAppointment %}
+ Special appointment
+ {% elseif slot.isHold %}
+ Held {% if slot.holdReason %}{{ slot.holdReason }} {% endif %}
+ {% endif %}
+ {% if slot.isCombined %}
+ {{ slot.span }} slots
+ {% endif %}
+
+ {% for position in slot.mergedChildren %}
+
+ {% endfor %}
+ {% endfor %}
-
+
{% endfor %}
{% endblock %}
diff --git a/app/views/sessions/03-slot-structure.html b/app/views/sessions/03-slot-structure.html
index ebe2df3..c1880df 100644
--- a/app/views/sessions/03-slot-structure.html
+++ b/app/views/sessions/03-slot-structure.html
@@ -92,15 +92,14 @@
- {{ slot.startLabel }}
- to {{ slot.endLabel }}
+ {{ slot.startLabel }} to {{ slot.endLabel }}
{% if slot.isNonBookable %}
- Non-bookable {% if slot.nonBookableReason %}{{ slot.nonBookableReason }} {% endif %}
+ Non-bookable {% if slot.nonBookableReason %}{{ slot.nonBookableReason }} {% endif %}
{% elseif slot.isSpecialAppointment %}
Special appointment
{% elseif slot.isHold %}
- Held {% if slot.holdReason %}{{ slot.holdReason }} {% endif %}
+ Held {% if slot.holdReason %}{{ slot.holdReason }} {% endif %}
{% endif %}
{% if slot.isCombined %}
{{ slot.span }} slots
diff --git a/app/views/sessions/day-slot-view.html b/app/views/sessions/day-slot-view.html
index 6271257..9129b28 100644
--- a/app/views/sessions/day-slot-view.html
+++ b/app/views/sessions/day-slot-view.html
@@ -930,9 +930,9 @@
-
+
Special appointment
-
+
3 slots
From ace5be5e85ae7436f55b148ecf3fe75c082cd1b6 Mon Sep 17 00:00:00 2001
From: Mat Johnson
Date: Tue, 20 Oct 2026 13:14:19 +0100
Subject: [PATCH 16/22] Sharing session templates across template prototype and
create capacity from zero
---
app/assets/sass/main.scss | 7 +-
app/data/session-data-defaults.js | 30 +++-
app/routes/create-capacity-from-zero.js | 21 ++-
.../select-session-template.html | 162 ++++--------------
app/views/sessions/00-template-listing.html | 27 +--
5 files changed, 84 insertions(+), 163 deletions(-)
diff --git a/app/assets/sass/main.scss b/app/assets/sass/main.scss
index e30654d..954912e 100755
--- a/app/assets/sass/main.scss
+++ b/app/assets/sass/main.scss
@@ -107,7 +107,12 @@ a {
.app-card-editable {
display: flex;
justify-content: space-between;
- align-items: baseline;
+ gap: 1rem;
+ align-items: first baseline;
+
+ // & > .nhsuk-button {
+ // vertical-align: baseline;
+ // }
}
.app-link--error,
diff --git a/app/data/session-data-defaults.js b/app/data/session-data-defaults.js
index 9cb2596..d735da4 100644
--- a/app/data/session-data-defaults.js
+++ b/app/data/session-data-defaults.js
@@ -23,5 +23,33 @@ module.exports = {
createSessionTemplate: {
},
- sessionTemplates: []
+ // Listings reverse this array, so the last entry shows first
+ sessionTemplates: [
+ {
+ id: 'example-special-appointments',
+ example: true,
+ slug: 'special-appointments',
+ name: "Special appointments",
+ note: "This is where a short note would go is where a short note would go is where a short note would go",
+ imagePath: "/assets/images/session-template-map-specials-day.svg",
+ sessionStartLabel: "09:00",
+ sessionEndLabel: "17:00",
+ duration: 10,
+ totalSlotCount: 42,
+ totalLabel: "Total slots"
+ },
+ {
+ id: 'example-standard',
+ example: true,
+ slug: 'standard',
+ name: "Standard day with a ludicrous name that's probably really long",
+ note: "Pattern for our static site",
+ imagePath: "/assets/images/session-template-map-standard-day.svg",
+ sessionStartLabel: "09:00",
+ sessionEndLabel: "17:00",
+ duration: 10,
+ totalSlotCount: 42,
+ totalLabel: "Total slots"
+ }
+ ]
}
\ No newline at end of file
diff --git a/app/routes/create-capacity-from-zero.js b/app/routes/create-capacity-from-zero.js
index 84ce5b6..e4d1e39 100644
--- a/app/routes/create-capacity-from-zero.js
+++ b/app/routes/create-capacity-from-zero.js
@@ -11,8 +11,7 @@ const router = express.Router()
// Slot/held totals per session template, matching select-session-template.html
const sessionTemplates = {
'Standard': { totalSlots: 42, heldSlots: 8 },
- 'Special appointments': { totalSlots: 42, heldSlots: 42 },
- 'Rubie default': { totalSlots: 42, heldSlots: 0 }
+ 'Special appointments': { totalSlots: 42, heldSlots: 42 }
}
const isWholeNumber = str => /^\d+$/.test(str)
@@ -178,7 +177,10 @@ router.get('/create-capacity-from-zero/clinic-summary', function (req, res) {
}
const templateName = dayTemplates[day.dateKey]
- const template = sessionTemplates[templateName] || {}
+ const savedTemplate = (req.session.data.sessionTemplates || [])
+ .find(saved => !saved.example && saved.name === templateName)
+ const template = sessionTemplates[templateName] ||
+ (savedTemplate && { totalSlots: savedTemplate.totalSlotCount, heldSlots: 0 }) || {}
return Object.assign({}, day, {
templateName,
@@ -209,8 +211,17 @@ router.post('/create-capacity-from-zero/select-days', function (req, res) {
res.redirect('/create-capacity-from-zero/select-session-template')
})
-router.post('/create-capacity-from-zero/apply-session-template', function (req, res) {
- const templateName = (req.body.templateName || '').trim()
+const exampleTemplateSlugs = {
+ 'standard': 'Standard',
+ 'special-appointments': 'Special appointments'
+}
+
+// Accepts an example template's slug or a user-created template's id
+router.get('/create-capacity-from-zero/apply-session-template/:templateKey', function (req, res) {
+ const { templateKey } = req.params
+ const savedTemplate = (req.session.data.sessionTemplates || [])
+ .find(saved => !saved.example && saved.id === templateKey)
+ const templateName = exampleTemplateSlugs[templateKey] || (savedTemplate && savedTemplate.name)
const selectedDays = req.session.data.createCapacityFromZero.selectedDays || []
if (!req.session.data.createCapacityFromZero.dayTemplates) {
diff --git a/app/views/create-capacity-from-zero/select-session-template.html b/app/views/create-capacity-from-zero/select-session-template.html
index 886476e..0d8792a 100644
--- a/app/views/create-capacity-from-zero/select-session-template.html
+++ b/app/views/create-capacity-from-zero/select-session-template.html
@@ -15,171 +15,73 @@
{% block content %}
-
-
+{# Session templates first (newest first), then the examples; a new grid row wraps every two cards #}
+{% set allTemplates = (data.sessionTemplates | reverse) %}
+{% for template in allTemplates %}
+{% if loop.index0 % 2 == 0 %}
{% endif %}
+
-
- Standard day
- This is where a short note would go
-
-
-
-
- Select this template
-
-
-
-
-
-
-
Full preview
-
-
-
-
- Total capacity
-
-
- 42 slots
-
-
-
-
- Session times
-
-
- 09:00 to 17:00
-
-
-
-
- Slot length
-
-
- 10 minutes
-
-
-
-
-
-
Slot breakdown
-
-
-
- Open slots
-
-
- 32 total
-
-
-
-
- Held slots
-
-
- 4 total
-
-
-
-
- Unavailable to book
-
-
- 6 total
-
-
-
+
+ {{ template.name }}
+ {% if templateSaved and loop.first and not template.example %}{{ "Updated" if templateSaved == "updated" else "New" }} {% endif %}
+
+ {% if template.note %}
+
{{ template.note }}
+ {% endif %}
-
-
-
-
-
-
- Special appointments
- 18 double appointment slots
-
-
-
-
- Select this template
-
-
+ {% if template.slug or template.id %}
+
Select template
+ {% endif %}
-
-
Full preview
+ {% if template.imagePath %}
+
+ {% else %}
+
+ {% endif %}
-
-
- Total capacity
-
-
- 42 slots
-
-
Session times
- 09:00 to 17:00
+ {{ template.sessionStartLabel }} to {{ template.sessionEndLabel }}
- Slot length
+ Base slot length
- 10 minutes
-
-
-
-
-
-
Slot breakdown
-
-
-
- Open slots
-
-
- 0
+ {{ template.duration }} minutes
- Held slots
+ {{ template.totalLabel or "Total slots" }}
- 36 total
-
-
-
-
- Unavailable to book
-
-
- 6 total
+ {{ template.totalSlotCount }}
+
Full details
-
-
- Create new session template
-
-
-
+{% if loop.index0 % 2 == 1 or loop.last %}
{% endif %}
+{% endfor %}
+
+
+ Create a new session template
+
diff --git a/app/views/sessions/00-template-listing.html b/app/views/sessions/00-template-listing.html
index 4493862..070522c 100644
--- a/app/views/sessions/00-template-listing.html
+++ b/app/views/sessions/00-template-listing.html
@@ -51,33 +51,8 @@ Session templates
Create a new session template
-{% set exampleTemplates = [
- {
- example: true,
- name: "Standard day with a ludicrous name that's probably really long",
- note: "Pattern for our static site",
- imagePath: "/assets/images/session-template-map-standard-day.svg",
- sessionStartLabel: "09:00",
- sessionEndLabel: "17:00",
- duration: 10,
- totalSlotCount: 42,
- totalLabel: "Total slots"
- },
- {
- example: true,
- name: "Special appointments",
- note: "This is where a short note would go is where a short note would go is where a short note would go",
- imagePath: "/assets/images/session-template-map-specials-day.svg",
- sessionStartLabel: "09:00",
- sessionEndLabel: "17:00",
- duration: 10,
- totalSlotCount: 42,
- totalLabel: "Total slots"
- }
-] %}
-
{# Session templates first (newest first), then the examples; a new grid row wraps every two cards #}
-{% set allTemplates = (data.sessionTemplates | reverse).concat(exampleTemplates) %}
+{% set allTemplates = (data.sessionTemplates | reverse) %}
{% for template in allTemplates %}
{% if loop.index0 % 2 == 0 %}{% endif %}
From db4f08d28422e7dcbd37e32e593b70332327dfae Mon Sep 17 00:00:00 2001
From: Mat Johnson
Date: Tue, 20 Oct 2026 13:16:58 +0100
Subject: [PATCH 17/22] Removing old preview
---
.../select-session-template-full-preview.html | 480 ------------------
.../select-session-template.html | 10 -
2 files changed, 490 deletions(-)
delete mode 100644 app/views/create-capacity-from-zero/select-session-template-full-preview.html
diff --git a/app/views/create-capacity-from-zero/select-session-template-full-preview.html b/app/views/create-capacity-from-zero/select-session-template-full-preview.html
deleted file mode 100644
index 751a678..0000000
--- a/app/views/create-capacity-from-zero/select-session-template-full-preview.html
+++ /dev/null
@@ -1,480 +0,0 @@
-{% extends 'layout.html' %}
-
-{% block header %}{% endblock %}
-
-{% block beforeContent %}
-
-{% endblock %}
-
-{% block content %}
-
-
-
-
-
-
-
-
- Template details
- Standard
-
-
- Session time
- 09:00 to 17:00
-
-
-
-
-
-
- 09:00
- to 09:20
- Special appointment
- Merged (2)
-
-
-
-
- 09:10
- to 09:20
-
- Merged (1)
-
-
-
-
- 09:20
- to 09:40
- Special appointment
- Merged (2)
-
-
-
-
- 09:30
- to 09:40
-
- Merged (1)
-
-
-
-
- 09:40
- to 09:50
-
-
-
-
-
-
- 09:50
- to 10:00
-
-
-
-
-
-
- 10:00
- to 10:10
-
-
-
-
-
-
- 10:10
- to 10:20
-
-
-
-
-
-
- 10:20
- to 10:30
-
-
-
-
-
-
- 10:30
- to 10:40
-
-
-
-
-
-
- 10:40
- to 10:50
-
-
-
-
-
-
- 10:50
- to 11:00
-
-
-
-
-
-
- 11:00
- to 11:10
-
-
-
-
-
-
- 11:10
- to 11:20
-
-
-
-
-
-
- 11:20
- to 11:30
-
-
-
-
-
-
- 11:30
- to 11:40
-
-
-
-
-
-
- 11:40
- to 11:50
-
-
-
-
-
-
- 11:50
- to 12:00
-
-
-
-
-
-
- 12:00
- to 12:10
-
-
-
-
-
-
- 12:10
- to 12:20
-
-
-
-
-
-
- 12:20
- to 12:30
-
-
-
-
-
-
- 12:30
- to 12:40
-
-
-
-
-
-
- 12:40
- to 12:50
-
-
-
-
-
-
- 12:50
- to 13:00
-
-
-
-
-
-
- 13:00
- to 14:00
- Staff break
- Merged (6)
-
-
-
-
- 13:10
- to 13:20
-
- Merged (1)
-
-
-
-
- 13:20
- to 13:30
-
- Merged (1)
-
-
-
-
- 13:30
- to 13:40
-
- Merged (1)
-
-
-
-
- 13:40
- to 13:50
-
- Merged (1)
-
-
-
-
- 13:50
- to 14:00
-
- Merged (1)
-
-
-
-
- 14:00
- to 14:10
-
-
-
-
-
-
- 14:10
- to 14:20
-
-
-
-
-
-
- 14:20
- to 14:30
-
-
-
-
-
-
- 14:30
- to 14:40
-
-
-
-
-
-
- 14:40
- to 14:50
-
-
-
-
-
-
- 14:50
- to 15:00
-
-
-
-
-
-
- 15:00
- to 15:10
-
-
-
-
-
-
- 15:10
- to 15:20
-
-
-
-
-
-
- 15:20
- to 15:30
-
-
-
-
-
-
- 15:30
- to 15:40
-
-
-
-
-
-
- 15:40
- to 15:50
-
-
-
-
-
-
- 15:50
- to 16:00
-
-
-
-
-
-
- 16:00
- to 16:10
-
-
-
-
-
-
- 16:10
- to 16:20
-
-
-
-
-
-
- 16:20
- to 16:30
- Held
-
-
-
-
-
- 16:30
- to 16:40
- Held
-
-
-
-
-
- 16:40
- to 16:50
- Held
-
-
-
-
-
- 16:50
- to 17:00
- Held
-
-
-
-
-
-
-
-
-{% endblock %}
-{% block footer %}{% endblock %}
\ No newline at end of file
diff --git a/app/views/create-capacity-from-zero/select-session-template.html b/app/views/create-capacity-from-zero/select-session-template.html
index 0d8792a..edebff3 100644
--- a/app/views/create-capacity-from-zero/select-session-template.html
+++ b/app/views/create-capacity-from-zero/select-session-template.html
@@ -83,14 +83,4 @@
Create a new session template
-
-
-
-
- ×
- Close full template preview
-
-
-
-
{% endblock %}
\ No newline at end of file
From 9f2cc1384d14779bd17ef8af0d2ea06f2fa2bae5 Mon Sep 17 00:00:00 2001
From: Mat Johnson
Date: Tue, 20 Oct 2026 17:02:34 +0100
Subject: [PATCH 18/22] Semi big IA reckons, trying to tie session templates
meaningfully into clinic creation
---
.../components/_secondary-navigation.scss | 7 +
app/data/session-data-defaults.js | 178 +++++++++++++++++-
app/routes/clinic-sessions.js | 2 +-
app/routes/create-capacity-from-zero.js | 6 +-
app/views/_includes/primary-navigation.html | 10 +-
.../clinic-summary-empty.html | 2 +-
.../clinic-summary-in-progress.html | 2 +-
.../clinic-summary.html | 48 ++++-
.../create-clinic.html | 2 +-
.../create-capacity-from-zero/index.html | 137 +++++++-------
.../open-clinics.html | 85 +++++++++
.../select-session-template.html | 4 -
app/views/sessions/00-template-listing.html | 48 +++--
13 files changed, 408 insertions(+), 123 deletions(-)
create mode 100644 app/views/create-capacity-from-zero/open-clinics.html
diff --git a/app/assets/sass/components/_secondary-navigation.scss b/app/assets/sass/components/_secondary-navigation.scss
index 345b6c0..90c0ad5 100644
--- a/app/assets/sass/components/_secondary-navigation.scss
+++ b/app/assets/sass/components/_secondary-navigation.scss
@@ -94,3 +94,10 @@
.app-secondary-navigation__list-item {
margin-bottom: 0;
}
+
+// Pushes a list item to the right-hand end of the row; add alongside .app-secondary-navigation__list-item
+.app-secondary-navigation--aside {
+ @include nhsuk-media-query($from: tablet) {
+ margin-left: auto;
+ }
+}
diff --git a/app/data/session-data-defaults.js b/app/data/session-data-defaults.js
index d735da4..40b2268 100644
--- a/app/data/session-data-defaults.js
+++ b/app/data/session-data-defaults.js
@@ -1,5 +1,31 @@
const participants = require('./participants')
+const formatTime = (minutes) =>
+ `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`
+
+// 10:00 to 17:00 in ten-minute base slots: pairs are combined into 20-minute special appointments, with a staff break 13:00 to 14:00
+const specialsDayCombinedRanges = [
+ ...Array.from({ length: 9 }, (_, n) => ({ start: n * 2, end: n * 2 + 1 })),
+ ...Array.from({ length: 9 }, (_, n) => ({ start: 24 + n * 2, end: 24 + n * 2 + 1 }))
+]
+const specialsDaySlots = [
+ ...specialsDayCombinedRanges.filter(range => range.start < 18),
+ ...Array.from({ length: 6 }, (_, n) => ({ start: 18 + n, end: 18 + n, isBreak: true })),
+ ...specialsDayCombinedRanges.filter(range => range.start >= 24)
+].map(({ start, end, isBreak }) => ({
+ index: start,
+ startLabel: formatTime(600 + start * 10),
+ endLabel: formatTime(610 + end * 10),
+ isCombined: !isBreak,
+ span: end - start + 1,
+ mergedChildren: isBreak ? [] : [1],
+ isNonBookable: Boolean(isBreak),
+ nonBookableReason: isBreak ? 'staff break' : null,
+ isHold: false,
+ holdReason: null,
+ isSpecialAppointment: !isBreak
+}))
+
module.exports = {
participants,
stagedCount: 0,
@@ -30,26 +56,166 @@ module.exports = {
example: true,
slug: 'special-appointments',
name: "Special appointments",
- note: "This is where a short note would go is where a short note would go is where a short note would go",
+ note: "Day long session consisting of double length specials",
imagePath: "/assets/images/session-template-map-specials-day.svg",
- sessionStartLabel: "09:00",
+ sessionStartLabel: "10:00",
sessionEndLabel: "17:00",
duration: 10,
totalSlotCount: 42,
- totalLabel: "Total slots"
+ slots: specialsDaySlots,
+ source: {
+ sessionTemplateName: 'Special appointments',
+ sessionTemplateNote: 'Day long session consisting of double length specials',
+ startTime: {
+ hour: '10',
+ minute: '00'
+ },
+ endTime: {
+ hour: '17',
+ minute: '00'
+ },
+ duration: '10',
+ bookableSlotCount: 36,
+ nonBookableSlotCount: 6,
+ bookableBreakdown: [
+ {
+ label: 'Special appointment',
+ count: 36
+ }
+ ],
+ nonBookableBreakdown: [
+ {
+ label: 'Staff break',
+ count: 6
+ }
+ ],
+ heldSlots: {},
+ nonBookableSlots: {
+ '18': 'staff break',
+ '19': 'staff break',
+ '20': 'staff break',
+ '21': 'staff break',
+ '22': 'staff break',
+ '23': 'staff break'
+ },
+ specialAppointmentSlots: [
+ 0, 2, 4, 6, 8, 10, 12, 14, 16,
+ 24, 26, 28, 30, 32, 34, 36, 38, 40
+ ],
+ combinedSlots: specialsDayCombinedRanges
+ }
},
{
id: 'example-standard',
example: true,
slug: 'standard',
- name: "Standard day with a ludicrous name that's probably really long",
+ name: "Standard day",
note: "Pattern for our static site",
imagePath: "/assets/images/session-template-map-standard-day.svg",
- sessionStartLabel: "09:00",
+ imageAlt: "42 slots from 10:00 to 17:00. Special appointments 10:00 to 10:40. Non-bookable 13:00 to 14:00. Held 16:20 to 17:00.",
+ sessionStartLabel: "10:00",
sessionEndLabel: "17:00",
duration: 10,
totalSlotCount: 42,
- totalLabel: "Total slots"
+ slots: [
+ {index: 0, startLabel: '10:00', endLabel: '10:10', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: true},
+ {index: 1, startLabel: '10:10', endLabel: '10:20', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: true},
+ {index: 2, startLabel: '10:20', endLabel: '10:30', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: true},
+ {index: 3, startLabel: '10:30', endLabel: '10:40', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: true},
+ {index: 4, startLabel: '10:40', endLabel: '10:50', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 5, startLabel: '10:50', endLabel: '11:00', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 6, startLabel: '11:00', endLabel: '11:10', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 7, startLabel: '11:10', endLabel: '11:20', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 8, startLabel: '11:20', endLabel: '11:30', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 9, startLabel: '11:30', endLabel: '11:40', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 10, startLabel: '11:40', endLabel: '11:50', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 11, startLabel: '11:50', endLabel: '12:00', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 12, startLabel: '12:00', endLabel: '12:10', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 13, startLabel: '12:10', endLabel: '12:20', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 14, startLabel: '12:20', endLabel: '12:30', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 15, startLabel: '12:30', endLabel: '12:40', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 16, startLabel: '12:40', endLabel: '12:50', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 17, startLabel: '12:50', endLabel: '13:00', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 18, startLabel: '13:00', endLabel: '13:10', isCombined: false, span: 1, mergedChildren: [], isNonBookable: true, nonBookableReason: 'staff break', isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 19, startLabel: '13:10', endLabel: '13:20', isCombined: false, span: 1, mergedChildren: [], isNonBookable: true, nonBookableReason: 'staff break', isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 20, startLabel: '13:20', endLabel: '13:30', isCombined: false, span: 1, mergedChildren: [], isNonBookable: true, nonBookableReason: 'staff break', isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 21, startLabel: '13:30', endLabel: '13:40', isCombined: false, span: 1, mergedChildren: [], isNonBookable: true, nonBookableReason: 'staff break', isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 22, startLabel: '13:40', endLabel: '13:50', isCombined: false, span: 1, mergedChildren: [], isNonBookable: true, nonBookableReason: 'staff break', isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 23, startLabel: '13:50', endLabel: '14:00', isCombined: false, span: 1, mergedChildren: [], isNonBookable: true, nonBookableReason: 'staff break', isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 24, startLabel: '14:00', endLabel: '14:10', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 25, startLabel: '14:10', endLabel: '14:20', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 26, startLabel: '14:20', endLabel: '14:30', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 27, startLabel: '14:30', endLabel: '14:40', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 28, startLabel: '14:40', endLabel: '14:50', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 29, startLabel: '14:50', endLabel: '15:00', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 30, startLabel: '15:00', endLabel: '15:10', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 31, startLabel: '15:10', endLabel: '15:20', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 32, startLabel: '15:20', endLabel: '15:30', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 33, startLabel: '15:30', endLabel: '15:40', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 34, startLabel: '15:40', endLabel: '15:50', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 35, startLabel: '15:50', endLabel: '16:00', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 36, startLabel: '16:00', endLabel: '16:10', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 37, startLabel: '16:10', endLabel: '16:20', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: false, holdReason: null, isSpecialAppointment: false},
+ {index: 38, startLabel: '16:20', endLabel: '16:30', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: true, holdReason: 'rebooking', isSpecialAppointment: false},
+ {index: 39, startLabel: '16:30', endLabel: '16:40', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: true, holdReason: 'rebooking', isSpecialAppointment: false},
+ {index: 40, startLabel: '16:40', endLabel: '16:50', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: true, holdReason: 'rebooking', isSpecialAppointment: false},
+ {index: 41, startLabel: '16:50', endLabel: '17:00', isCombined: false, span: 1, mergedChildren: [], isNonBookable: false, nonBookableReason: null, isHold: true, holdReason: 'rebooking', isSpecialAppointment: false}
+ ],
+ source: {
+ sessionTemplateName: 'Standard day',
+ sessionTemplateNote: 'Pattern for our static site',
+ startTime: {
+ hour: '10',
+ minute: '00'
+ },
+ endTime: {
+ hour: '17',
+ minute: '00'
+ },
+ duration: '10',
+ bookableSlotCount: 36,
+ nonBookableSlotCount: 6,
+ bookableBreakdown: [
+ {
+ label: 'Held for rebooking',
+ count: 4
+ },
+ {
+ label: 'Special appointment',
+ count: 4
+ },
+ {
+ label: 'Standard',
+ count: 28
+ }
+ ],
+ nonBookableBreakdown: [
+ {
+ label: 'Staff break',
+ count: 6
+ }
+ ],
+ heldSlots: {
+ '38': 'rebooking',
+ '39': 'rebooking',
+ '40': 'rebooking',
+ '41': 'rebooking'
+ },
+ nonBookableSlots: {
+ '18': 'staff break',
+ '19': 'staff break',
+ '20': 'staff break',
+ '21': 'staff break',
+ '22': 'staff break',
+ '23': 'staff break'
+ },
+ specialAppointmentSlots: [
+ 0,
+ 1,
+ 2,
+ 3
+ ]
+ }
}
]
}
\ No newline at end of file
diff --git a/app/routes/clinic-sessions.js b/app/routes/clinic-sessions.js
index 2f00ef9..6e310de 100644
--- a/app/routes/clinic-sessions.js
+++ b/app/routes/clinic-sessions.js
@@ -157,7 +157,7 @@ router.get('/sessions/00-template-listing', function (req, res) {
// Images can be pruned or lost on restart while a template still references them
const templates = req.session.data.sessionTemplates || []
templates.forEach((template) => {
- if (template.imagePath && !sessionMapImageExists(template.imagePath)) {
+ if (!template.example && template.imagePath && !sessionMapImageExists(template.imagePath)) {
delete template.imagePath
delete template.imageAlt
}
diff --git a/app/routes/create-capacity-from-zero.js b/app/routes/create-capacity-from-zero.js
index e4d1e39..cd24fe1 100644
--- a/app/routes/create-capacity-from-zero.js
+++ b/app/routes/create-capacity-from-zero.js
@@ -177,13 +177,15 @@ router.get('/create-capacity-from-zero/clinic-summary', function (req, res) {
}
const templateName = dayTemplates[day.dateKey]
- const savedTemplate = (req.session.data.sessionTemplates || [])
- .find(saved => !saved.example && saved.name === templateName)
+ const matchedTemplate = (req.session.data.sessionTemplates || [])
+ .find(saved => saved.name === templateName || (saved.slug && exampleTemplateSlugs[saved.slug] === templateName))
+ const savedTemplate = matchedTemplate && !matchedTemplate.example ? matchedTemplate : null
const template = sessionTemplates[templateName] ||
(savedTemplate && { totalSlots: savedTemplate.totalSlotCount, heldSlots: 0 }) || {}
return Object.assign({}, day, {
templateName,
+ templateId: matchedTemplate && matchedTemplate.id,
totalSlots: template.totalSlots,
heldSlots: template.heldSlots
})
diff --git a/app/views/_includes/primary-navigation.html b/app/views/_includes/primary-navigation.html
index 6afab2c..98efb28 100644
--- a/app/views/_includes/primary-navigation.html
+++ b/app/views/_includes/primary-navigation.html
@@ -34,16 +34,16 @@
text: "Batches",
active: activeItem === "Batches"
},
- {
- href: "/create-capacity-from-zero/",
- text: "Clinics",
- active: activeItem === "Clinics"
- },
{
href: "/participants/",
text: "Participants",
active: activeItem === "Participants"
},
+ {
+ href: "/create-capacity-from-zero/",
+ text: "Clinics",
+ active: activeItem === "Clinics"
+ },
{
href: "#",
text: "Image reading",
diff --git a/app/views/create-capacity-from-zero/clinic-summary-empty.html b/app/views/create-capacity-from-zero/clinic-summary-empty.html
index 19d34bb..bc2c67c 100644
--- a/app/views/create-capacity-from-zero/clinic-summary-empty.html
+++ b/app/views/create-capacity-from-zero/clinic-summary-empty.html
@@ -8,7 +8,7 @@
{% endblock %}
{% block beforeContent %}
-
+
Back to clinic listing
{% endblock %}
diff --git a/app/views/create-capacity-from-zero/clinic-summary-in-progress.html b/app/views/create-capacity-from-zero/clinic-summary-in-progress.html
index e444b5f..fab4cfd 100644
--- a/app/views/create-capacity-from-zero/clinic-summary-in-progress.html
+++ b/app/views/create-capacity-from-zero/clinic-summary-in-progress.html
@@ -20,7 +20,7 @@
{% endblock %}
{% block beforeContent %}
-
+
Back to clinic listing
{% endblock %}
diff --git a/app/views/create-capacity-from-zero/clinic-summary.html b/app/views/create-capacity-from-zero/clinic-summary.html
index 5724c3a..873a103 100644
--- a/app/views/create-capacity-from-zero/clinic-summary.html
+++ b/app/views/create-capacity-from-zero/clinic-summary.html
@@ -8,7 +8,7 @@
{% endblock %}
{% block beforeContent %}
-
+
Back to clinic listing
{% endblock %}
@@ -57,7 +57,7 @@
{{ dayItem.date }}
{% if dayItem.templateName %}
{{ padlockSvg | safe }}
- {{ dayItem.templateName }}
+ {{ dayItem.templateName }}
{{ dayItem.totalSlots }} slots
0 booked
{{ dayItem.heldSlots }} held
@@ -105,7 +105,49 @@ With selected days:
- {% include "create-capacity-from-zero/_local-includes/template-preview.html" %}
+{% for template in data.sessionTemplates %}
+
+
+
+ {% for slot in template.slots %}
+
+ {{ slot.startLabel }}
+ to {{ slot.endLabel }}
+
+ {% if slot.isNonBookable %}
+ Non-bookable {% if slot.nonBookableReason %}{{ slot.nonBookableReason }} {% endif %}
+ {% elseif slot.isSpecialAppointment %}
+ Special appointment
+ {% elseif slot.isHold %}
+ Held {% if slot.holdReason %}{{ slot.holdReason }} {% endif %}
+ {% endif %}
+ {% if slot.isCombined %}
+ {{ slot.span }} slots
+ {% endif %}
+
+
+ {% for position in slot.mergedChildren %}
+
+ {% endfor %}
+ {% endfor %}
+
+
+{% endfor %}
{% endblock %}
diff --git a/app/views/create-capacity-from-zero/create-clinic.html b/app/views/create-capacity-from-zero/create-clinic.html
index 39b7cc0..cfd47a8 100644
--- a/app/views/create-capacity-from-zero/create-clinic.html
+++ b/app/views/create-capacity-from-zero/create-clinic.html
@@ -10,7 +10,7 @@
{% endblock %}
{% block beforeContent %}
-
+
Back to clinic listing
{% endblock %}
diff --git a/app/views/create-capacity-from-zero/index.html b/app/views/create-capacity-from-zero/index.html
index 79cbb64..ef75d54 100644
--- a/app/views/create-capacity-from-zero/index.html
+++ b/app/views/create-capacity-from-zero/index.html
@@ -11,80 +11,71 @@
{% block content %}
- {% set secondaryNavItems = [] %}
+ Clinics
- {% for item in [
- { href: '/create-capacity-from-zero/', label: 'Active clinics', selected: 'true' },
- { href: '#', label: 'Archived clinics' },
- { href: '/sessions/00-template-listing', label: 'Manage session templates' }
- ] %}
- {% set secondaryNavItems = secondaryNavItems | push({
- text: item.label | safe,
- href: item.href | trim,
- current: true if item.selected == 'true'
- }) %}
- {% endfor %}
-
- {{ appSecondaryNavigation({
- visuallyHiddenTitle: "Secondary menu",
- items: secondaryNavItems
- }) }}
-
-
-
-
-
Active clinics
- {{ button({
- text: "Create a new clinic",
- href: "/create-capacity-from-zero/create-clinic",
- classes: "nhsuk-button--secondary"
- }) }}
+
+
+
+
+
+ Run clinic sessions
+
+
+
Do the thing and do it today!
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Existing clinic user defined name
- NNN-NNN-systemid-20260501
-
-
- Unit label
- Location short name
-
-
- 31 August 2026 to 9 October 2026
-
-
- 1500 slots available
- of which 150 held
-
-
-
-
-
-
-
+
+
+
+
+
+ Manage clinics
+
+
+
Organise the sessions! Make clinics, schedules, timetables etc
+
+
+
+
+
{% endblock %}
diff --git a/app/views/create-capacity-from-zero/open-clinics.html b/app/views/create-capacity-from-zero/open-clinics.html
new file mode 100644
index 0000000..924e99c
--- /dev/null
+++ b/app/views/create-capacity-from-zero/open-clinics.html
@@ -0,0 +1,85 @@
+{% extends 'layout.html' %}
+
+{% set pageName = "" %}
+
+{% from "_includes/primary-navigation.html" import primaryNavigation %}
+{% block header %}
+ {{ primaryNavigation("Clinics", serviceName, bsoName) }}
+{% endblock %}
+
+{% block beforeContent %}{% endblock %}
+
+{% block content %}
+
+
+
+
+
Clinics with current schedules
+ {{ button({
+ text: "Create a new clinic",
+ href: "/create-capacity-from-zero/create-clinic",
+ classes: "nhsuk-button--secondary"
+ }) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Existing clinic user defined name
+ NNN-NNN-systemid-20260501
+
+
+ Unit label
+ Location short name
+
+
+ 31 August 2026 to 9 October 2026
+
+
+ 1500 slots available
+ of which 150 held
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/views/create-capacity-from-zero/select-session-template.html b/app/views/create-capacity-from-zero/select-session-template.html
index edebff3..e6d66ef 100644
--- a/app/views/create-capacity-from-zero/select-session-template.html
+++ b/app/views/create-capacity-from-zero/select-session-template.html
@@ -79,8 +79,4 @@
{% if loop.index0 % 2 == 1 or loop.last %} {% endif %}
{% endfor %}
-
- Create a new session template
-
-
{% endblock %}
\ No newline at end of file
diff --git a/app/views/sessions/00-template-listing.html b/app/views/sessions/00-template-listing.html
index 070522c..0e7fede 100644
--- a/app/views/sessions/00-template-listing.html
+++ b/app/views/sessions/00-template-listing.html
@@ -26,31 +26,29 @@
{% endif %}
-
-{% set secondaryNavItems = [] %}
-
-{% for item in [
- { href: '/create-capacity-from-zero/', label: 'Active clinics' },
- { href: '#', label: 'Archived clinics' },
- { href: '/sessions/00-template-listing', label: 'Manage session templates', selected: 'true' }
-] %}
- {% set secondaryNavItems = secondaryNavItems | push({
- text: item.label | safe,
- href: item.href | trim,
- current: true if item.selected == 'true'
- }) %}
-{% endfor %}
-
-{{ appSecondaryNavigation({
- visuallyHiddenTitle: "Secondary menu",
- items: secondaryNavItems
-}) }}
-
+
+
+
+
+
+
{# Session templates first (newest first), then the examples; a new grid row wraps every two cards #}
{% set allTemplates = (data.sessionTemplates | reverse) %}
@@ -106,14 +104,14 @@
- {{ template.totalLabel or "Total slots" }}
+ Total slots
{{ template.totalSlotCount }}
-
Full details
+
Full details
@@ -165,6 +163,4 @@
{% endfor %}
-{% endblock %}
-
-{% block pageScripts %}{% endblock %}
\ No newline at end of file
+{% endblock %}
\ No newline at end of file
From 7eebe7e53cf894ac5f4484940c482ccf53323aea Mon Sep 17 00:00:00 2001
From: Mat Johnson
Date: Wed, 21 Oct 2026 11:48:44 +0100
Subject: [PATCH 19/22] Breaking away from the silly secondary nav.
---
app/views/sessions/00-template-listing.html | 41 +++++++++++----------
1 file changed, 21 insertions(+), 20 deletions(-)
diff --git a/app/views/sessions/00-template-listing.html b/app/views/sessions/00-template-listing.html
index 0e7fede..1e9f623 100644
--- a/app/views/sessions/00-template-listing.html
+++ b/app/views/sessions/00-template-listing.html
@@ -7,7 +7,26 @@
{{ primaryNavigation("Clinics", serviceName, bsoName) }}
{% endblock %}
-{% block beforeContent %}{% endblock %}
+{% block beforeContent %}
+
+
+
+
+ Home
+
+
+
+
+ Clinics
+
+
+
+
+ Back to Clinics
+
+
+
+{% endblock %}
{% block content %}
@@ -27,28 +46,10 @@
{% endif %}
-
-
-
-
-
-
{# Session templates first (newest first), then the examples; a new grid row wraps every two cards #}
{% set allTemplates = (data.sessionTemplates | reverse) %}
From 3829528fee6c22d2a0238e1d23bc806e74fd4bce Mon Sep 17 00:00:00 2001
From: Mat Johnson
Date: Wed, 21 Oct 2026 13:25:08 +0100
Subject: [PATCH 20/22] Collapse clinics back down 1 level
---
.../_local-includes/clinic-summary.html | 3 -
.../clinic-summary-empty.html | 2 +-
.../clinic-summary-in-progress.html | 2 +-
.../clinic-summary.html | 15 +-
.../create-clinic.html | 12 +-
.../create-capacity-from-zero/index.html | 256 +++++++++++++-----
.../open-clinics.html | 85 ------
app/views/sessions/00-template-listing.html | 5 +-
8 files changed, 216 insertions(+), 164 deletions(-)
delete mode 100644 app/views/create-capacity-from-zero/open-clinics.html
diff --git a/app/views/create-capacity-from-zero/_local-includes/clinic-summary.html b/app/views/create-capacity-from-zero/_local-includes/clinic-summary.html
index d05bfb9..198fc41 100644
--- a/app/views/create-capacity-from-zero/_local-includes/clinic-summary.html
+++ b/app/views/create-capacity-from-zero/_local-includes/clinic-summary.html
@@ -13,9 +13,6 @@
Edit clinic properties
-
- Archive clinic
-
diff --git a/app/views/create-capacity-from-zero/clinic-summary-empty.html b/app/views/create-capacity-from-zero/clinic-summary-empty.html
index bc2c67c..0e2cbee 100644
--- a/app/views/create-capacity-from-zero/clinic-summary-empty.html
+++ b/app/views/create-capacity-from-zero/clinic-summary-empty.html
@@ -8,7 +8,7 @@
{% endblock %}
{% block beforeContent %}
-
+
Back to clinic listing
{% endblock %}
diff --git a/app/views/create-capacity-from-zero/clinic-summary-in-progress.html b/app/views/create-capacity-from-zero/clinic-summary-in-progress.html
index fab4cfd..e444b5f 100644
--- a/app/views/create-capacity-from-zero/clinic-summary-in-progress.html
+++ b/app/views/create-capacity-from-zero/clinic-summary-in-progress.html
@@ -20,7 +20,7 @@
{% endblock %}
{% block beforeContent %}
-
+
Back to clinic listing
{% endblock %}
diff --git a/app/views/create-capacity-from-zero/clinic-summary.html b/app/views/create-capacity-from-zero/clinic-summary.html
index 873a103..680c908 100644
--- a/app/views/create-capacity-from-zero/clinic-summary.html
+++ b/app/views/create-capacity-from-zero/clinic-summary.html
@@ -8,7 +8,7 @@
{% endblock %}
{% block beforeContent %}
-
+
Back to clinic listing
{% endblock %}
@@ -99,7 +99,18 @@
With selected days:
{% if data.createCapacityFromZero.schedules.length === 0 %}
-
This clinic contains no schedules
+
+
+
+
+ Important: No current schedule
+
+
+
+ A schedule is a block of confirmed time and capacity (usually agreed as a rota). Each schedule has a start date and an end date, for example a 6 week block.
+
+
+
{% endif %}
Add a new schedule
diff --git a/app/views/create-capacity-from-zero/create-clinic.html b/app/views/create-capacity-from-zero/create-clinic.html
index cfd47a8..2f93783 100644
--- a/app/views/create-capacity-from-zero/create-clinic.html
+++ b/app/views/create-capacity-from-zero/create-clinic.html
@@ -10,8 +10,8 @@
{% endblock %}
{% block beforeContent %}
-
- Back to clinic listing
+
+ Back
{% endblock %}
@@ -64,8 +64,6 @@
Clinic details
-
-
@@ -73,7 +71,7 @@
Clinic details
- Unit
+ Choose unit:
{% if errors and errors.unit %}
@@ -101,12 +99,12 @@ Clinic details
-