sof-cycle 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ea1c2c9a48bba72114b7681278cd467c57ee147f6f8b1ff176b4508de355920b
4
- data.tar.gz: 93a7560799df4730a5eaf2d14708b9a24199c0a95d2b01f1487ddc587b0846da
3
+ metadata.gz: f9b1ae30ef2bd0d0fb9dbc08efb701b9813d997c216a22bf1df67fcbed7efe29
4
+ data.tar.gz: ac73534e2d48354722e929e41fccf0bae4a913466b181802daa8afe3daa9beae
5
5
  SHA512:
6
- metadata.gz: baf569f5fb7e0500b8a28915c8f19b86dc97ebee380301787182216d162fd02e659645345d369440959ebde737b59bb8ddd4372dae0bf014a9e3b448e8455e03
7
- data.tar.gz: 84756e0428c56a7f6dd72e5dc7aa545a587bbffd49479a436e4f8bee2d60ec3496e128f713a36b0169799ff908e0690e4518b147e750eaa0fb662681f0e036a0
6
+ metadata.gz: 6b4a0d60e48feed105e71c6410e4ba22152e88ed8502b43b32974e585cc1abce432867b0c635ada9376258df79d799d2f3f383e4499742f5023db623a4ac8ebe
7
+ data.tar.gz: e506926aed85332f02e8f5790ec752416679fe9a0c1542bd750f6097d204c4ed4237a866cc4552e67f612218ae58dbb819f68260de67e84fcf32b3d78351e0ba
data/CHANGELOG.md CHANGED
@@ -5,18 +5,14 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [0.1.2] - 2024-08-09
8
+ ## [0.1.4] - 2024-09-02
9
9
 
10
10
  ### Added
11
11
 
12
- - `Cycle#recurring?` to reveal if a given Cycle is one-and-done or must be repeated.
12
+ - `Cycle#last_completed` to return the last completion given the cycle and an array of dates
13
13
 
14
- ## [0.1.1] - 2024-08-09
14
+ ## [0.1.3] - 2024-09-01
15
15
 
16
- ### Added
16
+ ### Fixed
17
17
 
18
- - Basic example in README.md
19
- - Add `Cycles::EndOf` to handle cycles that cover through the end of the nth
20
- subsequent period
21
- - Add predicate methods for each `Cycle` subclass. E.g. `#dormant?`, `#within?`, etc
22
- - Refactor into namespaces
18
+ - `Cycles::EndOf` to have the correct behavior
@@ -2,6 +2,6 @@
2
2
 
3
3
  module SOF
4
4
  class Cycle
5
- VERSION = "0.1.2"
5
+ VERSION = "0.1.4"
6
6
  end
7
7
  end
data/lib/sof/cycle.rb CHANGED
@@ -11,142 +11,142 @@ module SOF
11
11
 
12
12
  class InvalidKind < InvalidInput; end
13
13
 
14
- def initialize(notation, parser: Parser.new(notation))
15
- @notation = notation
16
- @parser = parser
17
- validate_period
14
+ class << self
15
+ # Turn a cycle or notation string into a hash
16
+ def dump(cycle_or_string)
17
+ if cycle_or_string.is_a? Cycle
18
+ cycle_or_string
19
+ else
20
+ Cycle.for(cycle_or_string)
21
+ end.to_h
22
+ end
18
23
 
19
- return if @parser.valid?
24
+ # Return a Cycle object from a hash
25
+ def load(hash)
26
+ symbolized_hash = hash.symbolize_keys
27
+ cycle_class = class_for_kind(symbolized_hash[:kind])
20
28
 
21
- raise InvalidInput, "'#{notation}' is not a valid input"
22
- end
29
+ unless cycle_class.valid_periods.empty?
30
+ cycle_class.validate_period(
31
+ TimeSpan.notation_id_from_name(symbolized_hash[:period])
32
+ )
33
+ end
23
34
 
24
- attr_reader :parser
35
+ Cycle.for notation(symbolized_hash)
36
+ rescue TimeSpan::InvalidPeriod => exc
37
+ raise InvalidPeriod, exc.message
38
+ end
25
39
 
