lytx 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/.env.example +37 -0
  2. package/README.md +486 -0
  3. package/alchemy.run.ts +155 -0
  4. package/cli/bootstrap-admin.ts +284 -0
  5. package/cli/deploy-staging.ts +692 -0
  6. package/cli/import-events.ts +628 -0
  7. package/cli/import-sites.ts +518 -0
  8. package/cli/index.ts +609 -0
  9. package/cli/init-db.ts +269 -0
  10. package/cli/migrate-to-durable-objects.ts +564 -0
  11. package/cli/migration-worker.ts +300 -0
  12. package/cli/performance-test.ts +588 -0
  13. package/cli/pg/client.ts +4 -0
  14. package/cli/pg/new-site.ts +153 -0
  15. package/cli/rollback-durable-objects.ts +622 -0
  16. package/cli/seed-data.ts +459 -0
  17. package/cli/setup.js +18 -0
  18. package/cli/setup.ts +463 -0
  19. package/cli/validate-migration.ts +200 -0
  20. package/cli/wrangler-migration.jsonc +28 -0
  21. package/db/adapter.ts +166 -0
  22. package/db/analytics_engine/client.ts +0 -0
  23. package/db/analytics_engine/sites.ts +0 -0
  24. package/db/client.ts +16 -0
  25. package/db/d1/client.ts +8 -0
  26. package/db/d1/drizzle.config.ts +35 -0
  27. package/db/d1/migrations/0000_true_maelstrom.sql +165 -0
  28. package/db/d1/migrations/0001_wonderful_bloodaxe.sql +12 -0
  29. package/db/d1/migrations/0002_late_frightful_four.sql +1 -0
  30. package/db/d1/migrations/0003_cuddly_obadiah_stane.sql +16 -0
  31. package/db/d1/migrations/0004_mute_stardust.sql +1 -0
  32. package/db/d1/migrations/0005_awesome_silvermane.sql +3 -0
  33. package/db/d1/migrations/0006_volatile_shriek.sql +2 -0
  34. package/db/d1/migrations/0007_superb_lila_cheney.sql +1 -0
  35. package/db/d1/migrations/0008_bitter_longshot.sql +17 -0
  36. package/db/d1/migrations/0009_wonderful_madame_masque.sql +28 -0
  37. package/db/d1/migrations/meta/0000_snapshot.json +1112 -0
  38. package/db/d1/migrations/meta/0001_snapshot.json +1187 -0
  39. package/db/d1/migrations/meta/0002_snapshot.json +1194 -0
  40. package/db/d1/migrations/meta/0003_snapshot.json +1296 -0
  41. package/db/d1/migrations/meta/0004_snapshot.json +1303 -0
  42. package/db/d1/migrations/meta/0005_snapshot.json +1325 -0
  43. package/db/d1/migrations/meta/0006_snapshot.json +1339 -0
  44. package/db/d1/migrations/meta/0007_snapshot.json +1347 -0
  45. package/db/d1/migrations/meta/0008_snapshot.json +1464 -0
  46. package/db/d1/migrations/meta/0009_snapshot.json +1648 -0
  47. package/db/d1/migrations/meta/_journal.json +76 -0
  48. package/db/d1/schema.ts +407 -0
  49. package/db/d1/sites.ts +374 -0
  50. package/db/d1/teamAiUsage.ts +101 -0
  51. package/db/d1/teams.ts +127 -0
  52. package/db/durable/drizzle.config.ts +8 -0
  53. package/db/durable/durableObjectClient.ts +480 -0
  54. package/db/durable/events.ts +100 -0
  55. package/db/durable/migrations/0000_fair_bucky.sql +38 -0
  56. package/db/durable/migrations/meta/0000_snapshot.json +278 -0
  57. package/db/durable/migrations/meta/_journal.json +13 -0
  58. package/db/durable/migrations/migrations.js +10 -0
  59. package/db/durable/schema.ts +5 -0
  60. package/db/durable/siteDurableObject.ts +1352 -0
  61. package/db/durable/types.ts +53 -0
  62. package/db/postgres/client.ts +13 -0
  63. package/db/postgres/drizzle.config.ts +12 -0
  64. package/db/postgres/migrations/0000_brainy_sprite.sql +116 -0
  65. package/db/postgres/migrations/meta/0000_snapshot.json +681 -0
  66. package/db/postgres/migrations/meta/_journal.json +13 -0
  67. package/db/postgres/schema.ts +145 -0
  68. package/db/postgres/sites.ts +118 -0
  69. package/db/tranformReports.ts +595 -0
  70. package/db/types.ts +55 -0
  71. package/endpoints/api_worker.tsx +1854 -0
  72. package/endpoints/site_do_worker.ts +11 -0
  73. package/index.d.ts +63 -0
  74. package/index.ts +83 -0
  75. package/lib/auth.ts +279 -0
  76. package/lib/geojson/world_countries.json +45307 -0
  77. package/lib/random_name.ts +41 -0
  78. package/lib/sendMail.ts +252 -0
  79. package/package.json +142 -0
  80. package/public/favicon.ico +0 -0
  81. package/public/images/android-chrome-192x192.png +0 -0
  82. package/public/images/android-chrome-512x512.png +0 -0
  83. package/public/images/apple-touch-icon.png +0 -0
  84. package/public/images/favicon-16x16.png +0 -0
  85. package/public/images/favicon-32x32.png +0 -0
  86. package/public/images/lytx_dark_dashboard.png +0 -0
  87. package/public/images/lytx_light_dashboard.png +0 -0
  88. package/public/images/safari-pinned-tab.svg +4 -0
  89. package/public/logo.png +0 -0
  90. package/public/site.webmanifest +26 -0
  91. package/public/sw.js +107 -0
  92. package/src/Document.tsx +86 -0
  93. package/src/api/ai_api.ts +1156 -0
  94. package/src/api/authMiddleware.ts +45 -0
  95. package/src/api/auth_api.ts +465 -0
  96. package/src/api/event_labels_api.ts +193 -0
  97. package/src/api/events_api.ts +210 -0
  98. package/src/api/queueWorker.ts +303 -0
  99. package/src/api/reports_api.ts +278 -0
  100. package/src/api/seed_api.ts +288 -0
  101. package/src/api/sites_api.ts +904 -0
  102. package/src/api/tag_api.ts +458 -0
  103. package/src/api/tag_api_v2.ts +289 -0
  104. package/src/api/team_api.ts +456 -0
  105. package/src/app/Dashboard.tsx +1339 -0
  106. package/src/app/Events.tsx +974 -0
  107. package/src/app/Explore.tsx +312 -0
  108. package/src/app/Layout.tsx +58 -0
  109. package/src/app/Settings.tsx +1302 -0
  110. package/src/app/components/DashboardCard.tsx +118 -0
  111. package/src/app/components/EditableCell.tsx +123 -0
  112. package/src/app/components/EventForm.tsx +93 -0
  113. package/src/app/components/MarketingFooter.tsx +49 -0
  114. package/src/app/components/MarketingNav.tsx +150 -0
  115. package/src/app/components/Nav.tsx +755 -0
  116. package/src/app/components/NewSiteSetup.tsx +298 -0
  117. package/src/app/components/SQLEditor.tsx +740 -0
  118. package/src/app/components/SiteSelector.tsx +126 -0
  119. package/src/app/components/SiteTag.tsx +42 -0
  120. package/src/app/components/SiteTagInstallCard.tsx +241 -0
  121. package/src/app/components/WorldMapCard.tsx +337 -0
  122. package/src/app/components/charts/ChartComponents.tsx +1481 -0
  123. package/src/app/components/charts/EventFunnel.tsx +45 -0
  124. package/src/app/components/charts/EventSummary.tsx +194 -0
  125. package/src/app/components/charts/SankeyFlows.tsx +72 -0
  126. package/src/app/components/marketing/CheckIcon.tsx +16 -0
  127. package/src/app/components/marketing/MarketingLayout.tsx +23 -0
  128. package/src/app/components/marketing/SectionHeading.tsx +35 -0
  129. package/src/app/components/reports/AskAiWorkspace.tsx +371 -0
  130. package/src/app/components/reports/CreateReportStarter.tsx +74 -0
  131. package/src/app/components/reports/DashboardRouteFiltersContext.tsx +14 -0
  132. package/src/app/components/reports/DashboardToolbar.tsx +154 -0
  133. package/src/app/components/reports/DashboardWorkspaceLayout.tsx +63 -0
  134. package/src/app/components/reports/DashboardWorkspaceShell.tsx +118 -0
  135. package/src/app/components/reports/ReportBuilderWorkspace.tsx +76 -0
  136. package/src/app/components/reports/custom/CustomReportBuilderPage.tsx +1667 -0
  137. package/src/app/components/reports/custom/ReportWidgetChart.tsx +297 -0
  138. package/src/app/components/reports/custom/buildWidgetSql.ts +151 -0
  139. package/src/app/components/reports/custom/chartPalettes.ts +18 -0
  140. package/src/app/components/reports/custom/types.ts +50 -0
  141. package/src/app/components/reports/reportBuilderMenuItems.ts +17 -0
  142. package/src/app/components/reports/useDashboardToolbarControls.tsx +235 -0
  143. package/src/app/components/ui/AlertBanner.tsx +101 -0
  144. package/src/app/components/ui/Button.tsx +55 -0
  145. package/src/app/components/ui/Card.tsx +80 -0
  146. package/src/app/components/ui/Input.tsx +72 -0
  147. package/src/app/components/ui/Link.tsx +23 -0
  148. package/src/app/components/ui/ReportBuilderMenu.tsx +246 -0
  149. package/src/app/components/ui/ThemeToggle.tsx +54 -0
  150. package/src/app/constants.ts +6 -0
  151. package/src/app/headers.ts +33 -0
  152. package/src/app/providers/AuthProvider.tsx +189 -0
  153. package/src/app/providers/ClientProviders.tsx +18 -0
  154. package/src/app/providers/QueryProvider.tsx +23 -0
  155. package/src/app/providers/ThemeProvider.tsx +88 -0
  156. package/src/app/utils/chartThemes.ts +146 -0
  157. package/src/app/utils/keybinds.ts +96 -0
  158. package/src/app/utils/media.tsx +24 -0
  159. package/src/client.tsx +114 -0
  160. package/src/config/createLytxAppConfig.ts +252 -0
  161. package/src/config/resourceNames.ts +88 -0
  162. package/src/db/index.ts +67 -0
  163. package/src/index.css +285 -0
  164. package/src/lib/featureFlags.ts +69 -0
  165. package/src/pages/GetStarted.tsx +290 -0
  166. package/src/pages/Home.tsx +268 -0
  167. package/src/pages/Login.tsx +283 -0
  168. package/src/pages/PrivacyPolicy.tsx +120 -0
  169. package/src/pages/Signup.tsx +267 -0
  170. package/src/pages/TermsOfService.tsx +126 -0
  171. package/src/pages/VerifyEmail.tsx +56 -0
  172. package/src/session/durableObject.ts +7 -0
  173. package/src/session/siteSchema.ts +86 -0
  174. package/src/session/types.ts +36 -0
  175. package/src/templates/README.md +80 -0
  176. package/src/templates/cleanFunctions.js +44 -0
  177. package/src/templates/embedFunctions.js +52 -0
  178. package/src/templates/lytx-shared.ts +662 -0
  179. package/src/templates/lytxpixel-core.ts +144 -0
  180. package/src/templates/lytxpixel.ts +267 -0
  181. package/src/templates/lytxpixelBrowser.js +634 -0
  182. package/src/templates/lytxpixelBrowser.mjs +634 -0
  183. package/src/templates/parseData.js +12 -0
  184. package/src/templates/script.ts +31 -0
  185. package/src/templates/template.tsx +50 -0
  186. package/src/templates/test.js +3 -0
  187. package/src/templates/trackWebEvents.ts +177 -0
  188. package/src/templates/vendors/clickcease.ts +8 -0
  189. package/src/templates/vendors/google.ts +174 -0
  190. package/src/templates/vendors/linkedin.ts +23 -0
  191. package/src/templates/vendors/meta.ts +56 -0
  192. package/src/templates/vendors/quantcast.ts +22 -0
  193. package/src/templates/vendors/simplfi.ts +7 -0
  194. package/src/types/app-context.ts +16 -0
  195. package/src/utilities/dashboardParams.ts +188 -0
  196. package/src/utilities/dashboardQueries.ts +537 -0
  197. package/src/utilities/dashboardTransforms.ts +167 -0
  198. package/src/utilities/dataValidation.ts +414 -0
  199. package/src/utilities/detector.ts +73 -0
  200. package/src/utilities/encrypt.ts +103 -0
  201. package/src/utilities/index.ts +13 -0
  202. package/src/utilities/parser.ts +117 -0
  203. package/src/utilities/performanceMonitoring.ts +570 -0
  204. package/src/utilities/route_interuptors.ts +24 -0
  205. package/src/worker.tsx +675 -0
  206. package/tsconfig.json +78 -0
  207. package/types/env.d.ts +16 -0
  208. package/types/rw.d.ts +7 -0
  209. package/types/shims.d.ts +53 -0
  210. package/types/vite.d.ts +19 -0
  211. package/vite/vite-plugin-pixel-bundle.ts +126 -0
  212. package/vite.config.ts +53 -0
  213. package/worker-configuration.d.ts +8401 -0
