CI / test (push) Successful in 8m12s
CI / frontend (push) Successful in 4m15s
CI / reuse (push) Successful in 5s
CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 9m6s
CI / frontend (pull_request) Successful in 5m17s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m26s
1583 lines
63 KiB
TypeScript
1583 lines
63 KiB
TypeScript
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
//
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
import { hostState } from "./host.js";
|
|
import { hasAuthRole, type AuthRole } from "../api/auth.js";
|
|
|
|
import type {
|
|
ScheduleEntry,
|
|
SchedulerBookmark,
|
|
SchedulerConfig,
|
|
SchedulerService,
|
|
SchedulerStatus,
|
|
SchedulerWindow,
|
|
} from "./scheduler-types.js";
|
|
|
|
export {};
|
|
|
|
/* Scheduler DOM IDs are part of the server-rendered page contract. */
|
|
/* Timeline SVG and table templates intentionally concatenate typed numeric
|
|
* coordinates with markup fragments. */
|
|
|
|
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<Element>();
|
|
function schedulerEl(id: string): SchedulerElement {
|
|
const element = document.getElementById(id);
|
|
if (!element) throw new Error(`Missing scheduler element #${id}`);
|
|
return element as SchedulerElement;
|
|
}
|
|
|
|
/* The timeline needle group is drawn by this feature's own SVG markup, so it is
|
|
* absent until the timeline has rendered at least once. */
|
|
function schedulerOptionalEl(id: string): SchedulerElement | null {
|
|
return document.getElementById(id) as SchedulerElement | null;
|
|
}
|
|
|
|
// Background Decoding Scheduler UI
|
|
|
|
(function () {
|
|
"use strict";
|
|
|
|
// -------------------------------------------------------------------------
|
|
// State
|
|
// -------------------------------------------------------------------------
|
|
let schedulerRoles: readonly AuthRole[] = [];
|
|
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: number | null = null;
|
|
let schedulerDirty = false; // true when unsaved changes exist
|
|
// Satellite entry editing state moved to sat-scheduler.js
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Init
|
|
// -------------------------------------------------------------------------
|
|
function initScheduler(rigId: string | null, roles: readonly AuthRole[]): void {
|
|
schedulerRoles = roles;
|
|
currentRigId = rigId || null;
|
|
if (currentRigId) loadScheduler();
|
|
startStatusPolling();
|
|
startInterleaveTicker();
|
|
}
|
|
|
|
function destroyScheduler() {
|
|
if (statusInterval) {
|
|
clearInterval(statusInterval);
|
|
statusInterval = null;
|
|
}
|
|
if (interleaveTicker) {
|
|
clearInterval(interleaveTicker);
|
|
interleaveTicker = null;
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Active rig (mirrors top-bar rig picker in app.js)
|
|
// -------------------------------------------------------------------------
|
|
function setSchedulerRig(rigId: string | null): void {
|
|
const nextRigId = rigId || null;
|
|
if (nextRigId === currentRigId) return;
|
|
currentRigId = nextRigId;
|
|
renderSchedulerInterleaveStatus();
|
|
if (!currentRigId) return;
|
|
loadScheduler();
|
|
pollStatus();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// API helpers
|
|
// -------------------------------------------------------------------------
|
|
function apiGetScheduler(rigId: string): Promise<SchedulerConfig> {
|
|
return fetch("/scheduler/" + encodeURIComponent(rigId)).then(function (r) {
|
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
return r.json() as Promise<SchedulerConfig>;
|
|
});
|
|
}
|
|
|
|
function apiPutScheduler(rigId: string, config: SchedulerConfig): Promise<SchedulerConfig> {
|
|
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() as Promise<SchedulerConfig>;
|
|
});
|
|
}
|
|
|
|
function apiDeleteScheduler(rigId: string): Promise<unknown> {
|
|
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: string): Promise<SchedulerStatus> {
|
|
return fetch("/scheduler/" + encodeURIComponent(rigId) + "/status").then(
|
|
function (r) {
|
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
return r.json() as Promise<SchedulerStatus>;
|
|
}
|
|
);
|
|
}
|
|
|
|
function apiActivateSchedulerEntry(rigId: string, entryId: string): Promise<SchedulerStatus> {
|
|
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() as Promise<SchedulerStatus>;
|
|
});
|
|
}
|
|
|
|
function apiGetBookmarks(): Promise<SchedulerBookmark[]> {
|
|
// Fetch merged general + rig-specific bookmarks in a single request.
|
|
const url = currentRigId
|
|
? "/bookmarks?scope=" + encodeURIComponent(currentRigId)
|
|
: "/bookmarks";
|
|
return fetch(url).then(function (r) { return r.ok ? r.json() as Promise<SchedulerBookmark[]> : []; });
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Load config + bookmarks
|
|
// -------------------------------------------------------------------------
|
|
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: unknown) {
|
|
console.error("scheduler load failed", error);
|
|
renderSchedulerInterleaveStatus();
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Status polling
|
|
// -------------------------------------------------------------------------
|
|
function startStatusPolling() {
|
|
if (statusInterval) clearInterval(statusInterval);
|
|
statusInterval = setInterval(pollStatus, 15000);
|
|
pollStatus();
|
|
}
|
|
|
|
function startInterleaveTicker() {
|
|
if (interleaveTicker) clearInterval(interleaveTicker);
|
|
interleaveTicker = setInterval(renderSchedulerInterleaveStatus, 1000);
|
|
renderSchedulerInterleaveStatus();
|
|
}
|
|
|
|
function schedulerUtcSeconds() {
|
|
return Math.floor(Date.now() / 1000);
|
|
}
|
|
|
|
function schedulerUtcMinuteInfo() {
|
|
const secs = schedulerUtcSeconds();
|
|
const secsIntoDay = ((secs % 86400) + 86400) % 86400;
|
|
return {
|
|
minuteOfDay: Math.floor(secsIntoDay / 60),
|
|
secondOfMinute: secsIntoDay % 60,
|
|
};
|
|
}
|
|
|
|
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;
|
|
if (start === end) return true;
|
|
if (start < end) return nowMin >= start && nowMin < end;
|
|
return nowMin >= start || nowMin < end;
|
|
}
|
|
|
|
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;
|
|
if (start === end) return 0;
|
|
if (start < end) return start;
|
|
return nowMin >= start ? start : (start - 1440);
|
|
}
|
|
|
|
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: SchedulerConfig | null): { activeEntries: ScheduleEntry[]; currentIndex: number; remainingSec: number; cycleMin: number } {
|
|
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 };
|
|
}
|
|
// Exclusive entry wins outright — no interleaving.
|
|
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: number, value: number) { 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 remainingSec = (manualDurationMin > 0)
|
|
? Math.max(1, (manualDurationMin * 60) - elapsedSec)
|
|
: 0;
|
|
if (remainingSec > 0) {
|
|
return {
|
|
activeEntries: active,
|
|
currentIndex: statusIndex,
|
|
remainingSec: remainingSec,
|
|
cycleMin: cycleMin,
|
|
};
|
|
}
|
|
}
|
|
const overlapStart = active.reduce(function (maxStart: number, entry: ScheduleEntry) {
|
|
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: currentIndex,
|
|
remainingSec: remainingSec,
|
|
cycleMin: 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";
|
|
}
|
|
|
|
// Also update the timeline needle if visible
|
|
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 =
|
|
hasAuthRole(schedulerRoles, "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: 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.";
|
|
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 * 1000);
|
|
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 += " \u00B7 " + st.mode;
|
|
if (st.active_decoders && st.active_decoders.length > 0) {
|
|
details += " \u00B7 " + 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;
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Activity log
|
|
// -------------------------------------------------------------------------
|
|
function apiGetSchedulerLog(rigId: string): Promise<SchedulerLogEntry[]> {
|
|
return fetch("/scheduler/" + encodeURIComponent(rigId) + "/log").then(function (r) {
|
|
return r.ok ? r.json() as Promise<SchedulerLogEntry[]> : [];
|
|
});
|
|
}
|
|
|
|
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 * 1000);
|
|
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 () {});
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Render the full scheduler panel
|
|
// -------------------------------------------------------------------------
|
|
function renderScheduler() {
|
|
const panel = schedulerEl("scheduler-panel");
|
|
if (!panel) return;
|
|
|
|
const mode = (currentConfig && currentConfig.mode) || "disabled";
|
|
const isControl = hasAuthRole(schedulerRoles, "control");
|
|
|
|
// Mode selector
|
|
setSelected("scheduler-mode-select", mode);
|
|
|
|
// 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<HTMLElement>(".scheduler-control-row");
|
|
if (controlRow) controlRow.style.display = (mode !== "disabled" || satEnabled) ? "" : "none";
|
|
|
|
// Show/hide sections
|
|
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";
|
|
|
|
// Satellite overlay (always visible — independent of mode)
|
|
renderSatelliteSection();
|
|
|
|
// Grayline inputs
|
|
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 ?? 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") {
|
|
// No saved grayline config yet — pre-fill coords from server if available.
|
|
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);
|
|
}
|
|
|
|
// Interleave input
|
|
const ilEl = schedulerEl("scheduler-ts-interleave");
|
|
if (ilEl) {
|
|
const il = currentConfig && currentConfig.interleave_min;
|
|
ilEl.value = il ? String(il) : "";
|
|
}
|
|
|
|
// TimeSpan entries
|
|
renderTimespanEntries();
|
|
|
|
// Enable/disable controls
|
|
const formEls = panel.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>("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: string, value: string): void {
|
|
const el = schedulerEl(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: string, selectedId: string | null): void {
|
|
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: number): string {
|
|
if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz";
|
|
if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz";
|
|
return hz + " Hz";
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Entry form (inline card below Add Entry button)
|
|
// -------------------------------------------------------------------------
|
|
function schOpenEntryForm(entry: ScheduleEntry | null, idx?: number): void {
|
|
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: Event): void {
|
|
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: ScheduleEntry = {
|
|
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();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 24h Timeline Bar
|
|
// -------------------------------------------------------------------------
|
|
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 = 1000;
|
|
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">';
|
|
|
|
// Background bar
|
|
svg += '<rect x="0" y="' + BAR_Y + '" width="' + W + '" height="' + BAR_H + '" rx="3" fill="var(--btn-bg)" />';
|
|
|
|
// Entry segments
|
|
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) {
|
|
// All-day entry
|
|
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 {
|
|
// Wrap-around: two segments
|
|
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 + '" />';
|
|
}
|
|
});
|
|
|
|
// Interleave stripes for overlapping entries
|
|
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 (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) {
|
|
const stripeX = (m / 1440) * W;
|
|
const stripeW = Math.max(1, (interleaveMin / 1440) * W);
|
|
// Determine which entry "owns" this stripe via cycle position
|
|
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" />';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Tick marks every 3 hours
|
|
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>';
|
|
}
|
|
}
|
|
|
|
// Local time ticks
|
|
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 += '<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>';
|
|
}
|
|
|
|
// Current time needle
|
|
svg += '<g id="sch-timeline-needle-g">' + timelineNeedleSvg() + '</g>';
|
|
|
|
svg += '</svg>';
|
|
container.innerHTML = svg;
|
|
|
|
// Wire click events on segments
|
|
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);
|
|
});
|
|
});
|
|
|
|
// Click-to-add on empty timeline region
|
|
const svgEl = container.querySelector<SVGSVGElement>('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 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);
|
|
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) * 1000;
|
|
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();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Inline row editing
|
|
// -------------------------------------------------------------------------
|
|
function schInlineEdit(tr: HTMLTableRowElement, entry: ScheduleEntry, idx: number): void {
|
|
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">\u2807</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) : '\u2014') + '</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="\u2014" 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<HTMLElement>('.sch-inline-extra-chips');
|
|
const extraPick = tr.querySelector<HTMLSelectElement>('.sch-inline-extra-pick');
|
|
const extraAddBtn = tr.querySelector<HTMLButtonElement>('.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 = '\u00D7';
|
|
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 = '';
|
|
});
|
|
|
|
// Wire exclusive checkbox to disable interleave input.
|
|
const exclEl = tr.querySelector<HTMLInputElement>('[data-field="exclusive"]');
|
|
const ilInput = tr.querySelector<HTMLInputElement>('[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<HTMLInputElement>('[data-field="start"]');
|
|
const endEl = tr.querySelector<HTMLInputElement>('[data-field="end"]');
|
|
const bmEl = tr.querySelector<HTMLSelectElement>('[data-field="bookmark"]');
|
|
const labelEl = tr.querySelector<HTMLInputElement>('[data-field="label"]');
|
|
const ilEl = tr.querySelector<HTMLInputElement>('[data-field="interleave"]');
|
|
const recEl = tr.querySelector<HTMLInputElement>('[data-field="record"]');
|
|
const exEl = tr.querySelector<HTMLInputElement>('[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();
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// TimeSpan entries table
|
|
// -------------------------------------------------------------------------
|
|
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">\u2807</td>' +
|
|
'<td>' + (allDay ? "All day" : minToHHMM(entry.start_min) + ' <span class="sch-local-time">(' + minToLocal(entry.start_min) + ')</span>') + '</td>' +
|
|
'<td>' + (allDay ? "\u2014" : 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 as HTMLElement).dataset.idx ?? "", 10);
|
|
const entry = currentConfig && currentConfig.entries ? currentConfig.entries[i] : null;
|
|
const row = btn.closest<HTMLTableRowElement>('tr');
|
|
if (entry && row) schInlineEdit(row, entry, i);
|
|
});
|
|
});
|
|
tbody.querySelectorAll(".sch-remove-btn").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
removeEntry(parseInt((btn as HTMLElement).dataset.idx ?? "", 10));
|
|
});
|
|
});
|
|
|
|
// Drag-to-reorder
|
|
(function () {
|
|
const handles = tbody.querySelectorAll('.sch-drag-handle');
|
|
let dragIdx: number | null = null;
|
|
|
|
handles.forEach(function (handle, idx) {
|
|
const row = handle.parentElement;
|
|
if (!row) return;
|
|
const dragRow = row;
|
|
|
|
handle.addEventListener('dragstart', function (event) {
|
|
const e = event as DragEvent;
|
|
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 entries = currentConfig.entries;
|
|
const moved = entries.splice(dragIdx, 1)[0];
|
|
if (moved) entries.splice(idx, 0, moved);
|
|
renderTimespanEntries();
|
|
markSchedulerDirty();
|
|
});
|
|
|
|
handle.addEventListener('dragend', function () {
|
|
dragRow.classList.remove('sch-dragging');
|
|
dragIdx = null;
|
|
});
|
|
});
|
|
})();
|
|
|
|
renderTimeline();
|
|
}
|
|
|
|
function bmName(id: string): string {
|
|
const bm = bookmarkList.find(function (b) { return b.id === id; });
|
|
return bm ? bm.name : String(id || "");
|
|
}
|
|
|
|
function minToLocal(min: number): string {
|
|
// Convert UTC minutes-since-midnight to local time string
|
|
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: 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: string): number {
|
|
const parts = str.split(":");
|
|
return parseInt(parts[0] || "0", 10) * 60 + parseInt(parts[1] || "0", 10);
|
|
}
|
|
|
|
function gridToLatLon(grid: string): { lat: number; lon: number } | null {
|
|
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; // center of square
|
|
lat += 0.5;
|
|
}
|
|
return { lat: lat, lon: lon };
|
|
}
|
|
|
|
function latLonToGrid(lat: number, lon: number): string {
|
|
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: unknown): string {
|
|
return String(s)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
function schedulerSelectRelativeEntry(delta: number): void {
|
|
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: 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;
|
|
renderSchedulerStepControls();
|
|
});
|
|
}
|
|
|
|
function removeEntry(idx: number): void {
|
|
if (!currentConfig || !currentConfig.entries) return;
|
|
currentConfig.entries.splice(idx, 1);
|
|
renderTimespanEntries();
|
|
markSchedulerDirty();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Bookmark existence check
|
|
// -------------------------------------------------------------------------
|
|
function bookmarkExists(id: string): boolean {
|
|
if (!id) return true; // null/empty is allowed
|
|
return bookmarkList.some(function (bm) { return bm.id === id; });
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Save
|
|
// -------------------------------------------------------------------------
|
|
function saveScheduler() {
|
|
const rig = currentRigId;
|
|
if (!rig) return;
|
|
|
|
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: SchedulerConfig = {
|
|
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;
|
|
}
|
|
|
|
// Satellite overlay — saved regardless of base mode.
|
|
config.satellites = collectSatelliteConfig();
|
|
|
|
// Validate bookmark existence before saving
|
|
const missingBmErrors: string[] = [];
|
|
if (mode === "grayline" && config.grayline) {
|
|
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) {
|
|
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: unknown) {
|
|
showSchedulerToast("Save failed: " + (error instanceof Error ? error.message : String(error)), true);
|
|
})
|
|
.finally(function () {
|
|
if (btn) btn.disabled = false;
|
|
});
|
|
}
|
|
|
|
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 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: unknown) {
|
|
showSchedulerToast("Reset failed: " + (error instanceof Error ? error.message : String(error)), true);
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Dirty-state tracking
|
|
// -------------------------------------------------------------------------
|
|
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");
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Toast helper
|
|
// -------------------------------------------------------------------------
|
|
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)";
|
|
el.style.display = "block";
|
|
setTimeout(function () {
|
|
el.style.display = "none";
|
|
}, 3000);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Wire events (called once DOM is ready)
|
|
// -------------------------------------------------------------------------
|
|
function wireSchedulerEvents(): void {
|
|
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);
|
|
});
|
|
|
|
// Dirty-state: mark dirty on any user input/change within the scheduler panel
|
|
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 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();
|
|
});
|
|
}
|
|
|
|
// Grid square ↔ lat/lon sync
|
|
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;
|
|
});
|
|
}
|
|
|
|
// Pending extra bookmark IDs for the entry being composed in the add form.
|
|
let pendingExtraBmIds: string[] = [];
|
|
|
|
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 = "\u00D7";
|
|
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);
|
|
});
|
|
|
|
// Disable already-added bookmarks in dropdown
|
|
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 = "";
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Satellite overlay (delegated to sat-scheduler.js)
|
|
// -------------------------------------------------------------------------
|
|
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();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Keyboard shortcuts for scheduler control
|
|
// -------------------------------------------------------------------------
|
|
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();
|
|
// Toggle release to scheduler
|
|
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);
|
|
}
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Public API
|
|
// -------------------------------------------------------------------------
|
|
// Persist details open/closed state
|
|
(function () {
|
|
const details = document.querySelector<HTMLDetailsElement>(".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: 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 (!hostState.authEnabled || hostState.authRoles.length > 0) {
|
|
initScheduler(hostState.lastActiveRigId, hostState.authRoles);
|
|
wireSchedulerEvents();
|
|
}
|
|
})();
|