@stacksjs/ts-analytics 0.1.6

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 (237) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +361 -0
  4. package/bin/cli.ts +169 -0
  5. package/dist/Analytics.d.ts +558 -0
  6. package/dist/api.d.ts +109 -0
  7. package/dist/batching.d.ts +93 -0
  8. package/dist/chunk-2mx7fq49.js +4 -0
  9. package/dist/chunk-3z29508k.js +204 -0
  10. package/dist/chunk-deephkz6.js +56 -0
  11. package/dist/chunk-j261vgyp.js +305 -0
  12. package/dist/chunk-xga17tz7.js +207 -0
  13. package/dist/config.d.ts +132 -0
  14. package/dist/dashboard/components/index.d.ts +80 -0
  15. package/dist/dashboard/composables/useAnalytics.d.ts +99 -0
  16. package/dist/dashboard/index.d.ts +110 -0
  17. package/dist/dashboard/types/index.d.ts +144 -0
  18. package/dist/dashboard/utils/index.d.ts +81 -0
  19. package/dist/dynamodb.d.ts +86 -0
  20. package/dist/funnels.d.ts +104 -0
  21. package/dist/geolocation.d.ts +96 -0
  22. package/dist/index.d.ts +371 -0
  23. package/dist/index.js +10211 -0
  24. package/dist/infrastructure/cdk.d.ts +60 -0
  25. package/dist/infrastructure/cloudformation.d.ts +44 -0
  26. package/dist/infrastructure/index.d.ts +45 -0
  27. package/dist/infrastructure/setup.d.ts +133 -0
  28. package/dist/integrations/cloudflare.d.ts +76 -0
  29. package/dist/integrations/hono.d.ts +60 -0
  30. package/dist/integrations/index.d.ts +24 -0
  31. package/dist/integrations/nuxt.d.ts +7 -0
  32. package/dist/integrations/nuxt.js +42 -0
  33. package/dist/integrations/runtime/use-ts-analytics.d.ts +18 -0
  34. package/dist/integrations/runtime/use-ts-analytics.js +17 -0
  35. package/dist/integrations/stx.d.ts +88 -0
  36. package/dist/integrations/stx.js +18 -0
  37. package/dist/lib/crypto-random.d.ts +4 -0
  38. package/dist/lib/salt.d.ts +16 -0
  39. package/dist/local.d.ts +56 -0
  40. package/dist/model-connector.d.ts +145 -0
  41. package/dist/models/AggregatedStats.d.ts +9 -0
  42. package/dist/models/CampaignStats.d.ts +9 -0
  43. package/dist/models/Conversion.d.ts +11 -0
  44. package/dist/models/CustomEvent.d.ts +11 -0
  45. package/dist/models/DeviceStats.d.ts +9 -0
  46. package/dist/models/EventStats.d.ts +9 -0
  47. package/dist/models/GeoStats.d.ts +9 -0
  48. package/dist/models/Goal.d.ts +9 -0
  49. package/dist/models/GoalStats.d.ts +9 -0
  50. package/dist/models/PageStats.d.ts +11 -0
  51. package/dist/models/PageView.d.ts +13 -0
  52. package/dist/models/RealtimeStats.d.ts +9 -0
  53. package/dist/models/ReferrerStats.d.ts +9 -0
  54. package/dist/models/Session.d.ts +11 -0
  55. package/dist/models/Site.d.ts +11 -0
  56. package/dist/models/index.d.ts +28 -0
  57. package/dist/models/types.d.ts +60 -0
  58. package/dist/sqs-buffering.d.ts +243 -0
  59. package/dist/stacks-integration.d.ts +159 -0
  60. package/dist/tracking-script.d.ts +71 -0
  61. package/dist/tracking.d.ts +17 -0
  62. package/dist/tracking.js +26 -0
  63. package/dist/types.d.ts +595 -0
  64. package/dist/utils/geolocation.d.ts +111 -0
  65. package/dist/utils/user-agent.d.ts +17 -0
  66. package/dist/version.d.ts +7 -0
  67. package/package.json +119 -0
  68. package/src/Analytics.ts +3349 -0
  69. package/src/api.ts +1286 -0
  70. package/src/assets/crosswind.css +2220 -0
  71. package/src/batching.ts +452 -0
  72. package/src/components/dashboard/index.ts +18 -0
  73. package/src/config.ts +456 -0
  74. package/src/dashboard/Dashboard.stx +1517 -0
  75. package/src/dashboard/components/AlertCard.stx +177 -0
  76. package/src/dashboard/components/AnalyticsDashboard.stx +354 -0
  77. package/src/dashboard/components/AnimatedNumber.stx +86 -0
  78. package/src/dashboard/components/BarChart.stx +220 -0
  79. package/src/dashboard/components/BrowserBreakdown.stx +98 -0
  80. package/src/dashboard/components/BrowsersTable.stx +125 -0
  81. package/src/dashboard/components/CampaignBreakdown.stx +163 -0
  82. package/src/dashboard/components/CampaignTable.stx +238 -0
  83. package/src/dashboard/components/CountryList.stx +101 -0
  84. package/src/dashboard/components/DataTable.stx +226 -0
  85. package/src/dashboard/components/DateRangePicker.stx +77 -0
  86. package/src/dashboard/components/DeviceBreakdown.stx +94 -0
  87. package/src/dashboard/components/DevicesTable.stx +163 -0
  88. package/src/dashboard/components/DonutChart.stories.ts +55 -0
  89. package/src/dashboard/components/DonutChart.stx +157 -0
  90. package/src/dashboard/components/EmptyState.stx +90 -0
  91. package/src/dashboard/components/EngagementMetrics.stx +183 -0
  92. package/src/dashboard/components/EventsSection.stx +192 -0
  93. package/src/dashboard/components/FilterBar.stx +104 -0
  94. package/src/dashboard/components/FullAnalyticsDashboard.stx +455 -0
  95. package/src/dashboard/components/FunnelChart.stx +142 -0
  96. package/src/dashboard/components/GeoTable.stx +337 -0
  97. package/src/dashboard/components/GoalsPanel.stx +109 -0
  98. package/src/dashboard/components/Header.stx +39 -0
  99. package/src/dashboard/components/HeatmapChart.stx +140 -0
  100. package/src/dashboard/components/LiveActivityFeed.stx +172 -0
  101. package/src/dashboard/components/MetricComparison.stx +106 -0
  102. package/src/dashboard/components/MiniStats.stx +96 -0
  103. package/src/dashboard/components/OSBreakdown.stx +124 -0
  104. package/src/dashboard/components/PageDetailCard.stx +102 -0
  105. package/src/dashboard/components/PagesTable.stx +127 -0
  106. package/src/dashboard/components/ProgressRing.stx +89 -0
  107. package/src/dashboard/components/RealtimeCounter.stories.ts +45 -0
  108. package/src/dashboard/components/RealtimeCounter.stx +46 -0
  109. package/src/dashboard/components/ReferrersTable.stx +180 -0
  110. package/src/dashboard/components/SparklineChart.stx +160 -0
  111. package/src/dashboard/components/StatCard.stories.ts +58 -0
  112. package/src/dashboard/components/StatCard.stx +90 -0
  113. package/src/dashboard/components/SummaryStats.stx +81 -0
  114. package/src/dashboard/components/TabNav.stx +66 -0
  115. package/src/dashboard/components/ThemeSwitcher.stx +124 -0
  116. package/src/dashboard/components/TimeSeriesChart.stx +106 -0
  117. package/src/dashboard/components/TopList.stories.ts +58 -0
  118. package/src/dashboard/components/TopList.stx +74 -0
  119. package/src/dashboard/components/TrendIndicator.stx +84 -0
  120. package/src/dashboard/components/heatmap/ClickHeatmap.stx +264 -0
  121. package/src/dashboard/components/heatmap/ElementClickList.stx +125 -0
  122. package/src/dashboard/components/heatmap/HeatmapControls.stx +125 -0
  123. package/src/dashboard/components/heatmap/HeatmapLegend.stx +69 -0
  124. package/src/dashboard/components/heatmap/PageHeatmap.stx +264 -0
  125. package/src/dashboard/components/heatmap/ScrollHeatmap.stx +127 -0
  126. package/src/dashboard/components/heatmap/index.ts +12 -0
  127. package/src/dashboard/components/index.ts +86 -0
  128. package/src/dashboard/composables/useAnalytics.ts +465 -0
  129. package/src/dashboard/demo/DemoApp.stx +370 -0
  130. package/src/dashboard/demo/index.ts +8 -0
  131. package/src/dashboard/demo/mockData.ts +234 -0
  132. package/src/dashboard/index.ts +117 -0
  133. package/src/dashboard/stx-shim.d.ts +4 -0
  134. package/src/dashboard/types/index.ts +203 -0
  135. package/src/dashboard/utils/index.ts +426 -0
  136. package/src/dynamodb.ts +344 -0
  137. package/src/funnels.ts +534 -0
  138. package/src/geolocation.ts +515 -0
  139. package/src/handlers/alerts.ts +328 -0
  140. package/src/handlers/annotations.ts +128 -0
  141. package/src/handlers/api-keys.ts +240 -0
  142. package/src/handlers/auth.ts +1020 -0
  143. package/src/handlers/authz.ts +137 -0
  144. package/src/handlers/collect.ts +724 -0
  145. package/src/handlers/data.ts +625 -0
  146. package/src/handlers/experiments.ts +138 -0
  147. package/src/handlers/funnels.ts +216 -0
  148. package/src/handlers/goals.ts +218 -0
  149. package/src/handlers/heatmaps.ts +272 -0
  150. package/src/handlers/index.ts +56 -0
  151. package/src/handlers/lib/read-cache.ts +63 -0
  152. package/src/handlers/misc.ts +486 -0
  153. package/src/handlers/oauth.ts +233 -0
  154. package/src/handlers/performance.ts +388 -0
  155. package/src/handlers/sessions.ts +413 -0
  156. package/src/handlers/sharing.ts +198 -0
  157. package/src/handlers/stats.ts +1368 -0
  158. package/src/handlers/team.ts +161 -0
  159. package/src/handlers/uptime.ts +283 -0
  160. package/src/handlers/views.ts +390 -0
  161. package/src/handlers/webhooks.ts +226 -0
  162. package/src/heatmap/index.ts +32 -0
  163. package/src/heatmap/tracking-script.ts +452 -0
  164. package/src/heatmap/types.ts +79 -0
  165. package/src/index.ts +387 -0
  166. package/src/infrastructure/cdk.ts +496 -0
  167. package/src/infrastructure/cloudformation.ts +595 -0
  168. package/src/infrastructure/index.ts +48 -0
  169. package/src/infrastructure/setup.ts +611 -0
  170. package/src/integrations/cloudflare.ts +732 -0
  171. package/src/integrations/hono.ts +589 -0
  172. package/src/integrations/index.ts +27 -0
  173. package/src/integrations/nuxt.ts +78 -0
  174. package/src/integrations/runtime/use-ts-analytics.ts +32 -0
  175. package/src/integrations/stx.ts +138 -0
  176. package/src/jobs/index.ts +127 -0
  177. package/src/lib/crypto-random.ts +41 -0
  178. package/src/lib/ddb-errors.ts +20 -0
  179. package/src/lib/dynamodb.ts +216 -0
  180. package/src/lib/email.ts +38 -0
  181. package/src/lib/ga-import.ts +471 -0
  182. package/src/lib/ga4-api.ts +244 -0
  183. package/src/lib/goals.ts +205 -0
  184. package/src/lib/index.ts +6 -0
  185. package/src/lib/ingest-counters.ts +123 -0
  186. package/src/lib/log.ts +36 -0
  187. package/src/lib/membership.ts +98 -0
  188. package/src/lib/plans.ts +90 -0
  189. package/src/lib/quota.ts +46 -0
  190. package/src/lib/rate-limit.ts +27 -0
  191. package/src/lib/rollups.ts +472 -0
  192. package/src/lib/salt.ts +97 -0
  193. package/src/lib/scheduler.ts +97 -0
  194. package/src/lib/significance.ts +66 -0
  195. package/src/lib/site-retention.ts +56 -0
  196. package/src/local.ts +360 -0
  197. package/src/model-connector.ts +616 -0
  198. package/src/models/AggregatedStats.ts +162 -0
  199. package/src/models/CampaignStats.ts +141 -0
  200. package/src/models/Conversion.ts +135 -0
  201. package/src/models/CustomEvent.ts +123 -0
  202. package/src/models/DeviceStats.ts +105 -0
  203. package/src/models/EventStats.ts +108 -0
  204. package/src/models/GeoStats.ts +112 -0
  205. package/src/models/Goal.ts +105 -0
  206. package/src/models/GoalStats.ts +106 -0
  207. package/src/models/PageStats.ts +152 -0
  208. package/src/models/PageView.ts +277 -0
  209. package/src/models/RealtimeStats.ts +96 -0
  210. package/src/models/ReferrerStats.ts +115 -0
  211. package/src/models/Session.ts +235 -0
  212. package/src/models/Site.ts +115 -0
  213. package/src/models/index.ts +50 -0
  214. package/src/models/orm/index.ts +2043 -0
  215. package/src/models/types.ts +71 -0
  216. package/src/router.ts +521 -0
  217. package/src/sqs-buffering.ts +806 -0
  218. package/src/stacks-integration.ts +527 -0
  219. package/src/tracking-script.ts +944 -0
  220. package/src/tracking.ts +27 -0
  221. package/src/types/analytics.ts +219 -0
  222. package/src/types/api.ts +75 -0
  223. package/src/types/bun-router.d.ts +15 -0
  224. package/src/types/dashboard.ts +62 -0
  225. package/src/types/index.ts +66 -0
  226. package/src/types/stx.d.ts +27 -0
  227. package/src/types/window.d.ts +101 -0
  228. package/src/types.ts +911 -0
  229. package/src/utils/cache.ts +148 -0
  230. package/src/utils/date.ts +118 -0
  231. package/src/utils/filters.ts +71 -0
  232. package/src/utils/geolocation.ts +323 -0
  233. package/src/utils/index.ts +9 -0
  234. package/src/utils/response.ts +180 -0
  235. package/src/utils/timezone-country.ts +242 -0
  236. package/src/utils/user-agent.ts +110 -0
  237. package/src/version.ts +7 -0
