[feat](trx-frontend-http): implement scheduler UI improvements (P0–P3)

Implement all 15 scheduler improvement tasks from docs/scheduler_improvements.md:

P0 — Usability Fixes:
- Highlight active entry in time-span table with sch-active class
- Bookmark existence validation on save with toast error
- Dirty-state indicator for satellite section via markDirty bridge

P1 — Information Density & Clarity:
- Show local time alongside UTC in entry table and timeline
- Expand entry details by default with localStorage persistence
- Richer "Now Playing" status card with freq, mode, active decoders

P2 — Interaction Improvements:
- Inline entry editing directly in table rows
- Drag-to-reorder entries with HTML5 drag-and-drop
- Timeline click-to-add with pre-filled hour range
- Improved extra-channels management with chip list and dropdown

P3 — Feature Enhancements:
- Grayline location lookup by Maidenhead grid square
- Expanded satellite preset library (NOAA 15/18/19, ISS, SO-50)
- Scheduler activity log with ring buffer backend and UI
- Timeline interleave visualization with alternating color stripes
- Keyboard shortcuts (Shift+R/N/P) for scheduler control

https://claude.ai/code/session_01VFLAHs1UMzPso3GWSQP9wJ
Signed-off-by: Claude <noreply@anthropic.com>
This commit is contained in:
Claude
2026-04-01 07:43:59 +00:00
committed by Stan Grams
parent c5ccac3a17
commit 06f7c43799
7 changed files with 668 additions and 21 deletions
@@ -1113,6 +1113,10 @@
<div class="now-playing-card">
<div id="scheduler-status-card" class="sch-status-card">No activity yet.</div>
</div>
<details class="sch-activity-log-details" id="scheduler-activity-log-wrap" style="display:none;">
<summary>Activity Log</summary>
<div id="scheduler-activity-log" class="sch-activity-log"></div>
</details>
<div class="sch-row">
<label class="sch-label">Mode
<select id="scheduler-mode-select" class="status-input" aria-label="Scheduler mode">
@@ -1133,6 +1137,9 @@
<label class="sch-label">Longitude (°)
<input type="number" id="scheduler-gl-lon" class="status-input" step="0.001" placeholder="e.g. 18.646" />
</label>
<label class="sch-label">Grid square
<input type="text" id="scheduler-gl-grid" class="status-input" placeholder="e.g. JO94" maxlength="8" style="width:7rem;" />
</label>
<label class="sch-label">Transition window (min)
<input type="number" id="scheduler-gl-window" class="status-input" min="5" max="120" value="20" />
</label>
@@ -1204,11 +1211,11 @@
</div>
</form>
</div>
<details class="sch-ts-details">
<details class="sch-ts-details" open>
<summary>Entry details</summary>
<table class="sch-ts-table">
<thead>
<tr><th>Start</th><th>End</th><th>Center freq</th><th>Primary bookmark</th><th>Extra channels</th><th>Label</th><th>Interleave (min)</th><th>REC</th><th></th></tr>
<tr><th class="sch-drag-th"></th><th>Start</th><th>End</th><th>Center freq</th><th>Primary bookmark</th><th>Extra channels</th><th>Label</th><th>Interleave (min)</th><th>REC</th><th></th></tr>
</thead>
<tbody id="scheduler-ts-tbody"></tbody>
</table>
@@ -1254,6 +1261,11 @@
<option value="">— custom —</option>
<option value="METEOR-M2 3|57166">Meteor-M2 3 (137.900 MHz LRPT)</option>
<option value="METEOR-M2-4|59051">Meteor-M2-4 (137.900 MHz LRPT)</option>
<option value="NOAA 15|25338">NOAA 15 (137.620 MHz APT)</option>
<option value="NOAA 18|28654">NOAA 18 (137.9125 MHz APT)</option>
<option value="NOAA 19|33591">NOAA 19 (137.100 MHz APT)</option>
<option value="ISS|25544">ISS (145.825 MHz APRS)</option>
<option value="SO-50|27607">SO-50 (436.795 MHz FM)</option>
</select>
</label>
<label class="bm-label">Satellite name
@@ -55,6 +55,11 @@
return typeof b.getBookmarks === "function" ? b.getBookmarks() : [];
}
function markDirty() {
var b = getBridge();
if (typeof b.markDirty === "function") b.markDirty();
}
function bmName(id) {
const bm = getBookmarks().find(function (b) { return b.id === id; });
return bm ? bm.name : String(id || "");
@@ -208,6 +213,7 @@
var sat = ensureSatelliteConfig();
sat.entries.splice(idx, 1);
renderEntries();
markDirty();
}
// ── Form: open ────────────────────────────────────────────────────
@@ -275,6 +281,7 @@
closeForm();
renderEntries();
markDirty();
}
// ── Preset change handler ─────────────────────────────────────────
@@ -290,6 +297,12 @@
if (dom.enabled) {
dom.enabled.addEventListener("change", function () {
if (dom.body) dom.body.style.display = dom.enabled.checked ? "" : "none";
markDirty();
});
}
if (dom.pretune) {
dom.pretune.addEventListener("input", function () {
markDirty();
});
}
if (dom.addBtn) dom.addBtn.addEventListener("click", function () { openForm(null, null); });
@@ -337,6 +337,7 @@
currentSchedulerStatus = st || null;
renderStatus(st);
renderSchedulerInterleaveStatus();
renderActivityLog();
renderSatPassStatus();
})
.catch(function () {});
@@ -364,7 +365,57 @@
const satLabel = st.active_satellite
? " [SAT: " + st.active_satellite + "]"
: "";
el.textContent = "Last applied: " + name + satLabel + ts;
var 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) {
return fetch("/scheduler/" + encodeURIComponent(rigId) + "/log").then(function (r) {
return r.ok ? r.json() : [];
});
}
function renderActivityLog() {
var wrap = document.getElementById("scheduler-activity-log-wrap");
var container = document.getElementById("scheduler-activity-log");
if (!wrap || !container || !currentRigId) return;
apiGetSchedulerLog(currentRigId).then(function (entries) {
if (!entries || entries.length === 0) {
wrap.style.display = "none";
return;
}
wrap.style.display = "";
var html = entries.slice().reverse().map(function (e) {
var d = new Date(e.utc * 1000);
var ts = d.toUTCString();
var action = e.action || "unknown";
var label = e.entry_label || "";
var 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 () {});
}
// -------------------------------------------------------------------------
@@ -402,6 +453,10 @@
const lon = gl.lon != null ? gl.lon : (typeof serverLon !== "undefined" ? serverLon : "");
setInputValue("scheduler-gl-lat", lat != null ? lat : "");
setInputValue("scheduler-gl-lon", lon != null ? lon : "");
var gridEl = document.getElementById("scheduler-gl-grid");
if (gridEl && lat !== "" && lon !== "") {
gridEl.value = latLonToGrid(lat, lon);
}
setInputValue("scheduler-gl-window", gl.transition_window_min != null ? gl.transition_window_min : 20);
renderBookmarkSelect("scheduler-gl-dawn", gl.dawn_bookmark_id);
renderBookmarkSelect("scheduler-gl-day", gl.day_bookmark_id);
@@ -413,6 +468,10 @@
const lon = typeof serverLon !== "undefined" ? serverLon : "";
setInputValue("scheduler-gl-lat", lat != null ? lat : "");
setInputValue("scheduler-gl-lon", lon != null ? lon : "");
var gridEl2 = document.getElementById("scheduler-gl-grid");
if (gridEl2 && lat !== "" && lon !== "") {
gridEl2.value = latLonToGrid(lat, lon);
}
setInputValue("scheduler-gl-window", 20);
renderBookmarkSelect("scheduler-gl-dawn", null);
renderBookmarkSelect("scheduler-gl-day", null);
@@ -596,7 +655,7 @@
}
var W = 1000;
var H = 62;
var H = 80;
var BAR_Y = 6;
var BAR_H = 30;
var TICK_Y = BAR_Y + BAR_H + 2;
@@ -634,6 +693,31 @@
}
});
// Interleave stripes for overlapping entries
var interleaveMin = currentConfig && currentConfig.interleave_min ? Number(currentConfig.interleave_min) : 0;
if (interleaveMin > 0 && entries.length > 1) {
// Find overlap regions where 2+ entries are active
for (var m = 0; m < 1440; m += interleaveMin) {
var overlapping = [];
entries.forEach(function (entry, idx) {
if (schedulerEntryIsActive(entry, m)) {
overlapping.push(idx);
}
});
if (overlapping.length > 1) {
var stripeX = (m / 1440) * W;
var stripeW = Math.max(1, (interleaveMin / 1440) * W);
// Determine which entry "owns" this stripe via cycle position
var cyclePos = m % (interleaveMin * overlapping.length);
var ownerSlot = Math.floor(cyclePos / interleaveMin);
var ownerIdx = overlapping[ownerSlot % overlapping.length];
var stripeColor = TIMELINE_COLORS[ownerIdx % TIMELINE_COLORS.length];
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 (var h = 0; h <= 24; h += 3) {
var tx = (h / 24) * W;
@@ -645,6 +729,17 @@
}
}
// Local time ticks
var LOCAL_TICK_Y = TICK_Y + 18;
for (var h = 0; h < 24; h += 3) {
var localMin = h * 60;
var utcOffset = new Date().getTimezoneOffset(); // offset in minutes (negative for east of UTC)
var utcMin = (localMin + utcOffset + 1440) % 1440;
var tx = (utcMin / 1440) * W;
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>';
@@ -659,6 +754,29 @@
if (entry) schOpenEntryForm(entry, i);
});
});
// Click-to-add on empty timeline region
var svgEl = container.querySelector('svg');
if (svgEl) {
svgEl.addEventListener('click', function (e) {
// Only trigger if clicking on the background bar, not on a segment
if (e.target.classList.contains('sch-timeline-seg')) return;
var rect = svgEl.getBoundingClientRect();
var xPct = (e.clientX - rect.left) / rect.width;
var clickMin = Math.floor(xPct * 1440);
var startHour = Math.floor(clickMin / 60);
var startMin = startHour * 60;
var endMin = ((startHour + 1) % 24) * 60;
// Pre-fill the entry form with the clicked hour
schOpenEntryForm(null, null);
var startEl = document.getElementById('scheduler-ts-start');
var endEl = document.getElementById('scheduler-ts-end');
if (startEl) startEl.value = minToHHMM(startMin);
if (endEl) endEl.value = minToHHMM(endMin);
});
svgEl.style.cursor = 'crosshair';
}
}
function timelineNeedleSvg() {
@@ -675,6 +793,57 @@
if (g) g.innerHTML = timelineNeedleSvg();
}
// -------------------------------------------------------------------------
// Inline row editing
// -------------------------------------------------------------------------
function schInlineEdit(tr, entry, idx) {
var bmOptions = bookmarkList.map(function (bm) {
var sel = bm.id === entry.bookmark_id ? ' selected' : '';
return '<option value="' + escHtml(bm.id) + '"' + sel + '>' + escHtml(bm.name) + '</option>';
}).join('');
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>' + (Array.isArray(entry.bookmark_ids) && entry.bookmark_ids.length ? entry.bookmark_ids.map(function(id) { return escHtml(bmName(id)); }).join(', ') : '\u2014') + '</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;" /></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');
tr.querySelector('.sch-inline-save').addEventListener('click', function () {
var startEl = tr.querySelector('[data-field="start"]');
var endEl = tr.querySelector('[data-field="end"]');
var bmEl = tr.querySelector('[data-field="bookmark"]');
var labelEl = tr.querySelector('[data-field="label"]');
var ilEl = tr.querySelector('[data-field="interleave"]');
var recEl = tr.querySelector('[data-field="record"]');
if (bmEl && !bmEl.value) { alert('Please select a bookmark.'); return; }
entry.start_min = hhmmToMin(startEl.value);
entry.end_min = hhmmToMin(endEl.value);
entry.bookmark_id = bmEl.value;
entry.label = labelEl.value.trim() || null;
var ilVal = parseInt(ilEl.value, 10);
entry.interleave_min = (!isNaN(ilVal) && ilVal > 0) ? ilVal : null;
entry.record = recEl.checked;
currentConfig.entries[idx] = entry;
renderTimespanEntries();
markSchedulerDirty();
});
tr.querySelector('.sch-inline-cancel').addEventListener('click', function () {
renderTimespanEntries();
});
}
// -------------------------------------------------------------------------
// TimeSpan entries table
// -------------------------------------------------------------------------
@@ -688,6 +857,10 @@
: [];
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.interleave_min ? String(entry.interleave_min) + " min" : "—";
const allDay = entry.start_min === entry.end_min;
const centerCell = entry.center_hz ? formatFreq(entry.center_hz) : "—";
@@ -696,8 +869,9 @@
? extraIds.map(function (id) { return escHtml(bmName(id)); }).join(", ")
: "—";
tr.innerHTML =
'<td>' + (allDay ? "All day" : minToHHMM(entry.start_min)) + '</td>' +
'<td>' + (allDay ? "" : minToHHMM(entry.end_min)) + '</td>' +
'<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>' +
@@ -714,7 +888,7 @@
btn.addEventListener("click", function () {
const i = parseInt(btn.dataset.idx, 10);
const entry = currentConfig && currentConfig.entries ? currentConfig.entries[i] : null;
if (entry) schOpenEntryForm(entry, i);
if (entry) schInlineEdit(btn.closest('tr'), entry, i);
});
});
tbody.querySelectorAll(".sch-remove-btn").forEach(function (btn) {
@@ -722,6 +896,50 @@
removeEntry(parseInt(btn.dataset.idx, 10));
});
});
// Drag-to-reorder
(function () {
var handles = tbody.querySelectorAll('.sch-drag-handle');
var dragIdx = null;
handles.forEach(function (handle, idx) {
var row = handle.parentElement;
handle.addEventListener('dragstart', function (e) {
dragIdx = idx;
row.classList.add('sch-dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(idx));
});
row.addEventListener('dragover', function (e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
row.classList.add('sch-drag-over');
});
row.addEventListener('dragleave', function () {
row.classList.remove('sch-drag-over');
});
row.addEventListener('drop', function (e) {
e.preventDefault();
row.classList.remove('sch-drag-over');
if (dragIdx === null || dragIdx === idx) return;
var entries = currentConfig.entries;
var moved = entries.splice(dragIdx, 1)[0];
entries.splice(idx, 0, moved);
renderTimespanEntries();
markSchedulerDirty();
});
handle.addEventListener('dragend', function () {
row.classList.remove('sch-dragging');
dragIdx = null;
});
});
})();
renderTimeline();
}
@@ -730,6 +948,15 @@
return bm ? bm.name : String(id || "");
}
function minToLocal(min) {
// Convert UTC minutes-since-midnight to local time string
var now = new Date();
var utcMidnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
var utcMs = utcMidnight.getTime() + min * 60000;
var local = new Date(utcMs);
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;
@@ -741,6 +968,43 @@
return parseInt(parts[0] || "0", 10) * 60 + parseInt(parts[1] || "0", 10);
}
function gridToLatLon(grid) {
grid = String(grid).toUpperCase().trim();
if (grid.length < 4) return null;
var lonField = grid.charCodeAt(0) - 65;
var latField = grid.charCodeAt(1) - 65;
var lonSquare = parseInt(grid.charAt(2), 10);
var latSquare = parseInt(grid.charAt(3), 10);
if (isNaN(lonSquare) || isNaN(latSquare) || lonField < 0 || lonField > 17 || latField < 0 || latField > 17) return null;
var lon = lonField * 20 + lonSquare * 2 - 180;
var lat = latField * 10 + latSquare * 1 - 90;
if (grid.length >= 6) {
var lonSub = grid.charCodeAt(4) - 65;
var latSub = grid.charCodeAt(5) - 65;
if (lonSub >= 0 && lonSub < 24 && latSub >= 0 && latSub < 24) {
lon += lonSub * (2 / 24) + (1 / 24);
lat += latSub * (1 / 24) + (0.5 / 24);
}
} else {
lon += 1; // center of square
lat += 0.5;
}
return { lat: lat, lon: lon };
}
function latLonToGrid(lat, lon) {
lon = parseFloat(lon) + 180;
lat = parseFloat(lat) + 90;
if (isNaN(lon) || isNaN(lat)) return "";
var lonField = String.fromCharCode(65 + Math.floor(lon / 20));
var latField = String.fromCharCode(65 + Math.floor(lat / 10));
var lonSquare = Math.floor((lon % 20) / 2);
var latSquare = Math.floor(lat % 10);
var lonSub = String.fromCharCode(97 + Math.floor(((lon % 2) / 2) * 24));
var latSub = String.fromCharCode(97 + Math.floor((lat % 1) * 24));
return lonField + latField + lonSquare + latSquare + lonSub + latSub;
}
function escHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
@@ -795,6 +1059,14 @@
markSchedulerDirty();
}
// -------------------------------------------------------------------------
// Bookmark existence check
// -------------------------------------------------------------------------
function bookmarkExists(id) {
if (!id) return true; // null/empty is allowed
return bookmarkList.some(function (bm) { return bm.id === id; });
}
// -------------------------------------------------------------------------
// Save
// -------------------------------------------------------------------------
@@ -835,6 +1107,47 @@
// Satellite overlay — saved regardless of base mode.
config.satellites = collectSatelliteConfig();
// Validate bookmark existence before saving
var missingBmErrors = [];
if (mode === "grayline" && config.grayline) {
var gl = config.grayline;
var glFields = [
["dawn_bookmark_id", "Grayline dawn"],
["day_bookmark_id", "Grayline day"],
["dusk_bookmark_id", "Grayline dusk"],
["night_bookmark_id", "Grayline night"],
];
glFields.forEach(function (pair) {
if (!bookmarkExists(gl[pair[0]])) missingBmErrors.push(pair[1] + " (bookmark " + gl[pair[0]] + ")");
});
}
if (mode === "time_span" && Array.isArray(config.entries)) {
config.entries.forEach(function (entry, idx) {
var label = entry.label || "Entry #" + (idx + 1);
if (!bookmarkExists(entry.bookmark_id)) {
missingBmErrors.push(label + " primary bookmark (" + entry.bookmark_id + ")");
}
var extras = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids : [];
extras.forEach(function (id) {
if (!bookmarkExists(id)) {
missingBmErrors.push(label + " extra channel (" + id + ")");
}
});
});
}
if (config.satellites && Array.isArray(config.satellites.entries)) {
config.satellites.entries.forEach(function (sat, idx) {
var satLabel = sat.name || "Satellite #" + (idx + 1);
if (!bookmarkExists(sat.bookmark_id)) {
missingBmErrors.push(satLabel + " bookmark (" + sat.bookmark_id + ")");
}
});
}
if (missingBmErrors.length > 0) {
showSchedulerToast("Missing bookmarks: " + missingBmErrors.join("; "), true);
return;
}
const btn = document.getElementById("scheduler-save-btn");
if (btn) btn.disabled = true;
@@ -963,6 +1276,33 @@
});
}
// Grid square ↔ lat/lon sync
var gridEl = document.getElementById("scheduler-gl-grid");
if (gridEl) {
gridEl.addEventListener("input", function () {
var ll = gridToLatLon(gridEl.value);
if (ll) {
setInputValue("scheduler-gl-lat", ll.lat.toFixed(3));
setInputValue("scheduler-gl-lon", ll.lon.toFixed(3));
markSchedulerDirty();
}
});
}
var latEl = document.getElementById("scheduler-gl-lat");
var lonEl = document.getElementById("scheduler-gl-lon");
[latEl, lonEl].forEach(function (el) {
if (el) {
el.addEventListener("input", function () {
var la = parseFloat(document.getElementById("scheduler-gl-lat").value);
var lo = parseFloat(document.getElementById("scheduler-gl-lon").value);
var gEl = document.getElementById("scheduler-gl-grid");
if (gEl && !isNaN(la) && !isNaN(lo)) {
gEl.value = latLonToGrid(la, lo);
}
});
}
});
wireExtraBmAdd();
wireSatelliteEvents();
}
@@ -988,25 +1328,36 @@
let pendingExtraBmIds = [];
function renderExtraBmList() {
const container = document.getElementById("scheduler-ts-extra-bm-list");
var container = document.getElementById("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 tag = document.createElement("span");
tag.className = "sch-extra-bm-tag";
tag.textContent = bm ? bm.name : id;
const rm = document.createElement("span");
rm.className = "sch-extra-bm-rm";
rm.textContent = "×";
rm.title = "Remove";
rm.addEventListener("click", function () {
var bm = bookmarkList.find(function (b) { return b.id === id; });
var chip = document.createElement("span");
chip.className = "sch-extra-bm-chip";
var rmBtn = document.createElement("span");
rmBtn.className = "sch-extra-bm-chip-rm";
rmBtn.textContent = "\u00D7";
rmBtn.title = "Remove";
rmBtn.addEventListener("click", function () {
pendingExtraBmIds.splice(idx, 1);
renderExtraBmList();
});
tag.appendChild(rm);
container.appendChild(tag);
chip.appendChild(rmBtn);
var label = document.createTextNode(" " + (bm ? bm.name : id));
chip.appendChild(label);
container.appendChild(chip);
});
// Disable already-added bookmarks in dropdown
var pick = document.getElementById("scheduler-ts-extra-bm-pick");
if (pick) {
Array.from(pick.options).forEach(function (opt) {
if (opt.value) {
opt.disabled = pendingExtraBmIds.includes(opt.value);
}
});
}
}
function wireExtraBmAdd() {
@@ -1047,13 +1398,53 @@
getConfig: function () { return currentConfig; },
getStatus: function () { return currentSchedulerStatus; },
getBookmarks: function () { return bookmarkList; },
markDirty: function () { markSchedulerDirty(); },
};
if (window.satScheduler) window.satScheduler.wireEvents();
}
// -------------------------------------------------------------------------
// Keyboard shortcuts for scheduler control
// -------------------------------------------------------------------------
function isInputFocused() {
var el = document.activeElement;
if (!el) return false;
var tag = el.tagName;
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
}
document.addEventListener("keydown", function (e) {
if (isInputFocused()) return;
if (e.shiftKey && e.key === "R") {
e.preventDefault();
// Toggle release to scheduler
var releaseBtn = document.getElementById("scheduler-release-btn");
if (releaseBtn && !releaseBtn.disabled) releaseBtn.click();
} else if (e.shiftKey && e.key === "N") {
e.preventDefault();
schedulerSelectRelativeEntry(1);
} else if (e.shiftKey && e.key === "P") {
e.preventDefault();
schedulerSelectRelativeEntry(-1);
}
});
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
// Persist details open/closed state
(function () {
var details = document.querySelector(".sch-ts-details");
if (!details) return;
var key = "sch-details-open";
var saved = localStorage.getItem(key);
if (saved !== null) details.open = saved === "1";
details.addEventListener("toggle", function () {
localStorage.setItem(key, details.open ? "1" : "0");
});
})();
window.initScheduler = initScheduler;
window.destroyScheduler = destroyScheduler;
window.wireSchedulerEvents = wireSchedulerEvents;
@@ -4573,6 +4573,25 @@ button:focus-visible, input:focus-visible, select:focus-visible {
.sch-ts-table td:last-child button:last-child {
margin-right: 0;
}
.sch-ts-table tr.sch-active {
border-left: 3px solid var(--accent);
}
.sch-ts-table tr.sch-active td:first-child {
padding-left: calc(0.6rem - 3px);
}
.sch-local-time {
color: var(--text-muted);
font-size: 0.78rem;
opacity: 0.7;
}
.sch-timeline-local-tick {
opacity: 0.6;
}
.sch-status-detail {
font-size: 0.82rem;
color: var(--accent);
font-weight: 600;
}
.sch-add-row {
align-items: flex-end;
}
@@ -4778,7 +4797,7 @@ button:focus-visible, input:focus-visible, select:focus-visible {
.sch-timeline-wrap svg {
display: block;
width: 100%;
height: 62px;
height: 80px;
}
.sch-timeline-seg {
cursor: pointer;
@@ -4812,6 +4831,91 @@ button:focus-visible, input:focus-visible, select:focus-visible {
padding: 0.3rem 0;
user-select: none;
}
/* ── Inline Row Editing ──────────────────────────────────────────── */
.sch-inline-editing td {
padding: 0.25rem 0.4rem;
}
.sch-inline-input {
font-size: 0.8rem;
padding: 0.15rem 0.3rem;
width: 100%;
min-width: 0;
}
.sch-inline-save, .sch-inline-cancel {
font-size: 0.75rem !important;
padding: 0.15rem 0.4rem !important;
}
/* ── Drag-to-reorder ─────────────────────────────────────────────── */
.sch-drag-handle {
cursor: grab;
user-select: none;
text-align: center;
color: var(--text-muted);
font-size: 1rem;
width: 1.5rem;
padding: 0.3rem !important;
}
.sch-drag-handle:active { cursor: grabbing; }
.sch-drag-th { width: 1.5rem; }
.sch-dragging { opacity: 0.5; }
.sch-drag-over { border-top: 2px solid var(--accent); }
/* ── Extra Bookmark Chips ────────────────────────────────────────── */
.sch-extra-bm-chip {
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0.15rem 0.5rem;
border-radius: 1rem;
background: var(--accent);
color: #fff;
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
}
.sch-extra-bm-chip-rm {
cursor: pointer;
font-size: 0.9rem;
opacity: 0.8;
font-weight: 700;
line-height: 1;
}
.sch-extra-bm-chip-rm:hover { opacity: 1; }
/* ── Activity Log ────────────────────────────────────────────────── */
.sch-activity-log-details {
margin-bottom: 1rem;
}
.sch-activity-log-details summary {
cursor: pointer;
font-size: 0.82rem;
color: var(--text-muted);
font-weight: 600;
padding: 0.3rem 0;
}
.sch-activity-log {
max-height: 12rem;
overflow-y: auto;
font-size: 0.78rem;
padding: 0.3rem 0;
}
.sch-log-entry {
padding: 0.15rem 0;
border-bottom: 1px solid var(--border-light);
}
.sch-log-time {
color: var(--text-muted);
}
.sch-log-action {
font-weight: 600;
text-transform: uppercase;
font-size: 0.72rem;
}
.sch-log-bm {
color: var(--accent);
}
.sch-log-label {
color: var(--text-muted);
font-style: italic;
}
/* ── Inline Entry Editor (replaces modal overlay) ─────────────────── */
#sch-entry-form-wrap {
position: static;
@@ -602,6 +602,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
.service(crate::server::scheduler::put_scheduler_activate_entry)
.service(crate::server::scheduler::get_scheduler_control)
.service(crate::server::scheduler::put_scheduler_control)
.service(crate::server::scheduler::get_scheduler_log)
.service(crate::server::background_decode::get_background_decode)
.service(crate::server::background_decode::put_background_decode)
.service(crate::server::background_decode::delete_background_decode)
@@ -529,6 +529,15 @@ pub struct SchedulerStatus {
/// Name of the satellite whose pass is currently active (if any).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_satellite: Option<String>,
/// Frequency in Hz of the active bookmark.
#[serde(skip_serializing_if = "Option::is_none")]
pub freq_hz: Option<u64>,
/// Mode string of the active bookmark.
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
/// Decoders active from the primary and extra bookmarks.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub active_decoders: Vec<String>,
}
#[allow(clippy::too_many_arguments)]
@@ -542,6 +551,7 @@ async fn apply_scheduler_target(
center_hz: Option<u64>,
extra_bm_ids: &[String],
satellite_name: Option<&str>,
log_map: &SharedActivityLogMap,
) -> Result<SchedulerStatus, String> {
let bookmark = bookmarks
.get_for_rig(remote, bookmark_id)
@@ -592,6 +602,15 @@ async fn apply_scheduler_target(
apply_scheduler_decoders(rig_tx, remote, &bookmark, &extra_bookmarks).await;
let mut all_decoders: Vec<String> = bookmark.decoders.clone();
for ebm in &extra_bookmarks {
for d in &ebm.decoders {
if !all_decoders.contains(d) {
all_decoders.push(d.clone());
}
}
}
let status = SchedulerStatus {
active: true,
last_entry_id: entry_id.map(str::to_string),
@@ -606,6 +625,9 @@ async fn apply_scheduler_target(
last_center_hz: center_hz,
last_bookmark_ids: extra_bm_ids.to_vec(),
active_satellite: satellite_name.map(str::to_string),
freq_hz: Some(bookmark.freq_hz),
mode: Some(bookmark.mode.clone()),
active_decoders: all_decoders,
};
{
@@ -613,12 +635,82 @@ async fn apply_scheduler_target(
map.insert(remote.to_string(), status.clone());
}
{
let log = {
let mut map = log_map.write().unwrap_or_else(|e| e.into_inner());
map.entry(remote.to_string())
.or_insert_with(|| Arc::new(ActivityLog::new()))
.clone()
};
log.push(ActivityLogEntry {
utc: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64,
action: if satellite_name.is_some() {
"satellite_aos".to_string()
} else {
"applied".to_string()
},
entry_label: entry_id.map(|s| s.to_string()),
bookmark_name: Some(bookmark.name.clone()),
});
}
Ok(status)
}
/// Shared mutable state for scheduler status (one entry per rig).
pub type SchedulerStatusMap = Arc<RwLock<HashMap<String, SchedulerStatus>>>;
// ============================================================================
// Activity log
// ============================================================================
#[derive(Debug, Clone, Serialize)]
pub struct ActivityLogEntry {
pub utc: i64,
pub action: String,
pub entry_label: Option<String>,
pub bookmark_name: Option<String>,
}
pub struct ActivityLog {
entries: std::sync::Mutex<std::collections::VecDeque<ActivityLogEntry>>,
}
impl Default for ActivityLog {
fn default() -> Self {
Self::new()
}
}
impl ActivityLog {
pub fn new() -> Self {
Self {
entries: std::sync::Mutex::new(std::collections::VecDeque::with_capacity(101)),
}
}
pub fn push(&self, entry: ActivityLogEntry) {
if let Ok(mut entries) = self.entries.lock() {
if entries.len() >= 100 {
entries.pop_front();
}
entries.push_back(entry);
}
}
pub fn entries(&self) -> Vec<ActivityLogEntry> {
self.entries
.lock()
.map(|e| e.iter().cloned().collect())
.unwrap_or_default()
}
}
pub type SharedActivityLogMap = Arc<RwLock<HashMap<String, Arc<ActivityLog>>>>;
#[derive(Debug, Clone, PartialEq, Eq)]
struct AppliedTarget {
bookmark_id: String,
@@ -692,6 +784,7 @@ impl SchedulerControlManager {
pub type SharedSchedulerControlManager = Arc<SchedulerControlManager>;
#[allow(clippy::too_many_arguments)]
pub fn spawn_scheduler_task(
context: Arc<FrontendRuntimeContext>,
rig_tx: mpsc::Sender<RigRequest>,
@@ -700,6 +793,7 @@ pub fn spawn_scheduler_task(
status_map: SchedulerStatusMap,
control: SharedSchedulerControlManager,
recorder_mgr: Option<Arc<super::recorder::RecorderManager>>,
log_map: SharedActivityLogMap,
) {
tokio::spawn(async move {
let mut interval = time::interval(Duration::from_secs(30));
@@ -772,6 +866,7 @@ pub fn spawn_scheduler_task(
sat_target.center_hz,
&sat_target.extra_bm_ids,
Some(&sat_target.satellite),
&log_map,
)
.await
{
@@ -882,6 +977,7 @@ pub fn spawn_scheduler_task(
center_hz,
&extra_bm_ids,
None,
&log_map,
)
.await
{
@@ -1060,6 +1156,7 @@ async fn apply_last_scheduler_cycle(
remote: &str,
status_map: &SchedulerStatusMap,
bookmarks: &BookmarkStoreMap,
log_map: &SharedActivityLogMap,
) {
let status = {
let Ok(map) = status_map.read() else {
@@ -1092,6 +1189,7 @@ async fn apply_last_scheduler_cycle(
status.last_center_hz,
&status.last_bookmark_ids,
status.active_satellite.as_deref(),
log_map,
)
.await
{
@@ -1275,6 +1373,7 @@ pub async fn put_scheduler_activate_entry(
status_map: web::Data<SchedulerStatusMap>,
bookmarks: web::Data<Arc<BookmarkStoreMap>>,
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
log_map: web::Data<SharedActivityLogMap>,
) -> impl Responder {
let rig_id = path.into_inner();
let Some(config) = store_map.store_for(&rig_id).get_config() else {
@@ -1302,6 +1401,7 @@ pub async fn put_scheduler_activate_entry(
entry.center_hz,
&entry.bookmark_ids,
None,
log_map.get_ref(),
)
.await
{
@@ -1338,6 +1438,7 @@ pub async fn put_scheduler_control(
rig_tx: web::Data<mpsc::Sender<RigRequest>>,
status_map: web::Data<SchedulerStatusMap>,
bookmarks: web::Data<Arc<BookmarkStoreMap>>,
log_map: web::Data<SharedActivityLogMap>,
) -> impl Responder {
let body = body.into_inner();
let summary = control.set_released(body.session_id, body.released);
@@ -1348,6 +1449,7 @@ pub async fn put_scheduler_control(
remote,
status_map.get_ref(),
bookmarks.get_ref().as_ref(),
log_map.get_ref(),
)
.await;
}
@@ -1355,6 +1457,22 @@ pub async fn put_scheduler_control(
HttpResponse::Ok().json(summary)
}
/// GET /scheduler/{remote}/log
#[get("/scheduler/{remote}/log")]
pub async fn get_scheduler_log(
path: web::Path<String>,
log_map: web::Data<SharedActivityLogMap>,
) -> impl Responder {
let remote = path.into_inner();
let entries = {
let map = log_map.read().unwrap_or_else(|e| e.into_inner());
map.get(&remote)
.map(|log| log.entries())
.unwrap_or_default()
};
HttpResponse::Ok().json(entries)
}
#[cfg(test)]
mod tests {
use super::{
@@ -43,7 +43,9 @@ use trx_frontend::{FrontendRuntimeContext, FrontendSpawner};
use auth::{AuthConfig, AuthState, SameSite};
use background_decode::{BackgroundDecodeManager, BackgroundDecodeStore};
use recorder::{RecorderConfig, RecorderManager};
use scheduler::{SchedulerControlManager, SchedulerStatusMap, SchedulerStoreMap};
use scheduler::{
SchedulerControlManager, SchedulerStatusMap, SchedulerStoreMap, SharedActivityLogMap,
};
use vchan::ClientChannelManager;
/// HTTP frontend implementation.
@@ -88,6 +90,7 @@ async fn serve(
let bookmark_store_map = Arc::new(bookmarks::BookmarkStoreMap::new());
let scheduler_status: SchedulerStatusMap = Arc::new(RwLock::new(HashMap::new()));
let scheduler_control = Arc::new(SchedulerControlManager::default());
let activity_log_map: SharedActivityLogMap = Arc::new(RwLock::new(HashMap::new()));
let recorder_config = RecorderConfig::default();
let recorder_mgr = Arc::new(RecorderManager::new(recorder_config));
@@ -100,6 +103,7 @@ async fn serve(
scheduler_status.clone(),
scheduler_control.clone(),
Some(recorder_mgr.clone()),
activity_log_map.clone(),
);
let background_decode_path = BackgroundDecodeStore::default_path();
@@ -155,6 +159,7 @@ async fn serve(
scheduler_store,
scheduler_status,
scheduler_control,
activity_log_map,
vchan_mgr,
session_rig_mgr,
background_decode_mgr,
@@ -182,6 +187,7 @@ fn build_server(
scheduler_store: Arc<SchedulerStoreMap>,
scheduler_status: SchedulerStatusMap,
scheduler_control: Arc<SchedulerControlManager>,
activity_log_map: SharedActivityLogMap,
vchan_mgr: Arc<ClientChannelManager>,
session_rig_mgr: Arc<api::SessionRigManager>,
background_decode_mgr: Arc<BackgroundDecodeManager>,
@@ -198,6 +204,7 @@ fn build_server(
let scheduler_store = web::Data::new(scheduler_store);
let scheduler_status = web::Data::new(scheduler_status);
let scheduler_control = web::Data::new(scheduler_control);
let activity_log_map = web::Data::new(activity_log_map);
let vchan_mgr = web::Data::new(vchan_mgr);
let session_rig_mgr = web::Data::new(session_rig_mgr);
let background_decode_mgr = web::Data::new(background_decode_mgr);
@@ -255,6 +262,7 @@ fn build_server(
.app_data(scheduler_store.clone())
.app_data(scheduler_status.clone())
.app_data(scheduler_control.clone())
.app_data(activity_log_map.clone())
.app_data(vchan_mgr.clone())
.app_data(session_rig_mgr.clone())
.app_data(background_decode_mgr.clone())