Skip to content

Commit 9920424

Browse files
committed
Enhance time arithmetic methods for unit-based calculations and improve documentation
1 parent 8183e9f commit 9920424

2 files changed

Lines changed: 184 additions & 58 deletions

File tree

README.md

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -144,19 +144,61 @@ Unit.new("100 kg").to_s(:stone) # returns 15 stone, 10 lb
144144

145145
### Time Helpers
146146

147-
`Time`, `Date`, and `DateTime` objects can have time units added or subtracted.
147+
Ruby-units extends the `Time`, `Date`, and `DateTime` classes to support unit-based arithmetic,
148+
allowing you to add or subtract durations from time objects naturally.
149+
150+
#### Adding and Subtracting Durations
148151

149152
```ruby
150-
Time.now + Unit.new("10 min")
153+
Time.now + Unit.new("10 min") #=> 10 minutes from now
154+
Time.now - Unit.new("2 hours") #=> 2 hours ago
151155
```
152156

153-
Several helpers have also been defined. Note: If you include the 'Chronic' gem,
154-
you can specify times in natural language.
157+
**Important:** When adding or subtracting large time units (years, decades, centuries),
158+
the duration is first converted to days and rounded to maintain calendar accuracy.
159+
This means `1 year` is treated as approximately 365 days rather than an exact number of seconds.
155160

156161
```ruby
157-
Unit.new('min').since(DateTime.parse('9/18/06 3:00pm'))
162+
Time.now + Unit.new("1 year") #=> Approximately 365 days from now
163+
Time.now - Unit.new("1 decade") #=> Approximately 3650 days ago
164+
```
165+
166+
For more precise durations, use smaller units (hours, minutes, seconds):
167+
168+
```ruby
169+
Time.now + Unit.new("24 hours") #=> Exactly 24 hours from now
170+
```
171+
172+
#### Converting Time to Units
173+
174+
You can convert `Time` objects to units representing the duration since the Unix epoch:
175+
176+
```ruby
177+
Time.now.to_unit #=> Duration in seconds since epoch
178+
Time.now.to_unit('hours') #=> Duration in hours since epoch
179+
Time.now.to_unit('days') #=> Duration in days since epoch
180+
```
181+
182+
#### Creating Time from Units
183+
184+
Use `Time.at` to create a Time object from a duration unit:
185+
186+
```ruby
187+
Time.at(Unit.new("1000 seconds")) #=> Time 1000 seconds after epoch
188+
Time.at(Unit.new("1 hour"), 500, :ms) #=> Time 1 hour + 500 milliseconds after epoch
189+
```
190+
191+
#### Convenience Methods
192+
193+
The `Time.in` method provides a shorthand for calculating future times:
194+
195+
```ruby
196+
Time.in('5 min') #=> 5 minutes from now
197+
Time.in('2 hours') #=> 2 hours from now
158198
```
159199

200+
#### Duration Formats
201+
160202
Durations may be entered as 'HH:MM:SS, usec' and will be returned in 'hours'.
161203

162204
```ruby
@@ -168,6 +210,21 @@ Unit.new('0:30:30') #=> 0.5 h + 30 sec
168210
If only one ":" is present, it is interpreted as the separator between hours and
169211
minutes.
170212

213+
#### Compatibility with Chronic
214+
215+
Several helpers are available for working with natural language time parsing.
216+
Note: If you include the 'Chronic' gem, you can specify times in natural language.
217+
218+
```ruby
219+
Unit.new('min').since(DateTime.parse('9/18/06 3:00pm'))
220+
```
221+
222+
#### Range Errors and DateTime Fallback
223+
224+
If time arithmetic would result in a date outside the valid range for the `Time` class
225+
(typically 1970-2038 on 32-bit systems), ruby-units automatically falls back to using
226+
`DateTime` objects to handle the calculation.
227+
171228
### Ranges
172229

173230
```ruby

lib/ruby_units/time.rb

