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,608 @@
|
|
|
1
|
+
import { useRef, useState, useEffect, lazy, Suspense } from "react";
|
|
2
|
+
import { Head } from "@inertiajs/react";
|
|
3
|
+
import AnalyticsLayout from "@/layouts/analytics-layout";
|
|
4
|
+
// Avoid importing the VisitorGlobe module at SSR time. It pulls in browser‑only
|
|
5
|
+
// three.js/three-globe dependencies. Use React.lazy so SSR renders a fallback
|
|
6
|
+
// and the heavy code loads only on the client.
|
|
7
|
+
const VisitorGlobe = lazy(() =>
|
|
8
|
+
import("@/components/analytics/visitor-globe").then((m) => ({ default: m.VisitorGlobe }))
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
// Lightweight local types and constants to avoid touching the heavy module during SSR
|
|
12
|
+
type VisitorGlobeZoomState = {
|
|
13
|
+
distance: number
|
|
14
|
+
minDistance: number
|
|
15
|
+
maxDistance: number
|
|
16
|
+
}
|
|
17
|
+
type VisitorGlobeHandle = {
|
|
18
|
+
zoomIn: () => void
|
|
19
|
+
zoomOut: () => void
|
|
20
|
+
getDistance: () => number
|
|
21
|
+
focusOn: (lat: number, lng: number, distance?: number) => void
|
|
22
|
+
flyTo: (lat: number, lng: number, distance?: number, durationMs?: number) => void
|
|
23
|
+
getView: () => { lat: number; lng: number; distance: number }
|
|
24
|
+
}
|
|
25
|
+
const VISITOR_GLOBE_MIN_DISTANCE = 1.8
|
|
26
|
+
const VISITOR_GLOBE_MAX_DISTANCE = 3.2
|
|
27
|
+
import { MetricCard } from "@/components/analytics/metric-card";
|
|
28
|
+
import { SessionsByLocation } from "@/components/analytics/sessions-by-location";
|
|
29
|
+
import { Input } from "@/components/ui/input";
|
|
30
|
+
import { geocodeOsm } from "@/lib/geocode";
|
|
31
|
+
import { Button } from "@/components/ui/button";
|
|
32
|
+
import { Search, Maximize2, Minimize2, Minus, Plus, Eye, EyeOff, X, Loader2 } from "lucide-react";
|
|
33
|
+
import { getConsumer, type Subscription } from "@/lib/cable";
|
|
34
|
+
|
|
35
|
+
type VisitorDot = {
|
|
36
|
+
lat: number;
|
|
37
|
+
lng: number;
|
|
38
|
+
type: "visitor";
|
|
39
|
+
ts?: number; // epoch ms (optional; used for client-side fading)
|
|
40
|
+
city?: string | null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type LocationSession = {
|
|
44
|
+
country: string;
|
|
45
|
+
city: string;
|
|
46
|
+
region?: string;
|
|
47
|
+
countryCode: string;
|
|
48
|
+
visitors: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type SparklinePair = { today: number[]; yesterday: number[] }
|
|
52
|
+
|
|
53
|
+
type LiveStats = {
|
|
54
|
+
currentVisitors: number;
|
|
55
|
+
todayVisitors: {
|
|
56
|
+
count: number;
|
|
57
|
+
change: number;
|
|
58
|
+
sparkline?: SparklinePair;
|
|
59
|
+
};
|
|
60
|
+
todaySessions: {
|
|
61
|
+
count: number;
|
|
62
|
+
change: number;
|
|
63
|
+
sparkline: SparklinePair;
|
|
64
|
+
};
|
|
65
|
+
todayPageviews: {
|
|
66
|
+
count: number;
|
|
67
|
+
change: number;
|
|
68
|
+
sparkline: SparklinePair;
|
|
69
|
+
};
|
|
70
|
+
sessionsByLocation: LocationSession[];
|
|
71
|
+
visitorDots: VisitorDot[];
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const DESKTOP_CARD_WIDTH = 520;
|
|
75
|
+
const DESKTOP_GAP = 24;
|
|
76
|
+
const DESKTOP_PADDING = 24;
|
|
77
|
+
const DESKTOP_HEADER_SPACING = 40;
|
|
78
|
+
const DESKTOP_BOTTOM_PADDING = 16;
|
|
79
|
+
|
|
80
|
+
export default function LiveAnalytics({
|
|
81
|
+
initialStats,
|
|
82
|
+
}: {
|
|
83
|
+
initialStats: LiveStats;
|
|
84
|
+
}) {
|
|
85
|
+
const [stats, setStats] = useState(initialStats);
|
|
86
|
+
const globeRef = useRef<VisitorGlobeHandle>(null);
|
|
87
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
88
|
+
const [globeZoomState, setGlobeZoomState] = useState<VisitorGlobeZoomState>({
|
|
89
|
+
distance: VISITOR_GLOBE_MAX_DISTANCE,
|
|
90
|
+
minDistance: VISITOR_GLOBE_MIN_DISTANCE,
|
|
91
|
+
maxDistance: VISITOR_GLOBE_MAX_DISTANCE,
|
|
92
|
+
});
|
|
93
|
+
const [areCardsVisible, setAreCardsVisible] = useState(true);
|
|
94
|
+
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
95
|
+
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
|
96
|
+
const [query, setQuery] = useState("");
|
|
97
|
+
const [suggestions, setSuggestions] = useState<Array<{ name: string; lat: number; lng: number }>>([]);
|
|
98
|
+
const searchAbort = useRef<AbortController | null>(null);
|
|
99
|
+
const [connectionStatus, setConnectionStatus] = useState<'connected' | 'disconnected' | 'connecting'>('connecting');
|
|
100
|
+
const [desktopFocused, setDesktopFocused] = useState(false)
|
|
101
|
+
const [activeIndex, setActiveIndex] = useState<number>(-1)
|
|
102
|
+
const desktopInputRef = useRef<HTMLInputElement>(null)
|
|
103
|
+
// Track current view for hemisphere bias when searching
|
|
104
|
+
const [view, setView] = useState({ lat: 39, lng: -98, distance: VISITOR_GLOBE_MAX_DISTANCE })
|
|
105
|
+
const [isSearching, setIsSearching] = useState(false)
|
|
106
|
+
|
|
107
|
+
const zoomAtClosest =
|
|
108
|
+
globeZoomState.distance <= globeZoomState.minDistance + 0.05;
|
|
109
|
+
const zoomAtFarthest =
|
|
110
|
+
globeZoomState.distance >= globeZoomState.maxDistance - 0.05;
|
|
111
|
+
|
|
112
|
+
const handleZoomIn = () => {
|
|
113
|
+
if (zoomAtClosest) return;
|
|
114
|
+
globeRef.current?.zoomIn();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const handleZoomOut = () => {
|
|
118
|
+
if (zoomAtFarthest) return;
|
|
119
|
+
globeRef.current?.zoomOut();
|
|
120
|
+
};
|
|
121
|
+
const toggleCardsVisibility = () => {
|
|
122
|
+
setAreCardsVisible((visible) => !visible);
|
|
123
|
+
};
|
|
124
|
+
const toggleSearchVisibility = () => {
|
|
125
|
+
setIsSearchVisible((visible) => !visible);
|
|
126
|
+
setSuggestions([]);
|
|
127
|
+
};
|
|
128
|
+
const toggleFullscreen = async () => {
|
|
129
|
+
if (!containerRef.current) return;
|
|
130
|
+
|
|
131
|
+
if (!document.fullscreenElement) {
|
|
132
|
+
try {
|
|
133
|
+
await containerRef.current.requestFullscreen();
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.error('Failed to enter fullscreen:', err);
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
try {
|
|
139
|
+
await document.exitFullscreen();
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.error('Failed to exit fullscreen:', err);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
setStats(initialStats);
|
|
148
|
+
}, [initialStats]);
|
|
149
|
+
|
|
150
|
+
// Subscribe to realtime analytics updates via ActionCable
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
let subscription: Subscription | null = null;
|
|
153
|
+
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
154
|
+
|
|
155
|
+
const connect = () => {
|
|
156
|
+
try {
|
|
157
|
+
setConnectionStatus('connecting');
|
|
158
|
+
const consumer = getConsumer();
|
|
159
|
+
subscription = consumer.subscriptions.create({ channel: "AhoyAnalytics::AnalyticsChannel" }, {
|
|
160
|
+
connected: () => {
|
|
161
|
+
setConnectionStatus('connected');
|
|
162
|
+
},
|
|
163
|
+
disconnected: () => {
|
|
164
|
+
setConnectionStatus('disconnected');
|
|
165
|
+
// Attempt reconnect after 5 seconds
|
|
166
|
+
reconnectTimeout = setTimeout(() => {
|
|
167
|
+
if (subscription) {
|
|
168
|
+
(subscription as any).unsubscribe?.();
|
|
169
|
+
}
|
|
170
|
+
connect();
|
|
171
|
+
}, 5000);
|
|
172
|
+
},
|
|
173
|
+
received: (data: LiveStats) => {
|
|
174
|
+
setStats((prev) => ({ ...prev, ...data }));
|
|
175
|
+
},
|
|
176
|
+
rejected: () => {
|
|
177
|
+
setConnectionStatus('disconnected');
|
|
178
|
+
console.error("WebSocket connection rejected");
|
|
179
|
+
}
|
|
180
|
+
}) as unknown as Subscription;
|
|
181
|
+
} catch (e) {
|
|
182
|
+
console.error("Cable subscribe failed", e);
|
|
183
|
+
setConnectionStatus('disconnected');
|
|
184
|
+
// Retry connection after 5 seconds
|
|
185
|
+
reconnectTimeout = setTimeout(connect, 5000);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
connect();
|
|
190
|
+
|
|
191
|
+
return () => {
|
|
192
|
+
if (reconnectTimeout) clearTimeout(reconnectTimeout);
|
|
193
|
+
try { subscription && (subscription as any).unsubscribe?.(); } catch {}
|
|
194
|
+
};
|
|
195
|
+
}, []);
|
|
196
|
+
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
const handleFullscreenChange = () => {
|
|
199
|
+
setIsFullscreen(!!document.fullscreenElement);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
|
203
|
+
return () => {
|
|
204
|
+
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
|
205
|
+
};
|
|
206
|
+
}, []);
|
|
207
|
+
const cardsTranslateX = areCardsVisible
|
|
208
|
+
? 0
|
|
209
|
+
: -(DESKTOP_CARD_WIDTH + DESKTOP_GAP);
|
|
210
|
+
const globeTranslateX = areCardsVisible
|
|
211
|
+
? (DESKTOP_CARD_WIDTH + DESKTOP_GAP) / 2
|
|
212
|
+
: 0;
|
|
213
|
+
const formattedTimestamp = new Date().toLocaleDateString("en-US", {
|
|
214
|
+
month: "short",
|
|
215
|
+
day: "numeric",
|
|
216
|
+
year: "numeric",
|
|
217
|
+
hour: "2-digit",
|
|
218
|
+
minute: "2-digit",
|
|
219
|
+
timeZoneName: "short",
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Geocode search (OSS: Nominatim) – shared for mobile + desktop
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
const open = isSearchVisible || desktopFocused
|
|
225
|
+
if (!open) return
|
|
226
|
+
if (!query || query.trim().length < 2) {
|
|
227
|
+
setSuggestions([])
|
|
228
|
+
setIsSearching(false)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
const ac = new AbortController()
|
|
232
|
+
searchAbort.current?.abort()
|
|
233
|
+
searchAbort.current = ac
|
|
234
|
+
// show spinner immediately while we debounce the network call
|
|
235
|
+
setIsSearching(true)
|
|
236
|
+
const id = setTimeout(async () => {
|
|
237
|
+
try {
|
|
238
|
+
const results = await geocodeOsm(query.trim(), { biasLng: view.lng }, ac.signal)
|
|
239
|
+
setSuggestions(results)
|
|
240
|
+
setActiveIndex(results.length ? 0 : -1)
|
|
241
|
+
setIsSearching(false)
|
|
242
|
+
} catch {}
|
|
243
|
+
}, 300)
|
|
244
|
+
return () => { clearTimeout(id); ac.abort(); setIsSearching(false) }
|
|
245
|
+
}, [query, isSearchVisible, desktopFocused, view.lng])
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<AnalyticsLayout fullBleed className="p-4 lg:p-6">
|
|
249
|
+
<Head title="Live View - Analytics" />
|
|
250
|
+
|
|
251
|
+
<div className="flex flex-1 flex-col overflow-hidden h-full rounded-xl">
|
|
252
|
+
<div className="flex flex-1 flex-col overflow-hidden lg:overflow-visible">
|
|
253
|
+
{/* Mobile Layout */}
|
|
254
|
+
<div className="lg:hidden flex-1 overflow-auto p-4">
|
|
255
|
+
<div className="flex flex-col gap-6">
|
|
256
|
+
{/* Legend and Search */}
|
|
257
|
+
<div className="flex flex-col gap-3">
|
|
258
|
+
<div className="flex items-center justify-between">
|
|
259
|
+
<div className="flex items-center gap-4 text-sm">
|
|
260
|
+
<div className="flex items-center gap-2">
|
|
261
|
+
<div className="size-3 rounded-full bg-blue-600 shadow-[0_0_8px_rgba(37,99,235,0.55)]" />
|
|
262
|
+
<span className="text-foreground">Visitors right now</span>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
<div className="flex items-center gap-2">
|
|
266
|
+
<Button
|
|
267
|
+
variant="ghost"
|
|
268
|
+
size="icon"
|
|
269
|
+
className="size-10"
|
|
270
|
+
onClick={toggleSearchVisibility}
|
|
271
|
+
aria-pressed={isSearchVisible}
|
|
272
|
+
>
|
|
273
|
+
<Search className="size-4" />
|
|
274
|
+
<span className="sr-only">
|
|
275
|
+
{isSearchVisible ? "Hide search" : "Show search"}
|
|
276
|
+
</span>
|
|
277
|
+
</Button>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
{isSearchVisible && (
|
|
281
|
+
<div className="relative">
|
|
282
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
283
|
+
<Input
|
|
284
|
+
placeholder="Search location"
|
|
285
|
+
className="pl-9"
|
|
286
|
+
autoFocus
|
|
287
|
+
value={query}
|
|
288
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
289
|
+
/>
|
|
290
|
+
{(query.trim().length === 1 || isSearching || suggestions.length > 0) && (
|
|
291
|
+
<div className="absolute z-20 mt-1 w-full overflow-hidden rounded border border-white/12 bg-[#11131b] text-sm shadow-lg">
|
|
292
|
+
{query.trim().length === 1 ? (
|
|
293
|
+
<div className="px-3 py-2 text-muted-foreground">Type one more character…</div>
|
|
294
|
+
) : isSearching && suggestions.length === 0 ? (
|
|
295
|
+
<div className="flex items-center gap-2 px-3 py-2 text-muted-foreground"><Loader2 className="size-4 animate-spin" /> Searching…</div>
|
|
296
|
+
) : (
|
|
297
|
+
suggestions.map((s, i) => (
|
|
298
|
+
<button
|
|
299
|
+
key={`${s.name}-${i}`}
|
|
300
|
+
className="w-full truncate px-3 py-2 text-left hover:bg-white/5"
|
|
301
|
+
onClick={() => {
|
|
302
|
+
setSuggestions([])
|
|
303
|
+
setIsSearchVisible(false)
|
|
304
|
+
try { globeRef.current?.flyTo(s.lat, s.lng) } catch {}
|
|
305
|
+
}}
|
|
306
|
+
>
|
|
307
|
+
{s.name}
|
|
308
|
+
</button>
|
|
309
|
+
))
|
|
310
|
+
)}
|
|
311
|
+
</div>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
|
|
317
|
+
{/* Globe */}
|
|
318
|
+
<div className="aspect-square w-full">
|
|
319
|
+
<Suspense fallback={<div className="h-full w-full rounded-xl bg-muted/10" />}>
|
|
320
|
+
<VisitorGlobe
|
|
321
|
+
ref={globeRef}
|
|
322
|
+
visitors={stats.visitorDots}
|
|
323
|
+
onViewChange={setView}
|
|
324
|
+
/>
|
|
325
|
+
</Suspense>
|
|
326
|
+
</div>
|
|
327
|
+
|
|
328
|
+
{/* Metrics Grid */}
|
|
329
|
+
<div className="grid grid-cols-2 gap-4">
|
|
330
|
+
<MetricCard
|
|
331
|
+
title="Visitors right now"
|
|
332
|
+
value={stats.currentVisitors}
|
|
333
|
+
variant="large"
|
|
334
|
+
showChange={false}
|
|
335
|
+
/>
|
|
336
|
+
<MetricCard
|
|
337
|
+
title="Visitors today"
|
|
338
|
+
value={stats.todayVisitors.count}
|
|
339
|
+
change={stats.todayVisitors.change}
|
|
340
|
+
sparklineData={stats.todayVisitors.sparkline}
|
|
341
|
+
/>
|
|
342
|
+
<MetricCard
|
|
343
|
+
title="Sessions"
|
|
344
|
+
value={stats.todaySessions.count}
|
|
345
|
+
change={stats.todaySessions.change}
|
|
346
|
+
sparklineData={stats.todaySessions.sparkline}
|
|
347
|
+
/>
|
|
348
|
+
<MetricCard
|
|
349
|
+
title="Pageviews"
|
|
350
|
+
value={stats.todayPageviews.count}
|
|
351
|
+
change={stats.todayPageviews.change}
|
|
352
|
+
sparklineData={stats.todayPageviews.sparkline}
|
|
353
|
+
/>
|
|
354
|
+
</div>
|
|
355
|
+
|
|
356
|
+
{/* Sessions by Location */}
|
|
357
|
+
<div>
|
|
358
|
+
<SessionsByLocation sessions={stats.sessionsByLocation} />
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
|
|
363
|
+
{/* Desktop Layout */}
|
|
364
|
+
<div ref={containerRef} className="relative hidden flex-1 min-h-0 overflow-hidden bg-[#0f1118] lg:block">
|
|
365
|
+
<div className="absolute inset-0 overflow-hidden">
|
|
366
|
+
<div
|
|
367
|
+
className="h-full w-full transition-transform duration-500 ease-out"
|
|
368
|
+
style={{ transform: `translateX(${globeTranslateX}px)` }}
|
|
369
|
+
>
|
|
370
|
+
<Suspense fallback={<div className="h-full w-full rounded-xl bg-muted/10" />}>
|
|
371
|
+
<VisitorGlobe
|
|
372
|
+
ref={globeRef}
|
|
373
|
+
visitors={stats.visitorDots}
|
|
374
|
+
onZoomChange={setGlobeZoomState}
|
|
375
|
+
/>
|
|
376
|
+
</Suspense>
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
|
|
380
|
+
<div
|
|
381
|
+
className="absolute z-30 flex items-center gap-3"
|
|
382
|
+
style={{ top: DESKTOP_PADDING, left: DESKTOP_PADDING }}
|
|
383
|
+
>
|
|
384
|
+
<div
|
|
385
|
+
className={`size-2 rounded-full ${
|
|
386
|
+
connectionStatus === 'connected'
|
|
387
|
+
? 'bg-cyan-400 animate-pulse'
|
|
388
|
+
: connectionStatus === 'connecting'
|
|
389
|
+
? 'bg-yellow-400 animate-pulse'
|
|
390
|
+
: 'bg-red-400'
|
|
391
|
+
}`}
|
|
392
|
+
title={connectionStatus === 'connected' ? 'Connected' : connectionStatus === 'connecting' ? 'Connecting...' : 'Disconnected'}
|
|
393
|
+
/>
|
|
394
|
+
<h1 className="text-2xl font-bold">Live View</h1>
|
|
395
|
+
<span className="text-xs text-muted-foreground">
|
|
396
|
+
{formattedTimestamp}
|
|
397
|
+
</span>
|
|
398
|
+
{connectionStatus === 'disconnected' && (
|
|
399
|
+
<span className="text-xs text-red-400">
|
|
400
|
+
Reconnecting...
|
|
401
|
+
</span>
|
|
402
|
+
)}
|
|
403
|
+
{!areCardsVisible && (
|
|
404
|
+
<Button
|
|
405
|
+
variant="ghost"
|
|
406
|
+
size="icon"
|
|
407
|
+
className="size-8 bg-[#11131b]/80 text-foreground border border-white/12 backdrop-blur-sm"
|
|
408
|
+
onClick={toggleCardsVisibility}
|
|
409
|
+
>
|
|
410
|
+
<EyeOff className="size-4" />
|
|
411
|
+
<span className="sr-only">Show analytics cards</span>
|
|
412
|
+
</Button>
|
|
413
|
+
)}
|
|
414
|
+
</div>
|
|
415
|
+
|
|
416
|
+
<div
|
|
417
|
+
className="absolute z-30 flex items-center gap-2"
|
|
418
|
+
style={{ top: DESKTOP_PADDING, right: DESKTOP_PADDING }}
|
|
419
|
+
>
|
|
420
|
+
<div className="relative">
|
|
421
|
+
<Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
422
|
+
<Input
|
|
423
|
+
ref={desktopInputRef}
|
|
424
|
+
placeholder="Search location"
|
|
425
|
+
className="w-[22rem] pl-9 pr-8 bg-[#11131b]/80 border-white/12 text-foreground"
|
|
426
|
+
value={query}
|
|
427
|
+
onFocus={() => setDesktopFocused(true)}
|
|
428
|
+
onBlur={() => {
|
|
429
|
+
// Close suggestions shortly after blur unless moving to dropdown
|
|
430
|
+
setTimeout(() => setDesktopFocused(false), 120)
|
|
431
|
+
}}
|
|
432
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
433
|
+
onKeyDown={(e) => {
|
|
434
|
+
if (!suggestions.length) return
|
|
435
|
+
if (e.key === 'ArrowDown') {
|
|
436
|
+
e.preventDefault()
|
|
437
|
+
setActiveIndex((i) => Math.min(i + 1, suggestions.length - 1))
|
|
438
|
+
} else if (e.key === 'ArrowUp') {
|
|
439
|
+
e.preventDefault()
|
|
440
|
+
setActiveIndex((i) => Math.max(i - 1, 0))
|
|
441
|
+
} else if (e.key === 'Enter') {
|
|
442
|
+
e.preventDefault()
|
|
443
|
+
const s = suggestions[activeIndex >= 0 ? activeIndex : 0]
|
|
444
|
+
if (s) {
|
|
445
|
+
setSuggestions([])
|
|
446
|
+
try { globeRef.current?.flyTo(s.lat, s.lng) } catch {}
|
|
447
|
+
}
|
|
448
|
+
} else if (e.key === 'Escape') {
|
|
449
|
+
setSuggestions([])
|
|
450
|
+
setQuery('')
|
|
451
|
+
}
|
|
452
|
+
}}
|
|
453
|
+
/>
|
|
454
|
+
{query && (
|
|
455
|
+
<button
|
|
456
|
+
type="button"
|
|
457
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
458
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
459
|
+
onClick={() => { setQuery(''); setSuggestions([]) }}
|
|
460
|
+
aria-label="Clear search"
|
|
461
|
+
>
|
|
462
|
+
<X className="size-4" />
|
|
463
|
+
</button>
|
|
464
|
+
)}
|
|
465
|
+
{(desktopFocused && (isSearching || suggestions.length > 0 || (query && query.trim().length === 1))) && (
|
|
466
|
+
<div className="absolute z-20 mt-2 w-[22rem] overflow-hidden rounded border border-white/12 bg-[#11131b] text-sm shadow-lg">
|
|
467
|
+
{query.trim().length === 1 ? (
|
|
468
|
+
<div className="px-3 py-2 text-muted-foreground">Type one more character…</div>
|
|
469
|
+
) : isSearching && suggestions.length === 0 ? (
|
|
470
|
+
<div className="flex items-center gap-2 px-3 py-2 text-muted-foreground"><Loader2 className="size-4 animate-spin" /> Searching…</div>
|
|
471
|
+
) : (
|
|
472
|
+
suggestions.map((s, i) => (
|
|
473
|
+
<button
|
|
474
|
+
key={`${s.name}-${i}`}
|
|
475
|
+
className={`w-full truncate px-3 py-2 text-left hover:bg-white/5 ${i === activeIndex ? 'bg-white/10' : ''}`}
|
|
476
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
477
|
+
onClick={() => {
|
|
478
|
+
setSuggestions([])
|
|
479
|
+
try { globeRef.current?.flyTo(s.lat, s.lng) } catch {}
|
|
480
|
+
}}
|
|
481
|
+
>
|
|
482
|
+
{s.name}
|
|
483
|
+
</button>
|
|
484
|
+
))
|
|
485
|
+
)}
|
|
486
|
+
</div>
|
|
487
|
+
)}
|
|
488
|
+
</div>
|
|
489
|
+
<Button
|
|
490
|
+
variant="ghost"
|
|
491
|
+
size="icon"
|
|
492
|
+
className="bg-[#11131b]/80 text-foreground border border-white/12 backdrop-blur-sm"
|
|
493
|
+
onClick={toggleCardsVisibility}
|
|
494
|
+
aria-pressed={areCardsVisible}
|
|
495
|
+
>
|
|
496
|
+
{areCardsVisible ? (
|
|
497
|
+
<Eye className="size-4" />
|
|
498
|
+
) : (
|
|
499
|
+
<EyeOff className="size-4" />
|
|
500
|
+
)}
|
|
501
|
+
<span className="sr-only">
|
|
502
|
+
{areCardsVisible
|
|
503
|
+
? "Hide analytics cards"
|
|
504
|
+
: "Show analytics cards"}
|
|
505
|
+
</span>
|
|
506
|
+
</Button>
|
|
507
|
+
<Button
|
|
508
|
+
variant="ghost"
|
|
509
|
+
size="icon"
|
|
510
|
+
className="bg-[#11131b]/80 text-foreground border border-white/12 backdrop-blur-sm"
|
|
511
|
+
onClick={toggleFullscreen}
|
|
512
|
+
>
|
|
513
|
+
{isFullscreen ? (
|
|
514
|
+
<Minimize2 className="size-4" />
|
|
515
|
+
) : (
|
|
516
|
+
<Maximize2 className="size-4" />
|
|
517
|
+
)}
|
|
518
|
+
<span className="sr-only">
|
|
519
|
+
{isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
|
520
|
+
</span>
|
|
521
|
+
</Button>
|
|
522
|
+
</div>
|
|
523
|
+
|
|
524
|
+
<aside
|
|
525
|
+
className="absolute z-30 flex w-[520px] flex-col"
|
|
526
|
+
style={{
|
|
527
|
+
top: DESKTOP_PADDING + DESKTOP_HEADER_SPACING,
|
|
528
|
+
left: DESKTOP_PADDING,
|
|
529
|
+
bottom: DESKTOP_BOTTOM_PADDING,
|
|
530
|
+
transform: `translateX(${cardsTranslateX}px)`,
|
|
531
|
+
transition: "transform 420ms cubic-bezier(0.22, 0.61, 0.36, 1)",
|
|
532
|
+
willChange: "transform",
|
|
533
|
+
pointerEvents: areCardsVisible ? "auto" : "none",
|
|
534
|
+
opacity: areCardsVisible ? 1 : 0,
|
|
535
|
+
}}
|
|
536
|
+
>
|
|
537
|
+
<div className="flex h-full flex-col gap-5 pr-5">
|
|
538
|
+
<div className="grid grid-cols-2 gap-3">
|
|
539
|
+
<MetricCard
|
|
540
|
+
title="Visitors right now"
|
|
541
|
+
value={stats.currentVisitors}
|
|
542
|
+
showChange={false}
|
|
543
|
+
/>
|
|
544
|
+
<MetricCard
|
|
545
|
+
title="Visitors today"
|
|
546
|
+
value={stats.todayVisitors.count}
|
|
547
|
+
change={stats.todayVisitors.change}
|
|
548
|
+
sparklineData={stats.todayVisitors.sparkline}
|
|
549
|
+
/>
|
|
550
|
+
<MetricCard
|
|
551
|
+
title="Sessions"
|
|
552
|
+
value={stats.todaySessions.count}
|
|
553
|
+
change={stats.todaySessions.change}
|
|
554
|
+
sparklineData={stats.todaySessions.sparkline}
|
|
555
|
+
/>
|
|
556
|
+
<MetricCard
|
|
557
|
+
title="Pageviews"
|
|
558
|
+
value={stats.todayPageviews.count}
|
|
559
|
+
change={stats.todayPageviews.change}
|
|
560
|
+
sparklineData={stats.todayPageviews.sparkline}
|
|
561
|
+
/>
|
|
562
|
+
</div>
|
|
563
|
+
|
|
564
|
+
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
|
|
565
|
+
<SessionsByLocation sessions={stats.sessionsByLocation} />
|
|
566
|
+
</div>
|
|
567
|
+
</div>
|
|
568
|
+
</aside>
|
|
569
|
+
|
|
570
|
+
<div
|
|
571
|
+
className="pointer-events-none absolute z-30 flex items-end gap-3"
|
|
572
|
+
style={{ bottom: DESKTOP_BOTTOM_PADDING, right: DESKTOP_PADDING }}
|
|
573
|
+
>
|
|
574
|
+
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/12 bg-[#11131b]/80 px-3 py-1.5 text-xs text-muted-foreground">
|
|
575
|
+
<div className="flex items-center gap-1.5">
|
|
576
|
+
<div className="size-2.5 rounded-full bg-blue-600 ring-1 ring-blue-400/60 shadow-[0_0_6px_rgba(37,99,235,0.6)]" />
|
|
577
|
+
<span className="text-foreground/80">Visitors right now</span>
|
|
578
|
+
</div>
|
|
579
|
+
</div>
|
|
580
|
+
<div className="pointer-events-auto flex flex-col items-center gap-1.5">
|
|
581
|
+
<Button
|
|
582
|
+
variant="ghost"
|
|
583
|
+
size="icon"
|
|
584
|
+
className="border border-white/12 bg-[#11131b]/80 text-foreground"
|
|
585
|
+
onClick={handleZoomIn}
|
|
586
|
+
disabled={zoomAtClosest}
|
|
587
|
+
>
|
|
588
|
+
<Plus className="size-4" />
|
|
589
|
+
<span className="sr-only">Zoom in</span>
|
|
590
|
+
</Button>
|
|
591
|
+
<Button
|
|
592
|
+
variant="ghost"
|
|
593
|
+
size="icon"
|
|
594
|
+
className="border border-white/12 bg-[#11131b]/80 text-foreground"
|
|
595
|
+
onClick={handleZoomOut}
|
|
596
|
+
disabled={zoomAtFarthest}
|
|
597
|
+
>
|
|
598
|
+
<Minus className="size-4" />
|
|
599
|
+
<span className="sr-only">Zoom out</span>
|
|
600
|
+
</Button>
|
|
601
|
+
</div>
|
|
602
|
+
</div>
|
|
603
|
+
</div>
|
|
604
|
+
</div>
|
|
605
|
+
</div>
|
|
606
|
+
</AnalyticsLayout>
|
|
607
|
+
);
|
|
608
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import type { AnalyticsQuery } from './types'
|
|
3
|
+
import { buildQueryParams } from './api'
|
|
4
|
+
|
|
5
|
+
export type QueryContextValue = {
|
|
6
|
+
query: AnalyticsQuery
|
|
7
|
+
updateQuery: (updater: (current: AnalyticsQuery) => AnalyticsQuery) => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const QueryContext = createContext<QueryContextValue | null>(null)
|
|
11
|
+
|
|
12
|
+
export function QueryProvider({
|
|
13
|
+
initialQuery,
|
|
14
|
+
children
|
|
15
|
+
}: {
|
|
16
|
+
initialQuery: AnalyticsQuery
|
|
17
|
+
children: ReactNode
|
|
18
|
+
}) {
|
|
19
|
+
const [query, setQuery] = useState<AnalyticsQuery>(initialQuery)
|
|
20
|
+
const isFirstRender = useRef(true)
|
|
21
|
+
|
|
22
|
+
const updateQuery = useCallback((updater: (current: AnalyticsQuery) => AnalyticsQuery) => {
|
|
23
|
+
setQuery((current) => updater(current))
|
|
24
|
+
}, [])
|
|
25
|
+
|
|
26
|
+
// Keep URL query string in sync with local query state (push new history entries, like Plausible)
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
// Skip initial mount: server already rendered with matching URL
|
|
29
|
+
if (isFirstRender.current) {
|
|
30
|
+
isFirstRender.current = false
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const qs = buildQueryParams(query)
|
|
35
|
+
const url = `${window.location.pathname}${qs ? `?${qs}` : ''}`
|
|
36
|
+
window.history.pushState({}, '', url)
|
|
37
|
+
} catch (e) {
|
|
38
|
+
// Non-fatal: log for debugging
|
|
39
|
+
// eslint-disable-next-line no-console
|
|
40
|
+
console.warn('Failed to update URL params for analytics query', e)
|
|
41
|
+
}
|
|
42
|
+
}, [query])
|
|
43
|
+
|
|
44
|
+
const value = useMemo<QueryContextValue>(
|
|
45
|
+
() => ({
|
|
46
|
+
query,
|
|
47
|
+
updateQuery
|
|
48
|
+
}),
|
|
49
|
+
[query, updateQuery]
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return <QueryContext.Provider value={value}>{children}</QueryContext.Provider>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useQueryContext() {
|
|
56
|
+
const context = useContext(QueryContext)
|
|
57
|
+
if (!context) {
|
|
58
|
+
throw new Error('useQueryContext must be used within a QueryProvider')
|
|
59
|
+
}
|
|
60
|
+
return context
|
|
61
|
+
}
|