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,286 @@
1
+ module Ahoy::Visit::Filters
2
+ extend ActiveSupport::Concern
3
+
4
+ class_methods do
5
+ # Build a case-insensitive SQL LIKE pattern that safely treats user input literally
6
+ def like_contains(value)
7
+ "%#{ActiveRecord::Base.sanitize_sql_like(value.to_s.downcase)}%"
8
+ end
9
+
10
+ def normalized_filter(filters, key)
11
+ return unless filters
12
+ value = filters[key]&.to_s
13
+ return unless value
14
+ value.strip.downcase.tr(" ", "-")
15
+ end
16
+
17
+ def filtered_visits(filters, advanced_filters: [])
18
+ scope = Ahoy::Visit.all
19
+
20
+ if filters.present?
21
+ if (source = filters["source"]).present?
22
+ s_down = source.to_s.downcase.strip
23
+ if %w[direct / none (none) direct none directlink].include?(s_down)
24
+ scope = scope.where(referring_domain: [ nil, "" ])
25
+ elsif (pattern = domain_pattern_for_source_label(source))
26
+ aliases = alias_sources_map.select { |_k, v| v.to_s.downcase == s_down }.keys
27
+ if aliases.any?
28
+ scope = scope.where(
29
+ "referring_domain ~* ? OR LOWER(utm_source) IN (?) OR LOWER(utm_source) LIKE ?",
30
+ pattern, aliases, "#{s_down}%"
31
+ )
32
+ else
33
+ scope = scope.where(
34
+ "referring_domain ~* ? OR LOWER(utm_source) = ? OR LOWER(utm_source) LIKE ?",
35
+ pattern, s_down, "#{s_down}%"
36
+ )
37
+ end
38
+ else
39
+ if source.include?(".")
40
+ scope = scope.where(referring_domain: source)
41
+ else
42
+ like = like_contains(source)
43
+ scope = scope.where("LOWER(referring_domain) LIKE ? OR LOWER(utm_source) LIKE ?", like, like)
44
+ end
45
+ end
46
+ elsif normalized_filter(filters, "source") == "direct"
47
+ scope = scope.where(referring_domain: [ nil, "" ])
48
+ end
49
+
50
+ if (channel = filters["channel"]).present?
51
+ val = Ahoy::Visit.connection.quote(channel)
52
+ cond = "(#{Ahoy::Visit::Sources::CHANNEL_CASE_SQL}) = #{val}"
53
+ scope = scope.where(Arel.sql(cond))
54
+ end
55
+ scope = scope.where(referrer: filters["referrer"]) if filters["referrer"].present?
56
+ scope = scope.where(country: filters["country"]) if filters["country"].present?
57
+ scope = scope.where(region: filters["region"]) if filters["region"].present?
58
+ scope = scope.where(city: filters["city"]) if filters["city"].present?
59
+ scope = scope.where(utm_source: filters["utm_source"]) if filters["utm_source"].present?
60
+ scope = scope.where(utm_medium: filters["utm_medium"]) if filters["utm_medium"].present?
61
+ scope = scope.where(utm_campaign: filters["utm_campaign"]) if filters["utm_campaign"].present?
62
+ scope = scope.where(browser: filters["browser"]) if filters["browser"].present?
63
+ scope = scope.where(os: filters["os"]) if filters["os"].present?
64
+ end
65
+
66
+ Array(advanced_filters).each do |op, dim, value|
67
+ if dim == "channel"
68
+ case op
69
+ when "is_not"
70
+ val = Ahoy::Visit.connection.quote(value)
71
+ cond = "(#{Ahoy::Visit::Sources::CHANNEL_CASE_SQL}) <> #{val}"
72
+ scope = scope.where(Arel.sql(cond))
73
+ when "contains"
74
+ pat = Ahoy::Visit.connection.quote(like_contains(value))
75
+ cond = "LOWER((#{Ahoy::Visit::Sources::CHANNEL_CASE_SQL})) LIKE #{pat}"
76
+ scope = scope.where(Arel.sql(cond))
77
+ end
78
+ next
79
+ end
80
+ if dim == "source"
81
+ like = like_contains(value)
82
+ case op
83
+ when "contains"
84
+ scope = scope.where("LOWER(referring_domain) LIKE ? OR LOWER(utm_source) LIKE ?", like, like)
85
+ when "is_not"
86
+ scope = scope.where("NOT (LOWER(referring_domain) LIKE ? OR LOWER(utm_source) LIKE ?)", like, like)
87
+ end
88
+ next
89
+ end
90
+
91
+ column = case dim
92
+ when "referrer" then "referrer"
93
+ when "utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term", "country", "region", "city", "browser", "os"
94
+ dim
95
+ else
96
+ nil
97
+ end
98
+ next unless column
99
+
100
+ if op == "is_not"
101
+ scope = scope.where.not(Arel.sql(column) => value)
102
+ elsif op == "contains"
103
+ scope = scope.where("LOWER(#{column}) LIKE ?", like_contains(value))
104
+ end
105
+ end
106
+
107
+ scope
108
+ end
109
+
110
+ def scoped_visits(range, filters, advanced_filters: [])
111
+ basic_filters = filters.to_h.dup
112
+ exit_page = basic_filters&.delete("exit_page")
113
+ page_eq = basic_filters&.delete("page")
114
+ size_eq = basic_filters&.delete("size")
115
+
116
+ visits = filtered_visits(basic_filters, advanced_filters: advanced_filters).where(started_at: range)
117
+
118
+ if (entry = filters["entry_page"]).present?
119
+ decoded = begin
120
+ CGI.unescape(entry.to_s)
121
+ rescue StandardError
122
+ entry.to_s
123
+ end
124
+ raw = normalized_path_and_query(decoded) || decoded
125
+ label = raw.to_s.split("?").first.presence || "/"
126
+
127
+ expr = "COALESCE(CASE WHEN strpos(regexp_replace(landing_page, '^(https://|http://)[^/]+', ''), chr(63)) > 0 THEN left(regexp_replace(landing_page, '^(https://|http://)[^/]+', ''), strpos(regexp_replace(landing_page, '^(https://|http://)[^/]+', ''), chr(63)) - 1) ELSE NULLIF(regexp_replace(landing_page, '^(https://|http://)[^/]+', ''), '') END, '/')"
128
+ by_landing = visits.where(Arel.sql("#{expr} = ?"), label)
129
+
130
+ candidate_ids = visits
131
+ .where("landing_page IS NULL OR landing_page = '' OR regexp_replace(landing_page, '^(https://|http://)[^/]+', '') SIMILAR TO ?", "(/ahoy%|/cable%|/rails/%|/assets/%|/up%|/jobs%|/webhooks%)")
132
+ .pluck(:id)
133
+
134
+ derived_ids = []
135
+ if candidate_ids.any?
136
+ ev_rows = Ahoy::Event
137
+ .where(name: "pageview", time: range, visit_id: candidate_ids)
138
+ .pluck(Arel.sql("visit_id, time, COALESCE(ahoy_events.properties->>'page', '')"))
139
+ first_page_by_visit = {}
140
+ ev_rows.each do |vid, t, pg|
141
+ prev = first_page_by_visit[vid]
142
+ t_val = t.respond_to?(:to_time) ? t.to_time : t
143
+ if prev.nil? || t_val < prev[0]
144
+ first_page_by_visit[vid] = [ t_val, pg.to_s ]
145
+ end
146
+ end
147
+ first_page_by_visit.each do |vid, (_t, pg)|
148
+ next if pg.to_s.strip.empty?
149
+ lp = normalized_path_only(pg)
150
+ derived_ids << vid if lp.to_s == label
151
+ end
152
+ end
153
+
154
+ visits = by_landing.or(visits.where(id: derived_ids.presence || [ 0 ]))
155
+ end
156
+
157
+ if exit_page.present?
158
+ ids = visit_ids_with_exit_page(range, visits, exit_page)
159
+ visits = visits.where(id: ids.presence || [ 0 ])
160
+ end
161
+
162
+ if page_eq.present?
163
+ sub = Ahoy::Event
164
+ .where(name: "pageview")
165
+ .where(visit_id: visits.select(:id))
166
+ .where(Arel.sql("ahoy_events.properties->>'page' = ?"), page_eq)
167
+ .select(:visit_id)
168
+ .distinct
169
+ visits = visits.where(id: sub)
170
+ end
171
+
172
+ if size_eq.present?
173
+ ids = visit_ids_for_screen_size_categories(visits, [ size_eq ])
174
+ visits = visits.where(id: ids.presence || [ 0 ])
175
+ end
176
+
177
+ Array(advanced_filters).each do |op, dim, value|
178
+ next unless dim == "page"
179
+ next if value.to_s.strip.empty?
180
+ case op
181
+ when "contains"
182
+ sub = Ahoy::Event
183
+ .where(name: "pageview")
184
+ .where(visit_id: visits.select(:id))
185
+ .where("LOWER(ahoy_events.properties->>'page') LIKE ?", like_contains(value))
186
+ .select(:visit_id).distinct
187
+ visits = visits.where(id: sub)
188
+ when "is_not"
189
+ sub = Ahoy::Event
190
+ .where(name: "pageview")
191
+ .where(visit_id: visits.select(:id))
192
+ .where(Arel.sql("ahoy_events.properties->>'page' = ?"), value)
193
+ .select(:visit_id).distinct
194
+ visits = visits.where.not(id: sub)
195
+ end
196
+ end
197
+
198
+ Array(advanced_filters).each do |op, dim, value|
199
+ next unless dim == "size"
200
+ needle = value.to_s.strip.downcase
201
+ next if needle.empty?
202
+
203
+ case op
204
+ when "contains"
205
+ categories = %w[Mobile Tablet Laptop Desktop (not\ set)].select { |c| c.downcase.include?(needle) }
206
+ ids = visit_ids_for_screen_size_categories(visits, categories)
207
+ visits = visits.where(id: ids.presence || [ 0 ])
208
+ when "is_not"
209
+ ids = visit_ids_for_screen_size_categories(visits, [ value.to_s ])
210
+ visits = visits.where.not(id: ids.presence || [ 0 ])
211
+ end
212
+ end
213
+
214
+ visits
215
+ end
216
+
217
+ def scoped_events(range, filters, advanced_filters: [])
218
+ basic_filters = filters.to_h.dup
219
+ exit_page = basic_filters&.delete("exit_page")
220
+
221
+ scope = Ahoy::Event
222
+ .where(name: "pageview", time: range)
223
+ .joins(:visit)
224
+ .merge(filtered_visits(basic_filters, advanced_filters: advanced_filters))
225
+
226
+ if exit_page.present?
227
+ visit_scope = Ahoy::Visit.where(id: scope.select(:visit_id).distinct)
228
+ ids = visit_ids_with_exit_page(range, visit_scope, exit_page)
229
+ scope = scope.where(visit_id: ids.presence || [ 0 ])
230
+ end
231
+
232
+ if filters["page"].present?
233
+ scope = scope.where(Arel.sql("ahoy_events.properties->>'page' = ?"), filters["page"])
234
+ end
235
+
236
+ Array(advanced_filters).each do |op, dim, value|
237
+ next unless dim == "page"
238
+ if op == "is_not"
239
+ scope = scope.where.not(Arel.sql("ahoy_events.properties->>'page' = ?"), value)
240
+ elsif op == "contains"
241
+ scope = scope.where("LOWER(ahoy_events.properties->>'page') LIKE ?", like_contains(value))
242
+ end
243
+ end
244
+
245
+ scope
246
+ end
247
+
248
+ def visit_ids_with_exit_page(range, visits_scope, exit_page)
249
+ return [] if visits_scope.none?
250
+
251
+ expr = "COALESCE(NULLIF(split_part(ahoy_events.properties->>'page', '?', 1), ''), '')"
252
+ rows = Ahoy::Event
253
+ .where(name: "pageview", time: range)
254
+ .where(visit_id: visits_scope.select(:id))
255
+ .pluck(Arel.sql("visit_id, time, #{expr}"))
256
+
257
+ last_page_by_visit = {}
258
+ rows.each do |vid, t, page_name|
259
+ prev = last_page_by_visit[vid]
260
+ t_val = t.respond_to?(:to_time) ? t.to_time : t
261
+ prev_time = prev ? (prev.is_a?(Array) ? prev[0] : prev.first) : nil
262
+ if prev.nil? || t_val > prev_time
263
+ last_page_by_visit[vid] = [ t_val, page_name.to_s ]
264
+ end
265
+ end
266
+
267
+ needle = exit_page.to_s.split("?").first
268
+ last_page_by_visit.filter_map { |vid, (_t, page)| vid if page == needle }
269
+ end
270
+
271
+ def visit_ids_for_screen_size_categories(visits_scope, categories)
272
+ return [] if visits_scope.none?
273
+
274
+ raw = visits_scope.group(:screen_size).pluck(:screen_size, Arel.sql("ARRAY_AGG(id)"))
275
+ categories_down = categories.map(&:to_s)
276
+ selected = []
277
+ raw.each do |screen_size, visit_ids|
278
+ cat = categorize_screen_size(screen_size)
279
+ if categories_down.any? { |c| c.to_s == cat.to_s }
280
+ selected.concat(Array(visit_ids))
281
+ end
282
+ end
283
+ selected
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,36 @@
1
+ module Ahoy::Visit::Imports
2
+ extend ActiveSupport::Concern
3
+
4
+ class_methods do
5
+ def imported_pages_aggregates(range)
6
+ rows = AhoyAnalytics::ImportedPage.where(date: range).group(:page)
7
+ .pluck(:page, Arel.sql("SUM(visitors), SUM(pageviews)"))
8
+ rows.each_with_object({}) do |(page, visitors, pageviews), h|
9
+ name = page.to_s.presence || "(unknown)"
10
+ h[name] = { visitors: visitors.to_i, pageviews: pageviews.to_i }
11
+ end
12
+ end
13
+
14
+ def imported_entry_aggregates(range)
15
+ rows = AhoyAnalytics::ImportedEntryPage.where(date: range).group(:entry_page)
16
+ .pluck(:entry_page, Arel.sql("SUM(visitors), SUM(entrances)"))
17
+ rows.each_with_object({}) do |(page, visitors, entrances), h|
18
+ name = page.to_s.presence || "(unknown)"
19
+ h[name] = { visitors: visitors.to_i, entrances: entrances.to_i }
20
+ end
21
+ end
22
+
23
+ def imported_exit_aggregates(range)
24
+ rows = AhoyAnalytics::ImportedExitPage.where(date: range).group(:exit_page)
25
+ .pluck(:exit_page, Arel.sql("SUM(visitors), SUM(exits), SUM(pageviews)"))
26
+ rows.each_with_object({}) do |(page, visitors, exits, pageviews), h|
27
+ name = page.to_s.presence || "(unknown)"
28
+ h[name] = { visitors: visitors.to_i, exits: exits.to_i, pageviews: pageviews.to_i }
29
+ end
30
+ end
31
+
32
+ def skip_imported_reason(query)
33
+ query[:with_imported] ? "not_supported" : nil
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,276 @@
1
+ module Ahoy::Visit::Locations
2
+ extend ActiveSupport::Concern
3
+
4
+ class_methods do
5
+ def locations_payload(query, limit: nil, page: nil, search: nil, order_by: nil)
6
+ mode = query[:mode] || "map"
7
+ filters = query[:filters] || {}
8
+ range, = Ahoy::Visit.range_and_interval_for(query[:period], nil, query)
9
+ visits = Ahoy::Visit.scoped_visits(range, filters)
10
+ goal = filters["goal"].presence
11
+
12
+ case mode
13
+ when "map"
14
+ counts = visits.group(:country).count("DISTINCT visitor_token")
15
+ map_from_counts(counts)
16
+ when "countries"
17
+ if limit && page
18
+ expr = "COALESCE(country, '(unknown)')"
19
+ pattern = search.present? ? Ahoy::Visit.like_contains(search) : nil
20
+ rel = visits
21
+ rel = rel.where("LOWER(COALESCE(country, '(unknown)')) LIKE ?", pattern) if pattern.present?
22
+ grouped_visit_ids = rel.group(Arel.sql(expr)).pluck(Arel.sql("#{expr}, ARRAY_AGG(ahoy_visits.id)")).to_h
23
+ counts = Ahoy::Visit.unique_counts_from_grouped_visit_ids(grouped_visit_ids, visits)
24
+ total = counts.values.sum.nonzero? || 1
25
+
26
+ if goal.present?
27
+ conversions, cr = Ahoy::Visit.conversions_and_rates(grouped_visit_ids, visits, range, filters, goal)
28
+ sorted_names = Ahoy::Visit.order_names_with_conversions(conversions: conversions, cr: cr, order_by: order_by)
29
+
30
+ paged_names, has_more = Ahoy::Visit.paginate_names(sorted_names, limit: limit, page: page)
31
+
32
+ items = paged_names.map do |code|
33
+ code_str = code.to_s
34
+ name = if code_str.present? && code_str != "(unknown)"
35
+ c = ISO3166::Country.new(code_str.upcase)
36
+ c ? short_country_name(c) : code_str
37
+ else
38
+ "(unknown)"
39
+ end
40
+ { name: name, code: code_str != "(unknown)" ? code_str : nil, visitors: conversions[code] || 0, conversion_rate: cr[code] }.compact
41
+ end
42
+ { results: items, metrics: %i[visitors conversion_rate], meta: { has_more: has_more, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query), metric_labels: { visitors: "Conversions", conversionRate: "Conversion Rate" } } }
43
+ else
44
+ sorted_names = begin
45
+ if order_by && order_by[0] == "percentage"
46
+ perc = counts.keys.index_with { |k| { percentage: (counts[k].to_f / total) } }
47
+ Ahoy::Visit.order_names(counts: counts, metrics_map: perc, order_by: order_by)
48
+ else
49
+ Ahoy::Visit.order_names(counts: counts, metrics_map: {}, order_by: order_by)
50
+ end
51
+ end
52
+
53
+ paged_names, has_more = Ahoy::Visit.paginate_names(sorted_names, limit: limit, page: page)
54
+
55
+ items = paged_names.map do |code|
56
+ v = counts[code]
57
+ code_str = code.to_s
58
+ name = if code_str.present? && code_str != "(unknown)"
59
+ c = ISO3166::Country.new(code_str.upcase)
60
+ c ? short_country_name(c) : code_str
61
+ else
62
+ "(unknown)"
63
+ end
64
+ { name: name, code: code_str != "(unknown)" ? code_str : nil, visitors: v, percentage: (v.to_f / total).round(3) }.compact
65
+ end
66
+ { results: items, metrics: %i[visitors percentage], meta: { has_more: has_more, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query), metric_labels: { percentage: "Percentage" } } }
67
+ end
68
+ else
69
+ counts = visits.group(:country).count("DISTINCT visitor_token")
70
+ items = counts.map do |code, v|
71
+ code_str = code.to_s
72
+ if code_str.present?
73
+ c = ISO3166::Country.new(code_str.upcase)
74
+ if c
75
+ { name: short_country_name(c), code: c.alpha2, visitors: v }
76
+ else
77
+ { name: code_str, visitors: v }
78
+ end
79
+ else
80
+ { name: "(unknown)", visitors: v }
81
+ end
82
+ end
83
+ items = items.sort_by { |it| [ -it[:visitors].to_i, it[:name].to_s ] }
84
+ { results: items, metrics: %i[visitors], meta: { has_more: false, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query) } }
85
+ end
86
+ when "regions"
87
+ if limit && page
88
+ expr = "COALESCE(region, '(unknown)')"
89
+ pattern = search.present? ? Ahoy::Visit.like_contains(search) : nil
90
+ rel = visits
91
+ rel = rel.where("LOWER(COALESCE(region, '(unknown)')) LIKE ?", pattern) if pattern.present?
92
+ grouped_visit_ids = rel.group(Arel.sql(expr)).pluck(Arel.sql("#{expr}, ARRAY_AGG(ahoy_visits.id)")).to_h
93
+ counts = Ahoy::Visit.unique_counts_from_grouped_visit_ids(grouped_visit_ids, visits)
94
+
95
+ if goal.present?
96
+ conversions, cr = Ahoy::Visit.conversions_and_rates(grouped_visit_ids, visits, range, filters, goal)
97
+ sorted_names = Ahoy::Visit.order_names_with_conversions(conversions: conversions, cr: cr, order_by: order_by)
98
+
99
+ paged_names, has_more = Ahoy::Visit.paginate_names(sorted_names, limit: limit, page: page)
100
+ flags_by_region = country_flags_for_grouped(grouped_visit_ids.slice(*paged_names), visits, :region, filters)
101
+ results = paged_names.map do |name|
102
+ { name: name.to_s.presence || "(none)", visitors: conversions[name] || 0, conversion_rate: cr[name], country_flag: flags_by_region[name] }.compact
103
+ end
104
+ { results: results, metrics: %i[visitors conversion_rate], meta: { has_more: has_more, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query), metric_labels: { visitors: "Conversions", conversionRate: "Conversion Rate" } } }
105
+ else
106
+ sorted_names = Ahoy::Visit.order_names(counts: counts, metrics_map: {}, order_by: order_by)
107
+
108
+ paged_names, has_more = Ahoy::Visit.paginate_names(sorted_names, limit: limit, page: page)
109
+ flags_by_region = country_flags_for_grouped(grouped_visit_ids.slice(*paged_names), visits, :region, filters)
110
+ results = paged_names.map do |name|
111
+ v = counts[name]
112
+ { name: name.to_s.presence || "(none)", visitors: v, country_flag: flags_by_region[name] }.compact
113
+ end
114
+ { results: results, metrics: %i[visitors], meta: { has_more: has_more, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query) } }
115
+ end
116
+ else
117
+ counts = visits.group(:region).count("DISTINCT visitor_token")
118
+ flags_by_region = if filters[:country].present?
119
+ code = filters[:country].to_s.upcase
120
+ flag = emoji_flag_for(code)
121
+ counts.keys.each_with_object({}) { |r, h| h[r] = flag }
122
+ else
123
+ pairs = visits.group(:region, :country).count("DISTINCT visitor_token")
124
+ dominant = Hash.new { |h, k| h[k] = { country: nil, count: -1 } }
125
+ pairs.each do |(region, country), c|
126
+ next if region.blank?
127
+ cur = dominant[region]
128
+ if c.to_i > cur[:count].to_i
129
+ dominant[region] = { country: country, count: c.to_i }
130
+ end
131
+ end
132
+ dominant.transform_values { |v| emoji_flag_for(v[:country].to_s.upcase) }
133
+ end
134
+
135
+ rows = counts.sort_by { |_, v| -v.to_i }.map do |(name, v)|
136
+ label = name.to_s.presence || "(unknown)"
137
+ h = { name: label, visitors: v }
138
+ flag = flags_by_region[name]
139
+ flag.present? ? h.merge(country_flag: flag) : h
140
+ end
141
+ { results: rows, metrics: %i[visitors], meta: { has_more: false, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query) } }
142
+ end
143
+ when "cities"
144
+ if limit && page
145
+ expr = "COALESCE(city, '(unknown)')"
146
+ pattern = search.present? ? Ahoy::Visit.like_contains(search) : nil
147
+ rel = visits
148
+ rel = rel.where("LOWER(COALESCE(city, '(unknown)')) LIKE ?", pattern) if pattern.present?
149
+ grouped_visit_ids = rel.group(Arel.sql(expr)).pluck(Arel.sql("#{expr}, ARRAY_AGG(ahoy_visits.id)")).to_h
150
+ counts = Ahoy::Visit.unique_counts_from_grouped_visit_ids(grouped_visit_ids, visits)
151
+
152
+ if goal.present?
153
+ conversions, cr = Ahoy::Visit.conversions_and_rates(grouped_visit_ids, visits, range, filters, goal)
154
+ sorted_names = Ahoy::Visit.order_names_with_conversions(conversions: conversions, cr: cr, order_by: order_by)
155
+
156
+ paged_names, has_more = Ahoy::Visit.paginate_names(sorted_names, limit: limit, page: page)
157
+ flags_by_city = country_flags_for_grouped(grouped_visit_ids.slice(*paged_names), visits, :city, filters)
158
+ results = paged_names.map do |name|
159
+ { name: name.to_s.presence || "(none)", visitors: conversions[name] || 0, conversion_rate: cr[name], country_flag: flags_by_city[name] }.compact
160
+ end
161
+ { results: results, metrics: %i[visitors conversion_rate], meta: { has_more: has_more, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query), metric_labels: { visitors: "Conversions", conversionRate: "Conversion Rate" } } }
162
+ else
163
+ sorted_names = Ahoy::Visit.order_names(counts: counts, metrics_map: {}, order_by: order_by)
164
+
165
+ paged_names, has_more = Ahoy::Visit.paginate_names(sorted_names, limit: limit, page: page)
166
+ flags_by_city = country_flags_for_grouped(grouped_visit_ids.slice(*paged_names), visits, :city, filters)
167
+ results = paged_names.map do |name|
168
+ v = counts[name]
169
+ { name: name.to_s.presence || "(none)", visitors: v, country_flag: flags_by_city[name] }.compact
170
+ end
171
+ { results: results, metrics: %i[visitors], meta: { has_more: has_more, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query) } }
172
+ end
173
+ else
174
+ counts = visits.group(:city).count("DISTINCT visitor_token")
175
+ flags_by_city = if filters[:country].present?
176
+ code = filters[:country].to_s.upcase
177
+ flag = emoji_flag_for(code)
178
+ counts.keys.each_with_object({}) { |c, h| h[c] = flag }
179
+ else
180
+ pairs = visits.group(:city, :country).count("DISTINCT visitor_token")
181
+ dominant = Hash.new { |h, k| h[k] = { country: nil, count: -1 } }
182
+ pairs.each do |(city, country), c|
183
+ next if city.blank?
184
+ cur = dominant[city]
185
+ if c.to_i > cur[:count].to_i
186
+ dominant[city] = { country: country, count: c.to_i }
187
+ end
188
+ end
189
+ dominant.transform_values { |v| emoji_flag_for(v[:country].to_s.upcase) }
190
+ end
191
+
192
+ rows = counts.sort_by { |_, v| -v.to_i }.map do |(name, v)|
193
+ label = name.to_s.presence || "(unknown)"
194
+ h = { name: label, visitors: v }
195
+ flag = flags_by_city[name]
196
+ flag.present? ? h.merge(country_flag: flag) : h
197
+ end
198
+ { results: rows, metrics: %i[visitors], meta: { has_more: false, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query) } }
199
+ end
200
+ else
201
+ counts = visits.group(:country).count
202
+ rows = counts.sort_by { |_, v| -v }.map { |(name, v)| { name: name.to_s.presence || "(none)", visitors: v } }
203
+ { results: rows, metrics: %i[visitors], meta: { has_more: false, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query) } }
204
+ end
205
+ end
206
+
207
+ def map_from_counts(counts)
208
+ total = counts.values.sum
209
+ results = counts
210
+ .sort_by { |_, v| -v }
211
+ .filter_map do |(code, visitors)|
212
+ next if code.blank?
213
+ normalized = code.to_s.upcase
214
+ country = ISO3166::Country.new(normalized)
215
+ next unless country
216
+ { alpha3: country.alpha3, alpha2: country.alpha2, numeric: country.number, code: normalized, name: short_country_name(country), visitors: visitors }
217
+ end
218
+ { map: { results: results, meta: { total: total } } }
219
+ end
220
+
221
+ def emoji_flag_for(code)
222
+ return nil if code.blank?
223
+ iso2 = code.to_s.upcase
224
+ return nil unless iso2.match?(/\A[A-Z]{2}\z/)
225
+ base = 0x1F1E6
226
+ iso2.each_char.map { |ch| (base + (ch.ord - "A".ord)).chr(Encoding::UTF_8) }.join
227
+ rescue
228
+ nil
229
+ end
230
+
231
+ def country_flags_for_grouped(grouped_visit_ids, visits_relation, dimension, filters)
232
+ return {} if grouped_visit_ids.blank?
233
+ if filters[:country].present?
234
+ flag = emoji_flag_for(filters[:country].to_s.upcase)
235
+ return grouped_visit_ids.keys.each_with_object({}) { |name, h| h[name] = flag }
236
+ end
237
+ all_ids = grouped_visit_ids.values.flatten.uniq
238
+ return {} if all_ids.empty?
239
+ pairs = visits_relation.where(id: all_ids).group(dimension, :country).count
240
+ best = {}
241
+ pairs.each do |(name, country), c|
242
+ next if name.blank?
243
+ prev = best[name]
244
+ if prev.nil? || c.to_i > prev[:count].to_i
245
+ best[name] = { country: country, count: c.to_i }
246
+ end
247
+ end
248
+ best.transform_values { |v| emoji_flag_for(v[:country].to_s.upcase) }
249
+ rescue
250
+ {}
251
+ end
252
+
253
+ def alpha3_for(code)
254
+ country = ISO3166::Country.new(code)
255
+ country&.alpha3 || code
256
+ end
257
+
258
+ def country_name_for(code)
259
+ country = ISO3166::Country.new(code)
260
+ country ? short_country_name(country) : code
261
+ end
262
+
263
+ def short_country_name(country)
264
+ return "" unless country
265
+ name = country.respond_to?(:common_name) && country.common_name.present? ? country.common_name : country.iso_short_name.to_s
266
+ name = name.to_s.gsub(/\s*\(the\)\s*\z/i, "").gsub(/\s{2,}/, " ").strip
267
+ case name
268
+ when "Viet Nam" then "Vietnam"
269
+ when "Korea (Republic of)" then "South Korea"
270
+ when "Korea (Democratic People's Republic of)" then "North Korea"
271
+ else
272
+ name
273
+ end
274
+ end
275
+ end
276
+ end