clay-server 2.18.0-beta.9 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { renderMarkdown } from './markdown.js';
9
9
  import { iconHtml } from './icons.js';
10
+ import { showToast } from './utils.js';
10
11
 
11
12
  var ctx = null;
12
13
  var records = []; // all loop registry records
@@ -33,7 +34,6 @@ var panel = null; // #scheduler-panel
33
34
  var bodyEl = null;
34
35
  var monthLabel = null;
35
36
  var calHeader = null;
36
- var editModal = null;
37
37
  var popoverEl = null;
38
38
  var panelOpen = false;
39
39
 
@@ -46,7 +46,6 @@ var messagesOrigParent = null; // for reparenting
46
46
  var inputOrigNextSibling = null; // anchor for restoring input-area position
47
47
 
48
48
  // Edit state
49
- var editingId = null;
50
49
 
51
50
  // Create popover state
52
51
  var createEditingRecId = null; // non-null when editing existing schedule
@@ -56,6 +55,9 @@ var createRecurrence = "none"; // current recurrence selection
56
55
  var createCustomConfirmed = false; // whether custom repeat was confirmed via OK
57
56
  var createInterval = "none"; // current interval selection: "none", "1", "5", "15", "30", "60", "custom"
58
57
  var createIntervalCustom = null; // { value: N, unit: "minute"|"hour" } for custom interval
58
+ var createIntervalEnd = "allday"; // "allday" | "after" | "until"
59
+ var createIntervalEndAfter = 5; // occurrence count for "after" end type
60
+ var createIntervalEndTime = ""; // HH:MM string for "until" end type
59
61
  var createColor = "#ffb86c"; // selected event color (default: accent)
60
62
  var createEndType = "never"; // "never" | "until" | "after"
61
63
  var createEndDate = null; // Date for "until" end type
@@ -77,7 +79,6 @@ var MONTH_NAMES = [
77
79
  export function initScheduler(_ctx) {
78
80
  ctx = _ctx;
79
81
  currentProjectSlug = ctx.currentSlug || null;
80
- editModal = document.getElementById("schedule-edit-modal");
81
82
  createPopover = document.getElementById("schedule-create-popover");
82
83
  popoverEl = document.getElementById("schedule-popover");
83
84
 
@@ -95,9 +96,6 @@ export function initScheduler(_ctx) {
95
96
  });
96
97
  }
97
98
 
98
- // Edit modal
99
- setupEditModal();
100
-
101
99
  // Create modal
102
100
  setupCreateModal();
103
101
 
