lib/timeframe.rb in timeframe-0.0.11 vs lib/timeframe.rb in timeframe-0.1.0
- old
+ new
@@ -1,40 +1,111 @@
require 'date'
+require 'multi_json'
require 'active_support/version'
-%w{
- active_support/core_ext/hash
- active_support/core_ext/array/extract_options
- active_support/core_ext/string/conversions
- active_support/core_ext/date/conversions
- active_support/core_ext/integer/time
- active_support/core_ext/numeric/time
- active_support/json
-}.each do |active_support_3_requirement|
- require active_support_3_requirement
-end if ActiveSupport::VERSION::MAJOR == 3
+require 'active_support/core_ext' if ActiveSupport::VERSION::MAJOR >= 3
# 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.
#
# # from 2007-10-01 00:00:00.000 to 2007-10-31 23:59:59.999
# Timeframe.new(Date(2007,10,1), Date(2007,11,1))
# # and holds 31 days
# Timeframe.new(Date(2007,10,1), Date(2007,11,1)).days #=> 31
class Timeframe
- attr_accessor :from, :to
+ class << self
+ # Shortcut method to return the Timeframe representing the current year (as defined by Time.now)
+ def this_year
+ new :year => Time.now.year
+ end
+
+ # Construct a new Timeframe, but constrain it by another
+ def constrained_new(start_date, end_date, constraint)
+ start_date, end_date = make_dates start_date, end_date
+ raise ArgumentError, 'Constraint must be a Timeframe' unless constraint.is_a? Timeframe
+ raise ArgumentError, "Start date #{start_date} should be earlier than end date #{end_date}" if start_date > end_date
+ if end_date <= constraint.start_date or start_date >= constraint.end_date
+ new constraint.start_date, constraint.start_date
+ elsif start_date.year == end_date.yesterday.year
+ new(start_date, end_date) & constraint
+ elsif start_date.year < constraint.start_date.year and constraint.start_date.year < end_date.yesterday.year
+ constraint
+ else
+ new [constraint.start_date, start_date].max, [constraint.end_date, end_date].min
+ end
+ end
+
+ # Create a timeframe +/- number of years around today
+ def mid(number)
+ start_date = Time.now.today - number.years
+ end_date = Time.now.today + number.years
+ new start_date, end_date
+ end
+
+ # 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('/')
+ end
+
+ # Construct a new Timeframe from a hash with keys startDate and endDate
+ def from_hash(hsh)
+ hsh = hsh.symbolize_keys
+ new hsh[:startDate], hsh[:endDate]
+ end
+
+ # Construct a new Timeframe from a year.
+ def from_year(year)
+ new :year => year.to_i
+ end
+
+ # Automagically parse a Timeframe from either a String or a Hash
+ def parse(input)
+ case input
+ when ::Integer
+ from_year input
+ when ::Hash
+ from_hash input
+ when ::String
+ str = input.strip
+ if str.start_with?('{')
+ from_hash ::MultiJson.decode(str)
+ elsif input =~ /\A\d\d\d\d\z/
+ from_year input
+ else
+ from_iso8601 str
+ end
+ else
+ raise ::ArgumentError, "Must be String or Hash"
+ end
+ end
+ alias :interval :parse
+ alias :from_json :parse
+
+ # Deprecated
+ def multiyear(*args) # :nodoc:
+ new *args
+ end
+ private
+
+ def make_dates(start_date, end_date)
+ [start_date.to_date, end_date.to_date]
+ end
+ end
+
+ attr_reader :start_date
+ attr_reader :end_date
+
# Creates a new instance of Timeframe. You can either pass a start and end Date or a Hash with named arguments,
# with the following options:
#
# <tt>:month</tt>: Start date becomes the first day of this month, and the end date becomes the first day of
# the next month. If no <tt>:year</tt> is specified, the current year is used.
# <tt>:year</tt>: Start date becomes the first day of this year, and the end date becomes the first day of the
# next year.
#
- # By default, Timeframe.new will die if the resulting Timeframe would cross year boundaries. This can be overridden
- # by setting the <tt>:skip_year_boundary_crossing_check</tt> option.
- #
# Examples:
#
# Timeframe.new Date.new(2007, 2, 1), Date.new(2007, 4, 1) # February and March
# Timeframe.new :year => 2004 # The year 2004
# Timeframe.new :month => 4 # April
@@ -43,265 +114,184 @@
options = args.extract_options!
if month = options[:month]
month = Date.parse(month).month if month.is_a? String
year = options[:year] || Date.today.year
- from = Date.new(year, month, 1)
- to = from.next_month
+ start_date = Date.new(year, month, 1)
+ end_date = start_date.next_month
elsif year = options[:year]
- from = Date.new(year, 1, 1)
- to = Date.new(year+1, 1, 1)
+ start_date = Date.new(year, 1, 1)
+ end_date = Date.new(year+1, 1, 1)
end
- from = args.shift.to_date if from.nil? and args.any?
- to = args.shift.to_date if to.nil? and args.any?
+ start_date = args.shift.to_date if start_date.nil? and args.any?
+ end_date = args.shift.to_date if end_date.nil? and args.any?
- raise ArgumentError, "Please supply a start and end date, `#{args.map(&:inspect).to_sentence}' is not enough" if from.nil? or to.nil?
- raise ArgumentError, "Start date #{from} should be earlier than end date #{to}" if from > to
- raise ArgumentError, 'Timeframes that cross year boundaries are dangerous' unless options[:skip_year_boundary_crossing_check] or from.year == to.yesterday.year or from == to
+ raise ArgumentError, "Please supply a start and end date, `#{args.map(&:inspect).to_sentence}' is not enough" if start_date.nil? or end_date.nil?
+ raise ArgumentError, "Start date #{start_date} should be earlier than end date #{end_date}" if start_date > end_date
- @from, @to = from, to
+ @start_date, @end_date = start_date, end_date
end
-
+
def inspect # :nodoc:
- "<Timeframe(#{object_id}) #{days} days starting #{from} ending #{to}>"
+ "<Timeframe(#{object_id}) #{days} days starting #{start_date} ending #{end_date}>"
end
-
+
# The number of days in the timeframe
#
# Timeframe.new(Date.new(2007, 11, 1), Date.new(2007, 12, 1)).days #=> 30
# Timeframe.new(:month => 1).days #=> 31
# Timeframe.new(:year => 2004).days #=> 366
def days
- (to - from).to_i
+ (end_date - start_date).to_i
end
-
+
# Returns true when a Date or other Timeframe is included in this Timeframe
def include?(obj)
- # puts "checking to see if #{date} is between #{from} and #{to}" if Emitter::DEBUG
+ # puts "checking to see if #{date} is between #{start_date} and #{end_date}" if Emitter::DEBUG
case obj
when Date
- (from...to).include?(obj)
+ (start_date...end_date).include?(obj)
when Time
- # (from...to).include?(obj.to_date)
+ # (start_date...end_date).include?(obj.to_date)
raise "this wasn't previously supported, but it could be"
when Timeframe
- from <= obj.from and to >= obj.to
+ start_date <= obj.start_date and end_date >= obj.end_date
end
end
-
+
# Returns true when the parameter Timeframe is properly included in the Timeframe
def proper_include?(other_timeframe)
raise ArgumentError, 'Proper inclusion only makes sense when testing other Timeframes' unless other_timeframe.is_a? Timeframe
- (from < other_timeframe.from) and (to > other_timeframe.to)
+ (start_date < other_timeframe.start_date) and (end_date > other_timeframe.end_date)
end
-
+
# Returns true when this timeframe is equal to the other timeframe
def ==(other)
# puts "checking to see if #{self} is equal to #{other}" if Emitter::DEBUG
return false unless other.is_a?(Timeframe)
- from == other.from and to == other.to
+ start_date == other.start_date and end_date == other.end_date
end
alias :eql? :==
-
+
# Calculates a hash value for the Timeframe, used for equality checking and Hash lookups.
- #--
- # This needs to be an integer or else it won't use #eql?
def hash
- from.hash + to.hash
+ start_date.hash + end_date.hash
end
-
- # Returns an array of month-long subtimeframes
- #--
- # TODO: rename to month_subtimeframes
- def months
- raise ArgumentError, "Please only provide whole-month timeframes to Timeframe#months" unless from.day == 1 and to.day == 1
- raise ArgumentError, 'Timeframes that cross year boundaries are dangerous during Timeframe#months' unless from.year == to.yesterday.year
- year = from.year # therefore this only works in the from year
- (from.month..to.yesterday.month).map { |m| Timeframe.new :month => m, :year => year }
- end
-
+
# Returns the relevant year as a Timeframe
def year
- raise ArgumentError, 'Timeframes that cross year boundaries are dangerous during Timeframe#year' unless from.year == to.yesterday.year
- Timeframe.new :year => from.year
+ raise ArgumentError, 'Timeframes that cross year boundaries are dangerous during Timeframe#year' unless start_date.year == end_date.yesterday.year
+ Timeframe.new :year => start_date.year
end
-
- # Divides a Timeframe into component parts, each no more than a month long.
- #--
- # multiyear safe
- def month_subtimeframes
- (from.year..to.yesterday.year).map do |year|
- (1..12).map do |month|
- Timeframe.new(:year => year, :month => month) & self
- end
- end.flatten.compact
- end
-
- # Like #month_subtimeframes, but will discard partial months
- # multiyear safe
- def full_month_subtimeframes
- month_subtimeframes.map { |st| Timeframe.new(:year => st.from.year, :month => st.from.month) }
- end
-
- # Divides a Timeframe into component parts, each no more than a year long.
- #--
- # multiyear safe
- def year_subtimeframes
- (from.year..to.yesterday.year).map do |year|
- Timeframe.new(:year => year) & self
+
+ # Returns an Array of month-long Timeframes. Partial months are **not** included by default.
+ # http://stackoverflow.com/questions/1724639/iterate-every-month-with-date-objects
+ def months
+ memo = []
+ ptr = start_date
+ while ptr <= end_date do
+ memo.push(Timeframe.new(:year => ptr.year, :month => ptr.month) & self)
+ ptr = ptr >> 1
end
+ memo.flatten.compact
end
-
- # Like #year_subtimeframes, but will discard partial years
- #--
- # multiyear safe
- def full_year_subtimeframes
- (from.year..to.yesterday.year).map do |year|
- Timeframe.new :year => year
- end
- end
-
+
# Crop a Timeframe to end no later than the provided date.
- #--
- # multiyear safe
def ending_no_later_than(date)
- if to < date
+ if end_date < date
self
- elsif from >= date
+ elsif start_date >= date
nil
else
- Timeframe.multiyear from, date
+ Timeframe.new start_date, date
end
end
-
+
# Returns a timeframe representing the intersection of the given timeframes
def &(other_timeframe)
this_timeframe = self
if other_timeframe == this_timeframe
this_timeframe
- elsif this_timeframe.from > other_timeframe.from and this_timeframe.to < other_timeframe.to
+ elsif this_timeframe.start_date > other_timeframe.start_date and this_timeframe.end_date < other_timeframe.end_date
this_timeframe
- elsif other_timeframe.from > this_timeframe.from and other_timeframe.to < this_timeframe.to
+ elsif other_timeframe.start_date > this_timeframe.start_date and other_timeframe.end_date < this_timeframe.end_date
other_timeframe
- elsif this_timeframe.from >= other_timeframe.to or this_timeframe.to <= other_timeframe.from
+ elsif this_timeframe.start_date >= other_timeframe.end_date or this_timeframe.end_date <= other_timeframe.start_date
nil
else
- Timeframe.new [this_timeframe.from, other_timeframe.from].max, [this_timeframe.to, other_timeframe.to].min, :skip_year_boundary_crossing_check => true
+ Timeframe.new [this_timeframe.start_date, other_timeframe.start_date].max, [this_timeframe.end_date, other_timeframe.end_date].min
end
end
-
+
# Returns the fraction (as a Float) of another Timeframe that this Timeframe represents
def /(other_timeframe)
raise ArgumentError, 'You can only divide a Timeframe by another Timeframe' unless other_timeframe.is_a? Timeframe
self.days.to_f / other_timeframe.days.to_f
end
-
+
# Crop a Timeframe by another Timeframe
def crop(container)
raise ArgumentError, 'You can only crop a timeframe by another timeframe' unless container.is_a? Timeframe
- self.class.new [from, container.from].max, [to, container.to].min
+ self.class.new [start_date, container.start_date].max, [end_date, container.end_date].min
end
-
+
# Returns an array of Timeframes representing the gaps left in the Timeframe after removing all given Timeframes
def gaps_left_by(*timeframes)
# remove extraneous timeframes
- timeframes.reject! { |t| t.to <= from }
- timeframes.reject! { |t| t.from >= to }
+ timeframes.reject! { |t| t.end_date <= start_date }
+ timeframes.reject! { |t| t.start_date >= end_date }
# crop timeframes
timeframes.map! { |t| t.crop self }
-
+
# remove proper subtimeframes
timeframes.reject! { |t| timeframes.detect { |u| u.proper_include? t } }
-
+
# escape
return [self] if timeframes.empty?
- timeframes.sort! { |x, y| x.from <=> y.from }
+ timeframes.sort! { |x, y| x.start_date <=> y.start_date }
- a = [ from ] + timeframes.collect(&:to)
- b = timeframes.collect(&:from) + [ to ]
+ a = [ start_date ] + timeframes.collect(&:end_date)
+ b = timeframes.collect(&:start_date) + [ end_date ]
a.zip(b).map do |gap|
- Timeframe.new(*gap) if gap[1] > gap[0]
+ Timeframe.new(*gap, :skip_year_boundary_crossing_check => true) if gap[1] > gap[0]
end.compact
end
-
+
# Returns true if the union of the given Timeframes includes the Timeframe
def covered_by?(*timeframes)
gaps_left_by(*timeframes).empty?
end
-
+
# Returns the same Timeframe, only a year earlier
def last_year
- self.class.new((from - 1.year), (to - 1.year))
+ self.class.new((start_date - 1.year), (end_date - 1.year))
end
-
- def as_json(*)
- to_param
- end
- # overriding this so that as_json is not used
def to_json(*)
- to_param
+ %({"startDate":"#{start_date.iso8601}","endDate":"#{end_date.iso8601}"})
end
+ def as_json(*)
+ { :startDate => start_date.iso8601, :endDate => end_date.iso8601 }
+ end
+
# An ISO 8601 "time interval" like YYYY-MM-DD/YYYY-MM-DD
- def to_param
- "#{from}/#{to}"
+ def iso8601
+ "#{start_date.iso8601}/#{end_date.iso8601}"
end
-
- # The String representation is equivalent to its ISO 8601 form
- def to_s
- to_param
+ alias :to_s :iso8601
+ alias :to_param :iso8601
+
+ # Deprecated
+ def from # :nodoc:
+ @start_date
end
- class << self
- def make_dates(from, to) # :nodoc:
- return from.to_date, to.to_date
- end
-
- # Shortcut method to return the Timeframe representing the current year (as defined by Time.now)
- def this_year
- new :year => Time.now.year
- end
-
- # Construct a new Timeframe, but constrain it by another
- def constrained_new(from, to, constraint)
- from, to = make_dates from, to
- raise ArgumentError, 'Constraint must be a Timeframe' unless constraint.is_a? Timeframe
- raise ArgumentError, "Start date #{from} should be earlier than end date #{to}" if from > to
- if to <= constraint.from or from >= constraint.to
- new constraint.from, constraint.from
- elsif from.year == to.yesterday.year
- new(from, to) & constraint
- elsif from.year < constraint.from.year and constraint.from.year < to.yesterday.year
- constraint
- else
- new [constraint.from, from].max, [constraint.to, to].min
- end
- end
-
- # Shortcut for #new that automatically skips year boundary crossing checks
- def multiyear(from, to)
- from, to = make_dates from, to
- new from, to, :skip_year_boundary_crossing_check => true
- end
-
- # Create a multiyear timeframe +/- number of years around today
- def mid(number)
- from = Time.zone.today - number.years
- to = Time.zone.today + number.years
- multiyear from, to
- end
-
- # Construct a new Timeframe by parsing an ISO 8601 time interval string
- # http://en.wikipedia.org/wiki/ISO_8601#Time_intervals
- def interval(str)
- raise ArgumentError, 'Intervals should be specified as a string' unless str.is_a? String
- 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$/
- multiyear *str.split('/')
- end
- alias :from_json :interval
+ # Deprecated
+ def to # :nodoc:
+ @end_date
end
end