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
}
// TradingHours represents an array of (StartTime,EndTime) pairs, one for each day of the week.
// The array is 0 indexed, with 0 being Sunday and 6 being Saturday and 7 being public holidays.
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")
}