159 lines
5.2 KiB
Go
159 lines
5.2 KiB
Go
package callwindow
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// WeeklySchedule is a Shanghai-local, half-open weekly timetable. A period
|
|
// crossing midnight must be represented by two windows on adjacent days.
|
|
type WeeklySchedule struct {
|
|
TimeZone string `json:"time_zone"`
|
|
WeeklyWindows map[string][]Window `json:"weekly_windows"`
|
|
}
|
|
|
|
type Window struct {
|
|
Start string `json:"start"`
|
|
End string `json:"end"`
|
|
}
|
|
|
|
type TaskSchedule struct {
|
|
WeeklySchedule
|
|
StartsAt string `json:"starts_at"`
|
|
EndsAt string `json:"ends_at"`
|
|
ExcludedDates []string `json:"excluded_dates"`
|
|
}
|
|
|
|
var weekdays = [...]string{"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"}
|
|
|
|
var ErrWindowClosed = errors.New("outside the allowed calling window")
|
|
|
|
// Evaluate returns the earliest time at which the task/line intersection
|
|
// closes. A rejected command must not wait for the next window or choose a
|
|
// different line after selection.
|
|
func Evaluate(task TaskSchedule, line WeeklySchedule, at time.Time) (time.Time, error) {
|
|
loc, err := time.LoadLocation("Asia/Shanghai")
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("load outbound time zone: %w", err)
|
|
}
|
|
if err := task.WeeklySchedule.validate(); err != nil {
|
|
return time.Time{}, fmt.Errorf("task schedule: %w", err)
|
|
}
|
|
if err := line.validate(); err != nil {
|
|
return time.Time{}, fmt.Errorf("line schedule: %w", err)
|
|
}
|
|
var end time.Time
|
|
if task.StartsAt != "" {
|
|
start, err := time.Parse(time.RFC3339, task.StartsAt)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("parse task starts_at: %w", err)
|
|
}
|
|
if at.Before(start) {
|
|
return time.Time{}, fmt.Errorf("%w: task is before its validity period", ErrWindowClosed)
|
|
}
|
|
if task.EndsAt != "" {
|
|
end, err = time.Parse(time.RFC3339, task.EndsAt)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("parse task ends_at: %w", err)
|
|
}
|
|
if !start.Before(end) {
|
|
return time.Time{}, errors.New("task schedule starts_at must precede ends_at")
|
|
}
|
|
}
|
|
} else if task.EndsAt != "" {
|
|
var err error
|
|
end, err = time.Parse(time.RFC3339, task.EndsAt)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("parse task ends_at: %w", err)
|
|
}
|
|
}
|
|
if !end.IsZero() && !at.Before(end) {
|
|
return time.Time{}, fmt.Errorf("%w: task is past its validity period", ErrWindowClosed)
|
|
}
|
|
seen := make(map[string]struct{}, len(task.ExcludedDates))
|
|
for _, date := range task.ExcludedDates {
|
|
parsed, err := time.Parse("2006-01-02", date)
|
|
if err != nil || parsed.Format("2006-01-02") != date {
|
|
return time.Time{}, fmt.Errorf("invalid excluded date %q", date)
|
|
}
|
|
if _, duplicate := seen[date]; duplicate {
|
|
return time.Time{}, fmt.Errorf("duplicate excluded date %q", date)
|
|
}
|
|
seen[date] = struct{}{}
|
|
}
|
|
if _, excluded := seen[at.In(loc).Format("2006-01-02")]; excluded {
|
|
return time.Time{}, fmt.Errorf("%w: task excludes the Shanghai calendar date", ErrWindowClosed)
|
|
}
|
|
taskEnd, err := task.WeeklySchedule.endAt(at, loc)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("task schedule: %w", err)
|
|
}
|
|
lineEnd, err := line.endAt(at, loc)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("line schedule: %w", err)
|
|
}
|
|
if lineEnd.Before(taskEnd) {
|
|
taskEnd = lineEnd
|
|
}
|
|
if !end.IsZero() && end.Before(taskEnd) {
|
|
taskEnd = end
|
|
}
|
|
return taskEnd, nil
|
|
}
|
|
|
|
func (schedule WeeklySchedule) validate() error {
|
|
if schedule.TimeZone != "Asia/Shanghai" || len(schedule.WeeklyWindows) != len(weekdays) {
|
|
return errors.New("schedule must define all seven weekdays in Asia/Shanghai")
|
|
}
|
|
for _, name := range weekdays {
|
|
windows, exists := schedule.WeeklyWindows[name]
|
|
if !exists {
|
|
return fmt.Errorf("missing weekday %s", name)
|
|
}
|
|
previousEnd := 0
|
|
for _, window := range windows {
|
|
start, err := minuteOfDay(window.Start, false)
|
|
if err != nil {
|
|
return fmt.Errorf("%s start: %w", name, err)
|
|
}
|
|
end, err := minuteOfDay(window.End, true)
|
|
if err != nil {
|
|
return fmt.Errorf("%s end: %w", name, err)
|
|
}
|
|
if start >= end || start < previousEnd {
|
|
return fmt.Errorf("%s windows must be ordered, disjoint, and split at midnight", name)
|
|
}
|
|
previousEnd = end
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func minuteOfDay(clock string, allowEndOfDay bool) (int, error) {
|
|
if len(clock) != 5 || clock[2] != ':' || clock[0] < '0' || clock[0] > '9' || clock[1] < '0' || clock[1] > '9' || clock[3] < '0' || clock[3] > '9' || clock[4] < '0' || clock[4] > '9' {
|
|
return 0, fmt.Errorf("invalid clock time %q", clock)
|
|
}
|
|
hours := int(clock[0]-'0')*10 + int(clock[1]-'0')
|
|
minutes := int(clock[3]-'0')*10 + int(clock[4]-'0')
|
|
if minutes > 59 || hours > 24 || hours == 24 && (minutes != 0 || !allowEndOfDay) {
|
|
return 0, fmt.Errorf("invalid clock time %q", clock)
|
|
}
|
|
return hours*60 + minutes, nil
|
|
}
|
|
|
|
func (schedule WeeklySchedule) endAt(at time.Time, loc *time.Location) (time.Time, error) {
|
|
local := at.In(loc)
|
|
windows := schedule.WeeklyWindows[weekdays[local.Weekday()]]
|
|
minute := local.Hour()*60 + local.Minute()
|
|
for _, window := range windows {
|
|
start, _ := minuteOfDay(window.Start, false) // validated above
|
|
end, _ := minuteOfDay(window.End, true)
|
|
if minute >= start && minute < end {
|
|
midnight := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, loc)
|
|
return midnight.Add(time.Duration(end) * time.Minute), nil
|
|
}
|
|
}
|
|
return time.Time{}, ErrWindowClosed
|
|
}
|