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 }