rails_onboarding 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +1351 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/rails_onboarding/admin/chart_controller.js +25 -0
- data/app/assets/javascripts/rails_onboarding/admin/filter_controller.js +49 -0
- data/app/assets/javascripts/rails_onboarding/admin/flash_controller.js +21 -0
- data/app/assets/javascripts/rails_onboarding/admin/flow_editor_controller.js +86 -0
- data/app/assets/javascripts/rails_onboarding/application.js +231 -0
- data/app/assets/javascripts/rails_onboarding/milestone_celebration_controller.js +177 -0
- data/app/assets/javascripts/rails_onboarding/milestone_dashboard_controller.js +132 -0
- data/app/assets/javascripts/rails_onboarding/milestone_detail_controller.js +34 -0
- data/app/assets/javascripts/rails_onboarding/navigation_controller.js +277 -0
- data/app/assets/javascripts/rails_onboarding/onboarding_controller.js +326 -0
- data/app/assets/javascripts/rails_onboarding/progress_controller.js +203 -0
- data/app/assets/javascripts/rails_onboarding/progressive_disclosure_controller.js +218 -0
- data/app/assets/javascripts/rails_onboarding/tooltip_controller.js +1237 -0
- data/app/assets/javascripts/rails_onboarding/tooltip_scheduler_controller.js +511 -0
- data/app/assets/javascripts/rails_onboarding/tour_controller.js +763 -0
- data/app/assets/stylesheets/rails_onboarding/accessibility.css +536 -0
- data/app/assets/stylesheets/rails_onboarding/admin.css +816 -0
- data/app/assets/stylesheets/rails_onboarding/application.css +1145 -0
- data/app/assets/stylesheets/rails_onboarding/flash_messages.css +237 -0
- data/app/assets/stylesheets/rails_onboarding/milestones.css +545 -0
- data/app/assets/stylesheets/rails_onboarding/mobile.css +856 -0
- data/app/assets/stylesheets/rails_onboarding/progressive_disclosure.css +299 -0
- data/app/assets/stylesheets/rails_onboarding/tooltips.css +468 -0
- data/app/assets/stylesheets/rails_onboarding/tour.css +495 -0
- data/app/assets/stylesheets/rails_onboarding/utilities.css +393 -0
- data/app/controllers/concerns/rails_onboarding/admin_authorization.rb +98 -0
- data/app/controllers/concerns/rails_onboarding/rate_limitable.rb +104 -0
- data/app/controllers/rails_onboarding/ab_tests_controller.rb +257 -0
- data/app/controllers/rails_onboarding/admin/ab_tests_controller.rb +121 -0
- data/app/controllers/rails_onboarding/admin/base_controller.rb +68 -0
- data/app/controllers/rails_onboarding/admin/dashboard_controller.rb +189 -0
- data/app/controllers/rails_onboarding/admin/flows_controller.rb +175 -0
- data/app/controllers/rails_onboarding/admin/users_controller.rb +366 -0
- data/app/controllers/rails_onboarding/application_controller.rb +9 -0
- data/app/controllers/rails_onboarding/milestones_controller.rb +126 -0
- data/app/controllers/rails_onboarding/onboarding_controller.rb +409 -0
- data/app/controllers/rails_onboarding/progressive_features_controller.rb +134 -0
- data/app/controllers/rails_onboarding/templates_controller.rb +162 -0
- data/app/controllers/rails_onboarding/test_sessions_controller.rb +16 -0
- data/app/controllers/rails_onboarding/tooltips_controller.rb +92 -0
- data/app/helpers/rails_onboarding/application_helper.rb +4 -0
- data/app/helpers/rails_onboarding/i18n_helper.rb +137 -0
- data/app/helpers/rails_onboarding/personalization_helper.rb +184 -0
- data/app/helpers/rails_onboarding/templates_helper.rb +253 -0
- data/app/jobs/rails_onboarding/application_job.rb +4 -0
- data/app/mailers/rails_onboarding/application_mailer.rb +6 -0
- data/app/mailers/rails_onboarding/onboarding_mailer.rb +72 -0
- data/app/models/concerns/rails_onboarding/ab_testable.rb +149 -0
- data/app/models/concerns/rails_onboarding/onboardable.rb +649 -0
- data/app/models/concerns/rails_onboarding/personalizable.rb +211 -0
- data/app/models/concerns/rails_onboarding/progressive_disclosure.rb +276 -0
- data/app/models/rails_onboarding/analytics.rb +255 -0
- data/app/models/rails_onboarding/analytics_event.rb +168 -0
- data/app/models/rails_onboarding/application_record.rb +5 -0
- data/app/models/rails_onboarding/flow.rb +41 -0
- data/app/services/rails_onboarding/milestone_service.rb +105 -0
- data/app/views/layouts/rails_onboarding/admin.html.erb +106 -0
- data/app/views/layouts/rails_onboarding/application.html.erb +65 -0
- data/app/views/rails_onboarding/ab_tests/index.html.erb +209 -0
- data/app/views/rails_onboarding/ab_tests/results.html.erb +253 -0
- data/app/views/rails_onboarding/admin/ab_tests/_ab_test_card.html.erb +24 -0
- data/app/views/rails_onboarding/admin/ab_tests/index.html.erb +57 -0
- data/app/views/rails_onboarding/admin/ab_tests/show.html.erb +41 -0
- data/app/views/rails_onboarding/admin/dashboard/index.html.erb +224 -0
- data/app/views/rails_onboarding/admin/flows/_form.html.erb +147 -0
- data/app/views/rails_onboarding/admin/flows/edit.html.erb +7 -0
- data/app/views/rails_onboarding/admin/flows/index.html.erb +125 -0
- data/app/views/rails_onboarding/admin/flows/new.html.erb +7 -0
- data/app/views/rails_onboarding/admin/flows/preview.html.erb +40 -0
- data/app/views/rails_onboarding/admin/flows/show.html.erb +114 -0
- data/app/views/rails_onboarding/admin/users/index.html.erb +180 -0
- data/app/views/rails_onboarding/admin/users/show.html.erb +187 -0
- data/app/views/rails_onboarding/milestones/index.html.erb +64 -0
- data/app/views/rails_onboarding/milestones/show.html.erb +57 -0
- data/app/views/rails_onboarding/onboarding/_custom_footer.html.erb +2 -0
- data/app/views/rails_onboarding/onboarding/_progress_indicator.html.erb +56 -0
- data/app/views/rails_onboarding/onboarding/_step_navigation.html.erb +44 -0
- data/app/views/rails_onboarding/onboarding/step.html.erb +38 -0
- data/app/views/rails_onboarding/onboarding/welcome.html.erb +37 -0
- data/app/views/rails_onboarding/onboarding_mailer/completion_email.html.erb +109 -0
- data/app/views/rails_onboarding/onboarding_mailer/completion_email.text.erb +29 -0
- data/app/views/rails_onboarding/onboarding_mailer/reminder_email.html.erb +99 -0
- data/app/views/rails_onboarding/onboarding_mailer/reminder_email.text.erb +20 -0
- data/app/views/rails_onboarding/onboarding_mailer/step_completed_email.html.erb +105 -0
- data/app/views/rails_onboarding/onboarding_mailer/step_completed_email.text.erb +19 -0
- data/app/views/rails_onboarding/onboarding_mailer/welcome_email.html.erb +74 -0
- data/app/views/rails_onboarding/onboarding_mailer/welcome_email.text.erb +17 -0
- data/app/views/rails_onboarding/shared/_flash.html.erb +25 -0
- data/app/views/rails_onboarding/shared/_milestone_badge.html.erb +22 -0
- data/app/views/rails_onboarding/shared/_milestone_celebration.html.erb +44 -0
- data/app/views/rails_onboarding/shared/_onboarding_banner.html.erb +48 -0
- data/app/views/rails_onboarding/templates/index.html.erb +255 -0
- data/config/importmap.rb +33 -0
- data/config/locales/en.yml +62 -0
- data/config/locales/es.yml +62 -0
- data/config/locales/fr.yml +62 -0
- data/config/routes.rb +79 -0
- data/lib/generators/rails_onboarding/install_generator.rb +124 -0
- data/lib/generators/rails_onboarding/templates/README +120 -0
- data/lib/generators/rails_onboarding/templates/add_analytics_to_rails_onboarding.rb +33 -0
- data/lib/generators/rails_onboarding/templates/add_milestone_tracking_to_users.rb +38 -0
- data/lib/generators/rails_onboarding/templates/add_onboarding_indexes.rb +93 -0
- data/lib/generators/rails_onboarding/templates/add_onboarding_to_users.rb +51 -0
- data/lib/generators/rails_onboarding/templates/add_robustness_fields_to_users.rb.tt +20 -0
- data/lib/generators/rails_onboarding/templates/create_rails_onboarding_flows.rb +18 -0
- data/lib/generators/rails_onboarding/templates/onboarding.css +18 -0
- data/lib/generators/rails_onboarding/templates/rails_onboarding.rb +75 -0
- data/lib/generators/rails_onboarding/update_generator.rb +73 -0
- data/lib/rails_onboarding/api_mode.rb +367 -0
- data/lib/rails_onboarding/backfill.rb +130 -0
- data/lib/rails_onboarding/background_jobs.rb +412 -0
- data/lib/rails_onboarding/caching.rb +180 -0
- data/lib/rails_onboarding/cdn_support.rb +202 -0
- data/lib/rails_onboarding/configuration/ab_testing.rb +24 -0
- data/lib/rails_onboarding/configuration/analytics.rb +29 -0
- data/lib/rails_onboarding/configuration/integrations.rb +31 -0
- data/lib/rails_onboarding/configuration/milestones.rb +92 -0
- data/lib/rails_onboarding/configuration/personalization.rb +25 -0
- data/lib/rails_onboarding/configuration/progressive_disclosure.rb +41 -0
- data/lib/rails_onboarding/configuration/rate_limiting.rb +18 -0
- data/lib/rails_onboarding/configuration/steps.rb +121 -0
- data/lib/rails_onboarding/configuration/templates.rb +85 -0
- data/lib/rails_onboarding/configuration/tooltips.rb +32 -0
- data/lib/rails_onboarding/configuration.rb +133 -0
- data/lib/rails_onboarding/configuration_errors.rb +19 -0
- data/lib/rails_onboarding/configuration_validator.rb +477 -0
- data/lib/rails_onboarding/controller_helpers.rb +153 -0
- data/lib/rails_onboarding/current.rb +13 -0
- data/lib/rails_onboarding/deprecation.rb +64 -0
- data/lib/rails_onboarding/devise_integration.rb +122 -0
- data/lib/rails_onboarding/engine.rb +245 -0
- data/lib/rails_onboarding/error_recovery.rb +173 -0
- data/lib/rails_onboarding/lazy_loading.rb +155 -0
- data/lib/rails_onboarding/multi_tenant.rb +306 -0
- data/lib/rails_onboarding/requirements_validator.rb +315 -0
- data/lib/rails_onboarding/responsive_helper.rb +218 -0
- data/lib/rails_onboarding/session_manager.rb +187 -0
- data/lib/rails_onboarding/skip_logic.rb +159 -0
- data/lib/rails_onboarding/turbo_compatibility.rb +229 -0
- data/lib/rails_onboarding/version.rb +3 -0
- data/lib/rails_onboarding.rb +44 -0
- data/lib/tasks/rails_onboarding_analytics.rake +136 -0
- data/lib/tasks/rails_onboarding_tasks.rake +162 -0
- metadata +303 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsOnboarding
|
|
4
|
+
# Controller for managing A/B tests and viewing results
|
|
5
|
+
# Provides endpoints for test management and analytics
|
|
6
|
+
class AbTestsController < ApplicationController
|
|
7
|
+
include RailsOnboarding::AdminAuthorization
|
|
8
|
+
include RailsOnboarding::RateLimitable
|
|
9
|
+
|
|
10
|
+
# These actions view A/B test results and flip tests on/off, and
|
|
11
|
+
# assign_variant writes onto arbitrary user records - admin-only, same gate
|
|
12
|
+
# as the Admin dashboard. No StandardError handler here, so the two
|
|
13
|
+
# specific rescues need no ordering guard.
|
|
14
|
+
before_action :authenticate_admin!
|
|
15
|
+
before_action :verify_admin_authorization!
|
|
16
|
+
|
|
17
|
+
rescue_from UnauthorizedError, with: :handle_admin_unauthorized
|
|
18
|
+
rescue_from NotImplementedError, with: :handle_admin_not_implemented
|
|
19
|
+
|
|
20
|
+
before_action :set_ab_test, only: [ :show, :results, :toggle ]
|
|
21
|
+
|
|
22
|
+
# GET /ab_tests
|
|
23
|
+
# List all configured A/B tests
|
|
24
|
+
def index
|
|
25
|
+
@ab_tests = RailsOnboarding.configuration.ab_tests || {}
|
|
26
|
+
|
|
27
|
+
respond_to do |format|
|
|
28
|
+
format.html
|
|
29
|
+
format.json { render json: @ab_tests }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# GET /ab_tests/:test_name
|
|
34
|
+
# Show details for a specific A/B test
|
|
35
|
+
def show
|
|
36
|
+
respond_to do |format|
|
|
37
|
+
format.html
|
|
38
|
+
format.json { render json: test_details }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# GET /ab_tests/:test_name/results
|
|
43
|
+
# View results and analytics for an A/B test
|
|
44
|
+
def results
|
|
45
|
+
@results = calculate_results
|
|
46
|
+
@funnel = calculate_funnel_by_variant
|
|
47
|
+
|
|
48
|
+
respond_to do |format|
|
|
49
|
+
format.html
|
|
50
|
+
format.json { render json: { results: @results, funnel: @funnel } }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# POST /ab_tests/:test_name/toggle
|
|
55
|
+
# Enable or disable an A/B test
|
|
56
|
+
def toggle
|
|
57
|
+
# Note: This modifies runtime configuration, not persistent storage
|
|
58
|
+
# For production use, configuration should be managed via config files or database
|
|
59
|
+
current_state = @test_config[:enabled]
|
|
60
|
+
@test_config[:enabled] = !current_state
|
|
61
|
+
|
|
62
|
+
flash[:notice] = "A/B test #{@test_name} #{@test_config[:enabled] ? 'enabled' : 'disabled'}"
|
|
63
|
+
|
|
64
|
+
respond_to do |format|
|
|
65
|
+
format.html { redirect_to ab_tests_path }
|
|
66
|
+
format.json { render json: { enabled: @test_config[:enabled] } }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# POST /ab_tests/:test_name/assign_variant
|
|
71
|
+
# Manually assign a user to a specific variant
|
|
72
|
+
def assign_variant
|
|
73
|
+
user_id = params[:user_id]
|
|
74
|
+
variant = params[:variant]
|
|
75
|
+
test_name = params[:test_name]
|
|
76
|
+
|
|
77
|
+
user = user_class.find_by(id: user_id)
|
|
78
|
+
|
|
79
|
+
if user && user.respond_to?(:assign_variant)
|
|
80
|
+
user.assign_variant(test_name, variant)
|
|
81
|
+
render json: { success: true, variant: variant }
|
|
82
|
+
else
|
|
83
|
+
render json: { success: false, error: "User not found or AbTestable not included" }, status: :unprocessable_entity
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def set_ab_test
|
|
90
|
+
# Sanitize test_name to prevent SQL injection
|
|
91
|
+
raw_test_name = params[:test_name] || params[:id]
|
|
92
|
+
|
|
93
|
+
# Only allow alphanumeric characters, underscores, and hyphens
|
|
94
|
+
unless raw_test_name =~ /\A[a-zA-Z0-9_-]+\z/
|
|
95
|
+
respond_to do |format|
|
|
96
|
+
format.html { redirect_to ab_tests_path, alert: "Invalid test name format" }
|
|
97
|
+
format.json { render json: { error: "Invalid test name format" }, status: :bad_request }
|
|
98
|
+
end
|
|
99
|
+
return
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
@test_name = raw_test_name
|
|
103
|
+
@test_config = RailsOnboarding.configuration.ab_tests[@test_name.to_sym]
|
|
104
|
+
|
|
105
|
+
unless @test_config
|
|
106
|
+
respond_to do |format|
|
|
107
|
+
format.html { redirect_to ab_tests_path, alert: "A/B test not found: #{@test_name}" }
|
|
108
|
+
format.json { render json: { error: "Test not found" }, status: :not_found }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def test_details
|
|
114
|
+
{
|
|
115
|
+
name: @test_name,
|
|
116
|
+
enabled: @test_config[:enabled],
|
|
117
|
+
variants: @test_config[:variants],
|
|
118
|
+
weights: @test_config[:weights],
|
|
119
|
+
participant_count: participant_count,
|
|
120
|
+
variant_distribution: variant_distribution
|
|
121
|
+
}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# ab_test_assignments is a serialized JSON column that isn't a queryable
|
|
125
|
+
# jsonb type on every database the gem supports, so all variant filtering is
|
|
126
|
+
# done in Ruby to stay portable (mirrors Admin::AbTestsController#calculate_results).
|
|
127
|
+
def all_test_users
|
|
128
|
+
@all_test_users ||= user_class.all.select { |user| user.respond_to?(:ab_test_variant) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def users_in_variant(variant)
|
|
132
|
+
all_test_users.select { |user| user.ab_test_variant(@test_name) == variant }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def participant_count
|
|
136
|
+
all_test_users.count { |user| user.ab_test_variant(@test_name).present? }
|
|
137
|
+
rescue StandardError
|
|
138
|
+
0
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def variant_distribution
|
|
142
|
+
return {} unless user_class.method_defined?(:ab_test_variant)
|
|
143
|
+
|
|
144
|
+
@test_config[:variants].each_with_object({}) do |variant, distribution|
|
|
145
|
+
distribution[variant] = users_in_variant(variant).count
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def calculate_results
|
|
150
|
+
variants = @test_config[:variants]
|
|
151
|
+
results = {}
|
|
152
|
+
|
|
153
|
+
variants.each do |variant|
|
|
154
|
+
results[variant] = {
|
|
155
|
+
participants: participant_count_for_variant(variant),
|
|
156
|
+
completions: completion_count_for_variant(variant),
|
|
157
|
+
conversion_rate: conversion_rate_for_variant(variant),
|
|
158
|
+
average_time: average_completion_time_for_variant(variant),
|
|
159
|
+
skip_rate: skip_rate_for_variant(variant)
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
results
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def participant_count_for_variant(variant)
|
|
167
|
+
users_in_variant(variant).count
|
|
168
|
+
rescue StandardError
|
|
169
|
+
0
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def completion_count_for_variant(variant)
|
|
173
|
+
users_in_variant(variant).count(&:onboarding_completed)
|
|
174
|
+
rescue StandardError
|
|
175
|
+
0
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def conversion_rate_for_variant(variant)
|
|
179
|
+
participants = participant_count_for_variant(variant)
|
|
180
|
+
return 0 if participants.zero?
|
|
181
|
+
|
|
182
|
+
completions = completion_count_for_variant(variant)
|
|
183
|
+
(completions.to_f / participants * 100).round(2)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def average_completion_time_for_variant(variant)
|
|
187
|
+
users = users_in_variant(variant).select do |user|
|
|
188
|
+
user.onboarding_completed && user.onboarding_completed_at
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
return 0 if users.empty?
|
|
192
|
+
|
|
193
|
+
times = users.map do |user|
|
|
194
|
+
next unless user.created_at && user.onboarding_completed_at
|
|
195
|
+
|
|
196
|
+
(user.onboarding_completed_at - user.created_at).to_i
|
|
197
|
+
end.compact
|
|
198
|
+
|
|
199
|
+
return 0 if times.empty?
|
|
200
|
+
|
|
201
|
+
(times.sum / times.size.to_f).round(2)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def skip_rate_for_variant(variant)
|
|
205
|
+
participants = participant_count_for_variant(variant)
|
|
206
|
+
return 0 if participants.zero?
|
|
207
|
+
|
|
208
|
+
skipped = users_in_variant(variant).count(&:onboarding_skipped)
|
|
209
|
+
|
|
210
|
+
(skipped.to_f / participants * 100).round(2)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def calculate_funnel_by_variant
|
|
214
|
+
variants = @test_config[:variants]
|
|
215
|
+
steps = RailsOnboarding.configuration.steps.map { |s| s[:name].to_s }
|
|
216
|
+
|
|
217
|
+
funnel = {}
|
|
218
|
+
|
|
219
|
+
variants.each do |variant|
|
|
220
|
+
funnel[variant] = {}
|
|
221
|
+
|
|
222
|
+
steps.each_with_index do |step, index|
|
|
223
|
+
if index.zero?
|
|
224
|
+
# First step - all participants start here
|
|
225
|
+
funnel[variant][step] = participant_count_for_variant(variant)
|
|
226
|
+
else
|
|
227
|
+
# Subsequent steps - count users who reached this step
|
|
228
|
+
funnel[variant][step] = users_reached_step_count(variant, step)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
funnel
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def users_reached_step_count(variant, step)
|
|
237
|
+
# This requires analytics events to track step progression
|
|
238
|
+
# For now, return a simplified version
|
|
239
|
+
step_index = RailsOnboarding.configuration.steps.index { |s| s[:name].to_s == step }
|
|
240
|
+
return 0 unless step_index
|
|
241
|
+
|
|
242
|
+
# Users who completed onboarding definitely reached all steps
|
|
243
|
+
completed = completion_count_for_variant(variant)
|
|
244
|
+
|
|
245
|
+
# Users currently on this step or beyond
|
|
246
|
+
current_on_step = users_in_variant(variant).count do |user|
|
|
247
|
+
user.onboarding_current_step == step
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
completed + current_on_step
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def user_class
|
|
254
|
+
@user_class ||= RailsOnboarding.configuration.user_class_name.constantize
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsOnboarding
|
|
4
|
+
module Admin
|
|
5
|
+
# Admin A/B tests controller
|
|
6
|
+
# View and toggle the A/B tests defined in RailsOnboarding.configuration.ab_tests,
|
|
7
|
+
# and inspect their results. Tests are config-defined (see RailsOnboarding::AbTestable),
|
|
8
|
+
# so there is no create/edit/destroy here - only viewing and enabling/disabling.
|
|
9
|
+
class AbTestsController < BaseController
|
|
10
|
+
before_action :set_ab_test, only: [ :show, :start, :stop, :export ]
|
|
11
|
+
|
|
12
|
+
def index
|
|
13
|
+
@ab_tests = ab_tests_collection
|
|
14
|
+
@active_tests = @ab_tests.select { |t| t[:enabled] }
|
|
15
|
+
@inactive_tests = @ab_tests.reject { |t| t[:enabled] }
|
|
16
|
+
|
|
17
|
+
# The screen renders two lists off one collection, so paginate the
|
|
18
|
+
# concatenation - active first, matching the order the sections appear in -
|
|
19
|
+
# and split the current page back out. Paginating the two lists separately
|
|
20
|
+
# would need two page parameters and two navs.
|
|
21
|
+
#
|
|
22
|
+
# @active_tests/@inactive_tests stay whole so the stat cards keep reporting
|
|
23
|
+
# collection totals rather than per-page counts.
|
|
24
|
+
@pagy, page_of_tests = paginate(@active_tests + @inactive_tests)
|
|
25
|
+
@page_active_tests = page_of_tests.select { |t| t[:enabled] }
|
|
26
|
+
@page_inactive_tests = page_of_tests.reject { |t| t[:enabled] }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def show
|
|
30
|
+
@results = calculate_results(@test_name, @test_config)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def start
|
|
34
|
+
set_enabled(true)
|
|
35
|
+
flash[:notice] = "A/B test '#{@test_name}' started"
|
|
36
|
+
redirect_to admin_ab_test_path(@test_name)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def stop
|
|
40
|
+
set_enabled(false)
|
|
41
|
+
flash[:notice] = "A/B test '#{@test_name}' stopped"
|
|
42
|
+
redirect_to admin_ab_test_path(@test_name)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def export
|
|
46
|
+
results = calculate_results(@test_name, @test_config)
|
|
47
|
+
|
|
48
|
+
respond_to do |format|
|
|
49
|
+
format.csv do
|
|
50
|
+
send_data generate_csv_export(results),
|
|
51
|
+
filename: "ab_test_#{@test_name}_results_#{Date.current}.csv",
|
|
52
|
+
type: "text/csv"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def ab_tests_collection
|
|
60
|
+
(RailsOnboarding.configuration.ab_tests || {}).map do |name, config|
|
|
61
|
+
{
|
|
62
|
+
name: name.to_s,
|
|
63
|
+
enabled: !!config[:enabled],
|
|
64
|
+
variants: config[:variants] || [],
|
|
65
|
+
weights: config[:weights]
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def set_ab_test
|
|
71
|
+
@test_name = params[:id]
|
|
72
|
+
@test_config = RailsOnboarding.configuration.ab_tests&.[](@test_name.to_sym)
|
|
73
|
+
|
|
74
|
+
unless @test_config
|
|
75
|
+
flash[:alert] = "A/B test not found: #{@test_name}"
|
|
76
|
+
redirect_to admin_ab_tests_path
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def set_enabled(value)
|
|
81
|
+
RailsOnboarding.configuration.ab_tests[@test_name.to_sym][:enabled] = value
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Computed in Ruby rather than SQL: ab_test_assignments is a serialized
|
|
85
|
+
# JSON column, not always a queryable jsonb type, so this stays portable
|
|
86
|
+
# across the databases the gem supports.
|
|
87
|
+
def calculate_results(test_name, test_config)
|
|
88
|
+
variants = test_config[:variants] || []
|
|
89
|
+
users = user_class.all.select { |u| u.respond_to?(:ab_test_variant) }
|
|
90
|
+
|
|
91
|
+
variants.each_with_object({}) do |variant, results|
|
|
92
|
+
in_variant = users.select { |u| u.ab_test_variant(test_name) == variant }
|
|
93
|
+
total = in_variant.size
|
|
94
|
+
completed = in_variant.count(&:onboarding_completed)
|
|
95
|
+
|
|
96
|
+
results[variant] = {
|
|
97
|
+
participants: total,
|
|
98
|
+
completions: completed,
|
|
99
|
+
conversion_rate: total.zero? ? 0 : (completed.to_f / total * 100).round(2)
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def generate_csv_export(results)
|
|
105
|
+
require "csv"
|
|
106
|
+
|
|
107
|
+
CSV.generate do |csv|
|
|
108
|
+
csv << [ "Variant", "Participants", "Completions", "Conversion Rate (%)" ]
|
|
109
|
+
|
|
110
|
+
results.each do |variant, data|
|
|
111
|
+
csv << [ variant, data[:participants], data[:completions], data[:conversion_rate] ]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def user_class
|
|
117
|
+
@user_class ||= RailsOnboarding.configuration.user_class_name.constantize
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsOnboarding
|
|
4
|
+
module Admin
|
|
5
|
+
# Base controller for admin interface
|
|
6
|
+
# Provides authentication and authorization for admin actions
|
|
7
|
+
class BaseController < ApplicationController
|
|
8
|
+
include RailsOnboarding::AdminAuthorization
|
|
9
|
+
include Pagy::Method
|
|
10
|
+
|
|
11
|
+
DEFAULT_PER_PAGE = 25
|
|
12
|
+
# Upper bound on the client-supplied ?per_page=, so a crafted URL can't ask
|
|
13
|
+
# for a whole table in one query.
|
|
14
|
+
MAX_PER_PAGE = 100
|
|
15
|
+
|
|
16
|
+
before_action :authenticate_admin!
|
|
17
|
+
before_action :verify_admin_authorization!
|
|
18
|
+
layout "rails_onboarding/admin"
|
|
19
|
+
|
|
20
|
+
# rescue_from handlers are checked most-recently-registered-first, so the
|
|
21
|
+
# generic StandardError handler must be registered BEFORE the specific
|
|
22
|
+
# ones or it swallows them all (UnauthorizedError is a StandardError
|
|
23
|
+
# subclass). Keep StandardError first; the concern supplies the handler
|
|
24
|
+
# methods for the two specific cases.
|
|
25
|
+
rescue_from StandardError, with: :handle_admin_error
|
|
26
|
+
rescue_from NotImplementedError, with: :handle_admin_not_implemented
|
|
27
|
+
rescue_from UnauthorizedError, with: :handle_admin_unauthorized
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Shared pagination entry point for the admin index screens.
|
|
32
|
+
#
|
|
33
|
+
# limit_key keeps the ?per_page= parameter these screens have always
|
|
34
|
+
# documented working - pagy's own default is ?limit=. max_limit is doing
|
|
35
|
+
# double duty: it is both what makes pagy honour a client-supplied value at
|
|
36
|
+
# all (without it the parameter is ignored outright) and the cap enforced on
|
|
37
|
+
# that value.
|
|
38
|
+
#
|
|
39
|
+
# Accepts an Array as well as a relation - pagy slices arrays directly - but
|
|
40
|
+
# returns [] rather than nil for a page past the end of an array, which is
|
|
41
|
+
# what pagy's own Array slicing gives you.
|
|
42
|
+
def paginate(collection, limit: DEFAULT_PER_PAGE)
|
|
43
|
+
pagy, records = pagy(collection,
|
|
44
|
+
limit: limit,
|
|
45
|
+
limit_key: "per_page",
|
|
46
|
+
max_limit: MAX_PER_PAGE)
|
|
47
|
+
[ pagy, records || [] ]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Admin-area catch-all: unlike the concern's admin-only handlers, an
|
|
51
|
+
# unexpected error here sends the operator back to the admin dashboard.
|
|
52
|
+
def handle_admin_error(exception)
|
|
53
|
+
logger.error "Admin error: #{exception.message}"
|
|
54
|
+
logger.error exception.backtrace.join("\n")
|
|
55
|
+
|
|
56
|
+
respond_to do |format|
|
|
57
|
+
format.html do
|
|
58
|
+
flash[:alert] = "An error occurred: #{exception.message}"
|
|
59
|
+
redirect_to admin_dashboard_path
|
|
60
|
+
end
|
|
61
|
+
format.json do
|
|
62
|
+
render json: { error: exception.message }, status: :internal_server_error
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsOnboarding
|
|
4
|
+
module Admin
|
|
5
|
+
# Admin dashboard controller
|
|
6
|
+
# Provides analytics overview and metrics visualization
|
|
7
|
+
class DashboardController < BaseController
|
|
8
|
+
def index
|
|
9
|
+
@date_range = params[:date_range] || "30"
|
|
10
|
+
@start_date = date_range_start(@date_range)
|
|
11
|
+
@end_date = Time.current
|
|
12
|
+
|
|
13
|
+
load_analytics_data
|
|
14
|
+
load_milestone_data
|
|
15
|
+
load_ab_test_data
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def load_analytics_data
|
|
21
|
+
return unless defined?(RailsOnboarding::AnalyticsEvent)
|
|
22
|
+
|
|
23
|
+
# Overall metrics
|
|
24
|
+
@total_users = user_class.count
|
|
25
|
+
@onboarding_started = user_class.where.not(onboarding_current_step: nil).count
|
|
26
|
+
@onboarding_completed = user_class.where(onboarding_completed: true).count
|
|
27
|
+
@completion_rate = calculate_completion_rate
|
|
28
|
+
|
|
29
|
+
# Time-based metrics
|
|
30
|
+
@avg_completion_time = calculate_avg_completion_time
|
|
31
|
+
@recent_completions = recent_completions_count
|
|
32
|
+
|
|
33
|
+
# Step funnel
|
|
34
|
+
@step_funnel = calculate_step_funnel
|
|
35
|
+
|
|
36
|
+
# Recent events
|
|
37
|
+
@recent_events = RailsOnboarding::AnalyticsEvent
|
|
38
|
+
.where("created_at >= ?", @start_date)
|
|
39
|
+
.order(created_at: :desc)
|
|
40
|
+
.limit(10)
|
|
41
|
+
|
|
42
|
+
# Daily completion trend
|
|
43
|
+
@daily_completions = daily_completion_trend
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
logger.error "Error loading analytics: #{e.message}"
|
|
46
|
+
@analytics_error = e.message
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def load_milestone_data
|
|
50
|
+
return unless defined?(RailsOnboarding::Milestone)
|
|
51
|
+
|
|
52
|
+
@total_milestones = RailsOnboarding::Milestone.count
|
|
53
|
+
|
|
54
|
+
# Use Arel to build safe SQL
|
|
55
|
+
users_table = user_class.arel_table
|
|
56
|
+
@milestone_achievements = user_class
|
|
57
|
+
.joins("LEFT JOIN rails_onboarding_milestone_achievements ON rails_onboarding_milestone_achievements.user_id = #{users_table.name}.id")
|
|
58
|
+
.group(users_table[:id])
|
|
59
|
+
.count
|
|
60
|
+
@top_milestones = top_achieved_milestones
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
logger.error "Error loading milestone data: #{e.message}"
|
|
63
|
+
@milestone_error = e.message
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def load_ab_test_data
|
|
67
|
+
ab_tests = RailsOnboarding.configuration.ab_tests || {}
|
|
68
|
+
|
|
69
|
+
@total_tests = ab_tests.size
|
|
70
|
+
@active_tests = ab_tests.count { |_name, config| config[:enabled] }
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
logger.error "Error loading A/B test data: #{e.message}"
|
|
73
|
+
@ab_test_error = e.message
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def calculate_completion_rate
|
|
77
|
+
return 0 if @onboarding_started.zero?
|
|
78
|
+
(@onboarding_completed.to_f / @onboarding_started * 100).round(2)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def calculate_avg_completion_time
|
|
82
|
+
completed_users = user_class
|
|
83
|
+
.where(onboarding_completed: true)
|
|
84
|
+
.where.not(onboarding_completed_at: nil)
|
|
85
|
+
.where("onboarding_completed_at >= ?", @start_date)
|
|
86
|
+
|
|
87
|
+
return 0 if completed_users.empty?
|
|
88
|
+
|
|
89
|
+
total_time = completed_users.sum do |user|
|
|
90
|
+
next 0 unless user.created_at && user.onboarding_completed_at
|
|
91
|
+
(user.onboarding_completed_at - user.created_at).to_i
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
(total_time / completed_users.count / 3600.0).round(2) # Convert to hours
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def recent_completions_count
|
|
98
|
+
user_class
|
|
99
|
+
.where(onboarding_completed: true)
|
|
100
|
+
.where("onboarding_completed_at >= ?", @start_date)
|
|
101
|
+
.count
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def calculate_step_funnel
|
|
105
|
+
steps = RailsOnboarding.configuration.steps
|
|
106
|
+
funnel = []
|
|
107
|
+
|
|
108
|
+
# A true entry funnel: count distinct users who *reached* each step
|
|
109
|
+
# (step_started), not just those who completed it. `properties` is a
|
|
110
|
+
# JSON-serialized text column, so filter by step in Ruby rather than
|
|
111
|
+
# with a DB JSON operator (not portable to text/MySQL).
|
|
112
|
+
step_events = if defined?(RailsOnboarding::AnalyticsEvent)
|
|
113
|
+
RailsOnboarding::AnalyticsEvent
|
|
114
|
+
.where(event_type: RailsOnboarding::AnalyticsEvent::ONBOARDING_STEP_STARTED)
|
|
115
|
+
.where("created_at >= ?", @start_date)
|
|
116
|
+
.to_a
|
|
117
|
+
else
|
|
118
|
+
[]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
steps.each do |step|
|
|
122
|
+
step_name = step[:name].to_s
|
|
123
|
+
users_reached = step_events
|
|
124
|
+
.select { |e| e.properties.to_h["step_name"].to_s == step_name }
|
|
125
|
+
.map(&:user_id).uniq.count
|
|
126
|
+
|
|
127
|
+
funnel << {
|
|
128
|
+
step: step_name,
|
|
129
|
+
title: step[:title],
|
|
130
|
+
users: users_reached,
|
|
131
|
+
percentage: @onboarding_started.zero? ? 0 : (users_reached.to_f / @onboarding_started * 100).round(2)
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
funnel
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def daily_completion_trend
|
|
139
|
+
return [] unless defined?(RailsOnboarding::AnalyticsEvent)
|
|
140
|
+
|
|
141
|
+
days = 7
|
|
142
|
+
trend = []
|
|
143
|
+
|
|
144
|
+
days.times do |i|
|
|
145
|
+
date = i.days.ago.to_date
|
|
146
|
+
completions = user_class
|
|
147
|
+
.where(onboarding_completed: true)
|
|
148
|
+
.where("DATE(onboarding_completed_at) = ?", date)
|
|
149
|
+
.count
|
|
150
|
+
|
|
151
|
+
trend.unshift({ date: date.strftime("%m/%d"), count: completions })
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
trend
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def top_achieved_milestones
|
|
158
|
+
return [] unless defined?(RailsOnboarding::Milestone)
|
|
159
|
+
|
|
160
|
+
RailsOnboarding::Milestone
|
|
161
|
+
.joins("LEFT JOIN rails_onboarding_milestone_achievements ON rails_onboarding_milestone_achievements.milestone_id = rails_onboarding_milestones.id")
|
|
162
|
+
.group("rails_onboarding_milestones.id", "rails_onboarding_milestones.name", "rails_onboarding_milestones.title")
|
|
163
|
+
.order("COUNT(rails_onboarding_milestone_achievements.id) DESC")
|
|
164
|
+
.limit(5)
|
|
165
|
+
.pluck("rails_onboarding_milestones.name", "rails_onboarding_milestones.title", "COUNT(rails_onboarding_milestone_achievements.id)")
|
|
166
|
+
.map { |name, title, count| { name: name, title: title, count: count } }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def date_range_start(range)
|
|
170
|
+
case range
|
|
171
|
+
when "7"
|
|
172
|
+
7.days.ago
|
|
173
|
+
when "30"
|
|
174
|
+
30.days.ago
|
|
175
|
+
when "90"
|
|
176
|
+
90.days.ago
|
|
177
|
+
when "all"
|
|
178
|
+
100.years.ago
|
|
179
|
+
else
|
|
180
|
+
30.days.ago
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def user_class
|
|
185
|
+
@user_class ||= RailsOnboarding.configuration.user_class_name.constantize
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|