function toast(message, error = false) {
const host = $('#toastStack'); if (!host) return;
const item = document.createElement('div');
item.className = `toast-item ${error ? 'error' : 'success'}`;
item.innerHTML = `${error ? '!' : '✓'}
${esc(error ? tr('toast.errorTitle') : tr('toast.successTitle'))}${esc(message)}
`;
host.appendChild(item);
requestAnimationFrame(() => item.classList.add('show'));
const remove = () => { item.classList.remove('show'); item.classList.add('leaving'); setTimeout(() => item.remove(), 220); };
item.querySelector('.toast-close').addEventListener('click', remove);
item._timer = setTimeout(remove, error ? 5200 : 3600);
}
const cleanFormSnapshots = new WeakMap();
function formSnapshot(form) {
if (!form) return '';
const rows = [];
$$('input, select, textarea', form).forEach((field, index) => {
if (field.type === 'submit' || field.type === 'button' || field.type === 'file') return;
const key = field.name || field.id || field.dataset.sensorAlias || `field-${index}`;
const value = (field.type === 'checkbox' || field.type === 'radio') ? String(field.checked) : String(field.value ?? '');
rows.push([key, value, String(field.disabled)]);
});
return JSON.stringify(rows);
}
function markFormClean(form) {
if (!form) return;
cleanFormSnapshots.set(form, formSnapshot(form));
form.classList.remove('has-unsaved-changes');
}
function updateDirtyIndicator(form) {
if (!form || !cleanFormSnapshots.has(form)) return;
form.classList.toggle('has-unsaved-changes', isFormDirty(form));
}
function isFormDirty(form) {
if (!form || !cleanFormSnapshots.has(form)) return false;
return cleanFormSnapshots.get(form) !== formSnapshot(form);
}
function clearFormErrors(form) {
if (!form) return;
$$('.field-error, .form-error-summary', form).forEach(node => node.remove());
$$('[aria-invalid="true"]', form).forEach(field => field.removeAttribute('aria-invalid'));
$$('.has-error', form).forEach(node => node.classList.remove('has-error'));
}
function showFieldError(field, message) {
if (!field || !message) return;
field.setAttribute('aria-invalid', 'true');
const label = field.closest('label');
if (label) label.classList.add('has-error');
const existing = field.nextElementSibling?.classList?.contains('field-error') ? field.nextElementSibling : null;
if (existing) { existing.textContent = message; return; }
const note = document.createElement('small');
note.className = 'field-error';
note.textContent = message;
field.insertAdjacentElement('afterend', note);
}
function showFormError(form, message) {
if (!form || !message) return;
const summary = document.createElement('div');
summary.className = 'form-error-summary';
summary.setAttribute('role', 'alert');
summary.textContent = message;
const anchor = $('.settings-save-bar', form) || $('.form-actions', form);
if (anchor?.parentElement) anchor.parentElement.insertBefore(summary, anchor);
else form.appendChild(summary);
}
function validationMessageFor(field) {
if (field.validity?.valueMissing) return tr('validation.required');
if (field.validity?.typeMismatch) return field.type === 'url' ? tr('validation.url') : tr('validation.invalid');
if (field.validity?.rangeUnderflow || field.validity?.rangeOverflow) {
return tr('validation.range', { min: field.min || '—', max: field.max || '—' });
}
if (field.validity?.badInput || field.validity?.stepMismatch || field.validity?.patternMismatch) return tr('validation.invalid');
return field.validity?.valid === false ? tr('validation.invalid') : '';
}
function httpUrlValid(value) {
if (!String(value || '').trim()) return true;
try { return ['http:', 'https:'].includes(new URL(value).protocol); } catch (_) { return false; }
}
function entityIdValid(value) {
return !String(value || '').trim() || /^[a-z0-9_]+\.[a-z0-9_]+$/i.test(String(value).trim());
}
function validateDecimalField(form, name, min, max) {
const field = form?.elements?.[name];
if (!field || field.disabled) return true;
const value = parseDecimal(field.value);
if (!Number.isFinite(value) || value < min || value > max) {
showFieldError(field, tr('validation.range', { min, max }));
return false;
}
return true;
}
function validateForm(form) {
if (!form) return true;
clearFormErrors(form);
let valid = true;
$$('input, select, textarea', form).forEach(field => {
if (field.disabled || field.type === 'hidden' || field.type === 'file') return;
const message = validationMessageFor(field);
if (message) { showFieldError(field, message); valid = false; }
});
if (form.id === 'homeAssistantForm') {
const url = form.elements.ha_url;
if (url?.value && !httpUrlValid(url.value)) { showFieldError(url, tr('validation.url')); valid = false; }
['ha_entity_id', 'ha_outdoor_entity_id'].forEach(name => {
const field = form.elements[name];
if (field?.value && !entityIdValid(field.value)) { showFieldError(field, tr('validation.entityId')); valid = false; }
});
}
if (form.id === 'settingsForm' && form.elements.influx_enabled?.checked) {
const url = form.elements.influx_url;
if (!url?.value?.trim()) { showFieldError(url, tr('validation.required')); valid = false; }
else if (!httpUrlValid(url.value)) { showFieldError(url, tr('validation.url')); valid = false; }
}
if (form.id === 'temporaryThermostatForm') {
if (!validateDecimalField(form, 'target_temperature', 8, 30)) valid = false;
}
if (form.id === 'zoneForm') {
const source = form.elements.sensor_source?.value;
const entity = form.elements.ha_entity_id;
if (source !== 'device' && (!entity?.value?.trim() || !entityIdValid(entity.value))) {
showFieldError(entity, entity?.value?.trim() ? tr('validation.entityId') : tr('validation.required'));
valid = false;
}
const zoneDecimalFields = [
['cool_comfort_setpoint', 8, 30], ['cool_sleep_setpoint', 8, 30], ['cool_away_setpoint', 8, 30],
['heat_comfort_setpoint', 8, 30], ['heat_sleep_setpoint', 8, 30], ['heat_away_setpoint', 8, 30],
['max_sensor_difference', 0.1, 20], ['standby_offset_c', 0.5, 8],
...(form.elements.separate_hysteresis?.checked
? [['cool_hysteresis', 0.1, 5], ['heat_hysteresis', 0.1, 5]]
: [['hysteresis', 0.1, 5]])
];
zoneDecimalFields.forEach(([name, min, max]) => { if (!validateDecimalField(form, name, min, max)) valid = false; });
}
if (!valid) {
showFormError(form, tr('validation.fixFields'));
const first = $('[aria-invalid="true"]', form);
const pane = first?.closest?.('[data-settings-pane]');
if (pane?.dataset.settingsPane) setSettingsTab(pane.dataset.settingsPane);
first?.focus({ preventScroll: true });
first?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
return valid;
}
function apiErrorField(form, message) {
const text = String(message || '').toLowerCase();
const candidates = [];
if (text.includes('influx')) candidates.push('influx_url');
if (text.includes('home assistant') || text.includes('ha ')) candidates.push('ha_url');
if (text.includes('entity')) candidates.push('ha_entity_id', 'ha_outdoor_entity_id');
if (text.includes('device')) candidates.push('device_id', 'action_device_id', 'trigger_device_id');
if (text.includes('temperature') || text.includes('setpoint')) candidates.push('target_temperature', 'cool_comfort_setpoint', 'action_target_temperature');
if (text.includes('name')) candidates.push('name', 'controller_id');
if (text.includes('token')) candidates.push('ha_token', 'influx_token', 'pushover_app_token');
if (text.includes('url')) candidates.push('ha_url', 'influx_url', 'slack_webhook_url', 'discord_webhook_url');
return candidates.map(name => form?.elements?.[name]).find(Boolean) || null;
}
function presentFormError(form, error) {
clearFormErrors(form);
const message = error?.message || tr('validation.invalid');
const field = apiErrorField(form, message);
if (field) showFieldError(field, message);
showFormError(form, message);
if (field) {
const pane = field.closest?.('[data-settings-pane]');
if (pane?.dataset.settingsPane) setSettingsTab(pane.dataset.settingsPane);
field.focus({ preventScroll: true });
field.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
toast(message, true);
}
function setFormBusy(form, busy, busyKey = 'actions.saving') {
if (!form) return;
const buttons = $$('button[type="submit"]', form);
if (busy) {
form.dataset.submitting = 'true';
form.setAttribute('aria-busy', 'true');
form.classList.add('is-saving');
buttons.forEach(button => {
if (!button.dataset.idleHtml) button.dataset.idleHtml = button.innerHTML;
button.disabled = true;
button.innerHTML = `${esc(tr(busyKey))}`;
});
} else {
delete form.dataset.submitting;
form.removeAttribute('aria-busy');
form.classList.remove('is-saving');
buttons.forEach(button => {
button.disabled = false;
if (button.dataset.idleHtml) { button.innerHTML = button.dataset.idleHtml; delete button.dataset.idleHtml; }
});
}
}
async function runFormTask(form, task, { busyKey = 'actions.saving', validate = true } = {}) {
if (!form || form.dataset.submitting === 'true') return { ok: false, duplicate: true };
if (validate && !validateForm(form)) return { ok: false, validation: true };
clearFormErrors(form);
setFormBusy(form, true, busyKey);
try {
const value = await task();
markFormClean(form);
return { ok: true, value };
} catch (error) {
presentFormError(form, error);
return { ok: false, error };
} finally {
setFormBusy(form, false);
}
}
function restoreTrackedForm(form) {
if (!form) return;
if (form.id === 'settingsForm') renderSettings();
else if (form.id === 'nightModeForm') renderNightSettings();
else if (form.id === 'homeAssistantForm') {
app.sensorAliases = { ...(app.settings?.home_assistant?.sensor_aliases || {}) };
app.flowSharedInputs = JSON.parse(JSON.stringify(app.settings?.home_assistant?.flow_inputs || []));
renderHomeAssistantSettings();
} else markFormClean(form);
}
function confirmDiscardForm(form) {
if (!isFormDirty(form)) return true;
if (!confirm(tr('confirm.discardChanges'))) return false;
restoreTrackedForm(form);
return true;
}
function activeDirtySettingsForm() {
const active = $('.view.active');
if (!active) return null;
const form = $('form.settings-form', active);
return form && isFormDirty(form) ? form : null;
}
function canLeaveCurrentView(nextName) {
if (nextName === app.currentView) return true;
const form = activeDirtySettingsForm();
return !form || confirmDiscardForm(form);
}
function requestDialogClose(dialog) {
if (!dialog) return true;
const form = $('form', dialog);
if (form && !confirmDiscardForm(form)) return false;
dialog.close();
return true;
}
function setupFormUx() {
$$('form').forEach(form => {
form.noValidate = true;
if (!cleanFormSnapshots.has(form)) markFormClean(form);
});
}
document.addEventListener('input', event => {
const field = event.target.closest?.('input, select, textarea');
const form = field?.form;
if (!field || !form) return;
if (field.getAttribute('aria-invalid') === 'true') {
field.removeAttribute('aria-invalid');
field.closest('label')?.classList.remove('has-error');
if (field.nextElementSibling?.classList?.contains('field-error')) field.nextElementSibling.remove();
$('.form-error-summary', form)?.remove();
}
updateDirtyIndicator(form);
});
document.addEventListener('change', event => updateDirtyIndicator(event.target?.form));
window.addEventListener('beforeunload', event => {
const dirty = activeDirtySettingsForm() || $$('dialog[open] form').some(form => isFormDirty(form));
if (!dirty) return;
event.preventDefault();
event.returnValue = '';
});
document.addEventListener('cancel', event => {
const dialog = event.target.closest?.('dialog');
if (!dialog) return;
const form = $('form', dialog);
if (!form || !isFormDirty(form)) return;
event.preventDefault();
if (confirmDiscardForm(form)) dialog.close();
}, true);
function showTokenDialog() {
const dialog = $('#tokenDialog');
if (!dialog.open) dialog.showModal();
}