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
|