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