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,22 @@
1
+ import { createContext, useContext, type ReactNode } from 'react'
2
+ import type { UserContextValue } from './types'
3
+
4
+ const UserContext = createContext<UserContextValue | null>(null)
5
+
6
+ export function UserProvider({
7
+ value,
8
+ children
9
+ }: {
10
+ value: UserContextValue
11
+ children: ReactNode
12
+ }) {
13
+ return <UserContext.Provider value={value}>{children}</UserContext.Provider>
14
+ }
15
+
16
+ export function useUserContext() {
17
+ const context = useContext(UserContext)
18
+ if (!context) {
19
+ throw new Error('useUserContext must be used within a UserProvider')
20
+ }
21
+ return context
22
+ }
@@ -0,0 +1,156 @@
1
+ /* Global/Shared Styles - Application-wide CSS */
2
+ /* This file contains only shared Tailwind configuration and global utilities */
3
+ /* Theme-specific styles are in public.css (public pages) or admin.css (admin pages) */
4
+
5
+ @import "tailwindcss";
6
+ @import "tw-animate-css";
7
+
8
+ /* Shared Tailwind plugins used across the application */
9
+ @plugin "@tailwindcss/typography";
10
+ @plugin "@tailwindcss/forms";
11
+
12
+ /* Global utility for dark mode support across all layouts */
13
+ @custom-variant dark (&:where(.dark, .dark *, [data-theme="dark"], [data-theme="dark"] *, [data-theme="dark-pro"], [data-theme="dark-pro"] *));
14
+
15
+ /* Sonner Toast Customization */
16
+ [data-sonner-toaster] {
17
+ font-family: inherit;
18
+ }
19
+
20
+ [data-sonner-toast] {
21
+ backdrop-filter: blur(8px);
22
+ background: color-mix(in oklch, var(--background) 95%, transparent 5%) !important;
23
+ border-radius: var(--radius-md);
24
+ box-shadow:
25
+ 0 0 0 1px color-mix(in oklch, var(--border) 40%, transparent 60%),
26
+ 0 4px 12px color-mix(in oklch, oklch(0 0 0) 20%, transparent 80%),
27
+ 0 8px 24px color-mix(in oklch, oklch(0 0 0) 10%, transparent 90%);
28
+ transition: all 0.2s ease;
29
+ }
30
+
31
+ [data-sonner-toast]:hover {
32
+ box-shadow:
33
+ 0 0 0 1px color-mix(in oklch, var(--border) 60%, transparent 40%),
34
+ 0 8px 20px color-mix(in oklch, oklch(0 0 0) 25%, transparent 75%),
35
+ 0 12px 32px color-mix(in oklch, oklch(0 0 0) 15%, transparent 85%);
36
+ transform: translateY(-2px);
37
+ }
38
+
39
+ /* Success toasts with golden theme */
40
+ [data-sonner-toast][data-type="success"] {
41
+ background: color-mix(in oklch, var(--primary) 8%, var(--background) 92%) !important;
42
+ border: 1px solid color-mix(in oklch, var(--primary) 30%, transparent 70%) !important;
43
+ color: var(--primary) !important;
44
+ }
45
+
46
+ [data-sonner-toast][data-type="success"] [data-icon] {
47
+ color: var(--primary);
48
+ }
49
+
50
+ /* Error toasts */
51
+ [data-sonner-toast][data-type="error"] {
52
+ background: color-mix(in oklch, var(--destructive) 8%, var(--background) 92%) !important;
53
+ border: 1px solid color-mix(in oklch, var(--destructive) 30%, transparent 70%) !important;
54
+ color: var(--destructive) !important;
55
+ }
56
+
57
+ [data-sonner-toast][data-type="error"] [data-icon] {
58
+ color: var(--destructive);
59
+ }
60
+
61
+ /* Warning toasts */
62
+ [data-sonner-toast][data-type="warning"] {
63
+ background: color-mix(in oklch, oklch(0.75 0.15 75) 8%, var(--background) 92%) !important;
64
+ border: 1px solid color-mix(in oklch, oklch(0.75 0.15 75) 30%, transparent 70%) !important;
65
+ color: oklch(0.75 0.15 75) !important;
66
+ }
67
+
68
+ /* Info toasts */
69
+ [data-sonner-toast][data-type="info"] {
70
+ background: color-mix(in oklch, oklch(0.65 0.15 240) 8%, var(--background) 92%) !important;
71
+ border: 1px solid color-mix(in oklch, oklch(0.65 0.15 240) 30%, transparent 70%) !important;
72
+ color: oklch(0.65 0.15 240) !important;
73
+ }
74
+
75
+ /* Action buttons */
76
+ [data-sonner-toast] [data-button] {
77
+ background: var(--primary) !important;
78
+ color: var(--primary-foreground) !important;
79
+ border-radius: calc(var(--radius-md) - 2px);
80
+ padding: 0.375rem 0.75rem;
81
+ font-weight: 500;
82
+ transition: all 0.15s ease;
83
+ }
84
+
85
+ [data-sonner-toast] [data-button]:hover {
86
+ background: color-mix(in oklch, var(--primary) 85%, oklch(0 0 0) 15%) !important;
87
+ transform: translateY(-1px);
88
+ }
89
+
90
+ [data-sonner-toast] [data-cancel] {
91
+ background: color-mix(in oklch, var(--muted) 50%, transparent 50%) !important;
92
+ color: var(--muted-foreground) !important;
93
+ }
94
+
95
+ /* Close button */
96
+ [data-sonner-toast] [data-close-button] {
97
+ background: color-mix(in oklch, var(--foreground) 10%, transparent 90%) !important;
98
+ border: 1px solid color-mix(in oklch, var(--border) 40%, transparent 60%) !important;
99
+ transition: all 0.15s ease;
100
+ }
101
+
102
+ [data-sonner-toast] [data-close-button]:hover {
103
+ background: color-mix(in oklch, var(--foreground) 20%, transparent 80%) !important;
104
+ border-color: var(--border) !important;
105
+ }
106
+
107
+ /* Shared theme token mappings */
108
+ @theme inline {
109
+ --radius-sm: calc(var(--radius) - 4px);
110
+ --radius-md: calc(var(--radius) - 2px);
111
+ --radius-lg: var(--radius);
112
+ --radius-xl: calc(var(--radius) + 4px);
113
+
114
+ --color-background: var(--background);
115
+ --color-foreground: var(--foreground);
116
+ --color-card: var(--card);
117
+ --color-card-foreground: var(--card-foreground);
118
+ --color-popover: var(--popover);
119
+ --color-popover-foreground: var(--popover-foreground);
120
+ --color-primary: var(--primary);
121
+ --color-primary-foreground: var(--primary-foreground);
122
+ --color-secondary: var(--secondary);
123
+ --color-secondary-foreground: var(--secondary-foreground);
124
+ --color-muted: var(--muted);
125
+ --color-muted-foreground: var(--muted-foreground);
126
+ --color-accent: var(--accent);
127
+ --color-accent-foreground: var(--accent-foreground);
128
+ --color-destructive: var(--destructive);
129
+ --color-destructive-foreground: var(--destructive-foreground);
130
+ --color-border: var(--border);
131
+ --color-input: var(--input);
132
+ --color-ring: var(--ring);
133
+
134
+ --color-chart-1: var(--chart-1);
135
+ --color-chart-2: var(--chart-2);
136
+ --color-chart-3: var(--chart-3);
137
+ --color-chart-4: var(--chart-4);
138
+ --color-chart-5: var(--chart-5);
139
+
140
+ --color-sidebar: var(--sidebar);
141
+ --color-sidebar-foreground: var(--sidebar-foreground);
142
+ --color-sidebar-primary: var(--sidebar-primary);
143
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
144
+ --color-sidebar-accent: var(--sidebar-accent);
145
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
146
+ --color-sidebar-border: var(--sidebar-border);
147
+ --color-sidebar-ring: var(--sidebar-ring);
148
+ }
149
+
150
+ /* Global base resets - minimal and shared across all layouts */
151
+ @layer base {
152
+ *, *::before, *::after { box-sizing: border-box; }
153
+ html, body { margin: 0; padding: 0; }
154
+ * { @apply border-border outline-ring/50; }
155
+ button:not([disabled]), [role="button"]:not([disabled]) { cursor: pointer; }
156
+ }
@@ -0,0 +1,96 @@
1
+ module AhoyAnalytics
2
+ module ApplicationHelper
3
+ def ahoy_analytics_base_path
4
+ request&.script_name.presence || AhoyAnalytics.config.mount_path.to_s
5
+ end
6
+
7
+ def ahoy_analytics_head_tags(entrypoint)
8
+ # In dev mode with Vite, only include the client tag
9
+ # CSS is imported by JS files and handled by Vite automatically
10
+ return vite_client_tag if use_vite_dev_server?
11
+
12
+ entry = ahoy_analytics_manifest.entry(entrypoint)
13
+ css = Array(entry["css"])
14
+ return nil if css.empty?
15
+
16
+ safe_join(css.map { |path| stylesheet_link_tag(ahoy_analytics_asset_path(path), media: "all") })
17
+ end
18
+
19
+ def ahoy_analytics_body_tags(entrypoint)
20
+ return vite_javascript_tag(entrypoint) if use_vite_dev_server?
21
+
22
+ entry = ahoy_analytics_manifest.entry(entrypoint)
23
+ javascript_include_tag(
24
+ ahoy_analytics_asset_path(entry.fetch("file")),
25
+ type: "module",
26
+ defer: true,
27
+ crossorigin: "anonymous"
28
+ )
29
+ end
30
+
31
+ def ahoy_analytics_tracking_tag
32
+ config = tracking_config
33
+ config_json = json_escape(config.to_json)
34
+ safe_join([
35
+ javascript_tag("window.analyticsConfig = #{config_json};"),
36
+ ahoy_analytics_body_tags("analytics-tracker")
37
+ ])
38
+ end
39
+
40
+ def ahoy_analytics_window_config_tag
41
+ config_json = json_escape(ahoy_analytics_window_config.to_json)
42
+ javascript_tag("window.AhoyAnalytics = #{config_json};")
43
+ end
44
+
45
+ private
46
+
47
+ def ahoy_analytics_manifest
48
+ @ahoy_analytics_manifest ||= AhoyAnalytics::AssetManifest.new(
49
+ path: AhoyAnalytics::Engine.root.join("app/assets/ahoy_analytics/build/.vite/manifest.json")
50
+ )
51
+ end
52
+
53
+ def ahoy_analytics_asset_path(path)
54
+ cleaned = path.to_s.sub(/\A\//, "")
55
+ cleaned = cleaned.sub(/\Aassets\//, "")
56
+ ahoy_analytics.engine_asset_path(path: cleaned)
57
+ end
58
+
59
+ def tracking_config
60
+ ahoy_path = AhoyAnalytics.config.ahoy_path.to_s
61
+ ahoy_path = "/#{ahoy_path}" unless ahoy_path.start_with?("/")
62
+ exclude_paths = Array(AhoyAnalytics.config.tracking_exclude_paths)
63
+ exclude_paths << AhoyAnalytics.config.mount_path if AhoyAnalytics.config.mount_path.present?
64
+ exclude_paths = exclude_paths.compact.uniq
65
+
66
+ base = {
67
+ eventsEndpoint: "#{ahoy_path}/events",
68
+ visitsEndpoint: "#{ahoy_path}/visits",
69
+ excludePaths: exclude_paths,
70
+ hashBasedRouting: AhoyAnalytics.config.tracking_hash_based_routing,
71
+ debug: AhoyAnalytics.config.tracking_debug
72
+ }
73
+
74
+ include_paths = Array(AhoyAnalytics.config.tracking_include_paths).reject(&:blank?)
75
+ base[:includePaths] = include_paths if include_paths.any?
76
+
77
+ base.merge(AhoyAnalytics.config.tracking_options.to_h)
78
+ end
79
+
80
+ def ahoy_analytics_window_config
81
+ {
82
+ basePath: ahoy_analytics_base_path,
83
+ cablePath: AhoyAnalytics.config.cable_path,
84
+ geocodeEmail: AhoyAnalytics.config.geocode_email
85
+ }.compact
86
+ end
87
+
88
+ def use_vite_dev_server?
89
+ return false unless AhoyAnalytics.config.use_vite_dev_server
90
+
91
+ respond_to?(:vite_client_tag) &&
92
+ respond_to?(:vite_stylesheet_tag) &&
93
+ respond_to?(:vite_javascript_tag)
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,4 @@
1
+ module AhoyAnalytics
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AhoyAnalytics
4
+ class UpdateJob < ApplicationJob
5
+ queue_as :default
6
+
7
+ def perform
8
+ payload = AhoyAnalytics::LiveStats.build(now: Time.zone.now)
9
+ ActionCable.server.broadcast(AhoyAnalytics.config.cable_stream, payload)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,6 @@
1
+ module AhoyAnalytics
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,7 @@
1
+ module Ahoy::Event::Filters
2
+ extend ActiveSupport::Concern
3
+
4
+ class_methods do
5
+ # Event-side filter helpers to be moved here (if/when needed)
6
+ end
7
+ end
@@ -0,0 +1,9 @@
1
+ class Ahoy::Event < ApplicationRecord
2
+ include Ahoy::QueryMethods
3
+ include Ahoy::Event::Filters
4
+
5
+ self.table_name = "ahoy_events"
6
+
7
+ belongs_to :visit
8
+ belongs_to :user, optional: true
9
+ end
@@ -0,0 +1,15 @@
1
+ module Ahoy::Visit::CacheKey
2
+ extend ActiveSupport::Concern
3
+
4
+ class_methods do
5
+ def analytics_data_version
6
+ visit_time = Ahoy::Visit.maximum(:started_at)
7
+ event_time = Ahoy::Event.maximum(:time)
8
+ if visit_time || event_time
9
+ [ visit_time, event_time ].compact.map { |time| time.utc.to_f }.max
10
+ else
11
+ [ Ahoy::Visit.maximum(:id), Ahoy::Event.maximum(:id) ].compact.max.to_i
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ module Ahoy::Visit::Constants
2
+ extend ActiveSupport::Concern
3
+
4
+ UNKNOWN_LABEL = "(unknown)".freeze
5
+ NONE_LABEL = "(none)".freeze
6
+ NOT_SET_LABEL = "(not set)".freeze
7
+ DIRECT_LABEL = "Direct / None".freeze
8
+
9
+ EVENT_PAGEVIEW = "pageview".freeze
10
+ EVENT_ENGAGEMENT = "engagement".freeze
11
+ end
@@ -0,0 +1,144 @@
1
+ module Ahoy::Visit::Devices
2
+ extend ActiveSupport::Concern
3
+
4
+ class_methods do
5
+ def devices_payload(query, limit: nil, page: nil, search: nil, order_by: nil)
6
+ mode = query[:mode] || "browsers"
7
+ filters = query[:filters] || {}
8
+ range, = Ahoy::Visit.range_and_interval_for(query[:period], nil, query)
9
+ visits = Ahoy::Visit.scoped_visits(range, filters)
10
+ goal = filters["goal"].presence
11
+
12
+ if mode == "screen-sizes"
13
+ raw_grouped = visits.group(:screen_size).pluck(:screen_size, Arel.sql("ARRAY_AGG(id)"))
14
+ categorized_visit_ids = Hash.new { |h, k| h[k] = [] }
15
+ raw_grouped.each do |screen_size, visit_ids|
16
+ category = categorize_screen_size(screen_size)
17
+ categorized_visit_ids[category].concat(visit_ids)
18
+ end
19
+ counts = Ahoy::Visit.unique_counts_from_grouped_visit_ids(categorized_visit_ids, visits)
20
+ items = counts.map { |name, n| { name: name.to_s.presence || UNKNOWN_LABEL, visitors: n } }
21
+ if search.present?
22
+ items = items.select { |it| it[:name].to_s.downcase.include?(search.downcase) }
23
+ end
24
+
25
+ if limit && page
26
+ # Build counts from (possibly filtered) items
27
+ items_counts = items.each_with_object({}) { |it, h| h[it[:name]] = it[:visitors].to_i }
28
+ total = items_counts.values.sum.nonzero? || 1
29
+
30
+ metrics_map = {}
31
+ if order_by
32
+ metric, _ = order_by
33
+ if metric == "percentage"
34
+ metrics_map = items_counts.keys.index_with { |n| { percentage: (items_counts[n].to_f / total) } }
35
+ elsif %w[bounce_rate visit_duration].include?(metric)
36
+ metrics_all = Ahoy::Visit.calculate_group_metrics(categorized_visit_ids, range, filters)
37
+ metrics_map = items_counts.keys.index_with { |n| metrics_all[n] || {} }
38
+ end
39
+ end
40
+
41
+ sorted_names = Ahoy::Visit.order_names(counts: items_counts, metrics_map: metrics_map, order_by: order_by)
42
+
43
+ paged_names, has_more = Ahoy::Visit.paginate_names(sorted_names, limit: limit, page: page)
44
+ grouped_page_visit_ids = categorized_visit_ids.slice(*paged_names)
45
+
46
+ if goal.present?
47
+ conversions, cr = Ahoy::Visit.conversions_and_rates(grouped_page_visit_ids, visits, range, filters, goal)
48
+ page_items = paged_names.map { |name| { name: name, visitors: conversions[name] || 0, conversion_rate: cr[name] } }
49
+ { results: page_items, metrics: %i[visitors conversion_rate], meta: { has_more: has_more, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query), metric_labels: { visitors: "Conversions", conversionRate: "Conversion Rate" } } }
50
+ else
51
+ page_items = paged_names.map do |name|
52
+ v = items_counts[name]
53
+ { name: name, visitors: v, percentage: (v.to_f / total).round(3) }
54
+ end
55
+ group_metrics = Ahoy::Visit.calculate_group_metrics(grouped_page_visit_ids, range, filters)
56
+ page_items.each { |it| it[:bounce_rate] = group_metrics.dig(it[:name], :bounce_rate); it[:visit_duration] = group_metrics.dig(it[:name], :visit_duration) }
57
+ { results: page_items, metrics: %i[visitors percentage bounce_rate visit_duration], meta: { has_more: has_more, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query) } }
58
+ end
59
+ else
60
+ total = counts.values.sum.nonzero? || 1
61
+ results = items.map { |it| it.merge(percentage: (it[:visitors].to_f / total).round(3)) }
62
+ { results: results, metrics: %i[visitors percentage], meta: { has_more: false, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query) } }
63
+ end
64
+ else
65
+ column = mode == "operating-systems" || mode == "operating-system-versions" ? :os : :browser
66
+ expr = column.to_s
67
+ pattern = search.present? ? Ahoy::Visit.like_contains(search) : nil
68
+
69
+ if limit && page
70
+ rel = visits
71
+ rel = rel.where([ "LOWER(#{expr}) LIKE ?", pattern ]) if pattern.present?
72
+ grouped_visit_ids = rel.group(Arel.sql(expr)).pluck(Arel.sql("#{expr}, ARRAY_AGG(ahoy_visits.id)")).to_h
73
+ counts = Ahoy::Visit.unique_counts_from_grouped_visit_ids(grouped_visit_ids, visits)
74
+ total = counts.values.sum.nonzero? || 1
75
+
76
+ metrics_map = {}
77
+ if order_by
78
+ metric, _ = order_by
79
+ if metric == "percentage"
80
+ metrics_map = counts.keys.index_with { |n| { percentage: (counts[n].to_f / total) } }
81
+ elsif %w[bounce_rate visit_duration].include?(metric)
82
+ metrics_all = Ahoy::Visit.calculate_group_metrics(grouped_visit_ids, range, filters)
83
+ metrics_map = counts.keys.index_with { |n| metrics_all[n] || {} }
84
+ end
85
+ end
86
+ sorted_names = Ahoy::Visit.order_names(counts: counts, metrics_map: metrics_map, order_by: order_by)
87
+
88
+ paged_names, has_more = Ahoy::Visit.paginate_names(sorted_names, limit: limit, page: page)
89
+ page_visit_ids = grouped_visit_ids.slice(*paged_names)
90
+
91
+ if goal.present?
92
+ conversions, cr = Ahoy::Visit.conversions_and_rates(page_visit_ids, visits, range, filters, goal)
93
+ results = paged_names.map { |name| { name: name.to_s.presence || UNKNOWN_LABEL, visitors: conversions[name] || 0, conversion_rate: cr[name] } }
94
+ { 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" } } }
95
+ else
96
+ results = paged_names.map do |name|
97
+ n = counts[name]
98
+ { name: name.to_s.presence || UNKNOWN_LABEL, visitors: n, percentage: (n.to_f / total).round(3) }
99
+ end
100
+ group_metrics = Ahoy::Visit.calculate_group_metrics(page_visit_ids, range, filters)
101
+ paged_names.each_with_index do |name, i|
102
+ results[i][:bounce_rate] = group_metrics.dig(name, :bounce_rate)
103
+ results[i][:visit_duration] = group_metrics.dig(name, :visit_duration)
104
+ end
105
+ { results: results, metrics: %i[visitors percentage bounce_rate visit_duration], meta: { has_more: has_more, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query) } }
106
+ end
107
+ else
108
+ grouped_visit_ids = visits.group(column).pluck(column, Arel.sql("ARRAY_AGG(id)")).to_h
109
+ counts = Ahoy::Visit.unique_counts_from_grouped_visit_ids(grouped_visit_ids, visits)
110
+ total = counts.values.sum.nonzero? || 1
111
+ results = counts.map { |name, n| { name: name.to_s.presence || UNKNOWN_LABEL, visitors: n, percentage: (n.to_f / total).round(3) } }
112
+ { results: results, metrics: %i[visitors percentage], meta: { has_more: false, skip_imported_reason: Ahoy::Visit.skip_imported_reason(query) } }
113
+ end
114
+ end
115
+ end
116
+ def categorize_screen_sizes(visits_scope)
117
+ raw_counts = visits_scope.group(:screen_size).count
118
+ categorized = Hash.new(0)
119
+
120
+ raw_counts.each do |screen_size, count|
121
+ category = categorize_screen_size(screen_size)
122
+ categorized[category] += count
123
+ end
124
+
125
+ categorized
126
+ end
127
+
128
+ def categorize_screen_size(screen_size)
129
+ return "(not set)" if screen_size.blank?
130
+
131
+ if screen_size =~ /^(\d+)x(\d+)$/
132
+ width = $1.to_i
133
+ case width
134
+ when 0...768 then "Mobile"
135
+ when 768...1024 then "Tablet"
136
+ when 1024...1440 then "Laptop"
137
+ else "Desktop"
138
+ end
139
+ else
140
+ screen_size
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,24 @@
1
+ require "csv"
2
+
3
+ module Ahoy::Visit::Export
4
+ extend ActiveSupport::Concern
5
+
6
+ class_methods do
7
+ def csv_export(range, filters)
8
+ visits = Ahoy::Visit.scoped_visits(range, filters)
9
+ data = visits.group(Arel.sql("COALESCE(referring_domain, 'Direct / None')")).count
10
+ CSV.generate do |csv|
11
+ csv << %w[name visitors]
12
+ data.sort_by { |_, v| -v }.each do |(name, v)|
13
+ csv << [ csv_safe_value(name), v ]
14
+ end
15
+ end
16
+ end
17
+
18
+ def csv_safe_value(value)
19
+ str = value.to_s
20
+ str = "'#{str}" if str.match?(/\A[=+\-@]/)
21
+ str
22
+ end
23
+ end
24
+ end