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 +4 -4
- data/CHANGELOG.md +5 -9
- data/lib/sof/cycle/version.rb +1 -1
- data/lib/sof/cycle.rb +116 -113
- data/lib/sof/cycles/end_of.rb +38 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f9b1ae30ef2bd0d0fb9dbc08efb701b9813d997c216a22bf1df67fcbed7efe29
|
|
4
|
+
data.tar.gz: ac73534e2d48354722e929e41fccf0bae4a913466b181802daa8afe3daa9beae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
8
|
+
## [0.1.4] - 2024-09-02
|
|
9
9
|
|
|
10
10
|
### Added
|
|
11
11
|
|
|
12
|
-
- `Cycle#
|
|
12
|
+
- `Cycle#last_completed` to return the last completion given the cycle and an array of dates
|
|
13
13
|
|
|
14
|
-
## [0.1.
|
|
14
|
+
## [0.1.3] - 2024-09-01
|
|
15
15
|
|
|
16
|
-
###
|
|
16
|
+
### Fixed
|
|
17
17
|
|
|
18
|
-
-
|
|
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
|
data/lib/sof/cycle/version.rb
CHANGED
data/lib/sof/cycle.rb
CHANGED
|
@@ -11,142 +11,142 @@ module SOF
|
|
|
11
11
|
|
|
12
12
|
class InvalidKind < InvalidInput; end
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
35
|
+
Cycle.for notation(symbolized_hash)
|
|
36
|
+
rescue TimeSpan::InvalidPeriod => exc
|
|
37
|
+
raise InvalidPeriod, exc.message
|
|
38
|
+
end
|
|
25
39
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
54
|
-
rescue TimeSpan::InvalidPeriod => exc
|
|
55
|
-
raise InvalidPeriod, exc.message
|
|
56
|
-
end
|
|
102
|
+
def cycle_handlers = @cycle_handlers ||= Set.new
|
|
57
103
|
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
110
|
+
@volume_only = false
|
|
111
|
+
@notation_id = nil
|
|
112
|
+
@kind = nil
|
|
113
|
+
@valid_periods = []
|
|
93
114
|
|
|
94
|
-
|
|
95
|
-
|
|
115
|
+
attr_reader :notation_id, :kind, :valid_periods
|
|
116
|
+
def volume_only? = @volume_only
|
|
96
117
|
|
|
97
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
132
|
+
def initialize(notation, parser: Parser.new(notation))
|
|
133
|
+
@notation = notation
|
|
134
|
+
@parser = parser
|
|
135
|
+
validate_period
|
|
121
136
|
|
|
122
|
-
|
|
137
|
+
return if @parser.valid?
|
|
123
138
|
|
|
124
|
-
|
|
125
|
-
sym && kind == sym.to_sym
|
|
139
|
+
raise InvalidInput, "'#{notation}' is not a valid input"
|
|
126
140
|
end
|
|
127
141
|
|
|
128
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
#
|
data/lib/sof/cycles/end_of.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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.
|
|
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.
|