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,891 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CategoryScale,
|
|
3
|
+
Chart as ChartJS,
|
|
4
|
+
Filler,
|
|
5
|
+
Legend,
|
|
6
|
+
LinearScale,
|
|
7
|
+
LineElement,
|
|
8
|
+
PointElement,
|
|
9
|
+
Title,
|
|
10
|
+
Tooltip as ChartTooltip,
|
|
11
|
+
type ChartDataset
|
|
12
|
+
} from 'chart.js'
|
|
13
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
14
|
+
import { Line } from 'react-chartjs-2'
|
|
15
|
+
import { ChevronDown } from 'lucide-react'
|
|
16
|
+
import dayjs from 'dayjs'
|
|
17
|
+
import utc from 'dayjs/plugin/utc'
|
|
18
|
+
import timezone from 'dayjs/plugin/timezone'
|
|
19
|
+
|
|
20
|
+
import { Button } from '@/components/ui/button'
|
|
21
|
+
import {
|
|
22
|
+
DropdownMenu,
|
|
23
|
+
DropdownMenuContent,
|
|
24
|
+
DropdownMenuItem,
|
|
25
|
+
DropdownMenuTrigger
|
|
26
|
+
} from '@/components/ui/dropdown-menu'
|
|
27
|
+
import { Skeleton } from '@/components/ui/skeleton'
|
|
28
|
+
// Tooltip imports removed (sampling tooltip currently commented out)
|
|
29
|
+
|
|
30
|
+
import { fetchMainGraph, fetchTopStats } from '../api'
|
|
31
|
+
import { useLastLoadContext } from '../last-load-context'
|
|
32
|
+
import { useQueryContext } from '../query-context'
|
|
33
|
+
import { useSiteContext } from '../site-context'
|
|
34
|
+
import { useTopStatsContext } from '../top-stats-context'
|
|
35
|
+
import type { MainGraphPayload, TopStat } from '../types'
|
|
36
|
+
|
|
37
|
+
dayjs.extend(utc)
|
|
38
|
+
dayjs.extend(timezone)
|
|
39
|
+
|
|
40
|
+
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, ChartTooltip, Legend, Filler)
|
|
41
|
+
|
|
42
|
+
const INTERVAL_LABELS: Record<string, string> = {
|
|
43
|
+
minute: 'Minutes',
|
|
44
|
+
hour: 'Hours',
|
|
45
|
+
day: 'Days',
|
|
46
|
+
week: 'Weeks',
|
|
47
|
+
month: 'Months'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const STORAGE_PREFIX = 'admin.analytics'
|
|
51
|
+
|
|
52
|
+
// Detect if user prefers 12-hour clock
|
|
53
|
+
function is12HourClock(): boolean {
|
|
54
|
+
const browserFormat = new Intl.DateTimeFormat(navigator.language, { hour: 'numeric' })
|
|
55
|
+
return browserFormat.resolvedOptions().hour12 ?? false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Date formatting utilities matching Plausible's exact logic
|
|
59
|
+
function formatHour(isoDate: string, tz: string): string {
|
|
60
|
+
const date = dayjs.utc(isoDate).tz(tz)
|
|
61
|
+
if (is12HourClock()) {
|
|
62
|
+
return date.format('ha') // "3pm", "12am"
|
|
63
|
+
} else {
|
|
64
|
+
return date.format('HH:mm') // "15:00", "00:00"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function formatDay(isoDate: string, includeYear: boolean, tz: string): string {
|
|
69
|
+
const date = dayjs.utc(isoDate).tz(tz)
|
|
70
|
+
if (includeYear) {
|
|
71
|
+
return date.format('D MMM YY') // "5 Oct 25"
|
|
72
|
+
} else {
|
|
73
|
+
return date.format('D MMM') // "5 Oct"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatMonth(isoDate: string, tz: string): string {
|
|
78
|
+
const date = dayjs.utc(isoDate).tz(tz)
|
|
79
|
+
return date.format('MMMM YYYY') // "October 2025"
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hasMultipleYears(labels: string[]): boolean {
|
|
83
|
+
const years = labels
|
|
84
|
+
.filter((label) => typeof label === 'string')
|
|
85
|
+
.map((label) => label.split('-')[0])
|
|
86
|
+
return new Set(years).size > 1
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type VisitorGraphProps = {
|
|
90
|
+
initialGraph: MainGraphPayload
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export default function VisitorGraph({ initialGraph }: VisitorGraphProps) {
|
|
94
|
+
const { query, updateQuery } = useQueryContext()
|
|
95
|
+
const { payload, update } = useTopStatsContext()
|
|
96
|
+
const { touch } = useLastLoadContext()
|
|
97
|
+
const site = useSiteContext()
|
|
98
|
+
|
|
99
|
+
const [graph, setGraph] = useState<MainGraphPayload>(initialGraph)
|
|
100
|
+
const [loading, setLoading] = useState(false)
|
|
101
|
+
const [metric, setMetric] = useState(() => initialGraph.metric)
|
|
102
|
+
const [interval, setInterval] = useState(() => initialGraph.interval)
|
|
103
|
+
const abortRef = useRef<AbortController | null>(null)
|
|
104
|
+
const mouseYRef = useRef<number | null>(null)
|
|
105
|
+
|
|
106
|
+
const graphableMetrics = payload.graphableMetrics
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
const stored = localStorage.getItem(`${STORAGE_PREFIX}.${site.domain}.metric`)
|
|
110
|
+
if (stored && graphableMetrics.includes(stored)) {
|
|
111
|
+
setMetric(stored)
|
|
112
|
+
}
|
|
113
|
+
}, [graphableMetrics, site.domain])
|
|
114
|
+
|
|
115
|
+
const fetchGraph = useCallback(
|
|
116
|
+
async (
|
|
117
|
+
nextMetric: string,
|
|
118
|
+
nextInterval: string,
|
|
119
|
+
controller: AbortController
|
|
120
|
+
) => {
|
|
121
|
+
const data = await fetchMainGraph(query, { metric: nextMetric, interval: nextInterval }, controller.signal)
|
|
122
|
+
setGraph(data)
|
|
123
|
+
},
|
|
124
|
+
[query]
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
const controller = new AbortController()
|
|
129
|
+
abortRef.current?.abort()
|
|
130
|
+
abortRef.current = controller
|
|
131
|
+
|
|
132
|
+
setLoading(true)
|
|
133
|
+
|
|
134
|
+
fetchTopStats(query, controller.signal)
|
|
135
|
+
.then((data) => {
|
|
136
|
+
update(data)
|
|
137
|
+
touch()
|
|
138
|
+
const preferredMetric = (() => {
|
|
139
|
+
const stored = localStorage.getItem(`${STORAGE_PREFIX}.${site.domain}.metric`)
|
|
140
|
+
if (stored && data.graphableMetrics.includes(stored)) {
|
|
141
|
+
return stored
|
|
142
|
+
}
|
|
143
|
+
return data.graphableMetrics[0] ?? 'visitors'
|
|
144
|
+
})()
|
|
145
|
+
setMetric(preferredMetric)
|
|
146
|
+
const preferredInterval = data.interval || interval
|
|
147
|
+
setInterval(preferredInterval)
|
|
148
|
+
return fetchGraph(preferredMetric, preferredInterval, controller)
|
|
149
|
+
})
|
|
150
|
+
.catch((error) => {
|
|
151
|
+
if (error.name !== 'AbortError') {
|
|
152
|
+
console.error(error)
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
.finally(() => {
|
|
156
|
+
setLoading(false)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
return () => controller.abort()
|
|
160
|
+
}, [fetchGraph, query, site.domain, update])
|
|
161
|
+
|
|
162
|
+
const changeMetric = useCallback(
|
|
163
|
+
(next: string) => {
|
|
164
|
+
setMetric(next)
|
|
165
|
+
localStorage.setItem(`${STORAGE_PREFIX}.${site.domain}.metric`, next)
|
|
166
|
+
const controller = new AbortController()
|
|
167
|
+
abortRef.current?.abort()
|
|
168
|
+
abortRef.current = controller
|
|
169
|
+
setLoading(true)
|
|
170
|
+
fetchGraph(next, interval, controller)
|
|
171
|
+
.catch((error) => {
|
|
172
|
+
if (error.name !== 'AbortError') console.error(error)
|
|
173
|
+
})
|
|
174
|
+
.finally(() => setLoading(false))
|
|
175
|
+
},
|
|
176
|
+
[fetchGraph, interval, site.domain]
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
const changeInterval = useCallback(
|
|
180
|
+
(nextInterval: string) => {
|
|
181
|
+
setInterval(nextInterval)
|
|
182
|
+
const controller = new AbortController()
|
|
183
|
+
abortRef.current?.abort()
|
|
184
|
+
abortRef.current = controller
|
|
185
|
+
setLoading(true)
|
|
186
|
+
fetchGraph(metric, nextInterval, controller)
|
|
187
|
+
.catch((error) => {
|
|
188
|
+
if (error.name !== 'AbortError') console.error(error)
|
|
189
|
+
})
|
|
190
|
+
.finally(() => setLoading(false))
|
|
191
|
+
},
|
|
192
|
+
[fetchGraph, metric]
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
const chartData = useMemo(() => createChartData(graph), [graph])
|
|
196
|
+
const chartOptions = useMemo(
|
|
197
|
+
() => createChartOptions({ ...graph, metric }, query.period, site.timezone, mouseYRef),
|
|
198
|
+
[graph, metric, query.period, site.timezone]
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<section className="rounded-xl border border-border bg-card shadow-[0_12px_26px_rgba(7,9,16,0.32)]">
|
|
203
|
+
<div className="space-y-4 p-4 sm:p-6">
|
|
204
|
+
<TopStatsGrid
|
|
205
|
+
stats={payload.topStats}
|
|
206
|
+
graphableMetrics={graphableMetrics}
|
|
207
|
+
selectedMetric={metric}
|
|
208
|
+
onSelectMetric={changeMetric}
|
|
209
|
+
comparingFrom={payload.comparingFrom}
|
|
210
|
+
comparingTo={payload.comparingTo}
|
|
211
|
+
period={query.period}
|
|
212
|
+
timezone={site.timezone}
|
|
213
|
+
showComparison={Boolean(query.comparison && payload.comparingFrom)}
|
|
214
|
+
primaryFrom={payload.from}
|
|
215
|
+
primaryTo={payload.to}
|
|
216
|
+
/>
|
|
217
|
+
|
|
218
|
+
<div className="relative mt-4">
|
|
219
|
+
{loading && (
|
|
220
|
+
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-xs bg-card/75 backdrop-blur-sm">
|
|
221
|
+
<Spinner />
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
224
|
+
<div className="flex justify-end gap-2 pb-2">
|
|
225
|
+
{/* <TooltipProvider>
|
|
226
|
+
<Tooltip>
|
|
227
|
+
<TooltipTrigger asChild>
|
|
228
|
+
<Button variant="ghost" size="icon" aria-label="Sampling notice" disabled>
|
|
229
|
+
<Info className="size-4" />
|
|
230
|
+
</Button>
|
|
231
|
+
</TooltipTrigger>
|
|
232
|
+
<TooltipContent>Sampling disabled in demo mode</TooltipContent>
|
|
233
|
+
</Tooltip>
|
|
234
|
+
</TooltipProvider> */}
|
|
235
|
+
{payload.withImportedSwitch.visible && (
|
|
236
|
+
<Button
|
|
237
|
+
variant={query.withImported ? 'default' : 'outline'}
|
|
238
|
+
size="sm"
|
|
239
|
+
onClick={() =>
|
|
240
|
+
updateQuery((current) => ({
|
|
241
|
+
...current,
|
|
242
|
+
withImported: !current.withImported
|
|
243
|
+
}))
|
|
244
|
+
}
|
|
245
|
+
disabled={!payload.withImportedSwitch.togglable}
|
|
246
|
+
>
|
|
247
|
+
{query.withImported ? 'Showing imported' : 'Show imported'}
|
|
248
|
+
</Button>
|
|
249
|
+
)}
|
|
250
|
+
<IntervalPicker interval={interval} onChange={changeInterval} />
|
|
251
|
+
</div>
|
|
252
|
+
<div className="h-96">
|
|
253
|
+
<Line options={chartOptions} data={chartData} />
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
</section>
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function Spinner() {
|
|
262
|
+
return (
|
|
263
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
264
|
+
<Skeleton className="size-6 rounded-full" />
|
|
265
|
+
Loading…
|
|
266
|
+
</div>
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function createChartData(graph: MainGraphPayload) {
|
|
271
|
+
// Plausible-like palette with better contrast on dark backgrounds
|
|
272
|
+
// const isDark = typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
273
|
+
// Borrow hues from Live View metric card sparkline (cyan)
|
|
274
|
+
const CYAN = 'rgba(56, 189, 248, 1)' // sky-400
|
|
275
|
+
const CYAN_SOFT = 'rgba(56, 189, 248, 0.55)'
|
|
276
|
+
const CYAN_FILL = 'rgba(56, 189, 248, 0.10)'
|
|
277
|
+
const CYAN_FILL_SOFT = 'rgba(56, 189, 248, 0.08)'
|
|
278
|
+
|
|
279
|
+
const PRIMARY_STROKE = CYAN
|
|
280
|
+
const PRIMARY_FILL_START = CYAN_FILL
|
|
281
|
+
const COMP_STROKE = CYAN_SOFT
|
|
282
|
+
const COMP_POINT = CYAN_SOFT
|
|
283
|
+
const COMP_POINT_HOVER = 'rgba(56, 189, 248, 0.9)'
|
|
284
|
+
const COMP_FILL_START = CYAN_FILL_SOFT
|
|
285
|
+
|
|
286
|
+
const datasets: ChartDataset<'line', number[]>[] = [
|
|
287
|
+
{
|
|
288
|
+
label: graph.metric,
|
|
289
|
+
data: graph.plot,
|
|
290
|
+
borderColor: PRIMARY_STROKE,
|
|
291
|
+
backgroundColor: (context) => {
|
|
292
|
+
const ctx = context.chart.ctx
|
|
293
|
+
const gradient = ctx.createLinearGradient(0, 0, 0, 300)
|
|
294
|
+
gradient.addColorStop(0, PRIMARY_FILL_START)
|
|
295
|
+
gradient.addColorStop(1, 'rgba(101, 116, 205, 0)')
|
|
296
|
+
return gradient
|
|
297
|
+
},
|
|
298
|
+
tension: 0, // Straight lines, not curved
|
|
299
|
+
fill: true,
|
|
300
|
+
pointRadius: 0,
|
|
301
|
+
pointBackgroundColor: PRIMARY_STROKE,
|
|
302
|
+
pointHoverBackgroundColor: 'rgba(71, 87, 193, 1)',
|
|
303
|
+
pointBorderColor: 'transparent',
|
|
304
|
+
pointHoverRadius: 3,
|
|
305
|
+
borderWidth: 2.25
|
|
306
|
+
}
|
|
307
|
+
]
|
|
308
|
+
|
|
309
|
+
if (graph.comparisonPlot) {
|
|
310
|
+
datasets.push({
|
|
311
|
+
label: 'Comparison',
|
|
312
|
+
data: graph.comparisonPlot,
|
|
313
|
+
borderDash: [5, 4],
|
|
314
|
+
borderColor: COMP_STROKE,
|
|
315
|
+
backgroundColor: (context) => {
|
|
316
|
+
const ctx = context.chart.ctx
|
|
317
|
+
const gradient = ctx.createLinearGradient(0, 0, 0, 300)
|
|
318
|
+
gradient.addColorStop(0, COMP_FILL_START)
|
|
319
|
+
gradient.addColorStop(1, 'rgba(101, 116, 205, 0)')
|
|
320
|
+
return gradient
|
|
321
|
+
},
|
|
322
|
+
tension: 0,
|
|
323
|
+
pointRadius: 0,
|
|
324
|
+
pointBackgroundColor: COMP_POINT,
|
|
325
|
+
pointHoverBackgroundColor: COMP_POINT_HOVER,
|
|
326
|
+
pointBorderColor: 'transparent',
|
|
327
|
+
pointHoverRadius: 3,
|
|
328
|
+
fill: true,
|
|
329
|
+
borderWidth: 2,
|
|
330
|
+
yAxisID: 'y' // Use same y-axis
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
labels: graph.labels,
|
|
336
|
+
datasets
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function createChartOptions(graph: MainGraphPayload, period: string, tz: string, mouseYRef: React.MutableRefObject<number | null>) {
|
|
341
|
+
const METRIC_LABELS: Record<string, string> = {
|
|
342
|
+
visitors: 'Visitors',
|
|
343
|
+
visits: 'Visits',
|
|
344
|
+
pageviews: 'Pageviews',
|
|
345
|
+
views_per_visit: 'Views per visit',
|
|
346
|
+
bounce_rate: 'Bounce rate',
|
|
347
|
+
visit_duration: 'Visit duration'
|
|
348
|
+
}
|
|
349
|
+
const metricFormatter = (val: number): string => {
|
|
350
|
+
const m = graph.metric
|
|
351
|
+
if (m === 'visit_duration') return durationFormatter(val)
|
|
352
|
+
if (m === 'bounce_rate' || m === 'conversion_rate' || m === 'scroll_depth') return `${val.toFixed(2)}%`
|
|
353
|
+
if (m === 'views_per_visit') return val.toFixed(2)
|
|
354
|
+
return numberShortFormatter(val)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const externalTooltip = (ctx: any) => {
|
|
358
|
+
const { chart, tooltip } = ctx
|
|
359
|
+
let el = chart.canvas.parentNode.querySelector('.analytics-tooltip') as HTMLDivElement | null
|
|
360
|
+
if (!el) {
|
|
361
|
+
el = document.createElement('div')
|
|
362
|
+
el.className = 'analytics-tooltip'
|
|
363
|
+
el.style.position = 'absolute'
|
|
364
|
+
el.style.pointerEvents = 'none'
|
|
365
|
+
el.style.background = 'rgba(17, 19, 27, 0.95)'
|
|
366
|
+
el.style.border = '1px solid rgba(255,255,255,0.12)'
|
|
367
|
+
el.style.borderRadius = '10px'
|
|
368
|
+
el.style.padding = '10px 12px'
|
|
369
|
+
el.style.color = 'rgba(255,255,255,0.9)'
|
|
370
|
+
el.style.zIndex = '60'
|
|
371
|
+
el.style.minWidth = '220px'
|
|
372
|
+
el.style.boxShadow = '0 8px 24px rgba(0,0,0,0.35)'
|
|
373
|
+
chart.canvas.parentNode.appendChild(el)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (tooltip.opacity === 0) {
|
|
377
|
+
el.style.opacity = '0'
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const idx = tooltip.dataPoints?.[0]?.dataIndex ?? 0
|
|
382
|
+
const labelISO = graph.labels[idx]
|
|
383
|
+
const comparisonISO = graph.comparisonLabels?.[idx]
|
|
384
|
+
|
|
385
|
+
const shouldShowYear = hasMultipleYears(graph.labels)
|
|
386
|
+
const baseTitle = METRIC_LABELS[graph.metric] || graph.metric
|
|
387
|
+
|
|
388
|
+
const fmtPrimary = (() => {
|
|
389
|
+
if (!labelISO) return ''
|
|
390
|
+
if (graph.interval === 'hour') return `${formatDay(labelISO, shouldShowYear, tz)}, ${formatHour(labelISO, tz)}`
|
|
391
|
+
if (graph.interval === 'minute') return formatHour(labelISO, tz)
|
|
392
|
+
if (graph.interval === 'month') return formatMonth(labelISO, tz)
|
|
393
|
+
return formatDay(labelISO, shouldShowYear, tz)
|
|
394
|
+
})()
|
|
395
|
+
|
|
396
|
+
const fmtComparison = (() => {
|
|
397
|
+
if (!comparisonISO) return null
|
|
398
|
+
if (graph.interval === 'hour') return `${formatDay(comparisonISO, hasMultipleYears(graph.comparisonLabels || []), tz)}, ${formatHour(comparisonISO, tz)}`
|
|
399
|
+
if (graph.interval === 'minute') return formatHour(comparisonISO, tz)
|
|
400
|
+
if (graph.interval === 'month') return formatMonth(comparisonISO, tz)
|
|
401
|
+
return formatDay(comparisonISO, hasMultipleYears(graph.comparisonLabels || []), tz)
|
|
402
|
+
})()
|
|
403
|
+
|
|
404
|
+
const currentVal = Number(graph.plot[idx] ?? 0)
|
|
405
|
+
const comparisonVal = graph.comparisonPlot ? Number(graph.comparisonPlot[idx] ?? 0) : null
|
|
406
|
+
const changePct = comparisonVal && comparisonVal !== 0 ? ((currentVal - comparisonVal) / comparisonVal) * 100 : null
|
|
407
|
+
|
|
408
|
+
const up = changePct != null && changePct >= 0
|
|
409
|
+
const changeStr = changePct == null ? '' : `${up ? '▲' : '▼'} ${Math.round(Math.abs(changePct))}%`
|
|
410
|
+
const changeColor = up ? '#34d399' : '#fb7185'
|
|
411
|
+
|
|
412
|
+
// Colors based on datasets
|
|
413
|
+
const ds = chart.config.data.datasets || []
|
|
414
|
+
const primaryColor = (ds[0]?.borderColor as string) || 'rgba(96,165,250,1)'
|
|
415
|
+
const compColor = (ds[1]?.borderColor as string) || 'rgba(167,139,250,0.75)'
|
|
416
|
+
|
|
417
|
+
const primaryValStr = metricFormatter(currentVal)
|
|
418
|
+
const compValStr = comparisonVal == null ? null : metricFormatter(comparisonVal)
|
|
419
|
+
|
|
420
|
+
const header = document.createElement('div')
|
|
421
|
+
header.style.display = 'flex'
|
|
422
|
+
header.style.alignItems = 'center'
|
|
423
|
+
header.style.gap = '12px'
|
|
424
|
+
header.style.marginBottom = '6px'
|
|
425
|
+
|
|
426
|
+
const title = document.createElement('div')
|
|
427
|
+
title.style.fontWeight = '800'
|
|
428
|
+
title.style.fontSize = '16px'
|
|
429
|
+
title.style.lineHeight = '1.2'
|
|
430
|
+
title.textContent = baseTitle
|
|
431
|
+
header.appendChild(title)
|
|
432
|
+
|
|
433
|
+
if (changePct != null) {
|
|
434
|
+
const change = document.createElement('div')
|
|
435
|
+
change.style.marginLeft = 'auto'
|
|
436
|
+
change.style.fontWeight = '600'
|
|
437
|
+
change.style.color = changeColor
|
|
438
|
+
change.textContent = changeStr
|
|
439
|
+
header.appendChild(change)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const grid = document.createElement('div')
|
|
443
|
+
grid.style.display = 'grid'
|
|
444
|
+
grid.style.gridTemplateColumns = 'auto 1fr auto'
|
|
445
|
+
grid.style.gap = '6px 10px'
|
|
446
|
+
grid.style.alignItems = 'center'
|
|
447
|
+
|
|
448
|
+
const primaryDot = document.createElement('span')
|
|
449
|
+
primaryDot.style.width = '10px'
|
|
450
|
+
primaryDot.style.height = '10px'
|
|
451
|
+
primaryDot.style.borderRadius = '50%'
|
|
452
|
+
primaryDot.style.background = primaryColor
|
|
453
|
+
primaryDot.style.display = 'inline-block'
|
|
454
|
+
|
|
455
|
+
const primaryLabel = document.createElement('div')
|
|
456
|
+
primaryLabel.style.opacity = '0.85'
|
|
457
|
+
primaryLabel.style.fontSize = '13px'
|
|
458
|
+
primaryLabel.textContent = fmtPrimary
|
|
459
|
+
|
|
460
|
+
const primaryValue = document.createElement('div')
|
|
461
|
+
primaryValue.style.fontWeight = '800'
|
|
462
|
+
primaryValue.style.fontSize = '16px'
|
|
463
|
+
primaryValue.textContent = primaryValStr
|
|
464
|
+
|
|
465
|
+
grid.appendChild(primaryDot)
|
|
466
|
+
grid.appendChild(primaryLabel)
|
|
467
|
+
grid.appendChild(primaryValue)
|
|
468
|
+
|
|
469
|
+
if (compValStr != null) {
|
|
470
|
+
const compDot = document.createElement('span')
|
|
471
|
+
compDot.style.width = '10px'
|
|
472
|
+
compDot.style.height = '10px'
|
|
473
|
+
compDot.style.borderRadius = '50%'
|
|
474
|
+
compDot.style.background = compColor
|
|
475
|
+
compDot.style.display = 'inline-block'
|
|
476
|
+
compDot.style.opacity = '0.7'
|
|
477
|
+
|
|
478
|
+
const compLabel = document.createElement('div')
|
|
479
|
+
compLabel.style.opacity = '0.65'
|
|
480
|
+
compLabel.style.fontSize = '13px'
|
|
481
|
+
compLabel.textContent = fmtComparison || ''
|
|
482
|
+
|
|
483
|
+
const compValue = document.createElement('div')
|
|
484
|
+
compValue.style.fontWeight = '800'
|
|
485
|
+
compValue.style.fontSize = '16px'
|
|
486
|
+
compValue.style.opacity = '0.85'
|
|
487
|
+
compValue.textContent = compValStr
|
|
488
|
+
|
|
489
|
+
grid.appendChild(compDot)
|
|
490
|
+
grid.appendChild(compLabel)
|
|
491
|
+
grid.appendChild(compValue)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
el.replaceChildren(header, grid)
|
|
495
|
+
|
|
496
|
+
const parent = chart.canvas.parentNode as HTMLElement
|
|
497
|
+
const { offsetLeft: positionX, offsetTop: positionY } = chart.canvas
|
|
498
|
+
el.style.opacity = '1'
|
|
499
|
+
|
|
500
|
+
// Use tracked mouse Y position or fall back to caret Y
|
|
501
|
+
const mouseY = mouseYRef.current ?? tooltip.caretY
|
|
502
|
+
|
|
503
|
+
// Position tooltip top-left corner at mouse cursor
|
|
504
|
+
let left = positionX + tooltip.caretX
|
|
505
|
+
let top = positionY + mouseY
|
|
506
|
+
|
|
507
|
+
// Clamp to container bounds
|
|
508
|
+
const minX = 6
|
|
509
|
+
const maxX = parent.clientWidth - el.offsetWidth - 6
|
|
510
|
+
const minY = 6
|
|
511
|
+
const maxY = parent.clientHeight - el.offsetHeight - 6
|
|
512
|
+
if (left < minX) left = minX
|
|
513
|
+
if (left > maxX) left = maxX
|
|
514
|
+
if (top < minY) top = minY
|
|
515
|
+
if (top > maxY) top = maxY
|
|
516
|
+
el.style.left = left + 'px'
|
|
517
|
+
el.style.top = top + 'px'
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return {
|
|
521
|
+
responsive: true,
|
|
522
|
+
maintainAspectRatio: false,
|
|
523
|
+
onHover: (event: any, _activeElements: any, chart: any) => {
|
|
524
|
+
// Track mouse Y position relative to canvas
|
|
525
|
+
if (event.native) {
|
|
526
|
+
const canvasPosition = chart.canvas.getBoundingClientRect()
|
|
527
|
+
mouseYRef.current = event.native.clientY - canvasPosition.top
|
|
528
|
+
}
|
|
529
|
+
// Change cursor to pointer when hovering over chart
|
|
530
|
+
chart.canvas.style.cursor = 'pointer'
|
|
531
|
+
},
|
|
532
|
+
interaction: {
|
|
533
|
+
mode: 'index' as const,
|
|
534
|
+
intersect: false
|
|
535
|
+
},
|
|
536
|
+
plugins: {
|
|
537
|
+
legend: { display: false },
|
|
538
|
+
tooltip: {
|
|
539
|
+
enabled: false,
|
|
540
|
+
external: externalTooltip
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
scales: {
|
|
544
|
+
y: {
|
|
545
|
+
beginAtZero: true,
|
|
546
|
+
ticks: {
|
|
547
|
+
precision: 0,
|
|
548
|
+
color: 'rgba(255, 255, 255, 0.5)',
|
|
549
|
+
callback: function (value: number | string) {
|
|
550
|
+
const num = Number(value)
|
|
551
|
+
// Plausible shows whole numbers on Y-axis for views per visit
|
|
552
|
+
if (graph.metric === 'views_per_visit') return String(Math.round(num))
|
|
553
|
+
return metricFormatter(num)
|
|
554
|
+
}
|
|
555
|
+
},
|
|
556
|
+
grid: {
|
|
557
|
+
color: 'rgba(255, 255, 255, 0.06)',
|
|
558
|
+
drawBorder: false
|
|
559
|
+
}
|
|
560
|
+
},
|
|
561
|
+
x: {
|
|
562
|
+
ticks: {
|
|
563
|
+
maxRotation: 0,
|
|
564
|
+
maxTicksLimit: 8,
|
|
565
|
+
autoSkip: true,
|
|
566
|
+
autoSkipPadding: 20,
|
|
567
|
+
color: 'rgba(255, 255, 255, 0.5)',
|
|
568
|
+
callback: function (val: number | string) {
|
|
569
|
+
// Use Chart.js label mapping like Plausible
|
|
570
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
571
|
+
const scale = this as any
|
|
572
|
+
const label: string = scale.getLabelForValue(val)
|
|
573
|
+
if (!label || label === '__blank__') return ''
|
|
574
|
+
|
|
575
|
+
const shouldShowYear = hasMultipleYears(graph.labels)
|
|
576
|
+
|
|
577
|
+
if (graph.interval === 'hour' && period !== 'day') {
|
|
578
|
+
const d = formatDay(label, shouldShowYear, tz)
|
|
579
|
+
const h = formatHour(label, tz)
|
|
580
|
+
return `${d}, ${h}`
|
|
581
|
+
}
|
|
582
|
+
if (graph.interval === 'minute' && period !== 'realtime') {
|
|
583
|
+
return formatHour(label, tz)
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
switch (graph.interval) {
|
|
587
|
+
case 'minute':
|
|
588
|
+
case 'hour':
|
|
589
|
+
return formatHour(label, tz)
|
|
590
|
+
case 'day':
|
|
591
|
+
case 'week':
|
|
592
|
+
return formatDay(label, shouldShowYear, tz)
|
|
593
|
+
case 'month':
|
|
594
|
+
return formatMonth(label, tz)
|
|
595
|
+
default:
|
|
596
|
+
return formatDay(label, shouldShowYear, tz)
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
},
|
|
600
|
+
grid: {
|
|
601
|
+
display: false
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
type TopStatsGridProps = {
|
|
609
|
+
stats: TopStat[]
|
|
610
|
+
graphableMetrics: string[]
|
|
611
|
+
selectedMetric: string
|
|
612
|
+
onSelectMetric: (metric: string) => void
|
|
613
|
+
comparingFrom?: string | null
|
|
614
|
+
comparingTo?: string | null
|
|
615
|
+
period?: string
|
|
616
|
+
timezone?: string
|
|
617
|
+
showComparison?: boolean
|
|
618
|
+
primaryFrom?: string
|
|
619
|
+
primaryTo?: string
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function TopStatsGrid({ stats, graphableMetrics, selectedMetric, onSelectMetric, comparingFrom, comparingTo, period = 'day', timezone = dayjs.tz.guess(), showComparison = false, primaryFrom, primaryTo }: TopStatsGridProps) {
|
|
623
|
+
const selectable = new Set(graphableMetrics)
|
|
624
|
+
|
|
625
|
+
// Filter out "Live visitors" - it's shown in the top bar, not as a graphable metric
|
|
626
|
+
const displayStats = stats.filter((stat) => stat.graphMetric !== 'currentVisitors')
|
|
627
|
+
|
|
628
|
+
const items = displayStats.map((stat) => {
|
|
629
|
+
const canSelect = stat.graphMetric && selectable.has(stat.graphMetric)
|
|
630
|
+
const isSelected = canSelect && stat.graphMetric === selectedMetric
|
|
631
|
+
const classes = [
|
|
632
|
+
'group flex min-w-[140px] flex-1 flex-col gap-1 px-4 py-3 text-left transition',
|
|
633
|
+
canSelect ? 'hover:bg-white/5 focus:bg-white/8 focus:outline-hidden' : 'cursor-default',
|
|
634
|
+
isSelected ? 'bg-cyan-400/5' : ''
|
|
635
|
+
]
|
|
636
|
+
.filter(Boolean)
|
|
637
|
+
.join(' ');
|
|
638
|
+
|
|
639
|
+
// Primary period label (always shown like Plausible)
|
|
640
|
+
const primaryLabel = formatPrimaryRangeLabel(period, primaryFrom, primaryTo, timezone)
|
|
641
|
+
|
|
642
|
+
// Optional comparison value + range label (rendered as two lines like Plausible)
|
|
643
|
+
const hasComparison = showComparison && typeof stat.comparisonValue === 'number' && !Number.isNaN(stat.comparisonValue)
|
|
644
|
+
let comparisonValue: string | null = null
|
|
645
|
+
let comparisonLabel: string | null = null
|
|
646
|
+
if (hasComparison) {
|
|
647
|
+
const comp: TopStat = { ...stat, value: stat.comparisonValue as number }
|
|
648
|
+
comparisonValue = formatTopStatValue(comp)
|
|
649
|
+
comparisonLabel = formatComparisonRangeLabel(comparingFrom, comparingTo, period, timezone)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
return (
|
|
653
|
+
<button
|
|
654
|
+
key={stat.name}
|
|
655
|
+
type="button"
|
|
656
|
+
className={classes}
|
|
657
|
+
onClick={() => {
|
|
658
|
+
if (canSelect && stat.graphMetric) {
|
|
659
|
+
onSelectMetric(stat.graphMetric)
|
|
660
|
+
}
|
|
661
|
+
}}
|
|
662
|
+
disabled={!canSelect}
|
|
663
|
+
>
|
|
664
|
+
<span
|
|
665
|
+
className={[
|
|
666
|
+
'text-[11px] font-semibold uppercase tracking-wide w-fit border-b',
|
|
667
|
+
isSelected ? 'text-cyan-400/70 border-cyan-400' : 'text-foreground/60 border-transparent group-hover:text-cyan-400/50'
|
|
668
|
+
].join(' ')}
|
|
669
|
+
>
|
|
670
|
+
{stat.name}
|
|
671
|
+
</span>
|
|
672
|
+
<span className="text-xl font-bold tabular-nums text-foreground/90">
|
|
673
|
+
{formatTopStatValue(stat)}
|
|
674
|
+
</span>
|
|
675
|
+
{primaryLabel && showComparison ? (
|
|
676
|
+
<span className="text-xs text-foreground/60">
|
|
677
|
+
{primaryLabel}
|
|
678
|
+
</span>
|
|
679
|
+
) : null}
|
|
680
|
+
{comparisonValue ? (
|
|
681
|
+
<>
|
|
682
|
+
<span className="text-xl font-bold tabular-nums text-foreground/60">
|
|
683
|
+
{comparisonValue}
|
|
684
|
+
</span>
|
|
685
|
+
{comparisonLabel ? (
|
|
686
|
+
<span className="text-xs text-foreground/60">{comparisonLabel}</span>
|
|
687
|
+
) : null}
|
|
688
|
+
</>
|
|
689
|
+
) : null}
|
|
690
|
+
{!showComparison && stat.change != null ? (
|
|
691
|
+
<span
|
|
692
|
+
className={`inline-flex items-center gap-1 text-xs font-medium ${
|
|
693
|
+
stat.change >= 0 ? 'text-emerald-400' : 'text-rose-400'
|
|
694
|
+
}`}
|
|
695
|
+
>
|
|
696
|
+
{stat.change >= 0 ? '▲' : '▼'} {Math.round(Math.abs(stat.change) * 1000) / 10}%
|
|
697
|
+
</span>
|
|
698
|
+
) : null}
|
|
699
|
+
</button>
|
|
700
|
+
)
|
|
701
|
+
})
|
|
702
|
+
|
|
703
|
+
return (
|
|
704
|
+
<div className="flex flex-wrap divide-y divide-white/12 border-b border-white/12 sm:divide-y-0 sm:divide-x">
|
|
705
|
+
{items}
|
|
706
|
+
</div>
|
|
707
|
+
)
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Plausible's exact formatting logic
|
|
711
|
+
function numberShortFormatter(num: number): string {
|
|
712
|
+
const THOUSAND = 1000
|
|
713
|
+
const HUNDRED_THOUSAND = 100000
|
|
714
|
+
const MILLION = 1000000
|
|
715
|
+
const HUNDRED_MILLION = 100000000
|
|
716
|
+
const BILLION = 1000000000
|
|
717
|
+
const HUNDRED_BILLION = 100000000000
|
|
718
|
+
|
|
719
|
+
if (num >= THOUSAND && num < MILLION) {
|
|
720
|
+
const thousands = num / THOUSAND
|
|
721
|
+
if (thousands === Math.floor(thousands) || num >= HUNDRED_THOUSAND) {
|
|
722
|
+
return Math.floor(thousands) + 'k'
|
|
723
|
+
} else {
|
|
724
|
+
return Math.floor(thousands * 10) / 10 + 'k'
|
|
725
|
+
}
|
|
726
|
+
} else if (num >= MILLION && num < BILLION) {
|
|
727
|
+
const millions = num / MILLION
|
|
728
|
+
if (millions === Math.floor(millions) || num >= HUNDRED_MILLION) {
|
|
729
|
+
return Math.floor(millions) + 'M'
|
|
730
|
+
} else {
|
|
731
|
+
return Math.floor(millions * 10) / 10 + 'M'
|
|
732
|
+
}
|
|
733
|
+
} else if (num >= BILLION) {
|
|
734
|
+
const billions = num / BILLION
|
|
735
|
+
if (billions === Math.floor(billions) || num >= HUNDRED_BILLION) {
|
|
736
|
+
return Math.floor(billions) + 'B'
|
|
737
|
+
} else {
|
|
738
|
+
return Math.floor(billions * 10) / 10 + 'B'
|
|
739
|
+
}
|
|
740
|
+
} else {
|
|
741
|
+
return num.toString()
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function durationFormatter(duration: number): string {
|
|
746
|
+
const hours = Math.floor(duration / 60 / 60)
|
|
747
|
+
const minutes = Math.floor(duration / 60) % 60
|
|
748
|
+
const seconds = Math.floor(duration - minutes * 60 - hours * 60 * 60)
|
|
749
|
+
|
|
750
|
+
if (hours > 0) {
|
|
751
|
+
return `${hours}h ${minutes}m ${seconds}s`
|
|
752
|
+
} else if (minutes > 0) {
|
|
753
|
+
const paddedSeconds = seconds.toString().padStart(2, '0')
|
|
754
|
+
return `${minutes}m ${paddedSeconds}s`
|
|
755
|
+
} else {
|
|
756
|
+
return `${seconds}s`
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function formatTopStatValue(stat: TopStat) {
|
|
761
|
+
const value = Number(stat.value ?? 0)
|
|
762
|
+
|
|
763
|
+
// Prefer explicit metric key when present for stable formatting
|
|
764
|
+
const metric = (stat.graphMetric || '').toString().toLowerCase()
|
|
765
|
+
switch (metric) {
|
|
766
|
+
case 'bounce_rate':
|
|
767
|
+
case 'conversion_rate':
|
|
768
|
+
case 'scroll_depth':
|
|
769
|
+
return `${value.toFixed(2)}%`
|
|
770
|
+
case 'visit_duration':
|
|
771
|
+
return durationFormatter(value)
|
|
772
|
+
case 'views_per_visit':
|
|
773
|
+
return value.toFixed(2)
|
|
774
|
+
case 'visitors':
|
|
775
|
+
case 'visits':
|
|
776
|
+
case 'pageviews':
|
|
777
|
+
return numberShortFormatter(value)
|
|
778
|
+
default: {
|
|
779
|
+
// Fallback to name heuristics (covers rare tiles without graphMetric)
|
|
780
|
+
const name = (stat.name || '').toLowerCase()
|
|
781
|
+
if (name.includes('rate') || name.includes('scroll')) return `${value.toFixed(2)}%`
|
|
782
|
+
if (name.includes('duration') || name.includes('time on')) return durationFormatter(value)
|
|
783
|
+
if (name.includes('views per')) return value.toFixed(2)
|
|
784
|
+
return numberShortFormatter(value)
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Format the comparison period label to mirror Plausible cards
|
|
790
|
+
function formatComparisonRangeLabel(fromISO?: string | null, toISO?: string | null, period = 'day', tz = dayjs.tz.guess()) {
|
|
791
|
+
if (!fromISO && !toISO) return ''
|
|
792
|
+
const from = fromISO ? dayjs.utc(fromISO).tz(tz) : null
|
|
793
|
+
const to = toISO ? dayjs.utc(toISO).tz(tz) : null
|
|
794
|
+
|
|
795
|
+
// Helper formatters
|
|
796
|
+
const fmtDay = (d: dayjs.Dayjs) => d.format('ddd, D MMM YYYY')
|
|
797
|
+
const fmtMonth = (d: dayjs.Dayjs) => d.format('MMM YYYY')
|
|
798
|
+
|
|
799
|
+
// Prefer concise single-labels when the comparison covers a whole day/month/year
|
|
800
|
+
if (period === 'day' && from) return fmtDay(from)
|
|
801
|
+
|
|
802
|
+
if (period === 'month' && from && to) {
|
|
803
|
+
const isFullMonth = from.date() === 1 && to.endOf('month').isSame(to)
|
|
804
|
+
if (isFullMonth) return fmtMonth(from)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (period === 'year' && from && to) {
|
|
808
|
+
const isFullYear = from.month() === 0 && from.date() === 1 && to.month() === 11 && to.date() === 31
|
|
809
|
+
if (isFullYear) return from.format('YYYY')
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Generic fallback: compact range
|
|
813
|
+
if (from && to) return `${from.format('D MMM YYYY')} – ${to.format('D MMM YYYY')}`
|
|
814
|
+
if (from) return fmtDay(from)
|
|
815
|
+
if (to) return fmtDay(to)
|
|
816
|
+
return ''
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function formatPrimaryRangeLabel(period?: string, fromISO?: string | null, toISO?: string | null, tz = dayjs.tz.guess()) {
|
|
820
|
+
if (!period) return ''
|
|
821
|
+
if (fromISO) {
|
|
822
|
+
const from = dayjs.utc(fromISO).tz(tz)
|
|
823
|
+
const to = toISO ? dayjs.utc(toISO).tz(tz) : null
|
|
824
|
+
switch (period) {
|
|
825
|
+
case 'day':
|
|
826
|
+
return from.format('ddd, D MMM')
|
|
827
|
+
case 'month':
|
|
828
|
+
return from.format('MMM YYYY')
|
|
829
|
+
case 'year':
|
|
830
|
+
return from.format('YYYY')
|
|
831
|
+
default:
|
|
832
|
+
if (to) return `${from.format('D MMM YYYY')} – ${to.format('D MMM YYYY')}`
|
|
833
|
+
return from.format('D MMM YYYY')
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
return ''
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
type IntervalPickerProps = {
|
|
840
|
+
interval: string
|
|
841
|
+
onChange: (interval: string) => void
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function IntervalPicker({ interval, onChange }: IntervalPickerProps) {
|
|
845
|
+
// Determine allowed options similar to Plausible
|
|
846
|
+
const { query } = useQueryContext()
|
|
847
|
+
const options = (() => {
|
|
848
|
+
switch (query.period) {
|
|
849
|
+
case 'realtime':
|
|
850
|
+
return ['minute']
|
|
851
|
+
case 'day':
|
|
852
|
+
return ['minute', 'hour']
|
|
853
|
+
case '7d':
|
|
854
|
+
return ['hour', 'day']
|
|
855
|
+
case '28d':
|
|
856
|
+
case '30d':
|
|
857
|
+
return ['day', 'week']
|
|
858
|
+
case '91d':
|
|
859
|
+
return ['day', 'week', 'month']
|
|
860
|
+
case 'month':
|
|
861
|
+
return ['day', 'week']
|
|
862
|
+
case '12mo':
|
|
863
|
+
case 'year':
|
|
864
|
+
case 'all':
|
|
865
|
+
case 'custom':
|
|
866
|
+
return ['day', 'week', 'month']
|
|
867
|
+
default:
|
|
868
|
+
return ['day']
|
|
869
|
+
}
|
|
870
|
+
})()
|
|
871
|
+
|
|
872
|
+
const currentLabel = INTERVAL_LABELS[interval] || interval
|
|
873
|
+
|
|
874
|
+
return (
|
|
875
|
+
<DropdownMenu>
|
|
876
|
+
<DropdownMenuTrigger asChild>
|
|
877
|
+
<Button variant="link" size="sm" className="text-primary">
|
|
878
|
+
{currentLabel}
|
|
879
|
+
<ChevronDown className="ml-1 h-4 w-4" />
|
|
880
|
+
</Button>
|
|
881
|
+
</DropdownMenuTrigger>
|
|
882
|
+
<DropdownMenuContent align="end">
|
|
883
|
+
{options.map((opt) => (
|
|
884
|
+
<DropdownMenuItem key={opt} onClick={() => onChange(opt)} data-selected={opt === interval}>
|
|
885
|
+
<span className={opt === interval ? 'font-semibold' : ''}>{INTERVAL_LABELS[opt]}</span>
|
|
886
|
+
</DropdownMenuItem>
|
|
887
|
+
))}
|
|
888
|
+
</DropdownMenuContent>
|
|
889
|
+
</DropdownMenu>
|
|
890
|
+
)
|
|
891
|
+
}
|