release: redesign cron preset schedule builder — sentence UI with time picker + day dropdowns (#5554)

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 <rod.boev@gmail.com>
This commit is contained in:
nesquena-hermes 2026-07-06 01:35:55 +00:00
parent 13ecbd79fb
commit 936c5b051b
5 changed files with 394 additions and 88 deletions

View file

@ -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)

View file

@ -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)',

View file

@ -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
<input type="text" id="cronFormName" value="${esc(name || '')}" placeholder="${esc(t('cron_name_placeholder') || 'Optional')}" autocomplete="off">
</div>
<div class="detail-form-row">
<label for="cronFormSchedulePreset">${esc(t('cron_schedule_preset_label') || 'Preset')}</label>
<label for="cronFormSchedulePreset">${esc(t('cron_schedule_preset_label') || 'Schedule')}</label>
<div class="cron-schedule-preset-shell">
<select id="cronFormSchedulePreset">
<select id="cronFormSchedulePreset" class="cron-schedule-preset-select">
${_cronSchedulePresetOptionHtml()}
</select>
<div id="cronFormSchedulePresetParams" class="cron-schedule-preset-params" style="display:none">
<div class="cron-schedule-preset-field" id="cronFormScheduleHourField">
<label for="cronFormScheduleHour">${esc(t('cron_schedule_hour_label') || 'Hour')}</label>
<input type="number" id="cronFormScheduleHour" min="0" max="23" step="1" value="9" autocomplete="off">
</div>
<div class="cron-schedule-preset-field" id="cronFormScheduleMinuteField">
<label for="cronFormScheduleMinute">${esc(t('cron_schedule_minute_label') || 'Minute')}</label>
<input type="number" id="cronFormScheduleMinute" min="0" max="59" step="1" value="0" autocomplete="off">
</div>
<div class="cron-schedule-preset-field" id="cronFormScheduleWeekdayField">
<label for="cronFormScheduleWeekday">${esc(t('cron_schedule_weekday_label') || 'Weekday')}</label>
<input type="number" id="cronFormScheduleWeekday" min="0" max="6" step="1" value="1" autocomplete="off">
<span class="cron-schedule-preset-conj" aria-hidden="true">${esc(t('cron_schedule_conj_on') || 'on')}</span>
<select id="cronFormScheduleWeekday" aria-label="${esc(t('cron_schedule_weekday_label') || 'Day of week')}">
<option value="0">${esc(t('cron_weekday_sun') || 'Sunday')}</option>
<option value="1" selected>${esc(t('cron_weekday_mon') || 'Monday')}</option>
<option value="2">${esc(t('cron_weekday_tue') || 'Tuesday')}</option>
<option value="3">${esc(t('cron_weekday_wed') || 'Wednesday')}</option>
<option value="4">${esc(t('cron_weekday_thu') || 'Thursday')}</option>
<option value="5">${esc(t('cron_weekday_fri') || 'Friday')}</option>
<option value="6">${esc(t('cron_weekday_sat') || 'Saturday')}</option>
</select>
</div>
<div class="cron-schedule-preset-field" id="cronFormScheduleMonthDayField">
<label for="cronFormScheduleMonthDay">${esc(t('cron_schedule_month_day_label') || 'Day')}</label>
<input type="number" id="cronFormScheduleMonthDay" min="1" max="31" step="1" value="1" autocomplete="off">
<span class="cron-schedule-preset-conj" aria-hidden="true">${esc(t('cron_schedule_conj_on_day') || 'on day')}</span>
<select id="cronFormScheduleMonthDay" aria-label="${esc(t('cron_schedule_month_day_label') || 'Day of month')}">
${Array.from({length:31},(_,i)=>`<option value="${i+1}">${i+1}</option>`).join('')}
</select>
</div>
<div class="detail-form-hint cron-schedule-preset-time-hint">${esc(t('cron_schedule_time_hint') || 'Time is server time; cron runs server-side.')}</div>
<div class="cron-schedule-preset-field" id="cronFormScheduleTimeField">
<span class="cron-schedule-preset-conj" aria-hidden="true">${esc(t('cron_schedule_conj_at') || 'at')}</span>
<input type="time" id="cronFormScheduleTime" value="09:00" step="60" autocomplete="off" aria-label="${esc(t('cron_schedule_time_label') || 'Time')}">
</div>
<div class="cron-schedule-preset-field" id="cronFormScheduleMinuteField">
<span class="cron-schedule-preset-conj" aria-hidden="true">${esc(t('cron_schedule_conj_at_minute') || 'at minute')}</span>
<input type="number" id="cronFormScheduleMinute" min="0" max="59" step="1" value="0" autocomplete="off" aria-label="${esc(t('cron_schedule_minute_label') || 'Minute')}">
</div>
<div class="detail-form-hint cron-schedule-preset-time-hint"><span id="cronFormSchedulePreview" class="cron-schedule-preview"></span>${esc(t('cron_schedule_time_hint') || 'Time is server time; cron runs server-side.')}</div>
</div>
</div>
</div>
<div class="detail-form-row">
<label for="cronFormSchedule">${esc(t('cron_schedule_label') || 'Schedule')}</label>
<div class="detail-form-row" id="cronFormScheduleCustomRow" style="display:none">
<label for="cronFormSchedule">${esc(t('cron_schedule_label') || 'Cron expression')}</label>
<input type="text" id="cronFormSchedule" value="${esc(schedule || '')}" placeholder="0 9 * * * — every 1h — @daily" autocomplete="off" required>
<div class="detail-form-hint">${esc(t('cron_schedule_hint') || "Cron expression or shorthand like 'every 1h'.")}</div>
<div id="cronFormScheduleOnceWarning" class="detail-form-warning cron-once-warning" style="display:none">${esc(t('cron_schedule_once_warning') || "Duration forms like '30m' run once and are removed after running. Use 'every 30m' to keep a recurring job.")}</div>

