ahoy_analytics 0.1.0

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 (198) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +163 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/ahoy_analytics/build/assets/Combination-BpSXUjp9.js +41 -0
  6. data/app/assets/ahoy_analytics/build/assets/analytics-5KyfCxh6.css +1 -0
  7. data/app/assets/ahoy_analytics/build/assets/analytics-dashboard-uOXx8zYZ.js +1 -0
  8. data/app/assets/ahoy_analytics/build/assets/analytics-layout-ClAft5OU.js +1 -0
  9. data/app/assets/ahoy_analytics/build/assets/analytics-tracker-B3f8P98z.js +1 -0
  10. data/app/assets/ahoy_analytics/build/assets/analytics-ui-DMSkNqd6.js +90 -0
  11. data/app/assets/ahoy_analytics/build/assets/behaviors-panel-ChNGYbdH.js +1 -0
  12. data/app/assets/ahoy_analytics/build/assets/button-JVCrlR4s.js +1 -0
  13. data/app/assets/ahoy_analytics/build/assets/cable-DO-7y1-E.js +1 -0
  14. data/app/assets/ahoy_analytics/build/assets/createLucideIcon-BGzacY2v.js +1 -0
  15. data/app/assets/ahoy_analytics/build/assets/date-range-dialog-DWDp3cLG.js +1 -0
  16. data/app/assets/ahoy_analytics/build/assets/details-button-NqKfSGEG.js +1 -0
  17. data/app/assets/ahoy_analytics/build/assets/devices-panel-cXvlmNBY.js +1 -0
  18. data/app/assets/ahoy_analytics/build/assets/dialog-path-BBPNlB4Z.js +1 -0
  19. data/app/assets/ahoy_analytics/build/assets/dropdown-menu-Adj3O5fh.js +1 -0
  20. data/app/assets/ahoy_analytics/build/assets/filter-dialog-BN-rf4lp.js +1 -0
  21. data/app/assets/ahoy_analytics/build/assets/index-B1K1NTKT.js +3 -0
  22. data/app/assets/ahoy_analytics/build/assets/index-BcHeb-Rh.js +1 -0
  23. data/app/assets/ahoy_analytics/build/assets/index-DzpzLoG4.js +1 -0
  24. data/app/assets/ahoy_analytics/build/assets/index-vX97OY1J.js +1 -0
  25. data/app/assets/ahoy_analytics/build/assets/input-e4v_v0kE.js +1 -0
  26. data/app/assets/ahoy_analytics/build/assets/jsx-runtime-u17CrQMm.js +1 -0
  27. data/app/assets/ahoy_analytics/build/assets/last-load-context-De5uA95L.js +1 -0
  28. data/app/assets/ahoy_analytics/build/assets/list-table-ChHEzzF9.js +1 -0
  29. data/app/assets/ahoy_analytics/build/assets/live-Cp2MHECh.js +2 -0
  30. data/app/assets/ahoy_analytics/build/assets/locations-panel-BaISRmaQ.js +1 -0
  31. data/app/assets/ahoy_analytics/build/assets/mercator-BnxX5RzL.js +1 -0
  32. data/app/assets/ahoy_analytics/build/assets/pages-panel-Bh25L8mP.js +1 -0
  33. data/app/assets/ahoy_analytics/build/assets/panel-tabs-B2kvGFJx.js +1 -0
  34. data/app/assets/ahoy_analytics/build/assets/query-context-B-PgE00D.js +1 -0
  35. data/app/assets/ahoy_analytics/build/assets/remote-details-dialog-DDTcKaM5.js +1 -0
  36. data/app/assets/ahoy_analytics/build/assets/show-CCRicksg.js +1 -0
  37. data/app/assets/ahoy_analytics/build/assets/simple-tabs-D6G6Bs0k.js +1 -0
  38. data/app/assets/ahoy_analytics/build/assets/site-context-BNteYRlR.js +1 -0
  39. data/app/assets/ahoy_analytics/build/assets/sources-panel-DyB21hxD.js +1 -0
  40. data/app/assets/ahoy_analytics/build/assets/top-bar-FSiLBjq6.js +1 -0
  41. data/app/assets/ahoy_analytics/build/assets/top-stats-context-DU15P9jS.js +1 -0
  42. data/app/assets/ahoy_analytics/build/assets/use-debounce-VBpXQRL8.js +1 -0
  43. data/app/assets/ahoy_analytics/build/assets/user-context-DbYteluY.js +1 -0
  44. data/app/assets/ahoy_analytics/build/assets/visitor-globe-BWLDihid.js +4789 -0
  45. data/app/assets/ahoy_analytics/build/assets/visitor-graph-uKXjLvcu.js +1 -0
  46. data/app/assets/ahoy_analytics/images/icon/browser/brave.svg +1 -0
  47. data/app/assets/ahoy_analytics/images/icon/browser/chrome.svg +1 -0
  48. data/app/assets/ahoy_analytics/images/icon/browser/chromium.svg +1 -0
  49. data/app/assets/ahoy_analytics/images/icon/browser/duckduckgo.svg +2151 -0
  50. data/app/assets/ahoy_analytics/images/icon/browser/edge.svg +1 -0
  51. data/app/assets/ahoy_analytics/images/icon/browser/fallback.svg +5 -0
  52. data/app/assets/ahoy_analytics/images/icon/browser/firefox.svg +1 -0
  53. data/app/assets/ahoy_analytics/images/icon/browser/opera.svg +1 -0
  54. data/app/assets/ahoy_analytics/images/icon/browser/safari.png +0 -0
  55. data/app/assets/ahoy_analytics/images/icon/browser/samsung-internet.svg +1 -0
  56. data/app/assets/ahoy_analytics/images/icon/browser/uc.svg +1 -0
  57. data/app/assets/ahoy_analytics/images/icon/browser/vivaldi.svg +1 -0
  58. data/app/assets/ahoy_analytics/images/icon/browser/yandex.png +0 -0
  59. data/app/assets/ahoy_analytics/images/icon/os/android.png +0 -0
  60. data/app/assets/ahoy_analytics/images/icon/os/chrome_os.png +0 -0
  61. data/app/assets/ahoy_analytics/images/icon/os/fallback.svg +5 -0
  62. data/app/assets/ahoy_analytics/images/icon/os/fedora.png +0 -0
  63. data/app/assets/ahoy_analytics/images/icon/os/freebsd.png +0 -0
  64. data/app/assets/ahoy_analytics/images/icon/os/gnu_linux.png +0 -0
  65. data/app/assets/ahoy_analytics/images/icon/os/ios.png +0 -0
  66. data/app/assets/ahoy_analytics/images/icon/os/ipad_os.png +0 -0
  67. data/app/assets/ahoy_analytics/images/icon/os/mac.png +0 -0
  68. data/app/assets/ahoy_analytics/images/icon/os/ubuntu.png +0 -0
  69. data/app/assets/ahoy_analytics/images/icon/os/windows.png +0 -0
  70. data/app/assets/stylesheets/ahoy_analytics/application.css +15 -0
  71. data/app/channels/ahoy_analytics/analytics_channel.rb +9 -0
  72. data/app/controllers/ahoy_analytics/analytics_controller.rb +9 -0
  73. data/app/controllers/ahoy_analytics/application_controller.rb +8 -0
  74. data/app/controllers/ahoy_analytics/assets_controller.rb +46 -0
  75. data/app/controllers/ahoy_analytics/base_controller.rb +285 -0
  76. data/app/controllers/ahoy_analytics/behaviors_controller.rb +14 -0
  77. data/app/controllers/ahoy_analytics/devices_controller.rb +14 -0
  78. data/app/controllers/ahoy_analytics/export_controller.rb +12 -0
  79. data/app/controllers/ahoy_analytics/live_controller.rb +9 -0
  80. data/app/controllers/ahoy_analytics/locations_controller.rb +14 -0
  81. data/app/controllers/ahoy_analytics/main_graph_controller.rb +10 -0
  82. data/app/controllers/ahoy_analytics/pages_controller.rb +14 -0
  83. data/app/controllers/ahoy_analytics/referrers_controller.rb +34 -0
  84. data/app/controllers/ahoy_analytics/search_terms_controller.rb +47 -0
  85. data/app/controllers/ahoy_analytics/sources_controller.rb +14 -0
  86. data/app/controllers/ahoy_analytics/top_stats_controller.rb +13 -0
  87. data/app/controllers/concerns/ahoy_analytics/set_current_request.rb +17 -0
  88. data/app/frontend/components/analytics/hex-highlights.tsx +165 -0
  89. data/app/frontend/components/analytics/hex-land-layer.tsx +61 -0
  90. data/app/frontend/components/analytics/metric-card.tsx +138 -0
  91. data/app/frontend/components/analytics/sessions-by-location.tsx +62 -0
  92. data/app/frontend/components/analytics/visitor-globe.tsx +424 -0
  93. data/app/frontend/components/ui/accordion.tsx +64 -0
  94. data/app/frontend/components/ui/alert.tsx +66 -0
  95. data/app/frontend/components/ui/avatar.tsx +53 -0
  96. data/app/frontend/components/ui/badge.tsx +46 -0
  97. data/app/frontend/components/ui/button.tsx +62 -0
  98. data/app/frontend/components/ui/calendar.tsx +212 -0
  99. data/app/frontend/components/ui/card.tsx +91 -0
  100. data/app/frontend/components/ui/checkbox.tsx +32 -0
  101. data/app/frontend/components/ui/dropdown-menu.tsx +255 -0
  102. data/app/frontend/components/ui/input.tsx +21 -0
  103. data/app/frontend/components/ui/label.tsx +22 -0
  104. data/app/frontend/components/ui/popover.tsx +46 -0
  105. data/app/frontend/components/ui/select.tsx +183 -0
  106. data/app/frontend/components/ui/separator.tsx +26 -0
  107. data/app/frontend/components/ui/sheet.tsx +139 -0
  108. data/app/frontend/components/ui/sidebar.tsx +726 -0
  109. data/app/frontend/components/ui/skeleton.tsx +13 -0
  110. data/app/frontend/components/ui/sonner.tsx +33 -0
  111. data/app/frontend/components/ui/tooltip.tsx +59 -0
  112. data/app/frontend/data/countries-110m.json +1 -0
  113. data/app/frontend/data/globe-data.json +1 -0
  114. data/app/frontend/entrypoints/analytics-tracker.ts +680 -0
  115. data/app/frontend/entrypoints/analytics-ui.tsx +26 -0
  116. data/app/frontend/entrypoints/analytics.css +77 -0
  117. data/app/frontend/layouts/analytics-layout.tsx +28 -0
  118. data/app/frontend/lib/cable.ts +13 -0
  119. data/app/frontend/lib/geocode.ts +65 -0
  120. data/app/frontend/lib/utils.ts +6 -0
  121. data/app/frontend/pages/admin/analytics/api.ts +221 -0
  122. data/app/frontend/pages/admin/analytics/hooks/use-debounce.ts +36 -0
  123. data/app/frontend/pages/admin/analytics/last-load-context.tsx +29 -0
  124. data/app/frontend/pages/admin/analytics/lib/base-path.ts +28 -0
  125. data/app/frontend/pages/admin/analytics/lib/dialog-path.ts +242 -0
  126. data/app/frontend/pages/admin/analytics/lib/number-formatter.ts +100 -0
  127. data/app/frontend/pages/admin/analytics/live.tsx +608 -0
  128. data/app/frontend/pages/admin/analytics/query-context.tsx +61 -0
  129. data/app/frontend/pages/admin/analytics/show.tsx +40 -0
  130. data/app/frontend/pages/admin/analytics/site-context.tsx +22 -0
  131. data/app/frontend/pages/admin/analytics/top-stats-context.tsx +37 -0
  132. data/app/frontend/pages/admin/analytics/types.ts +161 -0
  133. data/app/frontend/pages/admin/analytics/ui/analytics-dashboard.tsx +60 -0
  134. data/app/frontend/pages/admin/analytics/ui/behaviors-panel.tsx +456 -0
  135. data/app/frontend/pages/admin/analytics/ui/date-range-dialog.tsx +173 -0
  136. data/app/frontend/pages/admin/analytics/ui/details-button.tsx +33 -0
  137. data/app/frontend/pages/admin/analytics/ui/devices-panel.tsx +474 -0
  138. data/app/frontend/pages/admin/analytics/ui/filter-dialog.tsx +558 -0
  139. data/app/frontend/pages/admin/analytics/ui/list-table.tsx +346 -0
  140. data/app/frontend/pages/admin/analytics/ui/locations-panel.tsx +566 -0
  141. data/app/frontend/pages/admin/analytics/ui/pages-panel.tsx +207 -0
  142. data/app/frontend/pages/admin/analytics/ui/panel-tabs.tsx +65 -0
  143. data/app/frontend/pages/admin/analytics/ui/remote-details-dialog.tsx +356 -0
  144. data/app/frontend/pages/admin/analytics/ui/simple-tabs.tsx +54 -0
  145. data/app/frontend/pages/admin/analytics/ui/sources-panel.tsx +771 -0
  146. data/app/frontend/pages/admin/analytics/ui/top-bar.tsx +793 -0
  147. data/app/frontend/pages/admin/analytics/ui/visitor-graph.tsx +891 -0
  148. data/app/frontend/pages/admin/analytics/user-context.tsx +22 -0
  149. data/app/frontend/styles/shared.css +156 -0
  150. data/app/helpers/ahoy_analytics/application_helper.rb +96 -0
  151. data/app/jobs/ahoy_analytics/application_job.rb +4 -0
  152. data/app/jobs/ahoy_analytics/update_job.rb +12 -0
  153. data/app/mailers/ahoy_analytics/application_mailer.rb +6 -0
  154. data/app/models/ahoy/event/filters.rb +7 -0
  155. data/app/models/ahoy/event.rb +9 -0
  156. data/app/models/ahoy/visit/cache_key.rb +15 -0
  157. data/app/models/ahoy/visit/constants.rb +11 -0
  158. data/app/models/ahoy/visit/devices.rb +144 -0
  159. data/app/models/ahoy/visit/export.rb +24 -0
  160. data/app/models/ahoy/visit/filters.rb +286 -0
  161. data/app/models/ahoy/visit/imports.rb +36 -0
  162. data/app/models/ahoy/visit/locations.rb +276 -0
  163. data/app/models/ahoy/visit/metrics.rb +473 -0
  164. data/app/models/ahoy/visit/ordering.rb +110 -0
  165. data/app/models/ahoy/visit/pages.rb +533 -0
  166. data/app/models/ahoy/visit/pagination.rb +17 -0
  167. data/app/models/ahoy/visit/ranges.rb +227 -0
  168. data/app/models/ahoy/visit/series.rb +177 -0
  169. data/app/models/ahoy/visit/sources.rb +418 -0
  170. data/app/models/ahoy/visit/url_labels.rb +32 -0
  171. data/app/models/ahoy/visit.rb +143 -0
  172. data/app/models/ahoy_analytics/application_record.rb +5 -0
  173. data/app/models/ahoy_analytics/current.rb +8 -0
  174. data/app/models/ahoy_analytics/funnel.rb +16 -0
  175. data/app/models/ahoy_analytics/imported_entry_page.rb +5 -0
  176. data/app/models/ahoy_analytics/imported_exit_page.rb +5 -0
  177. data/app/models/ahoy_analytics/imported_page.rb +5 -0
  178. data/app/models/ahoy_analytics/live_stats.rb +152 -0
  179. data/app/models/ahoy_analytics/setting.rb +19 -0
  180. data/app/models/analytics/source_catalog.rb +48 -0
  181. data/app/views/layouts/ahoy_analytics/application.html.erb +15 -0
  182. data/config/routes.rb +21 -0
  183. data/config/vite.json +22 -0
  184. data/db/migrate/20251006104056_create_ahoy_visits_and_events.rb +62 -0
  185. data/db/migrate/20251006105012_add_analytics_fields_to_ahoy_visits.rb +11 -0
  186. data/db/migrate/20251012090000_create_analytics_funnels_and_imports.rb +52 -0
  187. data/db/migrate/20251013021500_add_analytics_indexes.rb +14 -0
  188. data/lib/ahoy_analytics/ahoy_store.rb +429 -0
  189. data/lib/ahoy_analytics/asset_manifest.rb +56 -0
  190. data/lib/ahoy_analytics/device_bucket.rb +39 -0
  191. data/lib/ahoy_analytics/engine.rb +55 -0
  192. data/lib/ahoy_analytics/maxmind_geo.rb +77 -0
  193. data/lib/ahoy_analytics/version.rb +3 -0
  194. data/lib/ahoy_analytics.rb +52 -0
  195. data/lib/generators/ahoy_analytics/install/install_generator.rb +111 -0
  196. data/lib/generators/ahoy_analytics/install/templates/initializer.rb +28 -0
  197. data/lib/tasks/ahoy_analytics_tasks.rake +4 -0
  198. metadata +352 -0
