sof-cycle 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 416c2fd3c9e369ece57bafe7cbf5a58f07b9e30177c04b7253059cb87d6e32dc
4
+ data.tar.gz: 00fd30f51d4efb2eeabbe7eef1b8fdd58a6290a08579ae661984b2de359585e4
5
+ SHA512:
6
+ metadata.gz: 582f1597715e073ea8d517b8236ad3daf81fcdf9ce023c860a77ed2ddb4847e4df027b606764915d816d7d5ddd06ec383bcae58fb0174e8e1e6f02d97265ed71
7
+ data.tar.gz: 8d9e747a1ac2d0a649f911f6c7de8e81dc7a8139294c41c37eb20b8d9cc67e52e5242f61df1315b74aab24127f5c0c7ea7738a9d9fc7e1130ddc94dec9de6445
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --require spec_helper
2
+ --order random
data/.simplecov ADDED
@@ -0,0 +1,4 @@
1
+ require "simplecov"
2
+ SimpleCov.start do
3
+ add_filter "/spec/"
4
+ end
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # CHANGELOG
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2024-07-09
9
+
10
+ ### Added
11
+
12
+ - Initial extraction
data/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # SOF::Cycle
2
+
3
+ Parse and interact with SOF cycle notation.
4
+
5
+ ## Installation
6
+
7
+ Install the gem and add to the application's Gemfile by executing:
8
+
9
+ $ bundle add sof-cycle
10
+
11
+ If bundler is not being used to manage dependencies, install the gem by executing:
12
+
13
+ $ gem install sof-cycle
14
+
15
+ ## Usage
16
+
17
+ TODO: Write usage instructions here
18
+
19
+ ## Development
20
+
21
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
22
+
23
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
24
+
25
+ ## Contributing
26
+
27
+ Bug reports and pull requests are welcome on GitHub at https://github.com/SOFware/sof-cycle.
data/Rakefile ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+
5
+ require "rspec/core/rake_task"
6
+
7
+ RSpec::Core::RakeTask.new(:spec) do |t|
8
+ t.pattern = "spec/**/*_spec.rb"
9
+ end
10
+
11
+ task default: :spec
12
+
13
+ require "reissue/gem"
14
+
15
+ Reissue::Task.create do |task|
16
+ task.version_file = "lib/sof/cycle/version.rb"
17
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cycle"
4
+ require "active_support/core_ext/hash/keys"
5
+ require "active_support/core_ext/object/blank"
6
+ require "active_support/core_ext/object/inclusion"
7
+ require "active_support/core_ext/hash/reverse_merge"
8
+ require "active_support/isolated_execution_state"
9
+
10
+ module SOF
11
+ # This class is not intended to be referenced directly.
12
+ # This is an internal implementation of Cycle behavior.
13
+ class Cycle::Parser
14
+ extend Forwardable
15
+ PARTS_REGEX = /
16
+ ^(?<vol>V(?<volume>\d*))? # optional volume
17
+ (?<set>(?<kind>L|C|W) # kind
18
+ (?<period_count>\d+) # period count
19
+ (?<period_key>D|W|M|Q|Y)?)? # period_key
20
+ (?<from>F(?<from_date>\d{4}-\d{2}-\d{2}))?$ # optional from
21
+ /ix
22
+
23
+ def self.dormant_capable_kinds = %w[W]
24
+
25
+ def self.for(str_or_notation)
26
+ return str_or_notation if str_or_notation.is_a? self
27
+
28
+ new(str_or_notation)
29
+ end
30
+
31
+ def self.load(hash)
32
+ hash.symbolize_keys!
33
+ hash.reverse_merge!(volume: 1)
34
+ keys = %i[volume kind period_count period_key]
35
+ str = "V#{hash.values_at(*keys).join}"
36
+ return new(str) unless hash[:from_date]
37
+
38
+ new([str, "F#{hash[:from_date]}"].join)
39
+ end
40
+
41
+ def initialize(notation)
42
+ @notation = notation&.upcase
43
+ @match = @notation&.match(PARTS_REGEX)
44
+ end
45
+
46
+ attr_reader :match, :notation
47
+
48
+ delegate [:dormant_capable_kinds] => "self.class"
49
+ delegate [:period, :humanized_period] => :time_span
50
+
51
+ # Return a TimeSpan object for the period and period_count
52
+ def time_span
53
+ @time_span ||= Cycle::TimeSpan.for(period_count, period_key)
54
+ end
55
+
56
+ def valid? = match.present?
57
+
58
+ def inspect = notation
59
+ alias_method :to_s, :inspect
60
+
61
+ def activated_notation(date)
62
+ return notation unless dormant_capable?
63
+
64
+ self.class.load(to_h.merge(from_date: date.to_date)).notation
65
+ end
66
+
67
+ def ==(other) = other.to_h == to_h
68
+
69
+ def to_h
70
+ {
71
+ volume:,
72
+ kind:,
73
+ period_count:,
74
+ period_key:,
75
+ from_date:
76
+ }
77
+ end
78
+
79
+ def parses?(notation_id) = kind == notation_id
80
+
81
+ def active? = !dormant?
82
+
83
+ def dormant? = dormant_capable? && from_date.nil?
84
+
85
+ def dormant_capable? = kind.in?(dormant_capable_kinds)
86
+
87
+ def period_count = match[:period_count]
88
+
89
+ def period_key = match[:period_key]
90
+
91
+ def vol = match[:vol] || "V1"
92
+
93
+ def volume = (match[:volume] || 1).to_i
94
+
95
+ def from_data
96
+ return {} unless from
97
+
98
+ {from: from}
99
+ end
100
+
101
+ def from_date = match[:from_date]
102
+
103
+ def from = match[:from]
104
+
105
+ def kind = match[:kind]
106
+ end
107
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cycle"
4
+ require "active_support/deprecator"
5
+ require "active_support/deprecation"
6
+ require "active_support/core_ext/numeric/time"
7
+ require "active_support/core_ext/integer/time"
8
+ require "active_support/core_ext/string/conversions"
9
+ module SOF
10
+ # This class is not intended to be referenced directly.
11
+ # This is an internal implementation of Cycle behavior.
12
+ class Cycle::TimeSpan
13
+ extend Forwardable
14
+ # TimeSpan objects map Cycle notations to behaviors for their periods
15
+ #
16
+ # For example:
17
+ # 'M' => TimeSpan::DatePeriod::Month
18
+ # 'Y' => TimeSpan::DatePeriod::Year
19
+ # Read each DatePeriod subclass for more information.
20
+ #
21
+ class InvalidPeriod < StandardError; end
22
+
23
+ class << self
24
+ # Return a time_span for the given count and period
25
+ def for(count, period)
26
+ case count.to_i
27
+ when 0
28
+ TimeSpanNothing
29
+ when 1
30
+ TimeSpanOne
31
+ else
32
+ self
33
+ end.new(count, period)
34
+ end
35
+
36
+ # Return a notation string from a hash
37
+ def notation(hash)
38
+ return unless hash.key?(:period)
39
+
40
+ [
41
+ hash.fetch(:period_count) { 1 },
42
+ notation_id_from_name(hash[:period])
43
+ ].compact.join
44
+ end
45
+
46
+ # Return the notation character for the given period name
47
+ def notation_id_from_name(name)
48
+ type = DatePeriod.types.find do |klass|
49
+ klass.period.to_s == name.to_s
50
+ end
51
+
52
+ raise InvalidPeriod, "'#{name}' is not a valid period" unless type
53
+
54
+ type.code
55
+ end
56
+ end
57
+
58
+ # Class used to calculate the windows of time so that
59
+ # a TimeSpan object will know the correct end of year,
60
+ # quarter, etc.
61
+ class DatePeriod
62
+ extend Forwardable
63
+ class << self
64
+ def for(count, period_notation)
65
+ @cached_periods ||= {}
66
+ @cached_periods[period_notation] ||= {}
67
+ @cached_periods[period_notation][count] ||= (for_notation(period_notation) || self).new(count)
68
+ @cached_periods[period_notation][count]
69
+ end
70
+
71
+ def for_notation(notation)
72
+ types.find do |klass|
73
+ klass.code == notation.to_s.upcase
74
+ end
75
+ end
76
+
77
+ def types = @types ||= Set.new
78
+
79
+ def inherited(klass)
80
+ DatePeriod.types << klass
81
+ end
82
+
83
+ @period = nil
84
+ @code = nil
85
+ @interval = nil
86
+ attr_reader :period, :code, :interval
87
+ end
88
+
89
+ delegate [:period, :code, :interval] => "self.class"
90
+
91
+ def initialize(count)
92
+ @count = count
93
+ end
94
+ attr_reader :count
95
+
96
+ def end_date(date)
97
+ @end_date ||= {}
98
+ @end_date[date] ||= date + duration
99
+ end
100
+
101
+ def begin_date(date)
102
+ @begin_date ||= {}
103
+ @begin_date[date] ||= date - duration
104
+ end
105
+
106
+ def duration = count.send(period)
107
+
108
+ def end_of_period(_) = nil
109
+
110
+ def humanized_period
111
+ return period if count == 1
112
+
113
+ "#{period}s"
114
+ end
115
+
116
+ class Year < self
117
+ @period = :year
118
+ @code = "Y"
119
+ @interval = "years"
120
+
121
+ def end_of_period(date)
122
+ date.end_of_year
123
+ end
124
+
125
+ def beginning_of_period(date)
126
+ date.beginning_of_year
127
+ end
128
+ end
129
+
130
+ class Quarter < self
131
+ @period = :quarter
132
+ @code = "Q"
133
+ @interval = "quarters"
134
+
135
+ def duration
136
+ (count * 3).months
137
+ end
138
+
139
+ def end_of_period(date)
140
+ date.end_of_quarter
141
+ end
142
+
143
+ def beginning_of_period(date)
144
+ date.beginning_of_quarter
145
+ end
146
+ end
147
+
148
+ class Month < self
149
+ @period = :month
150
+ @code = "M"
151
+ @interval = "months"
152
+
153
+ def end_of_period(date)
154
+ date.end_of_month
155
+ end
156
+
157
+ def beginning_of_period(date)
158
+ date.beginning_of_month
159
+ end
160
+ end
161
+
162
+ class Week < self
163
+ @period = :week
164
+ @code = "W"
165
+ @interval = "weeks"
166
+
167
+ def end_of_period(date)
168
+ date.end_of_week
169
+ end
170
+
171
+ def beginning_of_period(date)
172
+ date.beginning_of_week
173
+ end
174
+ end
175
+
176
+ class Day < self
177
+ @period = :day
178
+ @code = "D"
179
+ @interval = "days"
180
+
181
+ def end_of_period(date)
182
+ date
183
+ end
184
+
185
+ def beginning_of_period(date)
186
+ date
187
+ end
188
+ end
189
+ end
190
+ private_constant :DatePeriod
191
+
192
+ def initialize(count, period_id)
193
+ @count = Integer(count, exception: false)
194
+ @window = DatePeriod.for(period_count, period_id)
195
+ end
196
+ attr_reader :window
197
+
198
+ delegate [:end_date, :begin_date] => :window
199
+
200
+ def end_date_of_period(date)
201
+ window.end_of_period(date)
202
+ end
203
+
204
+ def begin_date_of_period(date)
205
+ window.beginning_of_period(date)
206
+ end
207
+
208
+ # Integer value for the period count or nil
209
+ def period_count
210
+ @count
211
+ end
212
+
213
+ delegate [:period, :duration, :interval, :humanized_period] => :window
214
+
215
+ # Return a date according to the rules of the time_span
216
+ def final_date(date)
217
+ return unless period
218
+
219
+ window.end_date(date.to_date)
220
+ end
221
+
222
+ def to_h
223
+ {
224
+ period:,
225
+ period_count:
226
+ }
227
+ end
228
+
229
+ class TimeSpanNothing < self
230
+ end
231
+
232
+ class TimeSpanOne < self
233
+ def interval = humanized_period
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SOF
4
+ class Cycle
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/sof/cycle.rb ADDED
@@ -0,0 +1,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cycle/version"
4
+ require "forwardable"
5
+ require_relative "cycle/parser"
6
+ require_relative "cycle/time_span"
7
+ require "active_support/core_ext/date/conversions"
8
+ require "active_support/core_ext/string/filters"
9
+
10
+ module SOF
11
+ class Cycle
12
+ extend Forwardable
13
+ class InvalidInput < StandardError; end
14
+
15
+ class InvalidPeriod < InvalidInput; end
16
+
17
+ class InvalidKind < InvalidInput; end
18
+
19
+ def initialize(notation, parser: Parser.new(notation))
20
+ @notation = notation
21
+ @parser = parser
22
+ validate_period
23
+
24
+ return if @parser.valid?
25
+
26
+ raise InvalidInput, "'#{notation}' is not a valid input"
27
+ end
28
+
29
+ attr_reader :parser
30
+
31
+ delegate [:activated_notation, :volume, :from, :from_date, :time_span, :period,
32
+ :humanized_period, :period_key, :active?] => :@parser
33
+ delegate [:kind, :volume_only?, :valid_periods] => "self.class"
34
+ delegate [:period_count, :duration] => :time_span
35
+
36
+ # Turn a cycle or notation string into a hash
37
+ def self.dump(cycle_or_string)
38
+ if cycle_or_string.is_a? Cycle
39
+ cycle_or_string
40
+ else
41
+ Cycle.for(cycle_or_string)
42
+ end.to_h
43
+ end
44
+
45
+ # Return a Cycle object from a hash
46
+ def self.load(hash)
47
+ symbolized_hash = hash.symbolize_keys
48
+ cycle_class = class_for_kind(symbolized_hash[:kind])
49
+
50
+ unless cycle_class.valid_periods.empty?
51
+ cycle_class.validate_period(
52
+ TimeSpan.notation_id_from_name(symbolized_hash[:period])
53
+ )
54
+ end
55
+
56
+ Cycle.for notation(symbolized_hash)
57
+ rescue TimeSpan::InvalidPeriod => exc
58
+ raise InvalidPeriod, exc.message
59
+ end
60
+
61
+ # Retun a notation string from a hash
62
+ #
63
+ # @param hash [Hash] hash of data for a valid Cycle
64
+ # @return [String] string representation of a Cycle
65
+ def self.notation(hash)
66
+ volume_notation = "V#{hash.fetch(:volume) { 1 }}"
67
+ return volume_notation if hash[:kind].nil? || hash[:kind].to_sym == :volume_only
68
+
69
+ cycle_class = class_for_kind(hash[:kind].to_sym)
70
+ [
71
+ volume_notation,
72
+ cycle_class.notation_id,
73
+ TimeSpan.notation(hash.slice(:period, :period_count)),
74
+ hash.fetch(:from, nil)
75
+ ].compact.join
76
+ end
77
+
78
+ # Return a Cycle object from a notation string
79
+ #
80
+ # @param notation [String] a string notation representing a Cycle
81
+ # @example
82
+ # Cycle.for('V2C1Y)
83
+ # @return [Cycle] a Cycle object representing the provide string notation
84
+ def self.for(notation)
85
+ return notation if notation.is_a? Cycle
86
+ return notation if notation.is_a? Cycle::Dormant
87
+ parser = Parser.new(notation)
88
+ unless parser.valid?
89
+ raise InvalidInput, "'#{notation}' is not a valid input"
90
+ end
91
+
92
+ cycle = cycle_handlers.find do |klass|
93
+ parser.parses?(klass.notation_id)
94
+ end.new(notation, parser:)
95
+ return cycle if parser.active?
96
+
97
+ Cycle::Dormant.new(cycle, parser:)
98
+ end
99
+
100
+ # Return the appropriate class for the give notation id
101
+ #
102
+ # @param notation [String] notation id matching the kind of Cycle class
103
+ # @example
104
+ # class_for_notation_id('L')
105
+ #
106
+ def self.class_for_notation_id(notation_id)
107
+ cycle_handlers.find do |klass|
108
+ klass.notation_id == notation_id
109
+ end || raise(InvalidKind, "'#{notation_id}' is not a valid kind of #{name}")
110
+ end
111
+
112
+ # Return the class handling the kind
113
+ #
114
+ # @param sym [Symbol] symbol matching the kind of Cycle class
115
+ # @example
116
+ # class_for_kind(:lookback)
117
+ def self.class_for_kind(sym)
118
+ Cycle.cycle_handlers.find do |klass|
119
+ klass.handles?(sym)
120
+ end || raise(InvalidKind, "':#{sym}' is not a valid kind of Cycle")
121
+ end
122
+
123
+ def self.cycle_handlers = @cycle_handlers ||= Set.new
124
+
125
+ def self.inherited(klass) = cycle_handlers << klass
126
+
127
+ def self.handles?(sym)
128
+ sym && kind == sym.to_sym
129
+ end
130
+
131
+ @volume_only = false
132
+ @notation_id = nil
133
+ @kind = nil
134
+ @valid_periods = []
135
+
136
+ def self.volume_only? = @volume_only
137
+
138
+ class << self
139
+ attr_reader :notation_id, :kind, :valid_periods
140
+ end
141
+
142
+ # Raises an error if the given period isn't in the list of valid periods.
143
+ #
144
+ # @param period [String] period matching the class valid periods
145
+ # @raise [InvalidPeriod]
146
+ def self.validate_period(period)
147
+ raise InvalidPeriod, <<~ERR.squish unless valid_periods.include?(period)
148
+ Invalid period value of '#{period}' provided. Valid periods are:
149
+ #{valid_periods.join(", ")}
150
+ ERR
151
+ end
152
+
153
+ def validate_period
154
+ return if valid_periods.empty?
155
+
156
+ self.class.validate_period(period_key)
157
+ end
158
+
159
+ # Return the cycle representation as a notation string
160
+ def notation = self.class.notation(to_h)
161
+
162
+ # Cycles are considered equal if their hash representations are equal
163
+ def ==(other) = to_h == other.to_h
164
+
165
+ # From the supplied anchor date, are there enough in-window completions to
166
+ # satisfy the cycle?
167
+ #
168
+ # @return [Boolean] true if the cycle is satisfied, false otherwise
169
+ def satisfied_by?(completion_dates, anchor: Date.current)
170
+ covered_dates(completion_dates, anchor:).size >= volume
171
+ end
172
+
173
+ def covered_dates(dates, anchor: Date.current)
174
+ dates.select do |date|
175
+ cover?(date, anchor:)
176
+ end
177
+ end
178
+
179
+ def cover?(date, anchor: Date.current)
180
+ range(anchor).cover?(date)
181
+ end
182
+
183
+ def range(anchor) = start_date(anchor)..final_date(anchor)
184
+
185
+ def humanized_span = [period_count, humanized_period].join(" ")
186
+
187
+ # Return the final date of the cycle
188
+ def final_date(_anchor) = nil
189
+
190
+ def expiration_of(_completion_dates, anchor: Date.current) = nil
191
+
192
+ def volume_to_delay_expiration(_completion_dates, anchor:) = 0
193
+
194
+ def to_h
195
+ {
196
+ kind:,
197
+ volume:,
198
+ period:,
199
+ period_count:,
200
+ **from_data
201
+ }
202
+ end
203
+
204
+ def from_data
205
+ return {} unless from
206
+
207
+ {from: from}
208
+ end
209
+
210
+ def as_json(...) = notation
211
+
212
+ class Dormant
213
+ def initialize(cycle, parser:)
214
+ @cycle = cycle
215
+ @parser = parser
216
+ end
217
+
218
+ attr_reader :cycle, :parser
219
+
220
+ def to_s
221
+ cycle.to_s + " (dormant)"
222
+ end
223
+
224
+ def covered_dates(...) = []
225
+
226
+ def expiration_of(...) = nil
227
+
228
+ def satisfied_by?(...) = false
229
+
230
+ def cover?(...) = false
231
+
232
+ def method_missing(method, ...) = cycle.send(method, ...)
233
+
234
+ def respond_to_missing?(method, include_private = false)
235
+ cycle.respond_to?(method, include_private)
236
+ end
237
+ end
238
+
239
+ class Within < self
240
+ @volume_only = false
241
+ @notation_id = "W"
242
+ @kind = :within
243
+ @valid_periods = %w[D W M Y]
244
+
245
+ def to_s = "#{volume}x within #{date_range}"
246
+
247
+ def date_range
248
+ return humanized_span unless active?
249
+
250
+ [start_date, final_date].map { _1.to_fs(:american) }.join(" - ")
251
+ end
252
+
253
+ def final_date(_ = nil) = time_span.end_date(start_date)
254
+
255
+ def start_date(_ = nil) = from_date.to_date
256
+ end
257
+
258
+ class VolumeOnly < self
259
+ @volume_only = true
260
+ @notation_id = nil
261
+ @kind = :volume_only
262
+ @valid_periods = []
263
+
264
+ class << self
265
+ def handles?(sym) = sym.nil? || super
266
+
267
+ def validate_period(period)
268
+ raise InvalidPeriod, <<~ERR.squish unless period.nil?
269
+ Invalid period value of '#{period}' provided. Valid periods are:
270
+ #{valid_periods.join(", ")}
271
+ ERR
272
+ end
273
+ end
274
+
275
+ def to_s = "#{volume}x total"
276
+
277
+ def covered_dates(dates, ...) = dates
278
+
279
+ def cover?(...) = true
280
+ end
281
+
282
+ class Lookback < self
283
+ @volume_only = false
284
+ @notation_id = "L"
285
+ @kind = :lookback
286
+ @valid_periods = %w[D W M Y]
287
+
288
+ def to_s = "#{volume}x in the prior #{period_count} #{humanized_period}"
289
+
290
+ def volume_to_delay_expiration(completion_dates, anchor:)
291
+ oldest_relevant_completion = completion_dates.min
292
+ [completion_dates.count(oldest_relevant_completion), volume].min
293
+ end
294
+
295
+ # "Absent further completions, you go red on this date"
296
+ # @return [Date, nil] the date on which the cycle will expire given the
297
+ # provided completion dates. Returns nil if the cycle is already unsatisfied.
298
+ def expiration_of(completion_dates)
299
+ anchor = completion_dates.max_by(volume) { _1 }.min
300
+ return unless satisfied_by?(completion_dates, anchor:)
301
+
302
+ window_end anchor
303
+ end
304
+
305
+ def final_date(anchor)
306
+ return if anchor.nil?
307
+
308
+ time_span.end_date(anchor.to_date)
309
+ end
310
+ alias_method :window_end, :final_date
311
+
312
+ def start_date(anchor)
313
+ time_span.begin_date(anchor.to_date)
314
+ end
315
+ alias_method :window_start, :start_date
316
+ end
317
+
318
+ class Calendar < self
319
+ @volume_only = false
320
+ @notation_id = "C"
321
+ @kind = :calendar
322
+ @valid_periods = %w[M Q Y]
323
+
324
+ class << self
325
+ def frame_of_reference = "total"
326
+ end
327
+
328
+ def to_s
329
+ "#{volume}x every #{period_count} calendar #{humanized_period}"
330
+ end
331
+
332
+ # "Absent further completions, you go red on this date"
333
+ # @return [Date, nil] the date on which the cycle will expire given the
334
+ # provided completion dates. Returns nil if the cycle is already unsatisfied.
335
+ def expiration_of(completion_dates)
336
+ anchor = completion_dates.max_by(volume) { _1 }.min
337
+ return unless satisfied_by?(completion_dates, anchor:)
338
+
339
+ window_end(anchor) + duration
340
+ end
341
+
342
+ def final_date(anchor)
343
+ return if anchor.nil?
344
+ time_span.end_date_of_period(anchor.to_date)
345
+ end
346
+ alias_method :window_end, :final_date
347
+
348
+ def start_date(anchor)
349
+ time_span.begin_date_of_period(anchor.to_date)
350
+ end
351
+ end
352
+ end
353
+ end
data/lib/sof-cycle.rb ADDED
@@ -0,0 +1 @@
1
+ require_relative "sof/cycle"
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sof-cycle
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jim Gay
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-07-09 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: forwardable
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '6.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '6.0'
41
+ description:
42
+ email:
43
+ - jim@saturnflyer.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - ".rspec"
49
+ - ".simplecov"
50
+ - CHANGELOG.md
51
+ - README.md
52
+ - Rakefile
53
+ - lib/sof-cycle.rb
54
+ - lib/sof/cycle.rb
55
+ - lib/sof/cycle/parser.rb
56
+ - lib/sof/cycle/time_span.rb
57
+ - lib/sof/cycle/version.rb
58
+ homepage: https://github.com/SOFware/sof-cycle
59
+ licenses: []
60
+ metadata:
61
+ homepage_uri: https://github.com/SOFware/sof-cycle
62
+ changelog_uri: https://github.com/SOFware/sof-cycle/blob/main/CHANGELOG.md
63
+ post_install_message:
64
+ rdoc_options: []
65
+ require_paths:
66
+ - lib
67
+ required_ruby_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: 3.0.0
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ requirements: []
78
+ rubygems_version: 3.5.9
79
+ signing_key:
80
+ specification_version: 4
81
+ summary: Parse and interact with SOF cycle notation.
82
+ test_files: []