Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/sof-cycle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@

Dir[File.join(__dir__, "sof", "cycles", "*.rb")].each { |file| require file }

require_relative "sof/time_span"
require_relative "sof/cycle/time_span"
2 changes: 1 addition & 1 deletion lib/sof/cycle.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# frozen_string_literal: true

require "forwardable"
require_relative "parser"
require_relative "cycle/parser"

module SOF
class Cycle
Expand Down
117 changes: 117 additions & 0 deletions lib/sof/cycle/parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# frozen_string_literal: true

require_relative "../cycle"
require "active_support/core_ext/hash/keys"
Comment thread
saturnflyer marked this conversation as resolved.
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
class Cycle
# This class is not intended to be referenced directly.
# This is an internal implementation of Cycle behavior.
class Parser
Comment thread
saturnflyer marked this conversation as resolved.
extend Forwardable

PARTS_REGEX = /
^(?<vol>V(?<volume>\d*))? # optional volume
(?<set>(?<kind>LE|L|C|W|E|I) # kind
(?<period_count>\d+) # period count
(?<period_key>D|W|M|Q|Y)?)? # period_key
(?<from>F(?<from_date>\d{4}-\d{2}-\d{2}))?$ # optional from
/ix

# Deliberately a method, not a constant: handlers register themselves
# through Cycle.inherited as each file in sof/cycles loads, which happens
# after this file. A constant would capture the empty set and leave every
# dormant-capable kind unrecognized.
def self.dormant_capable_kinds
Cycle.cycle_handlers.select(&:dormant_capable?).map(&:notation_id).compact.freeze
end

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)
Comment thread
saturnflyer marked this conversation as resolved.
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
Comment thread
saturnflyer marked this conversation as resolved.
@match = @notation&.match(PARTS_REGEX)
@time_span = nil
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
Comment thread
saturnflyer marked this conversation as resolved.
@time_span ||= TimeSpan.for(period_count, period_key)
end

def valid? = match.present?

def inspect = notation
Comment thread
saturnflyer marked this conversation as resolved.
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
Comment thread
saturnflyer marked this conversation as resolved.
end

def ==(other) = other.to_h == to_h

def to_h
Comment thread
saturnflyer marked this conversation as resolved.
{
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
238 changes: 238 additions & 0 deletions lib/sof/cycle/time_span.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
# frozen_string_literal: true

module SOF
class Cycle
# 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[:period].present?

[
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

def notation
[period_count, code].join
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)
DatePeriod.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_date = {}
@begin_date = {}
end
attr_reader :count

def end_date(date)
@end_date[date] ||= date + duration
end

def begin_date(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, :code] => :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
2 changes: 1 addition & 1 deletion lib/sof/cycles/within.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def to_s = "#{volume}x within #{date_range}"

def extend_period(count)
Cycle.for(
Parser.load(
Cycle::Parser.load(
parser.to_h.merge(period_count: period_count + count)
).to_s
)
Expand Down
Loading