"use strict"; (() => { const API_BASE = "/api/v1"; const STATE_POLL_MS = 5000; const MAX_SCHEDULES = 100; const MAX_PROFILES = 8; const MAX_PATTERNS = 3; const MAX_HOLIDAYS = 64; const MAX_PHASE_SECONDS = 99; const MAX_PATTERN_SECONDS = 600; const REQUEST_TIMEOUT_MS = 12000; const DAY_NAMES = [ { index: 0, short: "D", name: "Domingo" }, { index: 1, short: "L", name: "Lunes" }, { index: 2, short: "M", name: "Martes" }, { index: 3, short: "X", name: "Miércoles" }, { index: 4, short: "J", name: "Jueves" }, { index: 5, short: "V", name: "Viernes" }, { index: 6, short: "S", name: "Sábado" } ]; const PHASE_NAMES = ["Alto 1", "Bajo 1", "Alto 2", "Bajo 2", "Alto 3", "Bajo 3"]; const MONTH_NAMES = [ "enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre" ]; const $ = (selector, root = document) => root.querySelector(selector); const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector)); const refs = { app: $("#app"), screens: { bootstrap: $("#screen-bootstrap"), claim: $("#screen-claim"), login: $("#screen-login") }, dashboard: $("#dashboard"), bootstrapMessage: $("#bootstrap-message"), bootstrapError: $("#bootstrap-error"), bootstrapErrorMessage: $("#bootstrap-error-message"), bootstrapRetry: $("#bootstrap-retry"), claimForm: $("#claim-form"), claimToken: $("#claim-token"), claimPassword: $("#claim-password"), claimPasswordConfirm: $("#claim-password-confirm"), claimError: $("#claim-error"), claimSubmit: $("#claim-submit"), loginForm: $("#login-form"), loginPassword: $("#login-password"), loginIntro: $("#login-intro"), loginError: $("#login-error"), loginSubmit: $("#login-submit"), logoutButton: $("#logout-button"), refreshButton: $("#refresh-data"), csrfWarning: $("#csrf-warning"), connectionBadge: $("#connection-badge"), connectionText: $("#connection-text"), clockValue: $("#clock-value"), clockDate: $("#clock-date"), clockStatus: $("#clock-status"), clockIndicator: $("#clock-indicator"), syncTimeButton: $("#sync-time-button"), sequenceIndicator: $("#sequence-indicator"), activePatternLabel: $("#active-pattern-label"), stateMessage: $("#state-message"), stopButton: $("#stop-button"), addProfileButton: $("#add-profile-button"), activeProfileForm: $("#active-profile-form"), activeProfileSelect: $("#active-profile-select"), activeProfileSubmit: $("#active-profile-submit"), profilesContent: $("#profiles-content"), addScheduleButton: $("#add-schedule-button"), profileTabs: $("#profile-tabs"), scheduleCount: $("#schedule-count"), schedulesContent: $("#schedules-content"), addPatternButton: $("#add-pattern-button"), patternsContent: $("#patterns-content"), holidayForm: $("#holiday-form"), holidayMonth: $("#holiday-month"), holidayDay: $("#holiday-day"), holidaySubmit: $("#holiday-submit"), holidayError: $("#holiday-error"), holidaysContent: $("#holidays-content"), profileDialog: $("#profile-dialog"), profileForm: $("#profile-form"), profileDialogTitle: $("#profile-dialog-title"), profileId: $("#profile-id"), profileName: $("#profile-name"), profileFormError: $("#profile-form-error"), profileSubmit: $("#profile-submit"), scheduleDialog: $("#schedule-dialog"), scheduleForm: $("#schedule-form"), scheduleDialogTitle: $("#schedule-dialog-title"), scheduleId: $("#schedule-id"), scheduleTime: $("#schedule-time"), scheduleProfile: $("#schedule-profile"), schedulePattern: $("#schedule-pattern"), scheduleDays: $("#schedule-days"), scheduleFormError: $("#schedule-form-error"), scheduleSubmit: $("#schedule-submit"), patternDialog: $("#pattern-dialog"), patternForm: $("#pattern-form"), patternDialogTitle: $("#pattern-dialog-title"), patternId: $("#pattern-id"), patternCode: $("#pattern-code"), patternPhases: $("#pattern-phases"), patternEnabled: $("#pattern-enabled"), patternFormError: $("#pattern-form-error"), patternSubmit: $("#pattern-submit"), confirmDialog: $("#confirm-dialog"), confirmTitle: $("#confirm-title"), confirmMessage: $("#confirm-message"), confirmActionButton: $("#confirm-action-button"), toastRegion: $("#toast-region") }; const appState = { authenticated: false, csrfToken: null, selectedProfileId: null, activeProfileId: null, activeProfileKnown: false, timeSyncAttempted: false, status: null, profiles: [], schedules: [], patterns: [], holidays: [], resources: { profiles: { loading: true, error: null }, schedules: { loading: true, error: null }, patterns: { loading: true, error: null }, holidays: { loading: true, error: null } } }; let statePollTimer = null; let clockTimer = null; let stateRequestInFlight = false; let confirmHandler = null; class ApiError extends Error { constructor(message, status = 0, code = "") { super(message); this.name = "ApiError"; this.status = status; this.code = code; } } function createElement(tagName, className = "", text = null) { const node = document.createElement(tagName); if (className) node.className = className; if (text !== null && text !== undefined) node.textContent = String(text); return node; } function setHidden(node, hidden) { node.hidden = Boolean(hidden); } function firstDefined(...values) { return values.find((value) => value !== undefined && value !== null); } function asString(value) { if (value === undefined || value === null) return ""; return String(value); } function asFiniteNumber(value) { if (typeof value === "number") return Number.isFinite(value) ? value : null; if (typeof value !== "string" || value.trim() === "") return null; const number = Number(value); return Number.isFinite(number) ? number : null; } function asBoolean(value) { if (typeof value === "boolean") return value; if (typeof value === "number") return value !== 0; if (typeof value !== "string") return null; const normalized = value.trim().toLowerCase(); if (["true", "1", "yes", "sí", "si", "valid", "válido", "active", "activo"].includes(normalized)) return true; if (["false", "0", "no", "invalid", "inválido", "inactive", "inactivo"].includes(normalized)) return false; return null; } function sameId(left, right) { return left !== undefined && left !== null && right !== undefined && right !== null && String(left) === String(right); } function findById(items, id) { return items.find((item) => sameId(item.id, id)) || null; } function extractData(payload, aliases = []) { let candidate = payload; for (let depth = 0; depth < 3; depth += 1) { if (Array.isArray(candidate) || candidate === null || candidate === undefined) return candidate; if (typeof candidate !== "object") return candidate; for (const alias of aliases) { if (Object.prototype.hasOwnProperty.call(candidate, alias)) { candidate = candidate[alias]; break; } } if (!Array.isArray(candidate) && typeof candidate === "object" && Object.prototype.hasOwnProperty.call(candidate, "data")) { candidate = candidate.data; continue; } return candidate; } return candidate; } function extractList(payload, aliases, label) { let candidate = payload; for (let depth = 0; depth < 4; depth += 1) { if (Array.isArray(candidate)) return candidate; if (candidate === null || candidate === undefined || typeof candidate !== "object") break; const alias = aliases.find((name) => Object.prototype.hasOwnProperty.call(candidate, name)); if (alias) { candidate = candidate[alias]; continue; } if (Object.prototype.hasOwnProperty.call(candidate, "data")) { candidate = candidate.data; continue; } if (Object.prototype.hasOwnProperty.call(candidate, "items")) { candidate = candidate.items; continue; } break; } throw new ApiError(`La respuesta de ${label} no contiene una lista válida.`); } function responseMetadata(payload) { const metadata = []; let current = payload; for (let depth = 0; depth < 3 && current && typeof current === "object"; depth += 1) { if (!Array.isArray(current)) metadata.push(current); current = current.data; } return metadata; } function metadataValue(payload, aliases) { for (const object of responseMetadata(payload)) { for (const alias of aliases) { if (Object.prototype.hasOwnProperty.call(object, alias)) return object[alias]; } } return undefined; } function errorMessageFromPayload(payload, response) { if (payload && typeof payload === "object") { if (typeof payload.message === "string" && payload.message.trim()) return payload.message; if (payload.error && typeof payload.error === "object" && typeof payload.error.message === "string") { return payload.error.message; } if (typeof payload.error === "string" && payload.error.trim()) return payload.error; } if (response.status === 400) return "El dispositivo rechazó los datos enviados."; if (response.status === 401) return "La sesión no es válida o ha expirado."; if (response.status === 403) return "No se pudo validar la sesión de protección."; if (response.status === 404) return "El recurso solicitado no está disponible."; if (response.status === 409) return "La operación entra en conflicto con el estado actual."; if (response.status >= 500) return "El dispositivo no pudo completar la operación."; return `La solicitud falló con el código ${response.status}.`; } async function api(path, options = {}) { const method = String(options.method || "GET").toUpperCase(); const isMutation = !["GET", "HEAD", "OPTIONS"].includes(method); const isAuthenticatedMutation = isMutation && appState.authenticated; if (isAuthenticatedMutation && !appState.csrfToken) { throw new ApiError("No hay un token de protección en memoria. Actualiza la sesión antes de modificar datos."); } const headers = { Accept: "application/json" }; if (options.body !== undefined) headers["Content-Type"] = "application/json"; if (isAuthenticatedMutation) headers["X-CSRF-Token"] = appState.csrfToken; const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); let response; let payload = null; try { const requestOptions = { method, headers, credentials: "same-origin", cache: "no-store", signal: controller.signal }; if (options.body !== undefined) requestOptions.body = JSON.stringify(options.body); response = await fetch(`${API_BASE}${path}`, requestOptions); const rawText = await response.text(); if (rawText) { try { payload = JSON.parse(rawText); } catch (_error) { if (response.ok) throw new ApiError("El dispositivo devolvió una respuesta que no es JSON válido.", response.status); payload = null; } } } catch (error) { if (error instanceof ApiError) throw error; if (error && error.name === "AbortError") { throw new ApiError("El dispositivo tardó demasiado en responder.", 0, "timeout"); } throw new ApiError("No se pudo conectar con TimbreESP. Comprueba que el equipo esté accesible.", 0, "network"); } finally { window.clearTimeout(timeoutId); } const payloadFailed = payload && typeof payload === "object" && payload.ok === false; if (!response.ok || payloadFailed) { const code = payload && typeof payload === "object" && payload.error && typeof payload.error === "object" ? asString(payload.error.code) : asString(payload && typeof payload === "object" ? payload.error : ""); throw new ApiError(errorMessageFromPayload(payload, response), response.status, code); } return payload; } function setFormError(node, message = "") { node.textContent = message; setHidden(node, !message); } function setButtonBusy(button, busy, busyText = "Guardando…") { if (!button) return; if (busy) { button.dataset.idleText = button.textContent; button.dataset.wasDisabled = button.disabled ? "true" : "false"; button.textContent = busyText; button.disabled = true; button.setAttribute("aria-busy", "true"); button.classList.add("is-busy"); } else { if (button.dataset.idleText !== undefined) button.textContent = button.dataset.idleText; button.disabled = button.dataset.wasDisabled === "true"; button.removeAttribute("aria-busy"); button.classList.remove("is-busy"); } } function showToast(message, type = "success") { const toast = createElement("div", `toast toast--${type}`, message); toast.setAttribute("role", type === "error" ? "alert" : "status"); refs.toastRegion.append(toast); window.setTimeout(() => toast.remove(), type === "error" ? 7000 : 4200); } function setConnection(kind, text) { refs.connectionBadge.className = `connection-badge connection-badge--${kind}`; refs.connectionText.textContent = text; } function showScreen(name) { for (const [key, screen] of Object.entries(refs.screens)) setHidden(screen, key !== name); setHidden(refs.dashboard, name !== "dashboard"); } function showBootstrapError(error) { refs.bootstrapMessage.textContent = "No se pudo iniciar la interfaz."; refs.bootstrapErrorMessage.textContent = error instanceof Error ? error.message : "Ha ocurrido un error inesperado."; setHidden(refs.bootstrapError, false); } function stopTimers() { if (statePollTimer !== null) window.clearInterval(statePollTimer); if (clockTimer !== null) window.clearInterval(clockTimer); statePollTimer = null; clockTimer = null; stateRequestInFlight = false; } function startTimers() { stopTimers(); clockTimer = window.setInterval(renderClock, 1000); statePollTimer = window.setInterval(() => void pollStatus(false), STATE_POLL_MS); } function updateSessionFromPayload(payload) { const session = extractData(payload, ["session"]) || {}; const authenticated = asBoolean(session.authenticated) === true; const csrfToken = asString(session.csrfToken || session.csrf_token); appState.authenticated = authenticated; appState.csrfToken = authenticated && csrfToken ? csrfToken : null; setHidden(refs.csrfWarning, !authenticated || Boolean(appState.csrfToken)); return { authenticated, setupRequired: asBoolean(session.setupRequired) === true }; } function resetDashboardData() { appState.status = null; appState.selectedProfileId = null; appState.activeProfileId = null; appState.activeProfileKnown = false; appState.profiles = []; appState.schedules = []; appState.patterns = []; appState.holidays = []; for (const resource of Object.values(appState.resources)) { resource.loading = true; resource.error = null; } for (const container of [refs.profilesContent, refs.schedulesContent, refs.patternsContent, refs.holidaysContent]) { container.setAttribute("aria-busy", "true"); container.replaceChildren(); } refs.profileTabs.replaceChildren(); refs.scheduleCount.textContent = `0 / ${MAX_SCHEDULES} horarios`; refs.addProfileButton.disabled = true; refs.addScheduleButton.disabled = true; refs.addPatternButton.disabled = true; refs.holidaySubmit.disabled = true; renderLoading(refs.profilesContent, "Cargando perfiles…"); renderLoading(refs.schedulesContent, "Cargando horarios…"); renderLoading(refs.patternsContent, "Cargando patrones…"); renderLoading(refs.holidaysContent, "Cargando feriados…"); renderStatus(); } function showClaim(message = "") { stopTimers(); appState.authenticated = false; appState.csrfToken = null; resetDashboardData(); showScreen("claim"); setFormError(refs.claimError, message); window.setTimeout(() => refs.claimToken.focus(), 0); } function showLogin(message = "") { stopTimers(); appState.authenticated = false; appState.csrfToken = null; resetDashboardData(); showScreen("login"); refs.loginPassword.value = ""; refs.loginIntro.textContent = "Introduce la contraseña configurada para controlar el timbre."; setFormError(refs.loginError, message); window.setTimeout(() => refs.loginPassword.focus(), 0); } function handleSessionExpired(message = "La sesión expiró. Vuelve a iniciar sesión.") { appState.authenticated = false; appState.csrfToken = null; showLogin(message); } function showDashboard(session) { appState.timeSyncAttempted = false; if (session && session.setupRequired) { showClaim(); return; } resetDashboardData(); showScreen("dashboard"); setHidden(refs.csrfWarning, Boolean(appState.csrfToken)); setConnection("loading", "Conectando"); startTimers(); void pollStatus(true); void loadAllData(); } async function bootstrap() { stopTimers(); appState.authenticated = false; appState.csrfToken = null; showScreen("bootstrap"); refs.app.setAttribute("aria-busy", "true"); refs.bootstrapMessage.textContent = "Comprobando el estado de la configuración."; setHidden(refs.bootstrapError, true); try { const setupPayload = await api("/setup"); const setup = extractData(setupPayload, ["setup"]) || {}; const setupRequired = asBoolean(firstDefined(setup.setupRequired, setup.required, setup.claimRequired)); if (setupRequired === true) { showClaim(); return; } const sessionPayload = await api("/session"); const session = updateSessionFromPayload(sessionPayload); if (session.setupRequired) { showClaim(); } else if (session.authenticated) { showDashboard(session); } else { showLogin(); } } catch (error) { showScreen("bootstrap"); showBootstrapError(error); } finally { refs.app.setAttribute("aria-busy", "false"); } } async function handleClaimSubmit(event) { event.preventDefault(); setFormError(refs.claimError); if (!refs.claimForm.reportValidity()) return; if (refs.claimPassword.value !== refs.claimPasswordConfirm.value) { setFormError(refs.claimError, "Las contraseñas no coinciden."); return; } setButtonBusy(refs.claimSubmit, true, "Claim…"); try { await api("/setup/claim", { method: "POST", body: { token: refs.claimToken.value.trim(), password: refs.claimPassword.value } }); refs.claimForm.reset(); let sessionPayload; try { sessionPayload = await api("/session"); } catch (_error) { sessionPayload = null; } if (sessionPayload) { const session = updateSessionFromPayload(sessionPayload); if (session.authenticated) { showDashboard(session); showToast("Configuración inicial completada."); return; } } appState.authenticated = false; appState.csrfToken = null; showLogin("Configuración completada. Inicia sesión con la contraseña nueva."); } catch (error) { setFormError(refs.claimError, error instanceof Error ? error.message : "No se pudo completar el Claim."); } finally { setButtonBusy(refs.claimSubmit, false); } } async function handleLoginSubmit(event) { event.preventDefault(); setFormError(refs.loginError); if (!refs.loginForm.reportValidity()) return; setButtonBusy(refs.loginSubmit, true, "Entrando…"); try { const loginPayload = await api("/session", { method: "POST", body: { password: refs.loginPassword.value } }); const directSession = updateSessionFromPayload(loginPayload); if (directSession.authenticated && appState.csrfToken) { refs.loginForm.reset(); showDashboard(directSession); return; } const sessionPayload = await api("/session"); const session = updateSessionFromPayload(sessionPayload); if (session.setupRequired) { showClaim(); } else if (session.authenticated) { refs.loginForm.reset(); showDashboard(session); } else { setFormError(refs.loginError, "La contraseña no fue aceptada."); } } catch (error) { setFormError(refs.loginError, error instanceof Error ? error.message : "No se pudo iniciar sesión."); } finally { setButtonBusy(refs.loginSubmit, false); } } async function handleLogout() { setButtonBusy(refs.logoutButton, true, "Cerrando…"); try { await api("/session", { method: "DELETE" }); appState.authenticated = false; appState.csrfToken = null; showLogin("Sesión cerrada."); } catch (error) { if (error instanceof ApiError && error.status === 401) { appState.authenticated = false; appState.csrfToken = null; showLogin(); } else { showToast(error instanceof Error ? error.message : "No se pudo cerrar la sesión.", "error"); } } finally { setButtonBusy(refs.logoutButton, false); } } function normalizeDayMask(value) { if (Array.isArray(value)) { return value.reduce((mask, day) => { if (typeof day === "number" && day >= 0 && day <= 6) return mask | (1 << day); const legacyIndex = DAY_NAMES.findIndex((entry) => entry.short.toLowerCase() === asString(day).trim().toLowerCase()); return legacyIndex >= 0 ? mask | (1 << legacyIndex) : mask; }, 0); } const number = asFiniteNumber(value); if (number !== null) { if (String(value).trim().toLowerCase().startsWith("0b")) { const binary = Number.parseInt(String(value).slice(2), 2); return Number.isInteger(binary) ? binary & 0x7f : 0; } return Number.isInteger(number) ? number & 0x7f : 0; } if (typeof value === "string") { const legacyCodes = value.split(",").map((part) => part.trim()).filter(Boolean); return legacyCodes.reduce((mask, code) => { const index = DAY_NAMES.findIndex((entry) => entry.short.toLowerCase() === code.toLowerCase()); return index >= 0 ? mask | (1 << index) : mask; }, 0); } return 0; } function parseProfiles(payload) { const items = extractList(payload, ["profiles", "perfiles"], "perfiles"); const profiles = items.map((item, index) => ({ raw: item, id: firstDefined(item.id, item.profileId, item.perfilId, index), name: asString(firstDefined(item.name, item.nombre, item.label) || `Perfil ${index + 1}`) })); const activeValue = metadataValue(payload, [ "activeProfileId", "activeProfile", "perfilActivoId", "perfilActivo", "activo" ]); return { profiles, activeProfileId: activeValue ?? null, activeProfileKnown: activeValue !== undefined }; } function minuteToTime(value) { const minute = asFiniteNumber(value); if (minute === null || !Number.isInteger(minute) || minute < 0 || minute > 1439) { return "00:00"; } return `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`; } function parseSchedules(payload) { const items = extractList(payload, ["schedules", "horarios"], "horarios"); return items.map((item, index) => ({ raw: item, id: firstDefined(item.id, item.scheduleId, item.horarioId, index), profileId: firstDefined(item.profileId, item.perfilId, item.profile, item.perfil), patternId: firstDefined(item.patternId, item.patronId, item.pattern, item.patron), time: firstDefined(item.time, item.hora) !== undefined ? asString(firstDefined(item.time, item.hora)) : minuteToTime(item.minuteOfDay), days: normalizeDayMask(firstDefined(item.days, item.weekdays, item.dayMask, item.dias, 0)), enabled: asBoolean(firstDefined(item.enabled, true)) !== false })); } function parsePatterns(payload) { const items = extractList(payload, ["patterns", "patrones"], "patrones"); return items.map((item, index) => { const phases = Array.isArray(item.phaseSeconds) ? item.phaseSeconds : (Array.isArray(item.fases) ? item.fases : []); const phaseSeconds = Array.from({ length: 6 }, (_, phaseIndex) => { const value = asFiniteNumber(phases[phaseIndex]); return value === null || value < 0 ? 0 : value; }); return { raw: item, id: firstDefined(item.id, item.patternId, index), legacyCode: asString(firstDefined(item.legacyCode, item.code, `Patrón ${index + 1}`)).toUpperCase(), target: firstDefined(item.target, "GPIO22"), phaseSeconds, enabled: asBoolean(firstDefined(item.enabled, true)) !== false }; }); } function parseHolidays(payload) { const items = extractList(payload, ["holidays", "feriados"], "feriados"); return items .map((item) => ({ raw: item, month: asFiniteNumber(firstDefined(item.month, item.mes)), day: asFiniteNumber(firstDefined(item.day, item.dia)) })) .filter((holiday) => holiday.month !== null && holiday.day !== null) .map((holiday) => ({ ...holiday, month: Math.trunc(holiday.month), day: Math.trunc(holiday.day) })) .sort((left, right) => left.month - right.month || left.day - right.day); } function normalizeEpoch(value) { const epoch = asFiniteNumber(value); if (epoch === null) return null; const seconds = epoch > 10_000_000_000 ? epoch / 1000 : epoch; return Number.isInteger(seconds) ? seconds : Math.trunc(seconds); } function parseStatus(payload) { const root = extractData(payload, ["estado", "state"]) || {}; if (typeof root !== "object" || Array.isArray(root)) { return { clockValid: false, sequenceActive: false, epoch: null }; } const clock = root.clock && typeof root.clock === "object" ? root.clock : {}; const clockEs = root.reloj && typeof root.reloj === "object" ? root.reloj : {}; const epoch = normalizeEpoch(firstDefined( root.epoch, root.timeEpoch, root.unixTime, root.horaEpoch, typeof root.reloj === "number" ? root.reloj : undefined, typeof root.hora === "number" ? root.hora : undefined, clock.epoch, clockEs.epoch )); const validFlag = asBoolean(firstDefined( root.clockValid, root.timeValid, root.hasValidTime, root.relojValido, root.horaValida, clock.valid, clock.clockValid, clockEs.valido, clockEs.valid )); const plausibleEpoch = epoch !== null && epoch >= 1_577_836_800 && epoch <= 4_102_444_800; const sequenceFlag = asBoolean(firstDefined( root.sequenceActive, root.secuenciaActiva, root.ringing, root.sonando, root.outputActive, root.salidaActiva, root.patternRunning, root.patronEnEjecucion, root.actionActive, root.accionActiva )); return { epoch, clockValid: validFlag === null ? plausibleEpoch : validFlag, sequenceActive: sequenceFlag === true, activePatternId: firstDefined( root.activePatternId, root.currentPatternId, root.patternId, root.patronActivoId, root.patronActualId, root.patronId ) ?? null, activeProfileId: firstDefined( root.activeProfileId, root.perfilActivoId, root.profileId, root.perfilId ) ?? null, wifiConnected: asBoolean(firstDefined(root.wifiConnected, root.wifiConectado, root.online)) }; } function renderClock() { if (!appState.status) return; if (!appState.status.clockValid || appState.status.epoch === null) { refs.clockValue.textContent = "--:--:--"; refs.clockValue.removeAttribute("datetime"); refs.clockDate.textContent = "Hora no disponible"; return; } const date = new Date(appState.status.epoch * 1000); if (Number.isNaN(date.getTime())) { refs.clockValue.textContent = "--:--:--"; refs.clockValue.removeAttribute("datetime"); refs.clockDate.textContent = "Hora no disponible"; return; } try { const time = new Intl.DateTimeFormat("es-ES", { timeZone: "Etc/GMT+6", hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23" }).format(date); const fullDate = new Intl.DateTimeFormat("es-ES", { timeZone: "Etc/GMT+6", weekday: "long", year: "numeric", month: "long", day: "2-digit" }).format(date); refs.clockValue.textContent = time; refs.clockValue.setAttribute("datetime", date.toISOString()); refs.clockDate.textContent = fullDate; } catch (_error) { refs.clockValue.textContent = "--:--:--"; refs.clockValue.removeAttribute("datetime"); refs.clockDate.textContent = "Hora no disponible"; } } function renderStatus() { if (!appState.status) { refs.clockIndicator.textContent = "Inválido"; refs.clockIndicator.className = "state-pill state-pill--invalid"; refs.clockValue.textContent = "--:--:--"; refs.clockValue.removeAttribute("datetime"); refs.clockDate.textContent = "Hora no disponible"; refs.clockStatus.textContent = "No se ha podido validar el reloj. Se considera inválido hasta recibir una hora válida."; refs.clockStatus.className = "status-message status-message--error"; refs.sequenceIndicator.textContent = "Desconocido"; refs.sequenceIndicator.className = "state-pill state-pill--muted"; refs.stopButton.disabled = true; return; } if (appState.status.clockValid && appState.status.epoch !== null) { refs.clockIndicator.textContent = "Válido"; refs.clockIndicator.className = "state-pill state-pill--valid"; refs.clockStatus.textContent = "Reloj válido. El estado se consulta cada 5 segundos."; refs.clockStatus.className = "status-message status-message--success"; } else { refs.clockIndicator.textContent = "Inválido"; refs.clockIndicator.className = "state-pill state-pill--invalid"; refs.clockStatus.textContent = "Reloj inválido. Sincroniza la hora antes de confiar en la programación."; refs.clockStatus.className = "status-message status-message--error"; } renderClock(); const activePattern = findById(appState.patterns, appState.status.activePatternId); refs.activePatternLabel.textContent = activePattern ? `${activePattern.legacyCode} · ${activePattern.enabled ? "habilitado" : "deshabilitado"}` : (appState.status.activePatternId !== null ? `Patrón ${appState.status.activePatternId}` : "Ninguno"); if (appState.status.sequenceActive) { refs.sequenceIndicator.textContent = "Activo"; refs.sequenceIndicator.className = "state-pill state-pill--active"; } else { refs.sequenceIndicator.textContent = "Inactivo"; refs.sequenceIndicator.className = "state-pill state-pill--muted"; } refs.stopButton.disabled = !appState.status.sequenceActive; } async function pollStatus(initial) { if (!appState.authenticated || stateRequestInFlight || refs.dashboard.hidden) return; const requestToken = appState.csrfToken; stateRequestInFlight = true; if (initial) { setConnection("loading", "Sincronizando"); refs.stateMessage.textContent = "Consultando el estado…"; refs.stateMessage.className = "status-message status-message--muted"; } try { const payload = await api("/estado"); if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return; appState.status = parseStatus(payload); if (!appState.status.clockValid && !appState.timeSyncAttempted) { appState.timeSyncAttempted = true; try { await api("/hora", { method: "POST", body: { epoch: Math.floor(Date.now() / 1000) } }); const refreshed = await api("/estado"); if (!refs.dashboard.hidden && appState.csrfToken === requestToken) { appState.status = parseStatus(refreshed); } } catch (syncError) { refs.clockStatus.textContent = syncError instanceof Error ? syncError.message : "No se pudo sincronizar la hora automáticamente."; } } if (appState.status.activeProfileId !== null) { appState.activeProfileId = appState.status.activeProfileId; appState.activeProfileKnown = true; renderActiveProfileControl(); renderProfiles(); } setConnection("online", "En línea"); refs.stateMessage.textContent = `Actualizado a las ${new Intl.DateTimeFormat("es-ES", { hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23" }).format(new Date())}`; refs.stateMessage.className = "status-message status-message--success"; renderStatus(); } catch (error) { if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return; if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { handleSessionExpired(); return; } setConnection("error", "Sin respuesta"); if (!appState.status) { refs.clockIndicator.textContent = "Inválido"; refs.clockIndicator.className = "state-pill state-pill--invalid"; refs.clockValue.textContent = "--:--:--"; refs.clockDate.textContent = "Hora no disponible"; refs.clockStatus.textContent = "No se pudo consultar el reloj. Se considera inválido hasta recibir una respuesta válida."; refs.clockStatus.className = "status-message status-message--error"; } refs.stateMessage.textContent = error instanceof Error ? error.message : "No se pudo consultar el estado."; refs.stateMessage.className = "status-message status-message--error"; renderStatus(); } finally { if (appState.csrfToken === requestToken) stateRequestInFlight = false; } } function renderLoading(container, message) { container.setAttribute("aria-busy", "true"); const wrapper = createElement("div", "resource-state"); const spinner = createElement("span", "spinner"); spinner.setAttribute("aria-hidden", "true"); const text = createElement("p", "", message); wrapper.append(spinner, text); container.replaceChildren(wrapper); } function renderResourceError(container, title, error, retry) { container.setAttribute("aria-busy", "false"); const wrapper = createElement("div", "resource-state resource-state--error"); wrapper.append(createElement("h3", "", title)); wrapper.append(createElement("p", "", error instanceof Error ? error.message : "Ha ocurrido un error inesperado.")); const button = createElement("button", "button button--secondary", "Reintentar"); button.type = "button"; button.addEventListener("click", retry); wrapper.append(button); container.replaceChildren(wrapper); } function renderEmpty(container, title, message, actionLabel, action) { container.setAttribute("aria-busy", "false"); const wrapper = createElement("div", "resource-state"); wrapper.append(createElement("h3", "", title)); wrapper.append(createElement("p", "", message)); if (actionLabel && action) { const button = createElement("button", "button button--secondary", actionLabel); button.type = "button"; button.addEventListener("click", action); wrapper.append(button); } container.replaceChildren(wrapper); } async function loadProfiles() { if (!appState.authenticated || refs.dashboard.hidden) return false; const requestToken = appState.csrfToken; const resource = appState.resources.profiles; const initial = resource.loading && !appState.profiles.length; if (initial) renderLoading(refs.profilesContent, "Cargando perfiles…"); try { const payload = await api("/perfiles"); if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false; const parsed = parseProfiles(payload); appState.profiles = parsed.profiles; if (parsed.activeProfileKnown) { appState.activeProfileId = parsed.activeProfileId; appState.activeProfileKnown = true; } if (parsed.activeProfileKnown && !findById(appState.profiles, appState.activeProfileId)) { appState.activeProfileId = appState.profiles[0]?.id ?? null; } if (!findById(appState.profiles, appState.selectedProfileId)) { appState.selectedProfileId = appState.profiles[0]?.id ?? null; } resource.loading = false; resource.error = null; renderActiveProfileControl(); renderProfiles(); renderProfileTabs(); renderSchedules(); return true; } catch (error) { if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false; if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { handleSessionExpired(); return false; } resource.loading = false; resource.error = error; if (initial) { renderResourceError(refs.profilesContent, "No se pudieron cargar los perfiles", error, () => void loadProfiles()); } return false; } } async function loadSchedules() { if (!appState.authenticated || refs.dashboard.hidden) return false; const requestToken = appState.csrfToken; const resource = appState.resources.schedules; const initial = resource.loading && !appState.schedules.length; if (initial) renderLoading(refs.schedulesContent, "Cargando horarios…"); try { const payload = await api("/horarios"); if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false; appState.schedules = parseSchedules(payload); resource.loading = false; resource.error = null; renderSchedules(); return true; } catch (error) { if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false; if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { handleSessionExpired(); return false; } resource.loading = false; resource.error = error; if (initial) { renderResourceError(refs.schedulesContent, "No se pudieron cargar los horarios", error, () => void loadSchedules()); } return false; } } async function loadPatterns() { if (!appState.authenticated || refs.dashboard.hidden) return false; const requestToken = appState.csrfToken; const resource = appState.resources.patterns; const initial = resource.loading && !appState.patterns.length; if (initial) renderLoading(refs.patternsContent, "Cargando patrones…"); try { const payload = await api("/patrones"); if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false; appState.patterns = parsePatterns(payload); resource.loading = false; resource.error = null; renderPatterns(); renderStatus(); return true; } catch (error) { if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false; if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { handleSessionExpired(); return false; } resource.loading = false; resource.error = error; if (initial) { renderResourceError(refs.patternsContent, "No se pudieron cargar los patrones", error, () => void loadPatterns()); } return false; } } async function loadHolidays() { if (!appState.authenticated || refs.dashboard.hidden) return false; const requestToken = appState.csrfToken; const resource = appState.resources.holidays; const initial = resource.loading && !appState.holidays.length; if (initial) renderLoading(refs.holidaysContent, "Cargando feriados…"); try { const payload = await api("/feriados"); if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false; appState.holidays = parseHolidays(payload); resource.loading = false; resource.error = null; renderHolidays(); return true; } catch (error) { if (refs.dashboard.hidden || appState.csrfToken !== requestToken) return false; if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { handleSessionExpired(); return false; } resource.loading = false; resource.error = error; if (initial) { renderResourceError(refs.holidaysContent, "No se pudieron cargar los feriados", error, () => void loadHolidays()); } return false; } } async function loadAllData() { const results = []; for (const loader of [loadProfiles, loadSchedules, loadPatterns, loadHolidays]) { if (!appState.authenticated || refs.dashboard.hidden) return false; results.push(await loader()); } return results.every(Boolean); } async function refreshAll() { setButtonBusy(refs.refreshButton, true, "Actualizando…"); const resourcesOk = await loadAllData(); await pollStatus(false); setButtonBusy(refs.refreshButton, false); showToast(resourcesOk ? "Datos actualizados." : "Los datos se actualizaron parcialmente; revisa los avisos.", resourcesOk ? "success" : "error"); } function updateActiveProfileSubmit() { const selected = findById(appState.profiles, refs.activeProfileSelect.value); const unchanged = appState.activeProfileKnown && sameId(selected?.id, appState.activeProfileId); refs.activeProfileSubmit.disabled = !selected || unchanged || !appState.csrfToken; } function renderActiveProfileControl() { const hasProfiles = appState.profiles.length > 0; const select = refs.activeProfileSelect; select.replaceChildren(); if (!hasProfiles && appState.resources.profiles.loading) { select.append(createElement("option", "", "Cargando perfiles…")); select.value = ""; } else if (!hasProfiles) { const option = createElement("option", "", "No hay perfiles disponibles"); option.value = ""; select.append(option); select.value = ""; } else { if (!appState.activeProfileKnown) { const placeholder = createElement("option", "", "Selecciona un perfil"); placeholder.value = ""; select.append(placeholder); } for (const profile of appState.profiles) { const option = createElement("option", "", profile.name); option.value = String(profile.id); select.append(option); } select.value = appState.activeProfileKnown && findById(appState.profiles, appState.activeProfileId) ? String(appState.activeProfileId) : ""; } select.disabled = !hasProfiles; updateActiveProfileSubmit(); } function renderProfiles() { refs.addProfileButton.disabled = appState.profiles.length >= MAX_PROFILES; if (!appState.profiles.length) { if (appState.resources.profiles.loading) { renderLoading(refs.profilesContent, "Cargando perfiles…"); } else { renderEmpty( refs.profilesContent, "No hay perfiles", "Crea el primer perfil para empezar a organizar los horarios.", "Añadir perfil", () => openProfileDialog() ); } return; } const grid = createElement("div", "profile-grid"); for (const profile of appState.profiles) { const isActive = appState.activeProfileKnown && sameId(profile.id, appState.activeProfileId); const card = createElement("article", `profile-card${isActive ? " profile-card--active" : ""}`); const heading = createElement("div", "profile-card__title"); heading.append(createElement("h3", "", profile.name)); if (isActive) { const badge = createElement("span", "state-pill state-pill--active", "Activo"); heading.append(badge); } const actions = createElement("div", "profile-card__actions"); const editButton = createElement("button", "button button--secondary button--small", "Renombrar"); editButton.type = "button"; editButton.addEventListener("click", () => openProfileDialog(profile)); actions.append(editButton); const deleteButton = createElement("button", "button button--danger button--small", "Eliminar"); deleteButton.type = "button"; deleteButton.disabled = appState.profiles.length <= 1; deleteButton.addEventListener("click", () => confirmProfileDeletion(profile)); actions.append(deleteButton); card.append(heading, actions); grid.append(card); } refs.profilesContent.setAttribute("aria-busy", "false"); refs.profilesContent.replaceChildren(grid); } function renderProfileTabs() { refs.profileTabs.replaceChildren(); if (!appState.profiles.length) { const empty = createElement("span", "field-help", "Crea un perfil para ver sus horarios."); refs.profileTabs.append(empty); return; } for (const profile of appState.profiles) { const button = createElement("button", "", profile.name); button.type = "button"; button.setAttribute("role", "tab"); const selected = sameId(profile.id, appState.selectedProfileId); button.setAttribute("aria-selected", selected ? "true" : "false"); button.tabIndex = selected ? 0 : -1; button.addEventListener("click", () => selectProfile(profile.id)); button.addEventListener("keydown", (event) => handleProfileTabKeydown(event, profile.id)); refs.profileTabs.append(button); } } function handleProfileTabKeydown(event, currentId) { if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; event.preventDefault(); if (!appState.profiles.length) return; let index = appState.profiles.findIndex((profile) => sameId(profile.id, currentId)); if (index < 0) index = 0; if (event.key === "Home") index = 0; else if (event.key === "End") index = appState.profiles.length - 1; else if (event.key === "ArrowRight") index = (index + 1) % appState.profiles.length; else index = (index - 1 + appState.profiles.length) % appState.profiles.length; const nextId = appState.profiles[index].id; selectProfile(nextId); const buttons = $$('[role="tab"]', refs.profileTabs); if (buttons[index]) buttons[index].focus(); } function selectProfile(id) { appState.selectedProfileId = id; renderProfileTabs(); renderSchedules(); } function formatDays(mask) { const selected = DAY_NAMES.filter((day) => (mask & (1 << day.index)) !== 0); return selected.length ? selected.map((day) => day.short).join(" · ") : "Ningún día"; } function dayBadges(mask) { const list = createElement("div", "day-list"); for (const day of DAY_NAMES) { const badge = createElement("span", "day-badge", day.short); badge.title = day.name; if ((mask & (1 << day.index)) === 0) badge.setAttribute("aria-label", `${day.name}: no seleccionado`); else badge.setAttribute("aria-label", `${day.name}: seleccionado`); list.append(badge); } return list; } function renderSchedules() { refs.scheduleCount.textContent = `${appState.schedules.length} / ${MAX_SCHEDULES} horarios`; refs.addScheduleButton.disabled = ( appState.schedules.length >= MAX_SCHEDULES || !appState.profiles.length || !appState.patterns.length || appState.resources.schedules.loading ); renderProfileTabs(); if (appState.resources.schedules.loading && !appState.schedules.length) { renderLoading(refs.schedulesContent, "Cargando horarios…"); return; } if (appState.resources.schedules.error && !appState.schedules.length) { renderResourceError(refs.schedulesContent, "No se pudieron cargar los horarios", appState.resources.schedules.error, () => void loadSchedules()); return; } if (!appState.profiles.length) { renderEmpty(refs.schedulesContent, "Primero crea un perfil", "Los horarios se organizan dentro de un perfil."); return; } if (!findById(appState.profiles, appState.selectedProfileId)) { appState.selectedProfileId = appState.profiles[0]?.id ?? null; renderProfileTabs(); } const visibleSchedules = appState.schedules .filter((schedule) => sameId(schedule.profileId, appState.selectedProfileId)) .sort((left, right) => left.time.localeCompare(right.time)); if (!visibleSchedules.length) { const profile = findById(appState.profiles, appState.selectedProfileId); renderEmpty( refs.schedulesContent, "No hay horarios en este perfil", `Añade una hora para ${profile ? profile.name : "este perfil"} o elige otro perfil.`, "Añadir horario", () => openScheduleDialog() ); return; } const wrapper = createElement("div", "table-wrap"); const table = createElement("table", "data-table"); const caption = createElement("caption", "visually-hidden", "Horarios del perfil seleccionado"); const head = createElement("thead"); const headRow = createElement("tr"); for (const label of ["Hora", "Patrón", "Días", "Acciones"]) { const th = createElement("th", "", label); th.scope = "col"; headRow.append(th); } head.append(headRow); const body = createElement("tbody"); for (const schedule of visibleSchedules) { const row = createElement("tr"); const timeCell = createElement("td"); timeCell.append(createElement("span", "cell-title", schedule.time)); row.append(timeCell); const pattern = findById(appState.patterns, schedule.patternId); const patternCell = createElement("td"); patternCell.append(createElement("span", "cell-title", pattern ? pattern.legacyCode : `Patrón ${schedule.patternId}`)); patternCell.append(createElement("span", "cell-subtitle", pattern && !pattern.enabled ? "Deshabilitado" : "GPIO22 · principal")); row.append(patternCell); const daysCell = createElement("td"); daysCell.append(dayBadges(schedule.days)); row.append(daysCell); const actionCell = createElement("td"); const actions = createElement("div", "cell-actions"); const editButton = createElement("button", "button button--secondary button--small", "Editar"); editButton.type = "button"; editButton.addEventListener("click", () => openScheduleDialog(schedule)); const deleteButton = createElement("button", "button button--danger button--small", "Eliminar"); deleteButton.type = "button"; deleteButton.addEventListener("click", () => confirmScheduleDeletion(schedule)); actions.append(editButton, deleteButton); actionCell.append(actions); row.append(actionCell); body.append(row); } table.append(caption, head, body); wrapper.append(table); refs.schedulesContent.setAttribute("aria-busy", "false"); refs.schedulesContent.replaceChildren(wrapper); } function renderPatterns() { refs.addPatternButton.disabled = appState.patterns.length >= MAX_PATTERNS; if (appState.resources.patterns.loading && !appState.patterns.length) { renderLoading(refs.patternsContent, "Cargando patrones…"); return; } if (appState.resources.patterns.error && !appState.patterns.length) { renderResourceError(refs.patternsContent, "No se pudieron cargar los patrones", appState.resources.patterns.error, () => void loadPatterns()); return; } if (!appState.patterns.length) { renderEmpty( refs.patternsContent, "No hay patrones", "Crea un ciclo de seis fases para asociarlo a los horarios.", "Añadir patrón", () => openPatternDialog() ); return; } const grid = createElement("div", "pattern-card-grid"); for (const pattern of appState.patterns) { const card = createElement("article", "pattern-card"); const header = createElement("div", "pattern-card__header"); const title = createElement("div", "pattern-card__title"); title.append(createElement("span", "pattern-card__code", pattern.legacyCode)); const headingText = createElement("div"); headingText.append(createElement("h3", "", pattern.enabled ? "Patrón habilitado" : "Patrón deshabilitado")); headingText.append(createElement("p", "cell-subtitle", "Destino: GPIO22 · principal")); title.append(headingText); header.append(title); header.append(createElement("span", `state-pill ${pattern.enabled ? "state-pill--valid" : "state-pill--muted"}`, pattern.enabled ? "Activo" : "Inactivo")); card.append(header); const phases = createElement("div", "pattern-phases"); pattern.phaseSeconds.forEach((seconds, index) => { const phase = createElement("div", "pattern-phase"); phase.append(createElement("strong", "", `${seconds} s`)); phase.append(createElement("span", "", PHASE_NAMES[index])); phases.append(phase); }); card.append(phases); const total = pattern.phaseSeconds.reduce((sum, seconds) => sum + seconds, 0); const footer = createElement("div", "pattern-card__footer"); footer.append(createElement("p", "", `Duración total: ${total} segundos`)); const actions = createElement("div", "pattern-card__actions"); const testButton = createElement("button", "button button--secondary button--small", "Probar"); testButton.type = "button"; testButton.disabled = !pattern.enabled; testButton.addEventListener("click", (event) => void testPattern(pattern, event.currentTarget)); const editButton = createElement("button", "button button--secondary button--small", "Editar"); editButton.type = "button"; editButton.addEventListener("click", () => openPatternDialog(pattern)); actions.append(testButton, editButton); footer.append(actions); card.append(footer); grid.append(card); } refs.patternsContent.setAttribute("aria-busy", "false"); refs.patternsContent.replaceChildren(grid); } function renderHolidays() { refs.holidaySubmit.disabled = appState.holidays.length >= MAX_HOLIDAYS; if (appState.resources.holidays.loading && !appState.holidays.length) { renderLoading(refs.holidaysContent, "Cargando feriados…"); return; } if (appState.resources.holidays.error && !appState.holidays.length) { renderResourceError(refs.holidaysContent, "No se pudieron cargar los feriados", appState.resources.holidays.error, () => void loadHolidays()); return; } if (!appState.holidays.length) { renderEmpty(refs.holidaysContent, "No hay feriados", "Añade arriba una fecha que deba silenciar el timbre todos los años."); return; } const list = createElement("div", "holiday-list"); for (const holiday of appState.holidays) { const item = createElement("div", "holiday-item"); const date = createElement("div", "holiday-date"); date.append(createElement("strong", "", String(holiday.day).padStart(2, "0"))); date.append(createElement("span", "", MONTH_NAMES[holiday.month - 1] || `Mes ${holiday.month}`)); item.append(date); const deleteButton = createElement("button", "button button--danger button--small", "Eliminar"); deleteButton.type = "button"; deleteButton.addEventListener("click", () => confirmHolidayDeletion(holiday)); item.append(deleteButton); list.append(item); } refs.holidaysContent.setAttribute("aria-busy", "false"); refs.holidaysContent.replaceChildren(list); } function openDialog(dialog) { if (typeof dialog.showModal === "function") dialog.showModal(); else dialog.setAttribute("open", ""); } function closeDialog(dialog) { if (typeof dialog.close === "function") dialog.close(); else dialog.removeAttribute("open"); } function openProfileDialog(profile = null) { refs.profileForm.reset(); setFormError(refs.profileFormError); refs.profileId.value = profile ? String(profile.id) : ""; refs.profileName.value = profile ? profile.name : ""; refs.profileDialogTitle.textContent = profile ? "Renombrar perfil" : "Añadir perfil"; openDialog(refs.profileDialog); window.setTimeout(() => refs.profileName.focus(), 0); } function selectOptions(select, items, selectedValue) { select.replaceChildren(); for (const item of items) { const option = createElement("option", "", item.label); option.value = String(item.value); select.append(option); } if (selectedValue !== undefined && selectedValue !== null) select.value = String(selectedValue); } function buildProfileOptions() { return appState.profiles.map((profile) => ({ value: profile.id, label: profile.name })); } function buildPatternOptions() { return appState.patterns.map((pattern) => ({ value: pattern.id, label: `${pattern.legacyCode} · ${pattern.enabled ? "habilitado" : "deshabilitado"}` })); } function openScheduleDialog(schedule = null) { if (appState.schedules.length >= MAX_SCHEDULES && !schedule) { showToast(`El límite es de ${MAX_SCHEDULES} horarios. Elimina uno antes de añadir otro.`, "error"); return; } if (!appState.profiles.length) { showToast("Crea un perfil antes de añadir un horario.", "error"); return; } if (!appState.patterns.length) { showToast("Crea un patrón antes de añadir un horario.", "error"); return; } refs.scheduleForm.reset(); setFormError(refs.scheduleFormError); refs.scheduleId.value = schedule ? String(schedule.id) : ""; refs.scheduleTime.value = schedule ? schedule.time : ""; selectOptions(refs.scheduleProfile, buildProfileOptions(), schedule ? schedule.profileId : appState.selectedProfileId); selectOptions(refs.schedulePattern, buildPatternOptions(), schedule ? schedule.patternId : appState.patterns[0]?.id); setScheduleDayMask(schedule ? schedule.days : 0); refs.scheduleDialogTitle.textContent = schedule ? "Editar horario" : "Añadir horario"; openDialog(refs.scheduleDialog); window.setTimeout(() => refs.scheduleTime.focus(), 0); } function setScheduleDayMask(mask) { for (const input of $$('input[name="scheduleDay"]', refs.scheduleForm)) { const day = Number(input.value); input.checked = (mask & (1 << day)) !== 0; } } function readScheduleDayMask() { return $$('input[name="scheduleDay"]', refs.scheduleForm).reduce((mask, input) => { return input.checked ? mask | (1 << Number(input.value)) : mask; }, 0); } function openPatternDialog(pattern = null) { refs.patternForm.reset(); setFormError(refs.patternFormError); refs.patternId.value = pattern ? String(pattern.id) : ""; refs.patternCode.value = pattern ? pattern.legacyCode : ""; refs.patternEnabled.checked = pattern ? pattern.enabled : true; for (let index = 0; index < 6; index += 1) { const input = $(`#pattern-phase-${index}`, refs.patternForm); input.value = pattern ? String(pattern.phaseSeconds[index]) : "0"; } refs.patternDialogTitle.textContent = pattern ? "Editar patrón" : "Añadir patrón"; openDialog(refs.patternDialog); window.setTimeout(() => refs.patternCode.focus(), 0); } function openConfirm(title, message, actionLabel, handler, danger = true) { confirmHandler = handler; refs.confirmTitle.textContent = title; refs.confirmMessage.textContent = message; refs.confirmActionButton.textContent = actionLabel; refs.confirmActionButton.className = `button ${danger ? "button--danger" : "button--primary"}`; openDialog(refs.confirmDialog); window.setTimeout(() => refs.confirmActionButton.focus(), 0); } function confirmProfileDeletion(profile) { if (appState.profiles.length <= 1) { showToast("Debe existir al menos un perfil.", "error"); return; } openConfirm( "Eliminar perfil", `Se eliminará “${profile.name}” y sus horarios. Esta acción no se puede deshacer.`, "Eliminar perfil", () => deleteProfile(profile) ); } function confirmScheduleDeletion(schedule) { openConfirm( "Eliminar horario", `Se eliminará el horario de las ${schedule.time}.`, "Eliminar horario", () => deleteSchedule(schedule) ); } function confirmHolidayDeletion(holiday) { openConfirm( "Eliminar feriado", `Se eliminará el ${holiday.day} de ${MONTH_NAMES[holiday.month - 1] || "este mes"}.`, "Eliminar feriado", () => deleteHoliday(holiday) ); } async function handleMutation(button, operation, successMessage, busyText = "Guardando…") { setButtonBusy(button, true, busyText); try { await operation(); if (successMessage) showToast(successMessage); return true; } catch (error) { if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { handleSessionExpired(); } else { showToast(error instanceof Error ? error.message : "No se pudo completar la operación.", "error"); } return false; } finally { setButtonBusy(button, false); } } async function handleProfileSubmit(event) { event.preventDefault(); setFormError(refs.profileFormError); if (!refs.profileForm.reportValidity()) return; const id = refs.profileId.value; const existing = findById(appState.profiles, id); const name = refs.profileName.value.trim(); if (!name) { setFormError(refs.profileFormError, "Escribe un nombre para el perfil."); return; } const duplicate = appState.profiles.some((profile) => ( profile.name.trim().toLocaleLowerCase("es") === name.toLocaleLowerCase("es") && !sameId(profile.id, id) )); if (duplicate) { setFormError(refs.profileFormError, "Ya existe un perfil con ese nombre."); return; } if (!id && appState.profiles.length >= MAX_PROFILES) { setFormError(refs.profileFormError, `No se pueden crear más de ${MAX_PROFILES} perfiles.`); return; } setButtonBusy(refs.profileSubmit, true); try { await api(id ? "/perfiles/actualizar" : "/perfiles", { method: "POST", body: id ? { id: existing.id, name } : { name } }); closeDialog(refs.profileDialog); showToast(id ? "Perfil actualizado." : "Perfil creado."); await loadProfiles(); } catch (error) { if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { handleSessionExpired(); } else { setFormError(refs.profileFormError, error instanceof Error ? error.message : "No se pudo guardar el perfil."); } } finally { setButtonBusy(refs.profileSubmit, false); } } async function deleteProfile(profile) { const ok = await handleMutation( null, () => api("/perfiles/eliminar", { method: "POST", body: { id: profile.id } }), "Perfil eliminado.", "Eliminando…" ); if (ok) await loadAllData(); } async function handleActiveProfileSubmit(event) { event.preventDefault(); const profile = findById(appState.profiles, refs.activeProfileSelect.value); if (!profile) { showToast("Selecciona un perfil válido.", "error"); return; } const ok = await handleMutation( refs.activeProfileSubmit, () => api("/config/perfil-activo", { method: "POST", body: { id: profile.id } }), "Perfil activo actualizado.", "Guardando…" ); if (ok) { appState.activeProfileId = profile.id; appState.activeProfileKnown = true; renderActiveProfileControl(); renderProfiles(); } } async function handleScheduleSubmit(event) { event.preventDefault(); setFormError(refs.scheduleFormError); if (!refs.scheduleForm.reportValidity()) return; const id = refs.scheduleId.value; const days = readScheduleDayMask(); if (days === 0) { setFormError(refs.scheduleFormError, "Selecciona al menos un día de la semana."); return; } if (!id && appState.schedules.length >= MAX_SCHEDULES) { setFormError(refs.scheduleFormError, `No se pueden guardar más de ${MAX_SCHEDULES} horarios.`); return; } const profile = findById(appState.profiles, refs.scheduleProfile.value); const pattern = findById(appState.patterns, refs.schedulePattern.value); if (!profile || !pattern) { setFormError(refs.scheduleFormError, "El perfil o el patrón seleccionados ya no están disponibles."); return; } const body = { profileId: profile.id, time: refs.scheduleTime.value, patternId: pattern.id, days }; if (id) body.id = findById(appState.schedules, id)?.id ?? id; setButtonBusy(refs.scheduleSubmit, true); try { await api(id ? "/horarios/actualizar" : "/horarios", { method: "POST", body }); closeDialog(refs.scheduleDialog); showToast(id ? "Horario actualizado." : "Horario creado."); await loadSchedules(); } catch (error) { if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { handleSessionExpired(); } else { setFormError(refs.scheduleFormError, error instanceof Error ? error.message : "No se pudo guardar el horario."); } } finally { setButtonBusy(refs.scheduleSubmit, false); } } async function deleteSchedule(schedule) { const ok = await handleMutation( null, () => api("/horarios/eliminar", { method: "POST", body: { id: schedule.id } }), "Horario eliminado.", "Eliminando…" ); if (ok) await loadSchedules(); } async function handlePatternSubmit(event) { event.preventDefault(); setFormError(refs.patternFormError); if (!refs.patternForm.reportValidity()) return; const id = refs.patternId.value; const legacyCode = refs.patternCode.value.trim().toUpperCase(); if (!["A", "B", "C"].includes(legacyCode)) { setFormError(refs.patternFormError, "El código debe ser A, B o C."); return; } const duplicate = appState.patterns.some((pattern) => ( pattern.legacyCode.toUpperCase() === legacyCode && !sameId(pattern.id, id) )); if (duplicate) { setFormError(refs.patternFormError, "Ya existe un patrón con ese código."); return; } const phaseSeconds = Array.from({ length: 6 }, (_, index) => { const input = $(`#pattern-phase-${index}`, refs.patternForm); return Math.max(0, Math.trunc(asFiniteNumber(input.value) ?? 0)); }); if (phaseSeconds.some((seconds) => seconds > MAX_PHASE_SECONDS)) { setFormError(refs.patternFormError, `Cada fase debe durar como máximo ${MAX_PHASE_SECONDS} segundos.`); return; } const totalSeconds = phaseSeconds.reduce((sum, seconds) => sum + seconds, 0); if (totalSeconds > MAX_PATTERN_SECONDS) { setFormError(refs.patternFormError, `La duración total no puede superar ${MAX_PATTERN_SECONDS} segundos.`); return; } if (!id && appState.patterns.length >= MAX_PATTERNS) { setFormError(refs.patternFormError, `No se pueden crear más de ${MAX_PATTERNS} patrones.`); return; } const existing = findById(appState.patterns, id); const targetReference = existing || appState.patterns.find((pattern) => sameId(pattern.target, 22)); const targetUsesPinNumber = targetReference && ( targetReference.target === 22 || asString(targetReference.target).trim() === "22" ); const body = { legacyCode, target: targetUsesPinNumber ? 22 : "GPIO22", phaseSeconds, enabled: refs.patternEnabled.checked }; if (id) body.id = existing ? existing.id : id; setButtonBusy(refs.patternSubmit, true); try { await api(id ? "/patrones/actualizar" : "/patrones", { method: "POST", body }); closeDialog(refs.patternDialog); showToast(id ? "Patrón actualizado." : "Patrón creado."); await loadPatterns(); await loadSchedules(); } catch (error) { if (error instanceof ApiError && (error.status === 401 || error.status === 403)) { handleSessionExpired(); } else { setFormError(refs.patternFormError, error instanceof Error ? error.message : "No se pudo guardar el patrón."); } } finally { setButtonBusy(refs.patternSubmit, false); } } async function testPattern(pattern, button) { const ok = await handleMutation( button, () => api("/acciones/probar", { method: "POST", body: { patternId: pattern.id } }), `Prueba del patrón ${pattern.legacyCode} iniciada.`, "Probando…" ); if (ok) window.setTimeout(() => void pollStatus(false), 250); } async function handleStopAction() { const ok = await handleMutation( refs.stopButton, () => api("/acciones/detener", { method: "POST" }), "Secuencia detenida.", "Deteniendo…" ); if (ok) window.setTimeout(() => void pollStatus(false), 250); } function maximumDayForMonth(month) { return [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] || 31; } function updateHolidayDayLimit() { const month = Math.trunc(asFiniteNumber(refs.holidayMonth.value) || 1); const maximum = maximumDayForMonth(month); refs.holidayDay.max = String(maximum); if (asFiniteNumber(refs.holidayDay.value) > maximum) refs.holidayDay.value = String(maximum); } async function handleHolidaySubmit(event) { event.preventDefault(); setFormError(refs.holidayError); if (!refs.holidayForm.reportValidity()) return; const month = Math.trunc(asFiniteNumber(refs.holidayMonth.value) ?? 0); const day = Math.trunc(asFiniteNumber(refs.holidayDay.value) ?? 0); if (month < 1 || month > 12) { setFormError(refs.holidayError, "Selecciona un mes válido."); return; } if (day < 1 || day > maximumDayForMonth(month)) { setFormError(refs.holidayError, "Selecciona un día válido para el mes."); return; } if (appState.holidays.some((holiday) => holiday.month === month && holiday.day === day)) { setFormError(refs.holidayError, "Ese feriado ya está registrado."); return; } const ok = await handleMutation( refs.holidaySubmit, () => api("/feriados", { method: "POST", body: { month, day } }), "Feriado añadido.", "Añadiendo…" ); if (ok) { refs.holidayDay.value = "1"; await loadHolidays(); } } async function deleteHoliday(holiday) { const ok = await handleMutation( null, () => api("/feriados/eliminar", { method: "POST", body: { month: holiday.month, day: holiday.day } }), "Feriado eliminado.", "Eliminando…" ); if (ok) await loadHolidays(); } async function handleSyncTime() { const ok = await handleMutation( refs.syncTimeButton, () => api("/hora", { method: "POST", body: { epoch: Math.floor(Date.now() / 1000) } }), "Hora del dispositivo sincronizada.", "Sincronizando…" ); if (ok) { appState.timeSyncAttempted = true; await pollStatus(false); } } function buildStaticControls() { for (let month = 1; month <= 12; month += 1) { const option = createElement("option", "", `${month} · ${MONTH_NAMES[month - 1]}`); option.value = String(month); refs.holidayMonth.append(option); } refs.holidayMonth.value = "1"; for (const day of DAY_NAMES) { const label = createElement("label", "day-option"); const input = document.createElement("input"); input.type = "checkbox"; input.name = "scheduleDay"; input.value = String(day.index); const text = createElement("span", "", day.short); text.title = day.name; label.append(input, text); refs.scheduleDays.append(label); } for (let index = 0; index < 6; index += 1) { const field = createElement("div", "phase-field"); const label = createElement("label", "", PHASE_NAMES[index]); label.htmlFor = `pattern-phase-${index}`; const input = document.createElement("input"); input.type = "number"; input.id = `pattern-phase-${index}`; input.name = `phaseSeconds[${index}]`; input.min = "0"; input.max = String(MAX_PHASE_SECONDS); input.step = "1"; input.value = "0"; input.required = true; input.inputMode = "numeric"; field.append(label, input); refs.patternPhases.append(field); } } function bindEvents() { refs.bootstrapRetry.addEventListener("click", () => void bootstrap()); refs.claimForm.addEventListener("submit", (event) => void handleClaimSubmit(event)); refs.loginForm.addEventListener("submit", (event) => void handleLoginSubmit(event)); refs.logoutButton.addEventListener("click", () => void handleLogout()); refs.refreshButton.addEventListener("click", () => void refreshAll()); refs.syncTimeButton.addEventListener("click", () => void handleSyncTime()); refs.stopButton.addEventListener("click", () => openConfirm( "Detener secuencia", "La salida GPIO22 se pondrá en reposo de inmediato.", "Detener ahora", () => handleStopAction(), true )); refs.addProfileButton.addEventListener("click", () => openProfileDialog()); refs.activeProfileForm.addEventListener("submit", (event) => void handleActiveProfileSubmit(event)); refs.activeProfileSelect.addEventListener("change", updateActiveProfileSubmit); refs.addScheduleButton.addEventListener("click", () => openScheduleDialog()); refs.addPatternButton.addEventListener("click", () => openPatternDialog()); refs.profileForm.addEventListener("submit", (event) => void handleProfileSubmit(event)); refs.scheduleForm.addEventListener("submit", (event) => void handleScheduleSubmit(event)); refs.patternForm.addEventListener("submit", (event) => void handlePatternSubmit(event)); refs.holidayForm.addEventListener("submit", (event) => void handleHolidaySubmit(event)); refs.holidayMonth.addEventListener("change", updateHolidayDayLimit); for (const closeButton of $$("[data-close-dialog]")) { closeButton.addEventListener("click", () => closeDialog(closeButton.closest("dialog"))); } refs.confirmDialog.addEventListener("close", () => { confirmHandler = null; }); refs.confirmActionButton.addEventListener("click", () => { const handler = confirmHandler; confirmHandler = null; closeDialog(refs.confirmDialog); if (handler) void handler(); }); } buildStaticControls(); bindEvents(); void bootstrap(); })();