sof-cycle 0.1.13 → 0.1.15

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.
data/lib/sof/cycle.rb CHANGED
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "forwardable"
4
- require_relative "parser"
4
+ require_relative "cycle/registry"
5
+ require_relative "cycle/parser"
5
6
 
6
7
  module SOF
7
8
  class Cycle
8
9
  extend ::Forwardable
10
+
9
11
  class InvalidInput < StandardError; end
10
12
 
11
13
  class InvalidPeriod < InvalidInput; end
@@ -69,9 +71,7 @@ module SOF
69
71
  raise InvalidInput, "'#{notation}' is not a valid input"
70
72
  end
71
73
 
72
- cycle = Cycle.cycle_handlers.find do |klass|
73
- parser.parses?(klass.notation_id)
74
- end.new(notation, parser:)
74
+ cycle = registry.for_notation_id(parser.kind).new(notation, parser:)
75
75
  return cycle if parser.active?
76
76
 
77
77
  Cycles::Dormant.new(cycle, parser:)
@@ -83,22 +83,14 @@ module SOF
83
83
  # @example
84
84
  # class_for_notation_id('L')
85
85
  #
86
- def class_for_notation_id(notation_id)
87
- Cycle.cycle_handlers.find do |klass|
88
- klass.notation_id == notation_id
89
- end || raise(InvalidKind, "'#{notation_id}' is not a valid kind of #{name}")
90
- end
86
+ def class_for_notation_id(notation_id) = registry.for_notation_id(notation_id)
91
87
 
92
88
  # Return the class handling the kind
93
89
  #
94
90
  # @param sym [Symbol] symbol matching the kind of Cycle class
95
91
  # @example
96
92
  # class_for_kind(:lookback)
97
- def class_for_kind(sym)
98
- Cycle.cycle_handlers.find do |klass|
99
- klass.handles?(sym)
100
- end || raise(InvalidKind, "':#{sym}' is not a valid kind of Cycle")
101
- end
93
+ def class_for_kind(sym) = registry.handling(sym)
102
94
 
103
95
  # Return a legend explaining all notation components
104
96
  #
@@ -122,6 +114,8 @@ module SOF
122
114
  }
123
115
  end
124
116
 
117
+ # Defaults for the base class itself, which handles no kind and so is
118
+ # never registered.
125
119
  @volume_only = false
126
120
  @notation_id = nil
127
121
  @kind = nil
@@ -149,23 +143,77 @@ module SOF
149
143
  kind.to_s == sym.to_s
150
144
  end
151
145
 
152
- def cycle_handlers
153
- @cycle_handlers ||= Set.new
146
+ # Declare what kind of cycle this class handles, and register it.
147
+ #
148
+ # Registration is a consequence of declaring, so the two can never
149
+ # disagree, and subclassing Cycle for any other reason — an abstract
150
+ # intermediate, a test double — registers nothing.
151
+ #
152
+ # An application can declare its own kind this way and the parser will
153
+ # recognise its notation, because the pattern is built from what is
154
+ # registered.
155
+ #
156
+ # @param kind [Symbol] the kind this class handles, e.g. :lookback
157
+ # @param notation [String, nil] the notation id opening the cycle, e.g.
158
+ # "L". Omit for a cycle with no notation of its own, such as
159
+ # volume-only.
160
+ # @param periods [Array<String>] period keys this kind accepts. Empty
161
+ # means the kind takes no period.
162
+ # @param volume_only [Boolean] whether this kind carries volume alone.
163
+ #
164
+ # @example
165
+ # class Fortnightly < SOF::Cycle
166
+ # handles :fortnightly, notation: "X", periods: %w[D W]
167
+ # def self.recurring? = true
168
+ # end
169
+ def handles(kind, notation: nil, periods: [], volume_only: false)
170
+ @kind = kind
171
+ @notation_id = notation
172
+ @valid_periods = periods
173
+ @volume_only = volume_only
174
+ register(self)
154
175
  end
155
176
 
