Skip to content

Commit c9c5e5c

Browse files
committed
Add Interval cycle kind (I notation)
New cycle type for repeating windows anchored to a from_date. V1I24MF2026-03-31 = complete 1 every 24 months from March 31, 2026. After completion, consuming app re-anchors from the completion date. Named "Interval" (not "Recurring") to avoid collision with the existing Cycle#recurring? predicate which indicates whether a cycle type repeats. Key differences from Within: - recurring? returns true (Within returns false) - #to_s uses "every...from" format (Within uses "within...range") - No extend_period (window re-anchors, not extends) Dormant behavior handled by Dormant wrapper via Parser.dormant_capable_kinds, consistent with Within pattern (no redundant guards in class methods).
1 parent 38917eb commit c9c5e5c

6 files changed

Lines changed: 264 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [0.1.13] - Unreleased
99

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+
1014
## [0.1.12] - 2025-09-05
1115

1216
### Added

lib/sof/cycle.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,8 @@ def initialize(notation, parser: Parser.new(notation))
219219
:humanized_period, :period_key, :active?] => :@parser
220220
delegate [:kind, :recurring?, :volume_only?, :valid_periods] => "self.class"
221221
delegate [:period_count, :duration] => :time_span
222-
delegate [:calendar?, :dormant?, :end_of?, :lookback?, :volume_only?,
223-
:within?] => :kind_inquiry
222+
delegate [:calendar?, :dormant?, :end_of?, :interval?, :lookback?,
223+
:volume_only?, :within?] => :kind_inquiry
224224

225225
def kind_inquiry = ActiveSupport::StringInquirer.new(kind.to_s)
226226

lib/sof/cycles/interval.rb

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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.description
22+
"Interval - occurrences within a repeating window that re-anchors from completion date"
23+
end
24+
25+
def self.examples
26+
["V1I24MF2026-03-31 - once every 24 months from March 31, 2026 (re-anchors after completion)"]
27+
end
28+
29+
def to_s
30+
return dormant_to_s unless active?
31+
32+
"#{volume}x every #{humanized_span} from #{start_date.to_fs(:american)}"
33+
end
34+
35+
# Returns the expiration date for the current window
36+
#
37+
# @return [Date, nil] The final date of the current window
38+
def expiration_of(_ = nil, anchor: nil)
39+
final_date
40+
end
41+
42+
# Is the supplied anchor date within the current window?
43+
#
44+
# @return [Boolean] true if the anchor is before or on the final date
45+
def satisfied_by?(_ = nil, anchor: Date.current)
46+
anchor <= final_date
47+
end
48+
49+
# Returns the from_date as the last completed date
50+
def last_completed(_ = nil) = from_date&.to_date
51+
52+
# Calculates the final date of the current window
53+
#
54+
# @return [Date] from_date + period (no end-of-month rounding)
55+
#
56+
# @example
57+
# Cycle.for("V1I24MF2026-03-31").final_date
58+
# # => #<Date: 2028-03-31>
59+
def final_date(_ = nil)
60+
return nil if start_date.nil?
61+
time_span.end_date(start_date)
62+
end
63+
64+
def start_date(_ = nil) = from_date&.to_date
65+
66+
private
67+
68+
def dormant_to_s
69+
"#{volume}x every #{humanized_span}"
70+
end
71+
end
72+
end
73+
end

