package schedule import ( "errors" "fmt" "slices" "strings" "time" "g.fc.run/ai/byre/internal/api" ) var ErrInvalid = errors.New("invalid schedule") var Weekdays = []api.Weekday{api.Mon, api.Tue, api.Wed, api.Thu, api.Fri, api.Sat, api.Sun} var goWeekdays = map[api.Weekday]time.Weekday{ api.Mon: time.Monday, api.Tue: time.Tuesday, api.Wed: time.Wednesday, api.Thu: time.Thursday, api.Fri: time.Friday, api.Sat: time.Saturday, api.Sun: time.Sunday, } func invalid(format string, args ...any) error { return fmt.Errorf("%w: %s", ErrInvalid, fmt.Sprintf(format, args...)) } func Timing(save api.ScheduleSave) (api.Schedule, error) { sc := api.Schedule{ Title: strings.TrimSpace(save.Title), Every: save.Every, Weekdays: []api.Weekday{}, Hour: save.Hour, Minute: save.Minute, } if sc.Title == "" { return sc, invalid("title is required") } if save.Minute < 0 || save.Minute > 59 { return sc, invalid("minute must be 0 to 59") } if save.Hour < 0 || save.Hour > 23 { return sc, invalid("hour must be 0 to 23") } switch save.Every { case api.Hour, api.Day, api.Week, api.Month: default: return sc, invalid("every must be hour, day, week or month") } if save.Every == api.Hour && save.Hour != 0 { return sc, invalid("an hourly schedule runs at a minute past every hour, so hour must be 0") } if (save.Weekdays != nil && len(*save.Weekdays) > 0) != (save.Every == api.Week) { return sc, invalid("weekdays are required for a weekly schedule, and only allowed for one") } if save.Weekdays != nil { for _, d := range Weekdays { if slices.Contains(*save.Weekdays, d) { sc.Weekdays = append(sc.Weekdays, d) } } for _, d := range *save.Weekdays { if _, found := goWeekdays[d]; !found { return sc, invalid("unknown weekday %q", d) } } } if (save.DayOfMonth != nil) != (save.Every == api.Month) { return sc, invalid("day_of_month is required for a monthly schedule, and only allowed for one") } if save.DayOfMonth != nil { if *save.DayOfMonth < 1 || *save.DayOfMonth > 31 { return sc, invalid("day_of_month must be 1 to 31") } sc.DayOfMonth = *save.DayOfMonth } return sc, nil } func Next(sc api.Schedule, after time.Time) time.Time { t := after.Local() y, m, d := t.Date() at := func(y int, m time.Month, d, h int) time.Time { return time.Date(y, m, d, h, sc.Minute, 0, 0, time.Local) } switch sc.Every { case api.Hour: c := at(y, m, d, t.Hour()) for !c.After(t) { c = c.Add(time.Hour) } return c case api.Day: c := at(y, m, d, sc.Hour) if !c.After(t) { c = at(y, m, d+1, sc.Hour) } return c case api.Week: days := map[time.Weekday]bool{} for _, w := range sc.Weekdays { days[goWeekdays[w]] = true } for i := 0; i <= 7; i++ { c := at(y, m, d+i, sc.Hour) if days[c.Weekday()] && c.After(t) { return c } } case api.Month: for i := 0; i <= 1; i++ { first := time.Date(y, m+time.Month(i), 1, 0, 0, 0, 0, time.Local) last := first.AddDate(0, 1, -1).Day() c := at(first.Year(), first.Month(), min(sc.DayOfMonth, last), sc.Hour) if c.After(t) { return c } } } panic(fmt.Sprintf("schedule %s has no next run after %s", sc.Id, after)) }