From bcae4bb318f100291a2730a25d0e38b38fa910e9 Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Sat, 8 Aug 2026 00:00:51 +0100 Subject: [PATCH] Add timetable end-time validation --- CHANGELOG.md | 6 + package.json | 2 +- .../js/data-sources/timetable-group-form.js | 97 ++++++++ .../views/data-sources/timetables/form.hbs | 2 + test/timetable-group-form.test.js | 207 ++++++++++++++++++ 5 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 test/timetable-group-form.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c940e6..0683f1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## 2.6.6 - 2026-08-07 + +### Fixed + +- Timetable entry editing now validates end times locally, requires the end to be at least one minute after the start, and highlights the end field when the value is invalid. + ## 2.6.5 - 2026-08-07 ### Added diff --git a/package.json b/package.json index cb2960b..4556c1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.6.5", + "version": "2.6.6", "private": false, "description": "Pulse Signage application with MySQL and media storage", "repository": { diff --git a/src/web/public/js/data-sources/timetable-group-form.js b/src/web/public/js/data-sources/timetable-group-form.js index 0a0322d..69b9778 100644 --- a/src/web/public/js/data-sources/timetable-group-form.js +++ b/src/web/public/js/data-sources/timetable-group-form.js @@ -47,6 +47,101 @@ return Number.isNaN(date.getTime()) ? null : date; } + function parseDateTimeLocalValue(value) { + var raw = String(value || '').trim(); + if (!raw) { + return null; + } + + var date = new Date(raw); + return Number.isNaN(date.getTime()) ? null : date; + } + + function clearFieldValidity(input) { + if (!input) { + return; + } + + input.setCustomValidity(''); + input.classList.remove('is-invalid'); + if (input.removeAttribute) { + input.removeAttribute('aria-invalid'); + } + } + + function setFieldValidity(input, message) { + if (!input) { + return; + } + + input.setCustomValidity(message); + input.classList.add('is-invalid'); + if (input.setAttribute) { + input.setAttribute('aria-invalid', 'true'); + } + } + + function validateEntryRow(row) { + if (!row) { + return; + } + + var startInput = row.querySelector('[name="entry_start_datetime[]"]'); + var endInput = row.querySelector('[name="entry_end_datetime[]"]'); + var startValue; + var endValue; + var startDate; + var endDate; + + clearFieldValidity(endInput); + + if (!startInput || !endInput) { + return; + } + + startValue = String(startInput.value || '').trim(); + endValue = String(endInput.value || '').trim(); + + if (!startValue || !endValue) { + return; + } + + startDate = parseDateTimeLocalValue(startValue); + endDate = parseDateTimeLocalValue(endValue); + + if (!startDate || !endDate) { + return; + } + + if (endDate.getTime() < startDate.getTime() + 60000) { + setFieldValidity(endInput, 'End time must be at least 1 minute after the start time.'); + } + } + + function bindRowValidation(row) { + if (!row) { + return; + } + + var startInput = row.querySelector('[name="entry_start_datetime[]"]'); + var endInput = row.querySelector('[name="entry_end_datetime[]"]'); + + if (!startInput || !endInput) { + return; + } + + function handleValidation() { + validateEntryRow(row); + } + + startInput.addEventListener('input', handleValidation); + startInput.addEventListener('change', handleValidation); + endInput.addEventListener('input', handleValidation); + endInput.addEventListener('change', handleValidation); + + validateEntryRow(row); + } + function toUtcDateTimeLocalValue(value) { var localDate = new Date(String(value || '').trim()); return Number.isNaN(localDate.getTime()) ? '' : localDate.toISOString(); @@ -110,6 +205,7 @@ return; } bindRemove(row); + bindRowValidation(row); body.appendChild(fragment); markDirty(); } @@ -117,6 +213,7 @@ body.querySelectorAll('[data-timetable-entry-row]').forEach(function (row) { bindRemove(row); syncRowValuesToLocal(row); + bindRowValidation(row); }); form.addEventListener('formdata', function (event) { diff --git a/src/web/views/data-sources/timetables/form.hbs b/src/web/views/data-sources/timetables/form.hbs index 6bfdb0c..63081e9 100644 --- a/src/web/views/data-sources/timetables/form.hbs +++ b/src/web/views/data-sources/timetables/form.hbs @@ -59,6 +59,7 @@ +
End time must be at least 1 minute after the start time.
@@ -86,6 +87,7 @@ +
End time must be at least 1 minute after the start time.
diff --git a/test/timetable-group-form.test.js b/test/timetable-group-form.test.js new file mode 100644 index 0000000..ec59947 --- /dev/null +++ b/test/timetable-group-form.test.js @@ -0,0 +1,207 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +function createClassList() { + const classes = new Set(); + + return { + add(name) { + classes.add(name); + }, + remove(name) { + classes.delete(name); + }, + contains(name) { + return classes.has(name); + } + }; +} + +function createInput(name, value) { + const listeners = Object.create(null); + const attributes = Object.create(null); + + return { + name: name, + value: value || '', + dataset: {}, + classList: createClassList(), + attributes: attributes, + validityMessage: '', + addEventListener(type, handler) { + if (!listeners[type]) { + listeners[type] = []; + } + listeners[type].push(handler); + }, + dispatchEvent(event) { + const handlers = listeners[event.type] || []; + handlers.forEach(function (handler) { + handler.call(this, event); + }, this); + }, + setCustomValidity(message) { + this.validityMessage = String(message || ''); + }, + setAttribute(name, value) { + attributes[name] = String(value); + }, + removeAttribute(name) { + delete attributes[name]; + } + }; +} + +function createRow(startValue, endValue) { + const startInput = createInput('entry_start_datetime[]', startValue); + const endInput = createInput('entry_end_datetime[]', endValue); + const titleInput = createInput('entry_title[]', 'Opening'); + const descriptionInput = createInput('entry_short_description[]', ''); + const idInput = createInput('entry_id[]', '1'); + const removeButton = { + addEventListener() {} + }; + + const row = { + startInput: startInput, + endInput: endInput, + querySelector(selector) { + if (selector === '[name="entry_start_datetime[]"]') { + return startInput; + } + if (selector === '[name="entry_end_datetime[]"]') { + return endInput; + } + if (selector === '[name="entry_title[]"]') { + return titleInput; + } + if (selector === '[name="entry_short_description[]"]') { + return descriptionInput; + } + if (selector === '[name="entry_id[]"]') { + return idInput; + } + if (selector === '[data-remove-timetable-entry]') { + return removeButton; + } + return null; + }, + remove() { + row.removed = true; + } + }; + + return row; +} + +function formatDateTimeLocalValue(date) { + function pad(value) { + return String(value).padStart(2, '0'); + } + + return [ + String(date.getFullYear()).padStart(4, '0'), + '-', + pad(date.getMonth() + 1), + '-', + pad(date.getDate()), + 'T', + pad(date.getHours()), + ':', + pad(date.getMinutes()) + ].join(''); +} + +test('timetable group form highlights an end time that is too early', () => { + const row = createRow('2026-08-07T10:00', '2026-08-07T10:00'); + const body = { + rows: [row], + querySelectorAll(selector) { + return selector === '[data-timetable-entry-row]' ? this.rows.slice() : []; + }, + appendChild(node) { + this.rows.push(node); + return node; + } + }; + const addButton = { + listeners: Object.create(null), + addEventListener(type, handler) { + this.listeners[type] = handler; + }, + click() { + if (this.listeners.click) { + this.listeners.click(); + } + } + }; + const form = { + dataset: {}, + addEventListener() {} + }; + const templateRow = createRow('', ''); + const template = { + content: { + cloneNode() { + return { + querySelector(selector) { + return selector === '[data-timetable-entry-row]' ? templateRow : null; + } + }; + } + } + }; + + const sandbox = { + document: { + getElementById(id) { + if (id === 'timetable-group-form') { + return form; + } + if (id === 'timetable-entry-row-template') { + return template; + } + return null; + }, + querySelector(selector) { + if (selector === '[data-timetable-entries-body]') { + return body; + } + if (selector === '[data-add-timetable-entry]') { + return addButton; + } + return null; + } + }, + window: {}, + Date: Date, + Number: Number, + String: String, + Array: Array, + Object: Object, + Math: Math, + JSON: JSON, + console: console, + module: { exports: {} }, + exports: {} + }; + sandbox.window = sandbox; + + const scriptPath = path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'data-sources', 'timetable-group-form.js'); + const script = fs.readFileSync(scriptPath, 'utf8'); + vm.runInNewContext(script, sandbox, { filename: scriptPath }); + + assert.equal(row.endInput.validityMessage, 'End time must be at least 1 minute after the start time.'); + assert.equal(row.endInput.classList.contains('is-invalid'), true); + assert.equal(row.endInput.attributes['aria-invalid'], 'true'); + + row.endInput.value = formatDateTimeLocalValue(new Date(new Date(row.startInput.value).getTime() + 60000)); + row.endInput.dispatchEvent({ type: 'input' }); + + assert.equal(row.endInput.validityMessage, ''); + assert.equal(row.endInput.classList.contains('is-invalid'), false); + assert.equal(Object.prototype.hasOwnProperty.call(row.endInput.attributes, 'aria-invalid'), false); +}); \ No newline at end of file