lib/sof/parser.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@ class Parser
1414
extend Forwardable
1515
PARTS_REGEX = /
1616
^(?<vol>V(?<volume>\d*))? # optional volume
17-
(?<set>(?<kind>L|C|W|E) # kind
17+
(?<set>(?<kind>L|C|W|E|I) # kind
1818
(?<period_count>\d+) # period count
1919
(?<period_key>D|W|M|Q|Y)?)? # period_key
2020
(?<from>F(?<from_date>\d{4}-\d{2}-\d{2}))?$ # optional from
2121
/ix
2222

23-
def self.dormant_capable_kinds = %w[E W]
23+
def self.dormant_capable_kinds = %w[E W I]
2424

2525
def self.for(notation_or_parser)
2626
return notation_or_parser if notation_or_parser.is_a? self

spec/sof/cycles/dormant_spec.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ module SOF
1212
let(:end_of_cycle) { Cycle.for(end_of_notation) }
1313
let(:end_of_notation) { "V2E18M" }
1414

15+
let(:interval_cycle) { Cycle.for(interval_notation) }
16+
let(:interval_notation) { "V1I24M" }
17+
1518
let(:anchor) { "2020-08-01".to_date }
1619
let(:completed_dates) do
1720
[
@@ -61,6 +64,12 @@ module SOF
6164
expect(end_of_cycle.to_s).to eq "2x by the last day of the 17th subsequent month (dormant)"
6265
end
6366
end
67+
68+
context "with a dormant Interval cycle" do
69+
it "returns the cycle string representation with (dormant) suffix" do
70+
expect(interval_cycle.to_s).to eq "1x every 24 months (dormant)"
71+
end
72+
end
6473
end
6574

6675
describe "#kind & #kind?" do
@@ -77,6 +86,8 @@ module SOF
7786
.to eq("#{within_notation}F2024-06-09")
7887
expect(end_of_cycle.activated_notation("2024-06-09"))
7988
.to eq("#{end_of_notation}F2024-06-09")
89+
expect(interval_cycle.activated_notation("2024-06-09"))
90+
.to eq("#{interval_notation}F2024-06-09")
8091
end
8192
end
8293

@@ -87,6 +98,8 @@ module SOF
8798
.to eq("#{within_notation}F2024-06-09")
8899
expect(end_of_cycle.activated_notation(time))
89100
.to eq("#{end_of_notation}F2024-06-09")
101+
expect(interval_cycle.activated_notation(time))
102+
.to eq("#{interval_notation}F2024-06-09")
90103
end
91104
end
92105
end
@@ -96,6 +109,7 @@ module SOF
96109
aggregate_failures do
97110
expect(within_cycle.covered_dates(completed_dates, anchor:)).to be_empty
98111
expect(end_of_cycle.covered_dates(completed_dates, anchor:)).to be_empty
112+
expect(interval_cycle.covered_dates(completed_dates, anchor:)).to be_empty
99113
end
100114
end
101115
end
@@ -112,6 +126,11 @@ module SOF
112126
expect(end_of_cycle).not_to be_satisfied_by(completed_dates, anchor:)
113127
expect(end_of_cycle).not_to be_satisfied_by([], anchor:)
114128
expect(end_of_cycle).not_to be_satisfied_by(completed_dates, anchor: 5.years.from_now)
129+
130+
expect(interval_cycle).not_to be_satisfied_by(completed_dates, anchor: 5.years.ago)
131+
expect(interval_cycle).not_to be_satisfied_by(completed_dates, anchor:)
132+
expect(interval_cycle).not_to be_satisfied_by([], anchor:)
133+
expect(interval_cycle).not_to be_satisfied_by(completed_dates, anchor: 5.years.from_now)
115134
end
116135
end
117136
end
@@ -124,6 +143,9 @@ module SOF
124143

125144
expect(end_of_cycle.expiration_of(completed_dates)).to be_nil
126145
expect(end_of_cycle.expiration_of([])).to be_nil
146+
147+
expect(interval_cycle.expiration_of(completed_dates)).to be_nil
148+
expect(interval_cycle.expiration_of([])).to be_nil
127149
end
128150
end
129151
end
@@ -133,6 +155,7 @@ module SOF
133155
aggregate_failures do
134156
expect(within_cycle.volume).to eq(2)
135157
expect(end_of_cycle.volume).to eq(2)
158+
expect(interval_cycle.volume).to eq(1)
136159
end
137160
end
138161
end
@@ -142,6 +165,7 @@ module SOF
142165
aggregate_failures do
143166
expect(within_cycle.notation).to eq(within_notation)
144167
expect(end_of_cycle.notation).to eq(end_of_notation)
168+
expect(interval_cycle.notation).to eq(interval_notation)
145169
end
146170
end
147171
end

spec/sof/cycles/interval_spec.rb

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
require_relative "shared_examples"
5+
6+
module SOF
7+
RSpec.describe Cycles::Interval, type: :value do
8+
subject(:cycle) { Cycle.for(notation) }
9+
10+
let(:notation) { "V1I24MF#{from_date}" }
11+
let(:anchor) { nil }
12+
13+
let(:end_date) { from_date + 24.months }
14+
let(:from_date) { "2026-03-31".to_date }
15+
16+
let(:completed_dates) { [] }
17+
18+
it_behaves_like "#kind returns", :interval
19+
it_behaves_like "#valid_periods are", %w[D W M Y]
20+
21+
describe "#recurring?" do
22+
it "repeats" do
23+
expect(cycle).to be_recurring
24+
end
25+
end
26+
27+
@from = "2026-03-31".to_date.to_fs(:american)
28+
it_behaves_like "#to_s returns",
29+
"1x every 24 months from #{@from}"
30+
31+
context "when the cycle is dormant" do
32+
before { allow(cycle.parser).to receive(:dormant?).and_return(true) }
33+
34+
it_behaves_like "#to_s returns",
35+
"1x every 24 months"
36+
end
37+
38+
it_behaves_like "#volume returns the volume"
39+
it_behaves_like "#notation returns the notation"
40+
it_behaves_like "#as_json returns the notation"
41+
it_behaves_like "it computes #final_date(given)",
42+
given: nil, returns: "2026-03-31".to_date + 24.months
43+
it_behaves_like "it cannot be extended"
44+
45+
describe "#last_completed" do
46+
context "with an activated cycle" do
47+
it_behaves_like "last_completed is", :from_date
48+
end
49+
50+
context "with a dormant cycle" do
51+
let(:notation) { "V1I24M" }
52+
53+
it "returns nil" do
54+
expect(cycle.last_completed).to be_nil
55+
end
56+
end
57+
end
58+
59+
describe "#final_date" do
60+
it "returns from_date + period without end-of-month rounding" do
61+
expect(cycle.final_date).to eq "2028-03-31".to_date
62+
end
63+
64+
context "with a mid-month from_date" do
65+
let(:from_date) { "2026-06-15".to_date }
66+
67+
it "preserves the exact day" do
68+
expect(cycle.final_date).to eq "2028-06-15".to_date
69+
end
70+
end
71+
end
72+
73+
describe "#covered_dates" do
74+
let(:completed_dates) do
75+
[
76+
within_window,
77+
just_before_end,
78+
too_early_date,
79+
too_late_date
80+
]
81+
end
82+
let(:within_window) { from_date + 6.months }
83+
let(:just_before_end) { end_date - 1.day }
84+
let(:too_early_date) { from_date - 1.day }
85+
let(:too_late_date) { end_date + 1.day }
86+
87+
let(:anchor) { from_date + 1.year }
88+
89+
it "returns dates that fall within the window" do
90+
expect(cycle.covered_dates(completed_dates, anchor:)).to eq([
91+
within_window,
92+
just_before_end
93+
])
94+
end
95+
end
96+
97+
describe "#satisfied_by?(anchor:)" do
98+
context "when the anchor date is < the final date" do
99+
let(:anchor) { "2028-03-30".to_date }
100+
101+
it "returns true" do
102+
expect(cycle).to be_satisfied_by(anchor:)
103+
end
104+
end
105+
106+
context "when the anchor date is = the final date" do
107+
let(:anchor) { "2028-03-31".to_date }
108+
109+
it "returns true" do
110+
expect(cycle).to be_satisfied_by(anchor:)
111+
end
112+
end
113+
114+
context "when the anchor date is > the final date" do
115+
let(:anchor) { "2028-04-01".to_date }
116+
117+
it "returns false" do
118+
expect(cycle).not_to be_satisfied_by(completed_dates, anchor:)
119+
end
120+
end
121+
end
122+
123+
describe "#expiration_of" do
124+
it "returns the final date" do
125+
expect(cycle.expiration_of).to eq "2028-03-31".to_date
126+
end
127+
end
128+
129+
describe "dormant behavior" do
130+
let(:notation) { "V1I24M" }
131+
132+
it "is dormant without a from_date" do
133+
expect(cycle).to be_dormant
134+
end
135+
136+
it "returns nil for final_date" do
137+
expect(cycle.final_date).to be_nil
138+
end
139+
140+
it "returns nil for expiration_of" do
141+
expect(cycle.expiration_of).to be_nil
142+
end
143+
144+
it "returns false for satisfied_by?" do
145+
expect(cycle).not_to be_satisfied_by(anchor: Date.current)
146+
end
147+
end
148+
149+
describe "activation" do
150+
let(:notation) { "V1I24M" }
151+
152+
it "can be activated with a date" do
153+
activated = Cycle.for(cycle.parser.activated_notation("2026-03-31".to_date))
154+
expect(activated.notation).to eq "V1I24MF2026-03-31"
155+
expect(activated).not_to be_dormant
156+
end
157+
end
158+
end
159+
end

0 commit comments

Comments
 (0)