@@ -240,6 +240,73 @@ func ScatterSchedule(fuzzyCron, workflowIdentifier string) (string, error) {
240240 return fmt .Sprintf ("%d %d * * *" , minute , hour ), nil
241241 }
242242
243+ // For FUZZY:DAILY_BETWEEN:START_H:START_M:END_H:END_M * * *, scatter within the time range
244+ if strings .HasPrefix (fuzzyCron , "FUZZY:DAILY_BETWEEN:" ) {
245+ // Extract the start and end times from FUZZY:DAILY_BETWEEN:START_H:START_M:END_H:END_M
246+ parts := strings .Split (fuzzyCron , " " )
247+ if len (parts ) < 1 {
248+ return "" , fmt .Errorf ("invalid fuzzy daily between pattern: %s" , fuzzyCron )
249+ }
250+
251+ // Parse the times from FUZZY:DAILY_BETWEEN:START_H:START_M:END_H:END_M
252+ timePart := strings .TrimPrefix (parts [0 ], "FUZZY:DAILY_BETWEEN:" )
253+ timeParts := strings .Split (timePart , ":" )
254+ if len (timeParts ) != 4 {
255+ return "" , fmt .Errorf ("invalid time format in fuzzy daily between pattern: %s" , fuzzyCron )
256+ }
257+
258+ startHour , err := strconv .Atoi (timeParts [0 ])
259+ if err != nil || startHour < 0 || startHour > 23 {
260+ return "" , fmt .Errorf ("invalid start hour in fuzzy daily between pattern: %s" , fuzzyCron )
261+ }
262+
263+ startMinute , err := strconv .Atoi (timeParts [1 ])
264+ if err != nil || startMinute < 0 || startMinute > 59 {
265+ return "" , fmt .Errorf ("invalid start minute in fuzzy daily between pattern: %s" , fuzzyCron )
266+ }
267+
268+ endHour , err := strconv .Atoi (timeParts [2 ])
269+ if err != nil || endHour < 0 || endHour > 23 {
270+ return "" , fmt .Errorf ("invalid end hour in fuzzy daily between pattern: %s" , fuzzyCron )
271+ }
272+
273+ endMinute , err := strconv .Atoi (timeParts [3 ])
274+ if err != nil || endMinute < 0 || endMinute > 59 {
275+ return "" , fmt .Errorf ("invalid end minute in fuzzy daily between pattern: %s" , fuzzyCron )
276+ }
277+
278+ // Calculate start and end times in minutes since midnight
279+ startMinutes := startHour * 60 + startMinute
280+ endMinutes := endHour * 60 + endMinute
281+
282+ // Calculate the range size, handling ranges that cross midnight
283+ var rangeSize int
284+ if endMinutes > startMinutes {
285+ // Normal case: range within a single day (e.g., 9:00 to 17:00)
286+ rangeSize = endMinutes - startMinutes
287+ } else {
288+ // Range crosses midnight (e.g., 22:00 to 02:00)
289+ rangeSize = (24 * 60 - startMinutes ) + endMinutes
290+ }
291+
292+ // Use a stable hash to get a deterministic offset within the range
293+ hash := stableHash (workflowIdentifier , rangeSize )
294+
295+ // Calculate the scattered time by adding hash offset to start time
296+ scatteredMinutes := startMinutes + hash
297+
298+ // Handle wrap-around for ranges that cross midnight
299+ if scatteredMinutes >= 24 * 60 {
300+ scatteredMinutes -= 24 * 60
301+ }
302+
303+ hour := scatteredMinutes / 60
304+ minute := scatteredMinutes % 60
305+
306+ // Return scattered daily cron: minute hour * * *
307+ return fmt .Sprintf ("%d %d * * *" , minute , hour ), nil
308+ }
309+
243310 // For FUZZY:DAILY * * *, we scatter across 24 hours
244311 if strings .HasPrefix (fuzzyCron , "FUZZY:DAILY" ) {
245312 // Use a stable hash of the workflow identifier to get a deterministic time
@@ -595,11 +662,59 @@ func (p *ScheduleParser) parseBase() (string, error) {
595662 // daily -> FUZZY:DAILY (fuzzy schedule, time will be scattered)
596663 // daily at HH:MM -> MM HH * * *
597664 // daily around HH:MM -> FUZZY:DAILY_AROUND:HH:MM (fuzzy schedule with target time)
665+ // daily between HH:MM and HH:MM -> FUZZY:DAILY_BETWEEN:START_H:START_M:END_H:END_M (fuzzy schedule within time range)
598666 if len (p .tokens ) == 1 {
599667 // Just "daily" with no time - this is a fuzzy schedule
600668 return "FUZZY:DAILY * * *" , nil
601669 }
602670 if len (p .tokens ) > 1 {
671+ // Check if "between" keyword is used
672+ if p .tokens [1 ] == "between" {
673+ // Parse: "daily between START and END"
674+ // We need at least: daily between TIME and TIME (5 tokens minimum)
675+ if len (p .tokens ) < 5 {
676+ return "" , fmt .Errorf ("invalid 'between' format, expected 'daily between START and END'" )
677+ }
678+
679+ // Find the "and" keyword to split start and end times
680+ andIndex := - 1
681+ for i := 2 ; i < len (p .tokens ); i ++ {
682+ if p .tokens [i ] == "and" {
683+ andIndex = i
684+ break
685+ }
686+ }
687+ if andIndex == - 1 {
688+ return "" , fmt .Errorf ("missing 'and' keyword in 'between' clause" )
689+ }
690+
691+ // Extract start time (tokens between "between" and "and")
692+ startTimeStr , err := p .extractTimeBetween (2 , andIndex )
693+ if err != nil {
694+ return "" , fmt .Errorf ("invalid start time in 'between' clause: %w" , err )
695+ }
696+ startMinute , startHour := parseTime (startTimeStr )
697+
698+ // Extract end time (tokens after "and")
699+ endTimeStr , err := p .extractTimeAfter (andIndex + 1 )
700+ if err != nil {
701+ return "" , fmt .Errorf ("invalid end time in 'between' clause: %w" , err )
702+ }
703+ endMinute , endHour := parseTime (endTimeStr )
704+
705+ // Validate that start is before end (in minutes since midnight)
706+ startMinutes := parseTimeToMinutes (startHour , startMinute )
707+ endMinutes := parseTimeToMinutes (endHour , endMinute )
708+
709+ // Allow ranges that cross midnight (e.g., 22:00 to 02:00)
710+ // We'll handle this in the scattering logic
711+ if startMinutes == endMinutes {
712+ return "" , fmt .Errorf ("start and end times cannot be the same in 'between' clause" )
713+ }
714+
715+ // Return fuzzy between format: FUZZY:DAILY_BETWEEN:START_H:START_M:END_H:END_M
716+ return fmt .Sprintf ("FUZZY:DAILY_BETWEEN:%s:%s:%s:%s * * *" , startHour , startMinute , endHour , endMinute ), nil
717+ }
603718 // Check if "around" keyword is used
604719 if p .tokens [1 ] == "around" {
605720 // Extract time after "around"
@@ -730,6 +845,61 @@ func (p *ScheduleParser) extractTime(startPos int) (string, error) {
730845 return timeStr , nil
731846}
732847
848+ // extractTimeBetween extracts a time specification from tokens between startPos and endPos (exclusive)
849+ // Used for parsing the start time in "between START and END" clauses
850+ func (p * ScheduleParser ) extractTimeBetween (startPos , endPos int ) (string , error ) {
851+ if startPos >= len (p .tokens ) || startPos >= endPos {
852+ return "" , fmt .Errorf ("expected time specification" )
853+ }
854+
855+ // The time is in the tokens between startPos and endPos
856+ // It might be a single token (e.g., "9am") or multiple tokens (e.g., "14:00 utc+9")
857+ timeTokens := []string {}
858+ for i := startPos ; i < endPos && i < len (p .tokens ); i ++ {
859+ timeTokens = append (timeTokens , p .tokens [i ])
860+ }
861+
862+ if len (timeTokens ) == 0 {
863+ return "" , fmt .Errorf ("expected time specification" )
864+ }
865+
866+ // Check if there's a UTC offset
867+ if len (timeTokens ) >= 2 && strings .HasPrefix (strings .ToLower (timeTokens [1 ]), "utc" ) {
868+ return timeTokens [0 ] + " " + timeTokens [1 ], nil
869+ }
870+
871+ return timeTokens [0 ], nil
872+ }
873+
874+ // extractTimeAfter extracts a time specification from tokens starting at startPos until the end
875+ // Used for parsing the end time in "between START and END" clauses
876+ func (p * ScheduleParser ) extractTimeAfter (startPos int ) (string , error ) {
877+ if startPos >= len (p .tokens ) {
878+ return "" , fmt .Errorf ("expected time specification" )
879+ }
880+
881+ // Collect remaining tokens (time and optional UTC offset)
882+ timeStr := p .tokens [startPos ]
883+
884+ // Check if there's a UTC offset in the next token
885+ if startPos + 1 < len (p .tokens ) {
886+ nextToken := strings .ToLower (p .tokens [startPos + 1 ])
887+ if strings .HasPrefix (nextToken , "utc" ) {
888+ // Combine time and UTC offset
889+ timeStr = timeStr + " " + p .tokens [startPos + 1 ]
890+ }
891+ }
892+
893+ return timeStr , nil
894+ }
895+
896+ // parseTimeToMinutes converts hour and minute strings to total minutes since midnight
897+ func parseTimeToMinutes (hourStr , minuteStr string ) int {
898+ hour , _ := strconv .Atoi (hourStr )
899+ minute , _ := strconv .Atoi (minuteStr )
900+ return hour * 60 + minute
901+ }
902+
733903// parseTime converts a time string to minute and hour, with optional UTC offset
734904// Supports formats: HH:MM, midnight, noon, 3pm, 1am, HH:MM utc+N, HH:MM utc+HH:MM, HH:MM utc-N, 3pm utc+9
735905func parseTime (timeStr string ) (minute string , hour string ) {
0 commit comments