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