156
- def inherited(klass)
157
- Cycle.cycle_handlers << klass
158
- end
177
+ # Register a handler that does not inherit from Cycle. Inheriting and
178
+ # declaring with `handles` is the easier path, because it supplies the
179
+ # behaviour Cycle.for goes on to use; this is for a class that would
180
+ # rather implement that itself.
181
+ #
182
+ # The class must answer, at the class level:
183
+ #
184
+ # kind the kind it handles, e.g. :lookback
185
+ # notation_id the notation opening it, e.g. "L", or nil
186
+ # valid_periods period keys it accepts, e.g. %w[D W M Y]
187
+ # volume_only? whether it carries volume alone
188
+ # handles?(kind) whether it answers to a kind
189
+ # recurring? whether the window repeats
190
+ # dormant_capable? whether it can be written without a from date
191
+ # validate_period(key)
192
+ # description, examples (for the legend)
193
+ #
194
+ # and instances are built as `new(notation, parser:)`.
195
+ #
196
+ # Registering displaces any handler already answering to the same kind
197
+ # or notation id, so this replaces a built-in as readily as it adds one.
198
+ #
199
+ # @param cycle_class [Class] the handler to register
200
+ # @return [Class] the class registered
201
+ def register(cycle_class) = registry.register(cycle_class)
202
+
203
+ # Drop a handler, undoing `register` or a `handles` declaration.
204
+ def unregister(cycle_class) = registry.unregister(cycle_class)
205
+
206
+ def registry = Registry.instance
159
207
 
160
208
  private
161
209
 
162
210
  def build_kind_legend
163
211
  legend = {}
164
- Cycle.cycle_handlers.each do |handler|
212
+ registry.cycle_classes.each do |handler|
165
213
  # Skip volume_only since it doesn't have a notation_id
166
- next if handler.instance_variable_get(:@volume_only)
214
+ next if handler.volume_only?
167
215
 
168
- notation_id = handler.instance_variable_get(:@notation_id)
216
+ notation_id = handler.notation_id
169
217
  next unless notation_id
170
218
 
