File: ./doc.go
   1 /*
   2 # timeplus
   3 
   4 Functionality which should be part of the standard library's time package,
   5 but somehow isn't.
   6 
   7 # Constants
   8 
   9 Day
  10 Week
  11 NormalYear (365 days)
  12 JulianYear (365.25 days)
  13 LeapYear   (366 days)
  14 
  15 DateTimeFormat
  16 DateFormat
  17 TimeFormat
  18 
  19 # Functions
  20 
  21 func ParseYMD(s string) (year, month, day int, err error)
  22 
  23 ParseYMD gives you year, month, and day of the month from the string given.
  24 
  25 func SplitSeconds(sec float64) (w, d, h, m int, s float64)
  26 
  27 SplitSeconds turns non-negative seconds into weeks, days, hours, minutes,
  28 and leftover seconds.
  29 
  30 func ParseDuration(s string) (time.Duration, error)
  31 
  32 ParseDuration extends time-format support to the likes of HH:MM:SS.
  33 */
  34 package timeplus

     File: ./go.mod
   1 module timeplus
   2 
   3 go 1.16

     File: ./parsing.go
   1 package timeplus
   2 
   3 import (
   4     "math"
   5     "strconv"
   6     "strings"
   7     "time"
   8 )
   9 
  10 // daysInMonth is the day-count for each month in a leap year
  11 var daysInMonth = [12]int{
  12     31, // January
  13     29, // February
  14     31, // March
  15     30, // April
  16     31, // May
  17     30, // June
  18     31, // July
  19     31, // August
  20     30, // September
  21     31, // October
  22     30, // November
  23     31, // December
  24 }
  25 
  26 // ParseYMD parses a year-month-day-style date or a year-month-style date out of the
  27 // string given to it.
  28 //
  29 // If the day returned is 0, it means no day was given: this can happen when the date
  30 // string given is meant to represent a whole month in a year, instead of a specific
  31 // day during that month. Similarly, a 0-month 0-day date can represent a whole year.
  32 func ParseYMD(s string) (year, month, day int, err error) {
  33     switch len(s) {
  34     case 10: // yyyy-mm-dd
  35         if r := s[4]; '0' <= r && r <= '9' {
  36             return 0, 0, 0, ErrInvalidDateString
  37         }
  38         if r := s[7]; '0' <= r && r <= '9' {
  39             return 0, 0, 0, ErrInvalidDateString
  40         }
  41         return parseYMD(s[:4], s[5:7], s[8:])
  42 
  43     case 8: // yyyymmdd or yy-mm-dd
  44         if r := s[2]; '0' <= r && r <= '9' {
  45             // handle yyyymmdd
  46             return parseYMD(s[:4], s[4:6], s[6:])
  47         }
  48         // handle yy-mm-dd
  49         return parseYMD(s[:2], s[3:5], s[6:])
  50 
  51     case 7: // yyyy-mm
  52         if r := s[4]; '0' <= r && r <= '9' {
  53             return 0, 0, 0, ErrInvalidDateString
  54         }
  55         // day is 0, since the date-string actually represents a whole month
  56         return parseYMD(s[:4], s[5:], "0")
  57 
  58     case 6: // yymmdd
  59         y, m, d, err := parseYMD(s[:2], s[2:4], s[4:])
  60         if err != nil {
  61             return y, m, d, err
  62         }
  63         return 1900 + y, m, d, nil
  64 
  65     default:
  66         return 0, 0, 0, ErrInvalidDateString
  67     }
  68 }
  69 
  70 // parseYMD simplifies the explicit control-flow of func ParseYMD
  71 func parseYMD(a, b, c string) (int, int, int, error) {
  72     y, m, d, err := parse3Ints(a, b, c)
  73     if err != nil {
  74         return y, m, d, err
  75     }
  76 
  77     // 0s are allowed to indicate which values/parts are unknown/unspecified:
  78     // this allows representing a whole month in a year, or even a whole year,
  79     // for example
  80 
  81     if y < 0 {
  82         return y, m, d, ErrInvalidDateString
  83     }
  84     if m < 0 || m > 12 {
  85         return y, m, d, ErrInvalidMonth
  86     }
  87     // condition used to be d < 0 || d > 31
  88     if d < 0 || (m != 0 && d > daysInMonth[m-1]) {
  89         return y, m, d, ErrInvalidDay
  90     }
  91     return y, m, d, nil
  92 }
  93 
  94 // parse3Ints simplifies the explicit control-flow of func parseYMD
  95 func parse3Ints(a, b, c string) (int, int, int, error) {
  96     x, err := strconv.ParseInt(a, 10, 64)
  97     if err != nil {
  98         return int(x), 0, 0, err
  99     }
 100     y, err := strconv.ParseInt(b, 10, 64)
 101     if err != nil {
 102         return int(x), int(y), 0, err
 103     }
 104     z, err := strconv.ParseInt(c, 10, 64)
 105     return int(x), int(y), int(z), err
 106 }
 107 
 108 // ParseDuration extends the stdlib time-duration parser to also allow commonly-used
 109 // time notations like
 110 //
 111 //  MM:SS           minutes and seconds
 112 //  HH:MM:SS        hours, minutes, and seconds
 113 //  DD:HH:MM:SS     days, hours, minutes, and seconds
 114 //  WW:DD:HH:MM:SS  weeks, days, hours, minutes, and seconds
 115 //
 116 // Decimals are also supported, but only for the final seconds field.
 117 // Again, this function also supports the stdlib time-duration notation.
 118 func ParseDuration(s string) (time.Duration, error) {
 119     s = strings.TrimSpace(s)
 120     if s == "" {
 121         return 0, ErrEmptyString
 122     }
 123 
 124     // handle shortcuts for time units such as weeks and (normal) years
 125     if s[len(s)-1] == 'w' {
 126         f, err := strconv.ParseFloat(s[:len(s)-1], 64)
 127         if err != nil {
 128             return 0, err
 129         }
 130         return time.Duration(float64(Week) * f), nil
 131     }
 132     if s[len(s)-1] == 'y' {
 133         f, err := strconv.ParseFloat(s[:len(s)-1], 64)
 134         if err != nil {
 135             return 0, err
 136         }
 137         return time.Duration(float64(NormalYear) * f), nil
 138     }
 139 
 140     // see if the stdlib can handle it directly
 141     d, err := time.ParseDuration(s)
 142     if err == nil {
 143         return d, nil
 144     }
 145 
 146     return parseColonDuration(s)
 147 }
 148 
 149 // DurationParts is the result of func SplitDuration. Fields include values up
 150 // to weeks, which is the longest constant time-unit commonly used.
 151 type DurationParts struct {
 152     Weeks   int // 0+
 153     Days    int // 0..6
 154     Hours   int // 0..23
 155     Minutes int // 0..59
 156     Seconds int // 0..59
 157 
 158     Nanoseconds int // 0..999_999_999
 159 }
 160 
 161 func (dp DurationParts) Milliseconds() int {
 162     return dp.Nanoseconds / 1_000_000
 163 }
 164 
 165 func (dp DurationParts) Microseconds() int {
 166     return dp.Nanoseconds / 1_000
 167 }
 168 
 169 // SplitDuration splits all common time components from the duration value given.
 170 func SplitDuration(d time.Duration) DurationParts {
 171     sec := float64(d / time.Second)
 172 
 173     var parts DurationParts
 174     parts.Weeks = int((sec - math.Mod(sec, SecondsInWeek)) / SecondsInWeek)
 175     parts.Days = int(math.Mod(sec, SecondsInWeek) / SecondsInDay)
 176     parts.Hours = int(math.Mod(sec, SecondsInDay) / SecondsInHour)
 177     parts.Minutes = int(math.Mod(sec, SecondsInHour) / SecondsInMinute)
 178     parts.Seconds = int(math.Mod(sec, SecondsInMinute))
 179     parts.Nanoseconds = int(d) % int(time.Nanosecond)
 180     return parts
 181 }
 182 
 183 // durationFragments helps func parseDuration keep track of all fields without
 184 // depending on functionality from external packages, such as strings.Split
 185 type durationFragments struct {
 186     weeks   int
 187     days    int
 188     hours   int
 189     minutes int
 190     seconds int
 191 
 192     numfields int
 193 }
 194 
 195 func (f *durationFragments) update(n int) error {
 196     f.numfields++
 197     switch f.numfields {
 198     case 1:
 199         f.seconds = n
 200         return nil
 201 
 202     case 2:
 203         f.minutes = f.seconds
 204         f.seconds = n
 205         return nil
 206 
 207     case 3:
 208         f.hours = f.minutes
 209         f.minutes = f.seconds
 210         f.seconds = n
 211         return nil
 212 
 213     case 4:
 214         f.days = f.hours
 215         f.hours = f.minutes
 216         f.minutes = f.seconds
 217         f.seconds = n
 218         return nil
 219 
 220     case 5:
 221         f.weeks = f.days
 222         f.days = f.hours
 223         f.hours = f.minutes
 224         f.minutes = f.seconds
 225         f.seconds = n
 226         return nil
 227 
 228     default:
 229         // weeks are the largest constant time unit there is
 230         return ErrTooManyFields
 231     }
 232 }
 233 
 234 func (f durationFragments) duration() time.Duration {
 235     d := time.Duration(f.weeks) * Week
 236     d += time.Duration(f.days) * Day
 237     d += time.Duration(f.hours) * Hour
 238     d += time.Duration(f.minutes) * Minute
 239     d += time.Duration(f.seconds) * Second
 240     return d
 241 }
 242 
 243 // parseColonDuration handles HH:MM:SS-like strings for func ParseDuration
 244 func parseColonDuration(s string) (time.Duration, error) {
 245     n := 0         // value for current field
 246     dec := false   // was a decimal point found?
 247     numdigits := 0 // how many digits current field has
 248     frags := durationFragments{}
 249 
 250     for _, r := range s {
 251         switch r {
 252         case '.':
 253             // handle decimals
 254             if dec {
 255                 return 0, ErrMisplacedDots
 256             }
 257             dec = true
 258             // remember value for seconds
 259             if err := frags.update(n); err != nil {
 260                 return 0, err
 261             }
 262             numdigits = 0
 263             n = 0
 264 
 265         case ':':
 266             // switch to next fragment/group
 267             if dec {
 268                 return 0, ErrMisplacedDots
 269             }
 270             if err := frags.update(n); err != nil {
 271                 return 0, err
 272             }
 273             numdigits = 0
 274             n = 0
 275 
 276         default:
 277             // update value in current field
 278             if r < '0' || r > '9' {
 279                 return 0, ErrNonDigitsFound
 280             }
 281             n *= 10
 282             n += int(r - '0')
 283             numdigits++
 284         }
 285     }
 286 
 287     // handle subsecond values: seconds are already counted for in this case
 288     if dec {
 289         return frags.duration() + fractionalSecond(n, numdigits), nil
 290     }
 291 
 292     // remember value for seconds
 293     if err := frags.update(n); err != nil {
 294         return 0, err
 295     }
 296     return frags.duration(), nil
 297 }
 298 
 299 // fractionalSecond turns the int-pair (mantissa, -log10) into the sub-second
 300 // time-duration it represents
 301 func fractionalSecond(fraction int, numdigits int) time.Duration {
 302     nd := math.Pow10(numdigits)
 303     return time.Duration(fraction) * time.Second / time.Duration(int64(nd))
 304 }

     File: ./parsing_test.go
   1 package timeplus
   2 
   3 import (
   4     "testing"
   5     "time"
   6 )
   7 
   8 func TestParseYMD(t *testing.T) {
   9     var durationTestCases = []struct {
  10         Name  string
  11         Input string
  12         Year  int
  13         Month int
  14         Day   int
  15     }{
  16         {"yyyy-mm-dd", "1925-03-29", 1925, 3, 29},
  17         {"yyyymmdd 1900s", "19250329", 1925, 3, 29},
  18         {"yyyymmdd 2400s", "24250329", 2425, 3, 29},
  19         {"yyyymmdd ancient", "04250329", 425, 3, 29},
  20         {"yymmdd", "860329", 1986, 3, 29},
  21         {"yyyy$mm", "2011M03", 2011, 3, 0},
  22     }
  23 
  24     for _, tc := range durationTestCases {
  25         t.Run(tc.Name, func(t *testing.T) {
  26             y, m, d, err := ParseYMD(tc.Input)
  27             if err != nil {
  28                 t.Fatalf("input was %q: expected %d-%d-%d, got error %q (%d-%d-%d) instead\n",
  29                     tc.Input, tc.Year, tc.Month, tc.Day, err.Error(), y, m, d)
  30                 return
  31             }
  32 
  33             if y != tc.Year || m != tc.Month || d != tc.Day {
  34                 t.Fatalf("input was %q: expected %d-%d-%d, got %d-%d-%d instead\n",
  35                     tc.Input, tc.Year, tc.Month, tc.Day, y, m, d)
  36             }
  37         })
  38     }
  39 }
  40 
  41 func TestParseDuration(t *testing.T) {
  42     var durationTestCases = []struct {
  43         Name     string
  44         Input    string
  45         Expected time.Duration
  46     }{
  47         {"integer", "24", 24 * time.Second},
  48         {"integer seconds", "24s", 24 * time.Second},
  49         {"fractional 1", "0.9", 900 * time.Millisecond},
  50         {"fractional 2", "0.9235", 923500 * time.Microsecond},
  51         {"fractional 3", "12.42", 12*time.Second + 420*time.Millisecond},
  52         {"mm:ss", "31:05", 31*time.Minute + 5*time.Second},
  53         {"hh:mm:ss", "13:31:05", 13*time.Hour + 31*time.Minute + 5*time.Second},
  54         {
  55             "dd:hh:mm:ss", "3:9:31:05",
  56             3*24*time.Hour + 9*time.Hour + 31*time.Minute + 5*time.Second,
  57         },
  58         {
  59             "dd:hh:mm:ss.ffff", "3:9:31:05.9235",
  60             3*24*time.Hour + 9*time.Hour + 31*time.Minute + 5*time.Second + 923500*time.Microsecond,
  61         },
  62     }
  63 
  64     for _, tc := range durationTestCases {
  65         t.Run(tc.Name, func(t *testing.T) {
  66             v, err := ParseDuration(tc.Input)
  67             if err != nil {
  68                 t.Fatalf("input was %q: expected %v, got error %q instead\n", tc.Input, err.Error(), v)
  69                 return
  70             }
  71 
  72             if v != tc.Expected {
  73                 t.Fatalf("input was %q: expected %v, got %v instead\n", tc.Input, tc.Expected, v)
  74             }
  75         })
  76     }
  77 }
  78 
  79 func TestData(t *testing.T) {
  80     days := 0
  81     for _, v := range daysInMonth {
  82         days += v
  83     }
  84 
  85     if len(daysInMonth) != 12 {
  86         t.Fatalf("expected 12 months, but got %d instead", len(daysInMonth))
  87     }
  88     if days != 366 {
  89         t.Fatalf("expected 366 days in total, but got %d instead", days)
  90     }
  91 }

     File: ./timeplus.go
   1 package timeplus
   2 
   3 import (
   4     "errors"
   5     "math"
   6     "time"
   7 )
   8 
   9 // these aliases are package-name-consistent with other constants defined later
  10 const (
  11     Nanosecond  = time.Nanosecond
  12     Microsecond = time.Microsecond
  13     Millisecond = time.Millisecond
  14     Second      = time.Second
  15     Minute      = time.Minute
  16     Hour        = time.Hour
  17 
  18     Day        = 24 * time.Hour
  19     Week       = 7 * Day
  20     NormalYear = 365 * Day
  21     JulianYear = NormalYear + 6*Hour // 365.25 days
  22     LeapYear   = 366 * Day
  23 
  24     // decades, centuries, and millennia aren't constant, due to leap years
  25 
  26     // time-format parameters for the stdlib time package
  27     DateTimeFormat = "2006-01-02 15:04:05"
  28     DateFormat     = "2006-01-02"
  29     TimeFormat     = "15:04:05"
  30 
  31     // other constants
  32     SecondsInMinute = 60
  33     SecondsInHour   = 3_600
  34     SecondsInDay    = 24 * SecondsInHour
  35     SecondsInWeek   = 7 * SecondsInDay
  36 )
  37 
  38 const (
  39     msgEmptyString    = "can't parse time values from empty strings"
  40     msgTooManyFields  = "semicolon-separated time fields stop at weeks"
  41     msgNonDigitsFound = "non-digits found in what's supposed to be a valid numeric substring"
  42     msgMisplacedDots  = "misplaced decimal dot in time-duration"
  43 
  44     msgInvalidDay           = "day number isn't valid"
  45     msgInvalidMonth         = "month number isn't valid"
  46     msgInvalidDateString    = "can't parse string given as a date"
  47     msgInvalidIntegerString = "can't parse string given as an integer number"
  48 )
  49 
  50 var (
  51     ErrEmptyString    = errors.New(msgEmptyString)
  52     ErrTooManyFields  = errors.New(msgTooManyFields)
  53     ErrNonDigitsFound = errors.New(msgNonDigitsFound)
  54     ErrMisplacedDots  = errors.New(msgMisplacedDots)
  55 
  56     ErrInvalidDay           = errors.New(msgInvalidDay)
  57     ErrInvalidMonth         = errors.New(msgInvalidMonth)
  58     ErrInvalidDateString    = errors.New(msgInvalidDateString)
  59     ErrInvalidIntegerString = errors.New(msgInvalidIntegerString)
  60 )
  61 
  62 // Months is a slice of full-name months of the year.
  63 var Months = [12]string{
  64     "January",
  65     "February",
  66     "March",
  67     "April",
  68     "May",
  69     "June",
  70     "July",
  71     "August",
  72     "September",
  73     "October",
  74     "November",
  75     "December",
  76 }
  77 
  78 // WeekDaysEnglish is the sequence of week-days ordered the `English` way,
  79 // with the week starting on Sunday.
  80 var WeekDaysEnglish = [7]string{
  81     "Sunday",
  82     "Monday",
  83     "Tuesday",
  84     "Wednesday",
  85     "Thursday",
  86     "Friday",
  87     "Saturday",
  88 }
  89 
  90 // WeekDaysOther is the sequence of week-days with the week starting on
  91 // Monday, as is convention in most `non-English` countries.
  92 var WeekDaysOther = [7]string{
  93     "Monday",
  94     "Tuesday",
  95     "Wednesday",
  96     "Thursday",
  97     "Friday",
  98     "Saturday",
  99     "Sunday",
 100 }
 101 
 102 // SplitSeconds takes a number representing a duration in seconds and gives
 103 // you back the weeks, days, hours, minutes, and remaining seconds.
 104 //
 105 // # Example
 106 //
 107 // // formatSeconds returns time strings following the HH:MM:SS.ff, DD:HH:MM:SS.ff,
 108 // // or even the WW:DD:HH:MM:SS.ff format, days and weeks permitting
 109 //
 110 //  func formatSeconds(sec float64) string {
 111 //      w, d, h, m, s := SplitSeconds(sec)
 112 //      if w > 0 {
 113 //          return fmt.Sprintf("%02d:%02d:%02d:%02d:%05.2f", w, d, h, m, s)
 114 //      }
 115 //      if d > 0 {
 116 //          return fmt.Sprintf("%02d:%02d:%02d:%05.2f", d, h, m, s)
 117 //      }
 118 //      return fmt.Sprintf("%02d:%02d:%05.2f", h, m, s)
 119 //  }
 120 func SplitSeconds(sec float64) (w, d, h, m int, s float64) {
 121     w = int((sec - math.Mod(sec, float64(Week))) / float64(Week))
 122     d = int(math.Mod(sec, float64(Week)) / float64(Day))
 123     h = int(math.Mod(sec, float64(Day)) / float64(Hour))
 124     m = int(math.Mod(sec, float64(Hour)) / float64(Minute))
 125     s = math.Mod(sec, float64(Minute))
 126     return w, d, h, m, s
 127 }