View file

@ -6319,6 +6319,7 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
.detail-form-row input[type="text"],
.detail-form-row input[type="password"],
.detail-form-row input[type="number"],
.detail-form-row input[type="time"],
.detail-form-row input:not([type]),
.detail-form-row select,
.detail-form-row textarea{
@ -6335,13 +6336,18 @@ main.main > .main-view:not([id="mainChat"]):not([id="mainSettings"]) .main-view-
.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%;}
.cron-schedule-preset-shell{display:flex;flex-wrap:wrap;gap:10px;align-items:center;}
.cron-schedule-preset-shell > select.cron-schedule-preset-select{width:auto;min-width:150px;flex:0 0 auto;}
.cron-schedule-preset-params{display:flex;flex-wrap:wrap;gap:8px 10px;align-items:center;min-width:0;}
.cron-schedule-preset-field{display:inline-flex;flex-direction:row;align-items:center;gap:6px;}
.cron-schedule-preset-conj{font-size:13px;color:var(--muted);white-space:nowrap;}
.cron-schedule-preset-field select,.cron-schedule-preset-field input[type="time"]{width:auto;min-width:0;}
.cron-schedule-preset-field select{min-width:120px;}
#cronFormScheduleMonthDay{min-width:72px;}
.cron-schedule-preset-field input[type="time"]{min-width:120px;}
.cron-schedule-preset-field input[type="number"]{width:64px;}
.cron-schedule-preset-time-hint{flex-basis:100%;margin-top:2px;}
.cron-schedule-preview{font-family:'SF Mono',ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text);opacity:.85;}
.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;}

View file

