From 9742fd8e60e63ee878e718ce0e3548feed4c36f9 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 4 Jul 2026 14:22:41 -0400 Subject: [PATCH 1/3] feat(#5552): add time and day controls to cron presets --- static/panels.js | 206 +++++++++++- static/style.css | 9 + tests/test_issue2031_cron_once_visibility.py | 332 ++++++++++++++++--- 3 files changed, 474 insertions(+), 73 deletions(-) diff --git a/static/panels.js b/static/panels.js index a1ecd517f..4a918aa24 100644 --- a/static/panels.js +++ b/static/panels.js @@ -467,12 +467,12 @@ function _syncCronScheduleWarning() { } const CRON_SCHEDULE_PRESETS = [ - { id: 'custom', value: '', label: 'cron_schedule_preset_custom', fallback: 'Custom' }, - { id: 'hourly', value: 'every 1h', label: 'cron_schedule_preset_hourly', fallback: 'Hourly' }, - { id: 'daily', value: '0 9 * * *', label: 'cron_schedule_preset_daily', fallback: 'Daily' }, - { id: 'weekdays', value: '0 9 * * 1-5', label: 'cron_schedule_preset_weekdays', fallback: 'Weekdays' }, - { id: 'weekly', value: '0 9 * * 1', label: 'cron_schedule_preset_weekly', fallback: 'Weekly' }, - { id: 'monthly', value: '0 9 1 * *', label: 'cron_schedule_preset_monthly', fallback: 'Monthly' }, + { id: 'custom', label: 'cron_schedule_preset_custom', fallback: 'Custom', fields: [] }, + { id: 'hourly', label: 'cron_schedule_preset_hourly', fallback: 'Hourly', fields: ['minute'], defaults: { minute: 0 } }, + { id: 'daily', label: 'cron_schedule_preset_daily', fallback: 'Daily', fields: ['hour', 'minute'], defaults: { hour: 9, minute: 0 } }, + { id: 'weekdays', label: 'cron_schedule_preset_weekdays', fallback: 'Weekdays', fields: ['hour', 'minute'], defaults: { hour: 9, minute: 0 } }, + { id: 'weekly', label: 'cron_schedule_preset_weekly', fallback: 'Weekly', fields: ['hour', 'minute', 'weekday'], defaults: { hour: 9, minute: 0, weekday: 1 } }, + { id: 'monthly', label: 'cron_schedule_preset_monthly', fallback: 'Monthly', fields: ['hour', 'minute', 'monthDay'], defaults: { hour: 9, minute: 0, monthDay: 1 } }, ]; function _cronSchedulePresetOptionHtml() { @@ -481,18 +481,159 @@ function _cronSchedulePresetOptionHtml() { .join(''); } -function _cronSchedulePresetIdForValue(value) { +function _cronSchedulePresetForId(presetId) { + return CRON_SCHEDULE_PRESETS.find((entry) => entry.id === presetId) || null; +} + +function _cronSchedulePresetControlIds() { + return { + hour: 'cronFormScheduleHour', + minute: 'cronFormScheduleMinute', + weekday: 'cronFormScheduleWeekday', + monthDay: 'cronFormScheduleMonthDay', + }; +} + +function _cronSchedulePresetFieldId(field) { + const ids = _cronSchedulePresetControlIds(); + return ids[field] || ''; +} + +function _cronSchedulePresetFieldEl(field) { + const id = _cronSchedulePresetFieldId(field); + return id ? $(id) : null; +} + +function _cronSchedulePresetBounds(field) { + if (field === 'hour') return { min: 0, max: 23 }; + if (field === 'minute') return { min: 0, max: 59 }; + if (field === 'weekday') return { min: 0, max: 7 }; + if (field === 'monthDay') return { min: 1, max: 31 }; + return { min: 0, max: 999 }; +} + +function _cronSchedulePresetNormalizeValue(field, value, fallback) { + const bounds = _cronSchedulePresetBounds(field); + const parsed = parseInt(String(value ?? '').trim(), 10); + const fallbackParsed = parseInt(String(fallback ?? bounds.min).trim(), 10); + const safeFallback = Number.isFinite(fallbackParsed) ? fallbackParsed : bounds.min; + const n = Number.isFinite(parsed) ? parsed : safeFallback; + return String(Math.min(bounds.max, Math.max(bounds.min, n))); +} + +function _cronSchedulePresetValueForField(field, fallback) { + const el = _cronSchedulePresetFieldEl(field); + const raw = el ? String(el.value || '').trim() : ''; + return _cronSchedulePresetNormalizeValue(field, raw, fallback); +} + +function _cronSchedulePresetRawFieldInBounds(field, value) { + const raw = String(value || '').trim(); + if (!/^\d+$/.test(raw)) return false; + const bounds = _cronSchedulePresetBounds(field); + const parsed = parseInt(raw, 10); + return Number.isFinite(parsed) && parsed >= bounds.min && parsed <= bounds.max; +} + +function _cronSchedulePresetApplyValues(values) { + ['hour', 'minute', 'weekday', 'monthDay'].forEach((field) => { + const el = _cronSchedulePresetFieldEl(field); + if (!el || values[field] == null) return; + el.value = _cronSchedulePresetNormalizeValue(field, values[field], values[field]); + }); +} + +function _cronSchedulePresetSyncVisibility(presetId) { + const wrapper = $('cronFormSchedulePresetParams'); + const preset = _cronSchedulePresetForId(presetId); + const showFields = preset && Array.isArray(preset.fields) ? preset.fields : []; + if (wrapper) wrapper.style.display = presetId === 'custom' ? 'none' : ''; + ['hour', 'minute', 'weekday', 'monthDay'].forEach((field) => { + const fieldWrap = $(`cronFormSchedule${field === 'monthDay' ? 'MonthDay' : field.charAt(0).toUpperCase() + field.slice(1)}Field`); + if (fieldWrap) fieldWrap.style.display = showFields.includes(field) ? '' : 'none'; + }); +} + +function _cronSchedulePresetValuesForSelection(presetId) { + const preset = _cronSchedulePresetForId(presetId); + const defaults = (preset && preset.defaults) || {}; + if (presetId === 'hourly') { + return { minute: _cronSchedulePresetValueForField('minute', defaults.minute) }; + } + if (presetId === 'daily' || presetId === 'weekdays') { + return { + hour: _cronSchedulePresetValueForField('hour', defaults.hour), + minute: _cronSchedulePresetValueForField('minute', defaults.minute), + }; + } + if (presetId === 'weekly') { + return { + hour: _cronSchedulePresetValueForField('hour', defaults.hour), + minute: _cronSchedulePresetValueForField('minute', defaults.minute), + weekday: _cronSchedulePresetValueForField('weekday', defaults.weekday), + }; + } + if (presetId === 'monthly') { + return { + hour: _cronSchedulePresetValueForField('hour', defaults.hour), + minute: _cronSchedulePresetValueForField('minute', defaults.minute), + monthDay: _cronSchedulePresetValueForField('monthDay', defaults.monthDay), + }; + } + return {}; +} + +function _cronSchedulePresetValueForSelection(presetId) { + const values = _cronSchedulePresetValuesForSelection(presetId); + if (presetId === 'hourly') return `${values.minute} * * * *`; + if (presetId === 'daily') return `${values.minute} ${values.hour} * * *`; + if (presetId === 'weekdays') return `${values.minute} ${values.hour} * * 1-5`; + if (presetId === 'weekly') return `${values.minute} ${values.hour} * * ${values.weekday}`; + if (presetId === 'monthly') return `${values.minute} ${values.hour} ${values.monthDay} * *`; + return ''; +} + +function _cronSchedulePresetStateForInput(value) { const schedule = String(value || '').trim(); - const preset = CRON_SCHEDULE_PRESETS.find((entry) => entry.value && entry.value === schedule); - return preset ? preset.id : 'custom'; + if (!schedule) return { presetId: 'custom' }; + if (/^every\s+1h$/i.test(schedule)) { + return { presetId: 'hourly', minute: '0' }; + } + if (schedule.startsWith('@')) return { presetId: 'custom' }; + const parts = schedule.split(/\s+/); + if (parts.length !== 5) return { presetId: 'custom' }; + const [minute, hour, dayOfMonth, month, dayOfWeek] = parts; + if (!_cronSchedulePresetRawFieldInBounds('minute', minute)) return { presetId: 'custom' }; + if (_cronSchedulePresetRawFieldInBounds('hour', hour) && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { + return { presetId: 'daily', minute, hour }; + } + if (_cronSchedulePresetRawFieldInBounds('hour', hour) && dayOfMonth === '*' && month === '*' && dayOfWeek === '1-5') { + return { presetId: 'weekdays', minute, hour }; + } + if (hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { + return { presetId: 'hourly', minute }; + } + if (_cronSchedulePresetRawFieldInBounds('hour', hour) && dayOfMonth === '*' && month === '*' && _cronSchedulePresetRawFieldInBounds('weekday', dayOfWeek)) { + return { presetId: 'weekly', minute, hour, weekday: dayOfWeek }; + } + if (_cronSchedulePresetRawFieldInBounds('hour', hour) && _cronSchedulePresetRawFieldInBounds('monthDay', dayOfMonth) && month === '*' && dayOfWeek === '*') { + return { presetId: 'monthly', minute, hour, monthDay: dayOfMonth }; + } + return { presetId: 'custom' }; +} + +function _cronSchedulePresetIdForValue(value) { + return _cronSchedulePresetStateForInput(value).presetId; } function _syncCronSchedulePresetFromInput() { const presetEl = $('cronFormSchedulePreset'); const scheduleEl = $('cronFormSchedule'); if (!presetEl || !scheduleEl) return; - const presetId = _cronSchedulePresetIdForValue(scheduleEl.value); - presetEl.value = presetId; + const state = _cronSchedulePresetStateForInput(scheduleEl.value); + presetEl.value = state.presetId; + _cronSchedulePresetSyncVisibility(state.presetId); + if (state.presetId !== 'custom') _cronSchedulePresetApplyValues(state); } function _syncCronSchedulePresetAndWarning() { @@ -505,20 +646,28 @@ function _applyCronSchedulePresetSelection() { const scheduleEl = $('cronFormSchedule'); if (!presetEl || !scheduleEl) return; const presetId = presetEl.value; - const preset = CRON_SCHEDULE_PRESETS.find((entry) => entry.id === presetId); - if (preset && preset.value) { - scheduleEl.value = preset.value; + if (presetId !== 'custom') { + scheduleEl.value = _cronSchedulePresetValueForSelection(presetId); + _cronSchedulePresetApplyValues(_cronSchedulePresetValuesForSelection(presetId)); _syncCronSchedulePresetAndWarning(); return; } + _cronSchedulePresetSyncVisibility(presetId); _syncCronScheduleWarning(); } function _initCronSchedulePresetControls() { const presetEl = $('cronFormSchedulePreset'); const scheduleEl = $('cronFormSchedule'); - if (!presetEl || !scheduleEl) return; + const presetParams = $('cronFormSchedulePresetParams'); + if (!presetEl || !scheduleEl || !presetParams) return; presetEl.addEventListener('change', _applyCronSchedulePresetSelection); + ['cronFormScheduleHour', 'cronFormScheduleMinute', 'cronFormScheduleWeekday', 'cronFormScheduleMonthDay'].forEach((id) => { + const el = $(id); + if (!el) return; + el.addEventListener('change', _applyCronSchedulePresetSelection); + el.addEventListener('input', _applyCronSchedulePresetSelection); + }); scheduleEl.addEventListener('input', _syncCronSchedulePresetAndWarning); scheduleEl.addEventListener('change', _syncCronSchedulePresetAndWarning); _syncCronSchedulePresetAndWarning(); @@ -1371,9 +1520,30 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, toast_notif
- +
+ + +
diff --git a/static/style.css b/static/style.css index 705096950..2006cd07c 100644 --- a/static/style.css +++ b/static/style.css @@ -6197,6 +6197,7 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view- .detail-form-row > label{font-size:12px;font-weight:500;color:var(--muted);letter-spacing:.01em;} .detail-form-row input[type="text"], .detail-form-row input[type="password"], +.detail-form-row input[type="number"], .detail-form-row input:not([type]), .detail-form-row select, .detail-form-row textarea{ @@ -6208,10 +6209,18 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view- } .detail-form-row input[type="text"]:focus, .detail-form-row input[type="password"]:focus, +.detail-form-row input[type="number"]:focus, .detail-form-row input:not([type]):focus, .detail-form-row select:focus, .detail-form-row textarea:focus{border-color:var(--accent,var(--accent-bg-strong,var(--border)));} .detail-form-row input:disabled{opacity:.6;cursor:not-allowed;} +.cron-schedule-preset-shell{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-start;} +.cron-schedule-preset-shell > select{width:auto;min-width:160px;flex:0 0 160px;} +.cron-schedule-preset-params{display:flex;flex-wrap:wrap;gap:8px;align-items:flex-end;min-width:0;} +.cron-schedule-preset-field{display:flex;flex-direction:column;gap:4px;flex:0 0 110px;min-width:110px;} +.cron-schedule-preset-field label{font-size:11px;font-weight:500;color:var(--muted);letter-spacing:.01em;} +.cron-schedule-preset-field input[type="number"]{width:100%;} +.cron-schedule-preset-time-hint{flex-basis:100%;} .detail-form-row textarea{resize:vertical;font-family:'SF Mono',ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;} .detail-form-row .detail-form-hint{font-size:11px;color:var(--muted);line-height:1.5;} .detail-form-warning{font-size:11px;line-height:1.5;border:1px solid rgba(245,158,11,.35);background:rgba(245,158,11,.1);color:rgba(245,158,11,.98);border-radius:8px;padding:8px 10px;} diff --git a/tests/test_issue2031_cron_once_visibility.py b/tests/test_issue2031_cron_once_visibility.py index 3ea70781e..f2ccfbe94 100644 --- a/tests/test_issue2031_cron_once_visibility.py +++ b/tests/test_issue2031_cron_once_visibility.py @@ -81,19 +81,27 @@ def test_cron_schedule_preset_matching(): script = _cron_schedule_source() + r""" const cases = { hourly: _cronSchedulePresetIdForValue("every 1h"), - daily: _cronSchedulePresetIdForValue("0 9 * * *"), - weekdays: _cronSchedulePresetIdForValue("0 9 * * 1-5"), - weekly: _cronSchedulePresetIdForValue("0 9 * * 1"), - monthly: _cronSchedulePresetIdForValue("0 9 1 * *"), + hourlyCron: _cronSchedulePresetIdForValue("15 * * * *"), + daily: _cronSchedulePresetIdForValue("30 7 * * *"), + weekdays: _cronSchedulePresetIdForValue("5 22 * * 1-5"), + weekly: _cronSchedulePresetIdForValue("45 8 * * 3"), + monthly: _cronSchedulePresetIdForValue("10 6 15 * *"), empty: _cronSchedulePresetIdForValue(""), - trimMatch: _cronSchedulePresetIdForValue(" 0 9 * * * "), + trimMatch: _cronSchedulePresetIdForValue(" 30 7 * * * "), custom: _cronSchedulePresetIdForValue("0 9 * * * 0"), + shorthand: _cronSchedulePresetIdForValue("@daily"), + invalidDaily: _cronSchedulePresetIdForValue("99 99 * * *"), + invalidHourly: _cronSchedulePresetIdForValue("60 * * * *"), + invalidWeekly: _cronSchedulePresetIdForValue("0 9 * * 8"), + invalidMonthDayZero: _cronSchedulePresetIdForValue("0 9 0 * *"), + invalidMonthly: _cronSchedulePresetIdForValue("0 9 32 * *"), }; console.log(JSON.stringify(cases)); """ cases = json.loads(_run_node(script)) assert cases["hourly"] == "hourly" + assert cases["hourlyCron"] == "hourly" assert cases["daily"] == "daily" assert cases["weekdays"] == "weekdays" assert cases["weekly"] == "weekly" @@ -101,16 +109,22 @@ console.log(JSON.stringify(cases)); assert cases["empty"] == "custom" assert cases["trimMatch"] == "daily" assert cases["custom"] == "custom" + assert cases["shorthand"] == "custom" + assert cases["invalidDaily"] == "custom" + assert cases["invalidHourly"] == "custom" + assert cases["invalidWeekly"] == "custom" + assert cases["invalidMonthDayZero"] == "custom" + assert cases["invalidMonthly"] == "custom" def test_cron_schedule_preset_controls_sync_raw_and_preset_paths(): script = _cron_schedule_source() + r""" const elements = {}; function $(id) { return elements[id]; } -function makeElement() { +function makeElement(initialValue = '') { return { - value: '', - style: {}, + value: initialValue, + style: { display: '' }, listeners: {}, addEventListener(type, handler) { (this.listeners[type] || (this.listeners[type] = [])).push(handler); @@ -135,64 +149,264 @@ function t(key) { } function esc(value) { return value == null ? '' : String(value); } -elements.cronFormSchedule = makeElement(); -elements.cronFormSchedulePreset = makeElement(); -elements.cronFormScheduleOnceWarning = { style: {} }; +[ + 'cronFormSchedule', + 'cronFormSchedulePreset', + 'cronFormSchedulePresetParams', + 'cronFormScheduleHourField', + 'cronFormScheduleMinuteField', + 'cronFormScheduleWeekdayField', + 'cronFormScheduleMonthDayField', + 'cronFormScheduleHour', + 'cronFormScheduleMinute', + 'cronFormScheduleWeekday', + 'cronFormScheduleMonthDay', +].forEach((id) => { + elements[id] = makeElement(); +}); +elements.cronFormScheduleOnceWarning = { style: { display: 'none' } }; +elements.cronFormSchedulePresetParams.style.display = 'none'; _initCronSchedulePresetControls(); -elements.cronFormSchedule.value = "0 9 * * *"; -elements.cronFormSchedule.dispatchEvent('input'); -const selectAfterDaily = elements.cronFormSchedulePreset.value; -const warningAfterDaily = elements.cronFormScheduleOnceWarning.style.display; +function snapshot() { + return { + preset: elements.cronFormSchedulePreset.value, + schedule: elements.cronFormSchedule.value, + paramsDisplay: elements.cronFormSchedulePresetParams.style.display, + hourFieldDisplay: elements.cronFormScheduleHourField.style.display, + minuteFieldDisplay: elements.cronFormScheduleMinuteField.style.display, + weekdayFieldDisplay: elements.cronFormScheduleWeekdayField.style.display, + monthDayFieldDisplay: elements.cronFormScheduleMonthDayField.style.display, + hour: elements.cronFormScheduleHour.value, + minute: elements.cronFormScheduleMinute.value, + weekday: elements.cronFormScheduleWeekday.value, + monthDay: elements.cronFormScheduleMonthDay.value, + warning: elements.cronFormScheduleOnceWarning.style.display, + kind: _cronScheduleKindForInput(elements.cronFormSchedule.value), + }; +} -elements.cronFormSchedule.value = "30m"; +elements.cronFormSchedule.value = '15 * * * *'; elements.cronFormSchedule.dispatchEvent('input'); -const selectAfterOnce = elements.cronFormSchedulePreset.value; -const warningAfterOnce = elements.cronFormScheduleOnceWarning.style.display; +const hourlySync = snapshot(); elements.cronFormSchedulePreset.value = 'hourly'; elements.cronFormSchedulePreset.dispatchEvent('change'); -const presetWriteValue = elements.cronFormSchedule.value; -const selectAfterPresetWrite = elements.cronFormSchedulePreset.value; +elements.cronFormScheduleMinute.value = '15'; +elements.cronFormScheduleMinute.dispatchEvent('change'); +const hourlyWrite = snapshot(); -elements.cronFormSchedulePreset.value = "custom"; -elements.cronFormSchedulePreset.dispatchEvent('change'); -const preservedExactPresetValue = elements.cronFormSchedule.value; -const selectAfterExactPresetCustom = elements.cronFormSchedulePreset.value; - -elements.cronFormSchedule.value = "advanced: cron expression"; +elements.cronFormSchedule.value = '30 7 * * *'; elements.cronFormSchedule.dispatchEvent('input'); -elements.cronFormSchedulePreset.value = "custom"; +const dailySync = snapshot(); +elements.cronFormScheduleHour.value = '7'; +elements.cronFormScheduleHour.dispatchEvent('change'); +elements.cronFormScheduleMinute.value = '30'; +elements.cronFormScheduleMinute.dispatchEvent('change'); +const dailyWrite = snapshot(); + +elements.cronFormSchedule.value = '5 22 * * 1-5'; +elements.cronFormSchedule.dispatchEvent('input'); +const weekdaysSync = snapshot(); + +elements.cronFormSchedule.value = '45 8 * * 3'; +elements.cronFormSchedule.dispatchEvent('input'); +const weeklySync = snapshot(); +elements.cronFormScheduleWeekday.value = '3'; +elements.cronFormScheduleWeekday.dispatchEvent('change'); +elements.cronFormScheduleHour.value = '8'; +elements.cronFormScheduleHour.dispatchEvent('change'); +elements.cronFormScheduleMinute.value = '45'; +elements.cronFormScheduleMinute.dispatchEvent('change'); +const weeklyWrite = snapshot(); + +elements.cronFormSchedule.value = '10 6 15 * *'; +elements.cronFormSchedule.dispatchEvent('input'); +const monthlySync = snapshot(); +elements.cronFormScheduleMonthDay.value = '15'; +elements.cronFormScheduleMonthDay.dispatchEvent('change'); +elements.cronFormScheduleHour.value = '6'; +elements.cronFormScheduleHour.dispatchEvent('change'); +elements.cronFormScheduleMinute.value = '10'; +elements.cronFormScheduleMinute.dispatchEvent('change'); +const monthlyWrite = snapshot(); + +elements.cronFormSchedule.value = '30 7 * * *'; +elements.cronFormSchedule.dispatchEvent('input'); +const dailyAfterMonthlySync = snapshot(); + +elements.cronFormSchedulePreset.value = 'monthly'; elements.cronFormSchedulePreset.dispatchEvent('change'); -const preservedCustomValue = elements.cronFormSchedule.value; -const selectAfterCustom = elements.cronFormSchedulePreset.value; +elements.cronFormScheduleHour.value = '99'; +elements.cronFormScheduleMinute.value = '-8'; +elements.cronFormScheduleMonthDay.value = '99'; +elements.cronFormScheduleMonthDay.dispatchEvent('change'); +const normalizedMonthlyWrite = snapshot(); + +elements.cronFormSchedule.value = '@daily'; +elements.cronFormSchedule.dispatchEvent('input'); +const shorthandCustom = snapshot(); + +elements.cronFormSchedule.value = 'advanced: cron expression'; +elements.cronFormSchedule.dispatchEvent('input'); +const unsupportedCustom = snapshot(); + +elements.cronFormSchedulePreset.value = 'custom'; +elements.cronFormSchedulePreset.dispatchEvent('change'); +const customSelectionPreserved = snapshot(); console.log(JSON.stringify({ - selectAfterDaily, - warningAfterDaily, - selectAfterOnce, - warningAfterOnce, - presetWriteValue, - selectAfterPresetWrite, - preservedExactPresetValue, - selectAfterExactPresetCustom, - preservedCustomValue, - selectAfterCustom, + hourlySync, + hourlyWrite, + dailySync, + dailyWrite, + weekdaysSync, + weeklySync, + weeklyWrite, + monthlySync, + monthlyWrite, + dailyAfterMonthlySync, + normalizedMonthlyWrite, + shorthandCustom, + unsupportedCustom, + customSelectionPreserved, })); """ result = json.loads(_run_node(script)) - assert result["selectAfterDaily"] == "daily" - assert result["warningAfterDaily"] == "none" - assert result["selectAfterOnce"] == "custom" - assert result["warningAfterOnce"] == "" - assert result["presetWriteValue"] == "every 1h" - assert result["selectAfterPresetWrite"] == "hourly" - assert result["preservedExactPresetValue"] == "every 1h" - assert result["selectAfterExactPresetCustom"] == "custom" - assert result["preservedCustomValue"] == "advanced: cron expression" - assert result["selectAfterCustom"] == "custom" + assert result["hourlySync"]["preset"] == "hourly" + assert result["hourlySync"]["schedule"] == "15 * * * *" + assert result["hourlySync"]["paramsDisplay"] == "" + assert result["hourlySync"]["hourFieldDisplay"] == "none" + assert result["hourlySync"]["minuteFieldDisplay"] == "" + assert result["hourlySync"]["kind"] == "cron" + assert result["hourlyWrite"]["schedule"] == "15 * * * *" + assert result["hourlyWrite"]["kind"] == "cron" + + assert result["dailySync"]["preset"] == "daily" + assert result["dailySync"]["schedule"] == "30 7 * * *" + assert result["dailySync"]["hourFieldDisplay"] == "" + assert result["dailySync"]["minuteFieldDisplay"] == "" + assert result["dailySync"]["weekdayFieldDisplay"] == "none" + assert result["dailySync"]["monthDayFieldDisplay"] == "none" + assert result["dailyWrite"]["schedule"] == "30 7 * * *" + assert result["dailyWrite"]["kind"] == "cron" + + assert result["weekdaysSync"]["preset"] == "weekdays" + assert result["weekdaysSync"]["schedule"] == "5 22 * * 1-5" + assert result["weekdaysSync"]["hourFieldDisplay"] == "" + assert result["weekdaysSync"]["minuteFieldDisplay"] == "" + assert result["weekdaysSync"]["weekdayFieldDisplay"] == "none" + assert result["weekdaysSync"]["monthDayFieldDisplay"] == "none" + + assert result["weeklySync"]["preset"] == "weekly" + assert result["weeklySync"]["schedule"] == "45 8 * * 3" + assert result["weeklySync"]["weekdayFieldDisplay"] == "" + assert result["weeklySync"]["monthDayFieldDisplay"] == "none" + assert result["weeklyWrite"]["schedule"] == "45 8 * * 3" + assert result["weeklyWrite"]["kind"] == "cron" + + assert result["monthlySync"]["preset"] == "monthly" + assert result["monthlySync"]["schedule"] == "10 6 15 * *" + assert result["monthlySync"]["monthDayFieldDisplay"] == "" + assert result["monthlySync"]["weekdayFieldDisplay"] == "none" + assert result["monthlyWrite"]["schedule"] == "10 6 15 * *" + assert result["monthlyWrite"]["kind"] == "cron" + + assert result["dailyAfterMonthlySync"]["preset"] == "daily" + assert result["dailyAfterMonthlySync"]["schedule"] == "30 7 * * *" + assert result["dailyAfterMonthlySync"]["hour"] == "7" + assert result["dailyAfterMonthlySync"]["minute"] == "30" + assert result["dailyAfterMonthlySync"]["monthDayFieldDisplay"] == "none" + + assert result["normalizedMonthlyWrite"]["schedule"] == "0 23 31 * *" + assert result["normalizedMonthlyWrite"]["hour"] == "23" + assert result["normalizedMonthlyWrite"]["minute"] == "0" + assert result["normalizedMonthlyWrite"]["monthDay"] == "31" + assert result["normalizedMonthlyWrite"]["kind"] == "cron" + + assert result["shorthandCustom"]["preset"] == "custom" + assert result["shorthandCustom"]["schedule"] == "@daily" + assert result["shorthandCustom"]["paramsDisplay"] == "none" + + assert result["unsupportedCustom"]["preset"] == "custom" + assert result["unsupportedCustom"]["schedule"] == "advanced: cron expression" + assert result["unsupportedCustom"]["paramsDisplay"] == "none" + + assert result["customSelectionPreserved"]["preset"] == "custom" + assert result["customSelectionPreserved"]["schedule"] == "advanced: cron expression" + assert result["customSelectionPreserved"]["paramsDisplay"] == "none" + + +def test_cron_schedule_custom_selection_preserves_raw_schedule_exactly(): + script = _cron_schedule_source() + r""" +const elements = {}; +function $(id) { return elements[id]; } +function makeElement(initialValue = '') { + return { + value: initialValue, + style: { display: '' }, + listeners: {}, + addEventListener(type, handler) { + (this.listeners[type] || (this.listeners[type] = [])).push(handler); + }, + dispatchEvent(eventType) { + const handlers = this.listeners[eventType] || []; + for (const handler of handlers) handler({ type: eventType, target: this }); + }, + }; +} +function t(key) { + const dict = { + cron_schedule_preset_label: 'Preset', + cron_schedule_preset_hourly: 'Hourly', + cron_schedule_preset_daily: 'Daily', + cron_schedule_preset_weekdays: 'Weekdays', + cron_schedule_preset_weekly: 'Weekly', + cron_schedule_preset_monthly: 'Monthly', + cron_schedule_preset_custom: 'Custom', + }; + return dict[key]; +} +function esc(value) { return value == null ? '' : String(value); } + +[ + 'cronFormSchedule', + 'cronFormSchedulePreset', + 'cronFormSchedulePresetParams', + 'cronFormScheduleHourField', + 'cronFormScheduleMinuteField', + 'cronFormScheduleWeekdayField', + 'cronFormScheduleMonthDayField', + 'cronFormScheduleHour', + 'cronFormScheduleMinute', + 'cronFormScheduleWeekday', + 'cronFormScheduleMonthDay', +].forEach((id) => { + elements[id] = makeElement(); +}); +elements.cronFormScheduleOnceWarning = { style: { display: 'none' } }; +elements.cronFormSchedulePresetParams.style.display = 'none'; + +_initCronSchedulePresetControls(); + +elements.cronFormSchedule.value = 'advanced: cron expression'; +elements.cronFormSchedule.dispatchEvent('input'); +elements.cronFormSchedulePreset.value = 'custom'; +elements.cronFormSchedulePreset.dispatchEvent('change'); + +console.log(JSON.stringify({ + preset: elements.cronFormSchedulePreset.value, + schedule: elements.cronFormSchedule.value, + paramsDisplay: elements.cronFormSchedulePresetParams.style.display, +})); +""" + result = json.loads(_run_node(script)) + + assert result["preset"] == "custom" + assert result["schedule"] == "advanced: cron expression" + assert result["paramsDisplay"] == "none" def test_cron_form_surfaces_one_shot_warning_copy_markers_and_preset_markup(): @@ -202,6 +416,11 @@ def test_cron_form_surfaces_one_shot_warning_copy_markers_and_preset_markup(): assert "id=\"cronFormScheduleOnceWarning\"" in panels assert "id=\"cronFormSchedulePreset\"" in panels + assert "id=\"cronFormSchedulePresetParams\"" in panels + assert "id=\"cronFormScheduleHour\"" in panels + assert "id=\"cronFormScheduleMinute\"" in panels + assert "id=\"cronFormScheduleWeekday\"" in panels + assert "id=\"cronFormScheduleMonthDay\"" in panels assert "cron_schedule_once_warning" in panels assert "_cronSchedulePresetIdForValue" in panels assert "_cronSchedulePresetOptionHtml" in panels @@ -210,12 +429,15 @@ def test_cron_form_surfaces_one_shot_warning_copy_markers_and_preset_markup(): assert "addEventListener('change', _syncCronSchedulePresetAndWarning" in panels assert "addEventListener('change', _applyCronSchedulePresetSelection" in panels assert ".cron-once-warning" in style - assert "id: 'hourly'" in panels - assert "id: 'daily'" in panels - assert "id: 'weekdays'" in panels - assert "id: 'weekly'" in panels - assert "id: 'monthly'" in panels - assert "id: 'custom'" in panels + assert ".cron-schedule-preset-shell" in style + assert ".cron-schedule-preset-params" in style + assert ".cron-schedule-preset-field" in style + assert ".cron-schedule-preset-time-hint" in style + assert "Time is server time; cron runs server-side." in panels + assert "fields: ['minute']" in panels + assert "fields: ['hour', 'minute']" in panels + assert "fields: ['hour', 'minute', 'weekday']" in panels + assert "fields: ['hour', 'minute', 'monthDay']" in panels assert "Duration forms like '30m' run once" in i18n From 2e27ff64d619543beec503b9cacabe0bed8a2231 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 4 Jul 2026 15:22:37 -0400 Subject: [PATCH 2/3] fix(cron): harden preset controls after review --- static/i18n.js | 70 ++++++++++++++++++++ static/panels.js | 39 ++++++----- tests/test_issue2031_cron_once_visibility.py | 50 +++++++++++++- 3 files changed, 140 insertions(+), 19 deletions(-) diff --git a/static/i18n.js b/static/i18n.js index 4055d47a9..c46f3aa91 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1591,6 +1591,11 @@ const LOCALES = { cron_schedule_preset_weekly: 'Weekly', cron_schedule_preset_monthly: 'Monthly', cron_schedule_preset_custom: 'Custom', + cron_schedule_hour_label: 'Hour', + cron_schedule_minute_label: 'Minute', + cron_schedule_weekday_label: 'Weekday (0-6, Sun-Sat)', + cron_schedule_month_day_label: 'Day', + cron_schedule_time_hint: 'Time is server time; cron runs server-side.', cron_schedule_hint: "Use 'every 1h' or a cron expression for recurring jobs. Bare durations like '30m' run once.", cron_schedule_once_warning: "Duration forms like '30m' run once and are removed after running. Use 'every 30m' to keep a recurring job.", cron_prompt_label: 'Prompt', @@ -3256,6 +3261,11 @@ const LOCALES = { cron_schedule_preset_weekly: 'Ogni settimana', cron_schedule_preset_monthly: 'Ogni mese', cron_schedule_preset_custom: 'Personalizzato', + cron_schedule_hour_label: 'Ora', + cron_schedule_minute_label: 'Minuto', + cron_schedule_weekday_label: 'Giorno della settimana (0-6, Dom-Sab)', + cron_schedule_month_day_label: 'Giorno', + cron_schedule_time_hint: 'L\'ora è quella del server; cron viene eseguito lato server.', cron_schedule_hint: 'Usa \'every 1h\' o un\'espressione cron per job ricorrenti. Durate semplici come \'30m\' vengono eseguite una volta sola.', cron_schedule_once_warning: 'Forme di durata come \'30m\' vengono eseguite una volta e rimosse dopo l\'esecuzione. Usa \'every 30m\' per mantenere un job ricorrente.', cron_prompt_label: 'Prompt', @@ -4929,6 +4939,11 @@ const LOCALES = { cron_schedule_preset_weekly: '毎週', cron_schedule_preset_monthly: '毎月', cron_schedule_preset_custom: 'カスタム', + cron_schedule_hour_label: '時', + cron_schedule_minute_label: '分', + cron_schedule_weekday_label: '曜日 (0-6, 日-土)', + cron_schedule_month_day_label: '日', + cron_schedule_time_hint: '時刻はサーバー時刻です。cron はサーバー側で実行されます。', cron_schedule_hint: "繰り返し実行には 'every 1h' または Cron 式を使います。'30m' のような期間だけの指定は 1 回だけ実行されます。", cron_schedule_once_warning: "'30m' のような期間指定は 1 回だけ実行され、実行後に削除されます。繰り返すには 'every 30m' を使ってください。", cron_prompt_label: 'プロンプト', @@ -6244,6 +6259,11 @@ const LOCALES = { cron_schedule_preset_weekly: 'Еженедельно', cron_schedule_preset_monthly: 'Ежемесячно', cron_schedule_preset_custom: 'Пользовательский', + cron_schedule_hour_label: 'Час', + cron_schedule_minute_label: 'Минута', + cron_schedule_weekday_label: 'День недели (0-6, вс-сб)', + cron_schedule_month_day_label: 'День', + cron_schedule_time_hint: 'Время указано по серверу; cron выполняется на сервере.', cron_schedule_hint: "Для повторяющихся заданий используйте 'every 1h' или cron-выражение. Простые интервалы вроде '30m' выполняются один раз.", cron_schedule_once_warning: "Интервалы вроде '30m' выполняются один раз и удаляются после запуска. Используйте 'every 30m' для повторяющегося задания.", cron_prompt_label: 'Запрос', @@ -7836,6 +7856,11 @@ const LOCALES = { cron_schedule_preset_weekly: 'Semanal', cron_schedule_preset_monthly: 'Mensual', cron_schedule_preset_custom: 'Personalizado', + cron_schedule_hour_label: 'Hora', + cron_schedule_minute_label: 'Minuto', + cron_schedule_weekday_label: 'Día de la semana (0-6, dom-sáb)', + cron_schedule_month_day_label: 'Día', + cron_schedule_time_hint: 'La hora es la del servidor; cron se ejecuta del lado del servidor.', cron_schedule_hint: "Usa 'every 1h' o una expresión cron para trabajos recurrentes. Duraciones como '30m' se ejecutan una sola vez.", cron_schedule_once_warning: "Las duraciones como '30m' se ejecutan una vez y se eliminan después de correr. Usa 'every 30m' para mantener un trabajo recurrente.", cron_prompt_label: 'Prompt', @@ -9110,6 +9135,11 @@ const LOCALES = { cron_schedule_preset_weekly: 'Wöchentlich', cron_schedule_preset_monthly: 'Monatlich', cron_schedule_preset_custom: 'Benutzerdefiniert', + cron_schedule_hour_label: 'Stunde', + cron_schedule_minute_label: 'Minute', + cron_schedule_weekday_label: 'Wochentag (0-6, So-Sa)', + cron_schedule_month_day_label: 'Tag', + cron_schedule_time_hint: 'Die Zeit ist Serverzeit; cron läuft serverseitig.', cron_schedule_hint: "Für wiederkehrende Aufgaben 'every 1h' oder einen Cron-Ausdruck verwenden. Reine Dauern wie '30m' laufen einmal.", cron_schedule_once_warning: "Dauerangaben wie '30m' laufen einmal und werden nach der Ausführung entfernt. Verwende 'every 30m' für eine wiederkehrende Aufgabe.", cron_prompt_label: 'Prompt', @@ -11056,6 +11086,11 @@ const LOCALES = { cron_schedule_preset_weekly: '每周', cron_schedule_preset_monthly: '每月', cron_schedule_preset_custom: '自定义', + cron_schedule_hour_label: '小时', + cron_schedule_minute_label: '分钟', + cron_schedule_weekday_label: '星期 (0-6,周日-周六)', + cron_schedule_month_day_label: '日期', + cron_schedule_time_hint: '时间以服务器时间为准;cron 在服务器端运行。', cron_schedule_hint: "循环任务请用 'every 1h' 或 Cron 表达式。像 '30m' 这样的裸时长只会运行一次。", cron_schedule_once_warning: "像 '30m' 这样的时长写法只会运行一次,并在运行后移除。要保留循环任务,请使用 'every 30m'。", cron_prompt_label: '提示词', @@ -13023,6 +13058,11 @@ const LOCALES = { cron_schedule_preset_weekly: '每週', cron_schedule_preset_monthly: '每月', cron_schedule_preset_custom: '自訂', + cron_schedule_hour_label: '小時', + cron_schedule_minute_label: '分鐘', + cron_schedule_weekday_label: '星期 (0-6,週日-週六)', + cron_schedule_month_day_label: '日期', + cron_schedule_time_hint: '時間以伺服器時間為準;cron 在伺服器端執行。', cron_schedule_hint: '週期性任務請用「every 1h」或 Cron 表示式。像「30m」這樣的裸時長只會執行一次。', cron_schedule_once_warning: '像「30m」這樣的時長寫法只會執行一次,並在執行後移除。要保留週期性任務,請使用「every 30m」。', cron_prompt_label: '提示', @@ -14429,6 +14469,11 @@ const LOCALES = { cron_schedule_preset_weekly: 'Semanal', cron_schedule_preset_monthly: 'Mensal', cron_schedule_preset_custom: 'Personalizado', + cron_schedule_hour_label: 'Hora', + cron_schedule_minute_label: 'Minuto', + cron_schedule_weekday_label: 'Dia da semana (0-6, dom-sáb)', + cron_schedule_month_day_label: 'Dia', + cron_schedule_time_hint: 'O horário usa a hora do servidor; cron roda no servidor.', cron_schedule_hint: "Use 'every 1h' ou uma expressão Cron para tarefas recorrentes. Durações como '30m' rodam uma vez.", cron_schedule_once_warning: "Durações como '30m' rodam uma vez e são removidas após executar. Use 'every 30m' para manter uma tarefa recorrente.", cron_prompt_label: 'Prompt', @@ -16078,6 +16123,11 @@ const LOCALES = { cron_schedule_preset_weekly: 'Weekly', cron_schedule_preset_monthly: 'Monthly', cron_schedule_preset_custom: 'Custom', + cron_schedule_hour_label: '시간', + cron_schedule_minute_label: '분', + cron_schedule_weekday_label: '요일 (0-6, 일-토)', + cron_schedule_month_day_label: '일', + cron_schedule_time_hint: '시간은 서버 시간 기준이며 cron은 서버에서 실행됩니다.', cron_schedule_hint: "Use 'every 1h' or a cron expression for recurring jobs. Bare durations like '30m' run once.", cron_schedule_once_warning: "Duration forms like '30m' run once and are removed after running. Use 'every 30m' to keep a recurring job.", cron_prompt_label: 'Prompt', @@ -17819,6 +17869,11 @@ const LOCALES = { cron_schedule_preset_weekly: 'Hebdomadaire', cron_schedule_preset_monthly: 'Mensuel', cron_schedule_preset_custom: 'Personnalisé', + cron_schedule_hour_label: 'Heure', + cron_schedule_minute_label: 'Minute', + cron_schedule_weekday_label: 'Jour de la semaine (0-6, dim-sam)', + cron_schedule_month_day_label: 'Jour', + cron_schedule_time_hint: 'L\'heure est celle du serveur; cron s\'exécute côté serveur.', cron_schedule_hint: "Utilisez 'every 1h' ou une expression cron pour les tâches récurrentes. Les durées simples comme '30m' s'exécutent une fois.", cron_schedule_once_warning: "Les formes de durée comme '30m' s'exécutent une fois et sont supprimées après l'exécution. Utilisez 'every 30m' pour conserver une tâche récurrente.", cron_prompt_label: 'Invite', @@ -19392,6 +19447,11 @@ const LOCALES = { cron_schedule_preset_weekly: 'Haftalık', cron_schedule_preset_monthly: 'Aylık', cron_schedule_preset_custom: 'Özel', + cron_schedule_hour_label: 'Saat', + cron_schedule_minute_label: 'Dakika', + cron_schedule_weekday_label: 'Haftanın günü (0-6, Paz-Cmt)', + cron_schedule_month_day_label: 'Gün', + cron_schedule_time_hint: 'Saat sunucu saatidir; cron sunucu tarafında çalışır.', cron_schedule_hint: "Use 'every 1h' or a cron expression for recurring jobs. Bare durations like '30m' run once.", cron_schedule_once_warning: "Duration forms like '30m' run once and are removed after running. Use 'every 30m' to keep a recurring job.", cron_prompt_label: 'Çabuk', @@ -21145,6 +21205,11 @@ const LOCALES = { cron_schedule_preset_weekly: 'Co tydzień', cron_schedule_preset_monthly: 'Co miesiąc', cron_schedule_preset_custom: 'Niestandardowe', + cron_schedule_hour_label: 'Godzina', + cron_schedule_minute_label: 'Minuta', + cron_schedule_weekday_label: 'Dzień tygodnia (0-6, niedz.-sob.)', + cron_schedule_month_day_label: 'Dzień', + cron_schedule_time_hint: 'Czas jest czasem serwera; cron działa po stronie serwera.', cron_schedule_hint: "Użyj 'every 1h' lub wyrażenia cron dla zadań cyklicznych. Same formy czasu trwania, jak '30m', zostaną uruchomione tylko raz.", cron_schedule_once_warning: 'Formy czasu trwania, takie jak \'30m\', są uruchamiane raz i usuwane po uruchomieniu. Użyj \'every 30m\', aby utrzymać zadanie cykliczne.', cron_prompt_label: 'Monit', @@ -22652,6 +22717,11 @@ const LOCALES = { cron_schedule_preset_weekly: 'Hàng tuần', cron_schedule_preset_monthly: 'Hàng tháng', cron_schedule_preset_custom: 'Tùy chỉnh', + cron_schedule_hour_label: 'Giờ', + cron_schedule_minute_label: 'Phút', + cron_schedule_weekday_label: 'Thứ trong tuần (0-6, CN-T7)', + cron_schedule_month_day_label: 'Ngày', + cron_schedule_time_hint: 'Thời gian dùng giờ máy chủ; cron chạy phía máy chủ.', cron_schedule_hint: "Dùng 'every 1h' hoặc biểu thức cron cho job lặp lại. Các khoảng thời gian đơn như '30m' chỉ chạy một lần.", cron_schedule_once_warning: "Các dạng thời lượng như '30m' chỉ chạy một lần và sẽ bị xóa sau khi chạy. Dùng 'every 30m' để giữ job lặp lại.", cron_prompt_label: 'Prompt', diff --git a/static/panels.js b/static/panels.js index 4a918aa24..b653ed6ff 100644 --- a/static/panels.js +++ b/static/panels.js @@ -507,7 +507,7 @@ function _cronSchedulePresetFieldEl(field) { function _cronSchedulePresetBounds(field) { if (field === 'hour') return { min: 0, max: 23 }; if (field === 'minute') return { min: 0, max: 59 }; - if (field === 'weekday') return { min: 0, max: 7 }; + if (field === 'weekday') return { min: 0, max: 6 }; if (field === 'monthDay') return { min: 1, max: 31 }; return { min: 0, max: 999 }; } @@ -583,8 +583,8 @@ function _cronSchedulePresetValuesForSelection(presetId) { return {}; } -function _cronSchedulePresetValueForSelection(presetId) { - const values = _cronSchedulePresetValuesForSelection(presetId); +function _cronSchedulePresetValueForSelection(presetId, selectedValues) { + const values = selectedValues || _cronSchedulePresetValuesForSelection(presetId); if (presetId === 'hourly') return `${values.minute} * * * *`; if (presetId === 'daily') return `${values.minute} ${values.hour} * * *`; if (presetId === 'weekdays') return `${values.minute} ${values.hour} * * 1-5`; @@ -613,8 +613,8 @@ function _cronSchedulePresetStateForInput(value) { if (hour === '*' && dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { return { presetId: 'hourly', minute }; } - if (_cronSchedulePresetRawFieldInBounds('hour', hour) && dayOfMonth === '*' && month === '*' && _cronSchedulePresetRawFieldInBounds('weekday', dayOfWeek)) { - return { presetId: 'weekly', minute, hour, weekday: dayOfWeek }; + if (_cronSchedulePresetRawFieldInBounds('hour', hour) && dayOfMonth === '*' && month === '*' && (_cronSchedulePresetRawFieldInBounds('weekday', dayOfWeek) || dayOfWeek === '7')) { + return { presetId: 'weekly', minute, hour, weekday: dayOfWeek === '7' ? '0' : dayOfWeek }; } if (_cronSchedulePresetRawFieldInBounds('hour', hour) && _cronSchedulePresetRawFieldInBounds('monthDay', dayOfMonth) && month === '*' && dayOfWeek === '*') { return { presetId: 'monthly', minute, hour, monthDay: dayOfMonth }; @@ -647,9 +647,11 @@ function _applyCronSchedulePresetSelection() { if (!presetEl || !scheduleEl) return; const presetId = presetEl.value; if (presetId !== 'custom') { - scheduleEl.value = _cronSchedulePresetValueForSelection(presetId); - _cronSchedulePresetApplyValues(_cronSchedulePresetValuesForSelection(presetId)); - _syncCronSchedulePresetAndWarning(); + const values = _cronSchedulePresetValuesForSelection(presetId); + _cronSchedulePresetApplyValues(values); + scheduleEl.value = _cronSchedulePresetValueForSelection(presetId, values); + _cronSchedulePresetSyncVisibility(presetId); + _syncCronScheduleWarning(); return; } _cronSchedulePresetSyncVisibility(presetId); @@ -659,15 +661,16 @@ function _applyCronSchedulePresetSelection() { function _initCronSchedulePresetControls() { const presetEl = $('cronFormSchedulePreset'); const scheduleEl = $('cronFormSchedule'); - const presetParams = $('cronFormSchedulePresetParams'); - if (!presetEl || !scheduleEl || !presetParams) return; + if (!presetEl || !scheduleEl) return; presetEl.addEventListener('change', _applyCronSchedulePresetSelection); - ['cronFormScheduleHour', 'cronFormScheduleMinute', 'cronFormScheduleWeekday', 'cronFormScheduleMonthDay'].forEach((id) => { - const el = $(id); - if (!el) return; - el.addEventListener('change', _applyCronSchedulePresetSelection); - el.addEventListener('input', _applyCronSchedulePresetSelection); - }); + if ($('cronFormSchedulePresetParams')) { + ['cronFormScheduleHour', 'cronFormScheduleMinute', 'cronFormScheduleWeekday', 'cronFormScheduleMonthDay'].forEach((id) => { + const el = $(id); + if (!el) return; + el.addEventListener('change', _applyCronSchedulePresetSelection); + el.addEventListener('input', _applyCronSchedulePresetSelection); + }); + } scheduleEl.addEventListener('input', _syncCronSchedulePresetAndWarning); scheduleEl.addEventListener('change', _syncCronSchedulePresetAndWarning); _syncCronSchedulePresetAndWarning(); @@ -1535,13 +1538,13 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, toast_notif
- +
-
Time is server time; cron runs server-side.
+
${esc(t('cron_schedule_time_hint') || 'Time is server time; cron runs server-side.')}
diff --git a/tests/test_issue2031_cron_once_visibility.py b/tests/test_issue2031_cron_once_visibility.py index f2ccfbe94..8f525754d 100644 --- a/tests/test_issue2031_cron_once_visibility.py +++ b/tests/test_issue2031_cron_once_visibility.py @@ -92,6 +92,7 @@ const cases = { shorthand: _cronSchedulePresetIdForValue("@daily"), invalidDaily: _cronSchedulePresetIdForValue("99 99 * * *"), invalidHourly: _cronSchedulePresetIdForValue("60 * * * *"), + ambiguousSunday: _cronSchedulePresetIdForValue("0 9 * * 7"), invalidWeekly: _cronSchedulePresetIdForValue("0 9 * * 8"), invalidMonthDayZero: _cronSchedulePresetIdForValue("0 9 0 * *"), invalidMonthly: _cronSchedulePresetIdForValue("0 9 32 * *"), @@ -112,6 +113,7 @@ console.log(JSON.stringify(cases)); assert cases["shorthand"] == "custom" assert cases["invalidDaily"] == "custom" assert cases["invalidHourly"] == "custom" + assert cases["ambiguousSunday"] == "weekly" assert cases["invalidWeekly"] == "custom" assert cases["invalidMonthDayZero"] == "custom" assert cases["invalidMonthly"] == "custom" @@ -213,6 +215,9 @@ const weekdaysSync = snapshot(); elements.cronFormSchedule.value = '45 8 * * 3'; elements.cronFormSchedule.dispatchEvent('input'); const weeklySync = snapshot(); +elements.cronFormSchedule.value = '0 9 * * 7'; +elements.cronFormSchedule.dispatchEvent('input'); +const weeklyRawSundaySync = snapshot(); elements.cronFormScheduleWeekday.value = '3'; elements.cronFormScheduleWeekday.dispatchEvent('change'); elements.cronFormScheduleHour.value = '8'; @@ -263,6 +268,7 @@ console.log(JSON.stringify({ dailyWrite, weekdaysSync, weeklySync, + weeklyRawSundaySync, weeklyWrite, monthlySync, monthlyWrite, @@ -304,6 +310,9 @@ console.log(JSON.stringify({ assert result["weeklySync"]["schedule"] == "45 8 * * 3" assert result["weeklySync"]["weekdayFieldDisplay"] == "" assert result["weeklySync"]["monthDayFieldDisplay"] == "none" + assert result["weeklyRawSundaySync"]["preset"] == "weekly" + assert result["weeklyRawSundaySync"]["schedule"] == "0 9 * * 7" + assert result["weeklyRawSundaySync"]["weekday"] == "0" assert result["weeklyWrite"]["schedule"] == "45 8 * * 3" assert result["weeklyWrite"]["kind"] == "cron" @@ -339,6 +348,45 @@ console.log(JSON.stringify({ assert result["customSelectionPreserved"]["paramsDisplay"] == "none" +def test_cron_schedule_raw_warning_listener_survives_missing_preset_params(): + script = _cron_schedule_source() + r""" +const elements = {}; +function $(id) { return elements[id]; } +function makeElement(initialValue = '') { + return { + value: initialValue, + style: { display: '' }, + listeners: {}, + addEventListener(type, handler) { + (this.listeners[type] || (this.listeners[type] = [])).push(handler); + }, + dispatchEvent(eventType) { + const handlers = this.listeners[eventType] || []; + for (const handler of handlers) handler({ type: eventType, target: this }); + }, + }; +} +elements.cronFormSchedule = makeElement(); +elements.cronFormSchedulePreset = makeElement('custom'); +elements.cronFormScheduleOnceWarning = { style: { display: 'none' } }; + +_initCronSchedulePresetControls(); +elements.cronFormSchedule.value = '30m'; +elements.cronFormSchedule.dispatchEvent('input'); + +console.log(JSON.stringify({ + warning: elements.cronFormScheduleOnceWarning.style.display, + inputListeners: (elements.cronFormSchedule.listeners.input || []).length, + presetChangeListeners: (elements.cronFormSchedulePreset.listeners.change || []).length, +})); +""" + result = json.loads(_run_node(script)) + + assert result["warning"] == "" + assert result["inputListeners"] == 1 + assert result["presetChangeListeners"] == 1 + + def test_cron_schedule_custom_selection_preserves_raw_schedule_exactly(): script = _cron_schedule_source() + r""" const elements = {}; @@ -433,7 +481,7 @@ def test_cron_form_surfaces_one_shot_warning_copy_markers_and_preset_markup(): assert ".cron-schedule-preset-params" in style assert ".cron-schedule-preset-field" in style assert ".cron-schedule-preset-time-hint" in style - assert "Time is server time; cron runs server-side." in panels + assert "cron_schedule_time_hint" in panels assert "fields: ['minute']" in panels assert "fields: ['hour', 'minute']" in panels assert "fields: ['hour', 'minute', 'weekday']" in panels From 936c5b051bb828afd45144f0911bdde4369fc123 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 6 Jul 2026 01:35:55 +0000 Subject: [PATCH 3/3] =?UTF-8?q?release:=20redesign=20cron=20preset=20sched?= =?UTF-8?q?ule=20builder=20=E2=80=94=20sentence=20UI=20with=20time=20picke?= =?UTF-8?q?r=20+=20day=20dropdowns=20(#5554)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the #5552 preset builder per maintainer UX feedback + Fable design advisory: one natural 'Schedule: [preset] on [day] at [time]' row; native time picker (themed) replacing hour/minute number boxes; day-of-week + day-of-month dropdowns; raw cron field shown only on Custom; live cron preview; Custom last; new jobs default to Daily 09:00; keystroke-clamp + mid-typing-hide bugs fixed. New i18n keys across all 14 locales. Full gate: Codex SAFE, Fable SHIP-UX, suite 12160/0, browser clean. Co-authored-by: Rod Boev --- CHANGELOG.md | 1 + static/i18n.js | 178 ++++++++++++++++++- static/panels.js | 162 +++++++++++++---- static/style.css | 20 ++- tests/test_issue2031_cron_once_visibility.py | 121 ++++++++----- 5 files changed, 394 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7725e0c05..9b361d8a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Added +- **Pick the exact time and day for scheduled jobs — no cron syntax needed.** The Tasks "New job" form now builds the schedule as a plain sentence: choose a frequency (Hourly / Daily / Weekdays / Weekly / Monthly / Custom) and it shows just the controls that matter — a time picker, a day-of-week dropdown, or a day-of-month dropdown — e.g. "Weekly on Monday at 09:00." The generated cron expression is previewed inline, and the raw cron field now appears only under "Custom." Thanks @rodboev. (#5554, #5552) - **Transparent Stream fades newly streamed words in.** In Transparent Stream mode, newly arriving assistant words now fade in at the prose level as they stream — without animating (or re-flickering) the thinking rows, tool rows, or the transparent event rows. It respects your OS "reduce motion" setting (no fade when reduced motion is on) and is part of the Transparent Stream experience regardless of the "Fade text effect" toggle. Thanks @rodboev. (#5506, #5367) - **The Preferences default-model setting now uses the rich model picker.** The "Default Model" control in Settings → Preferences was a plain dropdown; it now uses the same searchable, provider-grouped picker as the composer (with a "Custom Model ID" field), so choosing a default model is consistent everywhere. On touch devices the picker no longer pops the keyboard the instant you open it (it still focuses search on desktop and while you're typing), and its label now activates the picker directly. Thanks @rodboev. (#5502, #5497) - **Scheduled jobs get a preset schedule builder.** The cron job form now has a "Preset" dropdown (Hourly / Daily / Weekdays / Weekly / Monthly, plus Custom) above the Schedule field — pick one and it fills the schedule for you (e.g. Daily → `0 9 * * *`, Hourly → `every 1h`) instead of hand-writing a cron expression. "Custom" keeps the free-text field, and editing an existing job maps its schedule back to the matching preset. Thanks @rodboev. (#5438, #5427) diff --git a/static/i18n.js b/static/i18n.js index 3e4362ba6..88832e471 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1584,18 +1584,30 @@ const LOCALES = { // cron form cron_name_label: 'Name', cron_name_placeholder: 'Optional', - cron_schedule_label: 'Schedule', - cron_schedule_preset_label: 'Preset', + cron_schedule_label: 'Cron expression', + cron_schedule_preset_label: 'Schedule', cron_schedule_preset_hourly: 'Hourly', cron_schedule_preset_daily: 'Daily', - cron_schedule_preset_weekdays: 'Weekdays', + cron_schedule_preset_weekdays: 'Weekdays (Mon–Fri)', cron_schedule_preset_weekly: 'Weekly', cron_schedule_preset_monthly: 'Monthly', cron_schedule_preset_custom: 'Custom', cron_schedule_hour_label: 'Hour', cron_schedule_minute_label: 'Minute', - cron_schedule_weekday_label: 'Weekday (0-6, Sun-Sat)', - cron_schedule_month_day_label: 'Day', + cron_schedule_weekday_label: 'Day of week', + cron_schedule_month_day_label: 'Day of month', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', cron_schedule_time_hint: 'Time is server time; cron runs server-side.', cron_schedule_hint: "Use 'every 1h' or a cron expression for recurring jobs. Bare durations like '30m' run once.", cron_schedule_once_warning: "Duration forms like '30m' run once and are removed after running. Use 'every 30m' to keep a recurring job.", @@ -3263,6 +3275,18 @@ const LOCALES = { cron_schedule_preset_weekly: 'Ogni settimana', cron_schedule_preset_monthly: 'Ogni mese', cron_schedule_preset_custom: 'Personalizzato', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', cron_schedule_hour_label: 'Ora', cron_schedule_minute_label: 'Minuto', cron_schedule_weekday_label: 'Giorno della settimana (0-6, Dom-Sab)', @@ -4942,6 +4966,18 @@ const LOCALES = { cron_schedule_preset_weekly: '毎週', cron_schedule_preset_monthly: '毎月', cron_schedule_preset_custom: 'カスタム', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', cron_schedule_hour_label: '時', cron_schedule_minute_label: '分', cron_schedule_weekday_label: '曜日 (0-6, 日-土)', @@ -6263,6 +6299,18 @@ const LOCALES = { cron_schedule_preset_weekly: 'Еженедельно', cron_schedule_preset_monthly: 'Ежемесячно', cron_schedule_preset_custom: 'Пользовательский', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', cron_schedule_hour_label: 'Час', cron_schedule_minute_label: 'Минута', cron_schedule_weekday_label: 'День недели (0-6, вс-сб)', @@ -7861,6 +7909,18 @@ const LOCALES = { cron_schedule_preset_weekly: 'Semanal', cron_schedule_preset_monthly: 'Mensual', cron_schedule_preset_custom: 'Personalizado', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', cron_schedule_hour_label: 'Hora', cron_schedule_minute_label: 'Minuto', cron_schedule_weekday_label: 'Día de la semana (0-6, dom-sáb)', @@ -9141,6 +9201,18 @@ const LOCALES = { cron_schedule_preset_weekly: 'Wöchentlich', cron_schedule_preset_monthly: 'Monatlich', cron_schedule_preset_custom: 'Benutzerdefiniert', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', cron_schedule_hour_label: 'Stunde', cron_schedule_minute_label: 'Minute', cron_schedule_weekday_label: 'Wochentag (0-6, So-Sa)', @@ -11093,6 +11165,18 @@ const LOCALES = { cron_schedule_preset_weekly: '每周', cron_schedule_preset_monthly: '每月', cron_schedule_preset_custom: '自定义', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', cron_schedule_hour_label: '小时', cron_schedule_minute_label: '分钟', cron_schedule_weekday_label: '星期 (0-6,周日-周六)', @@ -13066,6 +13150,18 @@ const LOCALES = { cron_schedule_preset_weekly: '每週', cron_schedule_preset_monthly: '每月', cron_schedule_preset_custom: '自訂', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', cron_schedule_hour_label: '小時', cron_schedule_minute_label: '分鐘', cron_schedule_weekday_label: '星期 (0-6,週日-週六)', @@ -14478,6 +14574,18 @@ const LOCALES = { cron_schedule_preset_weekly: 'Semanal', cron_schedule_preset_monthly: 'Mensal', cron_schedule_preset_custom: 'Personalizado', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', cron_schedule_hour_label: 'Hora', cron_schedule_minute_label: 'Minuto', cron_schedule_weekday_label: 'Dia da semana (0-6, dom-sáb)', @@ -16133,6 +16241,18 @@ const LOCALES = { cron_schedule_preset_weekly: 'Weekly', cron_schedule_preset_monthly: 'Monthly', cron_schedule_preset_custom: 'Custom', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', cron_schedule_hour_label: '시간', cron_schedule_minute_label: '분', cron_schedule_weekday_label: '요일 (0-6, 일-토)', @@ -17880,6 +18000,18 @@ const LOCALES = { cron_schedule_preset_weekly: 'Hebdomadaire', cron_schedule_preset_monthly: 'Mensuel', cron_schedule_preset_custom: 'Personnalisé', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', cron_schedule_hour_label: 'Heure', cron_schedule_minute_label: 'Minute', cron_schedule_weekday_label: 'Jour de la semaine (0-6, dim-sam)', @@ -19459,6 +19591,18 @@ const LOCALES = { cron_schedule_preset_weekly: 'Haftalık', cron_schedule_preset_monthly: 'Aylık', cron_schedule_preset_custom: 'Özel', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', cron_schedule_hour_label: 'Saat', cron_schedule_minute_label: 'Dakika', cron_schedule_weekday_label: 'Haftanın günü (0-6, Paz-Cmt)', @@ -21218,6 +21362,18 @@ const LOCALES = { cron_schedule_preset_weekly: 'Co tydzień', cron_schedule_preset_monthly: 'Co miesiąc', cron_schedule_preset_custom: 'Niestandardowe', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', cron_schedule_hour_label: 'Godzina', cron_schedule_minute_label: 'Minuta', cron_schedule_weekday_label: 'Dzień tygodnia (0-6, niedz.-sob.)', @@ -22731,6 +22887,18 @@ const LOCALES = { cron_schedule_preset_weekly: 'Hàng tuần', cron_schedule_preset_monthly: 'Hàng tháng', cron_schedule_preset_custom: 'Tùy chỉnh', + cron_schedule_time_label: 'Time', + cron_schedule_conj_on: 'on', + cron_schedule_conj_on_day: 'on day', + cron_schedule_conj_at: 'at', + cron_schedule_conj_at_minute: 'at minute', + cron_weekday_sun: 'Sunday', + cron_weekday_mon: 'Monday', + cron_weekday_tue: 'Tuesday', + cron_weekday_wed: 'Wednesday', + cron_weekday_thu: 'Thursday', + cron_weekday_fri: 'Friday', + cron_weekday_sat: 'Saturday', cron_schedule_hour_label: 'Giờ', cron_schedule_minute_label: 'Phút', cron_schedule_weekday_label: 'Thứ trong tuần (0-6, CN-T7)', diff --git a/static/panels.js b/static/panels.js index 8a4813014..13ba281e8 100644 --- a/static/panels.js +++ b/static/panels.js @@ -464,15 +464,29 @@ function _syncCronScheduleWarning() { const warning = $('cronFormScheduleOnceWarning'); if (!input || !warning) return; warning.style.display = _cronScheduleKindForInput(input.value) === 'once' ? '' : 'none'; + _syncCronSchedulePreview(); +} + +// Live preview of the generated cron expression in the preset hint line (the +// cron-job.org / GitHub-schedule-editor convention) so a cron-literate user sees +// exactly what the friendly controls produce. Empty on Custom (the raw field is shown). +function _syncCronSchedulePreview() { + const preview = $('cronFormSchedulePreview'); + const presetEl = $('cronFormSchedulePreset'); + const scheduleEl = $('cronFormSchedule'); + if (!preview) return; + const presetId = presetEl ? presetEl.value : 'custom'; + const expr = String((scheduleEl && scheduleEl.value) || '').trim(); + preview.textContent = (presetId !== 'custom' && expr) ? `${expr} · ` : ''; } const CRON_SCHEDULE_PRESETS = [ - { id: 'custom', label: 'cron_schedule_preset_custom', fallback: 'Custom', fields: [] }, { id: 'hourly', label: 'cron_schedule_preset_hourly', fallback: 'Hourly', fields: ['minute'], defaults: { minute: 0 } }, - { id: 'daily', label: 'cron_schedule_preset_daily', fallback: 'Daily', fields: ['hour', 'minute'], defaults: { hour: 9, minute: 0 } }, - { id: 'weekdays', label: 'cron_schedule_preset_weekdays', fallback: 'Weekdays', fields: ['hour', 'minute'], defaults: { hour: 9, minute: 0 } }, - { id: 'weekly', label: 'cron_schedule_preset_weekly', fallback: 'Weekly', fields: ['hour', 'minute', 'weekday'], defaults: { hour: 9, minute: 0, weekday: 1 } }, - { id: 'monthly', label: 'cron_schedule_preset_monthly', fallback: 'Monthly', fields: ['hour', 'minute', 'monthDay'], defaults: { hour: 9, minute: 0, monthDay: 1 } }, + { id: 'daily', label: 'cron_schedule_preset_daily', fallback: 'Daily', fields: ['time'], defaults: { hour: 9, minute: 0 } }, + { id: 'weekdays', label: 'cron_schedule_preset_weekdays', fallback: 'Weekdays (Mon–Fri)', fields: ['time'], defaults: { hour: 9, minute: 0 } }, + { id: 'weekly', label: 'cron_schedule_preset_weekly', fallback: 'Weekly', fields: ['weekday', 'time'], defaults: { hour: 9, minute: 0, weekday: 1 } }, + { id: 'monthly', label: 'cron_schedule_preset_monthly', fallback: 'Monthly', fields: ['monthDay', 'time'], defaults: { hour: 9, minute: 0, monthDay: 1 } }, + { id: 'custom', label: 'cron_schedule_preset_custom', fallback: 'Custom', fields: [] }, ]; function _cronSchedulePresetOptionHtml() { @@ -487,13 +501,24 @@ function _cronSchedulePresetForId(presetId) { function _cronSchedulePresetControlIds() { return { - hour: 'cronFormScheduleHour', + time: 'cronFormScheduleTime', minute: 'cronFormScheduleMinute', weekday: 'cronFormScheduleWeekday', monthDay: 'cronFormScheduleMonthDay', }; } +// Which visible control wrapper each logical field lives in. `hour`+`minute` for +// time-based presets share the single #cronFormScheduleTime picker (in the Time +// field); `minute` alone (Hourly) uses the standalone Minute field. +function _cronSchedulePresetFieldWrapId(field) { + if (field === 'time' || field === 'hour') return 'cronFormScheduleTimeField'; + if (field === 'minute') return 'cronFormScheduleMinuteField'; + if (field === 'weekday') return 'cronFormScheduleWeekdayField'; + if (field === 'monthDay') return 'cronFormScheduleMonthDayField'; + return ''; +} + function _cronSchedulePresetFieldId(field) { const ids = _cronSchedulePresetControlIds(); return ids[field] || ''; @@ -522,11 +547,34 @@ function _cronSchedulePresetNormalizeValue(field, value, fallback) { } function _cronSchedulePresetValueForField(field, fallback) { + // hour/minute for time-based presets come from the single #cronFormScheduleTime + // picker ("HH:MM"); the standalone Minute box (Hourly) still reads directly. + if (field === 'hour' || field === 'minute') { + const timeEl = $('cronFormScheduleTime'); + const minuteBox = $('cronFormScheduleMinute'); + // Hourly uses the standalone minute box; if the Time picker isn't the visible + // source for minute, prefer the minute box when it's the shown control. + if (field === 'minute' && minuteBox && (!timeEl || _cronScheduleMinuteBoxIsActive())) { + return _cronSchedulePresetNormalizeValue('minute', minuteBox.value, fallback); + } + if (timeEl && /^\d{1,2}:\d{2}$/.test(String(timeEl.value || '').trim())) { + const [h, m] = String(timeEl.value).trim().split(':'); + return _cronSchedulePresetNormalizeValue(field, field === 'hour' ? h : m, fallback); + } + return _cronSchedulePresetNormalizeValue(field, fallback, fallback); + } const el = _cronSchedulePresetFieldEl(field); const raw = el ? String(el.value || '').trim() : ''; return _cronSchedulePresetNormalizeValue(field, raw, fallback); } +// The standalone Minute box is the active minute source only for the Hourly preset +// (the only preset whose visible fields include a bare 'minute'). +function _cronScheduleMinuteBoxIsActive() { + const presetEl = $('cronFormSchedulePreset'); + return !!(presetEl && presetEl.value === 'hourly'); +} + function _cronSchedulePresetRawFieldInBounds(field, value) { const raw = String(value || '').trim(); if (!/^\d+$/.test(raw)) return false; @@ -536,7 +584,20 @@ function _cronSchedulePresetRawFieldInBounds(field, value) { } function _cronSchedulePresetApplyValues(values) { - ['hour', 'minute', 'weekday', 'monthDay'].forEach((field) => { + // Write hour/minute into the single time picker as zero-padded HH:MM. + if (values.hour != null || values.minute != null) { + const timeEl = $('cronFormScheduleTime'); + if (timeEl) { + const h = _cronSchedulePresetNormalizeValue('hour', values.hour, values.hour ?? 9); + const m = _cronSchedulePresetNormalizeValue('minute', values.minute, values.minute ?? 0); + timeEl.value = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; + } + } + if (values.minute != null) { + const minuteBox = $('cronFormScheduleMinute'); + if (minuteBox) minuteBox.value = _cronSchedulePresetNormalizeValue('minute', values.minute, values.minute); + } + ['weekday', 'monthDay'].forEach((field) => { const el = _cronSchedulePresetFieldEl(field); if (!el || values[field] == null) return; el.value = _cronSchedulePresetNormalizeValue(field, values[field], values[field]); @@ -545,11 +606,16 @@ function _cronSchedulePresetApplyValues(values) { function _cronSchedulePresetSyncVisibility(presetId) { const wrapper = $('cronFormSchedulePresetParams'); + const customRow = $('cronFormScheduleCustomRow'); const preset = _cronSchedulePresetForId(presetId); const showFields = preset && Array.isArray(preset.fields) ? preset.fields : []; - if (wrapper) wrapper.style.display = presetId === 'custom' ? 'none' : ''; - ['hour', 'minute', 'weekday', 'monthDay'].forEach((field) => { - const fieldWrap = $(`cronFormSchedule${field === 'monthDay' ? 'MonthDay' : field.charAt(0).toUpperCase() + field.slice(1)}Field`); + const isCustom = presetId === 'custom'; + // Preset param controls hide entirely on Custom; the raw cron expression row + // shows ONLY on Custom (kept in the DOM as a hidden field otherwise). + if (wrapper) wrapper.style.display = isCustom ? 'none' : ''; + if (customRow) customRow.style.display = isCustom ? '' : 'none'; + ['time', 'minute', 'weekday', 'monthDay'].forEach((field) => { + const fieldWrap = $(_cronSchedulePresetFieldWrapId(field)); if (fieldWrap) fieldWrap.style.display = showFields.includes(field) ? '' : 'none'; }); } @@ -658,20 +724,44 @@ function _applyCronSchedulePresetSelection() { _syncCronScheduleWarning(); } +// Regenerate the cron expression from the current field values WITHOUT writing the +// clamped values back into the field the user is editing — so typing into the Minute +// box (or the time picker) doesn't snap a half-entered value to the default/clamp +// mid-keystroke. Value clamping still happens on `change`/blur via the full apply. +function _regenCronScheduleFromFields() { + const presetEl = $('cronFormSchedulePreset'); + const scheduleEl = $('cronFormSchedule'); + if (!presetEl || !scheduleEl) return; + const presetId = presetEl.value; + if (presetId === 'custom') return; + scheduleEl.value = _cronSchedulePresetValueForSelection(presetId); + _syncCronScheduleWarning(); +} + function _initCronSchedulePresetControls() { const presetEl = $('cronFormSchedulePreset'); const scheduleEl = $('cronFormSchedule'); if (!presetEl || !scheduleEl) return; presetEl.addEventListener('change', _applyCronSchedulePresetSelection); if ($('cronFormSchedulePresetParams')) { - ['cronFormScheduleHour', 'cronFormScheduleMinute', 'cronFormScheduleWeekday', 'cronFormScheduleMonthDay'].forEach((id) => { + ['cronFormScheduleTime', 'cronFormScheduleMinute', 'cronFormScheduleWeekday', 'cronFormScheduleMonthDay'].forEach((id) => { const el = $(id); if (!el) return; + // On `change`/blur, clamp + normalize (writes values back). On `input` + // (per-keystroke), only regenerate the expression — never rewrite the field + // being typed into, so partial input like clearing Hour to type "14" isn't + // snapped to the default (#5554 UX fix). el.addEventListener('change', _applyCronSchedulePresetSelection); - el.addEventListener('input', _applyCronSchedulePresetSelection); + el.addEventListener('input', _regenCronScheduleFromFields); }); } - scheduleEl.addEventListener('input', _syncCronSchedulePresetAndWarning); + // On raw-cron `input` (only reachable on Custom, where the raw row is shown), + // update ONLY the warning + preview — do NOT re-detect the preset or sync + // visibility, or a partial value momentarily matching a preset (e.g. typing + // "0 9 * * 1,3" transiently equals Weekly's "0 9 * * 1") would switch the preset + // and hide the focused raw field mid-keystroke (#5554). Preset re-detection runs + // on initial render and on `change`/blur. + scheduleEl.addEventListener('input', _syncCronScheduleWarning); scheduleEl.addEventListener('change', _syncCronSchedulePresetAndWarning); _syncCronSchedulePresetAndWarning(); } @@ -1451,7 +1541,7 @@ function openCronCreate(){ _cronMode = 'create'; _cronIsDuplicate = false; _cronSelectedSkills = []; - _renderCronForm({ name:'', schedule:'', prompt:'', deliver:'local', profile:'', toast_notifications:true, model:'', provider:'', isEdit:false }); + _renderCronForm({ name:'', schedule:'0 9 * * *', prompt:'', deliver:'local', profile:'', toast_notifications:true, model:'', provider:'', isEdit:false }); _cronSkillsCache = null; api('/api/skills').then(d=>{_cronSkillsCache=d.skills||[]; _bindCronSkillPicker();}).catch(()=>{}); loadCronProfiles().then(()=>_refreshCronProfileSelect('')).catch(()=>{}); @@ -1522,34 +1612,44 @@ function _renderCronForm({ name, schedule, prompt, deliver, profile, toast_notif
- +
- ${_cronSchedulePresetOptionHtml()}
-
- +