From 273ab8417801875f5ad70b203ba5076bea022b50 Mon Sep 17 00:00:00 2001 From: Jim Gay Date: Fri, 10 Apr 2026 17:56:53 -0400 Subject: [PATCH] Add LookbackEndOf (LE) cycle type with end-of-period window rounding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new LE notation for cycles that behave like Lookback but round the window boundary to the end of the calendar period, preventing completions from expiring mid-period. Supports D, W, M, Q, Y periods. Added: LookbackEndOf (`LE`) cycle type — lookback window that expires at end of calendar period Version: minor --- lib/sof/cycle.rb | 2 +- lib/sof/cycles/lookback_end_of.rb | 43 ++++++++ lib/sof/parser.rb | 2 +- spec/sof/cycles/lookback_end_of_spec.rb | 127 ++++++++++++++++++++++++ spec/sof/parser_spec.rb | 15 +++ 5 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 lib/sof/cycles/lookback_end_of.rb create mode 100644 spec/sof/cycles/lookback_end_of_spec.rb diff --git a/lib/sof/cycle.rb b/lib/sof/cycle.rb index a853e46..92cd75d 100644 --- a/lib/sof/cycle.rb +++ b/lib/sof/cycle.rb @@ -224,7 +224,7 @@ def initialize(notation, parser: Parser.new(notation)) delegate [:kind, :recurring?, :volume_only?, :valid_periods] => "self.class" delegate [:period_count, :duration] => :time_span delegate [:calendar?, :dormant?, :end_of?, :interval?, :lookback?, - :volume_only?, :within?] => :kind_inquiry + :lookback_end_of?, :volume_only?, :within?] => :kind_inquiry def kind_inquiry = ActiveSupport::StringInquirer.new(kind.to_s) diff --git a/lib/sof/cycles/lookback_end_of.rb b/lib/sof/cycles/lookback_end_of.rb new file mode 100644 index 0000000..3727536 --- /dev/null +++ b/lib/sof/cycles/lookback_end_of.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module SOF + module Cycles + class LookbackEndOf < Cycle + @volume_only = false + @notation_id = "LE" + @kind = :lookback_end_of + @valid_periods = %w[D W M Q Y] + + def self.recurring? = true + + def self.description + "Lookback End of Period - occurrences within a prior time period, expiring at the end of the calendar period" + end + + def self.examples + ["V1LE24M - once in the prior 24 months (expires end of month)", "V2LE3W - twice in the prior 3 weeks (expires end of week)"] + end + + def to_s = "#{volume}x in the prior #{period_count} #{humanized_period} (end of period)" + + def expiration_of(completion_dates, anchor: Date.current) + oldest = completion_dates.max_by(volume) { it }.min + return unless satisfied_by?(completion_dates, anchor:) + + final_date(oldest) + end + + def final_date(anchor) + return if anchor.nil? + + time_span.end_date_of_period(time_span.end_date(anchor.to_date)) + end + alias_method :window_end, :final_date + + def start_date(anchor) + time_span.begin_date_of_period(time_span.begin_date(anchor.to_date)) + end + alias_method :window_start, :start_date + end + end +end diff --git a/lib/sof/parser.rb b/lib/sof/parser.rb index a01e275..fca667c 100644 --- a/lib/sof/parser.rb +++ b/lib/sof/parser.rb @@ -15,7 +15,7 @@ class Parser PARTS_REGEX = / ^(?V(?\d*))? # optional volume - (?(?L|C|W|E|I) # kind + (?(?LE|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/lookback_end_of_spec.rb b/spec/sof/cycles/lookback_end_of_spec.rb new file mode 100644 index 0000000..88bc927 --- /dev/null +++ b/spec/sof/cycles/lookback_end_of_spec.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require "spec_helper" +require_relative "shared_examples" + +module SOF + RSpec.describe Cycles::LookbackEndOf, type: :value do + subject(:cycle) { Cycle.for(notation) } + + let(:notation) { "V1LE24M" } + + # April 10, 2026 — used as "today" in anchor-based tests + let(:anchor) { "2026-04-10".to_date } + + # April 9, 2024 — one day before the standard 24-month lookback window + # but inside the beginning_of_month-rounded window + let(:edge_completion) { "2024-04-09".to_date } + + # May 15, 2024 — clearly inside the 24-month window (no rounding needed) + let(:inside_completion) { "2024-05-15".to_date } + + # March 31, 2024 — one day before the rounded window start (April 1, 2024), + # proving the boundary is beginning_of_month(anchor - period), not further back + let(:outside_completion) { "2024-03-31".to_date } + + it_behaves_like "#kind returns", :lookback_end_of + it_behaves_like "#valid_periods are", %w[D W M Q Y] + it_behaves_like "#to_s returns", "1x in the prior 24 months (end of period)" + 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 cannot be extended" + + describe "#recurring?" do + it "repeats" do + expect(cycle).to be_recurring + end + end + + describe "#satisfied_by?(completion_dates, anchor:)" do + context "when a completion is one day before the exact lookback boundary but within the start of the period" do + # Standard L24M window from April 10, 2026 starts April 10, 2024. + # LE24M rounds to April 1, 2024 — so April 9, 2024 qualifies. + it "returns true" do + expect(cycle).to be_satisfied_by([edge_completion], anchor:) + end + end + + context "when a completion is clearly inside the rounded window" do + it "returns true" do + expect(cycle).to be_satisfied_by([inside_completion], anchor:) + end + end + + context "when a completion is outside the window even after rounding" do + it "returns false" do + expect(cycle).not_to be_satisfied_by([outside_completion], anchor:) + end + end + + context "with volume > 1" do + let(:notation) { "V2LE24M" } + + it "requires the minimum number of completions" do + expect(cycle).not_to be_satisfied_by([edge_completion], anchor:) + expect(cycle).to be_satisfied_by([edge_completion, inside_completion], anchor:) + end + end + + context "when there are no completions" do + it "returns false" do + expect(cycle).not_to be_satisfied_by([], anchor:) + end + end + end + + describe "#expiration_of(completion_dates)" do + context "when satisfied" do + # anchor = edge_completion = April 9, 2024 + # April 9, 2024 + 24 months = April 9, 2026 → end_of_month = April 30, 2026 + it "returns the end of the period in which the window boundary falls" do + expect(cycle.expiration_of([edge_completion])).to eq("2026-04-30".to_date) + end + end + + context "with a completion in the middle of a period" do + # May 15, 2024 + 24 months = May 15, 2026 → end_of_month = May 31, 2026 + it "rounds to end of that period" do + expect(cycle.expiration_of([inside_completion])).to eq("2026-05-31".to_date) + end + end + + context "when not satisfied" do + it "returns nil" do + expect(cycle.expiration_of([outside_completion])).to be_nil + end + end + + context "with volume > 1" do + let(:notation) { "V2LE24M" } + + # Uses the oldest of the most recent 2 completions as the anchor. + # edge_completion (April 9, 2024) is the older of the two → April 9, 2024 + 24 months = April 30, 2026 + it "uses the oldest of the most recent volume completions" do + expect(cycle.expiration_of([inside_completion, edge_completion])).to eq("2026-04-30".to_date) + end + end + end + + describe "#final_date(anchor)" do + # April 10, 2026 + 24 months = April 10, 2028 → end_of_month = April 30, 2028 + it "returns anchor + period rounded to end of period" do + expect(cycle.final_date("2026-04-10".to_date)).to eq("2028-04-30".to_date) + end + end + + describe "Cycle.for" do + it "returns a LookbackEndOf instance for LE notation" do + expect(Cycle.for("V1LE24M")).to be_a(Cycles::LookbackEndOf) + end + + it "does not affect plain Lookback" do + expect(Cycle.for("V1L24M")).to be_a(Cycles::Lookback) + end + end + end +end diff --git a/spec/sof/parser_spec.rb b/spec/sof/parser_spec.rb index 4c843f3..7bacca9 100644 --- a/spec/sof/parser_spec.rb +++ b/spec/sof/parser_spec.rb @@ -225,5 +225,20 @@ module SOF end end end + + describe "LE kind" do + it "parses V1LE24M as a valid LE notation" do + parser = described_class.new("V1LE24M") + expect(parser).to be_valid + expect(parser.kind).to eq("LE") + expect(parser.period_count).to eq("24") + expect(parser.period_key).to eq("M") + end + + it "does not confuse V1L24M (Lookback) with LE" do + parser = described_class.new("V1L24M") + expect(parser.kind).to eq("L") + end + end end end