Skip to content

Commit

Permalink
Merge pull request #7 from seamusabshere/fancyintervals
Browse files Browse the repository at this point in the history
parse most forms of ISO 8601 intervals, including shorthand
  • Loading branch information
rossmeissl committed Feb 21, 2012
2 parents b4587a6 + d275317 commit 8a866a2
Show file tree
Hide file tree
Showing 6 changed files with 171 additions and 14 deletions.
1 change: 0 additions & 1 deletion Gemfile
Expand Up @@ -5,5 +5,4 @@ gemspec
# development dependencies
gem 'yard'
gem 'minitest'
gem 'home_run'
gem 'rake'
25 changes: 25 additions & 0 deletions README.markdown
Expand Up @@ -2,6 +2,31 @@

A Ruby class for describing and interacting with timeframes.

## Based on ISO 8601

As [documented by wikipedia](http://en.wikipedia.org/wiki/ISO_8601#Time_intervals), time intervals are like:

1. Start and end, such as `2007-03-01T13:00:00Z/2008-05-11T15:30:00Z`
2. Start and duration, such as `2007-03-01T13:00:00Z/P1Y2M10DT2H30M`
3. Duration and end, such as `P1Y2M10DT2H30M/2008-05-11T15:30:00Z`
4. Duration only, such as `P1Y2M10DT2H30M`, with additional context information [not supported]

or more simply

<start>/<end>
<start>/<duration>
<duration>/<end>
<duration> [not supported]

## Precision

Currently the end result is precise to 1 day, so these are the same:

* `2007-03-01T00:00:00Z/2008-05-11T00:00:00Z`
* `2007-03-01/2008-05-11`

This may change in the future.

## Documentation

http://rdoc.info/projects/rossmeissl/timeframe
Expand Down
5 changes: 0 additions & 5 deletions Rakefile
@@ -1,8 +1,3 @@
# must be included before rubygems or bundler
if ENV['TIMEFRAME_HOME_RUN'] == 'true'
require 'home_run'
end

require 'bundler'
Bundler::GemHelper.install_tasks

Expand Down
9 changes: 7 additions & 2 deletions lib/timeframe.rb
Expand Up @@ -3,6 +3,8 @@
require 'active_support/version'
require 'active_support/core_ext' if ActiveSupport::VERSION::MAJOR >= 3

require 'timeframe/iso_8601'

# Encapsulates a timeframe between two dates. The dates provided to the class are always until the last date. That means
# that the last date is excluded.
#
Expand Down Expand Up @@ -43,8 +45,11 @@ def mid(number)
# Construct a new Timeframe by parsing an ISO 8601 time interval string
# http://en.wikipedia.org/wiki/ISO_8601#Time_intervals
def from_iso8601(str)
raise ArgumentError, 'Intervals should be specified according to ISO 8601, method 1, eliding times' unless str =~ /^\d\d\d\d-\d\d-\d\d\/\d\d\d\d-\d\d-\d\d$/
new *str.split('/')
delimiter = str.include?('/') ? '/' : '--'
a_raw, b_raw = str.split delimiter
a = Iso8601::A.new a_raw
b = Iso8601::B.new b_raw
new a.to_time(b), b.to_time(a)
end

# Construct a new Timeframe from a hash with keys startDate and endDate
Expand Down
93 changes: 93 additions & 0 deletions lib/timeframe/iso_8601.rb
@@ -0,0 +1,93 @@
class Timeframe
module Iso8601
# Internal use.
#
# Parses a duration like 'P1Y2M4DT3H4M2S'
class Duration < ::Struct.new(:date_part, :time_part)
def seconds
(y*31_556_926 + m*2_629_743.83 + d*86_400 + h*3_600 + minutes*60 + s).ceil
end
private
def y; @y ||= parse(date_part, :Y); end
def m; @m ||= parse(date_part, :M); end
def d; @d ||= parse(date_part, :D); end
def h; @h ||= parse(time_part, :H); end
def minutes; @minutes ||= parse(time_part, :M); end
def s; @s ||= parse(time_part, :S); end
def parse(part, indicator)
if part =~ /(\d+)#{indicator.to_s}/
$1.to_f
else
0
end
end
end

# Internal use.
class Side
# We add one day because so that it can be excluded per timeframe's conventions.
EXCLUDED_LAST_DAY = 86_400
attr_reader :date_part, :time_part
def to_time(counterpart)
if date_part.start_with?('P')
counterpart.resolve_time(self) + resolve_offset + EXCLUDED_LAST_DAY
else
resolve_time counterpart
end
end
end

# Internal use.
#
# The "A" side of "A/B"
class A < Side
def initialize(raw)
raw = raw.upcase
@date_part, @time_part = raw.split('T')
@time_part ||= ''
end
def resolve_time(*)
Time.parse [date_part, time_part].join('T')
end
# When A is a period, it counts as a negative offset to B.
def resolve_offset
0.0 - Duration.new(date_part, time_part).seconds
end
end

# Internal use.
#
# The "B" side of "A/B"
class B < Side
def initialize(raw)
raw = raw.upcase
if raw.include?(':') and not raw.include?('T')
# it's just a shorthand for time
@date_part = ''
@time_part = raw
else
@date_part, @time_part = raw.split('T')
@time_part ||= ''
end
end
# When shorthand is used, we need to peek at our counterpart (A) in order to steal letters
# Shorthand can only be used on the B side, and only in <start>/<end> format.
def resolve_time(counterpart)
filled_in_date_part = unless date_part.count('-') == 2
counterpart.date_part[0..(0-date_part.length-1)] + date_part
else
date_part
end
filled_in_time_part = if time_part.count(':') < 2
counterpart.time_part[0..(0-time_part.length-1)] + time_part
else
time_part
end
Time.parse [filled_in_date_part, filled_in_time_part].join('T')
end
def resolve_offset
Duration.new(date_part, time_part).seconds
end
end
end
end
52 changes: 46 additions & 6 deletions spec/timeframe_spec.rb
Expand Up @@ -59,7 +59,7 @@
end
end

describe '.constrained_new' do
describe :constrained_new do
let(:start) { Date.parse('2008-02-14') }
let(:finish) { Date.parse('2008-05-10') }
let(:constraint_start) { Date.parse('2008-01-01') }
Expand Down Expand Up @@ -108,7 +108,7 @@
end
end

describe '.this_year' do
describe :this_year do
it "should return the current year" do
Timeframe.this_year.must_equal Timeframe.new(:year => Time.now.year)
end
Expand Down Expand Up @@ -190,7 +190,7 @@

describe '#/' do
it "should return a fraction of another timeframe" do
(Timeframe.new(:month => 4, :year => 2009) / Timeframe.new(:year => 2009)).must_equal (30.0 / 365.0)
(Timeframe.new(:month => 4, :year => 2009) / Timeframe.new(:year => 2009)).must_equal(30.0 / 365.0)
end
end

Expand Down Expand Up @@ -224,10 +224,50 @@
end
end

describe '.parse' do
it 'understands ISO8601' do
Timeframe.parse('2009-01-01/2010-01-01').must_equal Timeframe.new(:year => 2009)
describe :parse do
describe 'ISO 8601 <start>/<end>' do
it 'works without time' do
Timeframe.parse('2007-03-01/2008-05-11').must_equal Timeframe.new(Date.new(2007, 3, 1), Date.new(2008, 5, 11))
Timeframe.parse('2007-03-01--2008-05-11').must_equal Timeframe.new(Date.new(2007, 3, 1), Date.new(2008, 5, 11))
end
it 'works with time' do
Timeframe.parse('2007-03-01T13:00:00Z/2008-05-11T15:30:00Z').must_equal Timeframe.new(Date.new(2007, 3, 1), Date.new(2008, 5, 11))
Timeframe.parse('2007-03-01T13:00:00Z--2008-05-11T15:30:00Z').must_equal Timeframe.new(Date.new(2007, 3, 1), Date.new(2008, 5, 11))
end
it 'takes shorthand' do
Timeframe.parse('2007-11-13/15').must_equal Timeframe.new(Date.new(2007, 11, 13), Date.new(2007, 11, 15)) # "2007-11-13/15", i.e. from any time on 2007-11-13 to any time on 2007-11-15
Timeframe.parse("2008-02-15/03-14").must_equal Timeframe.new(Date.new(2008, 2, 15), Date.new(2008, 3, 14)) # "2008-02-15/2008-03-14"
Timeframe.parse("2007-12-14T13:30/15:30").must_equal Timeframe.new(Date.new(2007, 12, 14), Date.new(2007, 12, 14)) # "2007-12-14T13:30/2007-12-14T15:30".. imprecise!
end
end

describe 'ISO 8601 <start>/<duration>' do
it 'works without time' do
Timeframe.parse('2007-03-01/P1Y2M10DT2H30M').must_equal Timeframe.new(Date.new(2007, 3, 1), Date.new(2008, 5, 11))
Timeframe.parse('2007-03-01--P1Y2M10DT2H30M').must_equal Timeframe.new(Date.new(2007, 3, 1), Date.new(2008, 5, 11))
end
it 'works with time' do
Timeframe.parse('2007-03-01T13:00:00Z/P1Y2M10DT2H30M').must_equal Timeframe.new(Date.new(2007, 3, 1), Date.new(2008, 5, 11))
Timeframe.parse('2007-03-01T13:00:00Z--P1Y2M10DT2H30M').must_equal Timeframe.new(Date.new(2007, 3, 1), Date.new(2008, 5, 11))
end
end

# note that 2008 was a leap year
describe 'ISO 8601 <duration>/<end>' do
it 'works with leap years' do
Timeframe.parse('2007-02-28--P1Y').must_equal Timeframe.new(Date.new(2007, 2, 28), Date.new(2008, 2, 29))
Timeframe.parse('P1Y--2008-02-29').must_equal Timeframe.new(Date.new(2007, 3, 1), Date.new(2008, 2, 29))
end
it 'works without time' do
Timeframe.parse('P1Y2M10DT2H30M/2008-05-11').must_equal Timeframe.new(Date.new(2007, 3, 2), Date.new(2008, 5, 11))
Timeframe.parse('P1Y2M10DT2H30M--2008-05-11').must_equal Timeframe.new(Date.new(2007, 3, 2), Date.new(2008, 5, 11))
end
it 'works with time' do
Timeframe.parse('P1Y2M10DT2H30M/2008-05-11T15:30:00Z').must_equal Timeframe.new(Date.new(2007, 3, 3), Date.new(2008, 5, 11))
Timeframe.parse('P1Y2M10DT2H30M--2008-05-11T15:30:00Z').must_equal Timeframe.new(Date.new(2007, 3, 3), Date.new(2008, 5, 11))
end
end

it 'understands plain year' do
plain_year = 2009
Timeframe.parse(plain_year).must_equal Timeframe.new(:year => plain_year)
Expand Down

0 comments on commit 8a866a2

Please sign in to comment.