Skip to content

Commit 711ef0c

Browse files
jnunemakerclaude
andauthored
Add time-based feature enablements to expressions (#971)
* Add time-based feature enablements to expressions Introduce Flipper.now, Flipper.time, and Flipper.duration DSL methods to enable scheduling features, setting expiration times, and defining time windows without requiring custom properties on actors. Enhance the Time expression to handle both epoch timestamps (integers/floats) and ISO8601 datetime strings. Include comprehensive tests for time-based feature flag scenarios. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * Ensure Time.parse returns UTC for consistency with Numeric path Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add UTC tests, conductor-setup script, and mise activation for scripts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 0e19a53 commit 711ef0c

7 files changed

Lines changed: 159 additions & 14 deletions

File tree

examples/expressions.rb

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,9 @@ class Org < Struct.new(:id, :flipper_properties)
3131
include Flipper::Identifier
3232
end
3333

34-
NOW = Time.now.to_i
35-
DAY = 60 * 60 * 24
36-
3734
org = Org.new(1, {
3835
"type" => "Org",
3936
"id" => 1,
40-
"now" => NOW,
4137
})
4238

4339
user = User.new(1, {
@@ -46,15 +42,13 @@ class Org < Struct.new(:id, :flipper_properties)
4642
"plan" => "basic",
4743
"age" => 39,
4844
"team_user" => true,
49-
"now" => NOW,
5045
})
5146

5247
admin_user = User.new(2, {
5348
"type" => "User",
5449
"id" => 2,
5550
"admin" => true,
5651
"team_user" => true,
57-
"now" => NOW,
5852
})
5953

6054
other_user = User.new(3, {
@@ -63,7 +57,6 @@ class Org < Struct.new(:id, :flipper_properties)
6357
"plan" => "plus",
6458
"age" => 18,
6559
"org_admin" => true,
66-
"now" => NOW - DAY,
6760
})
6861

6962
age_expression = Flipper.property(:age).gte(21)
@@ -205,9 +198,40 @@ class Org < Struct.new(:id, :flipper_properties)
205198
assert Flipper.enabled?(:something, admin_user)
206199
refute Flipper.enabled?(:something, other_user)
207200

208-
puts "\n\nEnabling based on time"
209-
scheduled_time_expression = Flipper.property(:now).gte(NOW)
210-
Flipper.enable :something, scheduled_time_expression
201+
puts "\n\nEnabling based on time (epoch)"
202+
scheduled_epoch = Flipper.now.gte(Flipper.time(Time.now.to_i - 86_400))
203+
Flipper.enable :something, scheduled_epoch
211204
assert Flipper.enabled?(:something, user)
212205
assert Flipper.enabled?(:something, admin_user)
213-
refute Flipper.enabled?(:something, other_user)
206+
assert Flipper.enabled?(:something, other_user)
207+
reset
208+
209+
puts "\n\nEnabling based on time (datetime)"
210+
past_time = (Time.now.utc - 86_400).iso8601
211+
scheduled_datetime = Flipper.now.gte(Flipper.time(past_time))
212+
Flipper.enable :something, scheduled_datetime
213+
assert Flipper.enabled?(:something, user)
214+
assert Flipper.enabled?(:something, admin_user)
215+
assert Flipper.enabled?(:something, other_user)
216+
reset
217+
218+
puts "\n\nDisabling after a time (expiring feature)"
219+
future_time = (Time.now.utc + 86_400).iso8601
220+
expiring_expression = Flipper.now.lt(Flipper.time(future_time))
221+
Flipper.enable :something, expiring_expression
222+
assert Flipper.enabled?(:something, user)
223+
assert Flipper.enabled?(:something, admin_user)
224+
assert Flipper.enabled?(:something, other_user)
225+
reset
226+
227+
puts "\n\nEnabling within a time window"
228+
start_time = (Time.now.utc - 86_400).iso8601
229+
end_time = (Time.now.utc + 86_400).iso8601
230+
time_window = Flipper.all(
231+
Flipper.now.gte(Flipper.time(start_time)),
232+
Flipper.now.lt(Flipper.time(end_time))
233+
)
234+
Flipper.enable :something, time_window
235+
assert Flipper.enabled?(:something, user)
236+
assert Flipper.enabled?(:something, admin_user)
237+
assert Flipper.enabled?(:something, other_user)

lib/flipper.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,18 @@ def random(max)
100100
Expression.build({ Random: max })
101101
end
102102

103+
def now
104+
Expression.build({ Now: [] })
105+
end
106+
107+
def time(value)
108+
Expression.build({ Time: value })
109+
end
110+
111+
def duration(scalar, unit = 'second')
112+
Expression.build({ Duration: [scalar, unit] })
113+
end
114+
103115
def feature_enabled(name)
104116
Expression.build({ FeatureEnabled: name })
105117
end

lib/flipper/expressions/time.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1+
require "time"
2+
13
module Flipper
24
module Expressions
35
class Time
46
def self.call(value)
5-
::Time.parse(value)
7+
case value
8+
when Numeric
9+
::Time.at(value).utc
10+
else
11+
::Time.parse(value).utc
12+
end
613
end
714
end
815
end

script/conductor-setup

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
#!/bin/bash
2+
set -e
3+
cd "$(dirname "$0")/.."
4+
5+
if command -v mise &> /dev/null; then
6+
# Copy .tool-versions from root workspace if it exists
7+
if [ -n "$CONDUCTOR_ROOT_PATH" ] && [ -f "$CONDUCTOR_ROOT_PATH/.tool-versions" ]; then
8+
ln -sf "$CONDUCTOR_ROOT_PATH/.tool-versions" .tool-versions
9+
fi
10+
11+
# Install ruby version from .tool-versions
12+
mise install
13+
14+
# Activate mise so gem/bundle use the correct ruby
15+
eval "$(mise activate bash)"
16+
fi
17+
18+
# Install the required bundler version
19+
gem install bundler:4.0.6
20+
21+
# Bundle install and setup binstubs
22+
script/bootstrap

script/server

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#!/bin/sh
1+
#!/bin/bash
22
#/ Usage: server
33
#/
44
#/ Starts a server for perusing the UI locally.
@@ -16,4 +16,10 @@ cd $(dirname "$0")/..
1616
exit 0
1717
}
1818

