Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [0.1.13] - Unreleased

### Added

- Interval cycle kind (`I` notation) — repeating windows anchored to a from_date that re-anchor from completion date (e.g., `V1I24MF2026-03-31`)

### Changed

- Dormant capability is now declared on each cycle class (`def self.dormant_capable? = true`) instead of only in `Parser.dormant_capable_kinds`
Expand Down
7 changes: 4 additions & 3 deletions lib/sof/cycle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -217,12 +217,13 @@ def initialize(notation, parser: Parser.new(notation))

attr_reader :parser

delegate [:activated_notation, :volume, :from, :from_date, :time_span, :period,
delegate [:activated_notation, :volume, :from,
:from_date, :time_span, :period,
: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?, :interval?, :lookback?,
:volume_only?, :within?] => :kind_inquiry

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

Expand Down
75 changes: 75 additions & 0 deletions lib/sof/cycles/interval.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# frozen_string_literal: true

# Captures the logic for enforcing the Interval cycle variant
# E.g. "V1I24MF2026-03-31" means:
# Complete 1 every 24 months, current window from 2026-03-31.
# After completion, the consuming app re-anchors 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.
# Unlike Within, the window is repeating — it re-anchors from the completion date.
module SOF
module Cycles
class Interval < Cycle
@volume_only = false
@notation_id = "I"
@kind = :interval
@valid_periods = %w[D W M Y]

def self.recurring? = true

def self.dormant_capable? = true

def self.description
"Interval - occurrences within a repeating window that re-anchors from completion date"
end

def self.examples
["V1I24MF2026-03-31 - once every 24 months from March 31, 2026 (re-anchors after completion)"]
end

def to_s
return dormant_to_s unless active?

"#{volume}x every #{humanized_span} from #{start_date.to_fs(:american)}"
end

# Returns the expiration date for the current window
#
# @return [Date, nil] The final date of the current window
def expiration_of(_ = nil, anchor: 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)
anchor <= final_date
end

# Returns the from_date as the last completed 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("V1I24MF2026-03-31").final_date
# # => #<Date: 2028-03-31>
def final_date(_ = nil)
return nil if start_date.nil?
time_span.end_date(start_date)
end

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

private

def dormant_to_s
"#{volume}x every #{humanized_span}"
end
end
end
end
2 changes: 1 addition & 1 deletion lib/sof/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ 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|I) # 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
Expand Down
24 changes: 24 additions & 0 deletions spec/sof/cycles/dormant_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ module SOF
let(:end_of_cycle) { Cycle.for(end_of_notation) }
let(:end_of_notation) { "V2E18M" }

let(:interval_cycle) { Cycle.for(interval_notation) }
let(:interval_notation) { "V1I24M" }

