trailguide 0.1.31 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +73 -8
  3. data/app/assets/javascripts/trail_guide/admin/application.js +20 -1
  4. data/app/assets/stylesheets/trail_guide/admin/application.css +22 -0
  5. data/app/assets/stylesheets/trail_guide/admin/experiments.css +36 -3
  6. data/app/controllers/trail_guide/admin/application_controller.rb +59 -8
  7. data/app/controllers/trail_guide/admin/experiments_controller.rb +209 -16
  8. data/app/controllers/trail_guide/admin/groups_controller.rb +34 -0
  9. data/app/controllers/trail_guide/experiments_controller.rb +1 -1
  10. data/app/views/layouts/trail_guide/admin/_calculator.erb +24 -0
  11. data/app/views/layouts/trail_guide/admin/_footer.html.erb +27 -0
  12. data/app/views/layouts/trail_guide/admin/_header.html.erb +147 -0
  13. data/app/views/layouts/trail_guide/admin/_import_modal.html.erb +45 -0
  14. data/app/views/layouts/trail_guide/admin/application.html.erb +17 -3
  15. data/app/views/trail_guide/admin/experiments/_alert_peek.html.erb +19 -0
  16. data/app/views/trail_guide/admin/experiments/_alert_state.html.erb +49 -0
  17. data/app/views/trail_guide/admin/experiments/_btn_analyze.html.erb +11 -0
  18. data/app/views/trail_guide/admin/experiments/_btn_analyze_goal.html.erb +5 -0
  19. data/app/views/trail_guide/admin/experiments/_btn_convert.html.erb +33 -0
  20. data/app/views/trail_guide/admin/experiments/_btn_enroll.html.erb +3 -0
  21. data/app/views/trail_guide/admin/experiments/_btn_join.html.erb +11 -0
  22. data/app/views/trail_guide/admin/experiments/_btn_leave.html.erb +7 -0
  23. data/app/views/trail_guide/admin/experiments/_btn_pause.html.erb +5 -0
  24. data/app/views/trail_guide/admin/experiments/_btn_peek.html.erb +13 -0
  25. data/app/views/trail_guide/admin/experiments/_btn_reset.html.erb +5 -0
  26. data/app/views/trail_guide/admin/experiments/_btn_restart.html.erb +5 -0
  27. data/app/views/trail_guide/admin/experiments/_btn_resume.html.erb +5 -0
  28. data/app/views/trail_guide/admin/experiments/_btn_schedule.html.erb +6 -0
  29. data/app/views/trail_guide/admin/experiments/_btn_start.html.erb +5 -0
  30. data/app/views/trail_guide/admin/experiments/_btn_stop.html.erb +5 -0
  31. data/app/views/trail_guide/admin/experiments/_experiment.html.erb +68 -172
  32. data/app/views/trail_guide/admin/experiments/_header.html.erb +87 -0
  33. data/app/views/trail_guide/admin/experiments/_start_modal.html.erb +57 -0
  34. data/app/views/trail_guide/admin/experiments/_tbody.html.erb +112 -0
  35. data/app/views/trail_guide/admin/experiments/_thead.html.erb +38 -0
  36. data/app/views/trail_guide/admin/experiments/index.html.erb +8 -16
  37. data/app/views/trail_guide/admin/experiments/show.html.erb +78 -0
  38. data/app/views/trail_guide/admin/groups/index.html.erb +40 -0
  39. data/app/views/trail_guide/admin/groups/show.html.erb +9 -0
  40. data/app/views/trail_guide/admin/orphans/_alert.html.erb +44 -0
  41. data/config/initializers/trailguide.rb +72 -9
  42. data/config/routes/admin.rb +19 -12
  43. data/lib/trail_guide/admin/engine.rb +2 -0
  44. data/lib/trail_guide/admin.rb +2 -0
  45. data/lib/trail_guide/calculators/bayesian.rb +98 -0
  46. data/lib/trail_guide/calculators/calculator.rb +58 -0
  47. data/lib/trail_guide/calculators/score.rb +68 -0
  48. data/lib/trail_guide/calculators.rb +8 -0
  49. data/lib/trail_guide/catalog.rb +134 -19
  50. data/lib/trail_guide/combined_experiment.rb +8 -3
  51. data/lib/trail_guide/config.rb +6 -1
  52. data/lib/trail_guide/engine.rb +2 -0
  53. data/lib/trail_guide/errors.rb +30 -1
  54. data/lib/trail_guide/experiment.rb +0 -1
  55. data/lib/trail_guide/experiments/base.rb +189 -53
  56. data/lib/trail_guide/experiments/config.rb +82 -13
  57. data/lib/trail_guide/experiments/participant.rb +21 -1
  58. data/lib/trail_guide/helper.rb +59 -32
  59. data/lib/trail_guide/metrics/checkpoint.rb +24 -0
  60. data/lib/trail_guide/metrics/config.rb +45 -0
  61. data/lib/trail_guide/metrics/funnel.rb +24 -0
  62. data/lib/trail_guide/metrics/goal.rb +89 -0
  63. data/lib/trail_guide/metrics.rb +9 -0
  64. data/lib/trail_guide/participant.rb +54 -21
  65. data/lib/trail_guide/variant.rb +34 -12
  66. data/lib/trail_guide/version.rb +2 -2
  67. data/lib/trailguide.rb +4 -0
  68. metadata +112 -7
  69. data/app/views/layouts/trail_guide/admin/_footer.erb +0 -10
  70. data/app/views/layouts/trail_guide/admin/_header.erb +0 -42
  71. data/app/views/trail_guide/admin/experiments/_combined_experiment.html.erb +0 -189
  72. data/config/routes.rb +0 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e756ddcc8e8a84fc537bbc83d97ee29ffac4fe3252c7d0a292b387cade57b611
