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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions lib/sof/cycle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
74 changes: 74 additions & 0 deletions lib/sof/cycles/repeating_within.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 4 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|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
/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
Expand Down Expand Up @@ -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
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(:repeating_within_cycle) { Cycle.for(repeating_within_notation) }
let(:repeating_within_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 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
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(repeating_within_cycle.activated_notation("2024-06-09"))
.to eq("#{repeating_within_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(repeating_within_cycle.activated_notation(time))
.to eq("#{repeating_within_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(repeating_within_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(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
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(repeating_within_cycle.expiration_of(completed_dates)).to be_nil
expect(repeating_within_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(repeating_within_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(repeating_within_cycle.notation).to eq(repeating_within_notation)
end
end
end
Expand Down
Loading
Loading