diff --git a/.gitignore b/.gitignore index d6e7d58..aa1e901 100755 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,8 @@ /dist /tmp /public -/app/data/generated/ +app/data/generated/ +reference # Runtime data pids diff --git a/README.md b/README.md index e5c7a39..ad546ae 100755 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ The prototype will generate example data on first run. - Delete the generated folder to regenerate fresh example data - You can also run the generator directly with `node app/lib/generate-seed-data.js` - Uses NHS.UK design system components and patterns +- Use `tree app` to generate a tree diagram of the project ## Security diff --git a/app.js b/app.js index ff87c61..e391805 100755 --- a/app.js +++ b/app.js @@ -10,6 +10,7 @@ const express = require('express'); const nunjucks = require('nunjucks'); const sessionInCookie = require('client-sessions'); const sessionInMemory = require('express-session'); +const flash = require('connect-flash'); // Run before other code to make sure variables from .env are available dotenv.config(); @@ -111,6 +112,9 @@ if (useAutoStoreData === 'true') { utils.addCheckedFunction(nunjucksAppEnv); } +// Flash messages +app.use(flash()); + // Warn if node_modules folder doesn't exist function checkFiles() { const nodeModulesExists = fs.existsSync(path.join(__dirname, '/node_modules')); @@ -148,6 +152,8 @@ if (!sessionDataDefaultsFileExists) { // Local variables app.use(locals(config)); + + // View engine app.set('view engine', 'html'); documentationApp.set('view engine', 'html'); diff --git a/app/data/breast-screening-units.js b/app/data/breast-screening-units.js index 92e4862..0e01168 100644 --- a/app/data/breast-screening-units.js +++ b/app/data/breast-screening-units.js @@ -1,14 +1,61 @@ +// app/data/breast-screening-units.js +const generateId = require('../lib/utils/id-generator'); + module.exports = [ { - id: "f66f2a7d-99a8-4793-8371-3d075e1a7c54", + id: "m5ekcxvu", // Must be hardcoded so it matches generated data name: "Oxford Breast Imaging Centre", - address: `Surgery and Diagnostics Centre -Churchill Hospital -Old Road, -Headington -Oxford -OX3 7LE`, - phoneNumber: "01865 235621", - abbreviation: "OXF" + address: { + line1: "Surgery and Diagnostics Centre", + line2: "Churchill Hospital", + line3: "Old Road", + line4: "Headington", + city: "Oxford", + postcode: "OX3 7LE" + }, + phoneNumber: "01865235621", + abbreviation: "OXF", + locations: [ + { + id: generateId(), + name: "Churchill Hospital breast unit", + type: "hospital", + isMainSite: true, + address: { + line1: "Surgery and Diagnostics Centre", + line2: "Churchill Hospital", + line3: "Old Road", + line4: "Headington", + city: "Oxford", + postcode: "OX3 7LE" + } + }, + // { + // id: generateId(), + // name: "Horton Hospital breast unit", + // type: "hospital", + // isMainSite: false, + // address: { + // line1: "Horton General Hospital", + // line2: "Oxford Road", + // city: "Banbury", + // postcode: "OX16 9AL" + // } + // }, + { + id: generateId(), + name: "Mobile Unit WX71 HCP", + type: "mobile_unit", + isMainSite: false, + registration: "WX71 HCP" + }, + { + id: generateId(), + name: "Mobile Unit WX71 HCR", + type: "mobile_unit", + isMainSite: false, + registration: "WX71 HCR" + } + ] } -] +]; diff --git a/app/data/session-data-defaults.js b/app/data/session-data-defaults.js index 2eccf84..bfec6f5 100644 --- a/app/data/session-data-defaults.js +++ b/app/data/session-data-defaults.js @@ -5,18 +5,48 @@ const breastScreeningUnits = require("./breast-screening-units"); const ethnicities = require("./ethnicities"); const path = require('path'); const fs = require('fs'); +const dayjs = require('dayjs'); -// Check if generated data folder exists +// Check if generated data folder exists and create if needed const generatedDataPath = path.join(__dirname, 'generated'); +if (!fs.existsSync(generatedDataPath)) { + fs.mkdirSync(generatedDataPath); +} let participants = []; let clinics = []; let events = []; +let generationInfo = { + generatedAt: 'Never', + stats: { participants: 0, clinics: 0, events: 0 } +}; -// Generate data if folder doesn't exist -if (!fs.existsSync(generatedDataPath)) { - console.log('Generating seed data...'); +// Check if we need to regenerate data +const generationInfoPath = path.join(generatedDataPath, 'generation-info.json'); +let needsRegeneration = true; + +if (fs.existsSync(generationInfoPath)) { + try { + generationInfo = JSON.parse(fs.readFileSync(generationInfoPath)); + const generatedDate = dayjs(generationInfo.generatedAt).startOf('day'); + const today = dayjs().startOf('day'); + needsRegeneration = !generatedDate.isSame(today, 'day'); + } catch (err) { + console.warn('Error reading generation info:', err); + needsRegeneration = true; + } +} + +// Generate or load data +if (needsRegeneration) { + console.log('Generating new seed data...'); require('../lib/generate-seed-data.js'); + + // Save generation info + fs.writeFileSync( + generationInfoPath, + JSON.stringify({ generatedAt: new Date().toISOString() }) + ); } // Load generated data @@ -34,5 +64,6 @@ module.exports = { breastScreeningUnits, participants, clinics, - events + events, + generationInfo }; diff --git a/app/lib/generate-seed-data.js b/app/lib/generate-seed-data.js index 35e2cde..e91df49 100644 --- a/app/lib/generate-seed-data.js +++ b/app/lib/generate-seed-data.js @@ -94,6 +94,14 @@ const generateData = async () => { writeData('participants.json', { participants }); writeData('clinics.json', { clinics }); writeData('events.json', { events }); + writeData('generation-info.json', { + generatedAt: new Date().toISOString(), + stats: { + participants: participants.length, + clinics: clinics.length, + events: events.length + } + }); console.log('\nData generation complete!'); console.log(`Generated:`); @@ -102,5 +110,10 @@ const generateData = async () => { console.log(`- ${events.length} events`); }; -// Run the generator -generateData().catch(console.error); +// Export the function instead of running it immediately +module.exports = generateData; + +// Only run if this file is being run directly +if (require.main === module) { + generateData().catch(console.error); +} diff --git a/app/lib/generators/clinic-generator.js b/app/lib/generators/clinic-generator.js index e6f9f0e..12d2f5d 100644 --- a/app/lib/generators/clinic-generator.js +++ b/app/lib/generators/clinic-generator.js @@ -2,12 +2,7 @@ const { faker } = require('@faker-js/faker'); const generateId = require('../utils/id-generator'); -const weighted = require('weighted'); - -const CLINIC_TYPES = [ - { type: 'hospital', weight: 0.7 }, - { type: 'mobile_unit', weight: 0.3 } -]; +const dayjs = require('dayjs'); const generateTimeSlots = (date, config) => { const slots = []; @@ -29,29 +24,66 @@ const generateTimeSlots = (date, config) => { return slots; }; +const determineClinicStatus = (date) => { + const now = dayjs(); + const clinicDate = dayjs(date); + const clinicStart = clinicDate.hour(8); // Assume clinic starts at 8am + const clinicEnd = clinicDate.hour(17); // Assume clinic ends at 5pm + + if (clinicDate.isBefore(now, 'day')) { + return 'closed'; + } else if (clinicDate.isAfter(now, 'day')) { + return 'scheduled'; + } else { + // Today - check time + if (now.isBefore(clinicStart)) { + return 'scheduled'; + } else if (now.isAfter(clinicEnd)) { + return 'closed'; + } else { + return 'in_progress'; + } + } +}; + +const generateMobileSiteName = () => { + const sites = [ + "Tesco Extra Banbury", + "Witney Community Hospital", + "Thame Community Hospital", + "Bicester Community Hospital", + "Sainsbury's Kidlington", + "Carterton Health Centre", + "Wantage Community Hospital", + "Tesco Faringdon", + "Didcot Civic Hall", + "Chipping Norton Health Centre" + ]; + + return faker.helpers.arrayElement(sites); +}; + // Generate multiple clinics for a BSU on a given day const generateClinicsForBSU = ({ date, breastScreeningUnit, config }) => { // Determine number of clinics for this BSU today (1-2) const numberOfClinics = Math.random() < 0.3 ? 2 : 1; - return Array.from({ length: numberOfClinics }, () => { - // If this is the second clinic for the day, make it more likely to be a mobile unit - const isSecondClinic = numberOfClinics === 2; - const clinicType = weighted.select( - CLINIC_TYPES.map(t => t.type), - CLINIC_TYPES.map(t => isSecondClinic ? (t.type === 'mobile_unit' ? 0.7 : 0.3) : t.weight) - ); - + // Randomly select locations from available ones + const selectedLocations = faker.helpers.arrayElements( + breastScreeningUnit.locations, + { min: numberOfClinics, max: numberOfClinics } + ); + + return selectedLocations.map(location => { return { id: generateId(), date: date.toISOString().split('T')[0], breastScreeningUnitId: breastScreeningUnit.id, - clinicType, - location: clinicType === 'hospital' ? - breastScreeningUnit.address : - generateMobileLocation(breastScreeningUnit), + clinicType: location.type, + locationId: location.id, + siteName: location.type === 'mobile_unit' ? generateMobileSiteName() : null, slots: generateTimeSlots(date, config), - status: date < new Date() ? 'completed' : 'scheduled', + status: determineClinicStatus(date), staffing: { mamographers: [], radiologists: [], @@ -64,26 +96,6 @@ const generateClinicsForBSU = ({ date, breastScreeningUnit, config }) => { }); }; -const generateMobileLocation = (bsu) => { - const locations = [ - 'Community Centre', - 'Health Centre', - 'Leisure Centre', - 'Shopping Centre Car Park', - 'Supermarket Car Park' - ]; - - const location = faker.helpers.arrayElement(locations); - return { - name: `${faker.location.city()} ${location}`, - address: { - line1: faker.location.streetAddress(), - city: faker.location.city(), - postcode: faker.location.zipCode('??# #??') - } - }; -}; - module.exports = { generateClinicsForBSU }; diff --git a/app/lib/generators/event-generator.js b/app/lib/generators/event-generator.js index f2537ec..f33b564 100644 --- a/app/lib/generators/event-generator.js +++ b/app/lib/generators/event-generator.js @@ -3,6 +3,7 @@ const generateId = require('../utils/id-generator'); const { faker } = require('@faker-js/faker'); const weighted = require('weighted'); +const dayjs = require('dayjs'); const NOT_SCREENED_REASONS = [ "Recent mammogram at different facility", @@ -14,54 +15,58 @@ const NOT_SCREENED_REASONS = [ ]; const generateEvent = ({ slot, participant, clinic, outcomeWeights }) => { - const isPast = new Date(slot.dateTime) < new Date(); - const isToday = new Date(slot.dateTime).toDateString() === new Date().toDateString(); + // Check if the event is from yesterday or before + const eventDate = dayjs(slot.dateTime).startOf('day'); + const today = dayjs().startOf('day'); + const isPast = eventDate.isBefore(today); - // Define possible statuses based on timing - let status; - let attendanceStatus = null; - let notScreenedReason = null; - - if (isPast) { - // For past events, generate final status - const statuses = ['attended', 'did_not_attend', 'attended_not_screened']; - const weights = [0.65, 0.25, 0.1]; - attendanceStatus = weighted.select(statuses, weights); - - if (attendanceStatus === 'attended_not_screened') { - notScreenedReason = faker.helpers.arrayElement(NOT_SCREENED_REASONS); - } - - status = 'completed'; - } else if (isToday) { - // For today's events, mix of statuses - const statuses = ['scheduled', 'checked_in', 'pre_screening', 'completed']; - const weights = [0.4, 0.2, 0.2, 0.2]; - status = weighted.select(statuses, weights); - } else { - status = 'scheduled'; + // All future events and today's events start as scheduled + if (!isPast) { + return { + id: generateId(), + participantId: participant.id, + clinicId: clinic.id, + slotId: slot.id, + type: 'screening', + status: 'scheduled', + details: { + screeningType: 'mammogram', + machineId: generateId() + }, + statusHistory: [ + { + status: 'scheduled', + timestamp: new Date(new Date(slot.dateTime).getTime() - (24 * 60 * 60 * 1000)).toISOString() + } + ] + }; } + // For past events, generate final status + const finalStatuses = ['attended', 'did_not_attend', 'attended_not_screened']; + const weights = [0.65, 0.25, 0.1]; + const status = weighted.select(finalStatuses, weights); + const event = { id: generateId(), participantId: participant.id, clinicId: clinic.id, slotId: slot.id, - status, type: 'screening', + status, details: { - attendanceStatus, - notScreenedReason, screeningType: 'mammogram', machineId: generateId(), - imagesTaken: status === 'completed' && attendanceStatus === 'attended' ? - ['RCC', 'LCC', 'RMLO', 'LMLO'] : null + imagesTaken: status === 'attended' ? + ['RCC', 'LCC', 'RMLO', 'LMLO'] : null, + notScreenedReason: status === 'attended_not_screened' ? + faker.helpers.arrayElement(NOT_SCREENED_REASONS) : null }, statusHistory: generateStatusHistory(status, slot.dateTime) }; - // Add outcome for completed events where participant attended and was screened - if (status === 'completed' && attendanceStatus === 'attended') { + // Add outcome for completed events where participant attended + if (status === 'attended') { const outcomeKeys = Object.keys(outcomeWeights); const outcomeValues = Object.values(outcomeWeights); const outcome = weighted.select(outcomeKeys, outcomeValues); @@ -77,27 +82,38 @@ const generateEvent = ({ slot, participant, clinic, outcomeWeights }) => { return event; }; -const generateStatusHistory = (currentStatus, dateTime) => { +const generateStatusHistory = (finalStatus, dateTime) => { const history = []; const baseDate = new Date(dateTime); - // Always starts with scheduled + // Always starts with scheduled status history.push({ status: 'scheduled', timestamp: new Date(baseDate.getTime() - (24 * 60 * 60 * 1000)).toISOString() // Day before }); - // Add intermediate statuses based on current status - const statusSequence = ['checked_in', 'pre_screening', 'completed']; - const currentIndex = statusSequence.indexOf(currentStatus); - - if (currentIndex >= 0) { - for (let i = 0; i <= currentIndex; i++) { - history.push({ - status: statusSequence[i], - timestamp: new Date(baseDate.getTime() + (i * 15 * 60 * 1000)).toISOString() // 15 min intervals - }); - } + // Add intermediate statuses based on final status + if (finalStatus === 'attended') { + history.push( + { + status: 'pre_screening', + timestamp: new Date(baseDate.getTime() - (10 * 60 * 1000)).toISOString() // 10 mins before + }, + { + status: 'in_progress', + timestamp: new Date(baseDate).toISOString() + }, + { + status: finalStatus, + timestamp: new Date(baseDate.getTime() + (15 * 60 * 1000)).toISOString() // 15 mins after + } + ); + } else { + // For did_not_attend and attended_not_screened, just add the final status + history.push({ + status: finalStatus, + timestamp: new Date(baseDate.getTime() + (15 * 60 * 1000)).toISOString() + }); } return history; diff --git a/app/lib/generators/participant-generator.js b/app/lib/generators/participant-generator.js index 20e477c..6b29ca7 100644 --- a/app/lib/generators/participant-generator.js +++ b/app/lib/generators/participant-generator.js @@ -3,6 +3,27 @@ const { faker } = require('@faker-js/faker'); const generateId = require('../utils/id-generator'); const weighted = require('weighted'); +// Generate a UK phone number +const generateUKPhoneNumber = () => { + // 80% mobile, 20% landline + if (Math.random() < 0.8) { + // Mobile number in range 07700900000 to 07700900999 + const suffix = faker.number.int({ min: 0, max: 999 }).toString().padStart(3, '0'); + return `07700900${suffix}`; + } else { + // 50/50 split between London and Sheffield landlines + if (Math.random() < 0.5) { + // London: 02079460000 to 02079460999 + const suffix = faker.number.int({ min: 0, max: 999 }).toString().padStart(3, '0'); + return `02079460${suffix}`; + } else { + // Sheffield: 01144960000 to 01144960999 + const suffix = faker.number.int({ min: 0, max: 999 }).toString().padStart(3, '0'); + return `01144960${suffix}`; + } + } +}; + // Helper functions for name formatting const formatName = (person) => ({ get fullName() { @@ -79,8 +100,8 @@ const generateParticipant = ({ ethnicities, breastScreeningUnits }) => { city: faker.location.city(), postcode: faker.location.zipCode('??# #??') }, - phone: faker.phone.number('07### ######'), - email: faker.internet.email(), + phone: generateUKPhoneNumber(), + email: `${faker.internet.username().toLowerCase()}@example.com`, ethnicGroup, ethnicBackground }, diff --git a/app/lib/utils/arrays.js b/app/lib/utils/arrays.js new file mode 100644 index 0000000..b5bbda5 --- /dev/null +++ b/app/lib/utils/arrays.js @@ -0,0 +1,16 @@ +// app/lib/utils/arrays.js + +/** + * Find an object by ID in an array + * @param {Array} array - Array to search + * @param {string} id - ID to find + * @returns {Object} Found object or undefined + */ +const findById = (array, id) => { + if (!array || !Array.isArray(array)) return undefined; + return array.find(item => item.id === id); +}; + +module.exports = { + findById +}; diff --git a/app/lib/utils/clinics.js b/app/lib/utils/clinics.js index fbd0ef4..0244129 100644 --- a/app/lib/utils/clinics.js +++ b/app/lib/utils/clinics.js @@ -1,14 +1,5 @@ // app/lib/utils/clinics.js -/** - * Find a clinic by ID - * @param {Array} clinics - Array of all clinics - * @param {string} id - id of clinic to find - */ -const findById = (clinics, id) => { - return clinics.find(c => c.id === id); -}; - /** * Get today's clinics * @param {Array} clinics - Array of all clinics @@ -43,9 +34,39 @@ const formatTimeSlot = (dateTime) => { }); }; +/** + * Calculate the end time of a slot + * @param {string} slotDateTime - ISO date string + * @param {number} durationMinutes - Duration of slot in minutes + * @returns {Date} End time of slot + */ +const getSlotEndTime = (slotDateTime, durationMinutes = 8) => { + const date = new Date(slotDateTime); + date.setMinutes(date.getMinutes() + durationMinutes); + return date; +}; + +/** + * Get clinic opening hours + * @param {Object} clinic - Clinic object + * @returns {Object} Start and end times as Date objects + */ +const getClinicHours = (clinic) => { + if (!clinic?.slots?.length) return null; + + const firstSlot = clinic.slots[0]; + const lastSlot = clinic.slots[clinic.slots.length - 1]; + + return { + start: new Date(firstSlot.dateTime), + end: getSlotEndTime(lastSlot.dateTime) + }; +}; + + module.exports = { - findById, getTodaysClinics, getClinicEvents, - formatTimeSlot + formatTimeSlot, + getClinicHours }; diff --git a/app/lib/utils/dates.js b/app/lib/utils/dates.js index b1ad616..a85a977 100644 --- a/app/lib/utils/dates.js +++ b/app/lib/utils/dates.js @@ -62,9 +62,9 @@ const formatRelativeDate = (dateString, withoutSuffix = false) => { if (!dateString) return ''; const date = dayjs(dateString); - if (date.isToday()) return 'Today'; - if (date.isTomorrow()) return 'Tomorrow'; - if (date.isYesterday()) return 'Yesterday'; + if (date.isToday()) return 'today'; + if (date.isTomorrow()) return 'tomorrow'; + if (date.isYesterday()) return 'yesterday'; return date.fromNow(withoutSuffix); }; diff --git a/app/lib/utils/participants.js b/app/lib/utils/participants.js index a48d7b7..ef4ca5f 100644 --- a/app/lib/utils/participants.js +++ b/app/lib/utils/participants.js @@ -1,14 +1,5 @@ // app/lib/utils/participants.js -/** - * Find a participant by ID - * @param {Array} participants - Array of all participants - * @param {string} id - id of participant to find - */ -const findById = (participants, id) => { - return participants.find(p => p.id === id); -}; - /** * Get full name of participant * @param {Object} participant - Participant object @@ -55,7 +46,6 @@ const getAge = (participant) => { }; module.exports = { - findById, getFullName, getShortName, findBySXNumber, diff --git a/app/lib/utils/status.js b/app/lib/utils/status.js new file mode 100644 index 0000000..7270447 --- /dev/null +++ b/app/lib/utils/status.js @@ -0,0 +1,51 @@ +// app/lib/utils/status.js + + +/** + * Map a status to its corresponding tag colour in NHS.UK frontend + * @param {string} status - The status to map + * @returns {string} The tag colour + */ +const getStatusTagColour = (status) => { + const colourMap = { + // Clinic statuses + scheduled: '', // default blue + in_progress: 'blue', + closed: 'grey', + + // Event statuses + pre_screening: 'yellow', + attended: 'green', + did_not_attend: 'red', + attended_not_screened: 'orange' + }; + return colourMap[status] || ''; +} + +/** + * Get descriptive text for an event status + * @param {string} status - The status to describe + * @returns {string} Description of the status + */ +const getStatusDescription = (status) => { + const descriptions = { + // Clinic statuses + scheduled: 'Clinic is scheduled to run', + in_progress: 'Clinic is currently running', + closed: 'Clinic has finished', + + // Event statuses + scheduled: 'Appointment is booked', + pre_screening: 'Patient is completing pre-screening', + in_progress: 'Screening in progress', + attended: 'Patient attended and was screened', + did_not_attend: 'Patient did not attend', + attended_not_screened: 'Patient attended but was not screened' + }; + return descriptions[status] || status; +} + +module.exports = { + getStatusTagColour, + getStatusDescription +}; diff --git a/app/lib/utils/strings.js b/app/lib/utils/strings.js new file mode 100644 index 0000000..bcea1d7 --- /dev/null +++ b/app/lib/utils/strings.js @@ -0,0 +1,196 @@ +// app/lib/utils/strings.js + +/** + * Convert string to sentence case + * @param {string} input - String to convert + * @returns {string} Sentence case string + */ +const sentenceCase = (input) => { + if (!input) return ''; + if (typeof input !== 'string') return input; + return input.charAt(0).toUpperCase() + input.slice(1).toLowerCase(); +}; + +/** + * Convert string to start with lowercase + * @param {string} input - String to convert + * @returns {string} String starting with lowercase + */ +const startLowerCase = (input) => { + if (!input) return ''; + if (typeof input !== 'string') return input; + return input.charAt(0).toLowerCase() + input.slice(1); +}; + +/** + * Separate words with hyphens + * @param {string} input - String to convert + * @returns {string} Hyphen-separated string + */ +const kebabCase = (input) => { + if (!input) return ''; + return input.replace(/([a-z])([A-Z])/g, '$1-$2').replace(/\s+/g, '-').toLowerCase(); +}; + +/** + * Create URL-friendly slug from string + * @param {string} input - String to convert + * @returns {string} URL-safe slug + */ +const slugify = (input) => { + if (!input) return ''; + return input.toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); +}; + +/** + * Split a string using a separator + * @param {string} input - String to split + * @param {string} separator - Separator to split on + * @returns {Array} Array of split strings + */ +const split = (input, separator) => { + if (!input || typeof input !== 'string') return []; + return input.split(separator); +}; + +/** + * Add appropriate indefinite article (a/an) before a word + * @param {string} input - Word to prefix + * @returns {string} Word with appropriate article + */ +const addIndefiniteArticle = (input) => { + if (!input) return ''; + return /^[aeiou]/i.test(input) ? `an ${input}` : `a ${input}`; +}; + +/** + * Make a string possessive + * @param {string} input - String to make possessive + * @returns {string} Possessive form of string + */ +const possessive = (input) => { + if (!input) return ''; + const isAllUpperCase = input === input.toUpperCase(); + const endsInS = /s$/i.test(input); + + if (endsInS) { + return `${input}'`; + } + if (isAllUpperCase) { + return `${input}'S`; + } + return `${input}'s`; +}; + +/** + * Pad a number with leading zeros + * @param {number|string} input - Number to pad + * @param {number} length - Desired length + * @returns {string} Padded number + */ +const padDigits = (input, length) => { + if (!input) return ''; + return input.toString().padStart(length, '0'); +}; + +/** + * Format number as currency with thousands separators + * @param {number} input - Number to format + * @returns {string} Formatted currency string + */ +const formatCurrency = (input) => { + if (!input) return '–'; + const value = parseInt(input, 10); + const formatted = Math.abs(value).toLocaleString(); + return value < 0 ? `–£${formatted}` : `£${formatted}`; +}; + +/** + * Format number as currency without separators (for CSV) + * @param {number} input - Number to format + * @returns {string} Formatted currency string + */ +const formatCurrencyForCsv = (input) => { + if (!input) return '0'; + const value = parseInt(input, 10); + return value < 0 ? `-£${Math.abs(value)}` : `£${value}`; +}; + +/** + * Check if string starts with target + * @param {string} input - String to check + * @param {string} target - String to look for at start + * @returns {boolean} Whether string starts with target + */ +const startsWith = (input, target) => { + if (typeof input !== 'string') return false; + return input.startsWith(target); +}; + +/** + * Check if value is a string + * @param {any} input - Value to check + * @returns {boolean} Whether value is a string + */ +const isString = (input) => { + return typeof input === 'string'; +}; + + +/** + * Format separated words as a sentence + * Example: 'in_progress' becomes 'In progress' + * @param {string} input - String to format + * @param {string} [separator='_'] - Character that separates words + * @returns {string} Formatted string as words + */ +const formatWords = (input, separator = '_') => { + if (!input) return ''; + if (typeof input !== 'string') return input; + + return input + .split(separator) + .map(word => word.toLowerCase()) + .join(' ') + // .replace(/^./, firstChar => firstChar.toUpperCase()); +}; + +/** + * Support for template literals in Nunjucks + * Usage: {{ 'The count is ${count}' | stringLiteral }} + * @param {string} str - Template string + * @returns {string} Processed string with variables replaced + */ +const stringLiteral = function(str) { + return (new Function('with (this) { return `' + str + '` }')).call(this.ctx); +}; + +/** + * Wrap string in a no-wrap span + * @param {string} input - String to wrap + * @returns {string} HTML string with no-wrap class + */ +const nowrap = (input) => { + if (!input) return ''; + return `${input}`; +}; + +module.exports = { + addIndefiniteArticle, + formatCurrency, + formatCurrencyForCsv, + formatWords, + isString, + kebabCase, + nowrap, + padDigits, + possessive, + sentenceCase, + slugify, + split, + startLowerCase, + startsWith, + stringLiteral, +}; diff --git a/app/locals.js b/app/locals.js index dbbee9c..2586cbc 100644 --- a/app/locals.js +++ b/app/locals.js @@ -1,5 +1,7 @@ module.exports = (config) => (req, res, next) => { - res.locals.serviceName = config.serviceName; - + res.locals.serviceName = config.serviceName + res.locals.url = req.path + res.locals.flash = req.flash() + res.locals.query = req.query next(); }; diff --git a/app/routes.js b/app/routes.js index 65de85c..c86c94c 100644 --- a/app/routes.js +++ b/app/routes.js @@ -3,6 +3,7 @@ const express = require('express'); const router = express.Router(); +require('./routes/settings')(router); require('./routes/clinics')(router); diff --git a/app/routes/settings.js b/app/routes/settings.js new file mode 100644 index 0000000..a2ec1cc --- /dev/null +++ b/app/routes/settings.js @@ -0,0 +1,38 @@ +// app/routes/settings.js + +const generateData = require('../lib/generate-seed-data'); +const dayjs = require('dayjs'); +const path = require('path'); +const fs = require('fs'); + +module.exports = router => { + +// Handle regenerate data action +router.post('/settings/regenerate', async (req, res) => { + try { + await generateData(); + + // Clear the require cache for session data defaults and generated data + const sessionDataPath = path.resolve(__dirname, '../data/session-data-defaults.js'); + delete require.cache[require.resolve(sessionDataPath)]; + + // Also clear cache for the generated JSON files + const generatedDataPath = path.resolve(__dirname, '../data/generated'); + Object.keys(require.cache).forEach(key => { + if (key.startsWith(generatedDataPath)) { + delete require.cache[key]; + } + }); + + // Now reload session data defaults with fresh data + req.session.data = require('../data/session-data-defaults'); + req.flash('success', 'Data regenerated successfully'); + } catch (err) { + console.error('Error regenerating data:', err); + req.flash('error', 'Error regenerating data'); + } + + res.redirect('/settings'); +}); + +} diff --git a/app/views/_templates/layout-app.html b/app/views/_templates/layout-app.html index 7aeac5b..79c1f3a 100755 --- a/app/views/_templates/layout-app.html +++ b/app/views/_templates/layout-app.html @@ -5,8 +5,8 @@ {% block beforeContent %} {% if not hideBackLink %} {{ backLink({ - href: "javascript:history.back();", - text: "Go back" + href: back.href or "javascript:history.back();", + text: back.text or "Go back" }) }} {% endif %} @@ -18,6 +18,14 @@ {{ data | log }}
+ + {% if flash.error %} + {{ errorSummary({ + "titleText": "There is a problem", + "errorList": flash.error + }) }} + {% endif %} + {% if formAction or isForm %}
{% endif %} diff --git a/app/views/_templates/layout.html b/app/views/_templates/layout.html index 974776d..85dafcb 100755 --- a/app/views/_templates/layout.html +++ b/app/views/_templates/layout.html @@ -44,8 +44,8 @@ "label": "Home" }, { - "URL": "/page-example", - "label": "Example page" + "URL": "/settings", + "label": "Settings" } ] })}} diff --git a/app/views/clinics/index.html b/app/views/clinics/index.html index f232e54..232c2cb 100644 --- a/app/views/clinics/index.html +++ b/app/views/clinics/index.html @@ -1,41 +1,62 @@ {% extends 'layout-app.html' %} -{% set pageHeading = "Today’s clinics" %} +{% set pageHeading = "All clinics" %} {% block pageContent %} -