@@ -751,6 +749,10 @@ function renderDetailBody(tab, rec) {
751
749
  html += '<span class="scheduler-detail-meta-value">' + esc(createdStr) + '</span>';
752
750
  html += '<span class="scheduler-detail-meta-label">Last Run</span>';
753
751
  html += '<span class="scheduler-detail-meta-value">' + esc(lastRunStr) + '</span>';
752
+ if (isScheduled && rec.nextRunAt) {
753
+ html += '<span class="scheduler-detail-meta-label">Next Run</span>';
754
+ html += '<span class="scheduler-detail-meta-value">' + esc(formatDateTime(new Date(rec.nextRunAt))) + '</span>';
755
+ }
754
756
  html += '</div>';
755
757
  bodyEl2.innerHTML = html;
756
758
  } else {
@@ -874,8 +876,9 @@ function renderMonthView() {
874
876
  var dateStr = cursor.getFullYear() + "-" + pad(cursor.getMonth() + 1) + "-" + pad(cursor.getDate());
875
877
  var isOther = cursor.getMonth() !== month;
876
878
  var isToday = dateStr === todayStr;
879
+ var isPast = dateStr < todayStr;
877
880
  var isWeekend = d === 0 || d === 6;
878
- var cls = "scheduler-cell" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (isWeekend ? " weekend" : "");
881
+ var cls = "scheduler-cell" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (isPast ? " past" : "") + (isWeekend ? " weekend" : "");
879
882
  html += '<div class="' + cls + '" data-date="' + dateStr + '">';
880
883
  var dayLabel = cursor.getDate() === 1
881
884
  ? MONTH_NAMES[cursor.getMonth()].substring(0, 3) + ", " + cursor.getDate()
@@ -884,7 +887,8 @@ function renderMonthView() {
884
887
  var events = getEventsForDate(cursor);
885
888
  for (var e = 0; e < events.length && e < 3; e++) {
886
889
  var ev = events[e];
887
- html += '<div class="scheduler-event ' + (ev.enabled ? "enabled" : "disabled") + '" data-rec-id="' + ev.id + '">';
890
+ var evFullText = ev.timeStr + " " + ev.name;
891
+ html += '<div class="scheduler-event ' + (ev.enabled ? "enabled" : "disabled") + '" data-rec-id="' + ev.id + '" data-tip="' + esc(evFullText) + '">';
888
892
  html += '<span class="scheduler-event-time">' + ev.timeStr + '</span> ' + esc(ev.name);
889
893
  html += '</div>';
890
894
  }
@@ -1010,9 +1014,10 @@ function renderWeekView() {
1010
1014
  if (ev.intervalBadge) {
1011
1015
  var badgeStyle = "";
1012
1016
  if (ev.color) badgeStyle = "background:" + ev.color;
1013
- html += '<div class="scheduler-week-event interval-badge ' + (ev.enabled ? "enabled" : "disabled") + '" data-rec-id="' + ev.id + '" style="position:relative;top:0;left:0;width:85%;height:auto;' + badgeStyle + '">';
1014
- html += '<span class="scheduler-week-event-title">' + esc(ev.name) + '</span>';
1017
+ var weekIntFullText = ev.timeStr + " " + ev.name;
1018
+ html += '<div class="scheduler-week-event interval-badge ' + (ev.enabled ? "enabled" : "disabled") + '" data-rec-id="' + ev.id + '" data-tip="' + esc(weekIntFullText) + '" style="position:relative;top:0;left:0;width:85%;height:auto;' + badgeStyle + '">';
1015
1019
  html += '<span class="scheduler-week-event-time">' + esc(ev.timeStr) + '</span>';
1020
+ html += '<span class="scheduler-week-event-title">' + esc(ev.name) + '</span>';
1016
1021
  html += '</div>';
1017
1022
  continue;
1018
1023
  }
@@ -1026,7 +1031,8 @@ function renderWeekView() {
1026
1031
  var evStyle = "top:" + topPct + "%;height:calc(160vh / 48)";
1027
1032
  evStyle += ";left:" + leftPct + "%;width:" + (colWidth - 1) + "%";
1028
1033
  if (evColor) evStyle += ";background:" + evColor;
1029
- html += '<div class="scheduler-week-event ' + (ev.enabled ? "enabled" : "disabled") + '" data-rec-id="' + ev.id + '" style="' + evStyle + '">';
1034
+ var weekEvFullText = ev.timeStr + " " + ev.name;
1035
+ html += '<div class="scheduler-week-event ' + (ev.enabled ? "enabled" : "disabled") + '" data-rec-id="' + ev.id + '" data-tip="' + esc(weekEvFullText) + '" style="' + evStyle + '">';
1030
1036
  html += '<span class="scheduler-week-event-title">' + esc(ev.name) + '</span>';
1031
1037
  html += '<span class="scheduler-week-event-time">' + ev.timeStr + '</span>';
1032
1038
  html += '</div>';
@@ -1208,7 +1214,7 @@ function getEventsForDate(date) {
1208
1214
  results.push({
1209
1215
  id: r.id, name: r.name, enabled: r.enabled,
1210
1216
  hour: 0, minute: 0,
1211
- timeStr: cronToHuman(r.cron) || "Interval",
1217
+ timeStr: (r.time || "00:00") + " " + (cronToHuman(r.cron) || "Interval"),
1212
1218
  allDay: true,
1213
1219
  intervalBadge: true,
1214
1220
  color: r.color || null,
@@ -1277,7 +1283,13 @@ function showPopover(recId, anchorEl) {
1277
1283
  var action = btn.dataset.action;
1278
1284
  var id = btn.dataset.id;
1279
1285
  popoverEl.classList.add("hidden");
1280
- if (action === "edit") openEditModal(id);
1286
+ if (action === "edit") {
1287
+ var rec = null;
1288
+ for (var ri = 0; ri < records.length; ri++) {
1289
+ if (records[ri].id === id) { rec = records[ri]; break; }
1290
+ }
1291
+ if (rec) openCreateModalWithRecord(rec, btn);
1292
+ }
1281
1293
  else if (action === "toggle") send({ type: "loop_registry_toggle", id: id });
1282
1294
  else if (action === "rerun") send({ type: "loop_registry_rerun", id: id });
1283
1295
  else if (action === "move") showMovePopover(id, btn);
@@ -1390,148 +1402,6 @@ function attachEventClicks(container, selector) {
1390
1402
  }
1391
1403
  }
1392
1404
 
1393
- // --- Edit Modal (for changing cron/name on existing records) ---
1394
-
1395
- function setupEditModal() {
1396
- if (!editModal) return;
1397
- document.getElementById("schedule-edit-close").addEventListener("click", function () { closeEditModal(); });
1398
- document.getElementById("sched-cancel").addEventListener("click", function () { closeEditModal(); });
1399
- editModal.querySelector(".confirm-backdrop").addEventListener("click", function () { closeEditModal(); });
1400
-
1401
- // Presets
1402
- var presetBtns = document.querySelectorAll("#sched-presets .sched-preset-btn");
1403
- for (var i = 0; i < presetBtns.length; i++) {
1404
- (function (btn) {
1405
- btn.addEventListener("click", function () { selectPreset(btn.dataset.preset); });
1406
- })(presetBtns[i]);
1407
- }
1408
-
1409
- // DOW
1410
- var dowBtns = document.querySelectorAll("#sched-dow-row .sched-dow-btn");
1411
- for (var i = 0; i < dowBtns.length; i++) {
1412
- (function (btn) {
1413
- btn.addEventListener("click", function () { btn.classList.toggle("active"); updateEditCronPreview(); });
1414
- })(dowBtns[i]);
1415
- }
1416
-
1417
- document.getElementById("sched-time").addEventListener("change", function () { updateEditCronPreview(); });
1418
- document.getElementById("sched-save").addEventListener("click", function () { saveEdit(); });
1419
- document.getElementById("sched-delete").addEventListener("click", function () {
1420
- if (editingId && confirm("Delete this job?")) {
1421
- send({ type: "loop_registry_remove", id: editingId });
1422
- closeEditModal();
1423
- }
1424
- });
1425
- }
1426
-
1427
- var editPreset = "daily";
1428
-
1429
- function selectPreset(preset) {
1430
- editPreset = preset;
1431
- var btns = document.querySelectorAll("#sched-presets .sched-preset-btn");
1432
- for (var i = 0; i < btns.length; i++) btns[i].classList.toggle("active", btns[i].dataset.preset === preset);
1433
- var dowField = document.getElementById("sched-dow-field");
1434
- if (dowField) dowField.style.display = (preset === "custom" || preset === "weekly") ? "" : "none";
1435
- updateEditCronPreview();
1436
- }
1437
-
1438
- function buildEditCron() {
1439
- var timeVal = document.getElementById("sched-time").value || "09:00";
1440
- var parts = timeVal.split(":");
1441
- var h = parseInt(parts[0], 10);
1442
- var m = parseInt(parts[1], 10);
1443
- var dow = "*";
1444
- if (editPreset === "weekdays") dow = "1-5";
1445
- else if (editPreset === "weekly" || editPreset === "custom") {
1446
- var days = [];
1447
- var btns = document.querySelectorAll("#sched-dow-row .sched-dow-btn.active");
1448
- for (var i = 0; i < btns.length; i++) days.push(btns[i].dataset.dow);
1449
- if (days.length > 0 && days.length < 7) dow = days.sort().join(",");
1450
- } else if (editPreset === "monthly") {
1451
- return m + " " + h + " " + new Date().getDate() + " * *";
1452
- }
1453
- return m + " " + h + " * * " + dow;
1454
- }
1455
-
1456
- function updateEditCronPreview() {
1457
- var cron = buildEditCron();
1458
- var humanEl = document.getElementById("sched-human-text");
1459
- var cronEl = document.getElementById("sched-cron-text");
1460
- if (humanEl) humanEl.textContent = cronToHuman(cron);
1461
- if (cronEl) cronEl.textContent = cron;
1462
- }
1463
-
1464
- function openEditModal(recId) {
1465
- if (!editModal) return;
1466
- editingId = recId;
1467
- var rec = null;
1468
- for (var i = 0; i < records.length; i++) {
1469
- if (records[i].id === recId) { rec = records[i]; break; }
1470
- }
1471
- if (!rec) return;
1472
-
1473
- document.getElementById("schedule-edit-title").textContent = "Edit Schedule";
1474
- document.getElementById("sched-name").value = rec.name || "";
1475
- document.getElementById("sched-enabled").checked = rec.enabled;
1476
- document.getElementById("sched-delete").style.display = "";
1477
-
1478
- // Show job name
1479
- var jobNameEl = document.getElementById("sched-job-name");
1480
- if (jobNameEl) jobNameEl.textContent = rec.task ? rec.task.substring(0, 80) : rec.id;
1481
-
1482
- // History
1483
- var historyField = document.getElementById("sched-history-field");
1484
- if (rec.runs && rec.runs.length > 0) {
1485
- if (historyField) historyField.style.display = "";
1486
- renderHistory(rec.runs);
1487
- } else {
1488
- if (historyField) historyField.style.display = "none";
1489
- }
1490
-
1491
- // Parse cron
1492
- if (rec.cron) {
1493
- var parsed = parseCronSimple(rec.cron);
1494
- if (parsed) {
1495
- document.getElementById("sched-time").value = pad(parsed.hours[0] || 9) + ":" + pad(parsed.minutes[0] || 0);
1496
- var dowArr = parsed.daysOfWeek;
1497
- if (dowArr.length === 7) selectPreset("daily");
1498
- else if (dowArr.length === 5 && dowArr[0] === 1 && dowArr[4] === 5) selectPreset("weekdays");
1499
- else {
1500
- selectPreset("custom");
1501
- var dowBtns = document.querySelectorAll("#sched-dow-row .sched-dow-btn");
1502
- for (var j = 0; j < dowBtns.length; j++) {
1503
- dowBtns[j].classList.toggle("active", dowArr.indexOf(parseInt(dowBtns[j].dataset.dow)) !== -1);
1504
- }
1505
- }
1506
- }
1507
- } else {
1508
- document.getElementById("sched-time").value = "09:00";
1509
- selectPreset("daily");
1510
- }
1511
-
1512
- updateEditCronPreview();
1513
- editModal.classList.remove("hidden");
1514
- }
1515
-
1516
- function closeEditModal() {
1517
- if (editModal) editModal.classList.add("hidden");
1518
- editingId = null;
1519
- }
1520
-
1521
- function saveEdit() {
1522
- var name = document.getElementById("sched-name").value.trim();
1523
- var enabled = document.getElementById("sched-enabled").checked;
1524
- var cron = buildEditCron();
1525
- if (!name) { alert("Please enter a name."); return; }
1526
-
1527
- send({
1528
- type: "loop_registry_update",
1529
- id: editingId,
1530
- data: { name: name, cron: cron, enabled: enabled },
1531
- });
1532
- closeEditModal();
1533
- }
1534
-
1535
1405
  function renderHistory(runs) {
1536
1406
  var el = document.getElementById("sched-history");
1537
1407
  if (!el || !runs || runs.length === 0) { if (el) el.innerHTML = '<div class="sched-history-empty">No runs yet</div>'; return; }
@@ -1615,9 +1485,9 @@ export function handleLoopRegistryFiles(msg) {
1615
1485
  // Disable "Run now" if PROMPT.md or JUDGE.md is missing
1616
1486
  var runBtn = contentDetailEl ? contentDetailEl.querySelector('[data-action="run"]') : null;
1617
1487
  if (runBtn) {
1618
- var filesReady = !!msg.prompt && !!msg.judge;
1488
+ var filesReady = !!msg.prompt;
1619
1489
  runBtn.disabled = !filesReady;
1620
- runBtn.title = filesReady ? "Run now" : "PROMPT.md and JUDGE.md are required to run";
1490
+ runBtn.title = filesReady ? "Run now" : "PROMPT.md is required to run";
1621
1491
  }
1622
1492
  }
1623
1493
 
@@ -1662,6 +1532,13 @@ function attachCellClicks(container) {
1662
1532
  if (e.target.closest(".scheduler-event")) return;
1663
1533
  var parts = cell.dataset.date.split("-");
1664
1534
  var d = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10));
1535
+ // Block creating tasks on past dates
1536
+ var now = new Date();
1537
+ var todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
1538
+ if (d < todayStart) {
1539
+ showToast("Cannot schedule a task in the past", "error");
1540
+ return;
1541
+ }
1665
1542
  openCreateModal(d, null, cell);
1666
1543
  });
1667
1544
  cell.addEventListener("dragover", function (e) {
@@ -1683,6 +1560,12 @@ function attachCellClicks(container) {
1683
1560
  removePreview();
1684
1561
  var parts = cell.dataset.date.split("-");
1685
1562
  var d = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10));
1563
+ var now = new Date();
1564
+ var todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
1565
+ if (d < todayStart) {
1566
+ showToast("Cannot schedule a task in the past", "error");
1567
+ return;
1568
+ }
1686
1569
  openCreateModal(d, null, cell);
1687
1570
  applyDraggedTask();
1688
1571
  });
@@ -1701,6 +1584,11 @@ function attachWeekSlotClicks(container) {
1701
1584
  var quarter = parseInt(slot.dataset.quarter || "0", 10);
1702
1585
  var minute = quarter * 15;
1703
1586
  var d = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10), hour, minute, 0);
1587
+ // Block creating tasks in the past
1588
+ if (d < new Date()) {
1589
+ showToast("Cannot schedule a task in the past", "error");
1590
+ return;
1591
+ }
1704
1592
  openCreateModal(d, hour, slot);
1705
1593
  });
1706
1594
  slot.addEventListener("dragover", function (e) {
@@ -1725,6 +1613,10 @@ function attachWeekSlotClicks(container) {
1725
1613
  var quarter = parseInt(slot.dataset.quarter || "0", 10);
1726
1614
  var minute = quarter * 15;
1727
1615
  var d = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10), hour, minute, 0);
1616
+ if (d < new Date()) {
1617
+ showToast("Cannot schedule a task in the past", "error");
1618
+ return;
1619
+ }
1728
1620
  openCreateModal(d, hour, slot);
1729
1621
  applyDraggedTask();
1730
1622
  });
@@ -1775,10 +1667,19 @@ function setupCreateModal() {
1775
1667
  createSelectedDate = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10));