19-
script/bootstrap && bundle exec rackup examples/ui/basic.ru -p 9999
19+
# Activate mise if available so we use the correct ruby
20+
if command -v mise &> /dev/null; then
21+
eval "$(mise activate bash)"
22+
fi
23+
24+
PORT="${CONDUCTOR_PORT:-9999}"
25+
script/bootstrap && bundle exec rackup examples/ui/basic.ru -p "$PORT"

spec/flipper/expressions/time_spec.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,21 @@
99
it "returns time for #iso8601 format" do
1010
expect(described_class.call(time.iso8601)).to eq(time)
1111
end
12+
13+
it "returns time for epoch integer" do
14+
expect(described_class.call(time.to_i)).to eq(Time.at(time.to_i).utc)
15+
end
16+
17+
it "returns time for epoch float" do
18+
expect(described_class.call(time.to_f)).to be_within(0.001).of(time)
19+
end
20+
21+
it "returns utc for string input" do
22+
expect(described_class.call(time.to_s).utc?).to be(true)
23+
end
24+
25+
it "returns utc for numeric input" do
26+
expect(described_class.call(time.to_i).utc?).to be(true)
27+
end
1228
end
1329
end

spec/flipper/gates/expression_spec.rb

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,64 @@ def context(expression, properties: {})
9999
expect(subject.open?(context)).to be(false)
100100
end
101101
end
102+
103+
context 'for time-based expressions' do
104+
it 'enables when now is past a scheduled epoch' do
105+
past_epoch = Time.now.to_i - 86_400
106+
expression = Flipper.now.gte(Flipper.time(past_epoch))
107+
expect(subject.open?(context(expression.value))).to be(true)
108+
end
109+
110+
it 'does not enable when now is before a future epoch' do
111+
future_epoch = Time.now.to_i + 86_400
112+
expression = Flipper.now.gte(Flipper.time(future_epoch))
113+
expect(subject.open?(context(expression.value))).to be(false)
114+
end
115+
116+
it 'enables when now is past a scheduled datetime' do
117+
past_time = (Time.now.utc - 86_400).iso8601
118+
expression = Flipper.now.gte(Flipper.time(past_time))
119+
expect(subject.open?(context(expression.value))).to be(true)
120+
end
121+
122+
it 'does not enable when now is before a future datetime' do
123+
future_time = (Time.now.utc + 86_400).iso8601
124+
expression = Flipper.now.gte(Flipper.time(future_time))
125+
expect(subject.open?(context(expression.value))).to be(false)
126+
end
127+
128+
it 'enables expiring features with lt' do
129+
future_time = (Time.now.utc + 86_400).iso8601
130+
expression = Flipper.now.lt(Flipper.time(future_time))
131+
expect(subject.open?(context(expression.value))).to be(true)
132+
end
133+
134+
it 'disables expired features with lt' do
135+
past_time = (Time.now.utc - 86_400).iso8601
136+
expression = Flipper.now.lt(Flipper.time(past_time))
137+
expect(subject.open?(context(expression.value))).to be(false)
138+
end
139+
140+
it 'enables within a time window using all' do
141+
start_time = (Time.now.utc - 86_400).iso8601
142+
end_time = (Time.now.utc + 86_400).iso8601
143+
expression = Flipper.all(
144+
Flipper.now.gte(Flipper.time(start_time)),
145+
Flipper.now.lt(Flipper.time(end_time))
146+
)
147+
expect(subject.open?(context(expression.value))).to be(true)
148+
end
149+
150+
it 'does not enable outside a time window' do
151+
start_time = (Time.now.utc + 86_400).iso8601
152+
end_time = (Time.now.utc + 172_800).iso8601
153+
expression = Flipper.all(
154+
Flipper.now.gte(Flipper.time(start_time)),
155+
Flipper.now.lt(Flipper.time(end_time))
156+
)
157+
expect(subject.open?(context(expression.value))).to be(false)
158+
end
159+
end
102160
end
103161

104162
describe '#protects?' do

0 commit comments

Comments
 (0)