All clinics

+

{{pageHeading}}

{% if data.clinics.length === 0 %} -

No clinics found.

+

No clinics found.

{% else %} - - - + + + + {% for clinic in data.clinics | sort(false, false, 'date') %} {% set unit = data.breastScreeningUnits | findById(clinic.breastScreeningUnitId) %} + {% set location = unit.locations | findById(clinic.locationId) %} {% set events = data.events | getClinicEvents(clinic.id) %} - - - - + + + + {% endfor %}
DateLocationActionsLocationDateStatusParticipants
{{ clinic.date | formatDate }}{{ unit.name }} - View clinic - ({{ events.length }} participants) +
+ + {% if location.type === 'mobile_unit' %} + {{ location.name }} at {{ clinic.siteName }} + {% else %} + {{ location.name }} + {% endif %} + + {{ clinic.date | formatDate }} + {{ tag({ + text: clinic.status | formatWords | sentenceCase, + classes: "nhsuk-tag--" + clinic.status | getStatusTagColour + })}} + + {{ events.length }}
- {% endif %} -

- View today's clinics -

+ + {% endif %} {% endblock %} diff --git a/app/views/clinics/today.html b/app/views/clinics/today.html index ea8830e..2ef587f 100644 --- a/app/views/clinics/today.html +++ b/app/views/clinics/today.html @@ -1,43 +1,63 @@ - {% extends 'layout-app.html' %} -{% set pageHeading = "Today’s clinics" %} - +{% set pageHeading = "Today's clinics" %} {% block pageContent %} - {% set todaysClinics = data.clinics | getTodaysClinics %} + {% set todaysClinics = data.clinics | getTodaysClinics %} -