1776
1668
  document.getElementById("sched-create-date").value = this.value;
1777
1669
  updateRecurrenceLabels(createSelectedDate);
1670
+ enforceMinTime();
1778
1671
  }
1779
1672
  });
1780
1673
  }
1781
1674
 
1675
+ // Time input change — enforce min time when today is selected
1676
+ var timeInputEl = document.getElementById("sched-create-time");
1677
+ if (timeInputEl) {
1678
+ timeInputEl.addEventListener("blur", function () {
1679
+ enforceMinTime();
1680
+ });
1681
+ }
1682
+
1782
1683
  // Task dropdown
1783
1684
  var taskBtn = document.getElementById("sched-create-task-btn");
1784
1685
  var taskList = document.getElementById("sched-create-task-list");
@@ -1809,8 +1710,6 @@ function setupCreateModal() {
1809
1710
  if (dd) {
1810
1711
  var wasHidden = dd.classList.contains("hidden");
1811
1712
  dd.classList.toggle("hidden");
1812
- document.getElementById("sched-custom-repeat-panel").classList.add("hidden");
1813
- document.getElementById("sched-create-recurrence-list").style.display = "";
1814
1713
  if (wasHidden && btn) {
1815
1714
  var bRect = btn.getBoundingClientRect();
1816
1715
  var ddW = 280;
@@ -1823,31 +1722,8 @@ function setupCreateModal() {
1823
1722
  }
1824
1723
  });
1825
1724
 
1826
- // Recurrence option clicks
1827
- var recOptions = createPopover.querySelectorAll(".sched-recurrence-option");
1828
- for (var i = 0; i < recOptions.length; i++) {
1829
- (function (opt) {
1830
- opt.addEventListener("click", function (e) {
1831
- e.stopPropagation();
1832
- var rec = opt.dataset.recurrence;
1833
- if (rec === "custom") {
1834
- document.getElementById("sched-create-recurrence-list").style.display = "none";
1835
- document.getElementById("sched-custom-repeat-panel").classList.remove("hidden");
1836
- return;
1837
- }
1838
- for (var j = 0; j < recOptions.length; j++) {
1839
- recOptions[j].classList.toggle("active", recOptions[j] === opt);
1840
- }
1841
- createRecurrence = rec;
1842
- createCustomConfirmed = false;
1843
- // Close dropdown
1844
- document.getElementById("sched-create-recurrence-dropdown").classList.add("hidden");
1845
- updateRecurrenceBtn();
1846
- });
1847
- })(recOptions[i]);
1848
- }
1849
-
1850
1725
  // --- Interval button + dropdown ---
1726
+ var intervalSnapshot = null;
1851
1727
  document.getElementById("sched-create-interval-btn").addEventListener("click", function (e) {
1852
1728
  e.stopPropagation();
1853
1729
  var dd = document.getElementById("sched-create-interval-dropdown");
@@ -1858,6 +1734,14 @@ function setupCreateModal() {
1858
1734
  var wasHidden = dd.classList.contains("hidden");
1859
1735
  dd.classList.toggle("hidden");
1860
1736
  if (wasHidden && btn) {
1737
+ // Save snapshot for cancel
1738
+ intervalSnapshot = {
1739
+ interval: createInterval,
1740
+ custom: createIntervalCustom ? { value: createIntervalCustom.value, unit: createIntervalCustom.unit } : null,
1741
+ end: createIntervalEnd,
1742
+ endAfter: createIntervalEndAfter,
1743
+ endTime: createIntervalEndTime
1744
+ };
1861
1745
  var bRect = btn.getBoundingClientRect();
1862
1746
  var ddW = 220;
1863
1747
  var ddLeft = bRect.left;
@@ -1865,29 +1749,36 @@ function setupCreateModal() {
1865
1749
  if (ddLeft < 10) ddLeft = 10;
1866
1750
  dd.style.left = ddLeft + "px";
1867
1751
  dd.style.top = (bRect.bottom + 4) + "px";
1752
+ // Auto-apply custom interval when opening
1753
+ if (createInterval === "none") {
1754
+ applyInlineInterval();
1755
+ }
1868
1756
  }
1869
1757
  }
1870
1758
  });
1871
1759
 
1872
- // Interval option clicks
1873
- var intOptions = document.querySelectorAll(".sched-interval-option");
1874
- for (var ii = 0; ii < intOptions.length; ii++) {
1875
- (function (opt) {
1876
- opt.addEventListener("click", function (e) {
1877
- e.stopPropagation();
1878
- var val = opt.dataset.interval;
1879
- for (var j = 0; j < intOptions.length; j++) {
1880
- intOptions[j].classList.toggle("active", intOptions[j] === opt);
1881
- }
1882
- createInterval = val;
1883
- createIntervalCustom = null;
1884
- document.getElementById("sched-create-interval-dropdown").classList.add("hidden");
1885
- updateIntervalBtn();
1886
- });
1887
- })(intOptions[ii]);
1888
- }
1760
+ // Interval Cancel button - revert to snapshot
1761
+ document.getElementById("sched-interval-cancel").addEventListener("click", function (e) {
1762
+ e.stopPropagation();
1763
+ if (intervalSnapshot) {
1764
+ createInterval = intervalSnapshot.interval;
1765
+ createIntervalCustom = intervalSnapshot.custom;
1766
+ createIntervalEnd = intervalSnapshot.end;
1767
+ createIntervalEndAfter = intervalSnapshot.endAfter;
1768
+ createIntervalEndTime = intervalSnapshot.endTime;
1769
+ updateIntervalBtn();
1770
+ }
1771
+ document.getElementById("sched-create-interval-dropdown").classList.add("hidden");
1772
+ });
1889
1773
 
1890
- // Interval inline custom input
1774
+ // Interval OK button - accept and close
1775
+ document.getElementById("sched-interval-ok").addEventListener("click", function (e) {
1776
+ e.stopPropagation();
1777
+ applyInlineInterval();
1778
+ document.getElementById("sched-create-interval-dropdown").classList.add("hidden");
1779
+ });
1780
+
1781
+ // Interval custom input
1891
1782
  var intCustomValue = document.getElementById("sched-interval-custom-value");
1892
1783
  var intUnitSegs = document.querySelectorAll(".sched-interval-seg");
1893
1784
  function getIntervalUnit() {
@@ -1901,15 +1792,9 @@ function setupCreateModal() {
1901
1792
  var u = getIntervalUnit();
1902
1793
  createInterval = "custom";
1903
1794
  createIntervalCustom = { value: v, unit: u };
1904
- for (var j = 0; j < intOptions.length; j++) {
1905
- intOptions[j].classList.remove("active");
1906
- }
1907
1795
  updateIntervalBtn();
1908
1796
  }
1909
1797
  intCustomValue.addEventListener("change", applyInlineInterval);
1910
- intCustomValue.addEventListener("keydown", function (e) { e.stopPropagation(); });
1911
- intCustomValue.addEventListener("keyup", function (e) { e.stopPropagation(); });
1912
- intCustomValue.addEventListener("keypress", function (e) { e.stopPropagation(); });
1913
1798
  for (var si = 0; si < intUnitSegs.length; si++) {
1914
1799
  (function (seg) {
1915
1800
  seg.addEventListener("click", function (e) {
@@ -1922,18 +1807,44 @@ function setupCreateModal() {
1922
1807
  })(intUnitSegs[si]);
1923
1808
  }
1924
1809
 
1925
- // Custom repeat: back
1926
- document.getElementById("sched-custom-back").addEventListener("click", function (e) {
1927
- e.stopPropagation();
1928
- document.getElementById("sched-custom-repeat-panel").classList.add("hidden");
1929
- document.getElementById("sched-create-recurrence-list").style.display = "";
1930
- });
1810
+ // Interval end condition options
1811
+ var iendOpts = document.querySelectorAll(".sched-interval-end-opt");
1812
+ for (var ie = 0; ie < iendOpts.length; ie++) {
1813
+ (function (opt) {
1814
+ opt.addEventListener("click", function (e) {
1815
+ e.stopPropagation();
1816
+ var val = opt.dataset.iend;
1817
+ for (var j = 0; j < iendOpts.length; j++) {
1818
+ iendOpts[j].classList.toggle("active", iendOpts[j] === opt);
1819
+ }
1820
+ createIntervalEnd = val;
1821
+ var afterRow = document.getElementById("sched-interval-end-after-row");
1822
+ var untilRow = document.getElementById("sched-interval-end-until-row");
1823
+ if (afterRow) afterRow.classList.toggle("hidden", val !== "after");
1824
+ if (untilRow) untilRow.classList.toggle("hidden", val !== "until");
1825
+ });
1826
+ })(iendOpts[ie]);
1827
+ }
1828
+
1829
+ var iendAfterInput = document.getElementById("sched-interval-end-after");
1830
+ if (iendAfterInput) {
1831
+ iendAfterInput.addEventListener("change", function () {
1832
+ createIntervalEndAfter = parseInt(this.value, 10) || 5;
1833
+ if (createIntervalEndAfter < 1) createIntervalEndAfter = 1;
1834
+ });
1835
+ }
1836
+
1837
+ var iendTimeInput = document.getElementById("sched-interval-end-time");
1838
+ if (iendTimeInput) {
1839
+ iendTimeInput.addEventListener("change", function () {
1840
+ createIntervalEndTime = this.value;
1841
+ });
1842
+ }
1931
1843
 
1932
1844
  // Custom repeat: cancel
1933
1845
  document.getElementById("sched-custom-cancel").addEventListener("click", function (e) {
1934
1846
  e.stopPropagation();
1935
- document.getElementById("sched-custom-repeat-panel").classList.add("hidden");
1936
- document.getElementById("sched-create-recurrence-list").style.display = "";
1847
+ document.getElementById("sched-create-recurrence-dropdown").classList.add("hidden");
1937
1848
  });
1938
1849
 
1939
1850
  // Custom repeat: unit change
@@ -2057,10 +1968,6 @@ function setupCreateModal() {
2057
1968
  e.stopPropagation();
2058
1969
  createRecurrence = "custom";
2059
1970
  createCustomConfirmed = true;
2060
- var recOptions = createPopover.querySelectorAll(".sched-recurrence-option");
2061
- for (var j = 0; j < recOptions.length; j++) {
2062
- recOptions[j].classList.toggle("active", recOptions[j].dataset.recurrence === "custom");
2063
- }
2064
1971
  document.getElementById("sched-create-recurrence-dropdown").classList.add("hidden");
2065
1972
  updateRecurrenceBtn();
2066
1973
  });
@@ -2184,9 +2091,36 @@ function updateRecurrenceBtn() {
2184
2091
  if (btn) {
2185
2092
  btn.classList.toggle("has-recurrence", createRecurrence !== "none");
2186
2093
  }
2187
- var skipRow = document.getElementById("sched-skip-running-row");
2188
- if (skipRow) {
2189
- skipRow.classList.toggle("hidden", createInterval === "none");
2094
+ }
2095
+
2096
+ /**
2097
+ * When today is selected, set min on the time input so past times appear disabled.
2098
+ * If the current time value is before now, bump it to the next quarter-hour.
2099
+ */
2100
+ function enforceMinTime() {
2101
+ var timeInput = document.getElementById("sched-create-time");
2102
+ var datePicker = document.getElementById("sched-create-date-picker");
2103
+ if (!timeInput || !datePicker) return;
2104
+
2105
+ var now = new Date();
2106
+ var todayStr = now.getFullYear() + "-" + pad(now.getMonth() + 1) + "-" + pad(now.getDate());
2107
+ var isToday = datePicker.value === todayStr;
2108
+
2109
+ if (isToday) {
2110
+ // Round up to the next minute for min
2111
+ var minMinutes = now.getHours() * 60 + now.getMinutes() + 1;
2112
+ var minH = Math.floor(minMinutes / 60);
2113
+ var minM = minMinutes % 60;
2114
+ if (minH >= 24) { minH = 23; minM = 59; }
2115
+ var minVal = pad(minH) + ":" + pad(minM);
2116
+ timeInput.min = minVal;
2117
+
2118
+ // If current value is before min, bump it
2119
+ if (timeInput.value < minVal) {
2120
+ timeInput.value = minVal;
2121
+ }
2122
+ } else {
2123
+ timeInput.removeAttribute("min");
2190
2124
  }
2191
2125
  }
2192
2126
 
@@ -2195,11 +2129,12 @@ function updateIntervalBtn() {
2195
2129
  if (btn) {
2196
2130
  btn.classList.toggle("has-recurrence", createInterval !== "none");
2197
2131
  }
2198
- // Hide time picker when interval is set
2199
- var timeInput = document.getElementById("sched-create-time");
2200
- if (timeInput) {
2201
- timeInput.style.display = createInterval !== "none" ? "none" : "";
2132
+ // Show/hide interval end conditions section
2133
+ var endSection = document.getElementById("sched-interval-end-section");
2134
+ if (endSection) {
2135
+ endSection.classList.toggle("hidden", createInterval === "none");
2202
2136
  }
2137
+ // Always show time picker (start time is needed even with interval)
2203
2138
  // Update skip-if-running visibility
2204
2139
  updateRecurrenceBtn();
2205
2140
  }
@@ -2383,6 +2318,104 @@ function openCreateModalWithRecord(rec, anchorEl) {
2383
2318
  }
2384
2319
  }
2385
2320
 
2321
+ // Restore interval from cron
2322
+ if (rec.cron) {
2323
+ var cronParts = rec.cron.trim().split(/\s+/);
2324
+ if (cronParts.length === 5) {
2325
+ var detectedMinInterval = null;
2326
+ var detectedHrInterval = null;
2327
+ // Detect minute-level interval: e.g. "0,5,10,... * * * *" or "*/5 * * * *"
2328
+ if (cronParts[1] === "*" && cronParts[2] === "*") {
2329
+ detectedMinInterval = detectInterval(cronParts[0], 60);
2330
+ }
2331
+ // Detect hour-level interval: e.g. "0 1,3,5,... * * *"
2332
+ if (!detectedMinInterval && cronParts[2] === "*") {
2333
+ detectedHrInterval = detectInterval(cronParts[1], 24);
2334
+ }
2335
+
2336
+ if (detectedMinInterval) {
2337
+ createInterval = "custom";
2338
+ createIntervalCustom = { value: detectedMinInterval, unit: "minute" };
2339
+ var intValEl = document.getElementById("sched-interval-custom-value");
2340
+ if (intValEl) intValEl.value = detectedMinInterval;
2341
+ var intUnitBtns = document.querySelectorAll("#sched-interval-custom-unit .sched-interval-seg");
2342
+ for (var iu = 0; iu < intUnitBtns.length; iu++) {
2343
+ intUnitBtns[iu].classList.toggle("active", intUnitBtns[iu].dataset.unit === "minute");
2344
+ }
2345
+ updateIntervalBtn();
2346
+ } else if (detectedHrInterval) {
2347
+ createInterval = "custom";
2348
+ createIntervalCustom = { value: detectedHrInterval, unit: "hour" };
2349
+ var intValEl2 = document.getElementById("sched-interval-custom-value");
2350
+ if (intValEl2) intValEl2.value = detectedHrInterval;
2351
+ var intUnitBtns2 = document.querySelectorAll("#sched-interval-custom-unit .sched-interval-seg");
2352
+ for (var iu2 = 0; iu2 < intUnitBtns2.length; iu2++) {
2353
+ intUnitBtns2[iu2].classList.toggle("active", intUnitBtns2[iu2].dataset.unit === "hour");
2354
+ }
2355
+ updateIntervalBtn();
2356
+ }
2357
+
2358
+ // Restore recurrence from cron (if no interval detected, or combined with interval)
2359
+ if (!detectedMinInterval && !detectedHrInterval) {
2360
+ var cronDow = cronParts[4];
2361
+ var cronDom = cronParts[2];
2362
+ var cronMonth = cronParts[3];
2363
+ if (cronDow === "*" && cronDom === "*" && cronMonth === "*") {
2364
+ createRecurrence = "daily";
2365
+ } else if (cronDow === "1-5" && cronDom === "*") {
2366
+ createRecurrence = "weekdays";
2367
+ } else if (cronDom !== "*" && cronMonth !== "*") {
2368
+ createRecurrence = "yearly";
2369
+ } else if (cronDom !== "*" && cronDow === "*") {
2370
+ createRecurrence = "monthly";
2371
+ } else if (cronDow !== "*" && cronDom === "*") {
2372
+ // Check if it matches a single day (weekly)
2373
+ var dowVals = cronDow.split(",");
2374
+ if (dowVals.length === 1) {
2375
+ createRecurrence = "weekly";
2376
+ } else if (dowVals.length === 7) {
2377
+ createRecurrence = "daily";
2378
+ } else {
2379
+ createRecurrence = "custom";
2380
+ createCustomConfirmed = true;
2381
+ // Set custom panel values
2382
+ document.getElementById("sched-custom-interval").value = "1";
2383
+ document.getElementById("sched-custom-unit").value = "week";
2384
+ var customDowBtns = document.querySelectorAll("#sched-custom-dow-row .sched-dow-btn");
2385
+ for (var cd = 0; cd < customDowBtns.length; cd++) {
2386
+ customDowBtns[cd].classList.toggle("active", dowVals.indexOf(customDowBtns[cd].dataset.dow) !== -1);
2387
+ }
2388
+ }
2389
+ }
2390
+ updateRecurrenceBtn();
2391
+ }
2392
+ }
2393
+ }
2394
+
2395
+ // Restore interval end conditions
2396
+ if (rec.intervalEnd) {
2397
+ createIntervalEnd = rec.intervalEnd.type || "allday";
2398
+ var editIendOpts = document.querySelectorAll(".sched-interval-end-opt");
2399
+ for (var ei = 0; ei < editIendOpts.length; ei++) {
2400
+ editIendOpts[ei].classList.toggle("active", editIendOpts[ei].dataset.iend === createIntervalEnd);
2401
+ }
2402
+ var editAfterRow = document.getElementById("sched-interval-end-after-row");
2403
+ var editUntilRow = document.getElementById("sched-interval-end-until-row");
2404
+ if (createIntervalEnd === "after") {
2405
+ createIntervalEndAfter = rec.intervalEnd.count || 5;
2406
+ if (editAfterRow) editAfterRow.classList.remove("hidden");
2407
+ if (editUntilRow) editUntilRow.classList.add("hidden");
2408
+ var editAfterInput = document.getElementById("sched-interval-end-after");
2409
+ if (editAfterInput) editAfterInput.value = createIntervalEndAfter;
2410
+ } else if (createIntervalEnd === "until") {
2411
+ createIntervalEndTime = rec.intervalEnd.time || "18:00";
2412
+ if (editAfterRow) editAfterRow.classList.add("hidden");
2413
+ if (editUntilRow) editUntilRow.classList.remove("hidden");
2414
+ var editTimeInput = document.getElementById("sched-interval-end-time");
2415
+ if (editTimeInput) editTimeInput.value = createIntervalEndTime;
2416
+ }
2417
+ }
2418
+
2386
2419
  // Update preview to show record name
2387
2420
  if (previewEl) {
2388
2421
  var previewText = rec.name || "(No title)";
@@ -2405,6 +2438,9 @@ function openCreateModal(date, hour, anchorEl) {
2405
2438
  createCustomConfirmed = false;
2406
2439
  createInterval = "none";
2407
2440
  createIntervalCustom = null;
2441
+ createIntervalEnd = "allday";
2442
+ createIntervalEndAfter = 5;
2443
+ createIntervalEndTime = "";
2408
2444
  createColor = "#ffb86c";
2409
2445
 
2410
2446
  // Reset form
@@ -2476,40 +2512,62 @@ function openCreateModal(date, hour, anchorEl) {
2476
2512
  var dateStr = createSelectedDate.getFullYear() + "-" + pad(createSelectedDate.getMonth() + 1) + "-" + pad(createSelectedDate.getDate());
2477
2513
  document.getElementById("sched-create-date").value = dateStr;
2478
2514
  var datePicker = document.getElementById("sched-create-date-picker");
2479
- if (datePicker) datePicker.value = dateStr;
2515
+ if (datePicker) {
2516
+ datePicker.value = dateStr;
2517
+ var todayNow = new Date();
2518
+ var todayMin = todayNow.getFullYear() + "-" + pad(todayNow.getMonth() + 1) + "-" + pad(todayNow.getDate());
2519
+ datePicker.min = todayMin;
2520
+ }
2480
2521
 
2481
2522
  // Time (use minutes from createSelectedDate for 15-min snapping)
2482
2523
  if (hour !== null && hour !== undefined) {
2483
2524
  var mins = createSelectedDate.getMinutes ? createSelectedDate.getMinutes() : 0;
2484
2525
  document.getElementById("sched-create-time").value = pad(hour) + ":" + pad(mins);
2485
2526
  } else {
2486
- document.getElementById("sched-create-time").value = "09:00";
2527
+ // Default to current time (next quarter-hour)
2528
+ var nowT = new Date();
2529
+ var nowMins = nowT.getHours() * 60 + nowT.getMinutes();
2530
+ var nextQ = Math.ceil(nowMins / 15) * 15;
2531
+ var defH = Math.floor(nextQ / 60);
2532
+ var defM = nextQ % 60;
2533
+ if (defH >= 24) { defH = 23; defM = 45; }
2534
+ document.getElementById("sched-create-time").value = pad(defH) + ":" + pad(defM);
2487
2535
  }
2488
2536
 
2489
2537
  // Update recurrence labels
2490
2538
  updateRecurrenceLabels(createSelectedDate);
2491
2539
 
2540
+ // Enforce min time for today
2541
+ enforceMinTime();
2542
+
2492
2543
  // Reset recurrence
2493
- var recOptions = createPopover.querySelectorAll(".sched-recurrence-option");
2494
- for (var i = 0; i < recOptions.length; i++) {
2495
- recOptions[i].classList.toggle("active", recOptions[i].dataset.recurrence === "none");
2496
- }
2544
+ createRecurrence = "none";
2545
+ createCustomConfirmed = false;
2497
2546
  updateRecurrenceBtn();
2498
2547
 
2499
2548
  // Reset interval
2500
- var intOpts = document.querySelectorAll(".sched-interval-option");
2501
- for (var io = 0; io < intOpts.length; io++) {
2502
- intOpts[io].classList.toggle("active", intOpts[io].dataset.interval === "none");
2503
- }
2504
2549
  document.getElementById("sched-create-interval-dropdown").classList.add("hidden");
2505
2550
  var timeInput = document.getElementById("sched-create-time");
2506
2551
  if (timeInput) timeInput.style.display = "";
2552
+
2553
+ // Reset interval end conditions
2554
+ var iendOpts = document.querySelectorAll(".sched-interval-end-opt");
2555
+ for (var ie = 0; ie < iendOpts.length; ie++) {
2556
+ iendOpts[ie].classList.toggle("active", iendOpts[ie].dataset.iend === "allday");
2557
+ }
2558
+ var iendAfterRow = document.getElementById("sched-interval-end-after-row");
2559
+ if (iendAfterRow) iendAfterRow.classList.add("hidden");
2560
+ var iendUntilRow = document.getElementById("sched-interval-end-until-row");
2561
+ if (iendUntilRow) iendUntilRow.classList.add("hidden");
2562
+ var iendAfterInput = document.getElementById("sched-interval-end-after");
2563
+ if (iendAfterInput) iendAfterInput.value = "5";
2564
+ var iendTimeInput = document.getElementById("sched-interval-end-time");
2565
+ if (iendTimeInput) iendTimeInput.value = "18:00";
2566
+
2507
2567
  updateIntervalBtn();
2508
2568
 
2509
2569
  // Reset custom panel
2510
2570
  document.getElementById("sched-create-recurrence-dropdown").classList.add("hidden");
2511
- document.getElementById("sched-custom-repeat-panel").classList.add("hidden");
2512
- document.getElementById("sched-create-recurrence-list").style.display = "";
2513
2571
  document.getElementById("sched-custom-interval").value = "1";
2514
2572
  document.getElementById("sched-custom-unit").value = "week";
2515
2573
  document.getElementById("sched-custom-dow-section").style.display = "";
@@ -2681,6 +2739,18 @@ function closeDeleteDialog() {
2681
2739
  }
2682
2740
  }
2683
2741
 
2742
+ // Build an explicit list of values offset from a start value with a given step, wrapping at max
2743
+ function buildOffsetList(start, step, max) {
2744
+ var vals = [];
2745
+ var v = start % max;
2746
+ for (var i = 0; i < max; i += step) {
2747
+ vals.push(v);
2748
+ v = (v + step) % max;
2749
+ }
2750
+ vals.sort(function (a, b) { return a - b; });
2751
+ return vals.join(",");
2752
+ }
2753
+
2684
2754
  function buildCreateCron() {
2685
2755
  if (!createSelectedDate) return null;
2686
2756
 
@@ -2707,9 +2777,9 @@ function buildCreateCron() {
2707
2777
 
2708
2778
  // Interval only (no recurrence) = interval every day
2709
2779
  if (intervalMins > 0 && createRecurrence === "none") {
2710
- if (intervalMins < 60) return "*/" + intervalMins + " * * * *";
2780
+ if (intervalMins < 60) return buildOffsetList(m, intervalMins, 60) + " * * * *";
2711
2781
  var intHrs = Math.floor(intervalMins / 60);
2712
- return "0 */" + intHrs + " * * *";
2782
+ return String(m) + " " + buildOffsetList(h, intHrs, 24) + " * * *";
2713
2783
  }
2714
2784
 
2715
2785
  if (createRecurrence === "none" && intervalMins === 0) return null;
@@ -2718,12 +2788,12 @@ function buildCreateCron() {
2718
2788
  var minField = String(m);
2719
2789
  var hourField = String(h);
2720
2790
  if (intervalMins > 0 && intervalMins < 60) {
2721
- minField = "*/" + intervalMins;
2791
+ minField = buildOffsetList(m, intervalMins, 60);
2722
2792
  hourField = "*";
2723
2793
  } else if (intervalMins >= 60) {
2724
2794
  var intHrs2 = Math.floor(intervalMins / 60);
2725
2795
  minField = String(m);
2726
- hourField = "*/" + intHrs2;
2796
+ hourField = buildOffsetList(h, intHrs2, 24);
2727
2797
  }
2728
2798
 
2729
2799
  if (createRecurrence === "daily") return minField + " " + hourField + " * * *";
@@ -2837,10 +2907,10 @@ function buildCustomCron(h, m) {
2837
2907
  var unit = document.getElementById("sched-custom-unit").value;
2838
2908
 
2839
2909
  if (unit === "minute") {
2840
- return interval === 1 ? "*/1 * * * *" : "*/" + interval + " * * * *";
2910
+ return interval === 1 ? "*/1 * * * *" : buildOffsetList(m, interval, 60) + " * * * *";
2841
2911
  }
2842
2912
  if (unit === "hour") {
2843
- return interval === 1 ? "0 */1 * * *" : "0 */" + interval + " * * *";
2913
+ return interval === 1 ? m + " */1 * * *" : m + " " + buildOffsetList(h, interval, 24) + " * * *";
2844
2914
  }
2845
2915
  if (unit === "day") {
2846
2916
  if (interval === 1) return m + " " + h + " * * *";
@@ -2886,10 +2956,31 @@ function submitCreateSchedule() {
2886
2956
  var datePicker = document.getElementById("sched-create-date-picker");
2887
2957
  var dateVal = datePicker ? datePicker.value : document.getElementById("sched-create-date").value;
2888
2958
  var timeVal = document.getElementById("sched-create-time").value || "09:00";
2959
+
2960
+ // Reject scheduling in the past
2961
+ if (dateVal && timeVal) {
2962
+ var dp = dateVal.split("-");
2963
+ var tp = timeVal.split(":");
2964
+ if (dp.length === 3 && tp.length >= 2) {
2965
+ var schedDate = new Date(
2966
+ parseInt(dp[0], 10), parseInt(dp[1], 10) - 1, parseInt(dp[2], 10),
2967
+ parseInt(tp[0], 10), parseInt(tp[1], 10), 0
2968
+ );
2969
+ if (schedDate < new Date()) {
2970
+ showToast("Cannot schedule a task in the past", "error");
2971
+ return;
2972
+ }
2973
+ }
2974
+ }
2975
+
2889
2976
  var cron = buildCreateCron();
2890
2977
 
2891
2978
  // Build recurrence end info
2892
2979
  var recurrenceEnd = null;
2980
+ // Interval-only (no recurrence): limit to the scheduled date only
2981
+ if (cron && createRecurrence === "none" && createInterval !== "none" && dateVal) {
2982
+ recurrenceEnd = { type: "until", date: dateVal };
2983
+ }
2893
2984
  if (cron && createRecurrence === "custom" && createCustomConfirmed) {
2894
2985
  if (createEndType === "until" && createEndDate) {
2895
2986
  var ey = createEndDate.getFullYear();
@@ -2901,6 +2992,17 @@ function submitCreateSchedule() {
2901
2992
  }
2902
2993
  }
2903
2994
 
2995
+ // Build interval end info
2996
+ var intervalEnd = null;
2997
+ if (createInterval !== "none") {
2998
+ if (createIntervalEnd === "after" && createIntervalEndAfter > 0) {
2999
+ intervalEnd = { type: "after", count: createIntervalEndAfter };
3000
+ } else if (createIntervalEnd === "until" && createIntervalEndTime) {
3001
+ intervalEnd = { type: "until", time: createIntervalEndTime };
3002
+ }
3003
+ // "allday" = null (no limit)
3004
+ }
3005
+
2904
3006
  var skipRunningEl = document.getElementById("sched-skip-running");
2905
3007
  var skipIfRunning = skipRunningEl ? skipRunningEl.checked : true;
2906
3008
 
@@ -2928,6 +3030,7 @@ function submitCreateSchedule() {
2928
3030
  enabled: cron ? true : false,
2929
3031
  color: createColor,
2930
3032
  recurrenceEnd: recurrenceEnd,
3033
+ intervalEnd: intervalEnd,
2931
3034
  maxIterations: maxIterations,
2932
3035
  skipIfRunning: skipIfRunning,
2933
3036
  },
@@ -2946,6 +3049,7 @@ function submitCreateSchedule() {
2946
3049
  enabled: cron ? true : false,
2947
3050
  color: createColor,
2948
3051
  recurrenceEnd: recurrenceEnd,
3052
+ intervalEnd: intervalEnd,
2949
3053
  maxIterations: maxIterations,
2950
3054
  skipIfRunning: skipIfRunning,
2951
3055
  },
@@ -3022,15 +3126,15 @@ function cronToHuman(cron) {
3022
3126
  if (!cron) return "";
3023
3127
  var parts = cron.trim().split(/\s+/);
3024
3128
  if (parts.length !== 5) return cron;
3025
- // Minute interval patterns (e.g. */5 * * * *)
3026
- if (parts[0].indexOf("/") !== -1 && parts[1] === "*" && parts[2] === "*") {
3027
- var minStep = parseInt(parts[0].split("/")[1], 10);
3028
- return minStep === 1 ? "Every minute" : "Every " + minStep + " minutes";
3129
+ // Minute interval patterns (e.g. */5 * * * * or 0,15,30,45 * * * *)
3130
+ if (parts[1] === "*" && parts[2] === "*") {
3131
+ var minStep = detectInterval(parts[0], 60);
3132
+ if (minStep) return minStep === 1 ? "Every minute" : "Every " + minStep + " minutes";
3029
3133
  }
3030
- // Hour interval patterns (e.g. 0 */2 * * *)
3031
- if (parts[1].indexOf("/") !== -1 && parts[2] === "*") {
3032
- var hrStep = parseInt(parts[1].split("/")[1], 10);
3033
- return hrStep === 1 ? "Every hour" : "Every " + hrStep + " hours";
3134
+ // Hour interval patterns (e.g. 0 */2 * * * or 0 1,5,9,13,17,21 * * *)
3135
+ if (parts[2] === "*") {
3136
+ var hrStep = detectInterval(parts[1], 24);
3137
+ if (hrStep) return hrStep === 1 ? "Every hour" : "Every " + hrStep + " hours";
3034
3138
  }
3035
3139
  var t = pad(parseInt(parts[1], 10)) + ":" + pad(parseInt(parts[0], 10));
3036
3140
  var dow = parts[4], dom = parts[2];
@@ -3043,3 +3147,20 @@ function cronToHuman(cron) {
3043
3147
  }
3044
3148
  return cron;
3045
3149
  }
3150
+
3151
+ // Detect if a cron field represents an evenly-spaced interval (*/N or comma-separated offset list)
3152
+ function detectInterval(field, max) {
3153
+ if (field.indexOf("/") !== -1) return parseInt(field.split("/")[1], 10) || null;
3154
+ if (field.indexOf(",") === -1) return null;
3155
+ var vals = field.split(",").map(function (v) { return parseInt(v, 10); }).sort(function (a, b) { return a - b; });
3156
+ if (vals.length < 2) return null;
3157
+ var step = vals[1] - vals[0];
3158
+ if (step <= 0) return null;
3159
+ // Verify all values are evenly spaced (wrapping around max)
3160
+ for (var i = 1; i < vals.length; i++) {
3161
+ if (vals[i] - vals[i - 1] !== step) return null;
3162
+ }
3163
+ // Check the wrap-around gap matches too
3164
+ if ((max - vals[vals.length - 1] + vals[0]) !== step) return null;
3165
+ return step;
3166
+ }