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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions app/assets/sass/components/_session-slots.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
@use "nhsuk-frontend/dist/nhsuk/core" as *;

.slot {
background-color: nhsuk-colour("white");
border: 1px solid $nhsuk-input-border-colour;
padding-left: nhsuk-spacing(2);
user-select: none;
@include nhsuk-responsive-margin(2, "bottom");
min-height: nhsuk-px-to-rem(46px);
}

.slot-end {
@include nhsuk-font-size(16);
margin-right: 1rem;
}
.slot-tag { margin-left: 1rem; }

.slot.is-hold {}

.slot.is-staff-break {
background: repeating-linear-gradient(130deg, nhsuk-colour("grey-4"), nhsuk-colour("grey-4") 1px, transparent 1px, transparent 12px);
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-last-child {
border-bottom: 1px solid #212b32;
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%;
}

.panel-item { display: block; cursor: pointer; }
.panel-item.disabled { opacity: 0.38; cursor: default; pointer-events: none; }
3 changes: 1 addition & 2 deletions app/assets/sass/main.scss
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@
$nhsuk-gutter: 24px,

// Reduce layout spacing points (5–9). Points 0–4 stay at NHS defaults.
// Checkbox and radio geometry is fixed in px rather than driven by the spacing scale,
// and _compact.scss applies the --small variant to all checkboxes/radios.
$nhsuk-spacing-points: (
0: 0,
1: 4px,
Expand Down Expand Up @@ -93,6 +91,7 @@
@forward "components/participant-table";
//@forward "components/participant-search";
@forward "components/progress-bar";
@forward "components/session-slots";
@forward "components/template-card";
@forward "components/template-preview-dialog";
@forward "components/workflow";
Expand Down
1 change: 1 addition & 0 deletions app/data/session-data-defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ module.exports = {
clinicCapacityPercentBooked: "0",
clinicCapacityAvailable: "10",
clinicCapacityTotal: "10",
missionOne: {},
createCapacityFromZero: {
schedules: [],
selectedDays: [],
Expand Down
224 changes: 224 additions & 0 deletions app/routes/mission-1.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,31 @@ const router = express.Router()

*/

const isWholeNumber = str => /^\d+$/.test(str)

// Checks a { day, month, year } object has numeric values for each field, regardless of whether they form a real date
const hasDateFields = (dateObj) =>
Boolean(dateObj) && isWholeNumber(dateObj.day) && isWholeNumber(dateObj.month) && isWholeNumber(dateObj.year)

// Parses a { day, month, year } object into a Date, or null if invalid
const parseDateFields = (dateObj) => {
if (!hasDateFields(dateObj)) {
return null
}

const day = parseInt(dateObj.day, 10)
const month = parseInt(dateObj.month, 10)
// Treat 2-digit years as shorthand for the 2000s, eg "26" becomes 2026
const year = dateObj.year.length === 2 ? parseInt(`20${dateObj.year}`, 10) : parseInt(dateObj.year, 10)
const date = new Date(year, month - 1, day)

if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
return null
}

return date
}

// simple search
router.get('/september-iteration-2/clickthru/04a-example-search-result', function (req, res) {
const query = (req.query['search-params'] || '').trim()
Expand Down Expand Up @@ -61,6 +86,205 @@ router.get('/action/unstage/:participantId', function (req, res) {
res.redirect(req.get('referer') || '/september-iteration-2/clickthru/04-choose-participants')
});

// validating clinic name and session date for 1 day clinic creation
router.post('/september-iteration-2/create-clinic-rev-1', function (req, res) {
const clinicName = (req.body.clinicName || '').trim()
const clinicDate = req.body.clinicDate || {}

if (typeof req.session.data.missionOne !== 'object') {
req.session.data.missionOne = {}
}
req.session.data.missionOne.clinicName = clinicName
req.session.data.missionOne.clinicDate = clinicDate

const errors = {}

if (!clinicName) {
errors.clinicName = 'Clinic must be given a name'
}

if (!hasDateFields(clinicDate)) {
errors.clinicDate = 'Date of clinic must be given a day, month, and year'
} else {
const parsedDate = parseDateFields(clinicDate)
const today = new Date()
today.setHours(0, 0, 0, 0)

if (!parsedDate || parsedDate <= today) {
errors.clinicDate = 'Date of clinic must be in the future'
}
}

if (Object.keys(errors).length > 0) {
return res.render('september-iteration-2/create-clinic-rev-1', {
errors
})
}

res.redirect('/september-iteration-2/create-clinic-rev-1-set-timings')
})

// validating clinic session times for 1 day clinic creation
router.post('/september-iteration-2/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'
}
}

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

if (Object.keys(errors).length > 0) {
return res.render('september-iteration-2/create-clinic-rev-1-set-timings', {
errors
})
}

res.redirect('/september-iteration-2/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('/september-iteration-2/create-clinic-rev-1-slot-structure', function (req, res) {
const missionOne = req.session.data.missionOne || {}

res.render('september-iteration-2/create-clinic-rev-1-slot-structure', buildSessionSlots(missionOne))
})

// marking the selected slots as staff breaks for 1 day clinic creation
router.post('/september-iteration-2/create-clinic-rev-1-slot-structure/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 = 'you need to select some slots'
}

if (Object.keys(errors).length > 0) {
return res.render('september-iteration-2/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('/september-iteration-2/create-clinic-rev-1-slot-structure')
})

// clearing staff break status from the selected slots for 1 day clinic creation
router.post('/september-iteration-2/create-clinic-rev-1-slot-structure/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 = 'you need to select some slots'
}

if (Object.keys(errors).length > 0) {
return res.render('september-iteration-2/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('/september-iteration-2/create-clinic-rev-1-slot-structure')
})

// creating and passing Total Slots through for 1 day clinic creation
router.post('/september-iteration-2/create-clinic-rev-1-publish-check', function (req, res) {
const newSession = req.session.data.newSession || {}
Expand Down
Loading
Loading