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,26 @@
1
+ import '@vitejs/plugin-react/preamble'
2
+ import '@/entrypoints/analytics.css'
3
+ import { createInertiaApp } from '@inertiajs/react'
4
+ import { createElement } from 'react'
5
+ import type { ComponentType } from 'react'
6
+ import { createRoot } from 'react-dom/client'
7
+
8
+ type ResolvedComponent = {
9
+ default: ComponentType
10
+ }
11
+
12
+ createInertiaApp({
13
+ resolve: (name) => {
14
+ const pages = import.meta.glob<ResolvedComponent>('../pages/**/*.tsx')
15
+ const loader = pages[`../pages/${name}.tsx`]
16
+ if (!loader) {
17
+ console.error(`Missing Inertia page component: '${name}.tsx'`)
18
+ return Promise.reject(new Error(`Missing Inertia page component: '${name}.tsx'`))
19
+ }
20
+ return loader()
21
+ },
22
+ setup({ el, App, props }) {
23
+ if (!el) return
24
+ createRoot(el).render(createElement(App, props))
25
+ }
26
+ })
@@ -0,0 +1,77 @@
1
+ @import "../styles/shared.css";
2
+
3
+ /* Admin Panel - Theme Styles */
4
+ /* Following shadcn/ui theming convention with CSS variables */
5
+ /* Color scheme adapted from public.css dark theme for brand consistency */
6
+ /* Note: Tailwind CSS is imported only once in application.css */
7
+
8
+ /* Admin theme - applied globally when admin.css is loaded */
9
+ :root {
10
+ /* Dark theme by default for admin */
11
+ color-scheme: dark;
12
+
13
+ /* Base radius - matching public for consistency */
14
+ --radius: 0.625rem;
15
+
16
+ /* Core colors - matching public.css dark theme palette */
17
+ --background: oklch(0.18 0.015 265); /* Same as public dark background */
18
+ --foreground: oklch(0.92 0.02 265); /* Same as public dark foreground */
19
+
20
+ /* Card colors - elevated surface for better hierarchy */
21
+ --card: oklch(0.24 0.01 265); /* Elevated from background */
22
+ --card-foreground: oklch(0.92 0.02 265); /* Consistent foreground */
23
+
24
+ /* Popover/Dropdown - more elevated for better contrast */
25
+ --popover: oklch(0.30 0.015 265); /* More elevated than card */
26
+ --popover-foreground: oklch(0.95 0.02 265); /* Brighter text for contrast */
27
+
28
+ /* Primary - golden accent from public.css */
29
+ --primary: oklch(0.78 0.18 84); /* Golden primary from public */
30
+ --primary-foreground: oklch(0.22 0.02 265); /* Dark text on golden */
31
+
32
+ /* Secondary - subtle elevated surface */
33
+ --secondary: oklch(0.28 0.01 265); /* Slightly elevated */
34
+ --secondary-foreground: oklch(0.90 0.015 265); /* Bright foreground */
35
+
36
+ /* Muted - for less prominent elements */
37
+ --muted: oklch(0.32 0.009 265); /* Subtle elevation */
38
+ --muted-foreground: oklch(0.65 0.018 265); /* Dimmed text */
39
+
40
+ /* Accent (UI hover/background) — keep neutral like shadcn default */
41
+ --accent: oklch(0.36 0.015 265);
42
+ --accent-foreground: oklch(0.95 0.02 265);
43
+
44
+ /* Data accent (analytics visuals only) */
45
+ --data-accent: oklch(0.75 0.13 210); /* cyan-400 vibe */
46
+
47
+ /* Destructive - error/danger state */
48
+ --destructive: oklch(0.56 0.25 26); /* Red tone from public */
49
+ --destructive-foreground: oklch(0.95 0.02 265); /* Bright text */
50
+
51
+ /* Form elements and borders */
52
+ --border: oklch(0.38 0.015 265); /* More visible border */
53
+ --input: oklch(0.95 0.005 265); /* Very light for 30% opacity in dark mode */
54
+ --ring: oklch(0.78 0.15 90); /* Golden ring focus */
55
+
56
+ /* Chart palette (analytics) — first tone uses data accent */
57
+ --chart-1: var(--data-accent);
58
+ --chart-2: oklch(0.72 0.12 230);
59
+ --chart-3: oklch(0.70 0.12 200);
60
+ --chart-4: oklch(0.76 0.10 220);
61
+ --chart-5: oklch(0.68 0.10 240);
62
+
63
+ /* Sidebar - consistent with card styling */
64
+ --sidebar: oklch(0.24 0.01 265); /* Same as card */
65
+ --sidebar-foreground: oklch(0.90 0.02 265); /* Slightly dimmed */
66
+ --sidebar-primary: var(--primary); /* Golden primary */
67
+ --sidebar-primary-foreground: var(--primary-foreground);
68
+ --sidebar-accent: oklch(0.32 0.015 265); /* Hover state */
69
+ --sidebar-accent-foreground: oklch(0.95 0.02 265); /* Bright on hover */
70
+ --sidebar-border: oklch(0.32 0.01 265); /* Subtle border */
71
+ --sidebar-ring: var(--ring); /* Same ring color */
72
+ }
73
+
74
+ /* Since admin.css is only loaded for admin pages, these variables
75
+ become the default for everything including portaled content like
76
+ dropdowns. The Button component has a "golden" variant that matches
77
+ the public.css 3D button style systematically. */
@@ -0,0 +1,28 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ import { cn } from '@/lib/utils'
4
+
5
+ type AnalyticsLayoutProps = {
6
+ children: ReactNode
7
+ fullBleed?: boolean
8
+ className?: string
9
+ }
10
+
11
+ export default function AnalyticsLayout({
12
+ children,
13
+ fullBleed = false,
14
+ className
15
+ }: AnalyticsLayoutProps) {
16
+ return (
17
+ <div className={cn("min-h-dvh bg-background text-foreground", fullBleed && "flex flex-col")}>
18
+ <main
19
+ className={cn(
20
+ fullBleed ? 'flex flex-1 flex-col min-h-dvh' : 'mx-auto w-full max-w-[1400px] px-4 py-6 lg:px-8',
21
+ className
22
+ )}
23
+ >
24
+ {children}
25
+ </main>
26
+ </div>
27
+ )
28
+ }
@@ -0,0 +1,13 @@
1
+ import * as ActionCable from '@rails/actioncable'
2
+
3
+ let consumer: ActionCable.Cable | null = null
4
+
5
+ export function getConsumer() {
6
+ if (!consumer) {
7
+ const cablePath = (window as any)?.AhoyAnalytics?.cablePath || '/cable'
8
+ consumer = ActionCable.createConsumer(cablePath)
9
+ }
10
+ return consumer
11
+ }
12
+
13
+ export type Subscription = ActionCable.Channel
@@ -0,0 +1,65 @@
1
+ export type GeocodeResult = { name: string; lat: number; lng: number }
2
+ export type GeocodeOptions = {
3
+ countryCodes?: string[]
4
+ biasLng?: number // hemisphere bias around this longitude (degrees)
5
+ biasWidthDeg?: number // width of bias viewbox in degrees (default ~160)
6
+ limit?: number
7
+ placeTypes?: string[] // whitelist of Nominatim addresstype/type when category==='place'
8
+ }
9
+
10
+ // Simple OSS geocoder (OpenStreetMap Nominatim). For production, respect usage policy.
11
+ export async function geocodeOsm(query: string, optsOrSignal?: GeocodeOptions | AbortSignal, maybeSignal?: AbortSignal): Promise<GeocodeResult[]> {
12
+ const opts: GeocodeOptions | undefined = optsOrSignal instanceof AbortSignal ? undefined : optsOrSignal
13
+ const signal: AbortSignal | undefined = (optsOrSignal instanceof AbortSignal ? optsOrSignal : maybeSignal) || undefined
14
+
15
+ const url = new URL('https://nominatim.openstreetmap.org/search')
16
+ url.searchParams.set('format', 'jsonv2')
17
+ url.searchParams.set('q', query)
18
+ url.searchParams.set('limit', String(opts?.limit ?? 5))
19
+ // Identify requests per Nominatim usage policy. In browsers we cannot set
20
+ // a custom User-Agent header, so provide a contact email in the query.
21
+ // See: https://operations.osmfoundation.org/policies/nominatim/
22
+ const email = (window as any)?.AhoyAnalytics?.geocodeEmail
23
+ if (email) url.searchParams.set('email', email)
24
+ if (opts?.countryCodes?.length) {
25
+ url.searchParams.set('countrycodes', opts.countryCodes.join(','))
26
+ }
27
+ // Bias to current hemisphere via a wide viewbox centered on current longitude
28
+ if (typeof opts?.biasLng === 'number') {
29
+ const width = Math.max(30, Math.min(180, opts.biasWidthDeg ?? 160))
30
+ const left = normalizeLng(opts.biasLng - width / 2)
31
+ const right = normalizeLng(opts.biasLng + width / 2)
32
+ // If the box would cross the antimeridian, skip viewbox to avoid invalid l>r
33
+ if (left < right) {
34
+ // Use almost-full latitude to cover the hemisphere; avoid +/-90 edge cases
35
+ url.searchParams.set('viewbox', `${left},${85},${right},${-85}`)
36
+ // Do not set bounded=1; viewbox here is a boost, not a hard filter
37
+ }
38
+ }
39
+ const res = await fetch(url.toString(), {
40
+ headers: { 'Accept-Language': navigator.language || 'en' },
41
+ method: 'GET',
42
+ signal
43
+ })
44
+ if (!res.ok) return []
45
+ const data = await res.json()
46
+ let rows: any[] = Array.isArray(data) ? data : []
47
+ // Prefer place results with common city/region/country-like types
48
+ const defaultPlaceTypes = opts?.placeTypes ?? [
49
+ 'city','town','village','hamlet','suburb','locality',
50
+ 'municipality','borough','county','state','region','province','country'
51
+ ]
52
+ const placeRows = rows.filter((d) => d && d.category === 'place' && defaultPlaceTypes.includes(d.type || d.addresstype))
53
+ if (placeRows.length) rows = placeRows
54
+ return rows.map((d: any) => ({
55
+ name: d.display_name as string,
56
+ lat: parseFloat(d.lat),
57
+ lng: parseFloat(d.lon)
58
+ }))
59
+ }
60
+
61
+ function normalizeLng(lng: number) {
62
+ let x = ((lng + 180) % 360 + 360) % 360 - 180
63
+ if (x === -180) x = 180
64
+ return x
65
+ }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
@@ -0,0 +1,221 @@
1
+ import type {
2
+ AnalyticsQuery,
3
+ BehaviorsPayload,
4
+ DevicesPayload,
5
+ ListPayload,
6
+ MainGraphPayload,
7
+ MapPayload,
8
+ TopStatsPayload
9
+ } from './types'
10
+ import { analyticsPath } from './lib/base-path'
11
+
12
+ // --- URL param helpers (Plausible-style f/l scheme) ---
13
+ const NOT_URL_ENCODED_CHARACTERS = ':/'
14
+
15
+ function encodeURIComponentPermissive(input: string, permittedCharacters: string): string {
16
+ let result = encodeURIComponent(input)
17
+ for (const ch of permittedCharacters) {
18
+ const enc = encodeURIComponent(ch)
19
+ if (enc !== ch) {
20
+ // Replace all occurrences without using replaceAll (ES2021)
21
+ result = result.split(enc).join(ch)
22
+ }
23
+ }
24
+ return result
25
+ }
26
+
27
+ function serializeFilterEntry(operator: string, key: string, value: string) {
28
+ // f=<operator>,<dimension>,<clause>
29
+ const op = encodeURIComponentPermissive(operator, NOT_URL_ENCODED_CHARACTERS)
30
+ const dim = encodeURIComponentPermissive(key, NOT_URL_ENCODED_CHARACTERS)
31
+ const clause = encodeURIComponentPermissive(value, NOT_URL_ENCODED_CHARACTERS)
32
+ return `f=${op},${dim},${clause}`
33
+ }
34
+
35
+ function serializeLabelEntry(key: string, label: string) {
36
+ const k = encodeURIComponentPermissive(key, NOT_URL_ENCODED_CHARACTERS)
37
+ const v = encodeURIComponentPermissive(label, NOT_URL_ENCODED_CHARACTERS)
38
+ return `l=${k},${v}`
39
+ }
40
+
41
+ export function buildQueryParams(query: AnalyticsQuery, extras: Record<string, unknown> = {}) {
42
+ const pieces: string[] = []
43
+ const merged: Record<string, unknown> = { ...query, ...extras }
44
+
45
+ if (merged.period) pieces.push(`period=${encodeURIComponent(String(merged.period))}`)
46
+ if (merged.comparison) pieces.push(`comparison=${encodeURIComponent(String(merged.comparison))}`)
47
+ if (merged.metric) pieces.push(`metric=${encodeURIComponent(String(merged.metric))}`)
48
+ if (merged.interval) pieces.push(`interval=${encodeURIComponent(String(merged.interval))}`)
49
+ if (merged.mode) pieces.push(`mode=${encodeURIComponent(String(merged.mode))}`)
50
+ if (merged.funnel) pieces.push(`funnel=${encodeURIComponent(String(merged.funnel))}`)
51
+ if (merged.withImported) pieces.push(`with_imported=${encodeURIComponent(String(merged.withImported))}`)
52
+ if ((merged as any).dialog) pieces.push(`dialog=${encodeURIComponent(String((merged as any).dialog))}`)
53
+ if ((merged as any).date) pieces.push(`date=${encodeURIComponent(String((merged as any).date))}`)
54
+ if ((merged as any).from) pieces.push(`from=${encodeURIComponent(String((merged as any).from))}`)
55
+ if ((merged as any).to) pieces.push(`to=${encodeURIComponent(String((merged as any).to))}`)
56
+ if (merged.comparison) {
57
+ if ((merged as any).compareFrom) pieces.push(`compare_from=${encodeURIComponent(String((merged as any).compareFrom))}`)
58
+ if ((merged as any).compareTo) pieces.push(`compare_to=${encodeURIComponent(String((merged as any).compareTo))}`)
59
+ if ((merged as any).matchDayOfWeek != null) pieces.push(`match_day_of_week=${encodeURIComponent(String((merged as any).matchDayOfWeek))}`)
60
+ }
61
+
62
+ const filters = (merged.filters as AnalyticsQuery['filters']) || {}
63
+ for (const [key, value] of Object.entries(filters)) {
64
+ if (value == null || value === '') continue
65
+ pieces.push(serializeFilterEntry('is', key, String(value)))
66
+ }
67
+
68
+ // Advanced filters (is_not / contains) — append as repeated f entries
69
+ const advanced = (merged.advancedFilters as AnalyticsQuery['advancedFilters']) || []
70
+ for (const entry of advanced) {
71
+ if (!Array.isArray(entry) || entry.length < 3) continue
72
+ const [op, dim, clause] = entry
73
+ if (!op || !dim || clause == null) continue
74
+ pieces.push(serializeFilterEntry(String(op), String(dim), String(clause)))
75
+ }
76
+
77
+ const labels = (merged.labels as AnalyticsQuery['labels']) || {}
78
+ const filtersObj = (merged.filters as AnalyticsQuery['filters']) || {}
79
+ for (const [k, v] of Object.entries(labels)) {
80
+ if (!v) continue
81
+ // Skip numeric label keys (e.g., city ID) — backend maps them to dimension labels.
82
+ if (/^\d+$/.test(k)) continue
83
+ // Only emit a label when there's a corresponding filter present.
84
+ const hasFilter = Object.prototype.hasOwnProperty.call(filtersObj, k)
85
+ if (!hasFilter) continue
86
+ // Avoid duplicate when label equals the filter value (e.g., city "Mumbai").
87
+ const filterVal = (filtersObj as any)[k]
88
+ if (String(filterVal) === String(v)) continue
89
+ pieces.push(serializeLabelEntry(k, String(v)))
90
+ }
91
+
92
+ // Extras (pagination/search/sort) remain standard encoding
93
+ for (const [k, v] of Object.entries(extras)) {
94
+ if (v == null) continue
95
+ if (k === 'order_by' && typeof v !== 'string') {
96
+ pieces.push(`${encodeURIComponent(k)}=${encodeURIComponent(JSON.stringify(v))}`)
97
+ } else {
98
+ pieces.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
99
+ }
100
+ }
101
+
102
+ // Final dedup (idempotent URL): remove any accidental duplicates while preserving order
103
+ const seen = new Set<string>()
104
+ const deduped: string[] = []
105
+ for (const p of pieces) {
106
+ if (seen.has(p)) continue
107
+ seen.add(p)
108
+ deduped.push(p)
109
+ }
110
+ return deduped.join('&')
111
+ }
112
+
113
+ async function fetchJson<T>(path: string, query: AnalyticsQuery, extras: Record<string, unknown> = {}, signal?: AbortSignal) {
114
+ const qs = buildQueryParams(query, extras)
115
+ const response = await fetch(`${path}?${qs}`, {
116
+ headers: { Accept: 'application/json' },
117
+ signal
118
+ })
119
+ if (!response.ok) {
120
+ throw new Error(`Request failed with status ${response.status}`)
121
+ }
122
+ return (await response.json()) as T
123
+ }
124
+
125
+ export function fetchTopStats(query: AnalyticsQuery, signal?: AbortSignal) {
126
+ return fetchJson<TopStatsPayload>(analyticsPath('top_stats'), query, {}, signal)
127
+ }
128
+
129
+ export function fetchMainGraph(
130
+ query: AnalyticsQuery,
131
+ extras: { metric?: string; interval?: string } = {},
132
+ signal?: AbortSignal
133
+ ) {
134
+ return fetchJson<MainGraphPayload>(analyticsPath('main_graph'), query, extras, signal)
135
+ }
136
+
137
+ export function fetchSources(
138
+ query: AnalyticsQuery,
139
+ extras: { mode?: string } = {},
140
+ signal?: AbortSignal
141
+ ) {
142
+ return fetchJson<ListPayload>(analyticsPath('sources'), query, extras, signal)
143
+ }
144
+
145
+ export function fetchReferrers(
146
+ query: AnalyticsQuery,
147
+ extras: { source: string },
148
+ signal?: AbortSignal
149
+ ) {
150
+ return fetchJson<ListPayload>(analyticsPath('referrers'), query, extras, signal)
151
+ }
152
+
153
+ export function fetchSearchTerms(
154
+ query: AnalyticsQuery,
155
+ extras: Record<string, unknown> = {},
156
+ signal?: AbortSignal
157
+ ) {
158
+ return fetchJson<ListPayload>(analyticsPath('search_terms'), query, extras, signal)
159
+ }
160
+
161
+ export function fetchPages(
162
+ query: AnalyticsQuery,
163
+ extras: { mode?: string } = {},
164
+ signal?: AbortSignal
165
+ ) {
166
+ return fetchJson<ListPayload>(analyticsPath('pages'), query, extras, signal)
167
+ }
168
+
169
+ export function fetchLocations(
170
+ query: AnalyticsQuery,
171
+ extras: { mode?: string } = {},
172
+ signal?: AbortSignal
173
+ ) {
174
+ return fetchJson<MapPayload | ListPayload>(analyticsPath('locations'), query, extras, signal)
175
+ }
176
+
177
+ export function fetchDevices(
178
+ query: AnalyticsQuery,
179
+ extras: { mode?: string } = {},
180
+ signal?: AbortSignal
181
+ ) {
182
+ return fetchJson<DevicesPayload>(analyticsPath('devices'), query, extras, signal)
183
+ }
184
+
185
+ export function fetchBehaviors(
186
+ query: AnalyticsQuery,
187
+ extras: { mode?: string; funnel?: string } = {},
188
+ signal?: AbortSignal
189
+ ) {
190
+ return fetchJson<BehaviorsPayload>(analyticsPath('behaviors'), query, extras, signal)
191
+ }
192
+
193
+ // Generic paginated list fetcher for Details modals
194
+ export async function fetchListPage(
195
+ path: string,
196
+ query: AnalyticsQuery,
197
+ extras: Record<string, unknown> = {},
198
+ opts: { limit?: number; page?: number; search?: string; orderBy?: unknown[][] } = {},
199
+ signal?: AbortSignal
200
+ ) {
201
+ const params: Record<string, unknown> = { ...extras }
202
+ if (typeof opts.limit === 'number') params.limit = String(opts.limit)
203
+ if (typeof opts.page === 'number') params.page = String(opts.page)
204
+ if (typeof opts.search === 'string') params.search = opts.search
205
+ // Send order_by as JSON string following Plausible's pattern: [["metric", "direction"]]
206
+ if (opts.orderBy && Array.isArray(opts.orderBy)) {
207
+ params.order_by = JSON.stringify(opts.orderBy)
208
+ }
209
+ return fetchJson<ListPayload>(path, query, params, signal)
210
+ }
211
+
212
+ export async function exportCsv(query: AnalyticsQuery) {
213
+ const qs = buildQueryParams(query)
214
+ const response = await fetch(`${analyticsPath('export')}?${qs}`, {
215
+ headers: { Accept: 'text/csv' }
216
+ })
217
+ if (!response.ok) {
218
+ throw new Error('Failed to export data')
219
+ }
220
+ return response.blob()
221
+ }
@@ -0,0 +1,36 @@
1
+ import { useCallback, useEffect, useRef } from 'react'
2
+
3
+ const DEBOUNCE_DELAY = 300
4
+
5
+ /**
6
+ * Debounce hook following Plausible's pattern.
7
+ * Delays function execution until after the specified delay has passed
8
+ * since the last time it was invoked.
9
+ */
10
+ export function useDebounce<T extends (...args: any[]) => any>(
11
+ fn: T,
12
+ delay = DEBOUNCE_DELAY
13
+ ): T {
14
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
15
+
16
+ useEffect(() => {
17
+ return () => {
18
+ if (timerRef.current) {
19
+ clearTimeout(timerRef.current)
20
+ }
21
+ }
22
+ }, [])
23
+
24
+ return useCallback(
25
+ ((...args: Parameters<T>) => {
26
+ if (timerRef.current) {
27
+ clearTimeout(timerRef.current)
28
+ }
29
+
30
+ timerRef.current = setTimeout(() => {
31
+ fn(...args)
32
+ }, delay)
33
+ }) as T,
34
+ [fn, delay]
35
+ )
36
+ }
@@ -0,0 +1,29 @@
1
+ import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
2
+
3
+ export type LastLoadContextValue = {
4
+ lastLoadedAt: number
5
+ touch: () => void
6
+ }
7
+
8
+ const LastLoadContext = createContext<LastLoadContextValue | null>(null)
9
+
10
+ export function LastLoadProvider({ children }: { children: ReactNode }) {
11
+ const [lastLoadedAt, setLastLoadedAt] = useState(() => Date.now())
12
+
13
+ const touch = useCallback(() => setLastLoadedAt(Date.now()), [])
14
+
15
+ const value = useMemo<LastLoadContextValue>(
16
+ () => ({ lastLoadedAt, touch }),
17
+ [lastLoadedAt, touch]
18
+ )
19
+
20
+ return <LastLoadContext.Provider value={value}>{children}</LastLoadContext.Provider>
21
+ }
22
+
23
+ export function useLastLoadContext() {
24
+ const context = useContext(LastLoadContext)
25
+ if (!context) {
26
+ throw new Error('useLastLoadContext must be used within a LastLoadProvider')
27
+ }
28
+ return context
29
+ }
@@ -0,0 +1,28 @@
1
+ type AnalyticsWindowConfig = {
2
+ basePath?: string
3
+ cablePath?: string
4
+ }
5
+
6
+ declare global {
7
+ interface Window {
8
+ AhoyAnalytics?: AnalyticsWindowConfig
9
+ }
10
+ }
11
+
12
+ export function analyticsBasePath() {
13
+ if (typeof window === 'undefined') return '/admin/analytics'
14
+ const base = window.AhoyAnalytics?.basePath || '/admin/analytics'
15
+ return base.replace(/\/+$/, '')
16
+ }
17
+
18
+ export function analyticsPath(path: string = '') {
19
+ const base = analyticsBasePath()
20
+ if (!path) return base
21
+ const trimmed = path.replace(/^\/+/, '')
22
+ return `${base}/${trimmed}`
23
+ }
24
+
25
+ export function analyticsCablePath() {
26
+ if (typeof window === 'undefined') return '/cable'
27
+ return window.AhoyAnalytics?.cablePath || '/cable'
28
+ }