diff --git a/.gitignore b/.gitignore
index 0fe1248d..dda066c4 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 17fa9842..8910dcff 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 00000000..ef2001fb
--- /dev/null
+++ b/app/assets/images/session-template-map-specials-day.svg
@@ -0,0 +1 @@
+
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 00000000..781b36a9
--- /dev/null
+++ b/app/assets/images/session-template-map-standard-day.svg
@@ -0,0 +1 @@
+
diff --git a/app/assets/images/session-templates/.gitkeep b/app/assets/images/session-templates/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/app/assets/sass/components/_calendar-table.scss b/app/assets/sass/components/_calendar-table.scss
index 4858c63d..26d2040b 100644
--- a/app/assets/sass/components/_calendar-table.scss
+++ b/app/assets/sass/components/_calendar-table.scss
@@ -105,7 +105,7 @@
.--session-locked {
display: flex;
align-items: center;
- @include nhsuk-font-size(14);
+ @include nhsuk-font-size(16);
color: nhsuk-colour("grey-2");
margin-bottom: nhsuk-spacing(2);
}
diff --git a/app/assets/sass/components/_secondary-navigation.scss b/app/assets/sass/components/_secondary-navigation.scss
index 345b6c02..90c0ad5e 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/assets/sass/components/_session-slots.scss b/app/assets/sass/components/_session-slots.scss
index 9434a726..9a225071 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;
@@ -13,50 +18,53 @@
border-right: 0;
}
+.slot .nhsuk-checkboxes__label {
+ width: 100%;
+}
+
+.slot-time {
+ margin-right: nhsuk-px-to-rem(6px);
+}
.slot-end {
@include nhsuk-font-size(16);
margin-right: 1rem;
}
-//.slot-tag { margin-left: 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;
}
-.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("white");
+ border-top: 0;
+ 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;
}
.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 4fdcf446..596a66d9 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.5rem;
+}
- .preview-image {
- width: 100px;
- outline: 1px solid $nhsuk-border-colour;
+.app-session-template-preview-image {
+ min-width: 22.5%;
+ & > img {
+ display: block;
+ width: 100%;
+ outline: 1px solid $nhsuk-input-border-colour;
+ margin-bottom: nhsuk-spacing(2);
}
-}
\ 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 9d9b9a91..8b902016 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/assets/sass/main.scss b/app/assets/sass/main.scss
index edeba8dc..954912e4 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,
@@ -210,8 +215,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/data/session-data-defaults.js b/app/data/session-data-defaults.js
index 45ef5a4b..40b22681 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,
@@ -19,5 +45,177 @@ module.exports = {
schedules: [],
selectedDays: [],
dayTemplates: {}
- }
+ },
+ createSessionTemplate: {
+
+ },
+ // Listings reverse this array, so the last entry shows first
+ sessionTemplates: [
+ {
+ id: 'example-special-appointments',
+ example: true,
+ slug: 'special-appointments',
+ name: "Special appointments",
+ note: "Day long session consisting of double length specials",
+ imagePath: "/assets/images/session-template-map-specials-day.svg",
+ sessionStartLabel: "10:00",
+ sessionEndLabel: "17:00",
+ duration: 10,
+ totalSlotCount: 42,
+ 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",
+ note: "Pattern for our static site",
+ imagePath: "/assets/images/session-template-map-standard-day.svg",
+ 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,
+ 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/lib/session-map-svg.js b/app/lib/session-map-svg.js
new file mode 100644
index 00000000..01b1a25e
--- /dev/null
+++ b/app/lib/session-map-svg.js
@@ -0,0 +1,113 @@
+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' ? '' : ` a portrait intrinsic ratio; the viewBox stretches to fill it
+ const svg = `\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)))
+
+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.js b/app/routes.js
index c6b389bb..2759188f 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',
@@ -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/routes/clinic-sessions.js b/app/routes/clinic-sessions.js
new file mode 100644
index 00000000..6e310de0
--- /dev/null
+++ b/app/routes/clinic-sessions.js
@@ -0,0 +1,182 @@
+const express = require('express')
+const { randomUUID } = require('crypto')
+const router = express.Router()
+const { validateSessionTimes } = require('./session-times-handlers')
+const {
+ buildSessionSlots,
+ clearTagsHandler,
+ nonBookableHandler,
+ holdHandler,
+ specialAppointmentHandler,
+ combineSlotsHandler,
+ divideSlotsHandler
+} = require('./slot-structure-handlers')
+const { deleteSessionMapImage, saveSessionMapImage, sessionMapImageExists } = require('../lib/session-map-svg')
+/*
+
+/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))
+})
+
+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')
+})
+
+// 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)
+ const { bookableSlotCount, nonBookableSlotCount, sessionStartLabel, sessionEndLabel } = sessionSlots
+
+ if (!Array.isArray(req.session.data.sessionTemplates)) {
+ req.session.data.sessionTemplates = []
+ }
+ const templates = req.session.data.sessionTemplates
+
+ 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,
+ sessionEndLabel,
+ duration: template.duration,
+ totalSlotCount: bookableSlotCount + nonBookableSlotCount,
+ slots: sessionSlots.slots,
+ source: JSON.parse(JSON.stringify(template)),
+ ...saveSessionMapImage(sessionSlots)
+ })
+
+ req.session.data.createSessionTemplate = {}
+ 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 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 || []
+ templates.forEach((template) => {
+ if (!template.example && template.imagePath && !sessionMapImageExists(template.imagePath)) {
+ delete template.imagePath
+ delete template.imageAlt
+ }
+ })
+
+ 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 }))
+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))
+
+module.exports = router
\ 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 84ce5b6d..cd24fe1a 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,10 +177,15 @@ router.get('/create-capacity-from-zero/clinic-summary', function (req, res) {
}
const templateName = dayTemplates[day.dateKey]
- const template = sessionTemplates[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
})
@@ -209,8 +213,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/routes/mission-1.js b/app/routes/mission-1.js
index dcedaa4b..c81f78e9 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, nonBookableHandler, clearTagsHandler } = 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 || {}
@@ -282,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', 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/session-times-handlers.js b/app/routes/session-times-handlers.js
new file mode 100644
index 00000000..8b185f2b
--- /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 00000000..1f90766c
--- /dev/null
+++ b/app/routes/slot-structure-handlers.js
@@ -0,0 +1,250 @@
+// 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')}`
+}
+
+// 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 || {}
+
+ 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 heldSlots = asTagMap(sessionData.heldSlots)
+ const nonBookableSlots = asTagMap(sessionData.nonBookableSlots)
+ 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)
+
+ // 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(startMinutes + (i * duration)),
+ 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]),
+ holdReason: tagReason(heldSlots[i]),
+ isSpecialAppointment: specialAppointmentSlots.includes(i)
+ })
+ i = lastIndex + 1
+ }
+ }
+
+ // 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.isNonBookable))
+ const nonBookableSlotCount = sumSpans(slots.filter((slot) => slot.isNonBookable))
+ sessionData.bookableSlotCount = bookableSlotCount
+ sessionData.nonBookableSlotCount = nonBookableSlotCount
+
+ const capitalise = (text) => text.charAt(0).toUpperCase() + text.slice(1)
+ const slotTypeLabel = (slot) => {
+ 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'
+ }
+ const breakdown = (list, order) => 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
+ }, []).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', 'Staff meeting', 'Non-bookable'
+ ])
+
+ return {
+ slots,
+ bookableSlotCount,
+ nonBookableSlotCount,
+ sessionStartLabel: formatClock(startMinutes),
+ sessionEndLabel: formatClock(endMinutes)
+ }
+}
+
+// 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 = {
+ 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.heldSlots = tags.heldSlots
+ sessionData.nonBookableSlots = tags.nonBookableSlots
+ 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) => {
+ delete tags.heldSlots[index]
+ delete tags.nonBookableSlots[index]
+ tags.specialAppointmentSlots.delete(index)
+}
+
+// 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) => {
+ tags.nonBookableSlots[index] = reason || true
+ }
+})
+
+// Holds the selected slots for `reason`
+const holdHandler = ({ reason, dataKey, view }) => slotUpdateHandler({
+ dataKey,
+ view,
+ update: (index, tags) => {
+ // Holds without a reason are stored as `true`
+ tags.heldSlots[index] = reason || true
+ }
+})
+
+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
+ 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 = withoutAbsorbed(sessionData.heldSlots)
+ sessionData.nonBookableSlots = withoutAbsorbed(sessionData.nonBookableSlots)
+
+ 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,
+ clearTagsHandler,
+ nonBookableHandler,
+ holdHandler,
+ specialAppointmentHandler,
+ combineSlotsHandler,
+ divideSlotsHandler
+}
diff --git a/app/views/_includes/primary-navigation.html b/app/views/_includes/primary-navigation.html
index 6afab2cb..98efb286 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/clinics-schedules/04-organise-slots.html b/app/views/clinics-schedules/04-organise-slots.html
index 11d5ec1d..de5f5cfd 100644
--- a/app/views/clinics-schedules/04-organise-slots.html
+++ b/app/views/clinics-schedules/04-organise-slots.html
@@ -150,7 +150,7 @@
| - Clinic - | -- Unit and location - | -- Current schedules - | -- Current capacity - | -
|---|---|---|---|
|
- 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 - |
-
+ Run screening sessions +
+ +| - Clinic - | -- Unit and location - | -- Current schedules - | -- Current capacity - | -
|---|---|---|---|
|
- 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 - |
-
+ Set up clinics, book participants +
+ +Do stuff to days
+