Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 64 additions & 26 deletions duration.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,8 @@ func Parse(d string) (*Duration, error) {
return duration, nil
}

// FromTimeDuration converts the given time.Duration into duration.Duration.
// Note that for *Duration's with period values of a month or year that the duration becomes a bit fuzzy
// since obviously those things vary month to month and year to year.
// FromTimeDuration converts a time.Duration into a *Duration using exact fixed-length
// units (Weeks, Days, Hours, Minutes, Seconds). Never produces Years or Months.
func FromTimeDuration(d time.Duration) *Duration {
duration := &Duration{}
if d == 0 {
Expand All @@ -167,46 +166,38 @@ func FromTimeDuration(d time.Duration) *Duration {
duration.Negative = true
}

if d.Hours() >= hoursPerYear {
duration.Years = math.Floor(d.Hours() / hoursPerYear)
d -= time.Duration(duration.Years) * nsPerYear
}
if d.Hours() >= hoursPerMonth {
duration.Months = math.Floor(d.Hours() / hoursPerMonth)
d -= time.Duration(duration.Months) * nsPerMonth
}
if d.Hours() >= hoursPerWeek {
duration.Weeks = math.Floor(d.Hours() / hoursPerWeek)
if d >= nsPerWeek {
duration.Weeks = float64(d / nsPerWeek)
d -= time.Duration(duration.Weeks) * nsPerWeek
}
if d.Hours() >= hoursPerDay {
duration.Days = math.Floor(d.Hours() / hoursPerDay)
if d >= nsPerDay {
duration.Days = float64(d / nsPerDay)
d -= time.Duration(duration.Days) * nsPerDay
}
if d.Hours() >= 1 {
duration.Hours = math.Floor(d.Hours())
if d >= nsPerHour {
duration.Hours = float64(d / nsPerHour)
d -= time.Duration(duration.Hours) * nsPerHour
}
if d.Minutes() >= 1 {
duration.Minutes = math.Floor(d.Minutes())
if d >= nsPerMinute {
duration.Minutes = float64(d / nsPerMinute)
d -= time.Duration(duration.Minutes) * nsPerMinute
}
duration.Seconds = d.Seconds()

return duration
}

// Format formats the given time.Duration into an ISO 8601 duration string (e.g., P1DT6H5M),
// negative durations are prefixed with a minus sign, for a zero duration "PT0S" is returned.
// Note that for *Duration's with period values of a month or year that the duration becomes a bit fuzzy
// since obviously those things vary month to month and year to year.
// Format formats a time.Duration into an ISO 8601 string (e.g. P1DT6H5M).
// Negative durations are prefixed with "-"; zero returns "PT0S".
// Output never contains Years or Months; see FromTimeDuration.
func Format(d time.Duration) string {
return FromTimeDuration(d).String()
}

// ToTimeDuration converts the *Duration to the standard library's time.Duration.
// Note that for *Duration's with period values of a month or year that the duration becomes a bit fuzzy
// since obviously those things vary month to month and year to year.
// ToTimeDuration converts the *Duration to time.Duration.
// Durations with Years or Months use fixed-length approximations and may be inexact.
//
// Deprecated: Use ToTimeDurationFrom for exact results when Years or Months are set.
func (duration *Duration) ToTimeDuration() time.Duration {
var timeDuration time.Duration

Expand Down Expand Up @@ -239,6 +230,12 @@ func (duration *Duration) ToTimeDuration() time.Duration {
return timeDuration
}

// ToTimeDurationFrom returns the exact time.Duration by applying the duration to ref via
// Shift and subtracting. Accurate for all units including Years and Months.
func (duration *Duration) ToTimeDurationFrom(ref time.Time) time.Duration {
return duration.Shift(ref).Sub(ref)
}

// String returns the ISO8601 duration string for the *Duration
func (duration *Duration) String() string {
d := "P"
Expand Down Expand Up @@ -293,6 +290,47 @@ func (duration *Duration) String() string {
return d
}

// Shift applies the duration to t using calendar arithmetic and returns the result.
// Years and Months are applied with day-preservation clamping per ISO 8601: if the
// resulting day would exceed the last day of the target month, it is clamped to that
// last day (e.g. Jan 31 + 1M = Feb 28, not March 3).
// Fractional Years/Months are truncated; fractional Weeks/Days carry into the ns offset.
func (duration *Duration) Shift(t time.Time) time.Time {
sign := 1
if duration.Negative {
sign = -1
}

years := int(duration.Years) * sign
months := int(duration.Months) * sign

totalDays := duration.Weeks*7 + duration.Days
wholeDays := math.Trunc(totalDays)
fracDayNs := (totalDays - wholeDays) * float64(nsPerDay)

days := int(wholeDays) * sign

// Compute the intended target month before AddDate so overflow can be detected.
normTarget := time.Date(t.Year()+years, t.Month()+time.Month(months), 1, 0, 0, 0, 0, t.Location())

t = t.AddDate(years, months, days)

// Clamp per ISO 8601: if AddDate overflowed into the next month (i.e. no explicit
// day offset was requested), step back to the last day of the intended month.
if days == 0 && t.Month() != normTarget.Month() {
t = time.Date(t.Year(), t.Month(), 1, t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), t.Location()).AddDate(0, 0, -1)
}

ns := fracDayNs +
duration.Hours*float64(nsPerHour) +
duration.Minutes*float64(nsPerMinute) +
duration.Seconds*float64(nsPerSecond)

t = t.Add(time.Duration(math.Round(ns)) * time.Duration(sign))

return t
}

// MarshalJSON satisfies the Marshaler interface by return a valid JSON string representation of the duration
func (duration Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(duration.String())
Expand Down
159 changes: 157 additions & 2 deletions duration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,29 @@ func TestFromTimeDuration(t *testing.T) {
Negative: true,
},
},
{
give: time.Hour * 24 * 7,
want: &Duration{
Weeks: 1,
},
},
{
give: time.Hour * 24 * 365,
want: &Duration{
Weeks: 52,
Days: 1,
},
},
{
give: time.Second * 465461651,
want: &Duration{
Weeks: 769,
Days: 4,
Hours: 6,
Minutes: 54,
Seconds: 11,
},
},
}
for _, tt := range tests {
t.Run(tt.give.String(), func(t *testing.T) {
Expand Down Expand Up @@ -167,11 +190,11 @@ func TestFormat(t *testing.T) {
},
{
give: time.Second * 465461651,
want: "P14Y9M3DT12H54M11S",
want: "P769W4DT6H54M11S",
},
{
give: -time.Hour * 99544,
want: "-P11Y4M1W4D",
want: "-P592W3DT16H",
},
{
give: -time.Second * 10,
Expand Down Expand Up @@ -406,3 +429,135 @@ func TestDuration_UnmarshalText(t *testing.T) {
t.Errorf("Text Unmarshal ptr got = %s, want %s", &dur, expected)
}
}

func TestShift(t *testing.T) {
var (
ref = time.Date(2020, 3, 15, 10, 30, 0, 0, time.UTC)
jan31 = time.Date(2025, 1, 31, 0, 0, 0, 0, time.UTC)
feb292024 = time.Date(2024, 2, 29, 0, 0, 0, 0, time.UTC)
)

tests := []struct {
name string
d *Duration
ref time.Time
want time.Time
}{
{
name: "zero",
d: &Duration{},
ref: ref,
want: ref,
},
{
name: "1 month from Jan 31 clamps to Feb 28",
d: &Duration{Months: 1},
ref: jan31,
want: time.Date(2025, 2, 28, 0, 0, 0, 0, time.UTC),
},
{
name: "1 year from leap Feb 29 clamps to Feb 28",
d: &Duration{Years: 1},
ref: feb292024,
want: time.Date(2025, 2, 28, 0, 0, 0, 0, time.UTC),
},
{
name: "1 month from Jan 30 lands on Feb 28 (not clamped, day exists)",
d: &Duration{Months: 1},
ref: time.Date(2025, 1, 28, 0, 0, 0, 0, time.UTC),
want: time.Date(2025, 2, 28, 0, 0, 0, 0, time.UTC),
},
{
name: "full calendar duration",
d: &Duration{Years: 1, Months: 2, Days: 3, Hours: 4, Minutes: 5, Seconds: 6},
ref: ref,
want: time.Date(2021, 5, 18, 14, 35, 6, 0, time.UTC),
},
{
name: "negative shifts backward",
d: &Duration{Hours: 2, Negative: true},
ref: ref,
want: time.Date(2020, 3, 15, 8, 30, 0, 0, time.UTC),
},
{
name: "weeks and days and hours",
d: &Duration{Weeks: 1, Days: 2, Hours: 3},
ref: ref,
want: time.Date(2020, 3, 24, 13, 30, 0, 0, time.UTC),
},
{
name: "fractional weeks",
d: &Duration{Weeks: 1.5},
ref: ref,
want: time.Date(2020, 3, 25, 22, 30, 0, 0, time.UTC),
},
{
name: "45 days crosses month boundary",
d: &Duration{Days: 45},
ref: ref,
want: time.Date(2020, 4, 29, 10, 30, 0, 0, time.UTC),
},
{
name: "5 weeks crosses month boundary",
d: &Duration{Weeks: 5},
ref: ref,
want: time.Date(2020, 4, 19, 10, 30, 0, 0, time.UTC),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.d.Shift(tt.ref)
if !got.Equal(tt.want) {
t.Errorf("Shift() = %v, want %v", got, tt.want)
}
})
}
}

func TestToTimeDurationFrom(t *testing.T) {
tests := []struct {
name string
d *Duration
ref time.Time
want time.Duration
}{
{
name: "1 month from March 1 is 31 days",
d: &Duration{Months: 1},
ref: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC),
want: 31 * 24 * time.Hour,
},
{
name: "1 year from Jan 1 non-leap is 365 days",
d: &Duration{Years: 1},
ref: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
want: 365 * 24 * time.Hour,
},
{
name: "1 year from Jan 1 leap year is 366 days",
d: &Duration{Years: 1},
ref: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
want: 366 * 24 * time.Hour,
},
{
name: "negative 1 month from April 1 is -31 days",
d: &Duration{Months: 1, Negative: true},
ref: time.Date(2025, 4, 1, 0, 0, 0, 0, time.UTC),
want: -31 * 24 * time.Hour,
},
{
name: "sub-year duration matches ToTimeDuration",
d: &Duration{Days: 3, Hours: 2},
ref: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
want: 3*24*time.Hour + 2*time.Hour,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.d.ToTimeDurationFrom(tt.ref)
if got != tt.want {
t.Errorf("ToTimeDurationFrom() = %v, want %v", got, tt.want)
}
})
}
}
28 changes: 24 additions & 4 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,33 @@ func main() {
}

fmt.Println(d.ToTimeDuration() == time.Second*33+time.Millisecond*300) // true

// For durations with years or months, use Shift or ToTimeDurationFrom
// with a reference time to get exact calendar arithmetic.
ref := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)

d, err = duration.Parse("P1Y")
if err != nil {
panic(err)
}
fmt.Println(d.Shift(ref)) // 2025-01-01 00:00:00 +0000 UTC
fmt.Println(d.ToTimeDurationFrom(ref)) // 8784h0m0s (366 days, 2024 is a leap year)
}
```

## correctness

This module aims to implement the ISO 8601 duration specification correctly. It properly supports fractional units and has unit tests
that assert the correctness of it's parsing and conversion to a `time.Duration`.
This module aims to implement the ISO 8601 duration specification correctly. It properly supports fractional units and has unit tests that assert the correctness of its parsing and conversions.

### `FromTimeDuration` and `Format`

`FromTimeDuration` decomposes a `time.Duration` into exact fixed-length units only (Weeks, Days, Hours, Minutes, Seconds). It never produces Years or Months, since those are calendar-dependent and cannot be derived from a plain `time.Duration` without a reference point. `Format` delegates to `FromTimeDuration`.

### `ToTimeDuration`

`ToTimeDuration` is exact for all units except Years and Months, which are approximated using fixed-length values (365-day year, 365/12-day month). It is **deprecated** for use with durations that contain Years or Months — use `ToTimeDurationFrom` instead.

### `Shift` and `ToTimeDurationFrom`

For exact calendar arithmetic, use `Shift(ref time.Time) time.Time` or `ToTimeDurationFrom(ref time.Time) time.Duration`. Both implement the ISO 8601 day-preservation rule: if adding months would produce an invalid date, the day is clamped to the last valid day of the target month — for example, January 31 + 1 month = **February 28**, not March 3.

With that said durations with months or years specified will be converted to `time.Duration` with a little fuzziness. Since I
couldn't find a standard value, and they obviously vary, for those I used `2.628e+15` nanoseconds for a month and `3.154e+16` nanoseconds for a year.