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