@@ -0,0 +1,471 @@
1
+ /**
2
+ * GA4 CSV importer — Phase A of #155.
3
+ *
4
+ * Migrating users start from zero history; this backfills it WITHOUT
5
+ * fabricating raw events: GA4 exports are daily aggregates by dimension,
6
+ * which map 1:1 onto the rollup store the dashboard already reads for
7
+ * settled days (#172) — ROLLUP#DAY (scalars) + ROLLUP#DIMS (breakdowns).
8
+ * Even the uniques semantics line up: GA reports daily users and our
9
+ * invariant is "uniques = sum of daily uniques".
10
+ *
11
+ * Input: one or more CSVs exported from GA4 (Explorations or the report UI
12
+ * with a Date dimension added; UA exports with the same columns also work).
13
+ * Files are auto-detected by header:
14
+ * - traffic: Date + Views/Users/Sessions (+ optional bounce/duration/events)
15
+ * - pages: Date + Page path + Views (+ Users)
16
+ * - sources: Date + Session source|Channel group + Users (+ Views|Sessions)
17
+ * - devices/browsers/os/countries/regions/cities: Date + that dimension + Users
18
+ * - events: Date + Event name + Event count (+ Users)
19
+ *
20
+ * Import policy (honest + idempotent):
21
+ * - A day that already has a REAL (tracked) rollup is never overwritten;
22
+ * re-importing over previous imports is fine (items carry source:'ga-import').
23
+ * - Incomplete days (today onward) are skipped.
24
+ * - The imported span is zero-filled through yesterday so the dashboard's
25
+ * contiguous-settled-prefix reads (#172) never die at a gap between the
26
+ * end of GA history and the start of live tracking.
27
+ */
28
+ import { dynamodb, marshall, TABLE_NAME, unmarshall } from './dynamodb'
29
+
30
+ export interface GaImportFileResult {
31
+ name: string
32
+ kind: string
33
+ rows: number
34
+ days: number
35
+ }
36
+
37
+ export interface GaImportResult {
38
+ files: GaImportFileResult[]
39
+ daysWritten: number
40
+ daysSkipped: string[]
41
+ zeroFilled: number
42
+ span: { start: string, end: string } | null
43
+ }
44
+
45
+ /** Minimal CSV parser: quoted fields, embedded commas/quotes, CRLF, BOM, and GA's leading # comment lines. */
46
+ export function parseCsv(text: string): string[][] {
47
+ const src = text.replace(/^/, '')
48
+ const rows: string[][] = []
49
+ let row: string[] = []
50
+ let field = ''
51
+ let inQuotes = false
52
+ for (let i = 0; i < src.length; i++) {
53
+ const c = src[i]
54
+ if (inQuotes) {
55
+ if (c === '"') {
56
+ if (src[i + 1] === '"') {
57
+ field += '"'
58
+ i++
59
+ }
60
+ else {
61
+ inQuotes = false
62
+ }
63
+ }
64
+ else {
65
+ field += c
66
+ }
67
+ continue
68
+ }
69
+ if (c === '"') {
70
+ inQuotes = true
71
+ }
72
+ else if (c === ',') {
73
+ row.push(field)
74
+ field = ''
75
+ }
76
+ else if (c === '\n' || c === '\r') {
77
+ if (c === '\r' && src[i + 1] === '\n')
78
+ i++
79
+ row.push(field)
80
+ field = ''
81
+ if (row.length > 1 || row[0] !== '')
82
+ rows.push(row)
83
+ row = []
84
+ }
85
+ else {
86
+ field += c
87
+ }
88
+ }
89
+ if (field !== '' || row.length > 0) {
90
+ row.push(field)
91
+ if (row.length > 1 || row[0] !== '')
92
+ rows.push(row)
93
+ }
94
+ // GA4 prepends metadata lines starting with '#' — drop them.
95
+ return rows.filter(r => !String(r[0] || '').trimStart().startsWith('#'))
96
+ }
97
+
98
+ /** Normalize a header for matching: lowercase, alphanumerics only. */
99
+ function norm(h: string): string {
100
+ return h.toLowerCase().replace(/[^a-z0-9]/g, '')
101
+ }
102
+
103
+ /** Column aliases (GA4 + UA English exports + our canonical names). */
104
+ const COLS: Record<string, string[]> = {
105
+ date: ['date', 'day'],
106
+ views: ['views', 'screenpageviews', 'pageviews', 'viewsperpage'],
107
+ visitors: ['activeusers', 'totalusers', 'users', 'visitors', 'people'],
108
+ sessions: ['sessions', 'visits'],
109
+ bounceRate: ['bouncerate'],
110
+ engagementRate: ['engagementrate'],
111
+ avgSessionSeconds: ['averagesessionduration', 'avgsessionduration'],
112
+ eventCount: ['eventcount', 'events', 'totalevents'],
113
+ page: ['pagepathandscreenclass', 'pagepath', 'pagepathscreenclass', 'landingpage', 'page'],
114
+ source: ['sessionsource', 'firstusersource', 'sessiondefaultchannelgroup', 'sessiondefaultchannelgrouping', 'defaultchannelgroup', 'source', 'referrersource'],
115
+ device: ['devicecategory', 'device'],
116
+ browser: ['browser'],
117
+ os: ['operatingsystem', 'os'],
118
+ country: ['country'],
119
+ region: ['region'],
120
+ city: ['city', 'town'],
121
+ eventName: ['eventname'],
122
+ }
123
+
124
+ function findCol(headers: string[], key: string): number {
125
+ const wanted = COLS[key]
126
+ for (let i = 0; i < headers.length; i++) {
127
+ if (wanted.includes(norm(headers[i])))
128
+ return i
129
+ }
130
+ return -1
131
+ }
132
+
133
+ /** '20260101', '2026-01-01', or '01/01/2026' (UA) → '2026-01-01' (or null). */
134
+ export function parseGaDate(value: string): string | null {
135
+ const v = value.trim()
136
+ let m = v.match(/^(\d{4})(\d{2})(\d{2})$/)
137
+ if (m)
138
+ return `${m[1]}-${m[2]}-${m[3]}`
139
+ m = v.match(/^(\d{4})-(\d{2})-(\d{2})$/)
140
+ if (m)
141
+ return v
142
+ m = v.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/)
143
+ if (m)
144
+ return `${m[3]}-${m[1].padStart(2, '0')}-${m[2].padStart(2, '0')}`
145
+ return null
146
+ }
147
+
148
+ function num(value: string | undefined): number {
149
+ if (value === undefined)
150
+ return 0
151
+ const v = value.replace(/[%,\s]/g, '').replace(/^</, '')
152
+ const n = Number(v)
153
+ return Number.isFinite(n) ? n : 0
154
+ }
155
+
156
+ /** Rate cells come as '0.42', '42%', or '42.0' — normalize to 0..1. */
157
+ function rate(value: string | undefined): number {
158
+ const n = num(value)
159
+ return n > 1 ? n / 100 : n
160
+ }
161
+
162
+ export type GaFileKind
163
+ = 'traffic' | 'pages' | 'sources' | 'devices' | 'browsers' | 'os'
164
+ | 'countries' | 'regions' | 'cities' | 'events' | 'unknown'
165
+
166
+ export function detectKind(headers: string[]): GaFileKind {
167
+ if (findCol(headers, 'date') === -1)
168
+ return 'unknown'
169
+ if (findCol(headers, 'page') !== -1)
170
+ return 'pages'
171
+ if (findCol(headers, 'eventName') !== -1)
172
+ return 'events'
173
+ if (findCol(headers, 'source') !== -1)
174
+ return 'sources'
175
+ if (findCol(headers, 'city') !== -1)
176
+ return 'cities'
177
+ if (findCol(headers, 'region') !== -1)
178
+ return 'regions'
179
+ if (findCol(headers, 'country') !== -1)
180
+ return 'countries'
181
+ if (findCol(headers, 'browser') !== -1)
182
+ return 'browsers'
183
+ if (findCol(headers, 'os') !== -1)
184
+ return 'os'
185
+ if (findCol(headers, 'device') !== -1)
186
+ return 'devices'
187
+ if (findCol(headers, 'views') !== -1 || findCol(headers, 'visitors') !== -1 || findCol(headers, 'sessions') !== -1)
188
+ return 'traffic'
189
+ return 'unknown'
190
+ }
191
+
192
+ export interface DayAccumulator {
193
+ scalars: { views: number, visitors: number, sessions: number, bounces: number, totalDuration: number, events: number }
194
+ hasTraffic: boolean
195
+ pages: Record<string, { w: number, v: number, e: number }>
196
+ sources: Record<string, { v: number, w: number }>
197
+ devices: Record<string, { v: number }>
198
+ browsers: Record<string, { v: number }>
199
+ os: Record<string, { v: number }>
200
+ countries: Record<string, { v: number }>
201
+ regions: Record<string, { v: number }>
202
+ cities: Record<string, { v: number }>
203
+ events: Record<string, { c: number, v: number, val: number }>
204
+ }
205
+
206
+ export function dayAcc(days: Map<string, DayAccumulator>, day: string): DayAccumulator {
207
+ let acc = days.get(day)
208
+ if (!acc) {
209
+ acc = {
210
+ scalars: { views: 0, visitors: 0, sessions: 0, bounces: 0, totalDuration: 0, events: 0 },
211
+ hasTraffic: false,
212
+ pages: {},
213
+ sources: {},
214
+ devices: {},
215
+ browsers: {},
216
+ os: {},
217
+ countries: {},
218
+ regions: {},
219
+ cities: {},
220
+ events: {},
221
+ }
222
+ days.set(day, acc)
223
+ }
224
+ return acc
225
+ }
226
+
227
+ /**
228
+ * Parse the provided files into per-day rollup data. Pure — unit-testable
229
+ * without DynamoDB.
230
+ */
231
+ export function buildDailyData(files: Array<{ name: string, content: string }>): { days: Map<string, DayAccumulator>, results: GaImportFileResult[] } {
232
+ const days = new Map<string, DayAccumulator>()
233
+ const results: GaImportFileResult[] = []
234
+
235
+ for (const file of files) {
236
+ const rows = parseCsv(file.content)
237
+ if (rows.length < 2) {
238
+ results.push({ name: file.name, kind: 'unknown', rows: 0, days: 0 })
239
+ continue
240
+ }
241
+ const headers = rows[0]
242
+ const kind = detectKind(headers)
243
+ const dateCol = findCol(headers, 'date')
244
+ const daysSeen = new Set<string>()
245
+ let parsed = 0
246
+ if (kind === 'unknown') {
247
+ results.push({ name: file.name, kind, rows: 0, days: 0 })
248
+ continue
249
+ }
250
+
251
+ const col = (key: string): number => findCol(headers, key)
252
+ const cViews = col('views')
253
+ const cVisitors = col('visitors')
254
+ const cSessions = col('sessions')
255
+ const cBounce = col('bounceRate')
256
+ const cEngage = col('engagementRate')
257
+ const cAvgDur = col('avgSessionSeconds')
258
+ const cEvents = col('eventCount')
259
+
260
+ for (const row of rows.slice(1)) {
261
+ const day = parseGaDate(String(row[dateCol] ?? ''))
262
+ if (!day)
263
+ continue // Totals/blank rows GA appends
264
+ const acc = dayAcc(days, day)
265
+ daysSeen.add(day)
266
+ parsed++
267
+ const views = num(row[cViews])
268
+ const visitors = num(row[cVisitors])
269
+ const sessions = num(row[cSessions])
270
+
271
+ if (kind === 'traffic') {
272
+ acc.hasTraffic = true
273
+ acc.scalars.views += views
274
+ acc.scalars.visitors += visitors
275
+ acc.scalars.sessions += sessions
276
+ acc.scalars.events += num(row[cEvents])
277
+ const sess = sessions || visitors
278
+ if (cBounce !== -1)
279
+ acc.scalars.bounces += Math.round(sess * rate(row[cBounce]))
280
+ else if (cEngage !== -1)
281
+ acc.scalars.bounces += Math.round(sess * (1 - rate(row[cEngage])))
282
+ if (cAvgDur !== -1)
283
+ acc.scalars.totalDuration += Math.round(sess * num(row[cAvgDur]) * 1000)
284
+ }
285
+ else if (kind === 'pages') {
286
+ const path = String(row[col('page')] || '').trim() || '/'
287
+ const cell = (acc.pages[path] ||= { w: 0, v: 0, e: 0 })
288
+ cell.w += views || visitors
289
+ cell.v += visitors
290
+ }
291
+ else if (kind === 'sources') {
292
+ const raw = String(row[col('source')] || '').trim()
293
+ const source = !raw || /^\(direct\)$|^direct$|^\(none\)$/i.test(raw) ? 'Direct' : raw
294
+ const cell = (acc.sources[source] ||= { v: 0, w: 0 })
295
+ cell.v += visitors || sessions
296
+ cell.w += views || sessions || visitors
297
+ }
298
+ else if (kind === 'events') {
299
+ const name = String(row[col('eventName')] || '').trim() || 'unknown'
300
+ const cell = (acc.events[name] ||= { c: 0, v: 0, val: 0 })
301
+ cell.c += num(row[cEvents]) || views || 1
302
+ cell.v += visitors
303
+ }
304
+ else if (kind === 'regions') {
305
+ const region = String(row[col('region')] || '').trim() || 'Unknown'
306
+ const country = String(row[col('country')] || '').trim() || 'Unknown'
307
+ const key = `${country}:${region}`
308
+ const cell = (acc.regions[key] ||= { v: 0 })
309
+ cell.v += visitors || sessions
310
+ }
311
+ else if (kind === 'cities') {
312
+ const city = String(row[col('city')] || '').trim() || 'Unknown'
313
+ const region = String(row[col('region')] || '').trim() || 'Unknown'
314
+ const country = String(row[col('country')] || '').trim() || 'Unknown'
315
+ const key = `${country}:${region}:${city}`
316
+ const cell = (acc.cities[key] ||= { v: 0 })
317
+ cell.v += visitors || sessions
318
+ }
319
+ else {
320
+ // Single-key visitor dimensions: devices/browsers/os/countries.
321
+ const colKey = kind === 'devices' ? 'device' : kind === 'browsers' ? 'browser' : kind === 'os' ? 'os' : 'country'
322
+ let value = String(row[col(colKey)] || '').trim()
323
+ if (!value)
324
+ continue
325
+ if (kind === 'devices')
326
+ value = value.toLowerCase()
327
+ const cell = ((acc as any)[kind][value] ||= { v: 0 })
328
+ cell.v += visitors || sessions
329
+ }
330
+ }
331
+ results.push({ name: file.name, kind, rows: parsed, days: daysSeen.size })
332
+ }
333
+ return { days, results }
334
+ }
335
+
336
+ /**
337
+ * When no traffic file was provided, derive day scalars from the dimension
338
+ * files (documented approximation): views from pages, visitors/sessions from
339
+ * sources (each session has exactly one source, so per-source user counts sum
340
+ * to ~daily users — the same semantics our own rollups use).
341
+ */
342
+ function deriveScalars(acc: DayAccumulator): void {
343
+ if (acc.hasTraffic)
344
+ return
345
+ const pageViews = Object.values(acc.pages).reduce((sum, c) => sum + c.w, 0)
346
+ const sourceVisitors = Object.values(acc.sources).reduce((sum, c) => sum + c.v, 0)
347
+ const dimVisitors = Object.values(acc.devices).reduce((sum, c) => sum + c.v, 0)
348
+ acc.scalars.views = pageViews
349
+ acc.scalars.visitors = sourceVisitors || dimVisitors
350
+ acc.scalars.sessions = sourceVisitors || dimVisitors
351
+ acc.scalars.events = Object.values(acc.events).reduce((sum, c) => sum + c.c, 0)
352
+ }
353
+
354
+ /** Write the parsed CSV files into the rollup store. */
355
+ export async function importGaData(siteId: string, files: Array<{ name: string, content: string }>): Promise<GaImportResult> {
356
+ const { days, results } = buildDailyData(files)
357
+ const write = await writeImportedDays(siteId, days)
358
+ return { files: results, ...write }
359
+ }
360
+
361
+ /**
362
+ * Shared write phase for both import paths (CSV + Data API): collision policy,
363
+ * zero-fill, and batched rollup writes.
364
+ */
365
+ export async function writeImportedDays(siteId: string, days: Map<string, DayAccumulator>): Promise<Omit<GaImportResult, 'files'>> {
366
+ const today = new Date().toISOString().slice(0, 10)
367
+ const importedDays = [...days.keys()].filter(d => d < today).sort()
368
+ if (importedDays.length === 0)
369
+ return { daysWritten: 0, daysSkipped: [], zeroFilled: 0, span: null }
370
+
371
+ // Zero-fill from the span start through YESTERDAY so contiguous-prefix
372
+ // reads never break between GA history and live tracking (#172).
373
+ const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
374
+ const allDays: string[] = []
375
+ const cursor = new Date(`${importedDays[0]}T00:00:00.000Z`)
376
+ while (cursor.toISOString().slice(0, 10) <= yesterday) {
377
+ allDays.push(cursor.toISOString().slice(0, 10))
378
+ cursor.setUTCDate(cursor.getUTCDate() + 1)
379
+ }
380
+
381
+ // Never overwrite a day that has a REAL (tracked) rollup.
382
+ const existingReal = new Set<string>()
383
+ {
384
+ const res = await import('./dynamodb').then(m => m.queryAllItems({
385
+ TableName: TABLE_NAME,
386
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
387
+ ExpressionAttributeValues: {
388
+ ':pk': { S: `SITE#${siteId}` },
389
+ ':start': { S: `ROLLUP#DAY#${allDays[0]}` },
390
+ ':end': { S: `ROLLUP#DAY#${allDays[allDays.length - 1]}~` },
391
+ },
392
+ }))
393
+ for (const raw of (res.Items || [])) {
394
+ const item = unmarshall(raw)
395
+ if (item.source !== 'ga-import' && item.day)
396
+ existingReal.add(item.day)
397
+ }
398
+ }
399
+
400
+ let daysWritten = 0
401
+ let zeroFilled = 0
402
+ const daysSkipped: string[] = []
403
+ const empty = (): DayAccumulator => dayAcc(new Map(), 'x')
404
+
405
+ // Batched writes (25/request — the BatchWriteItem cap): a multi-year import
406
+ // is ~1,500 items, which as sequential puts would blow API-gateway timeouts.
407
+ const pending: Record<string, unknown>[] = []
408
+ const now = new Date().toISOString()
409
+ for (const day of allDays) {
410
+ if (existingReal.has(day)) {
411
+ if (days.has(day))
412
+ daysSkipped.push(day)
413
+ continue
414
+ }
415
+ const acc = days.get(day) ?? empty()
416
+ deriveScalars(acc)
417
+ pending.push({
418
+ pk: `SITE#${siteId}`,
419
+ sk: `ROLLUP#DAY#${day}`,
420
+ day,
421
+ ...acc.scalars,
422
+ source: 'ga-import',
423
+ computedAt: now,
424
+ })
425
+ pending.push({
426
+ pk: `SITE#${siteId}`,
427
+ sk: `ROLLUP#DIMS#${day}`,
428
+ day,
429
+ pages: acc.pages,
430
+ sources: acc.sources,
431
+ devices: acc.devices,
432
+ browsers: acc.browsers,
433
+ os: acc.os,
434
+ countries: acc.countries,
435
+ regions: acc.regions,
436
+ cities: acc.cities,
437
+ campaigns: {},
438
+ events: acc.events,
439
+ entries: {},
440
+ exits: {},
441
+ source: 'ga-import',
442
+ computedAt: now,
443
+ })
444
+ if (days.has(day))
445
+ daysWritten++
446
+ else zeroFilled++
447
+ }
448
+
449
+ for (let i = 0; i < pending.length; i += 25) {
450
+ const batch = pending.slice(i, i + 25)
451
+ let unprocessed = batch.map(item => ({ PutRequest: { Item: marshall(item) } }))
452
+ // Retry unprocessed items (throughput spikes) a few times before failing.
453
+ for (let attempt = 0; unprocessed.length > 0 && attempt < 5; attempt++) {
454
+ const res = await dynamodb.batchWriteItem({
455
+ RequestItems: { [TABLE_NAME]: unprocessed },
456
+ }) as { UnprocessedItems?: Record<string, any[]> }
457
+ unprocessed = res.UnprocessedItems?.[TABLE_NAME] || []
458
+ if (unprocessed.length > 0)
459
+ await new Promise(resolve => setTimeout(resolve, 200 * (attempt + 1)))
460
+ }
461
+ if (unprocessed.length > 0)
462
+ throw new Error(`Import incomplete: ${unprocessed.length} items unwritten after retries`)
463
+ }
464
+
465
+ return {
466
+ daysWritten,
467
+ daysSkipped,
468
+ zeroFilled,
469
+ span: { start: allDays[0], end: allDays[allDays.length - 1] },
470
+ }
471
+ }