171
219
  legend[notation_id] = {
@@ -178,14 +226,10 @@ module SOF
178
226
 
179
227
  def build_period_legend
180
228
  legend = {}
181
- # Use known period codes since DatePeriod is private
182
- period_mappings = {
183
- "D" => "day",
184
- "W" => "week",
185
- "M" => "month",
186
- "Q" => "quarter",
187
- "Y" => "year"
188
- }
229
+ # Built from the registered periods, so one an application adds shows
230
+ # up here too.
231
+ period_mappings = TimeSpan.period_registry.period_classes
232
+ .to_h { |klass| [klass.code, klass.period.to_s] }
189
233
 
190
234
  period_mappings.each do |code, period_name|
191
235
  legend[code] = {
@@ -223,7 +267,7 @@ module SOF
223
267
  delegate [:kind, :recurring?, :volume_only?, :valid_periods] => "self.class"
224
268
  delegate [:period_count, :duration] => :time_span
225
269
  delegate [:calendar?, :dormant?, :end_of?, :interval?, :lookback?,
226
- :volume_only?, :within?] => :kind_inquiry
270
+ :lookback_end_of?, :volume_only?, :within?] => :kind_inquiry
227
271
 
228
272
  def kind_inquiry = ActiveSupport::StringInquirer.new(kind.to_s)
229
273
 
@@ -252,6 +296,39 @@ module SOF
252
296
 
253
297
  def extend_period(_ = nil) = self
254
298
 
299
+ # Whether this kind of cycle can be written without a from date, and so
300
+ # can be anchored later. Answers on the instance so a dormant cycle can
301
+ # be asked too — `cycle.class` is Cycles::Dormant there, which knows
302
+ # nothing of the kind it wraps.
303
+ def dormant_capable? = self.class.dormant_capable?
304
+
305
+ # The cycle restarted from the latest of `dates` past its current anchor.
306
+ #
307
+ # A recurring, dormant-capable window (E and I) is reset by the act that
308
+ # satisfies it — "complete 1 by that date to reset the cycle". Nothing in
309
+ # the notation does that on its own, so this used to fall to each
310
+ # consuming app.
311
+ #
312
+ # Dates on or before the current anchor are ignored: a back-dated act
313
+ # must not drag a forward-running window backwards. Every other kind
314
+ # returns itself — a lookback window already slides, a calendar window is
315
+ # pinned to the calendar, a one-shot window does not repeat, and an
316
+ # un-anchored one has no window to move until it is activated.
317
+ #
318
+ # @param dates [Array<Date, Time, nil>] the acts that satisfy this cycle
319
+ # @return [Cycle] the reset cycle, or self when nothing resets it
320
+ #
321
+ # @example
322
+ # Cycle.for("V1E18MF2026-02-01").reset_by([Date.new(2026, 9, 15)])
323
+ # # => Cycle.for("V1E18MF2026-09-15")
324
+ def reset_by(dates)
325
+ return self unless recurring? && dormant_capable?
326
+ return self if from_date.nil?
327
+
328
+ reset = reset_date(dates)
329
+ reset ? Cycle.for(activated_notation(reset)) : self
330
+ end
331
+
255
332
  # From the supplied anchor date, are there enough in-window completions to
256
333
  # satisfy the cycle?
257
334
  #
@@ -302,5 +379,13 @@ module SOF
302
379
  end
303
380
 
304
381
  def as_json(...) = notation
382
+
383
+ private
384
+
385
+ # The latest date that moves this cycle's window forward, or nil.
386
+ def reset_date(dates)
387
+ anchored_on = from_date.to_date
388
+ Array(dates).filter_map { it&.to_date }.select { it > anchored_on }.max
389
+ end
305
390
  end
306
391
  end
@@ -3,10 +3,7 @@
3
3
  module SOF
4
4
  module Cycles
5
5
  class Calendar < Cycle
6
- @volume_only = false
7
- @notation_id = "C"
8
- @kind = :calendar
9
- @valid_periods = %w[M Q Y]
6
+ handles :calendar, notation: "C", periods: %w[M Q Y]
10
7
 
11
8
  class << self
12
9
  def frame_of_reference = "total"
@@ -22,6 +22,10 @@ module SOF
22
22
 
23
23
  def covered_dates(...) = []
24
24
 
25
+ # No anchor yet, so nothing to move: a dormant cycle is reset by being
26
+ # activated, not by the acts that satisfy it.
27
+ def reset_by(...) = self
28
+
25
29
  def expiration_of(...) = nil
26
30
 
27
31
  def satisfied_by?(...) = false
@@ -11,10 +11,7 @@
11
11
  module SOF
12
12
  module Cycles
13
13
  class EndOf < Cycle
14
- @volume_only = false
15
- @notation_id = "E"
16
- @kind = :end_of
17
- @valid_periods = %w[W M Q Y]
14
+ handles :end_of, notation: "E", periods: %w[W M Q Y]
18
15
 
19
16
  def self.recurring? = true
20
17
 
@@ -11,10 +11,7 @@
11
11
  module SOF
12
12
  module Cycles
13
13
  class Interval < Cycle
14
- @volume_only = false
15
- @notation_id = "I"
16
- @kind = :interval
17
- @valid_periods = %w[D W M Y]
14
+ handles :interval, notation: "I", periods: %w[D W M Y]
18
15
 
19
16
  def self.recurring? = true
20
17
 
@@ -3,10 +3,7 @@
3
3
  module SOF
4
4
  module Cycles
5
5
  class Lookback < Cycle
6
- @volume_only = false
7
- @notation_id = "L"
8
- @kind = :lookback
9
- @valid_periods = %w[D W M Y]
6
+ handles :lookback, notation: "L", periods: %w[D W M Y]
10
7
 
11
8
  def self.recurring? = true
12
9
 
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SOF
4
+ module Cycles
5
+ class LookbackEndOf < Cycle
6
+ handles :lookback_end_of, notation: "LE", periods: %w[D W M Q Y]
7
+
8
+ def self.recurring? = true
9
+
10
+ def self.description
11
+ "Lookback End of Period - occurrences within a prior time period, expiring at the end of the calendar period"
12
+ end
13
+
14
+ def self.examples
15
+ ["V1LE24M - once in the prior 24 months (expires end of month)", "V2LE3W - twice in the prior 3 weeks (expires end of week)"]
16
+ end
17
+
18
+ def to_s = "#{volume}x in the prior #{period_count} #{humanized_period} (end of period)"
19
+
20
+ def expiration_of(completion_dates, anchor: Date.current)
21
+ oldest = completion_dates.max_by(volume) { it }.min
22
+ return unless satisfied_by?(completion_dates, anchor:)
23
+
24
+ final_date(oldest)
25
+ end
26
+
27
+ def final_date(anchor)
28
+ return if anchor.nil?
29
+
30
+ time_span.end_date_of_period(time_span.end_date(anchor.to_date))
31
+ end
32
+ alias_method :window_end, :final_date
33
+
34
+ def start_date(anchor)
35
+ time_span.begin_date_of_period(time_span.begin_date(anchor.to_date))
36
+ end
37
+ alias_method :window_start, :start_date
38
+ end
39
+ end
40
+ end
@@ -3,10 +3,7 @@
3
3
  module SOF
4
4
  module Cycles
5
5
  class VolumeOnly < Cycle
6
- @volume_only = true
7
- @notation_id = nil
8
- @kind = :volume_only
9
- @valid_periods = []
6
+ handles :volume_only, volume_only: true
10
7
 
11
8
  class << self
12
9
  def handles?(sym) = sym.nil? || sym.to_s == "volume_only"
@@ -3,10 +3,7 @@
3
3
  module SOF
4
4
  module Cycles
5
5
  class Within < Cycle
6
- @volume_only = false
7
- @notation_id = "W"
8
- @kind = :within
9
- @valid_periods = %w[D W M Y]
6
+ handles :within, notation: "W", periods: %w[D W M Y]
10
7
 
11
8
  def self.recurring? = false
12
9
 
@@ -24,7 +21,7 @@ module SOF
24
21
 
25
22
  def extend_period(count)
26
23
  Cycle.for(
27
- Parser.load(
24
+ Cycle::Parser.load(
28
25
  parser.to_h.merge(period_count: period_count + count)
29
26
  ).to_s
30
27
  )
data/lib/sof-cycle.rb CHANGED
@@ -14,4 +14,4 @@ require_relative "sof/cycle"
14
14
 
15
15
  Dir[File.join(__dir__, "sof", "cycles", "*.rb")].each { |file| require file }
16
16
 
17
- require_relative "sof/time_span"
17
+ require_relative "sof/cycle/time_span"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sof-cycle
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.13
4
+ version: 0.1.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay
@@ -43,7 +43,6 @@ executables: []
43
43
  extensions: []
44
44
  extra_rdoc_files: []
45
45
  files:
46
- - ".claude/settings.local.json"
47
46
  - ".rspec"
48
47
  - ".simplecov"
49
48
  - ".tool-versions"
@@ -56,23 +55,30 @@ files:
56
55
  - checksums/sof-cycle-0.1.10.gem.sha512
57
56
  - checksums/sof-cycle-0.1.11.gem.sha512
58
57
  - checksums/sof-cycle-0.1.12.gem.sha512
58
+ - checksums/sof-cycle-0.1.13.gem.sha512
59
+ - checksums/sof-cycle-0.1.14.gem.sha512
59
60
  - checksums/sof-cycle-0.1.2.gem.sha512
61
+ - checksums/sof-cycle-0.1.3.gem.sha512
62
+ - checksums/sof-cycle-0.1.4.gem.sha512
63
+ - checksums/sof-cycle-0.1.5.gem.sha512
60
64
  - checksums/sof-cycle-0.1.6.gem.sha512
61
65
  - checksums/sof-cycle-0.1.7.gem.sha512
62
66
  - checksums/sof-cycle-0.1.8.gem.sha512
63
67
  - checksums/sof-cycle-0.1.9.gem.sha512
64
68
  - lib/sof-cycle.rb
65
69
  - lib/sof/cycle.rb
70
+ - lib/sof/cycle/parser.rb
71
+ - lib/sof/cycle/registry.rb
72
+ - lib/sof/cycle/time_span.rb
66
73
  - lib/sof/cycle/version.rb
67
74
  - lib/sof/cycles/calendar.rb
68
75
  - lib/sof/cycles/dormant.rb
69
76
  - lib/sof/cycles/end_of.rb
70
77
  - lib/sof/cycles/interval.rb
71
78
  - lib/sof/cycles/lookback.rb
79
+ - lib/sof/cycles/lookback_end_of.rb
72
80
  - lib/sof/cycles/volume_only.rb
73
81
  - lib/sof/cycles/within.rb
74
- - lib/sof/parser.rb
75
- - lib/sof/time_span.rb
76
82
  homepage: https://github.com/SOFware/sof-cycle
77
83
  licenses: []
78
84
  metadata:
@@ -92,7 +98,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
92
98
  - !ruby/object:Gem::Version
93
99
  version: '0'
94
100
  requirements: []
95
- rubygems_version: 3.6.7
101
+ rubygems_version: 4.0.6
96
102
  specification_version: 4
97
103
  summary: Parse and interact with SOF cycle notation.
98
104
  test_files: []
@@ -1,17 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(find:*)",
5
- "Bash(ls:*)",
6
- "Bash(mkdir:*)",
7
- "Bash(touch:*)",
8
- "Bash(rm:*)",
9
- "Bash(bundle exec rspec:*)",
10
- "Bash(bundle exec rake:*)",
11
- "Bash(bundle exec standardrb:*)",
12
- "Bash(git checkout:*)",
13
- "Bash(bundle exec ruby:*)"
14
- ],
15
- "deny": []
16
- }
17
- }
data/lib/sof/parser.rb DELETED
@@ -1,109 +0,0 @@
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 Parser
14
- extend Forwardable
15
- PARTS_REGEX = /
16
- ^(?<vol>V(?<volume>\d*))? # optional volume
17
- (?<set>(?<kind>L|C|W|E|I) # 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
24
- Cycle.cycle_handlers.select(&:dormant_capable?).map(&:notation_id).compact
25
- end
26
-
27
- def self.for(notation_or_parser)
28
- return notation_or_parser if notation_or_parser.is_a? self
29
-
30
- new(notation_or_parser)
31
- end
32
-
33
- def self.load(hash)
34
- hash.symbolize_keys!
35
- hash.reverse_merge!(volume: 1)
36
- keys = %i[volume kind period_count period_key]
37
- str = "V#{hash.values_at(*keys).join}"
38
- return new(str) unless hash[:from_date]
39
-
40
- new([str, "F#{hash[:from_date]}"].join)
41
- end
42
-
43
- def initialize(notation)
44
- @notation = notation&.upcase
45
- @match = @notation&.match(PARTS_REGEX)
46
- end
47
-
48
- attr_reader :match, :notation
49
-
50
- delegate [:dormant_capable_kinds] => "self.class"
51
- delegate [:period, :humanized_period] => :time_span
52
-
53
- # Return a TimeSpan object for the period and period_count
54
- def time_span
55
- @time_span ||= TimeSpan.for(period_count, period_key)
56
- end
57
-
58
- def valid? = match.present?
59
-
60
- def inspect = notation
61
- alias_method :to_s, :inspect
62
-
63
- def activated_notation(date)
64
- return notation unless dormant_capable?
65
-
66
- self.class.load(to_h.merge(from_date: date.to_date)).notation
67
- end
68
-
69
- def ==(other) = other.to_h == to_h
70
-
71
- def to_h
72
- {
73
- volume:,
74
- kind:,
75
- period_count:,
76
- period_key:,
77
- from_date:
78
- }
79
- end
80
-
81
- def parses?(notation_id) = kind == notation_id
82
-
83
- def active? = !dormant?
84
-
85
- def dormant? = dormant_capable? && from_date.nil?
86
-
87
- def dormant_capable? = kind.in?(dormant_capable_kinds)
88
-
89
- def period_count = match[:period_count]
90
-
91
- def period_key = match[:period_key]
92
-
93
- def vol = match[:vol] || "V1"
94
-
95
- def volume = (match[:volume] || 1).to_i
96
-
97
- def from_data
98
- return {} unless from
99
-
100
- {from: from}
101
- end
102
-
103
- def from_date = match[:from_date]
104
-
105
- def from = match[:from]
106
-
107
- def kind = match[:kind]
108
- end
109
- end