sof-cycle 0.1.14 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4bf300b22d76c7736f524022360c052b1546fa573ca39e851d4ce2a2ec2de4b7
4
- data.tar.gz: 30f5a7972655d52acd3a547f59482c32d6c8fbdf22711f9f23eca27e695053cf
3
+ metadata.gz: ace7e7bdb57808f1c633cf78aaab4fc7949e6820e86089c283926e0c22558bbd
4
+ data.tar.gz: 5c12b7d8d840f2afe367d13cc7e5864fd835d62e2f0b9bc480d17873e706182c
5
5
  SHA512:
6
- metadata.gz: 63208d5574eaf86293c0a39214155f7a645b74d51b2dd23bfd03ae83b9dcbd032d145ffd909a8b3a675caef7485f6abe14d5b4bbfcbaafac3c383e29193fa8a7
7
- data.tar.gz: e4caecf602ea58c7b0cec5a27b1efe36e23b65d0cf191b9c71eb7415e75a01c05079d4783c75d69e02d1ed31da76ef2765a15bc93ee84d43b39c8f03d615a17d
6
+ metadata.gz: d1c8dda915931d2a5cd860ebf1ad79d2c6cc353d8794a159cc52720bde02f2e3e3aa7001fbb7af240dbf16c1b212f44ce28eacb7ec10fa1868dd47c7860406ae
7
+ data.tar.gz: ea2ca6604e38d85894d0df757010449f839040b28c01de608270cdffc536ed5f8e62125ecea7ec59f9100106eae6a3ab2a3243b68b4218a7269b671f483e9bad
data/CHANGELOG.md CHANGED
@@ -5,18 +5,6 @@ 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.14] - 2026-04-27
9
-
10
- ## [0.1.13] - 2026-03-20
11
-
12
- ### Added
13
-
14
- - Interval cycle kind (`I` notation) — repeating windows anchored to a from_date that re-anchor from completion date (e.g., `V1I24MF2026-03-31`)
8
+ ## [0.1.15] - 2026-07-27
15
9
 
