blob: e76192a8904ee252e2b2d4be893e997d1aea3b81 [file] [log] [blame]
package time_window
import (
"fmt"
"strconv"
"strings"
"time"
)
var (
// dayMap maps abbreviated day name to the index of the day in the week,
// as defined by the time package.
dayMap = map[string]time.Weekday{
"Su": time.Sunday,
"M": time.Monday,
"Tu": time.Tuesday,
"W": time.Wednesday,
"Th": time.Thursday,
"F": time.Friday,
"Sa": time.Saturday,
}
)
// Parse returns a TimeWindow instance based on the given string. Times are
// interpreted as GMT. The accepted format is as follows:
//
// FullWindowExpr = SingleDayWindowExpr(;SingleDayWindowExpr)*
// SingleDayWindowExpr = DayRangesExpr TimeExpr-TimeExpr
// DayRangesExpr = (*|DayRangeExpr(,DayRangeExpr)*)
// DayRangeExpr = DayExpr(-DayExpr)?
// DayExpr = (Su|M|Tu|W|Th|F|Sa)
// TimeExpr = \d\d:\d\d
//
// Examples:
// Day range: M-F 09:00-17:00
// Every day: * 00:00-23:59
// Multiple days, same time: Sa,M-W 08:00-09:00
// Multiple days, different times: Sa 08:00-09:00; M-W 12:00-03:00
// Wrap around to next day: M-F 22:00-02:00
//
func Parse(s string) (*TimeWindow, error) {
if s == "" {
// A nil TimeWindow always returns true from Test().
return nil, nil
}
dayWindows := []*dayWindow{}
split := strings.Split(strings.TrimSpace(s), ";")
for _, s := range split {
dw, err := parseDayWindows(s)
if err != nil {
return nil, err
}
dayWindows = append(dayWindows, dw...)
}
return &TimeWindow{
dayWindows: dayWindows,
}, nil
}
// dayTime represents a single time of day, specified using hours and minutes.
type dayTime struct {
hours int
minutes int
}
// parse a dayTime from a string formatted like: "02:34"
func parseDayTime(s string) (dayTime, error) {
var rv dayTime
split := strings.Split(strings.TrimSpace(s), ":")
if len(split) != 2 {
return rv, fmt.Errorf("Expected time format \"hh:mm\", not %q", s)
}
for _, comp := range split {
if len(comp) != 2 {
return rv, fmt.Errorf("Expected time format \"hh:mm\", not %q", s)
}
}
hours, err := strconv.Atoi(split[0])
if err != nil {
return rv, fmt.Errorf("Failed to parse %q as hours: %s", split[0], err)
}
if hours < 0 || hours >= 24 {
return rv, fmt.Errorf("Hours must be between 0-23, not %d", hours)
}
minutes, err := strconv.Atoi(split[1])
if err != nil {
return rv, fmt.Errorf("Failed to parse %q as minutes: %s", split[1], err)
}
if minutes < 0 || minutes >= 60 {
return rv, fmt.Errorf("Minutes must be between 0-59, not %d", minutes)
}
rv.hours = hours
rv.minutes = minutes
return rv, nil
}
// dayWindow represents a single window of time.
type dayWindow struct {
day time.Weekday
start dayTime
end dayTime
}
// test returns true iff the given time.Time occurs within the dayWindow.
func (w dayWindow) test(t time.Time) bool {
// Find the nearest start and end to this t.
start := time.Date(t.Year(), t.Month(), t.Day(), w.start.hours, w.start.minutes, 0, 0, time.UTC)
for start.Weekday() != w.day {
start = start.Add(-24 * time.Hour)
}
end := time.Date(start.Year(), start.Month(), start.Day(), w.end.hours, w.end.minutes, 0, 0, time.UTC)
if !start.After(t) && end.After(t) {
return true
}
start = start.Add(7 * 24 * time.Hour)
end = end.Add(7 * 24 * time.Hour)
return !start.After(t) && end.After(t)
}
// parse days and dayWindows from a string formatted like: "M-W,Th,Sa 02:34-03:45"
func parseDayWindows(s string) ([]*dayWindow, error) {
split := strings.SplitN(strings.TrimSpace(s), " ", 2)
if len(split) != 2 {
return nil, fmt.Errorf("Expected format \"D hh:mm\", not %q", s)
}
dayExpr := strings.TrimSpace(split[0])
timeExpr := strings.TrimSpace(split[1])
// Parse the starting and ending times.
timeSplit := strings.Split(timeExpr, "-")
if len(timeSplit) != 2 {
return nil, fmt.Errorf("Expected window format \"hh:mm-hh:mm\", not %q", split[1])
}
start, err := parseDayTime(timeSplit[0])
if err != nil {
return nil, err
}
end, err := parseDayTime(timeSplit[1])
if err != nil {
return nil, err
}
// If the end time is before the start time, the window rolls over to
// the next day.
now := time.Now()
startTs := time.Date(now.Year(), now.Month(), now.Day(), start.hours, start.minutes, 0, 0, time.UTC)
endTs := time.Date(now.Year(), now.Month(), now.Day(), end.hours, end.minutes, 0, 0, time.UTC)
if !endTs.After(startTs) {
end.hours += 24
}
// Parse the day(s).
// "*" means every day.
rv := []*dayWindow{}
if dayExpr == "*" {
for _, d := range dayMap {
rv = append(rv, &dayWindow{
day: d,
start: start,
end: end,
})
}
return rv, nil
}
// We support multiple day expressions.
daySplit := strings.Split(dayExpr, ",")
for _, dayExpr := range daySplit {
rangeSplit := strings.Split(dayExpr, "-")
if len(rangeSplit) == 1 {
day, ok := dayMap[dayExpr]
if !ok {
return nil, fmt.Errorf("Unknown day %q", dayExpr)
}
rv = append(rv, &dayWindow{
day: day,
start: start,
end: end,
})
} else if len(rangeSplit) == 2 {
startDay, ok := dayMap[rangeSplit[0]]
if !ok {
return nil, fmt.Errorf("Unknown day %q", rangeSplit[0])
}
endDay, ok := dayMap[rangeSplit[1]]
if !ok {
return nil, fmt.Errorf("Unknown day %q", rangeSplit[1])
}
if endDay < startDay {
endDay += 7
}
for i := startDay; i <= endDay; i++ {
day := time.Weekday(i % 7)
rv = append(rv, &dayWindow{
day: day,
start: start,
end: end,
})
}
} else {
return nil, fmt.Errorf("Invalid day expression: %q", dayExpr)
}
}
return rv, nil
}
// TimeWindow specifies a set of time windows on each day of the week in which
// a roller is allowed to upload rolls.
type TimeWindow struct {
dayWindows []*dayWindow
}
// Test returns true iff the given time.Time occurs within the TimeWindow.
func (w *TimeWindow) Test(t time.Time) bool {
if w == nil {
return true
}
t = t.UTC()
for _, dw := range w.dayWindows {
if dw.test(t) {
return true
}
}
return false
}