@@ -0,0 +1,473 @@
1
+ require "zlib"
2
+
3
+ module Ahoy::Visit::Metrics
4
+ extend ActiveSupport::Concern
5
+
6
+ class_methods do
7
+ def change_ratio(prev, curr)
8
+ return nil if prev.nil?
9
+ return 0 if prev.to_i <= 0
10
+ ((curr.to_f - prev.to_f) / prev.to_f).round(4)
11
+ end
12
+
13
+ def visit_metrics(visits_scope, events_scope)
14
+ total_visits = visits_scope.count
15
+
16
+ live_visitors = Ahoy::Visit.live_visitors_count
17
+
18
+ pageview_events_present = events_scope.exists?
19
+
20
+ pageviews = 0
21
+ views_per_visit = 0.0
22
+ bounce_rate = 0.0
23
+ average_duration = 0.0
24
+
25
+ if pageview_events_present
26
+ events_grouped = events_scope.group(:visit_id)
27
+ pageviews_by_visit = events_grouped.count
28
+ pageviews = pageviews_by_visit.values.sum
29
+
30
+ visits_with_events = pageviews_by_visit.size
31
+ unless visits_with_events.zero?
32
+ views_per_visit = pageviews.to_f / visits_with_events
33
+
34
+ durations_seconds = events_grouped.pluck(Arel.sql("GREATEST(EXTRACT(EPOCH FROM (MAX(time) - MIN(time))), 0)"))
35
+ total_duration = durations_seconds.compact.sum
36
+ average_duration = total_duration.to_f / visits_with_events
37
+ end
38
+ else
39
+ pageviews = total_visits
40
+ views_per_visit = total_visits.zero? ? 0.0 : (pageviews.to_f / total_visits)
41
+ end
42
+
43
+ if total_visits > 0
44
+ pv_counts = pageview_events_present ? pageviews_by_visit : Hash.new(0)
45
+
46
+ non_pv_ids = Ahoy::Event
47
+ .where(visit_id: visits_scope.select(:id))
48
+ .where.not(name: "pageview")
49
+ .distinct
50
+ .pluck(:visit_id)
51
+ .to_set
52
+
53
+ bounces = 0
54
+ visits_scope.pluck(:id).each do |vid|
55
+ pv = pv_counts[vid].to_i
56
+ bounces += 1 if pv == 1 && !non_pv_ids.include?(vid)
57
+ end
58
+ bounce_rate = (bounces.to_f / total_visits.to_f * 100.0)
59
+ end
60
+
61
+ {
62
+ total_visits: total_visits,
63
+ live_visitors: live_visitors,
64
+ pageviews: pageviews,
65
+ pageviews_per_visit: views_per_visit,
66
+ bounce_rate: bounce_rate,
67
+ average_duration: average_duration
68
+ }
69
+ end
70
+
71
+ def top_stats_payload(query)
72
+ range, interval = Ahoy::Visit.range_and_interval_for(query[:period], query[:interval], query)
73
+ filters = query[:filters] || {}
74
+ adv = query[:advanced_filters] || []
75
+
76
+ visits = Ahoy::Visit.scoped_visits(range, filters, advanced_filters: adv)
77
+ events = Ahoy::Visit.scoped_events(range, filters, advanced_filters: adv)
78
+ metrics = visit_metrics(visits, events)
79
+
80
+ prev_range = case query[:comparison]
81
+ when "year_over_year"
82
+ r = Ahoy::Visit.year_over_year_range(range)
83
+ r = Ahoy::Visit.align_comparison_weekday(r, range) if ActiveModel::Type::Boolean.new.cast(query[:match_day_of_week])
84
+ r
85
+ when "custom"
86
+ Ahoy::Visit.custom_compare_range(query) || Ahoy::Visit.previous_range(range)
87
+ when "previous_period"
88
+ r = Ahoy::Visit.previous_range(range)
89
+ r = Ahoy::Visit.align_comparison_weekday(r, range) if ActiveModel::Type::Boolean.new.cast(query[:match_day_of_week])
90
+ r
91
+ else
92
+ Ahoy::Visit.previous_range(range)
93
+ end
94
+
95
+ prev_visits = Ahoy::Visit.scoped_visits(prev_range, filters, advanced_filters: adv)
96
+ prev_events = Ahoy::Visit.scoped_events(prev_range, filters, advanced_filters: adv)
97
+ prev_metrics = visit_metrics(prev_visits, prev_events)
98
+
99
+ live_visitors = metrics[:live_visitors]
100
+ uniques = visits.select(:visitor_token).distinct.count
101
+ prev_uniques = prev_visits.select(:visitor_token).distinct.count
102
+
103
+ total_visits = metrics[:total_visits]
104
+ prev_total_visits = prev_metrics[:total_visits]
105
+
106
+ pageviews = metrics[:pageviews]
107
+ prev_pageviews = prev_metrics[:pageviews]
108
+
109
+ stats = [
110
+ { name: "Live visitors", value: live_visitors, graph_metric: :currentVisitors, change: nil, comparison_value: nil },
111
+ { name: "Unique visitors", value: uniques, graph_metric: :visitors, change: change_ratio(prev_uniques, uniques), comparison_value: prev_uniques },
112
+ { name: "Total visits", value: total_visits, graph_metric: :visits, change: change_ratio(prev_total_visits, total_visits), comparison_value: prev_total_visits },
113
+ { name: "Total pageviews", value: pageviews, graph_metric: :pageviews, change: change_ratio(prev_pageviews, pageviews), comparison_value: prev_pageviews },
114
+ {
115
+ name: "Views per visit",
116
+ value: metrics[:pageviews_per_visit].round(2),
117
+ graph_metric: :views_per_visit,
118
+ change: change_ratio(prev_metrics[:pageviews_per_visit], metrics[:pageviews_per_visit]),
119
+ comparison_value: prev_metrics[:pageviews_per_visit]
120
+ },
121
+ {
122
+ name: "Bounce rate",
123
+ value: metrics[:bounce_rate].round(2),
124
+ graph_metric: :bounce_rate,
125
+ change: change_ratio(prev_metrics[:bounce_rate], metrics[:bounce_rate]),
126
+ comparison_value: prev_metrics[:bounce_rate]
127
+ },
128
+ {
129
+ name: "Visit duration",
130
+ value: metrics[:average_duration].round(1),
131
+ graph_metric: :visit_duration,
132
+ change: change_ratio(prev_metrics[:average_duration], metrics[:average_duration]),
133
+ comparison_value: prev_metrics[:average_duration]
134
+ }
135
+ ]
136
+
137
+ {
138
+ top_stats: stats,
139
+ graphable_metrics: %w[visitors visits pageviews views_per_visit bounce_rate visit_duration],
140
+ meta: { metric_warnings: {}, imports_included: false },
141
+ interval: interval,
142
+ includes_imported: false,
143
+ with_imported_switch: { visible: false, togglable: false, tooltip_msg: nil },
144
+ sample_percent: 100,
145
+ from: range.begin.iso8601,
146
+ to: range.end.iso8601,
147
+ comparing_from: prev_range.begin.iso8601,
148
+ comparing_to: prev_range.end.iso8601
149
+ }
150
+ end
151
+
152
+ # Calculate bounce rate and visit duration for grouped visits
153
+ # grouped_visit_ids: { label => [visit_id, ...] }
154
+ def calculate_group_metrics(grouped_visit_ids, range, filters)
155
+ return {} if grouped_visit_ids.empty?
156
+
157
+ all_visit_ids = grouped_visit_ids.values.flatten
158
+ return {} if all_visit_ids.empty?
159
+
160
+ events_scope = Ahoy::Visit.scoped_events(range, filters)
161
+
162
+ pageviews_by_visit = events_scope
163
+ .where(visit_id: all_visit_ids)
164
+ .group(:visit_id)
165
+ .count
166
+
167
+ non_pv_ids = Ahoy::Event
168
+ .where(visit_id: all_visit_ids)
169
+ .where.not(name: "pageview")
170
+ .distinct
171
+ .pluck(:visit_id)
172
+ .to_set
173
+
174
+ durations_by_visit = events_scope
175
+ .where(visit_id: all_visit_ids)
176
+ .group(:visit_id)
177
+ .pluck(Arel.sql("visit_id, GREATEST(EXTRACT(EPOCH FROM (MAX(time) - MIN(time))), 0) as duration"))
178
+ .to_h
179
+
180
+ grouped_visit_ids.each_with_object({}) do |(name, visit_ids), result|
181
+ denom = visit_ids.size
182
+ if denom <= 0
183
+ result[name] = { bounce_rate: nil, visit_duration: nil }
184
+ else
185
+ bounces = visit_ids.count { |vid| pageviews_by_visit[vid].to_i == 1 && !non_pv_ids.include?(vid) }
186
+ bounce = (bounces.to_f / denom.to_f * 100.0).round(2)
187
+
188
+ with_pv = visit_ids.select { |vid| durations_by_visit.key?(vid) }
189
+ avg_duration = if with_pv.empty?
190
+ 0.0
191
+ else
192
+ with_pv.map { |vid| durations_by_visit[vid].to_f }.sum / with_pv.length
193
+ end
194
+
195
+ result[name] = { bounce_rate: bounce, visit_duration: avg_duration.round(1) }
196
+ end
197
+ end
198
+ end
199
+
200
+ # Unique visitor counts for grouped visit IDs using visitor_token
201
+ def unique_counts_from_grouped_visit_ids(grouped_visit_ids, visits_relation)
202
+ return {} if grouped_visit_ids.empty?
203
+ all_ids = grouped_visit_ids.values.flatten
204
+ return {} if all_ids.empty?
205
+ token_by_id = visits_relation.where(id: all_ids).pluck(:id, :visitor_token).to_h
206
+ grouped_visit_ids.transform_values do |ids|
207
+ ids.filter_map { |vid| token_by_id[vid] }.uniq.size
208
+ end
209
+ end
210
+
211
+ # Compute conversions per group and conversion_rate
212
+ def conversions_and_rates(grouped_visit_ids, visits_relation, range, filters, goal_name)
213
+ return [ {}, {} ] if grouped_visit_ids.blank? || goal_name.blank?
214
+ all_ids = grouped_visit_ids.values.flatten.uniq
215
+ return [ {}, {} ] if all_ids.empty?
216
+
217
+ token_by_id = visits_relation.where(id: all_ids).pluck(:id, :visitor_token).to_h
218
+
219
+ goal_visit_ids = Ahoy::Event
220
+ .joins(:visit)
221
+ .merge(Ahoy::Visit.filtered_visits(filters))
222
+ .where(name: goal_name, time: range, visit_id: all_ids)
223
+ .distinct
224
+ .pluck(:visit_id)
225
+ .to_set
226
+
227
+ uniques_by_group = unique_counts_from_grouped_visit_ids(grouped_visit_ids, visits_relation)
228
+
229
+ conversions = {}
230
+ cr = {}
231
+ grouped_visit_ids.each do |name, ids|
232
+ tokens = ids.select { |vid| goal_visit_ids.include?(vid) }.filter_map { |vid| token_by_id[vid] }.uniq
233
+ conversions[name] = tokens.size
234
+ denom = uniques_by_group[name].to_i
235
+ cr[name] = denom > 0 ? ((conversions[name].to_f / denom) * 100.0).round(2) : nil
236
+ end
237
+
238
+ [ conversions, cr ]
239
+ end
240
+
241
+ # Search Terms (demo via referrer parsing)
242
+ def search_terms_payload(query, limit:, page:, search: nil, order_by: nil)
243
+ range, = Ahoy::Visit.range_and_interval_for(query[:period], nil, query)
244
+ filters = query[:filters] || {}
245
+
246
+ visits = Ahoy::Visit.scoped_visits(range, filters)
247
+ .where("referring_domain ~* ?", 'google\\.')
248
+ .where.not(referrer: nil)
249
+
250
+ rows = visits
251
+ .pluck(:id, :referrer)
252
+ .map do |id, ref|
253
+ begin
254
+ uri = URI.parse(ref)
255
+ next nil unless uri.query
256
+ q = CGI.parse(uri.query)["q"]&.first
257
+ next nil if q.blank?
258
+ [ q.downcase.strip, id ]
259
+ rescue URI::InvalidURIError
260
+ nil
261
+ end
262
+ end
263
+ .compact
264
+
265
+ grouped = Hash.new { |h, k| h[k] = [] }
266
+ rows.each { |term, vid| grouped[term] << vid }
267
+
268
+ if search.present?
269
+ needle = search.downcase
270
+ grouped.select! { |term, _| term.include?(needle) }
271
+ end
272
+
273
+ counts = grouped.transform_values(&:size)
274
+
275
+ sorted_terms = if order_by
276
+ metric, dir = order_by
277
+ dir = (dir&.downcase == "asc") ? "asc" : "desc"
278
+ case metric
279
+ when "name"
280
+ names = counts.keys.sort
281
+ names.reverse! if dir == "desc"; names
282
+ when "visitors", nil
283
+ names = counts.sort_by { |k, v| [ v, k ] }.map(&:first)
284
+ names.reverse! if dir == "desc"; names
285
+ when "impressions", "ctr", "position"
286
+ derived = counts.each_with_object({}) do |(k2, v2), h|
287
+ h[k2.to_s] = fake_gsc_metrics_for(k: nil, term: k2, visitors: v2)
288
+ end
289
+ names = counts.keys.sort_by do |k|
290
+ val = derived[k.to_s][metric.to_sym]
291
+ [ val || -Float::INFINITY, k ]
292
+ end
293
+ names.reverse! if dir == "desc"; names
294
+ when "bounce_rate", "visit_duration"
295
+ metrics_all = calculate_group_metrics(grouped, range, filters)
296
+ names = counts.keys.sort_by do |k|
297
+ val = metrics_all.dig(k, metric.to_sym)
298
+ [ val || -Float::INFINITY, k ]
299
+ end
300
+ names.reverse! if dir == "desc"; names
301
+ else
302
+ counts.sort_by { |k, v| [ v, k ] }.map(&:first).reverse
303
+ end
304
+ else
305
+ counts.sort_by { |k, v| [ v, k ] }.map(&:first).reverse
306
+ end
307
+
308
+ paged_names, has_more = Ahoy::Visit.paginate_names(sorted_terms, limit: limit, page: page)
309
+
310
+ page_visit_ids = grouped.slice(*paged_names)
311
+ group_metrics = calculate_group_metrics(page_visit_ids, range, filters)
312
+
313
+ results = paged_names.map do |term|
314
+ visitors = counts[term]
315
+ gsc = fake_gsc_metrics_for(k: nil, term: term, visitors: visitors)
316
+ {
317
+ name: term,
318
+ visitors: visitors,
319
+ impressions: gsc[:impressions],
320
+ ctr: gsc[:ctr],
321
+ position: gsc[:position]
322
+ }
323
+ end
324
+
325
+ { results: results, metrics: %i[visitors impressions ctr position], meta: { has_more: has_more, skip_imported_reason: nil } }
326
+ end
327
+
328
+ def fake_gsc_metrics_for(k:, term:, visitors:)
329
+ seed_str = (term || k || "").to_s
330
+ crc = Zlib.crc32(seed_str)
331
+ factor = 1.5 + (crc % 4850) / 100.0
332
+ impressions = [ (visitors * factor).round, visitors ].max
333
+ ctr = (visitors.to_f / impressions.to_f) * 100.0
334
+ pos_int = (crc % 10) + 1
335
+ pos_dec = ((crc / 10) % 10) / 10.0
336
+ position = (pos_int + pos_dec).round(1)
337
+ { impressions: impressions, ctr: ctr, position: position }
338
+ end
339
+
340
+ def behaviors_payload(query, limit: nil, page: nil, search: nil, order_by: nil)
341
+ mode = (query[:mode] || "conversions").to_s
342
+ range, = Ahoy::Visit.range_and_interval_for(query[:period], nil, query)
343
+ filters = query[:filters] || {}
344
+ visits = Ahoy::Visit.scoped_visits(range, filters)
345
+
346
+ case mode
347
+ when "props"
348
+ props = {
349
+ "utm_source" => visits.group(:utm_source).count,
350
+ "utm_medium" => visits.group(:utm_medium).count,
351
+ "utm_campaign" => visits.group(:utm_campaign).count
352
+ }
353
+ flat = props.flat_map do |k, counts|
354
+ counts.map { |val, n| { name: k.humanize, value: val.to_s.presence || "(none)", visitors: n, percentage: 0.0 } }
355
+ end
356
+ { list: { results: flat, metrics: %i[visitors percentage], meta: { has_more: false, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query) } }, goal_highlighted: nil }
357
+ when "funnels"
358
+ names = AhoyAnalytics::Funnel.order(:name).pluck(:name)
359
+ active_name = query[:funnel].presence || names.first
360
+ return { funnels: names, active: { name: "", steps: [] } } if active_name.blank?
361
+
362
+ funnel = AhoyAnalytics::Funnel.find_by(name: active_name)
363
+ return { funnels: names, active: { name: "", steps: [] } } unless funnel
364
+
365
+ ev_rows = Ahoy::Event
366
+ .joins(:visit)
367
+ .merge(Ahoy::Visit.filtered_visits(filters))
368
+ .where(time: range)
369
+ .pluck(Arel.sql("ahoy_events.visit_id, ahoy_events.time, ahoy_events.name, COALESCE(ahoy_events.properties->>'page', '')"))
370
+
371
+ by_visit = Hash.new { |h, k| h[k] = [] }
372
+ ev_rows.each { |vid, t, n, pg| by_visit[vid] << [ (t.respond_to?(:to_time) ? t.to_time : t), n.to_s, pg.to_s ] }
373
+ by_visit.each_value { |arr| arr.sort_by!(&:first) }
374
+
375
+ token_by_visit = visits.pluck(:id, :visitor_token).to_h
376
+
377
+ sets = Array.new(funnel.steps.length) { Set.new }
378
+ by_visit.each do |vid, arr|
379
+ next if arr.empty?
380
+ step_index = 0
381
+ current_time = Time.at(0)
382
+ while step_index < funnel.steps.length
383
+ step = funnel.steps[step_index].to_h.with_indifferent_access
384
+ typ = step[:type].to_s
385
+ match = step[:match].to_s
386
+ value = step[:value].to_s
387
+ found = false
388
+ arr.each do |t, name, page|
389
+ next if t < current_time
390
+ ok = case typ
391
+ when "event"
392
+ (match == "equals" ? (name == value) : name.include?(value))
393
+ when "page"
394
+ (match == "contains" ? page.include?(value) : page == value)
395
+ else
396
+ false
397
+ end
398
+ if ok
399
+ current_time = t
400
+ found = true
401
+ break
402
+ end
403
+ end
404
+ break unless found
405
+ token = token_by_visit[vid]
406
+ sets[step_index] << token if token.present?
407
+ step_index += 1
408
+ end
409
+ end
410
+
411
+ first_visitors = sets.first&.size.to_i
412
+ steps = funnel.steps.each_with_index.map do |s, idx|
413
+ s = s.with_indifferent_access
414
+ v = sets[idx].size
415
+ rate = first_visitors > 0 ? ((v.to_f / first_visitors.to_f) * 100.0) : 0.0
416
+ label = s[:name] || s[:type].to_s.capitalize
417
+ { name: label, visitors: v, conversion_rate: rate.round(2) }
418
+ end
419
+
420
+ { funnels: names, active: { name: funnel.name, steps: steps } }
421
+ else
422
+ events = Ahoy::Event
423
+ .joins(:visit)
424
+ .merge(Ahoy::Visit.filtered_visits(filters))
425
+ .where(time: range)
426
+ .where.not(name: [ "pageview", "engagement" ])
427
+
428
+ grouped_visit_ids = events.group(:name).pluck(:name, Arel.sql("ARRAY_AGG(ahoy_events.visit_id)")).to_h
429
+ uniques_by_group = unique_counts_from_grouped_visit_ids(grouped_visit_ids, visits)
430
+
431
+ total_uniques = visits.select(:visitor_token).distinct.count
432
+ total_uniques = 1 if total_uniques <= 0
433
+
434
+ conversions = {}
435
+ rows = grouped_visit_ids.map do |goal_name, visit_ids|
436
+ token_by_id = visits.where(id: visit_ids).pluck(:id, :visitor_token).to_h
437
+ conv = visit_ids.filter_map { |vid| token_by_id[vid] }.uniq.size
438
+ conversions[goal_name] = conv
439
+ rate = (conv.to_f / total_uniques.to_f) * 100.0
440
+ { name: goal_name.to_s, visitors: conv, conversion_rate: rate.round(2) }
441
+ end
442
+
443
+ if search.present?
444
+ needle = search.downcase
445
+ rows.select! { |r| r[:name].downcase.include?(needle) }
446
+ end
447
+
448
+ rows = if order_by
449
+ metric, dir = order_by
450
+ dir = (dir&.downcase == "asc") ? 1 : -1
451
+ rows.sort_by do |r|
452
+ key = case metric
453
+ when "name" then r[:name].downcase
454
+ when "conversion_rate" then r[:conversion_rate] || -Float::INFINITY
455
+ else r[:visitors].to_i
456
+ end
457
+ key
458
+ end
459
+ dir == -1 ? rows.reverse : rows
460
+ else
461
+ rows.sort_by { |r| [ -r[:visitors].to_i, r[:name].downcase ] }
462
+ end
463
+
464
+ if limit && page
465
+ window, has_more = Ahoy::Visit.paginate_names(rows, limit: limit, page: page)
466
+ { results: window, metrics: %i[visitors conversion_rate], meta: { has_more: has_more, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query) }, goal_highlighted: nil }
467
+ else
468
+ { results: rows, metrics: %i[visitors conversion_rate], meta: { has_more: false, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query) } }
469
+ end
470
+ end
471
+ end
472
+ end
473
+ end
@@ -0,0 +1,110 @@
1
+ module Ahoy::Visit::Ordering
2
+ extend ActiveSupport::Concern
3
+
4
+ class_methods do
5
+ ALLOWED_METRICS = %w[
6
+ name visitors pageviews percentage bounce_rate visit_duration conversion_rate
7
+ time_on_page scroll_depth impressions ctr position visits exit_rate exits
8
+ ].freeze
9
+
10
+ # Accept both camelCase and snake_case metric names from the UI
11
+ def normalize_metric_key(metric)
12
+ key = metric.to_s
13
+ alias_map = {
14
+ "bounceRate" => "bounce_rate",
15
+ "visitDuration" => "visit_duration",
16
+ "timeOnPage" => "time_on_page",
17
+ "conversionRate" => "conversion_rate",
18
+ "exitRate" => "exit_rate"
19
+ }
20
+ key = alias_map[key] || key
21
+ key.gsub(/([A-Z])/, '_\\1').downcase
22
+ end
23
+
24
+ # Parse Plausible-style order_by parameter: [[metric, direction]]
25
+ # Returns [metric, direction] with metric whitelisted and direction sanitized
26
+ def parsed_order_by(param)
27
+ return nil unless param.present?
28
+
29
+ raw = case param
30
+ when String
31
+ begin
32
+ JSON.parse(param)
33
+ rescue JSON::ParserError
34
+ nil
35
+ end
36
+ when Array
37
+ param
38
+ end
39
+ return nil unless raw.is_a?(Array) && raw.first.is_a?(Array)
40
+
41
+ metric, direction = raw.first
42
+ metric = normalize_metric_key(metric)
43
+ metric = "visitors" unless ALLOWED_METRICS.include?(metric)
44
+ dir = direction.to_s.downcase == "asc" ? "asc" : "desc"
45
+ [ metric, dir ]
46
+ end
47
+
48
+ # Generic ordering for analytics payloads.
49
+ #
50
+ # counts: Hash(name => Integer) as the primary numeric to sort by for
51
+ # simple metrics (visitors/visits/pageviews/exits)
52
+ # metrics_map: Hash(name => { metric_sym => Numeric }) for derived metrics
53
+ # like percentage, bounce_rate, visit_duration, exit_rate
54
+ # order_by: [metric, direction] as returned by parsed_order_by
55
+ #
56
+ # Returns: ordered array of names
57
+ def order_names(counts:, metrics_map: {}, order_by: nil)
58
+ metric, direction = order_by || [ "visitors", "desc" ]
59
+ metric = normalize_metric_key(metric)
60
+ dir = direction.to_s.downcase == "asc" ? :asc : :desc
61
+
62
+ names = counts.keys
63
+
64
+ sorted = case metric
65
+ when "name"
66
+ names.sort_by { |n| n.to_s.downcase }
67
+ when "visitors", "visits", "pageviews", "exits"
68
+ names.sort_by { |n| [ counts[n].to_i, n.to_s.downcase ] }
69
+ when "percentage", "bounce_rate", "visit_duration", "exit_rate"
70
+ names.sort_by do |n|
71
+ v = metrics_map.dig(n, metric.to_sym)
72
+ [ (v.nil? ? -Float::INFINITY : v), n.to_s.downcase ]
73
+ end
74
+ else
75
+ # Fallback to primary counts
76
+ names.sort_by { |n| [ counts[n].to_i, n.to_s.downcase ] }
77
+ end
78
+
79
+ dir == :asc ? sorted : sorted.reverse
80
+ end
81
+
82
+ # Ordering for goal conversion payloads.
83
+ #
84
+ # conversions: Hash(name => Integer) conversion counts per group
85
+ # cr: Hash(name => Float) conversion rates per group
86
+ # order_by: [metric, direction] as returned by parsed_order_by
87
+ #
88
+ # Returns: ordered array of names
89
+ def order_names_with_conversions(conversions:, cr:, order_by: nil)
90
+ return conversions.sort_by { |(n, v)| [ v, n.to_s.downcase ] }.map(&:first).reverse unless order_by
91
+
92
+ metric, direction = order_by
93
+ metric = normalize_metric_key(metric)
94
+ dir = direction.to_s.downcase == "asc" ? :asc : :desc
95
+
96
+ sorted = case metric
97
+ when "name"
98
+ conversions.keys.sort_by { |n| n.to_s.downcase }
99
+ when "visitors"
100
+ conversions.sort_by { |(n, v)| [ v, n.to_s.downcase ] }.map(&:first)
101
+ when "conversion_rate"
102
+ conversions.keys.sort_by { |n| [ cr[n] || -Float::INFINITY, n.to_s.downcase ] }
103
+ else
104
+ conversions.sort_by { |(n, v)| [ v, n.to_s.downcase ] }.map(&:first)
105
+ end
106
+
107
+ dir == :asc ? sorted : sorted.reverse
108
+ end
109
+ end
110
+ end