From dba3a731eed1fad4c57b9709aab82065ce0880bc Mon Sep 17 00:00:00 2001 From: Jon Roobottom <=> Date: Thu, 29 Jan 2026 18:26:40 +0000 Subject: [PATCH 1/4] global date override --- app/filters.js | 24 +++++++++ app/flags/defaults.js | 3 +- app/flags/env.js | 35 +++---------- app/flags/flags-library.js | 97 ++++++++++++----------------------- app/flags/parser.js | 20 ++++++++ app/routes/base.js | 47 +++++++++++------ app/views/features.html | 19 +++++++ app/views/layouts/layout.html | 2 +- 8 files changed, 136 insertions(+), 111 deletions(-) create mode 100644 app/flags/parser.js diff --git a/app/filters.js b/app/filters.js index 8b595c9..18920db 100644 --- a/app/filters.js +++ b/app/filters.js @@ -2,6 +2,8 @@ * @param {Environment} env */ +const { DateTime } = require("luxon"); + const registerDateTimeFilters = require("./filters/datetime") const util = require("util") @@ -54,6 +56,28 @@ module.exports = function (env) { return Math.floor(Math.random() * (max - min + 1)) + min; } + filters.featureType = (value) => { + if (value === null || value === undefined) return "empty"; + + if (Array.isArray(value)) return "json"; + + const t = typeof value; + + if (t === "boolean") return "boolean"; + if (t === "number") return Number.isInteger(value) ? "int" : "number"; + if (t === "object") return "json"; + + if (t === "string") { + // ISO date heuristic: yyyy-mm-dd + const dt = DateTime.fromISO(value, { zone: "utc" }); + if (dt.isValid && value.length === 10) return "date"; + return "string"; + } + + return "string"; + } + + return filters } diff --git a/app/flags/defaults.js b/app/flags/defaults.js index 125fcb3..6c8ce4d 100644 --- a/app/flags/defaults.js +++ b/app/flags/defaults.js @@ -2,6 +2,7 @@ // eg. featureX: true, featureY: false module.exports = { allAvailability: false, - cancelDateRange: true + cancelDateRange: true, + today: null } diff --git a/app/flags/env.js b/app/flags/env.js index bd7da94..42b362f 100644 --- a/app/flags/env.js +++ b/app/flags/env.js @@ -1,35 +1,14 @@ -require('dotenv').config(); +require("dotenv").config(); +const { interpretUserInput } = require("./parser"); const fromEnv = () => { - const flags = {} + const flags = {}; for (const [key, value] of Object.entries(process.env)) { - if (key.startsWith('FEATURE_')) { - const name = key.replace('FEATURE_', '').toLowerCase(); - - //parse bools, numbers, JSON or leave as a string - let parsedValue = value; - - //bools - if (value === 'true') parsedValue = true; - else if (value === 'false') parsedValue = false; - - //numbers - else if (!Number.isNaN(Number(value))) parsedValue = Number(value); - - //JSON - else if( - (value.startsWith('{') && value.endsWith('}')) || - (value.startsWith('[') && value.endsWith(']')) - ) { - try { parsedValue = JSON.parse(value); } - catch { parsedValue = value; } - } - - flags[name] = parsedValue; - - } + if (!key.startsWith("FEATURE_")) continue; + const name = key.replace("FEATURE_", "").toLowerCase(); + flags[name] = interpretUserInput(value); } return flags; -} +}; module.exports = fromEnv(); diff --git a/app/flags/flags-library.js b/app/flags/flags-library.js index 64d6233..e906ecc 100644 --- a/app/flags/flags-library.js +++ b/app/flags/flags-library.js @@ -1,93 +1,62 @@ -const envFlags = require('./env') -const defaults = require('./defaults') +const envFlags = require("./env"); +const defaults = require("./defaults"); +const { interpretUserInput } = require("./parser"); - -// Parse overrides from the querystring -// E.g., `?feature=calendar:on,search:off` const parseQueryOverrides = (req) => { const overrides = {}; + if (!req.query.features) return overrides; - if(req.query.features) { - const items = String(req.query.features).split(','); - for (const item of items) { - const [rawKey, rawVal] = item.split(':'); - if (!rawKey) continue; - const key = rawKey.trim(); - //set value to true if none is provided - const val = (rawVal ?? 'true').trim(); + for (const item of String(req.query.features).split(",")) { + const [rawKey, rawVal] = item.split(":"); + if (!rawKey) continue; - //normalise and add the key/value to the overrides object - overrides[key] = normaliseValue(val); - } - } + const key = rawKey.trim(); + const raw = (rawVal ?? "true").trim(); + overrides[key] = interpretUserInput(raw); + } return overrides; +}; -} - -const parseFormOverrdes = (req) => { +const parseFormOverrides = (req) => { const overrides = {}; - - //only process form post data - if (req.method !== 'POST') return overrides; - - //only process POST with body data - if (!req.body) return overrides; + if (req.method !== "POST" || !req.body) return overrides; const featuresFromForm = req.body.features || {}; - for (const [key, value] of Object.entries(featuresFromForm)) { - if (value === '' || value === null) continue; - overrides[key] = normaliseValue(String(value)); + for (const [key, raw] of Object.entries(featuresFromForm)) { + if (raw === "" || raw === null) continue; + overrides[key] = interpretUserInput(raw); } return overrides; -} - -const normaliseValue = (val) => { - if (val === 'on' || val === 'true') return true; - if (val === 'off' || val === 'false') return false; - if (!Number.isNaN(Number(val))) return Number(val); - try { return JSON.parse(val); } catch { return val; } -} +}; const buildFlags = (req) => { const sessionOverrides = req.session?.features || {}; const queryOverrides = parseQueryOverrides(req); - const formOverrides = parseFormOverrdes(req); + const formOverrides = parseFormOverrides(req); + const flags = { ...defaults, ...envFlags, ...sessionOverrides, ...queryOverrides, - ...formOverrides - } + ...formOverrides, + }; - //persist anything new in the form or query string - const newValues = { - ...queryOverrides, - ...formOverrides - } + // persist query/form overrides into session (raw values only) + const newValues = { ...queryOverrides, ...formOverrides }; if (Object.keys(newValues).length) { - req.session.features = { - ...sessionOverrides, - ...newValues - } + req.session.features = { ...sessionOverrides, ...newValues }; } return flags; -} - -const flagsMiddleware = () => { - return (req, res, next) => { - const flags = buildFlags(req); - - //attach flags to req to use in routes - req.features = flags; - - //attach flags to locals to use in views - res.locals.features = flags; - - next(); - } -} +}; + +const flagsMiddleware = () => (req, res, next) => { + const flags = buildFlags(req); + req.features = flags; + res.locals.features = flags; + next(); +}; module.exports = { flagsMiddleware }; \ No newline at end of file diff --git a/app/flags/parser.js b/app/flags/parser.js new file mode 100644 index 0000000..262ffd6 --- /dev/null +++ b/app/flags/parser.js @@ -0,0 +1,20 @@ + +function interpretUserInput(raw) { + const s = String(raw ?? "").trim(); + + if (s === "on" || s === "true") return true; + if (s === "off" || s === "false") return false; + + if (!Number.isNaN(Number(s))) return Number(s); + + if ( + (s.startsWith("{") && s.endsWith("}")) || + (s.startsWith("[") && s.endsWith("]")) + ) { + try { return JSON.parse(s); } catch {} + } + + return s; +} + +module.exports = { interpretUserInput }; diff --git a/app/routes/base.js b/app/routes/base.js index e57c4c4..16fc45f 100644 --- a/app/routes/base.js +++ b/app/routes/base.js @@ -4,15 +4,32 @@ const router = express.Router(); const { DateTime } = require('luxon'); const { availabilityGroups } = require('../helpers/availabilityGroups'); -const { calendar } = require('../helpers/calendar'); const updateDailyAvailability = require('../helpers/updateDailyAvailability'); const enhanceData = require('../helpers/enhanceData'); const summarise = require('../helpers/summaries'); const compareGroups = require('../helpers/compareGroups'); -const removeServicesFromDailyAvailability = require('../helpers/removeDailyAvailability'); -const { every } = require('lodash'); -const override_today = '2026-09-28'; //for testing purposes +// ----------------------------------------------------------------------------- +// TODAY OVERRIDE +// ----------------------------------------------------------------------------- +function resolveToday(req) { + const override = req.features?.today; + const realToday = DateTime.now().toFormat("yyyy-MM-dd"); + + if (override) { + const dt = DateTime.fromISO(String(override), { zone: "utc" }); + if (dt.isValid) return dt.toFormat("yyyy-MM-dd"); + } + + return realToday; +} + +router.use((req, res, next) => { + //make 'today' available globally + override_today = resolveToday(req); + res.locals.today = override_today; + next(); +}); // ----------------------------------------------------------------------------- // PARAM HANDLER – capture site_id once for all /site/:id routes @@ -30,9 +47,10 @@ router.param('id', (req, res, next, id) => { router.use('/site/:id', (req, res, next) => { const data = req.session.data; const site_id = String(req.site_id); + const today = resolveToday(req); + // Maybe: Remove this filters stuff as we're not doing any filtering in this prototype? //use filters everywhere - const today = override_today || DateTime.now().toFormat('yyyy-MM-dd'); const sessionFilters = data.filters?.[site_id] || {}; // --- Prefer query, then session, then default --- @@ -63,8 +81,6 @@ router.use('/site/:id', (req, res, next) => { until: resolvedUntil }; - data.today = today; //expose to session data for convenience - if (!data?.sites?.[site_id]) { console.warn(`⚠️ Site ${site_id} not found in session data`); @@ -101,7 +117,7 @@ router.use('/site/:id', (req, res, next) => { router.post('/set-filters', (req, res) => { const filters = { ...req.body.filters } - req.session.data.filters = filters; + req.session.filters = filters; res.redirect(req.body.next); }); @@ -191,21 +207,18 @@ router.get('/site/:id/create-availability/process-new-session', (req, res) => { // VIEW AVAILABILITY // ----------------------------------------------------------------------------- router.get('/site/:id/availability/day', (req, res) => { - const date = req.query.date || override_today || DateTime.now().toFormat('yyyy-MM-dd'); + const startFromDate = req.query.date || resolveToday(req); res.render('site/availability/day', { - date, - today: override_today || DateTime.now().toFormat('yyyy-MM-dd'), - tomorrow: DateTime.fromISO(date).plus({ days: 1 }).toISODate(), - yesterday: DateTime.fromISO(date).minus({ days: 1 }).toISODate() + date: startFromDate, + tomorrow: DateTime.fromISO(startFromDate).plus({ days: 1 }).toISODate(), + yesterday: DateTime.fromISO(startFromDate).minus({ days: 1 }).toISODate() }); }); router.get('/site/:id/availability/week', (req, res) => { - const data = req.session.data; - const site_id = req.site_id; - const startFromDate = req.query.date || override_today || DateTime.now().toFormat('yyyy-MM-dd'); - const today = override_today || DateTime.now().toFormat('yyyy-MM-dd'); + const startFromDate = req.query.date || resolveToday(req); + const today = resolveToday(req); //return dates for the week containing 'date' const week = []; diff --git a/app/views/features.html b/app/views/features.html index 43cf10a..16aac32 100644 --- a/app/views/features.html +++ b/app/views/features.html @@ -10,6 +10,8 @@
You cannot edit sessions once they have been created. To make changes, you need to cancel the existing sessions and create new ones.
+You cannot edit sessions after you create them. To change a session, you must:
Any bookings you keep will be automatically moved into the new sessions.
From ae5960f3820226bc74d8e1fd7a53411faaf79fb0 Mon Sep 17 00:00:00 2001 From: Jon Roobottom <=> Date: Fri, 30 Jan 2026 10:53:51 +0000 Subject: [PATCH 3/4] content tweaks --- app/views/site/cancel-availability/edit-guidance.html | 2 +- app/views/site/cancel-availability/sessions-and-bookings.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/site/cancel-availability/edit-guidance.html b/app/views/site/cancel-availability/edit-guidance.html index 17451f6..585d744 100644 --- a/app/views/site/cancel-availability/edit-guidance.html +++ b/app/views/site/cancel-availability/edit-guidance.html @@ -18,7 +18,7 @@Any bookings you keep will be automatically moved into the new sessions.
+ {#Any bookings you keep will be automatically moved into the new sessions.
#} {{ button({ text: "Continue", diff --git a/app/views/site/cancel-availability/sessions-and-bookings.html b/app/views/site/cancel-availability/sessions-and-bookings.html index 2b57b69..42431e5 100644 --- a/app/views/site/cancel-availability/sessions-and-bookings.html +++ b/app/views/site/cancel-availability/sessions-and-bookings.html @@ -136,7 +136,7 @@ }) }} {% endblock %} -{% set secondParagraph = "Any bookings you keep will appear in your scheduled list of appointments." %} +{% set secondParagraph = "Bookings you keep appear in your appointments list. We automatically move them into new sessions when you add matching availability." %} {% if content == '2' %} {% set secondParagraph = "By cancelling these sessions, these bookings will be unsupported by a session but they will still appear in your scheduled list of appointments." %} {% endif %} From aa9b5a7ae68b6a857ce7c80b8bf58f5eae0c6413 Mon Sep 17 00:00:00 2001 From: Jon Roobottom <=> Date: Fri, 30 Jan 2026 11:50:50 +0000 Subject: [PATCH 4/4] updated grouped checkboxes --- app/data/session-data-defaults.js | 4 ++-- app/views/concepts/custom-components/index.html | 6 +++++- app/views/site/create-availability/services.html | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/data/session-data-defaults.js b/app/data/session-data-defaults.js index e4eeda3..11df0fb 100644 --- a/app/data/session-data-defaults.js +++ b/app/data/session-data-defaults.js @@ -93,7 +93,7 @@ const base = { }, 'FLU_AND_COVID': { id: 'FLU_AND_COVID', - title: 'Flu and COVID-19 services' + title: 'Flu and COVID-19 co-admin services' }, 'RSV': { id: 'RSV', @@ -101,7 +101,7 @@ const base = { }, 'RSV_AND_COVID': { id: 'RSV_AND_COVID', - title: 'RSV and COVID-19 services' + title: 'RSV and COVID-19 co-admin services' } }, services: { diff --git a/app/views/concepts/custom-components/index.html b/app/views/concepts/custom-components/index.html index 11dd53e..9ec247d 100644 --- a/app/views/concepts/custom-components/index.html +++ b/app/views/concepts/custom-components/index.html @@ -35,6 +35,7 @@Co-admin appointments can only be booked by people eligible for both vaccinations.