diff --git a/.changeset/relative-date-month-clamp.md b/.changeset/relative-date-month-clamp.md new file mode 100644 index 00000000..1b7b4435 --- /dev/null +++ b/.changeset/relative-date-month-clamp.md @@ -0,0 +1,5 @@ +--- +"posthog-ruby": patch +--- + +Relative date filters in local flag evaluation now clamp the day after every month or year step, as the SDK spec requires. `2m` on March 31 resolves to January 28 instead of January 31, and `4y` on February 29, 2024 to February 28, 2020. diff --git a/lib/posthog/feature_flags.rb b/lib/posthog/feature_flags.rb index 9f57d23c..64b2cc2e 100644 --- a/lib/posthog/feature_flags.rb +++ b/lib/posthog/feature_flags.rb @@ -548,9 +548,9 @@ def self.relative_date_parse_for_feature_flag_matching(value) when 'w' parsed_dt = parsed_dt.prev_day(number * 7) when 'm' - parsed_dt = parsed_dt.prev_month(number) + parsed_dt = subtract_months(parsed_dt, number) when 'y' - parsed_dt = parsed_dt.prev_year(number) + parsed_dt = subtract_years(parsed_dt, number) else return nil end @@ -558,6 +558,30 @@ def self.relative_date_parse_for_feature_flag_matching(value) parsed_dt end + # Steps back one month at a time and clamps the day after every step, as the + # SDK spec requires: March 31 minus two months is January 28. A single + # prev_month(2) clamps once and lands on January 31. + def self.subtract_months(date_time, months) + steps = 0 + # Every month has at least 28 days, so once the day is 28 or less no step + # can clamp it again and the rest can be done in one jump. + while steps < months && date_time.day > 28 + date_time = date_time.prev_month + steps += 1 + end + date_time.prev_month(months - steps) + end + private_class_method :subtract_months + + # Years work the same way. Only February 29 can clamp, and only on the first + # step, so one step followed by a single jump matches stepping every year. + def self.subtract_years(date_time, years) + return date_time if years.zero? + + date_time.prev_year.prev_year(years - 1) + end + private_class_method :subtract_years + # Parse a single semver numeric identifier, rejecting empty, non-digit, or # leading-zero values per semver 2.0.0 ยง2. `reason` names the owner of the value: # UNSUPPORTED_DEFINITION for a filter value, MISSING_CONTEXT for a value the call passed. diff --git a/spec/posthog/feature_flag_spec.rb b/spec/posthog/feature_flag_spec.rb index e0fdb047..a9b5986e 100644 --- a/spec/posthog/feature_flag_spec.rb +++ b/spec/posthog/feature_flag_spec.rb @@ -2537,6 +2537,29 @@ module PostHog end end + it 'clamps month and year lookbacks after every step' do + [ + [[2025, 3, 31], '1m', [2025, 2, 28]], + [[2025, 5, 31], '1m', [2025, 4, 30]], + [[2024, 3, 31], '1m', [2024, 2, 29]], + [[2025, 3, 31], '2m', [2025, 1, 28]], + [[2025, 3, 31], '13m', [2024, 2, 28]], + [[2025, 12, 31], '10m', [2025, 2, 28]], + [[2019, 1, 29], '12m', [2018, 1, 28]], + [[2019, 1, 29], '1y', [2018, 1, 29]], + [[2025, 3, 31], '0m', [2025, 3, 31]], + [[2024, 2, 29], '1y', [2023, 2, 28]], + [[2024, 2, 29], '4y', [2020, 2, 28]], + [[2024, 2, 29], '0y', [2024, 2, 29]] + ].each do |now, value, expected| + Timecop.freeze(Time.gm(*now, 12, 0, 0)) do + expect( + FeatureFlagsPoller.relative_date_parse_for_feature_flag_matching(value) + ).to eq(DateTime.new(*expected, 12, 0, 0, Rational(0, 24))), "#{value} from #{now.join('-')}" + end + end + end + it 'test year parsing' do Timecop.freeze(Time.gm(2020, 1, 1, 12, 1, 20, 134_000, 0)) do expect(