26
- delegate [:activated_notation, :volume, :from, :from_date, :time_span, :period,
27
- :humanized_period, :period_key, :active?] => :@parser
28
- delegate [:kind, :recurring?, :volume_only?, :valid_periods] => "self.class"
29
- delegate [:period_count, :duration] => :time_span
30
- delegate [:calendar?, :dormant?, :end_of?, :lookback?, :volume_only?,
31
- :within?] => :kind_inquiry
40
+ # Retun a notation string from a hash
41
+ #
42
+ # @param hash [Hash] hash of data for a valid Cycle
43
+ # @return [String] string representation of a Cycle
44
+ def notation(hash)
45
+ volume_notation = "V#{hash.fetch(:volume) { 1 }}"
46
+ return volume_notation if hash[:kind].nil? || hash[:kind].to_sym == :volume_only
47
+
48
+ cycle_class = class_for_kind(hash[:kind].to_sym)
49
+ [
50
+ volume_notation,
51
+ cycle_class.notation_id,
52
+ TimeSpan.notation(hash.slice(:period, :period_count)),
53
+ hash.fetch(:from, nil)
54
+ ].compact.join
55
+ end
32
56
 
33
- # Turn a cycle or notation string into a hash
34
- def self.dump(cycle_or_string)
35
- if cycle_or_string.is_a? Cycle
36
- cycle_or_string
37
- else
38
- Cycle.for(cycle_or_string)
39
- end.to_h
40
- end
57
+ # Return a Cycle object from a notation string
58
+ #
59
+ # @param notation [String] a string notation representing a Cycle
60
+ # @example
61
+ # Cycle.for('V2C1Y)
62
+ # @return [Cycle] a Cycle object representing the provide string notation
63
+ def for(notation)
64
+ return notation if notation.is_a? Cycle
65
+ return notation if notation.is_a? Cycles::Dormant
66
+ parser = Parser.new(notation)
67
+ unless parser.valid?
68
+ raise InvalidInput, "'#{notation}' is not a valid input"
69
+ end
70
+
71
+ cycle = cycle_handlers.find do |klass|
72
+ parser.parses?(klass.notation_id)
73
+ end.new(notation, parser:)
74
+ return cycle if parser.active?
75
+
76
+ Cycles::Dormant.new(cycle, parser:)
77
+ end
41
78
 
42
- # Return a Cycle object from a hash
43
- def self.load(hash)
44
- symbolized_hash = hash.symbolize_keys
45
- cycle_class = class_for_kind(symbolized_hash[:kind])
79
+ # Return the appropriate class for the give notation id
80
+ #
81
+ # @param notation [String] notation id matching the kind of Cycle class
82
+ # @example
83
+ # class_for_notation_id('L')
84
+ #
85
+ def class_for_notation_id(notation_id)
86
+ cycle_handlers.find do |klass|
87
+ klass.notation_id == notation_id
88
+ end || raise(InvalidKind, "'#{notation_id}' is not a valid kind of #{name}")
89
+ end
46
90
 
47
- unless cycle_class.valid_periods.empty?
48
- cycle_class.validate_period(
49
- TimeSpan.notation_id_from_name(symbolized_hash[:period])
50
- )
91
+ # Return the class handling the kind
92
+ #
93
+ # @param sym [Symbol] symbol matching the kind of Cycle class
94
+ # @example
95
+ # class_for_kind(:lookback)
96
+ def class_for_kind(sym)
97
+ Cycle.cycle_handlers.find do |klass|
98
+ klass.handles?(sym)
99
+ end || raise(InvalidKind, "':#{sym}' is not a valid kind of Cycle")
51
100
  end
52
101
 
53
- Cycle.for notation(symbolized_hash)
54
- rescue TimeSpan::InvalidPeriod => exc
55
- raise InvalidPeriod, exc.message
56
- end
102
+ def cycle_handlers = @cycle_handlers ||= Set.new
57
103
 
