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