From 7be6041ee5195a8ae3447f79cc809670fec724e9 Mon Sep 17 00:00:00 2001 From: "ryan.weiss" Date: Fri, 20 Mar 2026 11:25:47 -0700 Subject: [PATCH] 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, the consuming app re-anchors via activated_notation. Addresses QUAL-6317 --- CHANGELOG.md | 4 + lib/sof/cycle.rb | 7 +- lib/sof/cycles/interval.rb | 75 ++++++++++++++ lib/sof/parser.rb | 2 +- spec/sof/cycles/dormant_spec.rb | 24 +++++ spec/sof/cycles/interval_spec.rb | 173 +++++++++++++++++++++++++++++++ 6 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 lib/sof/cycles/interval.rb create mode 100644 spec/sof/cycles/interval_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index b13e77d..cb123b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/lib/sof/cycle.rb b/lib/sof/cycle.rb index 0010e51..6b90f6b 100644 --- a/lib/sof/cycle.rb +++ b/lib/sof/cycle.rb @@ -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) diff --git a/lib/sof/cycles/interval.rb b/lib/sof/cycles/interval.rb new file mode 100644 index 0000000..9d56cf1 --- /dev/null +++ b/lib/sof/cycles/interval.rb @@ -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 + # # => # + 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 diff --git a/lib/sof/parser.rb b/lib/sof/parser.rb index cdc9b3b..d118600 100644 --- a/lib/sof/parser.rb +++ b/lib/sof/parser.rb @@ -14,7 +14,7 @@ class Parser extend Forwardable PARTS_REGEX = / ^(?V(?\d*))? # optional volume - (?(?L|C|W|E) # kind + (?(?L|C|W|E|I) # kind (?\d+) # period count (?D|W|M|Q|Y)?)? # period_key (?F(?\d{4}-\d{2}-\d{2}))?$ # optional from diff --git a/spec/sof/cycles/dormant_spec.rb b/spec/sof/cycles/dormant_spec.rb index e3ecbb9..00c6824 100644 --- a/spec/sof/cycles/dormant_spec.rb +++ b/spec/sof/cycles/dormant_spec.rb @@ -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 [ @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/spec/sof/cycles/interval_spec.rb b/spec/sof/cycles/interval_spec.rb new file mode 100644 index 0000000..d753ea5 --- /dev/null +++ b/spec/sof/cycles/interval_spec.rb @@ -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