diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js index 67087b20..6c49c1e8 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -1258,7 +1258,7 @@ function applyRigList(activeRigId, rigIds, displayNames) { updateRigIdentitySummary(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId); if (rigListChanged) { - if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId); + window.trx.modules.scheduler?.setRig(lastActiveRigId); if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId); if (typeof bmPopulateScopePicker === "function") bmPopulateScopePicker(); if (typeof bmFetch === "function") bmFetch(document.getElementById("bm-category-filter")?.value || ""); @@ -3593,7 +3593,7 @@ async function switchRigFromSelect(selectEl) { updateRigSubtitle(lastActiveRigId); updateRigIdentitySummary(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId); - if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId); + window.trx.modules.scheduler?.setRig(lastActiveRigId); if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId); if (typeof bmFetch === "function") bmFetch(document.getElementById("bm-category-filter")?.value || ""); window.trx.modules.map?.syncAprsReceiverMarker(); @@ -4333,10 +4333,8 @@ async function initializeApp() { } } function initSettingsUI() { - if (typeof initScheduler === "function") { - initScheduler(lastActiveRigId, authRole); - wireSchedulerEvents(); - } + window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole); + window.trx.modules.scheduler?.wireEvents(); if (typeof initBackgroundDecode === "function") { initBackgroundDecode(lastActiveRigId, authRole); wireBackgroundDecodeEvents(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js index 08fe1bbe..090bf81f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js @@ -10,7 +10,7 @@ const pluginGroups = { }; const loaded = /* @__PURE__ */ new Set(); const loading = /* @__PURE__ */ new Map(); -const modulePlugins = /* @__PURE__ */ new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js", "/vchan.js", "/bookmarks.js"]); +const modulePlugins = /* @__PURE__ */ new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js", "/vchan.js", "/bookmarks.js", "/scheduler.js"]); function loadLegacyScript(path) { return new Promise((resolve, reject) => { const script = document.createElement("script"); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat-scheduler.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat-scheduler.js index fcd55ae2..7a8848a0 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat-scheduler.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat-scheduler.js @@ -24,8 +24,9 @@ centerHz: document.getElementById("scheduler-sat-center-hz") }; let editIdx = null; + let eventsWired = false; function getBridge() { - return satSchedulerWindow.schedulerBridge ?? null; + return satSchedulerWindow.trx?.modules?.scheduler ?? null; } function getConfig() { const b = getBridge(); @@ -230,6 +231,8 @@ if (dom.norad) dom.norad.value = parts[1] || ""; } function wireEvents() { + if (eventsWired) return; + eventsWired = true; if (dom.enabled) { const enabledInput = dom.enabled; dom.enabled.addEventListener("change", function() { @@ -255,5 +258,9 @@ renderPassStatus, collectSatelliteConfig }; + if (getBridge()) { + wireEvents(); + renderSection(); + } })(); })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js index e5493205..289e8bb9 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js @@ -1,1195 +1,1223 @@ "use strict"; -(function() { - "use strict"; - let schedulerRole = null; - let currentRigId = null; - let currentConfig = null; - let currentSchedulerStatus = null; - let bookmarkList = []; - let statusInterval = null; - let interleaveTicker = null; - let schedulerStepPending = false; - let schEntryEditIdx = null; - let schedulerDirty = false; - function initScheduler(rigId, role) { - schedulerRole = role; - currentRigId = rigId || null; - if (currentRigId) loadScheduler(); - startStatusPolling(); - startInterleaveTicker(); +(() => { + // src/plugins/scheduler.ts + var schedulerWindow = window; + var wiredElements = /* @__PURE__ */ new WeakSet(); + function schedulerEl(id) { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing scheduler element #${id}`); + return element; } - function destroyScheduler() { - if (statusInterval) { - clearInterval(statusInterval); - statusInterval = null; + (function() { + "use strict"; + let schedulerRole = null; + let currentRigId = null; + let currentConfig = null; + let currentSchedulerStatus = null; + let bookmarkList = []; + let statusInterval = null; + let interleaveTicker = null; + let schedulerStepPending = false; + let schEntryEditIdx = null; + let schedulerDirty = false; + function initScheduler(rigId, role) { + schedulerRole = role; + currentRigId = rigId || null; + if (currentRigId) loadScheduler(); + startStatusPolling(); + startInterleaveTicker(); } - if (interleaveTicker) { - clearInterval(interleaveTicker); - interleaveTicker = null; + function destroyScheduler() { + if (statusInterval) { + clearInterval(statusInterval); + statusInterval = null; + } + if (interleaveTicker) { + clearInterval(interleaveTicker); + interleaveTicker = null; + } } - } - function setSchedulerRig(rigId) { - const nextRigId = rigId || null; - if (nextRigId === currentRigId) return; - currentRigId = nextRigId; - renderSchedulerInterleaveStatus(); - if (!currentRigId) return; - loadScheduler(); - pollStatus(); - } - function apiGetScheduler(rigId) { - return fetch("/scheduler/" + encodeURIComponent(rigId)).then(function(r) { - if (!r.ok) throw new Error("HTTP " + r.status); - return r.json(); - }); - } - function apiPutScheduler(rigId, config) { - return fetch("/scheduler/" + encodeURIComponent(rigId), { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(config) - }).then(function(r) { - if (!r.ok) throw new Error("HTTP " + r.status); - return r.json(); - }); - } - function apiDeleteScheduler(rigId) { - return fetch("/scheduler/" + encodeURIComponent(rigId), { - method: "DELETE" - }).then(function(r) { - if (!r.ok) throw new Error("HTTP " + r.status); - return r.json(); - }); - } - function apiGetStatus(rigId) { - return fetch("/scheduler/" + encodeURIComponent(rigId) + "/status").then( - function(r) { + function setSchedulerRig(rigId) { + const nextRigId = rigId || null; + if (nextRigId === currentRigId) return; + currentRigId = nextRigId; + renderSchedulerInterleaveStatus(); + if (!currentRigId) return; + loadScheduler(); + pollStatus(); + } + function apiGetScheduler(rigId) { + return fetch("/scheduler/" + encodeURIComponent(rigId)).then(function(r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); - } - ); - } - function apiActivateSchedulerEntry(rigId, entryId) { - return fetch("/scheduler/" + encodeURIComponent(rigId) + "/activate", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ entry_id: entryId }) - }).then(function(r) { - if (!r.ok) throw new Error("HTTP " + r.status); - return r.json(); - }); - } - function apiGetBookmarks() { - var url = currentRigId ? "/bookmarks?scope=" + encodeURIComponent(currentRigId) : "/bookmarks"; - return fetch(url).then(function(r) { - return r.ok ? r.json() : []; - }); - } - function loadScheduler() { - const rig = currentRigId; - if (!rig) return; - Promise.all([apiGetScheduler(rig), apiGetBookmarks()]).then(function([config, bms]) { - currentConfig = config; - bookmarkList = Array.isArray(bms) ? bms : []; - populateTsBookmarkSelect(); - renderScheduler(); - clearSchedulerDirty(); - renderSchedulerInterleaveStatus(); - }).catch(function(e) { - console.error("scheduler load failed", e); - renderSchedulerInterleaveStatus(); - }); - } - function startStatusPolling() { - if (statusInterval) clearInterval(statusInterval); - statusInterval = setInterval(pollStatus, 15e3); - pollStatus(); - } - function startInterleaveTicker() { - if (interleaveTicker) clearInterval(interleaveTicker); - interleaveTicker = setInterval(renderSchedulerInterleaveStatus, 1e3); - renderSchedulerInterleaveStatus(); - } - function schedulerUtcSeconds() { - return Math.floor(Date.now() / 1e3); - } - function schedulerUtcMinuteInfo() { - const secs = schedulerUtcSeconds(); - const secsIntoDay = (secs % 86400 + 86400) % 86400; - return { - minuteOfDay: Math.floor(secsIntoDay / 60), - secondOfMinute: secsIntoDay % 60 - }; - } - function schedulerEntryIsActive(entry, nowMin) { - const start = Number(entry && entry.start_min); - const end = Number(entry && entry.end_min); - if (!Number.isFinite(start) || !Number.isFinite(end)) return false; - if (start === end) return true; - if (start < end) return nowMin >= start && nowMin < end; - return nowMin >= start || nowMin < end; - } - function schedulerEntryCurrentWindowStart(entry, nowMin) { - const start = Number(entry && entry.start_min); - const end = Number(entry && entry.end_min); - if (!Number.isFinite(start) || !Number.isFinite(end)) return Number.NEGATIVE_INFINITY; - if (start === end) return 0; - if (start < end) return start; - return nowMin >= start ? start : start - 1440; - } - function schedulerEntryDisplayName(entry) { - if (!entry) return "Scheduler entry"; - if (entry.label) return String(entry.label); - const bookmarkName = bmName(entry.bookmark_id); - return bookmarkName || "Scheduler entry"; - } - function schedulerInterleaveState(config) { - if (!config || config.mode !== "time_span") { - return { activeEntries: [], currentIndex: -1, remainingSec: 0, cycleMin: 0 }; + }); } - const entries = Array.isArray(config.entries) ? config.entries : []; - const minuteInfo = schedulerUtcMinuteInfo(); - const nowMin = minuteInfo.minuteOfDay; - const active = entries.filter(function(entry) { - return schedulerEntryIsActive(entry, nowMin); - }); - if (active.length === 0) { - return { activeEntries: [], currentIndex: -1, remainingSec: 0, cycleMin: 0 }; + function apiPutScheduler(rigId, config) { + return fetch("/scheduler/" + encodeURIComponent(rigId), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config) + }).then(function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }); } - if (active.length === 1) { - return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + function apiDeleteScheduler(rigId) { + return fetch("/scheduler/" + encodeURIComponent(rigId), { + method: "DELETE" + }).then(function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }); } - var exclIdx = active.findIndex(function(e) { - return e.exclusive; - }); - if (exclIdx >= 0) { - return { activeEntries: [active[exclIdx]], currentIndex: 0, remainingSec: 0, cycleMin: 0 }; - } - const defaultInterleave = Number(config.interleave_min); - const durations = active.map(function(entry) { - const own = Number(entry && entry.interleave_min); - if (Number.isFinite(own) && own > 0) return Math.floor(own); - if (Number.isFinite(defaultInterleave) && defaultInterleave > 0) return Math.floor(defaultInterleave); - return 0; - }); - const cycleMin = durations.reduce(function(sum, value) { - return sum + value; - }, 0); - if (!(cycleMin > 0)) { - return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; - } - const statusEntryId = currentSchedulerStatus && currentSchedulerStatus.last_entry_id ? String(currentSchedulerStatus.last_entry_id) : ""; - const statusIndex = statusEntryId ? active.findIndex(function(entry) { - return String(entry && entry.id || "") === statusEntryId; - }) : -1; - const statusAppliedUtc = currentSchedulerStatus && Number.isFinite(Number(currentSchedulerStatus.last_applied_utc)) ? Number(currentSchedulerStatus.last_applied_utc) : null; - if (statusIndex >= 0 && statusAppliedUtc != null) { - const manualDurationMin = durations[statusIndex]; - const elapsedSec = Math.max(0, schedulerUtcSeconds() - statusAppliedUtc); - const remainingSec2 = manualDurationMin > 0 ? Math.max(1, manualDurationMin * 60 - elapsedSec) : 0; - if (remainingSec2 > 0) { - return { - activeEntries: active, - currentIndex: statusIndex, - remainingSec: remainingSec2, - cycleMin - }; - } - } - const overlapStart = active.reduce(function(maxStart, entry) { - return Math.max(maxStart, schedulerEntryCurrentWindowStart(entry, nowMin)); - }, Number.NEGATIVE_INFINITY); - if (!Number.isFinite(overlapStart)) { - return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; - } - const nowMinPrecise = minuteInfo.minuteOfDay + minuteInfo.secondOfMinute / 60; - const posMin = ((nowMinPrecise - overlapStart) % cycleMin + cycleMin) % cycleMin; - let cumulative = 0; - let slotStart = 0; - let currentIndex = 0; - let currentDuration = 0; - for (let i = 0; i < durations.length; i += 1) { - const nextCumulative = cumulative + durations[i]; - if (posMin < nextCumulative) { - slotStart = cumulative; - cumulative = nextCumulative; - currentIndex = i; - currentDuration = durations[i]; - break; - } - cumulative = nextCumulative; - } - if (!(currentDuration > 0)) { - return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; - } - const elapsedSlotSec = Math.max(0, Math.floor((posMin - slotStart) * 60)); - const remainingSec = Math.max(1, currentDuration * 60 - elapsedSlotSec); - return { - activeEntries: active, - currentIndex, - remainingSec, - cycleMin - }; - } - function renderSchedulerInterleaveStatus() { - const wrap = document.getElementById("scheduler-cycle-status"); - if (!wrap) return; - const state = schedulerInterleaveState(currentConfig); - const isActive = state.activeEntries.length > 1 && state.cycleMin > 0; - wrap.style.display = isActive ? "" : "none"; - if (isActive) { - var activeName = schedulerEntryDisplayName(state.activeEntries[state.currentIndex]); - var totalSlotSec = state.cycleMin > 0 ? state.cycleMin * 60 / state.activeEntries.length : 0; - var elapsedPct = totalSlotSec > 0 ? Math.min(100, Math.max(0, (totalSlotSec - state.remainingSec) / totalSlotSec * 100)) : 0; - var ringFill = document.getElementById("interleave-ring-fill"); - if (ringFill) ringFill.setAttribute("stroke-dashoffset", String(100 - elapsedPct)); - var nameEl = document.getElementById("interleave-active-name"); - if (nameEl) nameEl.textContent = activeName; - var countdownEl = document.getElementById("interleave-countdown"); - if (countdownEl) countdownEl.textContent = "next in " + state.remainingSec + "s · " + state.cycleMin + "m cycle"; - } - renderTimelineNeedle(); - renderSchedulerStepControls(); - } - function renderSchedulerStepControls() { - const prevBtn = document.getElementById("scheduler-prev-btn"); - const nextBtn = document.getElementById("scheduler-next-btn"); - if (!prevBtn || !nextBtn) return; - const state = schedulerInterleaveState(currentConfig); - const enabled = schedulerRole === "control" && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1; - prevBtn.disabled = !enabled; - nextBtn.disabled = !enabled; - const hint = enabled ? "Select a different active scheduler entry" : "Available only when multiple scheduler entries are active"; - prevBtn.title = hint; - nextBtn.title = hint; - } - function pollStatus() { - const rig = currentRigId; - if (!rig) return; - apiGetStatus(rig).then(function(st) { - currentSchedulerStatus = st || null; - renderStatus(st); - renderSchedulerInterleaveStatus(); - renderActivityLog(); - renderSatPassStatus(); - }).catch(function() { - }); - } - function renderStatus(st) { - const el = document.getElementById("scheduler-status-card"); - if (!el) return; - if (!st || !st.active && !st.last_bookmark_id) { - el.textContent = "No activity yet."; - return; - } - const statusEntryId = st.last_entry_id ? String(st.last_entry_id) : ""; - const entry = statusEntryId && currentConfig && Array.isArray(currentConfig.entries) ? currentConfig.entries.find(function(item) { - return String(item && item.id || "") === statusEntryId; - }) : null; - const name = entry ? schedulerEntryDisplayName(entry) : st.last_bookmark_name || st.last_bookmark_id || "—"; - let ts = ""; - if (st.last_applied_utc) { - const d = new Date(st.last_applied_utc * 1e3); - ts = " at " + d.toUTCString(); - } - const satLabel = st.active_satellite ? " [SAT: " + st.active_satellite + "]" : ""; - var details = ""; - if (st.freq_hz) { - details += formatFreq(st.freq_hz); - if (st.mode) details += " · " + st.mode; - if (st.active_decoders && st.active_decoders.length > 0) { - details += " · " + st.active_decoders.join(", ") + " active"; - } - } - if (details) { - el.innerHTML = "Last applied: " + escHtml(name) + satLabel + ts + '
' + escHtml(details) + ""; - } else { - el.textContent = "Last applied: " + name + satLabel + ts; - } - } - function apiGetSchedulerLog(rigId) { - return fetch("/scheduler/" + encodeURIComponent(rigId) + "/log").then(function(r) { - return r.ok ? r.json() : []; - }); - } - function renderActivityLog() { - var wrap = document.getElementById("scheduler-activity-log-wrap"); - var container = document.getElementById("scheduler-activity-log"); - if (!wrap || !container || !currentRigId) return; - apiGetSchedulerLog(currentRigId).then(function(entries) { - if (!entries || entries.length === 0) { - wrap.style.display = "none"; - return; - } - wrap.style.display = ""; - var html = entries.slice().reverse().map(function(e) { - var d = new Date(e.utc * 1e3); - var ts = d.toUTCString(); - var action = e.action || "unknown"; - var label = e.entry_label || ""; - var bm = e.bookmark_name || ""; - return '
' + escHtml(ts) + ' ' + escHtml(action) + " " + (bm ? '' + escHtml(bm) + "" : "") + (label ? ' (' + escHtml(label) + ")" : "") + "
"; - }).join(""); - container.innerHTML = html; - }).catch(function() { - }); - } - function renderScheduler() { - const panel = document.getElementById("scheduler-panel"); - if (!panel) return; - const mode = currentConfig && currentConfig.mode || "disabled"; - const isControl = schedulerRole === "control"; - setSelected("scheduler-mode-select", mode); - const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled; - const controlRow = document.querySelector(".scheduler-control-row"); - if (controlRow) controlRow.style.display = mode !== "disabled" || satEnabled ? "" : "none"; - const glSection = document.getElementById("scheduler-grayline-section"); - const tsSection = document.getElementById("scheduler-timespan-section"); - if (glSection) glSection.style.display = mode === "grayline" ? "" : "none"; - if (tsSection) tsSection.style.display = mode === "time_span" ? "" : "none"; - renderSatelliteSection(); - if (mode === "grayline" && currentConfig && currentConfig.grayline) { - const gl = currentConfig.grayline; - const lat = gl.lat != null ? gl.lat : typeof serverLat !== "undefined" ? serverLat : ""; - const lon = gl.lon != null ? gl.lon : typeof serverLon !== "undefined" ? serverLon : ""; - setInputValue("scheduler-gl-lat", lat != null ? lat : ""); - setInputValue("scheduler-gl-lon", lon != null ? lon : ""); - var gridEl = document.getElementById("scheduler-gl-grid"); - if (gridEl && lat !== "" && lon !== "") { - gridEl.value = latLonToGrid(lat, lon); - } - setInputValue("scheduler-gl-window", gl.transition_window_min != null ? gl.transition_window_min : 20); - renderBookmarkSelect("scheduler-gl-dawn", gl.dawn_bookmark_id); - renderBookmarkSelect("scheduler-gl-day", gl.day_bookmark_id); - renderBookmarkSelect("scheduler-gl-dusk", gl.dusk_bookmark_id); - renderBookmarkSelect("scheduler-gl-night", gl.night_bookmark_id); - } else if (mode === "grayline") { - const lat = typeof serverLat !== "undefined" ? serverLat : ""; - const lon = typeof serverLon !== "undefined" ? serverLon : ""; - setInputValue("scheduler-gl-lat", lat != null ? lat : ""); - setInputValue("scheduler-gl-lon", lon != null ? lon : ""); - var gridEl2 = document.getElementById("scheduler-gl-grid"); - if (gridEl2 && lat !== "" && lon !== "") { - gridEl2.value = latLonToGrid(lat, lon); - } - setInputValue("scheduler-gl-window", 20); - renderBookmarkSelect("scheduler-gl-dawn", null); - renderBookmarkSelect("scheduler-gl-day", null); - renderBookmarkSelect("scheduler-gl-dusk", null); - renderBookmarkSelect("scheduler-gl-night", null); - } else { - renderBookmarkSelect("scheduler-gl-dawn", null); - renderBookmarkSelect("scheduler-gl-day", null); - renderBookmarkSelect("scheduler-gl-dusk", null); - renderBookmarkSelect("scheduler-gl-night", null); - } - const ilEl = document.getElementById("scheduler-ts-interleave"); - if (ilEl) { - const il = currentConfig && currentConfig.interleave_min; - ilEl.value = il ? il : ""; - } - renderTimespanEntries(); - const formEls = panel.querySelectorAll("input, select, button.sch-write"); - formEls.forEach(function(el) { - el.disabled = !isControl; - }); - const saveBtn = document.getElementById("scheduler-save-btn"); - if (saveBtn) { - saveBtn.style.display = isControl ? "" : "none"; - } - const resetBtn = document.getElementById("scheduler-reset-btn"); - if (resetBtn) { - resetBtn.style.display = isControl ? "" : "none"; - } - } - function setSelected(id, value) { - const el = document.getElementById(id); - if (el) el.value = value; - } - function setInputValue(id, value) { - const el = document.getElementById(id); - if (el) el.value = value; - } - function renderBookmarkSelect(id, selectedId) { - const sel = document.getElementById(id); - if (!sel) return; - sel.innerHTML = ''; - bookmarkList.forEach(function(bm) { - const opt = document.createElement("option"); - opt.value = bm.id; - opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")"; - if (bm.id === selectedId) opt.selected = true; - sel.appendChild(opt); - }); - } - function formatFreq(hz) { - if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz"; - if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz"; - return hz + " Hz"; - } - function schOpenEntryForm(entry, idx) { - schEntryEditIdx = idx != null ? idx : null; - const titleEl = document.getElementById("sch-entry-form-title"); - if (titleEl) titleEl.textContent = entry ? "Edit Entry" : "Add Entry"; - const startEl = document.getElementById("scheduler-ts-start"); - const endEl = document.getElementById("scheduler-ts-end"); - const bmEl = document.getElementById("scheduler-ts-bookmark"); - const labelEl = document.getElementById("scheduler-ts-label"); - const ilEl = document.getElementById("scheduler-ts-entry-interleave"); - const centerHzEl = document.getElementById("scheduler-ts-center-hz"); - if (startEl) startEl.value = entry ? minToHHMM(entry.start_min) : ""; - if (endEl) endEl.value = entry ? minToHHMM(entry.end_min) : ""; - if (bmEl) bmEl.value = entry ? entry.bookmark_id || "" : ""; - if (labelEl) labelEl.value = entry ? entry.label || "" : ""; - if (ilEl) ilEl.value = entry && entry.interleave_min ? entry.interleave_min : ""; - if (centerHzEl) centerHzEl.value = entry && entry.center_hz ? entry.center_hz : ""; - const recordEl = document.getElementById("scheduler-ts-entry-record"); - if (recordEl) recordEl.checked = !!(entry && entry.record); - pendingExtraBmIds = entry && Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids.slice() : []; - renderExtraBmList(); - const wrap = document.getElementById("sch-entry-form-wrap"); - if (wrap) { - wrap.style.display = "block"; - if (startEl) startEl.focus(); - } - } - function schCloseEntryForm() { - const wrap = document.getElementById("sch-entry-form-wrap"); - if (wrap) wrap.style.display = "none"; - schEntryEditIdx = null; - pendingExtraBmIds = []; - } - function schEntryFormSubmit(e) { - e.preventDefault(); - const startEl = document.getElementById("scheduler-ts-start"); - const endEl = document.getElementById("scheduler-ts-end"); - const bmEl = document.getElementById("scheduler-ts-bookmark"); - const labelEl = document.getElementById("scheduler-ts-label"); - const ilEl = document.getElementById("scheduler-ts-entry-interleave"); - const centerHzEl = document.getElementById("scheduler-ts-center-hz"); - if (!startEl || !endEl || !bmEl) return; - const bmId = bmEl.value; - if (!bmId) { - window.trxUi?.notify("Select a primary bookmark before saving.", { kind: "error" }); - return; - } - const startMin = hhmmToMin(startEl.value); - const endMin = hhmmToMin(endEl.value); - const label = labelEl ? labelEl.value.trim() : ""; - const ilVal = ilEl ? parseInt(ilEl.value, 10) : NaN; - const entryInterleave = !isNaN(ilVal) && ilVal > 0 ? ilVal : null; - const centerHzRaw = centerHzEl ? parseInt(centerHzEl.value, 10) : NaN; - const centerHz = !isNaN(centerHzRaw) && centerHzRaw > 0 ? centerHzRaw : null; - const extraBmIds = pendingExtraBmIds.slice(); - if (!currentConfig) { - currentConfig = { remote: currentRigId, mode: "time_span", entries: [] }; - } - if (!currentConfig.entries) currentConfig.entries = []; - const recordCb = document.getElementById("scheduler-ts-entry-record"); - const entryRecord = recordCb ? recordCb.checked : false; - const entryData = { - start_min: startMin, - end_min: endMin, - bookmark_id: bmId, - label: label || null, - interleave_min: entryInterleave, - center_hz: centerHz, - bookmark_ids: extraBmIds, - record: entryRecord - }; - if (schEntryEditIdx !== null) { - const existing = currentConfig.entries[schEntryEditIdx]; - entryData.id = existing ? existing.id : "ts_" + Date.now().toString(36); - currentConfig.entries[schEntryEditIdx] = entryData; - } else { - entryData.id = "ts_" + Date.now().toString(36); - currentConfig.entries.push(entryData); - } - schCloseEntryForm(); - renderTimespanEntries(); - markSchedulerDirty(); - } - var TIMELINE_COLORS = ["#38bdf8", "#f59e0b", "#a78bfa", "#34d399", "#fb7185", "#60a5fa"]; - function renderTimeline() { - var container = document.getElementById("scheduler-ts-timeline"); - if (!container) return; - var entries = currentConfig && Array.isArray(currentConfig.entries) ? currentConfig.entries : []; - if (entries.length === 0) { - container.innerHTML = ""; - return; - } - var W = 1e3; - var H = 80; - var BAR_Y = 6; - var BAR_H = 30; - var TICK_Y = BAR_Y + BAR_H + 2; - var svg = ''; - svg += ''; - entries.forEach(function(entry, idx) { - var start = Number(entry.start_min); - var end = Number(entry.end_min); - if (!Number.isFinite(start) || !Number.isFinite(end)) return; - var color = TIMELINE_COLORS[idx % TIMELINE_COLORS.length]; - if (start === end) { - svg += ''; - } else if (start < end) { - var x = start / 1440 * W; - var w = (end - start) / 1440 * W; - svg += ''; - } else { - var x1 = start / 1440 * W; - var w1 = W - x1; - svg += ''; - var w2 = end / 1440 * W; - svg += ''; - } - }); - var interleaveMin = currentConfig && currentConfig.interleave_min ? Number(currentConfig.interleave_min) : 0; - if (interleaveMin > 0 && entries.length > 1) { - for (var m = 0; m < 1440; m += interleaveMin) { - var overlapping = []; - entries.forEach(function(entry, idx) { - if (schedulerEntryIsActive(entry, m)) { - overlapping.push(idx); - } - }); - if (overlapping.length > 1) { - var stripeX = m / 1440 * W; - var stripeW = Math.max(1, interleaveMin / 1440 * W); - var cyclePos = m % (interleaveMin * overlapping.length); - var ownerSlot = Math.floor(cyclePos / interleaveMin); - var ownerIdx = overlapping[ownerSlot % overlapping.length]; - var stripeColor = TIMELINE_COLORS[ownerIdx % TIMELINE_COLORS.length]; - svg += ''; + function apiGetStatus(rigId) { + return fetch("/scheduler/" + encodeURIComponent(rigId) + "/status").then( + function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); } - } + ); } - for (var h = 0; h <= 24; h += 3) { - var tx = h / 24 * W; - svg += ''; - if (h < 24) { - svg += '' + String(h).padStart(2, "0") + ""; - } - } - var LOCAL_TICK_Y = TICK_Y + 18; - for (var h = 0; h < 24; h += 3) { - var localMin = h * 60; - var utcOffset = (/* @__PURE__ */ new Date()).getTimezoneOffset(); - var utcMin = (localMin + utcOffset + 1440) % 1440; - var tx = utcMin / 1440 * W; - svg += '' + String(h).padStart(2, "0") + "L"; - } - svg += '' + timelineNeedleSvg() + ""; - svg += ""; - container.innerHTML = svg; - container.querySelectorAll(".sch-timeline-seg").forEach(function(seg) { - seg.addEventListener("click", function() { - var i = parseInt(seg.getAttribute("data-idx"), 10); - var entry = currentConfig && currentConfig.entries ? currentConfig.entries[i] : null; - if (entry) schOpenEntryForm(entry, i); - }); - }); - var svgEl = container.querySelector("svg"); - if (svgEl) { - svgEl.addEventListener("click", function(e) { - if (e.target.classList.contains("sch-timeline-seg")) return; - var rect = svgEl.getBoundingClientRect(); - var xPct = (e.clientX - rect.left) / rect.width; - var clickMin = Math.floor(xPct * 1440); - var startHour = Math.floor(clickMin / 60); - var startMin = startHour * 60; - var endMin = (startHour + 1) % 24 * 60; - schOpenEntryForm(null, null); - var startEl = document.getElementById("scheduler-ts-start"); - var endEl = document.getElementById("scheduler-ts-end"); - if (startEl) startEl.value = minToHHMM(startMin); - if (endEl) endEl.value = minToHHMM(endMin); - }); - svgEl.style.cursor = "crosshair"; - } - } - function timelineNeedleSvg() { - var info = schedulerUtcMinuteInfo(); - var nowMin = info.minuteOfDay + info.secondOfMinute / 60; - var x = nowMin / 1440 * 1e3; - return ''; - } - function renderTimelineNeedle() { - var g = document.getElementById("sch-timeline-needle-g"); - if (g) g.innerHTML = timelineNeedleSvg(); - } - function schInlineEdit(tr, entry, idx) { - var bmOptions = bookmarkList.map(function(bm) { - var sel = bm.id === entry.bookmark_id ? " selected" : ""; - return '"; - }).join(""); - var extraBmOptions = '' + bookmarkList.map(function(bm) { - return '"; - }).join(""); - var inlineExtraIds = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids.slice() : []; - tr.innerHTML = '⠇' + (entry.center_hz ? formatFreq(entry.center_hz) : "—") + '
'; - tr.classList.add("sch-inline-editing"); - var chipsContainer = tr.querySelector(".sch-inline-extra-chips"); - var extraPick = tr.querySelector(".sch-inline-extra-pick"); - var extraAddBtn = tr.querySelector(".sch-inline-extra-add"); - function renderInlineExtraChips() { - chipsContainer.innerHTML = ""; - inlineExtraIds.forEach(function(id, i) { - var chip = document.createElement("span"); - chip.className = "sch-extra-bm-chip"; - var rmBtn = document.createElement("span"); - rmBtn.className = "sch-extra-bm-chip-rm"; - rmBtn.textContent = "×"; - rmBtn.title = "Remove"; - rmBtn.addEventListener("click", function() { - inlineExtraIds.splice(i, 1); - renderInlineExtraChips(); - }); - chip.appendChild(rmBtn); - chip.appendChild(document.createTextNode(" " + bmName(id))); - chipsContainer.appendChild(chip); - }); - Array.from(extraPick.options).forEach(function(opt) { - if (opt.value) opt.disabled = inlineExtraIds.includes(opt.value); + function apiActivateSchedulerEntry(rigId, entryId) { + return fetch("/scheduler/" + encodeURIComponent(rigId) + "/activate", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entry_id: entryId }) + }).then(function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); }); } - renderInlineExtraChips(); - extraAddBtn.addEventListener("click", function() { - if (!extraPick.value) return; - if (!inlineExtraIds.includes(extraPick.value)) { - inlineExtraIds.push(extraPick.value); - renderInlineExtraChips(); - } - extraPick.value = ""; - }); - var exclEl = tr.querySelector('[data-field="exclusive"]'); - var ilInput = tr.querySelector('[data-field="interleave"]'); - if (exclEl && ilInput) { - exclEl.addEventListener("change", function() { - ilInput.disabled = exclEl.checked; - if (exclEl.checked) ilInput.value = ""; + function apiGetBookmarks() { + const url = currentRigId ? "/bookmarks?scope=" + encodeURIComponent(currentRigId) : "/bookmarks"; + return fetch(url).then(function(r) { + return r.ok ? r.json() : []; }); } - tr.querySelector(".sch-inline-save").addEventListener("click", function() { - var startEl = tr.querySelector('[data-field="start"]'); - var endEl = tr.querySelector('[data-field="end"]'); - var bmEl = tr.querySelector('[data-field="bookmark"]'); - var labelEl = tr.querySelector('[data-field="label"]'); - var ilEl = tr.querySelector('[data-field="interleave"]'); - var recEl = tr.querySelector('[data-field="record"]'); - var exEl = tr.querySelector('[data-field="exclusive"]'); - if (bmEl && !bmEl.value) { - window.trxUi?.notify("Select a bookmark before saving.", { kind: "error" }); - bmEl.focus(); - return; - } - entry.start_min = hhmmToMin(startEl.value); - entry.end_min = hhmmToMin(endEl.value); - entry.bookmark_id = bmEl.value; - entry.label = labelEl.value.trim() || null; - entry.exclusive = exEl ? exEl.checked : false; - var ilVal = parseInt(ilEl.value, 10); - entry.interleave_min = entry.exclusive ? null : !isNaN(ilVal) && ilVal > 0 ? ilVal : null; - entry.bookmark_ids = inlineExtraIds.slice(); - entry.record = recEl.checked; - currentConfig.entries[idx] = entry; - renderTimespanEntries(); - markSchedulerDirty(); - }); - tr.querySelector(".sch-inline-cancel").addEventListener("click", function() { - renderTimespanEntries(); - }); - } - function renderTimespanEntries() { - const tbody = document.getElementById("scheduler-ts-tbody"); - if (!tbody) return; - tbody.innerHTML = ""; - const entries = currentConfig && Array.isArray(currentConfig.entries) ? currentConfig.entries : []; - entries.forEach(function(entry, idx) { - const tr = document.createElement("tr"); - if (currentSchedulerStatus && currentSchedulerStatus.last_entry_id && entry.id && String(entry.id) === String(currentSchedulerStatus.last_entry_id)) { - tr.classList.add("sch-active"); - } - const il = entry.exclusive ? "Exclusive" : entry.interleave_min ? String(entry.interleave_min) + " min" : "—"; - const allDay = entry.start_min === entry.end_min; - const centerCell = entry.center_hz ? formatFreq(entry.center_hz) : "—"; - const extraIds = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids : []; - const extraCell = extraIds.length ? extraIds.map(function(id) { - return escHtml(bmName(id)); - }).join(", ") : "—"; - tr.innerHTML = '⠇' + (allDay ? "All day" : minToHHMM(entry.start_min) + ' (' + minToLocal(entry.start_min) + ")") + "" + (allDay ? "—" : minToHHMM(entry.end_min) + ' (' + minToLocal(entry.end_min) + ")") + "" + centerCell + "" + escHtml(bmName(entry.bookmark_id)) + "" + extraCell + "" + escHtml(entry.label || "") + "" + il + "" + (entry.record ? "Yes" : "") + ''; - tbody.appendChild(tr); - }); - tbody.querySelectorAll(".sch-edit-btn").forEach(function(btn) { - btn.addEventListener("click", function() { - const i = parseInt(btn.dataset.idx, 10); - const entry = currentConfig && currentConfig.entries ? currentConfig.entries[i] : null; - if (entry) schInlineEdit(btn.closest("tr"), entry, i); - }); - }); - tbody.querySelectorAll(".sch-remove-btn").forEach(function(btn) { - btn.addEventListener("click", function() { - removeEntry(parseInt(btn.dataset.idx, 10)); - }); - }); - (function() { - var handles = tbody.querySelectorAll(".sch-drag-handle"); - var dragIdx = null; - handles.forEach(function(handle, idx) { - var row = handle.parentElement; - handle.addEventListener("dragstart", function(e) { - dragIdx = idx; - row.classList.add("sch-dragging"); - e.dataTransfer.effectAllowed = "move"; - e.dataTransfer.setData("text/plain", String(idx)); - }); - row.addEventListener("dragover", function(e) { - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - row.classList.add("sch-drag-over"); - }); - row.addEventListener("dragleave", function() { - row.classList.remove("sch-drag-over"); - }); - row.addEventListener("drop", function(e) { - e.preventDefault(); - row.classList.remove("sch-drag-over"); - if (dragIdx === null || dragIdx === idx) return; - var entries2 = currentConfig.entries; - var moved = entries2.splice(dragIdx, 1)[0]; - entries2.splice(idx, 0, moved); - renderTimespanEntries(); - markSchedulerDirty(); - }); - handle.addEventListener("dragend", function() { - row.classList.remove("sch-dragging"); - dragIdx = null; - }); - }); - })(); - renderTimeline(); - } - function bmName(id) { - const bm = bookmarkList.find(function(b) { - return b.id === id; - }); - return bm ? bm.name : String(id || ""); - } - function minToLocal(min) { - var now = /* @__PURE__ */ new Date(); - var utcMidnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); - var utcMs = utcMidnight.getTime() + min * 6e4; - var local = new Date(utcMs); - return String(local.getHours()).padStart(2, "0") + ":" + String(local.getMinutes()).padStart(2, "0"); - } - function minToHHMM(min) { - const h = Math.floor(min / 60) % 24; - const m = min % 60; - return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"); - } - function hhmmToMin(str) { - const parts = str.split(":"); - return parseInt(parts[0] || "0", 10) * 60 + parseInt(parts[1] || "0", 10); - } - function gridToLatLon(grid) { - grid = String(grid).toUpperCase().trim(); - if (grid.length < 4) return null; - var lonField = grid.charCodeAt(0) - 65; - var latField = grid.charCodeAt(1) - 65; - var lonSquare = parseInt(grid.charAt(2), 10); - var latSquare = parseInt(grid.charAt(3), 10); - if (isNaN(lonSquare) || isNaN(latSquare) || lonField < 0 || lonField > 17 || latField < 0 || latField > 17) return null; - var lon = lonField * 20 + lonSquare * 2 - 180; - var lat = latField * 10 + latSquare * 1 - 90; - if (grid.length >= 6) { - var lonSub = grid.charCodeAt(4) - 65; - var latSub = grid.charCodeAt(5) - 65; - if (lonSub >= 0 && lonSub < 24 && latSub >= 0 && latSub < 24) { - lon += lonSub * (2 / 24) + 1 / 24; - lat += latSub * (1 / 24) + 0.5 / 24; - } - } else { - lon += 1; - lat += 0.5; - } - return { lat, lon }; - } - function latLonToGrid(lat, lon) { - lon = parseFloat(lon) + 180; - lat = parseFloat(lat) + 90; - if (isNaN(lon) || isNaN(lat)) return ""; - var lonField = String.fromCharCode(65 + Math.floor(lon / 20)); - var latField = String.fromCharCode(65 + Math.floor(lat / 10)); - var lonSquare = Math.floor(lon % 20 / 2); - var latSquare = Math.floor(lat % 10); - var lonSub = String.fromCharCode(97 + Math.floor(lon % 2 / 2 * 24)); - var latSub = String.fromCharCode(97 + Math.floor(lat % 1 * 24)); - return lonField + latField + lonSquare + latSquare + lonSub + latSub; - } - function escHtml(s) { - return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); - } - function schedulerSelectRelativeEntry(delta) { - const state = schedulerInterleaveState(currentConfig); - if (!currentRigId || schedulerStepPending || state.activeEntries.length <= 1) return; - const count = state.activeEntries.length; - const currentIndex = state.currentIndex >= 0 ? state.currentIndex : 0; - const targetIndex = (currentIndex + delta + count) % count; - const target = state.activeEntries[targetIndex]; - if (!target || !target.id) return; - schedulerStepPending = true; - renderSchedulerStepControls(); - Promise.resolve(typeof vchanTakeSchedulerControl === "function" ? vchanTakeSchedulerControl() : null).then(function() { - return apiActivateSchedulerEntry(currentRigId, target.id); - }).then(function(status) { - currentSchedulerStatus = status || null; - return Promise.resolve( - typeof vchanToggleSchedulerRelease === "function" ? vchanToggleSchedulerRelease() : null - ).then(function() { - renderStatus(status); - renderSchedulerInterleaveStatus(); - showSchedulerToast("Selected " + schedulerEntryDisplayName(target) + "."); - pollStatus(); - }); - }).catch(function(e) { - console.error("scheduler entry selection failed", e); - showSchedulerToast("Scheduler entry selection failed: " + e.message, true); - }).finally(function() { - schedulerStepPending = false; - renderSchedulerStepControls(); - }); - } - function removeEntry(idx) { - if (!currentConfig || !currentConfig.entries) return; - currentConfig.entries.splice(idx, 1); - renderTimespanEntries(); - markSchedulerDirty(); - } - function bookmarkExists(id) { - if (!id) return true; - return bookmarkList.some(function(bm) { - return bm.id === id; - }); - } - function saveScheduler() { - const rig = currentRigId; - if (!rig) return; - const modeEl = document.getElementById("scheduler-mode-select"); - const mode = modeEl ? modeEl.value : "disabled"; - const config = { - remote: rig, - mode, - grayline: null, - entries: [] - }; - if (mode === "grayline") { - const lat = parseFloat(document.getElementById("scheduler-gl-lat").value); - const lon = parseFloat(document.getElementById("scheduler-gl-lon").value); - const win = parseInt(document.getElementById("scheduler-gl-window").value, 10); - config.grayline = { - lat: isNaN(lat) ? 0 : lat, - lon: isNaN(lon) ? 0 : lon, - transition_window_min: isNaN(win) ? 20 : win, - dawn_bookmark_id: selectVal("scheduler-gl-dawn") || null, - day_bookmark_id: selectVal("scheduler-gl-day") || null, - dusk_bookmark_id: selectVal("scheduler-gl-dusk") || null, - night_bookmark_id: selectVal("scheduler-gl-night") || null - }; - } else if (mode === "time_span") { - config.entries = currentConfig && currentConfig.entries ? currentConfig.entries : []; - const ilVal = parseInt(document.getElementById("scheduler-ts-interleave").value, 10); - config.interleave_min = isNaN(ilVal) || ilVal <= 0 ? null : ilVal; - } - config.satellites = collectSatelliteConfig(); - var missingBmErrors = []; - if (mode === "grayline" && config.grayline) { - var gl = config.grayline; - var glFields = [ - ["dawn_bookmark_id", "Grayline dawn"], - ["day_bookmark_id", "Grayline day"], - ["dusk_bookmark_id", "Grayline dusk"], - ["night_bookmark_id", "Grayline night"] - ]; - glFields.forEach(function(pair) { - if (!bookmarkExists(gl[pair[0]])) missingBmErrors.push(pair[1] + " (bookmark " + gl[pair[0]] + ")"); - }); - } - if (mode === "time_span" && Array.isArray(config.entries)) { - config.entries.forEach(function(entry, idx) { - var label = entry.label || "Entry #" + (idx + 1); - if (!bookmarkExists(entry.bookmark_id)) { - missingBmErrors.push(label + " primary bookmark (" + entry.bookmark_id + ")"); - } - var extras = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids : []; - extras.forEach(function(id) { - if (!bookmarkExists(id)) { - missingBmErrors.push(label + " extra channel (" + id + ")"); - } - }); - }); - } - if (config.satellites && Array.isArray(config.satellites.entries)) { - config.satellites.entries.forEach(function(sat, idx) { - var satLabel = sat.name || "Satellite #" + (idx + 1); - if (!bookmarkExists(sat.bookmark_id)) { - missingBmErrors.push(satLabel + " bookmark (" + sat.bookmark_id + ")"); - } - }); - } - if (missingBmErrors.length > 0) { - showSchedulerToast("Missing bookmarks: " + missingBmErrors.join("; "), true); - return; - } - const btn = document.getElementById("scheduler-save-btn"); - if (btn) btn.disabled = true; - apiPutScheduler(rig, config).then(function(saved) { - currentConfig = saved; - renderScheduler(); - clearSchedulerDirty(); - showSchedulerToast("Scheduler saved."); - }).catch(function(e) { - showSchedulerToast("Save failed: " + e.message, true); - }).finally(function() { - if (btn) btn.disabled = false; - }); - } - function selectVal(id) { - const el = document.getElementById(id); - return el ? el.value : ""; - } - async function resetScheduler() { - const rig = currentRigId; - if (!rig) return; - if (!await window.trxUi.confirm({ title: "Reset scheduler?", message: "This rig's scheduler configuration will be reset to Disabled.", confirmLabel: "Reset" })) return; - apiDeleteScheduler(rig).then(function() { - currentConfig = { - remote: rig, - mode: "disabled", - grayline: null, - entries: [] - }; - renderScheduler(); - clearSchedulerDirty(); - showSchedulerToast("Scheduler reset."); - }).catch(function(e) { - showSchedulerToast("Reset failed: " + e.message, true); - }); - } - function markSchedulerDirty() { - if (schedulerDirty) return; - schedulerDirty = true; - var btn = document.getElementById("scheduler-save-btn"); - if (btn) btn.classList.add("sch-dirty"); - } - function clearSchedulerDirty() { - schedulerDirty = false; - var btn = document.getElementById("scheduler-save-btn"); - if (btn) btn.classList.remove("sch-dirty"); - } - function showSchedulerToast(msg, isError) { - const el = document.getElementById("scheduler-toast"); - if (!el) return; - el.textContent = msg; - el.style.background = isError ? "var(--color-error, #c00)" : "var(--accent-green)"; - el.style.display = "block"; - setTimeout(function() { - el.style.display = "none"; - }, 3e3); - } - function wireSchedulerEvents() { - const modeEl = document.getElementById("scheduler-mode-select"); - if (modeEl) { - modeEl.addEventListener("change", function() { - if (!currentConfig) currentConfig = { remote: currentRigId, mode: modeEl.value, entries: [] }; - currentConfig.mode = modeEl.value; + function loadScheduler() { + const rig = currentRigId; + if (!rig) return; + Promise.all([apiGetScheduler(rig), apiGetBookmarks()]).then(function([config, bms]) { + currentConfig = config; + bookmarkList = Array.isArray(bms) ? bms : []; + populateTsBookmarkSelect(); renderScheduler(); + clearSchedulerDirty(); + renderSchedulerInterleaveStatus(); + }).catch(function(error) { + console.error("scheduler load failed", error); + renderSchedulerInterleaveStatus(); }); } - const saveBtn = document.getElementById("scheduler-save-btn"); - if (saveBtn) saveBtn.addEventListener("click", saveScheduler); - const resetBtn = document.getElementById("scheduler-reset-btn"); - if (resetBtn) resetBtn.addEventListener("click", resetScheduler); - const addBtn = document.getElementById("scheduler-ts-add-btn"); - if (addBtn) addBtn.addEventListener("click", function() { - schOpenEntryForm(null, null); - }); - const entryForm = document.getElementById("sch-entry-form"); - if (entryForm) entryForm.addEventListener("submit", schEntryFormSubmit); - const cancelBtn = document.getElementById("sch-entry-form-cancel"); - if (cancelBtn) cancelBtn.addEventListener("click", schCloseEntryForm); - const prevBtn = document.getElementById("scheduler-prev-btn"); - if (prevBtn) prevBtn.addEventListener("click", function() { - schedulerSelectRelativeEntry(-1); - }); - const nextBtn = document.getElementById("scheduler-next-btn"); - if (nextBtn) nextBtn.addEventListener("click", function() { - schedulerSelectRelativeEntry(1); - }); - var schPanel = document.getElementById("scheduler-panel"); - if (schPanel && !schPanel._dirtyWired) { - schPanel._dirtyWired = true; - schPanel.addEventListener("input", function(e) { - if (e.target.closest("#sch-entry-form") || e.target.closest("#sch-sat-form")) return; - markSchedulerDirty(); - }); - schPanel.addEventListener("change", function(e) { - if (e.target.closest("#sch-entry-form") || e.target.closest("#sch-sat-form")) return; - markSchedulerDirty(); - }); + function startStatusPolling() { + if (statusInterval) clearInterval(statusInterval); + statusInterval = setInterval(pollStatus, 15e3); + pollStatus(); } - var gridEl = document.getElementById("scheduler-gl-grid"); - if (gridEl) { - gridEl.addEventListener("input", function() { - var ll = gridToLatLon(gridEl.value); - if (ll) { - setInputValue("scheduler-gl-lat", ll.lat.toFixed(3)); - setInputValue("scheduler-gl-lon", ll.lon.toFixed(3)); - markSchedulerDirty(); - } - }); + function startInterleaveTicker() { + if (interleaveTicker) clearInterval(interleaveTicker); + interleaveTicker = setInterval(renderSchedulerInterleaveStatus, 1e3); + renderSchedulerInterleaveStatus(); } - var latEl = document.getElementById("scheduler-gl-lat"); - var lonEl = document.getElementById("scheduler-gl-lon"); - [latEl, lonEl].forEach(function(el) { - if (el) { - el.addEventListener("input", function() { - var la = parseFloat(document.getElementById("scheduler-gl-lat").value); - var lo = parseFloat(document.getElementById("scheduler-gl-lon").value); - var gEl = document.getElementById("scheduler-gl-grid"); - if (gEl && !isNaN(la) && !isNaN(lo)) { - gEl.value = latLonToGrid(la, lo); - } - }); + function schedulerUtcSeconds() { + return Math.floor(Date.now() / 1e3); + } + function schedulerUtcMinuteInfo() { + const secs = schedulerUtcSeconds(); + const secsIntoDay = (secs % 86400 + 86400) % 86400; + return { + minuteOfDay: Math.floor(secsIntoDay / 60), + secondOfMinute: secsIntoDay % 60 + }; + } + function schedulerEntryIsActive(entry, nowMin) { + const start = Number(entry && entry.start_min); + const end = Number(entry && entry.end_min); + if (!Number.isFinite(start) || !Number.isFinite(end)) return false; + if (start === end) return true; + if (start < end) return nowMin >= start && nowMin < end; + return nowMin >= start || nowMin < end; + } + function schedulerEntryCurrentWindowStart(entry, nowMin) { + const start = Number(entry && entry.start_min); + const end = Number(entry && entry.end_min); + if (!Number.isFinite(start) || !Number.isFinite(end)) return Number.NEGATIVE_INFINITY; + if (start === end) return 0; + if (start < end) return start; + return nowMin >= start ? start : start - 1440; + } + function schedulerEntryDisplayName(entry) { + if (!entry) return "Scheduler entry"; + if (entry.label) return String(entry.label); + const bookmarkName = bmName(entry.bookmark_id); + return bookmarkName || "Scheduler entry"; + } + function schedulerInterleaveState(config) { + if (!config || config.mode !== "time_span") { + return { activeEntries: [], currentIndex: -1, remainingSec: 0, cycleMin: 0 }; } - }); - wireExtraBmAdd(); - wireSatelliteEvents(); - } - function populateTsBookmarkSelect() { - const sel = document.getElementById("scheduler-ts-bookmark"); - const extraSel = document.getElementById("scheduler-ts-extra-bm-pick"); - [sel, extraSel].forEach(function(el) { + const entries = Array.isArray(config.entries) ? config.entries : []; + const minuteInfo = schedulerUtcMinuteInfo(); + const nowMin = minuteInfo.minuteOfDay; + const active = entries.filter(function(entry) { + return schedulerEntryIsActive(entry, nowMin); + }); + if (active.length === 0) { + return { activeEntries: [], currentIndex: -1, remainingSec: 0, cycleMin: 0 }; + } + if (active.length === 1) { + return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + } + const exclIdx = active.findIndex(function(e) { + return e.exclusive; + }); + if (exclIdx >= 0) { + const exclusive = active[exclIdx]; + return { activeEntries: exclusive ? [exclusive] : [], currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + } + const defaultInterleave = Number(config.interleave_min); + const durations = active.map(function(entry) { + const own = Number(entry && entry.interleave_min); + if (Number.isFinite(own) && own > 0) return Math.floor(own); + if (Number.isFinite(defaultInterleave) && defaultInterleave > 0) return Math.floor(defaultInterleave); + return 0; + }); + const cycleMin = durations.reduce(function(sum, value) { + return sum + value; + }, 0); + if (!(cycleMin > 0)) { + return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + } + const statusEntryId = currentSchedulerStatus && currentSchedulerStatus.last_entry_id ? String(currentSchedulerStatus.last_entry_id) : ""; + const statusIndex = statusEntryId ? active.findIndex(function(entry) { + return String(entry && entry.id || "") === statusEntryId; + }) : -1; + const statusAppliedUtc = currentSchedulerStatus && Number.isFinite(Number(currentSchedulerStatus.last_applied_utc)) ? Number(currentSchedulerStatus.last_applied_utc) : null; + if (statusIndex >= 0 && statusAppliedUtc != null) { + const manualDurationMin = durations[statusIndex] ?? 0; + const elapsedSec = Math.max(0, schedulerUtcSeconds() - statusAppliedUtc); + const remainingSec2 = manualDurationMin > 0 ? Math.max(1, manualDurationMin * 60 - elapsedSec) : 0; + if (remainingSec2 > 0) { + return { + activeEntries: active, + currentIndex: statusIndex, + remainingSec: remainingSec2, + cycleMin + }; + } + } + const overlapStart = active.reduce(function(maxStart, entry) { + return Math.max(maxStart, schedulerEntryCurrentWindowStart(entry, nowMin)); + }, Number.NEGATIVE_INFINITY); + if (!Number.isFinite(overlapStart)) { + return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + } + const nowMinPrecise = minuteInfo.minuteOfDay + minuteInfo.secondOfMinute / 60; + const posMin = ((nowMinPrecise - overlapStart) % cycleMin + cycleMin) % cycleMin; + let cumulative = 0; + let slotStart = 0; + let currentIndex = 0; + let currentDuration = 0; + for (let i = 0; i < durations.length; i += 1) { + const duration = durations[i] ?? 0; + const nextCumulative = cumulative + duration; + if (posMin < nextCumulative) { + slotStart = cumulative; + cumulative = nextCumulative; + currentIndex = i; + currentDuration = duration; + break; + } + cumulative = nextCumulative; + } + if (!(currentDuration > 0)) { + return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + } + const elapsedSlotSec = Math.max(0, Math.floor((posMin - slotStart) * 60)); + const remainingSec = Math.max(1, currentDuration * 60 - elapsedSlotSec); + return { + activeEntries: active, + currentIndex, + remainingSec, + cycleMin + }; + } + function renderSchedulerInterleaveStatus() { + const wrap = schedulerEl("scheduler-cycle-status"); + if (!wrap) return; + const state = schedulerInterleaveState(currentConfig); + const isActive = state.activeEntries.length > 1 && state.cycleMin > 0; + wrap.style.display = isActive ? "" : "none"; + if (isActive) { + const activeName = schedulerEntryDisplayName(state.activeEntries[state.currentIndex]); + const totalSlotSec = state.cycleMin > 0 ? state.cycleMin * 60 / state.activeEntries.length : 0; + const elapsedPct = totalSlotSec > 0 ? Math.min(100, Math.max(0, (totalSlotSec - state.remainingSec) / totalSlotSec * 100)) : 0; + const ringFill = schedulerEl("interleave-ring-fill"); + if (ringFill) ringFill.setAttribute("stroke-dashoffset", String(100 - elapsedPct)); + const nameEl = schedulerEl("interleave-active-name"); + if (nameEl) nameEl.textContent = activeName; + const countdownEl = schedulerEl("interleave-countdown"); + if (countdownEl) countdownEl.textContent = "next in " + state.remainingSec + "s · " + state.cycleMin + "m cycle"; + } + renderTimelineNeedle(); + renderSchedulerStepControls(); + } + function renderSchedulerStepControls() { + const prevBtn = schedulerEl("scheduler-prev-btn"); + const nextBtn = schedulerEl("scheduler-next-btn"); + if (!prevBtn || !nextBtn) return; + const state = schedulerInterleaveState(currentConfig); + const enabled = schedulerRole === "control" && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1; + prevBtn.disabled = !enabled; + nextBtn.disabled = !enabled; + const hint = enabled ? "Select a different active scheduler entry" : "Available only when multiple scheduler entries are active"; + prevBtn.title = hint; + nextBtn.title = hint; + } + function pollStatus() { + const rig = currentRigId; + if (!rig) return; + apiGetStatus(rig).then(function(st) { + currentSchedulerStatus = st || null; + renderStatus(st); + renderSchedulerInterleaveStatus(); + renderActivityLog(); + renderSatPassStatus(); + }).catch(function() { + }); + } + function renderStatus(st) { + const el = schedulerEl("scheduler-status-card"); if (!el) return; - const prev = el.value; - el.innerHTML = ''; + if (!st || !st.active && !st.last_bookmark_id) { + el.textContent = "No activity yet."; + return; + } + const statusEntryId = st.last_entry_id ? String(st.last_entry_id) : ""; + const entry = statusEntryId && currentConfig && Array.isArray(currentConfig.entries) ? currentConfig.entries.find(function(item) { + return String(item && item.id || "") === statusEntryId; + }) : null; + const name = entry ? schedulerEntryDisplayName(entry) : st.last_bookmark_name || st.last_bookmark_id || "—"; + let ts = ""; + if (st.last_applied_utc) { + const d = new Date(st.last_applied_utc * 1e3); + ts = " at " + d.toUTCString(); + } + const satLabel = st.active_satellite ? " [SAT: " + st.active_satellite + "]" : ""; + let details = ""; + if (st.freq_hz) { + details += formatFreq(st.freq_hz); + if (st.mode) details += " · " + st.mode; + if (st.active_decoders && st.active_decoders.length > 0) { + details += " · " + st.active_decoders.join(", ") + " active"; + } + } + if (details) { + el.innerHTML = "Last applied: " + escHtml(name) + satLabel + ts + '
' + escHtml(details) + ""; + } else { + el.textContent = "Last applied: " + name + satLabel + ts; + } + } + function apiGetSchedulerLog(rigId) { + return fetch("/scheduler/" + encodeURIComponent(rigId) + "/log").then(function(r) { + return r.ok ? r.json() : []; + }); + } + function renderActivityLog() { + const wrap = schedulerEl("scheduler-activity-log-wrap"); + const container = schedulerEl("scheduler-activity-log"); + if (!wrap || !container || !currentRigId) return; + apiGetSchedulerLog(currentRigId).then(function(entries) { + if (!entries || entries.length === 0) { + wrap.style.display = "none"; + return; + } + wrap.style.display = ""; + const html = entries.slice().reverse().map(function(e) { + const d = new Date(e.utc * 1e3); + const ts = d.toUTCString(); + const action = e.action || "unknown"; + const label = e.entry_label || ""; + const bm = e.bookmark_name || ""; + return '
' + escHtml(ts) + ' ' + escHtml(action) + " " + (bm ? '' + escHtml(bm) + "" : "") + (label ? ' (' + escHtml(label) + ")" : "") + "
"; + }).join(""); + container.innerHTML = html; + }).catch(function() { + }); + } + function renderScheduler() { + const panel = schedulerEl("scheduler-panel"); + if (!panel) return; + const mode = currentConfig && currentConfig.mode || "disabled"; + const isControl = schedulerRole === "control"; + setSelected("scheduler-mode-select", mode); + const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled; + const controlRow = document.querySelector(".scheduler-control-row"); + if (controlRow) controlRow.style.display = mode !== "disabled" || satEnabled ? "" : "none"; + const glSection = schedulerEl("scheduler-grayline-section"); + const tsSection = schedulerEl("scheduler-timespan-section"); + if (glSection) glSection.style.display = mode === "grayline" ? "" : "none"; + if (tsSection) tsSection.style.display = mode === "time_span" ? "" : "none"; + renderSatelliteSection(); + if (mode === "grayline" && currentConfig && currentConfig.grayline) { + const gl = currentConfig.grayline; + const lat = gl.lat ?? schedulerWindow.serverLat ?? ""; + const lon = gl.lon ?? schedulerWindow.serverLon ?? ""; + setInputValue("scheduler-gl-lat", lat != null ? lat : ""); + setInputValue("scheduler-gl-lon", lon != null ? lon : ""); + const gridEl = schedulerEl("scheduler-gl-grid"); + if (gridEl) { + gridEl.value = latLonToGrid(lat, lon); + } + setInputValue("scheduler-gl-window", gl.transition_window_min != null ? gl.transition_window_min : 20); + renderBookmarkSelect("scheduler-gl-dawn", gl.dawn_bookmark_id); + renderBookmarkSelect("scheduler-gl-day", gl.day_bookmark_id); + renderBookmarkSelect("scheduler-gl-dusk", gl.dusk_bookmark_id); + renderBookmarkSelect("scheduler-gl-night", gl.night_bookmark_id); + } else if (mode === "grayline") { + const lat = schedulerWindow.serverLat ?? ""; + const lon = schedulerWindow.serverLon ?? ""; + setInputValue("scheduler-gl-lat", lat != null ? lat : ""); + setInputValue("scheduler-gl-lon", lon != null ? lon : ""); + const gridEl2 = schedulerEl("scheduler-gl-grid"); + if (gridEl2 && lat !== "" && lon !== "") { + gridEl2.value = latLonToGrid(lat, lon); + } + setInputValue("scheduler-gl-window", 20); + renderBookmarkSelect("scheduler-gl-dawn", null); + renderBookmarkSelect("scheduler-gl-day", null); + renderBookmarkSelect("scheduler-gl-dusk", null); + renderBookmarkSelect("scheduler-gl-night", null); + } else { + renderBookmarkSelect("scheduler-gl-dawn", null); + renderBookmarkSelect("scheduler-gl-day", null); + renderBookmarkSelect("scheduler-gl-dusk", null); + renderBookmarkSelect("scheduler-gl-night", null); + } + const ilEl = schedulerEl("scheduler-ts-interleave"); + if (ilEl) { + const il = currentConfig && currentConfig.interleave_min; + ilEl.value = il ? String(il) : ""; + } + renderTimespanEntries(); + const formEls = panel.querySelectorAll("input, select, button.sch-write"); + formEls.forEach(function(el) { + el.disabled = !isControl; + }); + const saveBtn = schedulerEl("scheduler-save-btn"); + if (saveBtn) { + saveBtn.style.display = isControl ? "" : "none"; + } + const resetBtn = schedulerEl("scheduler-reset-btn"); + if (resetBtn) { + resetBtn.style.display = isControl ? "" : "none"; + } + } + function setSelected(id, value) { + const el = schedulerEl(id); + if (el) el.value = value; + } + function setInputValue(id, value) { + const el = schedulerEl(id); + if (el) el.value = String(value); + } + function renderBookmarkSelect(id, selectedId) { + const sel = schedulerEl(id); + if (!sel) return; + sel.innerHTML = ''; bookmarkList.forEach(function(bm) { const opt = document.createElement("option"); opt.value = bm.id; opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")"; - el.appendChild(opt); + if (bm.id === selectedId) opt.selected = true; + sel.appendChild(opt); }); - if (prev) el.value = prev; - }); - } - let pendingExtraBmIds = []; - function renderExtraBmList() { - var container = document.getElementById("scheduler-ts-extra-bm-list"); - if (!container) return; - container.innerHTML = ""; - pendingExtraBmIds.forEach(function(id, idx) { - var bm = bookmarkList.find(function(b) { - return b.id === id; - }); - var chip = document.createElement("span"); - chip.className = "sch-extra-bm-chip"; - var rmBtn = document.createElement("span"); - rmBtn.className = "sch-extra-bm-chip-rm"; - rmBtn.textContent = "×"; - rmBtn.title = "Remove"; - rmBtn.addEventListener("click", function() { - pendingExtraBmIds.splice(idx, 1); - renderExtraBmList(); - }); - chip.appendChild(rmBtn); - var label = document.createTextNode(" " + (bm ? bm.name : id)); - chip.appendChild(label); - container.appendChild(chip); - }); - var pick = document.getElementById("scheduler-ts-extra-bm-pick"); - if (pick) { - Array.from(pick.options).forEach(function(opt) { - if (opt.value) { - opt.disabled = pendingExtraBmIds.includes(opt.value); + } + function formatFreq(hz) { + if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz"; + if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz"; + return hz + " Hz"; + } + function schOpenEntryForm(entry, idx) { + schEntryEditIdx = idx != null ? idx : null; + const titleEl = schedulerEl("sch-entry-form-title"); + if (titleEl) titleEl.textContent = entry ? "Edit Entry" : "Add Entry"; + const startEl = schedulerEl("scheduler-ts-start"); + const endEl = schedulerEl("scheduler-ts-end"); + const bmEl = schedulerEl("scheduler-ts-bookmark"); + const labelEl = schedulerEl("scheduler-ts-label"); + const ilEl = schedulerEl("scheduler-ts-entry-interleave"); + const centerHzEl = schedulerEl("scheduler-ts-center-hz"); + if (startEl) startEl.value = entry ? minToHHMM(entry.start_min) : ""; + if (endEl) endEl.value = entry ? minToHHMM(entry.end_min) : ""; + if (bmEl) bmEl.value = entry ? entry.bookmark_id || "" : ""; + if (labelEl) labelEl.value = entry ? entry.label || "" : ""; + if (ilEl) ilEl.value = entry?.interleave_min ? String(entry.interleave_min) : ""; + if (centerHzEl) centerHzEl.value = entry?.center_hz ? String(entry.center_hz) : ""; + const recordEl = schedulerEl("scheduler-ts-entry-record"); + if (recordEl) recordEl.checked = !!(entry && entry.record); + pendingExtraBmIds = entry && Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids.slice() : []; + renderExtraBmList(); + const wrap = schedulerEl("sch-entry-form-wrap"); + if (wrap) { + wrap.style.display = "block"; + if (startEl) startEl.focus(); + } + } + function schCloseEntryForm() { + const wrap = schedulerEl("sch-entry-form-wrap"); + if (wrap) wrap.style.display = "none"; + schEntryEditIdx = null; + pendingExtraBmIds = []; + } + function schEntryFormSubmit(e) { + e.preventDefault(); + const startEl = schedulerEl("scheduler-ts-start"); + const endEl = schedulerEl("scheduler-ts-end"); + const bmEl = schedulerEl("scheduler-ts-bookmark"); + const labelEl = schedulerEl("scheduler-ts-label"); + const ilEl = schedulerEl("scheduler-ts-entry-interleave"); + const centerHzEl = schedulerEl("scheduler-ts-center-hz"); + if (!startEl || !endEl || !bmEl) return; + const bmId = bmEl.value; + if (!bmId) { + schedulerWindow.trxUi.notify?.("Select a primary bookmark before saving.", { kind: "error" }); + return; + } + const startMin = hhmmToMin(startEl.value); + const endMin = hhmmToMin(endEl.value); + const label = labelEl ? labelEl.value.trim() : ""; + const ilVal = ilEl ? parseInt(ilEl.value, 10) : NaN; + const entryInterleave = !isNaN(ilVal) && ilVal > 0 ? ilVal : null; + const centerHzRaw = centerHzEl ? parseInt(centerHzEl.value, 10) : NaN; + const centerHz = !isNaN(centerHzRaw) && centerHzRaw > 0 ? centerHzRaw : null; + const extraBmIds = pendingExtraBmIds.slice(); + currentConfig ??= { remote: currentRigId, mode: "time_span", grayline: null, entries: [] }; + const config = currentConfig; + const recordCb = schedulerEl("scheduler-ts-entry-record"); + const entryRecord = recordCb ? recordCb.checked : false; + const entryData = { + id: "ts_" + Date.now().toString(36), + start_min: startMin, + end_min: endMin, + bookmark_id: bmId, + label: label || null, + interleave_min: entryInterleave, + center_hz: centerHz, + bookmark_ids: extraBmIds, + record: entryRecord, + exclusive: false + }; + if (schEntryEditIdx !== null) { + const existing = config.entries[schEntryEditIdx]; + if (existing?.id) entryData.id = existing.id; + config.entries[schEntryEditIdx] = entryData; + } else { + config.entries.push(entryData); + } + schCloseEntryForm(); + renderTimespanEntries(); + markSchedulerDirty(); + } + const TIMELINE_COLORS = ["#38bdf8", "#f59e0b", "#a78bfa", "#34d399", "#fb7185", "#60a5fa"]; + function renderTimeline() { + const container = schedulerEl("scheduler-ts-timeline"); + if (!container) return; + const entries = currentConfig && Array.isArray(currentConfig.entries) ? currentConfig.entries : []; + if (entries.length === 0) { + container.innerHTML = ""; + return; + } + const W = 1e3; + const H = 80; + const BAR_Y = 6; + const BAR_H = 30; + const TICK_Y = BAR_Y + BAR_H + 2; + let svg = ''; + svg += ''; + entries.forEach(function(entry, idx) { + const start = Number(entry.start_min); + const end = Number(entry.end_min); + if (!Number.isFinite(start) || !Number.isFinite(end)) return; + const color = TIMELINE_COLORS[idx % TIMELINE_COLORS.length]; + if (start === end) { + svg += ''; + } else if (start < end) { + const x = start / 1440 * W; + const w = (end - start) / 1440 * W; + svg += ''; + } else { + const x1 = start / 1440 * W; + const w1 = W - x1; + svg += ''; + const w2 = end / 1440 * W; + svg += ''; } }); - } - } - function wireExtraBmAdd() { - const addBtn = document.getElementById("scheduler-ts-extra-bm-add"); - if (!addBtn || addBtn._wired) return; - addBtn._wired = true; - addBtn.addEventListener("click", function() { - const pick = document.getElementById("scheduler-ts-extra-bm-pick"); - if (!pick || !pick.value) return; - if (!pendingExtraBmIds.includes(pick.value)) { - pendingExtraBmIds.push(pick.value); - renderExtraBmList(); + const interleaveMin = currentConfig && currentConfig.interleave_min ? Number(currentConfig.interleave_min) : 0; + if (interleaveMin > 0 && entries.length > 1) { + for (let m = 0; m < 1440; m += interleaveMin) { + const overlapping = []; + entries.forEach(function(entry, idx) { + if (schedulerEntryIsActive(entry, m)) { + overlapping.push(idx); + } + }); + if (overlapping.length > 1) { + const stripeX = m / 1440 * W; + const stripeW = Math.max(1, interleaveMin / 1440 * W); + const cyclePos = m % (interleaveMin * overlapping.length); + const ownerSlot = Math.floor(cyclePos / interleaveMin); + const ownerIdx = overlapping[ownerSlot % overlapping.length] ?? 0; + const stripeColor = TIMELINE_COLORS[ownerIdx % TIMELINE_COLORS.length]; + svg += ''; + } + } } - pick.value = ""; - }); - } - function renderSatelliteSection() { - if (window.satScheduler) window.satScheduler.renderSection(); - } - function renderSatPassStatus() { - if (window.satScheduler) window.satScheduler.renderPassStatus(); - } - function collectSatelliteConfig() { - return window.satScheduler ? window.satScheduler.collectSatelliteConfig() : { enabled: false, pretune_secs: 60, entries: [] }; - } - function wireSatelliteEvents() { - window.schedulerBridge = { - getConfig: function() { - return currentConfig; - }, - getStatus: function() { - return currentSchedulerStatus; - }, - getBookmarks: function() { - return bookmarkList; - }, - markDirty: function() { + for (let h = 0; h <= 24; h += 3) { + const tx = h / 24 * W; + svg += ''; + if (h < 24) { + svg += '' + String(h).padStart(2, "0") + ""; + } + } + const LOCAL_TICK_Y = TICK_Y + 18; + for (let h = 0; h < 24; h += 3) { + const localMin = h * 60; + const utcOffset = (/* @__PURE__ */ new Date()).getTimezoneOffset(); + const utcMin = (localMin + utcOffset + 1440) % 1440; + const tx = utcMin / 1440 * W; + svg += '' + String(h).padStart(2, "0") + "L"; + } + svg += '' + timelineNeedleSvg() + ""; + svg += ""; + container.innerHTML = svg; + container.querySelectorAll(".sch-timeline-seg").forEach(function(seg) { + seg.addEventListener("click", function() { + const i = parseInt(seg.getAttribute("data-idx") ?? "", 10); + const entry = currentConfig && currentConfig.entries ? currentConfig.entries[i] : null; + if (entry) schOpenEntryForm(entry, i); + }); + }); + const svgEl = container.querySelector("svg"); + if (svgEl) { + const timelineSvg = svgEl; + svgEl.addEventListener("click", function(e) { + if (e.target instanceof Element && e.target.classList.contains("sch-timeline-seg")) return; + const rect = timelineSvg.getBoundingClientRect(); + const xPct = (e.clientX - rect.left) / rect.width; + const clickMin = Math.floor(xPct * 1440); + const startHour = Math.floor(clickMin / 60); + const startMin = startHour * 60; + const endMin = (startHour + 1) % 24 * 60; + schOpenEntryForm(null); + const startEl = schedulerEl("scheduler-ts-start"); + const endEl = schedulerEl("scheduler-ts-end"); + if (startEl) startEl.value = minToHHMM(startMin); + if (endEl) endEl.value = minToHHMM(endMin); + }); + svgEl.style.cursor = "crosshair"; + } + } + function timelineNeedleSvg() { + const info = schedulerUtcMinuteInfo(); + const nowMin = info.minuteOfDay + info.secondOfMinute / 60; + const x = nowMin / 1440 * 1e3; + return ''; + } + function renderTimelineNeedle() { + const g = schedulerEl("sch-timeline-needle-g"); + if (g) g.innerHTML = timelineNeedleSvg(); + } + function schInlineEdit(tr, entry, idx) { + const bmOptions = bookmarkList.map(function(bm) { + const sel = bm.id === entry.bookmark_id ? " selected" : ""; + return '"; + }).join(""); + const extraBmOptions = '' + bookmarkList.map(function(bm) { + return '"; + }).join(""); + const inlineExtraIds = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids.slice() : []; + tr.innerHTML = '⠇' + (entry.center_hz ? formatFreq(entry.center_hz) : "—") + '
'; + tr.classList.add("sch-inline-editing"); + const chipsContainer = tr.querySelector(".sch-inline-extra-chips"); + const extraPick = tr.querySelector(".sch-inline-extra-pick"); + const extraAddBtn = tr.querySelector(".sch-inline-extra-add"); + if (!chipsContainer || !extraPick || !extraAddBtn) return; + const chips = chipsContainer; + const picker = extraPick; + function renderInlineExtraChips() { + chips.innerHTML = ""; + inlineExtraIds.forEach(function(id, i) { + const chip = document.createElement("span"); + chip.className = "sch-extra-bm-chip"; + const rmBtn = document.createElement("span"); + rmBtn.className = "sch-extra-bm-chip-rm"; + rmBtn.textContent = "×"; + rmBtn.title = "Remove"; + rmBtn.addEventListener("click", function() { + inlineExtraIds.splice(i, 1); + renderInlineExtraChips(); + }); + chip.appendChild(rmBtn); + chip.appendChild(document.createTextNode(" " + bmName(id))); + chips.appendChild(chip); + }); + Array.from(picker.options).forEach(function(opt) { + if (opt.value) opt.disabled = inlineExtraIds.includes(opt.value); + }); + } + renderInlineExtraChips(); + extraAddBtn.addEventListener("click", function() { + if (!picker.value) return; + if (!inlineExtraIds.includes(picker.value)) { + inlineExtraIds.push(picker.value); + renderInlineExtraChips(); + } + picker.value = ""; + }); + const exclEl = tr.querySelector('[data-field="exclusive"]'); + const ilInput = tr.querySelector('[data-field="interleave"]'); + if (exclEl && ilInput) { + const exclusiveInput = exclEl; + const interleaveInput = ilInput; + exclEl.addEventListener("change", function() { + interleaveInput.disabled = exclusiveInput.checked; + if (exclusiveInput.checked) interleaveInput.value = ""; + }); + } + tr.querySelector(".sch-inline-save")?.addEventListener("click", function() { + const startEl = tr.querySelector('[data-field="start"]'); + const endEl = tr.querySelector('[data-field="end"]'); + const bmEl = tr.querySelector('[data-field="bookmark"]'); + const labelEl = tr.querySelector('[data-field="label"]'); + const ilEl = tr.querySelector('[data-field="interleave"]'); + const recEl = tr.querySelector('[data-field="record"]'); + const exEl = tr.querySelector('[data-field="exclusive"]'); + if (!startEl || !endEl || !bmEl || !labelEl || !ilEl || !recEl) return; + if (bmEl && !bmEl.value) { + schedulerWindow.trxUi.notify?.("Select a bookmark before saving.", { kind: "error" }); + bmEl.focus(); + return; + } + entry.start_min = hhmmToMin(startEl.value); + entry.end_min = hhmmToMin(endEl.value); + entry.bookmark_id = bmEl.value; + entry.label = labelEl.value.trim() || null; + entry.exclusive = exEl ? exEl.checked : false; + const ilVal = parseInt(ilEl.value, 10); + entry.interleave_min = entry.exclusive ? null : !isNaN(ilVal) && ilVal > 0 ? ilVal : null; + entry.bookmark_ids = inlineExtraIds.slice(); + entry.record = recEl.checked; + if (currentConfig) currentConfig.entries[idx] = entry; + renderTimespanEntries(); markSchedulerDirty(); - } - }; - if (window.satScheduler) window.satScheduler.wireEvents(); - } - function isInputFocused() { - var el = document.activeElement; - if (!el) return false; - var tag = el.tagName; - return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable; - } - document.addEventListener("keydown", function(e) { - if (isInputFocused()) return; - if (e.shiftKey && e.key === "R") { - e.preventDefault(); - var releaseBtn = document.getElementById("scheduler-release-btn"); - if (releaseBtn && !releaseBtn.disabled) releaseBtn.click(); - } else if (e.shiftKey && e.key === "N") { - e.preventDefault(); - schedulerSelectRelativeEntry(1); - } else if (e.shiftKey && e.key === "P") { - e.preventDefault(); - schedulerSelectRelativeEntry(-1); + }); + tr.querySelector(".sch-inline-cancel")?.addEventListener("click", function() { + renderTimespanEntries(); + }); } - }); - (function() { - var details = document.querySelector(".sch-ts-details"); - if (!details) return; - var key = "sch-details-open"; - var saved = localStorage.getItem(key); - if (saved !== null) details.open = saved === "1"; - details.addEventListener("toggle", function() { - localStorage.setItem(key, details.open ? "1" : "0"); + function renderTimespanEntries() { + const tbody = schedulerEl("scheduler-ts-tbody"); + if (!tbody) return; + tbody.innerHTML = ""; + const entries = currentConfig && Array.isArray(currentConfig.entries) ? currentConfig.entries : []; + entries.forEach(function(entry, idx) { + const tr = document.createElement("tr"); + if (currentSchedulerStatus && currentSchedulerStatus.last_entry_id && entry.id && String(entry.id) === String(currentSchedulerStatus.last_entry_id)) { + tr.classList.add("sch-active"); + } + const il = entry.exclusive ? "Exclusive" : entry.interleave_min ? String(entry.interleave_min) + " min" : "—"; + const allDay = entry.start_min === entry.end_min; + const centerCell = entry.center_hz ? formatFreq(entry.center_hz) : "—"; + const extraIds = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids : []; + const extraCell = extraIds.length ? extraIds.map(function(id) { + return escHtml(bmName(id)); + }).join(", ") : "—"; + tr.innerHTML = '⠇' + (allDay ? "All day" : minToHHMM(entry.start_min) + ' (' + minToLocal(entry.start_min) + ")") + "" + (allDay ? "—" : minToHHMM(entry.end_min) + ' (' + minToLocal(entry.end_min) + ")") + "" + centerCell + "" + escHtml(bmName(entry.bookmark_id)) + "" + extraCell + "" + escHtml(entry.label || "") + "" + il + "" + (entry.record ? "Yes" : "") + ''; + tbody.appendChild(tr); + }); + tbody.querySelectorAll(".sch-edit-btn").forEach(function(btn) { + btn.addEventListener("click", function() { + const i = parseInt(btn.dataset.idx ?? "", 10); + const entry = currentConfig && currentConfig.entries ? currentConfig.entries[i] : null; + const row = btn.closest("tr"); + if (entry && row) schInlineEdit(row, entry, i); + }); + }); + tbody.querySelectorAll(".sch-remove-btn").forEach(function(btn) { + btn.addEventListener("click", function() { + removeEntry(parseInt(btn.dataset.idx ?? "", 10)); + }); + }); + (function() { + const handles = tbody.querySelectorAll(".sch-drag-handle"); + let dragIdx = null; + handles.forEach(function(handle, idx) { + const row = handle.parentElement; + if (!row) return; + const dragRow = row; + handle.addEventListener("dragstart", function(event) { + const e = event; + dragIdx = idx; + dragRow.classList.add("sch-dragging"); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(idx)); + } + }); + dragRow.addEventListener("dragover", function(event) { + const e = event; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; + dragRow.classList.add("sch-drag-over"); + }); + dragRow.addEventListener("dragleave", function() { + dragRow.classList.remove("sch-drag-over"); + }); + dragRow.addEventListener("drop", function(event) { + const e = event; + e.preventDefault(); + dragRow.classList.remove("sch-drag-over"); + if (dragIdx === null || dragIdx === idx) return; + if (!currentConfig) return; + const entries2 = currentConfig.entries; + const moved = entries2.splice(dragIdx, 1)[0]; + if (moved) entries2.splice(idx, 0, moved); + renderTimespanEntries(); + markSchedulerDirty(); + }); + handle.addEventListener("dragend", function() { + dragRow.classList.remove("sch-dragging"); + dragIdx = null; + }); + }); + })(); + renderTimeline(); + } + function bmName(id) { + const bm = bookmarkList.find(function(b) { + return b.id === id; + }); + return bm ? bm.name : String(id || ""); + } + function minToLocal(min) { + const now = /* @__PURE__ */ new Date(); + const utcMidnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const utcMs = utcMidnight.getTime() + min * 6e4; + const local = new Date(utcMs); + return String(local.getHours()).padStart(2, "0") + ":" + String(local.getMinutes()).padStart(2, "0"); + } + function minToHHMM(min) { + const h = Math.floor(min / 60) % 24; + const m = min % 60; + return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"); + } + function hhmmToMin(str) { + const parts = str.split(":"); + return parseInt(parts[0] || "0", 10) * 60 + parseInt(parts[1] || "0", 10); + } + function gridToLatLon(grid) { + grid = String(grid).toUpperCase().trim(); + if (grid.length < 4) return null; + const lonField = grid.charCodeAt(0) - 65; + const latField = grid.charCodeAt(1) - 65; + const lonSquare = parseInt(grid.charAt(2), 10); + const latSquare = parseInt(grid.charAt(3), 10); + if (isNaN(lonSquare) || isNaN(latSquare) || lonField < 0 || lonField > 17 || latField < 0 || latField > 17) return null; + let lon = lonField * 20 + lonSquare * 2 - 180; + let lat = latField * 10 + latSquare * 1 - 90; + if (grid.length >= 6) { + const lonSub = grid.charCodeAt(4) - 65; + const latSub = grid.charCodeAt(5) - 65; + if (lonSub >= 0 && lonSub < 24 && latSub >= 0 && latSub < 24) { + lon += lonSub * (2 / 24) + 1 / 24; + lat += latSub * (1 / 24) + 0.5 / 24; + } + } else { + lon += 1; + lat += 0.5; + } + return { lat, lon }; + } + function latLonToGrid(lat, lon) { + lon += 180; + lat += 90; + if (isNaN(lon) || isNaN(lat)) return ""; + const lonField = String.fromCharCode(65 + Math.floor(lon / 20)); + const latField = String.fromCharCode(65 + Math.floor(lat / 10)); + const lonSquare = Math.floor(lon % 20 / 2); + const latSquare = Math.floor(lat % 10); + const lonSub = String.fromCharCode(97 + Math.floor(lon % 2 / 2 * 24)); + const latSub = String.fromCharCode(97 + Math.floor(lat % 1 * 24)); + return lonField + latField + lonSquare + latSquare + lonSub + latSub; + } + function escHtml(s) { + return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + function schedulerSelectRelativeEntry(delta) { + const state = schedulerInterleaveState(currentConfig); + const rigId = currentRigId; + if (!rigId || schedulerStepPending || state.activeEntries.length <= 1) return; + const count = state.activeEntries.length; + const currentIndex = state.currentIndex >= 0 ? state.currentIndex : 0; + const targetIndex = (currentIndex + delta + count) % count; + const target = state.activeEntries[targetIndex]; + if (!target || !target.id) return; + const targetId = target.id; + schedulerStepPending = true; + renderSchedulerStepControls(); + Promise.resolve(schedulerWindow.vchanTakeSchedulerControl?.() ?? null).then(function() { + return apiActivateSchedulerEntry(rigId, targetId); + }).then(function(status) { + currentSchedulerStatus = status || null; + return Promise.resolve( + schedulerWindow.vchanToggleSchedulerRelease?.() ?? null + ).then(function() { + renderStatus(status); + renderSchedulerInterleaveStatus(); + showSchedulerToast("Selected " + schedulerEntryDisplayName(target) + "."); + pollStatus(); + }); + }).catch(function(error) { + console.error("scheduler entry selection failed", error); + showSchedulerToast("Scheduler entry selection failed: " + (error instanceof Error ? error.message : String(error)), true); + }).finally(function() { + schedulerStepPending = false; + renderSchedulerStepControls(); + }); + } + function removeEntry(idx) { + if (!currentConfig || !currentConfig.entries) return; + currentConfig.entries.splice(idx, 1); + renderTimespanEntries(); + markSchedulerDirty(); + } + function bookmarkExists(id) { + if (!id) return true; + return bookmarkList.some(function(bm) { + return bm.id === id; + }); + } + function saveScheduler() { + const rig = currentRigId; + if (!rig) return; + const modeEl = schedulerEl("scheduler-mode-select"); + const rawMode = modeEl ? modeEl.value : "disabled"; + const mode = rawMode === "grayline" || rawMode === "time_span" ? rawMode : "disabled"; + const config = { + remote: rig, + mode, + grayline: null, + entries: [] + }; + if (mode === "grayline") { + const lat = parseFloat(schedulerEl("scheduler-gl-lat").value); + const lon = parseFloat(schedulerEl("scheduler-gl-lon").value); + const win = parseInt(schedulerEl("scheduler-gl-window").value, 10); + config.grayline = { + lat: isNaN(lat) ? 0 : lat, + lon: isNaN(lon) ? 0 : lon, + transition_window_min: isNaN(win) ? 20 : win, + dawn_bookmark_id: selectVal("scheduler-gl-dawn") || null, + day_bookmark_id: selectVal("scheduler-gl-day") || null, + dusk_bookmark_id: selectVal("scheduler-gl-dusk") || null, + night_bookmark_id: selectVal("scheduler-gl-night") || null + }; + } else if (mode === "time_span") { + config.entries = currentConfig && currentConfig.entries ? currentConfig.entries : []; + const ilVal = parseInt(schedulerEl("scheduler-ts-interleave").value, 10); + config.interleave_min = isNaN(ilVal) || ilVal <= 0 ? null : ilVal; + } + config.satellites = collectSatelliteConfig(); + const missingBmErrors = []; + if (mode === "grayline" && config.grayline) { + const gl = config.grayline; + const glFields = [ + ["dawn_bookmark_id", "Grayline dawn"], + ["day_bookmark_id", "Grayline day"], + ["dusk_bookmark_id", "Grayline dusk"], + ["night_bookmark_id", "Grayline night"] + ]; + glFields.forEach(function(pair) { + const bookmarkId = gl[pair[0]]; + if (typeof bookmarkId === "string" && !bookmarkExists(bookmarkId)) missingBmErrors.push(pair[1] + " (bookmark " + bookmarkId + ")"); + }); + } + if (mode === "time_span" && Array.isArray(config.entries)) { + config.entries.forEach(function(entry, idx) { + const label = entry.label || "Entry #" + (idx + 1); + if (!bookmarkExists(entry.bookmark_id)) { + missingBmErrors.push(label + " primary bookmark (" + entry.bookmark_id + ")"); + } + const extras = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids : []; + extras.forEach(function(id) { + if (!bookmarkExists(id)) { + missingBmErrors.push(label + " extra channel (" + id + ")"); + } + }); + }); + } + if (config.satellites && Array.isArray(config.satellites.entries)) { + config.satellites.entries.forEach(function(sat, idx) { + const satLabel = sat.satellite || "Satellite #" + (idx + 1); + if (!bookmarkExists(sat.bookmark_id)) { + missingBmErrors.push(satLabel + " bookmark (" + sat.bookmark_id + ")"); + } + }); + } + if (missingBmErrors.length > 0) { + showSchedulerToast("Missing bookmarks: " + missingBmErrors.join("; "), true); + return; + } + const btn = schedulerEl("scheduler-save-btn"); + if (btn) btn.disabled = true; + apiPutScheduler(rig, config).then(function(saved) { + currentConfig = saved; + renderScheduler(); + clearSchedulerDirty(); + showSchedulerToast("Scheduler saved."); + }).catch(function(error) { + showSchedulerToast("Save failed: " + (error instanceof Error ? error.message : String(error)), true); + }).finally(function() { + if (btn) btn.disabled = false; + }); + } + function selectVal(id) { + const el = schedulerEl(id); + return el ? el.value : ""; + } + async function resetScheduler() { + const rig = currentRigId; + if (!rig) return; + if (!await schedulerWindow.trxUi.confirm({ title: "Reset scheduler?", message: "This rig's scheduler configuration will be reset to Disabled.", confirmLabel: "Reset" })) return; + apiDeleteScheduler(rig).then(function() { + currentConfig = { + remote: rig, + mode: "disabled", + grayline: null, + entries: [] + }; + renderScheduler(); + clearSchedulerDirty(); + showSchedulerToast("Scheduler reset."); + }).catch(function(error) { + showSchedulerToast("Reset failed: " + (error instanceof Error ? error.message : String(error)), true); + }); + } + function markSchedulerDirty() { + if (schedulerDirty) return; + schedulerDirty = true; + const btn = schedulerEl("scheduler-save-btn"); + if (btn) btn.classList.add("sch-dirty"); + } + function clearSchedulerDirty() { + schedulerDirty = false; + const btn = schedulerEl("scheduler-save-btn"); + if (btn) btn.classList.remove("sch-dirty"); + } + function showSchedulerToast(msg, isError = false) { + const el = schedulerEl("scheduler-toast"); + if (!el) return; + el.textContent = msg; + el.style.background = isError ? "var(--color-error, #c00)" : "var(--accent-green)"; + el.style.display = "block"; + setTimeout(function() { + el.style.display = "none"; + }, 3e3); + } + function wireSchedulerEvents() { + const modeEl = schedulerEl("scheduler-mode-select"); + if (modeEl) { + modeEl.addEventListener("change", function() { + const mode = modeEl.value === "grayline" || modeEl.value === "time_span" ? modeEl.value : "disabled"; + currentConfig ??= { remote: currentRigId, mode, grayline: null, entries: [] }; + currentConfig.mode = mode; + renderScheduler(); + }); + } + const saveBtn = schedulerEl("scheduler-save-btn"); + if (saveBtn) saveBtn.addEventListener("click", saveScheduler); + const resetBtn = schedulerEl("scheduler-reset-btn"); + if (resetBtn) resetBtn.addEventListener("click", () => { + void resetScheduler(); + }); + const addBtn = schedulerEl("scheduler-ts-add-btn"); + if (addBtn) addBtn.addEventListener("click", function() { + schOpenEntryForm(null); + }); + const entryForm = schedulerEl("sch-entry-form"); + if (entryForm) entryForm.addEventListener("submit", schEntryFormSubmit); + const cancelBtn = schedulerEl("sch-entry-form-cancel"); + if (cancelBtn) cancelBtn.addEventListener("click", schCloseEntryForm); + const prevBtn = schedulerEl("scheduler-prev-btn"); + if (prevBtn) prevBtn.addEventListener("click", function() { + schedulerSelectRelativeEntry(-1); + }); + const nextBtn = schedulerEl("scheduler-next-btn"); + if (nextBtn) nextBtn.addEventListener("click", function() { + schedulerSelectRelativeEntry(1); + }); + const schPanel = schedulerEl("scheduler-panel"); + if (schPanel && !wiredElements.has(schPanel)) { + wiredElements.add(schPanel); + schPanel.addEventListener("input", function(e) { + if (!(e.target instanceof Element) || e.target.closest("#sch-entry-form") || e.target.closest("#sch-sat-form")) return; + markSchedulerDirty(); + }); + schPanel.addEventListener("change", function(e) { + if (!(e.target instanceof Element) || e.target.closest("#sch-entry-form") || e.target.closest("#sch-sat-form")) return; + markSchedulerDirty(); + }); + } + const gridEl = schedulerEl("scheduler-gl-grid"); + if (gridEl) { + gridEl.addEventListener("input", function() { + const ll = gridToLatLon(gridEl.value); + if (ll) { + setInputValue("scheduler-gl-lat", ll.lat.toFixed(3)); + setInputValue("scheduler-gl-lon", ll.lon.toFixed(3)); + markSchedulerDirty(); + } + }); + } + const latEl = schedulerEl("scheduler-gl-lat"); + const lonEl = schedulerEl("scheduler-gl-lon"); + [latEl, lonEl].forEach(function(el) { + if (el) { + el.addEventListener("input", function() { + const la = parseFloat(schedulerEl("scheduler-gl-lat").value); + const lo = parseFloat(schedulerEl("scheduler-gl-lon").value); + const gEl = schedulerEl("scheduler-gl-grid"); + if (gEl && !isNaN(la) && !isNaN(lo)) { + gEl.value = latLonToGrid(la, lo); + } + }); + } + }); + wireExtraBmAdd(); + wireSatelliteEvents(); + } + function populateTsBookmarkSelect() { + const sel = schedulerEl("scheduler-ts-bookmark"); + const extraSel = schedulerEl("scheduler-ts-extra-bm-pick"); + [sel, extraSel].forEach(function(el) { + if (!el) return; + const prev = el.value; + el.innerHTML = ''; + bookmarkList.forEach(function(bm) { + const opt = document.createElement("option"); + opt.value = bm.id; + opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")"; + el.appendChild(opt); + }); + if (prev) el.value = prev; + }); + } + let pendingExtraBmIds = []; + function renderExtraBmList() { + const container = schedulerEl("scheduler-ts-extra-bm-list"); + if (!container) return; + container.innerHTML = ""; + pendingExtraBmIds.forEach(function(id, idx) { + const bm = bookmarkList.find(function(b) { + return b.id === id; + }); + const chip = document.createElement("span"); + chip.className = "sch-extra-bm-chip"; + const rmBtn = document.createElement("span"); + rmBtn.className = "sch-extra-bm-chip-rm"; + rmBtn.textContent = "×"; + rmBtn.title = "Remove"; + rmBtn.addEventListener("click", function() { + pendingExtraBmIds.splice(idx, 1); + renderExtraBmList(); + }); + chip.appendChild(rmBtn); + const label = document.createTextNode(" " + (bm ? bm.name : id)); + chip.appendChild(label); + container.appendChild(chip); + }); + const pick = schedulerEl("scheduler-ts-extra-bm-pick"); + if (pick) { + Array.from(pick.options).forEach(function(opt) { + if (opt.value) { + opt.disabled = pendingExtraBmIds.includes(opt.value); + } + }); + } + } + function wireExtraBmAdd() { + const addBtn = schedulerEl("scheduler-ts-extra-bm-add"); + if (!addBtn || wiredElements.has(addBtn)) return; + wiredElements.add(addBtn); + addBtn.addEventListener("click", function() { + const pick = schedulerEl("scheduler-ts-extra-bm-pick"); + if (!pick || !pick.value) return; + if (!pendingExtraBmIds.includes(pick.value)) { + pendingExtraBmIds.push(pick.value); + renderExtraBmList(); + } + pick.value = ""; + }); + } + function renderSatelliteSection() { + schedulerWindow.satScheduler?.renderSection(); + } + function renderSatPassStatus() { + schedulerWindow.satScheduler?.renderPassStatus(); + } + function collectSatelliteConfig() { + return schedulerWindow.satScheduler ? schedulerWindow.satScheduler.collectSatelliteConfig() : { enabled: false, pretune_secs: 60, entries: [] }; + } + function wireSatelliteEvents() { + schedulerWindow.satScheduler?.wireEvents(); + } + function isInputFocused() { + const el = document.activeElement; + if (!el) return false; + const tag = el.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el instanceof HTMLElement && el.isContentEditable; + } + document.addEventListener("keydown", function(e) { + if (isInputFocused()) return; + if (e.shiftKey && e.key === "R") { + e.preventDefault(); + const releaseBtn = schedulerEl("scheduler-release-btn"); + if (releaseBtn && !releaseBtn.disabled) releaseBtn.click(); + } else if (e.shiftKey && e.key === "N") { + e.preventDefault(); + schedulerSelectRelativeEntry(1); + } else if (e.shiftKey && e.key === "P") { + e.preventDefault(); + schedulerSelectRelativeEntry(-1); + } }); + (function() { + const details = document.querySelector(".sch-ts-details"); + if (!details) return; + const schedulerDetails = details; + const key = "sch-details-open"; + const saved = localStorage.getItem(key); + if (saved !== null) schedulerDetails.open = saved === "1"; + details.addEventListener("toggle", function() { + localStorage.setItem(key, schedulerDetails.open ? "1" : "0"); + }); + })(); + const schedulerService = { + initialize: initScheduler, + destroy: destroyScheduler, + wireEvents: wireSchedulerEvents, + setRig: setSchedulerRig, + getConfig: () => currentConfig, + getStatus: () => currentSchedulerStatus, + getBookmarks: () => bookmarkList, + markDirty: markSchedulerDirty + }; + schedulerWindow.trx.modules.scheduler = schedulerService; + if (schedulerWindow.authRole != null) { + initScheduler(schedulerWindow.lastActiveRigId ?? null, schedulerWindow.authRole); + wireSchedulerEvents(); + } })(); - window.initScheduler = initScheduler; - window.destroyScheduler = destroyScheduler; - window.wireSchedulerEvents = wireSchedulerEvents; - window.setSchedulerRig = setSchedulerRig; - if (typeof authRole !== "undefined" && authRole !== null) { - initScheduler(typeof lastActiveRigId !== "undefined" ? lastActiveRigId : null, authRole); - wireSchedulerEvents(); - } })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs index 54ae2058..3d56c4d1 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs @@ -23,7 +23,6 @@ await build({ "plugin-runtime": path.join(sourceDir, "plugin-runtime.ts"), screenshot: path.join(sourceDir, "screenshot.ts"), "webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"), - scheduler: path.join(sourceDir, "plugins", "scheduler.js"), }, outdir: outputDir, bundle: false, @@ -52,6 +51,7 @@ await build({ "sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"), vchan: path.join(sourceDir, "plugins", "vchan.ts"), bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"), + scheduler: path.join(sourceDir, "plugins", "scheduler.ts"), }, outdir: outputDir, bundle: true, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js index 16d5691e..61efe784 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.js @@ -1269,7 +1269,7 @@ function applyRigList(activeRigId, rigIds, displayNames) { updateRigIdentitySummary(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId); if (rigListChanged) { - if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId); + window.trx.modules.scheduler?.setRig(lastActiveRigId); if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId); if (typeof bmPopulateScopePicker === "function") bmPopulateScopePicker(); if (typeof bmFetch === "function") bmFetch(document.getElementById("bm-category-filter")?.value || ""); @@ -3935,7 +3935,7 @@ async function switchRigFromSelect(selectEl) { updateRigSubtitle(lastActiveRigId); updateRigIdentitySummary(lastActiveRigId); window.trxUi?.setActiveRig(lastActiveRigId); - if (typeof setSchedulerRig === "function") setSchedulerRig(lastActiveRigId); + window.trx.modules.scheduler?.setRig(lastActiveRigId); if (typeof setBackgroundDecodeRig === "function") setBackgroundDecodeRig(lastActiveRigId); if (typeof bmFetch === "function") bmFetch(document.getElementById("bm-category-filter")?.value || ""); window.trx.modules.map?.syncAprsReceiverMarker(); @@ -4759,10 +4759,8 @@ async function initializeApp() { } function initSettingsUI() { - if (typeof initScheduler === "function") { - initScheduler(lastActiveRigId, authRole); - wireSchedulerEvents(); - } + window.trx.modules.scheduler?.initialize(lastActiveRigId, authRole); + window.trx.modules.scheduler?.wireEvents(); if (typeof initBackgroundDecode === "function") { initBackgroundDecode(lastActiveRigId, authRole); wireBackgroundDecodeEvents(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts index 166655c9..ba58791b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts @@ -16,7 +16,7 @@ const pluginGroups: Readonly> = { const loaded = new Set(); const loading = new Map>(); -const modulePlugins = new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js", "/vchan.js", "/bookmarks.js"]); +const modulePlugins = new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js", "/vchan.js", "/bookmarks.js", "/scheduler.js"]); function loadLegacyScript(path: string): Promise { return new Promise((resolve, reject) => { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sat-scheduler.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sat-scheduler.ts index 61ba946e..eda807dd 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sat-scheduler.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/sat-scheduler.ts @@ -7,13 +7,12 @@ import type { SatelliteScheduleConfig, SatelliteScheduleEntry, SatelliteSchedulerApi, - SatelliteSchedulerBridge, SatelliteSchedulerStatus, - SchedulerConfigWithSatellites, } from "./satellite-types"; +import type { SchedulerConfig, SchedulerService } from "./scheduler-types.js"; interface SatelliteSchedulerWindow { - schedulerBridge?: SatelliteSchedulerBridge; + trx?: { modules?: { scheduler?: SchedulerService } }; satScheduler?: SatelliteSchedulerApi; trxUi?: { notify(message: string, options: { kind: "error" }): void }; } @@ -49,15 +48,15 @@ const satSchedulerWindow = window as unknown as SatelliteSchedulerWindow; // ── Local state ─────────────────────────────────────────────────── let editIdx: number | null = null; + let eventsWired = false; // ── Scheduler bridge ────────────────────────────────────────────── - // These accessors call into scheduler.js via window.schedulerBridge, - // which is set up by scheduler.js after it initializes. - function getBridge(): SatelliteSchedulerBridge | null { - return satSchedulerWindow.schedulerBridge ?? null; + // These accessors use the shared typed scheduler service when available. + function getBridge(): SchedulerService | null { + return satSchedulerWindow.trx?.modules?.scheduler ?? null; } - function getConfig(): SchedulerConfigWithSatellites | null { + function getConfig(): SchedulerConfig | null { const b = getBridge(); return b?.getConfig() ?? null; } @@ -309,6 +308,8 @@ const satSchedulerWindow = window as unknown as SatelliteSchedulerWindow; // ── Wire all events ─────────────────────────────────────────────── function wireEvents() { + if (eventsWired) return; + eventsWired = true; if (dom.enabled) { const enabledInput = dom.enabled; dom.enabled.addEventListener("change", function () { @@ -334,4 +335,8 @@ const satSchedulerWindow = window as unknown as SatelliteSchedulerWindow; renderPassStatus: renderPassStatus, collectSatelliteConfig: collectSatelliteConfig, }; + if (getBridge()) { + wireEvents(); + renderSection(); + } })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler-types.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler-types.ts new file mode 100644 index 00000000..bb6caa76 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler-types.ts @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import type { SatelliteScheduleConfig, SatelliteSchedulerApi } from "./satellite-types.js"; + +export type SchedulerMode = "disabled" | "grayline" | "time_span"; + +export interface SchedulerBookmark { + id: string; + name: string; + freq_hz: number; + mode: string; +} + +export interface GraylineConfig { + lat: number; + lon: number; + transition_window_min: number; + day_bookmark_id: string | null; + night_bookmark_id: string | null; + dawn_bookmark_id: string | null; + dusk_bookmark_id: string | null; +} + +export interface ScheduleEntry { + id?: string; + start_min: number; + end_min: number; + bookmark_id: string; + label: string | null; + interleave_min: number | null; + center_hz: number | null; + bookmark_ids: string[]; + record: boolean; + exclusive?: boolean; +} + +export interface SchedulerConfig { + remote: string | null; + mode: SchedulerMode; + grayline: GraylineConfig | null; + entries: ScheduleEntry[]; + interleave_min?: number | null; + satellites?: SatelliteScheduleConfig | null; +} + +export interface SchedulerStatus { + active: boolean; + last_entry_id?: string | null; + last_bookmark_id?: string | null; + last_bookmark_name?: string | null; + last_applied_utc?: number | null; + last_center_hz?: number | null; + last_bookmark_ids?: string[]; + active_satellite?: string | null; + freq_hz?: number | null; + mode?: string | null; + active_decoders?: string[]; +} + +export interface SchedulerService { + initialize(rigId: string | null, role: string | null): void; + destroy(): void; + setRig(rigId: string | null): void; + wireEvents(): void; + getConfig(): SchedulerConfig | null; + getStatus(): SchedulerStatus | null; + getBookmarks(): SchedulerBookmark[]; + markDirty(): void; +} + +export interface SchedulerWindow extends Window { + trx: { modules: { scheduler?: SchedulerService } }; + trxUi: { + confirm(options: { title: string; message: string; confirmLabel: string; danger?: boolean }): Promise; + notify?(message: string, options: { kind: "error" }): void; + }; + authRole?: string | null; + lastActiveRigId?: string | null; + serverLat?: number | null; + serverLon?: number | null; + vchanTakeSchedulerControl?(): Promise; + vchanToggleSchedulerRelease?(): Promise; + satScheduler?: SatelliteSchedulerApi; +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler.ts similarity index 65% rename from src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler.js rename to src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler.ts index bb95d5e1..a97789bc 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/scheduler.ts @@ -2,6 +2,33 @@ // // SPDX-License-Identifier: GPL-2.0-or-later +import type { + ScheduleEntry, + SchedulerBookmark, + SchedulerConfig, + SchedulerService, + SchedulerStatus, + SchedulerWindow, +} from "./scheduler-types.js"; + +export {}; + +/* Scheduler DOM IDs are part of the server-rendered page contract. */ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ +/* Timeline SVG and table templates intentionally concatenate typed numeric + * coordinates with markup fragments. */ +/* eslint-disable @typescript-eslint/restrict-plus-operands, @typescript-eslint/no-unnecessary-type-conversion */ + +type SchedulerElement = HTMLElement & HTMLInputElement & HTMLSelectElement; +interface SchedulerLogEntry { utc: number; action?: string; entry_label?: string; bookmark_name?: string } +const schedulerWindow = window as unknown as SchedulerWindow; +const wiredElements = new WeakSet(); +function schedulerEl(id: string): SchedulerElement { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing scheduler element #${id}`); + return element as SchedulerElement; +} + // Background Decoding Scheduler UI (function () { @@ -10,22 +37,22 @@ // ------------------------------------------------------------------------- // State // ------------------------------------------------------------------------- - let schedulerRole = null; // "control" | "rx" | null - let currentRigId = null; - let currentConfig = null; - let currentSchedulerStatus = null; - let bookmarkList = []; // [{id, name, freq_hz, mode}, ...] - let statusInterval = null; - let interleaveTicker = null; + let schedulerRole: string | null = null; + let currentRigId: string | null = null; + let currentConfig: SchedulerConfig | null = null; + let currentSchedulerStatus: SchedulerStatus | null = null; + let bookmarkList: SchedulerBookmark[] = []; + let statusInterval: number | null = null; + let interleaveTicker: number | null = null; let schedulerStepPending = false; - let schEntryEditIdx = null; // null = adding, number = editing that index + let schEntryEditIdx: number | null = null; let schedulerDirty = false; // true when unsaved changes exist // Satellite entry editing state moved to sat-scheduler.js // ------------------------------------------------------------------------- // Init // ------------------------------------------------------------------------- - function initScheduler(rigId, role) { + function initScheduler(rigId: string | null, role: string | null): void { schedulerRole = role; currentRigId = rigId || null; if (currentRigId) loadScheduler(); @@ -47,7 +74,7 @@ // ------------------------------------------------------------------------- // Active rig (mirrors top-bar rig picker in app.js) // ------------------------------------------------------------------------- - function setSchedulerRig(rigId) { + function setSchedulerRig(rigId: string | null): void { const nextRigId = rigId || null; if (nextRigId === currentRigId) return; currentRigId = nextRigId; @@ -60,25 +87,25 @@ // ------------------------------------------------------------------------- // API helpers // ------------------------------------------------------------------------- - function apiGetScheduler(rigId) { + function apiGetScheduler(rigId: string): Promise { return fetch("/scheduler/" + encodeURIComponent(rigId)).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); - return r.json(); + return r.json() as Promise; }); } - function apiPutScheduler(rigId, config) { + function apiPutScheduler(rigId: string, config: SchedulerConfig): Promise { return fetch("/scheduler/" + encodeURIComponent(rigId), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(config), }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); - return r.json(); + return r.json() as Promise; }); } - function apiDeleteScheduler(rigId) { + function apiDeleteScheduler(rigId: string): Promise { return fetch("/scheduler/" + encodeURIComponent(rigId), { method: "DELETE", }).then(function (r) { @@ -87,32 +114,32 @@ }); } - function apiGetStatus(rigId) { + function apiGetStatus(rigId: string): Promise { return fetch("/scheduler/" + encodeURIComponent(rigId) + "/status").then( function (r) { if (!r.ok) throw new Error("HTTP " + r.status); - return r.json(); + return r.json() as Promise; } ); } - function apiActivateSchedulerEntry(rigId, entryId) { + function apiActivateSchedulerEntry(rigId: string, entryId: string): Promise { return fetch("/scheduler/" + encodeURIComponent(rigId) + "/activate", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ entry_id: entryId }), }).then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); - return r.json(); + return r.json() as Promise; }); } - function apiGetBookmarks() { + function apiGetBookmarks(): Promise { // Fetch merged general + rig-specific bookmarks in a single request. - var url = currentRigId + const url = currentRigId ? "/bookmarks?scope=" + encodeURIComponent(currentRigId) : "/bookmarks"; - return fetch(url).then(function (r) { return r.ok ? r.json() : []; }); + return fetch(url).then(function (r) { return r.ok ? r.json() as Promise : []; }); } // ------------------------------------------------------------------------- @@ -131,8 +158,8 @@ clearSchedulerDirty(); renderSchedulerInterleaveStatus(); }) - .catch(function (e) { - console.error("scheduler load failed", e); + .catch(function (error: unknown) { + console.error("scheduler load failed", error); renderSchedulerInterleaveStatus(); }); } @@ -165,7 +192,7 @@ }; } - function schedulerEntryIsActive(entry, nowMin) { + function schedulerEntryIsActive(entry: ScheduleEntry, nowMin: number): boolean { const start = Number(entry && entry.start_min); const end = Number(entry && entry.end_min); if (!Number.isFinite(start) || !Number.isFinite(end)) return false; @@ -174,7 +201,7 @@ return nowMin >= start || nowMin < end; } - function schedulerEntryCurrentWindowStart(entry, nowMin) { + function schedulerEntryCurrentWindowStart(entry: ScheduleEntry, nowMin: number): number { const start = Number(entry && entry.start_min); const end = Number(entry && entry.end_min); if (!Number.isFinite(start) || !Number.isFinite(end)) return Number.NEGATIVE_INFINITY; @@ -183,14 +210,14 @@ return nowMin >= start ? start : (start - 1440); } - function schedulerEntryDisplayName(entry) { + function schedulerEntryDisplayName(entry: ScheduleEntry | undefined): string { if (!entry) return "Scheduler entry"; if (entry.label) return String(entry.label); const bookmarkName = bmName(entry.bookmark_id); return bookmarkName || "Scheduler entry"; } - function schedulerInterleaveState(config) { + function schedulerInterleaveState(config: SchedulerConfig | null): { activeEntries: ScheduleEntry[]; currentIndex: number; remainingSec: number; cycleMin: number } { if (!config || config.mode !== "time_span") { return { activeEntries: [], currentIndex: -1, remainingSec: 0, cycleMin: 0 }; } @@ -207,9 +234,10 @@ return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; } // Exclusive entry wins outright — no interleaving. - var exclIdx = active.findIndex(function (e) { return e.exclusive; }); + const exclIdx = active.findIndex(function (e) { return e.exclusive; }); if (exclIdx >= 0) { - return { activeEntries: [active[exclIdx]], currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + const exclusive = active[exclIdx]; + return { activeEntries: exclusive ? [exclusive] : [], currentIndex: 0, remainingSec: 0, cycleMin: 0 }; } const defaultInterleave = Number(config.interleave_min); const durations = active.map(function (entry) { @@ -218,7 +246,7 @@ if (Number.isFinite(defaultInterleave) && defaultInterleave > 0) return Math.floor(defaultInterleave); return 0; }); - const cycleMin = durations.reduce(function (sum, value) { return sum + value; }, 0); + const cycleMin = durations.reduce(function (sum: number, value: number) { return sum + value; }, 0); if (!(cycleMin > 0)) { return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; } @@ -232,7 +260,7 @@ ? Number(currentSchedulerStatus.last_applied_utc) : null; if (statusIndex >= 0 && statusAppliedUtc != null) { - const manualDurationMin = durations[statusIndex]; + const manualDurationMin = durations[statusIndex] ?? 0; const elapsedSec = Math.max(0, schedulerUtcSeconds() - statusAppliedUtc); const remainingSec = (manualDurationMin > 0) ? Math.max(1, (manualDurationMin * 60) - elapsedSec) @@ -246,7 +274,7 @@ }; } } - const overlapStart = active.reduce(function (maxStart, entry) { + const overlapStart = active.reduce(function (maxStart: number, entry: ScheduleEntry) { return Math.max(maxStart, schedulerEntryCurrentWindowStart(entry, nowMin)); }, Number.NEGATIVE_INFINITY); if (!Number.isFinite(overlapStart)) { @@ -259,12 +287,13 @@ let currentIndex = 0; let currentDuration = 0; for (let i = 0; i < durations.length; i += 1) { - const nextCumulative = cumulative + durations[i]; + const duration = durations[i] ?? 0; + const nextCumulative = cumulative + duration; if (posMin < nextCumulative) { slotStart = cumulative; cumulative = nextCumulative; currentIndex = i; - currentDuration = durations[i]; + currentDuration = duration; break; } cumulative = nextCumulative; @@ -283,7 +312,7 @@ } function renderSchedulerInterleaveStatus() { - const wrap = document.getElementById("scheduler-cycle-status"); + const wrap = schedulerEl("scheduler-cycle-status"); if (!wrap) return; const state = schedulerInterleaveState(currentConfig); @@ -292,21 +321,21 @@ wrap.style.display = isActive ? "" : "none"; if (isActive) { - var activeName = schedulerEntryDisplayName(state.activeEntries[state.currentIndex]); - var totalSlotSec = state.cycleMin > 0 + const activeName = schedulerEntryDisplayName(state.activeEntries[state.currentIndex]); + const totalSlotSec = state.cycleMin > 0 ? (state.cycleMin * 60) / state.activeEntries.length : 0; - var elapsedPct = totalSlotSec > 0 + const elapsedPct = totalSlotSec > 0 ? Math.min(100, Math.max(0, ((totalSlotSec - state.remainingSec) / totalSlotSec) * 100)) : 0; - var ringFill = document.getElementById("interleave-ring-fill"); + const ringFill = schedulerEl("interleave-ring-fill"); if (ringFill) ringFill.setAttribute("stroke-dashoffset", String(100 - elapsedPct)); - var nameEl = document.getElementById("interleave-active-name"); + const nameEl = schedulerEl("interleave-active-name"); if (nameEl) nameEl.textContent = activeName; - var countdownEl = document.getElementById("interleave-countdown"); + const countdownEl = schedulerEl("interleave-countdown"); if (countdownEl) countdownEl.textContent = "next in " + state.remainingSec + "s · " + state.cycleMin + "m cycle"; } @@ -316,8 +345,8 @@ } function renderSchedulerStepControls() { - const prevBtn = document.getElementById("scheduler-prev-btn"); - const nextBtn = document.getElementById("scheduler-next-btn"); + const prevBtn = schedulerEl("scheduler-prev-btn"); + const nextBtn = schedulerEl("scheduler-next-btn"); if (!prevBtn || !nextBtn) return; const state = schedulerInterleaveState(currentConfig); const enabled = @@ -348,8 +377,8 @@ .catch(function () {}); } - function renderStatus(st) { - const el = document.getElementById("scheduler-status-card"); + function renderStatus(st: SchedulerStatus | null): void { + const el = schedulerEl("scheduler-status-card"); if (!el) return; if (!st || (!st.active && !st.last_bookmark_id)) { el.textContent = "No activity yet."; @@ -370,7 +399,7 @@ const satLabel = st.active_satellite ? " [SAT: " + st.active_satellite + "]" : ""; - var details = ""; + let details = ""; if (st.freq_hz) { details += formatFreq(st.freq_hz); if (st.mode) details += " \u00B7 " + st.mode; @@ -389,15 +418,15 @@ // ------------------------------------------------------------------------- // Activity log // ------------------------------------------------------------------------- - function apiGetSchedulerLog(rigId) { + function apiGetSchedulerLog(rigId: string): Promise { return fetch("/scheduler/" + encodeURIComponent(rigId) + "/log").then(function (r) { - return r.ok ? r.json() : []; + return r.ok ? r.json() as Promise : []; }); } function renderActivityLog() { - var wrap = document.getElementById("scheduler-activity-log-wrap"); - var container = document.getElementById("scheduler-activity-log"); + const wrap = schedulerEl("scheduler-activity-log-wrap"); + const container = schedulerEl("scheduler-activity-log"); if (!wrap || !container || !currentRigId) return; apiGetSchedulerLog(currentRigId).then(function (entries) { @@ -406,12 +435,12 @@ return; } wrap.style.display = ""; - var html = entries.slice().reverse().map(function (e) { - var d = new Date(e.utc * 1000); - var ts = d.toUTCString(); - var action = e.action || "unknown"; - var label = e.entry_label || ""; - var bm = e.bookmark_name || ""; + const html = entries.slice().reverse().map(function (e) { + const d = new Date(e.utc * 1000); + const ts = d.toUTCString(); + const action = e.action || "unknown"; + const label = e.entry_label || ""; + const bm = e.bookmark_name || ""; return '
' + '' + escHtml(ts) + ' ' + '' + escHtml(action) + ' ' + @@ -427,7 +456,7 @@ // Render the full scheduler panel // ------------------------------------------------------------------------- function renderScheduler() { - const panel = document.getElementById("scheduler-panel"); + const panel = schedulerEl("scheduler-panel"); if (!panel) return; const mode = (currentConfig && currentConfig.mode) || "disabled"; @@ -438,12 +467,12 @@ // Show/hide main-view scheduler controls (visible when base mode active OR satellites enabled) const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled; - const controlRow = document.querySelector(".scheduler-control-row"); + const controlRow = document.querySelector(".scheduler-control-row"); if (controlRow) controlRow.style.display = (mode !== "disabled" || satEnabled) ? "" : "none"; // Show/hide sections - const glSection = document.getElementById("scheduler-grayline-section"); - const tsSection = document.getElementById("scheduler-timespan-section"); + const glSection = schedulerEl("scheduler-grayline-section"); + const tsSection = schedulerEl("scheduler-timespan-section"); if (glSection) glSection.style.display = mode === "grayline" ? "" : "none"; if (tsSection) tsSection.style.display = mode === "time_span" ? "" : "none"; @@ -454,12 +483,12 @@ if (mode === "grayline" && currentConfig && currentConfig.grayline) { const gl = currentConfig.grayline; // Prefer saved value; fall back to server coordinates from app.js globals. - const lat = gl.lat != null ? gl.lat : (typeof serverLat !== "undefined" ? serverLat : ""); - const lon = gl.lon != null ? gl.lon : (typeof serverLon !== "undefined" ? serverLon : ""); + const lat = gl.lat ?? schedulerWindow.serverLat ?? ""; + const lon = gl.lon ?? schedulerWindow.serverLon ?? ""; setInputValue("scheduler-gl-lat", lat != null ? lat : ""); setInputValue("scheduler-gl-lon", lon != null ? lon : ""); - var gridEl = document.getElementById("scheduler-gl-grid"); - if (gridEl && lat !== "" && lon !== "") { + const gridEl = schedulerEl("scheduler-gl-grid"); + if (gridEl) { gridEl.value = latLonToGrid(lat, lon); } setInputValue("scheduler-gl-window", gl.transition_window_min != null ? gl.transition_window_min : 20); @@ -469,11 +498,11 @@ renderBookmarkSelect("scheduler-gl-night", gl.night_bookmark_id); } else if (mode === "grayline") { // No saved grayline config yet — pre-fill coords from server if available. - const lat = typeof serverLat !== "undefined" ? serverLat : ""; - const lon = typeof serverLon !== "undefined" ? serverLon : ""; + const lat = schedulerWindow.serverLat ?? ""; + const lon = schedulerWindow.serverLon ?? ""; setInputValue("scheduler-gl-lat", lat != null ? lat : ""); setInputValue("scheduler-gl-lon", lon != null ? lon : ""); - var gridEl2 = document.getElementById("scheduler-gl-grid"); + const gridEl2 = schedulerEl("scheduler-gl-grid"); if (gridEl2 && lat !== "" && lon !== "") { gridEl2.value = latLonToGrid(lat, lon); } @@ -490,42 +519,42 @@ } // Interleave input - const ilEl = document.getElementById("scheduler-ts-interleave"); + const ilEl = schedulerEl("scheduler-ts-interleave"); if (ilEl) { const il = currentConfig && currentConfig.interleave_min; - ilEl.value = il ? il : ""; + ilEl.value = il ? String(il) : ""; } // TimeSpan entries renderTimespanEntries(); // Enable/disable controls - const formEls = panel.querySelectorAll("input, select, button.sch-write"); + const formEls = panel.querySelectorAll("input, select, button.sch-write"); formEls.forEach(function (el) { el.disabled = !isControl; }); - const saveBtn = document.getElementById("scheduler-save-btn"); + const saveBtn = schedulerEl("scheduler-save-btn"); if (saveBtn) { saveBtn.style.display = isControl ? "" : "none"; } - const resetBtn = document.getElementById("scheduler-reset-btn"); + const resetBtn = schedulerEl("scheduler-reset-btn"); if (resetBtn) { resetBtn.style.display = isControl ? "" : "none"; } } - function setSelected(id, value) { - const el = document.getElementById(id); + function setSelected(id: string, value: string): void { + const el = schedulerEl(id); if (el) el.value = value; } - function setInputValue(id, value) { - const el = document.getElementById(id); - if (el) el.value = value; + function setInputValue(id: string, value: string | number): void { + const el = schedulerEl(id); + if (el) el.value = String(value); } - function renderBookmarkSelect(id, selectedId) { - const sel = document.getElementById(id); + function renderBookmarkSelect(id: string, selectedId: string | null): void { + const sel = schedulerEl(id); if (!sel) return; sel.innerHTML = ''; bookmarkList.forEach(function (bm) { @@ -537,7 +566,7 @@ }); } - function formatFreq(hz) { + function formatFreq(hz: number): string { if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz"; if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz"; return hz + " Hz"; @@ -546,33 +575,33 @@ // ------------------------------------------------------------------------- // Entry form (inline card below Add Entry button) // ------------------------------------------------------------------------- - function schOpenEntryForm(entry, idx) { + function schOpenEntryForm(entry: ScheduleEntry | null, idx?: number): void { schEntryEditIdx = (idx != null) ? idx : null; - const titleEl = document.getElementById("sch-entry-form-title"); + const titleEl = schedulerEl("sch-entry-form-title"); if (titleEl) titleEl.textContent = entry ? "Edit Entry" : "Add Entry"; - const startEl = document.getElementById("scheduler-ts-start"); - const endEl = document.getElementById("scheduler-ts-end"); - const bmEl = document.getElementById("scheduler-ts-bookmark"); - const labelEl = document.getElementById("scheduler-ts-label"); - const ilEl = document.getElementById("scheduler-ts-entry-interleave"); - const centerHzEl = document.getElementById("scheduler-ts-center-hz"); + const startEl = schedulerEl("scheduler-ts-start"); + const endEl = schedulerEl("scheduler-ts-end"); + const bmEl = schedulerEl("scheduler-ts-bookmark"); + const labelEl = schedulerEl("scheduler-ts-label"); + const ilEl = schedulerEl("scheduler-ts-entry-interleave"); + const centerHzEl = schedulerEl("scheduler-ts-center-hz"); if (startEl) startEl.value = entry ? minToHHMM(entry.start_min) : ""; if (endEl) endEl.value = entry ? minToHHMM(entry.end_min) : ""; if (bmEl) bmEl.value = entry ? (entry.bookmark_id || "") : ""; if (labelEl) labelEl.value = entry ? (entry.label || "") : ""; - if (ilEl) ilEl.value = entry && entry.interleave_min ? entry.interleave_min : ""; - if (centerHzEl) centerHzEl.value = entry && entry.center_hz ? entry.center_hz : ""; + if (ilEl) ilEl.value = entry?.interleave_min ? String(entry.interleave_min) : ""; + if (centerHzEl) centerHzEl.value = entry?.center_hz ? String(entry.center_hz) : ""; - const recordEl = document.getElementById("scheduler-ts-entry-record"); + const recordEl = schedulerEl("scheduler-ts-entry-record"); if (recordEl) recordEl.checked = !!(entry && entry.record); pendingExtraBmIds = entry && Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids.slice() : []; renderExtraBmList(); - const wrap = document.getElementById("sch-entry-form-wrap"); + const wrap = schedulerEl("sch-entry-form-wrap"); if (wrap) { wrap.style.display = "block"; if (startEl) startEl.focus(); @@ -580,26 +609,26 @@ } function schCloseEntryForm() { - const wrap = document.getElementById("sch-entry-form-wrap"); + const wrap = schedulerEl("sch-entry-form-wrap"); if (wrap) wrap.style.display = "none"; schEntryEditIdx = null; pendingExtraBmIds = []; } - function schEntryFormSubmit(e) { + function schEntryFormSubmit(e: Event): void { e.preventDefault(); - const startEl = document.getElementById("scheduler-ts-start"); - const endEl = document.getElementById("scheduler-ts-end"); - const bmEl = document.getElementById("scheduler-ts-bookmark"); - const labelEl = document.getElementById("scheduler-ts-label"); - const ilEl = document.getElementById("scheduler-ts-entry-interleave"); - const centerHzEl = document.getElementById("scheduler-ts-center-hz"); + const startEl = schedulerEl("scheduler-ts-start"); + const endEl = schedulerEl("scheduler-ts-end"); + const bmEl = schedulerEl("scheduler-ts-bookmark"); + const labelEl = schedulerEl("scheduler-ts-label"); + const ilEl = schedulerEl("scheduler-ts-entry-interleave"); + const centerHzEl = schedulerEl("scheduler-ts-center-hz"); if (!startEl || !endEl || !bmEl) return; const bmId = bmEl.value; if (!bmId) { - window.trxUi?.notify("Select a primary bookmark before saving.", { kind: "error" }); + schedulerWindow.trxUi.notify?.("Select a primary bookmark before saving.", { kind: "error" }); return; } @@ -612,15 +641,14 @@ const centerHz = !isNaN(centerHzRaw) && centerHzRaw > 0 ? centerHzRaw : null; const extraBmIds = pendingExtraBmIds.slice(); - if (!currentConfig) { - currentConfig = { remote: currentRigId, mode: "time_span", entries: [] }; - } - if (!currentConfig.entries) currentConfig.entries = []; + currentConfig ??= { remote: currentRigId, mode: "time_span", grayline: null, entries: [] }; + const config = currentConfig; - const recordCb = document.getElementById("scheduler-ts-entry-record"); + const recordCb = schedulerEl("scheduler-ts-entry-record"); const entryRecord = recordCb ? recordCb.checked : false; - const entryData = { + const entryData: ScheduleEntry = { + id: "ts_" + Date.now().toString(36), start_min: startMin, end_min: endMin, bookmark_id: bmId, @@ -629,15 +657,15 @@ center_hz: centerHz, bookmark_ids: extraBmIds, record: entryRecord, + exclusive: false, }; if (schEntryEditIdx !== null) { - const existing = currentConfig.entries[schEntryEditIdx]; - entryData.id = existing ? existing.id : ("ts_" + Date.now().toString(36)); - currentConfig.entries[schEntryEditIdx] = entryData; + const existing = config.entries[schEntryEditIdx]; + if (existing?.id) entryData.id = existing.id; + config.entries[schEntryEditIdx] = entryData; } else { - entryData.id = "ts_" + Date.now().toString(36); - currentConfig.entries.push(entryData); + config.entries.push(entryData); } schCloseEntryForm(); @@ -648,75 +676,75 @@ // ------------------------------------------------------------------------- // 24h Timeline Bar // ------------------------------------------------------------------------- - var TIMELINE_COLORS = ["#38bdf8", "#f59e0b", "#a78bfa", "#34d399", "#fb7185", "#60a5fa"]; + const TIMELINE_COLORS = ["#38bdf8", "#f59e0b", "#a78bfa", "#34d399", "#fb7185", "#60a5fa"]; function renderTimeline() { - var container = document.getElementById("scheduler-ts-timeline"); + const container = schedulerEl("scheduler-ts-timeline"); if (!container) return; - var entries = currentConfig && Array.isArray(currentConfig.entries) ? currentConfig.entries : []; + const entries = currentConfig && Array.isArray(currentConfig.entries) ? currentConfig.entries : []; if (entries.length === 0) { container.innerHTML = ""; return; } - var W = 1000; - var H = 80; - var BAR_Y = 6; - var BAR_H = 30; - var TICK_Y = BAR_Y + BAR_H + 2; + const W = 1000; + const H = 80; + const BAR_Y = 6; + const BAR_H = 30; + const TICK_Y = BAR_Y + BAR_H + 2; - var svg = ''; + let svg = ''; // Background bar svg += ''; // Entry segments entries.forEach(function (entry, idx) { - var start = Number(entry.start_min); - var end = Number(entry.end_min); + const start = Number(entry.start_min); + const end = Number(entry.end_min); if (!Number.isFinite(start) || !Number.isFinite(end)) return; - var color = TIMELINE_COLORS[idx % TIMELINE_COLORS.length]; + const color = TIMELINE_COLORS[idx % TIMELINE_COLORS.length]; if (start === end) { // All-day entry svg += ''; } else if (start < end) { - var x = (start / 1440) * W; - var w = ((end - start) / 1440) * W; + const x = (start / 1440) * W; + const w = ((end - start) / 1440) * W; svg += ''; } else { // Wrap-around: two segments - var x1 = (start / 1440) * W; - var w1 = W - x1; + const x1 = (start / 1440) * W; + const w1 = W - x1; svg += ''; - var w2 = (end / 1440) * W; + const w2 = (end / 1440) * W; svg += ''; } }); // Interleave stripes for overlapping entries - var interleaveMin = currentConfig && currentConfig.interleave_min ? Number(currentConfig.interleave_min) : 0; + const interleaveMin = currentConfig && currentConfig.interleave_min ? Number(currentConfig.interleave_min) : 0; if (interleaveMin > 0 && entries.length > 1) { // Find overlap regions where 2+ entries are active - for (var m = 0; m < 1440; m += interleaveMin) { - var overlapping = []; + for (let m = 0; m < 1440; m += interleaveMin) { + const overlapping: number[] = []; entries.forEach(function (entry, idx) { if (schedulerEntryIsActive(entry, m)) { overlapping.push(idx); } }); if (overlapping.length > 1) { - var stripeX = (m / 1440) * W; - var stripeW = Math.max(1, (interleaveMin / 1440) * W); + const stripeX = (m / 1440) * W; + const stripeW = Math.max(1, (interleaveMin / 1440) * W); // Determine which entry "owns" this stripe via cycle position - var cyclePos = m % (interleaveMin * overlapping.length); - var ownerSlot = Math.floor(cyclePos / interleaveMin); - var ownerIdx = overlapping[ownerSlot % overlapping.length]; - var stripeColor = TIMELINE_COLORS[ownerIdx % TIMELINE_COLORS.length]; + const cyclePos = m % (interleaveMin * overlapping.length); + const ownerSlot = Math.floor(cyclePos / interleaveMin); + const ownerIdx = overlapping[ownerSlot % overlapping.length] ?? 0; + const stripeColor = TIMELINE_COLORS[ownerIdx % TIMELINE_COLORS.length]; svg += ''; } @@ -724,8 +752,8 @@ } // Tick marks every 3 hours - for (var h = 0; h <= 24; h += 3) { - var tx = (h / 24) * W; + for (let h = 0; h <= 24; h += 3) { + const tx = (h / 24) * W; svg += ''; if (h < 24) { @@ -735,12 +763,12 @@ } // Local time ticks - var LOCAL_TICK_Y = TICK_Y + 18; - for (var h = 0; h < 24; h += 3) { - var localMin = h * 60; - var utcOffset = new Date().getTimezoneOffset(); // offset in minutes (negative for east of UTC) - var utcMin = (localMin + utcOffset + 1440) % 1440; - var tx = (utcMin / 1440) * W; + const LOCAL_TICK_Y = TICK_Y + 18; + for (let h = 0; h < 24; h += 3) { + const localMin = h * 60; + const utcOffset = new Date().getTimezoneOffset(); // offset in minutes (negative for east of UTC) + const utcMin = (localMin + utcOffset + 1440) % 1440; + const tx = (utcMin / 1440) * W; svg += '' + String(h).padStart(2, "0") + 'L'; } @@ -754,29 +782,30 @@ // Wire click events on segments container.querySelectorAll(".sch-timeline-seg").forEach(function (seg) { seg.addEventListener("click", function () { - var i = parseInt(seg.getAttribute("data-idx"), 10); - var entry = currentConfig && currentConfig.entries ? currentConfig.entries[i] : null; + const i = parseInt(seg.getAttribute("data-idx") ?? "", 10); + const entry = currentConfig && currentConfig.entries ? currentConfig.entries[i] : null; if (entry) schOpenEntryForm(entry, i); }); }); // Click-to-add on empty timeline region - var svgEl = container.querySelector('svg'); + const svgEl = container.querySelector('svg'); if (svgEl) { + const timelineSvg = svgEl; svgEl.addEventListener('click', function (e) { // Only trigger if clicking on the background bar, not on a segment - if (e.target.classList.contains('sch-timeline-seg')) return; - var rect = svgEl.getBoundingClientRect(); - var xPct = (e.clientX - rect.left) / rect.width; - var clickMin = Math.floor(xPct * 1440); - var startHour = Math.floor(clickMin / 60); - var startMin = startHour * 60; - var endMin = ((startHour + 1) % 24) * 60; + if (e.target instanceof Element && e.target.classList.contains('sch-timeline-seg')) return; + const rect = timelineSvg.getBoundingClientRect(); + const xPct = (e.clientX - rect.left) / rect.width; + const clickMin = Math.floor(xPct * 1440); + const startHour = Math.floor(clickMin / 60); + const startMin = startHour * 60; + const endMin = ((startHour + 1) % 24) * 60; // Pre-fill the entry form with the clicked hour - schOpenEntryForm(null, null); - var startEl = document.getElementById('scheduler-ts-start'); - var endEl = document.getElementById('scheduler-ts-end'); + schOpenEntryForm(null); + const startEl = schedulerEl('scheduler-ts-start'); + const endEl = schedulerEl('scheduler-ts-end'); if (startEl) startEl.value = minToHHMM(startMin); if (endEl) endEl.value = minToHHMM(endMin); }); @@ -785,33 +814,33 @@ } function timelineNeedleSvg() { - var info = schedulerUtcMinuteInfo(); - var nowMin = info.minuteOfDay + (info.secondOfMinute / 60); - var x = (nowMin / 1440) * 1000; + const info = schedulerUtcMinuteInfo(); + const nowMin = info.minuteOfDay + (info.secondOfMinute / 60); + const x = (nowMin / 1440) * 1000; return '' + ''; } function renderTimelineNeedle() { - var g = document.getElementById("sch-timeline-needle-g"); + const g = schedulerEl("sch-timeline-needle-g"); if (g) g.innerHTML = timelineNeedleSvg(); } // ------------------------------------------------------------------------- // Inline row editing // ------------------------------------------------------------------------- - function schInlineEdit(tr, entry, idx) { - var bmOptions = bookmarkList.map(function (bm) { - var sel = bm.id === entry.bookmark_id ? ' selected' : ''; + function schInlineEdit(tr: HTMLTableRowElement, entry: ScheduleEntry, idx: number): void { + const bmOptions = bookmarkList.map(function (bm) { + const sel = bm.id === entry.bookmark_id ? ' selected' : ''; return ''; }).join(''); - var extraBmOptions = '' + bookmarkList.map(function (bm) { + const extraBmOptions = '' + bookmarkList.map(function (bm) { return ''; }).join(''); - var inlineExtraIds = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids.slice() : []; + const inlineExtraIds = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids.slice() : []; tr.innerHTML = '\u2807' + @@ -832,16 +861,19 @@ tr.classList.add('sch-inline-editing'); - var chipsContainer = tr.querySelector('.sch-inline-extra-chips'); - var extraPick = tr.querySelector('.sch-inline-extra-pick'); - var extraAddBtn = tr.querySelector('.sch-inline-extra-add'); + const chipsContainer = tr.querySelector('.sch-inline-extra-chips'); + const extraPick = tr.querySelector('.sch-inline-extra-pick'); + const extraAddBtn = tr.querySelector('.sch-inline-extra-add'); + if (!chipsContainer || !extraPick || !extraAddBtn) return; + const chips = chipsContainer; + const picker = extraPick; function renderInlineExtraChips() { - chipsContainer.innerHTML = ''; + chips.innerHTML = ''; inlineExtraIds.forEach(function (id, i) { - var chip = document.createElement('span'); + const chip = document.createElement('span'); chip.className = 'sch-extra-bm-chip'; - var rmBtn = document.createElement('span'); + const rmBtn = document.createElement('span'); rmBtn.className = 'sch-extra-bm-chip-rm'; rmBtn.textContent = '\u00D7'; rmBtn.title = 'Remove'; @@ -851,60 +883,64 @@ }); chip.appendChild(rmBtn); chip.appendChild(document.createTextNode(' ' + bmName(id))); - chipsContainer.appendChild(chip); + chips.appendChild(chip); }); - Array.from(extraPick.options).forEach(function (opt) { + Array.from(picker.options).forEach(function (opt) { if (opt.value) opt.disabled = inlineExtraIds.includes(opt.value); }); } renderInlineExtraChips(); extraAddBtn.addEventListener('click', function () { - if (!extraPick.value) return; - if (!inlineExtraIds.includes(extraPick.value)) { - inlineExtraIds.push(extraPick.value); + if (!picker.value) return; + if (!inlineExtraIds.includes(picker.value)) { + inlineExtraIds.push(picker.value); renderInlineExtraChips(); } - extraPick.value = ''; + picker.value = ''; }); // Wire exclusive checkbox to disable interleave input. - var exclEl = tr.querySelector('[data-field="exclusive"]'); - var ilInput = tr.querySelector('[data-field="interleave"]'); + const exclEl = tr.querySelector('[data-field="exclusive"]'); + const ilInput = tr.querySelector('[data-field="interleave"]'); if (exclEl && ilInput) { + const exclusiveInput = exclEl; + const interleaveInput = ilInput; exclEl.addEventListener('change', function () { - ilInput.disabled = exclEl.checked; - if (exclEl.checked) ilInput.value = ''; + interleaveInput.disabled = exclusiveInput.checked; + if (exclusiveInput.checked) interleaveInput.value = ''; }); } - tr.querySelector('.sch-inline-save').addEventListener('click', function () { - var startEl = tr.querySelector('[data-field="start"]'); - var endEl = tr.querySelector('[data-field="end"]'); - var bmEl = tr.querySelector('[data-field="bookmark"]'); - var labelEl = tr.querySelector('[data-field="label"]'); - var ilEl = tr.querySelector('[data-field="interleave"]'); - var recEl = tr.querySelector('[data-field="record"]'); - var exEl = tr.querySelector('[data-field="exclusive"]'); + tr.querySelector('.sch-inline-save')?.addEventListener('click', function () { + const startEl = tr.querySelector('[data-field="start"]'); + const endEl = tr.querySelector('[data-field="end"]'); + const bmEl = tr.querySelector('[data-field="bookmark"]'); + const labelEl = tr.querySelector('[data-field="label"]'); + const ilEl = tr.querySelector('[data-field="interleave"]'); + const recEl = tr.querySelector('[data-field="record"]'); + const exEl = tr.querySelector('[data-field="exclusive"]'); - if (bmEl && !bmEl.value) { window.trxUi?.notify("Select a bookmark before saving.", { kind: "error" }); bmEl.focus(); return; } + if (!startEl || !endEl || !bmEl || !labelEl || !ilEl || !recEl) return; + + if (bmEl && !bmEl.value) { schedulerWindow.trxUi.notify?.("Select a bookmark before saving.", { kind: "error" }); bmEl.focus(); return; } entry.start_min = hhmmToMin(startEl.value); entry.end_min = hhmmToMin(endEl.value); entry.bookmark_id = bmEl.value; entry.label = labelEl.value.trim() || null; entry.exclusive = exEl ? exEl.checked : false; - var ilVal = parseInt(ilEl.value, 10); + const ilVal = parseInt(ilEl.value, 10); entry.interleave_min = entry.exclusive ? null : ((!isNaN(ilVal) && ilVal > 0) ? ilVal : null); entry.bookmark_ids = inlineExtraIds.slice(); entry.record = recEl.checked; - currentConfig.entries[idx] = entry; + if (currentConfig) currentConfig.entries[idx] = entry; renderTimespanEntries(); markSchedulerDirty(); }); - tr.querySelector('.sch-inline-cancel').addEventListener('click', function () { + tr.querySelector('.sch-inline-cancel')?.addEventListener('click', function () { renderTimespanEntries(); }); } @@ -913,7 +949,7 @@ // TimeSpan entries table // ------------------------------------------------------------------------- function renderTimespanEntries() { - const tbody = document.getElementById("scheduler-ts-tbody"); + const tbody = schedulerEl("scheduler-ts-tbody"); if (!tbody) return; tbody.innerHTML = ""; const entries = @@ -951,55 +987,64 @@ }); tbody.querySelectorAll(".sch-edit-btn").forEach(function (btn) { btn.addEventListener("click", function () { - const i = parseInt(btn.dataset.idx, 10); + const i = parseInt((btn as HTMLElement).dataset.idx ?? "", 10); const entry = currentConfig && currentConfig.entries ? currentConfig.entries[i] : null; - if (entry) schInlineEdit(btn.closest('tr'), entry, i); + const row = btn.closest('tr'); + if (entry && row) schInlineEdit(row, entry, i); }); }); tbody.querySelectorAll(".sch-remove-btn").forEach(function (btn) { btn.addEventListener("click", function () { - removeEntry(parseInt(btn.dataset.idx, 10)); + removeEntry(parseInt((btn as HTMLElement).dataset.idx ?? "", 10)); }); }); // Drag-to-reorder (function () { - var handles = tbody.querySelectorAll('.sch-drag-handle'); - var dragIdx = null; + const handles = tbody.querySelectorAll('.sch-drag-handle'); + let dragIdx: number | null = null; handles.forEach(function (handle, idx) { - var row = handle.parentElement; + const row = handle.parentElement; + if (!row) return; + const dragRow = row; - handle.addEventListener('dragstart', function (e) { + handle.addEventListener('dragstart', function (event) { + const e = event as DragEvent; dragIdx = idx; - row.classList.add('sch-dragging'); - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', String(idx)); + dragRow.classList.add('sch-dragging'); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(idx)); + } }); - row.addEventListener('dragover', function (e) { + dragRow.addEventListener('dragover', function (event) { + const e = event; e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - row.classList.add('sch-drag-over'); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + dragRow.classList.add('sch-drag-over'); }); - row.addEventListener('dragleave', function () { - row.classList.remove('sch-drag-over'); + dragRow.addEventListener('dragleave', function () { + dragRow.classList.remove('sch-drag-over'); }); - row.addEventListener('drop', function (e) { + dragRow.addEventListener('drop', function (event) { + const e = event; e.preventDefault(); - row.classList.remove('sch-drag-over'); + dragRow.classList.remove('sch-drag-over'); if (dragIdx === null || dragIdx === idx) return; - var entries = currentConfig.entries; - var moved = entries.splice(dragIdx, 1)[0]; - entries.splice(idx, 0, moved); + if (!currentConfig) return; + const entries = currentConfig.entries; + const moved = entries.splice(dragIdx, 1)[0]; + if (moved) entries.splice(idx, 0, moved); renderTimespanEntries(); markSchedulerDirty(); }); handle.addEventListener('dragend', function () { - row.classList.remove('sch-dragging'); + dragRow.classList.remove('sch-dragging'); dragIdx = null; }); }); @@ -1008,44 +1053,44 @@ renderTimeline(); } - function bmName(id) { + function bmName(id: string): string { const bm = bookmarkList.find(function (b) { return b.id === id; }); return bm ? bm.name : String(id || ""); } - function minToLocal(min) { + function minToLocal(min: number): string { // Convert UTC minutes-since-midnight to local time string - var now = new Date(); - var utcMidnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); - var utcMs = utcMidnight.getTime() + min * 60000; - var local = new Date(utcMs); + const now = new Date(); + const utcMidnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const utcMs = utcMidnight.getTime() + min * 60000; + const local = new Date(utcMs); return String(local.getHours()).padStart(2, "0") + ":" + String(local.getMinutes()).padStart(2, "0"); } - function minToHHMM(min) { + function minToHHMM(min: number): string { const h = Math.floor(min / 60) % 24; const m = min % 60; return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"); } - function hhmmToMin(str) { + function hhmmToMin(str: string): number { const parts = str.split(":"); return parseInt(parts[0] || "0", 10) * 60 + parseInt(parts[1] || "0", 10); } - function gridToLatLon(grid) { + function gridToLatLon(grid: string): { lat: number; lon: number } | null { grid = String(grid).toUpperCase().trim(); if (grid.length < 4) return null; - var lonField = grid.charCodeAt(0) - 65; - var latField = grid.charCodeAt(1) - 65; - var lonSquare = parseInt(grid.charAt(2), 10); - var latSquare = parseInt(grid.charAt(3), 10); + const lonField = grid.charCodeAt(0) - 65; + const latField = grid.charCodeAt(1) - 65; + const lonSquare = parseInt(grid.charAt(2), 10); + const latSquare = parseInt(grid.charAt(3), 10); if (isNaN(lonSquare) || isNaN(latSquare) || lonField < 0 || lonField > 17 || latField < 0 || latField > 17) return null; - var lon = lonField * 20 + lonSquare * 2 - 180; - var lat = latField * 10 + latSquare * 1 - 90; + let lon = lonField * 20 + lonSquare * 2 - 180; + let lat = latField * 10 + latSquare * 1 - 90; if (grid.length >= 6) { - var lonSub = grid.charCodeAt(4) - 65; - var latSub = grid.charCodeAt(5) - 65; + const lonSub = grid.charCodeAt(4) - 65; + const latSub = grid.charCodeAt(5) - 65; if (lonSub >= 0 && lonSub < 24 && latSub >= 0 && latSub < 24) { lon += lonSub * (2 / 24) + (1 / 24); lat += latSub * (1 / 24) + (0.5 / 24); @@ -1057,20 +1102,20 @@ return { lat: lat, lon: lon }; } - function latLonToGrid(lat, lon) { - lon = parseFloat(lon) + 180; - lat = parseFloat(lat) + 90; + function latLonToGrid(lat: number, lon: number): string { + lon += 180; + lat += 90; if (isNaN(lon) || isNaN(lat)) return ""; - var lonField = String.fromCharCode(65 + Math.floor(lon / 20)); - var latField = String.fromCharCode(65 + Math.floor(lat / 10)); - var lonSquare = Math.floor((lon % 20) / 2); - var latSquare = Math.floor(lat % 10); - var lonSub = String.fromCharCode(97 + Math.floor(((lon % 2) / 2) * 24)); - var latSub = String.fromCharCode(97 + Math.floor((lat % 1) * 24)); + const lonField = String.fromCharCode(65 + Math.floor(lon / 20)); + const latField = String.fromCharCode(65 + Math.floor(lat / 10)); + const lonSquare = Math.floor((lon % 20) / 2); + const latSquare = Math.floor(lat % 10); + const lonSub = String.fromCharCode(97 + Math.floor(((lon % 2) / 2) * 24)); + const latSub = String.fromCharCode(97 + Math.floor((lat % 1) * 24)); return lonField + latField + lonSquare + latSquare + lonSub + latSub; } - function escHtml(s) { + function escHtml(s: unknown): string { return String(s) .replace(/&/g, "&") .replace(/= 0 ? state.currentIndex : 0; const targetIndex = (currentIndex + delta + count) % count; const target = state.activeEntries[targetIndex]; if (!target || !target.id) return; + const targetId = target.id; schedulerStepPending = true; renderSchedulerStepControls(); - Promise.resolve(typeof vchanTakeSchedulerControl === "function" ? vchanTakeSchedulerControl() : null) + Promise.resolve(schedulerWindow.vchanTakeSchedulerControl?.() ?? null) .then(function () { - return apiActivateSchedulerEntry(currentRigId, target.id); + return apiActivateSchedulerEntry(rigId, targetId); }) .then(function (status) { currentSchedulerStatus = status || null; return Promise.resolve( - typeof vchanToggleSchedulerRelease === "function" - ? vchanToggleSchedulerRelease() - : null + schedulerWindow.vchanToggleSchedulerRelease?.() ?? null ).then(function () { renderStatus(status); renderSchedulerInterleaveStatus(); @@ -1107,9 +1152,9 @@ pollStatus(); }); }) - .catch(function (e) { - console.error("scheduler entry selection failed", e); - showSchedulerToast("Scheduler entry selection failed: " + e.message, true); + .catch(function (error: unknown) { + console.error("scheduler entry selection failed", error); + showSchedulerToast("Scheduler entry selection failed: " + (error instanceof Error ? error.message : String(error)), true); }) .finally(function () { schedulerStepPending = false; @@ -1117,7 +1162,7 @@ }); } - function removeEntry(idx) { + function removeEntry(idx: number): void { if (!currentConfig || !currentConfig.entries) return; currentConfig.entries.splice(idx, 1); renderTimespanEntries(); @@ -1127,7 +1172,7 @@ // ------------------------------------------------------------------------- // Bookmark existence check // ------------------------------------------------------------------------- - function bookmarkExists(id) { + function bookmarkExists(id: string): boolean { if (!id) return true; // null/empty is allowed return bookmarkList.some(function (bm) { return bm.id === id; }); } @@ -1139,10 +1184,11 @@ const rig = currentRigId; if (!rig) return; - const modeEl = document.getElementById("scheduler-mode-select"); - const mode = modeEl ? modeEl.value : "disabled"; + const modeEl = schedulerEl("scheduler-mode-select"); + const rawMode = modeEl ? modeEl.value : "disabled"; + const mode: SchedulerConfig["mode"] = rawMode === "grayline" || rawMode === "time_span" ? rawMode : "disabled"; - const config = { + const config: SchedulerConfig = { remote: rig, mode, grayline: null, @@ -1150,9 +1196,9 @@ }; if (mode === "grayline") { - const lat = parseFloat(document.getElementById("scheduler-gl-lat").value); - const lon = parseFloat(document.getElementById("scheduler-gl-lon").value); - const win = parseInt(document.getElementById("scheduler-gl-window").value, 10); + const lat = parseFloat(schedulerEl("scheduler-gl-lat").value); + const lon = parseFloat(schedulerEl("scheduler-gl-lon").value); + const win = parseInt(schedulerEl("scheduler-gl-window").value, 10); config.grayline = { lat: isNaN(lat) ? 0 : lat, lon: isNaN(lon) ? 0 : lon, @@ -1165,7 +1211,7 @@ } else if (mode === "time_span") { config.entries = currentConfig && currentConfig.entries ? currentConfig.entries : []; - const ilVal = parseInt(document.getElementById("scheduler-ts-interleave").value, 10); + const ilVal = parseInt(schedulerEl("scheduler-ts-interleave").value, 10); config.interleave_min = isNaN(ilVal) || ilVal <= 0 ? null : ilVal; } @@ -1173,26 +1219,27 @@ config.satellites = collectSatelliteConfig(); // Validate bookmark existence before saving - var missingBmErrors = []; + const missingBmErrors: string[] = []; if (mode === "grayline" && config.grayline) { - var gl = config.grayline; - var glFields = [ + const gl = config.grayline; + const glFields: Array<[keyof typeof gl, string]> = [ ["dawn_bookmark_id", "Grayline dawn"], ["day_bookmark_id", "Grayline day"], ["dusk_bookmark_id", "Grayline dusk"], ["night_bookmark_id", "Grayline night"], ]; glFields.forEach(function (pair) { - if (!bookmarkExists(gl[pair[0]])) missingBmErrors.push(pair[1] + " (bookmark " + gl[pair[0]] + ")"); + const bookmarkId = gl[pair[0]]; + if (typeof bookmarkId === "string" && !bookmarkExists(bookmarkId)) missingBmErrors.push(pair[1] + " (bookmark " + bookmarkId + ")"); }); } if (mode === "time_span" && Array.isArray(config.entries)) { config.entries.forEach(function (entry, idx) { - var label = entry.label || "Entry #" + (idx + 1); + const label = entry.label || "Entry #" + (idx + 1); if (!bookmarkExists(entry.bookmark_id)) { missingBmErrors.push(label + " primary bookmark (" + entry.bookmark_id + ")"); } - var extras = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids : []; + const extras = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids : []; extras.forEach(function (id) { if (!bookmarkExists(id)) { missingBmErrors.push(label + " extra channel (" + id + ")"); @@ -1202,7 +1249,7 @@ } if (config.satellites && Array.isArray(config.satellites.entries)) { config.satellites.entries.forEach(function (sat, idx) { - var satLabel = sat.name || "Satellite #" + (idx + 1); + const satLabel = sat.satellite || "Satellite #" + (idx + 1); if (!bookmarkExists(sat.bookmark_id)) { missingBmErrors.push(satLabel + " bookmark (" + sat.bookmark_id + ")"); } @@ -1213,7 +1260,7 @@ return; } - const btn = document.getElementById("scheduler-save-btn"); + const btn = schedulerEl("scheduler-save-btn"); if (btn) btn.disabled = true; apiPutScheduler(rig, config) @@ -1223,23 +1270,23 @@ clearSchedulerDirty(); showSchedulerToast("Scheduler saved."); }) - .catch(function (e) { - showSchedulerToast("Save failed: " + e.message, true); + .catch(function (error: unknown) { + showSchedulerToast("Save failed: " + (error instanceof Error ? error.message : String(error)), true); }) .finally(function () { if (btn) btn.disabled = false; }); } - function selectVal(id) { - const el = document.getElementById(id); + function selectVal(id: string): string | null { + const el = schedulerEl(id); return el ? el.value : ""; } async function resetScheduler() { const rig = currentRigId; if (!rig) return; - if (!await window.trxUi.confirm({ title: "Reset scheduler?", message: "This rig's scheduler configuration will be reset to Disabled.", confirmLabel: "Reset" })) return; + if (!await schedulerWindow.trxUi.confirm({ title: "Reset scheduler?", message: "This rig's scheduler configuration will be reset to Disabled.", confirmLabel: "Reset" })) return; apiDeleteScheduler(rig) .then(function () { @@ -1253,8 +1300,8 @@ clearSchedulerDirty(); showSchedulerToast("Scheduler reset."); }) - .catch(function (e) { - showSchedulerToast("Reset failed: " + e.message, true); + .catch(function (error: unknown) { + showSchedulerToast("Reset failed: " + (error instanceof Error ? error.message : String(error)), true); }); } @@ -1264,21 +1311,21 @@ function markSchedulerDirty() { if (schedulerDirty) return; schedulerDirty = true; - var btn = document.getElementById("scheduler-save-btn"); + const btn = schedulerEl("scheduler-save-btn"); if (btn) btn.classList.add("sch-dirty"); } function clearSchedulerDirty() { schedulerDirty = false; - var btn = document.getElementById("scheduler-save-btn"); + const btn = schedulerEl("scheduler-save-btn"); if (btn) btn.classList.remove("sch-dirty"); } // ------------------------------------------------------------------------- // Toast helper // ------------------------------------------------------------------------- - function showSchedulerToast(msg, isError) { - const el = document.getElementById("scheduler-toast"); + function showSchedulerToast(msg: string, isError = false): void { + const el = schedulerEl("scheduler-toast"); if (!el) return; el.textContent = msg; el.style.background = isError ? "var(--color-error, #c00)" : "var(--accent-green)"; @@ -1291,61 +1338,62 @@ // ------------------------------------------------------------------------- // Wire events (called once DOM is ready) // ------------------------------------------------------------------------- - function wireSchedulerEvents() { - const modeEl = document.getElementById("scheduler-mode-select"); + function wireSchedulerEvents(): void { + const modeEl = schedulerEl("scheduler-mode-select"); if (modeEl) { modeEl.addEventListener("change", function () { - if (!currentConfig) currentConfig = { remote: currentRigId, mode: modeEl.value, entries: [] }; - currentConfig.mode = modeEl.value; + const mode = modeEl.value === "grayline" || modeEl.value === "time_span" ? modeEl.value : "disabled"; + currentConfig ??= { remote: currentRigId, mode, grayline: null, entries: [] }; + currentConfig.mode = mode; renderScheduler(); }); } - const saveBtn = document.getElementById("scheduler-save-btn"); + const saveBtn = schedulerEl("scheduler-save-btn"); if (saveBtn) saveBtn.addEventListener("click", saveScheduler); - const resetBtn = document.getElementById("scheduler-reset-btn"); - if (resetBtn) resetBtn.addEventListener("click", resetScheduler); + const resetBtn = schedulerEl("scheduler-reset-btn"); + if (resetBtn) resetBtn.addEventListener("click", () => { void resetScheduler(); }); - const addBtn = document.getElementById("scheduler-ts-add-btn"); - if (addBtn) addBtn.addEventListener("click", function () { schOpenEntryForm(null, null); }); + const addBtn = schedulerEl("scheduler-ts-add-btn"); + if (addBtn) addBtn.addEventListener("click", function () { schOpenEntryForm(null); }); - const entryForm = document.getElementById("sch-entry-form"); + const entryForm = schedulerEl("sch-entry-form"); if (entryForm) entryForm.addEventListener("submit", schEntryFormSubmit); - const cancelBtn = document.getElementById("sch-entry-form-cancel"); + const cancelBtn = schedulerEl("sch-entry-form-cancel"); if (cancelBtn) cancelBtn.addEventListener("click", schCloseEntryForm); - const prevBtn = document.getElementById("scheduler-prev-btn"); + const prevBtn = schedulerEl("scheduler-prev-btn"); if (prevBtn) prevBtn.addEventListener("click", function () { schedulerSelectRelativeEntry(-1); }); - const nextBtn = document.getElementById("scheduler-next-btn"); + const nextBtn = schedulerEl("scheduler-next-btn"); if (nextBtn) nextBtn.addEventListener("click", function () { schedulerSelectRelativeEntry(1); }); // Dirty-state: mark dirty on any user input/change within the scheduler panel - var schPanel = document.getElementById("scheduler-panel"); - if (schPanel && !schPanel._dirtyWired) { - schPanel._dirtyWired = true; + const schPanel = schedulerEl("scheduler-panel"); + if (schPanel && !wiredElements.has(schPanel)) { + wiredElements.add(schPanel); schPanel.addEventListener("input", function (e) { // Ignore the entry-form inputs (they don't affect saved config until submitted) - if (e.target.closest("#sch-entry-form") || e.target.closest("#sch-sat-form")) return; + if (!(e.target instanceof Element) || e.target.closest("#sch-entry-form") || e.target.closest("#sch-sat-form")) return; markSchedulerDirty(); }); schPanel.addEventListener("change", function (e) { - if (e.target.closest("#sch-entry-form") || e.target.closest("#sch-sat-form")) return; + if (!(e.target instanceof Element) || e.target.closest("#sch-entry-form") || e.target.closest("#sch-sat-form")) return; markSchedulerDirty(); }); } // Grid square ↔ lat/lon sync - var gridEl = document.getElementById("scheduler-gl-grid"); + const gridEl = schedulerEl("scheduler-gl-grid"); if (gridEl) { gridEl.addEventListener("input", function () { - var ll = gridToLatLon(gridEl.value); + const ll = gridToLatLon(gridEl.value); if (ll) { setInputValue("scheduler-gl-lat", ll.lat.toFixed(3)); setInputValue("scheduler-gl-lon", ll.lon.toFixed(3)); @@ -1353,14 +1401,14 @@ } }); } - var latEl = document.getElementById("scheduler-gl-lat"); - var lonEl = document.getElementById("scheduler-gl-lon"); + const latEl = schedulerEl("scheduler-gl-lat"); + const lonEl = schedulerEl("scheduler-gl-lon"); [latEl, lonEl].forEach(function (el) { if (el) { el.addEventListener("input", function () { - var la = parseFloat(document.getElementById("scheduler-gl-lat").value); - var lo = parseFloat(document.getElementById("scheduler-gl-lon").value); - var gEl = document.getElementById("scheduler-gl-grid"); + const la = parseFloat(schedulerEl("scheduler-gl-lat").value); + const lo = parseFloat(schedulerEl("scheduler-gl-lon").value); + const gEl = schedulerEl("scheduler-gl-grid"); if (gEl && !isNaN(la) && !isNaN(lo)) { gEl.value = latLonToGrid(la, lo); } @@ -1373,8 +1421,8 @@ } function populateTsBookmarkSelect() { - const sel = document.getElementById("scheduler-ts-bookmark"); - const extraSel = document.getElementById("scheduler-ts-extra-bm-pick"); + const sel = schedulerEl("scheduler-ts-bookmark"); + const extraSel = schedulerEl("scheduler-ts-extra-bm-pick"); [sel, extraSel].forEach(function (el) { if (!el) return; const prev = el.value; @@ -1390,17 +1438,17 @@ } // Pending extra bookmark IDs for the entry being composed in the add form. - let pendingExtraBmIds = []; + let pendingExtraBmIds: string[] = []; function renderExtraBmList() { - var container = document.getElementById("scheduler-ts-extra-bm-list"); + const container = schedulerEl("scheduler-ts-extra-bm-list"); if (!container) return; container.innerHTML = ""; pendingExtraBmIds.forEach(function (id, idx) { - var bm = bookmarkList.find(function (b) { return b.id === id; }); - var chip = document.createElement("span"); + const bm = bookmarkList.find(function (b) { return b.id === id; }); + const chip = document.createElement("span"); chip.className = "sch-extra-bm-chip"; - var rmBtn = document.createElement("span"); + const rmBtn = document.createElement("span"); rmBtn.className = "sch-extra-bm-chip-rm"; rmBtn.textContent = "\u00D7"; rmBtn.title = "Remove"; @@ -1409,13 +1457,13 @@ renderExtraBmList(); }); chip.appendChild(rmBtn); - var label = document.createTextNode(" " + (bm ? bm.name : id)); + const label = document.createTextNode(" " + (bm ? bm.name : id)); chip.appendChild(label); container.appendChild(chip); }); // Disable already-added bookmarks in dropdown - var pick = document.getElementById("scheduler-ts-extra-bm-pick"); + const pick = schedulerEl("scheduler-ts-extra-bm-pick"); if (pick) { Array.from(pick.options).forEach(function (opt) { if (opt.value) { @@ -1426,11 +1474,11 @@ } function wireExtraBmAdd() { - const addBtn = document.getElementById("scheduler-ts-extra-bm-add"); - if (!addBtn || addBtn._wired) return; - addBtn._wired = true; + const addBtn = schedulerEl("scheduler-ts-extra-bm-add"); + if (!addBtn || wiredElements.has(addBtn)) return; + wiredElements.add(addBtn); addBtn.addEventListener("click", function () { - const pick = document.getElementById("scheduler-ts-extra-bm-pick"); + const pick = schedulerEl("scheduler-ts-extra-bm-pick"); if (!pick || !pick.value) return; if (!pendingExtraBmIds.includes(pick.value)) { pendingExtraBmIds.push(pick.value); @@ -1444,38 +1492,31 @@ // Satellite overlay (delegated to sat-scheduler.js) // ------------------------------------------------------------------------- function renderSatelliteSection() { - if (window.satScheduler) window.satScheduler.renderSection(); + schedulerWindow.satScheduler?.renderSection(); } function renderSatPassStatus() { - if (window.satScheduler) window.satScheduler.renderPassStatus(); + schedulerWindow.satScheduler?.renderPassStatus(); } function collectSatelliteConfig() { - return window.satScheduler - ? window.satScheduler.collectSatelliteConfig() + return schedulerWindow.satScheduler + ? schedulerWindow.satScheduler.collectSatelliteConfig() : { enabled: false, pretune_secs: 60, entries: [] }; } function wireSatelliteEvents() { - // Expose bridge for sat-scheduler.js to access shared state. - window.schedulerBridge = { - getConfig: function () { return currentConfig; }, - getStatus: function () { return currentSchedulerStatus; }, - getBookmarks: function () { return bookmarkList; }, - markDirty: function () { markSchedulerDirty(); }, - }; - if (window.satScheduler) window.satScheduler.wireEvents(); + schedulerWindow.satScheduler?.wireEvents(); } // ------------------------------------------------------------------------- // Keyboard shortcuts for scheduler control // ------------------------------------------------------------------------- function isInputFocused() { - var el = document.activeElement; + const el = document.activeElement; if (!el) return false; - var tag = el.tagName; - return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable; + const tag = el.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || (el instanceof HTMLElement && el.isContentEditable); } document.addEventListener("keydown", function (e) { @@ -1484,7 +1525,7 @@ if (e.shiftKey && e.key === "R") { e.preventDefault(); // Toggle release to scheduler - var releaseBtn = document.getElementById("scheduler-release-btn"); + const releaseBtn = schedulerEl("scheduler-release-btn"); if (releaseBtn && !releaseBtn.disabled) releaseBtn.click(); } else if (e.shiftKey && e.key === "N") { e.preventDefault(); @@ -1500,27 +1541,35 @@ // ------------------------------------------------------------------------- // Persist details open/closed state (function () { - var details = document.querySelector(".sch-ts-details"); + const details = document.querySelector(".sch-ts-details"); if (!details) return; - var key = "sch-details-open"; - var saved = localStorage.getItem(key); - if (saved !== null) details.open = saved === "1"; + const schedulerDetails = details; + const key = "sch-details-open"; + const saved = localStorage.getItem(key); + if (saved !== null) schedulerDetails.open = saved === "1"; details.addEventListener("toggle", function () { - localStorage.setItem(key, details.open ? "1" : "0"); + localStorage.setItem(key, schedulerDetails.open ? "1" : "0"); }); })(); - window.initScheduler = initScheduler; - window.destroyScheduler = destroyScheduler; - window.wireSchedulerEvents = wireSchedulerEvents; - window.setSchedulerRig = setSchedulerRig; + const schedulerService: SchedulerService = { + initialize: initScheduler, + destroy: destroyScheduler, + wireEvents: wireSchedulerEvents, + setRig: setSchedulerRig, + getConfig: () => currentConfig, + getStatus: () => currentSchedulerStatus, + getBookmarks: () => bookmarkList, + markDirty: markSchedulerDirty, + }; + schedulerWindow.trx.modules.scheduler = schedulerService; // Auto-initialize if the app has already booted (lazy-load case). // When loaded eagerly, initSettingsUI() in app.js calls initScheduler(); // when loaded lazily (e.g. settings tab click after boot), the app has // already passed that point, so we must self-initialize here. - if (typeof authRole !== "undefined" && authRole !== null) { - initScheduler(typeof lastActiveRigId !== "undefined" ? lastActiveRigId : null, authRole); + if (schedulerWindow.authRole != null) { + initScheduler(schedulerWindow.lastActiveRigId ?? null, schedulerWindow.authRole); wireSchedulerEvents(); } })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/scheduler.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/scheduler.test.mjs new file mode 100644 index 00000000..a2d9f015 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/scheduler.test.mjs @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import vm from "node:vm"; + +test("scheduler registers a typed module service without lifecycle globals", async () => { + const window = { trx: { modules: {} }, trxUi: { confirm: async () => true } }; + const context = vm.createContext({ + window, + document: { + activeElement: null, + addEventListener() {}, + getElementById: () => null, + querySelector: () => null, + }, + localStorage: { getItem: () => null, setItem() {} }, + setInterval: () => 1, + clearInterval() {}, + setTimeout: () => 1, + HTMLElement: class HTMLElement {}, + Element: class Element {}, + Date, + Number, + String, + Array, + Promise, + WeakSet, + console, + }); + const source = await readFile(new URL("../../assets/web/generated/scheduler.js", import.meta.url), "utf8"); + new vm.Script(source).runInContext(context); + + const service = window.trx.modules.scheduler; + assert.equal(typeof service.initialize, "function"); + assert.equal(typeof service.setRig, "function"); + assert.equal(typeof service.getConfig, "function"); + assert.equal(window.initScheduler, undefined); + assert.equal(window.schedulerBridge, undefined); +});