@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,1368 @@
1
+ /**
2
+ * Statistics handlers
3
+ */
4
+
5
+ import { dynamodb, querySessionItemsInRange, queryAllItems, TABLE_NAME, unmarshall } from '../lib/dynamodb'
6
+ import { parseDateRange, formatDuration } from '../utils/date'
7
+ import { jsonResponse, errorResponse } from '../utils/response'
8
+ import { countryCodeOf, countryFlagEmoji, getReferrerSourceChannel } from '../utils/geolocation'
9
+ import { parseFilters, matchesFilters, hasFilters } from '../utils/filters'
10
+ import { getQueryParams } from '../../deploy/lambda-adapter'
11
+ import { readDimRollupPrefix, type DayDimRollup, readDayRollups, fullyCoveredDays, isSettledDay } from '../lib/rollups'
12
+
13
+ /**
14
+ * GET /api/sites/{siteId}/stats
15
+ */
16
+ export async function handleGetStats(request: Request, siteId: string): Promise<Response> {
17
+ try {
18
+ const query = getQueryParams(request)
19
+ const { startDate, endDate } = parseDateRange(query)
20
+ const startDateStr = startDate.toISOString().slice(0, 10)
21
+ const endDateStr = endDate.toISOString().slice(0, 10)
22
+ const filters = parseFilters(query)
23
+
24
+ // Pre-aggregation (#94): without filters, complete past days are served
25
+ // from ROLLUP#DAY# items and raw events are touched only for the live
26
+ // remainder (today / un-rolled days). Filters can't be answered from
27
+ // rollups, so a filtered request takes the raw path.
28
+ const rolled: { day: string, views: number, visitors: number, sessions: number, bounces: number, totalDuration: number, events: number }[] = []
29
+ let rawWindowStart = startDate
30
+ if (!hasFilters(filters)) {
31
+ // Serve only SETTLED days from rollups; the most-recent complete day stays
32
+ // on the raw path so late-arriving events aren't dropped (#162).
33
+ const eligible = fullyCoveredDays(startDate, endDate).filter(d => isSettledDay(d))
34
+ if (eligible.length > 0) {
35
+ const rollups = await readDayRollups(siteId, eligible[0], eligible[eligible.length - 1])
36
+ // Use the contiguous covered prefix so one raw range query handles the
37
+ // rest; a mid-range gap simply ends the prefix and stays raw.
38
+ for (const day of eligible) {
39
+ const r = rollups.get(day)
40
+ if (!r)
41
+ break
42
+ rolled.push(r)
43
+ }
44
+ if (rolled.length > 0) {
45
+ const next = new Date(`${rolled[rolled.length - 1].day}T00:00:00.000Z`)
46
+ next.setUTCDate(next.getUTCDate() + 1)
47
+ rawWindowStart = next
48
+ }
49
+ }
50
+ }
51
+
52
+ // Query pageviews for the (remaining) raw window
53
+ const pageviewsResult = rawWindowStart <= endDate
54
+ ? await queryAllItems({
55
+ TableName: TABLE_NAME,
56
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
57
+ ExpressionAttributeValues: {
58
+ ':pk': { S: `SITE#${siteId}` },
59
+ ':start': { S: `PAGEVIEW#${rawWindowStart.toISOString()}` },
60
+ ':end': { S: `PAGEVIEW#${endDate.toISOString()}` },
61
+ },
62
+ }) as { Items?: any[], Count?: number }
63
+ : { Items: [] }
64
+
65
+ // Query sessions for the raw window via the time-keyed GSI entry (#171).
66
+ const sessionsResult = rawWindowStart <= endDate
67
+ ? await querySessionItemsInRange(siteId, rawWindowStart, endDate) as { Items?: any[], Count?: number }
68
+ : { Items: [] }
69
+
70
+ // Query realtime visitors (last 2 minutes)
71
+ const realtimeCutoff = new Date(Date.now() - 2 * 60 * 1000)
72
+ const realtimeResult = await queryAllItems({
73
+ TableName: TABLE_NAME,
74
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
75
+ ExpressionAttributeValues: {
76
+ ':pk': { S: `SITE#${siteId}` },
77
+ ':start': { S: `PAGEVIEW#${realtimeCutoff.toISOString()}` },
78
+ ':end': { S: 'PAGEVIEW#~' },
79
+ },
80
+ }) as { Items?: any[] }
81
+ // Realtime honors the active filters like every other number (#129).
82
+ const realtimePageviews = (realtimeResult.Items || []).map(unmarshall).filter((pv: any) => matchesFilters(pv, filters))
83
+ const realtimeVisitors = new Set(realtimePageviews.map(pv => pv.visitorId)).size
84
+
85
+ const pageviews = (pageviewsResult.Items || []).map(unmarshall).filter((pv: any) => matchesFilters(pv, filters))
86
+ const sessions = (sessionsResult.Items || []).map(unmarshall).filter((s) => {
87
+ const sessionStart = new Date(s.startedAt)
88
+ return sessionStart >= rawWindowStart && sessionStart <= endDate && matchesFilters(s, filters)
89
+ })
90
+
91
+ // Raw-window stats. Uniques are counted PER UTC DAY to match the rolled
92
+ // days' semantics (#146): the raw window used to dedupe across its whole
93
+ // span, so a visitor spanning two raw days counted once there but twice
94
+ // after those days rolled up — the same historical query changed answers
95
+ // day to day. Per-day bucketing makes raw + rolled consistent (Fathom's
96
+ // "people = sum of daily uniques" semantics).
97
+ const rawDailyVisitors = new Map<string, Set<string>>()
98
+ for (const pv of pageviews) {
99
+ const day = String(pv.timestamp).slice(0, 10)
100
+ let set = rawDailyVisitors.get(day)
101
+ if (!set) { set = new Set(); rawDailyVisitors.set(day, set) }
102
+ set.add(pv.visitorId)
103
+ }
104
+ const rawVisitors = [...rawDailyVisitors.values()].reduce((sum, set) => sum + set.size, 0)
105
+ const rawViews = pageviews.length
106
+ const rawSessions = sessions.length
107
+ const rawBounces = sessions.filter(s => s.isBounce).length
108
+ // Prefer real engaged time from departure pings (#167); fall back to the
109
+ // last-hit-minus-first-hit duration for sessions without pings.
110
+ const rawDuration = sessions.reduce((sum, s) => sum + (s.activeTime > 0 ? s.activeTime : (s.duration || 0)), 0)
111
+ const rawEvents = sessions.reduce((sum, s) => sum + (s.eventCount || 0), 0)
112
+
113
+ // Combine with rollup days. Note: `people` across rolled days sums daily
114
+ // uniques (a returning visitor counts once per day) — Fathom semantics.
115
+ const totalViews = rawViews + rolled.reduce((sum, r) => sum + r.views, 0)
116
+ const uniqueVisitors = rawVisitors + rolled.reduce((sum, r) => sum + r.visitors, 0)
117
+ const totalSessions = rawSessions + rolled.reduce((sum, r) => sum + r.sessions, 0)
118
+ const bounces = rawBounces + rolled.reduce((sum, r) => sum + r.bounces, 0)
119
+ const totalDuration = rawDuration + rolled.reduce((sum, r) => sum + r.totalDuration, 0)
120
+ const totalEvents = rawEvents + rolled.reduce((sum, r) => sum + r.events, 0)
121
+ const bounceRate = totalSessions > 0 ? Math.round((bounces / totalSessions) * 100) : 0
122
+ const avgDuration = totalSessions > 0 ? Math.round(totalDuration / totalSessions) : 0
123
+
124
+ return jsonResponse({
125
+ realtime: realtimeVisitors,
126
+ people: uniqueVisitors,
127
+ views: totalViews,
128
+ avgTime: formatDuration(avgDuration),
129
+ avgTimeMs: avgDuration,
130
+ bounceRate,
131
+ events: totalEvents,
132
+ sessions: totalSessions,
133
+ dateRange: { start: startDateStr, end: endDateStr },
134
+ })
135
+ }
136
+ catch (error) {
137
+ console.error('Stats error:', error)
138
+ return errorResponse('Failed to fetch stats')
139
+ }
140
+ }
141
+
142
+ /**
143
+ * GET /api/sites/{siteId}/realtime
144
+ */
145
+ export async function handleGetRealtime(request: Request, siteId: string): Promise<Response> {
146
+ try {
147
+ const query = getQueryParams(request)
148
+ const minutes = Number(query.minutes) || 2
149
+ const cutoff = new Date(Date.now() - minutes * 60 * 1000)
150
+
151
+ // Query recent pageviews
152
+ const result = await queryAllItems({
153
+ TableName: TABLE_NAME,
154
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
155
+ ExpressionAttributeValues: {
156
+ ':pk': { S: `SITE#${siteId}` },
157
+ ':start': { S: `PAGEVIEW#${cutoff.toISOString()}` },
158
+ ':end': { S: 'PAGEVIEW#~' },
159
+ },
160
+ }) as { Items?: any[] }
161
+
162
+ const filters = parseFilters(getQueryParams(request))
163
+ const pageviews = (result.Items || []).map(unmarshall).filter((pv: any) => matchesFilters(pv, filters))
164
+ const uniqueVisitors = new Set(pageviews.map(pv => pv.visitorId)).size
165
+
166
+ // Get active pages
167
+ const pageCounts: Record<string, number> = {}
168
+ for (const pv of pageviews) {
169
+ pageCounts[pv.path] = (pageCounts[pv.path] || 0) + 1
170
+ }
171
+
172
+ const topActivePages = Object.entries(pageCounts)
173
+ .map(([path, count]) => ({ name: path, value: count, percentage: 0 }))
174
+ .sort((a, b) => b.value - a.value)
175
+ .slice(0, 10)
176
+
177
+ const total = topActivePages.reduce((sum, p) => sum + p.value, 0)
178
+ topActivePages.forEach(p => {
179
+ p.percentage = total > 0 ? Math.round((p.value / total) * 100) : 0
180
+ })
181
+
182
+ return jsonResponse({
183
+ currentVisitors: uniqueVisitors,
184
+ pageViewsLastHour: pageviews.length,
185
+ topActivePages,
186
+ })
187
+ }
188
+ catch (error) {
189
+ console.error('Realtime error:', error)
190
+ return errorResponse('Failed to fetch realtime data')
191
+ }
192
+ }
193
+
194
+ /**
195
+ * GET /api/sites/{siteId}/pages
196
+ */
197
+ export async function handleGetPages(request: Request, siteId: string): Promise<Response> {
198
+ try {
199
+ const query = getQueryParams(request)
200
+ const { startDate, endDate } = parseDateRange(query)
201
+ const limit = Math.min(Number(query.limit) || 10, 100)
202
+
203
+ // Rolled prefix (#172): settled days come from ROLLUP#DIMS items — they
204
+ // survive the raw TTL, so this report works beyond 30 days ("All Time").
205
+ const { days: rolled, rawWindowStart } = await readDimRollupPrefix(siteId, startDate, endDate)
206
+
207
+ // Query raw pageviews only for the live tail
208
+ const result = rawWindowStart <= endDate
209
+ ? await queryAllItems({
210
+ TableName: TABLE_NAME,
211
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
212
+ ExpressionAttributeValues: {
213
+ ':pk': { S: `SITE#${siteId}` },
214
+ ':start': { S: `PAGEVIEW#${rawWindowStart.toISOString()}` },
215
+ ':end': { S: `PAGEVIEW#${endDate.toISOString()}` },
216
+ },
217
+ }) as { Items?: any[] }
218
+ : { Items: [] }
219
+
220
+ const pageviews = (result.Items || []).map(unmarshall)
221
+
222
+ // Hostname for building page links: first raw pageview, else the site's
223
+ // configured domain (a fully rolled-up range has no raw rows to read).
224
+ let siteHostname = pageviews.length > 0 ? pageviews[0].hostname : null
225
+ if (!siteHostname) {
226
+ const siteRes = await dynamodb.getItem({
227
+ TableName: TABLE_NAME,
228
+ Key: { pk: { S: 'SITES' }, sk: { S: `SITE#${siteId}` } },
229
+ }).catch(() => null)
230
+ const site = siteRes?.Item ? unmarshall(siteRes.Item) : null
231
+ siteHostname = Array.isArray(site?.domains) && site.domains.length > 0 ? site.domains[0] : null
232
+ }
233
+
234
+ // Aggregate by path
235
+ const pageStats: Record<string, { views: number; visitors: Set<string>; entries: number }> = {}
236
+ for (const pv of pageviews) {
237
+ if (!pageStats[pv.path]) {
238
+ pageStats[pv.path] = { views: 0, visitors: new Set(), entries: 0 }
239
+ }
240
+ pageStats[pv.path].views++
241
+ pageStats[pv.path].visitors.add(pv.visitorId)
242
+ if (pv.isUnique) pageStats[pv.path].entries++
243
+ }
244
+
245
+ // Merge the rolled prefix. Visitors sum daily uniques (Fathom semantics,
246
+ // consistent with the scalar rollups, #146).
247
+ const merged: Record<string, { views: number, visitors: number, entries: number }> = {}
248
+ for (const [path, stats] of Object.entries(pageStats))
249
+ merged[path] = { views: stats.views, visitors: stats.visitors.size, entries: stats.entries }
250
+ for (const dayR of rolled) {
251
+ for (const [path, cell] of Object.entries(dayR.pages || {})) {
252
+ const m = (merged[path] ||= { views: 0, visitors: 0, entries: 0 })
253
+ m.views += cell.w
254
+ m.visitors += cell.v
255
+ m.entries += cell.e
256
+ }
257
+ }
258
+
259
+ const pages = Object.entries(merged)
260
+ .map(([path, stats]) => ({
261
+ path,
262
+ views: stats.views,
263
+ visitors: stats.visitors,
264
+ entries: stats.entries,
265
+ }))
266
+ .sort((a, b) => b.views - a.views)
267
+ .slice(0, limit)
268
+
269
+ return jsonResponse({ pages, hostname: siteHostname })
270
+ }
271
+ catch (error) {
272
+ console.error('Pages error:', error)
273
+ return errorResponse('Failed to fetch pages')
274
+ }
275
+ }
276
+
277
+ /**
278
+ * GET /api/sites/{siteId}/referrers
279
+ */
280
+ export async function handleGetReferrers(request: Request, siteId: string): Promise<Response> {
281
+ try {
282
+ const query = getQueryParams(request)
283
+ const { startDate, endDate } = parseDateRange(query)
284
+ const limit = Math.min(Number(query.limit) || 10, 100)
285
+
286
+ const filters = parseFilters(query)
287
+ // Rolled prefix (#172): settled days come from ROLLUP#DIMS items — they
288
+ // survive the raw TTL, so this report works beyond 30 days ("All Time").
289
+ // Filtered queries can't be answered from aggregates and stay raw-only.
290
+ const { days: rolled, rawWindowStart } = hasFilters(filters)
291
+ ? { days: [] as DayDimRollup[], rawWindowStart: startDate }
292
+ : await readDimRollupPrefix(siteId, startDate, endDate)
293
+
294
+ // Query sessions for the live tail
295
+ const result = rawWindowStart <= endDate
296
+ ? await querySessionItemsInRange(siteId, rawWindowStart, endDate) as { Items?: any[] }
297
+ : { Items: [] }
298
+
299
+ const sessions = (result.Items || []).map(unmarshall).filter(s => {
300
+ const sessionStart = new Date(s.startedAt)
301
+ return sessionStart >= startDate && sessionStart <= endDate && matchesFilters(s, filters)
302
+ })
303
+
304
+ // Aggregate by referrer source (channels derive from the merged sources).
305
+ const referrerStats: Record<string, { visitors: Set<string>; views: number }> = {}
306
+ for (const s of sessions) {
307
+ const source = s.referrerSource || 'Direct'
308
+ if (!referrerStats[source]) {
309
+ referrerStats[source] = { visitors: new Set(), views: 0 }
310
+ }
311
+ referrerStats[source].visitors.add(s.visitorId)
312
+ referrerStats[source].views += s.pageViewCount || 1
313
+ }
314
+
315
+ // Merge the rolled prefix (daily-unique sums, #146 semantics).
316
+ const merged: Record<string, { visitors: number, views: number }> = {}
317
+ for (const [source, stats] of Object.entries(referrerStats))
318
+ merged[source] = { visitors: stats.visitors.size, views: stats.views }
319
+ for (const dayR of rolled) {
320
+ for (const [source, cell] of Object.entries(dayR.sources || {})) {
321
+ const m = (merged[source] ||= { visitors: 0, views: 0 })
322
+ m.visitors += cell.v
323
+ m.views += cell.w
324
+ }
325
+ }
326
+ const mergedChannels: Record<string, { visitors: number, views: number }> = {}
327
+ for (const [source, stats] of Object.entries(merged)) {
328
+ const channel = getReferrerSourceChannel(source)
329
+ const m = (mergedChannels[channel] ||= { visitors: 0, views: 0 })
330
+ m.visitors += stats.visitors
331
+ m.views += stats.views
332
+ }
333
+
334
+ const referrers = Object.entries(merged)
335
+ .map(([source, stats]) => ({
336
+ source,
337
+ channel: getReferrerSourceChannel(source),
338
+ visitors: stats.visitors,
339
+ views: stats.views,
340
+ }))
341
+ .sort((a, b) => b.visitors - a.visitors)
342
+ .slice(0, limit)
343
+
344
+ const byChannel = Object.entries(mergedChannels)
345
+ .map(([channel, stats]) => ({
346
+ channel,
347
+ visitors: stats.visitors,
348
+ views: stats.views,
349
+ }))
350
+ .sort((a, b) => b.visitors - a.visitors)
351
+
352
+ return jsonResponse({ referrers, byChannel })
353
+ }
354
+ catch (error) {
355
+ console.error('Referrers error:', error)
356
+ return errorResponse('Failed to fetch referrers')
357
+ }
358
+ }
359
+
360
+ /**
361
+ * GET /api/sites/{siteId}/devices
362
+ */
363
+ export async function handleGetDevices(request: Request, siteId: string): Promise<Response> {
364
+ try {
365
+ const query = getQueryParams(request)
366
+ const { startDate, endDate } = parseDateRange(query)
367
+
368
+ const filters = parseFilters(query)
369
+ // Rolled prefix (#172): settled days from ROLLUP#DIMS survive the raw TTL.
370
+ // Filtered queries stay raw-only.
371
+ const { days: rolled, rawWindowStart } = hasFilters(filters)
372
+ ? { days: [] as DayDimRollup[], rawWindowStart: startDate }
373
+ : await readDimRollupPrefix(siteId, startDate, endDate)
374
+
375
+ const result = rawWindowStart <= endDate
376
+ ? await querySessionItemsInRange(siteId, rawWindowStart, endDate) as { Items?: any[] }
377
+ : { Items: [] }
378
+
379
+ const sessions = (result.Items || []).map(unmarshall).filter(s => {
380
+ const sessionStart = new Date(s.startedAt)
381
+ return sessionStart >= startDate && sessionStart <= endDate && matchesFilters(s, filters)
382
+ })
383
+
384
+ // Aggregate by device type
385
+ const deviceStats: Record<string, Set<string>> = {}
386
+ const osStats: Record<string, Set<string>> = {}
387
+
388
+ for (const s of sessions) {
389
+ const device = s.deviceType || 'unknown'
390
+ const os = s.os || 'Unknown'
391
+
392
+ if (!deviceStats[device]) deviceStats[device] = new Set()
393
+ deviceStats[device].add(s.visitorId)
394
+
395
+ if (!osStats[os]) osStats[os] = new Set()
396
+ osStats[os].add(s.visitorId)
397
+ }
398
+
399
+ // Merge the rolled prefix (daily-unique sums); percentages come from the
400
+ // merged dimension totals.
401
+ const mergedDevices: Record<string, number> = {}
402
+ for (const [type, visitors] of Object.entries(deviceStats)) mergedDevices[type] = visitors.size
403
+ const mergedOs: Record<string, number> = {}
404
+ for (const [name, visitors] of Object.entries(osStats)) mergedOs[name] = visitors.size
405
+ for (const dayR of rolled) {
406
+ for (const [type, cell] of Object.entries(dayR.devices || {}))
407
+ mergedDevices[type] = (mergedDevices[type] || 0) + cell.v
408
+ for (const [name, cell] of Object.entries(dayR.os || {}))
409
+ mergedOs[name] = (mergedOs[name] || 0) + cell.v
410
+ }
411
+ const totalVisitors = Object.values(mergedDevices).reduce((a, b) => a + b, 0)
412
+
413
+ const deviceTypes = Object.entries(mergedDevices)
414
+ .map(([type, visitors]) => ({
415
+ type: type.charAt(0).toUpperCase() + type.slice(1),
416
+ visitors,
417
+ percentage: totalVisitors > 0 ? Math.round((visitors / totalVisitors) * 100) : 0,
418
+ }))
419
+ .sort((a, b) => b.visitors - a.visitors)
420
+
421
+ const osTotal = Object.values(mergedOs).reduce((a, b) => a + b, 0)
422
+ const operatingSystems = Object.entries(mergedOs)
423
+ .map(([name, visitors]) => ({
424
+ name,
425
+ visitors,
426
+ percentage: osTotal > 0 ? Math.round((visitors / osTotal) * 100) : 0,
427
+ }))
428
+ .sort((a, b) => b.visitors - a.visitors)
429
+
430
+ // `devices` is the canonical key (the analytics store's sectionMap and the
431
+ // detail page read data.devices — with only deviceTypes/operatingSystems the
432
+ // panel always showed "No device data"). Legacy keys kept for back-compat.
433
+ return jsonResponse({ devices: deviceTypes, deviceTypes, operatingSystems })
434
+ }
435
+ catch (error) {
436
+ console.error('Devices error:', error)
437
+ return errorResponse('Failed to fetch devices')
438
+ }
439
+ }
440
+
441
+ /**
442
+ * GET /api/sites/{siteId}/browsers
443
+ */
444
+ export async function handleGetBrowsers(request: Request, siteId: string): Promise<Response> {
445
+ try {
446
+ const query = getQueryParams(request)
447
+ const { startDate, endDate } = parseDateRange(query)
448
+ const limit = Math.min(Number(query.limit) || 10, 100)
449
+
450
+ const filters = parseFilters(query)
451
+ // Rolled prefix (#172): settled days from ROLLUP#DIMS survive the raw TTL.
452
+ // Filtered queries stay raw-only.
453
+ const { days: rolled, rawWindowStart } = hasFilters(filters)
454
+ ? { days: [] as DayDimRollup[], rawWindowStart: startDate }
455
+ : await readDimRollupPrefix(siteId, startDate, endDate)
456
+
457
+ const result = rawWindowStart <= endDate
458
+ ? await querySessionItemsInRange(siteId, rawWindowStart, endDate) as { Items?: any[] }
459
+ : { Items: [] }
460
+
461
+ const sessions = (result.Items || []).map(unmarshall).filter(s => {
462
+ const sessionStart = new Date(s.startedAt)
463
+ return sessionStart >= startDate && sessionStart <= endDate && matchesFilters(s, filters)
464
+ })
465
+
466
+ // Aggregate by browser
467
+ const browserStats: Record<string, Set<string>> = {}
468
+ for (const s of sessions) {
469
+ const browser = s.browser || 'Unknown'
470
+ if (!browserStats[browser]) browserStats[browser] = new Set()
471
+ browserStats[browser].add(s.visitorId)
472
+ }
473
+
474
+ // Merge the rolled prefix (daily-unique sums).
475
+ const merged: Record<string, number> = {}
476
+ for (const [name, visitors] of Object.entries(browserStats)) merged[name] = visitors.size
477
+ for (const dayR of rolled) {
478
+ for (const [name, cell] of Object.entries(dayR.browsers || {}))
479
+ merged[name] = (merged[name] || 0) + cell.v
480
+ }
481
+ const totalVisitors = Object.values(merged).reduce((a, b) => a + b, 0)
482
+
483
+ const browsers = Object.entries(merged)
484
+ .map(([name, visitors]) => ({
485
+ name,
486
+ visitors,
487
+ percentage: totalVisitors > 0 ? Math.round((visitors / totalVisitors) * 100) : 0,
488
+ }))
489
+ .sort((a, b) => b.visitors - a.visitors)
490
+ .slice(0, limit)
491
+
492
+ return jsonResponse({ browsers })
493
+ }
494
+ catch (error) {
495
+ console.error('Browsers error:', error)
496
+ return errorResponse('Failed to fetch browsers')
497
+ }
498
+ }
499
+
500
+ /**
501
+ * GET /api/sites/{siteId}/os — operating-system breakdown. Parsed server-side
502
+ * from the User-Agent at collect time (only the coarse OS name is stored).
503
+ */
504
+ export async function handleGetOS(request: Request, siteId: string): Promise<Response> {
505
+ try {
506
+ const query = getQueryParams(request)
507
+ const { startDate, endDate } = parseDateRange(query)
508
+ const limit = Math.min(Number(query.limit) || 10, 100)
509
+
510
+ const filters = parseFilters(query)
511
+ // Rolled prefix (#172): settled days from ROLLUP#DIMS survive the raw TTL.
512
+ // Filtered queries stay raw-only.
513
+ const { days: rolled, rawWindowStart } = hasFilters(filters)
514
+ ? { days: [] as DayDimRollup[], rawWindowStart: startDate }
515
+ : await readDimRollupPrefix(siteId, startDate, endDate)
516
+
517
+ const result = rawWindowStart <= endDate
518
+ ? await querySessionItemsInRange(siteId, rawWindowStart, endDate) as { Items?: any[] }
519
+ : { Items: [] }
520
+
521
+ const sessions = (result.Items || []).map(unmarshall).filter(s => {
522
+ const sessionStart = new Date(s.startedAt)
523
+ return sessionStart >= startDate && sessionStart <= endDate && matchesFilters(s, filters)
524
+ })
525
+
526
+ const osStats: Record<string, Set<string>> = {}
527
+ for (const s of sessions) {
528
+ const os = s.os || 'Unknown'
529
+ if (!osStats[os]) osStats[os] = new Set()
530
+ osStats[os].add(s.visitorId)
531
+ }
532
+
533
+ // Merge the rolled prefix (daily-unique sums).
534
+ const merged: Record<string, number> = {}
535
+ for (const [name, visitors] of Object.entries(osStats)) merged[name] = visitors.size
536
+ for (const dayR of rolled) {
537
+ for (const [name, cell] of Object.entries(dayR.os || {}))
538
+ merged[name] = (merged[name] || 0) + cell.v
539
+ }
540
+ const totalVisitors = Object.values(merged).reduce((a, b) => a + b, 0)
541
+
542
+ const os = Object.entries(merged)
543
+ .map(([name, visitors]) => ({
544
+ name,
545
+ visitors,
546
+ percentage: totalVisitors > 0 ? Math.round((visitors / totalVisitors) * 100) : 0,
547
+ }))
548
+ .sort((a, b) => b.visitors - a.visitors)
549
+ .slice(0, limit)
550
+
551
+ return jsonResponse({ os })
552
+ }
553
+ catch (error) {
554
+ console.error('OS error:', error)
555
+ return errorResponse('Failed to fetch operating systems')
556
+ }
557
+ }
558
+
559
+ /**
560
+ * GET /api/sites/{siteId}/countries
561
+ */
562
+ export async function handleGetCountries(request: Request, siteId: string): Promise<Response> {
563
+ try {
564
+ const query = getQueryParams(request)
565
+ const { startDate, endDate } = parseDateRange(query)
566
+ const limit = Math.min(Number(query.limit) || 10, 100)
567
+
568
+ const filters = parseFilters(query)
569
+ // Rolled prefix (#172): settled days from ROLLUP#DIMS survive the raw TTL.
570
+ // Filtered queries stay raw-only.
571
+ const { days: rolled, rawWindowStart } = hasFilters(filters)
572
+ ? { days: [] as DayDimRollup[], rawWindowStart: startDate }
573
+ : await readDimRollupPrefix(siteId, startDate, endDate)
574
+
575
+ const result = rawWindowStart <= endDate
576
+ ? await querySessionItemsInRange(siteId, rawWindowStart, endDate) as { Items?: any[] }
577
+ : { Items: [] }
578
+
579
+ const sessions = (result.Items || []).map(unmarshall).filter(s => {
580
+ const sessionStart = new Date(s.startedAt)
581
+ return sessionStart >= startDate && sessionStart <= endDate && matchesFilters(s, filters)
582
+ })
583
+
584
+ // Aggregate by country. Sessions without a resolved country (pre-geo data,
585
+ // clients with no timezone/headers) are omitted rather than reported as an
586
+ // "Unknown" bucket.
587
+ const countryStats: Record<string, Set<string>> = {}
588
+ for (const s of sessions) {
589
+ if (!s.country) continue
590
+ if (!countryStats[s.country]) countryStats[s.country] = new Set()
591
+ countryStats[s.country].add(s.visitorId)
592
+ }
593
+
594
+ // Merge the rolled prefix (daily-unique sums).
595
+ const merged: Record<string, number> = {}
596
+ for (const [name, visitors] of Object.entries(countryStats)) merged[name] = visitors.size
597
+ for (const dayR of rolled) {
598
+ for (const [name, cell] of Object.entries(dayR.countries || {}))
599
+ merged[name] = (merged[name] || 0) + cell.v
600
+ }
601
+
602
+ const countries = Object.entries(merged)
603
+ .map(([name, visitors]) => {
604
+ // Emoji flag from the ISO code (Fathom/GA-style) — stored values are
605
+ // display names (reverse-mapped) or bare ISO codes.
606
+ const code = countryCodeOf(name) || ''
607
+ return { name, code, flag: code ? countryFlagEmoji(code) : '', visitors }
608
+ })
609
+ .sort((a, b) => b.visitors - a.visitors)
610
+ .slice(0, limit)
611
+
612
+ return jsonResponse({ countries })
613
+ }
614
+ catch (error) {
615
+ console.error('Countries error:', error)
616
+ return errorResponse('Failed to fetch countries')
617
+ }
618
+ }
619
+
620
+ /**
621
+ * GET /api/sites/{siteId}/regions
622
+ */
623
+ export async function handleGetRegions(request: Request, siteId: string): Promise<Response> {
624
+ try {
625
+ const query = getQueryParams(request)
626
+ const { startDate, endDate } = parseDateRange(query)
627
+ const limit = Math.min(Number(query.limit) || 10, 100)
628
+ const filters = parseFilters(query)
629
+
630
+ // Rolled prefix (#172): settled days from ROLLUP#DIMS survive the raw TTL.
631
+ // Filtered queries stay raw-only.
632
+ const { days: rolled, rawWindowStart } = hasFilters(filters)
633
+ ? { days: [] as DayDimRollup[], rawWindowStart: startDate }
634
+ : await readDimRollupPrefix(siteId, startDate, endDate)
635
+
636
+ const result = rawWindowStart <= endDate
637
+ ? await querySessionItemsInRange(siteId, rawWindowStart, endDate) as { Items?: any[] }
638
+ : { Items: [] }
639
+
640
+ const sessions = (result.Items || []).map(unmarshall).filter(s => {
641
+ const sessionStart = new Date(s.startedAt)
642
+ if (sessionStart < startDate || sessionStart > endDate) return false
643
+ return matchesFilters(s, filters)
644
+ })
645
+
646
+ // Aggregate by region
647
+ const regionStats: Record<string, { visitors: Set<string>, country: string }> = {}
648
+ for (const s of sessions) {
649
+ const region = s.region || 'Unknown'
650
+ const country = s.country || 'Unknown'
651
+ const key = `${country}:${region}`
652
+ if (!regionStats[key]) regionStats[key] = { visitors: new Set(), country }
653
+ regionStats[key].visitors.add(s.visitorId)
654
+ }
655
+
656
+ // Merge the rolled prefix (composite `${country}:${region}` keys).
657
+ const merged: Record<string, number> = {}
658
+ for (const [key, data] of Object.entries(regionStats)) merged[key] = data.visitors.size
659
+ for (const dayR of rolled) {
660
+ for (const [key, cell] of Object.entries(dayR.regions || {}))
661
+ merged[key] = (merged[key] || 0) + cell.v
662
+ }
663
+
664
+ const regions = Object.entries(merged)
665
+ .map(([key, visitors]) => {
666
+ const [country, region] = key.split(':')
667
+ return { name: region, country, visitors }
668
+ })
669
+ .sort((a, b) => b.visitors - a.visitors)
670
+ .slice(0, limit)
671
+
672
+ return jsonResponse({ regions })
673
+ }
674
+ catch (error) {
675
+ console.error('Regions error:', error)
676
+ return errorResponse('Failed to fetch regions')
677
+ }
678
+ }
679
+
680
+ /**
681
+ * GET /api/sites/{siteId}/cities
682
+ */
683
+ export async function handleGetCities(request: Request, siteId: string): Promise<Response> {
684
+ try {
685
+ const query = getQueryParams(request)
686
+ const { startDate, endDate } = parseDateRange(query)
687
+ const limit = Math.min(Number(query.limit) || 10, 100)
688
+ const filters = parseFilters(query)
689
+
690
+ // Rolled prefix (#172): settled days from ROLLUP#DIMS survive the raw TTL.
691
+ // Filtered queries stay raw-only.
692
+ const { days: rolled, rawWindowStart } = hasFilters(filters)
693
+ ? { days: [] as DayDimRollup[], rawWindowStart: startDate }
694
+ : await readDimRollupPrefix(siteId, startDate, endDate)
695
+
696
+ const result = rawWindowStart <= endDate
697
+ ? await querySessionItemsInRange(siteId, rawWindowStart, endDate) as { Items?: any[] }
698
+ : { Items: [] }
699
+
700
+ const sessions = (result.Items || []).map(unmarshall).filter(s => {
701
+ const sessionStart = new Date(s.startedAt)
702
+ if (sessionStart < startDate || sessionStart > endDate) return false
703
+ return matchesFilters(s, filters)
704
+ })
705
+
706
+ // Aggregate by city
707
+ const cityStats: Record<string, { visitors: Set<string>, country: string, region: string }> = {}
708
+ for (const s of sessions) {
709
+ const city = s.city || 'Unknown'
710
+ const region = s.region || 'Unknown'
711
+ const country = s.country || 'Unknown'
712
+ const key = `${country}:${region}:${city}`
713
+ if (!cityStats[key]) cityStats[key] = { visitors: new Set(), country, region }
714
+ cityStats[key].visitors.add(s.visitorId)
715
+ }
716
+
717
+ // Merge the rolled prefix (composite `${country}:${region}:${city}` keys).
718
+ const merged: Record<string, number> = {}
719
+ for (const [key, data] of Object.entries(cityStats)) merged[key] = data.visitors.size
720
+ for (const dayR of rolled) {
721
+ for (const [key, cell] of Object.entries(dayR.cities || {}))
722
+ merged[key] = (merged[key] || 0) + cell.v
723
+ }
724
+
725
+ const cities = Object.entries(merged)
726
+ .map(([key, visitors]) => {
727
+ const [country, region, city] = key.split(':')
728
+ return { name: city, country, region, visitors }
729
+ })
730
+ .sort((a, b) => b.visitors - a.visitors)
731
+ .slice(0, limit)
732
+
733
+ return jsonResponse({ cities })
734
+ }
735
+ catch (error) {
736
+ console.error('Cities error:', error)
737
+ return errorResponse('Failed to fetch cities')
738
+ }
739
+ }
740
+
741
+ /**
742
+ * GET /api/sites/{siteId}/screen-sizes — viewport-width breakdown (#139).
743
+ * Raw-window report (screen dimensions aren't rolled up): buckets pageview
744
+ * screenWidth into device-class ranges with unique visitors per bucket.
745
+ */
746
+ export async function handleGetScreenSizes(request: Request, siteId: string): Promise<Response> {
747
+ try {
748
+ const query = getQueryParams(request)
749
+ const { startDate, endDate } = parseDateRange(query)
750
+
751
+ const result = await queryAllItems({
752
+ TableName: TABLE_NAME,
753
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
754
+ ExpressionAttributeValues: {
755
+ ':pk': { S: `SITE#${siteId}` },
756
+ ':start': { S: `PAGEVIEW#${startDate.toISOString()}` },
757
+ ':end': { S: `PAGEVIEW#${endDate.toISOString()}~` },
758
+ },
759
+ })
760
+
761
+ const BUCKETS: Array<{ name: string, min: number, max: number }> = [
762
+ { name: 'Mobile (< 480px)', min: 0, max: 479 },
763
+ { name: 'Mobile L (480–767px)', min: 480, max: 767 },
764
+ { name: 'Tablet (768–991px)', min: 768, max: 991 },
765
+ { name: 'Laptop (992–1199px)', min: 992, max: 1199 },
766
+ { name: 'Desktop (1200–1919px)', min: 1200, max: 1919 },
767
+ { name: 'Large (≥ 1920px)', min: 1920, max: Infinity },
768
+ ]
769
+ const filters = parseFilters(query)
770
+ const visitorsByBucket = new Map<string, Set<string>>()
771
+ for (const raw of (result.Items || [])) {
772
+ const pv = unmarshall(raw)
773
+ if (!matchesFilters(pv, filters))
774
+ continue
775
+ const width = Number(pv.screenWidth)
776
+ if (!Number.isFinite(width) || width <= 0)
777
+ continue
778
+ const bucket = BUCKETS.find(b => width >= b.min && width <= b.max)
779
+ if (!bucket)
780
+ continue
781
+ let set = visitorsByBucket.get(bucket.name)
782
+ if (!set) {
783
+ set = new Set()
784
+ visitorsByBucket.set(bucket.name, set)
785
+ }
786
+ if (pv.visitorId)
787
+ set.add(pv.visitorId)
788
+ }
789
+
790
+ const total = [...visitorsByBucket.values()].reduce((sum, set) => sum + set.size, 0)
791
+ const screenSizes = BUCKETS
792
+ .map(b => ({
793
+ name: b.name,
794
+ visitors: visitorsByBucket.get(b.name)?.size || 0,
795
+ percentage: total > 0 ? Math.round(((visitorsByBucket.get(b.name)?.size || 0) / total) * 100) : 0,
796
+ }))
797
+ .filter(b => b.visitors > 0)
798
+ .sort((a, b) => b.visitors - a.visitors)
799
+
800
+ return jsonResponse({ screenSizes })
801
+ }
802
+ catch (error) {
803
+ console.error('Screen sizes error:', error)
804
+ return errorResponse('Failed to fetch screen sizes')
805
+ }
806
+ }
807
+
808
+ /**
809
+ * GET /api/sites/{siteId}/timeseries
810
+ */
811
+ export async function handleGetTimeSeries(request: Request, siteId: string): Promise<Response> {
812
+ try {
813
+ const query = getQueryParams(request)
814
+ const { startDate, endDate } = parseDateRange(query)
815
+ const period = query.period || 'day'
816
+
817
+ // Pre-aggregation (#94): for day/month periods, complete past days come
818
+ // from ROLLUP#DAY# items; raw pageviews are queried only from the first
819
+ // un-rolled day onward (usually just today). Hour/minute periods are
820
+ // short ranges and stay raw. Month buckets sum daily uniques.
821
+ const rollupByDay = new Map<string, { views: number, visitors: number }>()
822
+ let rawWindowStart = startDate
823
+ if (period === 'day' || period === 'month') {
824
+ // Serve only SETTLED days from rollups; the most-recent complete day stays
825
+ // on the raw path so late-arriving events aren't dropped (#162).
826
+ const eligible = fullyCoveredDays(startDate, endDate).filter(d => isSettledDay(d))
827
+ if (eligible.length > 0) {
828
+ const rollups = await readDayRollups(siteId, eligible[0], eligible[eligible.length - 1])
829
+ for (const day of eligible) {
830
+ const r = rollups.get(day)
831
+ if (!r)
832
+ break
833
+ rollupByDay.set(day, { views: r.views, visitors: r.visitors })
834
+ }
835
+ if (rollupByDay.size > 0) {
836
+ const lastRolled = [...rollupByDay.keys()].pop()!
837
+ const next = new Date(`${lastRolled}T00:00:00.000Z`)
838
+ next.setUTCDate(next.getUTCDate() + 1)
839
+ rawWindowStart = next
840
+ }
841
+ }
842
+ }
843
+
844
+ // Query pageviews for the (remaining) raw window
845
+ const result = rawWindowStart <= endDate
846
+ ? await queryAllItems({
847
+ TableName: TABLE_NAME,
848
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
849
+ ExpressionAttributeValues: {
850
+ ':pk': { S: `SITE#${siteId}` },
851
+ ':start': { S: `PAGEVIEW#${rawWindowStart.toISOString()}` },
852
+ ':end': { S: `PAGEVIEW#${endDate.toISOString()}` },
853
+ },
854
+ }) as { Items?: any[] }
855
+ : { Items: [] }
856
+
857
+ const pageviews = (result.Items || []).map(unmarshall)
858
+
859
+ // Generate all time buckets in the range
860
+ const allBuckets: string[] = []
861
+ const current = new Date(startDate)
862
+ const end = new Date(endDate)
863
+
864
+ while (current <= end) {
865
+ let key: string
866
+ // Advance in UTC (#136): keys are UTC ISO strings but the cursor used
867
+ // local-time setters — on non-UTC servers, DST transitions and offset
868
+ // drift skipped or duplicated buckets.
869
+ if (period === 'minute') {
870
+ const mins = Math.floor(current.getUTCMinutes() / 5) * 5
871
+ key = `${current.toISOString().slice(0, 14)}${mins.toString().padStart(2, '0')}:00.000Z`
872
+ current.setUTCMinutes(current.getUTCMinutes() + 5)
873
+ }
874
+ else if (period === 'hour') {
875
+ key = `${current.toISOString().slice(0, 13)}:00:00.000Z`
876
+ current.setUTCHours(current.getUTCHours() + 1)
877
+ }
878
+ else if (period === 'month') {
879
+ key = `${current.toISOString().slice(0, 7)}-01T00:00:00.000Z`
880
+ current.setUTCMonth(current.getUTCMonth() + 1)
881
+ }
882
+ else {
883
+ key = `${current.toISOString().slice(0, 10)}T00:00:00.000Z`
884
+ current.setUTCDate(current.getUTCDate() + 1)
885
+ }
886
+ if (!allBuckets.includes(key)) allBuckets.push(key)
887
+ }
888
+
889
+ // Group pageviews by time bucket
890
+ const bucketMap: Record<string, { views: number; visitors: Set<string> }> = {}
891
+ for (const bucket of allBuckets) {
892
+ bucketMap[bucket] = { views: 0, visitors: new Set() }
893
+ }
894
+
895
+ for (const pv of pageviews) {
896
+ const timestamp = new Date(pv.timestamp)
897
+ let key: string
898
+ if (period === 'minute') {
899
+ const mins = Math.floor(timestamp.getUTCMinutes() / 5) * 5
900
+ key = `${timestamp.toISOString().slice(0, 14)}${mins.toString().padStart(2, '0')}:00.000Z`
901
+ }
902
+ else if (period === 'hour') {
903
+ key = `${timestamp.toISOString().slice(0, 13)}:00:00.000Z`
904
+ }
905
+ else if (period === 'month') {
906
+ key = `${timestamp.toISOString().slice(0, 7)}-01T00:00:00.000Z`
907
+ }
908
+ else {
909
+ key = `${timestamp.toISOString().slice(0, 10)}T00:00:00.000Z`
910
+ }
911
+ if (bucketMap[key]) {
912
+ bucketMap[key].views++
913
+ bucketMap[key].visitors.add(pv.visitorId)
914
+ }
915
+ }
916
+
917
+ // Fold rollup days into their buckets. A day-bucket is either rolled or
918
+ // raw (the raw window starts after the last rolled day); a month-bucket
919
+ // can mix both, so its visitors are summed daily uniques.
920
+ const rollupBuckets: Record<string, { views: number, visitors: number }> = {}
921
+ for (const [day, r] of rollupByDay) {
922
+ const key = period === 'month' ? `${day.slice(0, 7)}-01T00:00:00.000Z` : `${day}T00:00:00.000Z`
923
+ if (!rollupBuckets[key])
924
+ rollupBuckets[key] = { views: 0, visitors: 0 }
925
+ rollupBuckets[key].views += r.views
926
+ rollupBuckets[key].visitors += r.visitors
927
+ }
928
+
929
+ const timeSeries = allBuckets.map(bucket => ({
930
+ timestamp: bucket,
931
+ views: (bucketMap[bucket]?.views || 0) + (rollupBuckets[bucket]?.views || 0),
932
+ visitors: (bucketMap[bucket]?.visitors.size || 0) + (rollupBuckets[bucket]?.visitors || 0),
933
+ }))
934
+
935
+ return jsonResponse({ timeSeries })
936
+ }
937
+ catch (error) {
938
+ console.error('TimeSeries error:', error)
939
+ return errorResponse('Failed to fetch time series')
940
+ }
941
+ }
942
+
943
+ /**
944
+ * GET /api/sites/{siteId}/events
945
+ */
946
+ export async function handleGetEvents(request: Request, siteId: string): Promise<Response> {
947
+ try {
948
+ const query = getQueryParams(request)
949
+ const { startDate, endDate } = parseDateRange(query)
950
+ const limit = Math.min(Number(query.limit) || 10, 100)
951
+
952
+ // Query custom events
953
+ const result = await queryAllItems({
954
+ TableName: TABLE_NAME,
955
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
956
+ ExpressionAttributeValues: {
957
+ ':pk': { S: `SITE#${siteId}` },
958
+ ':start': { S: `EVENT#${startDate.toISOString()}` },
959
+ ':end': { S: `EVENT#${endDate.toISOString()}` },
960
+ },
961
+ }) as { Items?: any[] }
962
+
963
+ const filters = parseFilters(query)
964
+ const events = (result.Items || []).map(unmarshall).filter(e => matchesFilters(e, filters))
965
+
966
+ // Aggregate by event name
967
+ const eventStats: Record<string, { count: number; visitors: Set<string>; value: number }> = {}
968
+ for (const e of events) {
969
+ const name = e.name || 'unknown'
970
+ if (!eventStats[name]) {
971
+ eventStats[name] = { count: 0, visitors: new Set(), value: 0 }
972
+ }
973
+ eventStats[name].count++
974
+ eventStats[name].visitors.add(e.visitorId)
975
+ // Sum the event value so revenue/valued events report a total (#132).
976
+ if (typeof e.value === 'number')
977
+ eventStats[name].value += e.value
978
+ }
979
+
980
+ const eventsList = Object.entries(eventStats)
981
+ .map(([name, stats]) => ({
982
+ name,
983
+ count: stats.count,
984
+ visitors: stats.visitors.size,
985
+ value: stats.value,
986
+ }))
987
+ .sort((a, b) => b.count - a.count)
988
+ .slice(0, limit)
989
+
990
+ return jsonResponse({ events: eventsList })
991
+ }
992
+ catch (error) {
993
+ console.error('Events error:', error)
994
+ return errorResponse('Failed to fetch events')
995
+ }
996
+ }
997
+
998
+ /**
999
+ * Aggregate custom events into per-(event, property key, property value) rows
1000
+ * with counts + unique visitors. Pure (no DynamoDB) so it is unit-testable.
1001
+ * The reserved name/value keys are skipped, and non-primitive values ignored.
1002
+ */
1003
+ export function aggregateEventProperties(
1004
+ events: Array<{ name?: string, visitorId?: string, properties?: Record<string, any> | null }>,
1005
+ opts: { limit?: number } = {},
1006
+ ): {
1007
+ eventProperties: Array<{ event: string, key: string, value: string, count: number, visitors: number }>
1008
+ byEvent: Record<string, number>
1009
+ total: number
1010
+ } {
1011
+ const limit = opts.limit ?? 100
1012
+ const groups: Record<string, Record<string, Record<string, { count: number, visitors: Set<string> }>>> = {}
1013
+ const byEvent: Record<string, number> = {}
1014
+
1015
+ for (const e of events) {
1016
+ const event = e.name || 'unknown'
1017
+ byEvent[event] = (byEvent[event] || 0) + 1
1018
+ const props = e.properties
1019
+ if (!props || typeof props !== 'object') continue
1020
+ for (const [key, raw] of Object.entries(props)) {
1021
+ if (key === 'name' || key === 'value') continue
1022
+ if (raw === null || raw === undefined || typeof raw === 'object') continue
1023
+ const value = String(raw)
1024
+ const byKey = (groups[event] ||= {})
1025
+ const byValue = (byKey[key] ||= {})
1026
+ const bucket = (byValue[value] ||= { count: 0, visitors: new Set() })
1027
+ bucket.count++
1028
+ if (e.visitorId) bucket.visitors.add(e.visitorId)
1029
+ }
1030
+ }
1031
+
1032
+ const eventProperties: Array<{ event: string, key: string, value: string, count: number, visitors: number }> = []
1033
+ for (const event of Object.keys(groups).sort()) {
1034
+ for (const key of Object.keys(groups[event]).sort()) {
1035
+ const rows = Object.entries(groups[event][key])
1036
+ .map(([value, b]) => ({ event, key, value, count: b.count, visitors: b.visitors.size }))
1037
+ .sort((a, b) => b.count - a.count)
1038
+ .slice(0, limit)
1039
+ eventProperties.push(...rows)
1040
+ }
1041
+ }
1042
+
1043
+ return { eventProperties, byEvent, total: events.length }
1044
+ }
1045
+
1046
+ /**
1047
+ * GET /api/sites/{siteId}/event-properties
1048
+ * Per-event property value distribution (e.g. signup -> plan=pro/free).
1049
+ */
1050
+ export async function handleGetEventProperties(request: Request, siteId: string): Promise<Response> {
1051
+ try {
1052
+ const query = getQueryParams(request)
1053
+ const { startDate, endDate } = parseDateRange(query)
1054
+ const limit = Math.min(Number(query.limit) || 100, 200)
1055
+ const eventFilter = query.event
1056
+ const filters = parseFilters(query)
1057
+
1058
+ const result = await queryAllItems({
1059
+ TableName: TABLE_NAME,
1060
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
1061
+ ExpressionAttributeValues: {
1062
+ ':pk': { S: `SITE#${siteId}` },
1063
+ ':start': { S: `EVENT#${startDate.toISOString()}` },
1064
+ ':end': { S: `EVENT#${endDate.toISOString()}` },
1065
+ },
1066
+ }) as { Items?: any[] }
1067
+
1068
+ const events = (result.Items || [])
1069
+ .map(unmarshall)
1070
+ .filter((e: any) => (!eventFilter || e.name === eventFilter) && matchesFilters(e, filters))
1071
+ .map((e: any) => {
1072
+ // properties are persisted as a JSON string; parse defensively (mirrors the ORM read path)
1073
+ if (typeof e.properties === 'string') {
1074
+ try { e.properties = JSON.parse(e.properties) }
1075
+ catch { e.properties = {} }
1076
+ }
1077
+ return e
1078
+ })
1079
+
1080
+ return jsonResponse(aggregateEventProperties(events, { limit }))
1081
+ }
1082
+ catch (error) {
1083
+ console.error('Event properties error:', error)
1084
+ return errorResponse('Failed to fetch event properties')
1085
+ }
1086
+ }
1087
+
1088
+ /**
1089
+ * GET /api/sites/{siteId}/clicks
1090
+ * Aggregated link-click report (outbound, internal, download, mailto, tel).
1091
+ */
1092
+ export async function handleGetClicks(request: Request, siteId: string): Promise<Response> {
1093
+ try {
1094
+ const query = getQueryParams(request)
1095
+ const { startDate, endDate } = parseDateRange(query)
1096
+ const limit = Math.min(Number(query.limit) || 20, 100)
1097
+ const kindFilter = query.kind
1098
+
1099
+ // Query link clicks
1100
+ const result = await queryAllItems({
1101
+ TableName: TABLE_NAME,
1102
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
1103
+ ExpressionAttributeValues: {
1104
+ ':pk': { S: `SITE#${siteId}` },
1105
+ ':start': { S: `CLICK#${startDate.toISOString()}` },
1106
+ ':end': { S: `CLICK#${endDate.toISOString()}` },
1107
+ },
1108
+ }) as { Items?: any[] }
1109
+
1110
+ const filters = parseFilters(query)
1111
+ const clicks = (result.Items || [])
1112
+ .map(unmarshall)
1113
+ .filter(c => (!kindFilter || c.kind === kindFilter) && matchesFilters(c, filters))
1114
+
1115
+ // Aggregate by destination URL, plus a per-kind summary
1116
+ const urlStats: Record<string, { kind: string; text: string; count: number; visitors: Set<string> }> = {}
1117
+ const byKind: Record<string, number> = { outbound: 0, internal: 0, download: 0, mailto: 0, tel: 0 }
1118
+ for (const c of clicks) {
1119
+ const url = c.url || 'unknown'
1120
+ if (!urlStats[url]) {
1121
+ urlStats[url] = { kind: c.kind || 'outbound', text: c.text || '', count: 0, visitors: new Set() }
1122
+ }
1123
+ urlStats[url].count++
1124
+ urlStats[url].visitors.add(c.visitorId)
1125
+ if (byKind[c.kind] !== undefined) byKind[c.kind]++
1126
+ }
1127
+
1128
+ const clicksList = Object.entries(urlStats)
1129
+ .map(([url, s]) => ({
1130
+ url,
1131
+ kind: s.kind,
1132
+ text: s.text,
1133
+ count: s.count,
1134
+ visitors: s.visitors.size,
1135
+ }))
1136
+ .sort((a, b) => b.count - a.count)
1137
+ .slice(0, limit)
1138
+
1139
+ return jsonResponse({ clicks: clicksList, byKind, total: clicks.length })
1140
+ }
1141
+ catch (error) {
1142
+ console.error('Clicks error:', error)
1143
+ return errorResponse('Failed to fetch clicks')
1144
+ }
1145
+ }
1146
+
1147
+ /**
1148
+ * GET /api/sites/{siteId}/engagement
1149
+ * Per-page engagement: average scroll depth (%) and active time-on-page (s).
1150
+ */
1151
+ export async function handleGetEngagement(request: Request, siteId: string): Promise<Response> {
1152
+ try {
1153
+ const query = getQueryParams(request)
1154
+ const { startDate, endDate } = parseDateRange(query)
1155
+ const limit = Math.min(Number(query.limit) || 20, 100)
1156
+
1157
+ // Query engagement samples
1158
+ const result = await queryAllItems({
1159
+ TableName: TABLE_NAME,
1160
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
1161
+ ExpressionAttributeValues: {
1162
+ ':pk': { S: `SITE#${siteId}` },
1163
+ ':start': { S: `ENGAGEMENT#${startDate.toISOString()}` },
1164
+ ':end': { S: `ENGAGEMENT#${endDate.toISOString()}` },
1165
+ },
1166
+ }) as { Items?: any[] }
1167
+
1168
+ const filters = parseFilters(query)
1169
+ const samples = (result.Items || []).map(unmarshall).filter(s => matchesFilters(s, filters))
1170
+
1171
+ // Aggregate by page path
1172
+ const pageStats: Record<string, { count: number; sumScroll: number; sumTime: number; visitors: Set<string> }> = {}
1173
+ let totalScroll = 0
1174
+ let totalTime = 0
1175
+ for (const s of samples) {
1176
+ const path = s.path || '/'
1177
+ if (!pageStats[path]) {
1178
+ pageStats[path] = { count: 0, sumScroll: 0, sumTime: 0, visitors: new Set() }
1179
+ }
1180
+ const stat = pageStats[path]
1181
+ stat.count++
1182
+ stat.sumScroll += s.scrollDepth || 0
1183
+ stat.sumTime += s.timeOnPage || 0
1184
+ stat.visitors.add(s.visitorId)
1185
+ totalScroll += s.scrollDepth || 0
1186
+ totalTime += s.timeOnPage || 0
1187
+ }
1188
+
1189
+ const pages = Object.entries(pageStats)
1190
+ .map(([path, stat]) => ({
1191
+ path,
1192
+ samples: stat.count,
1193
+ visitors: stat.visitors.size,
1194
+ avgScrollDepth: Math.round(stat.sumScroll / stat.count),
1195
+ avgSeconds: Math.round(stat.sumTime / stat.count),
1196
+ }))
1197
+ .sort((a, b) => b.samples - a.samples)
1198
+ .slice(0, limit)
1199
+
1200
+ const n = samples.length
1201
+ return jsonResponse({
1202
+ engagement: pages,
1203
+ avgScrollDepth: n > 0 ? Math.round(totalScroll / n) : 0,
1204
+ avgSeconds: n > 0 ? Math.round(totalTime / n) : 0,
1205
+ samples: n,
1206
+ })
1207
+ }
1208
+ catch (error) {
1209
+ console.error('Engagement error:', error)
1210
+ return errorResponse('Failed to fetch engagement')
1211
+ }
1212
+ }
1213
+
1214
+ /**
1215
+ * GET /api/sites/{siteId}/campaigns
1216
+ */
1217
+ export async function handleGetCampaigns(request: Request, siteId: string): Promise<Response> {
1218
+ try {
1219
+ const query = getQueryParams(request)
1220
+ const { startDate, endDate } = parseDateRange(query)
1221
+ const limit = Math.min(Number(query.limit) || 10, 100)
1222
+
1223
+ // Rolled prefix (#172): settled days from ROLLUP#DIMS survive the raw TTL.
1224
+ const { days: rolled, rawWindowStart } = await readDimRollupPrefix(siteId, startDate, endDate)
1225
+
1226
+ // Query sessions with UTM data for the live tail
1227
+ const result = rawWindowStart <= endDate
1228
+ ? await querySessionItemsInRange(siteId, rawWindowStart, endDate) as { Items?: any[] }
1229
+ : { Items: [] }
1230
+
1231
+ const sessions = (result.Items || []).map(unmarshall).filter(s => {
1232
+ const sessionStart = new Date(s.startedAt)
1233
+ return sessionStart >= startDate && sessionStart <= endDate && s.utmCampaign
1234
+ })
1235
+
1236
+ // Aggregate by campaign
1237
+ const campaignStats: Record<string, { visitors: Set<string>; sessions: number; source: string; medium: string }> = {}
1238
+ for (const s of sessions) {
1239
+ const campaign = s.utmCampaign || 'unknown'
1240
+ if (!campaignStats[campaign]) {
1241
+ campaignStats[campaign] = { visitors: new Set(), sessions: 0, source: s.utmSource || '', medium: s.utmMedium || '' }
1242
+ }
1243
+ campaignStats[campaign].visitors.add(s.visitorId)
1244
+ campaignStats[campaign].sessions++
1245
+ }
1246
+
1247
+ // Merge the rolled prefix (daily-unique sums).
1248
+ const merged: Record<string, { visitors: number, sessions: number, source: string, medium: string }> = {}
1249
+ for (const [name, stats] of Object.entries(campaignStats))
1250
+ merged[name] = { visitors: stats.visitors.size, sessions: stats.sessions, source: stats.source, medium: stats.medium }
1251
+ for (const dayR of rolled) {
1252
+ for (const [name, cell] of Object.entries(dayR.campaigns || {})) {
1253
+ const m = (merged[name] ||= { visitors: 0, sessions: 0, source: cell.src, medium: cell.med })
1254
+ m.visitors += cell.v
1255
+ m.sessions += cell.s
1256
+ }
1257
+ }
1258
+
1259
+ const campaigns = Object.entries(merged)
1260
+ .map(([name, stats]) => ({
1261
+ name,
1262
+ visitors: stats.visitors,
1263
+ sessions: stats.sessions,
1264
+ source: stats.source,
1265
+ medium: stats.medium,
1266
+ }))
1267
+ .sort((a, b) => b.visitors - a.visitors)
1268
+ .slice(0, limit)
1269
+
1270
+ return jsonResponse({ campaigns })
1271
+ }
1272
+ catch (error) {
1273
+ console.error('Campaigns error:', error)
1274
+ return errorResponse('Failed to fetch campaigns')
1275
+ }
1276
+ }
1277
+
1278
+ /**
1279
+ * GET /api/sites/{siteId}/comparison
1280
+ */
1281
+ export async function handleGetComparison(request: Request, siteId: string): Promise<Response> {
1282
+ try {
1283
+ const query = getQueryParams(request)
1284
+ const { startDate, endDate } = parseDateRange(query)
1285
+
1286
+ // Calculate the comparison period (same duration, immediately before)
1287
+ const duration = endDate.getTime() - startDate.getTime()
1288
+ // Previous window is exactly [start - duration, start - 1ms] (#129): the
1289
+ // old derivation shifted the whole window back an extra millisecond.
1290
+ const comparisonEndDate = new Date(startDate.getTime() - 1)
1291
+ const comparisonStartDate = new Date(startDate.getTime() - duration)
1292
+
1293
+ // One filter parse + TWO queries over the combined span (#137) — the
1294
+ // old shape re-parsed filters and re-queried per period (4 queries).
1295
+ const filters = parseFilters(query)
1296
+ const [pageviewsResult, sessionsResult] = await Promise.all([
1297
+ queryAllItems({
1298
+ TableName: TABLE_NAME,
1299
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :start AND :end',
1300
+ ExpressionAttributeValues: {
1301
+ ':pk': { S: `SITE#${siteId}` },
1302
+ ':start': { S: `PAGEVIEW#${comparisonStartDate.toISOString()}` },
1303
+ ':end': { S: `PAGEVIEW#${endDate.toISOString()}` },
1304
+ },
1305
+ }) as Promise<{ Items?: any[] }>,
1306
+ querySessionItemsInRange(siteId, comparisonStartDate, endDate) as Promise<{ Items?: any[] }>,
1307
+ ])
1308
+ const allPageviews = (pageviewsResult.Items || []).map(unmarshall).filter((pv: any) => matchesFilters(pv, filters))
1309
+ const allSessions = (sessionsResult.Items || []).map(unmarshall).filter(s => matchesFilters(s, filters))
1310
+
1311
+ function getStatsForPeriod(start: Date, end: Date) {
1312
+ const pageviews = allPageviews.filter((pv: any) => {
1313
+ const t = new Date(pv.timestamp)
1314
+ return t >= start && t <= end
1315
+ })
1316
+ const sessions = allSessions.filter((s) => {
1317
+ const t = new Date(s.startedAt)
1318
+ return t >= start && t <= end
1319
+ })
1320
+
1321
+ const uniqueVisitors = new Set(pageviews.map((pv: any) => pv.visitorId)).size
1322
+ const totalViews = pageviews.length
1323
+ const totalSessions = sessions.length
1324
+ const bounces = sessions.filter(s => s.isBounce).length
1325
+ const bounceRate = totalSessions > 0 ? Math.round((bounces / totalSessions) * 100) : 0
1326
+ // Real engaged time when departure pings exist (#167), else wall-clock.
1327
+ const totalDuration = sessions.reduce((sum, s) => sum + (s.activeTime > 0 ? s.activeTime : (s.duration || 0)), 0)
1328
+ const avgDuration = totalSessions > 0 ? Math.round(totalDuration / totalSessions) : 0
1329
+
1330
+ return {
1331
+ visitors: uniqueVisitors,
1332
+ views: totalViews,
1333
+ sessions: totalSessions,
1334
+ bounceRate,
1335
+ avgDuration,
1336
+ }
1337
+ }
1338
+
1339
+ const currentStats = getStatsForPeriod(startDate, endDate)
1340
+ const previousStats = getStatsForPeriod(comparisonStartDate, comparisonEndDate)
1341
+
1342
+ // Calculate percentage changes
1343
+ const calcChange = (current: number, previous: number) => {
1344
+ if (previous === 0) return current > 0 ? 100 : 0
1345
+ return Math.round(((current - previous) / previous) * 100)
1346
+ }
1347
+
1348
+ return jsonResponse({
1349
+ current: currentStats,
1350
+ previous: previousStats,
1351
+ changes: {
1352
+ visitors: calcChange(currentStats.visitors, previousStats.visitors),
1353
+ views: calcChange(currentStats.views, previousStats.views),
1354
+ sessions: calcChange(currentStats.sessions, previousStats.sessions),
1355
+ bounceRate: calcChange(currentStats.bounceRate, previousStats.bounceRate),
1356
+ avgDuration: calcChange(currentStats.avgDuration, previousStats.avgDuration),
1357
+ },
1358
+ periods: {
1359
+ current: { start: startDate.toISOString(), end: endDate.toISOString() },
1360
+ previous: { start: comparisonStartDate.toISOString(), end: comparisonEndDate.toISOString() },
1361
+ },
1362
+ })
1363
+ }
1364
+ catch (error) {
1365
+ console.error('Comparison error:', error)
1366
+ return errorResponse('Failed to fetch comparison data')
1367
+ }
1368
+ }