4
- data.tar.gz: 18a31337e3a0a93ab5f72b547f6c2e74d2994bc882ca8bc7b03a8e52a78a9790
3
+ metadata.gz: 251168ab3ed863718c22e548576f02345799e64af8be5bc956b726cb568e7435
4
+ data.tar.gz: ff02dc4669e1979cedecf2d465552ffab671ac3c3fb288cc6fa38adc8afd6bb0
5
5
  SHA512:
6
- metadata.gz: 617d86a39bac52c3ab6faa4ca1052f4d968b2209bb2e9ba60a524cc5ad5484293986cb756bec6b98838e8a8e33c50581a3e599dd2a344e0b79194c45dfb327b7
7
- data.tar.gz: 924d10df7b49b1ad9d666ceadcb85e20ce831cc23ddbfb480d26e3066ff0d7da13e3911f136cdbeec4bea6c95b41661b15a3a38175692e513a5133d06ffbee86
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
- ## Metrics
773
+ ## Groups
768
774
 
769
- If you have multiple experiments that share a relevant conversion point, you can configure them with a shared metric. This allows you to reference and convert multiple experiments at once using that shared metric, and only experiments in which participants have been enrolled will be converted.
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 metrics can only be used for conversion, not for enrollment, since experiments don't share assignments.
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 metric then use that metric in your calls to `trailguide.convert`.
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.metric = :perform_search
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.metric = :perform_search
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
- config.metric = :perform_search
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
- Since experiments with defined goals require a goal to be passed in when converting, any experiments that are sharing a metric must define the same goals.
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
- $('[data-toggle="tooltip"]').tooltip()
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 table th, .experiment table td {
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
- params[TrailGuide::Admin.configuration.peek_parameter] == experiment.experiment_name.to_s
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 params[TrailGuide::Admin.configuration.peek_parameter] == experiment.experiment_name.to_s
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
- return helpers.number_with_delimiter(metric) if experiment_metrics_visible?(experiment)
42
- return helpers.content_tag('span', nil, class: 'fas fa-eye-slash', data: {toggle: 'tooltip'}, title: "metrics are hidden until this experiment reaches it's target sample size")
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.experiments_url(*args, opts.merge({TrailGuide::Admin.configuration.peek_parameter => experiment.experiment_name, anchor: experiment.experiment_name}))
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
- redirect_to trail_guide_admin.scoped_experiments_path(scope: :running, anchor: experiment.experiment_name)
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
- redirect_to trail_guide_admin.scoped_experiments_path(scope: :paused, anchor: experiment.experiment_name)
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
- redirect_to trail_guide_admin.scoped_experiments_path(scope: :stopped, anchor: experiment.experiment_name)
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
- redirect_to trail_guide_admin.experiments_path(anchor: experiment.experiment_name)
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
- redirect_to trail_guide_admin.scoped_experiments_path(scope: :running, anchor: experiment.experiment_name)
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
- redirect_to trail_guide_admin.scoped_experiments_path(scope: :running, anchor: experiment.experiment_name)
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
- participant.exit!(experiment)
48
- variant = experiment.variants.find { |var| var == params[:variant] }
49
- variant.increment_participation!
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
- participant.exit!(experiment)
56
- redirect_to trail_guide_admin.experiments_path(anchor: experiment.experiment_name)
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
- redirect_to trail_guide_admin.experiments_path(anchor: experiment.experiment_name)
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
- redirect_to trail_guide_admin.experiments_path(anchor: experiment.experiment_name)
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 metrics
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,