@ -139,10 +139,10 @@ function makeElement(initialValue = '') {
}
function t(key) {
const dict = {
cron_schedule_preset_label: 'Preset',
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',
@ -155,14 +155,16 @@ function esc(value) { return value == null ? '' : String(value); }
'cronFormSchedule',
'cronFormSchedulePreset',
'cronFormSchedulePresetParams',
'cronFormScheduleHourField',
'cronFormScheduleCustomRow',
'cronFormScheduleTimeField',
'cronFormScheduleMinuteField',
'cronFormScheduleWeekdayField',
'cronFormScheduleMonthDayField',
'cronFormScheduleHour',
'cronFormScheduleTime',
'cronFormScheduleMinute',
'cronFormScheduleWeekday',
'cronFormScheduleMonthDay',
'cronFormSchedulePreview',
].forEach((id) => {
elements[id] = makeElement();
});
@ -171,17 +173,24 @@ elements.cronFormSchedulePresetParams.style.display = 'none';
_initCronSchedulePresetControls();
// Split "HH:MM" from the time picker so assertions can read hour/minute separately.
function timeHour() { const v = String(elements.cronFormScheduleTime.value || ''); return v.includes(':') ? v.split(':')[0].replace(/^0/, '') || '0' : ''; }
function timeMin() { const v = String(elements.cronFormScheduleTime.value || ''); return v.includes(':') ? String(parseInt(v.split(':')[1], 10)) : ''; }
function snapshot() {
return {
preset: elements.cronFormSchedulePreset.value,
schedule: elements.cronFormSchedule.value,
paramsDisplay: elements.cronFormSchedulePresetParams.style.display,
hourFieldDisplay: elements.cronFormScheduleHourField.style.display,
customRowDisplay: elements.cronFormScheduleCustomRow.style.display,
timeFieldDisplay: elements.cronFormScheduleTimeField.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,
time: elements.cronFormScheduleTime.value,
hour: timeHour(),
minute: timeMin(),
minuteBox: elements.cronFormScheduleMinute.value,
weekday: elements.cronFormScheduleWeekday.value,
monthDay: elements.cronFormScheduleMonthDay.value,
warning: elements.cronFormScheduleOnceWarning.style.display,
@ -189,72 +198,67 @@ function snapshot() {
};
}
// Hourly: raw sync + editing the standalone minute box.
elements.cronFormSchedule.value = '15 * * * *';
elements.cronFormSchedule.dispatchEvent('input');
elements.cronFormSchedule.dispatchEvent('change');
const hourlySync = snapshot();
elements.cronFormSchedulePreset.value = 'hourly';
elements.cronFormSchedulePreset.dispatchEvent('change');
elements.cronFormScheduleMinute.value = '15';
elements.cronFormScheduleMinute.dispatchEvent('change');
const hourlyWrite = snapshot();
// Daily: raw sync populates the time picker; editing the time regenerates.
elements.cronFormSchedule.value = '30 7 * * *';
elements.cronFormSchedule.dispatchEvent('input');
elements.cronFormSchedule.dispatchEvent('change');
const dailySync = snapshot();
elements.cronFormScheduleHour.value = '7';
elements.cronFormScheduleHour.dispatchEvent('change');
elements.cronFormScheduleMinute.value = '30';
elements.cronFormScheduleMinute.dispatchEvent('change');
elements.cronFormScheduleTime.value = '07:30';
elements.cronFormScheduleTime.dispatchEvent('change');
const dailyWrite = snapshot();
elements.cronFormSchedule.value = '5 22 * * 1-5';
elements.cronFormSchedule.dispatchEvent('input');
elements.cronFormSchedule.dispatchEvent('change');
const weekdaysSync = snapshot();
elements.cronFormSchedule.value = '45 8 * * 3';
elements.cronFormSchedule.dispatchEvent('input');
elements.cronFormSchedule.dispatchEvent('change');
const weeklySync = snapshot();
elements.cronFormSchedule.value = '0 9 * * 7';
elements.cronFormSchedule.dispatchEvent('input');
elements.cronFormSchedule.dispatchEvent('change');
const weeklyRawSundaySync = 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');
elements.cronFormScheduleTime.value = '08:45';
elements.cronFormScheduleTime.dispatchEvent('change');
const weeklyWrite = snapshot();
elements.cronFormSchedule.value = '10 6 15 * *';
elements.cronFormSchedule.dispatchEvent('input');
elements.cronFormSchedule.dispatchEvent('change');
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');
elements.cronFormScheduleTime.value = '06:10';
elements.cronFormScheduleTime.dispatchEvent('change');
const monthlyWrite = snapshot();
elements.cronFormSchedule.value = '30 7 * * *';
elements.cronFormSchedule.dispatchEvent('input');
elements.cronFormSchedule.dispatchEvent('change');
const dailyAfterMonthlySync = snapshot();
// Monthly clamp: out-of-range monthDay clamps to 31 on change.
elements.cronFormSchedulePreset.value = 'monthly';
elements.cronFormSchedulePreset.dispatchEvent('change');
elements.cronFormScheduleHour.value = '99';
elements.cronFormScheduleMinute.value = '-8';
elements.cronFormScheduleTime.value = '23:00';
elements.cronFormScheduleMonthDay.value = '99';
elements.cronFormScheduleMonthDay.dispatchEvent('change');
const normalizedMonthlyWrite = snapshot();
elements.cronFormSchedule.value = '@daily';
elements.cronFormSchedule.dispatchEvent('input');
elements.cronFormSchedule.dispatchEvent('change');
const shorthandCustom = snapshot();
elements.cronFormSchedule.value = 'advanced: cron expression';
elements.cronFormSchedule.dispatchEvent('input');
elements.cronFormSchedule.dispatchEvent('change');
const unsupportedCustom = snapshot();
elements.cronFormSchedulePreset.value = 'custom';
@ -284,7 +288,8 @@ console.log(JSON.stringify({
assert result["hourlySync"]["preset"] == "hourly"
assert result["hourlySync"]["schedule"] == "15 * * * *"
assert result["hourlySync"]["paramsDisplay"] == ""
assert result["hourlySync"]["hourFieldDisplay"] == "none"
assert result["hourlySync"]["customRowDisplay"] == "none"
assert result["hourlySync"]["timeFieldDisplay"] == "none"
assert result["hourlySync"]["minuteFieldDisplay"] == ""
assert result["hourlySync"]["kind"] == "cron"
assert result["hourlyWrite"]["schedule"] == "15 * * * *"
@ -292,24 +297,30 @@ console.log(JSON.stringify({
assert result["dailySync"]["preset"] == "daily"
assert result["dailySync"]["schedule"] == "30 7 * * *"
assert result["dailySync"]["hourFieldDisplay"] == ""
assert result["dailySync"]["minuteFieldDisplay"] == ""
assert result["dailySync"]["timeFieldDisplay"] == ""
assert result["dailySync"]["minuteFieldDisplay"] == "none"
assert result["dailySync"]["weekdayFieldDisplay"] == "none"
assert result["dailySync"]["monthDayFieldDisplay"] == "none"
assert result["dailySync"]["customRowDisplay"] == "none"
assert result["dailySync"]["time"] == "07:30"
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"]["timeFieldDisplay"] == ""
assert result["weekdaysSync"]["minuteFieldDisplay"] == "none"
assert result["weekdaysSync"]["weekdayFieldDisplay"] == "none"
assert result["weekdaysSync"]["monthDayFieldDisplay"] == "none"
assert result["weekdaysSync"]["time"] == "22:05"
assert result["weeklySync"]["preset"] == "weekly"
assert result["weeklySync"]["schedule"] == "45 8 * * 3"
assert result["weeklySync"]["weekdayFieldDisplay"] == ""
assert result["weeklySync"]["timeFieldDisplay"] == ""
assert result["weeklySync"]["monthDayFieldDisplay"] == "none"
assert result["weeklySync"]["weekday"] == "3"
assert result["weeklySync"]["time"] == "08:45"
assert result["weeklyRawSundaySync"]["preset"] == "weekly"
assert result["weeklyRawSundaySync"]["schedule"] == "0 9 * * 7"
assert result["weeklyRawSundaySync"]["weekday"] == "0"
@ -319,33 +330,37 @@ console.log(JSON.stringify({
assert result["monthlySync"]["preset"] == "monthly"
assert result["monthlySync"]["schedule"] == "10 6 15 * *"
assert result["monthlySync"]["monthDayFieldDisplay"] == ""
assert result["monthlySync"]["timeFieldDisplay"] == ""
assert result["monthlySync"]["weekdayFieldDisplay"] == "none"
assert result["monthlySync"]["monthDay"] == "15"
assert result["monthlySync"]["time"] == "06:10"
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"]["time"] == "07: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"]["time"] == "23:00"
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["shorthandCustom"]["customRowDisplay"] == ""
assert result["unsupportedCustom"]["preset"] == "custom"
assert result["unsupportedCustom"]["schedule"] == "advanced: cron expression"
assert result["unsupportedCustom"]["paramsDisplay"] == "none"
assert result["unsupportedCustom"]["customRowDisplay"] == ""
assert result["customSelectionPreserved"]["preset"] == "custom"
assert result["customSelectionPreserved"]["schedule"] == "advanced: cron expression"
assert result["customSelectionPreserved"]["paramsDisplay"] == "none"
assert result["customSelectionPreserved"]["customRowDisplay"] == ""
def test_cron_schedule_raw_warning_listener_survives_missing_preset_params():
@ -465,27 +480,43 @@ 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=\"cronFormScheduleCustomRow\"" in panels
assert "id=\"cronFormScheduleTime\"" in panels
assert "type=\"time\"" in panels
assert "id=\"cronFormScheduleMinute\"" in panels
assert "id=\"cronFormScheduleWeekday\"" in panels
assert "id=\"cronFormScheduleMonthDay\"" in panels
assert "id=\"cronFormSchedulePreview\"" in panels
assert "cron_schedule_once_warning" in panels
assert "_cronSchedulePresetIdForValue" in panels
assert "_cronSchedulePresetOptionHtml" in panels
assert "_initCronSchedulePresetControls" in panels
assert "addEventListener('input', _syncCronSchedulePresetAndWarning" in panels
# Raw-cron `input` only updates the warning/preview (NOT preset re-detection),
# so a partial custom value that transiently matches a preset can't hide the
# focused raw field mid-typing (#5554). Preset re-sync runs on change/init.
assert "addEventListener('input', _syncCronScheduleWarning" in panels
assert "addEventListener('change', _syncCronSchedulePresetAndWarning" in panels
assert "addEventListener('change', _applyCronSchedulePresetSelection" in panels
# UX fix: field edits regenerate the expression on `input` WITHOUT writing the
# clamped value back into the field being typed (only clamp on `change`).
assert "addEventListener('input', _regenCronScheduleFromFields" in panels
assert ".cron-once-warning" in style
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 ".cron-schedule-preview" in style
# Time input is themed to match the other form controls (not bare native chrome).
assert "input[type=\"time\"]" in style
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
assert "fields: ['hour', 'minute', 'monthDay']" in panels
assert "fields: ['time']" in panels
assert "fields: ['weekday', 'time']" in panels
assert "fields: ['monthDay', 'time']" in panels
# Custom is last in the preset order (after the common frequencies).
custom_idx = panels.index("id: 'custom'")
monthly_idx = panels.index("id: 'monthly'")
assert monthly_idx < custom_idx
assert "Duration forms like '30m' run once" in i18n