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,793 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { Filter, Layers, Calendar, X, Shuffle } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
import { Button } from '@/components/ui/button'
|
|
5
|
+
import {
|
|
6
|
+
DropdownMenu,
|
|
7
|
+
DropdownMenuContent,
|
|
8
|
+
DropdownMenuGroup,
|
|
9
|
+
DropdownMenuItem,
|
|
10
|
+
DropdownMenuLabel,
|
|
11
|
+
DropdownMenuSeparator,
|
|
12
|
+
DropdownMenuTrigger
|
|
13
|
+
} from '@/components/ui/dropdown-menu'
|
|
14
|
+
import { Badge } from '@/components/ui/badge'
|
|
15
|
+
|
|
16
|
+
import { useSiteContext } from '../site-context'
|
|
17
|
+
import { useTopStatsContext } from '../top-stats-context'
|
|
18
|
+
import { useQueryContext } from '../query-context'
|
|
19
|
+
import type { AnalyticsQuery } from '../types'
|
|
20
|
+
import FilterDialog from './filter-dialog'
|
|
21
|
+
import DateRangePicker from './date-range-dialog'
|
|
22
|
+
import { analyticsPath } from '../lib/base-path'
|
|
23
|
+
import { getConsumer, type Subscription } from '@/lib/cable'
|
|
24
|
+
|
|
25
|
+
// No PERIOD_OPTIONS: menu rendered manually into Plausible-like groups
|
|
26
|
+
|
|
27
|
+
const FILTER_PICKER_COLUMNS = [
|
|
28
|
+
{
|
|
29
|
+
title: 'URL',
|
|
30
|
+
items: [
|
|
31
|
+
{ label: 'Page', key: 'page', value: '/dashboard' }
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
title: 'Acquisition',
|
|
36
|
+
items: [
|
|
37
|
+
{ label: 'Source', key: 'source', value: '' },
|
|
38
|
+
{ label: 'UTM tags', key: 'utm', value: '' }
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
title: 'Device',
|
|
43
|
+
items: [
|
|
44
|
+
{ label: 'Location', key: 'location', value: '' },
|
|
45
|
+
{ label: 'Screen size', key: 'size', value: '' },
|
|
46
|
+
{ label: 'Browser', key: 'browser', value: '' },
|
|
47
|
+
{ label: 'Operating System', key: 'os', value: '' }
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
type TopBarProps = {
|
|
53
|
+
showCurrentVisitors: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export default function TopBar({ showCurrentVisitors }: TopBarProps) {
|
|
57
|
+
// Feature flag: hide Segments until we have real saved segments
|
|
58
|
+
// We only show the Segments menu if there are more than the built-in "All visitors".
|
|
59
|
+
const site = useSiteContext()
|
|
60
|
+
const showSegments = Array.isArray(site.segments) && site.segments.length > 1
|
|
61
|
+
const [pinned, setPinned] = useState(false)
|
|
62
|
+
const sentinelRef = useRef<HTMLDivElement | null>(null)
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const sentinel = sentinelRef.current
|
|
66
|
+
if (!sentinel) return
|
|
67
|
+
|
|
68
|
+
const observer = new IntersectionObserver(
|
|
69
|
+
([entry]) => {
|
|
70
|
+
setPinned(!entry.isIntersecting)
|
|
71
|
+
},
|
|
72
|
+
{ rootMargin: '-80px 0px 0px 0px' }
|
|
73
|
+
)
|
|
74
|
+
observer.observe(sentinel)
|
|
75
|
+
return () => observer.disconnect()
|
|
76
|
+
}, [])
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="relative">
|
|
80
|
+
<div ref={sentinelRef} aria-hidden="true" className="absolute -top-16 h-16 w-full" />
|
|
81
|
+
<div
|
|
82
|
+
className={[
|
|
83
|
+
'relative z-10 flex flex-col gap-3 border-b border-transparent transition-colors',
|
|
84
|
+
pinned ? 'sticky top-0 bg-background/95 backdrop-blur-sm border-border shadow-[0_4px_12px_rgba(7,9,16,0.3)]' : ''
|
|
85
|
+
].join(' ')}
|
|
86
|
+
>
|
|
87
|
+
<div className="flex flex-col gap-3 px-2 pt-3 pb-1 sm:pb-2 sm:px-0">
|
|
88
|
+
{/* Desktop: single row with live visitors + badges (left) and buttons (right) */}
|
|
89
|
+
{/* Mobile: stacked - live visitors, then buttons, then badges */}
|
|
90
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
91
|
+
{/* Live visitors + badges (inline on desktop) */}
|
|
92
|
+
<div className="flex flex-wrap items-center gap-2.5 sm:gap-2.5">
|
|
93
|
+
{showCurrentVisitors && <CurrentVisitors />}
|
|
94
|
+
{/* Badges hidden on mobile, shown inline on desktop */}
|
|
95
|
+
<div className="hidden sm:contents">
|
|
96
|
+
<FiltersBar />
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
{/* Action buttons */}
|
|
101
|
+
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
|
102
|
+
<FilterMenu />
|
|
103
|
+
{showSegments ? <SegmentMenu /> : null}
|
|
104
|
+
<QueryPeriodsPicker />
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
{/* Filter badges row - only shown on mobile */}
|
|
109
|
+
<div className="flex flex-wrap items-center gap-2.5 sm:hidden">
|
|
110
|
+
<FiltersBar />
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function CurrentVisitors() {
|
|
119
|
+
const { payload, update } = useTopStatsContext()
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
let subscription: Subscription | null = null
|
|
123
|
+
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
|
|
124
|
+
|
|
125
|
+
const connect = () => {
|
|
126
|
+
try {
|
|
127
|
+
const consumer = getConsumer()
|
|
128
|
+
subscription = consumer.subscriptions.create({ channel: 'AhoyAnalytics::AnalyticsChannel' }, {
|
|
129
|
+
received: (data: { currentVisitors?: number }) => {
|
|
130
|
+
if (typeof data?.currentVisitors !== 'number') return
|
|
131
|
+
update((prev) => {
|
|
132
|
+
const nextStats = prev.topStats.map((stat) => {
|
|
133
|
+
if (stat.graphMetric !== 'currentVisitors') return stat
|
|
134
|
+
return { ...stat, value: data.currentVisitors }
|
|
135
|
+
})
|
|
136
|
+
return { ...prev, topStats: nextStats }
|
|
137
|
+
})
|
|
138
|
+
},
|
|
139
|
+
disconnected: () => {
|
|
140
|
+
reconnectTimeout = setTimeout(connect, 5000)
|
|
141
|
+
},
|
|
142
|
+
rejected: () => {
|
|
143
|
+
reconnectTimeout = setTimeout(connect, 5000)
|
|
144
|
+
}
|
|
145
|
+
}) as unknown as Subscription
|
|
146
|
+
} catch (e) {
|
|
147
|
+
reconnectTimeout = setTimeout(connect, 5000)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
connect()
|
|
152
|
+
|
|
153
|
+
return () => {
|
|
154
|
+
if (reconnectTimeout) clearTimeout(reconnectTimeout)
|
|
155
|
+
try {
|
|
156
|
+
subscription && (subscription as any).unsubscribe?.()
|
|
157
|
+
} catch {}
|
|
158
|
+
}
|
|
159
|
+
}, [update])
|
|
160
|
+
|
|
161
|
+
const current = useMemo(() => {
|
|
162
|
+
const live = payload.topStats.find((stat) => stat.graphMetric === 'currentVisitors')
|
|
163
|
+
if (live) return Math.round(live.value)
|
|
164
|
+
const fallback = payload.topStats[0]
|
|
165
|
+
return fallback ? Math.round(fallback.value) : 0
|
|
166
|
+
}, [payload.topStats])
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<a
|
|
170
|
+
href={analyticsPath('live')}
|
|
171
|
+
className="flex items-center gap-2 rounded-full bg-cyan-400/10 px-3 py-1.5 text-sm font-semibold text-cyan-400 transition hover:bg-cyan-400/15"
|
|
172
|
+
>
|
|
173
|
+
<span className="inline-flex size-2 animate-pulse rounded-full bg-cyan-400" aria-hidden="true" />
|
|
174
|
+
<span>{current} live visitors</span>
|
|
175
|
+
</a>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function FiltersBar() {
|
|
180
|
+
const { query, updateQuery } = useQueryContext()
|
|
181
|
+
const eqEntries = Object.entries(query.filters)
|
|
182
|
+
const advEntries = Array.isArray(query.advancedFilters) ? query.advancedFilters : []
|
|
183
|
+
|
|
184
|
+
const order = [
|
|
185
|
+
'page', 'entry_page', 'exit_page',
|
|
186
|
+
'source', 'channel', 'referrer',
|
|
187
|
+
'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term',
|
|
188
|
+
'country', 'region', 'city',
|
|
189
|
+
'browser', 'os',
|
|
190
|
+
'goal', 'prop', 'segment'
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
const sortedEq = eqEntries.slice().sort(([a], [b]) => order.indexOf(a) - order.indexOf(b))
|
|
194
|
+
const sortedAdv = advEntries.slice().sort((a, b) => order.indexOf(a[1]) - order.indexOf(b[1]))
|
|
195
|
+
|
|
196
|
+
if (sortedEq.length === 0 && sortedAdv.length === 0) {
|
|
197
|
+
return null
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<>
|
|
202
|
+
{sortedEq.map(([key, value]) => (
|
|
203
|
+
<Badge key={`eq:${key}`} variant="secondary" className="flex items-center gap-1 bg-white/10 hover:bg-primary/10">
|
|
204
|
+
<span className="capitalize">{filterLabel(key)}:</span>
|
|
205
|
+
<span>{(query.labels && query.labels[key]) || value}</span>
|
|
206
|
+
<Button
|
|
207
|
+
variant="ghost"
|
|
208
|
+
size="icon"
|
|
209
|
+
className="size-5 p-0"
|
|
210
|
+
onClick={() => {
|
|
211
|
+
updateQuery((current) => {
|
|
212
|
+
const nextFilters: Record<string, string> = { ...current.filters }
|
|
213
|
+
delete nextFilters[key]
|
|
214
|
+
const nextLabels = { ...(current.labels || {}) } as Record<string, string>
|
|
215
|
+
delete nextLabels[key]
|
|
216
|
+
const cleaned = Object.keys(nextFilters).length === 0 ? undefined : nextLabels
|
|
217
|
+
return { ...current, filters: nextFilters, labels: cleaned }
|
|
218
|
+
})
|
|
219
|
+
}}
|
|
220
|
+
>
|
|
221
|
+
<X className="size-3" />
|
|
222
|
+
<span className="sr-only">Remove filter {key}</span>
|
|
223
|
+
</Button>
|
|
224
|
+
</Badge>
|
|
225
|
+
))}
|
|
226
|
+
{sortedAdv.map(([op, dim, clause], i) => (
|
|
227
|
+
<Badge key={`adv:${i}:${op}:${dim}:${clause}`} variant="secondary" className="flex items-center gap-1 bg-white/10 hover:bg-primary/10">
|
|
228
|
+
<span className="capitalize">{filterLabel(dim)}:</span>
|
|
229
|
+
<span className="lowercase">{String(op).replace('_', ' ')}</span>
|
|
230
|
+
<span className="font-medium">{clause}</span>
|
|
231
|
+
<Button
|
|
232
|
+
variant="ghost"
|
|
233
|
+
size="icon"
|
|
234
|
+
className="size-5 p-0"
|
|
235
|
+
onClick={() => {
|
|
236
|
+
updateQuery((current) => {
|
|
237
|
+
const currentAdv = Array.isArray(current.advancedFilters) ? current.advancedFilters : []
|
|
238
|
+
const nextAdv = currentAdv.filter((t) => !(t[0] === op && t[1] === dim && t[2] === clause))
|
|
239
|
+
return { ...current, advancedFilters: nextAdv }
|
|
240
|
+
})
|
|
241
|
+
}}
|
|
242
|
+
>
|
|
243
|
+
<X className="size-3" />
|
|
244
|
+
<span className="sr-only">Remove filter {dim} {op} {clause}</span>
|
|
245
|
+
</Button>
|
|
246
|
+
</Badge>
|
|
247
|
+
))}
|
|
248
|
+
{(sortedEq.length + sortedAdv.length) >= 2 ? (
|
|
249
|
+
<Button
|
|
250
|
+
variant="ghost"
|
|
251
|
+
size="sm"
|
|
252
|
+
className="h-6 px-2 text-xs"
|
|
253
|
+
onClick={() => updateQuery((current) => ({ ...current, filters: {}, labels: undefined, advancedFilters: [] }))}
|
|
254
|
+
>
|
|
255
|
+
Clear all
|
|
256
|
+
</Button>
|
|
257
|
+
) : null}
|
|
258
|
+
</>
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function filterLabel(key: string) {
|
|
263
|
+
switch (key) {
|
|
264
|
+
case 'hostname':
|
|
265
|
+
return 'Hostname'
|
|
266
|
+
case 'source':
|
|
267
|
+
return 'Source'
|
|
268
|
+
case 'channel':
|
|
269
|
+
return 'Channel'
|
|
270
|
+
case 'size':
|
|
271
|
+
return 'Screen Size'
|
|
272
|
+
case 'country':
|
|
273
|
+
return 'Country'
|
|
274
|
+
case 'region':
|
|
275
|
+
return 'Region'
|
|
276
|
+
case 'city':
|
|
277
|
+
return 'City'
|
|
278
|
+
case 'goal':
|
|
279
|
+
return 'Goal'
|
|
280
|
+
case 'segment':
|
|
281
|
+
return 'Segment'
|
|
282
|
+
case 'page':
|
|
283
|
+
return 'Page'
|
|
284
|
+
case 'browser':
|
|
285
|
+
return 'Browser'
|
|
286
|
+
case 'os':
|
|
287
|
+
return 'Operating System'
|
|
288
|
+
case 'utm_source':
|
|
289
|
+
return 'UTM Source'
|
|
290
|
+
case 'utm_medium':
|
|
291
|
+
return 'UTM Medium'
|
|
292
|
+
case 'utm_campaign':
|
|
293
|
+
return 'UTM Campaign'
|
|
294
|
+
case 'utm_content':
|
|
295
|
+
return 'UTM Content'
|
|
296
|
+
case 'utm_term':
|
|
297
|
+
return 'UTM Term'
|
|
298
|
+
case 'referrer':
|
|
299
|
+
return 'Referrer URL'
|
|
300
|
+
case 'entry_page':
|
|
301
|
+
return 'Entry Page'
|
|
302
|
+
case 'exit_page':
|
|
303
|
+
return 'Exit Page'
|
|
304
|
+
case 'prop':
|
|
305
|
+
return 'Property'
|
|
306
|
+
default:
|
|
307
|
+
return key
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function FilterMenu() {
|
|
312
|
+
const { updateQuery } = useQueryContext()
|
|
313
|
+
const [open, setOpen] = useState(false)
|
|
314
|
+
const [dialogOpen, setDialogOpen] = useState(false)
|
|
315
|
+
const [dialogType, setDialogType] = useState<'page' | 'location' | 'source' | 'utm' | 'browser' | 'os' | 'size' | 'goal'>('page')
|
|
316
|
+
|
|
317
|
+
const setFilter = (key: string, value: string) => {
|
|
318
|
+
updateQuery((current) => ({
|
|
319
|
+
...current,
|
|
320
|
+
filters: { ...current.filters, [key]: value }
|
|
321
|
+
}))
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return (
|
|
325
|
+
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
326
|
+
<DropdownMenuTrigger asChild>
|
|
327
|
+
<Button variant="outline" size="sm" className="gap-2">
|
|
328
|
+
<Filter className="size-4" />
|
|
329
|
+
<span className="hidden sm:inline">Filters</span>
|
|
330
|
+
</Button>
|
|
331
|
+
</DropdownMenuTrigger>
|
|
332
|
+
<DropdownMenuContent align="start" sideOffset={8} alignOffset={-6} className="w-64 p-1 rounded-md border border-border bg-popover/95 shadow-md">
|
|
333
|
+
{FILTER_PICKER_COLUMNS.map((column, idx) => (
|
|
334
|
+
<div key={column.title} className="mb-1 last:mb-0">
|
|
335
|
+
<DropdownMenuLabel className="text-[11px] font-extrabold uppercase tracking-wider text-primary">
|
|
336
|
+
{column.title}
|
|
337
|
+
</DropdownMenuLabel>
|
|
338
|
+
<DropdownMenuGroup>
|
|
339
|
+
{column.items.map((item) => (
|
|
340
|
+
<DropdownMenuItem
|
|
341
|
+
key={item.label}
|
|
342
|
+
onClick={() => {
|
|
343
|
+
if (item.key === 'page') { setDialogType('page'); setDialogOpen(true) }
|
|
344
|
+
else if (item.key === 'location') { setDialogType('location'); setDialogOpen(true) }
|
|
345
|
+
else if (item.key === 'source') { setDialogType('source'); setDialogOpen(true) }
|
|
346
|
+
else if (item.key === 'utm') { setDialogType('utm'); setDialogOpen(true) }
|
|
347
|
+
else if (item.key === 'browser') { setDialogType('browser'); setDialogOpen(true) }
|
|
348
|
+
else if (item.key === 'os') { setDialogType('os'); setDialogOpen(true) }
|
|
349
|
+
else if (item.key === 'size') { setDialogType('size'); setDialogOpen(true) }
|
|
350
|
+
else { setFilter(item.key, item.value) }
|
|
351
|
+
setOpen(false)
|
|
352
|
+
}}
|
|
353
|
+
>
|
|
354
|
+
{item.label}
|
|
355
|
+
</DropdownMenuItem>
|
|
356
|
+
))}
|
|
357
|
+
</DropdownMenuGroup>
|
|
358
|
+
{idx < FILTER_PICKER_COLUMNS.length - 1 ? (
|
|
359
|
+
<DropdownMenuSeparator />
|
|
360
|
+
) : null}
|
|
361
|
+
</div>
|
|
362
|
+
))}
|
|
363
|
+
</DropdownMenuContent>
|
|
364
|
+
<FilterDialog open={dialogOpen} onOpenChange={setDialogOpen} type={dialogType} />
|
|
365
|
+
</DropdownMenu>
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function SegmentMenu() {
|
|
370
|
+
const site = useSiteContext()
|
|
371
|
+
const { query, updateQuery } = useQueryContext()
|
|
372
|
+
const activeSegment = (query.filters as Record<string, string | undefined>).segment
|
|
373
|
+
|
|
374
|
+
const applySegment = (segmentId: string | null) => {
|
|
375
|
+
updateQuery((current) => {
|
|
376
|
+
const nextFilters = { ...current.filters }
|
|
377
|
+
if (segmentId) {
|
|
378
|
+
nextFilters.segment = segmentId
|
|
379
|
+
} else {
|
|
380
|
+
delete nextFilters.segment
|
|
381
|
+
}
|
|
382
|
+
return { ...current, filters: nextFilters }
|
|
383
|
+
})
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return (
|
|
387
|
+
<DropdownMenu>
|
|
388
|
+
<DropdownMenuTrigger asChild>
|
|
389
|
+
<Button variant="outline" size="sm" className="gap-2">
|
|
390
|
+
<Layers className="size-4" />
|
|
391
|
+
<span className="hidden sm:inline">
|
|
392
|
+
{activeSegment ? site.segments.find((s) => s.id === activeSegment)?.name ?? 'Segment' : 'Segments'}
|
|
393
|
+
</span>
|
|
394
|
+
</Button>
|
|
395
|
+
</DropdownMenuTrigger>
|
|
396
|
+
<DropdownMenuContent align="end" className="w-56">
|
|
397
|
+
<DropdownMenuLabel>Saved segments</DropdownMenuLabel>
|
|
398
|
+
{site.segments.map((segment) => (
|
|
399
|
+
<DropdownMenuItem key={segment.id} onClick={() => applySegment(segment.id)} className="hover:bg-white/10 data-[selected=true]:bg-primary/10">
|
|
400
|
+
{segment.name}
|
|
401
|
+
</DropdownMenuItem>
|
|
402
|
+
))}
|
|
403
|
+
<DropdownMenuSeparator />
|
|
404
|
+
<DropdownMenuItem onClick={() => applySegment(null)} className="hover:bg-white/10">All visitors</DropdownMenuItem>
|
|
405
|
+
</DropdownMenuContent>
|
|
406
|
+
</DropdownMenu>
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function QueryPeriodsPicker() {
|
|
411
|
+
const { query, updateQuery } = useQueryContext()
|
|
412
|
+
const [dropdownOpen, setDropdownOpen] = useState(false)
|
|
413
|
+
const customCalendarButtonRef = useRef<HTMLButtonElement>(null)
|
|
414
|
+
const compareCalendarButtonRef = useRef<HTMLButtonElement>(null)
|
|
415
|
+
const [customOpen, setCustomOpen] = useState(false)
|
|
416
|
+
const [compareOpen, setCompareOpen] = useState(false)
|
|
417
|
+
|
|
418
|
+
useEffect(() => {
|
|
419
|
+
function onKeydown(e: KeyboardEvent) {
|
|
420
|
+
const target = e.target as HTMLElement | null
|
|
421
|
+
const tag = (target?.tagName || '').toLowerCase()
|
|
422
|
+
const isTyping = tag === 'input' || tag === 'textarea' || (target?.isContentEditable ?? false)
|
|
423
|
+
if (isTyping || e.metaKey || e.ctrlKey || e.altKey) return
|
|
424
|
+
|
|
425
|
+
const k = (e.key || '').toUpperCase()
|
|
426
|
+
const map: Record<string, { value: AnalyticsQuery['period']; setDate?: 'current' | 'last' } | 'toggle-compare' | 'custom'> = {
|
|
427
|
+
D: { value: 'day', setDate: 'current' },
|
|
428
|
+
E: { value: 'day', setDate: 'last' },
|
|
429
|
+
R: { value: 'realtime' },
|
|
430
|
+
W: { value: '7d' },
|
|
431
|
+
F: { value: '28d' },
|
|
432
|
+
N: { value: '91d' },
|
|
433
|
+
M: { value: 'month', setDate: 'current' },
|
|
434
|
+
P: { value: 'month', setDate: 'last' },
|
|
435
|
+
Y: { value: 'year', setDate: 'current' },
|
|
436
|
+
L: { value: '12mo' },
|
|
437
|
+
A: { value: 'all' },
|
|
438
|
+
C: 'custom',
|
|
439
|
+
X: 'toggle-compare'
|
|
440
|
+
}
|
|
441
|
+
const action = map[k]
|
|
442
|
+
if (!action) return
|
|
443
|
+
e.preventDefault()
|
|
444
|
+
if (action === 'custom') { setCustomOpen(true); return }
|
|
445
|
+
if (action === 'toggle-compare') {
|
|
446
|
+
updateQuery((current) => ({ ...current, comparison: current.comparison === 'previous_period' ? null : 'previous_period' }))
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
updateQuery((current) => applyPeriodSelection(current, action))
|
|
450
|
+
}
|
|
451
|
+
window.addEventListener('keydown', onKeydown)
|
|
452
|
+
return () => window.removeEventListener('keydown', onKeydown)
|
|
453
|
+
}, [updateQuery, customCalendarButtonRef])
|
|
454
|
+
|
|
455
|
+
const compareEnabled = Boolean(query.comparison)
|
|
456
|
+
const compareLabel = (() => {
|
|
457
|
+
if (!query.comparison) return 'Compare'
|
|
458
|
+
if (query.comparison === 'previous_period') return 'Previous period'
|
|
459
|
+
if (query.comparison === 'year_over_year') return 'Year over year'
|
|
460
|
+
if (query.comparison === 'custom' && (query as any).compareFrom && (query as any).compareTo) {
|
|
461
|
+
const from = String((query as any).compareFrom).slice(0, 10)
|
|
462
|
+
const to = String((query as any).compareTo).slice(0, 10)
|
|
463
|
+
|
|
464
|
+
const fromDate = new Date(from)
|
|
465
|
+
const toDate = new Date(to)
|
|
466
|
+
|
|
467
|
+
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
468
|
+
const fromMonth = monthNames[fromDate.getMonth()]
|
|
469
|
+
const toMonth = monthNames[toDate.getMonth()]
|
|
470
|
+
const fromDay = fromDate.getDate()
|
|
471
|
+
const toDay = toDate.getDate()
|
|
472
|
+
const fromYear = fromDate.getFullYear()
|
|
473
|
+
const toYear = toDate.getFullYear()
|
|
474
|
+
|
|
475
|
+
// Same year
|
|
476
|
+
if (fromYear === toYear) {
|
|
477
|
+
// Same month
|
|
478
|
+
if (fromMonth === toMonth) {
|
|
479
|
+
return `${fromMonth} ${fromDay}–${toDay}, ${fromYear}`
|
|
480
|
+
}
|
|
481
|
+
// Different months, same year
|
|
482
|
+
return `${fromMonth} ${fromDay}–${toMonth} ${toDay}, ${fromYear}`
|
|
483
|
+
}
|
|
484
|
+
// Different years
|
|
485
|
+
return `${fromMonth} ${fromDay}, ${fromYear}–${toMonth} ${toDay}, ${toYear}`
|
|
486
|
+
}
|
|
487
|
+
return 'Compare'
|
|
488
|
+
})()
|
|
489
|
+
|
|
490
|
+
return (
|
|
491
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
492
|
+
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
|
493
|
+
<DropdownMenuTrigger asChild>
|
|
494
|
+
<Button variant="outline" size="sm" className="gap-2">
|
|
495
|
+
<Calendar className="size-4 shrink-0" />
|
|
496
|
+
<span className="truncate">{getPeriodDisplay(query)}</span>
|
|
497
|
+
</Button>
|
|
498
|
+
</DropdownMenuTrigger>
|
|
499
|
+
<DropdownMenuContent align="end" className="w-56">
|
|
500
|
+
<DropdownMenuItem
|
|
501
|
+
className="hover:bg-white/10 data-[selected=true]:bg-primary/10"
|
|
502
|
+
onClick={() => updateQuery((c) => applyPeriodSelection(c, { value: 'day', setDate: 'current' }))}
|
|
503
|
+
>
|
|
504
|
+
<MenuRow label="Today" hint="D" active={isActiveDay(query, 'current')} />
|
|
505
|
+
</DropdownMenuItem>
|
|
506
|
+
<DropdownMenuItem
|
|
507
|
+
className="hover:bg-white/10 data-[selected=true]:bg-primary/10"
|
|
508
|
+
onClick={() => updateQuery((c) => applyPeriodSelection(c, { value: 'day', setDate: 'last' }))}
|
|
509
|
+
>
|
|
510
|
+
<MenuRow label="Yesterday" hint="E" active={isActiveDay(query, 'last')} />
|
|
511
|
+
</DropdownMenuItem>
|
|
512
|
+
<DropdownMenuItem onClick={() => updateQuery((c) => applyPeriodSelection(c, { value: 'realtime' }))} className="hover:bg-white/10 data-[selected=true]:bg-primary/10">
|
|
513
|
+
<MenuRow label="Realtime" hint="R" active={query.period === 'realtime'} />
|
|
514
|
+
</DropdownMenuItem>
|
|
515
|
+
<DropdownMenuSeparator />
|
|
516
|
+
<DropdownMenuItem onClick={() => updateQuery((c) => applyPeriodSelection(c, { value: '7d' }))} className="hover:bg-white/10 data-[selected=true]:bg-primary/10">
|
|
517
|
+
<MenuRow label="Last 7 Days" hint="W" active={query.period === '7d'} />
|
|
518
|
+
</DropdownMenuItem>
|
|
519
|
+
<DropdownMenuItem onClick={() => updateQuery((c) => applyPeriodSelection(c, { value: '28d' }))} className="hover:bg-white/10 data-[selected=true]:bg-primary/10">
|
|
520
|
+
<MenuRow label="Last 28 Days" hint="F" active={query.period === '28d'} />
|
|
521
|
+
</DropdownMenuItem>
|
|
522
|
+
<DropdownMenuItem onClick={() => updateQuery((c) => applyPeriodSelection(c, { value: '91d' }))} className="hover:bg-white/10 data-[selected=true]:bg-primary/10">
|
|
523
|
+
<MenuRow label="Last 91 Days" hint="N" active={query.period === '91d'} />
|
|
524
|
+
</DropdownMenuItem>
|
|
525
|
+
<DropdownMenuSeparator />
|
|
526
|
+
<DropdownMenuItem onClick={() => updateQuery((c) => applyPeriodSelection(c, { value: 'month', setDate: 'current' }))} className="hover:bg-white/10 data-[selected=true]:bg-primary/10">
|
|
527
|
+
<MenuRow label="Month to Date" hint="M" active={isActiveMonth(query, 'current')} />
|
|
528
|
+
</DropdownMenuItem>
|
|
529
|
+
<DropdownMenuItem onClick={() => updateQuery((c) => applyPeriodSelection(c, { value: 'month', setDate: 'last' }))} className="hover:bg-white/10 data-[selected=true]:bg-primary/10">
|
|
530
|
+
<MenuRow label="Last Month" hint="P" active={isActiveMonth(query, 'last')} />
|
|
531
|
+
</DropdownMenuItem>
|
|
532
|
+
<DropdownMenuSeparator />
|
|
533
|
+
<DropdownMenuItem onClick={() => updateQuery((c) => applyPeriodSelection(c, { value: 'year', setDate: 'current' }))} className="hover:bg-white/10 data-[selected=true]:bg-primary/10">
|
|
534
|
+
<MenuRow label="Year to Date" hint="Y" active={isActiveYear(query, 'current')} />
|
|
535
|
+
</DropdownMenuItem>
|
|
536
|
+
<DropdownMenuItem onClick={() => updateQuery((c) => applyPeriodSelection(c, { value: '12mo' }))} className="hover:bg-white/10 data-[selected=true]:bg-primary/10">
|
|
537
|
+
<MenuRow label="Last 12 Months" hint="L" active={query.period === '12mo'} />
|
|
538
|
+
</DropdownMenuItem>
|
|
539
|
+
<DropdownMenuSeparator />
|
|
540
|
+
<DropdownMenuItem onClick={() => updateQuery((c) => applyPeriodSelection(c, { value: 'all' }))} className="hover:bg-white/10 data-[selected=true]:bg-primary/10">
|
|
541
|
+
<MenuRow label="All time" hint="A" active={query.period === 'all'} />
|
|
542
|
+
</DropdownMenuItem>
|
|
543
|
+
<DropdownMenuItem
|
|
544
|
+
onClick={() => {
|
|
545
|
+
setDropdownOpen(false)
|
|
546
|
+
setTimeout(() => setCustomOpen(true), 0)
|
|
547
|
+
}}
|
|
548
|
+
className="hover:bg-white/10 data-[selected=true]:bg-primary/10"
|
|
549
|
+
>
|
|
550
|
+
<MenuRow label="Custom Range" hint="C" active={query.period === 'custom'} />
|
|
551
|
+
</DropdownMenuItem>
|
|
552
|
+
<DropdownMenuSeparator />
|
|
553
|
+
{Boolean(query.comparison) ? (
|
|
554
|
+
<DropdownMenuItem onClick={() => updateQuery((c) => ({ ...c, comparison: null, compareFrom: null as any, compareTo: null as any }))} className="hover:bg-white/10">
|
|
555
|
+
<MenuRow label="Disable comparison" hint="X" />
|
|
556
|
+
</DropdownMenuItem>
|
|
557
|
+
) : (
|
|
558
|
+
<DropdownMenuItem onClick={() => updateQuery((c) => ({ ...c, comparison: 'previous_period' }))} className="hover:bg-white/10">
|
|
559
|
+
<MenuRow label="Compare" hint="X" leftIcon={<Shuffle className="mr-2 size-4" />} />
|
|
560
|
+
</DropdownMenuItem>
|
|
561
|
+
)}
|
|
562
|
+
</DropdownMenuContent>
|
|
563
|
+
</DropdownMenu>
|
|
564
|
+
|
|
565
|
+
{compareEnabled && (
|
|
566
|
+
<>
|
|
567
|
+
<span className="text-sm text-muted-foreground shrink-0">vs.</span>
|
|
568
|
+
<DropdownMenu>
|
|
569
|
+
<DropdownMenuTrigger asChild>
|
|
570
|
+
<Button variant="outline" size="sm" className="gap-2">
|
|
571
|
+
<span className="truncate">{compareLabel}</span>
|
|
572
|
+
</Button>
|
|
573
|
+
</DropdownMenuTrigger>
|
|
574
|
+
<DropdownMenuContent align="end" className="w-56">
|
|
575
|
+
<DropdownMenuItem onClick={() => updateQuery((c) => ({ ...c, comparison: null, compareFrom: null as any, compareTo: null as any }))} className="hover:bg-white/10">
|
|
576
|
+
<MenuRow label="Disable comparison" />
|
|
577
|
+
</DropdownMenuItem>
|
|
578
|
+
<DropdownMenuItem onClick={() => updateQuery((c) => ({ ...c, comparison: 'previous_period', compareFrom: null as any, compareTo: null as any }))} className="hover:bg-white/10 data-[selected=true]:bg-primary/10">
|
|
579
|
+
<MenuRow label="Previous period" active={query.comparison === 'previous_period'} />
|
|
580
|
+
</DropdownMenuItem>
|
|
581
|
+
<DropdownMenuItem onClick={() => updateQuery((c) => ({ ...c, comparison: 'year_over_year', compareFrom: null as any, compareTo: null as any }))} className="hover:bg-white/10 data-[selected=true]:bg-primary/10">
|
|
582
|
+
<MenuRow label="Year over year" active={query.comparison === 'year_over_year'} />
|
|
583
|
+
</DropdownMenuItem>
|
|
584
|
+
<DropdownMenuItem
|
|
585
|
+
onClick={() => {
|
|
586
|
+
setDropdownOpen(false)
|
|
587
|
+
setTimeout(() => setCompareOpen(true), 0)
|
|
588
|
+
}}
|
|
589
|
+
className="hover:bg-white/10 data-[selected=true]:bg-primary/10"
|
|
590
|
+
>
|
|
591
|
+
<MenuRow label="Custom period…" active={query.comparison === 'custom'} />
|
|
592
|
+
</DropdownMenuItem>
|
|
593
|
+
<DropdownMenuSeparator />
|
|
594
|
+
<DropdownMenuItem onClick={() => updateQuery((c) => ({ ...c, matchDayOfWeek: true }))} className="hover:bg-white/10 data-[selected=true]:bg-primary/10">
|
|
595
|
+
<MenuRow label="Match day of week" active={Boolean(query.matchDayOfWeek)} />
|
|
596
|
+
</DropdownMenuItem>
|
|
597
|
+
<DropdownMenuItem onClick={() => updateQuery((c) => ({ ...c, matchDayOfWeek: false }))} className="hover:bg-white/10 data-[selected=true]:bg-primary/10">
|
|
598
|
+
<MenuRow label="Match exact date" active={Boolean(query.matchDayOfWeek) === false} />
|
|
599
|
+
</DropdownMenuItem>
|
|
600
|
+
</DropdownMenuContent>
|
|
601
|
+
</DropdownMenu>
|
|
602
|
+
</>
|
|
603
|
+
)}
|
|
604
|
+
|
|
605
|
+
{/* Custom Range Calendar Picker */}
|
|
606
|
+
<DateRangePicker
|
|
607
|
+
buttonRef={customCalendarButtonRef}
|
|
608
|
+
open={customOpen}
|
|
609
|
+
onOpenChange={setCustomOpen}
|
|
610
|
+
initialFrom={query.period === 'custom' ? (query as any).from : undefined}
|
|
611
|
+
initialTo={query.period === 'custom' ? (query as any).to : undefined}
|
|
612
|
+
onApply={(fromISO, toISO) => {
|
|
613
|
+
setCustomOpen(false)
|
|
614
|
+
updateQuery((current) => ({ ...current, period: 'custom', from: fromISO, to: toISO }))
|
|
615
|
+
}}
|
|
616
|
+
/>
|
|
617
|
+
|
|
618
|
+
{/* Comparison Custom Range Calendar Picker */}
|
|
619
|
+
<DateRangePicker
|
|
620
|
+
buttonRef={compareCalendarButtonRef}
|
|
621
|
+
open={compareOpen}
|
|
622
|
+
onOpenChange={setCompareOpen}
|
|
623
|
+
initialFrom={query.comparison === 'custom' ? (query as any).compareFrom : undefined}
|
|
624
|
+
initialTo={query.comparison === 'custom' ? (query as any).compareTo : undefined}
|
|
625
|
+
onApply={(fromISO, toISO) => {
|
|
626
|
+
setCompareOpen(false)
|
|
627
|
+
updateQuery((current) => ({ ...current, comparison: 'custom', compareFrom: fromISO, compareTo: toISO } as any))
|
|
628
|
+
}}
|
|
629
|
+
/>
|
|
630
|
+
</div>
|
|
631
|
+
)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function getPeriodDisplay(query: AnalyticsQuery) {
|
|
635
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
636
|
+
const ymd = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
637
|
+
const monthLabel = (dateStr?: string | null) => {
|
|
638
|
+
if (!dateStr) return 'Month to Date'
|
|
639
|
+
const [y, m] = String(dateStr).split('-')
|
|
640
|
+
const monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December']
|
|
641
|
+
const now = new Date()
|
|
642
|
+
if (Number(y) === now.getFullYear() && Number(m) === (now.getMonth() + 1)) return 'Month to Date'
|
|
643
|
+
return `${monthNames[Math.max(0, Math.min(11, Number(m) - 1))]} ${y}`
|
|
644
|
+
}
|
|
645
|
+
const yearLabel = (dateStr?: string | null) => {
|
|
646
|
+
if (!dateStr) return 'Year to Date'
|
|
647
|
+
const y = String(dateStr).slice(0, 4)
|
|
648
|
+
const now = new Date()
|
|
649
|
+
if (Number(y) === now.getFullYear()) return 'Year to Date'
|
|
650
|
+
return y
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
switch (query.period) {
|
|
654
|
+
case 'realtime':
|
|
655
|
+
return 'Realtime (30m)'
|
|
656
|
+
case 'day': {
|
|
657
|
+
const now = new Date()
|
|
658
|
+
if (!query.date) return 'Today'
|
|
659
|
+
const yest = new Date(now); yest.setDate(now.getDate() - 1)
|
|
660
|
+
if (query.date === ymd(now)) return 'Today'
|
|
661
|
+
if (query.date === ymd(yest)) return 'Yesterday'
|
|
662
|
+
return query.date
|
|
663
|
+
}
|
|
664
|
+
case '7d':
|
|
665
|
+
return 'Last 7 days'
|
|
666
|
+
case '28d':
|
|
667
|
+
return 'Last 28 days'
|
|
668
|
+
case '30d':
|
|
669
|
+
return 'Last 30 days'
|
|
670
|
+
case '91d':
|
|
671
|
+
return 'Last 91 days'
|
|
672
|
+
case 'month':
|
|
673
|
+
return monthLabel(query.date)
|
|
674
|
+
case 'year':
|
|
675
|
+
return yearLabel(query.date)
|
|
676
|
+
case '12mo':
|
|
677
|
+
return 'Last 12 Months'
|
|
678
|
+
case 'all':
|
|
679
|
+
return 'All time'
|
|
680
|
+
case 'custom': {
|
|
681
|
+
const from = (query as any).from as string | undefined
|
|
682
|
+
const to = (query as any).to as string | undefined
|
|
683
|
+
if (!from || !to) return 'Custom range'
|
|
684
|
+
|
|
685
|
+
const fromDate = new Date(String(from).slice(0, 10))
|
|
686
|
+
const toDate = new Date(String(to).slice(0, 10))
|
|
687
|
+
|
|
688
|
+
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
689
|
+
const fromMonth = monthNames[fromDate.getMonth()]
|
|
690
|
+
const toMonth = monthNames[toDate.getMonth()]
|
|
691
|
+
const fromDay = fromDate.getDate()
|
|
692
|
+
const toDay = toDate.getDate()
|
|
693
|
+
const fromYear = fromDate.getFullYear()
|
|
694
|
+
const toYear = toDate.getFullYear()
|
|
695
|
+
|
|
696
|
+
// Same year
|
|
697
|
+
if (fromYear === toYear) {
|
|
698
|
+
// Same month
|
|
699
|
+
if (fromMonth === toMonth) {
|
|
700
|
+
return `${fromMonth} ${fromDay}–${toDay}, ${fromYear}`
|
|
701
|
+
}
|
|
702
|
+
// Different months, same year
|
|
703
|
+
return `${fromMonth} ${fromDay}–${toMonth} ${toDay}, ${fromYear}`
|
|
704
|
+
}
|
|
705
|
+
// Different years
|
|
706
|
+
return `${fromMonth} ${fromDay}, ${fromYear}–${toMonth} ${toDay}, ${toYear}`
|
|
707
|
+
}
|
|
708
|
+
default:
|
|
709
|
+
return 'Period'
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function MenuRow({ label, hint, active, leftIcon, rightIcon }: { label: string; hint?: string; active?: boolean; leftIcon?: React.ReactNode; rightIcon?: React.ReactNode }) {
|
|
714
|
+
return (
|
|
715
|
+
<span className="flex w-full items-center justify-between">
|
|
716
|
+
<span className={`flex items-center ${active ? 'font-semibold text-primary' : ''}`}>{leftIcon}{label}</span>
|
|
717
|
+
{rightIcon ? rightIcon : hint ? (
|
|
718
|
+
<span className="rounded-md border border-foreground/20 px-1.5 py-0.5 text-[11px] text-foreground/60">{hint}</span>
|
|
719
|
+
) : null}
|
|
720
|
+
</span>
|
|
721
|
+
)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function isActiveDay(query: AnalyticsQuery, mode: 'current' | 'last') {
|
|
725
|
+
if (query.period !== 'day') return false
|
|
726
|
+
const now = new Date()
|
|
727
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
728
|
+
const ymd = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
729
|
+
if (mode === 'current') {
|
|
730
|
+
return !query.date || query.date === ymd(now)
|
|
731
|
+
}
|
|
732
|
+
const y = new Date(now); y.setDate(now.getDate() - 1)
|
|
733
|
+
return query.date === ymd(y)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function isActiveMonth(query: AnalyticsQuery, mode: 'current' | 'last') {
|
|
737
|
+
if (query.period !== 'month') return false
|
|
738
|
+
const now = new Date()
|
|
739
|
+
const target = new Date(now)
|
|
740
|
+
if (mode === 'last') target.setMonth(target.getMonth() - 1)
|
|
741
|
+
const y = target.getFullYear()
|
|
742
|
+
const m = target.getMonth() + 1
|
|
743
|
+
if (!query.date) {
|
|
744
|
+
// No date means current MTD
|
|
745
|
+
return mode === 'current'
|
|
746
|
+
}
|
|
747
|
+
const [qy, qm] = String(query.date).split('-').map((s) => Number(s))
|
|
748
|
+
return qy === y && qm === m
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function isActiveYear(query: AnalyticsQuery, mode: 'current' | 'last') {
|
|
752
|
+
if (query.period !== 'year') return false
|
|
753
|
+
const now = new Date()
|
|
754
|
+
const y = mode === 'last' ? now.getFullYear() - 1 : now.getFullYear()
|
|
755
|
+
if (!query.date) {
|
|
756
|
+
return mode === 'current'
|
|
757
|
+
}
|
|
758
|
+
const qy = Number(String(query.date).slice(0, 4))
|
|
759
|
+
return qy === y
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function applyPeriodSelection(current: AnalyticsQuery, option: { value: AnalyticsQuery['period']; setDate?: 'current' | 'last' }) {
|
|
763
|
+
const now = new Date()
|
|
764
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
765
|
+
const ymd = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
766
|
+
const setMonthDate = (mode: 'current' | 'last') => {
|
|
767
|
+
const d = new Date(now)
|
|
768
|
+
if (mode === 'last') {
|
|
769
|
+
d.setMonth(d.getMonth() - 1)
|
|
770
|
+
}
|
|
771
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-01`
|
|
772
|
+
}
|
|
773
|
+
const setYearDate = (mode: 'current' | 'last') => {
|
|
774
|
+
const y = mode === 'last' ? now.getFullYear() - 1 : now.getFullYear()
|
|
775
|
+
return `${y}-01-01`
|
|
776
|
+
}
|
|
777
|
+
let next: AnalyticsQuery = { ...current, period: option.value, from: null, to: null }
|
|
778
|
+
if (option.value === 'day' && option.setDate === 'last') {
|
|
779
|
+
const y = new Date(now); y.setDate(now.getDate() - 1)
|
|
780
|
+
next.date = ymd(y)
|
|
781
|
+
return next
|
|
782
|
+
}
|
|
783
|
+
if (option.value === 'month') {
|
|
784
|
+
next.date = setMonthDate(option.setDate ?? 'current')
|
|
785
|
+
return next
|
|
786
|
+
}
|
|
787
|
+
if (option.value === 'year') {
|
|
788
|
+
next.date = setYearDate(option.setDate ?? 'current')
|
|
789
|
+
return next
|
|
790
|
+
}
|
|
791
|
+
next.date = null
|
|
792
|
+
return next
|
|
793
|
+
}
|