trailguide 0.1.31 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +73 -8
- data/app/assets/javascripts/trail_guide/admin/application.js +20 -1
- data/app/assets/stylesheets/trail_guide/admin/application.css +22 -0
- data/app/assets/stylesheets/trail_guide/admin/experiments.css +36 -3
- data/app/controllers/trail_guide/admin/application_controller.rb +59 -8
- data/app/controllers/trail_guide/admin/experiments_controller.rb +209 -16
- data/app/controllers/trail_guide/admin/groups_controller.rb +34 -0
- data/app/controllers/trail_guide/experiments_controller.rb +1 -1
- data/app/views/layouts/trail_guide/admin/_calculator.erb +24 -0
- data/app/views/layouts/trail_guide/admin/_footer.html.erb +27 -0
- data/app/views/layouts/trail_guide/admin/_header.html.erb +147 -0
- data/app/views/layouts/trail_guide/admin/_import_modal.html.erb +45 -0
- data/app/views/layouts/trail_guide/admin/application.html.erb +17 -3
- data/app/views/trail_guide/admin/experiments/_alert_peek.html.erb +19 -0
- data/app/views/trail_guide/admin/experiments/_alert_state.html.erb +49 -0
- data/app/views/trail_guide/admin/experiments/_btn_analyze.html.erb +11 -0
- data/app/views/trail_guide/admin/experiments/_btn_analyze_goal.html.erb +5 -0
- data/app/views/trail_guide/admin/experiments/_btn_convert.html.erb +33 -0
- data/app/views/trail_guide/admin/experiments/_btn_enroll.html.erb +3 -0
- data/app/views/trail_guide/admin/experiments/_btn_join.html.erb +11 -0
- data/app/views/trail_guide/admin/experiments/_btn_leave.html.erb +7 -0
- data/app/views/trail_guide/admin/experiments/_btn_pause.html.erb +5 -0
- data/app/views/trail_guide/admin/experiments/_btn_peek.html.erb +13 -0
- data/app/views/trail_guide/admin/experiments/_btn_reset.html.erb +5 -0
- data/app/views/trail_guide/admin/experiments/_btn_restart.html.erb +5 -0
- data/app/views/trail_guide/admin/experiments/_btn_resume.html.erb +5 -0
- data/app/views/trail_guide/admin/experiments/_btn_schedule.html.erb +6 -0
- data/app/views/trail_guide/admin/experiments/_btn_start.html.erb +5 -0
- data/app/views/trail_guide/admin/experiments/_btn_stop.html.erb +5 -0
- data/app/views/trail_guide/admin/experiments/_experiment.html.erb +68 -172
- data/app/views/trail_guide/admin/experiments/_header.html.erb +87 -0
- data/app/views/trail_guide/admin/experiments/_start_modal.html.erb +57 -0
- data/app/views/trail_guide/admin/experiments/_tbody.html.erb +112 -0
- data/app/views/trail_guide/admin/experiments/_thead.html.erb +38 -0
- data/app/views/trail_guide/admin/experiments/index.html.erb +8 -16
- data/app/views/trail_guide/admin/experiments/show.html.erb +78 -0
- data/app/views/trail_guide/admin/groups/index.html.erb +40 -0
- data/app/views/trail_guide/admin/groups/show.html.erb +9 -0
- data/app/views/trail_guide/admin/orphans/_alert.html.erb +44 -0
- data/config/initializers/trailguide.rb +72 -9
- data/config/routes/admin.rb +19 -12
- data/lib/trail_guide/admin/engine.rb +2 -0
- data/lib/trail_guide/admin.rb +2 -0
- data/lib/trail_guide/calculators/bayesian.rb +98 -0
- data/lib/trail_guide/calculators/calculator.rb +58 -0
- data/lib/trail_guide/calculators/score.rb +68 -0
- data/lib/trail_guide/calculators.rb +8 -0
- data/lib/trail_guide/catalog.rb +134 -19
- data/lib/trail_guide/combined_experiment.rb +8 -3
- data/lib/trail_guide/config.rb +6 -1
- data/lib/trail_guide/engine.rb +2 -0
- data/lib/trail_guide/errors.rb +30 -1
- data/lib/trail_guide/experiment.rb +0 -1
- data/lib/trail_guide/experiments/base.rb +189 -53
- data/lib/trail_guide/experiments/config.rb +82 -13
- data/lib/trail_guide/experiments/participant.rb +21 -1
- data/lib/trail_guide/helper.rb +59 -32
- data/lib/trail_guide/metrics/checkpoint.rb +24 -0
- data/lib/trail_guide/metrics/config.rb +45 -0
- data/lib/trail_guide/metrics/funnel.rb +24 -0
- data/lib/trail_guide/metrics/goal.rb +89 -0
- data/lib/trail_guide/metrics.rb +9 -0
- data/lib/trail_guide/participant.rb +54 -21
- data/lib/trail_guide/variant.rb +34 -12
- data/lib/trail_guide/version.rb +2 -2
- data/lib/trailguide.rb +4 -0
- metadata +112 -7
- data/app/views/layouts/trail_guide/admin/_footer.erb +0 -10
- data/app/views/layouts/trail_guide/admin/_header.erb +0 -42
- data/app/views/trail_guide/admin/experiments/_combined_experiment.html.erb +0 -189
- data/config/routes.rb +0 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 251168ab3ed863718c22e548576f02345799e64af8be5bc956b726cb568e7435
|
4
|
+
data.tar.gz: ff02dc4669e1979cedecf2d465552ffab671ac3c3fb288cc6fa38adc8afd6bb0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4ba3e7a1c0555990b50db9b580ba65430de92ca317228ebba2580b467288659d1e503db21b2a9d3f5da2efb4de0c3b6f6039bc6e7472b5b655421793c05d669b
|
7
|
+
data.tar.gz: b0be0281fd6d0ca3deab6b7690e171493cdca59c7128d26e278ff5e5bba642a7a3d78a6aeffb3b7f6a5af55a08f988e6661d3afd2ca99c0267300bd23d39d3b8
|
data/README.md
CHANGED
@@ -188,6 +188,12 @@ You can put these classes anywhere rails will autoload them (or require them you
|
|
188
188
|
# app/experiments/my_complex_experiment.rb
|
189
189
|
|
190
190
|
class MyComplexExperiment < TrailGuide::Experiment
|
191
|
+
# if you want to actually use this class as an experiment (like we do in this
|
192
|
+
# example), you must call `register!` in order to register it in the catalog.
|
193
|
+
#
|
194
|
+
# if you want to use your class as a base class, so other experiments can
|
195
|
+
# inherit from it, you should _not_ call `register!`
|
196
|
+
register!
|
191
197
|
|
192
198
|
# all standard experiment config goes in the `configure` block
|
193
199
|
configure do |config|
|
@@ -764,24 +770,24 @@ When you define one or more named goals for an experiment, you must pass one of
|
|
764
770
|
trailguide.convert(:button_color, :signed_up)
|
765
771
|
```
|
766
772
|
|
767
|
-
##
|
773
|
+
## Groups
|
768
774
|
|
769
|
-
If you have multiple experiments that share a relevant conversion point, you can configure them with a shared
|
775
|
+
If you have multiple experiments that share a relevant conversion point, you can configure them with a shared group. This allows you to reference and convert multiple experiments at once using that shared group, and only experiments in which participants have been enrolled will be converted.
|
770
776
|
|
771
|
-
Shared
|
777
|
+
Shared groups can only be used for conversion, not for enrollment, since experiments don't share assignments.
|
772
778
|
|
773
|
-
For example if you have multiple experiments where performing a search is considered to be a successful conversion, you can configure them all with the same shared
|
779
|
+
For example if you have multiple experiments where performing a search is considered to be a successful conversion, you can configure them all with the same shared group then use that group in your calls to `trailguide.convert`.
|
774
780
|
|
775
781
|
```ruby
|
776
782
|
experiment :first_search_experiment do |config|
|
777
|
-
config.
|
783
|
+
config.group = :perform_search
|
778
784
|
|
779
785
|
variant :a
|
780
786
|
variant :b
|
781
787
|
end
|
782
788
|
|
783
789
|
experiment :second_search_experiment do |config|
|
784
|
-
config.
|
790
|
+
config.groups = [:perform_search, :other_group]
|
785
791
|
|
786
792
|
variant :one
|
787
793
|
variant :two
|
@@ -789,7 +795,9 @@ experiment :second_search_experiment do |config|
|
|
789
795
|
end
|
790
796
|
|
791
797
|
experiment :third_search_experiment do |config|
|
792
|
-
|
798
|
+
group :other_group
|
799
|
+
group :perform_search
|
800
|
+
groups :yet_another_group, :one_more
|
793
801
|
|
794
802
|
variant :red
|
795
803
|
variant :blue
|
@@ -803,7 +811,64 @@ class SearchController < ApplicationController
|
|
803
811
|
end
|
804
812
|
```
|
805
813
|
|
806
|
-
|
814
|
+
### Orphaned Groups
|
815
|
+
|
816
|
+
Sometimes in the real world, you might accidentally remove all the experiments that were sharing a given group, but miss one of the conversion calls that used one of it's groups. Maybe you forgot to search through your code for references to the group, or maybe you just didn't know you were removing the last experiment in that group. Ideally you'd be testing your code thoroughly, and you'd catch the problem before hitting production, but trailguide has a built-in safe guard just in case.
|
817
|
+
|
818
|
+
Instead of raising a `TrailGuide::NoExperimentsError` when no experiments match your arguments like `trailguide.choose` and related methods do, the `trailguide.convert` method will log a warning and return `false` as if no conversion happened.
|
819
|
+
|
820
|
+
After a failed conversion for an orphaned group, the next time you visit the trailguide admin dashboard you'll see an alert with the details of any logged orphaned groups. If you wish to ignore orphaned groups entirely, perhaps so you can leave conversion calls in your application while you regularly rotate experiments into and out of those groups, you can set the `TrailGuide.configuration.ignore_orphaned_groups = true` config option in your initializer.
|
821
|
+
|
822
|
+
### Groups with Goals
|
823
|
+
|
824
|
+
Since grouping is only useful when converting, and experiments with defined goals require a goal to be passed in when converting, **any experiments that are sharing a group must define the same goals in order to be converted together.** Not all goals need to overlap, but you will only be able to convert goals that are shared when referencing a group.
|
825
|
+
|
826
|
+
If you're grouping your experiments, that probably means you have multiple experiments that are all being used in the same area of your app and therefore are likely sharing the same (or similar) conversion goals. You can assign your groups and goals the same names to make converting easier by referencing a single key:
|
827
|
+
|
828
|
+
```ruby
|
829
|
+
experiment :first_search_experiment do |config|
|
830
|
+
variant :alpha
|
831
|
+
variant :bravo
|
832
|
+
|
833
|
+
config.groups = [:click_search, :click_banner, :search_experiments]
|
834
|
+
config.goals = [:click_search, :click_banner, :custom_goal]
|
835
|
+
end
|
836
|
+
|
837
|
+
experiment :second_search_experiment do |config|
|
838
|
+
variant :one
|
839
|
+
variant :two
|
840
|
+
variant :three
|
841
|
+
|
842
|
+
config.groups = [:click_search, :click_banner, :search_experiments]
|
843
|
+
config.goals = [:click_search, :click_banner, :some_other_goal]
|
844
|
+
end
|
845
|
+
|
846
|
+
experiment :third_search_experiment do |config|
|
847
|
+
variant :red
|
848
|
+
variant :blue
|
849
|
+
|
850
|
+
config.groups = [:click_search, :click_banner, :search_experiments]
|
851
|
+
config.goals = [:click_search, :click_banner]
|
852
|
+
end
|
853
|
+
|
854
|
+
# then to convert all three experiments for the click_search group, against the
|
855
|
+
# click_search goal
|
856
|
+
trailguide.convert(:click_search)
|
857
|
+
|
858
|
+
# the above is the equivalent of calling their group name (in this case not
|
859
|
+
# matching the goal name) and the goal name
|
860
|
+
trailguide.convert(:search_experiments, :click_search)
|
861
|
+
|
862
|
+
# or the equivalent of converting each of the three experiments individually
|
863
|
+
# for that goal
|
864
|
+
trailguide.convert(:first_search_experiment, :click_search)
|
865
|
+
trailguide.convert(:second_search_experiment, :click_search)
|
866
|
+
trailguide.convert(:third_search_experiment, :click_search)
|
867
|
+
|
868
|
+
# and you can still convert the individual experiments with goals that are not
|
869
|
+
# shared by their group
|
870
|
+
trailguide.convert(:first_search_experiment, :custom_goal)
|
871
|
+
```
|
807
872
|
|
808
873
|
## Combined Experiments
|
809
874
|
|
@@ -13,5 +13,24 @@
|
|
13
13
|
//= require_tree .
|
14
14
|
|
15
15
|
$(function () {
|
16
|
-
|
16
|
+
// using data-tooltip allows placing custom tooltips on objects already using
|
17
|
+
// data-toggle="modal"
|
18
|
+
$('[data-toggle="tooltip"], [data-tooltip="tooltip"]').tooltip()
|
19
|
+
|
20
|
+
// date/time pickers for scheduling experiments
|
21
|
+
$('.datepicker').datetimepicker({
|
22
|
+
format: 'MM/DD/YYYY hh:mm A Z',
|
23
|
+
icons: { time: 'fas fa-clock' },
|
24
|
+
keepOpen: true,
|
25
|
+
allowInputToggle: true,
|
26
|
+
})
|
27
|
+
|
28
|
+
// bootstrap custom file picker
|
29
|
+
bsCustomFileInput.init()
|
30
|
+
|
31
|
+
$('button[href]').on('click', function(evt) {
|
32
|
+
window.location.href = $(evt.currentTarget).attr('href')
|
33
|
+
})
|
34
|
+
|
35
|
+
$('.toast').toast('show')
|
17
36
|
})
|
@@ -19,10 +19,32 @@
|
|
19
19
|
height: 30px;
|
20
20
|
}
|
21
21
|
|
22
|
+
body {
|
23
|
+
padding-top: 60px;
|
24
|
+
padding-bottom: 40px;
|
25
|
+
}
|
26
|
+
|
22
27
|
main {
|
23
28
|
padding-top: 40px;
|
24
29
|
}
|
25
30
|
|
31
|
+
.toasts {
|
32
|
+
position: fixed;
|
33
|
+
top: 65px;
|
34
|
+
right: 10px;
|
35
|
+
z-index: 900;
|
36
|
+
max-width: 250px;
|
37
|
+
}
|
38
|
+
|
39
|
+
.alert p:last-child {
|
40
|
+
padding-bottom: 0;
|
41
|
+
margin-bottom: 0;
|
42
|
+
}
|
43
|
+
|
26
44
|
.footer {
|
45
|
+
position: fixed;
|
46
|
+
bottom: 0;
|
47
|
+
left: 0;
|
48
|
+
width: 100%;
|
27
49
|
padding: 10px 15px;
|
28
50
|
}
|
@@ -1,12 +1,45 @@
|
|
1
|
-
.experiment {
|
1
|
+
.experiments button.experiment {
|
2
|
+
display: block;
|
3
|
+
width: 100%;
|
4
|
+
padding: 20px;
|
5
|
+
margin-bottom: 20px;
|
6
|
+
border: none;
|
7
|
+
}
|
8
|
+
.experiments button.experiment:not(:hover):not(:active) {
|
9
|
+
background-color: #f8f9fa;
|
10
|
+
}
|
11
|
+
|
12
|
+
.experiments button.experiment .experiment-text {
|
13
|
+
color: #343a40;
|
14
|
+
}
|
15
|
+
.experiments button.experiment:hover .experiment-text,
|
16
|
+
.experiments button.experiment:active .experiment-text {
|
17
|
+
color: inherit !important;
|
18
|
+
}
|
19
|
+
|
20
|
+
div.experiment {
|
2
21
|
margin-bottom: 40px;
|
3
22
|
}
|
4
23
|
|
5
|
-
.experiment
|
24
|
+
.experiments button.experiment .experiment-text h4,
|
25
|
+
.experiments button.experiment .experiment-text p {
|
26
|
+
white-space: nowrap;
|
27
|
+
overflow: hidden;
|
28
|
+
text-overflow: ellipsis;
|
29
|
+
}
|
30
|
+
|
31
|
+
div.experiment table th, .experiment table td {
|
6
32
|
line-height: 31px;
|
33
|
+
vertical-align: middle;
|
34
|
+
text-align: center;
|
35
|
+
}
|
36
|
+
|
37
|
+
div.experiment table th.btn-col, .experiment table td.btn-col {
|
38
|
+
width: 36px;
|
39
|
+
text-align: center;
|
7
40
|
}
|
8
41
|
|
9
|
-
.experiment table th h5 {
|
42
|
+
div.experiment table th h5 {
|
10
43
|
margin: 0;
|
11
44
|
padding: 0;
|
12
45
|
line-height: 31px;
|
@@ -18,35 +18,86 @@ module TrailGuide
|
|
18
18
|
|
19
19
|
def experiment_peekable?(experiment)
|
20
20
|
return false unless TrailGuide::Admin.configuration.peek_parameter
|
21
|
-
return false unless experiment.started? && !experiment.stopped?
|
21
|
+
return false unless experiment.started? && !experiment.stopped? && !experiment.winner?
|
22
|
+
return true if experiment.combined? && !experiment.combined_experiments.all?(&:target_sample_size_reached?)
|
22
23
|
return false if experiment.target_sample_size_reached?
|
23
24
|
return true
|
24
25
|
end
|
25
26
|
helper_method :experiment_peekable?
|
26
27
|
|
28
|
+
def peek_param
|
29
|
+
params[TrailGuide::Admin.configuration.peek_parameter]
|
30
|
+
end
|
31
|
+
|
27
32
|
def experiment_peeking?(experiment)
|
28
|
-
|
33
|
+
# TODO deprecate the argument/param??
|
34
|
+
return params.key?(TrailGuide::Admin.configuration.peek_parameter)
|
35
|
+
peek_param == experiment.experiment_name.to_s ||
|
36
|
+
experiment.is_combined? && peek_param == experiment.parent.experiment_name.to_s
|
29
37
|
end
|
30
38
|
helper_method :experiment_peeking?
|
31
39
|
|
32
40
|
def experiment_metrics_visible?(experiment)
|
33
|
-
return true unless experiment.started? && !experiment.stopped?
|
34
|
-
return true if
|
41
|
+
return true unless experiment.started? && !experiment.stopped? && !experiment.winner?
|
42
|
+
return true if experiment_peeking?(experiment)
|
43
|
+
return false if experiment.combined? && !experiment.combined_experiments.all?(&:target_sample_size_reached?)
|
35
44
|
return true if experiment.target_sample_size_reached?
|
36
45
|
return false
|
37
46
|
end
|
38
47
|
helper_method :experiment_metrics_visible?
|
39
48
|
|
40
|
-
def experiment_metric(experiment, metric)
|
41
|
-
|
42
|
-
|
49
|
+
def experiment_metric(experiment, metric=nil, &block)
|
50
|
+
if experiment_metrics_visible?(experiment)
|
51
|
+
yield and return if block_given?
|
52
|
+
return helpers.number_with_delimiter(metric.to_i)
|
53
|
+
end
|
54
|
+
|
55
|
+
return helpers.content_tag('span', nil, class: 'fas fa-eye-slash text-muted', data: {toggle: 'tooltip'}, title: "metrics are hidden until this experiment reaches it's target sample size")
|
43
56
|
end
|
44
57
|
helper_method :experiment_metric
|
45
58
|
|
46
59
|
def peek_url(experiment, *args, **opts)
|
47
|
-
trail_guide_admin.
|
60
|
+
trail_guide_admin.experiment_url(experiment.experiment_name, *args, opts.merge({TrailGuide::Admin.configuration.peek_parameter => experiment.experiment_name}))
|
48
61
|
end
|
49
62
|
helper_method :peek_url
|
63
|
+
|
64
|
+
def experiment_icon(experiment)
|
65
|
+
if experiment.winner?
|
66
|
+
'fa-flag-checkered'
|
67
|
+
elsif experiment.started?
|
68
|
+
if experiment.stopped?
|
69
|
+
'fa-stop'
|
70
|
+
elsif experiment.paused?
|
71
|
+
'fa-pause'
|
72
|
+
else
|
73
|
+
'fa-play'
|
74
|
+
end
|
75
|
+
elsif experiment.scheduled?
|
76
|
+
'fa-clock'
|
77
|
+
else
|
78
|
+
'fa-flask'
|
79
|
+
end
|
80
|
+
end
|
81
|
+
helper_method :experiment_icon
|
82
|
+
|
83
|
+
def experiment_color(experiment)
|
84
|
+
if experiment.winner?
|
85
|
+
'primary'
|
86
|
+
elsif experiment.started?
|
87
|
+
if experiment.stopped?
|
88
|
+
'danger'
|
89
|
+
elsif experiment.paused?
|
90
|
+
'warning'
|
91
|
+
else
|
92
|
+
'success'
|
93
|
+
end
|
94
|
+
elsif experiment.scheduled?
|
95
|
+
'info'
|
96
|
+
else
|
97
|
+
'secondary'
|
98
|
+
end
|
99
|
+
end
|
100
|
+
helper_method :experiment_color
|
50
101
|
end
|
51
102
|
end
|
52
103
|
end
|
@@ -1,69 +1,150 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
1
3
|
module TrailGuide
|
2
4
|
module Admin
|
3
5
|
class ExperimentsController < ::TrailGuide::Admin::ApplicationController
|
4
|
-
before_action except: [:index] do
|
6
|
+
before_action except: [:index, :import] do
|
5
7
|
(redirect_to :back rescue redirect_to trail_guide_admin.experiments_path) and return unless experiment.present?
|
6
8
|
end
|
7
9
|
|
8
10
|
before_action :experiments, only: [:index]
|
11
|
+
before_action :experiment, except: [:index, :import]
|
9
12
|
|
10
13
|
def index
|
14
|
+
respond_to do |format|
|
15
|
+
format.html { render }
|
16
|
+
format.json {
|
17
|
+
send_data JSON.pretty_generate(TrailGuide.catalog.export),
|
18
|
+
filename: "trailguide-#{Rails.env}-#{Time.now.to_i}.json"
|
19
|
+
}
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def import
|
24
|
+
import_file = params[:file]
|
25
|
+
|
26
|
+
if import_file
|
27
|
+
if import_file.respond_to?(:read)
|
28
|
+
state_json = JSON.load(import_file.read)
|
29
|
+
elsif import_file.respond_to?(:path)
|
30
|
+
state_json= JSON.load(File.read(import_file.path))
|
31
|
+
end
|
32
|
+
TrailGuide.catalog.import(state_json)
|
33
|
+
flash[:success] = "Experiment state imported successfully"
|
34
|
+
redirect_to trail_guide_admin.experiments_path
|
35
|
+
else
|
36
|
+
raise "Please provide an import file"
|
37
|
+
end
|
38
|
+
rescue => e
|
39
|
+
flash[:error] = "There was a problem importing this file: #{e.message}"
|
40
|
+
redirect_to trail_guide_admin.experiments_path
|
41
|
+
end
|
42
|
+
|
43
|
+
def show
|
44
|
+
@analyzing = params.key?(:analyze)
|
45
|
+
@analyze_goal = params[:goal].present? ?
|
46
|
+
params[:goal].underscore.to_sym :
|
47
|
+
experiment.goals.first.try(:name)
|
48
|
+
@analyze_method = params[:method].present? ?
|
49
|
+
params[:method].underscore.to_sym :
|
50
|
+
:score
|
11
51
|
end
|
12
52
|
|
13
53
|
def start
|
54
|
+
experiment.reset!(self) if experiment.enable_calibration?
|
14
55
|
experiment.start!(self)
|
15
|
-
|
56
|
+
|
57
|
+
flash[:success] = "Experiment started"
|
58
|
+
redirect_to_experiment experiment
|
59
|
+
end
|
60
|
+
|
61
|
+
def schedule
|
62
|
+
experiment.schedule!(schedule_params[:start_at], schedule_params[:stop_at], self)
|
63
|
+
|
64
|
+
flash[:success] = "Experiment scheduled for <strong>#{experiment.started_at.strftime(TrailGuide::Admin::DISPLAY_DATE_FORMAT)}</strong>"
|
65
|
+
redirect_to_experiment experiment
|
66
|
+
rescue => e
|
67
|
+
flash[:danger] = e.message
|
68
|
+
redirect_to_experiment experiment
|
16
69
|
end
|
17
70
|
|
18
71
|
def pause
|
19
72
|
experiment.pause!(self)
|
20
|
-
|
73
|
+
|
74
|
+
flash[:success] = "Experiment paused"
|
75
|
+
redirect_to_experiment experiment
|
21
76
|
end
|
22
77
|
|
23
78
|
def stop
|
24
79
|
experiment.stop!(self)
|
25
|
-
|
80
|
+
|
81
|
+
flash[:success] = "Experiment stopped"
|
82
|
+
redirect_to_experiment experiment
|
26
83
|
end
|
27
84
|
|
28
85
|
def reset
|
29
86
|
experiment.stop!(self)
|
30
87
|
experiment.reset!(self)
|
31
|
-
|
88
|
+
|
89
|
+
flash[:success] = "Experiment reset"
|
90
|
+
redirect_to_experiment experiment
|
32
91
|
end
|
33
92
|
|
34
93
|
def resume
|
35
94
|
experiment.resume!(self)
|
36
|
-
|
95
|
+
|
96
|
+
flash[:success] = "Experiment resumed"
|
97
|
+
redirect_to_experiment experiment
|
37
98
|
end
|
38
99
|
|
39
100
|
def restart
|
40
101
|
experiment.stop!(self)
|
41
102
|
experiment.reset!(self)
|
42
103
|
experiment.start!(self)
|
43
|
-
|
104
|
+
|
105
|
+
flash[:success] = "Experiment restarted"
|
106
|
+
redirect_to_experiment experiment
|
107
|
+
end
|
108
|
+
|
109
|
+
def enroll
|
110
|
+
variant = enroll_experiment(experiment)
|
111
|
+
flash[:info] = "You were enrolled in the <strong>#{variant.to_s.humanize.titleize}</strong> variant"
|
112
|
+
redirect_to_experiment experiment
|
113
|
+
end
|
114
|
+
|
115
|
+
def convert
|
116
|
+
params[:goal] = nil if params[:goal] == 'converted'
|
117
|
+
variant = convert_experiment(experiment, params[:goal])
|
118
|
+
if variant
|
119
|
+
flash[:info] = "You successfully converted the goal <strong>#{params[:goal].to_s.humanize.titleize}</strong> in the <strong>#{variant.to_s.humanize.titleize}</strong> variant"
|
120
|
+
else
|
121
|
+
flash[:info] = "You did not convert the goal <strong>#{params[:goal].to_s.humanize.titleize}</strong>"
|
122
|
+
end
|
123
|
+
redirect_to_experiment experiment
|
44
124
|
end
|
45
125
|
|
46
126
|
def join
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
participant.participating!(variant)
|
51
|
-
redirect_to trail_guide_admin.experiments_path(anchor: experiment.experiment_name)
|
127
|
+
join_experiment(experiment)
|
128
|
+
flash[:success] = "You joined the <strong>#{params[:variant].to_s.humanize.titleize}</strong> cohort"
|
129
|
+
redirect_to_experiment experiment
|
52
130
|
end
|
53
131
|
|
54
132
|
def leave
|
55
|
-
|
56
|
-
|
133
|
+
leave_experiment(experiment)
|
134
|
+
flash[:success] = "You left the experiment"
|
135
|
+
redirect_to_experiment experiment
|
57
136
|
end
|
58
137
|
|
59
138
|
def winner
|
60
139
|
experiment.declare_winner!(params[:variant], self)
|
61
|
-
|
140
|
+
flash[:success] = "Declared <strong>#{params[:variant].to_s.humanize.titleize}</strong> as the winner"
|
141
|
+
redirect_to_experiment experiment
|
62
142
|
end
|
63
143
|
|
64
144
|
def clear
|
65
145
|
experiment.clear_winner!
|
66
|
-
|
146
|
+
flash[:success] = "Removed the winner"
|
147
|
+
redirect_to_experiment experiment
|
67
148
|
end
|
68
149
|
|
69
150
|
private
|
@@ -78,10 +159,122 @@ module TrailGuide
|
|
78
159
|
@experiment ||= TrailGuide.catalog.find(params[:id])
|
79
160
|
end
|
80
161
|
|
162
|
+
def schedule_params
|
163
|
+
@schedule_params ||= begin
|
164
|
+
exp_params = params.require(:experiment).permit(:start_at, :stop_at)
|
165
|
+
start_at = exp_params[:start_at]
|
166
|
+
stop_at = exp_params[:stop_at]
|
167
|
+
|
168
|
+
exp_params[:start_at] = DateTime.strptime(exp_params[:start_at], TrailGuide::SCHEDULE_DATE_FORMAT) rescue raise(ArgumentError, "Invalid start date")
|
169
|
+
raise ArgumentError, "Invalid start date" unless exp_params[:start_at].strftime(TrailGuide::SCHEDULE_DATE_FORMAT) == start_at
|
170
|
+
|
171
|
+
exp_params[:stop_at] = (DateTime.strptime(exp_params[:stop_at], TrailGuide::SCHEDULE_DATE_FORMAT) rescue nil)
|
172
|
+
if exp_params[:stop_at]
|
173
|
+
raise ArgumentError, "Invalid stop date" unless exp_params[:stop_at].strftime(TrailGuide::SCHEDULE_DATE_FORMAT) == stop_at
|
174
|
+
|
175
|
+
raise ArgumentError, "Experiments cannot be scheduled to stop before they start" if exp_params[:stop_at] <= exp_params[:start_at]
|
176
|
+
end
|
177
|
+
|
178
|
+
exp_params
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
def enroll_experiment(experiment)
|
183
|
+
if experiment.is_combined?
|
184
|
+
experiment.new(participant).choose!
|
185
|
+
else
|
186
|
+
leave_experiment(experiment)
|
187
|
+
variant = experiment.new(participant).choose!
|
188
|
+
if experiment.combined?
|
189
|
+
experiment.combined_experiments.each do |expmt|
|
190
|
+
expmt.new(participant).choose!
|
191
|
+
end
|
192
|
+
end
|
193
|
+
variant
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
def convert_experiment(experiment, checkpoint=nil)
|
198
|
+
return false if experiment.combined?
|
199
|
+
experiment.new(participant).convert!(checkpoint)
|
200
|
+
end
|
201
|
+
|
202
|
+
def join_experiment(experiment)
|
203
|
+
# TODO handle explicitly joining combined experiments
|
204
|
+
leave_experiment(experiment)
|
205
|
+
if experiment.is_combined?
|
206
|
+
variant = experiment.parent.variants.find { |var| var == params[:variant] }
|
207
|
+
variant.increment_participation!
|
208
|
+
participant.participating!(variant)
|
209
|
+
experiment.parent.combined_experiments.each do |expmt|
|
210
|
+
variant = expmt.variants.find { |var| var == params[:variant] }
|
211
|
+
variant.increment_participation!
|
212
|
+
participant.participating!(variant)
|
213
|
+
end
|
214
|
+
else
|
215
|
+
variant = experiment.variants.find { |var| var == params[:variant] }
|
216
|
+
variant.increment_participation!
|
217
|
+
participant.participating!(variant)
|
218
|
+
if experiment.combined?
|
219
|
+
experiment.combined_experiments.each do |expmt|
|
220
|
+
variant = expmt.variants.find { |var| var == params[:variant] }
|
221
|
+
variant.increment_participation!
|
222
|
+
participant.participating!(variant)
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
def leave_experiment(experiment)
|
229
|
+
participant.exit!(experiment)
|
230
|
+
if experiment.combined?
|
231
|
+
experiment.combined_experiments.each do |expmt|
|
232
|
+
participant.exit!(expmt)
|
233
|
+
end
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
81
237
|
def participant
|
82
238
|
@participant ||= TrailGuide::Participant.new(self)
|
83
239
|
end
|
84
240
|
helper_method :participant
|
241
|
+
|
242
|
+
def experiment_calculator(experiment, **opts)
|
243
|
+
klass = "TrailGuide::Calculators::#{@analyze_method.to_s.classify}".constantize
|
244
|
+
calculator = klass.new(experiment, **{base: :control, goal: @analyze_goal}.merge(opts))
|
245
|
+
calculator.calculate! if @analyzing
|
246
|
+
calculator
|
247
|
+
end
|
248
|
+
helper_method :experiment_calculator
|
249
|
+
|
250
|
+
def variant_analysis_color(variant, calculator)
|
251
|
+
if !@analyzing || !experiment_metrics_visible?(calculator.experiment)
|
252
|
+
'dark'
|
253
|
+
elsif variant.measure > 0
|
254
|
+
if variant == calculator.base
|
255
|
+
'dark'
|
256
|
+
elsif variant.measure == calculator.best.measure
|
257
|
+
'success'
|
258
|
+
elsif variant.measure == calculator.worst.measure
|
259
|
+
'danger'
|
260
|
+
elsif variant.measure > calculator.base.measure
|
261
|
+
'info'
|
262
|
+
elsif variant.measure < calculator.base.measure
|
263
|
+
'warning'
|
264
|
+
end
|
265
|
+
else
|
266
|
+
'muted'
|
267
|
+
end
|
268
|
+
end
|
269
|
+
helper_method :variant_analysis_color
|
270
|
+
|
271
|
+
def redirect_to_experiment(experiment)
|
272
|
+
if experiment <= TrailGuide::CombinedExperiment
|
273
|
+
redirect_back fallback_location: trail_guide_admin.experiment_path(experiment.parent.experiment_name)
|
274
|
+
else
|
275
|
+
redirect_to trail_guide_admin.experiment_path(experiment.experiment_name)
|
276
|
+
end
|
277
|
+
end
|
85
278
|
end
|
86
279
|
end
|
87
280
|
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module TrailGuide
|
2
|
+
module Admin
|
3
|
+
class GroupsController < ::TrailGuide::Admin::ApplicationController
|
4
|
+
before_action except: [:index] do
|
5
|
+
(redirect_to :back rescue redirect_to trail_guide_admin.experiments_path) and return unless group.present?
|
6
|
+
end
|
7
|
+
|
8
|
+
before_action :groups, only: [:index]
|
9
|
+
before_action :group, only: [:show]
|
10
|
+
before_action :experiments, only: [:show]
|
11
|
+
|
12
|
+
def index
|
13
|
+
end
|
14
|
+
|
15
|
+
def show
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def groups
|
21
|
+
@groups = TrailGuide.catalog.groups
|
22
|
+
end
|
23
|
+
|
24
|
+
def group
|
25
|
+
@group ||= TrailGuide.catalog.groups.find { |group| group == params[:id].to_s.underscore.to_sym }
|
26
|
+
end
|
27
|
+
|
28
|
+
def experiments
|
29
|
+
@experiments = TrailGuide.catalog.select(params[:id])
|
30
|
+
@experiments = @experiments.by_started
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -22,7 +22,7 @@ module TrailGuide
|
|
22
22
|
|
23
23
|
def convert
|
24
24
|
# we use the param here because convert can trigger multiple experiements
|
25
|
-
# based on the passed key via shared
|
25
|
+
# based on the passed key via shared groups
|
26
26
|
trailguide.convert!(params[:experiment_name], checkpoint, metadata: metadata)
|
27
27
|
render json: {
|
28
28
|
experiment: experiment.experiment_name,
|