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,1667 @@
1
+ "use client";
2
+
3
+ import {
4
+ useContext,
5
+ useEffect,
6
+ useId,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from "react";
11
+ import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
12
+ import { AuthContext } from "@/app/providers/AuthProvider";
13
+ import { AlertBanner } from "@/app/components/ui/AlertBanner";
14
+ import { DashboardCard } from "@/app/components/DashboardCard";
15
+ import { HelpTooltip } from "@/app/components/charts/ChartComponents";
16
+ import { ReportWidgetChart } from "@/app/components/reports/custom/ReportWidgetChart";
17
+ import { buildSqlForWidget } from "@/app/components/reports/custom/buildWidgetSql";
18
+ import {
19
+ reportColorPalettes,
20
+ reportPaletteOptions,
21
+ } from "@/app/components/reports/custom/chartPalettes";
22
+ import { useDashboardRouteFilters } from "@/app/components/reports/DashboardRouteFiltersContext";
23
+ import type {
24
+ CustomReportConfig,
25
+ CustomReportRecord,
26
+ CustomReportWidgetConfig,
27
+ ReportAggregation,
28
+ ReportChartType,
29
+ ReportColorPalette,
30
+ SiteEventsSchemaColumn,
31
+ } from "@/app/components/reports/custom/types";
32
+ import type { EventLabelSelect } from "@db/d1/schema";
33
+
34
+ type RowMode = "split" | "full";
35
+
36
+ type CanvasRow = {
37
+ mode: RowMode;
38
+ full: CustomReportWidgetConfig | null;
39
+ left: CustomReportWidgetConfig | null;
40
+ right: CustomReportWidgetConfig | null;
41
+ };
42
+
43
+ const ROW_HEIGHT = 4;
44
+ const HALF_SLOT_WIDTH = 6;
45
+ const FULL_SLOT_WIDTH = 12;
46
+ const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
47
+
48
+ const normalizeHexColor = (value: string): string | null => {
49
+ const trimmed = value.trim();
50
+ if (!trimmed) return null;
51
+
52
+ const withHash = trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
53
+ if (!HEX_COLOR_PATTERN.test(withHash)) return null;
54
+ return withHash.toUpperCase();
55
+ };
56
+
57
+ const getHalfSlotLayout = (rowIndex: number, slotIndex: 0 | 1): CustomReportWidgetConfig["layout"] => ({
58
+ x: slotIndex === 0 ? 0 : HALF_SLOT_WIDTH,
59
+ y: rowIndex * ROW_HEIGHT,
60
+ w: HALF_SLOT_WIDTH,
61
+ h: ROW_HEIGHT,
62
+ });
63
+
64
+ const getFullRowLayout = (rowIndex: number): CustomReportWidgetConfig["layout"] => ({
65
+ x: 0,
66
+ y: rowIndex * ROW_HEIGHT,
67
+ w: FULL_SLOT_WIDTH,
68
+ h: ROW_HEIGHT,
69
+ });
70
+
71
+ const getRowIndexFromLayout = (layout: CustomReportWidgetConfig["layout"]) =>
72
+ Math.max(0, Math.floor(layout.y / ROW_HEIGHT));
73
+
74
+ const getSlotIndexFromLayout = (layout: CustomReportWidgetConfig["layout"]) =>
75
+ layout.x >= HALF_SLOT_WIDTH ? 1 : 0;
76
+
77
+ const isFullRowLayout = (layout: CustomReportWidgetConfig["layout"]) => layout.w >= FULL_SLOT_WIDTH;
78
+
79
+ const buildCanvasRows = (widgetList: CustomReportWidgetConfig[]): CanvasRow[] => {
80
+ const groupedRows = new Map<number, CustomReportWidgetConfig[]>();
81
+
82
+ for (const widget of widgetList) {
83
+ const rowIndex = getRowIndexFromLayout(widget.layout);
84
+ const rowWidgets = groupedRows.get(rowIndex) ?? [];
85
+ rowWidgets.push(widget);
86
+ groupedRows.set(rowIndex, rowWidgets);
87
+ }
88
+
89
+ const rowIndexes = [...groupedRows.keys()].toSorted((a, b) => a - b);
90
+ const rows: CanvasRow[] = [];
91
+ const overflow: CustomReportWidgetConfig[] = [];
92
+
93
+ for (const rowIndex of rowIndexes) {
94
+ const rowWidgets = groupedRows.get(rowIndex) ?? [];
95
+ const fullWidget = rowWidgets.find((widget) => isFullRowLayout(widget.layout));
96
+
97
+ if (fullWidget) {
98
+ rows.push({ mode: "full", full: fullWidget, left: null, right: null });
99
+ for (const widget of rowWidgets) {
100
+ if (widget.id !== fullWidget.id) overflow.push(widget);
101
+ }
102
+ continue;
103
+ }
104
+
105
+ const ordered = [...rowWidgets].toSorted((a, b) => getSlotIndexFromLayout(a.layout) - getSlotIndexFromLayout(b.layout));
106
+ rows.push({
107
+ mode: "split",
108
+ full: null,
109
+ left: ordered[0] ?? null,
110
+ right: ordered[1] ?? null,
111
+ });
112
+
113
+ for (const widget of ordered.slice(2)) {
114
+ overflow.push(widget);
115
+ }
116
+ }
117
+
118
+ while (overflow.length > 0) {
119
+ const first = overflow.shift()!;
120
+ if (isFullRowLayout(first.layout)) {
121
+ rows.push({ mode: "full", full: first, left: null, right: null });
122
+ continue;
123
+ }
124
+
125
+ const second = overflow[0] && !isFullRowLayout(overflow[0].layout)
126
+ ? overflow.shift() ?? null
127
+ : null;
128
+
129
+ rows.push({
130
+ mode: "split",
131
+ full: null,
132
+ left: first,
133
+ right: second,
134
+ });
135
+ }
136
+
137
+ return rows;
138
+ };
139
+
140
+ const flattenCanvasRows = (rows: CanvasRow[]): CustomReportWidgetConfig[] => {
141
+ const nextWidgets: CustomReportWidgetConfig[] = [];
142
+
143
+ rows.forEach((row, rowIndex) => {
144
+ if (row.mode === "full") {
145
+ const widget = row.full ?? row.left ?? row.right;
146
+ if (!widget) return;
147
+ nextWidgets.push({
148
+ ...widget,
149
+ layout: getFullRowLayout(rowIndex),
150
+ });
151
+ return;
152
+ }
153
+
154
+ if (row.left) {
155
+ nextWidgets.push({
156
+ ...row.left,
157
+ layout: getHalfSlotLayout(rowIndex, 0),
158
+ });
159
+ }
160
+ if (row.right) {
161
+ nextWidgets.push({
162
+ ...row.right,
163
+ layout: getHalfSlotLayout(rowIndex, 1),
164
+ });
165
+ }
166
+ });
167
+
168
+ return nextWidgets;
169
+ };
170
+
171
+ const normalizeWidgetsForRows = (widgetList: CustomReportWidgetConfig[]) =>
172
+ flattenCanvasRows(buildCanvasRows(widgetList));
173
+
174
+ const getFocusableElements = (container: HTMLElement | null): HTMLElement[] => {
175
+ if (!container) return [];
176
+
177
+ const selectors = [
178
+ "a[href]",
179
+ "button:not([disabled])",
180
+ "input:not([disabled])",
181
+ "select:not([disabled])",
182
+ "textarea:not([disabled])",
183
+ "[tabindex]:not([tabindex='-1'])",
184
+ ];
185
+
186
+ return Array.from(container.querySelectorAll<HTMLElement>(selectors.join(","))).filter(
187
+ (element) =>
188
+ !element.hasAttribute("disabled") &&
189
+ element.getAttribute("aria-hidden") !== "true",
190
+ );
191
+ };
192
+
193
+ const chartTypeOptions: Array<{ value: ReportChartType; label: string }> = [
194
+ { value: "bar", label: "Bar" },
195
+ { value: "line", label: "Line" },
196
+ { value: "pie", label: "Pie" },
197
+ { value: "funnel", label: "Funnel" },
198
+ { value: "sankey", label: "Sankey" },
199
+ { value: "map", label: "Map" },
200
+ ];
201
+
202
+ const aggregationOptions: Array<{ value: ReportAggregation; label: string }> = [
203
+ { value: "count", label: "Count" },
204
+ { value: "unique_users", label: "Unique users" },
205
+ { value: "sum", label: "Sum of y" },
206
+ { value: "avg", label: "Average of y" },
207
+ ];
208
+
209
+ type CustomReportBuilderPageProps = {
210
+ reportUuid?: string;
211
+ initialTemplate?: string | null;
212
+ };
213
+
214
+ type SchemaResponse = {
215
+ tables?: Array<{ columns?: SiteEventsSchemaColumn[] }>;
216
+ error?: string;
217
+ };
218
+
219
+ type ReportApiResponse = {
220
+ report?: CustomReportRecord;
221
+ error?: string;
222
+ };
223
+
224
+ type WidgetColorField = "customPrimaryColor" | "customSecondaryColor";
225
+
226
+ const makeWidgetId = () =>
227
+ typeof crypto !== "undefined" && "randomUUID" in crypto
228
+ ? crypto.randomUUID()
229
+ : `widget_${Math.random().toString(36).slice(2)}`;
230
+
231
+ const pickFirstColumn = (columns: string[], preferred: string[]) => {
232
+ for (const value of preferred) {
233
+ if (columns.includes(value)) return value;
234
+ }
235
+ return columns[0] || "event";
236
+ };
237
+
238
+ const createWidget = (
239
+ chartType: ReportChartType,
240
+ availableColumns: string[],
241
+ ): CustomReportWidgetConfig => {
242
+ const xField = chartType === "map"
243
+ ? pickFirstColumn(availableColumns, ["country", "region", "city", "event"])
244
+ : pickFirstColumn(availableColumns, [
245
+ "event",
246
+ "client_page_url",
247
+ "referer",
248
+ "country",
249
+ "city",
250
+ "device_type",
251
+ "created_at",
252
+ ]);
253
+ const yField = pickFirstColumn(availableColumns, [
254
+ "screen_width",
255
+ "screen_height",
256
+ "id",
257
+ ]);
258
+ const sourceField = pickFirstColumn(availableColumns, ["referer", "country", "city", "event"]);
259
+ const targetField = pickFirstColumn(availableColumns, ["event", "client_page_url", "device_type", "country"]);
260
+
261
+ return {
262
+ id: makeWidgetId(),
263
+ title: `New ${chartType} chart`,
264
+ chartType,
265
+ xField,
266
+ yField,
267
+ aggregation: "count",
268
+ sourceField,
269
+ targetField,
270
+ colorPalette: "primary",
271
+ customPrimaryColor: null,
272
+ customSecondaryColor: null,
273
+ limit: 20,
274
+ layout: getHalfSlotLayout(0, 0),
275
+ };
276
+ };
277
+
278
+ const createTemplateWidgets = (
279
+ template: string | null | undefined,
280
+ availableColumns: string[],
281
+ ): CustomReportWidgetConfig[] => {
282
+ if (template === "ecomm-tracker") {
283
+ return [
284
+ {
285
+ ...createWidget("line", availableColumns),
286
+ title: "Daily page views",
287
+ xField: "created_at",
288
+ colorPalette: "line",
289
+ },
290
+ {
291
+ ...createWidget("bar", availableColumns),
292
+ title: "Top product pages",
293
+ xField: pickFirstColumn(availableColumns, ["client_page_url", "page_url", "event"]),
294
+ colorPalette: "primary",
295
+ },
296
+ ];
297
+ }
298
+
299
+ if (template === "marketing-leads") {
300
+ return [
301
+ {
302
+ ...createWidget("bar", availableColumns),
303
+ title: "Leads by source",
304
+ xField: pickFirstColumn(availableColumns, ["referer", "country", "event"]),
305
+ colorPalette: "mixed",
306
+ },
307
+ {
308
+ ...createWidget("pie", availableColumns),
309
+ title: "Leads by region",
310
+ xField: pickFirstColumn(availableColumns, ["region", "country", "city"]),
311
+ colorPalette: "secondary",
312
+ },
313
+ ];
314
+ }
315
+
316
+ return [];
317
+ };
318
+
319
+ export function CustomReportBuilderPage({ reportUuid, initialTemplate }: CustomReportBuilderPageProps) {
320
+ const queryClient = useQueryClient();
321
+ const isExistingReport = Boolean(reportUuid);
322
+ const { current_site, data: session, isPending: isSessionLoading } = useContext(AuthContext) || {
323
+ current_site: null,
324
+ data: null,
325
+ isPending: true,
326
+ };
327
+
328
+ const fallbackSiteId = session?.userSites?.[0]?.site_id ?? null;
329
+ const preferredSiteId = current_site?.id ?? fallbackSiteId;
330
+ const routeFilterContext = useDashboardRouteFilters();
331
+ const activeFilters = routeFilterContext?.filters;
332
+
333
+ const [reportName, setReportName] = useState("Untitled custom report");
334
+ const [reportSiteId, setReportSiteId] = useState<number | null>(preferredSiteId);
335
+ const [widgets, setWidgets] = useState<CustomReportWidgetConfig[]>([]);
336
+ const [selectedChartType, setSelectedChartType] = useState<ReportChartType>("bar");
337
+ const [selectedWidgetId, setSelectedWidgetId] = useState<string | null>(null);
338
+ const [isEditing, setIsEditing] = useState(!isExistingReport);
339
+ const [notice, setNotice] = useState<string | null>(null);
340
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
341
+ const [isSaving, setIsSaving] = useState(false);
342
+ const [isDeleting, setIsDeleting] = useState(false);
343
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
344
+ const [extraRows, setExtraRows] = useState(0);
345
+ const [rowModeOverrides, setRowModeOverrides] = useState<Record<number, RowMode>>({});
346
+ const [editingWidgetTitleId, setEditingWidgetTitleId] = useState<string | null>(null);
347
+ const [widgetTitleDraft, setWidgetTitleDraft] = useState("");
348
+ const [primaryColorDraft, setPrimaryColorDraft] = useState("");
349
+ const [secondaryColorDraft, setSecondaryColorDraft] = useState("");
350
+
351
+ const hasHydratedReportRef = useRef(false);
352
+ const hasInitializedTemplateRef = useRef(false);
353
+ const deleteModalRef = useRef<HTMLDivElement | null>(null);
354
+ const previousFocusedElementRef = useRef<HTMLElement | null>(null);
355
+ const deleteCancelButtonRef = useRef<HTMLButtonElement | null>(null);
356
+ const widgetTitleInputRef = useRef<HTMLInputElement | null>(null);
357
+ const deleteModalTitleId = useId();
358
+ const deleteModalDescriptionId = useId();
359
+
360
+ useEffect(() => {
361
+ if (!notice) return;
362
+
363
+ const timeoutId = window.setTimeout(() => {
364
+ setNotice(null);
365
+ }, 5000);
366
+
367
+ return () => {
368
+ window.clearTimeout(timeoutId);
369
+ };
370
+ }, [notice]);
371
+
372
+ useEffect(() => {
373
+ if (!isDeleteModalOpen) return;
374
+
375
+ previousFocusedElementRef.current = document.activeElement as HTMLElement | null;
376
+ const frame = requestAnimationFrame(() => {
377
+ deleteCancelButtonRef.current?.focus();
378
+ });
379
+
380
+ return () => {
381
+ cancelAnimationFrame(frame);
382
+ previousFocusedElementRef.current?.focus();
383
+ };
384
+ }, [isDeleteModalOpen]);
385
+
386
+ useEffect(() => {
387
+ if (!isExistingReport && preferredSiteId) {
388
+ setReportSiteId(preferredSiteId);
389
+ }
390
+ }, [isExistingReport, preferredSiteId]);
391
+
392
+ useEffect(() => {
393
+ setIsEditing(!isExistingReport);
394
+ }, [isExistingReport, reportUuid]);
395
+
396
+ useEffect(() => {
397
+ hasHydratedReportRef.current = false;
398
+ }, [reportUuid]);
399
+
400
+ const schemaQuery = useQuery({
401
+ queryKey: ["custom-report-schema", reportSiteId],
402
+ enabled: Boolean(reportSiteId),
403
+ queryFn: async () => {
404
+ const response = await fetch(`/api/site-events/schema?site_id=${reportSiteId}`);
405
+ const data = (await response.json().catch(() => null)) as SchemaResponse | null;
406
+ if (!response.ok) {
407
+ throw new Error(data?.error || "Failed to fetch site_events schema");
408
+ }
409
+ return data;
410
+ },
411
+ });
412
+
413
+ const reportQuery = useQuery({
414
+ queryKey: ["custom-report-config", reportUuid],
415
+ enabled: isExistingReport,
416
+ queryFn: async () => {
417
+ const response = await fetch(`/api/reports/custom/${reportUuid}`);
418
+ const data = (await response.json().catch(() => null)) as ReportApiResponse | null;
419
+ if (!response.ok) {
420
+ throw new Error(data?.error || "Failed to fetch custom report");
421
+ }
422
+ return data?.report ?? null;
423
+ },
424
+ });
425
+
426
+ const labelsQuery = useQuery<EventLabelSelect[], Error>({
427
+ queryKey: ["event-labels", reportSiteId],
428
+ queryFn: async () => {
429
+ if (!reportSiteId) return [];
430
+ const response = await fetch(`/api/event-labels?site_id=${reportSiteId}`);
431
+ if (!response.ok) throw new Error("Failed to fetch event labels");
432
+ return response.json();
433
+ },
434
+ enabled: Boolean(reportSiteId),
435
+ staleTime: 5 * 60 * 1000,
436
+ gcTime: 10 * 60 * 1000,
437
+ });
438
+
439
+ const eventLabelsMap = useMemo(() => {
440
+ const map = new Map<string, string>();
441
+ if (labelsQuery.data) {
442
+ for (const label of labelsQuery.data) {
443
+ map.set(label.event_name, label.label);
444
+ }
445
+ }
446
+ return map;
447
+ }, [labelsQuery.data]);
448
+
449
+ useEffect(() => {
450
+ if (!reportQuery.data || hasHydratedReportRef.current) return;
451
+
452
+ const report = reportQuery.data;
453
+ setReportName(report.name || "Untitled custom report");
454
+ setReportSiteId(report.site_id);
455
+ const normalizedWidgets = normalizeWidgetsForRows(report.config?.widgets || []);
456
+ setWidgets(normalizedWidgets);
457
+ setSelectedWidgetId(normalizedWidgets[0]?.id || null);
458
+ setExtraRows(0);
459
+ setRowModeOverrides({});
460
+ hasHydratedReportRef.current = true;
461
+ }, [reportQuery.data]);
462
+
463
+ const availableColumns = useMemo(() => {
464
+ const columns = schemaQuery.data?.tables?.[0]?.columns || [];
465
+ return columns.map((column) => column.name);
466
+ }, [schemaQuery.data]);
467
+
468
+ useEffect(() => {
469
+ if (isExistingReport) return;
470
+ if (hasInitializedTemplateRef.current) return;
471
+ if (availableColumns.length === 0) return;
472
+
473
+ const templateWidgets = normalizeWidgetsForRows(
474
+ createTemplateWidgets(initialTemplate, availableColumns),
475
+ );
476
+ if (templateWidgets.length > 0) {
477
+ setWidgets(templateWidgets);
478
+ setSelectedWidgetId(templateWidgets[0].id);
479
+ }
480
+ setExtraRows(0);
481
+ setRowModeOverrides({});
482
+ hasInitializedTemplateRef.current = true;
483
+ }, [isExistingReport, initialTemplate, availableColumns]);
484
+
485
+ const selectedWidget = widgets.find((widget) => widget.id === selectedWidgetId) ?? null;
486
+
487
+ useEffect(() => {
488
+ if (!editingWidgetTitleId) return;
489
+ if (widgets.some((widget) => widget.id === editingWidgetTitleId)) return;
490
+
491
+ setEditingWidgetTitleId(null);
492
+ setWidgetTitleDraft("");
493
+ }, [editingWidgetTitleId, widgets]);
494
+
495
+ useEffect(() => {
496
+ if (!editingWidgetTitleId) return;
497
+ widgetTitleInputRef.current?.focus();
498
+ widgetTitleInputRef.current?.select();
499
+ }, [editingWidgetTitleId]);
500
+
501
+ const startWidgetTitleEdit = (widget: CustomReportWidgetConfig) => {
502
+ setSelectedWidgetId(widget.id);
503
+ setEditingWidgetTitleId(widget.id);
504
+ setWidgetTitleDraft(widget.title);
505
+ };
506
+
507
+ const cancelWidgetTitleEdit = () => {
508
+ setEditingWidgetTitleId(null);
509
+ setWidgetTitleDraft("");
510
+ };
511
+
512
+ const commitWidgetTitleEdit = (widgetId: string) => {
513
+ updateWidget(widgetId, (widget) => ({
514
+ ...widget,
515
+ title: widgetTitleDraft,
516
+ }));
517
+ setEditingWidgetTitleId(null);
518
+ setWidgetTitleDraft("");
519
+ };
520
+
521
+ const renderWidgetTitle = (widget: CustomReportWidgetConfig) => {
522
+ if (!isEditing) return widget.title;
523
+
524
+ const isTitleEditing = editingWidgetTitleId === widget.id;
525
+ if (isTitleEditing) {
526
+ return (
527
+ <input
528
+ ref={widgetTitleInputRef}
529
+ value={widgetTitleDraft}
530
+ onChange={(event) => setWidgetTitleDraft(event.target.value)}
531
+ onBlur={() => commitWidgetTitleEdit(widget.id)}
532
+ onClick={(event) => event.stopPropagation()}
533
+ onKeyDown={(event) => {
534
+ event.stopPropagation();
535
+
536
+ if (event.key === "Enter") {
537
+ event.preventDefault();
538
+ commitWidgetTitleEdit(widget.id);
539
+ return;
540
+ }
541
+
542
+ if (event.key === "Escape") {
543
+ event.preventDefault();
544
+ cancelWidgetTitleEdit();
545
+ }
546
+ }}
547
+ className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1 text-sm font-semibold text-(--theme-text-primary)"
548
+ aria-label="Chart title"
549
+ />
550
+ );
551
+ }
552
+
553
+ return (
554
+ <button
555
+ type="button"
556
+ onClick={(event) => {
557
+ event.stopPropagation();
558
+ startWidgetTitleEdit(widget);
559
+ }}
560
+ className="-mx-1 rounded px-1 text-left text-lg sm:text-xl font-semibold text-(--theme-text-primary) hover:bg-(--theme-bg-secondary)"
561
+ aria-label={`Edit title for ${widget.title}`}
562
+ >
563
+ {widget.title || "Untitled chart"}
564
+ </button>
565
+ );
566
+ };
567
+
568
+ const canvasRows = useMemo(() => buildCanvasRows(widgets), [widgets]);
569
+
570
+ const totalRows = useMemo(
571
+ () => Math.max(1, canvasRows.length) + extraRows,
572
+ [canvasRows.length, extraRows],
573
+ );
574
+
575
+ const rowsForRender = useMemo(
576
+ () =>
577
+ Array.from({ length: totalRows }, (_, rowIndex) => {
578
+ const existingRow = canvasRows[rowIndex];
579
+ const mode = existingRow?.mode ?? rowModeOverrides[rowIndex] ?? "full";
580
+ return {
581
+ rowIndex,
582
+ mode,
583
+ full: existingRow?.full ?? null,
584
+ left: existingRow?.left ?? null,
585
+ right: existingRow?.right ?? null,
586
+ };
587
+ }),
588
+ [canvasRows, rowModeOverrides, totalRows],
589
+ );
590
+
591
+ const selectedWidgetRowIndex = selectedWidget ? getRowIndexFromLayout(selectedWidget.layout) : null;
592
+ const selectedRow = selectedWidgetRowIndex !== null ? rowsForRender[selectedWidgetRowIndex] ?? null : null;
593
+ const selectedWidgetPlacement = useMemo(() => {
594
+ if (!selectedWidgetId) return null;
595
+
596
+ for (const row of rowsForRender) {
597
+ if (row.full?.id === selectedWidgetId) {
598
+ return `Row ${row.rowIndex + 1} - Full width`;
599
+ }
600
+
601
+ if (row.left?.id === selectedWidgetId) {
602
+ return `Row ${row.rowIndex + 1} - Slot 1`;
603
+ }
604
+
605
+ if (row.right?.id === selectedWidgetId) {
606
+ return `Row ${row.rowIndex + 1} - Slot 2`;
607
+ }
608
+ }
609
+
610
+ return null;
611
+ }, [rowsForRender, selectedWidgetId]);
612
+ const yFieldUsedByAggregation = selectedWidget
613
+ ? selectedWidget.aggregation === "sum" || selectedWidget.aggregation === "avg"
614
+ : false;
615
+ const selectedPalette = selectedWidget
616
+ ? reportColorPalettes[selectedWidget.colorPalette]
617
+ : reportColorPalettes.primary;
618
+ const primaryFallbackColor = selectedPalette[0] ?? "#3B82F6";
619
+ const secondaryFallbackColor = selectedPalette[1] ?? primaryFallbackColor;
620
+
621
+ useEffect(() => {
622
+ if (!selectedWidget) {
623
+ setPrimaryColorDraft("");
624
+ setSecondaryColorDraft("");
625
+ return;
626
+ }
627
+
628
+ setPrimaryColorDraft(selectedWidget.customPrimaryColor ?? "");
629
+ setSecondaryColorDraft(selectedWidget.customSecondaryColor ?? "");
630
+ }, [
631
+ selectedWidget?.id,
632
+ selectedWidget?.customPrimaryColor,
633
+ selectedWidget?.customSecondaryColor,
634
+ ]);
635
+
636
+ const applyCustomColor = (widgetId: string, field: WidgetColorField, rawValue: string) => {
637
+ const normalized = normalizeHexColor(rawValue);
638
+ updateWidget(widgetId, (widget) => ({
639
+ ...widget,
640
+ [field]: normalized,
641
+ }));
642
+ };
643
+
644
+ const addWidgetAtSlot = (rowIndex: number, slotIndex: 0 | 1) => {
645
+ setErrorMessage(null);
646
+
647
+ const row = rowsForRender[rowIndex];
648
+ if (!row) return;
649
+
650
+ if (row.mode === "full") {
651
+ if (row.full) {
652
+ setSelectedWidgetId(row.full.id);
653
+ return;
654
+ }
655
+
656
+ const fullWidget: CustomReportWidgetConfig = {
657
+ ...createWidget(selectedChartType, availableColumns),
658
+ layout: getFullRowLayout(rowIndex),
659
+ };
660
+
661
+ const nextRows = [...canvasRows];
662
+ while (nextRows.length <= rowIndex) {
663
+ nextRows.push({ mode: "split", full: null, left: null, right: null });
664
+ }
665
+ nextRows[rowIndex] = { mode: "full", full: fullWidget, left: null, right: null };
666
+
667
+ setWidgets(normalizeWidgetsForRows(flattenCanvasRows(nextRows)));
668
+ setSelectedWidgetId(fullWidget.id);
669
+ setExtraRows((prev) => (rowIndex >= canvasRows.length ? Math.max(0, prev - 1) : prev));
670
+ return;
671
+ }
672
+
673
+ const existing = slotIndex === 0 ? row.left : row.right;
674
+ if (existing) {
675
+ setSelectedWidgetId(existing.id);
676
+ return;
677
+ }
678
+
679
+ const nextWidget: CustomReportWidgetConfig = {
680
+ ...createWidget(selectedChartType, availableColumns),
681
+ layout: getHalfSlotLayout(rowIndex, slotIndex),
682
+ };
683
+
684
+ const nextRows = [...canvasRows];
685
+ while (nextRows.length <= rowIndex) {
686
+ nextRows.push({ mode: "split", full: null, left: null, right: null });
687
+ }
688
+
689
+ const targetRow = nextRows[rowIndex] ?? { mode: "split", full: null, left: null, right: null };
690
+ nextRows[rowIndex] = {
691
+ mode: "split",
692
+ full: null,
693
+ left: slotIndex === 0 ? nextWidget : targetRow.left,
694
+ right: slotIndex === 1 ? nextWidget : targetRow.right,
695
+ };
696
+
697
+ setWidgets(normalizeWidgetsForRows(flattenCanvasRows(nextRows)));
698
+ setSelectedWidgetId(nextWidget.id);
699
+ setExtraRows((prev) => (rowIndex >= canvasRows.length ? Math.max(0, prev - 1) : prev));
700
+ };
701
+
702
+ const updateRowMode = (rowIndex: number, nextMode: RowMode) => {
703
+ const row = rowsForRender[rowIndex];
704
+ if (!row) return;
705
+
706
+ const hasWidgets = Boolean(row.full || row.left || row.right);
707
+ if (!hasWidgets) {
708
+ setRowModeOverrides((prev) => ({ ...prev, [rowIndex]: nextMode }));
709
+ return;
710
+ }
711
+
712
+ const nextRows = [...canvasRows];
713
+ while (nextRows.length <= rowIndex) {
714
+ nextRows.push({ mode: "split", full: null, left: null, right: null });
715
+ }
716
+
717
+ const currentRow = nextRows[rowIndex] ?? { mode: "split", full: null, left: null, right: null };
718
+
719
+ if (nextMode === "full") {
720
+ const candidates = [currentRow.full, currentRow.left, currentRow.right].filter(
721
+ (widget): widget is CustomReportWidgetConfig => Boolean(widget),
722
+ );
723
+ if (candidates.length === 0) {
724
+ setRowModeOverrides((prev) => ({ ...prev, [rowIndex]: "full" }));
725
+ return;
726
+ }
727
+
728
+ const primary =
729
+ candidates.find((widget) => widget.id === selectedWidgetId) ?? candidates[0];
730
+ const overflow = candidates.filter((widget) => widget.id !== primary.id);
731
+
732
+ nextRows[rowIndex] = { mode: "full", full: primary, left: null, right: null };
733
+ if (overflow.length > 0) {
734
+ nextRows.splice(rowIndex + 1, 0, {
735
+ mode: "split",
736
+ full: null,
737
+ left: overflow[0] ?? null,
738
+ right: overflow[1] ?? null,
739
+ });
740
+ }
741
+ setSelectedWidgetId(primary.id);
742
+ } else {
743
+ if (currentRow.mode === "full") {
744
+ let pulledWidget: CustomReportWidgetConfig | null = null;
745
+ const nextRow = nextRows[rowIndex + 1];
746
+ if (nextRow?.mode === "split") {
747
+ pulledWidget = nextRow.left ?? nextRow.right ?? null;
748
+
749
+ if (pulledWidget) {
750
+ const pulledWidgetId = pulledWidget.id;
751
+ const remaining = [nextRow.left, nextRow.right].filter(
752
+ (widget): widget is CustomReportWidgetConfig =>
753
+ widget != null && widget.id !== pulledWidgetId,
754
+ );
755
+
756
+ if (remaining.length === 0) {
757
+ nextRows.splice(rowIndex + 1, 1);
758
+ } else {
759
+ nextRows[rowIndex + 1] = {
760
+ mode: "split",
761
+ full: null,
762
+ left: remaining[0] ?? null,
763
+ right: remaining[1] ?? null,
764
+ };
765
+ }
766
+ }
767
+ }
768
+
769
+ nextRows[rowIndex] = {
770
+ mode: "split",
771
+ full: null,
772
+ left: currentRow.full,
773
+ right: pulledWidget,
774
+ };
775
+ if (currentRow.full) {
776
+ setSelectedWidgetId(currentRow.full.id);
777
+ }
778
+ } else {
779
+ nextRows[rowIndex] = {
780
+ mode: "split",
781
+ full: null,
782
+ left: currentRow.left,
783
+ right: currentRow.right,
784
+ };
785
+ }
786
+ }
787
+
788
+ setRowModeOverrides((prev) => {
789
+ const next = { ...prev };
790
+ delete next[rowIndex];
791
+ return next;
792
+ });
793
+
794
+ setWidgets(normalizeWidgetsForRows(flattenCanvasRows(nextRows)));
795
+ };
796
+
797
+ const updateWidget = (widgetId: string, updater: (widget: CustomReportWidgetConfig) => CustomReportWidgetConfig) => {
798
+ setWidgets((prev) =>
799
+ normalizeWidgetsForRows(
800
+ prev.map((widget) => {
801
+ if (widget.id !== widgetId) return widget;
802
+ return updater(widget);
803
+ }),
804
+ ),
805
+ );
806
+ };
807
+
808
+ const removeWidget = (widgetId: string) => {
809
+ setWidgets((prev) => normalizeWidgetsForRows(prev.filter((widget) => widget.id !== widgetId)));
810
+ if (selectedWidgetId === widgetId) {
811
+ setSelectedWidgetId(null);
812
+ }
813
+ };
814
+
815
+ const widgetDataQueries = useQueries({
816
+ queries: widgets.map((widget) => ({
817
+ queryKey: [
818
+ "custom-report-widget-data",
819
+ reportSiteId,
820
+ widget.id,
821
+ JSON.stringify(widget),
822
+ activeFilters?.dateRange.start,
823
+ activeFilters?.dateRange.end,
824
+ activeFilters?.deviceType,
825
+ activeFilters?.country,
826
+ activeFilters?.city,
827
+ activeFilters?.region,
828
+ activeFilters?.source,
829
+ activeFilters?.pageUrl,
830
+ activeFilters?.eventName,
831
+ ],
832
+ enabled: Boolean(reportSiteId) && availableColumns.length > 0,
833
+ queryFn: async () => {
834
+ const query = buildSqlForWidget(widget, availableColumns, {
835
+ dateRange: activeFilters?.dateRange,
836
+ deviceType: activeFilters?.deviceType,
837
+ country: activeFilters?.country,
838
+ city: activeFilters?.city,
839
+ region: activeFilters?.region,
840
+ source: activeFilters?.source,
841
+ pageUrl: activeFilters?.pageUrl,
842
+ eventName: activeFilters?.eventName,
843
+ });
844
+ const response = await fetch("/api/site-events/query", {
845
+ method: "POST",
846
+ headers: { "Content-Type": "application/json" },
847
+ body: JSON.stringify({
848
+ site_id: reportSiteId,
849
+ query,
850
+ }),
851
+ });
852
+
853
+ const payload = (await response.json().catch(() => null)) as
854
+ | { rows?: Array<Record<string, unknown>>; error?: string }
855
+ | null;
856
+
857
+ if (!response.ok) {
858
+ throw new Error(payload?.error || "Failed to load widget data");
859
+ }
860
+
861
+ return payload?.rows || [];
862
+ },
863
+ })),
864
+ });
865
+
866
+ const widgetQueryById = useMemo(() => {
867
+ const map = new Map<string, (typeof widgetDataQueries)[number]>();
868
+ widgets.forEach((widget, index) => {
869
+ map.set(widget.id, widgetDataQueries[index]);
870
+ });
871
+ return map;
872
+ }, [widgets, widgetDataQueries]);
873
+
874
+ const saveReport = async () => {
875
+ if (!reportSiteId) {
876
+ setErrorMessage("Select a site before saving a custom report.");
877
+ return;
878
+ }
879
+ if (!reportName.trim()) {
880
+ setErrorMessage("Report name is required.");
881
+ return;
882
+ }
883
+ if (widgets.length === 0) {
884
+ setErrorMessage("Add at least one chart widget before saving.");
885
+ return;
886
+ }
887
+
888
+ const config: CustomReportConfig = {
889
+ version: 1,
890
+ widgets,
891
+ };
892
+
893
+ setErrorMessage(null);
894
+ setIsSaving(true);
895
+ try {
896
+ const endpoint = isExistingReport
897
+ ? `/api/reports/custom/${reportUuid}`
898
+ : "/api/reports/custom";
899
+
900
+ const response = await fetch(endpoint, {
901
+ method: "POST",
902
+ headers: { "Content-Type": "application/json" },
903
+ body: JSON.stringify({
904
+ site_id: reportSiteId,
905
+ name: reportName.trim(),
906
+ description: null,
907
+ config,
908
+ }),
909
+ });
910
+
911
+ const payload = (await response.json().catch(() => null)) as
912
+ | { uuid?: string; error?: string }
913
+ | null;
914
+
915
+ if (!response.ok) {
916
+ throw new Error(payload?.error || "Failed to save report");
917
+ }
918
+
919
+ const nextUuid = payload?.uuid || reportUuid;
920
+ if (!nextUuid) {
921
+ throw new Error("Save succeeded but report id was missing");
922
+ }
923
+
924
+ const normalizedName = reportName.trim();
925
+ const normalizedSiteId = reportSiteId;
926
+ const normalizedTeamId = session?.team?.id ?? reportQuery.data?.team_id ?? 0;
927
+ const nextReportRecord: CustomReportRecord = {
928
+ uuid: nextUuid,
929
+ site_id: normalizedSiteId,
930
+ team_id: normalizedTeamId,
931
+ name: normalizedName,
932
+ description: null,
933
+ config,
934
+ };
935
+
936
+ queryClient.setQueryData(
937
+ ["dashboard-toolbar-active-custom-report", nextUuid],
938
+ { report: nextReportRecord },
939
+ );
940
+
941
+ queryClient.setQueryData<{ reports?: CustomReportRecord[] }>(
942
+ ["dashboard-toolbar-custom-reports", normalizedSiteId],
943
+ (current) => {
944
+ const reports = current?.reports ?? [];
945
+ const reportIndex = reports.findIndex((report) => report.uuid === nextUuid);
946
+ if (reportIndex >= 0) {
947
+ const nextReports = [...reports];
948
+ nextReports[reportIndex] = {
949
+ ...nextReports[reportIndex],
950
+ ...nextReportRecord,
951
+ };
952
+ return { reports: nextReports };
953
+ }
954
+
955
+ return { reports: [nextReportRecord, ...reports] };
956
+ },
957
+ );
958
+
959
+ void queryClient.invalidateQueries({
960
+ queryKey: ["dashboard-toolbar-custom-reports", normalizedSiteId],
961
+ });
962
+ void queryClient.invalidateQueries({
963
+ queryKey: ["dashboard-toolbar-active-custom-report", nextUuid],
964
+ });
965
+
966
+ setNotice("Report saved.");
967
+ if (!isExistingReport) {
968
+ window.location.assign(`/dashboard/reports/custom/${nextUuid}`);
969
+ } else {
970
+ setIsEditing(false);
971
+ }
972
+ } catch (error) {
973
+ setErrorMessage(error instanceof Error ? error.message : "Failed to save report");
974
+ } finally {
975
+ setIsSaving(false);
976
+ }
977
+ };
978
+
979
+ const deleteReport = async () => {
980
+ if (!isExistingReport || !reportUuid) return;
981
+
982
+ setErrorMessage(null);
983
+ setNotice(null);
984
+ setIsDeleteModalOpen(false);
985
+ setIsDeleting(true);
986
+
987
+ try {
988
+ const response = await fetch(`/api/reports/custom/${reportUuid}`, {
989
+ method: "DELETE",
990
+ });
991
+
992
+ const payload = (await response.json().catch(() => null)) as
993
+ | { error?: string }
994
+ | null;
995
+
996
+ if (!response.ok) {
997
+ throw new Error(payload?.error || "Failed to delete report");
998
+ }
999
+
1000
+ window.location.assign("/dashboard/reports/create-report");
1001
+ } catch (error) {
1002
+ setErrorMessage(error instanceof Error ? error.message : "Failed to delete report");
1003
+ } finally {
1004
+ setIsDeleting(false);
1005
+ }
1006
+ };
1007
+
1008
+ if (!isExistingReport && !reportSiteId && !isSessionLoading) {
1009
+ return (
1010
+ <div className="max-w-3xl mx-auto">
1011
+ <div className="rounded-xl border border-(--theme-border-primary) bg-(--theme-card-bg) p-6">
1012
+ <h2 className="text-xl font-semibold text-(--theme-text-primary)">Select a site first</h2>
1013
+ <p className="mt-2 text-(--theme-text-secondary)">
1014
+ Pick a site from the dashboard selector, then open custom report builder.
1015
+ </p>
1016
+ </div>
1017
+ </div>
1018
+ );
1019
+ }
1020
+
1021
+ return (
1022
+ <div className="space-y-6">
1023
+ {(notice || errorMessage) && (
1024
+ <div className="fixed bottom-4 right-4 z-[70] w-[min(24rem,calc(100vw-2rem))]">
1025
+ <AlertBanner
1026
+ tone={errorMessage ? "error" : "success"}
1027
+ message={errorMessage || notice || ""}
1028
+ onDismiss={() => {
1029
+ setNotice(null);
1030
+ setErrorMessage(null);
1031
+ }}
1032
+ />
1033
+ </div>
1034
+ )}
1035
+
1036
+ {isDeleteModalOpen ? (
1037
+ <div
1038
+ className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 px-4"
1039
+ role="presentation"
1040
+ onMouseDown={(event) => {
1041
+ if (event.target === event.currentTarget) {
1042
+ setIsDeleteModalOpen(false);
1043
+ }
1044
+ }}
1045
+ >
1046
+ <div
1047
+ ref={deleteModalRef}
1048
+ role="dialog"
1049
+ aria-modal="true"
1050
+ aria-labelledby={deleteModalTitleId}
1051
+ aria-describedby={deleteModalDescriptionId}
1052
+ tabIndex={-1}
1053
+ onKeyDown={(event) => {
1054
+ if (event.key === "Escape") {
1055
+ event.preventDefault();
1056
+ setIsDeleteModalOpen(false);
1057
+ return;
1058
+ }
1059
+
1060
+ if (event.key !== "Tab") return;
1061
+
1062
+ const focusable = getFocusableElements(deleteModalRef.current);
1063
+ if (focusable.length === 0) return;
1064
+
1065
+ const first = focusable[0];
1066
+ const last = focusable[focusable.length - 1];
1067
+ const active = document.activeElement as HTMLElement | null;
1068
+
1069
+ if (event.shiftKey) {
1070
+ if (!active || active === first) {
1071
+ event.preventDefault();
1072
+ last.focus();
1073
+ }
1074
+ return;
1075
+ }
1076
+
1077
+ if (!active || active === last) {
1078
+ event.preventDefault();
1079
+ first.focus();
1080
+ }
1081
+ }}
1082
+ className="w-[min(34rem,calc(100vw-2rem))] rounded-xl border border-(--theme-border-primary) bg-(--theme-card-bg) p-6 shadow-2xl"
1083
+ >
1084
+ <h3 id={deleteModalTitleId} className="text-lg font-semibold text-(--theme-text-primary)">
1085
+ Delete report?
1086
+ </h3>
1087
+ <p id={deleteModalDescriptionId} className="mt-2 text-sm text-(--theme-text-secondary)">
1088
+ Delete "{reportName}" permanently. This action cannot be undone.
1089
+ </p>
1090
+ <div className="mt-6 flex items-center justify-end gap-2">
1091
+ <button
1092
+ ref={deleteCancelButtonRef}
1093
+ type="button"
1094
+ onClick={() => setIsDeleteModalOpen(false)}
1095
+ className="rounded-md border border-(--theme-border-primary) px-3 py-1.5 text-sm font-medium text-(--theme-text-primary) hover:bg-(--theme-bg-secondary)"
1096
+ >
1097
+ Cancel
1098
+ </button>
1099
+ <button
1100
+ type="button"
1101
+ onClick={() => {
1102
+ void deleteReport();
1103
+ }}
1104
+ disabled={isDeleting}
1105
+ className="rounded-md border border-red-500/60 bg-red-500/10 px-3 py-1.5 text-sm font-semibold text-red-300 hover:bg-red-500/20 disabled:opacity-60"
1106
+ >
1107
+ {isDeleting ? "Deleting..." : "Delete report"}
1108
+ </button>
1109
+ </div>
1110
+ </div>
1111
+ </div>
1112
+ ) : null}
1113
+
1114
+ <header className="grid grid-cols-1 sm:grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
1115
+ <div className="min-w-0 w-full">
1116
+ {isEditing ? (
1117
+ <input
1118
+ value={reportName}
1119
+ onChange={(event) => setReportName(event.target.value)}
1120
+ className="w-full min-w-[220px] sm:min-w-[360px] max-w-2xl rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-3 py-1.5 text-sm text-(--theme-text-primary)"
1121
+ placeholder="Untitled custom report"
1122
+ aria-label="Report name"
1123
+ />
1124
+ ) : (
1125
+ <h2 className="text-2xl font-bold text-(--theme-text-primary) truncate">{reportName}</h2>
1126
+ )}
1127
+ </div>
1128
+
1129
+ <div className="flex items-center gap-2">
1130
+ {isExistingReport && !isEditing ? (
1131
+ <button
1132
+ type="button"
1133
+ onClick={() => setIsEditing(true)}
1134
+ className="rounded-md border border-(--theme-border-primary) px-2.5 py-1 text-xs font-semibold text-(--theme-text-secondary) hover:bg-(--theme-bg-secondary)"
1135
+ >
1136
+ Edit
1137
+ </button>
1138
+ ) : null}
1139
+
1140
+ {isEditing ? (
1141
+ <>
1142
+ {isExistingReport ? (
1143
+ <button
1144
+ type="button"
1145
+ onClick={() => setIsEditing(false)}
1146
+ disabled={isSaving || isDeleting}
1147
+ className="rounded-md border border-(--theme-border-primary) px-2.5 py-1 text-xs font-semibold text-(--theme-text-secondary) hover:bg-(--theme-bg-secondary)"
1148
+ >
1149
+ Cancel
1150
+ </button>
1151
+ ) : null}
1152
+ {isExistingReport ? (
1153
+ <button
1154
+ type="button"
1155
+ onClick={() => {
1156
+ setIsDeleteModalOpen(true);
1157
+ }}
1158
+ disabled={isSaving || isDeleting}
1159
+ className="rounded-md border border-red-500/60 px-2.5 py-1 text-xs font-semibold text-red-300 hover:bg-red-500/10 disabled:opacity-60"
1160
+ >
1161
+ Delete
1162
+ </button>
1163
+ ) : null}
1164
+ <button
1165
+ type="button"
1166
+ onClick={() => {
1167
+ void saveReport();
1168
+ }}
1169
+ disabled={isSaving || isDeleting}
1170
+ className="rounded-md border border-(--theme-button-bg) px-2.5 py-1 text-xs font-semibold text-(--theme-text-primary) hover:bg-(--theme-bg-secondary) disabled:opacity-60"
1171
+ >
1172
+ {isSaving ? "Saving..." : "Save"}
1173
+ </button>
1174
+ </>
1175
+ ) : null}
1176
+ </div>
1177
+ </header>
1178
+
1179
+ <section className={isEditing ? "grid grid-cols-1 items-start gap-4 xl:grid-cols-[minmax(0,4fr)_minmax(300px,1fr)]" : "space-y-4"}>
1180
+ <DashboardCard
1181
+ title={isEditing ? "Layout editor" : undefined}
1182
+ titleAs="h3"
1183
+ subtitle={isEditing ? "Build as many rows as you need. Each row can be split (two charts) or full-width (one chart)." : undefined}
1184
+ actions={isEditing ? (
1185
+ <div className="flex items-center gap-2">
1186
+ <label className="text-xs text-(--theme-text-secondary)">Chart type</label>
1187
+ <select
1188
+ value={selectedChartType}
1189
+ onChange={(event) => setSelectedChartType(event.target.value as ReportChartType)}
1190
+ className="rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1 text-sm text-(--theme-text-primary)"
1191
+ >
1192
+ {chartTypeOptions.map((option) => (
1193
+ <option key={option.value} value={option.value}>{option.label}</option>
1194
+ ))}
1195
+ </select>
1196
+ </div>
1197
+ ) : undefined}
1198
+ className={isEditing ? "space-y-4" : "!border-0 !bg-transparent !p-0 space-y-4"}
1199
+ >
1200
+ <div className="space-y-4">
1201
+ {rowsForRender.map((row) => {
1202
+ const hasWidgets = Boolean(row.full || row.left || row.right);
1203
+ if (!isEditing && !hasWidgets) {
1204
+ return null;
1205
+ }
1206
+
1207
+ const rowHeightClass = row.mode === "full" ? "h-[420px]" : "h-[340px]";
1208
+
1209
+ return (
1210
+ <div key={`row-${row.rowIndex}`} className="space-y-2">
1211
+ {isEditing ? (
1212
+ <div className="flex items-center justify-between">
1213
+ <p className="text-xs font-semibold uppercase tracking-wide text-(--theme-text-secondary)">
1214
+ Row {row.rowIndex + 1}
1215
+ </p>
1216
+ <select
1217
+ value={row.mode}
1218
+ onChange={(event) => updateRowMode(row.rowIndex, event.target.value as RowMode)}
1219
+ className="rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1 text-xs text-(--theme-text-primary)"
1220
+ >
1221
+ <option value="split">Two charts</option>
1222
+ <option value="full">Full-width chart</option>
1223
+ </select>
1224
+ </div>
1225
+ ) : null}
1226
+
1227
+ {row.mode === "full" ? (
1228
+ row.full ? (
1229
+ (() => {
1230
+ const fullWidget = row.full;
1231
+ const query = widgetQueryById.get(fullWidget.id);
1232
+ const rowsData =
1233
+ (query?.data as Array<Record<string, unknown>> | undefined) || [];
1234
+ const isSelected = isEditing && fullWidget.id === selectedWidgetId;
1235
+ const selectionClass = isEditing
1236
+ ? (isSelected
1237
+ ? "border-amber-500 ring-2 ring-amber-500 shadow-[0_0_0_1px_rgba(245,158,11,0.45)] dark:border-[var(--theme-border-secondary)] dark:ring-[var(--theme-border-secondary)] dark:shadow-none"
1238
+ : "hover:border-amber-400 dark:hover:border-[var(--theme-border-secondary)]")
1239
+ : "";
1240
+
1241
+ return (
1242
+ <div
1243
+ role={isEditing ? "button" : undefined}
1244
+ tabIndex={isEditing ? 0 : undefined}
1245
+ aria-pressed={isEditing ? isSelected : undefined}
1246
+ onClick={() => {
1247
+ if (!isEditing) return;
1248
+ setSelectedWidgetId(fullWidget.id);
1249
+ }}
1250
+ onKeyDown={(event) => {
1251
+ if (!isEditing) return;
1252
+ if (event.key !== "Enter" && event.key !== " ") return;
1253
+ event.preventDefault();
1254
+ setSelectedWidgetId(fullWidget.id);
1255
+ }}
1256
+ className={isEditing ? "rounded-lg focus:outline-none" : undefined}
1257
+ >
1258
+ <DashboardCard
1259
+ title={renderWidgetTitle(fullWidget)}
1260
+ titleAs="h3"
1261
+ subtitle={isEditing ? `${fullWidget.chartType} - Full row` : undefined}
1262
+ actions={isEditing ? (
1263
+ <button
1264
+ type="button"
1265
+ onClick={(event) => {
1266
+ event.stopPropagation();
1267
+ removeWidget(fullWidget.id);
1268
+ }}
1269
+ className="text-[10px] text-red-400 hover:text-red-300"
1270
+ >
1271
+ Remove
1272
+ </button>
1273
+ ) : undefined}
1274
+ className={`${rowHeightClass} flex flex-col transition-colors ${isSelected ? "bg-amber-500/15 dark:bg-(--theme-bg-tertiary)" : ""} ${selectionClass}`}
1275
+ >
1276
+ <div className="mt-1 flex-1 min-h-0">
1277
+ {schemaQuery.isLoading || query?.isLoading || !query ? (
1278
+ <div className="h-full flex items-center justify-center text-sm text-(--theme-text-secondary)">
1279
+ Loading chart data...
1280
+ </div>
1281
+ ) : query.error ? (
1282
+ <div className="h-full flex items-center justify-center text-sm text-red-400">
1283
+ {(query.error as Error).message}
1284
+ </div>
1285
+ ) : (
1286
+ <div className="h-full flex flex-col">
1287
+ <ReportWidgetChart widget={fullWidget} rows={rowsData} labelsMap={eventLabelsMap} />
1288
+ {rowsData.length === 0 ? (
1289
+ <p className="mt-2 text-xs text-(--theme-text-secondary)">
1290
+ No data for this date range.
1291
+ </p>
1292
+ ) : null}
1293
+ </div>
1294
+ )}
1295
+ </div>
1296
+ </DashboardCard>
1297
+ </div>
1298
+ );
1299
+ })()
1300
+ ) : (
1301
+ isEditing ? (
1302
+ <button
1303
+ type="button"
1304
+ onClick={() => addWidgetAtSlot(row.rowIndex, 0)}
1305
+ className={`${rowHeightClass} w-full rounded-lg border border-dashed border-(--theme-border-primary) bg-(--theme-bg-secondary) text-(--theme-text-secondary) hover:bg-(--theme-bg-tertiary) transition-colors`}
1306
+ >
1307
+ <span className="text-sm font-medium">+ Add full-width chart</span>
1308
+ </button>
1309
+ ) : null
1310
+ )
1311
+ ) : (
1312
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
1313
+ {([0, 1] as const).map((slotIndex) => {
1314
+ const slotWidget = slotIndex === 0 ? row.left : row.right;
1315
+ if (!slotWidget) {
1316
+ if (!isEditing) {
1317
+ return null;
1318
+ }
1319
+
1320
+ return (
1321
+ <button
1322
+ key={`row-${row.rowIndex}-slot-${slotIndex}`}
1323
+ type="button"
1324
+ onClick={() => addWidgetAtSlot(row.rowIndex, slotIndex)}
1325
+ className={`${rowHeightClass} rounded-lg border border-dashed border-(--theme-border-primary) bg-(--theme-bg-secondary) text-(--theme-text-secondary) hover:bg-(--theme-bg-tertiary) transition-colors`}
1326
+ >
1327
+ <span className="text-sm font-medium">+ Add chart to slot {slotIndex + 1}</span>
1328
+ </button>
1329
+ );
1330
+ }
1331
+
1332
+ const query = widgetQueryById.get(slotWidget.id);
1333
+ const rowsData =
1334
+ (query?.data as Array<Record<string, unknown>> | undefined) || [];
1335
+ const isSelected = isEditing && slotWidget.id === selectedWidgetId;
1336
+ const selectionClass = isEditing
1337
+ ? (isSelected
1338
+ ? "border-amber-500 ring-2 ring-amber-500 shadow-[0_0_0_1px_rgba(245,158,11,0.45)] dark:border-[var(--theme-border-secondary)] dark:ring-[var(--theme-border-secondary)] dark:shadow-none"
1339
+ : "hover:border-amber-400 dark:hover:border-[var(--theme-border-secondary)]")
1340
+ : "";
1341
+
1342
+ return (
1343
+ <div
1344
+ key={slotWidget.id}
1345
+ role={isEditing ? "button" : undefined}
1346
+ tabIndex={isEditing ? 0 : undefined}
1347
+ aria-pressed={isEditing ? isSelected : undefined}
1348
+ onClick={() => {
1349
+ if (!isEditing) return;
1350
+ setSelectedWidgetId(slotWidget.id);
1351
+ }}
1352
+ onKeyDown={(event) => {
1353
+ if (!isEditing) return;
1354
+ if (event.key !== "Enter" && event.key !== " ") return;
1355
+ event.preventDefault();
1356
+ setSelectedWidgetId(slotWidget.id);
1357
+ }}
1358
+ className={isEditing ? "rounded-lg focus:outline-none" : undefined}
1359
+ >
1360
+ <DashboardCard
1361
+ title={renderWidgetTitle(slotWidget)}
1362
+ titleAs="h3"
1363
+ subtitle={isEditing ? `${slotWidget.chartType} - Slot ${slotIndex + 1}` : undefined}
1364
+ actions={isEditing ? (
1365
+ <button
1366
+ type="button"
1367
+ onClick={(event) => {
1368
+ event.stopPropagation();
1369
+ removeWidget(slotWidget.id);
1370
+ }}
1371
+ className="text-[10px] text-red-400 hover:text-red-300"
1372
+ >
1373
+ Remove
1374
+ </button>
1375
+ ) : undefined}
1376
+ className={`${rowHeightClass} flex flex-col transition-colors ${isSelected ? "bg-amber-500/15 dark:bg-(--theme-bg-tertiary)" : ""} ${selectionClass}`}
1377
+ >
1378
+ <div className="mt-1 flex-1 min-h-0">
1379
+ {schemaQuery.isLoading || query?.isLoading || !query ? (
1380
+ <div className="h-full flex items-center justify-center text-sm text-(--theme-text-secondary)">
1381
+ Loading chart data...
1382
+ </div>
1383
+ ) : query.error ? (
1384
+ <div className="h-full flex items-center justify-center text-sm text-red-400">
1385
+ {(query.error as Error).message}
1386
+ </div>
1387
+ ) : (
1388
+ <div className="h-full flex flex-col">
1389
+ <ReportWidgetChart widget={slotWidget} rows={rowsData} labelsMap={eventLabelsMap} />
1390
+ {rowsData.length === 0 ? (
1391
+ <p className="mt-2 text-xs text-(--theme-text-secondary)">
1392
+ No data for this date range.
1393
+ </p>
1394
+ ) : null}
1395
+ </div>
1396
+ )}
1397
+ </div>
1398
+ </DashboardCard>
1399
+ </div>
1400
+ );
1401
+ })}
1402
+ </div>
1403
+ )}
1404
+ </div>
1405
+ );
1406
+ })}
1407
+
1408
+ {isEditing ? (
1409
+ <button
1410
+ type="button"
1411
+ onClick={() => {
1412
+ const nextRowIndex = canvasRows.length + extraRows;
1413
+ setRowModeOverrides((prev) => ({
1414
+ ...prev,
1415
+ [nextRowIndex]: "full",
1416
+ }));
1417
+ setExtraRows((prev) => prev + 1);
1418
+ }}
1419
+ className="rounded-md border border-dashed border-(--theme-border-primary) px-3 py-2 text-xs text-(--theme-text-secondary) hover:bg-(--theme-bg-secondary)"
1420
+ >
1421
+ + Add row
1422
+ </button>
1423
+ ) : null}
1424
+ </div>
1425
+ </DashboardCard>
1426
+
1427
+ {isEditing ? (
1428
+ <DashboardCard
1429
+ title="Widget settings"
1430
+ titleAs="h3"
1431
+ subtitle={selectedWidget
1432
+ ? `${selectedWidget.title} ${selectedWidgetPlacement ? `(${selectedWidgetPlacement})` : ""}`
1433
+ : undefined}
1434
+ className="space-y-3 xl:sticky xl:top-4"
1435
+ >
1436
+ {!selectedWidget ? (
1437
+ <p className="text-sm text-(--theme-text-secondary)">Select a widget from the grid to edit it.</p>
1438
+ ) : (
1439
+ <>
1440
+ <label className="block text-xs text-(--theme-text-secondary)">Title</label>
1441
+ <input
1442
+ value={selectedWidget.title}
1443
+ onChange={(event) => updateWidget(selectedWidget.id, (widget) => ({ ...widget, title: event.target.value }))}
1444
+ className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
1445
+ />
1446
+
1447
+ <label className="block text-xs text-(--theme-text-secondary)">Chart type</label>
1448
+ <select
1449
+ value={selectedWidget.chartType}
1450
+ onChange={(event) => updateWidget(selectedWidget.id, (widget) => ({
1451
+ ...widget,
1452
+ chartType: event.target.value as ReportChartType,
1453
+ }))}
1454
+ className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
1455
+ >
1456
+ {chartTypeOptions.map((option) => (
1457
+ <option key={option.value} value={option.value}>{option.label}</option>
1458
+ ))}
1459
+ </select>
1460
+
1461
+ {selectedWidget.chartType === "sankey" ? (
1462
+ <>
1463
+ <label className="block text-xs text-(--theme-text-secondary)">Source field</label>
1464
+ <select
1465
+ value={selectedWidget.sourceField}
1466
+ onChange={(event) => updateWidget(selectedWidget.id, (widget) => ({ ...widget, sourceField: event.target.value }))}
1467
+ className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
1468
+ >
1469
+ {availableColumns.map((column) => (
1470
+ <option key={`source-${column}`} value={column}>{column}</option>
1471
+ ))}
1472
+ </select>
1473
+
1474
+ <label className="block text-xs text-(--theme-text-secondary)">Target field</label>
1475
+ <select
1476
+ value={selectedWidget.targetField}
1477
+ onChange={(event) => updateWidget(selectedWidget.id, (widget) => ({ ...widget, targetField: event.target.value }))}
1478
+ className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
1479
+ >
1480
+ {availableColumns.map((column) => (
1481
+ <option key={`target-${column}`} value={column}>{column}</option>
1482
+ ))}
1483
+ </select>
1484
+ </>
1485
+ ) : (
1486
+ <>
1487
+ <label className="block text-xs text-(--theme-text-secondary)">X field</label>
1488
+ <select
1489
+ value={selectedWidget.xField}
1490
+ onChange={(event) => updateWidget(selectedWidget.id, (widget) => ({ ...widget, xField: event.target.value }))}
1491
+ className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
1492
+ >
1493
+ {availableColumns.map((column) => (
1494
+ <option key={`x-${column}`} value={column}>{column}</option>
1495
+ ))}
1496
+ </select>
1497
+ </>
1498
+ )}
1499
+
1500
+ <label className="block text-xs text-(--theme-text-secondary)">Y aggregation</label>
1501
+ <select
1502
+ value={selectedWidget.aggregation}
1503
+ onChange={(event) => updateWidget(selectedWidget.id, (widget) => ({ ...widget, aggregation: event.target.value as ReportAggregation }))}
1504
+ className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
1505
+ >
1506
+ {aggregationOptions.map((option) => (
1507
+ <option key={option.value} value={option.value}>{option.label}</option>
1508
+ ))}
1509
+ </select>
1510
+
1511
+ <label className="block text-xs text-(--theme-text-secondary)">Y field</label>
1512
+ <select
1513
+ value={selectedWidget.yField}
1514
+ onChange={(event) => updateWidget(selectedWidget.id, (widget) => ({ ...widget, yField: event.target.value }))}
1515
+ className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
1516
+ >
1517
+ {availableColumns.map((column) => (
1518
+ <option key={`y-${column}`} value={column}>{column}</option>
1519
+ ))}
1520
+ </select>
1521
+ {!yFieldUsedByAggregation ? (
1522
+ <p className="text-[11px] text-(--theme-text-secondary)">
1523
+ Y field is used when Y aggregation is Sum of y or Average of y.
1524
+ </p>
1525
+ ) : null}
1526
+
1527
+ <label className="block text-xs text-(--theme-text-secondary)">Color theme</label>
1528
+ <select
1529
+ value={selectedWidget.colorPalette}
1530
+ onChange={(event) => updateWidget(selectedWidget.id, (widget) => ({ ...widget, colorPalette: event.target.value as ReportColorPalette }))}
1531
+ className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
1532
+ >
1533
+ {reportPaletteOptions.map((palette) => (
1534
+ <option key={palette.value} value={palette.value}>{palette.label}</option>
1535
+ ))}
1536
+ </select>
1537
+
1538
+ <label className="block text-xs text-(--theme-text-secondary)">Custom colors (optional)</label>
1539
+ <div className="space-y-2">
1540
+ <div className="grid grid-cols-[40px_minmax(0,1fr)_auto] items-center gap-2">
1541
+ <input
1542
+ type="color"
1543
+ value={normalizeHexColor(selectedWidget.customPrimaryColor ?? "") ?? primaryFallbackColor}
1544
+ onChange={(event) => {
1545
+ const next = event.target.value.toUpperCase();
1546
+ setPrimaryColorDraft(next);
1547
+ applyCustomColor(selectedWidget.id, "customPrimaryColor", next);
1548
+ }}
1549
+ className="h-9 w-10 rounded border border-(--theme-border-primary) bg-(--theme-bg-secondary) p-1"
1550
+ aria-label="Primary chart color picker"
1551
+ />
1552
+ <input
1553
+ type="text"
1554
+ value={primaryColorDraft}
1555
+ onChange={(event) => setPrimaryColorDraft(event.target.value)}
1556
+ onBlur={() => {
1557
+ const normalized = normalizeHexColor(primaryColorDraft);
1558
+ if (primaryColorDraft.trim().length > 0 && !normalized) {
1559
+ setPrimaryColorDraft(selectedWidget.customPrimaryColor ?? "");
1560
+ return;
1561
+ }
1562
+
1563
+ applyCustomColor(selectedWidget.id, "customPrimaryColor", primaryColorDraft);
1564
+ }}
1565
+ placeholder="#FF6B35"
1566
+ className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
1567
+ aria-label="Primary chart color hex value"
1568
+ />
1569
+ <button
1570
+ type="button"
1571
+ onClick={() => {
1572
+ setPrimaryColorDraft("");
1573
+ applyCustomColor(selectedWidget.id, "customPrimaryColor", "");
1574
+ }}
1575
+ className="rounded-md border border-(--theme-border-primary) px-2 py-1 text-xs text-(--theme-text-secondary) hover:bg-(--theme-bg-secondary)"
1576
+ >
1577
+ Reset
1578
+ </button>
1579
+ </div>
1580
+
1581
+ <div className="grid grid-cols-[40px_minmax(0,1fr)_auto] items-center gap-2">
1582
+ <input
1583
+ type="color"
1584
+ value={normalizeHexColor(selectedWidget.customSecondaryColor ?? "") ?? secondaryFallbackColor}
1585
+ onChange={(event) => {
1586
+ const next = event.target.value.toUpperCase();
1587
+ setSecondaryColorDraft(next);
1588
+ applyCustomColor(selectedWidget.id, "customSecondaryColor", next);
1589
+ }}
1590
+ className="h-9 w-10 rounded border border-(--theme-border-primary) bg-(--theme-bg-secondary) p-1"
1591
+ aria-label="Secondary chart color picker"
1592
+ />
1593
+ <input
1594
+ type="text"
1595
+ value={secondaryColorDraft}
1596
+ onChange={(event) => setSecondaryColorDraft(event.target.value)}
1597
+ onBlur={() => {
1598
+ const normalized = normalizeHexColor(secondaryColorDraft);
1599
+ if (secondaryColorDraft.trim().length > 0 && !normalized) {
1600
+ setSecondaryColorDraft(selectedWidget.customSecondaryColor ?? "");
1601
+ return;
1602
+ }
1603
+
1604
+ applyCustomColor(selectedWidget.id, "customSecondaryColor", secondaryColorDraft);
1605
+ }}
1606
+ placeholder="#4ECDC4"
1607
+ className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
1608
+ aria-label="Secondary chart color hex value"
1609
+ />
1610
+ <button
1611
+ type="button"
1612
+ onClick={() => {
1613
+ setSecondaryColorDraft("");
1614
+ applyCustomColor(selectedWidget.id, "customSecondaryColor", "");
1615
+ }}
1616
+ className="rounded-md border border-(--theme-border-primary) px-2 py-1 text-xs text-(--theme-text-secondary) hover:bg-(--theme-bg-secondary)"
1617
+ >
1618
+ Reset
1619
+ </button>
1620
+ </div>
1621
+ </div>
1622
+
1623
+ <div className="flex items-center gap-2">
1624
+ <label className="block text-xs text-(--theme-text-secondary)">Rows limit</label>
1625
+ <HelpTooltip text={`Chart row limit ${selectedWidget.limit}`} />
1626
+ </div>
1627
+ <input
1628
+ type="number"
1629
+ min={1}
1630
+ max={500}
1631
+ value={selectedWidget.limit}
1632
+ onChange={(event) => {
1633
+ const nextValue = Number(event.target.value);
1634
+ updateWidget(selectedWidget.id, (widget) => ({
1635
+ ...widget,
1636
+ limit: Number.isFinite(nextValue) ? nextValue : widget.limit,
1637
+ }));
1638
+ }}
1639
+ className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
1640
+ />
1641
+
1642
+ {selectedWidgetRowIndex !== null && selectedRow ? (
1643
+ <>
1644
+ <label className="block text-xs text-(--theme-text-secondary)">Row layout</label>
1645
+ <select
1646
+ value={selectedRow.mode}
1647
+ onChange={(event) => updateRowMode(selectedWidgetRowIndex, event.target.value as RowMode)}
1648
+ className="w-full rounded-md border border-(--theme-border-primary) bg-(--theme-bg-secondary) px-2 py-1.5 text-sm text-(--theme-text-primary)"
1649
+ >
1650
+ <option value="split">Two charts</option>
1651
+ <option value="full">Full-width chart</option>
1652
+ </select>
1653
+ </>
1654
+ ) : null}
1655
+
1656
+ <p className="text-xs text-(--theme-text-secondary)">
1657
+ Layout stays on a row grid: each row can be split or full-width, and you can add more rows as needed.
1658
+ </p>
1659
+ </>
1660
+ )}
1661
+ </DashboardCard>
1662
+ ) : null}
1663
+ </section>
1664
+
1665
+ </div>
1666
+ );
1667
+ }