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,227 @@
1
+ module Ahoy::Visit::Ranges
2
+ extend ActiveSupport::Concern
3
+
4
+ class_methods do
5
+ # Build canonical ranges (no trimming) and clamp intervals, matching Plausible.
6
+ # Accepts optional query for date/from/to parameters.
7
+ def range_and_interval_for(period, requested_interval = nil, query = nil)
8
+ now = Time.zone.now
9
+
10
+ parse_date = lambda do |str|
11
+ begin
12
+ Time.zone.parse(str.to_s)
13
+ rescue ArgumentError
14
+ now
15
+ end
16
+ end
17
+
18
+ date_param = (query && query[:date]).presence
19
+ from_param = (query && query[:from]).presence
20
+ to_param = (query && query[:to]).presence
21
+
22
+ case period
23
+ when "realtime"
24
+ range = (now - 30.minutes)..now
25
+ allowed = %w[minute]
26
+ default = "minute"
27
+
28
+ when "day"
29
+ d = date_param.present? ? parse_date.call(date_param) : now
30
+ range = d.beginning_of_day..d.end_of_day
31
+ allowed = %w[minute hour]
32
+ default = "hour"
33
+
34
+ when "7d"
35
+ end_date = (date_param.present? ? parse_date.call(date_param) : now).to_date - 1.day
36
+ start_date = end_date - 6.days
37
+ range = start_date.beginning_of_day..end_date.end_of_day
38
+ allowed = %w[hour day]
39
+ default = "day"
40
+
41
+ when "28d"
42
+ end_date = (date_param.present? ? parse_date.call(date_param) : now).to_date - 1.day
43
+ start_date = end_date - 27.days
44
+ range = start_date.beginning_of_day..end_date.end_of_day
45
+ allowed = %w[day week]
46
+ default = "day"
47
+
48
+ when "30d"
49
+ end_date = (date_param.present? ? parse_date.call(date_param) : now).to_date - 1.day
50
+ start_date = end_date - 29.days
51
+ range = start_date.beginning_of_day..end_date.end_of_day
52
+ allowed = %w[day week]
53
+ default = "day"
54
+
55
+ when "91d"
56
+ end_date = (date_param.present? ? parse_date.call(date_param) : now).to_date - 1.day
57
+ start_date = end_date - 90.days
58
+ range = start_date.beginning_of_day..end_date.end_of_day
59
+ allowed = %w[day week month]
60
+ default = "day"
61
+
62
+ when "month"
63
+ d = date_param.present? ? parse_date.call(date_param) : now
64
+ range = d.beginning_of_month..d.end_of_month
65
+ allowed = %w[day week]
66
+ default = "day"
67
+
68
+ when "6mo"
69
+ d = date_param.present? ? parse_date.call(date_param) : now
70
+ end_date = (d.to_date - 1.month).end_of_month
71
+ start_date = (end_date.to_date - 5.months).beginning_of_month
72
+ range = start_date.beginning_of_day..end_date.end_of_day
73
+ allowed = %w[day week month]
74
+ default = "month"
75
+
76
+ when "12mo"
77
+ d = date_param.present? ? parse_date.call(date_param) : now
78
+ end_date = (d.to_date - 1.month).end_of_month
79
+ start_date = (end_date.to_date - 11.months).beginning_of_month
80
+ range = start_date.beginning_of_day..end_date.end_of_day
81
+ allowed = %w[day week month]
82
+ default = "month"
83
+
84
+ when "year"
85
+ d = date_param.present? ? parse_date.call(date_param) : now
86
+ range = d.beginning_of_year..d.end_of_year
87
+ allowed = %w[day week month]
88
+ default = "month"
89
+
90
+ when "all"
91
+ starts = [ Ahoy::Visit.minimum(:started_at), Ahoy::Event.minimum(:time) ].compact
92
+ start_date = (starts.min || now).to_date
93
+ end_date = now.to_date
94
+ range = start_date.beginning_of_day..end_date.end_of_day
95
+ months = ((end_date.year * 12 + end_date.month) - (start_date.year * 12 + start_date.month)).abs
96
+ allowed = months > 12 ? %w[week month] : %w[day week month]
97
+ default = months > 0 ? "month" : "day"
98
+
99
+ when "custom"
100
+ if from_param.present? && to_param.present?
101
+ begin
102
+ from = parse_date.call(from_param).beginning_of_day
103
+ to = parse_date.call(to_param).end_of_day
104
+ range = from..to
105
+ rescue ArgumentError
106
+ end_date = now.to_date - 1.day
107
+ start_date = end_date - 6.days
108
+ range = start_date.beginning_of_day..end_date.end_of_day
109
+ end
110
+ else
111
+ end_date = now.to_date - 1.day
112
+ start_date = end_date - 6.days
113
+ range = start_date.beginning_of_day..end_date.end_of_day
114
+ end
115
+
116
+ else
117
+ d = date_param.present? ? parse_date.call(date_param) : now
118
+ range = d.beginning_of_day..d.end_of_day
119
+ allowed = %w[minute hour]
120
+ default = "hour"
121
+ end
122
+
123
+ interval = requested_interval.to_s.presence
124
+ interval = default unless interval && allowed.include?(interval)
125
+ [ range, interval ]
126
+ end
127
+
128
+ # Trim to "now" for the main graph only (Plausible's include.trim_relative_date_range behavior)
129
+ def trim_range_to_now_if_applicable(range, period, comparison: nil)
130
+ return range unless %w[day month year].include?(period.to_s)
131
+ return range if comparison.present? && period.to_s == "day"
132
+
133
+ today = Time.zone.today
134
+ case period.to_s
135
+ when "day"
136
+ if range.begin.to_date == today && range.end.to_date == today
137
+ return range.begin..Time.zone.now
138
+ end
139
+ when "month"
140
+ month_start = today.beginning_of_month
141
+ month_end = today.end_of_month
142
+ if range.begin.to_date == month_start && range.end.to_date == month_end
143
+ return range.begin..today.end_of_day
144
+ end
145
+ when "year"
146
+ year_start = Date.new(today.year, 1, 1)
147
+ year_end = Date.new(today.year, 12, 31)
148
+ if range.begin.to_date == year_start && range.end.to_date == year_end
149
+ return range.begin..today.end_of_day
150
+ end
151
+ end
152
+ range
153
+ end
154
+
155
+ def previous_range(range)
156
+ from_date = range.begin.to_date
157
+ to_date = range.end.to_date
158
+ days_span = (to_date - from_date).to_i + 1 # inclusive number of days in source
159
+ prev_from = (from_date - days_span).beginning_of_day
160
+ prev_to = (to_date - days_span).end_of_day
161
+ prev_from..prev_to
162
+ end
163
+
164
+ def year_over_year_range(range)
165
+ (range.begin - 1.year)..(range.end - 1.year)
166
+ end
167
+
168
+ def custom_compare_range(query)
169
+ cf = query[:compare_from]
170
+ ct = query[:compare_to]
171
+ return nil unless cf.present? && ct.present?
172
+ begin
173
+ from = Time.zone.parse(cf.to_s)
174
+ to = Time.zone.parse(ct.to_s)
175
+ from..to
176
+ rescue ArgumentError
177
+ nil
178
+ end
179
+ end
180
+
181
+ # When enabled, adjust the comparison range to start on the same weekday as the source range.
182
+ def align_comparison_weekday(comparison_range, source_range)
183
+ return comparison_range unless comparison_range && source_range
184
+ source_first = source_range.begin.to_date
185
+ comp_first = comparison_range.begin.to_date
186
+
187
+ target_wday = source_first.wday # 0..6 (Sun..Sat)
188
+
189
+ return comparison_range if comp_first.wday == target_wday
190
+
191
+ next_occurring = begin
192
+ delta = (target_wday - comp_first.wday) % 7
193
+ delta = 7 if delta == 0
194
+ comp_first + delta
195
+ end
196
+
197
+ prev_occurring = begin
198
+ delta = (comp_first.wday - target_wday) % 7
199
+ delta = 7 if delta == 0
200
+ comp_first - delta
201
+ end
202
+
203
+ new_first_date = (next_occurring == source_first) ? prev_occurring : next_occurring
204
+ days_shifted = (new_first_date - comp_first).to_i
205
+ new_last_date = comparison_range.end.to_date + days_shifted
206
+
207
+ new_begin = Time.zone.parse(new_first_date.to_s).beginning_of_day
208
+ new_end = Time.zone.parse(new_last_date.to_s).end_of_day
209
+ new_begin..new_end
210
+ end
211
+
212
+ # Build SQL for time bucketing in the app (site) timezone, then convert back to UTC.
213
+ def bucket_sql_for(column, interval)
214
+ zone = ActiveRecord::Base.connection.quote(Time.zone.tzinfo.name)
215
+ local = "((#{column} AT TIME ZONE 'UTC') AT TIME ZONE #{zone})"
216
+ truncated = case interval
217
+ when "month" then "date_trunc('month', #{local})"
218
+ when "week" then "date_trunc('week', #{local})"
219
+ when "day" then "date_trunc('day', #{local})"
220
+ when "hour" then "date_trunc('hour', #{local})"
221
+ when "minute" then "date_trunc('minute', #{local})"
222
+ else "date_trunc('hour', #{local})"
223
+ end
224
+ "(#{truncated} AT TIME ZONE #{zone})"
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,177 @@
1
+ module Ahoy::Visit::Series
2
+ extend ActiveSupport::Concern
3
+
4
+ class_methods do
5
+ def main_graph_payload(query)
6
+ metric = (query[:metric] || "visitors").to_s
7
+ range, interval = Ahoy::Visit.range_and_interval_for(query[:period], query[:interval], query)
8
+ range = Ahoy::Visit.trim_range_to_now_if_applicable(range, query[:period], comparison: query[:comparison])
9
+ filters = query[:filters] || {}
10
+ advanced_filters = query[:advanced_filters] || []
11
+
12
+ series = series_for(range, interval, filters, metric, advanced_filters: advanced_filters)
13
+
14
+ comparison = nil
15
+ case query[:comparison]
16
+ when "previous_period"
17
+ prev_range = Ahoy::Visit.previous_range(range)
18
+ if ActiveModel::Type::Boolean.new.cast(query[:match_day_of_week])
19
+ prev_range = Ahoy::Visit.align_comparison_weekday(prev_range, range)
20
+ end
21
+ comparison = series_for(prev_range, interval, filters, metric, advanced_filters: advanced_filters)
22
+ when "year_over_year"
23
+ prev_range = Ahoy::Visit.year_over_year_range(range)
24
+ if ActiveModel::Type::Boolean.new.cast(query[:match_day_of_week])
25
+ prev_range = Ahoy::Visit.align_comparison_weekday(prev_range, range)
26
+ end
27
+ comparison = series_for(prev_range, interval, filters, metric, advanced_filters: advanced_filters)
28
+ when "custom"
29
+ prev_range = Ahoy::Visit.custom_compare_range(query)
30
+ comparison = prev_range ? series_for(prev_range, interval, filters, metric, advanced_filters: advanced_filters) : nil
31
+ end
32
+
33
+ full_intervals = case interval
34
+ when "week"
35
+ date_range = (range.begin.to_date..range.end.to_date)
36
+ series[:labels].each_with_object({}) do |label, acc|
37
+ begin
38
+ d = Date.parse(label)
39
+ start = d.beginning_of_week
40
+ finish = d.end_of_week
41
+ acc[label] = date_range.cover?(start) && date_range.cover?(finish)
42
+ rescue ArgumentError
43
+ acc[label] = false
44
+ end
45
+ end
46
+ when "month"
47
+ date_range = (range.begin.to_date..range.end.to_date)
48
+ series[:labels].each_with_object({}) do |label, acc|
49
+ begin
50
+ d = Date.parse(label)
51
+ start = d.beginning_of_month
52
+ finish = d.end_of_month
53
+ acc[label] = date_range.cover?(start) && date_range.cover?(finish)
54
+ rescue ArgumentError
55
+ acc[label] = false
56
+ end
57
+ end
58
+ else
59
+ nil
60
+ end
61
+
62
+ {
63
+ metric: metric,
64
+ plot: series[:values],
65
+ labels: series[:labels],
66
+ comparison_plot: comparison && comparison[:values],
67
+ comparison_labels: comparison && comparison[:labels],
68
+ present_index: series[:labels].length - 1,
69
+ interval: interval,
70
+ full_intervals: full_intervals
71
+ }
72
+ end
73
+
74
+ def series_for(range, interval, filters, metric, advanced_filters: [])
75
+ bucket_sql = Ahoy::Visit.bucket_sql_for("started_at", interval)
76
+ scope = Ahoy::Visit.scoped_visits(range, filters, advanced_filters: advanced_filters)
77
+
78
+ if %w[views_per_visit bounce_rate visit_duration].include?(metric.to_s)
79
+ map = calculate_complex_metric_series(range, interval, filters, metric.to_s, advanced_filters: advanced_filters)
80
+ else
81
+ grouped = case metric.to_s
82
+ when "visitors"
83
+ scope.group(Arel.sql(bucket_sql)).distinct.count(:visitor_token)
84
+ when "pageviews"
85
+ grouped_expression = Ahoy::Visit.bucket_sql_for("time", interval)
86
+ Ahoy::Visit.scoped_events(range, filters, advanced_filters: advanced_filters).group(Arel.sql(grouped_expression)).count
87
+ else # visits
88
+ scope.group(Arel.sql(bucket_sql)).count
89
+ end
90
+
91
+ map = grouped.each_with_object({}) do |(ts, v), h|
92
+ key = ts.is_a?(Time) ? ts.utc : ts.to_time.utc
93
+ h[key] = v.to_i
94
+ end
95
+ end
96
+
97
+ start_point = case interval
98
+ when "month" then range.begin.beginning_of_month
99
+ when "week" then range.begin.beginning_of_week
100
+ when "day" then range.begin.beginning_of_day
101
+ when "hour" then range.begin.beginning_of_hour
102
+ when "minute" then range.begin.beginning_of_minute
103
+ else range.begin.beginning_of_hour
104
+ end
105
+ step = case interval
106
+ when "month" then 1.month
107
+ when "week" then 1.week
108
+ when "day" then 1.day
109
+ when "hour" then 1.hour
110
+ when "minute" then 1.minute
111
+ else 1.hour
112
+ end
113
+ labels = []
114
+ values = []
115
+ t = start_point
116
+ while t <= range.end
117
+ key = t.utc
118
+ labels << key.iso8601
119
+ values << (map[key] || 0)
120
+ t += step
121
+ end
122
+
123
+ { values: values, labels: labels }
124
+ end
125
+
126
+ def calculate_complex_metric_series(range, interval, filters, metric, advanced_filters: [])
127
+ bucket_sql = Ahoy::Visit.bucket_sql_for("started_at", interval)
128
+ scope = Ahoy::Visit.scoped_visits(range, filters, advanced_filters: advanced_filters)
129
+ events_scope = Ahoy::Visit.scoped_events(range, filters, advanced_filters: advanced_filters)
130
+
131
+ visits_by_bucket = scope.group(Arel.sql(bucket_sql)).count
132
+ result = {}
133
+
134
+ visits_by_bucket.each do |bucket_time, _visit_count|
135
+ key = bucket_time.is_a?(Time) ? bucket_time.utc : bucket_time.to_time.utc
136
+ bucket_range = case interval
137
+ when "month" then key..(key + 1.month)
138
+ when "week" then key..(key + 1.week)
139
+ when "day" then key..(key + 1.day)
140
+ when "hour" then key..(key + 1.hour)
141
+ when "minute" then key..(key + 1.minute)
142
+ else key..(key + 1.hour)
143
+ end
144
+
145
+ bucket_visits = scope.where(started_at: bucket_range)
146
+ visit_ids = bucket_visits.pluck(:id)
147
+ next if visit_ids.empty?
148
+
149
+ case metric
150
+ when "views_per_visit"
151
+ pageviews_per_visit = events_scope.where(visit_id: visit_ids).group(:visit_id).count
152
+ pageviews = pageviews_per_visit.values.sum
153
+ v_with_events = pageviews_per_visit.size
154
+ result[key] = v_with_events > 0 ? (pageviews.to_f / v_with_events).round(2) : 0.0
155
+
156
+ when "bounce_rate"
157
+ pageviews_per_visit = events_scope.where(visit_id: visit_ids).group(:visit_id).count
158
+ v_with_events = pageviews_per_visit.size
159
+ bounces = pageviews_per_visit.count { |_, cnt| cnt == 1 }
160
+ result[key] = v_with_events > 0 ? ((bounces.to_f / v_with_events) * 100).round(2) : 0.0
161
+
162
+ when "visit_duration"
163
+ durations = events_scope
164
+ .where(visit_id: visit_ids)
165
+ .group(:visit_id)
166
+ .pluck(Arel.sql("visit_id, GREATEST(EXTRACT(EPOCH FROM (MAX(time) - MIN(time))), 0) as duration"))
167
+ .map { |_, duration| duration.to_f }
168
+
169
+ v_with_events = durations.size
170
+ result[key] = v_with_events > 0 ? (durations.sum / v_with_events).round(1) : 0.0
171
+ end
172
+ end
173
+
174
+ result
175
+ end
176
+ end
177
+ end