Lines changed: 122 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -8,77 +8,146 @@ module RubyUnits
88
# is in years, decades, or centuries. This leads to less precise values, but ones that match the
99
# calendar better.
1010
module Time
11-
# Class methods for [Time] objects
11+
# Class methods for [::Time] objects
1212
module ClassMethods
1313
# Convert a duration to a [::Time] object by considering the duration to be
14-
# the number of seconds since the epoch
14+
# the number of seconds since the epoch.
1515
#
16-
# @param [Array<RubyUnits::Unit, Numeric, Symbol, Hash>] args
17-
# @return [::Time]
18-
def at(*args, **kwargs)
19-
case args.first
20-
when RubyUnits::Unit
21-
options = args.last.is_a?(Hash) ? args.pop : kwargs
22-
secondary_unit = args[2] || "microsecond"
23-
case args[1]
24-
when Numeric
25-
super((args.first + RubyUnits::Unit.new(args[1], secondary_unit.to_s)).convert_to("second").scalar, **options)
26-
else
27-
super(args.first.convert_to("second").scalar, **options)
28-
end
29-
else
30-
super
31-
end
16+
# @example Create time from a Unit duration
17+
# Time.at(Unit.new("5 min")) #=> Time object 300 seconds after epoch
18+
#
19+
# @example Create time from Unit with offset
20+
# Time.at(Unit.new("1 h"), 500, :millisecond) #=> Time 1 hour + 500 ms after epoch
21+
#
22+
# @param [Array<RubyUnits::Unit, Numeric, Symbol, Hash>] args Arguments passed to Time.at
23+
# @param [Hash] kwargs Keyword arguments (e.g., in: timezone)
24+
# @return [::Time] A Time object representing the duration since epoch
25+
def at(*args, **)
26+
first_arg = args.first
27+
return super unless first_arg.is_a?(RubyUnits::Unit)
28+
29+
time_in_seconds = calculate_time_in_seconds(first_arg, args[1], args[2])
30+
remaining_args = build_remaining_args(args)
31+
32+
super(time_in_seconds, *remaining_args, **)
3233
end
3334

34-
# @example
35-
# Time.in '5 min'
36-
# @param duration [#to_unit]
37-
# @return [::Time]
35+
# Calculate a future time by adding a duration to the current time.
36+
# This is a convenience method equivalent to Time.now + duration.
37+
#
38+
# @example Get time 5 minutes from now
39+
# Time.in('5 min') #=> Time object 5 minutes in the future
40+
#
41+
# @example Using various duration formats
42+
# Time.in('2 hours') #=> 2 hours from now
43+
# Time.in(Unit.new('30 sec')) #=> 30 seconds from now
44+
#
45+
# @param duration [String, RubyUnits::Unit, #to_unit] A duration that can be converted to a Unit
46+
# @return [::Time] A Time object representing the current time plus the duration
47+
# :reek:UtilityFunction - This is a class method convenience wrapper, state independence is by design
3848
def in(duration)
3949
::Time.now + duration.to_unit
4050
end
51+
52+
private
53+
54+
# Calculate the time in seconds from a Unit and optional offset
55+
# @param base_unit [RubyUnits::Unit] The base time unit
56+
# @param offset_value [Numeric, nil] Optional offset value
57+
# @param offset_unit [String, Symbol, nil] Unit for the offset (default: "microsecond")
58+
# @return [Numeric] Time in seconds
59+
# :reek:UtilityFunction - Private helper method, state independence is acceptable
60+
# :reek:ControlParameter - Default parameter handling is appropriate here
61+
def calculate_time_in_seconds(base_unit, offset_value, offset_unit)
62+
return base_unit.convert_to("second").scalar unless offset_value.is_a?(Numeric)
63+
64+
unit_str = offset_unit&.to_s || "microsecond"
65+
(base_unit + RubyUnits::Unit.new(offset_value, unit_str)).convert_to("second").scalar
66+
end
67+
68+
# Build remaining arguments to pass to super, skipping the first 3 processed args
69+
# @param args [Array] Original arguments array
70+
# @return [Array] Remaining arguments after the first three
71+
# :reek:UtilityFunction - Private helper method, state independence is acceptable
72+
def build_remaining_args(args)
73+
args[3..] || []
74+
end
4175
end
4276

