Files
kjol/chrono/chrono.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)
}