package date_utils import ( "fmt" "reflect" "strconv" "strings" "time" "github.com/jinzhu/now" "github.com/araddon/dateparse" "gitlab.bob.co.za/bob-public-utils/bobgroup-go-utils/errors" ) const TimeZoneString = "Africa/Johannesburg" var currentLocation *time.Location func DateLayoutYearMonthDayTimeT() string { layout := "2006-01-02T15:04:05" return layout } func DateLayoutYearMonthDayTimeTZ() string { layout := "2006-01-02T15:04:05Z" return layout } func DateLayoutYearMonthDayTimeMillisecondTZ() string { layout := "2006-01-02T15:04:05.000Z" return layout } func DateLayoutDB() string { return "2006-01-02 15:04:05.000000-07" } func DateLayoutYearMonthDayTimeTimezone() string { layout := "2006-01-02 15:04:05-07:00" return layout } func DateLayoutForDB() string { layout := "2006-01-02 15:04:05-07" return layout } func DateLayoutYearMonthDayTime() string { layout := "2006-01-02 15:04:05" return layout } func DateLayoutFilenameSafe() string { layout := "02-Jan-2006-15h04" return layout } func DateLayoutYearMonthDay() string { layout := "2006-01-02" return layout } func DateLayoutTime() string { layout := "15:04:05" return layout } func DateLayoutHumanReadable() string { layout := "02 Jan 2006" return layout } func DateLayoutHumanReadableWithTime() string { layout := "02 Jan 2006 15:04" return layout } func DateLayoutTrimmed() string { layout := "20060102150405" return layout } func DateDBFormattedString(date time.Time) string { return date.Format(DateLayoutDB()) } func DateDBFormattedStringDateOnly(date time.Time) string { return date.Format(DateLayoutYearMonthDay()) } func CurrentLocation() *time.Location { if currentLocation == nil { currentLocation, _ = time.LoadLocation(TimeZoneString) } return currentLocation } func DateLocal(date *time.Time) { if date == nil { return } *date = (*date).In(CurrentLocation()) } func CurrentDate() time.Time { currentDate := time.Now().In(CurrentLocation()) return currentDate } func TodayStart() string { currentTime := CurrentDate() return DateDBFormattedString(now.With(currentTime).BeginningOfDay()) } func TodayEnd() string { currentTime := CurrentDate() return DateDBFormattedString(now.With(currentTime).EndOfDay()) } func StartOfDay(date time.Time) time.Time { day := now.With(date.In(CurrentLocation())).BeginningOfDay() return day } func EndOfDay(date time.Time) time.Time { // Subtract one second from the start of the next day so that we have 23:59:59 instead of 23:59:58.999999 day := now.With(date.AddDate(0, 0, 1).In(CurrentLocation())).BeginningOfDay().Add(-time.Second) return day } // BeginningOfNextDay is useful for specifying intervals where the beginning of the next day is excluded e.g. date < beginningOfNextDay func BeginningOfNextDay(date time.Time) time.Time { day := now.With(date.AddDate(0, 0, 1).In(CurrentLocation())).BeginningOfDay() return day } func DateEqual(date1, date2 time.Time) bool { y1, m1, d1 := date1.Date() y2, m2, d2 := date2.Date() return y1 == y2 && m1 == m2 && d1 == d2 } // TimeBefore determines whether a (string format HH:mm) is earlier than b (string format HH:mm) func TimeBefore(a string, b string) bool { if len(a) < 5 || len(b) < 5 { return false // can't detemrine before/after } hoursA, _ := strconv.Atoi(a[0:2]) hoursB, _ := strconv.Atoi(b[0:2]) minA, _ := strconv.Atoi(a[3:5]) minB, _ := strconv.Atoi(b[3:5]) if hoursA == hoursB { return minA < minB } return hoursA < hoursB } // DatePtrToString converts a time.Time pointer to a string in the format "2006-01-02 15:04:05". func DatePtrToString(date *time.Time) string { if date == nil { return "" } return date.In(CurrentLocation()).Format(DateLayoutYearMonthDayTime()) } // DatePtrToTimeString converts a time.Time pointer to a string in the format "15:04". If the pointer is nil, it returns "--:--". func DatePtrToTimeString(date *time.Time) string { if date != nil { // Convert to local time DateLocal(date) // Return formatted as time only return date.Format("15:04") } return "--:--" } // ConvertToNoDateTimeString - Converts a PSQL Time type to Go Time type func ConvertToNoDateTimeString(timeString *string) (*string, error) { parsedTime, err := time.Parse("15:04:05", *timeString) if err != nil { return nil, err } formattedTime := parsedTime.Format("15:04") return &formattedTime, nil } // ParseTimeString attempts to parse the string as the default date-time format, or as a date only format func ParseTimeString(timeString string) (time.Time, error) { // Try using the defined formats in date_utils parsedTime, err := time.Parse(DateLayoutYearMonthDayTimeMillisecondTZ(), timeString) if err != nil { parsedTime, err = time.Parse(DateLayoutYearMonthDayTimeT(), timeString) if err != nil { parsedTime, err = time.Parse(DateLayoutYearMonthDayTimeTZ(), timeString) if err != nil { parsedTime, err = time.Parse(DateLayoutYearMonthDay(), timeString) if err != nil { parsedTime, err = time.Parse(DateLayoutYearMonthDayTime(), timeString) if err != nil { parsedTime, err = time.Parse(DateLayoutYearMonthDayTimeTimezone(), timeString) if err != nil { parsedTime, err = time.Parse(DateLayoutDB(), timeString) } } } } } } if err != nil { // Try using other date formats from dateparse library parsedTime, err = dateparse.ParseAny(timeString) } return parsedTime, err } func FormatTimestampsWithTimeZoneOnStructRecursively(object any, location *time.Location) error { var objectValue reflect.Value objectValue = reflect.ValueOf(object) for objectValue.Kind() == reflect.Pointer || objectValue.Kind() == reflect.Interface { objectValue = objectValue.Elem() } err := formatTimestampsWithTimeZoneOnStruct(objectValue, location) return err } func formatTimestampsWithTimeZoneOnStruct(structValue reflect.Value, location *time.Location) error { numF := structValue.NumField() for i := 0; i < numF; i++ { fieldValue := structValue.Field(i) fieldType := fieldValue.Type() fieldKind := fieldValue.Type().Kind() timeType := reflect.TypeOf(time.Time{}) if fieldType == timeType && !fieldValue.IsZero() { fieldValue.Set(reflect.ValueOf(fieldValue.Interface().(time.Time).In(location))) continue } timePointerType := reflect.TypeOf(&time.Time{}) if fieldType == timePointerType && !fieldValue.IsNil() { timeInLocation := fieldValue.Interface().(*time.Time).In(location) timeInLocationPointer := reflect.ValueOf(timeInLocation) fieldPointer := fieldValue.Elem() fieldPointer.Set(timeInLocationPointer) continue } if fieldKind == reflect.Slice { // Loop over the slice items err := formatTimestampsWithTimeZoneInSlice(fieldValue, location) if err != nil { return err } continue } else if fieldKind == reflect.Struct { err := formatTimestampsWithTimeZoneOnStruct(fieldValue, location) if err != nil { return err } continue } else if fieldKind == reflect.Interface || fieldKind == reflect.Pointer { var objectValue reflect.Value objectValue = fieldValue.Elem() if objectValue.Kind() == reflect.Slice { err := formatTimestampsWithTimeZoneInSlice(objectValue, location) if err != nil { return err } } else if objectValue.Kind() == reflect.Struct { err := formatTimestampsWithTimeZoneOnStruct(objectValue, location) if err != nil { return err } } continue } } return nil } func formatTimestampsWithTimeZoneInSlice(fieldValue reflect.Value, location *time.Location) error { for j := 0; j < fieldValue.Len(); j++ { sliceItem := fieldValue.Index(j) if sliceItem.IsValid() { var sliceItemValue reflect.Value if sliceItem.Kind() == reflect.Ptr && sliceItem.IsValid() { // Dereference the pointer sliceItemValue = sliceItem.Elem() } else { sliceItemValue = sliceItem } if sliceItemValue.IsValid() { sliceItemKind := sliceItemValue.Kind() sliceItemType := sliceItemValue.Type() // Check whether we have a slice of time.Time, and set the location if we do. if sliceItemType == reflect.TypeOf(time.Time{}) { sliceItemValue.Set(reflect.ValueOf(sliceItemValue.Interface().(time.Time).In(location))) continue } if sliceItemKind == reflect.Struct { err := formatTimestampsWithTimeZoneOnStruct(sliceItemValue, location) if err != nil { return err } } } } } return nil } type TradingHours struct { Monday TradingHoursDay `json:"monday"` Tuesday TradingHoursDay `json:"tuesday"` Wednesday TradingHoursDay `json:"wednesday"` Thursday TradingHoursDay `json:"thursday"` Friday TradingHoursDay `json:"friday"` Saturday TradingHoursDay `json:"saturday"` Sunday TradingHoursDay `json:"sunday"` Holidays TradingHoursDay `json:"holidays"` } func (t TradingHours) Validate() error { if err := t.Monday.Validate(); err != nil { return errors.Wrapf(err, "Monday failed validation: %v", err.Error()) } if err := t.Tuesday.Validate(); err != nil { return errors.Wrapf(err, "Tuesday failed validation: %v", err.Error()) } if err := t.Wednesday.Validate(); err != nil { return errors.Wrapf(err, "Wednesday failed validation: %v", err.Error()) } if err := t.Thursday.Validate(); err != nil { return errors.Wrapf(err, "Thursday failed validation: %v", err.Error()) } if err := t.Friday.Validate(); err != nil { return errors.Wrapf(err, "Friday failed validation: %v", err.Error()) } if err := t.Saturday.Validate(); err != nil { return errors.Wrapf(err, "Saturday failed validation: %v", err.Error()) } if err := t.Sunday.Validate(); err != nil { return errors.Wrapf(err, "Sunday failed validation: %v", err.Error()) } if err := t.Holidays.Validate(); err != nil { return errors.Wrapf(err, "Holidays failed validation: %v", err.Error()) } return nil } func (day TradingHoursDay) Validate() error { if day.StartTime == "" || day.EndTime == "" { // Allow empty trading hours for a day to represent closed return nil } if !TimeBefore(day.StartTime, day.EndTime) { return errors.Error("start time must be before end time") } if len(day.StartTime) != 5 || len(day.EndTime) != 5 { return errors.Error("time must be in the format HH:MM") } startHourMinSlice := strings.Split(day.StartTime, ":") if len(startHourMinSlice) != 2 { return errors.Error("time must be in the format HH:MM") } startHour, startMin := startHourMinSlice[0], startHourMinSlice[1] startHourInt, err := strconv.Atoi(startHour) if err != nil || startHourInt < 0 || startHourInt > 23 { return errors.Error("start hour must be between 0 and 23") } startMinInt, err := strconv.Atoi(startMin) if err != nil || !(startMinInt == 0 || startMinInt == 30) { return errors.Error("start minute must be 0 or 30") } endHourMinSlice := strings.Split(day.EndTime, ":") if len(endHourMinSlice) != 2 { return errors.Error("time must be in the format HH:MM") } endHour, endMin := endHourMinSlice[0], endHourMinSlice[1] endHourInt, err := strconv.Atoi(endHour) if err != nil || endHourInt < 0 || endHourInt > 23 { return errors.Error("end hour must be between 0 and 23") } endMinInt, err := strconv.Atoi(endMin) if err != nil || !(endMinInt == 0 || endMinInt == 30 || endMinInt == 59) { return errors.Error("end minute must be 0, 30 or 59") } return nil } func (t TradingHours) String() string { var ( result strings.Builder weekdays = []TradingHoursDay{t.Monday, t.Tuesday, t.Wednesday, t.Thursday, t.Friday, t.Saturday, t.Sunday} rangeStartWeekday = time.Monday ) for i := range weekdays { currentTradingHoursDay := weekdays[i] // Here the index is wrapped so we will never go out of bounds. The next day after Sunday is Monday at index 0 because (6+1)%7 = 0 nextTradingHoursDay := weekdays[(i+1)%len(weekdays)] // Here we use the same value, as above, but for a different purpose of being compatible with the time package // This is because the time package uses 0 for Sunday and 1 for Monday. This means when we get to index 6 (Sunday), // to get the time.Weekday value for Sunday we need to use 0, and as before (6+1)%7 = 0 currentWeekday := time.Weekday((i + 1) % len(weekdays)) nextWeekday := time.Weekday((i + 2) % len(weekdays)) // Determine range description var rangeDescription string if currentWeekday == rangeStartWeekday { rangeDescription = currentWeekday.String()[:3] } else { rangeDescription = fmt.Sprintf("%s – %s", rangeStartWeekday.String()[:3], currentWeekday.String()[:3]) } // If the next day has the same times and we're not at the last element, we continue the current range if nextTradingHoursDay.StartTime == currentTradingHoursDay.StartTime && nextTradingHoursDay.EndTime == currentTradingHoursDay.EndTime && i < len(weekdays)-1 { continue } result.WriteString(fmt.Sprintf("%s: %s, ", rangeDescription, currentTradingHoursDay.String())) rangeStartWeekday = nextWeekday } // Public holidays result.WriteString(fmt.Sprintf("Public holidays: %s", t.Holidays.String())) return result.String() } type TradingHoursDay struct { StartTime string `json:"start_time"` EndTime string `json:"end_time"` } func (day TradingHoursDay) String() string { if day.StartTime == "" || day.EndTime == "" { return "Closed" } startTime, err := time.Parse("15:04", day.StartTime) if err != nil { return "" } endTime, err := time.Parse("15:04", day.EndTime) if err != nil { return "" } if day.StartTime == "00:00" && day.EndTime == "23:59" { return "All day" } return startTime.Format("3:04pm") + " – " + endTime.Format("3:04pm") }