Today's clinics

+

{{pageHeading}}

{% if todaysClinics.length === 0 %} -

No clinics scheduled for today.

+

No clinics scheduled for today.

{% else %} - - - + + + + {% for clinic in todaysClinics %} {% set unit = data.breastScreeningUnits | findById(clinic.breastScreeningUnitId) %} + {% set location = unit.locations | findById(clinic.locationId) %} {% set events = data.events | getClinicEvents(clinic.id) %} - - - - + + + + {% endfor %}
LocationTimeActionsLocationOpening hoursStatusParticipants
{{ unit.name }}{{ clinic.date | formatDate }} - View clinic - ({{ events.length }} participants) + {% set hours = clinic | getClinicHours %} +
+ + {% if location.type === 'mobile_unit' %} + {{ location.name }} at {{ clinic.siteName }} + {% else %} + {{ location.name }} + {% endif %} + + {{ hours.start | formatTime }} to {{ hours.end | formatTime }} + {{ tag({ + text: clinic.status | formatWords | sentenceCase, + classes: "nhsuk-tag--" + clinic.status | getStatusTagColour + })}} + + {{ events.length }}
- {% endif %} -

- View all clinics -

+ + {% endif %} {% endblock %} diff --git a/app/views/index.html b/app/views/index.html index 9c051ac..321ab40 100755 --- a/app/views/index.html +++ b/app/views/index.html @@ -14,19 +14,9 @@

