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,242 @@
1
+ // Central helpers for building and parsing analytics dialog deep-links
2
+ import { analyticsBasePath } from './base-path'
3
+
4
+ export type SourcesMode = 'channels' | 'all' | 'utm-medium' | 'utm-source' | 'utm-campaign' | 'utm-content' | 'utm-term'
5
+
6
+ export type DialogSegment =
7
+ | 'channels'
8
+ | 'sources'
9
+ | 'utm_mediums'
10
+ | 'utm_sources'
11
+ | 'utm_campaigns'
12
+ | 'utm_contents'
13
+ | 'utm_terms'
14
+ | 'pages'
15
+ | 'entry-pages'
16
+ | 'exit-pages'
17
+ | 'browsers'
18
+ | 'operating-systems'
19
+ | 'screen-sizes'
20
+ | 'countries'
21
+ | 'regions'
22
+ | 'cities'
23
+ | 'devices'
24
+ | 'locations'
25
+ | 'behaviors'
26
+
27
+ const MODE_TO_SEGMENT: Record<SourcesMode, DialogSegment> = {
28
+ channels: 'channels',
29
+ all: 'sources',
30
+ 'utm-medium': 'utm_mediums',
31
+ 'utm-source': 'utm_sources',
32
+ 'utm-campaign': 'utm_campaigns',
33
+ 'utm-content': 'utm_contents',
34
+ 'utm-term': 'utm_terms'
35
+ }
36
+
37
+ // Accepts underscore/hyphen and singular/plural variants
38
+ const SEGMENT_NORMALIZE: Record<string, DialogSegment> = {
39
+ channels: 'channels',
40
+ sources: 'sources',
41
+ // mediums
42
+ 'utm-medium': 'utm_mediums',
43
+ utm_medium: 'utm_mediums',
44
+ 'utm-mediums': 'utm_mediums',
45
+ utm_mediums: 'utm_mediums',
46
+ // sources
47
+ 'utm-source': 'utm_sources',
48
+ utm_source: 'utm_sources',
49
+ 'utm-sources': 'utm_sources',
50
+ utm_sources: 'utm_sources',
51
+ // campaigns
52
+ 'utm-campaign': 'utm_campaigns',
53
+ utm_campaign: 'utm_campaigns',
54
+ 'utm-campaigns': 'utm_campaigns',
55
+ utm_campaigns: 'utm_campaigns',
56
+ // contents
57
+ 'utm-content': 'utm_contents',
58
+ utm_content: 'utm_contents',
59
+ 'utm-contents': 'utm_contents',
60
+ utm_contents: 'utm_contents',
61
+ // terms
62
+ 'utm-term': 'utm_terms',
63
+ utm_term: 'utm_terms',
64
+ 'utm-terms': 'utm_terms',
65
+ utm_terms: 'utm_terms',
66
+ // other panels
67
+ pages: 'pages',
68
+ 'entry-pages': 'entry-pages',
69
+ entry_pages: 'entry-pages',
70
+ entry: 'entry-pages',
71
+ 'exit-pages': 'exit-pages',
72
+ exit_pages: 'exit-pages',
73
+ exit: 'exit-pages',
74
+ // devices
75
+ browsers: 'browsers',
76
+ 'operating-systems': 'operating-systems',
77
+ operating_systems: 'operating-systems',
78
+ 'screen-sizes': 'screen-sizes',
79
+ screen_sizes: 'screen-sizes',
80
+ // locations
81
+ countries: 'countries',
82
+ regions: 'regions',
83
+ cities: 'cities',
84
+ devices: 'devices',
85
+ locations: 'locations',
86
+ behaviors: 'behaviors'
87
+ }
88
+
89
+ export function dialogSegmentForMode(mode: SourcesMode): DialogSegment {
90
+ return MODE_TO_SEGMENT[mode] || 'sources'
91
+ }
92
+
93
+ export type ParsedDialog =
94
+ | { type: 'segment'; segment: DialogSegment }
95
+ | { type: 'referrers'; source: string }
96
+ | { type: 'none' }
97
+
98
+ export function parseDialogFromPath(pathname: string): ParsedDialog {
99
+ const base = analyticsBasePath()
100
+ const refPrefix = `${base}/_/referrers/`
101
+ if (pathname.startsWith(refPrefix)) {
102
+ const source = pathname.slice(refPrefix.length)
103
+ if (source) {
104
+ try {
105
+ return { type: 'referrers', source: decodeURIComponent(source) }
106
+ } catch {
107
+ return { type: 'referrers', source }
108
+ }
109
+ }
110
+ }
111
+
112
+ const segmentPrefix = `${base}/_/`
113
+ if (pathname.startsWith(segmentPrefix)) {
114
+ const raw = pathname.slice(segmentPrefix.length)
115
+ const seg = SEGMENT_NORMALIZE[raw]
116
+ if (seg) return { type: 'segment', segment: seg }
117
+ }
118
+
119
+ return { type: 'none' }
120
+ }
121
+
122
+ export function buildDialogPath(segment: DialogSegment, qs: string = ''): string {
123
+ const base = `${analyticsBasePath()}/_/${segment}`
124
+ return qs ? `${base}?${qs}` : base
125
+ }
126
+
127
+ export function buildReferrersPath(source: string, qs: string = ''): string {
128
+ const base = `${analyticsBasePath()}/_/referrers/${encodeURIComponent(source)}`
129
+ return qs ? `${base}?${qs}` : base
130
+ }
131
+
132
+ export function baseAnalyticsPath(qs: string = ''): string {
133
+ const base = analyticsBasePath()
134
+ return qs ? `${base}?${qs}` : base
135
+ }
136
+
137
+ // Map dialog segment back to the Sources panel mode
138
+ export function modeForSegment(segment: DialogSegment): SourcesMode | null {
139
+ switch (segment) {
140
+ case 'channels':
141
+ return 'channels'
142
+ case 'sources':
143
+ return 'all'
144
+ case 'utm_mediums':
145
+ return 'utm-medium'
146
+ case 'utm_sources':
147
+ return 'utm-source'
148
+ case 'utm_campaigns':
149
+ return 'utm-campaign'
150
+ case 'utm_contents':
151
+ return 'utm-content'
152
+ case 'utm_terms':
153
+ return 'utm-term'
154
+ default:
155
+ return null
156
+ }
157
+ }
158
+
159
+ // Pages mapping helpers
160
+ export type PagesMode = 'pages' | 'entry' | 'exit'
161
+
162
+ export function pagesSegmentForMode(mode: PagesMode): DialogSegment {
163
+ switch (mode) {
164
+ case 'entry':
165
+ return 'entry-pages'
166
+ case 'exit':
167
+ return 'exit-pages'
168
+ case 'pages':
169
+ default:
170
+ return 'pages'
171
+ }
172
+ }
173
+
174
+ export function pagesModeForSegment(segment: DialogSegment): PagesMode | null {
175
+ switch (segment) {
176
+ case 'pages':
177
+ return 'pages'
178
+ case 'entry-pages':
179
+ return 'entry'
180
+ case 'exit-pages':
181
+ return 'exit'
182
+ default:
183
+ return null
184
+ }
185
+ }
186
+
187
+ // Devices mapping helpers
188
+ export type DevicesMode = 'browsers' | 'operating-systems' | 'screen-sizes'
189
+
190
+ export function devicesSegmentForMode(mode: DevicesMode): DialogSegment {
191
+ switch (mode) {
192
+ case 'operating-systems':
193
+ return 'operating-systems'
194
+ case 'screen-sizes':
195
+ return 'screen-sizes'
196
+ case 'browsers':
197
+ default:
198
+ return 'browsers'
199
+ }
200
+ }
201
+
202
+ export function devicesModeForSegment(segment: DialogSegment): DevicesMode | null {
203
+ switch (segment) {
204
+ case 'browsers':
205
+ return 'browsers'
206
+ case 'operating-systems':
207
+ return 'operating-systems'
208
+ case 'screen-sizes':
209
+ return 'screen-sizes'
210
+ default:
211
+ return null
212
+ }
213
+ }
214
+
215
+ // Locations mapping helpers
216
+ export type LocationsMode = 'map' | 'countries' | 'regions' | 'cities'
217
+
218
+ export function locationsSegmentForMode(mode: LocationsMode): DialogSegment {
219
+ switch (mode) {
220
+ case 'regions':
221
+ return 'regions'
222
+ case 'cities':
223
+ return 'cities'
224
+ case 'countries':
225
+ case 'map':
226
+ default:
227
+ return 'countries' // default dialog for locations
228
+ }
229
+ }
230
+
231
+ export function locationsModeForSegment(segment: DialogSegment): Exclude<LocationsMode, 'map'> | null {
232
+ switch (segment) {
233
+ case 'countries':
234
+ return 'countries'
235
+ case 'regions':
236
+ return 'regions'
237
+ case 'cities':
238
+ return 'cities'
239
+ default:
240
+ return null
241
+ }
242
+ }
@@ -0,0 +1,100 @@
1
+ // Number formatting utilities following Plausible's exact implementation
2
+ // Source: plausible/assets/js/dashboard/util/number-formatter.ts
3
+
4
+ const THOUSAND = 1000
5
+ const HUNDRED_THOUSAND = 100000
6
+ const MILLION = 1000000
7
+ const HUNDRED_MILLION = 100000000
8
+ const BILLION = 1000000000
9
+ const HUNDRED_BILLION = 100000000000
10
+ const TRILLION = 1000000000000
11
+
12
+ const numberFormat = Intl.NumberFormat('en-US')
13
+
14
+ /**
15
+ * Formats numbers in short form with k/M/B suffixes.
16
+ * Examples: 1234 → "1.2k", 1234567 → "1.2M"
17
+ */
18
+ export function numberShortFormatter(num: number): string {
19
+ if (num >= THOUSAND && num < MILLION) {
20
+ const thousands = num / THOUSAND
21
+ if (thousands === Math.floor(thousands) || num >= HUNDRED_THOUSAND) {
22
+ return Math.floor(thousands) + 'k'
23
+ } else {
24
+ return Math.floor(thousands * 10) / 10 + 'k'
25
+ }
26
+ } else if (num >= MILLION && num < BILLION) {
27
+ const millions = num / MILLION
28
+ if (millions === Math.floor(millions) || num >= HUNDRED_MILLION) {
29
+ return Math.floor(millions) + 'M'
30
+ } else {
31
+ return Math.floor(millions * 10) / 10 + 'M'
32
+ }
33
+ } else if (num >= BILLION && num < TRILLION) {
34
+ const billions = num / BILLION
35
+ if (billions === Math.floor(billions) || num >= HUNDRED_BILLION) {
36
+ return Math.floor(billions) + 'B'
37
+ } else {
38
+ return Math.floor(billions * 10) / 10 + 'B'
39
+ }
40
+ } else {
41
+ return num.toString()
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Formats numbers with thousand separators.
47
+ * Examples: 1234 → "1,234", 1234567 → "1,234,567"
48
+ */
49
+ export function numberLongFormatter(num: number): string {
50
+ return numberFormat.format(num)
51
+ }
52
+
53
+ /**
54
+ * Wraps a formatter to handle null and undefined values.
55
+ * Returns "-" for null or undefined, otherwise applies the formatter.
56
+ */
57
+ export function nullable<T>(
58
+ formatter: (num: T) => string
59
+ ): (num: T | null | undefined) => string {
60
+ return (num: T | null | undefined): string => {
61
+ if (num == null) {
62
+ return '-'
63
+ }
64
+ return formatter(num)
65
+ }
66
+ }
67
+
68
+ function pad(num: number, size: number): string {
69
+ return ('000' + num).slice(size * -1)
70
+ }
71
+
72
+ /**
73
+ * Formats duration in seconds to human-readable format.
74
+ * Examples: 65 → "1m 05s", 3665 → "1h 1m 5s"
75
+ */
76
+ export function durationFormatter(duration: number): string {
77
+ const hours = Math.floor(duration / 60 / 60)
78
+ const minutes = Math.floor(duration / 60) % 60
79
+ const seconds = Math.floor(duration - minutes * 60 - hours * 60 * 60)
80
+ if (hours > 0) {
81
+ return `${hours}h ${minutes}m ${seconds}s`
82
+ } else if (minutes > 0) {
83
+ return `${minutes}m ${pad(seconds, 2)}s`
84
+ } else {
85
+ return `${seconds}s`
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Formats percentage values.
91
+ * Backend returns decimals (0.45), we convert to whole numbers (45%)
92
+ * Example: 0.45 → "45%", 0.123 → "12.3%"
93
+ */
94
+ export function percentageFormatter(value: number | null): string {
95
+ if (value == null || Number.isNaN(value as number)) return '-'
96
+ const num = Number(value)
97
+ // Accept both decimal fractions (0.0645) and percent values (6.45)
98
+ const pct = num <= 1 ? (Math.round(num * 1000) / 10) : (Math.round(num * 10) / 10)
99
+ return `${pct}%`
100
+ }