58
- # Retun a notation string from a hash
59
- #
60
- # @param hash [Hash] hash of data for a valid Cycle
61
- # @return [String] string representation of a Cycle
62
- def self.notation(hash)
63
- volume_notation = "V#{hash.fetch(:volume) { 1 }}"
64
- return volume_notation if hash[:kind].nil? || hash[:kind].to_sym == :volume_only
65
-
66
- cycle_class = class_for_kind(hash[:kind].to_sym)
67
- [
68
- volume_notation,
69
- cycle_class.notation_id,
70
- TimeSpan.notation(hash.slice(:period, :period_count)),
71
- hash.fetch(:from, nil)
72
- ].compact.join
73
- end
104
+ def inherited(klass) = cycle_handlers << klass
74
105
 
75
- # Return a Cycle object from a notation string
76
- #
77
- # @param notation [String] a string notation representing a Cycle
78
- # @example
79
- # Cycle.for('V2C1Y)
80
- # @return [Cycle] a Cycle object representing the provide string notation
81
- def self.for(notation)
82
- return notation if notation.is_a? Cycle
83
- return notation if notation.is_a? Cycles::Dormant
84
- parser = Parser.new(notation)
85
- unless parser.valid?
86
- raise InvalidInput, "'#{notation}' is not a valid input"
106
+ def handles?(sym)
107
+ sym && kind == sym.to_sym
87
108
  end
88
109
 
89
- cycle = cycle_handlers.find do |klass|
90
- parser.parses?(klass.notation_id)
91
- end.new(notation, parser:)
92
- return cycle if parser.active?
110
+ @volume_only = false
111
+ @notation_id = nil
112
+ @kind = nil
113
+ @valid_periods = []
93
114
 
94
- Cycles::Dormant.new(cycle, parser:)
95
- end
115
+ attr_reader :notation_id, :kind, :valid_periods
116
+ def volume_only? = @volume_only
96
117
 
97
- # Return the appropriate class for the give notation id
98
- #
99
- # @param notation [String] notation id matching the kind of Cycle class
100
- # @example
101
- # class_for_notation_id('L')
102
- #
103
- def self.class_for_notation_id(notation_id)
104
- cycle_handlers.find do |klass|
105
- klass.notation_id == notation_id
106
- end || raise(InvalidKind, "'#{notation_id}' is not a valid kind of #{name}")
107
- end
118
+ def recurring? = raise "#{name} must implement #{__method__}"
108
119
 
109
- # Return the class handling the kind
110
- #
111
- # @param sym [Symbol] symbol matching the kind of Cycle class
112
- # @example
113
- # class_for_kind(:lookback)
114
- def self.class_for_kind(sym)
115
- Cycle.cycle_handlers.find do |klass|
116
- klass.handles?(sym)
117
- end || raise(InvalidKind, "':#{sym}' is not a valid kind of Cycle")
120
+ # Raises an error if the given period isn't in the list of valid periods.
121
+ #
122
+ # @param period [String] period matching the class valid periods
123
+ # @raise [InvalidPeriod]
124
+ def validate_period(period)
125
+ raise InvalidPeriod, <<~ERR.squish unless valid_periods.include?(period)
126
+ Invalid period value of '#{period}' provided. Valid periods are:
127
+ #{valid_periods.join(", ")}
128
+ ERR
129
+ end
118
130
  end
119
131
 
120
- def self.cycle_handlers = @cycle_handlers ||= Set.new
132
+ def initialize(notation, parser: Parser.new(notation))
133
+ @notation = notation
134
+ @parser = parser
135
+ validate_period
121
136
 
122
- def self.inherited(klass) = cycle_handlers << klass
137
+ return if @parser.valid?
123
138
 
124
- def self.handles?(sym)
125
- sym && kind == sym.to_sym
139
+ raise InvalidInput, "'#{notation}' is not a valid input"
126
140
  end
127
141
 
128
- @volume_only = false
129
- @notation_id = nil
130
- @kind = nil
131
- @valid_periods = []
132
-
133
- class << self
134
- attr_reader :notation_id, :kind, :valid_periods
135
- def volume_only? = @volume_only
136
-
137
- def recurring? = raise "#{name} must implement #{__method__}"
138
- end
142
+ attr_reader :parser
139
143
 
