356 lines
9.7 KiB
JavaScript
356 lines
9.7 KiB
JavaScript
// Minimal row editor for timetable groups.
|
|
|
|
(function () {
|
|
var form = document.getElementById('timetable-group-form');
|
|
var timezoneInput = document.getElementById('timetable-group-timezone');
|
|
var body = document.querySelector('[data-timetable-entries-body]');
|
|
var addButton = document.querySelector('[data-add-timetable-entry]');
|
|
var template = document.getElementById('timetable-entry-row-template');
|
|
|
|
if (!form || !timezoneInput || !body || !addButton || !template) {
|
|
return;
|
|
}
|
|
|
|
var DEFAULT_TIME_ZONE = 'Europe/London';
|
|
|
|
function markDirty() {
|
|
form.dataset.dirty = 'true';
|
|
}
|
|
|
|
function pad(value) {
|
|
return String(value).padStart(2, '0');
|
|
}
|
|
|
|
function getDefaultTimeZone() {
|
|
try {
|
|
return Intl.DateTimeFormat().resolvedOptions().timeZone || DEFAULT_TIME_ZONE;
|
|
} catch (_error) {
|
|
return DEFAULT_TIME_ZONE;
|
|
}
|
|
}
|
|
|
|
function resolveTimeZone(value) {
|
|
var raw = String(value || '').trim();
|
|
if (!raw) {
|
|
return getDefaultTimeZone();
|
|
}
|
|
|
|
try {
|
|
new Intl.DateTimeFormat('en-GB', { timeZone: raw }).format(new Date());
|
|
return raw;
|
|
} catch (_error) {
|
|
return getDefaultTimeZone();
|
|
}
|
|
}
|
|
|
|
function parseDateTimeLocalParts(value) {
|
|
var raw = String(value || '').trim();
|
|
var match = raw.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
year: Number(match[1]),
|
|
month: Number(match[2]),
|
|
day: Number(match[3]),
|
|
hour: Number(match[4]),
|
|
minute: Number(match[5]),
|
|
second: Number(match[6] || '0')
|
|
};
|
|
}
|
|
|
|
function getDateTimeParts(date, timeZone) {
|
|
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
|
return null;
|
|
}
|
|
|
|
var resolvedTimeZone = resolveTimeZone(timeZone);
|
|
var parts = new Intl.DateTimeFormat('en-GB', {
|
|
timeZone: resolvedTimeZone,
|
|
hour12: false,
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit'
|
|
}).formatToParts(date);
|
|
var mapped = Object.create(null);
|
|
|
|
parts.forEach(function (part) {
|
|
if (part && part.type && part.type !== 'literal') {
|
|
mapped[part.type] = part.value;
|
|
}
|
|
});
|
|
|
|
return mapped;
|
|
}
|
|
|
|
function formatDateTimeLocalValue(date, timeZone) {
|
|
var parts = getDateTimeParts(date, timeZone);
|
|
if (!parts) {
|
|
return '';
|
|
}
|
|
|
|
return [
|
|
String(parts.year || '').padStart(4, '0'),
|
|
'-',
|
|
pad(parts.month),
|
|
'-',
|
|
pad(parts.day),
|
|
'T',
|
|
pad(parts.hour),
|
|
':',
|
|
pad(parts.minute)
|
|
].join('');
|
|
}
|
|
|
|
function getTimeZoneOffsetMillis(date, timeZone) {
|
|
var parts = getDateTimeParts(date, timeZone);
|
|
if (!parts) {
|
|
return 0;
|
|
}
|
|
|
|
var localAsUtc = Date.UTC(
|
|
Number(parts.year) || 0,
|
|
(Number(parts.month) || 1) - 1,
|
|
Number(parts.day) || 1,
|
|
Number(parts.hour) || 0,
|
|
Number(parts.minute) || 0,
|
|
Number(parts.second) || 0,
|
|
0
|
|
);
|
|
|
|
return localAsUtc - date.getTime();
|
|
}
|
|
|
|
function parseDateTimeLocalAsUtc(value, timeZone) {
|
|
var raw = String(value || '').trim();
|
|
var isoDate;
|
|
var parts = parseDateTimeLocalParts(value);
|
|
if (!parts) {
|
|
isoDate = /(?:[zZ]|[+-]\d\d(?::?\d\d)?)$/.test(raw) ? new Date(raw) : null;
|
|
return isoDate && !Number.isNaN(isoDate.getTime()) ? isoDate : null;
|
|
}
|
|
|
|
var resolvedTimeZone = resolveTimeZone(timeZone);
|
|
var utcMillis = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second, 0);
|
|
var date = new Date(utcMillis);
|
|
var offset = getTimeZoneOffsetMillis(date, resolvedTimeZone);
|
|
var adjusted = new Date(utcMillis - offset);
|
|
var adjustedOffset = getTimeZoneOffsetMillis(adjusted, resolvedTimeZone);
|
|
|
|
if (adjustedOffset !== offset) {
|
|
adjusted = new Date(utcMillis - adjustedOffset);
|
|
}
|
|
|
|
return Number.isNaN(adjusted.getTime()) ? null : adjusted;
|
|
}
|
|
|
|
function convertDateTimeLocalValue(value, sourceTimeZone, targetTimeZone) {
|
|
var raw = String(value || '').trim();
|
|
if (!raw) {
|
|
return '';
|
|
}
|
|
|
|
var utcDate = parseDateTimeLocalAsUtc(raw, sourceTimeZone);
|
|
if (!utcDate) {
|
|
return '';
|
|
}
|
|
|
|
var resolvedTargetTimeZone = resolveTimeZone(targetTimeZone);
|
|
if (resolvedTargetTimeZone === 'UTC') {
|
|
return utcDate.toISOString();
|
|
}
|
|
|
|
return formatDateTimeLocalValue(utcDate, resolvedTargetTimeZone);
|
|
}
|
|
|
|
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) {
|
|
var timezone = resolveTimeZone(timezoneInput.value);
|
|
var startInput;
|
|
var endInput;
|
|
var startValue;
|
|
var endValue;
|
|
var startDate;
|
|
var endDate;
|
|
|
|
if (!row) {
|
|
return;
|
|
}
|
|
|
|
startInput = row.querySelector('[name="entry_start_datetime[]"]');
|
|
endInput = row.querySelector('[name="entry_end_datetime[]"]');
|
|
|
|
clearFieldValidity(endInput);
|
|
|
|
if (!startInput || !endInput) {
|
|
return;
|
|
}
|
|
|
|
startValue = String(startInput.value || '').trim();
|
|
endValue = String(endInput.value || '').trim();
|
|
|
|
if (!startValue || !endValue) {
|
|
return;
|
|
}
|
|
|
|
startDate = parseDateTimeLocalAsUtc(startValue, timezone);
|
|
endDate = parseDateTimeLocalAsUtc(endValue, timezone);
|
|
|
|
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) {
|
|
var startInput;
|
|
var endInput;
|
|
|
|
if (!row) {
|
|
return;
|
|
}
|
|
|
|
startInput = row.querySelector('[name="entry_start_datetime[]"]');
|
|
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 syncRowValuesToTimeZone(row, sourceTimeZone, targetTimeZone) {
|
|
if (!row) {
|
|
return;
|
|
}
|
|
|
|
['entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) {
|
|
var input = row.querySelector('[name="' + name + '"]');
|
|
var convertedValue;
|
|
|
|
if (!input) {
|
|
return;
|
|
}
|
|
|
|
convertedValue = convertDateTimeLocalValue(input.value, sourceTimeZone, targetTimeZone);
|
|
if (convertedValue) {
|
|
input.value = convertedValue;
|
|
}
|
|
});
|
|
}
|
|
|
|
function syncAllRowsToTimeZone(sourceTimeZone, targetTimeZone) {
|
|
body.querySelectorAll('[data-timetable-entry-row]').forEach(function (row) {
|
|
syncRowValuesToTimeZone(row, sourceTimeZone, targetTimeZone);
|
|
validateEntryRow(row);
|
|
});
|
|
}
|
|
|
|
function syncFormDataToUtc(formData) {
|
|
var rows = body.querySelectorAll('[data-timetable-entry-row]');
|
|
var currentTimeZone = resolveTimeZone(timezoneInput.value);
|
|
|
|
['entry_id[]', 'entry_title[]', 'entry_short_description[]', 'entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) {
|
|
formData.delete(name);
|
|
});
|
|
|
|
rows.forEach(function (row) {
|
|
var idInput = row.querySelector('[name="entry_id[]"]');
|
|
var titleInput = row.querySelector('[name="entry_title[]"]');
|
|
var descriptionInput = row.querySelector('[name="entry_short_description[]"]');
|
|
var startInput = row.querySelector('[name="entry_start_datetime[]"]');
|
|
var endInput = row.querySelector('[name="entry_end_datetime[]"]');
|
|
|
|
formData.append('entry_id[]', idInput ? idInput.value : '');
|
|
formData.append('entry_title[]', titleInput ? titleInput.value : '');
|
|
formData.append('entry_short_description[]', descriptionInput ? descriptionInput.value : '');
|
|
formData.append('entry_start_datetime[]', startInput ? convertDateTimeLocalValue(startInput.value, currentTimeZone, 'UTC') : '');
|
|
formData.append('entry_end_datetime[]', endInput ? convertDateTimeLocalValue(endInput.value, currentTimeZone, 'UTC') : '');
|
|
});
|
|
}
|
|
|
|
function bindRemove(row) {
|
|
var button = row.querySelector('[data-remove-timetable-entry]');
|
|
if (!button) {
|
|
return;
|
|
}
|
|
button.addEventListener('click', function () {
|
|
row.remove();
|
|
markDirty();
|
|
});
|
|
}
|
|
|
|
function addRow() {
|
|
var fragment = template.content.cloneNode(true);
|
|
var row = fragment.querySelector('[data-timetable-entry-row]');
|
|
if (!row) {
|
|
return;
|
|
}
|
|
bindRemove(row);
|
|
bindRowValidation(row);
|
|
body.appendChild(fragment);
|
|
markDirty();
|
|
}
|
|
|
|
function handleTimezoneChange() {
|
|
var nextTimeZone = resolveTimeZone(timezoneInput.value || DEFAULT_TIME_ZONE);
|
|
form.dataset.timetableTimezone = nextTimeZone;
|
|
markDirty();
|
|
}
|
|
|
|
form.dataset.timetableTimezone = resolveTimeZone(timezoneInput.value || DEFAULT_TIME_ZONE);
|
|
|
|
body.querySelectorAll('[data-timetable-entry-row]').forEach(function (row) {
|
|
bindRemove(row);
|
|
bindRowValidation(row);
|
|
});
|
|
|
|
form.addEventListener('formdata', function (event) {
|
|
syncFormDataToUtc(event.formData);
|
|
});
|
|
|
|
timezoneInput.addEventListener('change', handleTimezoneChange);
|
|
timezoneInput.addEventListener('input', handleTimezoneChange);
|
|
|
|
addButton.addEventListener('click', addRow);
|
|
}()); |