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,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
+ }