package schedule import ( "crypto/sha256" "encoding/binary" "fmt" "strings" "time" ) const ( TimeModeFixed = "fixed" TimeModeRandomWindow = "random_window" maxRandomRunSearchDays = 370 ) type RandomPolicy struct { Days []time.Weekday TimeMode string WindowStart time.Duration WindowEnd time.Duration Seed int64 } func ParseDayKey(value string) (time.Weekday, bool) { switch strings.ToLower(strings.TrimSpace(value)) { case "sun", "sunday", "0", "7": return time.Sunday, true case "mon", "monday", "1": return time.Monday, true case "tue", "tuesday", "2": return time.Tuesday, true case "wed", "wednesday", "3": return time.Wednesday, true case "thu", "thursday", "4": return time.Thursday, true case "fri", "friday", "5": return time.Friday, true case "sat", "saturday", "6": return time.Saturday, true default: return time.Sunday, false } } func DayKey(day time.Weekday) string { switch day { case time.Sunday: return "sun" case time.Monday: return "mon" case time.Tuesday: return "tue" case time.Wednesday: return "wed" case time.Thursday: return "thu" case time.Friday: return "fri" case time.Saturday: return "sat" default: return "" } } func ParseClockDuration(value string) (time.Duration, error) { trimmed := strings.TrimSpace(value) if trimmed == "" { return 0, fmt.Errorf("time value is required") } parsed, err := time.Parse("15:04:05", trimmed) if err != nil { parsed, err = time.Parse("15:04", trimmed) } if err != nil { return 0, fmt.Errorf("parse time %q: %w", trimmed, err) } return time.Duration(parsed.Hour())*time.Hour + time.Duration(parsed.Minute())*time.Minute + time.Duration(parsed.Second())*time.Second, nil } func FormatClockDuration(value time.Duration) string { if value < 0 { value = 0 } value = value.Truncate(time.Second) hour := int(value / time.Hour) value -= time.Duration(hour) * time.Hour minute := int(value / time.Minute) value -= time.Duration(minute) * time.Minute second := int(value / time.Second) return fmt.Sprintf("%02d:%02d:%02d", hour, minute, second) } func ComputeNextRandomRun(policy RandomPolicy, startAt, endAt *time.Time, now time.Time) (*time.Time, error) { return computeNextRandomRun(policy, startAt, endAt, now, maxRandomRunSearchDays) } func computeNextRandomRun(policy RandomPolicy, startAt, endAt *time.Time, now time.Time, maxSearchDays int) (*time.Time, error) { days := normalizePolicyDays(policy.Days) if len(days) == 0 { return nil, fmt.Errorf("schedule days are required") } if strings.TrimSpace(policy.TimeMode) == "" { policy.TimeMode = TimeModeRandomWindow } if policy.TimeMode != TimeModeRandomWindow { return nil, fmt.Errorf("unsupported random policy time mode: %s", policy.TimeMode) } if policy.WindowEnd <= policy.WindowStart { return nil, fmt.Errorf("random window end must be after start") } notBefore := now.In(time.Local) if startAt != nil { start := startAt.In(time.Local) if start.After(notBefore) { notBefore = start } } allowed := make(map[time.Weekday]struct{}, len(days)) for _, day := range days { allowed[day] = struct{}{} } baseDate := time.Date(notBefore.Year(), notBefore.Month(), notBefore.Day(), 0, 0, 0, 0, notBefore.Location()) for offset := 0; offset <= maxSearchDays; offset++ { dayStart := baseDate.AddDate(0, 0, offset) if _, ok := allowed[dayStart.Weekday()]; !ok { continue } runAt := dayStart.Add(policy.WindowStart + stableWindowOffset(policy.Seed, dayStart, policy.WindowEnd-policy.WindowStart)) if runAt.Before(notBefore) || runAt.Equal(notBefore) { continue } if endAt != nil && runAt.After(endAt.In(time.Local)) { return nil, nil } return &runAt, nil } return nil, nil } func normalizePolicyDays(days []time.Weekday) []time.Weekday { seen := make(map[time.Weekday]struct{}, len(days)) result := make([]time.Weekday, 0, len(days)) for _, day := range days { if day < time.Sunday || day > time.Saturday { continue } if _, ok := seen[day]; ok { continue } seen[day] = struct{}{} result = append(result, day) } return result } func stableWindowOffset(seed int64, day time.Time, width time.Duration) time.Duration { widthSeconds := int64(width / time.Second) if widthSeconds <= 0 { return 0 } var buf [16]byte binary.BigEndian.PutUint64(buf[:8], uint64(seed)) binary.BigEndian.PutUint64(buf[8:], uint64(day.Year()*10000+int(day.Month())*100+day.Day())) sum := sha256.Sum256(buf[:]) value := binary.BigEndian.Uint64(sum[:8]) return time.Duration(int64(value%uint64(widthSeconds))) * time.Second }