Files
TimbreESP32/ui/app.js
Angel Ivan 2a9eabce14 Fist commit
2026-10-02 12:08:21 -06:00

1922 lines
74 KiB
JavaScript
Executable File

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