Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
saturnflyer committed Jul 9, 2024
1 parent 6e0e3b9 commit e78368c
Show file tree
Hide file tree
Showing 15 changed files with 1,477 additions and 9 deletions.
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ PATH
remote: .
specs:
SOF-cycle (0.1.0)
forwardable

GEM
remote: https://rubygems.org/
specs:
diff-lcs (1.5.1)
forwardable (1.3.3)
rake (13.2.1)
rspec (3.13.0)
rspec-core (~> 3.13.0)
Expand Down
347 changes: 344 additions & 3 deletions lib/sof/cycle.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,351 @@
# frozen_string_literal: true

require_relative "cycle/version"
require "forwardable"
require_relative "cycle/parser"
require_relative "cycle/time_span"

module SOF
module Cycle
class Error < StandardError; end
# Your code goes here...
class Cycle
extend Forwardable
class InvalidInput < StandardError; end

class InvalidPeriod < InvalidInput; end

class InvalidKind < InvalidInput; end

def initialize(notation, parser: Parser.new(notation))
@notation = notation
@parser = parser
validate_period

return if @parser.valid?

raise InvalidInput, "'#{notation}' is not a valid input"
end

attr_reader :parser

delegate [:activated_notation, :volume, :from, :from_date, :time_span, :period,
:humanized_period, :period_key, :"active?"] => :@parser
delegate [:kind, :"volume_only?", :valid_periods] => "self.class"
delegate [:period_count, :duration] => :time_span

# Turn a cycle or notation string into a hash
def self.dump(cycle_or_string)
if cycle_or_string.is_a? Cycle
cycle_or_string
else
Cycle.for(cycle_or_string)
end.to_h
end

# Return a Cycle object from a hash
def self.load(hash)
symbolized_hash = hash.symbolize_keys
cycle_class = class_for_kind(symbolized_hash[:kind])

unless cycle_class.valid_periods.empty?
cycle_class.validate_period(
TimeSpan.notation_id_from_name(symbolized_hash[:period])
)
end

Cycle.for notation(symbolized_hash)
rescue TimeSpan::InvalidPeriod => exc
raise InvalidPeriod, exc.message
end

# Retun a notation string from a hash
#
# @param hash [Hash] hash of data for a valid Cycle
# @return [String] string representation of a Cycle
def self.notation(hash)
volume_notation = "V#{hash.fetch(:volume) { 1 }}"
return volume_notation if hash[:kind].nil? || hash[:kind].to_sym == :volume_only

cycle_class = class_for_kind(hash[:kind].to_sym)
[
volume_notation,
cycle_class.notation_id,
TimeSpan.notation(hash.slice(:period, :period_count)),
hash.fetch(:from, nil)
].compact.join
end

# Return a Cycle object from a notation string
#
# @param notation [String] a string notation representing a Cycle
# @example
# Cycle.for('V2C1Y)
# @return [Cycle] a Cycle object representing the provide string notation
def self.for(notation)
return notation if notation.is_a? Cycle
return notation if notation.is_a? Cycle::Dormant
parser = Parser.new(notation)
unless parser.valid?
raise InvalidInput, "'#{notation}' is not a valid input"
end

cycle = cycle_handlers.find do |klass|
parser.parses?(klass.notation_id)
end.new(notation, parser:)
return cycle if parser.active?

Cycle::Dormant.new(cycle, parser:)
end

# Return the appropriate class for the give notation id
#
# @param notation [String] notation id matching the kind of Cycle class
# @example
# class_for_notation_id('L')
#
def self.class_for_notation_id(notation_id)
cycle_handlers.find do |klass|
klass.notation_id == notation_id
end || raise(InvalidKind, "'#{notation_id}' is not a valid kind of #{name}")
end

# Return the class handling the kind
#
# @param sym [Symbol] symbol matching the kind of Cycle class
# @example
# class_for_kind(:lookback)
def self.class_for_kind(sym)
Cycle.cycle_handlers.find do |klass|
klass.handles?(sym)
end || raise(InvalidKind, "':#{sym}' is not a valid kind of Cycle")
end

def self.cycle_handlers = @cycle_handlers ||= Set.new

def self.inherited(klass) = cycle_handlers << klass

def self.handles?(sym)
sym && kind == sym.to_sym
end

@volume_only = false
@notation_id = nil
@kind = nil
@valid_periods = []

def self.volume_only? = @volume_only

class << self
attr_reader :notation_id, :kind, :valid_periods
end

# Raises an error if the given period isn't in the list of valid periods.
#
# @param period [String] period matching the class valid periods
# @raise [InvalidPeriod]
def self.validate_period(period)
raise InvalidPeriod, <<~ERR.squish unless valid_periods.include?(period)
Invalid period value of '#{period}' provided. Valid periods are:
#{valid_periods.join(", ")}
ERR
end

