zverok / time_math2

Small library for operations with time steps (like "next day", "floor to hour" and so on)
MIT License
278 stars 8 forks source link

Time Math

Gem Version Dependency Status Code Climate Build Status Coverage Status

TimeCalc is the next iteration of ideas for the time-arithmetics library, with nicer API and better support for modern Ruby (for example, Ruby 2.6 real timezones). It would be evolved and supported instead of TimeMath. This gem should be considered discontinued.


TimeMath2 ~is~ was 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

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::Units::Day>
# or
TimeMath.day # => #<TimeMath::Units::Day>

# List of units supported:
TimeMath.units
# => [:sec, :min, :hour, :day, :week, :month, :year]

Then you use this unit for any math you want:

TimeMath.day.floor(Time.now) # => 2016-05-28 00:00:00 +0300
TimeMath.day.ceil(Time.now) # => 2016-05-29 00:00:00 +0300
TimeMath.day.advance(Time.now, +10) # => 2016-06-07 14:06:57 +0300
# ...and so on

Full list of simple arithmetic methods

Things to note:

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)
# => #<TimeMath::Op ceil(:week).advance(:hour, 10)>
op.call(Time.now)
# => 2016-06-27 10:00:00 +0300

# It also can be called on several arguments/array of arguments:
op.call(tm1, tm2, tm3)
op.call(array_of_timestamps)
# ...or even used as a block-ish object:
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
# => 2016-05-28 17:47:30 +0300
from = TimeMath.day.floor(to)
# => 2016-05-28 00:00:00 +0300
seq = TimeMath.hour.sequence(from...to)
# => #<TimeMath::Sequence(:hour, 2016-05-28 00:00:00 +0300...2016-05-28 17:47:30 +0300)>
p(*seq)
# 2016-05-28 00:00:00 +0300
# 2016-05-28 01:00:00 +0300
# 2016-05-28 02:00:00 +0300
# 2016-05-28 03:00:00 +0300
# 2016-05-28 04:00:00 +0300
# 2016-05-28 05:00:00 +0300
# 2016-05-28 06:00:00 +0300
# 2016-05-28 07:00:00 +0300
# ...and so on

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)
# => #<TimeMath::Sequence(:day, 2016-05-01 00:00:00 +0300...2016-05-04 00:00:00 +0300).advance(:hour, 10).decrease(:min, 5)>
seq.to_a
# => [2016-05-01 09:55:00 +0300, 2016-05-02 09:55:00 +0300, 2016-05-03 09:55:00 +0300]

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'))
# => 4

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'))
# => [4, 2016-05-29 00:00:00 +0300]

(on May 29 there would be exactly 4 weeks since May 1).

Multi-unit measuring:

# My real birthday, in fact!
birthday = Time.parse('1983-02-14 13:30')

# My full age
TimeMath.measure(birthday, Time.now)
# => {:years=>33, :months=>3, :weeks=>2, :days=>0, :hours=>1, :minutes=>25, :seconds=>52}

# NB: you can use this output with String#format or String%:
puts "%{years}y %{months}m %{weeks}w %{days}d %{hours}h %{minutes}m %{seconds}s" %
  TimeMath.measure(birthday, Time.now)
# 33y 3m 2w 0d 1h 26m 15s

# Option: measure without weeks
TimeMath.measure(birthday, Time.now, weeks: false)
# => {:years=>33, :months=>3, :days=>14, :hours=>1, :minutes=>26, :seconds=>31}

# My full age in days, hours, minutes
TimeMath.measure(birthday, Time.now, upto: :day)
# => {:days=>12157, :hours=>2, :minutes=>26, :seconds=>55}

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))
# => [#<Date: 2016-06-01>, #<Date: 2016-06-03>, #<Date: 2016-06-06>]
TimeMath.day.resample(dates)
# => [#<Date: 2016-06-01>, #<Date: 2016-06-02>, #<Date: 2016-06-03>, #<Date: 2016-06-04>, #<Date: 2016-06-05>, #<Date: 2016-06-06>]
TimeMath.week.resample(dates)
# => [#<Date: 2016-05-30>, #<Date: 2016-06-06>]
TimeMath.month.resample(dates)
# => [#<Date: 2016-06-01>]

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}
# => {#<Date: 2016-06-01>=>18, #<Date: 2016-06-03>=>8, #<Date: 2016-06-06>=>-4}
TimeMath.day.resample(data)
# => {#<Date: 2016-06-01>=>[18], #<Date: 2016-06-02>=>[], #<Date: 2016-06-03>=>[8], #<Date: 2016-06-04>=>[], #<Date: 2016-06-05>=>[], #<Date: 2016-06-06>=>[-4]}
TimeMath.week.resample(data)
# => {#<Date: 2016-05-30>=>[18, 8], #<Date: 2016-06-06>=>[-4]}
TimeMath.month.resample(data)
# => {#<Date: 2016-06-01>=>[18, 8, -4]}

For values grouping strategy, resample accepts symbol and block arguments:

TimeMath.week.resample(data, :first)
# => {#<Date: 2016-05-30>=>18, #<Date: 2016-06-06>=>-4}
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:

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.