16
- ### Changed
17
-
18
- - Dormant capability is now declared on each cycle class (`def self.dormant_capable? = true`) instead of only in `Parser.dormant_capable_kinds`
19
-
20
- ### Fixed
21
-
22
- - EndOf cycle `final_date` was off by one period — `V1E12M` now correctly expires at the end of the 12th month, not the 11th
10
+ ## [0.1.14] - 2026-04-27
@@ -0,0 +1 @@
1
+ bb21b935b95e99c2616c397155c6354976d343e669c34a7c36a319c495bdfda5b3a24c5d6042d891672215f90e405703a3ee24f79623e129c17bdd9967190a29
@@ -0,0 +1 @@
1
+ 33dfd415ab31a635f8af0e515187f22f958227d26cf9c27f087dccc03c4c8ff4b7c38f9f303f5f134597b652aafe8363d8683d82ccb93492e3bb4310f218f50d
@@ -0,0 +1 @@
1
+ 0c1dbd71ae4ba8c8dd68e6285acd567ebe5f715c88a6a25c16e9abc1baa48a02618e006a5b1234b0d2c97bacdef53d1a0deb0691a8659474eeb9304c95764289
@@ -0,0 +1 @@
1
+ 8692e06774fdb76269fa438824887f87991703885a2cf2cb7dc5bd77ba44b2e0ddce1a78c40e5411da8be2b88dfb0c875900b42f8a784a4060db7d4f4c4d4f1b
@@ -0,0 +1 @@
1
+ 7c06ae8b1c4eaafac55f16143c7f60b4ce3c613a83d75859e2f798e850a90190d1f26109057e349c42f03fcce8c0f81a85904bab3ca96877b60c27ad52ee1517
@@ -0,0 +1,127 @@
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
+ class Cycle
12
+ # This class is not intended to be referenced directly.
13
+ # This is an internal implementation of Cycle behavior.
14
+ class Parser
15
+ extend Forwardable
16
+
17
+ # Built from the registry rather than written out, so an application
18
+ # that declares its own kind has its notation recognised too. Rebuilt
19
+ # whenever a class registers; cached in between, since parsing is hot.
20
+ def self.parts_regex
21
+ pattern = [Cycle.registry.notation_pattern, Cycle::TimeSpan.period_registry.code_pattern]
22
+ return @parts_regex if @parts_regex && @parts_regex_pattern == pattern
23
+
24
+ @parts_regex_pattern = pattern
25
+ kinds, periods = pattern
26
+ @parts_regex = /
27
+ ^(?<vol>V(?<volume>\d*))? # optional volume
28
+ (?<set>(?<kind>#{kinds}) # kind
29
+ (?<period_count>\d+) # period count
30
+ (?<period_key>#{periods})?)? # period_key
31
+ (?<from>F(?<from_date>\d{4}-\d{2}-\d{2}))?$ # optional from
32
+ /ix
33
+ end
34
+
35
+ # Deliberately a method, not a constant: handlers register as each file
36
+ # in sof/cycles loads, which happens after this file. A constant would
37
+ # capture the empty set and leave every dormant-capable kind
38
+ # unrecognized.
39
+ def self.dormant_capable_kinds
40
+ Cycle.registry.cycle_classes.select(&:dormant_capable?).map(&:notation_id).compact.freeze
41
+ end
42
+
43
+ def self.for(notation_or_parser)
44
+ return notation_or_parser if notation_or_parser.is_a? self
45
+
46
+ new(notation_or_parser)
47
+ end
48
+
49
+ def self.load(hash)
50
+ hash.symbolize_keys!
51
+ hash.reverse_merge!(volume: 1)
52
+ keys = %i[volume kind period_count period_key]
53
+ str = "V#{hash.values_at(*keys).join}"
54
+ return new(str) unless hash[:from_date]
55
+
56
+ new([str, "F#{hash[:from_date]}"].join)
57
+ end
58
+
59
+ def initialize(notation)
60
+ @notation = notation&.upcase
61
+ @match = @notation&.match(self.class.parts_regex)
62
+ @time_span = nil
63
+ end
64
+
65
+ attr_reader :match, :notation
66
+
67
+ delegate [:dormant_capable_kinds] => "self.class"
68
+ delegate [:period, :humanized_period] => :time_span
69
+
70
+ # Return a TimeSpan object for the period and period_count
71
+ def time_span
72
+ @time_span ||= TimeSpan.for(period_count, period_key)
73
+ end
74
+
75
+ def valid? = match.present?
76
+
77
+ def inspect = notation
78
+ alias_method :to_s, :inspect
79
+
80
+ def activated_notation(date)
81
+ return notation unless dormant_capable?
82
+
83
+ self.class.load(to_h.merge(from_date: date.to_date)).notation
84
+ end
85
+
86
+ def ==(other) = other.to_h == to_h
87
+
88
+ def to_h
89
+ {
90
+ volume:,
91
+ kind:,
92
+ period_count:,
93
+ period_key:,
94
+ from_date:
95
+ }
96
+ end
97
+
98
+ def parses?(notation_id) = kind == notation_id
99
+
100
+ def active? = !dormant?
101
+
102
+ def dormant? = dormant_capable? && from_date.nil?
103
+
104
+ def dormant_capable? = kind.in?(dormant_capable_kinds)
105
+
106
+ def period_count = match[:period_count]
107
+
108
+ def period_key = match[:period_key]
109
+
110
+ def vol = match[:vol] || "V1"
111
+
112
+ def volume = (match[:volume] || 1).to_i
113
+
114
+ def from_data
115
+ return {} unless from
116
+
117
+ {from: from}
118
+ end
119
+
120
+ def from_date = match[:from_date]
121
+
122
+ def from = match[:from]
123
+
124
+ def kind = match[:kind]
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SOF
4
+ class Cycle
5
+ # The set of classes that handle cycle kinds.
6
+ #
7
+ # Registration is explicit — a class joins by declaring what it handles
8
+ # (see Cycle.handles), not by subclassing. Inheriting from Cycle for any
9
+ # other reason, such as a test double or an abstract intermediate, then
10
+ # carries no hidden side effect.
11
+ #
12
+ # The registry is also what the parser reads to recognise notations, so an
13
+ # application can add a kind of its own and have its notation parse without
14
+ # reopening this gem.
15
+ class Registry
16
+ def self.instance = @instance ||= new
17
+
18
+ def initialize
19
+ @cycle_classes = []
20
+ @notation_pattern = nil
21
+ end
22
+
23
+ # Add a handler, displacing any already handling the same kind or
24
+ # notation id. That is what lets an application override a built-in
25
+ # kind — declare a class handling :lookback and it takes over "L" —
26
+ # as well as add one of its own. Re-registering the same class is a
27
+ # no-op, so a reloaded file accumulates no duplicates.
28
+ #
29
+ # Returns the class, so a declaration can use the result.
30
+ def register(cycle_class)
31
+ displaced = @cycle_classes.reject { |klass| klass.equal?(cycle_class) }
32
+ .select { |klass| conflicts?(klass, cycle_class) }
33
+ @cycle_classes -= displaced
34
+ @cycle_classes << cycle_class unless @cycle_classes.include?(cycle_class)
35
+ @notation_pattern = nil
36
+ cycle_class
37
+ end
38
+
39
+ # Drop a handler. Mainly for an application undoing an override, and for
40
+ # tests that register a throwaway kind.
41
+ def unregister(cycle_class)
42
+ @cycle_classes.delete(cycle_class)
43
+ @notation_pattern = nil
44
+ cycle_class
45
+ end
46
+
47
+ def cycle_classes = @cycle_classes.dup
48
+
49
+ # The class declaring `kind`.
50
+ def handling(kind)
51
+ @cycle_classes.find { |klass| klass.handles?(kind) } ||
52
+ raise(InvalidKind, "':#{kind}' is not a valid kind of Cycle")
53
+ end
54
+
55
+ # The class declaring `notation_id` — the letter(s) that open a cycle's
56
+ # notation, such as "L" or "LE".
57
+ def for_notation_id(notation_id)
58
+ @cycle_classes.find { |klass| klass.notation_id == notation_id } ||
59
+ raise(InvalidKind, "'#{notation_id}' is not a valid kind of Cycle")
60
+ end
61
+
62
+ # Registered notation ids, longest first. A volume-only cycle has none,
63
+ # so it contributes nothing to parse.
64
+ def notation_ids
65
+ @cycle_classes.filter_map(&:notation_id).uniq.sort_by { |id| [-id.length, id] }
66
+ end
67
+
68
+ # The alternation the parser splices into its pattern. Rebuilt whenever a
69
+ # class registers, so a kind added at runtime is recognised from then on.
70
+ def notation_pattern
71
+ @notation_pattern ||= notation_ids.map { Regexp.escape(it) }.join("|")
72
+ end
73
+
74
+ private
75
+
76
+ # Two handlers conflict when they answer to the same kind, or claim the
77
+ # same notation id. A nil notation id claims nothing.
78
+ def conflicts?(existing, incoming)
79
+ return true if existing.kind == incoming.kind
80
+ return false if incoming.notation_id.nil?
81
+
82
+ existing.notation_id == incoming.notation_id
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,317 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SOF
4
+ class Cycle
5
+ # This class is not intended to be referenced directly.
6
+ # This is an internal implementation of Cycle behavior.
7
+ class TimeSpan
8
+ extend Forwardable
9
+
10
+ # TimeSpan objects map Cycle notations to behaviors for their periods
11
+ #
12
+ # For example:
13
+ # 'M' => TimeSpan::DatePeriod::Month
14
+ # 'Y' => TimeSpan::DatePeriod::Year
15
+ # Read each DatePeriod subclass for more information.
16
+ #
17
+ class InvalidPeriod < StandardError; end
18
+
19
+ # The set of classes measuring a period of time. Registration is
20
+ # explicit — a class joins by declaring what it measures, not by
21
+ # subclassing — which is what removes the inherited hook and lets the
22
+ # parser build its period alternation from what is registered.
23
+ #
24
+ # Internal, like DatePeriod itself: periods are not an extension point.
25
+ # Reach it through TimeSpan.period_registry.
26
+ class PeriodRegistry
27
+ def self.instance = @instance ||= new
28
+
29
+ def initialize
30
+ @period_classes = []
31
+ @code_pattern = nil
32
+ end
33
+
34
+ # Add a period class, displacing any already measuring the same period
35
+ # or claiming the same code, so an application can replace a built-in as
36
+ # well as add to it. Returns the class.
37
+ def register(period_class)
38
+ displaced = @period_classes.reject { |klass| klass.equal?(period_class) }
39
+ .select { |klass| conflicts?(klass, period_class) }
40
+ @period_classes -= displaced
41
+ @period_classes << period_class unless @period_classes.include?(period_class)
42
+ @code_pattern = nil
43
+ period_class
44
+ end
45
+
46
+ def unregister(period_class)
47
+ @period_classes.delete(period_class)
48
+ @code_pattern = nil
49
+ period_class
50
+ end
51
+
52
+ def period_classes = @period_classes.dup
53
+
54
+ # The class whose notation code this is, e.g. "M". Case-insensitive,
55
+ # since a notation may be written either way. Nil when unrecognised —
56
+ # DatePeriod.for falls back to its own default.
57
+ def for_code(code)
58
+ return if code.nil?
59
+
60
+ normalized = code.to_s.upcase
61
+ @period_classes.find { |klass| klass.code == normalized }
62
+ end
63
+
64
+ # The class measuring this period, e.g. :month. Nil when unrecognised.
65
+ def for_period(period)
66
+ @period_classes.find { |klass| klass.period.to_s == period.to_s }
67
+ end
68
+
69
+ # Registered codes, longest first so a longer code is never shadowed by
70
+ # its own prefix.
71
+ def codes
72
+ @period_classes.filter_map(&:code).uniq.sort_by { |code| [-code.length, code] }
73
+ end
74
+
75
+ # The alternation the parser splices in for the period key. Rebuilt
76
+ # whenever a class registers.
77
+ def code_pattern
78
+ @code_pattern ||= codes.map { Regexp.escape(it) }.join("|")
79
+ end
80
+
81
+ private
82
+
83
+ def conflicts?(existing, incoming)
84
+ return true if existing.period == incoming.period
85
+ return false if incoming.code.nil?
86
+
87
+ existing.code == incoming.code
88
+ end
89
+ end
90
+
91
+ class << self
92
+ # Return a time_span for the given count and period
93
+ def for(count, period)
94
+ case count.to_i
95
+ when 0
96
+ TimeSpanNothing
97
+ when 1
98
+ TimeSpanOne
99
+ else
100
+ self
101
+ end.new(count, period)
102
+ end
103
+
104
+ # Return a notation string from a hash
105
+ def notation(hash)
106
+ return unless hash.key?(:period) && hash[:period].present?
107
+
108
+ [
109
+ hash.fetch(:period_count) { 1 },
110
+ notation_id_from_name(hash[:period])
111
+ ].compact.join
112
+ end
113
+
114
+ # The registry of period classes, owned by DatePeriod. Exposed here
115
+ # because DatePeriod is a private constant, so callers outside cannot
116
+ # name it — the parser needs the code alternation to build its pattern.
117
+ def period_registry = PeriodRegistry.instance
118
+
119
+ # Return the notation character for the given period name
120
+ def notation_id_from_name(name)
121
+ period_registry.for_period(name)&.code ||
122
+ raise(InvalidPeriod, "'#{name}' is not a valid period")
123
+ end
124
+ end
125
+
126
+ def notation
127
+ [period_count, code].join
128
+ end
129
+
130
+ # Class used to calculate the windows of time so that
131
+ # a TimeSpan object will know the correct end of year,
132
+ # quarter, etc.
133
+ class DatePeriod
134
+ extend Forwardable
135
+
136
+ class << self
137
+ def for(count, period_notation)
138
+ @cached_periods ||= {}
139
+ @cached_periods[period_notation] ||= {}
140
+ @cached_periods[period_notation][count] ||= (for_notation(period_notation) || self).new(count)
141
+ @cached_periods[period_notation][count]
142
+ end
143
+
144
+ def for_notation(notation) = registry.for_code(notation)
145
+
146
+ def types = registry.period_classes
147
+
148
+ def registry = TimeSpan.period_registry
149
+
150
+ # Declare the period this class measures, and register it.
151
+ #
152
+ # As with Cycle.handles, registration follows from declaring, so
153
+ # subclassing DatePeriod on its own registers nothing, and an
154
+ # application can add a period of its own — its code is recognised
155
+ # because the parser builds that alternation from the registry.
156
+ #
157
+ # @param period [Symbol] the period measured, e.g. :month
158
+ # @param code [String] the notation character, e.g. "M"
159
+ # @param interval [String] the plural name used in descriptions
160
+ #
161
+ # @example
162
+ # class Fortnight < DatePeriod
163
+ # measures :fortnight, code: "N", interval: "fortnights"
164
+ # def duration = (count * 2).weeks
165
+ # end
166
+ def measures(period, code:, interval:)
167
+ @period = period
168
+ @code = code
169
+ @interval = interval
170
+ registry.register(self)
171
+ end
172
+
173
+ @period = nil
174
+ @code = nil
175
+ @interval = nil
176
+ attr_reader :period, :code, :interval
177
+ end
178
+
179
+ delegate [:period, :code, :interval] => "self.class"
180
+
181
+ def initialize(count)
182
+ @count = count
183
+ @end_date = {}
184
+ @begin_date = {}
185
+ end
186
+ attr_reader :count
187
+
188
+ def end_date(date)
189
+ @end_date[date] ||= date + duration
190
+ end
191
+
192
+ def begin_date(date)
193
+ @begin_date[date] ||= date - duration
194
+ end
195
+
196
+ def duration = count.send(period)
197
+
198
+ def end_of_period(_) = nil
199
+
200
+ def humanized_period
201
+ return period if count == 1
202
+
203
+ "#{period}s"
204
+ end
205
+
206
+ class Year < self
207
+ measures :year, code: "Y", interval: "years"
208
+
209
+ def end_of_period(date)
210
+ date.end_of_year
211
+ end
212
+
213
+ def beginning_of_period(date)
214
+ date.beginning_of_year
215
+ end
216
+ end
217
+
218
+ class Quarter < self
219
+ measures :quarter, code: "Q", interval: "quarters"
220
+
221
+ def duration
222
+ (count * 3).months
223
+ end
224
+
225
+ def end_of_period(date)
226
+ date.end_of_quarter
227
+ end
228
+
229
+ def beginning_of_period(date)
230
+ date.beginning_of_quarter
231
+ end
232
+ end
233
+
234
+ class Month < self
235
+ measures :month, code: "M", interval: "months"
236
+
237
+ def end_of_period(date)
238
+ date.end_of_month
239
+ end
240
+
241
+ def beginning_of_period(date)
242
+ date.beginning_of_month
243
+ end
244
+ end
245
+
246
+ class Week < self
247
+ measures :week, code: "W", interval: "weeks"
248
+
249
+ def end_of_period(date)
250
+ date.end_of_week
251
+ end
252
+
253
+ def beginning_of_period(date)
254
+ date.beginning_of_week
255
+ end
256
+ end
257
+
258
+ class Day < self
259
+ measures :day, code: "D", interval: "days"
260
+
261
+ def end_of_period(date)
262
+ date
263
+ end
264
+
265
+ def beginning_of_period(date)
266
+ date
267
+ end
268
+ end
269
+ end
270
+ private_constant :DatePeriod, :PeriodRegistry
271
+
272
+ def initialize(count, period_id)
273
+ @count = Integer(count, exception: false)
274
+ @window = DatePeriod.for(period_count, period_id)
275
+ end
276
+ attr_reader :window
277
+
278
+ delegate [:end_date, :begin_date, :code] => :window
279
+
280
+ def end_date_of_period(date)
281
+ window.end_of_period(date)
282
+ end
283
+
284
+ def begin_date_of_period(date)
285
+ window.beginning_of_period(date)
286
+ end
287
+
288
+ # Integer value for the period count or nil
289
+ def period_count
290
+ @count
291
+ end
292
+
293
+ delegate [:period, :duration, :interval, :humanized_period] => :window
294
+
295
+ # Return a date according to the rules of the time_span
296
+ def final_date(date)
297
+ return unless period
298
+
299
+ window.end_date(date.to_date)
300
+ end
301
+
302
+ def to_h
303
+ {
304
+ period:,
305
+ period_count:
306
+ }
307
+ end
308
+
309
+ class TimeSpanNothing < self
310
+ end
311
+
312
+ class TimeSpanOne < self
313
+ def interval = humanized_period
314
+ end
315
+ end
316
+ end
317
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module SOF
4
4
  class Cycle
5
- VERSION = "0.1.14"
5
+ VERSION = "0.1.15"
6
6
  end
7
7
  end
data/lib/sof/cycle.rb CHANGED
@@ -1,7 +1,8 @@
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
@@ -70,9 +71,7 @@ module SOF
70
71
  raise InvalidInput, "'#{notation}' is not a valid input"
71
72
  end
72
73
 
73
- cycle = Cycle.cycle_handlers.find do |klass|
74
- parser.parses?(klass.notation_id)
75
- end.new(notation, parser:)
74
+ cycle = registry.for_notation_id(parser.kind).new(notation, parser:)
76
75
  return cycle if parser.active?
77
76
 
78
77
  Cycles::Dormant.new(cycle, parser:)
@@ -84,22 +83,14 @@ module SOF
84
83
  # @example
85
84
  # class_for_notation_id('L')
86
85
  #
87
- def class_for_notation_id(notation_id)
88
- Cycle.cycle_handlers.find do |klass|
89
- klass.notation_id == notation_id
90
- end || raise(InvalidKind, "'#{notation_id}' is not a valid kind of #{name}")
91
- end
86
+ def class_for_notation_id(notation_id) = registry.for_notation_id(notation_id)
92
87
 
93
88
  # Return the class handling the kind
94
89
  #
95
90
  # @param sym [Symbol] symbol matching the kind of Cycle class
96
91
  # @example
97
92
  # class_for_kind(:lookback)
98
- def class_for_kind(sym)
99
- Cycle.cycle_handlers.find do |klass|
100
- klass.handles?(sym)
101
- end || raise(InvalidKind, "':#{sym}' is not a valid kind of Cycle")
102
- end
93
+ def class_for_kind(sym) = registry.handling(sym)
103
94
 
104
95
  # Return a legend explaining all notation components
105
96
  #
@@ -123,6 +114,8 @@ module SOF
123
114
  }
124
115
  end
125
116
 
117
+ # Defaults for the base class itself, which handles no kind and so is
118
+ # never registered.
126
119
  @volume_only = false
127
120
  @notation_id = nil
128
121
  @kind = nil
@@ -150,23 +143,77 @@ module SOF
150
143
  kind.to_s == sym.to_s
151
144
  end
152
145
 
153
- def cycle_handlers
154
- @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)
155
175
  end
156
176
 
157
- def inherited(klass)
158
- Cycle.cycle_handlers << klass
159
- 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
160
207
 
161
208
  private
162
209
 
163
210
  def build_kind_legend
164
211
  legend = {}
165
- Cycle.cycle_handlers.each do |handler|
212
+ registry.cycle_classes.each do |handler|
166
213
  # Skip volume_only since it doesn't have a notation_id
167
- next if handler.instance_variable_get(:@volume_only)
214
+ next if handler.volume_only?
168
215
 
169
- notation_id = handler.instance_variable_get(:@notation_id)
216
+ notation_id = handler.notation_id
170
217
  next unless notation_id
171
218
 
172
219
  legend[notation_id] = {
@@ -179,14 +226,10 @@ module SOF
179
226
 
180
227
  def build_period_legend
181
228
  legend = {}
182
- # Use known period codes since DatePeriod is private
183
- period_mappings = {
184
- "D" => "day",
185
- "W" => "week",
186
- "M" => "month",
187
- "Q" => "quarter",
188
- "Y" => "year"
189
- }
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] }
190
233
 
