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,680 @@
1
+ /**
2
+ * Standalone Analytics Tracker
3
+ *
4
+ * Universal tracker that works with:
5
+ * - Multi-page apps (traditional server-rendered navigation)
6
+ * - Single-page apps (React Router, Vue Router, Inertia.js, etc.)
7
+ * - Hybrid apps (mix of both)
8
+ *
9
+ * Similar to Plausible.io, wraps the History API to detect navigation.
10
+ * Usage: Add to <head> with <%= ahoy_analytics_tracking_tag %>
11
+ */
12
+
13
+ interface AnalyticsConfig {
14
+ // Ahoy endpoints
15
+ eventsEndpoint: string
16
+ visitsEndpoint: string
17
+ // Filters
18
+ excludePaths: string[]
19
+ includePaths?: string[] // if provided, only paths matching any will be tracked (plausible-style)
20
+ excludeAssets: string[]
21
+ // Storage + transport
22
+ useCookies: boolean // true = cookies (ahoy.js style); false = cookieless (localStorage). Default: false
23
+ visitDurationMinutes: number // new visit after X minutes of inactivity. Default: 240 (4h)
24
+ useBeaconForEvents: boolean // prefer sendBeacon for events. Default: true
25
+ trackVisits: boolean // create visit on frontend. Default: true
26
+ // Routing behavior
27
+ hashBasedRouting?: boolean // when true, treat hash changes as navigation (off by default)
28
+ // Dev
29
+ debug?: boolean
30
+ }
31
+
32
+ class StandaloneAnalytics {
33
+ private visitToken: string | null = null
34
+ private visitExpiresAt: number | null = null
35
+ private visitorToken: string | null = null
36
+ private lastTrackedHref: string | null = null
37
+ // Dedup key for pageviews: pathname + search (or + hash if hashBasedRouting)
38
+ private lastTrackedPageKey: string | null = null
39
+ private config: AnalyticsConfig
40
+ private ensureVisitPromise: Promise<void> | null = null
41
+
42
+ // Engagement tracking state (plausible-like)
43
+ private listeningOnEngagement = false
44
+ private currentEngagementIgnored = false
45
+ private currentEngagementURL: string | null = null
46
+ private currentEngagementMaxScrollDepth = -1
47
+ private runningEngagementStart = 0
48
+ private currentEngagementTime = 0
49
+ private currentDocumentHeight = 0
50
+ private maxScrollDepthPx = 0
51
+
52
+ constructor() {
53
+ this.config = {
54
+ eventsEndpoint: '/ahoy/events',
55
+ visitsEndpoint: '/ahoy/visits',
56
+ // Defaults similar to our app; can be overridden by data-* attributes or window.analyticsConfig
57
+ // Exclude internal/system endpoints to avoid accidental tracking
58
+ excludePaths: ['/admin', '/.well-known', '/ahoy', '/cable'],
59
+ excludeAssets: [
60
+ '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico',
61
+ '.woff', '.woff2', '.ttf', '.eot', '.otf',
62
+ '.pdf', '.zip', '.tar', '.gz',
63
+ '.mp4', '.webm', '.mp3', '.wav',
64
+ '.css', '.js', '.map', '.json'
65
+ ],
66
+ useCookies: false,
67
+ visitDurationMinutes: 30, // create a new visit after 30 minutes of inactivity (web analytics standard)
68
+ useBeaconForEvents: true,
69
+ trackVisits: true,
70
+ hashBasedRouting: false,
71
+ debug: false,
72
+ }
73
+ }
74
+
75
+ init(): void {
76
+ if (typeof window === 'undefined') return
77
+
78
+ // Optional runtime overrides via window.analyticsConfig
79
+ try {
80
+ const overrides = (window as any).analyticsConfig as Partial<AnalyticsConfig> | undefined
81
+ if (overrides && typeof overrides === 'object') {
82
+ this.config = { ...this.config, ...overrides }
83
+ }
84
+ } catch {}
85
+
86
+ // Read plausible-style include/exclude from script tag if present
87
+ this.readScriptAttributes()
88
+
89
+ // Load tokens (meta or storage) and ensure a visit exists
90
+ this.bootstrapIdentity()
91
+ if (this.config.trackVisits) this.ensureVisitPromise = this.ensureActiveVisit()
92
+
93
+ // Track initial page load only when the document is visible.
94
+ // This prevents background prerenders/prefetches from counting and avoids
95
+ // double pageviews when a hidden page is spun up by the browser.
96
+ const trackWhenVisible = () => {
97
+ if (document.visibilityState === 'visible') {
98
+ document.removeEventListener('visibilitychange', trackWhenVisible)
99
+ void this.trackPageview()
100
+ }
101
+ }
102
+
103
+ const triggerInitial = () => {
104
+ if (document.visibilityState === 'visible') {
105
+ void this.trackPageview()
106
+ } else {
107
+ document.addEventListener('visibilitychange', trackWhenVisible)
108
+ }
109
+ }
110
+
111
+ if (document.readyState === 'loading') {
112
+ document.addEventListener('DOMContentLoaded', triggerInitial)
113
+ } else {
114
+ triggerInitial()
115
+ }
116
+
117
+ // Listen for navigation events (works with both regular links and Inertia)
118
+ this.setupNavigationListener()
119
+
120
+ // Engagement and auto-capture listeners
121
+ this.initEngagement()
122
+ this.initAutoCapture()
123
+ }
124
+
125
+ private bootstrapIdentity(): void {
126
+ // First try meta tokens (if server provided)
127
+ const visitMeta = document.querySelector<HTMLMetaElement>('meta[name="ahoy-visit"]')
128
+ const visitorMeta = document.querySelector<HTMLMetaElement>('meta[name="ahoy-visitor"]')
129
+ if (visitMeta?.content) this.visitToken = visitMeta.content
130
+ if (visitorMeta?.content) this.visitorToken = visitorMeta.content
131
+
132
+ // Load from storage if missing (supports cookies or localStorage)
133
+ if (!this.visitorToken) this.visitorToken = this.getOrCreateVisitorToken()
134
+ // visit may expire; refresh if needed
135
+ const vt = this.getStoredVisit()
136
+ if (vt) {
137
+ this.visitToken = vt.token
138
+ this.visitExpiresAt = vt.expiresAt
139
+ }
140
+ if (!this.visitToken || this.isVisitExpired()) {
141
+ this.createNewVisitToken()
142
+ }
143
+ }
144
+
145
+ private setupNavigationListener(): void {
146
+ // Wrap history.pushState and history.replaceState to detect SPA navigation
147
+ // This works for ANY SPA framework (React Router, Vue Router, Inertia, etc.)
148
+ const originalPushState = history.pushState
149
+ const originalReplaceState = history.replaceState
150
+
151
+ if (originalPushState) {
152
+ history.pushState = (...args) => {
153
+ this.prePageview()
154
+ originalPushState.apply(history, args)
155
+ if (this.config.debug) console.debug('[analytics] pushState')
156
+ void this.trackPageview()
157
+ }
158
+ }
159
+
160
+ // Keep replaceState hook to catch rare replace-only navigations (e.g., Inertia Link with `replace`),
161
+ // while dedup in trackPageview() ensures we don't double count when frameworks call replaceState
162
+ // after pushState to update state/scroll.
163
+ if (originalReplaceState) {
164
+ history.replaceState = (...args) => {
165
+ this.prePageview()
166
+ originalReplaceState.apply(history, args)
167
+ if (this.config.debug) console.debug('[analytics] replaceState')
168
+ void this.trackPageview()
169
+ }
170
+ }
171
+
172
+ // Listen for popstate (back/forward buttons)
173
+ window.addEventListener('popstate', () => {
174
+ this.prePageview()
175
+ if (this.config.debug) console.debug('[analytics] popstate')
176
+ void this.trackPageview()
177
+ })
178
+
179
+ // Optional: treat hash changes as navigation (off by default)
180
+ if (this.config.hashBasedRouting) {
181
+ window.addEventListener('hashchange', () => {
182
+ this.prePageview()
183
+ if (this.config.debug) console.debug('[analytics] hashchange')
184
+ void this.trackPageview()
185
+ })
186
+ }
187
+ }
188
+
189
+ private async trackPageview(): Promise<void> {
190
+ if (typeof window === 'undefined') return
191
+
192
+ // Skip tracking in iframes to avoid embedded/preview contexts skewing data
193
+ try { if (window.top !== window.self) return } catch { /* cross-origin, treat as iframe */ return }
194
+
195
+ const href = window.location.href
196
+ const pathname = window.location.pathname
197
+ const pathQuery = pathname + window.location.search
198
+ const pageKey = this.config.hashBasedRouting ? (pathQuery + window.location.hash) : pathQuery
199
+
200
+ // Skip if same path+query as last tracked (ignore hash-only changes)
201
+ if (this.lastTrackedPageKey === pageKey) return
202
+ // Claim this pageKey immediately to avoid double-fire when pushState/replaceState
203
+ // happen back-to-back during the same navigation tick (Inertia / SPA frameworks).
204
+ this.lastTrackedPageKey = pageKey
205
+ if (this.config.debug) console.debug('[analytics] trackPageview pageKey=', pageKey)
206
+
207
+ // Skip excluded paths
208
+ if (this.shouldExclude(pathname)) {
209
+ this.lastTrackedHref = href
210
+ return
211
+ }
212
+
213
+ // Ensure the visit is created before the first pageview to avoid
214
+ // the server implicitly creating a visit for /ahoy/events
215
+ if (this.ensureVisitPromise) {
216
+ try { await this.ensureVisitPromise } catch {}
217
+ }
218
+
219
+ const referrer = this.lastTrackedHref || document.referrer || ''
220
+
221
+ // Plausible-style event name and props
222
+ this.sendEvent({
223
+ name: 'pageview',
224
+ page: pathQuery,
225
+ url: href,
226
+ title: document.title,
227
+ referrer,
228
+ screenSize: `${window.innerWidth}x${window.innerHeight}`
229
+ })
230
+
231
+ this.lastTrackedHref = href
232
+ this.postPageview({ url: href, page: pathQuery })
233
+ }
234
+
235
+ private shouldExclude(pathname: string): boolean {
236
+ const lowerPath = pathname.toLowerCase()
237
+
238
+ // Exclude admin paths
239
+ if (this.config.excludePaths.some(path => lowerPath.startsWith(path))) {
240
+ return true
241
+ }
242
+
243
+ // Exclude static assets
244
+ if (this.config.excludeAssets.some(ext => lowerPath.endsWith(ext))) {
245
+ return true
246
+ }
247
+
248
+ // Exclude special files
249
+ const specialFiles = [
250
+ '/favicon.ico',
251
+ '/robots.txt',
252
+ '/sitemap.xml',
253
+ '/manifest.json',
254
+ '/browserconfig.xml',
255
+ // Chrome DevTools well-known JSON
256
+ '/.well-known/appspecific/com.chrome.devtools.json'
257
+ ]
258
+ if (specialFiles.includes(lowerPath)) {
259
+ return true
260
+ }
261
+
262
+ // Exclude apple-touch-icon variants
263
+ if (lowerPath.includes('apple-touch-icon')) {
264
+ return true
265
+ }
266
+
267
+ // Apply plausible-style include/exclude rules
268
+ const inc = this.config.includePaths && this.config.includePaths.length > 0
269
+ if (inc) {
270
+ const pass = this.config.includePaths!.some((p) => this.pathMatches(p, lowerPath))
271
+ if (!pass) return true
272
+ }
273
+ if (this.config.excludePaths && this.config.excludePaths.length > 0) {
274
+ if (this.config.excludePaths.some((p) => this.pathMatches(p, lowerPath))) return true
275
+ }
276
+
277
+ return false
278
+ }
279
+
280
+ private sendEvent(properties: {
281
+ name: string
282
+ page: string
283
+ url: string
284
+ title: string
285
+ referrer: string
286
+ screenSize: string
287
+ }, extra?: Record<string, any>): void {
288
+ if (typeof window === 'undefined') return
289
+
290
+ const event = {
291
+ name: properties.name,
292
+ properties: {
293
+ page: properties.page,
294
+ url: properties.url,
295
+ title: properties.title,
296
+ referrer: properties.referrer,
297
+ screen_size: properties.screenSize,
298
+ ...(extra || {})
299
+ },
300
+ time: Date.now() / 1000
301
+ }
302
+
303
+ if (this.config.debug) {
304
+ try {
305
+ console.debug('[analytics] sendEvent', { name: event.name, page: (event as any).properties.page, url: (event as any).properties.url })
306
+ } catch {}
307
+ }
308
+
309
+ // Prefer sendBeacon with FormData + authenticity_token (like ahoy.js)
310
+ const canBeacon = this.config.useBeaconForEvents && typeof navigator.sendBeacon === 'function'
311
+ if (canBeacon) {
312
+ const data: Record<string, string> = {}
313
+ // Ahoy expects events_json to be a JSON string of the events array (not wrapped)
314
+ data['events_json'] = JSON.stringify([event])
315
+ // Include tokens explicitly so we can be cookieless
316
+ if (this.visitToken) data['visit_token'] = this.visitToken
317
+ if (this.visitorToken) data['visitor_token'] = this.visitorToken
318
+
319
+ // Rails CSRF param/name
320
+ const csrfParam = this.getCSRFParam()
321
+ const csrfToken = this.getCSRFToken()
322
+ if (csrfParam && csrfToken) data[csrfParam] = csrfToken
323
+
324
+ const form = new FormData()
325
+ Object.entries(data).forEach(([k, v]) => form.append(k, v))
326
+ navigator.sendBeacon(this.config.eventsEndpoint, form)
327
+ return
328
+ }
329
+
330
+ // Fallback: JSON + CSRF header via fetch
331
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' }
332
+ const csrfToken = this.getCSRFToken()
333
+ if (csrfToken) headers['X-CSRF-Token'] = csrfToken
334
+
335
+ const jsonPayload = {
336
+ visit_token: this.visitToken,
337
+ visitor_token: this.visitorToken,
338
+ events: [event]
339
+ }
340
+
341
+ fetch(this.config.eventsEndpoint, {
342
+ method: 'POST',
343
+ headers,
344
+ body: JSON.stringify(jsonPayload),
345
+ credentials: 'same-origin',
346
+ keepalive: true
347
+ }).catch(() => { /* never block app */ })
348
+ }
349
+
350
+ private generateToken(): string {
351
+ // Generate random token (UUIDv4-like hex)
352
+ const cryptoObj = (typeof globalThis !== 'undefined' ? (globalThis as any).crypto : undefined) as Crypto | undefined
353
+ if (cryptoObj?.randomUUID) return cryptoObj.randomUUID().replace(/-/g, '')
354
+ const array = new Uint8Array(16)
355
+ if (cryptoObj?.getRandomValues) {
356
+ cryptoObj.getRandomValues(array)
357
+ } else {
358
+ // Fallback to Math.random when Web Crypto is unavailable (very rare)
359
+ for (let i = 0; i < array.length; i++) array[i] = Math.floor(Math.random() * 256)
360
+ }
361
+ return Array.from(array, b => b.toString(16).padStart(2, '0')).join('')
362
+ }
363
+
364
+ private getOrCreateVisitorToken(): string {
365
+ if (this.config.useCookies) {
366
+ const cookie = this.getCookie('ahoy_visitor')
367
+ if (cookie) return cookie
368
+ const token = this.generateToken()
369
+ this.setCookie('ahoy_visitor', token, 60 * 24 * 365 * 2) // 2 years
370
+ return token
371
+ } else {
372
+ const stored = localStorage.getItem('ahoy_visitor')
373
+ if (stored) return stored
374
+ const token = this.generateToken()
375
+ localStorage.setItem('ahoy_visitor', token)
376
+ return token
377
+ }
378
+ }
379
+
380
+ private getCSRFToken(): string | null {
381
+ const meta = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')
382
+ return meta?.content || null
383
+ }
384
+
385
+ private getCSRFParam(): string | null {
386
+ const meta = document.querySelector<HTMLMetaElement>('meta[name="csrf-param"]')
387
+ return meta?.content || null
388
+ }
389
+
390
+ // ----- Visit lifecycle -----
391
+ private isVisitExpired(): boolean {
392
+ if (!this.visitExpiresAt) return true
393
+ return Date.now() > this.visitExpiresAt
394
+ }
395
+
396
+ private createNewVisitToken(): void {
397
+ this.visitToken = this.generateToken()
398
+ const ttlMs = this.config.visitDurationMinutes * 60 * 1000
399
+ this.visitExpiresAt = Date.now() + ttlMs
400
+ this.storeVisit(this.visitToken, this.visitExpiresAt)
401
+ }
402
+
403
+ private ensureActiveVisit(): Promise<void> {
404
+ if (!this.visitToken || this.isVisitExpired()) this.createNewVisitToken()
405
+ // Send visit to server (JSON + CSRF header); mirrors ahoy.js createVisit
406
+ const payload: Record<string, any> = {
407
+ visit_token: this.visitToken,
408
+ visitor_token: this.visitorToken,
409
+ platform: 'Web',
410
+ landing_page: window.location.href,
411
+ screen_width: window.innerWidth,
412
+ screen_height: window.innerHeight,
413
+ screen_size: `${window.innerWidth}x${window.innerHeight}`,
414
+ js: true
415
+ }
416
+ if (document.referrer) payload.referrer = document.referrer
417
+
418
+ // Include UTM parameters from the landing URL (Plausible-compatible behavior)
419
+ try {
420
+ const params = new URLSearchParams(window.location.search)
421
+ const utm_source = params.get('utm_source') || params.get('source') || params.get('ref')
422
+ const utm_medium = params.get('utm_medium')
423
+ const utm_campaign = params.get('utm_campaign')
424
+ const utm_content = params.get('utm_content')
425
+ const utm_term = params.get('utm_term')
426
+ if (utm_source) payload.utm_source = utm_source
427
+ if (utm_medium) payload.utm_medium = utm_medium
428
+ if (utm_campaign) payload.utm_campaign = utm_campaign
429
+ if (utm_content) payload.utm_content = utm_content
430
+ if (utm_term) payload.utm_term = utm_term
431
+ } catch {}
432
+
433
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' }
434
+ const csrf = this.getCSRFToken()
435
+ if (csrf) headers['X-CSRF-Token'] = csrf
436
+
437
+ return fetch(this.config.visitsEndpoint, {
438
+ method: 'POST',
439
+ headers,
440
+ body: JSON.stringify(payload),
441
+ credentials: 'same-origin',
442
+ keepalive: true
443
+ }).then(() => { /* ok */ }).catch(() => { /* ignore analytics errors */ })
444
+ }
445
+
446
+ private getStoredVisit(): { token: string, expiresAt: number } | null {
447
+ if (this.config.useCookies) {
448
+ const token = this.getCookie('ahoy_visit')
449
+ const exp = this.getCookie('ahoy_visit_expires')
450
+ if (token && exp) return { token, expiresAt: parseInt(exp, 10) }
451
+ return null
452
+ } else {
453
+ const token = localStorage.getItem('ahoy_visit')
454
+ const exp = localStorage.getItem('ahoy_visit_expires')
455
+ if (token && exp) return { token, expiresAt: parseInt(exp, 10) }
456
+ return null
457
+ }
458
+ }
459
+
460
+ private storeVisit(token: string, expiresAt: number): void {
461
+ if (this.config.useCookies) {
462
+ this.setCookie('ahoy_visit', token, this.config.visitDurationMinutes)
463
+ // store absolute expiry (ms since epoch)
464
+ this.setCookie('ahoy_visit_expires', String(expiresAt), this.config.visitDurationMinutes)
465
+ } else {
466
+ localStorage.setItem('ahoy_visit', token)
467
+ localStorage.setItem('ahoy_visit_expires', String(expiresAt))
468
+ }
469
+ }
470
+
471
+ // ----- Cookie helpers -----
472
+ private setCookie(name: string, value: string, ttlMinutes: number) {
473
+ const d = new Date()
474
+ d.setTime(d.getTime() + ttlMinutes * 60 * 1000)
475
+ document.cookie = `${name}=${encodeURIComponent(value)}; expires=${d.toUTCString()}; path=/; samesite=lax`
476
+ }
477
+ private getCookie(name: string): string | null {
478
+ const match = document.cookie.match(new RegExp('(^|; )' + name.replace(/([.$?*|{}()\[\]\\/+^])/g, '\\$1') + '=([^;]*)'))
479
+ return match ? decodeURIComponent(match[2]) : null
480
+ }
481
+
482
+ // ----- Engagement (Plausible-like) -----
483
+ private initEngagement(): void {
484
+ this.currentDocumentHeight = this.getDocumentHeight()
485
+ this.maxScrollDepthPx = this.getCurrentScrollDepthPx()
486
+
487
+ window.addEventListener('load', () => {
488
+ this.currentDocumentHeight = this.getDocumentHeight()
489
+ let count = 0
490
+ const interval = setInterval(() => {
491
+ this.currentDocumentHeight = this.getDocumentHeight()
492
+ if (++count === 15) clearInterval(interval)
493
+ }, 200)
494
+ })
495
+
496
+ document.addEventListener('scroll', () => {
497
+ this.currentDocumentHeight = this.getDocumentHeight()
498
+ const cur = this.getCurrentScrollDepthPx()
499
+ if (cur > this.maxScrollDepthPx) this.maxScrollDepthPx = cur
500
+ })
501
+ }
502
+
503
+ private prePageview(): void {
504
+ if (this.listeningOnEngagement) {
505
+ this.triggerEngagement()
506
+ this.currentDocumentHeight = this.getDocumentHeight()
507
+ this.maxScrollDepthPx = this.getCurrentScrollDepthPx()
508
+ }
509
+ }
510
+
511
+ private postPageview(payload: { url: string, page: string }): void {
512
+ this.currentEngagementIgnored = false
513
+ this.currentEngagementURL = payload.url
514
+ this.currentEngagementMaxScrollDepth = -1
515
+ this.currentEngagementTime = 0
516
+ this.runningEngagementStart = Date.now()
517
+ this.registerEngagementListeners()
518
+ }
519
+
520
+ private onVisibilityChange = (): void => {
521
+ if (document.visibilityState === 'visible' && document.hasFocus() && this.runningEngagementStart === 0) {
522
+ this.runningEngagementStart = Date.now()
523
+ } else if (document.visibilityState === 'hidden' || !document.hasFocus()) {
524
+ this.currentEngagementTime = this.getEngagementTime()
525
+ this.runningEngagementStart = 0
526
+ this.triggerEngagement()
527
+ }
528
+ }
529
+
530
+ private registerEngagementListeners(): void {
531
+ if (!this.listeningOnEngagement) {
532
+ document.addEventListener('visibilitychange', this.onVisibilityChange)
533
+ window.addEventListener('blur', this.onVisibilityChange)
534
+ window.addEventListener('focus', this.onVisibilityChange)
535
+ this.listeningOnEngagement = true
536
+ }
537
+ }
538
+
539
+ private getEngagementTime(): number {
540
+ if (this.runningEngagementStart) {
541
+ return this.currentEngagementTime + (Date.now() - this.runningEngagementStart)
542
+ } else {
543
+ return this.currentEngagementTime
544
+ }
545
+ }
546
+
547
+ private triggerEngagement(): void {
548
+ const engagementTime = this.getEngagementTime()
549
+ const increasedScroll = this.currentEngagementMaxScrollDepth < this.maxScrollDepthPx
550
+ // Send if first engagement (maxScrollDepth=-1), or scroll increased, or >=3s engaged
551
+ if (!this.currentEngagementIgnored && (increasedScroll || engagementTime >= 3000 || this.currentEngagementMaxScrollDepth === -1)) {
552
+ this.currentEngagementMaxScrollDepth = this.maxScrollDepthPx
553
+ const sd = this.currentDocumentHeight > 0 ? Math.round((this.maxScrollDepthPx / this.currentDocumentHeight) * 100) : 0
554
+ const url = this.currentEngagementURL || window.location.href
555
+ this.sendEvent({
556
+ name: 'engagement',
557
+ page: window.location.pathname + window.location.search,
558
+ url,
559
+ title: document.title,
560
+ referrer: document.referrer || '',
561
+ screenSize: `${window.innerWidth}x${window.innerHeight}`
562
+ }, { engaged_ms: engagementTime, scroll_depth: sd })
563
+ // Reset timers
564
+ this.runningEngagementStart = 0
565
+ this.currentEngagementTime = 0
566
+ }
567
+ }
568
+
569
+ private getDocumentHeight(): number {
570
+ const body = document.body as HTMLElement | null
571
+ const el = document.documentElement as HTMLElement | null
572
+ const b = body || ({} as any)
573
+ const d = el || ({} as any)
574
+ return Math.max(
575
+ b.scrollHeight || 0,
576
+ b.offsetHeight || 0,
577
+ b.clientHeight || 0,
578
+ d.scrollHeight || 0,
579
+ d.offsetHeight || 0,
580
+ d.clientHeight || 0
581
+ )
582
+ }
583
+
584
+ private getCurrentScrollDepthPx(): number {
585
+ const body = document.body as HTMLElement | null
586
+ const el = document.documentElement as HTMLElement | null
587
+ const viewportHeight = window.innerHeight || (el && el.clientHeight) || 0
588
+ const scrollTop = window.scrollY || (el && (el as any).scrollTop) || (body && (body as any).scrollTop) || 0
589
+ return this.currentDocumentHeight <= viewportHeight ? this.currentDocumentHeight : (scrollTop as number) + viewportHeight
590
+ }
591
+
592
+ // ----- Auto-capture: outbound links & file downloads -----
593
+ private initAutoCapture(): void {
594
+ const handler = (event: Event) => {
595
+ // auxclick only for middle button
596
+ if (event.type === 'auxclick' && (event as MouseEvent).button !== 1) return
597
+ const link = this.getLinkEl(event.target as Element | null)
598
+ if (!link || !link.href) return
599
+ try {
600
+ const url = new URL(link.href, window.location.origin)
601
+ // Ignore well-known DevTools config file
602
+ if (url.pathname.startsWith('/.well-known/')) return
603
+ } catch {}
604
+ const hrefWithoutQuery = link.href.split('?')[0]
605
+ if (this.isOutboundLink(link)) {
606
+ this.sendEvent({ name: 'Outbound Link: Click', page: window.location.pathname + window.location.search, url: link.href, title: document.title, referrer: document.referrer || '', screenSize: `${window.innerWidth}x${window.innerHeight}` })
607
+ return
608
+ }
609
+ if (this.isDownloadToTrack(hrefWithoutQuery)) {
610
+ this.sendEvent({ name: 'File Download', page: window.location.pathname + window.location.search, url: hrefWithoutQuery, title: document.title, referrer: document.referrer || '', screenSize: `${window.innerWidth}x${window.innerHeight}` })
611
+ }
612
+ }
613
+ document.addEventListener('click', handler)
614
+ document.addEventListener('auxclick', handler)
615
+ }
616
+
617
+ // Parse plausible-style data attributes from the <script> element that loaded this file
618
+ private readScriptAttributes(): void {
619
+ try {
620
+ const scripts = Array.from(document.getElementsByTagName('script')) as HTMLScriptElement[]
621
+ // Heuristic: find a script whose src contains 'analytics' and either data-include or data-exclude set
622
+ const el = scripts.reverse().find((s) => (s.getAttribute('data-include') || s.getAttribute('data-exclude') || (s.src && s.src.includes('analytics'))))
623
+ if (!el) return
624
+
625
+ const includeAttr = el.getAttribute('data-include')
626
+ const excludeAttr = el.getAttribute('data-exclude')
627
+ if (includeAttr) this.config.includePaths = includeAttr.split(',').map((t) => t.trim()).filter(Boolean)
628
+ if (excludeAttr) this.config.excludePaths = [...this.config.excludePaths, ...excludeAttr.split(',').map((t) => t.trim()).filter(Boolean)]
629
+ } catch {}
630
+ }
631
+
632
+ // Wildcard matching similar to Plausible tracker
633
+ // Supports '*' (single segment) and '**' (any), anchored to start and end by default
634
+ private pathMatches(wildcardPath: string, actualPath: string): boolean {
635
+ const wc = wildcardPath.trim()
636
+ const pattern = '^' + wc
637
+ .replace(/\./g, '\\.')
638
+ .replace(/\*\*/g, '.*')
639
+ .replace(/([^\\])\*/g, '$1[^\\s\/]*') + '\\/?$'
640
+ try {
641
+ return new RegExp(pattern).test(actualPath)
642
+ } catch {
643
+ return false
644
+ }
645
+ }
646
+
647
+ private getLinkEl(node: Element | null): HTMLAnchorElement | null {
648
+ let el: Element | null = node
649
+ let depth = 0
650
+ while (el && depth < 5) {
651
+ if (el instanceof HTMLAnchorElement && el.href) return el
652
+ el = el.parentElement
653
+ depth++
654
+ }
655
+ return null
656
+ }
657
+
658
+ private isOutboundLink(link: HTMLAnchorElement): boolean {
659
+ try {
660
+ return !!link.host && link.host !== window.location.host
661
+ } catch { return false }
662
+ }
663
+
664
+ private isDownloadToTrack(url: string | undefined | null): boolean {
665
+ if (!url) return false
666
+ const DEFAULT_FILE_TYPES = ['pdf','xlsx','docx','txt','rtf','csv','exe','key','pps','ppt','pptx','7z','pkg','rar','gz','zip','avi','mov','mp4','mpeg','wmv','midi','mp3','wav','wma','dmg']
667
+ const ext = url.split('.').pop()?.toLowerCase()
668
+ return !!ext && DEFAULT_FILE_TYPES.includes(ext)
669
+ }
670
+ }
671
+
672
+ // Auto-initialize when script loads (singleton pattern)
673
+ if (typeof window !== 'undefined') {
674
+ // Prevent multiple instances
675
+ if (!(window as any).__analyticsInitialized) {
676
+ const analytics = new StandaloneAnalytics()
677
+ analytics.init()
678
+ ;(window as any).__analyticsInitialized = true
679
+ }
680
+ }