140
- # Raises an error if the given period isn't in the list of valid periods.
141
- #
142
- # @param period [String] period matching the class valid periods
143
- # @raise [InvalidPeriod]
144
- def self.validate_period(period)
145
- raise InvalidPeriod, <<~ERR.squish unless valid_periods.include?(period)
146
- Invalid period value of '#{period}' provided. Valid periods are:
147
- #{valid_periods.join(", ")}
148
- ERR
149
- end
144
+ delegate [:activated_notation, :volume, :from, :from_date, :time_span, :period,
145
+ :humanized_period, :period_key, :active?] => :@parser
146
+ delegate [:kind, :recurring?, :volume_only?, :valid_periods] => "self.class"
147
+ delegate [:period_count, :duration] => :time_span
148
+ delegate [:calendar?, :dormant?, :end_of?, :lookback?, :volume_only?,
149
+ :within?] => :kind_inquiry
150
150
 
151
151
  def kind_inquiry = ActiveSupport::StringInquirer.new(kind.to_s)
152
152
 
@@ -162,6 +162,9 @@ module SOF
162
162
  # Cycles are considered equal if their hash representations are equal
163
163
  def ==(other) = to_h == other.to_h
164
164
 
165
+ # Return the most recent completion date from the supplied array of dates
166
+ def last_completed(dates) = dates.compact.map(&:to_date).max
167
+
165
168
  # From the supplied anchor date, are there enough in-window completions to
166
169
  # satisfy the cycle?
167
170
  #
@@ -1,5 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Captures the logic for enforcing the EndOf cycle variant
4
+ # E.g. "V1E18MF2020-01-05" means:
5
+ # You're good until the end of the 17th subsequent month from 2020-01-05.
6
+ # Complete 1 by that date to reset the cycle.
7
+ #
8
+ # Some of the calculations are quite different from other cycles.
9
+ # Whereas other cycles look at completion dates to determine if the cycle is
10
+ # satisfied, this cycle checks whether the anchor date is prior to the final date.
3
11
  module SOF
4
12
  module Cycles
5
13
  class EndOf < Cycle
@@ -16,8 +24,37 @@ module SOF
16
24
  "#{volume}x by #{final_date.to_fs(:american)}"
17
25
  end
18
26
 
27
+ # Always returns the from_date
28
+ def last_completed(_) = from_date.to_date
29
+
30
+ # Returns the expiration date for the cycle
31
+ #
32
+ # @param [nil] _ Unused parameter, maintained for compatibility
33
+ # @param anchor [nil] _ Unused parameter, maintained for compatibility
34
+ # @return [Date] The final date of the cycle
35
+ #
36
+ # @example
37
+ # Cycle.for("V1E18MF2020-01-09")
38
+ # .expiration_of(anchor: "2020-06-04".to_date)
39
+ # # => #<Date: 2021-06-30>
40
+ def expiration_of(_ = nil, anchor: nil) = final_date
41
+
42
+ # Is the supplied anchor date prior to the final date?
43
+ #
44
+ # @return [Boolean] true if the cycle is satisfied, false otherwise
45
+ def satisfied_by?(_ = nil, anchor: Date.current) = anchor <= final_date
46
+
47
+ # Calculates the final date of the cycle
48
+ #
49
+ # @param [nil] _ Unused parameter, maintained for compatibility
50
+ # @return [Date] The final date of the cycle calculated as the end of the
51
+ # nth subsequent period after the FROM date, where n = (period count - 1)
52
+ #
53
+ # @example
54
+ # Cycle.for("V1E18MF2020-01-09").final_date
55
+ # # => #<Date: 2021-06-30>
19
56
  def final_date(_ = nil) = time_span
20
- .end_date(start_date)
57
+ .end_date(start_date - 1.send(period))
21
58
  .end_of_month
22
59
 
23
60
  def start_date(_ = nil) = from_date.to_date
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sof-cycle
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-08-09 00:00:00.000000000 Z
11
+ date: 2024-09-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: forwardable
@@ -84,7 +84,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
84
84
  - !ruby/object:Gem::Version
85
85
  version: '0'
86
86
  requirements: []
87
- rubygems_version: 3.4.13
87
+ rubygems_version: 3.5.11
88
88
  signing_key:
89
89
  specification_version: 4
90
90
  summary: Parse and interact with SOF cycle notation.