From dd24561ef1beac27f599ca32fb1825ed75587bff Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:15:18 -0300 Subject: [PATCH] fix(flags): clamp the day after every step in relative month lookbacks prev_month(n) and prev_year(n) jump in one go and clamp the day once, so 2m on March 31 landed on January 31 and 4y on February 29, 2024 on February 29, 2020. Step back one unit at a time while the day can still clamp, as the SDK spec asks. --- .changeset/relative-date-month-clamp.md | 5 +++++ lib/posthog/feature_flags.rb | 28 +++++++++++++++++++++++-- spec/posthog/feature_flag_spec.rb | 23 ++++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 .changeset/relative-date-month-clamp.md 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(