package util import ( "errors" "fmt" "math" "strings" "time" basedomain "wucher/internal/domain/base" "wucher/internal/shared/pkg/suncalc" ) var ( ErrShiftScheduleMissing = errors.New("base shift schedule not found") ErrShiftTwilightMissing = errors.New("base twilight time not available") ErrShiftCoordinatesInvalid = errors.New("base coordinates invalid") ) func ResolveShiftWindow(b *basedomain.Base, date time.Time) (string, string, error) { if b == nil || date.IsZero() { return "", "", ErrShiftScheduleMissing } shift := PickShiftForDate(b, date) loc := LocationFromUTCOffset(b.UTC) scheduleDay := calendarDate(date) scheduleDate := time.Date(scheduleDay.Year(), scheduleDay.Month(), scheduleDay.Day(), 12, 0, 0, 0, loc) if shift == nil { if shiftWindowNeedsCoordinates(b.DefaultStartTimeType, b.DefaultEndTimeType) && !ValidCoordinates(b.Latitude, b.Longitude) { return "", "", ErrShiftCoordinatesInvalid } return resolveBaseDefaultShiftWindow(b, scheduleDate, loc) } if shiftWindowNeedsCoordinates(shift.StartTimeType, shift.EndTimeType) && !ValidCoordinates(b.Latitude, b.Longitude) { return "", "", ErrShiftCoordinatesInvalid } start, err := resolveShiftClock(scheduleDate, loc, b, shift.StartTimeType, shift.ShiftStart, true) if err != nil { return "", "", err } end, err := resolveShiftClock(scheduleDate, loc, b, shift.EndTimeType, shift.ShiftEnd, false) if err != nil { return "", "", err } return start, end, nil } func shiftWindowNeedsCoordinates(startTimeType, endTimeType string) bool { return NormalizeShiftTimeType(startTimeType) != basedomain.ShiftTimeTypeFixed || NormalizeShiftTimeType(endTimeType) != basedomain.ShiftTimeTypeFixed } func resolveBaseDefaultShiftWindow(b *basedomain.Base, scheduleDate time.Time, loc *time.Location) (string, string, error) { start, err := resolveShiftClock(scheduleDate, loc, b, b.DefaultStartTimeType, b.DefaultShiftStart, true) if err != nil { return "", "", err } end, err := resolveShiftClock(scheduleDate, loc, b, b.DefaultEndTimeType, b.DefaultShiftEnd, false) if err != nil { return "", "", err } if start == "" || end == "" { return "", "", ErrShiftScheduleMissing } return start, end, nil } func PickShiftForDate(b *basedomain.Base, date time.Time) *basedomain.BaseOperationalShiftTime { if b == nil { return nil } day := calendarDate(date) for i := range b.OperationalShiftTimes { row := &b.OperationalShiftTimes[i] if row.DateStart == nil || row.DateEnd == nil { continue } start := calendarDate(*row.DateStart) end := calendarDate(*row.DateEnd) if (day.Equal(start) || day.After(start)) && (day.Equal(end) || day.Before(end.Add(24*time.Hour))) { return row } } return nil } func calendarDate(t time.Time) time.Time { return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC) } func DefaultShiftWindow(b *basedomain.Base) (string, string, error) { if b == nil { return "", "", ErrShiftScheduleMissing } start := NormalizeClockValue(b.DefaultShiftStart) end := NormalizeClockValue(b.DefaultShiftEnd) if start == "" || end == "" { return "", "", ErrShiftScheduleMissing } return start, end, nil } func resolveShiftClock(date time.Time, loc *time.Location, b *basedomain.Base, timeType, fixedValue string, morning bool) (string, error) { switch NormalizeShiftTimeType(timeType) { case basedomain.ShiftTimeTypeFixed: clock := NormalizeClockValue(fixedValue) if clock == "" { return "", ErrShiftScheduleMissing } return clock, nil case basedomain.ShiftTimeTypeBMCT: return resolveSunCalcClock(date, loc, b, true) case basedomain.ShiftTimeTypeECET: return resolveSunCalcClock(date, loc, b, false) default: return "", fmt.Errorf("invalid shift time type %q", timeType) } } func resolveSunCalcClock(date time.Time, loc *time.Location, b *basedomain.Base, morning bool) (string, error) { twilight, err := suncalc.GetCivilTwilight(date, b.Latitude, b.Longitude) if err != nil { return "", ErrShiftTwilightMissing } var resolved time.Time if morning { resolved = twilight.Dawn } else { resolved = twilight.Dusk } if resolved.IsZero() { return "", ErrShiftTwilightMissing } if loc == nil { loc = time.UTC } return resolved.In(loc).Format("15:04"), nil } func NormalizeClockValue(raw string) string { trimmed := strings.TrimSpace(raw) if trimmed == "" { return "" } if len(trimmed) == 5 { trimmed += ":00" } layouts := []string{ "15:04:05", "15:04", "2006-01-02 15:04:05", "2006-01-02 15:04", time.RFC3339, time.RFC3339Nano, } for _, layout := range layouts { parsed, err := time.Parse(layout, trimmed) if err == nil { return parsed.UTC().Format("15:04") } } return "" } func LocationFromUTCOffset(raw string) *time.Location { trimmed := strings.TrimSpace(raw) if trimmed == "" || trimmed == "0" { return time.UTC } if loc, err := time.LoadLocation(trimmed); err == nil { return loc } if offset, ok := parseUTCOffset(trimmed); ok { return time.FixedZone("UTC", offset) } return time.UTC } func parseUTCOffset(raw string) (int, bool) { trimmed := strings.TrimSpace(raw) if trimmed == "" { return 0, false } sign := 1 switch { case strings.HasPrefix(trimmed, "+"): trimmed = strings.TrimPrefix(trimmed, "+") case strings.HasPrefix(trimmed, "-"): sign = -1 trimmed = strings.TrimPrefix(trimmed, "-") } trimmed = strings.TrimPrefix(strings.ToUpper(trimmed), "UTC") trimmed = strings.TrimSpace(trimmed) switch { case strings.HasPrefix(trimmed, "+"): trimmed = strings.TrimPrefix(trimmed, "+") case strings.HasPrefix(trimmed, "-"): sign = -1 trimmed = strings.TrimPrefix(trimmed, "-") } if trimmed == "" { return 0, false } parts := strings.Split(trimmed, ":") hours := 0 minutes := 0 switch len(parts) { case 1: if _, err := fmt.Sscanf(parts[0], "%d", &hours); err != nil { return 0, false } case 2: if _, err := fmt.Sscanf(parts[0], "%d", &hours); err != nil { return 0, false } if _, err := fmt.Sscanf(parts[1], "%d", &minutes); err != nil { return 0, false } default: return 0, false } total := ((hours * 3600) + (minutes * 60)) * sign return total, true } func ValidCoordinates(lat, lon float64) bool { return !math.IsNaN(lat) && !math.IsNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180 } func NormalizeShiftTimeType(raw string) string { switch strings.ToUpper(strings.TrimSpace(raw)) { case "", basedomain.ShiftTimeTypeFixed: return basedomain.ShiftTimeTypeFixed case basedomain.ShiftTimeTypeBMCT: return basedomain.ShiftTimeTypeBMCT case basedomain.ShiftTimeTypeECET: return basedomain.ShiftTimeTypeECET default: return strings.ToUpper(strings.TrimSpace(raw)) } }