diff --git a/spec/std/time/time_spec.cr b/spec/std/time/time_spec.cr index 7ff5788a8e85..f7c8ef67daf6 100644 --- a/spec/std/time/time_spec.cr +++ b/spec/std/time/time_spec.cr @@ -215,7 +215,7 @@ describe Time do (time == time.clone).should be_true end - describe "#add_span" do + describe "#shift" do it "adds hours, minutes, seconds" do t1 = Time.utc(2002, 2, 25, 15, 25, 13) t2 = t1 + Time::Span.new 3, 54, 1 @@ -244,10 +244,10 @@ describe Time do location = Time::Location.fixed(offset) time = Time.new(1, 1, 1, location: location) - time.add_span(0, 1).should eq Time.new(1, 1, 1, nanosecond: 1, location: location) - time.add_span(0, 0).should eq time + time.shift(0, 1).should eq Time.new(1, 1, 1, nanosecond: 1, location: location) + time.shift(0, 0).should eq time expect_raises(ArgumentError) do - time.add_span(0, -1) + time.shift(0, -1) end end end @@ -257,17 +257,44 @@ describe Time do location = Time::Location.fixed(offset) time = Time.new(9999, 12, 31, 23, 59, 59, nanosecond: 999_999_999, location: location) - time.add_span(0, -1).should eq Time.new(9999, 12, 31, 23, 59, 59, nanosecond: 999_999_998, location: location) - time.add_span(0, 0).should eq time + time.shift(0, -1).should eq Time.new(9999, 12, 31, 23, 59, 59, nanosecond: 999_999_998, location: location) + time.shift(0, 0).should eq time expect_raises(ArgumentError) do - time.add_span(0, 1) + time.shift(0, 1) end end end it "adds zero span" do time = Time.now - time.add_span(0, 0).should eq time + time.shift(0, 0).should eq time + end + + describe "irregular calendrical unit ratios" do + it "shifts by a week if one day is left out" do + # The week from 2011-12-25 to 2012-01-01 for example lasted only 6 days in Samoa, + # because it skipped 2011-12-28 due to changing time zone from -11:00 to +13:00. + with_zoneinfo do + samoa = Time::Location.load("Pacific/Apia") + start = Time.new(2011, 12, 25, 0, 0, 0, location: samoa) + + plus_one_week = start.shift days: 7 + plus_one_week.should eq start + 6.days + + plus_one_year = start.shift years: 1 + plus_one_year.should eq start + 365.days # 2012 is a leap year so it should've been 366 days, but 2011-12-28 was skipped + end + end + + it "shifts by conceptual hour even if elapsed time is less" do + # Venezuela switched from -4:30 to -4:00 on 2016-05-01, the hour between 2:00 and 3:00 lasted only 30 minutes + with_zoneinfo do + venezuela = Time::Location.load("America/Caracas") + start = Time.new(2016, 5, 1, 2, 0, 0, location: venezuela) + plus_one_hour = start.shift hours: 1 + plus_one_hour.should eq start + 30.minutes + end + end end describe "adds days" do @@ -284,13 +311,13 @@ describe Time do time.should eq Time.utc(2002, 3, 2, 17, 49, 13) end - pending "over dst" do + it "over dst" do with_zoneinfo do location = Time::Location.load("Europe/Berlin") reference = Time.new(2017, 10, 28, 13, 37, location: location) - next_day = Time.new(2017, 10, 29, 13, 37, location: location) + next_day = reference.shift days: 1 - (reference + 1.day).should eq next_day + next_day.should eq reference + 25.hours end end @@ -301,44 +328,59 @@ describe Time do end end + pending "out of range max (shift days)" do + # this will be fixed with raise on overflow + time = Time.utc(2002, 2, 25, 15, 25, 13) + expect_raises ArgumentError do + time.shift days: 10000000 + end + end + it "out of range min" do time = Time.utc(2002, 2, 25, 15, 25, 13) expect_raises ArgumentError do time - 10000000.days end end + + pending "out of range min (shift days)" do + # this will be fixed with raise on overflow + time = Time.utc(2002, 2, 25, 15, 25, 13) + expect_raises ArgumentError do + time.shift days: -10000000 + end + end end it "adds months" do t = Time.utc 2014, 10, 30, 21, 18, 13 - t2 = t + 1.month + t2 = t.shift months: 1 t2.should eq Time.utc(2014, 11, 30, 21, 18, 13) - t2 = t + 1.months + t2 = t.shift months: 1 t2.should eq Time.utc(2014, 11, 30, 21, 18, 13) t = Time.utc 2014, 10, 31, 21, 18, 13 - t2 = t + 1.month + t2 = t.shift months: 1 t2.should eq Time.utc(2014, 11, 30, 21, 18, 13) t = Time.utc 2014, 10, 31, 21, 18, 13 - t2 = t - 1.month + t2 = t.shift months: -1 t2.should eq Time.utc(2014, 9, 30, 21, 18, 13) t = Time.utc 2014, 10, 31, 21, 18, 13 - t2 = t + 6.month + t2 = t.shift months: 6 t2.should eq Time.utc(2015, 4, 30, 21, 18, 13) end it "adds years" do t = Time.utc 2014, 10, 30, 21, 18, 13 - - t2 = t + 1.year + t2 = t.shift years: 1 t2.should eq Time.utc(2015, 10, 30, 21, 18, 13) t = Time.utc 2014, 10, 30, 21, 18, 13 - t2 = t - 2.years + t2 = t.shift years: -2 t2.should eq Time.utc(2012, 10, 30, 21, 18, 13) end @@ -356,15 +398,16 @@ describe Time do end it "adds nanoseconds" do - time = Time.utc(2002, 2, 25, 15, 25, 13) - time = time + 1e16.nanoseconds - time.should eq Time.utc(2002, 6, 21, 9, 11, 53) + t1 = Time.utc(2002, 2, 25, 15, 25, 13) + t1 = t1.shift nanoseconds: 10_000_000_000_000_000 + + t1.should eq Time.utc(2002, 6, 21, 9, 11, 53) - time = time - 19e16.nanoseconds - time.should eq Time.utc(1996, 6, 13, 7, 25, 13) + t1 = t1.shift nanoseconds: -190_000_000_000_000_000 + t1.should eq Time.utc(1996, 6, 13, 7, 25, 13) - time = time + 15_623_487.nanoseconds - time.should eq Time.utc(1996, 6, 13, 7, 25, 13, nanosecond: 15_623_487) + t1 = t1.shift nanoseconds: 15_623_000 + t1.should eq Time.utc(1996, 6, 13, 7, 25, 13, nanosecond: 15_623_000) end it "preserves location when adding" do diff --git a/src/time.cr b/src/time.cr index bb4308de434c..49be144463aa 100644 --- a/src/time.cr +++ b/src/time.cr @@ -558,16 +558,16 @@ struct Time # Returns a copy of this `Time` with *span* added. # - # See `#add_span` for details. + # See `#shift` for details. def +(span : Time::Span) : Time - add_span span.to_i, span.nanoseconds + shift span.to_i, span.nanoseconds end # Returns a copy of this `Time` with *span* subtracted. # - # See `#add_span` for details. + # See `#shift` for details. def -(span : Time::Span) : Time - add_span -span.to_i, -span.nanoseconds + shift -span.to_i, -span.nanoseconds end # Returns a copy of this `Time` with *span* added. @@ -585,7 +585,7 @@ struct Time # If the resulting date-time is ambiguous due to time zone transitions, # a correct time will be returned, but it does not guarantee which. def +(span : Time::MonthSpan) : Time - add_months span.value + shift months: span.value.to_i end # Returns a copy of this `Time` with *span* subtracted. @@ -603,37 +603,15 @@ struct Time # If the resulting date-time is ambiguous due to time zone transitions, # a correct time will be returned, but it does not guarantee which. def -(span : Time::MonthSpan) : Time - add_months -span.value + shift months: -span.value.to_i end - private def add_months(months) - day = self.day - month = self.month + months.remainder(12) - year = self.year + months.tdiv(12) - - if month < 1 - month = 12 + month - year -= 1 - elsif month > 12 - month = month - 12 - year += 1 - end - - maxday = Time.days_in_month(year, month) - if day > maxday - day = maxday - end - - temp = Time.new(year, month, day, location: location) - temp + time_of_day - end - - # Returns a copy of this `Time` with the number of *seconds* and - # *nanoseconds* added. + # Returns a copy of this `Time` shifted by the number of *seconds* and + # *nanoseconds*. # # Positive values result in a later time, negative values in an earlier time. # - # This operates on the instant time-line, such that adding the eqivalent of + # This operates on the instant time-line, such that adding the equivalent of # one hour will always be a duration of one hour later. # The local date-time representation may change by a different amount, # depending on time zone transitions. @@ -643,7 +621,7 @@ struct Time # There is no explicit limit on the input values but the addition must result # in a valid time between `0001-01-01 00:00:00.0` and # `9999-12-31 23:59:59.999_999_999`. Otherwise `ArgumentError` is raised. - def add_span(seconds : Int, nanoseconds : Int) : Time + def shift(seconds : Int, nanoseconds : Int) : Time if seconds == 0 && nanoseconds == 0 return self end @@ -664,6 +642,96 @@ struct Time Time.new(seconds: seconds, nanoseconds: nanoseconds.to_i, location: location) end + # Returns a copy of this `Time` shifted by the amount of calendrical units + # provided as arguments. + # + # Positive values result in a later time, negative values in an earlier time. + # + # This operates on the local time-line, such that the local date-time + # represenation of the result will be apart by the specified amounts, but the + # elapsed time between both instances might not equal to the combined default + # durations + # This is the case for example when adding a day over a daylight-savings time + # change: + # + # ``` + # start = Time.new(2017, 10, 28, 13, 37, location: Time::Location.load("Europe/Berlin")) + # one_day_later = start.shift days: 1 + # + # one_day_later - start # => 25.hours + # ``` + # + # *years* is equivalent to `12` months and *weeks* is equivalent to `7` days. + # + # If the day-of-month resulting from shifting by *years* and *months* would be + # invalid, the date is adjusted to the last valid day of the month. + # For example, adding one month to `2018-07-31` would result in the invalid + # date `2018-08-31` which will be adjusted to `2018-08-30`: + # ``` + # Time.utc(2018, 7, 31).shift(months: 1) # => Time.utc(2018, 8, 30) + # ``` + # + # Overflow in smaller units is transferred to the next larger unit. + # + # Changes are applied in the same order as the arguments, sorted by increasing + # granularity. This is relevant because the order of operations can change the result: + # + # ``` + # Time.utc(2018, 7, 31).shift(months: 1, days: -1) # => Time.utc(2018, 8, 29) + # Time.utc(2018, 7, 31).shift(months: 1).shift(days: -1) # => Time.utc(2018, 8, 29) + # Time.utc(2018, 7, 31).shift(days: -1).shift(months: 1) # => Time.utc(2018, 8, 30) + # ``` + # + # There is no explicit limit on the input values but the shift must result + # in a valid time between `0001-01-01 00:00:00.0` and + # `9999-12-31 23:59:59.999_999_999`. Otherwise `ArgumentError` is raised. + # + # If the resulting date-time is ambiguous due to time zone transitions, + # a correct time will be returned, but it does not guarantee which. + def shift(*, years : Int = 0, months : Int = 0, weeks : Int = 0, days : Int = 0, + hours : Int = 0, minutes : Int = 0, seconds : Int = 0, nanoseconds : Int = 0) + seconds = seconds.to_i64 + + # Skip the entire month-based calculations if year and month are zero + if years.zero? && months.zero? + # Using offset_seconds with applied zone offset so that calculations + # are applied to the equivalent UTC representation of this local time. + seconds += offset_seconds + else + year, month, day, _ = to_utc.year_month_day_day_year + + year += years + + months += month + year += months.tdiv(12) + month = months.remainder(12) + + if month < 1 + month = 12 + month + year -= 1 + end + + maxday = Time.days_in_month(year, month) + if day > maxday + day = maxday + end + + seconds += Time.absolute_days(year, month, day).to_i64 * SECONDS_PER_DAY + seconds += offset_seconds % SECONDS_PER_DAY + end + + # FIXME: These operations currently don't have overflow checks applied. + # This should be fixed when operators by default raise on overflow. + seconds += weeks * SECONDS_PER_WEEK + seconds += days * SECONDS_PER_DAY + seconds += hours * SECONDS_PER_HOUR + seconds += minutes * SECONDS_PER_MINUTE + + # Apply the nanosecond shift (including overflow handling) and transform to + # local time zone in `location`: + Time.utc(seconds: seconds, nanoseconds: self.nanosecond).shift(0, nanoseconds).to_local_in(location) + end + # Returns a `Time::Span` amounting to the duration between *other* and `self`. # # The time span is negative if `self` is before *other*. @@ -1366,7 +1434,7 @@ struct Time @seconds + offset end - private def year_month_day_day_year + protected def year_month_day_day_year m = 1 days = DAYS_MONTH diff --git a/src/time/format/parser.cr b/src/time/format/parser.cr index c619f05d5f64..cd3fd968e3bb 100644 --- a/src/time/format/parser.cr +++ b/src/time/format/parser.cr @@ -55,7 +55,7 @@ struct Time::Format time = Time.new @year, @month, @day, @hour, @minute, @second, nanosecond: @nanosecond, location: location end - time = time.add_span 0, @nanosecond_offset + time = time.shift 0, @nanosecond_offset time end