diff --git a/duration.go b/duration.go index 832ce5d..96ca573 100644 --- a/duration.go +++ b/duration.go @@ -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 { @@ -167,28 +166,20 @@ 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() @@ -196,17 +187,17 @@ func FromTimeDuration(d time.Duration) *Duration { 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 @@ -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" @@ -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()) diff --git a/duration_test.go b/duration_test.go index 20e29e0..00b2500 100644 --- a/duration_test.go +++ b/duration_test.go @@ -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) { @@ -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, @@ -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) + } + }) + } +} diff --git a/readme.md b/readme.md index a1d9f37..d8fa015 100644 --- a/readme.md +++ b/readme.md @@ -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.