let(:anchor) { "2020-08-01".to_date }
let(:completed_dates) do
[
Expand Down Expand Up @@ -61,6 +64,12 @@ module SOF
expect(end_of_cycle.to_s).to eq "2x by the last day of the 18th month (dormant)"
end
end

context "with a dormant Interval cycle" do
it "returns the cycle string representation with (dormant) suffix" do
expect(interval_cycle.to_s).to eq "1x every 24 months (dormant)"
end
end
end

describe "#kind & #kind?" do
Expand All @@ -77,6 +86,8 @@ module SOF
.to eq("#{within_notation}F2024-06-09")
expect(end_of_cycle.activated_notation("2024-06-09"))
.to eq("#{end_of_notation}F2024-06-09")
expect(interval_cycle.activated_notation("2024-06-09"))
.to eq("#{interval_notation}F2024-06-09")
end
end

Expand All @@ -87,6 +98,8 @@ module SOF
.to eq("#{within_notation}F2024-06-09")
expect(end_of_cycle.activated_notation(time))
.to eq("#{end_of_notation}F2024-06-09")
expect(interval_cycle.activated_notation(time))
.to eq("#{interval_notation}F2024-06-09")
end
end
end
Expand All @@ -96,6 +109,7 @@ module SOF
aggregate_failures do
expect(within_cycle.covered_dates(completed_dates, anchor:)).to be_empty
expect(end_of_cycle.covered_dates(completed_dates, anchor:)).to be_empty
expect(interval_cycle.covered_dates(completed_dates, anchor:)).to be_empty
end
end
end
Expand All @@ -112,6 +126,11 @@ module SOF
expect(end_of_cycle).not_to be_satisfied_by(completed_dates, anchor:)
expect(end_of_cycle).not_to be_satisfied_by([], anchor:)
expect(end_of_cycle).not_to be_satisfied_by(completed_dates, anchor: 5.years.from_now)

expect(interval_cycle).not_to be_satisfied_by(completed_dates, anchor: 5.years.ago)
expect(interval_cycle).not_to be_satisfied_by(completed_dates, anchor:)
expect(interval_cycle).not_to be_satisfied_by([], anchor:)
expect(interval_cycle).not_to be_satisfied_by(completed_dates, anchor: 5.years.from_now)
end
end
end
Expand All @@ -124,6 +143,9 @@ module SOF

expect(end_of_cycle.expiration_of(completed_dates)).to be_nil
expect(end_of_cycle.expiration_of([])).to be_nil

expect(interval_cycle.expiration_of(completed_dates)).to be_nil
expect(interval_cycle.expiration_of([])).to be_nil
end
end
end
Expand All @@ -133,6 +155,7 @@ module SOF
aggregate_failures do
expect(within_cycle.volume).to eq(2)
expect(end_of_cycle.volume).to eq(2)
expect(interval_cycle.volume).to eq(1)
end
end
end
Expand All @@ -142,6 +165,7 @@ module SOF
aggregate_failures do
expect(within_cycle.notation).to eq(within_notation)
expect(end_of_cycle.notation).to eq(end_of_notation)
expect(interval_cycle.notation).to eq(interval_notation)
end
end
end
Expand Down
173 changes: 173 additions & 0 deletions spec/sof/cycles/interval_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
# frozen_string_literal: true

require "spec_helper"
require_relative "shared_examples"

module SOF
RSpec.describe Cycles::Interval, type: :value do
subject(:cycle) { Cycle.for(notation) }

let(:notation) { "V1I24MF#{from_date}" }
let(:anchor) { nil }

let(:end_date) { from_date + 24.months }
let(:from_date) { "2026-03-31".to_date }

let(:completed_dates) { [] }

it_behaves_like "#kind returns", :interval
it_behaves_like "#valid_periods are", %w[D W M Y]

describe "#recurring?" do
it "repeats" do
expect(cycle).to be_recurring
end
end

describe "relationship to Within" do
# Interval uses the same window computation as Within but with
# different lifecycle semantics (repeating vs one-shot)
let(:within) { Cycle.for("V1W24MF#{from_date}") }

it "computes final_date the same as Within" do
expect(cycle.final_date).to eq within.final_date
end

it "computes start_date the same as Within" do
expect(cycle.start_date).to eq within.start_date
end
end

@from = "2026-03-31".to_date.to_fs(:american)
it_behaves_like "#to_s returns",
"1x every 24 months from #{@from}"

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

it_behaves_like "#to_s returns",
"1x every 24 months"
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: "2026-03-31".to_date + 24.months
it_behaves_like "it cannot be extended"

describe "#last_completed" do
context "with an activated cycle" do
it_behaves_like "last_completed is", :from_date
end

context "with a dormant cycle" do
let(:notation) { "V1I24M" }

it "returns nil" do
expect(cycle.last_completed).to be_nil
end
end
end

describe "#final_date" do
it "returns from_date + period without end-of-month rounding" do
expect(cycle.final_date).to eq "2028-03-31".to_date
end

context "with a mid-month from_date" do
let(:from_date) { "2026-06-15".to_date }

it "preserves the exact day" do
expect(cycle.final_date).to eq "2028-06-15".to_date
end
end
end

describe "#covered_dates" do
let(:completed_dates) do
[
within_window,
just_before_end,
too_early_date,
too_late_date
]
end
let(:within_window) { from_date + 6.months }
let(:just_before_end) { end_date - 1.day }
let(:too_early_date) { from_date - 1.day }
let(:too_late_date) { end_date + 1.day }

let(:anchor) { from_date + 1.year }

it "returns dates that fall within the window" do
expect(cycle.covered_dates(completed_dates, anchor:)).to eq([
within_window,
just_before_end
])
end
end

describe "#satisfied_by?(anchor:)" do
context "when the anchor date is < the final date" do
let(:anchor) { "2028-03-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) { "2028-03-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) { "2028-04-01".to_date }

it "returns false" do
expect(cycle).not_to be_satisfied_by(completed_dates, anchor:)
end
end
end

describe "#expiration_of" do
it "returns the final date" do
expect(cycle.expiration_of).to eq "2028-03-31".to_date
end
end

describe "dormant behavior" do
let(:notation) { "V1I24M" }

it "is dormant without a from_date" do
expect(cycle).to be_dormant
end

it "returns nil for final_date" do
expect(cycle.final_date).to be_nil
end

it "returns nil for expiration_of" do
expect(cycle.expiration_of).to be_nil
end

it "returns false for satisfied_by?" do
expect(cycle).not_to be_satisfied_by(anchor: Date.current)
end
end

describe "activation" do
let(:notation) { "V1I24M" }

it "can be activated with a date" do
activated = Cycle.for(cycle.parser.activated_notation("2026-03-31".to_date))
expect(activated.notation).to eq "V1I24MF2026-03-31"
expect(activated).not_to be_dormant
end
end
end
end
Loading