Index page to link to bits of the prototype.

-{# {{ data.people[0].name() | log}} #} - - -{# {% set person = data.helpers.people.findById(personId) %} #} -{{ person.fullName }} - - - {{ button({ - text: "Go to dashboard", - href: "/dashboard" + text: "Clinics", + href: "/clinics" }) }} -

Clinics

- {% endblock %} diff --git a/app/views/settings.html b/app/views/settings.html new file mode 100755 index 0000000..3adfdc5 --- /dev/null +++ b/app/views/settings.html @@ -0,0 +1,92 @@ + +{% extends 'layout-app.html' %} + +{% set pageHeading = "Settings" %} + +{% set back = { + href: '/', + text: "Home" +} %} + +{% block pageContent %} + + +

+ {{pageHeading}} +

+ +{% if flash.error %} + +{% endif %} + +{% if flash.success %} + + {% set successMessage %} +

{{ flash.success }}

+ {% endset %} + + {{ insetText({ + html: successMessage + }) }} + +{% endif %} + +
+
+

Generated data

+ +
+
+
Last generated
+
{{ data.generationInfo.generatedAt | formatDateTime + }}
({{ data.generationInfo.generatedAt | formatRelativeDate }}) +
+
+ +
+
Participants
+
{{ data.generationInfo.stats.participants }}
+
+ +
+
Clinics
+
{{ data.generationInfo.stats.clinics }}
+
+ +
+
Events
+
{{ data.generationInfo.stats.events }}
+
+
+ + + + + +
+

+ + Important: + About data regeneration + +

+

Data is automatically regenerated each day to keep dates current. Manual regeneration will:

+
    +
  • create new test participants
  • +
  • generate new clinics for the next few days
  • +
  • reset all events and appointments
  • +
  • clear any changes you've made to the data
  • +
+
+
+
+ + +{% endblock %} diff --git a/gulpfile.js b/gulpfile.js index 3f85d17..3ec1922 100755 --- a/gulpfile.js +++ b/gulpfile.js @@ -53,14 +53,28 @@ function compileAssets() { .pipe(gulp.dest('public')); } -// Start nodemon +// Start nodemon with enhanced watching function startNodemon(done) { const server = nodemon({ script: 'app.js', stdout: true, - ext: 'js', + ext: 'js json', // Added json to watch for package.json changes + watch: [ + 'app/**/*.js', // Watch all JS files in app directory + 'app.js', // Watch main app file + 'routes/**/*.js', // Watch route files + 'lib/**/*.js', // Watch library files + 'config/**/*.js' // Watch configuration files + ], + ignore: [ + 'app/assets/**', // Ignore asset files + 'public/**', // Ignore compiled files + 'node_modules/**' // Ignore node_modules + ], + delay: 1000, // Add a small delay to prevent rapid restarts quiet: false, }); + let starting = false; const onReady = () => { @@ -79,38 +93,49 @@ function startNodemon(done) { onReady(); } }); + + // Add restart event handler + server.on('restart', () => { + console.log('Restarting server due to changes...'); + }); } function reload() { browserSync.reload(); } -// Start browsersync +// Start browsersync with enhanced configuration function startBrowserSync(done) { browserSync.init( { proxy: 'localhost:' + port, port: port + 1000, ui: false, - files: ['app/views/**/*.*', 'docs/views/**/*.*'], + files: [ + 'app/views/**/*.*', + 'docs/views/**/*.*', + 'public/**/*.*' + ], ghostMode: false, open: false, notify: true, watch: true, + reloadDelay: 1000, // Add delay before reload + reloadDebounce: 1000 // Debounce reloads }, done ); gulp.watch('public/**/*.*').on('change', reload); } -// Watch for changes within assets/ +// Enhanced watch function function watch() { - gulp.watch('app/assets/sass/**/*.scss', compileStyles); - gulp.watch('app/assets/javascript/**/*.js', compileScripts); - gulp.watch('app/assets/**/**/*.*', compileAssets); - gulp.watch('docs/assets/sass/**/*.scss', compileStyles); - gulp.watch('docs/assets/javascript/**/*.js', compileScripts); - gulp.watch('docs/assets/**/**/*.*', compileAssets); + gulp.watch('app/assets/sass/**/*.scss', gulp.series(compileStyles, reload)); + gulp.watch('app/assets/javascript/**/*.js', gulp.series(compileScripts, reload)); + gulp.watch('app/assets/**/**/*.*', gulp.series(compileAssets, reload)); + gulp.watch('docs/assets/sass/**/*.scss', gulp.series(compileStyles, reload)); + gulp.watch('docs/assets/javascript/**/*.js', gulp.series(compileScripts, reload)); + gulp.watch('docs/assets/**/**/*.*', gulp.series(compileAssets, reload)); } exports.watch = watch; diff --git a/package-lock.json b/package-lock.json index 7a2c432..3decd47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "body-parser": "^1.20.3", "browser-sync": "^3.0.3", "client-sessions": "^0.8.0", + "connect-flash": "^0.1.1", "cookie-parser": "^1.4.7", "dayjs": "^1.11.13", "dotenv": "^16.4.5", @@ -29,12 +30,10 @@ "gulp-rename": "^2.0.0", "gulp-sass": "^5.1.0", "lodash": "^4.17.21", - "nanoid": "^5.0.8", "nhsuk-frontend": "^9.1.0", "nunjucks": "^3.2.4", "path": "^0.12.7", "sass": "^1.80.6", - "uuid": "^11.0.3", "weighted": "^1.0.0" }, "devDependencies": { @@ -4580,6 +4579,14 @@ "node": ">= 0.10.0" } }, + "node_modules/connect-flash": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/connect-flash/-/connect-flash-0.1.1.tgz", + "integrity": "sha512-2rcfELQt/ZMP+SM/pG8PyhJRaLKp+6Hk2IUBNkEit09X+vwn3QsAL3ZbYtxUn7NVPzbMTSLRDhqe0B/eh30RYA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/connect-history-api-fallback": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz", @@ -11406,23 +11413,6 @@ "integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==", "optional": true }, - "node_modules/nanoid": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.8.tgz", - "integrity": "sha512-TcJPw+9RV9dibz1hHUzlLVy8N4X9TnwirAjrU08Juo6BNKggzVfP2ZJ/3ZUSq15Xl5i85i+Z89XBO90pB2PghQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^18 || >=20" - } - }, "node_modules/nanomatch": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", @@ -14551,18 +14541,6 @@ "node": ">= 0.4.0" } }, - "node_modules/uuid": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz", - "integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, "node_modules/v8-to-istanbul": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", @@ -18184,6 +18162,11 @@ } } }, + "connect-flash": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/connect-flash/-/connect-flash-0.1.1.tgz", + "integrity": "sha512-2rcfELQt/ZMP+SM/pG8PyhJRaLKp+6Hk2IUBNkEit09X+vwn3QsAL3ZbYtxUn7NVPzbMTSLRDhqe0B/eh30RYA==" + }, "connect-history-api-fallback": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-1.6.0.tgz", @@ -23284,11 +23267,6 @@ "integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==", "optional": true }, - "nanoid": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.8.tgz", - "integrity": "sha512-TcJPw+9RV9dibz1hHUzlLVy8N4X9TnwirAjrU08Juo6BNKggzVfP2ZJ/3ZUSq15Xl5i85i+Z89XBO90pB2PghQ==" - }, "nanomatch": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", @@ -25672,11 +25650,6 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" }, - "uuid": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz", - "integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==" - }, "v8-to-istanbul": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", diff --git a/package.json b/package.json index 8247803..04e5f98 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "body-parser": "^1.20.3", "browser-sync": "^3.0.3", "client-sessions": "^0.8.0", + "connect-flash": "^0.1.1", "cookie-parser": "^1.4.7", "dayjs": "^1.11.13", "dotenv": "^16.4.5",