43-
# Convert a [::Time] object to a [RubyUnits::Unit] object. The time is
44-
# considered to be a duration with the number of seconds since the epoch.
77+
# Convert a [::Time] object to a [RubyUnits::Unit] object representing
78+
# the duration in seconds since the Unix epoch (January 1, 1970 00:00:00 UTC).
79+
#
80+
# @example Convert time to unit
81+
# Time.now.to_unit #=> Unit representing seconds since epoch
82+
#
83+
# @example Convert time to specific unit
84+
# Time.now.to_unit('hour') #=> Unit in hours since epoch
4585
#
46-
# @param other [String, RubyUnits::Unit]
47-
# @return [RubyUnits::Unit]
86+
# @param other [String, RubyUnits::Unit, nil] Optional target unit for conversion
87+
# @return [RubyUnits::Unit] A Unit object representing the time as a duration
4888
def to_unit(other = nil)
49-
other ? RubyUnits::Unit.new(self).convert_to(other) : RubyUnits::Unit.new(self)
89+
unit = RubyUnits::Unit.new(self)
90+
other ? unit.convert_to(other) : unit
5091
end
5192

52-
# @param other [::Time, RubyUnits::Unit]
53-
# @return [RubyUnits::Unit, ::Time]
93+
# Add a duration to a time. For large units (years, decades, centuries),
94+
# the duration is first converted to days and rounded to handle calendar complexities.
95+
# If the result would be out of range for Time, falls back to DateTime.
96+
#
97+
# @example Add hours to time
98+
# Time.now + Unit.new('2 hours') #=> Time 2 hours in future
99+
#
100+
# @example Add years (rounded to days)
101+
# Time.now + Unit.new('1 year') #=> Time ~365 days in future
102+
#
103+
# @param other [::Time, RubyUnits::Unit, Numeric] Value to add
104+
# @return [::Time, DateTime] The resulting time, or DateTime if out of Time range
54105
def +(other)
55-
case other
56-
when RubyUnits::Unit
57-
other = other.convert_to("d").round.convert_to("s") if %w[y decade century].include? other.units
58-
begin
59-
super(other.convert_to("s").scalar)
60-
rescue RangeError
61-
to_datetime + other
62-
end
63-
else
64-
super
65-
end
106+
return super unless other.is_a?(RubyUnits::Unit)
107+
108+
duration_in_seconds = convert_to_seconds(other)
109+
super(duration_in_seconds)
110+
rescue RangeError
111+
to_datetime + other
66112
end
67113

68-
# @param other [::Time, RubyUnits::Unit]
69-
# @return [RubyUnits::Unit, ::Time]
114+
# Subtract a duration from a time. For large units (years, decades, centuries),
115+
# the duration is first converted to days and rounded to handle calendar complexities.
116+
# If the result would be out of range for Time, falls back to DateTime.
117+
#
118+
# @example Subtract hours from time
119+
# Time.now - Unit.new('2 hours') #=> Time 2 hours in past
120+
#
121+
# @example Subtract years (rounded to days)
122+
# Time.now - Unit.new('1 year') #=> Time ~365 days in past
123+
#
124+
# @param other [::Time, RubyUnits::Unit, Numeric] Value to subtract
125+
# @return [::Time, DateTime, Numeric] The resulting time (DateTime if out of range),
126+
# or numeric difference in seconds if subtracting another Time
70127
def -(other)
71-
case other
72-
when RubyUnits::Unit
73-
other = other.convert_to("d").round.convert_to("s") if %w[y decade century].include? other.units
74-
begin
75-
super(other.convert_to("s").scalar)
76-
rescue RangeError
77-
public_send(:to_datetime) - other
78-
end
79-
else
80-
super
81-
end
128+
return super unless other.is_a?(RubyUnits::Unit)
129+
130+
duration_in_seconds = convert_to_seconds(other)
131+
super(duration_in_seconds)
132+
rescue RangeError
133+
public_send(:to_datetime) - other
134+
end
135+
136+
private
137+
138+
# Convert a unit to seconds, rounding large time units (years, decades, centuries) to days first.
139+
# This handles calendar complexities where years don't have a fixed number of seconds.
140+
#
141+
# @param unit [RubyUnits::Unit] The duration unit to convert
142+
# @return [Numeric] The duration in seconds
143+
# :reek:UtilityFunction - Private helper method, state independence is acceptable
144+
def convert_to_seconds(unit)
145+
normalized_unit = if %w[y decade century].include?(unit.units)
146+
unit.convert_to("d").round.convert_to("s")
147+
else
148+
unit.convert_to("s")
149+
end
150+
normalized_unit.scalar
82151
end
83152
end
84153
end

0 commit comments

Comments
 (0)