Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [0.1.13] - Unreleased

### Added

- Recurring cycle kind (`R` notation) — recurring windows anchored to a from_date (e.g., `V1R24MF2026-03-31`)

### Fixed

- EndOf cycle `final_date` was off by one period — `V1E12M` now correctly expires at the end of the 12th month, not the 11th

## [0.1.12] - 2025-09-05

### Added
Expand Down
4 changes: 2 additions & 2 deletions lib/sof/cycle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,8 @@ def initialize(notation, parser: Parser.new(notation))
:humanized_period, :period_key, :active?] => :@parser
delegate [:kind, :recurring?, :volume_only?, :valid_periods] => "self.class"
delegate [:period_count, :duration] => :time_span
delegate [:calendar?, :dormant?, :end_of?, :lookback?, :volume_only?,
:within?] => :kind_inquiry
delegate [:calendar?, :dormant?, :end_of?, :lookback?,
:volume_only?, :within?] => :kind_inquiry

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

Expand Down
10 changes: 5 additions & 5 deletions lib/sof/cycles/end_of.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# Captures the logic for enforcing the EndOf cycle variant
# E.g. "V1E18MF2020-01-05" means:
# You're good until the end of the 17th subsequent month from 2020-01-05.
# You're good until the end of the 18th month from 2020-01-05.
# Complete 1 by that date to reset the cycle.
#
# Some of the calculations are quite different from other cycles.
Expand Down Expand Up @@ -62,15 +62,15 @@ def satisfied_by?(_ = nil, anchor: Date.current)
#
# @param [nil] _ Unused parameter, maintained for compatibility
# @return [Date] The final date of the cycle calculated as the end of the
# nth subsequent period after the FROM date, where n = (period count - 1)
# nth period after the FROM date
#
# @example
# Cycle.for("V1E18MF2020-01-09").final_date
# # => #<Date: 2021-06-30>
# # => #<Date: 2021-07-31>
def final_date(_ = nil)
return nil if parser.dormant? || from_date.nil?
time_span
.end_date(start_date - 1.send(period))
.end_date(start_date)
.end_of_month
end

Expand All @@ -86,7 +86,7 @@ def dormant_to_s
end

def subsequent_ordinal
ActiveSupport::Inflector.ordinalize(period_count - 1)
ActiveSupport::Inflector.ordinalize(period_count)
end
end
end
Expand Down
80 changes: 80 additions & 0 deletions lib/sof/cycles/recurring.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# frozen_string_literal: true

# Captures the logic for enforcing the Recurring cycle variant
# E.g. "V1R24MF2026-03-31" means:
# Complete 1 within 24 months starting from 2026-03-31.
# After completion, the next window starts from the completion date.
#
# Unlike EndOf, there is no end-of-month rounding.
# Unlike Lookback, the window is anchored to a from_date, not sliding from today.
module SOF
module Cycles
class Recurring < Cycle
@volume_only = false
@notation_id = "R"
@kind = :recurring
@valid_periods = %w[D W M Y]

def self.recurring? = true

def self.description
"Recurring - occurrences within a recurring time period anchored to a from date"
end

def self.examples
["V1R24MF2026-03-31 - once within 24 months from March 31, 2026"]
end

def to_s
return dormant_to_s if parser.dormant? || from_date.nil?

"#{volume}x within #{date_range}"
end

# Returns the expiration date for the cycle
#
# @return [Date, nil] The final date of the current window
def expiration_of(_ = nil, anchor: nil)
return nil if parser.dormant? || from_date.nil?
final_date
end

# Is the supplied anchor date within the current window?
#
# @return [Boolean] true if the anchor is before or on the final date
def satisfied_by?(_ = nil, anchor: Date.current)
return false if parser.dormant? || from_date.nil?
anchor <= final_date
end

# Always returns the from_date
def last_completed(_ = nil) = from_date&.to_date

# Calculates the final date of the current window
#
# @return [Date] from_date + period (no end-of-month rounding)
#
# @example
# Cycle.for("V1R24MF2026-03-31").final_date
# # => #<Date: 2028-03-31>
def final_date(_ = nil)
return nil if parser.dormant? || from_date.nil?
time_span.end_date(start_date)
end

def start_date(_ = nil) = from_date&.to_date

private

def dormant_to_s
<<~DESC.squish
#{volume}x every #{humanized_span}
DESC
end

