diff --git a/CHANGELOG.md b/CHANGELOG.md index c602b14..ba09cfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.1.13] - Unreleased +### Added + +- RepeatingWithin cycle kind (`I` notation) — a Within that repeats, re-anchoring from the completion date after satisfaction (e.g., `V1I24MF2026-03-31`) +- `reactivated_notation(date)` alias for `activated_notation` — self-documenting call site when re-anchoring a satisfied cycle + ## [0.1.12] - 2025-09-05 ### Added diff --git a/lib/sof/cycle.rb b/lib/sof/cycle.rb index 9adf732..7107ac5 100644 --- a/lib/sof/cycle.rb +++ b/lib/sof/cycle.rb @@ -215,12 +215,13 @@ def initialize(notation, parser: Parser.new(notation)) attr_reader :parser - delegate [:activated_notation, :volume, :from, :from_date, :time_span, :period, + delegate [:activated_notation, :reactivated_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?, :lookback?, + :repeating_within?, :volume_only?, :within?] => :kind_inquiry def kind_inquiry = ActiveSupport::StringInquirer.new(kind.to_s) diff --git a/lib/sof/cycles/repeating_within.rb b/lib/sof/cycles/repeating_within.rb new file mode 100644 index 0000000..e1f38d1 --- /dev/null +++ b/lib/sof/cycles/repeating_within.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require_relative "within" + +# A Within cycle that repeats — after satisfaction, the consuming app +# re-anchors the window from the completion date. +# +# E.g. "V1I24MF2026-03-31" means: +# Complete 1 every 24 months, current window from 2026-03-31. +# After completion, call reactivated_notation(completion_date) to start +# the next window. +# +# Inherits final_date, start_date, and date_range from Within. +# Overrides only what differs: recurring?, to_s, extend_period, +# last_completed, expiration_of, and satisfied_by?. +module SOF + module Cycles + class RepeatingWithin < Within + @volume_only = false + @notation_id = "I" + @kind = :repeating_within + @valid_periods = %w[D W M Y] + + def self.recurring? = true + + def self.description + "RepeatingWithin - like Within, but the window re-anchors from the completion date after satisfaction" + end + + def self.examples + ["V1I24MF2026-03-31 - once every 24 months from March 31, 2026 (re-anchors after completion)"] + end + + # --- Overrides from Within --- + + def to_s + return dormant_to_s unless active? + + "#{volume}x every #{humanized_span} from #{start_date.to_fs(:american)}" + end + + # Nil-safe for dormant state (Within assumes active via Dormant wrapper, + # but Dormant#method_missing passes through final_date/start_date) + def start_date(_ = nil) = from_date&.to_date + + def final_date(_ = nil) + return nil if start_date.nil? + super + end + + # RepeatingWithin re-anchors instead of extending + def extend_period(_ = nil) = self + + # The from_date represents when the current window started + def last_completed(_ = nil) = from_date&.to_date + + # Returns the final date of the current window + def expiration_of(_ = nil, anchor: nil) + final_date + end + + # Is the anchor still within the current window? + def satisfied_by?(_ = nil, anchor: Date.current) + anchor <= final_date + end + + 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 967fd45..79d9ca5 100644 --- a/lib/sof/parser.rb +++ b/lib/sof/parser.rb @@ -14,13 +14,13 @@ 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 /ix - def self.dormant_capable_kinds = %w[E W] + def self.dormant_capable_kinds = %w[E W I] def self.for(notation_or_parser) return notation_or_parser if notation_or_parser.is_a? self @@ -64,6 +64,8 @@ def activated_notation(date) self.class.load(to_h.merge(from_date: date.to_date)).notation end + alias_method :reactivated_notation, :activated_notation + def ==(other) = other.to_h == to_h def to_h diff --git a/spec/sof/cycles/dormant_spec.rb b/spec/sof/cycles/dormant_spec.rb index 180d09a..02bae4d 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(:repeating_within_cycle) { Cycle.for(repeating_within_notation) } + let(:repeating_within_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 17th subsequent month (dormant)" end end + + context "with a dormant RepeatingWithin cycle" do + it "returns the cycle string representation with (dormant) suffix" do + expect(repeating_within_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(repeating_within_cycle.activated_notation("2024-06-09")) + .to eq("#{repeating_within_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(repeating_within_cycle.activated_notation(time)) + .to eq("#{repeating_within_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(repeating_within_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(repeating_within_cycle).not_to be_satisfied_by(completed_dates, anchor: 5.years.ago) + expect(repeating_within_cycle).not_to be_satisfied_by(completed_dates, anchor:) + expect(repeating_within_cycle).not_to be_satisfied_by([], anchor:) + expect(repeating_within_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(repeating_within_cycle.expiration_of(completed_dates)).to be_nil + expect(repeating_within_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(repeating_within_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(repeating_within_cycle.notation).to eq(repeating_within_notation) end end end diff --git a/spec/sof/cycles/repeating_within_spec.rb b/spec/sof/cycles/repeating_within_spec.rb new file mode 100644 index 0000000..b8bf35d --- /dev/null +++ b/spec/sof/cycles/repeating_within_spec.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "shared_examples" + +module SOF + RSpec.describe Cycles::RepeatingWithin, 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", :repeating_within + 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 "inherits from Within" do + it "is a Within" do + expect(described_class).to be < Cycles::Within + end + + it "computes final_date the same as Within" do + within = Cycle.for("V1W24MF#{from_date}") + expect(cycle.final_date).to eq within.final_date + end + + it "computes start_date the same as Within" do + within = Cycle.for("V1W24MF#{from_date}") + 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 "#reactivated_notation" do + it "returns a new notation with the updated from_date" do + new_notation = cycle.reactivated_notation("2028-03-15".to_date) + expect(new_notation).to eq "V1I24MF2028-03-15" + end + + it "is functionally identical to activated_notation" do + date = "2028-06-01".to_date + expect(cycle.reactivated_notation(date)).to eq cycle.activated_notation(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