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,474 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react'
2
+
3
+ import { fetchDevices } from '../api'
4
+ import { useQueryContext } from '../query-context'
5
+ import type { DevicesPayload, ListMetricKey } from '../types'
6
+ import { useSiteContext } from '../site-context'
7
+ import { MetricTable } from './list-table'
8
+ import { PanelTab, PanelTabs } from './panel-tabs'
9
+ import RemoteDetailsDialog from './remote-details-dialog'
10
+ import {
11
+ parseDialogFromPath,
12
+ buildDialogPath,
13
+ baseAnalyticsPath,
14
+ devicesSegmentForMode,
15
+ devicesModeForSegment
16
+ } from '../lib/dialog-path'
17
+ import { analyticsPath } from '../lib/base-path'
18
+ import DetailsButton from './details-button'
19
+
20
+ const DEVICE_TABS: Array<{ value: 'browser' | 'os' | 'size'; label: string }> = [
21
+ { value: 'browser', label: 'Browser' },
22
+ { value: 'os', label: 'OS' },
23
+ { value: 'size', label: 'Size' }
24
+ ]
25
+
26
+ const TAB_TO_MODE: Record<'browser' | 'os' | 'size', string> = {
27
+ browser: 'browsers',
28
+ os: 'operating-systems',
29
+ size: 'screen-sizes'
30
+ }
31
+
32
+ const MODE_TO_TAB: Record<string, 'browser' | 'os' | 'size'> = {
33
+ browsers: 'browser',
34
+ 'browser-versions': 'browser',
35
+ 'operating-systems': 'os',
36
+ 'operating-system-versions': 'os',
37
+ 'screen-sizes': 'size'
38
+ }
39
+
40
+ const STORAGE_PREFIX = 'admin.analytics.devices'
41
+
42
+ type DevicesPanelProps = {
43
+ initialData: DevicesPayload
44
+ }
45
+
46
+ export default function DevicesPanel({ initialData }: DevicesPanelProps) {
47
+ const { query, updateQuery } = useQueryContext()
48
+ const site = useSiteContext()
49
+
50
+ const [mode, setMode] = useState(() => {
51
+ if (typeof window === 'undefined') {
52
+ return 'browsers'
53
+ }
54
+ const stored = localStorage.getItem(`${STORAGE_PREFIX}.${site.domain}`)
55
+ if (stored && Object.keys(MODE_TO_TAB).includes(stored)) {
56
+ return stored
57
+ }
58
+ return 'browsers'
59
+ })
60
+ const [data, setData] = useState<DevicesPayload>(initialData)
61
+ const [loading, setLoading] = useState(false)
62
+ const [detailsOpen, setDetailsOpen] = useState(false)
63
+
64
+ useEffect(() => {
65
+ const controller = new AbortController()
66
+ setLoading(true)
67
+ fetchDevices(query, { mode }, controller.signal)
68
+ .then(setData)
69
+ .catch((error) => {
70
+ if (error.name !== 'AbortError') console.error(error)
71
+ })
72
+ .finally(() => setLoading(false))
73
+
74
+ return () => controller.abort()
75
+ }, [mode, query])
76
+
77
+ // Deep-link: open Devices dialog for /_/browsers, /_/operating-systems, /_/screen-sizes
78
+ useEffect(() => {
79
+ const parsed = parseDialogFromPath(window.location.pathname)
80
+ if (parsed.type === 'segment') {
81
+ const modeFromSeg = devicesModeForSegment(parsed.segment)
82
+ if (modeFromSeg) {
83
+ if (mode !== modeFromSeg) setMode(modeFromSeg)
84
+ setDetailsOpen(true)
85
+ }
86
+ }
87
+ }, [])
88
+
89
+ const highlightMetric = useMemo<ListMetricKey>(() => 'visitors', [])
90
+
91
+ const activeTitle = useMemo(() => {
92
+ switch (mode) {
93
+ case 'browser-versions':
94
+ return 'Browser Versions'
95
+ case 'operating-systems':
96
+ return 'Operating Systems'
97
+ case 'operating-system-versions':
98
+ return 'OS Versions'
99
+ case 'screen-sizes':
100
+ return 'Screen Sizes'
101
+ case 'browsers':
102
+ default:
103
+ return 'Browsers'
104
+ }
105
+ }, [mode])
106
+
107
+ const activeTab = useMemo(() => MODE_TO_TAB[mode] ?? 'browser', [mode])
108
+
109
+ const firstColumnLabel = useMemo(() => {
110
+ switch (activeTab) {
111
+ case 'browser':
112
+ return 'Browser'
113
+ case 'os':
114
+ return 'OS'
115
+ case 'size':
116
+ return 'Screen Size'
117
+ default:
118
+ return 'Item'
119
+ }
120
+ }, [activeTab])
121
+
122
+ const setAndStoreMode = useCallback(
123
+ (next: string) => {
124
+ setMode(next)
125
+ if (typeof window !== 'undefined') {
126
+ localStorage.setItem(`${STORAGE_PREFIX}.${site.domain}`, next)
127
+ }
128
+ },
129
+ [site.domain]
130
+ )
131
+
132
+ const handleSelect = useCallback(
133
+ (itemName: string) => {
134
+ updateQuery((current) => {
135
+ const filters = { ...current.filters }
136
+ if (mode === 'browser-versions') {
137
+ filters.browser_version = itemName
138
+ } else if (mode === 'operating-system-versions') {
139
+ filters.os_version = itemName
140
+ } else if (mode === 'operating-systems') {
141
+ filters.os = itemName
142
+ } else if (mode === 'screen-sizes') {
143
+ filters.size = itemName
144
+ } else {
145
+ filters.browser = itemName
146
+ }
147
+ return { ...current, filters }
148
+ })
149
+ },
150
+ [mode, updateQuery]
151
+ )
152
+
153
+ // Limit card view to top 9 by the first metric; Details keeps full list
154
+ const limitedData = useMemo((): DevicesPayload => {
155
+ const metricKey = 'visitors'
156
+ const sorted = [...data.results].sort((a, b) => {
157
+ const av = Number(a[metricKey] ?? 0)
158
+ const bv = Number(b[metricKey] ?? 0)
159
+ if (av === bv) return String(a.name).localeCompare(String(b.name))
160
+ return bv - av
161
+ })
162
+ const sliced = sorted.slice(0, 9)
163
+ const cardMetrics: ListMetricKey[] = (data.metrics.includes('percentage' as ListMetricKey)
164
+ ? (['visitors', 'percentage'] as ListMetricKey[])
165
+ : (['visitors'] as ListMetricKey[]))
166
+ return { ...data, metrics: cardMetrics, results: sliced, meta: { ...data.meta, hasMore: data.results.length > 9 } }
167
+ }, [data])
168
+
169
+ return (
170
+ <section className="flex flex-col gap-5 rounded-xl border border-border bg-card p-5 shadow-[0_12px_26px_rgba(7,9,16,0.32)]" data-testid="devices-panel">
171
+ <header className="flex flex-wrap items-center justify-between gap-3">
172
+ <h2 className="text-lg/6 font-semibold text-foreground/80">Devices</h2>
173
+ <PanelTabs>
174
+ {DEVICE_TABS.map((tab) => (
175
+ <PanelTab
176
+ key={tab.value}
177
+ active={activeTab === tab.value}
178
+ onClick={() => setAndStoreMode(TAB_TO_MODE[tab.value])}
179
+ >
180
+ {tab.label}
181
+ </PanelTab>
182
+ ))}
183
+ </PanelTabs>
184
+ </header>
185
+
186
+ {loading ? (
187
+ <div className="flex h-40 items-center justify-center text-sm text-muted-foreground">Loading…</div>
188
+ ) : data.results.length === 0 ? (
189
+ <div className="flex h-40 items-center justify-center text-sm text-muted-foreground">No data yet</div>
190
+ ) : (
191
+ <>
192
+ <MetricTable
193
+ data={limitedData}
194
+ highlightedMetric={highlightMetric ?? 'percentage'}
195
+ onRowClick={(item) => handleSelect(String(item.name))}
196
+ renderLeading={(item) => renderDeviceLeading(mode, item)}
197
+ displayBars={false}
198
+ firstColumnLabel={firstColumnLabel}
199
+ barColorTheme="cyan"
200
+ testId="devices"
201
+ />
202
+ <div className="mt-auto flex justify-center pt-3">
203
+ <DetailsButton data-testid="devices-details-btn" onClick={() => {
204
+ setDetailsOpen(true)
205
+ try {
206
+ const sp = new URLSearchParams(window.location.search)
207
+ sp.delete('dialog'); sp.delete('mode')
208
+ const seg = devicesSegmentForMode(mode as 'browsers' | 'operating-systems' | 'screen-sizes')
209
+ window.history.pushState({}, '', buildDialogPath(seg, sp.toString()))
210
+ } catch {}
211
+ }}>Details</DetailsButton>
212
+ </div>
213
+ </>
214
+ )}
215
+
216
+ <RemoteDetailsDialog
217
+ open={detailsOpen}
218
+ onOpenChange={(open) => {
219
+ setDetailsOpen(open)
220
+ try {
221
+ const sp = new URLSearchParams(window.location.search)
222
+ sp.delete('dialog'); sp.delete('mode')
223
+ const qs = sp.toString()
224
+ if (open) {
225
+ const seg = devicesSegmentForMode(mode as 'browsers' | 'operating-systems' | 'screen-sizes')
226
+ window.history.pushState({}, '', buildDialogPath(seg, qs))
227
+ } else {
228
+ window.history.pushState({}, '', baseAnalyticsPath(qs))
229
+ }
230
+ } catch {}
231
+ }}
232
+ title={`Top ${activeTitle}`}
233
+ endpoint={analyticsPath('devices')}
234
+ extras={{ mode }}
235
+ firstColumnLabel={firstColumnLabel}
236
+ defaultSortKey={'visitors'}
237
+ onRowClick={(item) => {
238
+ handleSelect(String(item.name))
239
+ setDetailsOpen(false)
240
+ }}
241
+ renderLeading={(item) => renderDeviceLeading(mode, item)}
242
+ />
243
+ </section>
244
+ )
245
+ }
246
+
247
+ // Icons copied from https://github.com/alrra/browser-logos (same as Plausible)
248
+ const BROWSER_ICONS: Record<string, string> = {
249
+ Chrome: 'chrome.svg',
250
+ Safari: 'safari.png',
251
+ Firefox: 'firefox.svg',
252
+ 'Microsoft Edge': 'edge.svg',
253
+ Edge: 'edge.svg',
254
+ Vivaldi: 'vivaldi.svg',
255
+ Opera: 'opera.svg',
256
+ 'Samsung Browser': 'samsung-internet.svg',
257
+ Chromium: 'chromium.svg',
258
+ 'UC Browser': 'uc.svg',
259
+ 'Yandex Browser': 'yandex.png',
260
+ 'DuckDuckGo Privacy Browser': 'duckduckgo.svg',
261
+ Brave: 'brave.svg'
262
+ }
263
+
264
+ function getBrowserIcon(name: string): string {
265
+ // Extract base browser name (e.g., "Chrome 120" -> "Chrome")
266
+ const baseName = name.split(/\s+\d/)[0].trim()
267
+
268
+ // Check exact match first
269
+ if (BROWSER_ICONS[baseName]) {
270
+ return BROWSER_ICONS[baseName]
271
+ }
272
+
273
+ // Check if name contains browser keyword
274
+ for (const [browserName, filename] of Object.entries(BROWSER_ICONS)) {
275
+ if (name.toLowerCase().includes(browserName.toLowerCase())) {
276
+ return filename
277
+ }
278
+ }
279
+
280
+ return 'fallback.svg'
281
+ }
282
+
283
+ // Icons copied from https://github.com/ngeenx/operating-system-logos (same as Plausible)
284
+ const OS_ICONS: Record<string, string> = {
285
+ iOS: 'ios.png',
286
+ Mac: 'mac.png',
287
+ macOS: 'mac.png',
288
+ Windows: 'windows.png',
289
+ 'Windows Phone': 'windows.png',
290
+ Android: 'android.png',
291
+ 'GNU/Linux': 'gnu_linux.png',
292
+ Linux: 'gnu_linux.png',
293
+ Ubuntu: 'ubuntu.png',
294
+ 'Chrome OS': 'chrome_os.png',
295
+ iPadOS: 'ipad_os.png',
296
+ Fedora: 'fedora.png',
297
+ FreeBSD: 'freebsd.png'
298
+ }
299
+
300
+ function getOSIcon(name: string): string {
301
+ // Extract base OS name (e.g., "macOS 15" -> "macOS")
302
+ const baseName = name.split(/\s+\d/)[0].trim()
303
+
304
+ // Check exact match first
305
+ if (OS_ICONS[baseName]) {
306
+ return OS_ICONS[baseName]
307
+ }
308
+
309
+ // Check if name contains OS keyword
310
+ for (const [osName, filename] of Object.entries(OS_ICONS)) {
311
+ if (name.toLowerCase().includes(osName.toLowerCase())) {
312
+ return filename
313
+ }
314
+ }
315
+
316
+ return 'fallback.svg'
317
+ }
318
+
319
+ function renderDeviceLeading(mode: string, item: Record<string, unknown>) {
320
+ const tab = MODE_TO_TAB[mode] ?? 'browser'
321
+
322
+ if (tab === 'browser') {
323
+ const name = String((item as Record<string, unknown>).browser ?? item.name ?? '')
324
+ return <BrowserIcon name={name} />
325
+ }
326
+
327
+ if (tab === 'os') {
328
+ const name = String((item as Record<string, unknown>).os ?? item.name ?? '')
329
+ return <OSIcon name={name} />
330
+ }
331
+
332
+ // Screen sizes - use SVG icons like Plausible
333
+ const name = String(item.name ?? '')
334
+ return <ScreenSizeIcon screenSize={name} />
335
+ }
336
+
337
+ // Match Plausible's exact icon rendering approach
338
+ function BrowserIcon({ name }: { name: string }) {
339
+ const filename = getBrowserIcon(name)
340
+ return (
341
+ <img
342
+ alt=""
343
+ src={analyticsPath(`images/icon/browser/${filename}`)}
344
+ className="h-5 w-5 mr-2 shrink-0 object-contain"
345
+ />
346
+ )
347
+ }
348
+
349
+ function OSIcon({ name }: { name: string }) {
350
+ const filename = getOSIcon(name)
351
+ return (
352
+ <img
353
+ alt=""
354
+ src={analyticsPath(`images/icon/os/${filename}`)}
355
+ className="h-5 w-5 mr-2 shrink-0 object-contain"
356
+ />
357
+ )
358
+ }
359
+
360
+ // Screen size icons - SVG from Feather Icons (same as Plausible)
361
+ function ScreenSizeIcon({ screenSize }: { screenSize: string }) {
362
+ // Categorize by screen size dimensions
363
+ const category = categorizeScreenSize(screenSize)
364
+
365
+ if (category === 'Mobile') {
366
+ return (
367
+ <span className="mr-2">
368
+ <svg
369
+ xmlns="http://www.w3.org/2000/svg"
370
+ width="24"
371
+ height="24"
372
+ viewBox="0 0 24 24"
373
+ fill="none"
374
+ stroke="currentColor"
375
+ strokeWidth="2"
376
+ strokeLinecap="round"
377
+ strokeLinejoin="round"
378
+ className="size-4"
379
+ >
380
+ <rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
381
+ <line x1="12" y1="18" x2="12" y2="18" />
382
+ </svg>
383
+ </span>
384
+ )
385
+ }
386
+
387
+ if (category === 'Tablet') {
388
+ return (
389
+ <span className="mr-2">
390
+ <svg
391
+ xmlns="http://www.w3.org/2000/svg"
392
+ width="24"
393
+ height="24"
394
+ viewBox="0 0 24 24"
395
+ fill="none"
396
+ stroke="currentColor"
397
+ strokeWidth="2"
398
+ strokeLinecap="round"
399
+ strokeLinejoin="round"
400
+ className="size-4"
401
+ >
402
+ <rect x="4" y="2" width="16" height="20" rx="2" ry="2" transform="rotate(180 12 12)" />
403
+ <line x1="12" y1="18" x2="12" y2="18" />
404
+ </svg>
405
+ </span>
406
+ )
407
+ }
408
+
409
+ if (category === 'Laptop') {
410
+ return (
411
+ <span className="mr-2">
412
+ <svg
413
+ xmlns="http://www.w3.org/2000/svg"
414
+ width="24"
415
+ height="24"
416
+ viewBox="0 0 24 24"
417
+ fill="none"
418
+ stroke="currentColor"
419
+ strokeWidth="2"
420
+ strokeLinecap="round"
421
+ strokeLinejoin="round"
422
+ className="size-4"
423
+ >
424
+ <rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
425
+ <line x1="2" y1="20" x2="22" y2="20" />
426
+ </svg>
427
+ </span>
428
+ )
429
+ }
430
+
431
+ if (category === 'Desktop') {
432
+ return (
433
+ <span className="mr-2">
434
+ <svg
435
+ xmlns="http://www.w3.org/2000/svg"
436
+ width="24"
437
+ height="24"
438
+ viewBox="0 0 24 24"
439
+ fill="none"
440
+ stroke="currentColor"
441
+ strokeWidth="2"
442
+ strokeLinecap="round"
443
+ strokeLinejoin="round"
444
+ className="size-4"
445
+ >
446
+ <rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
447
+ <line x1="8" y1="21" x2="16" y2="21" />
448
+ <line x1="12" y1="17" x2="12" y2="21" />
449
+ </svg>
450
+ </span>
451
+ )
452
+ }
453
+
454
+ return null
455
+ }
456
+
457
+ function categorizeScreenSize(screenSize: string): string {
458
+ // Handle already categorized sizes (from Plausible data)
459
+ if (['Mobile', 'Tablet', 'Laptop', 'Desktop'].includes(screenSize)) {
460
+ return screenSize
461
+ }
462
+
463
+ // Parse dimensions (e.g., "1920x1080")
464
+ const match = screenSize.match(/(\d+)x(\d+)/)
465
+ if (!match) return 'Desktop'
466
+
467
+ const width = parseInt(match[1], 10)
468
+
469
+ // Categorization based on width (following common breakpoints)
470
+ if (width <= 768) return 'Mobile'
471
+ if (width <= 1024) return 'Tablet'
472
+ if (width <= 1440) return 'Laptop'
473
+ return 'Desktop'
474
+ }