Add timetable end-time validation

This commit is contained in:
2026-08-08 00:00:51 +01:00
parent 0b300d6ddb
commit bcae4bb318
5 changed files with 313 additions and 1 deletions
+6
View File
@@ -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
+1 -1
View File
@@ -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": {
@@ -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) {
@@ -59,6 +59,7 @@
</td>
<td>
<input type="datetime-local" name="entry_end_datetime[]" class="form-control" value="{{endValue}}" />
<div class="invalid-feedback">End time must be at least 1 minute after the start time.</div>
</td>
<td>
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-timetable-entry>Remove</button>
@@ -86,6 +87,7 @@
</td>
<td>
<input type="datetime-local" name="entry_end_datetime[]" class="form-control" value="" />
<div class="invalid-feedback">End time must be at least 1 minute after the start time.</div>
</td>
<td>
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-timetable-entry>Remove</button>
+207
View File
@@ -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);
});