@@ -0,0 +1,297 @@
1
+ "use client";
2
+
3
+ import { ResponsiveBar } from "@nivo/bar";
4
+ import { ResponsiveLine } from "@nivo/line";
5
+ import { ResponsivePie } from "@nivo/pie";
6
+ import { ResponsiveFunnel } from "@nivo/funnel";
7
+ import { ResponsiveSankey } from "@nivo/sankey";
8
+ import { useTheme } from "@/app/providers/ThemeProvider";
9
+ import { createChartTheme } from "@/app/utils/chartThemes";
10
+ import { reportColorPalettes } from "@/app/components/reports/custom/chartPalettes";
11
+ import { WorldMapCard } from "@/app/components/WorldMapCard";
12
+ import type { CustomReportWidgetConfig } from "@/app/components/reports/custom/types";
13
+
14
+ type ReportWidgetChartProps = {
15
+ widget: CustomReportWidgetConfig;
16
+ rows: Array<Record<string, unknown>>;
17
+ height?: number;
18
+ labelsMap?: Map<string, string>;
19
+ };
20
+
21
+ const toNumber = (value: unknown) => {
22
+ const numeric = Number(value);
23
+ return Number.isFinite(numeric) ? numeric : 0;
24
+ };
25
+
26
+ const truncateAxisLabel = (value: unknown, max = 20) => {
27
+ const label = String(value ?? "").trim();
28
+ if (label.length <= max) return label;
29
+ return `${label.slice(0, max - 3)}...`;
30
+ };
31
+
32
+ const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
33
+
34
+ const isValidHexColor = (value: unknown): value is string =>
35
+ typeof value === "string" && HEX_COLOR_PATTERN.test(value.trim());
36
+
37
+ const hasDirectedCycle = (nodes: string[], links: Array<{ source: string; target: string }>) => {
38
+ const adjacency = new Map<string, string[]>();
39
+ nodes.forEach((node) => adjacency.set(node, []));
40
+
41
+ for (const link of links) {
42
+ const sourceTargets = adjacency.get(link.source);
43
+ if (sourceTargets) {
44
+ sourceTargets.push(link.target);
45
+ }
46
+ }
47
+
48
+ const visiting = new Set<string>();
49
+ const visited = new Set<string>();
50
+
51
+ const dfs = (node: string): boolean => {
52
+ if (visiting.has(node)) return true;
53
+ if (visited.has(node)) return false;
54
+
55
+ visiting.add(node);
56
+ const targets = adjacency.get(node) ?? [];
57
+ for (const target of targets) {
58
+ if (dfs(target)) return true;
59
+ }
60
+ visiting.delete(node);
61
+ visited.add(node);
62
+ return false;
63
+ };
64
+
65
+ for (const node of nodes) {
66
+ if (dfs(node)) return true;
67
+ }
68
+
69
+ return false;
70
+ };
71
+
72
+ const getAutocaptureDisplayName = (eventName: string): string => {
73
+ if (!eventName.startsWith("$ac_")) return eventName;
74
+
75
+ const parts = eventName.split("_");
76
+ const elementText = parts[2] || "unnamed";
77
+ const elementId = parts[3] || null;
78
+
79
+ return elementId ? `${elementText}_${elementId}` : elementText;
80
+ };
81
+
82
+ export function ReportWidgetChart({ widget, rows, height = 280, labelsMap }: ReportWidgetChartProps) {
83
+ const { theme } = useTheme();
84
+ const chartTheme = createChartTheme(theme === "dark");
85
+ const basePalette = reportColorPalettes[widget.colorPalette];
86
+ const customPalette = [widget.customPrimaryColor, widget.customSecondaryColor]
87
+ .filter(isValidHexColor)
88
+ .map((color) => color.trim());
89
+ const palette = customPalette.length > 0
90
+ ? [...customPalette, ...basePalette]
91
+ : basePalette;
92
+ const legendTextColor = theme === "dark" ? "#ffffff" : "#4b5563";
93
+
94
+ const mapEventLabel = (value: unknown, fieldName: string) => {
95
+ const text = String(value ?? "Unknown");
96
+ if (fieldName !== "event") return text;
97
+ return labelsMap?.get(text) || getAutocaptureDisplayName(text);
98
+ };
99
+
100
+ const categoryRows = rows.map((row) => ({
101
+ x: mapEventLabel(row.x ?? row.label ?? "Unknown", widget.xField),
102
+ y: toNumber(row.y ?? row.value ?? 0),
103
+ }));
104
+
105
+ if (widget.chartType === "line") {
106
+ if (categoryRows.length < 2) {
107
+ return (
108
+ <div style={{ height }} className="flex items-center justify-center text-sm text-(--theme-text-secondary)">
109
+ Need at least 2 points to render a line chart.
110
+ </div>
111
+ );
112
+ }
113
+
114
+ const data = [
115
+ {
116
+ id: widget.title || widget.id,
117
+ data: categoryRows.map((item) => ({ x: item.x, y: item.y })),
118
+ },
119
+ ];
120
+
121
+ return (
122
+ <div style={{ height }}>
123
+ <ResponsiveLine
124
+ data={data}
125
+ margin={{ top: 20, right: 24, bottom: 44, left: 56 }}
126
+ xScale={{ type: "point" }}
127
+ yScale={{ type: "linear", min: 0, max: "auto", stacked: false, reverse: false }}
128
+ pointSize={8}
129
+ pointBorderWidth={2}
130
+ pointLabelYOffset={-12}
131
+ enableArea
132
+ areaOpacity={0.2}
133
+ useMesh
134
+ colors={palette}
135
+ theme={chartTheme}
136
+ />
137
+ </div>
138
+ );
139
+ }
140
+
141
+ if (widget.chartType === "bar") {
142
+ const data = categoryRows.map((item) => ({ x: item.x, y: item.y }));
143
+ return (
144
+ <div style={{ height }}>
145
+ <ResponsiveBar
146
+ data={data}
147
+ keys={["y"]}
148
+ indexBy="x"
149
+ margin={{ top: 20, right: 24, bottom: 56, left: 72 }}
150
+ padding={0.3}
151
+ colors={palette}
152
+ valueScale={{ type: "linear" }}
153
+ indexScale={{ type: "band", round: true }}
154
+ theme={chartTheme}
155
+ axisBottom={{
156
+ tickRotation: -20,
157
+ tickPadding: 14,
158
+ format: (value) => truncateAxisLabel(value),
159
+ }}
160
+ />
161
+ </div>
162
+ );
163
+ }
164
+
165
+ if (widget.chartType === "pie") {
166
+ const data = categoryRows.map((item) => ({ id: item.x || "Unknown", label: item.x || "Unknown", value: item.y }));
167
+
168
+ return (
169
+ <div style={{ height }}>
170
+ <ResponsivePie
171
+ data={data}
172
+ margin={{ top: 20, right: 24, bottom: 44, left: 24 }}
173
+ innerRadius={0.5}
174
+ padAngle={0.7}
175
+ cornerRadius={3}
176
+ activeOuterRadiusOffset={8}
177
+ colors={palette}
178
+ theme={chartTheme}
179
+ legends={[
180
+ {
181
+ anchor: "bottom",
182
+ direction: "row",
183
+ justify: false,
184
+ translateY: 36,
185
+ itemWidth: 90,
186
+ itemHeight: 18,
187
+ itemsSpacing: 4,
188
+ symbolSize: 12,
189
+ symbolShape: "circle",
190
+ itemTextColor: legendTextColor,
191
+ },
192
+ ]}
193
+ />
194
+ </div>
195
+ );
196
+ }
197
+
198
+ if (widget.chartType === "funnel") {
199
+ const data = categoryRows
200
+ .map((item) => ({ id: item.x || "Unknown", value: item.y, label: item.x || "Unknown" }))
201
+ .filter((item) => item.value > 0);
202
+
203
+ if (data.length < 2) {
204
+ return (
205
+ <div style={{ height }} className="flex items-center justify-center text-sm text-(--theme-text-secondary)">
206
+ Need at least 2 funnel steps with values.
207
+ </div>
208
+ );
209
+ }
210
+
211
+ return (
212
+ <div style={{ height }}>
213
+ <ResponsiveFunnel
214
+ data={data}
215
+ margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
216
+ interpolation="smooth"
217
+ shapeBlending={0.9}
218
+ colors={palette}
219
+ theme={chartTheme}
220
+ />
221
+ </div>
222
+ );
223
+ }
224
+
225
+ if (widget.chartType === "map") {
226
+ const aggregatedCountries = categoryRows
227
+ .map((item) => [item.x.trim().toUpperCase(), item.y] as [string, number])
228
+ .filter(([countryCode, value]) => countryCode.length > 0 && value > 0);
229
+
230
+ return (
231
+ <WorldMapCard
232
+ aggregatedCountries={aggregatedCountries}
233
+ isDark={theme === "dark"}
234
+ metricLabel="events"
235
+ height={height}
236
+ embedded
237
+ />
238
+ );
239
+ }
240
+
241
+ const links = rows
242
+ .map((row) => ({
243
+ source: mapEventLabel(row.source ?? "Unknown", widget.sourceField),
244
+ target: mapEventLabel(row.target ?? "Unknown", widget.targetField),
245
+ value: toNumber(row.value ?? 0),
246
+ }))
247
+ .filter((link) => link.value > 0)
248
+ .filter((link) => link.source.trim().length > 0 && link.target.trim().length > 0)
249
+ .filter((link) => link.source !== link.target);
250
+
251
+ const nodeSet = new Set<string>();
252
+ links.forEach((link) => {
253
+ nodeSet.add(link.source);
254
+ nodeSet.add(link.target);
255
+ });
256
+
257
+ const nodes = Array.from(nodeSet);
258
+
259
+ if (nodes.length < 2 || links.length === 0) {
260
+ return (
261
+ <div style={{ height }} className="flex items-center justify-center text-sm text-(--theme-text-secondary)">
262
+ Need at least 2 connected nodes for sankey.
263
+ </div>
264
+ );
265
+ }
266
+
267
+ if (hasDirectedCycle(nodes, links)) {
268
+ return (
269
+ <div style={{ height }} className="flex items-center justify-center text-sm text-(--theme-text-secondary)">
270
+ Sankey requires an acyclic flow graph.
271
+ </div>
272
+ );
273
+ }
274
+
275
+ const sankeyData = {
276
+ nodes: nodes.map((id) => ({ id })),
277
+ links,
278
+ };
279
+
280
+ return (
281
+ <div style={{ height }}>
282
+ <ResponsiveSankey
283
+ data={sankeyData}
284
+ margin={{ top: 20, right: 24, bottom: 20, left: 24 }}
285
+ nodeOpacity={1}
286
+ nodeHoverOthersOpacity={0.35}
287
+ nodeThickness={14}
288
+ nodeSpacing={16}
289
+ nodeBorderWidth={0}
290
+ linkOpacity={0.4}
291
+ colors={palette}
292
+ theme={chartTheme}
293
+ labelTextColor={legendTextColor}
294
+ />
295
+ </div>
296
+ );
297
+ }
@@ -0,0 +1,151 @@
1
+ import type { CustomReportWidgetConfig } from "@/app/components/reports/custom/types";
2
+
3
+ export type WidgetQueryFilters = {
4
+ dateRange?: {
5
+ start: string;
6
+ end: string;
7
+ };
8
+ deviceType?: string;
9
+ country?: string;
10
+ city?: string;
11
+ region?: string;
12
+ source?: string;
13
+ pageUrl?: string;
14
+ eventName?: string;
15
+ };
16
+
17
+ const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
18
+
19
+ const clampLimit = (value: number) => {
20
+ if (!Number.isFinite(value)) return 25;
21
+ return Math.min(Math.max(Math.floor(value), 1), 500);
22
+ };
23
+
24
+ const sanitizeIdentifier = (value: string, allowedColumns: Set<string>) => {
25
+ if (!SAFE_IDENTIFIER_PATTERN.test(value)) {
26
+ throw new Error(`Invalid field name: ${value}`);
27
+ }
28
+ if (!allowedColumns.has(value)) {
29
+ throw new Error(`Field is not available in site_events: ${value}`);
30
+ }
31
+ return `"${value}"`;
32
+ };
33
+
34
+ const getMetricExpression = (
35
+ widget: CustomReportWidgetConfig,
36
+ allowedColumns: Set<string>,
37
+ ) => {
38
+ if (widget.aggregation === "count") {
39
+ return "COUNT(*)";
40
+ }
41
+
42
+ if (widget.aggregation === "unique_users") {
43
+ return "COUNT(DISTINCT rid)";
44
+ }
45
+
46
+ const yField = sanitizeIdentifier(widget.yField, allowedColumns);
47
+ if (widget.aggregation === "sum") {
48
+ return `SUM(COALESCE(CAST(${yField} AS REAL), 0))`;
49
+ }
50
+
51
+ return `AVG(COALESCE(CAST(${yField} AS REAL), 0))`;
52
+ };
53
+
54
+ const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
55
+
56
+ const escapeSqlLiteral = (value: string) => value.replace(/'/g, "''");
57
+
58
+ const buildWhereClauses = (filters?: WidgetQueryFilters) => {
59
+ if (!filters) return [] as string[];
60
+
61
+ const clauses: string[] = [];
62
+ const start = filters.dateRange?.start;
63
+ const end = filters.dateRange?.end;
64
+
65
+ if (start && end && DATE_ONLY_PATTERN.test(start) && DATE_ONLY_PATTERN.test(end)) {
66
+ clauses.push(
67
+ `created_at >= CAST(strftime('%s', '${escapeSqlLiteral(start)} 00:00:00') AS INTEGER)`,
68
+ );
69
+ clauses.push(
70
+ `created_at <= CAST(strftime('%s', '${escapeSqlLiteral(end)} 23:59:59') AS INTEGER)`,
71
+ );
72
+ }
73
+
74
+ if (filters.deviceType) {
75
+ clauses.push(`COALESCE(CAST("device_type" AS TEXT), '') = '${escapeSqlLiteral(filters.deviceType)}'`);
76
+ }
77
+ if (filters.country) {
78
+ clauses.push(`COALESCE(CAST("country" AS TEXT), '') = '${escapeSqlLiteral(filters.country)}'`);
79
+ }
80
+ if (filters.city) {
81
+ clauses.push(`COALESCE(CAST("city" AS TEXT), '') = '${escapeSqlLiteral(filters.city)}'`);
82
+ }
83
+ if (filters.region) {
84
+ clauses.push(`COALESCE(CAST("region" AS TEXT), '') = '${escapeSqlLiteral(filters.region)}'`);
85
+ }
86
+ if (filters.source) {
87
+ clauses.push(`COALESCE(CAST("referer" AS TEXT), '') = '${escapeSqlLiteral(filters.source)}'`);
88
+ }
89
+ if (filters.pageUrl) {
90
+ clauses.push(
91
+ `COALESCE(CAST("client_page_url" AS TEXT), CAST("page_url" AS TEXT), '') = '${escapeSqlLiteral(filters.pageUrl)}'`,
92
+ );
93
+ }
94
+ if (filters.eventName) {
95
+ clauses.push(`COALESCE(CAST("event" AS TEXT), '') = '${escapeSqlLiteral(filters.eventName)}'`);
96
+ }
97
+
98
+ return clauses;
99
+ };
100
+
101
+ export function buildSqlForWidget(
102
+ widget: CustomReportWidgetConfig,
103
+ availableColumns: string[],
104
+ filters?: WidgetQueryFilters,
105
+ ) {
106
+ const allowedColumns = new Set(availableColumns);
107
+ const limit = clampLimit(widget.limit);
108
+ const metricExpression = getMetricExpression(widget, allowedColumns);
109
+ const whereClauses = buildWhereClauses(filters);
110
+ const whereSql = whereClauses.length > 0 ? `WHERE ${whereClauses.join(" AND ")}` : "";
111
+
112
+ if (widget.chartType === "sankey") {
113
+ const sourceField = sanitizeIdentifier(widget.sourceField, allowedColumns);
114
+ const targetField = sanitizeIdentifier(widget.targetField, allowedColumns);
115
+
116
+ return [
117
+ `SELECT COALESCE(CAST(${sourceField} AS TEXT), 'Unknown') AS source,`,
118
+ `COALESCE(CAST(${targetField} AS TEXT), 'Unknown') AS target,`,
119
+ `${metricExpression} AS value`,
120
+ "FROM site_events",
121
+ whereSql,
122
+ "GROUP BY 1, 2",
123
+ "ORDER BY value DESC",
124
+ `LIMIT ${limit}`,
125
+ ].join(" ");
126
+ }
127
+
128
+ if (widget.xField === "created_at") {
129
+ return [
130
+ "SELECT strftime('%Y-%m-%d', created_at, 'unixepoch') AS x,",
131
+ `${metricExpression} AS y`,
132
+ "FROM site_events",
133
+ whereSql,
134
+ "GROUP BY 1",
135
+ "ORDER BY 1 ASC",
136
+ `LIMIT ${limit}`,
137
+ ].join(" ");
138
+ }
139
+
140
+ const xField = sanitizeIdentifier(widget.xField, allowedColumns);
141
+
142
+ return [
143
+ `SELECT COALESCE(CAST(${xField} AS TEXT), 'Unknown') AS x,`,
144
+ `${metricExpression} AS y`,
145
+ "FROM site_events",
146
+ whereSql,
147
+ "GROUP BY 1",
148
+ "ORDER BY y DESC",
149
+ `LIMIT ${limit}`,
150
+ ].join(" ");
151
+ }
@@ -0,0 +1,18 @@
1
+ import { chartColors } from "@/app/utils/chartThemes";
2
+ import type { ReportColorPalette } from "@/app/components/reports/custom/types";
3
+
4
+ export const reportColorPalettes: Record<ReportColorPalette, string[]> = {
5
+ primary: chartColors.primary,
6
+ secondary: chartColors.secondary,
7
+ mixed: chartColors.mixed,
8
+ line: chartColors.line,
9
+ funnel: chartColors.funnel,
10
+ };
11
+
12
+ export const reportPaletteOptions: Array<{ value: ReportColorPalette; label: string }> = [
13
+ { value: "primary", label: "Brand Primary" },
14
+ { value: "secondary", label: "Brand Secondary" },
15
+ { value: "mixed", label: "Brand Blend" },
16
+ { value: "line", label: "Brand Focus" },
17
+ { value: "funnel", label: "Brand Warm" },
18
+ ];
@@ -0,0 +1,50 @@
1
+ export type ReportChartType = "bar" | "line" | "pie" | "funnel" | "sankey" | "map";
2
+
3
+ export type ReportAggregation = "count" | "unique_users" | "sum" | "avg";
4
+
5
+ export type ReportColorPalette = "primary" | "secondary" | "mixed" | "line" | "funnel";
6
+
7
+ export type ReportWidgetLayout = {
8
+ x: number;
9
+ y: number;
10
+ w: number;
11
+ h: number;
12
+ };
13
+
14
+ export type CustomReportWidgetConfig = {
15
+ id: string;
16
+ title: string;
17
+ chartType: ReportChartType;
18
+ xField: string;
19
+ yField: string;
20
+ aggregation: ReportAggregation;
21
+ sourceField: string;
22
+ targetField: string;
23
+ colorPalette: ReportColorPalette;
24
+ customPrimaryColor?: string | null;
25
+ customSecondaryColor?: string | null;
26
+ limit: number;
27
+ layout: ReportWidgetLayout;
28
+ };
29
+
30
+ export type CustomReportConfig = {
31
+ version: 1;
32
+ widgets: CustomReportWidgetConfig[];
33
+ };
34
+
35
+ export type CustomReportRecord = {
36
+ uuid: string;
37
+ site_id: number;
38
+ team_id: number;
39
+ name: string;
40
+ description: string | null;
41
+ config: CustomReportConfig;
42
+ };
43
+
44
+ export type SiteEventsSchemaColumn = {
45
+ name: string;
46
+ type: string;
47
+ nullable: boolean;
48
+ primaryKey: boolean;
49
+ defaultValue: string | null;
50
+ };
@@ -0,0 +1,17 @@
1
+ import type { ReportBuilderMenuItem } from "@/app/components/ui/ReportBuilderMenu";
2
+
3
+ export const getDashboardReportBuilderMenuItems = ({
4
+ askAiEnabled,
5
+ }: {
6
+ askAiEnabled: boolean;
7
+ }): ReportBuilderMenuItem[] => {
8
+ const items: ReportBuilderMenuItem[] = [
9
+ { id: "create-report", label: "Create report", href: "/dashboard/reports/create-report" },
10
+ ];
11
+
12
+ if (askAiEnabled) {
13
+ items.push({ id: "ask-ai", label: "Ask AI", href: "/dashboard/reports/ask-ai" });
14
+ }
15
+
16
+ return items;
17
+ };