Files
trx-rs/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js
T
sjgandClaude Opus 5 0d4c657b97
CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Failing after 1s
CI / frontend (pull_request) Failing after 41s
CI / reuse (pull_request) Failing after 2s
CI / lint (push) Failing after 2s
CI / test (push) Failing after 2s
CI / frontend (push) Failing after 36s
CI / reuse (push) Failing after 1s
[fix](trx-frontend-http): route feature bundles through the host contract
The bookmark fix addressed one instance of a defect the TypeScript
migration left across the feature entries.  app.js stopped being a
classic script, so its top-level declarations are no longer shared
globals, but the converted entries kept reading them as window
properties that nothing publishes.

Restore the broken behavior:

- ais, aprs, hf-aprs read serverLat, serverLon and haversineKm as
  undefined, so every positioned packet rendered an empty distance.
- ais, aprs, hf-aprs, cw, sat, vdes, wefax, wspr called an undefined
  postPath, so clear-history and decoder toggles threw.
- scheduler read authRole as undefined, so the lazy-load path never
  self-initialized and the Settings tab opened an inert scheduler.
- background-decode read authEnabled as undefined, so control gating
  fell back to role-only.
- vchan read fifteen application values and services as undefined:
  mode and bandwidth sync, the out-of-band hint, RX audio restart, and
  the frequency field all silently no-opped on a virtual channel.
- vchan wrapped window.refreshFreqDisplay, capturing an undefined
  original exactly as it did for setRigFrequency, so leaving a channel
  never restored the application's own frequency display.
- _audioChannelOverride was a const that nothing could assign, so RX
  audio always subscribed to the primary channel.
- ftx-family read fmtTime, a helper legacy ft8.js owned locally, so
  decode bar timestamps rendered empty.

Declare the contract once in plugins/host.ts and import it from the
feature entries, rather than restoring globals that
docs/frontend-architecture.md excludes.  trx.state gains jogUnit,
rxActive and audioChannelOverride, and makes lastModeName writable;
trx.core gains the tuning, RDS, WFM, jog and RX audio services the
entries need.  vchan interception moves to an interceptFreqDisplay
service method that refreshFreqDisplay calls, matching the frequency,
mode and bandwidth interception it already registers.

Reading registry-built elements through a strict lookup is the same
defect as in bookmarks: renderTimelineNeedle guards its result, but
schedulerEl throws, so the now-initializing scheduler crashed on the
timeline needle group that its own SVG creates.

Feature tests move onto a shared host fixture, and entries that now
import a common module are bundled through bundleEntry like the other
shared-module entries.  Covers scheduler self-initialization and the
distance path that the bare window reads broke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz
Signed-off-by: Stan Grams <sjg@haxx.space>
2026-08-02 11:20:17 +02:00

