392 lines
12 KiB
Go
392 lines
12 KiB
Go
// 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)
|
|
}
|