// 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); }());