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 @@ +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 00000000..781b36a9 --- /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 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' ? '' : ``, + 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))) + +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 @@

-
+

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 11d5ec1d..de5f5cfd 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/clinic-detail.html b/app/views/clinics/clinic-detail.html index cfcf2912..da4b7e78 100644 --- a/app/views/clinics/clinic-detail.html +++ b/app/views/clinics/clinic-detail.html @@ -61,80 +61,6 @@

    Schedules

    - - - - - - - - - - - - - - - - - - - - - - - - -
    - 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 -
    - - -
    diff --git a/app/views/clinics/index.html b/app/views/clinics/index.html index ad8231f9..37b0dd79 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,116 +11,65 @@ {% block content %} - {% set secondaryNavItems = [] %} - - {% for item in [ - { href: '/clinics/', label: 'Active clinics', selected: 'true' }, - { href: '#', label: 'Archived clinics' }, - { href: '/sessions/templating-01-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 clinics +

    +

    + 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 -
    - - - +
    +
    +
    +

    + Organise clinics +

    +

    + Set up clinics, book participants +

    + +
    +
    + +
    +
    + +
    +
    + ? +
    +
    + +
    +
    +

    + Manage session templates +

    +

    Do stuff to days

    +
    +
    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 bc6642d7..f2bae8b3 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/_local-includes/clinic-summary.html b/app/views/create-capacity-from-zero/_local-includes/clinic-summary.html index d05bfb9e..198fc411 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 19d34bb4..0e2cbee6 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 4bcfa9fd..e444b5fc 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:

    • diff --git a/app/views/create-capacity-from-zero/clinic-summary.html b/app/views/create-capacity-from-zero/clinic-summary.html index 54ec6a53..680c908d 100644 --- a/app/views/create-capacity-from-zero/clinic-summary.html +++ b/app/views/create-capacity-from-zero/clinic-summary.html @@ -57,7 +57,7 @@ {{ dayItem.date }} {% if dayItem.templateName %} {{ padlockSvg | safe }} -
      +
      {{ dayItem.totalSlots }} slots
      0 booked
      {{ dayItem.heldSlots }} held
      @@ -80,7 +80,7 @@
    -
    +

    With selected days:

      @@ -99,13 +99,66 @@

      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
      - {% include "create-capacity-from-zero/_local-includes/template-preview.html" %} +{% for template in data.sessionTemplates %} + +
      + +

      + {{ template.name }} +

      + {% if template.note %} +

      {{ template.note }}

      + {% endif %} +

      + Session times + {{ template.sessionStartLabel }} to {{ template.sessionEndLabel }} +

      +
      +
      + {% 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 39b7cc0d..2f937834 100644 --- a/app/views/create-capacity-from-zero/create-clinic.html +++ b/app/views/create-capacity-from-zero/create-clinic.html @@ -11,7 +11,7 @@ {% 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

    -
    +
    - Location + Choose location: {% if errors and errors.location %} diff --git a/app/views/create-capacity-from-zero/index-manage.html b/app/views/create-capacity-from-zero/index-manage.html new file mode 100644 index 00000000..7d7a8328 --- /dev/null +++ b/app/views/create-capacity-from-zero/index-manage.html @@ -0,0 +1,136 @@ +{% extends 'layout.html' %} + +{% set pageName = "" %} + +{% set padlockSvg %} +
    + + locked +
    +{% endset %} + +{% from "_includes/primary-navigation.html" import primaryNavigation %} +{% block header %} + {{ primaryNavigation("Clinics", serviceName, bsoName) }} +{% endblock %} + +{% block beforeContent %} + +{% endblock %} + +{% block content %} + + + +
    +
    +

    Today’s sessions

    +
    +
    + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Clinic + + Unit and location + + Timetable + + Type + + Participants + + Status +
    + Mereford central ongoing
    + NNN-NNN-systemid-20260501 +
    + Mereford and Wensley Screening Centre
    + Mereford and Wensley District Hospital +
    + 10:00 to 17:00 + + Screening + + 40 + + Scheduled +
    + New schedule ready but not open
    + NNN-NNN-systemid-20260501 +
    + Pankhurst
    + Riverside Sports Centre +
    + 8:00 to 15:00 + + Screening + + 40 + + In progress +
    + +
    +
    + +{% endblock %} diff --git a/app/views/create-capacity-from-zero/index.html b/app/views/create-capacity-from-zero/index.html index 03ec45e1..4cadaf09 100644 --- a/app/views/create-capacity-from-zero/index.html +++ b/app/views/create-capacity-from-zero/index.html @@ -2,89 +2,307 @@ {% set pageName = "" %} -{% from "_includes/primary-navigation.html" import primaryNavigation %} +{% set padlockSvg %} +
    + + locked +
    +{% endset %} + +{% macro primaryNavigation(activeItem, serviceName, bsoName) %} + {{ header({ + account: { + items: [ + { + text: bsoName + }, + { + href: "#", + text: "Thomas Pynchon", + icon: true + }, + { + href: "/", + text: "Log out" + } + ] + }, + service: { + text: serviceName, + href: "/" + }, + navigation: { + items: [ + { + href: "#", + text: "Dashboard", + active: activeItem === "Dashboard/home" + }, + { + href: "/participants/", + text: "Participants", + active: activeItem === "Participants" + }, + { + href: "/create-capacity-from-zero/", + text: "Clinics", + active: activeItem === "Clinics" + }, + { + href: "#", + text: "Image reading", + active: activeItem === "Image reading" + }, + { + href: "#", + text: "Reports", + active: activeItem === "Reports" + }, + { + href: "/bso-objects/", + text: "BSO management", + active: activeItem === "BSO management" + }, + { + href: "#", + text: "Messages", + active: activeItem === "Messages" + }, + { + href: "#", + text: "Help and support", + active: activeItem === "Help and support" + } + ] + } + }) }} +{% endmacro %} {% block header %} {{ primaryNavigation("Clinics", serviceName, bsoName) }} {% endblock %} -{% block beforeContent %}{% endblock %} +{% block beforeContent %} + +{% endblock %} {% block content %} - {% set secondaryNavItems = [] %} - - {% for item in [ - { href: '/create-capacity-from-zero/', label: 'Active clinics', selected: 'true' }, - { href: '#', label: 'Archived clinics' }, - { href: '#', 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" - }) }} -
    +
    +
    +
    +

    Current clinics

    + {{ button({ + text: "Create a new clinic", + href: "/create-capacity-from-zero/create-clinic", + classes: "nhsuk-button--secondary" + }) }}
    +
    -
    -
    +
    +
    - - - - - - - - - - - - - - - - - -
    - Clinic - - Unit and location - - Current schedules - - Current capacity -
    - 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 -
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + Clinic + + Unit and location + + Scheduled timespan + + Capacity +
    + Mereford central ongoing
    + NNN-NNN-systemid-20260501 +
    + Mereford and Wensley Screening Centre
    + Mereford and Wensley District Hospital +
    + 7 Sep 2026 to 27 Nov 2026 + + 1500 slots available
    + of which 150 held +
    + New schedule ready but not open
    + NNN-NNN-systemid-20260501 +
    + Pankhurst
    + Riverside Sports Centre +
    + 7 Sep 2026 to 17 Oct 2026 + + {{ padlockSvg | safe }} + 1500 slots
    + +
    + New schedule empty
    + NNN-NNN-systemid-20260501 +
    + Drummond
    + East Mereford Community Hub +
    + 7 Sep 2026 to 17 Oct 2026 + + No sessions assigned +
    + New clinic but empty
    + NNN-NNN-systemid-20260501 +
    + Drummond
    + East Mereford Community Hub +
    + No schedules assigned + + No sessions assigned +
    -
    +
    + + + + {% endblock %} 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 076707e3..ab18b877 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 c4669278..d09cdc08 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/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 751a6783..00000000 --- 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 a419a9a5..e6d66ef9 100644 --- a/app/views/create-capacity-from-zero/select-session-template.html +++ b/app/views/create-capacity-from-zero/select-session-template.html @@ -14,237 +14,69 @@ {% endblock %} {% block content %} -
      -
      -

      Choose session template

      +{# 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 -

      - - - - -
      -
      -
      - -
      -
      -
      -
      - Total capacity -
      -
      - 42 slots -
      -
      -
      -
      - Time -
      -
      - 09:00 to 17:00 -
      -
      -
      -
      - -
      -
      -
      -
      -
      -
      - Slot length -
      -
      - 10 minutes -
      -
      -
      -
      - Total held -
      -
      - 8 slots -
      -
      -
      -
      - Total cancelled -
      -
      - 6 slots -
      -
      -
      +

      + {{ template.name }} + {% if templateSaved and loop.first and not template.example %}{{ "Updated" if templateSaved == "updated" else "New" }}{% endif %} +

      + {% if template.note %} +

      {{ template.note }}

      + {% endif %}
      + {% if template.slug or template.id %} + Select template + {% endif %}
      -
      - -
      -
      -

      - Special appointments -

      -
      - - -
      -
      -
      -
      - +
      +
      + {% if template.imagePath %} + {{ template.imageAlt }} + {% else %} + + {% endif %}
      - Total capacity -
      -
      - 42 slots -
      -
      -
      -
      - Time -
      -
      - 09:00 to 17:00 -
      -
      -
      -
      - -
      -
      -
      -
      -
      -
      - Slot length + Session times
      -
      - 10 minutes +
      + {{ template.sessionStartLabel }} to {{ template.sessionEndLabel }}
      -
      -
      - Total held -
      -
      - 42 slots -
      -
      -
      -
      - Total cancelled -
      -
      - 6 slots -
      -
      -
      -
      -
      -
      - -
      -
      -

      - Rubie default -

      -
      - - -
      -
      -
      -
      - -
      -
      -
      - Total capacity + Base slot length
      -
      - 42 slots +
      + {{ template.duration }} minutes
      -
      -
      - Time -
      -
      - 09:00 to 17:00 -
      -
      -
      -
      - -
      -
      -
      -
      - Slot length + {{ template.totalLabel or "Total slots" }}
      -
      - 10 minutes -
      -
      -
      -
      - Total held -
      -
      - 0 slots -
      -
      -
      -
      - Total cancelled -
      -
      - 6 slots +
      + {{ template.totalSlotCount }}
      +
      - - -

      - Create new session template -

      -
      -
      +{% if loop.index0 % 2 == 1 or loop.last %}
      {% endif %} +{% endfor %} - - -
      - - -
      -
      {% endblock %} \ No newline at end of file diff --git a/app/views/index.html b/app/views/index.html index 362da9a4..e90c20b2 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

      1. Create a new clinic (containers) @@ -90,37 +91,59 @@

        August – September 2026

      +

      + May–June 2026 + + superceded + +

      +
      + + + Older work (may be broken, not maintained) + + +
      +

      Clinics: first run

      +
        +
      1. + Clinic listing +
      2. +
      3. + Clinic detail +
      4. +
      +
      +
      -

      July 2026

      -

      Sessions: first run

      -
        -
      1. - Set up session -
      2. -
      -

      Session templates: iteration 1

      + +

      Clinic sessions (days)

      +

      October 2026

      +

      Session templates: iteration 2

      1. - Template listing + Create a new session template
      -

      - May–June 2026 +

      July 2026

      +

      + Sessions: first run superceded -

-

Clinics: first run

+

  1. - Clinic listing + Set up session
  2. - Clinic detail + Template listing
-

April 2026

+ +

Batches

+

April 2026

Batches: first run @@ -140,9 +163,11 @@

April 2026

+ +

BSO management

March 2026

- BSO-level building blocks + BSO-level building blocks: units, locations, etc Paused 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 1940b5f8..c2d91d0a 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 e0914e0f..fb5eadb1 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 @@ -89,15 +91,15 @@

{% for slot in slots %} -
+
@@ -108,14 +110,14 @@

-
+

Set selected slots as:



-

+

{{ bookableSlotCount }} bookable slots

@@ -128,4 +130,60 @@

Set selected slots as:

+{% endblock %} + +{% block pageScripts %} + {% 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 new file mode 100644 index 00000000..c27cac34 --- /dev/null +++ b/app/views/sessions/00-template-listing.html @@ -0,0 +1,166 @@ +{% extends 'layout.html' %} + +{% set pageName = "" %} + +{% from "_includes/primary-navigation.html" import primaryNavigation %} +{% block header %} + {{ primaryNavigation("Clinics", serviceName, bsoName) }} +{% endblock %} + +{% block beforeContent %} + +{% endblock %} + +{% block content %} + +{% if templateSaved %} + +{% endif %} + +
+

Session templates

+ Create a new session template +
+ +{# 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 %} +
+
+
+
+

+ {{ template.name }} + {% if templateSaved and loop.first and not template.example %}{{ "Updated" if templateSaved == "updated" else "New" }}{% endif %} +

+ {% if template.note %} +

{{ template.note }}

+ {% endif %} +
+ {% if template.example %} + + {% elif template.id and template.source %} + + Edit template + + {% endif %} +
+
+
+ {% if template.imagePath %} + {{ template.imageAlt }} + {% else %} + + {% endif %} +
+
+
+
+
+ Session times +
+
+ {{ template.sessionStartLabel }} to {{ template.sessionEndLabel }} +
+
+
+
+ Base slot length +
+
+ {{ template.duration }} minutes +
+
+
+
+ Total slots +
+
+ {{ template.totalSlotCount }} +
+
+
+ +
+
+
+
+{% if loop.index0 % 2 == 1 or loop.last %}
{% endif %} +{% endfor %} + +{% for template in data.sessionTemplates %} + +
+ +

+ {{ template.name }} +

+ {% if template.note %} +

{{ template.note }}

+ {% endif %} +

+ Session times + {{ template.sessionStartLabel }} to {{ template.sessionEndLabel }} +

+
+
+ {% 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 %} \ 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 00000000..797a165f --- /dev/null +++ b/app/views/sessions/01-template-name.html @@ -0,0 +1,111 @@ +{% extends 'layout.html' %} + +{% set pageName = "" %} +{% set errorState = "true" if errors else "false" %} + +{% 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.sessionTemplateName %} + {% set errorList = errorList.concat([{ text: errors.sessionTemplateName, href: "#session-template-name" }]) %} + {% endif %} + {{ errorSummary({ + titleText: "There is a problem", + errorList: errorList + }) }} + {% endif %} + + + +
+

+ +

+ {% if errorState === "true" %} + + Error: {{ errors.sessionTemplateName }} + + {% endif %} + +
+ +
+ +
+ Put in a short note so you can remember stuff +
+ +
+ + + + + +
+
+
+{% 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 00000000..275c86d0 --- /dev/null +++ b/app/views/sessions/02-session-times.html @@ -0,0 +1,185 @@ +{% extends 'layout.html' %} + +{% set pageName = "" %} +{% set errorState = "true" if errors else "false" %} + +{% 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.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

+ +
+ +
+
+ + Session start time + +
+ Use 24 hour format, for example, 10:00 +
+ {% if errors and errors.startTime %} + + Error: {{ errors.startTime }} + + {% endif %} + +
+
+
+ + +
+
+ +
+
+ + +
+
+
+
+
+ +
+
+ + Session end time + +
+ Use 24 hour format, for example, 17:00 +
+ {% if errors and errors.endTime %} + + Error: {{ errors.endTime }} + + {% endif %} + +
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ +
+ + {% if errors and errors.duration %} + + Error: {{ errors.duration }} + + {% endif %} +
+ + +
+
+ + + +
+
+
+
+{% 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 00000000..c1880df2 --- /dev/null +++ b/app/views/sessions/03-slot-structure.html @@ -0,0 +1,252 @@ +{% extends 'layout.html' %} + +{% set pageName = "" %} +{% set errorState = "true" if errors else "false" %} + +{% 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 %} +
+
+ + +
+
+ {% for position in slot.mergedChildren %} + + {% endfor %} + {% endfor %} + +
+
+
+ +
+
+

+ Tag slots +

+

Hold:

+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+

Make non-bookable:

+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+
+ +
+
+ +
+
+

+ Combine slots +

+ +
+ +
+
+ +
+

+ {{ bookableSlotCount }} bookable slots +

+

+ +

+
+
+ +
+
+
+{% endblock %} + +{% block pageScripts %} + +{% endblock %} \ No newline at end of file diff --git a/app/views/sessions/04-check-template.html b/app/views/sessions/04-check-template.html new file mode 100644 index 00000000..5227aa24 --- /dev/null +++ b/app/views/sessions/04-check-template.html @@ -0,0 +1,158 @@ +{% extends 'layout.html' %} + +{% set pageName = "" %} + +{% from "_includes/primary-navigation.html" import primaryNavigation %} +{% block header %} + {{ primaryNavigation("Clinics", 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 +
+
+ {% if data.createSessionTemplate.bookableBreakdown | length %} +
+ + + {{ data.createSessionTemplate.bookableSlotCount }} bookable slots + + +
+
+ {% for item in data.createSessionTemplate.bookableBreakdown %} +
+
{{ item.label }}
+
{{ item.count }}
+
+ {% endfor %} +
+
+
+ {% else %} +

{{ data.createSessionTemplate.bookableSlotCount }} bookable slots

+ {% endif %} + {% if data.createSessionTemplate.nonBookableBreakdown | length %} +
+ + + {{ data.createSessionTemplate.nonBookableSlotCount }} non-bookable slots + + +
+
+ {% for item in data.createSessionTemplate.nonBookableBreakdown %} +
+
{{ item.label }}
+
{{ item.count }}
+
+ {% endfor %} +
+
+
+ {% else %} +

{{ data.createSessionTemplate.nonBookableSlotCount }} non-bookable slots

+ {% endif %} +
+
+ Change slot structure +
+
+
+ +
+ {% if data.editingTemplateId %} +
+ + Cancel +
+ {% else %} + + {% endif %} +
+ +
+
+ +{% 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 1fdc9c4e..9129b28f 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 @@ -948,7 +948,7 @@

-
+

Change slots

With selected:
    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 99% rename from app/views/sessions/02-organise-slots.html rename to app/views/sessions/z-older-versions/02-organise-slots.html index d1dbb4db..f79ee29f 100644 --- a/app/views/sessions/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/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 diff --git a/app/views/z-sketching/calc-2.html b/app/views/z-sketching/calc-2.html index 5700f4c0..231d4f01 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
  • diff --git a/package-lock.json b/package-lock.json index abb3a9c9..9b0d6d10 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": [