package conga import ( "fmt" "strings" "time" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/pact" "github.com/riverqueue/river" ) // Daily is a river.PeriodicSchedule that fires once a day at Hour:Minute:00 // wall-clock time in Loc (UTC when nil). Unlike PeriodicInterval(24h) it // does not drift when the scheduler restarts. type Daily struct { Hour, Minute int Loc *time.Location } // Next returns the first Hour:Minute:00 in Loc strictly after now. On a DST // day a time inside the gap resolves the way time.Date normalizes it. func (d Daily) Next(now time.Time) time.Time { loc := orUTC(d.Loc) n := now.In(loc) t := time.Date(n.Year(), n.Month(), n.Day(), d.Hour, d.Minute, 0, 0, loc) if !t.After(n) { t = time.Date(n.Year(), n.Month(), n.Day()+1, d.Hour, d.Minute, 0, 0, loc) } return t } // Every is a river.PeriodicSchedule that fires at every multiple of Interval // since local midnight in Loc (UTC when nil). Interval must be positive. type Every struct { Interval time.Duration Loc *time.Location } // Next returns local midnight plus the smallest multiple of Interval // strictly after now, and the next local midnight when that multiple falls // on or after it. func (e Every) Next(now time.Time) time.Time { loc := orUTC(e.Loc) n := now.In(loc) midnight := time.Date(n.Year(), n.Month(), n.Day(), 0, 0, 0, 0, loc) nextMidnight := time.Date(n.Year(), n.Month(), n.Day()+1, 0, 0, 0, 0, loc) if e.Interval <= 0 { return nextMidnight } k := n.Sub(midnight)/e.Interval + 1 t := midnight.Add(k * e.Interval) if !t.Before(nextMidnight) { return nextMidnight } return t } func orUTC(loc *time.Location) *time.Location { if loc == nil { return time.UTC } return loc } // scheduleFor returns the River schedule of a cadence in loc and its period. func scheduleFor(c pact.Cadence, loc *time.Location) (river.PeriodicSchedule, time.Duration, error) { if c.IsZero() { return nil, 0, fmt.Errorf("cadence is zero") } if h, m, ok := c.At(); ok { if h < 0 || h > 23 || m < 0 || m > 59 { return nil, 0, fmt.Errorf("daily time %02d:%02d is out of range", h, m) } return Daily{Hour: h, Minute: m, Loc: loc}, 24 * time.Hour, nil } d := c.Interval() if d <= 0 { return nil, 0, fmt.Errorf("interval %s is not positive", d) } if d < time.Second { return nil, 0, fmt.Errorf("interval %s is shorter than one second", d) } if (24*time.Hour)%d != 0 { return nil, 0, fmt.Errorf("interval %s does not divide 24h evenly", d) } return Every{Interval: d, Loc: loc}, d, nil } // appLocation is the app.timezone location, UTC when unset. func appLocation(app *backpack.App) (*time.Location, error) { name := "" if app != nil && app.Config != nil { name = strings.TrimSpace(app.Config.String("app.timezone")) } if name == "" { return time.UTC, nil } loc, err := time.LoadLocation(name) if err != nil { return nil, fmt.Errorf("conga: app.timezone %q: %w", name, err) } return loc, nil }