Time Math

TimeMath2 is a small, no-dependencies library attempting to make time
arithmetics easier. It provides you with simple, easy-to-remember API, without
any monkey-patching of core Ruby classes, so it can be used alongside
Rails or without it, for any purpose.
Table Of Contents
Features
- No monkey-patching of core classes (now strict; previously existing opt-in
core ext removed in 0.0.5);
- Works with Time, Date and DateTime;
- Accurately preserves timezone offset;
- Simple arithmetics: floor/ceil/round to any time unit (second, hour, year
or whatnot), advance/decrease by any unit;
- Chainable operations, including
construction of "set of operations" value object (like "10:20 at next
month first day"), clean and powerful;
- Easy generation of time sequences
(like "each day from this to that date");
- Measuring of time distances between two timestamps in any units;
- Powerful and flexible resampling of arbitrary time value
arrays/hashes into regular sequences.
Naming
TimeMath
is the best name I know for the task library does, yet
it is already taken. So, with no
other thoughts I came with the ugly solution.
(BTW, the previous version
had some dumb "funny" name for gem and all helper classes, and nobody liked it.)
Reasons
You frequently need to calculate things like "exact midnight of the next
day", but you don't want to monkey-patch all of your integers, tug in
5K LOC of ActiveSupport and you like to have things clean and readable.
Installation
Install it like always:
$ gem install time_math2
or add to your Gemfile
gem 'time_math2', require: 'time_math'
and bundle install
it.
Usage
First, you take time unit you want:
TimeMath[:day]
TimeMath.day
TimeMath.units
Then you use this unit for any math you want:
TimeMath.day.floor(Time.now)
TimeMath.day.ceil(Time.now)
TimeMath.day.advance(Time.now, +10)
Full list of simple arithmetic methods
<unit>.floor(tm)
-- rounds down to nearest <unit>
;
<unit>.ceil(tm)
-- rounds up to nearest <unit>
;
<unit>.round(tm)
-- rounds to nearest <unit>
(up or down);
<unit>.round?(tm)
-- checks if tm
is already round to <unit>
;
<unit>.prev(tm)
-- like floor
, but always decreases:
2015-06-27 13:30
would be converted to 2015-06-27 00:00
by both
floor
and prev
, but
2015-06-27 00:00
would be left intact on floor
, but would be
decreased to 2015-06-26 00:00
by prev
;
<unit>.next(tm)
-- like ceil
, but always increases;
<unit>.advance(tm, amount)
-- increases tm by integer amount of <unit>
s;
<unit>.decrease(tm, amount)
-- decreases tm by integer amount of <unit>
s;
<unit>.range(tm, amount)
-- creates range of tm ... tm + amount <units>
;
<unit>.range_back(tm, amount)
-- creates range of tm - amount <units> ... tm
.
Things to note:
- rounding methods (
floor
, ceil
and company) support optional second
argument—amount of units to round to, like "each 3 hours": hour.floor(tm, 3)
;
- both rounding and advance/decrease methods allow their last argument to
be float/rational, so you can
hour.advance(tm, 1/2r)
and this would
work as you may expect. Non-integer arguments are only supported for
units less than week (because "half of month" have no exact mathematical
sense).
See also Units::Base.
Set of operations as a value object
For example, you want "10 am at next monday". By using atomic time unit
operations, you'll need the code like:
TimeMath.hour.advance(TimeMath.week.ceil(Time.now), 10)
...which is not really readable, to say the least. So, TimeMath
provides
one top-level method allowing to chain any operations you want:
TimeMath(Time.now).ceil(:week).advance(:hour, 10).call
Much more readable, huh?
The best thing about it, that you can prepare "operations list" value
object, and then use it (or pass to methods, or
serialize to YAML and deserialize in some Sidekiq task and so on):
op = TimeMath().ceil(:week).advance(:hour, 10)
op.call(Time.now)
op.call(tm1, tm2, tm3)
op.call(array_of_timestamps)
array_of_timestamps.map(&op)
See also TimeMath()
and underlying TimeMath::Op
class docs.
Time sequence abstraction
Time sequence allows you to generate an array of time values between some
points:
to = Time.now
from = TimeMath.day.floor(to)
seq = TimeMath.hour.sequence(from...to)
p(*seq)
Note that sequence also play well with operation chain described above,
so you can
seq = TimeMath.day.sequence(Time.parse('2016-05-01')...Time.parse('2016-05-04')).advance(:hour, 10).decrease(:min, 5)
seq.to_a
See also Sequence YARD docs.
Measuring time periods
Simple measure: just "how many <unit>
s from date A to date B":
TimeMath.week.measure(Time.parse('2016-05-01'), Time.parse('2016-06-01'))
Measure with remaineder: returns number of <unit>
s between dates and
the date when this number would be exact:
TimeMath.week.measure_rem(Time.parse('2016-05-01'), Time.parse('2016-06-01'))
(on May 29 there would be exactly 4 weeks since May 1).
Multi-unit measuring:
birthday = Time.parse('1983-02-14 13:30')
TimeMath.measure(birthday, Time.now)
puts "%{years}y %{months}m %{weeks}w %{days}d %{hours}h %{minutes}m %{seconds}s" %
TimeMath.measure(birthday, Time.now)
TimeMath.measure(birthday, Time.now, weeks: false)
TimeMath.measure(birthday, Time.now, upto: :day)
Resampling
Resampling is useful for situations when you have some timestamped
data (with variable holes between values), and wantto make it regular,
e.g. for charts drawing.
The most simple (and not very useful) resampling just turns array of
irregular timestamps into regular one:
dates = %w[2016-06-01 2016-06-03 2016-06-06].map(&Date.method(:parse))
TimeMath.day.resample(dates)
TimeMath.week.resample(dates)
TimeMath.month.resample(dates)
Much more useful is hash resampling: when you have a hash of {timestamp => value}
and...
data = {Date.parse('2016-06-01') => 18, Date.parse('2016-06-03') => 8, Date.parse('2016-06-06') => -4}
TimeMath.day.resample(data)
TimeMath.week.resample(data)
TimeMath.month.resample(data)
For values grouping strategy, resample
accepts symbol and block arguments:
TimeMath.week.resample(data, :first)
TimeMath.week.resample(data) { |vals| vals.inject(:+) }
=> {#<Date: 2016-05-30>=>26, #<Date: 2016-06-06>=>-4}
The functionality currently considered experimental, please notify me
about your ideas and use cases via GitHub issues!
Notes on timezones
TimeMath tries its best to preserve timezones of original values. Currently,
it means:
- For
Time
instances, symbolic timezone is preserved; when jumping over
DST border, UTC offset will change and everything remains as expected;
- For
DateTime
Ruby not provides symbolic timezone, only numeric offset;
it is preserved by TimeMath (but be careful about jumping around DST,
offset would not change).
Compatibility notes
TimeMath is known to work on MRI Ruby >= 2.0 and JRuby >= 9.0.0.0.
On Rubinius, some of tests fail and I haven't time to investigate it. If
somebody still uses Rubinius and wants TimeMath to be working properly
on it, please let me know.
Alternatives
There's pretty small and useful AS::Duration
by Janko Marohnić, which is time durations, extracted from ActiveSupport,
but without any ActiveSupport bloat.
Links
Author
Victor Shepelev
License
MIT.