// Package scheduler runs a job at fixed times of day. package scheduler import ( "context" "fmt" "log/slog" "sort" "strings" "time" ) // TimeOfDay is a wall-clock time without a date. type TimeOfDay struct { Hour, Minute int } func (t TimeOfDay) String() string { return fmt.Sprintf("%02d:%02d", t.Hour, t.Minute) } // ParseTimes parses a comma-separated list of HH:MM times, e.g. "04:00" or // "06:00,18:00". "off" yields an empty schedule. The result is sorted and // de-duplicated. func ParseTimes(s string) ([]TimeOfDay, error) { s = strings.TrimSpace(s) if strings.EqualFold(s, "off") { return nil, nil } seen := map[TimeOfDay]bool{} var times []TimeOfDay for _, part := range strings.Split(s, ",") { part = strings.TrimSpace(part) parsed, err := time.Parse("15:04", part) if err != nil { return nil, fmt.Errorf("invalid time %q: want HH:MM, a comma-separated list of them, or \"off\"", part) } t := TimeOfDay{Hour: parsed.Hour(), Minute: parsed.Minute()} if !seen[t] { seen[t] = true times = append(times, t) } } sort.Slice(times, func(i, j int) bool { if times[i].Hour != times[j].Hour { return times[i].Hour < times[j].Hour } return times[i].Minute < times[j].Minute }) return times, nil } // Next returns the earliest scheduled instant strictly after now, with the // times read in loc. It returns the zero time when times is empty. func Next(now time.Time, times []TimeOfDay, loc *time.Location) time.Time { local := now.In(loc) var best time.Time for dayOffset := 0; dayOffset <= 1; dayOffset++ { for _, t := range times { c := time.Date(local.Year(), local.Month(), local.Day()+dayOffset, t.Hour, t.Minute, 0, 0, loc) if c.After(now) && (best.IsZero() || c.Before(best)) { best = c } } } return best } // pollInterval bounds each wait so the loop follows the wall clock across // clock adjustments and host suspend instead of relying on one long timer. const pollInterval = time.Minute // Run calls job at every scheduled time until ctx is cancelled. Nothing runs // at startup. Runs do not overlap: the next time is computed after job returns. func Run(ctx context.Context, times []TimeOfDay, loc *time.Location, logger *slog.Logger, job func(context.Context)) { for { next := Next(time.Now(), times, loc) if next.IsZero() { return } logger.Info("next scheduled run", "at", next.Format(time.RFC3339)) for { wait := time.Until(next) if wait <= 0 { break } timer := time.NewTimer(min(wait, pollInterval)) select { case <-ctx.Done(): timer.Stop() return case <-timer.C: } } job(ctx) } }