sof-cycle 0.1.0

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 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: []