@@ -18,6 +18,7 @@ import {
1818 Text ,
1919 TextField ,
2020} from "@radix-ui/themes" ;
21+ import { useState } from "react" ;
2122import {
2223 emptyLoopApiTriggerConfig ,
2324 emptyLoopGithubTriggerConfig ,
@@ -222,12 +223,92 @@ function TriggerCard({
222223 ) ;
223224}
224225
225- type ScheduleMode = "recurring" | "one_time" ;
226+ type ScheduleFrequency =
227+ | "hourly"
228+ | "daily"
229+ | "weekdays"
230+ | "weekly"
231+ | "once"
232+ | "custom" ;
226233
227- function scheduleModeOf (
228- config : LoopSchemas . LoopScheduleTriggerConfig ,
229- ) : ScheduleMode {
230- return config . run_at ? "one_time" : "recurring" ;
234+ const FREQUENCY_OPTIONS : { value : ScheduleFrequency ; label : string } [ ] = [
235+ { value : "hourly" , label : "Hourly" } ,
236+ { value : "daily" , label : "Daily" } ,
237+ { value : "weekdays" , label : "Weekdays" } ,
238+ { value : "weekly" , label : "Weekly" } ,
239+ { value : "once" , label : "Once" } ,
240+ { value : "custom" , label : "Custom (cron)" } ,
241+ ] ;
242+
243+ const WEEKDAY_OPTIONS = [
244+ { value : "0" , label : "Sunday" } ,
245+ { value : "1" , label : "Monday" } ,
246+ { value : "2" , label : "Tuesday" } ,
247+ { value : "3" , label : "Wednesday" } ,
248+ { value : "4" , label : "Thursday" } ,
249+ { value : "5" , label : "Friday" } ,
250+ { value : "6" , label : "Saturday" } ,
251+ ] ;
252+
253+ const DEFAULT_TIME = "09:00" ;
254+
255+ function localTimezone ( ) : string {
256+ try {
257+ return Intl . DateTimeFormat ( ) . resolvedOptions ( ) . timeZone || "UTC" ;
258+ } catch {
259+ return "UTC" ;
260+ }
261+ }
262+
263+ interface ParsedRecurringSchedule {
264+ frequency : "hourly" | "daily" | "weekdays" | "weekly" ;
265+ time : string ;
266+ weekday : string ;
267+ }
268+
269+ /** Reads the simple shapes the frequency picker writes; anything else is custom. */
270+ function parseCronSchedule (
271+ cron : string | null | undefined ,
272+ ) : ParsedRecurringSchedule | null {
273+ if ( ! cron ) return null ;
274+ const parts = cron . trim ( ) . split ( / \s + / ) ;
275+ if ( parts . length !== 5 ) return null ;
276+ const [ minute , hour , dayOfMonth , month , dayOfWeek ] = parts ;
277+ if ( dayOfMonth !== "*" || month !== "*" ) return null ;
278+ if ( ! / ^ \d { 1 , 2 } $ / . test ( minute ) ) return null ;
279+ if ( hour === "*" ) {
280+ return dayOfWeek === "*"
281+ ? { frequency : "hourly" , time : DEFAULT_TIME , weekday : "1" }
282+ : null ;
283+ }
284+ if ( ! / ^ \d { 1 , 2 } $ / . test ( hour ) ) return null ;
285+ const time = `${ hour . padStart ( 2 , "0" ) } :${ minute . padStart ( 2 , "0" ) } ` ;
286+ if ( dayOfWeek === "*" ) return { frequency : "daily" , time, weekday : "1" } ;
287+ if ( dayOfWeek === "1-5" ) return { frequency : "weekdays" , time, weekday : "1" } ;
288+ if ( / ^ [ 0 - 6 ] $ / . test ( dayOfWeek ) ) {
289+ return { frequency : "weekly" , time, weekday : dayOfWeek } ;
290+ }
291+ return null ;
292+ }
293+
294+ function compileCronSchedule (
295+ frequency : "hourly" | "daily" | "weekdays" | "weekly" ,
296+ time : string ,
297+ weekday : string ,
298+ ) : string {
299+ const [ hourPart , minutePart ] = time . split ( ":" ) ;
300+ const hour = Number ( hourPart ) || 0 ;
301+ const minute = Number ( minutePart ) || 0 ;
302+ switch ( frequency ) {
303+ case "hourly" :
304+ return "0 * * * *" ;
305+ case "daily" :
306+ return `${ minute } ${ hour } * * *` ;
307+ case "weekdays" :
308+ return `${ minute } ${ hour } * * 1-5` ;
309+ case "weekly" :
310+ return `${ minute } ${ hour } * * ${ weekday } ` ;
311+ }
231312}
232313
233314function ScheduleTriggerFields ( {
@@ -239,30 +320,125 @@ function ScheduleTriggerFields({
239320 disabled ?: boolean ;
240321 onChange : ( config : LoopSchemas . LoopScheduleTriggerConfig ) => void ;
241322} ) {
242- const mode = scheduleModeOf ( config ) ;
323+ const parsed = parseCronSchedule ( config . cron_expression ) ;
324+ // Custom is sticky UI state: "0 9 * * *" typed by hand still parses as
325+ // daily, so deriving the mode from the config alone would bounce the user
326+ // out of the cron editor while they type.
327+ const [ customMode , setCustomMode ] = useState (
328+ ( ) => ! config . run_at && ! ! config . cron_expression && ! parsed ,
329+ ) ;
330+ const frequency : ScheduleFrequency = config . run_at
331+ ? "once"
332+ : customMode
333+ ? "custom"
334+ : ( parsed ?. frequency ?? "daily" ) ;
335+ const time = parsed ?. time ?? DEFAULT_TIME ;
336+ const weekday = parsed ?. weekday ?? "1" ;
337+ const timezone = config . timezone || localTimezone ( ) ;
338+
339+ const setRecurring = (
340+ nextFrequency : "hourly" | "daily" | "weekdays" | "weekly" ,
341+ nextTime : string ,
342+ nextWeekday : string ,
343+ ) => {
344+ onChange ( {
345+ cron_expression : compileCronSchedule (
346+ nextFrequency ,
347+ nextTime ,
348+ nextWeekday ,
349+ ) ,
350+ timezone,
351+ } ) ;
352+ } ;
353+
354+ const handleFrequencyChange = ( value : string ) => {
355+ const next = value as ScheduleFrequency ;
356+ if ( next === "once" ) {
357+ setCustomMode ( false ) ;
358+ onChange ( { run_at : new Date ( ) . toISOString ( ) } ) ;
359+ return ;
360+ }
361+ if ( next === "custom" ) {
362+ setCustomMode ( true ) ;
363+ onChange ( {
364+ cron_expression :
365+ config . cron_expression ??
366+ emptyLoopScheduleTriggerConfig ( ) . cron_expression ,
367+ timezone,
368+ } ) ;
369+ return ;
370+ }
371+ setCustomMode ( false ) ;
372+ setRecurring ( next , time , weekday ) ;
373+ } ;
374+
375+ const timeInput = (
376+ < input
377+ type = "time"
378+ disabled = { disabled }
379+ value = { time }
380+ className = "h-8 rounded-(--radius-2) border border-border bg-(--color-panel-solid) px-2 text-[12.5px] text-gray-12"
381+ onChange = { ( e ) => {
382+ if ( ! e . target . value || frequency === "custom" || frequency === "once" )
383+ return ;
384+ setRecurring (
385+ frequency as "daily" | "weekdays" | "weekly" ,
386+ e . target . value ,
387+ weekday ,
388+ ) ;
389+ } }
390+ />
391+ ) ;
243392
244393 return (
245394 < Flex direction = "column" gap = "2" >
246- < Box className = "max-w-[220px]" >
247- < SettingsOptionSelect
248- value = { mode }
249- options = { [
250- { value : "recurring" , label : "Recurring (cron)" } ,
251- { value : "one_time" , label : "One-time" } ,
252- ] }
253- disabled = { disabled }
254- ariaLabel = "Schedule mode"
255- onValueChange = { ( value ) =>
256- onChange (
257- value === "one_time"
258- ? { run_at : new Date ( ) . toISOString ( ) }
259- : emptyLoopScheduleTriggerConfig ( ) ,
260- )
261- }
262- />
263- </ Box >
395+ < Flex align = "center" gap = "2" wrap = "wrap" >
396+ < Box className = "min-w-[150px]" >
397+ < SettingsOptionSelect
398+ value = { frequency }
399+ options = { FREQUENCY_OPTIONS }
400+ disabled = { disabled }
401+ ariaLabel = "Frequency"
402+ onValueChange = { handleFrequencyChange }
403+ />
404+ </ Box >
405+
406+ { frequency === "daily" ||
407+ frequency === "weekdays" ||
408+ frequency === "weekly"
409+ ? timeInput
410+ : null }
264411
265- { mode === "recurring" ? (
412+ { frequency === "weekly" ? (
413+ < Box className = "min-w-[140px]" >
414+ < SettingsOptionSelect
415+ value = { weekday }
416+ options = { WEEKDAY_OPTIONS }
417+ disabled = { disabled }
418+ ariaLabel = "Day of week"
419+ onValueChange = { ( value ) => setRecurring ( "weekly" , time , value ) }
420+ />
421+ </ Box >
422+ ) : null }
423+
424+ { frequency === "once" ? (
425+ < input
426+ type = "datetime-local"
427+ disabled = { disabled }
428+ className = "h-8 rounded-(--radius-2) border border-border bg-(--color-panel-solid) px-2 text-[12.5px] text-gray-12"
429+ value = { config . run_at ? toDatetimeLocal ( config . run_at ) : "" }
430+ onChange = { ( e ) =>
431+ onChange ( {
432+ run_at : e . target . value
433+ ? new Date ( e . target . value ) . toISOString ( )
434+ : undefined ,
435+ } )
436+ }
437+ />
438+ ) : null }
439+ </ Flex >
440+
441+ { frequency === "custom" ? (
266442 < Flex gap = "2" wrap = "wrap" >
267443 < Flex direction = "column" gap = "1" className = "min-w-[160px] flex-1" >
268444 < Text className = "text-[12px] text-gray-10" > Cron expression</ Text >
@@ -289,24 +465,9 @@ function ScheduleTriggerFields({
289465 />
290466 </ Flex >
291467 </ Flex >
292- ) : (
293- < Flex direction = "column" gap = "1" className = "max-w-[240px]" >
294- < Text className = "text-[12px] text-gray-10" > Run at</ Text >
295- < input
296- type = "datetime-local"
297- disabled = { disabled }
298- className = "h-8 rounded-(--radius-2) border border-border bg-(--color-panel-solid) px-2 text-[12.5px] text-gray-12"
299- value = { config . run_at ? toDatetimeLocal ( config . run_at ) : "" }
300- onChange = { ( e ) =>
301- onChange ( {
302- run_at : e . target . value
303- ? new Date ( e . target . value ) . toISOString ( )
304- : undefined ,
305- } )
306- }
307- />
308- </ Flex >
309- ) }
468+ ) : frequency !== "once" ? (
469+ < Text className = "text-[11px] text-gray-9" > Times in { timezone } </ Text >
470+ ) : null }
310471 </ Flex >
311472 ) ;
312473}
0 commit comments