[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
@@ -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;