sof-cycle 0.1.12 → 0.1.14

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: 24c5b52ab1efb27e840af6448d16f2d74074310909a00d6d8a9bd10d42466b83
4
- data.tar.gz: a7507d727a8c7d87b34a6cabeccd49abaabb9ae29374bed6c9eff70a800d9c9b
3
+ metadata.gz: 4bf300b22d76c7736f524022360c052b1546fa573ca39e851d4ce2a2ec2de4b7
4
+ data.tar.gz: 30f5a7972655d52acd3a547f59482c32d6c8fbdf22711f9f23eca27e695053cf
5
5
  SHA512:
6
- metadata.gz: deb2c0b296fef6fdc65fb0db0e7d92934de2c8a402f96757ac357446e87c2dc4d5cd880e6a8a210aed4bc794ff1977bba1397fb2de96aadd909879566851e783
7
- data.tar.gz: e238e4937718f34f644acf024ea4aae39742eee04a9b6d0b75a10df2306219ab8c3ac13abf6d7760e401d9554882f467518348ba0744e1eb51cf0fd2741452b9
6
+ metadata.gz: 63208d5574eaf86293c0a39214155f7a645b74d51b2dd23bfd03ae83b9dcbd032d145ffd909a8b3a675caef7485f6abe14d5b4bbfcbaafac3c383e29193fa8a7
7
+ data.tar.gz: e4caecf602ea58c7b0cec5a27b1efe36e23b65d0cf191b9c71eb7415e75a01c05079d4783c75d69e02d1ed31da76ef2765a15bc93ee84d43b39c8f03d615a17d
data/.tool-versions CHANGED
@@ -1 +1 @@
1
- ruby 3.4.3
1
+ ruby 4.0.2
data/CHANGELOG.md CHANGED
@@ -5,14 +5,18 @@ 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.12] - 2025-09-05
8
+ ## [0.1.14] - 2026-04-27
9
9
 
10
- ### Added
10
+ ## [0.1.13] - 2026-03-20
11
11
 
12
- - Missing code coverage and updated EndOf to properly handle dormant cycles.
12
+ ### Added
13
13
 
14
- ## [0.1.11] - 2025-09-05
14
+ - Interval cycle kind (`I` notation) — repeating windows anchored to a from_date that re-anchor from completion date (e.g., `V1I24MF2026-03-31`)
15
15
 
16
16
  ### Changed
17
17
 
18
- - Use `time_span.notation` instead of `TimeSpan.notation` in `Cycle#notation`.
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
data/README.md CHANGED
@@ -25,7 +25,9 @@ cycle.to_s # => "3x in the prior 3 days"
25
25
 
26
26
  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.
27
27
 