def validate_period
return if valid_periods.empty?

self.class.validate_period(period_key)
end

# Return the cycle representation as a notation string
def notation = self.class.notation(to_h)

# Cycles are considered equal if their hash representations are equal
def ==(other) = to_h == other.to_h

# From the supplied anchor date, are there enough in-window completions to
# satisfy the cycle?
#
# @return [Boolean] true if the cycle is satisfied, false otherwise
def satisfied_by?(completion_dates, anchor: Date.current)
covered_dates(completion_dates, anchor:).size >= volume
end

def covered_dates(dates, anchor: Date.current)
dates.select do |date|
cover?(date, anchor:)
end
end

def cover?(date, anchor: Date.current)
range(anchor).cover?(date)
end

def range(anchor) = start_date(anchor)..final_date(anchor)

def humanized_span = [period_count, humanized_period].join(" ")

# Return the final date of the cycle
def final_date(_anchor) = nil

def expiration_of(_completion_dates, anchor: Date.current) = nil

def volume_to_delay_expiration(_completion_dates, anchor:) = 0

def to_h
{
kind:,
volume:,
period:,
period_count:,
**from_data
}
end

def from_data
return {} unless from

{from: from}
end

def as_json(...) = notation

class Dormant
def initialize(cycle, parser:)
@cycle = cycle
@parser = parser
end

attr_reader :cycle, :parser

def to_s
cycle.to_s + " (dormant)"
end

def covered_dates(...) = []

def expiration_of(...) = nil

def satisfied_by?(...) = false

def cover?(...) = false

def method_missing(method, ...) = cycle.send(method, ...)

def respond_to_missing?(method, include_private = false)
cycle.respond_to?(method, include_private)
end
end

class Within < self
@volume_only = false
@notation_id = "W"
@kind = :within
@valid_periods = %w[D W M Y]

def to_s = "#{volume}x within #{date_range}"

def date_range
return humanized_span unless active?

[start_date, final_date].map { _1.to_fs(:american) }.join(" - ")
end

def final_date(_ = nil) = time_span.end_date(start_date)

def start_date(_ = nil) = from_date.to_date
end

class VolumeOnly < self
@volume_only = true
@notation_id = nil
@kind = :volume_only
@valid_periods = []

class << self
def handles?(sym) = sym.nil? || super

def validate_period(period)
raise InvalidPeriod, <<~ERR.squish unless period.nil?
Invalid period value of '#{period}' provided. Valid periods are:
#{valid_periods.join(", ")}
ERR
end
end

def to_s = "#{volume}x total"

def covered_dates(dates, ...) = dates

def cover?(...) = true
end

class Lookback < self
@volume_only = false
@notation_id = "L"
@kind = :lookback
@valid_periods = %w[D W M Y]

def to_s = "#{volume}x in the prior #{period_count} #{humanized_period}"

def volume_to_delay_expiration(completion_dates, anchor:)
oldest_relevant_completion = completion_dates.min
[completion_dates.count(oldest_relevant_completion), volume].min
end

# "Absent further completions, you go red on this date"
# @return [Date, nil] the date on which the cycle will expire given the
# provided completion dates. Returns nil if the cycle is already unsatisfied.
def expiration_of(completion_dates)
anchor = completion_dates.max_by(volume) { _1 }.min
return unless satisfied_by?(completion_dates, anchor:)

window_end anchor
end

def final_date(anchor)
return if anchor.nil?

time_span.end_date(anchor.to_date)
end
alias_method :window_end, :final_date

def start_date(anchor)
time_span.begin_date(anchor.to_date)
end
alias_method :window_start, :start_date
end

class Calendar < self
@volume_only = false
@notation_id = "C"
@kind = :calendar
@valid_periods = %w[M Q Y]

class << self
def frame_of_reference = "total"
end

def to_s
"#{volume}x every #{period_count} calendar #{humanized_period}"
end

# "Absent further completions, you go red on this date"
# @return [Date, nil] the date on which the cycle will expire given the
# provided completion dates. Returns nil if the cycle is already unsatisfied.
def expiration_of(completion_dates)
anchor = completion_dates.max_by(volume) { _1 }.min
return unless satisfied_by?(completion_dates, anchor:)

window_end(anchor) + duration
end

def final_date(anchor)
return if anchor.nil?
time_span.end_date_of_period(anchor.to_date)
end
alias_method :window_end, :final_date

def start_date(anchor)
time_span.begin_date_of_period(anchor.to_date)
end
end
end
end
Loading

0 comments on commit e78368c

Please sign in to comment.