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.
Files changed (148) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +1351 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/rails_onboarding/admin/chart_controller.js +25 -0
  6. data/app/assets/javascripts/rails_onboarding/admin/filter_controller.js +49 -0
  7. data/app/assets/javascripts/rails_onboarding/admin/flash_controller.js +21 -0
  8. data/app/assets/javascripts/rails_onboarding/admin/flow_editor_controller.js +86 -0
  9. data/app/assets/javascripts/rails_onboarding/application.js +231 -0
  10. data/app/assets/javascripts/rails_onboarding/milestone_celebration_controller.js +177 -0
  11. data/app/assets/javascripts/rails_onboarding/milestone_dashboard_controller.js +132 -0
  12. data/app/assets/javascripts/rails_onboarding/milestone_detail_controller.js +34 -0
  13. data/app/assets/javascripts/rails_onboarding/navigation_controller.js +277 -0
  14. data/app/assets/javascripts/rails_onboarding/onboarding_controller.js +326 -0
  15. data/app/assets/javascripts/rails_onboarding/progress_controller.js +203 -0
  16. data/app/assets/javascripts/rails_onboarding/progressive_disclosure_controller.js +218 -0
  17. data/app/assets/javascripts/rails_onboarding/tooltip_controller.js +1237 -0
  18. data/app/assets/javascripts/rails_onboarding/tooltip_scheduler_controller.js +511 -0
  19. data/app/assets/javascripts/rails_onboarding/tour_controller.js +763 -0
  20. data/app/assets/stylesheets/rails_onboarding/accessibility.css +536 -0
  21. data/app/assets/stylesheets/rails_onboarding/admin.css +816 -0
  22. data/app/assets/stylesheets/rails_onboarding/application.css +1145 -0
  23. data/app/assets/stylesheets/rails_onboarding/flash_messages.css +237 -0
  24. data/app/assets/stylesheets/rails_onboarding/milestones.css +545 -0
  25. data/app/assets/stylesheets/rails_onboarding/mobile.css +856 -0
  26. data/app/assets/stylesheets/rails_onboarding/progressive_disclosure.css +299 -0
  27. data/app/assets/stylesheets/rails_onboarding/tooltips.css +468 -0
  28. data/app/assets/stylesheets/rails_onboarding/tour.css +495 -0
  29. data/app/assets/stylesheets/rails_onboarding/utilities.css +393 -0
  30. data/app/controllers/concerns/rails_onboarding/admin_authorization.rb +98 -0
  31. data/app/controllers/concerns/rails_onboarding/rate_limitable.rb +104 -0
  32. data/app/controllers/rails_onboarding/ab_tests_controller.rb +257 -0
  33. data/app/controllers/rails_onboarding/admin/ab_tests_controller.rb +121 -0
  34. data/app/controllers/rails_onboarding/admin/base_controller.rb +68 -0
  35. data/app/controllers/rails_onboarding/admin/dashboard_controller.rb +189 -0
  36. data/app/controllers/rails_onboarding/admin/flows_controller.rb +175 -0
  37. data/app/controllers/rails_onboarding/admin/users_controller.rb +366 -0
  38. data/app/controllers/rails_onboarding/application_controller.rb +9 -0
  39. data/app/controllers/rails_onboarding/milestones_controller.rb +126 -0
  40. data/app/controllers/rails_onboarding/onboarding_controller.rb +409 -0
  41. data/app/controllers/rails_onboarding/progressive_features_controller.rb +134 -0
  42. data/app/controllers/rails_onboarding/templates_controller.rb +162 -0
  43. data/app/controllers/rails_onboarding/test_sessions_controller.rb +16 -0
  44. data/app/controllers/rails_onboarding/tooltips_controller.rb +92 -0
  45. data/app/helpers/rails_onboarding/application_helper.rb +4 -0
  46. data/app/helpers/rails_onboarding/i18n_helper.rb +137 -0
  47. data/app/helpers/rails_onboarding/personalization_helper.rb +184 -0
  48. data/app/helpers/rails_onboarding/templates_helper.rb +253 -0
  49. data/app/jobs/rails_onboarding/application_job.rb +4 -0
  50. data/app/mailers/rails_onboarding/application_mailer.rb +6 -0
  51. data/app/mailers/rails_onboarding/onboarding_mailer.rb +72 -0
  52. data/app/models/concerns/rails_onboarding/ab_testable.rb +149 -0
  53. data/app/models/concerns/rails_onboarding/onboardable.rb +649 -0
  54. data/app/models/concerns/rails_onboarding/personalizable.rb +211 -0
  55. data/app/models/concerns/rails_onboarding/progressive_disclosure.rb +276 -0
  56. data/app/models/rails_onboarding/analytics.rb +255 -0
  57. data/app/models/rails_onboarding/analytics_event.rb +168 -0
  58. data/app/models/rails_onboarding/application_record.rb +5 -0
  59. data/app/models/rails_onboarding/flow.rb +41 -0
  60. data/app/services/rails_onboarding/milestone_service.rb +105 -0
  61. data/app/views/layouts/rails_onboarding/admin.html.erb +106 -0
  62. data/app/views/layouts/rails_onboarding/application.html.erb +65 -0
  63. data/app/views/rails_onboarding/ab_tests/index.html.erb +209 -0
  64. data/app/views/rails_onboarding/ab_tests/results.html.erb +253 -0
  65. data/app/views/rails_onboarding/admin/ab_tests/_ab_test_card.html.erb +24 -0
  66. data/app/views/rails_onboarding/admin/ab_tests/index.html.erb +57 -0
  67. data/app/views/rails_onboarding/admin/ab_tests/show.html.erb +41 -0
  68. data/app/views/rails_onboarding/admin/dashboard/index.html.erb +224 -0
  69. data/app/views/rails_onboarding/admin/flows/_form.html.erb +147 -0
  70. data/app/views/rails_onboarding/admin/flows/edit.html.erb +7 -0
  71. data/app/views/rails_onboarding/admin/flows/index.html.erb +125 -0
  72. data/app/views/rails_onboarding/admin/flows/new.html.erb +7 -0
  73. data/app/views/rails_onboarding/admin/flows/preview.html.erb +40 -0
  74. data/app/views/rails_onboarding/admin/flows/show.html.erb +114 -0
  75. data/app/views/rails_onboarding/admin/users/index.html.erb +180 -0
  76. data/app/views/rails_onboarding/admin/users/show.html.erb +187 -0
  77. data/app/views/rails_onboarding/milestones/index.html.erb +64 -0
  78. data/app/views/rails_onboarding/milestones/show.html.erb +57 -0
  79. data/app/views/rails_onboarding/onboarding/_custom_footer.html.erb +2 -0
  80. data/app/views/rails_onboarding/onboarding/_progress_indicator.html.erb +56 -0
  81. data/app/views/rails_onboarding/onboarding/_step_navigation.html.erb +44 -0
  82. data/app/views/rails_onboarding/onboarding/step.html.erb +38 -0
  83. data/app/views/rails_onboarding/onboarding/welcome.html.erb +37 -0
  84. data/app/views/rails_onboarding/onboarding_mailer/completion_email.html.erb +109 -0
  85. data/app/views/rails_onboarding/onboarding_mailer/completion_email.text.erb +29 -0
  86. data/app/views/rails_onboarding/onboarding_mailer/reminder_email.html.erb +99 -0
  87. data/app/views/rails_onboarding/onboarding_mailer/reminder_email.text.erb +20 -0
  88. data/app/views/rails_onboarding/onboarding_mailer/step_completed_email.html.erb +105 -0
  89. data/app/views/rails_onboarding/onboarding_mailer/step_completed_email.text.erb +19 -0
  90. data/app/views/rails_onboarding/onboarding_mailer/welcome_email.html.erb +74 -0
  91. data/app/views/rails_onboarding/onboarding_mailer/welcome_email.text.erb +17 -0
  92. data/app/views/rails_onboarding/shared/_flash.html.erb +25 -0
  93. data/app/views/rails_onboarding/shared/_milestone_badge.html.erb +22 -0
  94. data/app/views/rails_onboarding/shared/_milestone_celebration.html.erb +44 -0
  95. data/app/views/rails_onboarding/shared/_onboarding_banner.html.erb +48 -0
  96. data/app/views/rails_onboarding/templates/index.html.erb +255 -0
  97. data/config/importmap.rb +33 -0
  98. data/config/locales/en.yml +62 -0
  99. data/config/locales/es.yml +62 -0
  100. data/config/locales/fr.yml +62 -0
  101. data/config/routes.rb +79 -0
  102. data/lib/generators/rails_onboarding/install_generator.rb +124 -0
  103. data/lib/generators/rails_onboarding/templates/README +120 -0
  104. data/lib/generators/rails_onboarding/templates/add_analytics_to_rails_onboarding.rb +33 -0
  105. data/lib/generators/rails_onboarding/templates/add_milestone_tracking_to_users.rb +38 -0
  106. data/lib/generators/rails_onboarding/templates/add_onboarding_indexes.rb +93 -0
  107. data/lib/generators/rails_onboarding/templates/add_onboarding_to_users.rb +51 -0
  108. data/lib/generators/rails_onboarding/templates/add_robustness_fields_to_users.rb.tt +20 -0
  109. data/lib/generators/rails_onboarding/templates/create_rails_onboarding_flows.rb +18 -0
  110. data/lib/generators/rails_onboarding/templates/onboarding.css +18 -0
  111. data/lib/generators/rails_onboarding/templates/rails_onboarding.rb +75 -0
  112. data/lib/generators/rails_onboarding/update_generator.rb +73 -0
  113. data/lib/rails_onboarding/api_mode.rb +367 -0
  114. data/lib/rails_onboarding/backfill.rb +130 -0
  115. data/lib/rails_onboarding/background_jobs.rb +412 -0
  116. data/lib/rails_onboarding/caching.rb +180 -0
  117. data/lib/rails_onboarding/cdn_support.rb +202 -0
  118. data/lib/rails_onboarding/configuration/ab_testing.rb +24 -0
  119. data/lib/rails_onboarding/configuration/analytics.rb +29 -0
  120. data/lib/rails_onboarding/configuration/integrations.rb +31 -0
  121. data/lib/rails_onboarding/configuration/milestones.rb +92 -0
  122. data/lib/rails_onboarding/configuration/personalization.rb +25 -0
  123. data/lib/rails_onboarding/configuration/progressive_disclosure.rb +41 -0
  124. data/lib/rails_onboarding/configuration/rate_limiting.rb +18 -0
  125. data/lib/rails_onboarding/configuration/steps.rb +121 -0
  126. data/lib/rails_onboarding/configuration/templates.rb +85 -0
  127. data/lib/rails_onboarding/configuration/tooltips.rb +32 -0
  128. data/lib/rails_onboarding/configuration.rb +133 -0
  129. data/lib/rails_onboarding/configuration_errors.rb +19 -0
  130. data/lib/rails_onboarding/configuration_validator.rb +477 -0
  131. data/lib/rails_onboarding/controller_helpers.rb +153 -0
  132. data/lib/rails_onboarding/current.rb +13 -0
  133. data/lib/rails_onboarding/deprecation.rb +64 -0
  134. data/lib/rails_onboarding/devise_integration.rb +122 -0
  135. data/lib/rails_onboarding/engine.rb +245 -0
  136. data/lib/rails_onboarding/error_recovery.rb +173 -0
  137. data/lib/rails_onboarding/lazy_loading.rb +155 -0
  138. data/lib/rails_onboarding/multi_tenant.rb +306 -0
  139. data/lib/rails_onboarding/requirements_validator.rb +315 -0
  140. data/lib/rails_onboarding/responsive_helper.rb +218 -0
  141. data/lib/rails_onboarding/session_manager.rb +187 -0
  142. data/lib/rails_onboarding/skip_logic.rb +159 -0
  143. data/lib/rails_onboarding/turbo_compatibility.rb +229 -0
  144. data/lib/rails_onboarding/version.rb +3 -0
  145. data/lib/rails_onboarding.rb +44 -0
  146. data/lib/tasks/rails_onboarding_analytics.rake +136 -0
  147. data/lib/tasks/rails_onboarding_tasks.rake +162 -0
  148. 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