[feat](trx-frontend-http): add interleave time for overlapping TimeSpan entries

When multiple time-span entries are active simultaneously, the scheduler
now cycles through them by slot: slot = floor(utc_min / interleave_min) % count.
The interleave_min field is optional (null disables, first match wins).

UI: "Interleave time (min)" number input in the TimeSpan section with a
hint explaining the behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-10 23:25:12 +01:00
parent c9d204ad1c
commit 4f9f93c9c1
3 changed files with 55 additions and 14 deletions
@@ -716,6 +716,12 @@
<!-- Time Span section -->
<div id="scheduler-timespan-section" class="sch-section" style="display:none;">
<div class="sch-section-title">Time Span Entries (UTC)</div>
<div class="sch-row" style="margin-bottom:0.75rem;">
<label class="sch-label">Interleave time (min)
<input type="number" id="scheduler-ts-interleave" class="status-input" min="1" max="60" placeholder="off" style="width:7rem;" />
</label>
<small style="color:var(--text-muted);align-self:flex-end;padding-bottom:0.35rem;">When multiple entries overlap, spend this many minutes at each before cycling. Leave blank to disable.</small>
</div>
<table class="sch-ts-table">
<thead>
<tr><th>Start</th><th>End</th><th>Bookmark</th><th>Label</th><th></th></tr>
@@ -195,6 +195,13 @@
renderBookmarkSelect("scheduler-gl-night", null);
}
// Interleave input
const ilEl = document.getElementById("scheduler-ts-interleave");
if (ilEl) {
const il = currentConfig && currentConfig.interleave_min;
ilEl.value = il ? il : "";
}
// TimeSpan entries
renderTimespanEntries();
@@ -374,6 +381,8 @@
} else if (mode === "time_span") {
config.entries =
currentConfig && currentConfig.entries ? currentConfig.entries : [];
const ilVal = parseInt(document.getElementById("scheduler-ts-interleave").value, 10);
config.interleave_min = isNaN(ilVal) || ilVal <= 0 ? null : ilVal;
}
const btn = document.getElementById("scheduler-save-btn");
@@ -78,6 +78,11 @@ pub struct SchedulerConfig {
pub grayline: Option<GraylineConfig>,
#[serde(default)]
pub entries: Vec<ScheduleEntry>,
/// When multiple entries are active simultaneously, cycle through them,
/// spending this many minutes at each before advancing to the next.
/// `None` (or 0) disables interleaving — the first matching entry wins.
#[serde(default)]
pub interleave_min: Option<u32>,
}
// ============================================================================
@@ -265,21 +270,41 @@ fn grayline_bookmark_id(gl: &GraylineConfig, now_min: f64) -> Option<String> {
}
}
fn timespan_bookmark_id(entries: &[ScheduleEntry], now_min: f64) -> Option<String> {
for entry in entries {
fn entry_is_active(entry: &ScheduleEntry, now_min: f64) -> bool {
let start = entry.start_min as f64;
let end = entry.end_min as f64;
let in_window = if start <= end {
if start <= end {
now_min >= start && now_min < end
} else {
// Spans midnight.
now_min >= start || now_min < end
};
if in_window {
return Some(entry.bookmark_id.clone());
}
}
None
fn timespan_bookmark_id(
entries: &[ScheduleEntry],
now_min: f64,
interleave_min: Option<u32>,
) -> Option<String> {
let active: Vec<&ScheduleEntry> = entries
.iter()
.filter(|e| entry_is_active(e, now_min))
.collect();
if active.is_empty() {
return None;
}
// With interleaving and more than one active entry, pick by time slot.
if active.len() > 1 {
if let Some(step) = interleave_min.filter(|&s| s > 0) {
let slot = (now_min as u64 / step as u64) as usize % active.len();
return Some(active[slot].bookmark_id.clone());
}
}
// Default: first matching entry wins.
Some(active[0].bookmark_id.clone())
}
/// Current UTC time as minutes since midnight.
@@ -346,7 +371,7 @@ pub fn spawn_scheduler_task(
.as_ref()
.and_then(|gl| grayline_bookmark_id(gl, now_min)),
SchedulerMode::TimeSpan => {
timespan_bookmark_id(&config.entries, now_min)
timespan_bookmark_id(&config.entries, now_min, config.interleave_min)
}
};
@@ -459,6 +484,7 @@ pub async fn get_scheduler(
mode: SchedulerMode::Disabled,
grayline: None,
entries: vec![],
interleave_min: None,
});
HttpResponse::Ok().json(config)
}