def date_range
[start_date, final_date].map { it.to_fs(:american) }.join(" - ")
end
end
end
end
4 changes: 2 additions & 2 deletions lib/sof/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ class Parser
extend Forwardable
PARTS_REGEX = /
^(?<vol>V(?<volume>\d*))? # optional volume
(?<set>(?<kind>L|C|W|E) # kind
(?<set>(?<kind>L|C|W|E|R) # kind
(?<period_count>\d+) # period count
(?<period_key>D|W|M|Q|Y)?)? # period_key
(?<from>F(?<from_date>\d{4}-\d{2}-\d{2}))?$ # optional from
/ix

def self.dormant_capable_kinds = %w[E W]
def self.dormant_capable_kinds = %w[E W R]

def self.for(notation_or_parser)
return notation_or_parser if notation_or_parser.is_a? self
Expand Down
2 changes: 1 addition & 1 deletion spec/sof/cycles/dormant_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ module SOF

context "with a dormant EndOf cycle" do
it "returns the cycle string representation with (dormant) suffix" do
expect(end_of_cycle.to_s).to eq "2x by the last day of the 17th subsequent month (dormant)"
expect(end_of_cycle.to_s).to eq "2x by the last day of the 18th subsequent month (dormant)"
end
end
end
Expand Down
28 changes: 14 additions & 14 deletions spec/sof/cycles/end_of_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ module SOF
let(:notation) { "V2E18MF#{from_date}" }
let(:anchor) { nil }

let(:end_date) { (from_date + 17.months).end_of_month }
let(:end_date) { (from_date + 18.months).end_of_month }
let(:from_date) { "2020-01-01".to_date }

let(:completed_dates) { [] }
Expand All @@ -24,21 +24,21 @@ module SOF
end
end

@end_date = ("2020-01-01".to_date + 17.months).end_of_month
@end_date = ("2020-01-01".to_date + 18.months).end_of_month
it_behaves_like "#to_s returns",
"2x by #{@end_date.to_fs(:american)}"

context "when the cycle is dormant" do
before { allow(cycle.parser).to receive(:dormant?).and_return(true) }

it_behaves_like "#to_s returns",
"2x by the last day of the 17th subsequent month"
"2x by the last day of the 18th subsequent month"
end
it_behaves_like "#volume returns the volume"
it_behaves_like "#notation returns the notation"
it_behaves_like "#as_json returns the notation"
it_behaves_like "it computes #final_date(given)",
given: nil, returns: ("2020-01-01".to_date + 17.months).end_of_month
given: nil, returns: ("2020-01-01".to_date + 18.months).end_of_month
it_behaves_like "it cannot be extended"

describe "#last_completed" do
Expand Down Expand Up @@ -66,7 +66,7 @@ module SOF
too_late_date
]
end
let(:recent_date) { from_date + 17.months }
let(:recent_date) { from_date + 18.months }
let(:middle_date) { from_date + 2.months }
let(:early_date) { from_date + 1.month }
let(:too_early_date) { from_date - 1.day }
Expand All @@ -85,23 +85,23 @@ module SOF

describe "#satisfied_by?(anchor:)" do
context "when the anchor date is < the final date" do
let(:anchor) { "2021-06-29".to_date }
let(:anchor) { "2021-07-30".to_date }

it "returns true" do
expect(cycle).to be_satisfied_by(anchor:)
end
end

context "when the anchor date is = the final date" do
let(:anchor) { "2021-06-30".to_date }
let(:anchor) { "2021-07-31".to_date }

it "returns true" do
expect(cycle).to be_satisfied_by(anchor:)
end
end

context "when the anchor date is > the final date" do
let(:anchor) { "2021-07-01".to_date }
let(:anchor) { "2021-08-01".to_date }

it "returns false" do
expect(cycle).not_to be_satisfied_by(completed_dates, anchor:)
Expand All @@ -111,26 +111,26 @@ module SOF

describe "#expiration_of(completion_dates)" do
context "when the anchor date is < the final date" do
let(:anchor) { "2021-06-29".to_date }
let(:anchor) { "2021-07-30".to_date }

it "returns the final date" do
expect(cycle.expiration_of(anchor:)).to eq "2021-06-30".to_date
expect(cycle.expiration_of(anchor:)).to eq "2021-07-31".to_date
end
end

context "when the anchor date = the final date" do
let(:anchor) { "2021-06-30".to_date }
let(:anchor) { "2021-07-31".to_date }

it "returns the final date" do
expect(cycle.expiration_of(anchor:)).to eq "2021-06-30".to_date
expect(cycle.expiration_of(anchor:)).to eq "2021-07-31".to_date
end
end

context "when the anchor date > the final date" do
let(:anchor) { "2021-07-31".to_date }
let(:anchor) { "2021-08-31".to_date }

it "returns the final date" do
expect(cycle.expiration_of(anchor:)).to eq "2021-06-30".to_date
expect(cycle.expiration_of(anchor:)).to eq "2021-07-31".to_date
end
end
end
Expand Down
Loading
Loading