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,418 @@
1
+ module Ahoy::Visit::Sources
2
+ extend ActiveSupport::Concern
3
+
4
+ class_methods do
5
+ CHANNEL_CASE_SQL = <<~SQL.squish.freeze
6
+ CASE
7
+ WHEN lower(utm_medium) IN ('cpc','ppc','paid','ads') THEN 'Paid Search'
8
+ WHEN lower(utm_medium) IN ('paid_social','social_paid') THEN 'Paid Social'
9
+ WHEN lower(utm_medium) IN ('display','banner','expandable','interstitial','cpm') THEN 'Display'
10
+ WHEN lower(utm_medium) = 'affiliate' THEN 'Affiliates'
11
+ WHEN lower(utm_campaign) LIKE '%cross-network%' THEN 'Cross-network'
12
+ WHEN (referring_domain ~* '(google\\.|bing\\.)' AND landing_page ILIKE '%gclid=%') THEN 'Paid Search'
13
+ WHEN (referring_domain ~* '(google\\.|bing\\.)' AND landing_page ILIKE '%msclkid=%') THEN 'Paid Search'
14
+ WHEN lower(utm_source) ~ '(adwords|googleads|ga-ads|bing-ads|msads|search-ads)' THEN 'Paid Search'
15
+ WHEN lower(utm_source) ~ '(fb[_-]?ad|facebook[_-]?ads?|meta[-_]?ads?|instagram[_-]?ads?|ig[-_]?ads?|tiktok[-_]?ads?|tt[-_]?ads?|linkedin[-_]?ads?|twitter[-_]?ads?|x[-_]?ads?)' THEN 'Paid Social'
16
+ WHEN lower(utm_medium) ~ 'e[-_ ]?mail|newsletter' THEN 'Email'
17
+ WHEN lower(utm_source) ~ 'e[-_ ]?mail|newsletter' THEN 'Email'
18
+ WHEN referring_domain ~* '(^|\\.)mail\\.google\\.com$|(^|\\.)gmail\\.' THEN 'Email'
19
+ WHEN referring_domain ~* '(^|\\.)mail\\.yahoo\\.' THEN 'Email'
20
+ WHEN referring_domain ~* '(^|\\.)outlook\\.|(^|\\.)live\\.|(^|\\.)office\\.com' THEN 'Email'
21
+ WHEN lower(COALESCE(referring_domain, '')) IN ('', 'localhost')
22
+ AND lower(COALESCE(utm_source, '')) ~ '(facebook|instagram|twitter|x$|x\\.com|linkedin|reddit|tiktok|discord|quora|weibo|vk(\\.com)?|pinterest)'
23
+ THEN 'Organic Social'
24
+ WHEN lower(COALESCE(referring_domain, '')) IN ('', 'localhost')
25
+ AND lower(COALESCE(utm_source, '')) ~ '(google|bing|duckduckgo|yahoo|baidu|yandex|naver|seznam|sogou|startpage|perplexity|chatgpt)'
26
+ THEN 'Organic Search'
27
+ WHEN lower(COALESCE(referring_domain, '')) IN ('', 'localhost')
28
+ AND lower(COALESCE(utm_source, '')) ~ '(youtube|youtu\\.be|vimeo|twitch|dailymotion|youku|bilibili)'
29
+ THEN 'Organic Video'
30
+ WHEN lower(COALESCE(utm_source, '')) IN ('direct','directlink','(direct)','none','(none)') THEN 'Direct'
31
+ WHEN lower(COALESCE(utm_medium, '')) IN ('direct','directlink','(direct)','none','(none)') THEN 'Direct'
32
+ WHEN referring_domain IS NULL OR referring_domain = '' THEN 'Direct'
33
+ WHEN lower(COALESCE(referring_domain, '')) = lower(COALESCE(hostname, '')) THEN 'Direct'
34
+ WHEN lower(COALESCE(referring_domain, '')) = lower(
35
+ COALESCE(
36
+ NULLIF(regexp_replace(landing_page, '^(https?://)([^/]+).*$','\\2'),''),
37
+ ''
38
+ )
39
+ ) THEN 'Direct'
40
+ WHEN lower(utm_medium) LIKE '%video%' THEN 'Organic Video'
41
+ WHEN referring_domain ~* '(youtube\\.|youtu\\.be|vimeo\\.|twitch\\.|dailymotion\\.|youku\\.|bilibili\\.)' THEN 'Organic Video'
42
+ WHEN (referring_domain ~* '(youtube\\.|youtu\\.be|vimeo\\.|twitch\\.|dailymotion\\.|youku\\.|bilibili\\.)'
43
+ AND lower(utm_medium) ~ '(^.*cp.*|ppc|retargeting|paid.*)') THEN 'Paid Video'
44
+ WHEN referring_domain ~* '(google\\.|bing\\.|duckduckgo\\.|yahoo\\.|baidu\\.|yandex\\.|naver\\.|seznam\\.|sogou\\.|startpage\\.|perplexity\\.|chatgpt\\.)' THEN 'Organic Search'
45
+ WHEN referring_domain ~* '(facebook\\.|instagram\\.|twitter\\.|x\\.com|linkedin\\.|reddit\\.|tiktok\\.|discord\\.|quora\\.|weibo\\.|vk\\.com|pinterest\\.)' THEN 'Organic Social'
46
+ WHEN lower(utm_medium) IN ('social','social-network','social-media','sm','social network','social media') THEN 'Organic Social'
47
+ WHEN (lower(utm_campaign) ~ '(^|[^a-df-z])shop|shopping') AND lower(utm_medium) ~ '(^.*cp.*|ppc|retargeting|paid.*)' THEN 'Paid Shopping'
48
+ WHEN lower(utm_campaign) ~ '(^|[^a-df-z])shop|shopping' THEN 'Organic Shopping'
49
+ WHEN lower(utm_medium) = 'audio' THEN 'Audio'
50
+ WHEN lower(utm_source) = 'sms' OR lower(utm_medium) = 'sms' THEN 'SMS'
51
+ WHEN right(lower(utm_medium), 4) = 'push' OR lower(utm_medium) LIKE '%mobile%' OR lower(utm_medium) LIKE '%notification%' OR lower(utm_source) = 'firebase' THEN 'Mobile Push Notifications'
52
+ WHEN lower(utm_medium) ~ '(^.*cp.*|ppc|retargeting|paid.*)' THEN 'Paid Other'
53
+ WHEN lower(utm_source) IN ('github','stack','stackoverflow','hn','hackernews') OR referring_domain ~* '(github\\.|stackoverflow\\.|news\\.ycombinator\\.com$)' THEN 'Developer'
54
+ ELSE 'Referral'
55
+ END
56
+ SQL
57
+ def paid_source_search_regex
58
+ "(adwords|googleads|ga-ads|bing-ads|msads|search-ads)"
59
+ end
60
+
61
+ def paid_source_social_regex
62
+ "(fb[_-]?ad|facebook[_-]?ads?|meta[-_]?ads?|instagram[_-]?ads?|ig[-_]?ads?|tiktok[-_]?ads?|tt[-_]?ads?|linkedin[-_]?ads?|twitter[-_]?ads?|x[-_]?ads?)"
63
+ end
64
+
65
+ def utm_medium_expr
66
+ <<~SQL
67
+ COALESCE(
68
+ NULLIF(utm_medium, ''),
69
+ CASE
70
+ WHEN landing_page ILIKE '%gclid=%' THEN '(gclid)'
71
+ WHEN landing_page ILIKE '%msclkid=%' THEN '(msclkid)'
72
+ ELSE '(not set)'
73
+ END
74
+ )
75
+ SQL
76
+ end
77
+
78
+ def normalize_source_label(domain)
79
+ host = domain.to_s.downcase.strip
80
+ return "Direct / None" if host.blank?
81
+
82
+ Analytics::SourceCatalog::SOURCE_MAP&.each do |label, regex|
83
+ return label if host.match?(regex)
84
+ end
85
+ host
86
+ end
87
+
88
+ def domain_pattern_for_source_label(label)
89
+ key = label.to_s.strip
90
+ map = Analytics::SourceCatalog::SOURCE_MAP || {}
91
+ return map[key]&.source if map[key]
92
+ alt = key.gsub(" ", "")
93
+ return map[alt]&.source if map[alt]
94
+ nil
95
+ end
96
+
97
+ def alias_sources_map
98
+ Rails.configuration.x.analytics.alias_sources_map || {}
99
+ end
100
+
101
+ def paid_sources_set
102
+ Rails.configuration.x.analytics.paid_sources_set || Set.new
103
+ end
104
+
105
+ def direct_utm?(value)
106
+ v = value.to_s.strip.downcase
107
+ return false if v.empty?
108
+ %w[direct directlink (direct) none (none)].include?(v)
109
+ end
110
+
111
+ def channel_case
112
+ Arel.sql(CHANNEL_CASE_SQL)
113
+ end
114
+
115
+ def sources_payload(query, limit: nil, page: nil, search: nil, order_by: nil)
116
+ mode = query[:mode] || "all"
117
+ filters = query[:filters] || {}
118
+ range, = Ahoy::Visit.range_and_interval_for(query[:period], nil, query)
119
+ visits = Ahoy::Visit.scoped_visits(range, filters)
120
+ goal = filters["goal"].presence
121
+
122
+ expr, where_clause = case mode
123
+ when "channels"
124
+ [ CHANNEL_CASE_SQL, "LOWER(#{CHANNEL_CASE_SQL}) LIKE ?" ]
125
+ when "referrers"
126
+ [ "COALESCE(referring_domain, 'Direct / None')", "LOWER(COALESCE(referring_domain, 'Direct / None')) LIKE ?" ]
127
+ when "all"
128
+ [ "COALESCE(referring_domain, '')", nil ]
129
+ when "utm-medium"
130
+ [ utm_medium_expr, "LOWER(#{utm_medium_expr}) LIKE ?" ]
131
+ when "utm-source"
132
+ [ "utm_source", "LOWER(utm_source) LIKE ?" ]
133
+ when "utm-campaign"
134
+ [ "utm_campaign", "LOWER(utm_campaign) LIKE ?" ]
135
+ when "utm-content"
136
+ [ "utm_content", "LOWER(utm_content) LIKE ?" ]
137
+ when "utm-term", "search-terms"
138
+ [ "utm_term", "LOWER(utm_term) LIKE ?" ]
139
+ else
140
+ [ "COALESCE(referring_domain, 'Direct / None')", "LOWER(COALESCE(referring_domain, 'Direct / None')) LIKE ?" ]
141
+ end
142
+
143
+ if limit && page
144
+ pattern = search.present? ? Ahoy::Visit.like_contains(search) : nil
145
+ rel = visits
146
+ if mode == "channels" && pattern.present?
147
+ pat = Ahoy::Visit.connection.quote(pattern)
148
+ rel = rel.where(Arel.sql("LOWER((#{CHANNEL_CASE_SQL})) LIKE #{pat}"))
149
+ elsif where_clause && pattern.present?
150
+ rel = rel.where([ where_clause, pattern ])
151
+ elsif mode == "all" && pattern.present?
152
+ rel = rel.where(
153
+ "LOWER(COALESCE(referring_domain, '')) LIKE ? OR LOWER(COALESCE(utm_source, '')) LIKE ?",
154
+ pattern, pattern
155
+ )
156
+ end
157
+
158
+ if mode == "all"
159
+ expr_tag = "COALESCE(utm_source, '')"
160
+ expr_dom = "COALESCE(referring_domain, '')"
161
+ rows = rel
162
+ .group(Arel.sql("#{expr_tag}, #{expr_dom}"))
163
+ .pluck(Arel.sql("#{expr_tag}, #{expr_dom}, ARRAY_AGG(ahoy_visits.id)"))
164
+
165
+ grouped_visit_ids = Hash.new { |h, k| h[k] = [] }
166
+ rows.each do |tag, dom, ids|
167
+ t = tag.to_s.strip
168
+ d = dom.to_s.strip
169
+ label = nil
170
+ if d.present?
171
+ brand = normalize_source_label(d)
172
+ if %w[Gmail Outlook.com Yahoo! Mail Proton Mail iCloud Mail].include?(brand)
173
+ label = brand
174
+ end
175
+ end
176
+ if label.nil? && t.present?
177
+ label = if direct_utm?(t)
178
+ "Direct / None"
179
+ else
180
+ alias_sources_map[t.downcase].presence || t
181
+ end
182
+ end
183
+ label ||= normalize_source_label(d)
184
+ grouped_visit_ids[label].concat(ids)
185
+ end
186
+
187
+ if search.present?
188
+ needle = search.downcase
189
+ grouped_visit_ids.select! { |label, _| label.downcase.include?(needle) }
190
+ end
191
+
192
+ counts = Ahoy::Visit.unique_counts_from_grouped_visit_ids(grouped_visit_ids, visits)
193
+ else
194
+ grouped_visit_ids = rel
195
+ .group(Arel.sql(expr))
196
+ .pluck(Arel.sql("#{expr}, ARRAY_AGG(ahoy_visits.id)"))
197
+ .to_h
198
+
199
+ counts = Ahoy::Visit.unique_counts_from_grouped_visit_ids(grouped_visit_ids, visits)
200
+ if mode.start_with?("utm-")
201
+ grouped_visit_ids.delete(nil)
202
+ counts.delete(nil)
203
+ grouped_visit_ids.delete("(not set)")
204
+ counts.delete("(not set)")
205
+ grouped_visit_ids.reject! { |k, _| k.to_s.strip.empty? }
206
+ counts.reject! { |k, _| k.to_s.strip.empty? }
207
+ end
208
+ end
209
+
210
+ sorted_names = if goal.present?
211
+ conversions_all, cr_all = Ahoy::Visit.conversions_and_rates(grouped_visit_ids, visits, range, filters, goal)
212
+ Ahoy::Visit.order_names_with_conversions(conversions: conversions_all, cr: cr_all, order_by: order_by)
213
+ else
214
+ if order_by && %w[bounce_rate visit_duration].include?(order_by[0])
215
+ metrics_all = Ahoy::Visit.calculate_group_metrics(grouped_visit_ids, range, filters)
216
+ Ahoy::Visit.order_names(counts: counts, metrics_map: counts.keys.index_with { |n| metrics_all[n] || {} }, order_by: order_by)
217
+ else
218
+ Ahoy::Visit.order_names(counts: counts, metrics_map: {}, order_by: order_by)
219
+ end
220
+ end
221
+
222
+ paged_names, has_more = Ahoy::Visit.paginate_names(sorted_names, limit: limit, page: page)
223
+
224
+ page_visit_ids = grouped_visit_ids.slice(*paged_names)
225
+
226
+ if goal.present?
227
+ conversions, cr = Ahoy::Visit.conversions_and_rates(page_visit_ids, visits, range, filters, goal)
228
+ results = paged_names.map do |name|
229
+ label = begin
230
+ empty_label = mode.start_with?("utm-") ? "(not set)" : "(none)"
231
+ name.to_s.presence || empty_label
232
+ end
233
+ { name: label, visitors: conversions[name] || 0, conversion_rate: cr[name] }
234
+ end
235
+ { 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" } } }
236
+ else
237
+ group_metrics = Ahoy::Visit.calculate_group_metrics(page_visit_ids, range, filters)
238
+ results = paged_names.map do |name|
239
+ v = counts[name]
240
+ {
241
+ name: begin
242
+ empty_label = mode.start_with?("utm-") ? "(not set)" : "(none)"
243
+ name.to_s.presence || empty_label
244
+ end,
245
+ visitors: v,
246
+ bounce_rate: group_metrics.dig(name, :bounce_rate),
247
+ visit_duration: group_metrics.dig(name, :visit_duration)
248
+ }
249
+ end
250
+ { results: results, metrics: %i[visitors bounce_rate visit_duration], meta: { has_more: has_more, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query) } }
251
+ end
252
+ else
253
+ counts = case mode
254
+ when "channels"
255
+ visits.group(channel_case).count("DISTINCT visitor_token").transform_keys { |k| k.to_s.presence || "Direct" }
256
+ when "referrers"
257
+ visits.group(Arel.sql("COALESCE(referring_domain, 'Direct / None')")).count("DISTINCT visitor_token")
258
+ when "all"
259
+ rows = visits
260
+ .group(Arel.sql("COALESCE(utm_source, '')"), Arel.sql("COALESCE(referring_domain, '')"))
261
+ .pluck(Arel.sql("COALESCE(utm_source, '')"), Arel.sql("COALESCE(referring_domain, '')"), Arel.sql("ARRAY_AGG(ahoy_visits.id)"))
262
+ grouped_visit_ids = Hash.new { |h, k| h[k] = [] }
263
+ rows.each do |tag, dom, ids|
264
+ t = tag.to_s.strip
265
+ d = dom.to_s.strip
266
+ label = nil
267
+ if d.present?
268
+ brand = normalize_source_label(d)
269
+ if %w[Gmail Outlook.com Yahoo! Mail Proton Mail iCloud Mail].include?(brand)
270
+ label = brand
271
+ end
272
+ end
273
+ if label.nil? && t.present?
274
+ label = if direct_utm?(t)
275
+ "Direct / None"
276
+ else
277
+ alias_sources_map[t.downcase].presence || t
278
+ end
279
+ end
280
+ label ||= normalize_source_label(d)
281
+ grouped_visit_ids[label].concat(ids)
282
+ end
283
+ Ahoy::Visit.unique_counts_from_grouped_visit_ids(grouped_visit_ids, visits)
284
+ when "utm-medium"
285
+ buckets = visits.group(Arel.sql(utm_medium_expr)).count("DISTINCT visitor_token")
286
+ buckets.delete("(not set)")
287
+ buckets
288
+ when "utm-source"
289
+ buckets = visits.group(:utm_source).count("DISTINCT visitor_token")
290
+ buckets.delete("")
291
+ buckets
292
+ when "utm-campaign"
293
+ buckets = visits.group(:utm_campaign).count("DISTINCT visitor_token")
294
+ buckets.delete("")
295
+ buckets
296
+ when "utm-content"
297
+ buckets = visits.group(:utm_content).count("DISTINCT visitor_token")
298
+ buckets.delete("")
299
+ buckets
300
+ when "utm-term", "search-terms"
301
+ buckets = visits.group(:utm_term).count("DISTINCT visitor_token")
302
+ buckets.delete("")
303
+ buckets
304
+ else
305
+ visits.group(Arel.sql("COALESCE(referring_domain, 'Direct / None')")).count("DISTINCT visitor_token")
306
+ end
307
+
308
+ rows = counts.sort_by { |_, v| -v }.map do |(name, v)|
309
+ label = name.to_s
310
+ if label.strip.empty?
311
+ label = mode.start_with?("utm-") ? "(not set)" : "(none)"
312
+ end
313
+ { name: label, visitors: v }
314
+ end
315
+ { results: rows, metrics: %i[visitors], meta: { has_more: false, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query) } }
316
+ end
317
+ end
318
+
319
+ def referrers_payload(query, source, limit: nil, page: nil, search: nil, order_by: nil)
320
+ range, = Ahoy::Visit.range_and_interval_for(query[:period], nil, query)
321
+ filters = query[:filters] || {}
322
+ goal = filters["goal"].presence
323
+
324
+ s_down = source.to_s.downcase.strip
325
+ direct = (s_down == "direct / none" || s_down == "(none)" || s_down == "direct" || s_down == "none")
326
+
327
+ base_visits = Ahoy::Visit.scoped_visits(range, filters)
328
+ visits = if direct
329
+ base_visits.where(referring_domain: [ nil, "" ])
330
+ else
331
+ if (pattern = domain_pattern_for_source_label(source))
332
+ aliases = alias_sources_map.select { |k, v| v.to_s.downcase == s_down }.keys
333
+ if aliases.any?
334
+ base_visits.where(
335
+ "referring_domain ~* ? OR LOWER(utm_source) IN (?) OR LOWER(utm_source) LIKE ?",
336
+ pattern, aliases, "#{s_down}%"
337
+ )
338
+ else
339
+ base_visits.where(
340
+ "referring_domain ~* ? OR LOWER(utm_source) = ? OR LOWER(utm_source) LIKE ?",
341
+ pattern, s_down, "#{s_down}%"
342
+ )
343
+ end
344
+ elsif source.include?(".")
345
+ base_visits.where("referring_domain = ? OR LOWER(utm_source) = ?", source, s_down)
346
+ else
347
+ like = Ahoy::Visit.like_contains(s_down)
348
+ base_visits.where("LOWER(referring_domain) LIKE ? OR LOWER(utm_source) LIKE ?", like, like)
349
+ end
350
+ end
351
+
352
+ if direct
353
+ counts = { "Direct / None" => visits.count }
354
+ if limit && page
355
+ grouped_visit_ids = { "Direct / None" => visits.pluck(:id) }
356
+ if goal.present?
357
+ conversions, cr = Ahoy::Visit.conversions_and_rates(grouped_visit_ids, visits, range, filters, goal)
358
+ rows = counts.map { |name, _n| { name: name, visitors: conversions[name] || 0, conversion_rate: cr[name] } }
359
+ { results: rows, metrics: %i[visitors conversion_rate], meta: { has_more: false, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query), metric_labels: { visitors: "Conversions", conversionRate: "Conversion Rate" } } }
360
+ else
361
+ metrics = Ahoy::Visit.calculate_group_metrics(grouped_visit_ids, range, filters)
362
+ rows = counts.map { |name, n| { name: name, visitors: n, bounce_rate: metrics.dig(name, :bounce_rate), visit_duration: metrics.dig(name, :visit_duration) } }
363
+ { results: rows, metrics: %i[visitors bounce_rate visit_duration], meta: { has_more: false, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query) } }
364
+ end
365
+ else
366
+ rows = counts.sort_by { |_, v| -v }.map { |(name, v)| { name: name, visitors: v } }
367
+ { results: rows, metrics: %i[visitors], meta: { has_more: false, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query) } }
368
+ end
369
+ else
370
+ expr = "COALESCE(referrer, 'Direct / None')"
371
+ pattern = search.present? ? Ahoy::Visit.like_contains(search) : nil
372
+
373
+ if limit && page
374
+ rel = visits
375
+ rel = rel.where("LOWER(referrer) LIKE ?", pattern) if pattern.present?
376
+ grouped_visit_ids = rel.group(Arel.sql(expr)).pluck(Arel.sql("#{expr}, ARRAY_AGG(ahoy_visits.id)")).to_h
377
+ counts = Ahoy::Visit.unique_counts_from_grouped_visit_ids(grouped_visit_ids, visits)
378
+
379
+ sorted_names = if goal.present?
380
+ conversions_all, cr_all = Ahoy::Visit.conversions_and_rates(grouped_visit_ids, visits, range, filters, goal)
381
+ Ahoy::Visit.order_names_with_conversions(conversions: conversions_all, cr: cr_all, order_by: order_by)
382
+ else
383
+ if order_by && %w[bounce_rate visit_duration].include?(order_by[0])
384
+ metrics_all = Ahoy::Visit.calculate_group_metrics(grouped_visit_ids, range, filters)
385
+ Ahoy::Visit.order_names(counts: counts, metrics_map: counts.keys.index_with { |n| metrics_all[n] || {} }, order_by: order_by)
386
+ else
387
+ Ahoy::Visit.order_names(counts: counts, metrics_map: {}, order_by: order_by)
388
+ end
389
+ end
390
+
391
+ paged_names, has_more = Ahoy::Visit.paginate_names(sorted_names, limit: limit, page: page)
392
+
393
+ page_visit_ids = grouped_visit_ids.slice(*paged_names)
394
+ if goal.present?
395
+ conversions, cr = Ahoy::Visit.conversions_and_rates(page_visit_ids, visits, range, filters, goal)
396
+ results = paged_names.map { |name| { name: name, visitors: conversions[name] || 0, conversion_rate: cr[name] } }
397
+ { 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" } } }
398
+ else
399
+ metrics = Ahoy::Visit.calculate_group_metrics(page_visit_ids, range, filters)
400
+ results = paged_names.map do |name|
401
+ {
402
+ name: name,
403
+ visitors: counts[name],
404
+ bounce_rate: metrics.dig(name, :bounce_rate),
405
+ visit_duration: metrics.dig(name, :visit_duration)
406
+ }
407
+ end
408
+ { results: results, metrics: %i[visitors bounce_rate visit_duration], meta: { has_more: has_more, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query) } }
409
+ end
410
+ else
411
+ counts = visits.group(Arel.sql(expr)).count
412
+ rows = counts.sort_by { |_, v| -v }.map { |(name, v)| { name: name.to_s.presence || "(none)", visitors: v } }
413
+ { results: rows, metrics: %i[visitors], meta: { has_more: false, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query) } }
414
+ end
415
+ end
416
+ end
417
+ end
418
+ end
@@ -0,0 +1,32 @@
1
+ module Ahoy::Visit::UrlLabels
2
+ extend ActiveSupport::Concern
3
+
4
+ class_methods do
5
+ # Normalize landing_page full URL to path+query (Plausible-style labels)
6
+ def normalized_path_and_query(url)
7
+ s = url.to_s.strip
8
+ return nil if s.empty?
9
+ begin
10
+ uri = URI.parse(s)
11
+ path = uri.path.presence || "/"
12
+ q = uri.query.to_s
13
+ q.present? ? "#{path}?#{q}" : path
14
+ rescue URI::InvalidURIError
15
+ s.start_with?("/") ? s : nil
16
+ end
17
+ end
18
+
19
+ # Normalize landing_page full URL to pathname only (Plausible stores entry_page as path)
20
+ def normalized_path_only(url)
21
+ s = url.to_s.strip
22
+ return nil if s.empty?
23
+ begin
24
+ uri = URI.parse(s)
25
+ path = uri.path.presence || "/"
26
+ path
27
+ rescue URI::InvalidURIError
28
+ s.start_with?("/") ? s.split("?").first : nil
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,143 @@
1
+ class Ahoy::Visit < ApplicationRecord
2
+ self.table_name = "ahoy_visits"
3
+
4
+ has_many :events, class_name: "Ahoy::Event"
5
+ belongs_to :user, optional: true
6
+ scope :with_coordinates, -> { where.not(latitude: nil, longitude: nil) }
7
+
8
+ # Analytics concerns (move-first scaffolding; methods will be relocated verbatim)
9
+ include Ahoy::Visit::Constants
10
+ include Ahoy::Visit::Filters
11
+ include Ahoy::Visit::Ranges
12
+ include Ahoy::Visit::Series
13
+ include Ahoy::Visit::Metrics
14
+ include Ahoy::Visit::Sources
15
+ include Ahoy::Visit::Pages
16
+ include Ahoy::Visit::Locations
17
+ include Ahoy::Visit::Devices
18
+ include Ahoy::Visit::Ordering
19
+ include Ahoy::Visit::Pagination
20
+ include Ahoy::Visit::Imports
21
+ include Ahoy::Visit::Export
22
+ include Ahoy::Visit::CacheKey
23
+ include Ahoy::Visit::UrlLabels
24
+
25
+ # Calculates current live visitors based on recent activity
26
+ # Prefers pageview events (more accurate), falls back to recent visits
27
+ def self.live_visitors_count
28
+ now = Time.zone.now
29
+ recent_event_visit_ids = Ahoy::Event
30
+ .where("time > ?", now - 5.minutes)
31
+ .distinct
32
+ .pluck(:visit_id)
33
+
34
+ if recent_event_visit_ids.any?
35
+ where(id: recent_event_visit_ids).distinct.count(:visitor_token)
36
+ else
37
+ where(started_at: (now - 5.minutes)..now).distinct.count(:visitor_token)
38
+ end
39
+ end
40
+
41
+ def self.recent_with_coordinates(window: 5.minutes)
42
+ now = Time.zone.now
43
+ window_start = now - window
44
+
45
+ scope = with_coordinates
46
+ recent = scope.where(started_at: window_start..now)
47
+
48
+ event_visit_ids = Ahoy::Event.where("time >= ?", window_start).pluck(:visit_id)
49
+ if event_visit_ids.present?
50
+ recent = recent.or(scope.where(id: event_visit_ids))
51
+ end
52
+
53
+ recent.distinct
54
+ end
55
+
56
+ def self.live_dots(limit: 200, window: 5.minutes, now: Time.zone.now)
57
+ current_time = now || Time.zone.now
58
+ window_start = current_time - window
59
+ visits = recent_with_coordinates(window: window)
60
+ .order(started_at: :desc)
61
+ .limit(limit)
62
+
63
+ event_times = Ahoy::Event
64
+ .where(visit_id: visits.map(&:id))
65
+ .where("time >= ?", window_start)
66
+ .group(:visit_id)
67
+ .maximum(:time)
68
+
69
+ visits.map do |visit|
70
+ last_activity = event_times[visit.id] || visit.started_at || current_time
71
+ {
72
+ lat: visit.latitude.to_f,
73
+ lng: visit.longitude.to_f,
74
+ city: visit.city.to_s.presence,
75
+ type: "visitor",
76
+ ts: (last_activity.to_f * 1000.0).to_i
77
+ }
78
+ end
79
+ end
80
+
81
+ # Return sparkline arrays for sessions today vs yesterday using time buckets
82
+ # Example: { today: [0,2,1,...], yesterday: [1,0,3,...] }
83
+ def self.sparkline_today_vs_yesterday(bucket: 15.minutes, now: Time.zone.now, yesterday_full_day: true)
84
+ current_time = now || Time.zone.now
85
+ start_today = current_time.beginning_of_day
86
+ bucket_seconds = bucket.to_i
87
+ # How many buckets have elapsed so far today (inclusive of current bucket)
88
+ bucket_count_today = (((current_time - start_today) / bucket).floor + 1).clamp(1, 24 * 60 * 60 / bucket_seconds)
89
+ full_day_buckets = (24 * 60 * 60) / bucket_seconds
90
+ bucket_count_yday = yesterday_full_day ? full_day_buckets : bucket_count_today
91
+
92
+ today_series = series_counts(
93
+ table: table_name,
94
+ column: "started_at",
95
+ start_at: start_today,
96
+ buckets: bucket_count_today,
97
+ bucket_seconds: bucket_seconds
98
+ )
99
+
100
+ yesterday_series = series_counts(
101
+ table: table_name,
102
+ column: "started_at",
103
+ start_at: start_today - 1.day,
104
+ buckets: bucket_count_yday,
105
+ bucket_seconds: bucket_seconds
106
+ )
107
+
108
+ { today: today_series, yesterday: yesterday_series }
109
+ end
110
+
111
+ # Internal: generic series counter used by sparkline helpers
112
+ def self.series_counts(table:, column:, start_at:, buckets:, bucket_seconds:)
113
+ finish = start_at + (buckets - 1) * bucket_seconds
114
+ sec = bucket_seconds.to_i
115
+ sec = 1 if sec <= 0
116
+ sec = 86_400 if sec > 86_400
117
+ start_ts = connection.quote("#{start_at.utc.strftime('%Y-%m-%d %H:%M:%S')}+00")
118
+ finish_ts = connection.quote("#{finish.utc.strftime('%Y-%m-%d %H:%M:%S')}+00")
119
+ tname = connection.quote_table_name(table)
120
+ col = connection.quote_column_name(column)
121
+ sql = <<~SQL.squish
122
+ WITH series AS (
123
+ SELECT generate_series(
124
+ TIMESTAMPTZ #{start_ts},
125
+ TIMESTAMPTZ #{finish_ts},
126
+ INTERVAL '#{sec} seconds'
127
+ ) AS bucket
128
+ )
129
+ SELECT
130
+ s.bucket AS bucket,
131
+ COUNT(t.id) AS value
132
+ FROM series s
133
+ LEFT JOIN #{tname} t
134
+ ON t.#{col} >= s.bucket
135
+ AND t.#{col} < s.bucket + INTERVAL '#{sec} seconds'
136
+ GROUP BY s.bucket
137
+ ORDER BY s.bucket ASC
138
+ SQL
139
+
140
+ rows = connection.exec_query(sql)
141
+ rows.rows.map { |(_, value)| value.to_i }
142
+ end
143
+ end
@@ -0,0 +1,5 @@
1
+ module AhoyAnalytics
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AhoyAnalytics
4
+ class Current < ActiveSupport::CurrentAttributes
5
+ attribute :request
6
+ delegate :host, :protocol, to: :request, prefix: true, allow_nil: true
7
+ end
8
+ end
@@ -0,0 +1,16 @@
1
+ module AhoyAnalytics
2
+ class Funnel < ApplicationRecord
3
+ self.table_name = "analytics_funnels"
4
+
5
+ validates :name, presence: true, uniqueness: true
6
+ validates :steps, presence: true
7
+
8
+ def step_labels
9
+ Array(steps).map do |s|
10
+ next s.to_s unless s.is_a?(Hash)
11
+ step = s.with_indifferent_access
12
+ step[:name] || step[:value]
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,5 @@
1
+ module AhoyAnalytics
2
+ class ImportedEntryPage < ApplicationRecord
3
+ self.table_name = "imported_entry_pages"
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module AhoyAnalytics
2
+ class ImportedExitPage < ApplicationRecord
3
+ self.table_name = "imported_exit_pages"
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module AhoyAnalytics
2
+ class ImportedPage < ApplicationRecord
3
+ self.table_name = "imported_pages"
4
+ end
5
+ end