191
234
  period_mappings.each do |code, period_name|
192
235
  legend[code] = {
@@ -253,6 +296,39 @@ module SOF
253
296
 
254
297
  def extend_period(_ = nil) = self
255
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
+
256
332
  # From the supplied anchor date, are there enough in-window completions to
257
333
  # satisfy the cycle?
258
334
  #
@@ -303,5 +379,13 @@ module SOF
303
379
  end
304
380
 
305
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
306
390
  end
307
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
 
@@ -3,10 +3,7 @@
3
3
  module SOF
4
4
  module Cycles
5
5
  class LookbackEndOf < Cycle
6
- @volume_only = false
7
- @notation_id = "LE"
8
- @kind = :lookback_end_of
9
- @valid_periods = %w[D W M Q Y]
6
+ handles :lookback_end_of, notation: "LE", periods: %w[D W M Q Y]
10
7
 
11
8
  def self.recurring? = true
12
9
 
@@ -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.14
4
+ version: 0.1.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay
@@ -55,13 +55,21 @@ files:
55
55
  - checksums/sof-cycle-0.1.10.gem.sha512
56
56
  - checksums/sof-cycle-0.1.11.gem.sha512
57
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
58
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
59
64
  - checksums/sof-cycle-0.1.6.gem.sha512
60
65
  - checksums/sof-cycle-0.1.7.gem.sha512
61
66
  - checksums/sof-cycle-0.1.8.gem.sha512
62
67
  - checksums/sof-cycle-0.1.9.gem.sha512
63
68
  - lib/sof-cycle.rb
64
69
  - lib/sof/cycle.rb
70
+ - lib/sof/cycle/parser.rb
71
+ - lib/sof/cycle/registry.rb
72
+ - lib/sof/cycle/time_span.rb
65
73
  - lib/sof/cycle/version.rb
66
74
  - lib/sof/cycles/calendar.rb
67
75
  - lib/sof/cycles/dormant.rb
@@ -71,8 +79,6 @@ files:
71
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:
data/lib/sof/parser.rb DELETED
@@ -1,110 +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
-
16
- PARTS_REGEX = /
17
- ^(?<vol>V(?<volume>\d*))? # optional volume
18
- (?<set>(?<kind>LE|L|C|W|E|I) # kind
19
- (?<period_count>\d+) # period count
20
- (?<period_key>D|W|M|Q|Y)?)? # period_key
21
- (?<from>F(?<from_date>\d{4}-\d{2}-\d{2}))?$ # optional from
22
- /ix
23
-
24
- def self.dormant_capable_kinds
25
- Cycle.cycle_handlers.select(&:dormant_capable?).map(&:notation_id).compact
26
- end
27
-
28
- def self.for(notation_or_parser)
29
- return notation_or_parser if notation_or_parser.is_a? self
30
-
31
- new(notation_or_parser)
32
- end
33
-
34
- def self.load(hash)
35
- hash.symbolize_keys!
36
- hash.reverse_merge!(volume: 1)
37
- keys = %i[volume kind period_count period_key]
38
- str = "V#{hash.values_at(*keys).join}"
39
- return new(str) unless hash[:from_date]
40
-
41
- new([str, "F#{hash[:from_date]}"].join)
42
- end
43
-
44
- def initialize(notation)
45
- @notation = notation&.upcase
46
- @match = @notation&.match(PARTS_REGEX)
47
- end
48
-
49
- attr_reader :match, :notation
50
-
51
- delegate [:dormant_capable_kinds] => "self.class"
52
- delegate [:period, :humanized_period] => :time_span
53
-
54
- # Return a TimeSpan object for the period and period_count
55
- def time_span
56
- @time_span ||= TimeSpan.for(period_count, period_key)
57
- end
58
-
59
- def valid? = match.present?
60
-
61
- def inspect = notation
62
- alias_method :to_s, :inspect
63
-
64
- def activated_notation(date)
65
- return notation unless dormant_capable?
66
-
67
- self.class.load(to_h.merge(from_date: date.to_date)).notation
68
- end
69
-
70
- def ==(other) = other.to_h == to_h
71
-
72
- def to_h
73
- {
74
- volume:,
75
- kind:,
76
- period_count:,
77
- period_key:,
78
- from_date:
79
- }
80
- end
81
-
82
- def parses?(notation_id) = kind == notation_id
83
-
84
- def active? = !dormant?
85
-
86
- def dormant? = dormant_capable? && from_date.nil?
87
-
88
- def dormant_capable? = kind.in?(dormant_capable_kinds)
89
-
90
- def period_count = match[:period_count]
91
-
92
- def period_key = match[:period_key]
93
-
94
- def vol = match[:vol] || "V1"
95
-
96
- def volume = (match[:volume] || 1).to_i
97
-
98
- def from_data
99
- return {} unless from
100
-
101
- {from: from}
102
- end
103
-
104
- def from_date = match[:from_date]
105
-
106
- def from = match[:from]
107
-
108
- def kind = match[:kind]
109
- end
110
- end
data/lib/sof/time_span.rb DELETED
@@ -1,236 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module SOF
4
- # This class is not intended to be referenced directly.
5
- # This is an internal implementation of Cycle behavior.
6
- class TimeSpan
7
- extend Forwardable
8
-
9
- # TimeSpan objects map Cycle notations to behaviors for their periods
10
- #
11
- # For example:
12
- # 'M' => TimeSpan::DatePeriod::Month
13
- # 'Y' => TimeSpan::DatePeriod::Year
14
- # Read each DatePeriod subclass for more information.
15
- #
16
- class InvalidPeriod < StandardError; end
17
-
18
- class << self
19
- # Return a time_span for the given count and period
20
- def for(count, period)
21
- case count.to_i
22
- when 0
23
- TimeSpanNothing
24
- when 1
25
- TimeSpanOne
26
- else
27
- self
28
- end.new(count, period)
29
- end
30
-
31
- # Return a notation string from a hash
32
- def notation(hash)
33
- return unless hash.key?(:period) && hash[:period].present?
34
-
35
- [
36
- hash.fetch(:period_count) { 1 },
37
- notation_id_from_name(hash[:period])
38
- ].compact.join
39
- end
40
-
41
- # Return the notation character for the given period name
42
- def notation_id_from_name(name)
43
- type = DatePeriod.types.find do |klass|
44
- klass.period.to_s == name.to_s
45
- end
46
-
47
- raise InvalidPeriod, "'#{name}' is not a valid period" unless type
48
-
49
- type.code
50
- end
51
- end
52
-
53
- def notation
54
- [period_count, code].join
55
- end
56
-
57
- # Class used to calculate the windows of time so that
58
- # a TimeSpan object will know the correct end of year,
59
- # quarter, etc.
60
- class DatePeriod
61
- extend Forwardable
62
-
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
- DatePeriod.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, :code] => :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