1228 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
hostState
} from "./chunk-KL66PICH.js";
// 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 schedulerOptionalEl(id) {
return document.getElementById(id);
}
(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();
}
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) {
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() {
const 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(error) {
console.error("scheduler load failed", error);
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 };
}
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;
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 + '<br><span class="sch-status-detail">' + escHtml(details) + "</span>";
} 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 '<div class="sch-log-entry"><span class="sch-log-time">' + escHtml(ts) + '</span> <span class="sch-log-action">' + escHtml(action) + "</span> " + (bm ? '<span class="sch-log-bm">' + escHtml(bm) + "</span>" : "") + (label ? ' <span class="sch-log-label">(' + escHtml(label) + ")</span>" : "") + "</div>";
}).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 ?? hostState.serverLat ?? "";
const lon = gl.lon ?? hostState.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 = hostState.serverLat ?? "";
const lon = hostState.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 = '<option value="">— none —</option>';
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 = 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 xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + W + " " + H + '" preserveAspectRatio="none">';
svg += '<rect x="0" y="' + BAR_Y + '" width="' + W + '" height="' + BAR_H + '" rx="3" fill="var(--btn-bg)" />';
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 += '<rect class="sch-timeline-seg" x="0" y="' + BAR_Y + '" width="' + W + '" height="' + BAR_H + '" rx="3" fill="' + color + '" data-idx="' + idx + '" />';
} else if (start < end) {
const x = start / 1440 * W;
const w = (end - start) / 1440 * W;
svg += '<rect class="sch-timeline-seg" x="' + x.toFixed(1) + '" y="' + BAR_Y + '" width="' + w.toFixed(1) + '" height="' + BAR_H + '" fill="' + color + '" data-idx="' + idx + '" />';
} else {
const x1 = start / 1440 * W;
const w1 = W - x1;
svg += '<rect class="sch-timeline-seg" x="' + x1.toFixed(1) + '" y="' + BAR_Y + '" width="' + w1.toFixed(1) + '" height="' + BAR_H + '" fill="' + color + '" data-idx="' + idx + '" />';
const w2 = end / 1440 * W;
svg += '<rect class="sch-timeline-seg" x="0" y="' + BAR_Y + '" width="' + w2.toFixed(1) + '" height="' + BAR_H + '" fill="' + color + '" data-idx="' + idx + '" />';
}
});
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 += '<rect x="' + stripeX.toFixed(1) + '" y="' + (BAR_Y + BAR_H - 5) + '" width="' + stripeW.toFixed(1) + '" height="5" fill="' + stripeColor + '" opacity="0.9" />';
}
}
}
for (let h = 0; h <= 24; h += 3) {
const tx = h / 24 * W;
svg += '<line x1="' + tx.toFixed(1) + '" y1="' + TICK_Y + '" x2="' + tx.toFixed(1) + '" y2="' + (TICK_Y + 5) + '" stroke="var(--border-light)" stroke-width="1" />';
if (h < 24) {
svg += '<text class="sch-timeline-tick-label" x="' + (tx + 3).toFixed(1) + '" y="' + (TICK_Y + 16) + '">' + String(h).padStart(2, "0") + "</text>";
}
}
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 += '<text class="sch-timeline-tick-label sch-timeline-local-tick" x="' + (tx + 3).toFixed(1) + '" y="' + (LOCAL_TICK_Y + 10) + '">' + String(h).padStart(2, "0") + "L</text>";
}
svg += '<g id="sch-timeline-needle-g">' + timelineNeedleSvg() + "</g>";
svg += "</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 '<line class="sch-timeline-needle" x1="' + x.toFixed(1) + '" y1="2" x2="' + x.toFixed(1) + '" y2="38" /><polygon class="sch-timeline-needle-head" points="' + (x - 3).toFixed(1) + ",2 " + (x + 3).toFixed(1) + ",2 " + x.toFixed(1) + ',6" />';
}
function renderTimelineNeedle() {
const g = schedulerOptionalEl("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 '<option value="' + escHtml(bm.id) + '"' + sel + ">" + escHtml(bm.name) + "</option>";
}).join("");
const extraBmOptions = '<option value="">— add channel —</option>' + bookmarkList.map(function(bm) {
return '<option value="' + escHtml(bm.id) + '">' + escHtml(bm.name) + "</option>";
}).join("");
const inlineExtraIds = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids.slice() : [];
tr.innerHTML = '<td class="sch-drag-handle" draggable="true" title="Drag to reorder">⠇</td><td><input type="time" class="status-input sch-inline-input" value="' + minToHHMM(entry.start_min) + '" data-field="start" /></td><td><input type="time" class="status-input sch-inline-input" value="' + minToHHMM(entry.end_min) + '" data-field="end" /></td><td>' + (entry.center_hz ? formatFreq(entry.center_hz) : "—") + '</td><td><select class="status-input sch-inline-input" data-field="bookmark">' + bmOptions + '</select></td><td><div class="sch-inline-extra-chips"></div><div style="display:flex;gap:0.4rem;margin-top:0.3rem;"><select class="status-input sch-inline-extra-pick">' + extraBmOptions + '</select><button class="sch-write sch-inline-extra-add" type="button" style="padding:0 0.7rem;">+</button></div></td><td><input type="text" class="status-input sch-inline-input" value="' + escHtml(entry.label || "") + '" data-field="label" /></td><td><input type="number" class="status-input sch-inline-input" value="' + (entry.interleave_min || "") + '" min="1" max="60" placeholder="—" data-field="interleave" style="width:4rem;" ' + (entry.exclusive ? "disabled" : "") + ' /><label style="display:block;font-size:0.85em;margin-top:0.2rem;"><input type="checkbox" data-field="exclusive" ' + (entry.exclusive ? "checked" : "") + ' /> Excl.</label></td><td><input type="checkbox" ' + (entry.record ? "checked" : "") + ' data-field="record" /></td><td><button class="sch-write sch-inline-save" type="button">Save</button><button class="sch-write sch-inline-cancel" type="button">Cancel</button></td>';
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();
});
tr.querySelector(".sch-inline-cancel")?.addEventListener("click", function() {
renderTimespanEntries();
});
}
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 = '<td class="sch-drag-handle" draggable="true" title="Drag to reorder">⠇</td><td>' + (allDay ? "All day" : minToHHMM(entry.start_min) + ' <span class="sch-local-time">(' + minToLocal(entry.start_min) + ")</span>") + "</td><td>" + (allDay ? "—" : minToHHMM(entry.end_min) + ' <span class="sch-local-time">(' + minToLocal(entry.end_min) + ")</span>") + "</td><td>" + centerCell + "</td><td>" + escHtml(bmName(entry.bookmark_id)) + "</td><td>" + extraCell + "</td><td>" + escHtml(entry.label || "") + "</td><td>" + il + "</td><td>" + (entry.record ? "Yes" : "") + '</td><td><button class="sch-write sch-edit-btn" data-idx="' + idx + '" type="button">Edit</button><button class="sch-write sch-remove-btn" data-idx="' + idx + '" type="button">Remove</button></td>';
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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.trx.modules.vchan?.takeSchedulerControl() ?? null).then(function() {
return apiActivateSchedulerEntry(rigId, targetId);
}).then(function(status) {
currentSchedulerStatus = status || null;
return Promise.resolve(
schedulerWindow.trx.modules.vchan?.releaseToScheduler() ?? 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 = '<option value="">— select bookmark —</option>';
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 (hostState.authRole != null) {
initScheduler(hostState.lastActiveRigId, hostState.authRole);
wireSchedulerEvents();
}
})();