// This package provides functionality for reading, writing, and converting time // Time is confusing and sucks because of timezones, format strings, daylight savings, etc. // In reality we only care about the UTC representation of a datetime. // // Ex: // If we say "something happened *now*", `now` is an absolute measurement. // You don't to care about format strings, daylight savings, or timezones to store it. // // For this reason, we store and manipulate ALL date/time values as UTC, and only // convert to the approprate timezone when presenting the output to the user. // The offset and format we use to display to the user can be provided // !IMPORTANTE! // IF ANY TIMEZONE CONVERSION FAILS DUE TO A MALFORMED TIMEZONE STRING, // THE OUTPUT WILL DEFAULT TO UTC. IT IS THE PROGRAMMER'S RESPONSIBILITY // TO PREVENT USERS FROM INPUTTING A RAW TIMEZONE STRING. // // INSTEAD, USE A STRUCTURED SELECT FORM INPUT THAT CAN ONLY RETURN // A VALID TIMEZONE STRING, OR CONVERT IT FROM ANOTHER INPUT SUCH AS A 'STATE' ABBREVIATION. package chrono import ( "errors" "fmt" "strconv" "strings" "time" _ "time/tzdata" ) // In addition to the constants provided by the time package (https://pkg.go.dev/time#Layout), // here are some more useful formatting constants. const ( MDYDateTime12Hour = "2006/01/02 03:04:05 PM" MDYDateTime12HourMinute = "2006/01/02 03:04 PM" DayMonDYDateTime12HourMinute = "Mon, Jan 02 2006 3:04 PM" MDYDateOnly = "01/02/2006" MDYDateOnlyShort = "01/02/06" HTMLDateTime = "2006-01-02T15:04" TimeOnly12Hour = "03:04 PM" MonthYear = "January 2006" ) // @TODO Possibly refactor below functions to wrap around this one (although maybe there are too many func calls) func FormatWithTz(utcTime time.Time, timezone string, format string) string { loc, locErr := time.LoadLocation(timezone) if locErr != nil { loc, _ = time.LoadLocation("UTC") } return utcTime.In(loc).Format(format) } // Take in a datetime-local from HTML form. The timezone is set on the users identity. // Returns time.Time struct in **UTC** func HTMLDatetimeLocalToTime(datetimelocal string, timezone string) time.Time { loc, locErr := time.LoadLocation(timezone) if locErr != nil { loc, _ = time.LoadLocation("UTC") } t, _ := time.ParseInLocation(HTMLDateTime, datetimelocal, loc) return t.UTC() } // Take in a date from an HTML form. The timezone of this input depends on what the user set on their identity. // Returns time.Time struct in **UTC** func HTMLDateToTime(date string, timezone string) time.Time { loc, locErr := time.LoadLocation(timezone) if locErr != nil { loc, _ = time.LoadLocation("UTC") } t, _ := time.ParseInLocation(time.DateOnly, date, loc) return t.UTC() } // Convert a time with timezone 'UTC' to the input timezone // and output as a formatted string. func TimeToString(utcTime time.Time, timezone string) string { loc, locErr := time.LoadLocation(timezone) if locErr != nil { loc, _ = time.LoadLocation("UTC") } return utcTime.In(loc).Format(TimeOnly12Hour) } // Convert a datetime with timezone 'UTC' to the input timezone // and output as a formatted string. func DateTimeToString(utcTime time.Time, timezone string) string { loc, locErr := time.LoadLocation(timezone) if locErr != nil { loc, _ = time.LoadLocation("UTC") } return utcTime.In(loc).Format(MDYDateTime12HourMinute) } // Convert a date with timezone 'UTC' to the input timezone // and output as a formatted string. func DateToString(utcTime time.Time, timezone string) string { loc, locErr := time.LoadLocation(timezone) if locErr != nil { loc, _ = time.LoadLocation("UTC") } return utcTime.In(loc).Format(MDYDateOnlyShort) } func TimeSinceToString(utcTime time.Time, timezone string) string { result := TimeElapsedToString(utcTime, time.Now(), timezone) if result == "Never" || result == "Just now" { return result } return result + " ago" } // Convert datetimes with timezone 'UTC' to the input timezone // and output the time that has elapsed since the two input datetimes. func TimeElapsedToString(utcTimeStart time.Time, utcTimeEnd time.Time, timezone string) string { if utcTimeStart.IsZero() || utcTimeEnd.IsZero() { return "Never" } loc, locErr := time.LoadLocation(timezone) if locErr != nil { loc, _ = time.LoadLocation("UTC") } diff := utcTimeEnd.Sub(utcTimeStart.In(loc)) seconds := int(diff.Seconds()) minutes := int(diff.Minutes()) hours := int(diff.Hours()) days := int(diff.Hours() / 24) weeks := days / 7 months := days / 30 years := days / 365 if years > 0 { if years == 1 { return "1 year" } return fmt.Sprintf("%d years", years) } else if months > 0 { if months == 1 { return "1 month" } return fmt.Sprintf("%d months", months) } else if weeks > 0 { if weeks == 1 { return "1 week" } return fmt.Sprintf("%d weeks", weeks) } else if days > 0 { if days == 1 { return "1 day" } return fmt.Sprintf("%d days", days) } else if hours > 0 { if hours == 1 { return "1 hour" } return fmt.Sprintf("%d hours", hours) } else if minutes > 0 { if minutes == 1 { return "1 minute" } return fmt.Sprintf("%d minutes", minutes) } else { if seconds <= 1 { return "Just now" } return fmt.Sprintf("%d seconds", seconds) } } // Convert a date with timezone 'UTC' to input timezone // and output as formatting string, suitable for use in HTML forms func DateToHTMLString(utcTime time.Time, timezone string) string { loc, locErr := time.LoadLocation(timezone) if locErr != nil { loc, _ = time.LoadLocation("UTC") } return utcTime.In(loc).Format(time.DateOnly) } func DatetimeToHTMLString(utcTime time.Time, timezone string) string { loc, locErr := time.LoadLocation(timezone) if locErr != nil { loc, _ = time.LoadLocation("UTC") } return utcTime.In(loc).Format(HTMLDateTime) } var stateTimezones = map[string]string{ "AL": "America/Chicago", // Alabama "AK": "America/Anchorage", // Alaska (main timezone) "AZ": "America/Phoenix", // Arizona "AR": "America/Chicago", // Arkansas "CA": "America/Los_Angeles", // California "CO": "America/Denver", // Colorado "CT": "America/New_York", // Connecticut "DE": "America/New_York", // Delaware "FL": "America/New_York", // Florida (most of state) "GA": "America/New_York", // Georgia "HI": "Pacific/Honolulu", // Hawaii "ID": "America/Boise", // Idaho (most of state) "IL": "America/Chicago", // Illinois "IN": "America/New_York", // Indiana (most of state) "IA": "America/Chicago", // Iowa "KS": "America/Chicago", // Kansas (most of state) "KY": "America/New_York", // Kentucky (most of state) "LA": "America/Chicago", // Louisiana "ME": "America/New_York", // Maine "MD": "America/New_York", // Maryland "MA": "America/New_York", // Massachusetts "MI": "America/Detroit", // Michigan (most of state) "MN": "America/Chicago", // Minnesota "MS": "America/Chicago", // Mississippi "MO": "America/Chicago", // Missouri "MT": "America/Denver", // Montana "NE": "America/Chicago", // Nebraska (most of state) "NV": "America/Los_Angeles", // Nevada (most of state) "NH": "America/New_York", // New Hampshire "NJ": "America/New_York", // New Jersey "NM": "America/Denver", // New Mexico "NY": "America/New_York", // New York "NC": "America/New_York", // North Carolina "ND": "America/Chicago", // North Dakota (most of state) "OH": "America/New_York", // Ohio "OK": "America/Chicago", // Oklahoma "OR": "America/Los_Angeles", // Oregon (most of state) "PA": "America/New_York", // Pennsylvania "RI": "America/New_York", // Rhode Island "SC": "America/New_York", // South Carolina "SD": "America/Chicago", // South Dakota (most of state) "TN": "America/Chicago", // Tennessee (most of state) "TX": "America/Chicago", // Texas (most of state) "UT": "America/Denver", // Utah "VT": "America/New_York", // Vermont "VA": "America/New_York", // Virginia "WA": "America/Los_Angeles", // Washington "WV": "America/New_York", // West Virginia "WI": "America/Chicago", // Wisconsin "WY": "America/Denver", // Wyoming } // Convert 2 character US state abbreviation to valid timezone func StateCodeToTimezone(stateCode string) (string, error) { // Convert input to uppercase to handle case variations stateCode = strings.ToUpper(stateCode) // Look up timezone timezone, exists := stateTimezones[stateCode] if !exists { return "", fmt.Errorf("invalid or unknown state code: %s", stateCode) } return timezone, nil } // ConvertTimeToMilliseconds converts a time string in HH:MM:SS.MS format to milliseconds. // Returns an error if the input format is invalid. func ConvertTimeStringToMilliseconds(timeStr string) (int64, error) { // Split on colon first mainParts := strings.Split(timeStr, ":") if len(mainParts) != 3 { return 0, errors.New("invalid time format, expected HH:MM:SS.MS") } // Split the last part on decimal point for seconds and milliseconds secMsParts := strings.Split(mainParts[2], ".") if len(secMsParts) != 2 { return 0, errors.New("invalid time format, expected decimal point for milliseconds") } hours, err := strconv.ParseInt(mainParts[0], 10, 64) if err != nil || hours < 0 { return 0, errors.New("invalid hours value") } minutes, err := strconv.ParseInt(mainParts[1], 10, 64) if err != nil || minutes < 0 || minutes > 59 { return 0, errors.New("invalid minutes value") } seconds, err := strconv.ParseInt(secMsParts[0], 10, 64) if err != nil || seconds < 0 || seconds > 59 { return 0, errors.New("invalid seconds value") } // Pad milliseconds to ensure 3 digits (e.g., "7" becomes "700") msStr := secMsParts[1] if len(msStr) > 3 { return 0, errors.New("milliseconds must be 3 digits or less") } msStr = msStr + strings.Repeat("0", 3-len(msStr)) milliseconds, err := strconv.ParseInt(msStr, 10, 64) if err != nil || milliseconds < 0 || milliseconds > 999 { return 0, errors.New("invalid milliseconds value") } totalMs := hours*3600*1000 + minutes*60*1000 + seconds*1000 + milliseconds return totalMs, nil } // ConvertMillisecondsToTime converts milliseconds to a time string in HH:MM:SS.MS format. // Returns an error if the input is negative. func ConvertMillisecondsToTimeString(milliseconds int64) (string, error) { if milliseconds < 0 { return "", errors.New("milliseconds cannot be negative") } hours := milliseconds / (3600 * 1000) milliseconds %= 3600 * 1000 minutes := milliseconds / (60 * 1000) milliseconds %= 60 * 1000 seconds := milliseconds / 1000 milliseconds %= 1000 return fmt.Sprintf("%02d:%02d:%02d.%03d", hours, minutes, seconds, milliseconds), nil } func ConvertMillisecondsToTimeStringNoErr(milliseconds int64) string { out, _ := ConvertMillisecondsToTimeString(milliseconds) return out } // formatTimeSince returns a human-readable "time since" string func TimeSince(t time.Time) string { if t.IsZero() { return "Never" } duration := time.Since(t) if duration < time.Minute { return "Just now" } if duration < time.Hour { minutes := int(duration.Minutes()) if minutes == 1 { return "1 minute ago" } return makeReadableDuration(minutes, "minute") } if duration < 24*time.Hour { hours := int(duration.Hours()) if hours == 1 { return "1 hour ago" } return makeReadableDuration(hours, "hour") } if duration < 30*24*time.Hour { days := int(duration.Hours() / 24) if days == 1 { return "1 day ago" } return makeReadableDuration(days, "day") } if duration < 365*24*time.Hour { months := int(duration.Hours() / 24 / 30) if months == 1 { return "1 month ago" } return makeReadableDuration(months, "month") } years := int(duration.Hours() / 24 / 365) if years == 1 { return "1 year ago" } return makeReadableDuration(years, "year") } func makeReadableDuration(value int, unit string) string { if value == 1 { return "1 " + unit + " ago" } return fmt.Sprintf("%d %ss ago", value, unit) }