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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0eb9458907426d68d8fc05c159a9bb782819b2ec821baf2ec24109b45c6794ff
4
- data.tar.gz: e4c0b55074d59aad3f4e562e579759d8d85413b1fd93c93c873b79bdbff1c629
3
+ metadata.gz: ace7e7bdb57808f1c633cf78aaab4fc7949e6820e86089c283926e0c22558bbd
4
+ data.tar.gz: 5c12b7d8d840f2afe367d13cc7e5864fd835d62e2f0b9bc480d17873e706182c
5
5
  SHA512:
6
- metadata.gz: c799aed72bf8973a8b6438cab7d2635f2563575fcf3d16eac4b4bf38df409953000d78afc8b691ed4b5e4eb40b39a7969858e79d9ee844697ce113e1f0170ae5
7
- data.tar.gz: c99b76559985729d0ea59ee3ff5be523500965578c450c85a90c24fd2d958ebd6eb4ff4ad550d101e8178423f2f39d02ffe79a47a28f716e34865c4028ce83a5
6
+ metadata.gz: d1c8dda915931d2a5cd860ebf1ad79d2c6cc353d8794a159cc52720bde02f2e3e3aa7001fbb7af240dbf16c1b212f44ce28eacb7ec10fa1868dd47c7860406ae
7
+ data.tar.gz: ea2ca6604e38d85894d0df757010449f839040b28c01de608270cdffc536ed5f8e62125ecea7ec59f9100106eae6a3ab2a3243b68b4218a7269b671f483e9bad
data/.tool-versions CHANGED
@@ -1 +1 @@
1
- ruby 3.4.3
1
+ ruby 4.0.2
data/CHANGELOG.md CHANGED
@@ -5,22 +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.13] - 2026-03-20
8
+ ## [0.1.15] - 2026-07-27
9
9
 
10
- ### Added
11
-
12
- - Interval cycle kind (`I` notation) — repeating windows anchored to a from_date that re-anchor from completion date (e.g., `V1I24MF2026-03-31`)
13
-
14
- ### Changed
15
-
16
- - Dormant capability is now declared on each cycle class (`def self.dormant_capable? = true`) instead of only in `Parser.dormant_capable_kinds`
17
-
18
- ### Fixed
19
-
20
- - EndOf cycle `final_date` was off by one period — `V1E12M` now correctly expires at the end of the 12th month, not the 11th
21
-
22
- ## [0.1.12] - 2025-09-05
23
-
24
- ### Added
25
-
26
- - Missing code coverage and updated EndOf to properly handle dormant cycles.
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.13"
5
+ VERSION = "0.1.15"
6
6
  end
7
7
  end