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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +163 -0
- data/Rakefile +6 -0
- data/app/assets/ahoy_analytics/build/assets/Combination-BpSXUjp9.js +41 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-5KyfCxh6.css +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-dashboard-uOXx8zYZ.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-layout-ClAft5OU.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-tracker-B3f8P98z.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/analytics-ui-DMSkNqd6.js +90 -0
- data/app/assets/ahoy_analytics/build/assets/behaviors-panel-ChNGYbdH.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/button-JVCrlR4s.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/cable-DO-7y1-E.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/createLucideIcon-BGzacY2v.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/date-range-dialog-DWDp3cLG.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/details-button-NqKfSGEG.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/devices-panel-cXvlmNBY.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/dialog-path-BBPNlB4Z.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/dropdown-menu-Adj3O5fh.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/filter-dialog-BN-rf4lp.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/index-B1K1NTKT.js +3 -0
- data/app/assets/ahoy_analytics/build/assets/index-BcHeb-Rh.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/index-DzpzLoG4.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/index-vX97OY1J.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/input-e4v_v0kE.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/jsx-runtime-u17CrQMm.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/last-load-context-De5uA95L.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/list-table-ChHEzzF9.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/live-Cp2MHECh.js +2 -0
- data/app/assets/ahoy_analytics/build/assets/locations-panel-BaISRmaQ.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/mercator-BnxX5RzL.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/pages-panel-Bh25L8mP.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/panel-tabs-B2kvGFJx.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/query-context-B-PgE00D.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/remote-details-dialog-DDTcKaM5.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/show-CCRicksg.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/simple-tabs-D6G6Bs0k.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/site-context-BNteYRlR.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/sources-panel-DyB21hxD.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/top-bar-FSiLBjq6.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/top-stats-context-DU15P9jS.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/use-debounce-VBpXQRL8.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/user-context-DbYteluY.js +1 -0
- data/app/assets/ahoy_analytics/build/assets/visitor-globe-BWLDihid.js +4789 -0
- data/app/assets/ahoy_analytics/build/assets/visitor-graph-uKXjLvcu.js +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/brave.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/chrome.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/chromium.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/duckduckgo.svg +2151 -0
- data/app/assets/ahoy_analytics/images/icon/browser/edge.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/fallback.svg +5 -0
- data/app/assets/ahoy_analytics/images/icon/browser/firefox.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/opera.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/safari.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/browser/samsung-internet.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/uc.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/vivaldi.svg +1 -0
- data/app/assets/ahoy_analytics/images/icon/browser/yandex.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/android.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/chrome_os.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/fallback.svg +5 -0
- data/app/assets/ahoy_analytics/images/icon/os/fedora.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/freebsd.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/gnu_linux.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/ios.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/ipad_os.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/mac.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/ubuntu.png +0 -0
- data/app/assets/ahoy_analytics/images/icon/os/windows.png +0 -0
- data/app/assets/stylesheets/ahoy_analytics/application.css +15 -0
- data/app/channels/ahoy_analytics/analytics_channel.rb +9 -0
- data/app/controllers/ahoy_analytics/analytics_controller.rb +9 -0
- data/app/controllers/ahoy_analytics/application_controller.rb +8 -0
- data/app/controllers/ahoy_analytics/assets_controller.rb +46 -0
- data/app/controllers/ahoy_analytics/base_controller.rb +285 -0
- data/app/controllers/ahoy_analytics/behaviors_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/devices_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/export_controller.rb +12 -0
- data/app/controllers/ahoy_analytics/live_controller.rb +9 -0
- data/app/controllers/ahoy_analytics/locations_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/main_graph_controller.rb +10 -0
- data/app/controllers/ahoy_analytics/pages_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/referrers_controller.rb +34 -0
- data/app/controllers/ahoy_analytics/search_terms_controller.rb +47 -0
- data/app/controllers/ahoy_analytics/sources_controller.rb +14 -0
- data/app/controllers/ahoy_analytics/top_stats_controller.rb +13 -0
- data/app/controllers/concerns/ahoy_analytics/set_current_request.rb +17 -0
- data/app/frontend/components/analytics/hex-highlights.tsx +165 -0
- data/app/frontend/components/analytics/hex-land-layer.tsx +61 -0
- data/app/frontend/components/analytics/metric-card.tsx +138 -0
- data/app/frontend/components/analytics/sessions-by-location.tsx +62 -0
- data/app/frontend/components/analytics/visitor-globe.tsx +424 -0
- data/app/frontend/components/ui/accordion.tsx +64 -0
- data/app/frontend/components/ui/alert.tsx +66 -0
- data/app/frontend/components/ui/avatar.tsx +53 -0
- data/app/frontend/components/ui/badge.tsx +46 -0
- data/app/frontend/components/ui/button.tsx +62 -0
- data/app/frontend/components/ui/calendar.tsx +212 -0
- data/app/frontend/components/ui/card.tsx +91 -0
- data/app/frontend/components/ui/checkbox.tsx +32 -0
- data/app/frontend/components/ui/dropdown-menu.tsx +255 -0
- data/app/frontend/components/ui/input.tsx +21 -0
- data/app/frontend/components/ui/label.tsx +22 -0
- data/app/frontend/components/ui/popover.tsx +46 -0
- data/app/frontend/components/ui/select.tsx +183 -0
- data/app/frontend/components/ui/separator.tsx +26 -0
- data/app/frontend/components/ui/sheet.tsx +139 -0
- data/app/frontend/components/ui/sidebar.tsx +726 -0
- data/app/frontend/components/ui/skeleton.tsx +13 -0
- data/app/frontend/components/ui/sonner.tsx +33 -0
- data/app/frontend/components/ui/tooltip.tsx +59 -0
- data/app/frontend/data/countries-110m.json +1 -0
- data/app/frontend/data/globe-data.json +1 -0
- data/app/frontend/entrypoints/analytics-tracker.ts +680 -0
- data/app/frontend/entrypoints/analytics-ui.tsx +26 -0
- data/app/frontend/entrypoints/analytics.css +77 -0
- data/app/frontend/layouts/analytics-layout.tsx +28 -0
- data/app/frontend/lib/cable.ts +13 -0
- data/app/frontend/lib/geocode.ts +65 -0
- data/app/frontend/lib/utils.ts +6 -0
- data/app/frontend/pages/admin/analytics/api.ts +221 -0
- data/app/frontend/pages/admin/analytics/hooks/use-debounce.ts +36 -0
- data/app/frontend/pages/admin/analytics/last-load-context.tsx +29 -0
- data/app/frontend/pages/admin/analytics/lib/base-path.ts +28 -0
- data/app/frontend/pages/admin/analytics/lib/dialog-path.ts +242 -0
- data/app/frontend/pages/admin/analytics/lib/number-formatter.ts +100 -0
- data/app/frontend/pages/admin/analytics/live.tsx +608 -0
- data/app/frontend/pages/admin/analytics/query-context.tsx +61 -0
- data/app/frontend/pages/admin/analytics/show.tsx +40 -0
- data/app/frontend/pages/admin/analytics/site-context.tsx +22 -0
- data/app/frontend/pages/admin/analytics/top-stats-context.tsx +37 -0
- data/app/frontend/pages/admin/analytics/types.ts +161 -0
- data/app/frontend/pages/admin/analytics/ui/analytics-dashboard.tsx +60 -0
- data/app/frontend/pages/admin/analytics/ui/behaviors-panel.tsx +456 -0
- data/app/frontend/pages/admin/analytics/ui/date-range-dialog.tsx +173 -0
- data/app/frontend/pages/admin/analytics/ui/details-button.tsx +33 -0
- data/app/frontend/pages/admin/analytics/ui/devices-panel.tsx +474 -0
- data/app/frontend/pages/admin/analytics/ui/filter-dialog.tsx +558 -0
- data/app/frontend/pages/admin/analytics/ui/list-table.tsx +346 -0
- data/app/frontend/pages/admin/analytics/ui/locations-panel.tsx +566 -0
- data/app/frontend/pages/admin/analytics/ui/pages-panel.tsx +207 -0
- data/app/frontend/pages/admin/analytics/ui/panel-tabs.tsx +65 -0
- data/app/frontend/pages/admin/analytics/ui/remote-details-dialog.tsx +356 -0
- data/app/frontend/pages/admin/analytics/ui/simple-tabs.tsx +54 -0
- data/app/frontend/pages/admin/analytics/ui/sources-panel.tsx +771 -0
- data/app/frontend/pages/admin/analytics/ui/top-bar.tsx +793 -0
- data/app/frontend/pages/admin/analytics/ui/visitor-graph.tsx +891 -0
- data/app/frontend/pages/admin/analytics/user-context.tsx +22 -0
- data/app/frontend/styles/shared.css +156 -0
- data/app/helpers/ahoy_analytics/application_helper.rb +96 -0
- data/app/jobs/ahoy_analytics/application_job.rb +4 -0
- data/app/jobs/ahoy_analytics/update_job.rb +12 -0
- data/app/mailers/ahoy_analytics/application_mailer.rb +6 -0
- data/app/models/ahoy/event/filters.rb +7 -0
- data/app/models/ahoy/event.rb +9 -0
- data/app/models/ahoy/visit/cache_key.rb +15 -0
- data/app/models/ahoy/visit/constants.rb +11 -0
- data/app/models/ahoy/visit/devices.rb +144 -0
- data/app/models/ahoy/visit/export.rb +24 -0
- data/app/models/ahoy/visit/filters.rb +286 -0
- data/app/models/ahoy/visit/imports.rb +36 -0
- data/app/models/ahoy/visit/locations.rb +276 -0
- data/app/models/ahoy/visit/metrics.rb +473 -0
- data/app/models/ahoy/visit/ordering.rb +110 -0
- data/app/models/ahoy/visit/pages.rb +533 -0
- data/app/models/ahoy/visit/pagination.rb +17 -0
- data/app/models/ahoy/visit/ranges.rb +227 -0
- data/app/models/ahoy/visit/series.rb +177 -0
- data/app/models/ahoy/visit/sources.rb +418 -0
- data/app/models/ahoy/visit/url_labels.rb +32 -0
- data/app/models/ahoy/visit.rb +143 -0
- data/app/models/ahoy_analytics/application_record.rb +5 -0
- data/app/models/ahoy_analytics/current.rb +8 -0
- data/app/models/ahoy_analytics/funnel.rb +16 -0
- data/app/models/ahoy_analytics/imported_entry_page.rb +5 -0
- data/app/models/ahoy_analytics/imported_exit_page.rb +5 -0
- data/app/models/ahoy_analytics/imported_page.rb +5 -0
- data/app/models/ahoy_analytics/live_stats.rb +152 -0
- data/app/models/ahoy_analytics/setting.rb +19 -0
- data/app/models/analytics/source_catalog.rb +48 -0
- data/app/views/layouts/ahoy_analytics/application.html.erb +15 -0
- data/config/routes.rb +21 -0
- data/config/vite.json +22 -0
- data/db/migrate/20251006104056_create_ahoy_visits_and_events.rb +62 -0
- data/db/migrate/20251006105012_add_analytics_fields_to_ahoy_visits.rb +11 -0
- data/db/migrate/20251012090000_create_analytics_funnels_and_imports.rb +52 -0
- data/db/migrate/20251013021500_add_analytics_indexes.rb +14 -0
- data/lib/ahoy_analytics/ahoy_store.rb +429 -0
- data/lib/ahoy_analytics/asset_manifest.rb +56 -0
- data/lib/ahoy_analytics/device_bucket.rb +39 -0
- data/lib/ahoy_analytics/engine.rb +55 -0
- data/lib/ahoy_analytics/maxmind_geo.rb +77 -0
- data/lib/ahoy_analytics/version.rb +3 -0
- data/lib/ahoy_analytics.rb +52 -0
- data/lib/generators/ahoy_analytics/install/install_generator.rb +111 -0
- data/lib/generators/ahoy_analytics/install/templates/initializer.rb +28 -0
- data/lib/tasks/ahoy_analytics_tasks.rake +4 -0
- 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,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
|
+
}
|