diff --git a/lib/sof-cycle.rb b/lib/sof-cycle.rb index f405c1e..d44a00c 100644 --- a/lib/sof-cycle.rb +++ b/lib/sof-cycle.rb @@ -12,5 +12,3 @@ require_relative "sof/cycle" Dir[File.join(__dir__, "sof", "cycles", "*.rb")].each { |file| require file } - -require_relative "sof/time_span" diff --git a/lib/sof/cycle.rb b/lib/sof/cycle.rb index 1fb41e5..6b04642 100644 --- a/lib/sof/cycle.rb +++ b/lib/sof/cycle.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true -require_relative "parser" +require_relative "cycle/parser" +require_relative "cycle/time_span" module SOF class Cycle diff --git a/lib/sof/cycle/parser.rb b/lib/sof/cycle/parser.rb new file mode 100644 index 0000000..5397d1a --- /dev/null +++ b/lib/sof/cycle/parser.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require "active_support/core_ext/hash/keys" +require "active_support/core_ext/object/blank" +require "active_support/core_ext/object/inclusion" +require "active_support/core_ext/hash/reverse_merge" +require "active_support/isolated_execution_state" + +module SOF + # This class is not intended to be referenced directly. + # This is an internal implementation of Cycle behavior. + class Cycle + class Parser + extend Forwardable + PARTS_REGEX = / + ^(?V(?\d*))? # optional volume + (?(?L|C|W|E) # kind + (?\d+) # period count + (?D|W|M|Q|Y)?)? # period_key + (?F(?\d{4}-\d{2}-\d{2}))?$ # optional from + /ix + + def self.dormant_capable_kinds = %w[E W] + + def self.for(notation_or_parser) + return notation_or_parser if notation_or_parser.is_a? self + + new(notation_or_parser) + end + + def self.load(hash) + hash.symbolize_keys! + hash.reverse_merge!(volume: 1) + keys = %i[volume kind period_count period_key] + str = "V#{hash.values_at(*keys).join}" + return new(str) unless hash[:from_date] + + new([str, "F#{hash[:from_date]}"].join) + end + + def initialize(notation) + @notation = notation&.upcase + @match = @notation&.match(PARTS_REGEX) + end + + attr_reader :match, :notation + + delegate [:dormant_capable_kinds] => "self.class" + delegate [:period, :humanized_period] => :time_span + + # Return a TimeSpan object for the period and period_count + def time_span + @time_span ||= TimeSpan.for(period_count, period_key) + end + + def valid? = match.present? + + def inspect = notation + alias_method :to_s, :inspect + + def activated_notation(date) + return notation unless dormant_capable? + + self.class.load(to_h.merge(from_date: date.to_date)).notation + end + + def ==(other) = other.to_h == to_h + + def to_h + { + volume:, + kind:, + period_count:, + period_key:, + from_date: + } + end + + def parses?(notation_id) = kind == notation_id + + def active? = !dormant? + + def dormant? = dormant_capable? && from_date.nil? + + def dormant_capable? = kind.in?(dormant_capable_kinds) + + def period_count = match[:period_count] + + def period_key = match[:period_key] + + def vol = match[:vol] || "V1" + + def volume = (match[:volume] || 1).to_i + + def from_data + return {} unless from + + {from: from} + end + + def from_date = match[:from_date] + + def from = match[:from] + + def kind = match[:kind] + end + end +end diff --git a/lib/sof/cycle/time_span.rb b/lib/sof/cycle/time_span.rb new file mode 100644 index 0000000..e0e6952 --- /dev/null +++ b/lib/sof/cycle/time_span.rb @@ -0,0 +1,232 @@ +# frozen_string_literal: true + +module SOF + # This class is not intended to be referenced directly. + # This is an internal implementation of Cycle behavior. + class Cycle + class TimeSpan + extend Forwardable + # TimeSpan objects map Cycle notations to behaviors for their periods + # + # For example: + # 'M' => TimeSpan::DatePeriod::Month + # 'Y' => TimeSpan::DatePeriod::Year + # Read each DatePeriod subclass for more information. + # + class InvalidPeriod < StandardError; end + + class << self + # Return a time_span for the given count and period + def for(count, period) + case count.to_i + when 0 + TimeSpanNothing + when 1 + TimeSpanOne + else + self + end.new(count, period) + end + + # Return a notation string from a hash + def notation(hash) + return unless hash.key?(:period) + + [ + hash.fetch(:period_count) { 1 }, + notation_id_from_name(hash[:period]) + ].compact.join + end + + # Return the notation character for the given period name + def notation_id_from_name(name) + type = DatePeriod.types.find do |klass| + klass.period.to_s == name.to_s + end + + raise InvalidPeriod, "'#{name}' is not a valid period" unless type + + type.code + end + end + + # Class used to calculate the windows of time so that + # a TimeSpan object will know the correct end of year, + # quarter, etc. + class DatePeriod + extend Forwardable + class << self + def for(count, period_notation) + @cached_periods ||= {} + @cached_periods[period_notation] ||= {} + @cached_periods[period_notation][count] ||= (for_notation(period_notation) || self).new(count) + @cached_periods[period_notation][count] + end + + def for_notation(notation) + types.find do |klass| + klass.code == notation.to_s.upcase + end + end + + def types = @types ||= Set.new + + def inherited(klass) + DatePeriod.types << klass + end + + @period = nil + @code = nil + @interval = nil + attr_reader :period, :code, :interval + end + + delegate [:period, :code, :interval] => "self.class" + + def initialize(count) + @count = count + end + attr_reader :count + + def end_date(date) + @end_date ||= {} + @end_date[date] ||= date + duration + end + + def begin_date(date) + @begin_date ||= {} + @begin_date[date] ||= date - duration + end + + def duration = count.send(period) + + def end_of_period(_) = nil + + def humanized_period + return period if count == 1 + + "#{period}s" + end + + class Year < self + @period = :year + @code = "Y" + @interval = "years" + + def end_of_period(date) + date.end_of_year + end + + def beginning_of_period(date) + date.beginning_of_year + end + end + + class Quarter < self + @period = :quarter + @code = "Q" + @interval = "quarters" + + def duration + (count * 3).months + end + + def end_of_period(date) + date.end_of_quarter + end + + def beginning_of_period(date) + date.beginning_of_quarter + end + end + + class Month < self + @period = :month + @code = "M" + @interval = "months" + + def end_of_period(date) + date.end_of_month + end + + def beginning_of_period(date) + date.beginning_of_month + end + end + + class Week < self + @period = :week + @code = "W" + @interval = "weeks" + + def end_of_period(date) + date.end_of_week + end + + def beginning_of_period(date) + date.beginning_of_week + end + end + + class Day < self + @period = :day + @code = "D" + @interval = "days" + + def end_of_period(date) + date + end + + def beginning_of_period(date) + date + end + end + end + private_constant :DatePeriod + + def initialize(count, period_id) + @count = Integer(count, exception: false) + @window = DatePeriod.for(period_count, period_id) + end + attr_reader :window + + delegate [:end_date, :begin_date] => :window + + def end_date_of_period(date) + window.end_of_period(date) + end + + def begin_date_of_period(date) + window.beginning_of_period(date) + end + + # Integer value for the period count or nil + def period_count + @count + end + + delegate [:period, :duration, :interval, :humanized_period] => :window + + # Return a date according to the rules of the time_span + def final_date(date) + return unless period + + window.end_date(date.to_date) + end + + def to_h + { + period:, + period_count: + } + end + + class TimeSpanNothing < self + end + + class TimeSpanOne < self + def interval = humanized_period + end + end + end +end diff --git a/lib/sof/parser.rb b/lib/sof/parser.rb deleted file mode 100644 index 967fd45..0000000 --- a/lib/sof/parser.rb +++ /dev/null @@ -1,107 +0,0 @@ -# frozen_string_literal: true - -require_relative "cycle" -require "active_support/core_ext/hash/keys" -require "active_support/core_ext/object/blank" -require "active_support/core_ext/object/inclusion" -require "active_support/core_ext/hash/reverse_merge" -require "active_support/isolated_execution_state" - -module SOF - # This class is not intended to be referenced directly. - # This is an internal implementation of Cycle behavior. - class Parser - extend Forwardable - PARTS_REGEX = / - ^(?V(?\d*))? # optional volume - (?(?L|C|W|E) # kind - (?\d+) # period count - (?D|W|M|Q|Y)?)? # period_key - (?F(?\d{4}-\d{2}-\d{2}))?$ # optional from - /ix - - def self.dormant_capable_kinds = %w[E W] - - def self.for(notation_or_parser) - return notation_or_parser if notation_or_parser.is_a? self - - new(notation_or_parser) - end - - def self.load(hash) - hash.symbolize_keys! - hash.reverse_merge!(volume: 1) - keys = %i[volume kind period_count period_key] - str = "V#{hash.values_at(*keys).join}" - return new(str) unless hash[:from_date] - - new([str, "F#{hash[:from_date]}"].join) - end - - def initialize(notation) - @notation = notation&.upcase - @match = @notation&.match(PARTS_REGEX) - end - - attr_reader :match, :notation - - delegate [:dormant_capable_kinds] => "self.class" - delegate [:period, :humanized_period] => :time_span - - # Return a TimeSpan object for the period and period_count - def time_span - @time_span ||= TimeSpan.for(period_count, period_key) - end - - def valid? = match.present? - - def inspect = notation - alias_method :to_s, :inspect - - def activated_notation(date) - return notation unless dormant_capable? - - self.class.load(to_h.merge(from_date: date.to_date)).notation - end - - def ==(other) = other.to_h == to_h - - def to_h - { - volume:, - kind:, - period_count:, - period_key:, - from_date: - } - end - - def parses?(notation_id) = kind == notation_id - - def active? = !dormant? - - def dormant? = dormant_capable? && from_date.nil? - - def dormant_capable? = kind.in?(dormant_capable_kinds) - - def period_count = match[:period_count] - - def period_key = match[:period_key] - - def vol = match[:vol] || "V1" - - def volume = (match[:volume] || 1).to_i - - def from_data - return {} unless from - - {from: from} - end - - def from_date = match[:from_date] - - def from = match[:from] - - def kind = match[:kind] - end -end diff --git a/lib/sof/time_span.rb b/lib/sof/time_span.rb deleted file mode 100644 index ff9b673..0000000 --- a/lib/sof/time_span.rb +++ /dev/null @@ -1,230 +0,0 @@ -# frozen_string_literal: true - -module SOF - # This class is not intended to be referenced directly. - # This is an internal implementation of Cycle behavior. - class TimeSpan - extend Forwardable - # TimeSpan objects map Cycle notations to behaviors for their periods - # - # For example: - # 'M' => TimeSpan::DatePeriod::Month - # 'Y' => TimeSpan::DatePeriod::Year - # Read each DatePeriod subclass for more information. - # - class InvalidPeriod < StandardError; end - - class << self - # Return a time_span for the given count and period - def for(count, period) - case count.to_i - when 0 - TimeSpanNothing - when 1 - TimeSpanOne - else - self - end.new(count, period) - end - - # Return a notation string from a hash - def notation(hash) - return unless hash.key?(:period) - - [ - hash.fetch(:period_count) { 1 }, - notation_id_from_name(hash[:period]) - ].compact.join - end - - # Return the notation character for the given period name - def notation_id_from_name(name) - type = DatePeriod.types.find do |klass| - klass.period.to_s == name.to_s - end - - raise InvalidPeriod, "'#{name}' is not a valid period" unless type - - type.code - end - end - - # Class used to calculate the windows of time so that - # a TimeSpan object will know the correct end of year, - # quarter, etc. - class DatePeriod - extend Forwardable - class << self - def for(count, period_notation) - @cached_periods ||= {} - @cached_periods[period_notation] ||= {} - @cached_periods[period_notation][count] ||= (for_notation(period_notation) || self).new(count) - @cached_periods[period_notation][count] - end - - def for_notation(notation) - types.find do |klass| - klass.code == notation.to_s.upcase - end - end - - def types = @types ||= Set.new - - def inherited(klass) - DatePeriod.types << klass - end - - @period = nil - @code = nil - @interval = nil - attr_reader :period, :code, :interval - end - - delegate [:period, :code, :interval] => "self.class" - - def initialize(count) - @count = count - end - attr_reader :count - - def end_date(date) - @end_date ||= {} - @end_date[date] ||= date + duration - end - - def begin_date(date) - @begin_date ||= {} - @begin_date[date] ||= date - duration - end - - def duration = count.send(period) - - def end_of_period(_) = nil - - def humanized_period - return period if count == 1 - - "#{period}s" - end - - class Year < self - @period = :year - @code = "Y" - @interval = "years" - - def end_of_period(date) - date.end_of_year - end - - def beginning_of_period(date) - date.beginning_of_year - end - end - - class Quarter < self - @period = :quarter - @code = "Q" - @interval = "quarters" - - def duration - (count * 3).months - end - - def end_of_period(date) - date.end_of_quarter - end - - def beginning_of_period(date) - date.beginning_of_quarter - end - end - - class Month < self - @period = :month - @code = "M" - @interval = "months" - - def end_of_period(date) - date.end_of_month - end - - def beginning_of_period(date) - date.beginning_of_month - end - end - - class Week < self - @period = :week - @code = "W" - @interval = "weeks" - - def end_of_period(date) - date.end_of_week - end - - def beginning_of_period(date) - date.beginning_of_week - end - end - - class Day < self - @period = :day - @code = "D" - @interval = "days" - - def end_of_period(date) - date - end - - def beginning_of_period(date) - date - end - end - end - private_constant :DatePeriod - - def initialize(count, period_id) - @count = Integer(count, exception: false) - @window = DatePeriod.for(period_count, period_id) - end - attr_reader :window - - delegate [:end_date, :begin_date] => :window - - def end_date_of_period(date) - window.end_of_period(date) - end - - def begin_date_of_period(date) - window.beginning_of_period(date) - end - - # Integer value for the period count or nil - def period_count - @count - end - - delegate [:period, :duration, :interval, :humanized_period] => :window - - # Return a date according to the rules of the time_span - def final_date(date) - return unless period - - window.end_date(date.to_date) - end - - def to_h - { - period:, - period_count: - } - end - - class TimeSpanNothing < self - end - - class TimeSpanOne < self - def interval = humanized_period - end - end -end diff --git a/spec/sof/parser_spec.rb b/spec/sof/parser_spec.rb index 6bbd252..61e49c6 100644 --- a/spec/sof/parser_spec.rb +++ b/spec/sof/parser_spec.rb @@ -3,7 +3,7 @@ require "spec_helper" module SOF - RSpec.describe Parser, type: :value do + RSpec.describe Cycle::Parser, type: :value do describe ".load(hash)" do it "returns a Parser instance" do hash = { diff --git a/spec/sof/time_span_spec.rb b/spec/sof/time_span_spec.rb index b9fd44b..36da6b5 100644 --- a/spec/sof/time_span_spec.rb +++ b/spec/sof/time_span_spec.rb @@ -3,7 +3,7 @@ require "spec_helper" module SOF - RSpec.describe SOF::TimeSpan, type: :value do + RSpec.describe Cycle::TimeSpan, type: :value do describe ".notation" do it "accepts a hash and returns a string notation" do aggregate_failures do