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,346 @@
1
+ import type { ReactNode } from 'react'
2
+ import { ExternalLink } from 'lucide-react'
3
+
4
+ import type { ListItem, ListMetricKey, ListPayload } from '../types'
5
+ import { numberShortFormatter, percentageFormatter, durationFormatter, nullable } from '../lib/number-formatter'
6
+
7
+ export const METRIC_LABELS: Record<ListMetricKey, string> = {
8
+ visitors: 'Visitors',
9
+ visits: 'Visits',
10
+ percentage: '%',
11
+ uniques: 'Uniques',
12
+ total: 'Total',
13
+ conversionRate: 'CR',
14
+ exitRate: 'Exit Rate',
15
+ bounceRate: 'Bounce Rate',
16
+ visitDuration: 'Visit duration',
17
+ scrollDepth: 'Scroll Depth',
18
+ timeOnPage: 'Time on Page',
19
+ pageviews: 'Pageviews',
20
+ impressions: 'Impressions',
21
+ ctr: 'CTR',
22
+ position: 'Position'
23
+ }
24
+
25
+ export const FORMATTERS: Partial<Record<ListMetricKey, (value: number | null | undefined) => string>> = {
26
+ visitors: (value) => numberShortFormatter(value ?? 0),
27
+ uniques: (value) => numberShortFormatter(value ?? 0),
28
+ total: (value) => numberShortFormatter(value ?? 0),
29
+ percentage: (value) => percentageFormatter(value ?? null),
30
+ conversionRate: (value) => percentageFormatter(value ?? null),
31
+ exitRate: (value) => percentageFormatter(value ?? null),
32
+ bounceRate: (value) => percentageFormatter(value ?? null),
33
+ visitDuration: nullable(durationFormatter),
34
+ scrollDepth: (value) => percentageFormatter(value ?? null),
35
+ timeOnPage: nullable(durationFormatter),
36
+ pageviews: (value) => numberShortFormatter(value ?? 0),
37
+ impressions: (value) => numberShortFormatter(value ?? 0),
38
+ ctr: (value) => percentageFormatter(value ?? null),
39
+ position: (value) => {
40
+ if (value == null || Number.isNaN(value as number)) return '-'
41
+ return (Math.round((value as number) * 10) / 10).toFixed(1)
42
+ }
43
+ }
44
+
45
+ type MetricTableProps = {
46
+ data: ListPayload
47
+ highlightedMetric?: ListMetricKey
48
+ onRowClick?: (item: ListItem) => void
49
+ renderLeading?: (item: ListItem) => ReactNode
50
+ rowBarClassName?: string
51
+ displayBars?: boolean
52
+ firstColumnLabel?: string
53
+ barColorTheme?: 'indigo' | 'emerald' | 'amber' | 'violet' | 'cyan'
54
+ metricLabels?: Partial<Record<ListMetricKey, string>>
55
+ // Optional test id root for system tests
56
+ testId?: string
57
+ }
58
+
59
+ export function MetricTable({
60
+ data,
61
+ onRowClick,
62
+ renderLeading,
63
+ rowBarClassName,
64
+ displayBars = true,
65
+ firstColumnLabel,
66
+ barColorTheme = 'emerald',
67
+ metricLabels,
68
+ testId
69
+ }: MetricTableProps) {
70
+ const metrics = data.metrics
71
+ // Base width used when labels are short
72
+ const BASE_NUM_COL_MIN_PX = 72
73
+
74
+ // Determine which metric to use for bar width to match Plausible:
75
+ // Prefer 'visitors' when available; otherwise fall back to the first metric provided.
76
+ const barMetric = metrics.includes('visitors') ? 'visitors' : metrics[0]
77
+
78
+ // Calculate max value for proportional bars
79
+ const maxValue = Math.max(
80
+ ...data.results.map((item) => Number(item[barMetric] ?? 0)),
81
+ 1
82
+ )
83
+
84
+ const itemLabel = firstColumnLabel ?? (data.meta.skipImportedReason ? 'Item*' : 'Item')
85
+
86
+ // Color mapping based on theme
87
+ const colorMap = {
88
+ indigo: ['bg-indigo-500/15', 'bg-indigo-500/12', 'bg-indigo-500/8'],
89
+ emerald: ['bg-emerald-500/15', 'bg-emerald-500/10', 'bg-emerald-500/5'],
90
+ amber: ['bg-amber-500/15', 'bg-amber-500/12', 'bg-amber-500/10'],
91
+ violet: ['bg-violet-500/12', 'bg-violet-500/8', 'bg-violet-500/6'],
92
+ cyan: ['bg-cyan-400/20', 'bg-cyan-400/12', 'bg-cyan-400/8']
93
+ }
94
+
95
+ // Use new DevicesPanel styling when displayBars is false
96
+ if (!displayBars) {
97
+ return (
98
+ <div className="overflow-hidden" data-testid={testId ? `${testId}-wrap` : undefined}>
99
+ <table className="min-w-full text-sm" data-testid={testId ? `${testId}-table` : undefined}>
100
+ <thead>
101
+ <tr className="border-b border-border">
102
+ <th scope="col" className="pb-2 pr-3 text-left text-xs font-semibold uppercase tracking-wide text-muted-foreground">
103
+ {itemLabel}
104
+ </th>
105
+ <th scope="col" className="pb-2 text-right">
106
+ <div className="flex items-center justify-end gap-8">
107
+ {metrics.map((metric) => {
108
+ const title = (metricLabels && metricLabels[metric]) ?? METRIC_LABELS[metric] ?? metric
109
+ const len = String(title).length
110
+ const w = len >= 16 ? 144 : len >= 12 ? 120 : BASE_NUM_COL_MIN_PX
111
+ return (
112
+ <span
113
+ key={metric}
114
+ className="text-right text-xs font-semibold uppercase tracking-wide text-muted-foreground whitespace-nowrap"
115
+ style={{ minWidth: w, width: w }}
116
+ >
117
+ {title}
118
+ </span>
119
+ )
120
+ })}
121
+ </div>
122
+ </th>
123
+ </tr>
124
+ </thead>
125
+ <tbody className="divide-y divide-border">
126
+ {data.results.map((item, index) => {
127
+ // Calculate bar width based on bar metric value
128
+ const value = Number(item[barMetric] ?? 0)
129
+ const barWidth = Math.max((value / maxValue) * 100, 0)
130
+
131
+ // Determine bar color based on theme
132
+ const colors = colorMap[barColorTheme]
133
+ const barColor = index === 0
134
+ ? colors[0]
135
+ : index === 1
136
+ ? colors[1]
137
+ : colors[2]
138
+
139
+ // Determine if this row has a leading icon/flag
140
+ const leadingEl = renderLeading ? renderLeading(item) : renderFlag(item)
141
+ const hasLeading = Boolean(leadingEl)
142
+
143
+ return (
144
+ <tr
145
+ key={item.name}
146
+ className={`group relative h-9 transition ${onRowClick ? 'cursor-pointer hover:bg-muted/20' : ''}`}
147
+ onClick={() => onRowClick?.(item)}
148
+ data-testid={testId ? `${testId}-row` : undefined}
149
+ data-name={String(item.name)}
150
+ >
151
+ <td className="" colSpan={2}>
152
+ {/* Two-layer layout so the bar respects the left content width and doesn't sit under the numbers */}
153
+ <div className="relative flex items-center justify-between">
154
+ {/* Left content with its own relative box for the bar; reserve icon column only when present */}
155
+ <div className={`relative flex min-w-0 flex-1 items-center gap-3 pr-3 ${hasLeading ? 'pl-8' : 'pl-2'}`}>
156
+ {/* Background bar sized to left content width */}
157
+ <div
158
+ className={`absolute inset-y-[1px] left-0 rounded-xs ${barColor}`}
159
+ style={{ width: `${barWidth}%` }}
160
+ aria-hidden="true"
161
+ />
162
+ {/* Fixed icon column so bars never overlap icons (only when present) */}
163
+ {hasLeading ? (
164
+ <span className="absolute left-1 z-10 inline-flex h-6 w-6 items-center justify-center">
165
+ {leadingEl}
166
+ </span>
167
+ ) : null}
168
+ <span className="relative z-10 break-all whitespace-normal font-medium text-foreground">
169
+ <span className="inline-flex items-center gap-1">
170
+ <span>{item.name}</span>
171
+ {isPathLike(item.name) ? (
172
+ <a
173
+ href={String(item.name)}
174
+ target="_blank"
175
+ rel="noopener noreferrer"
176
+ className="opacity-0 group-hover:opacity-100 transition text-muted-foreground hover:text-foreground"
177
+ onClick={(e) => e.stopPropagation()}
178
+ aria-label="Open page in new tab"
179
+ title="Open page"
180
+ >
181
+ <ExternalLink className="h-3.5 w-3.5" />
182
+ </a>
183
+ ) : null}
184
+ </span>
185
+ </span>
186
+ </div>
187
+ {/* Right metrics untouched by the bar */}
188
+ <div className="flex shrink-0 items-center gap-8">
189
+ {metrics.map((metric) => {
190
+ const title = (metricLabels && metricLabels[metric]) ?? METRIC_LABELS[metric] ?? metric
191
+ const len = String(title).length
192
+ const w = len >= 16 ? 144 : len >= 12 ? 120 : BASE_NUM_COL_MIN_PX
193
+ return (
194
+ <span
195
+ key={metric}
196
+ className="text-right font-semibold tabular-nums text-foreground whitespace-nowrap"
197
+ style={{ minWidth: w, width: w }}
198
+ >
199
+ {formatMetric(metric, item[metric])}
200
+ </span>
201
+ )
202
+ })}
203
+ </div>
204
+ </div>
205
+ </td>
206
+ </tr>
207
+ )
208
+ })}
209
+ </tbody>
210
+ </table>
211
+ {data.meta.skipImportedReason && (
212
+ <p className="px-4 py-2 text-xs text-muted-foreground">* Imported data omitted: {data.meta.skipImportedReason}</p>
213
+ )}
214
+ </div>
215
+ )
216
+ }
217
+
218
+ // Original table with bars for other panels
219
+ return (
220
+ <div className="overflow-hidden rounded-xs border">
221
+ <table className="min-w-full divide-y divide-border text-sm" data-testid={testId ? `${testId}-table` : undefined}>
222
+ <thead className="bg-muted/40">
223
+ <tr>
224
+ <th scope="col" className="px-4 py-1.5 text-left font-semibold text-muted-foreground">
225
+ {itemLabel}
226
+ </th>
227
+ {metrics.map((metric) => (
228
+ <th key={metric} scope="col" className="px-4 py-1.5 text-right font-semibold text-muted-foreground">
229
+ {METRIC_LABELS[metric] ?? metric}
230
+ </th>
231
+ ))}
232
+ </tr>
233
+ </thead>
234
+ <tbody className="divide-y divide-border bg-background">
235
+ {data.results.map((item) => (
236
+ <tr
237
+ key={item.name}
238
+ className={`group transition hover:bg-muted/40 ${onRowClick ? 'cursor-pointer' : ''}`}
239
+ onClick={() => onRowClick?.(item)}
240
+ data-testid={testId ? `${testId}-row` : undefined}
241
+ data-name={String(item.name)}
242
+ >
243
+ <td className="px-4 py-1.5">
244
+ <div className="relative flex items-center gap-2">
245
+ {rowBarClassName ? (
246
+ <span
247
+ aria-hidden
248
+ className={`pointer-events-none absolute inset-y-1 left-0 block rounded-xs ${rowBarClassName}`}
249
+ style={{
250
+ width: `${Math.max((Number(item[metrics[0]] ?? 0) / maxValue) * 100, 6)}%`
251
+ }}
252
+ />
253
+ ) : null}
254
+ <span className="relative z-10 flex items-center gap-2">
255
+ {renderLeading ? renderLeading(item) : renderFlag(item)}
256
+ <span className="font-medium text-foreground break-all whitespace-normal">
257
+ <span className="inline-flex items-center gap-1">
258
+ <span>{item.name}</span>
259
+ {isPathLike(item.name) ? (
260
+ <a
261
+ href={String(item.name)}
262
+ target="_blank"
263
+ rel="noopener noreferrer"
264
+ className="opacity-0 group-hover:opacity-100 transition text-muted-foreground hover:text-foreground"
265
+ onClick={(e) => e.stopPropagation()}
266
+ aria-label="Open page in new tab"
267
+ title="Open page"
268
+ >
269
+ <ExternalLink className="h-3.5 w-3.5" />
270
+ </a>
271
+ ) : null}
272
+ </span>
273
+ </span>
274
+ </span>
275
+ </div>
276
+ </td>
277
+ {metrics.map((metric, idx) => (
278
+ <td key={metric} className="px-4 py-1.5 text-right">
279
+ <div className="flex items-center justify-end gap-2.5">
280
+ {idx === 0 ? (
281
+ <span
282
+ aria-hidden
283
+ className="flex-1 rounded-full bg-primary/10"
284
+ style={{
285
+ maxWidth: 120,
286
+ height: 5,
287
+ position: 'relative'
288
+ }}
289
+ >
290
+ <span
291
+ className="absolute inset-y-0 left-0 rounded-full bg-primary"
292
+ style={{ width: `${(Number(item[metric] ?? 0) / maxValue) * 100}%` }}
293
+ />
294
+ </span>
295
+ ) : null}
296
+ <span className="tabular-nums text-foreground">
297
+ {formatMetric(metric, item[metric])}
298
+ </span>
299
+ </div>
300
+ </td>
301
+ ))}
302
+ </tr>
303
+ ))}
304
+ </tbody>
305
+ </table>
306
+ {data.meta.skipImportedReason && (
307
+ <p className="px-4 py-2 text-xs text-muted-foreground">* Imported data omitted: {data.meta.skipImportedReason}</p>
308
+ )}
309
+ </div>
310
+ )
311
+ }
312
+
313
+ export function renderFlag(item: ListItem) {
314
+ // Prefer explicit flags when present
315
+ if ('flag' in item && typeof item.flag === 'string') {
316
+ return <span aria-hidden>{item.flag}</span>
317
+ }
318
+ if ('countryFlag' in item && typeof (item as Record<string, unknown>).countryFlag === 'string') {
319
+ return <span aria-hidden>{(item as Record<string, string>).countryFlag}</span>
320
+ }
321
+ // Derive from country code if available (alpha2 preferred)
322
+ const code = (item.code || (item.alpha2 as any) || (item.alpha3 as any)) as string | undefined
323
+ const flag = flagFromIso2(code)
324
+ return flag ? <span aria-hidden>{flag}</span> : null
325
+ }
326
+
327
+ function flagFromIso2(code?: string) {
328
+ if (!code) return ''
329
+ const m = String(code).toUpperCase().match(/^[A-Z]{2}$/)
330
+ if (!m) return ''
331
+ const A = 0x1f1e6
332
+ return Array.from(m[0]).map((c) => String.fromCodePoint(A + (c.charCodeAt(0) - 65))).join('')
333
+ }
334
+
335
+ export function formatMetric(metric: ListMetricKey, value: ListItem[keyof ListItem]) {
336
+ const formatter = FORMATTERS[metric]
337
+ if (formatter) {
338
+ return formatter(typeof value === 'number' ? value : Number(value))
339
+ }
340
+ return value == null ? '—' : String(value)
341
+ }
342
+
343
+ function isPathLike(name: unknown): boolean {
344
+ const s = String(name || '')
345
+ return s.startsWith('/') && !s.startsWith('//')
346
+ }