When I’ve started to design time_math2 gem, I’ve already had a strong set of opinions in my head, of why and how it should be done. Like:

  • concise set of well-defined operators for math arithmetics, easy to comprehend and remember;
  • no invasion into Ruby core classes;
  • code using TimeMath should be readable, unambiguous and short.

As for me, I’ve managed to achieve this for simpe operations from the very beginning:

TimeMath.day.floor(Time.now) # => current midnight
TimeMath.day.advance(Time.now, 4) # => 4 days since now
#... and so on.

But, it turned out, typical math arithmetics code frequently require several consequtive operations, like “2 hours and 5 minutes before current midnight”. Also, “amount of time to add to something” turned out to be useful abstraction.

And in those cases TimeMath were still much more verbose (in a bad way! verbosity is not always a drawback) than ActiveSupport’s Time.now.midinight + 1.hour and similar stuff.

In chase of ActiveSupport-alike expressiveness, I’ve made several false moves in previous version:

  • added TimeMath::Span simple object, with usage like TimeMath.day.span(4).before(Time.now);
  • as a last resort for desperates, added optional core_ext.rb, now allowing Time.now.advance_by(:day, 2).

Both steps were obviously false and superfluous, adding several ways of do the same with methods with different names:

TimeMath.day.advance(Time.now, 1)
TimeMath.day.span(1).after(Time.now)
Time.now.advance_by(:day, 1)

Okay, ActiveSupport tends to allow itself in all kind of “useful friendly methods”, with all of midnight, beginning_of_week and others, but we can do better! Or so I hope.

So, let me introduce you to time math operation. It is:

  • chainable;
  • uses exactly the same names and abstraction as atomic operations;
  • behaves well in Ruby idiomatics;
  • can suite as a value object;
  • integrates enchantingly with other innumerous TimeMath abstractions.

Here it is:

Task: calculate 10:20 at first day of current month.

Implementations:

ActiveSupport (presumably, fix me if I’m wrong):

Time.now.beginning_of_month + 10.hours + 20.minutes

Cons: readable, but too many core extensions and method names to remember.

Plain atomic ops of TimeMath:

TimeMath.min.advance(TimeMath.hour.advance(TimeMath.month.floor(Time.now), 10), 20)

Cons: the sense can be grasped, but–it is really un-Ruby-ish, to be honest.

TimeMath chainable operations:

TimeMath(Time.now).floor(:month).advance(:hour, 10).advance(:min, 20).call

Yes, this is still longer than ActiveSupport version, yet it is chainable, readable and introduces minimal new concepts (and no core extensions).

The more I’ve thought about this concept, the more I’ve liked it. And, at some point, I’ve understood this form is also pretty legitimate:

op = TimeMath().floor(:month).advance(:hour, 10).advance(:min, 20)
# => #<TimeMath::Op floor(:month).advance(:hour, 10).advance(:min, 20)>

# What now?.. Whatever!
op.call(Time.now)
# or
op.call(Time.parse('2016-06-01'), Time.parse('2016-05-01'), Time.parse('2016-04-01'))
# or -- we have applicable operation here! Isn't it a block?..
[Time.parse('2016-06-01'), Time.parse('2016-05-01'), Time.parse('2016-04-01')].
  map(&op)

Do you like it? I, for one, do. (OK, I’ve designed it, but that’s not the cause, I swear!)

This is legitimate value object, which, without any additional effort, could be serialized/desirealized with YAML (and, therefore, be an argument for Sidekiq job, for example). So, you can now with an ease pass concepts like this (“calculate 10:20 at first-of-month for any argument”) wherever you want.

But, there is more.

When you design a good abstraction you know it by the fact it seduces your other abstractions in most of the library. Look, how it goes:

TimeMath.day
  .sequence(Time.parse('2016-06-01')..Time.parse('2016-06-08'))
  .to_a
# => [2016-06-01 00:00:00 +0300, 2016-06-02 00:00:00 +0300, 2016-06-03 00:00:00 +0300, 2016-06-04 00:00:00 +0300, 2016-06-05 00:00:00 +0300, 2016-06-06 00:00:00 +0300, 2016-06-07 00:00:00 +0300, 2016-06-08 00:00:00 +0300]

# ...and...
# why not?
TimeMath.day
  .sequence(Time.parse('2016-06-01')..Time.parse('2016-06-08'))
  .advance(:hour, 10).to_a
# => [2016-06-01 10:00:00 +0300, 2016-06-02 10:00:00 +0300, 2016-06-03 10:00:00 +0300, 2016-06-04 10:00:00 +0300, 2016-06-05 10:00:00 +0300, 2016-06-06 10:00:00 +0300, 2016-06-07 10:00:00 +0300, 2016-06-08 10:00:00 +0300]

Neat, huh?..

So, to the meat. TimeMath 0.0.5 adds the aforementioned operation object (including addition of it to sequences) and abandons any attempt to core_ext’ing Ruby classes (even opt-in ones).

It also adds:

  • support for Date, previously shamefully absent;
  • float/rational advances, yay for the hour.advance(Time.now, 1/3r);
  • arbitrary float/ceil, enjoy hour.floor(Time.now, 3) (floor to 3-hour mark), floats/rationals work too;
  • better syntax/semantics for time sequences creation (thanks Kenn Ejima for pointing at problem);
  • experimental (yet already pretty cool) time arrays resampling;
  • hell of cleanup and polishing.

Stay tuned! I feel like I already know how to solve entire crontab case with only 2 more core methods in the gem ;)