├── .travis.yml ├── LICENSE ├── README.md ├── coveralls.bash ├── duration.go └── duration_test.go /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | before_install: 4 | - go get github.com/mattn/goveralls 5 | - go get github.com/modocache/gover 6 | 7 | script: 8 | - go test -race -v ./... 9 | - ./coveralls.bash 10 | 11 | go: 12 | - 1.9.x 13 | - 1.10.x 14 | - tip 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Senseye 4 | Adapted from https://github.com/ChannelMeter/iso8601duration, Copyright (c) 2014 ChannelMeter 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Duration [![Build](https://travis-ci.org/senseyeio/duration.svg?branch=master)](https://travis-ci.org/senseyeio/duration) [![Coverage](https://coveralls.io/repos/github/senseyeio/duration/badge.svg?branch=master)](https://coveralls.io/github/senseyeio/duration?branch=master) [![Go Report Card](https://goreportcard.com/badge/senseyeio/duration)](https://goreportcard.com/report/senseyeio/duration) [![GoDoc](https://godoc.org/github.com/senseyeio/duration?status.svg)](https://godoc.org/github.com/senseyeio/duration) 2 | ======= 3 | Parse ISO8601 duration strings, and use to shift dates/times. 4 | 5 | Basic Example 6 | ------------- 7 | 8 | ```go 9 | package main 10 | 11 | import ( 12 | "fmt" 13 | "time" 14 | 15 | "github.com/senseyeio/duration" 16 | ) 17 | 18 | func main() { 19 | d, _ := iso8601.ParseISO8601("P1D") 20 | today := time.Now() 21 | tomorrow := d.Shift(today) 22 | fmt.Println(today.Format("Jan _2")) 23 | fmt.Println(tomorrow.Format("Jan _2")) 24 | } 25 | ``` 26 | 27 | Why Does This Package Exist 28 | --------------------------- 29 | > Why can't we just use a `time.Duration` and `time.Add`? 30 | 31 | A very reasonable question. 32 | 33 | The code below repeatedly adds 24 hours to a `time.Time`. You might expect the time on that date to stay the same, but [_there are not always 24 hours in a day_](http://infiniteundo.com/post/25326999628/falsehoods-programmers-believe-about-time). When the clocks change in New York, the time will skew by an hour. As you can see from the output, duration.Duration.Shift() can increment the date without shifting the time. 34 | 35 | ```go 36 | package main 37 | 38 | import ( 39 | "fmt" 40 | "time" 41 | 42 | "github.com/senseyeio/duration" 43 | ) 44 | 45 | func main() { 46 | loc, _ := time.LoadLocation("America/New_York") 47 | d, _ := iso8601.ParseISO8601("P1D") 48 | t1, _ := time.ParseInLocation("Jan 2, 2006 at 3:04pm", "Jan 1, 2006 at 3:04pm", loc) 49 | t2 := t1 50 | for i := 0; i < 365; i++ { 51 | t1 = t1.Add(24 * time.Hour) 52 | t2 = d.Shift(t2) 53 | fmt.Printf("time.Add:%d Duration.Shift:%d\n", t1.Hour(), t2.Hour()) 54 | } 55 | } 56 | 57 | // Outputs 58 | // time.Add:15 Duration.Shift:15 59 | // time.Add:15 Duration.Shift:15 60 | // time.Add:15 Duration.Shift:15 61 | // ... 62 | // time.Add:16 Duration.Shift:15 63 | // time.Add:16 Duration.Shift:15 64 | // time.Add:16 Duration.Shift:15 65 | // ... 66 | ``` 67 | 68 | ------- 69 | Months are tricky. Shifting by months uses `time.AddDate()`, which is great. However, be aware of how differing days in the month are accommodated. Dates will 'roll over' if the month you're shifting to has fewer days. e.g. if you start on Jan 30th and repeat every "P1M", you'll get this: 70 | 71 | ``` 72 | Jan 30, 2006 73 | Mar 2, 2006 74 | Apr 2, 2006 75 | May 2, 2006 76 | Jun 2, 2006 77 | Jul 2, 2006 78 | Aug 2, 2006 79 | Sep 2, 2006 80 | Oct 2, 2006 81 | Nov 2, 2006 82 | Dec 2, 2006 83 | Jan 2, 2007 84 | ``` 85 | -------------------------------------------------------------------------------- /coveralls.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if ! type -P gover 4 | then 5 | echo gover missing: go get github.com/modocache/gover 6 | exit 1 7 | fi 8 | 9 | if ! type -P goveralls 10 | then 11 | echo goveralls missing: go get github.com/mattn/goveralls 12 | exit 1 13 | fi 14 | 15 | if [[ "$COVERALLS_TOKEN" == "" ]] 16 | then 17 | echo COVERALLS_TOKEN not set 18 | exit 1 19 | fi 20 | 21 | go test -covermode count -coverprofile coverage.coverprofile 22 | 23 | gover 24 | goveralls -coverprofile gover.coverprofile -service travis-ci -repotoken $COVERALLS_TOKEN 25 | find . -name '*.coverprofile' -delete 26 | -------------------------------------------------------------------------------- /duration.go: -------------------------------------------------------------------------------- 1 | // Package duration handles ISO8601-formatted durations. 2 | package duration 3 | 4 | import ( 5 | "bytes" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "html/template" 10 | "regexp" 11 | "strconv" 12 | "time" 13 | ) 14 | 15 | // Duration represents an ISO8601 Duration 16 | // https://en.wikipedia.org/wiki/ISO_8601#Durations 17 | type Duration struct { 18 | Y int 19 | M int 20 | W int 21 | D int 22 | // Time Component 23 | TH int 24 | TM int 25 | TS int 26 | } 27 | 28 | var pattern = regexp.MustCompile(`^P((?P\d+)Y)?((?P\d+)M)?((?P\d+)W)?((?P\d+)D)?(T((?P\d+)H)?((?P\d+)M)?((?P\d+)S)?)?$`) 29 | 30 | // ParseISO8601 parses an ISO8601 duration string. 31 | func ParseISO8601(from string) (Duration, error) { 32 | var match []string 33 | var d Duration 34 | 35 | if pattern.MatchString(from) { 36 | match = pattern.FindStringSubmatch(from) 37 | } else { 38 | return d, errors.New("could not parse duration string") 39 | } 40 | 41 | for i, name := range pattern.SubexpNames() { 42 | part := match[i] 43 | if i == 0 || name == "" || part == "" { 44 | continue 45 | } 46 | 47 | val, err := strconv.Atoi(part) 48 | if err != nil { 49 | return d, err 50 | } 51 | switch name { 52 | case "year": 53 | d.Y = val 54 | case "month": 55 | d.M = val 56 | case "week": 57 | d.W = val 58 | case "day": 59 | d.D = val 60 | case "hour": 61 | d.TH = val 62 | case "minute": 63 | d.TM = val 64 | case "second": 65 | d.TS = val 66 | default: 67 | return d, fmt.Errorf("unknown field %s", name) 68 | } 69 | } 70 | 71 | return d, nil 72 | } 73 | 74 | // IsZero reports whether d represents the zero duration, P0D. 75 | func (d Duration) IsZero() bool { 76 | return d.Y == 0 && d.M == 0 && d.W == 0 && d.D == 0 && d.TH == 0 && d.TM == 0 && d.TS == 0 77 | } 78 | 79 | // HasTimePart returns true if the time part of the duration is non-zero. 80 | func (d Duration) HasTimePart() bool { 81 | return d.TH > 0 || d.TM > 0 || d.TS > 0 82 | } 83 | 84 | // Shift returns a time.Time, shifted by the duration from the given start. 85 | // 86 | // NB: Shift uses time.AddDate for years, months, weeks, and days, and so 87 | // shares its limitations. In particular, shifting by months is not recommended 88 | // unless the start date is before the 28th of the month. Otherwise, dates will 89 | // roll over, e.g. Aug 31 + P1M = Oct 1. 90 | // 91 | // Week and Day values will be combined as W*7 + D. 92 | func (d Duration) Shift(t time.Time) time.Time { 93 | if d.Y != 0 || d.M != 0 || d.W != 0 || d.D != 0 { 94 | days := d.W*7 + d.D 95 | t = t.AddDate(d.Y, d.M, days) 96 | } 97 | t = t.Add(d.timeDuration()) 98 | return t 99 | } 100 | 101 | func (d Duration) timeDuration() time.Duration { 102 | var dur time.Duration 103 | dur = dur + (time.Duration(d.TH) * time.Hour) 104 | dur = dur + (time.Duration(d.TM) * time.Minute) 105 | dur = dur + (time.Duration(d.TS) * time.Second) 106 | return dur 107 | } 108 | 109 | var tmpl = template.Must(template.New("duration").Parse(`P{{if .Y}}{{.Y}}Y{{end}}{{if .M}}{{.M}}M{{end}}{{if .W}}{{.W}}W{{end}}{{if .D}}{{.D}}D{{end}}{{if .HasTimePart}}T{{end }}{{if .TH}}{{.TH}}H{{end}}{{if .TM}}{{.TM}}M{{end}}{{if .TS}}{{.TS}}S{{end}}`)) 110 | 111 | // String returns an ISO8601-ish representation of the duration. 112 | func (d Duration) String() string { 113 | var s bytes.Buffer 114 | 115 | if d.IsZero() { 116 | return "P0D" 117 | } 118 | 119 | err := tmpl.Execute(&s, d) 120 | if err != nil { 121 | panic(err) 122 | } 123 | 124 | return s.String() 125 | } 126 | 127 | // MarshalJSON satisfies json.Marshaler. 128 | func (d Duration) MarshalJSON() ([]byte, error) { 129 | return json.Marshal(d.String()) 130 | } 131 | 132 | // UnmarshalJSON satisfies json.Unmarshaler. 133 | func (d *Duration) UnmarshalJSON(b []byte) error { 134 | var s string 135 | if err := json.Unmarshal(b, &s); err != nil { 136 | return err 137 | } 138 | 139 | tmp, err := ParseISO8601(s) 140 | if err != nil { 141 | return err 142 | } 143 | *d = tmp 144 | 145 | return nil 146 | } 147 | -------------------------------------------------------------------------------- /duration_test.go: -------------------------------------------------------------------------------- 1 | package duration_test 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | "time" 7 | 8 | "github.com/senseyeio/duration" 9 | ) 10 | 11 | const dateLayout = "Jan 2, 2006 at 03:04:05" 12 | 13 | func makeTime(t *testing.T, s string) time.Time { 14 | result, err := time.Parse(dateLayout, s) 15 | if err != nil { 16 | t.Fatal(err) 17 | } 18 | return result 19 | } 20 | 21 | func TestCanShift(t *testing.T) { 22 | cases := []struct { 23 | from string 24 | duration duration.Duration 25 | want string 26 | }{ 27 | {"Jan 1, 2018 at 00:00:00", duration.Duration{}, "Jan 1, 2018 at 00:00:00"}, 28 | {"Jan 1, 2018 at 00:00:00", duration.Duration{Y: 1}, "Jan 1, 2019 at 00:00:00"}, 29 | {"Jan 1, 2018 at 00:00:00", duration.Duration{M: 1}, "Feb 1, 2018 at 00:00:00"}, 30 | {"Jan 1, 2018 at 00:00:00", duration.Duration{M: 2}, "Mar 1, 2018 at 00:00:00"}, 31 | {"Jan 1, 2018 at 00:00:00", duration.Duration{W: 1}, "Jan 8, 2018 at 00:00:00"}, 32 | {"Jan 1, 2018 at 00:00:00", duration.Duration{D: 1}, "Jan 2, 2018 at 00:00:00"}, 33 | {"Jan 1, 2018 at 00:00:00", duration.Duration{TH: 1}, "Jan 1, 2018 at 01:00:00"}, 34 | {"Jan 1, 2018 at 00:00:00", duration.Duration{TM: 1}, "Jan 1, 2018 at 00:01:00"}, 35 | {"Jan 1, 2018 at 00:00:00", duration.Duration{TS: 1}, "Jan 1, 2018 at 00:00:01"}, 36 | {"Jan 1, 2018 at 00:00:00", duration.Duration{ 37 | Y: 10, 38 | M: 5, 39 | D: 8, 40 | TH: 5, 41 | TM: 10, 42 | TS: 6, 43 | //T: 5*time.Hour + 10*time.Minute + 6*time.Second, 44 | }, 45 | "Jun 9, 2028 at 05:10:06", 46 | }, 47 | } 48 | 49 | for k, c := range cases { 50 | from := makeTime(t, c.from) 51 | want := makeTime(t, c.want) 52 | 53 | got := c.duration.Shift(from) 54 | if !want.Equal(got) { 55 | t.Fatalf("Case %d: want=%s, got=%s", k, want, got) 56 | } 57 | } 58 | } 59 | 60 | func TestCanMaintainHourThroughDST(t *testing.T) { 61 | loc, err := time.LoadLocation("America/New_York") 62 | if err != nil { 63 | t.Fatal(err) 64 | } 65 | 66 | current, err := time.ParseInLocation(dateLayout, "Jan 1, 2018 at 00:00:00", loc) 67 | if err != nil { 68 | t.Fatal(err) 69 | } 70 | 71 | sut := duration.Duration{D: 1} 72 | for d := 0; d < 365; d++ { 73 | if got := current.Hour(); got != 0 { 74 | t.Fatalf("Day %d: want=0, got=%d", d, got) 75 | } 76 | current = sut.Shift(current) 77 | } 78 | } 79 | 80 | func TestCanParse(t *testing.T) { 81 | cases := []struct { 82 | from string 83 | want duration.Duration 84 | }{ 85 | {"P1Y", duration.Duration{Y: 1}}, 86 | {"P1M", duration.Duration{M: 1}}, 87 | {"P2M", duration.Duration{M: 2}}, 88 | {"P1W", duration.Duration{W: 1}}, 89 | {"P1D", duration.Duration{D: 1}}, 90 | {"PT1H", duration.Duration{TH: 1}}, 91 | {"PT1M", duration.Duration{TM: 1}}, 92 | {"PT1S", duration.Duration{TS: 1}}, 93 | {"P10Y5M8DT5H10M6S", duration.Duration{Y: 10, M: 5, D: 8, TH: 5, TM: 10, TS: 6}}, 94 | } 95 | 96 | for k, c := range cases { 97 | got, err := duration.ParseISO8601(c.from) 98 | if err != nil { 99 | t.Fatal(err) 100 | } 101 | if c.want != got { 102 | t.Fatalf("Case %d: want=%+v, got=%+v", k, c.want, got) 103 | } 104 | } 105 | } 106 | 107 | func TestCanRejectBadString(t *testing.T) { 108 | cases := []string{ 109 | "", 110 | "PP1D", 111 | "P1D2F", 112 | "P2F", 113 | } 114 | 115 | for _, c := range cases { 116 | _, err := duration.ParseISO8601(c) 117 | if err == nil { 118 | t.Fatalf("%s: Expected error, got none", c) 119 | } 120 | } 121 | } 122 | 123 | func TestCanStringifyZeroValue(t *testing.T) { 124 | sut := duration.Duration{} 125 | want := "P0D" 126 | got := sut.String() 127 | if want != got { 128 | t.Fatalf("want=%s, got=%s", want, got) 129 | } 130 | } 131 | 132 | func TestCanStringify(t *testing.T) { 133 | cases := []string{ 134 | "P1Y", 135 | "P2M", 136 | "P3W", 137 | "P4D", 138 | "PT5H", 139 | "PT6M", 140 | "PT7S", 141 | "P1Y2M3W4DT5H6M7S", 142 | } 143 | for _, want := range cases { 144 | sut, err := duration.ParseISO8601(want) 145 | if err != nil { 146 | t.Fatal(err) 147 | } 148 | got := sut.String() 149 | if want != got { 150 | t.Fatalf("Want %s, got %s", want, got) 151 | } 152 | } 153 | } 154 | 155 | func TestCanMarshalJSON(t *testing.T) { 156 | s := "P1Y2M3W4DT5H6M7S" 157 | sut, _ := duration.ParseISO8601(s) 158 | 159 | b, err := json.Marshal(sut) 160 | if err != nil { 161 | t.Fatal(err) 162 | } 163 | 164 | want := `"P1Y2M3W4DT5H6M7S"` 165 | got := string(b) 166 | if got != want { 167 | t.Fatalf("want=%s, got=%s", want, got) 168 | } 169 | } 170 | 171 | func TestCanUnmarshalJSON(t *testing.T) { 172 | j := []byte(`"P1Y2M3W4DT5H6M7S"`) 173 | var got duration.Duration 174 | err := json.Unmarshal(j, &got) 175 | if err != nil { 176 | t.Fatal(err) 177 | } 178 | 179 | s := "P1Y2M3W4DT5H6M7S" 180 | want, _ := duration.ParseISO8601(s) 181 | 182 | if got != want { 183 | t.Fatalf("want=%+v, got=%+v", want, got) 184 | } 185 | } 186 | 187 | func TestCanRejectDurationInJSON(t *testing.T) { 188 | j := []byte(`"PZY"`) 189 | var got duration.Duration 190 | err := json.Unmarshal(j, &got) 191 | if err == nil { 192 | t.Fatal("expected error, got none") 193 | } 194 | } 195 | 196 | func TestCanRejectBadJSON(t *testing.T) { 197 | j := []byte(`{"foo":"bar"}`) 198 | var got duration.Duration 199 | err := json.Unmarshal(j, &got) 200 | if err == nil { 201 | t.Fatal("expected error, got none") 202 | } 203 | } 204 | --------------------------------------------------------------------------------