28
- 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).
28
+ To install this gem onto your local machine, run `bundle exec rake install`.
29
+
30
+ This project is managed with [Reissue](https://github.com/SOFware/reissue). Releases are automated via the [shared release workflow](https://github.com/SOFware/reissue/blob/main/.github/workflows/SHARED_WORKFLOW_README.md). Trigger a release by running the "Release gem to RubyGems.org" workflow from the Actions tab.
29
31
 
30
32
  ## Contributing
31
33
 
data/Rakefile CHANGED
@@ -14,4 +14,5 @@ require "reissue/gem"
14
14
 
15
15
  Reissue::Task.create do |task|
16
16
  task.version_file = "lib/sof/cycle/version.rb"
17
+ task.push_finalize = :branch
17
18
  end
@@ -0,0 +1 @@
1
+ e7091d8a602dd216e947b2c0207ad66383b0322646c1b3ab4eacd670cd885035d3bf6e1e5ba3c848466aa9989f298ef8993fd1a260559953bb47a866cc613a52
@@ -2,6 +2,6 @@
2
2
 
3
3
  module SOF
4
4
  class Cycle
5
- VERSION = "0.1.12"
5
+ VERSION = "0.1.14"
6
6
  end
7
7
  end
data/lib/sof/cycle.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "parser"
6
6
  module SOF
7
7
  class Cycle
8
8
  extend ::Forwardable
9
+
9
10
  class InvalidInput < StandardError; end
10
11
 
11
12
  class InvalidPeriod < InvalidInput; end
@@ -130,6 +131,8 @@ module SOF
130
131
  attr_reader :notation_id, :kind, :valid_periods
131
132
  def volume_only? = @volume_only
132
133
 
134
+ def dormant_capable? = false
135
+
133
136
  def recurring? = raise "#{name} must implement #{__method__}"
134
137
 
135
138
  # Raises an error if the given period isn't in the list of valid periods.
@@ -215,12 +218,13 @@ module SOF
215
218
 
216
219
  attr_reader :parser
217
220
 
218
- delegate [:activated_notation, :volume, :from, :from_date, :time_span, :period,
221
+ delegate [:activated_notation, :volume, :from,
222
+ :from_date, :time_span, :period,
219
223
  :humanized_period, :period_key, :active?] => :@parser
220
224
  delegate [:kind, :recurring?, :volume_only?, :valid_periods] => "self.class"
221
225
  delegate [:period_count, :duration] => :time_span
222
- delegate [:calendar?, :dormant?, :end_of?, :lookback?, :volume_only?,
223
- :within?] => :kind_inquiry
226
+ delegate [:calendar?, :dormant?, :end_of?, :interval?, :lookback?,
227
+ :lookback_end_of?, :volume_only?, :within?] => :kind_inquiry
224
228
 
225
229
  def kind_inquiry = ActiveSupport::StringInquirer.new(kind.to_s)
226
230
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Captures the logic for enforcing the EndOf cycle variant
4
4
  # E.g. "V1E18MF2020-01-05" means:
5
- # You're good until the end of the 17th subsequent month from 2020-01-05.
5
+ # You're good until the end of the 18th month from 2020-01-05.
6
6
  # Complete 1 by that date to reset the cycle.
7
7
  #
8
8
  # Some of the calculations are quite different from other cycles.
@@ -18,6 +18,8 @@ module SOF
18
18
 
19
19
  def self.recurring? = true
20
20
 
21
+ def self.dormant_capable? = true
22
+
21
23
  def self.description
22
24
  "End of - occurrences by the end of a time period"
23
25
  end
@@ -62,15 +64,15 @@ module SOF
62
64
  #
63
65
  # @param [nil] _ Unused parameter, maintained for compatibility
64
66
  # @return [Date] The final date of the cycle calculated as the end of the
65
- # nth subsequent period after the FROM date, where n = (period count - 1)
67
+ # nth period after the FROM date
66
68
  #
67
69
  # @example
68
70
  # Cycle.for("V1E18MF2020-01-09").final_date
69
- # # => #<Date: 2021-06-30>
71
+ # # => #<Date: 2021-07-31>
70
72
  def final_date(_ = nil)
71
73
  return nil if parser.dormant? || from_date.nil?
72
74
  time_span
73
- .end_date(start_date - 1.send(period))
75
+ .end_date(start_date)
74
76
  .end_of_month
75
77
  end
76
78
 
@@ -79,14 +81,11 @@ module SOF
79
81
  private
80
82
 
81
83
  def dormant_to_s
82
- <<~DESC.squish
83
- #{volume}x by the last day of the #{subsequent_ordinal}
84
- subsequent #{period}
85
- DESC
84
+ "#{volume}x by the last day of the #{ordinalized_period_count} #{period}"
86
85
  end
87
86
 
88
- def subsequent_ordinal
89
- ActiveSupport::Inflector.ordinalize(period_count - 1)
87
+ def ordinalized_period_count
88
+ ActiveSupport::Inflector.ordinalize(period_count)
90
89
  end
91
90
  end
92
91
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Captures the logic for enforcing the Interval cycle variant
4
+ # E.g. "V1I24MF2026-03-31" means:
5
+ # Complete 1 every 24 months, current window from 2026-03-31.
6
+ # After completion, the consuming app re-anchors from the completion date.
7
+ #
8
+ # Unlike EndOf, there is no end-of-month rounding.
9
+ # Unlike Lookback, the window is anchored to a from_date, not sliding from today.
10
+ # Unlike Within, the window is repeating — it re-anchors from the completion date.
11
+ module SOF
12
+ module Cycles
13
+ class Interval < Cycle
14
+ @volume_only = false
15
+ @notation_id = "I"
16
+ @kind = :interval
17
+ @valid_periods = %w[D W M Y]
18
+
19
+ def self.recurring? = true
20
+
21
+ def self.dormant_capable? = true
22
+
23
+ def self.description
24
+ "Interval - occurrences within a repeating window that re-anchors from completion date"
25
+ end
26
+
27
+ def self.examples
28
+ ["V1I24MF2026-03-31 - once every 24 months from March 31, 2026 (re-anchors after completion)"]
29
+ end
30
+
31
+ def to_s
32
+ return dormant_to_s unless active?
33
+
34
+ "#{volume}x every #{humanized_span} from #{start_date.to_fs(:american)}"
35
+ end
36
+
37
+ # Returns the expiration date for the current window
38
+ #
39
+ # @return [Date, nil] The final date of the current window
40
+ def expiration_of(_ = nil, anchor: nil)
41
+ final_date
42
+ end
43
+
44
+ # Is the supplied anchor date within the current window?
45
+ #
46
+ # @return [Boolean] true if the anchor is before or on the final date
47
+ def satisfied_by?(_ = nil, anchor: Date.current)
48
+ anchor <= final_date
49
+ end
50
+
51
+ # Returns the from_date as the last completed date
52
+ def last_completed(_ = nil) = from_date&.to_date
53
+
54
+ # Calculates the final date of the current window
55
+ #
56
+ # @return [Date] from_date + period (no end-of-month rounding)
57
+ #
58
+ # @example
59
+ # Cycle.for("V1I24MF2026-03-31").final_date
60
+ # # => #<Date: 2028-03-31>
61
+ def final_date(_ = nil)
62
+ return nil if start_date.nil?
63
+ time_span.end_date(start_date)
64
+ end
65
+
66
+ def start_date(_ = nil) = from_date&.to_date
67
+
68
+ private
69
+
70
+ def dormant_to_s
71
+ "#{volume}x every #{humanized_span}"
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SOF
4
+ module Cycles
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]
10
+
11
+ def self.recurring? = true
12
+
13
+ def self.description
14
+ "Lookback End of Period - occurrences within a prior time period, expiring at the end of the calendar period"
15
+ end
16
+
17
+ def self.examples
18
+ ["V1LE24M - once in the prior 24 months (expires end of month)", "V2LE3W - twice in the prior 3 weeks (expires end of week)"]
19
+ end
20
+
21
+ def to_s = "#{volume}x in the prior #{period_count} #{humanized_period} (end of period)"
22
+
23
+ def expiration_of(completion_dates, anchor: Date.current)
24
+ oldest = completion_dates.max_by(volume) { it }.min
25
+ return unless satisfied_by?(completion_dates, anchor:)
26
+
27
+ final_date(oldest)
28
+ end
29
+
30
+ def final_date(anchor)
31
+ return if anchor.nil?
32
+
33
+ time_span.end_date_of_period(time_span.end_date(anchor.to_date))
34
+ end
35
+ alias_method :window_end, :final_date
36
+
37
+ def start_date(anchor)
38
+ time_span.begin_date_of_period(time_span.begin_date(anchor.to_date))
39
+ end
40
+ alias_method :window_start, :start_date
41
+ end
42
+ end
43
+ end
@@ -10,6 +10,8 @@ module SOF
10
10
 
11
11
  def self.recurring? = false
12
12
 
13
+ def self.dormant_capable? = true
14
+
13
15
  def self.description
14
16
  "Within - occurrences within a time period from a specific date"
15
17
  end
data/lib/sof/parser.rb CHANGED
@@ -12,15 +12,18 @@ module SOF
12
12
  # This is an internal implementation of Cycle behavior.
13
13
  class Parser
14
14
  extend Forwardable
15
+
15
16
  PARTS_REGEX = /
16
17
  ^(?<vol>V(?<volume>\d*))? # optional volume
17
- (?<set>(?<kind>L|C|W|E) # kind
18
+ (?<set>(?<kind>LE|L|C|W|E|I) # kind
18
19
  (?<period_count>\d+) # period count
19
20
  (?<period_key>D|W|M|Q|Y)?)? # period_key
20
21
  (?<from>F(?<from_date>\d{4}-\d{2}-\d{2}))?$ # optional from
21
22
  /ix
22
23
 
23
- def self.dormant_capable_kinds = %w[E W]
24
+ def self.dormant_capable_kinds
25
+ Cycle.cycle_handlers.select(&:dormant_capable?).map(&:notation_id).compact
26
+ end
24
27
 
25
28
  def self.for(notation_or_parser)
26
29
  return notation_or_parser if notation_or_parser.is_a? self
data/lib/sof/time_span.rb CHANGED
@@ -5,6 +5,7 @@ module SOF
5
5
  # This is an internal implementation of Cycle behavior.
6
6
  class TimeSpan
7
7
  extend Forwardable
8
+
8
9
  # TimeSpan objects map Cycle notations to behaviors for their periods
9
10
  #
10
11
  # For example:
@@ -58,6 +59,7 @@ module SOF
58
59
  # quarter, etc.
59
60
  class DatePeriod
60
61
  extend Forwardable
62
+
61
63
  class << self
62
64
  def for(count, period_notation)
63
65
  @cached_periods ||= {}
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.12
4
+ version: 0.1.14
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"
@@ -55,6 +54,7 @@ files:
55
54
  - checksums/sof-cycle-0.1.1.gem.sha512
56
55
  - checksums/sof-cycle-0.1.10.gem.sha512
57
56
  - checksums/sof-cycle-0.1.11.gem.sha512
57
+ - checksums/sof-cycle-0.1.12.gem.sha512
58
58
  - checksums/sof-cycle-0.1.2.gem.sha512
59
59
  - checksums/sof-cycle-0.1.6.gem.sha512
60
60
  - checksums/sof-cycle-0.1.7.gem.sha512
@@ -66,7 +66,9 @@ files:
66
66
  - lib/sof/cycles/calendar.rb
67
67
  - lib/sof/cycles/dormant.rb
68
68
  - lib/sof/cycles/end_of.rb
69
+ - lib/sof/cycles/interval.rb
69
70
  - lib/sof/cycles/lookback.rb
71
+ - lib/sof/cycles/lookback_end_of.rb
70
72
  - lib/sof/cycles/volume_only.rb
71
73
  - lib/sof/cycles/within.rb
72
74
  - lib/sof/parser.rb
@@ -90,7 +92,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
90
92
  - !ruby/object:Gem::Version
91
93
  version: '0'
92
94
  requirements: []
93
- rubygems_version: 3.6.7
95
+ rubygems_version: 4.0.6
94
96
  specification_version: 4
95
97
  summary: Parse and interact with SOF cycle notation.
96
98
  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
- }