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,188 @@
1
+ export function parseSiteIdParam(siteId: unknown): number | null {
2
+ if (typeof siteId === "number" && Number.isFinite(siteId)) return siteId;
3
+ if (typeof siteId === "string") {
4
+ const trimmed = siteId.trim();
5
+ if (trimmed.length === 0) return null;
6
+ const parsed = Number(trimmed);
7
+ if (Number.isFinite(parsed) && !Number.isNaN(parsed)) return parsed;
8
+ }
9
+ return null;
10
+ }
11
+
12
+ /** Returns true when value is a date-only string like "2026-02-08" (no time component). */
13
+ export function isDateOnly(value: unknown): boolean {
14
+ return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value);
15
+ }
16
+
17
+ export function isValidTimeZone(timeZone: unknown): timeZone is string {
18
+ if (typeof timeZone !== "string" || timeZone.trim().length === 0) return false;
19
+ try {
20
+ Intl.DateTimeFormat(undefined, { timeZone: timeZone.trim() });
21
+ return true;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ type DateBoundary = "start" | "end";
28
+
29
+ function getTimeZoneDateParts(date: Date, timeZone: string): {
30
+ year: number;
31
+ month: number;
32
+ day: number;
33
+ hour: number;
34
+ minute: number;
35
+ second: number;
36
+ } | null {
37
+ const formatter = new Intl.DateTimeFormat("en-CA", {
38
+ timeZone,
39
+ year: "numeric",
40
+ month: "2-digit",
41
+ day: "2-digit",
42
+ hour: "2-digit",
43
+ minute: "2-digit",
44
+ second: "2-digit",
45
+ hourCycle: "h23",
46
+ });
47
+
48
+ const parts = formatter.formatToParts(date);
49
+ const year = Number(parts.find((part) => part.type === "year")?.value);
50
+ const month = Number(parts.find((part) => part.type === "month")?.value);
51
+ const day = Number(parts.find((part) => part.type === "day")?.value);
52
+ const hour = Number(parts.find((part) => part.type === "hour")?.value);
53
+ const minute = Number(parts.find((part) => part.type === "minute")?.value);
54
+ const second = Number(parts.find((part) => part.type === "second")?.value);
55
+
56
+ if (
57
+ !Number.isFinite(year) ||
58
+ !Number.isFinite(month) ||
59
+ !Number.isFinite(day) ||
60
+ !Number.isFinite(hour) ||
61
+ !Number.isFinite(minute) ||
62
+ !Number.isFinite(second)
63
+ ) {
64
+ return null;
65
+ }
66
+
67
+ return {
68
+ year,
69
+ month,
70
+ day,
71
+ hour,
72
+ minute,
73
+ second,
74
+ };
75
+ }
76
+
77
+ function toUtcDateFromTimeZoneLocal(
78
+ year: number,
79
+ month: number,
80
+ day: number,
81
+ hour: number,
82
+ minute: number,
83
+ second: number,
84
+ millisecond: number,
85
+ timeZone: string,
86
+ ): Date | null {
87
+ const targetWithoutMs = Date.UTC(year, month - 1, day, hour, minute, second, 0);
88
+ let guess = targetWithoutMs;
89
+
90
+ for (let i = 0; i < 4; i++) {
91
+ const parts = getTimeZoneDateParts(new Date(guess), timeZone);
92
+ if (!parts) return null;
93
+
94
+ const representedWithoutMs = Date.UTC(
95
+ parts.year,
96
+ parts.month - 1,
97
+ parts.day,
98
+ parts.hour,
99
+ parts.minute,
100
+ parts.second,
101
+ 0,
102
+ );
103
+
104
+ const delta = targetWithoutMs - representedWithoutMs;
105
+ guess += delta;
106
+ if (delta === 0) break;
107
+ }
108
+
109
+ const parsed = new Date(guess + millisecond);
110
+ if (Number.isNaN(parsed.getTime())) return null;
111
+ return parsed;
112
+ }
113
+
114
+ function parseDateOnlyInTimeZone(value: string, timeZone: string, boundary: DateBoundary): Date | null {
115
+ const dateOnlyMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
116
+ if (!dateOnlyMatch) return null;
117
+
118
+ const year = Number(dateOnlyMatch[1]);
119
+ const month = Number(dateOnlyMatch[2]);
120
+ const day = Number(dateOnlyMatch[3]);
121
+
122
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
123
+ return null;
124
+ }
125
+
126
+ if (boundary === "end") {
127
+ return toUtcDateFromTimeZoneLocal(year, month, day, 23, 59, 59, 999, timeZone);
128
+ }
129
+
130
+ return toUtcDateFromTimeZoneLocal(year, month, day, 0, 0, 0, 0, timeZone);
131
+ }
132
+
133
+ export function parseDateParam(
134
+ value: unknown,
135
+ options: { timeZone?: string | null; boundary?: DateBoundary } = {},
136
+ ): Date | null {
137
+ if (!value) return null;
138
+ if (typeof value !== "string") return null;
139
+ const boundary = options.boundary ?? "start";
140
+ const timeZone = isValidTimeZone(options.timeZone) ? options.timeZone : null;
141
+ const dateOnlyMatch = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
142
+ if (dateOnlyMatch) {
143
+ if (timeZone) {
144
+ return parseDateOnlyInTimeZone(value, timeZone, boundary);
145
+ }
146
+ // Use UTC so date boundaries align with how events are stored (unix epoch / UTC)
147
+ const year = Number(dateOnlyMatch[1]);
148
+ const month = Number(dateOnlyMatch[2]);
149
+ const day = Number(dateOnlyMatch[3]);
150
+ const utcDate = new Date(Date.UTC(year, month - 1, day));
151
+ if (boundary === "end") {
152
+ utcDate.setUTCHours(23, 59, 59, 999);
153
+ }
154
+ if (Number.isNaN(utcDate.getTime())) return null;
155
+ return utcDate;
156
+ }
157
+ const parsed = new Date(value);
158
+ if (Number.isNaN(parsed.getTime())) return null;
159
+ return parsed;
160
+ }
161
+
162
+ export function matchesSourceFilter(
163
+ referer: unknown,
164
+ sourceFilter: string,
165
+ ): boolean {
166
+ if (sourceFilter.length === 0) return true;
167
+
168
+ if (!referer) {
169
+ return sourceFilter.toLowerCase() === "direct";
170
+ }
171
+
172
+ const refererString = String(referer);
173
+ if (refererString.length === 0 || refererString === "null") {
174
+ return sourceFilter.toLowerCase() === "direct";
175
+ }
176
+
177
+ const filterLower = sourceFilter.toLowerCase();
178
+ if (filterLower === "direct") {
179
+ return false;
180
+ }
181
+
182
+ try {
183
+ const refererUrl = new URL(refererString);
184
+ return refererUrl.hostname.toLowerCase().includes(filterLower);
185
+ } catch {
186
+ return refererString.toLowerCase().includes(filterLower);
187
+ }
188
+ }
@@ -0,0 +1,537 @@
1
+ /**
2
+ * Optimized Dashboard Query Functions for Durable Objects
3
+ *
4
+ * These functions provide efficient querying capabilities specifically designed
5
+ * for site-specific durable objects, taking advantage of the optimized indexes
6
+ * and storage structure.
7
+ */
8
+
9
+ import { eq, and, gte, lte, desc, count, sql } from "drizzle-orm";
10
+ import { drizzle } from "drizzle-orm/d1";
11
+ import { siteEvents } from "@/session/siteSchema";
12
+
13
+ type DatabaseType = ReturnType<typeof drizzle>;
14
+
15
+ /**
16
+ * Date range interface for queries
17
+ */
18
+ export interface DateRange {
19
+ start?: Date;
20
+ end?: Date;
21
+ }
22
+
23
+ /**
24
+ * Dashboard filter options
25
+ */
26
+ export interface DashboardFilters {
27
+ dateRange?: DateRange;
28
+ eventTypes?: string[];
29
+ countries?: string[];
30
+ deviceTypes?: string[];
31
+ referers?: string[];
32
+ tagIds?: string[];
33
+ }
34
+
35
+ /**
36
+ * Pagination options
37
+ */
38
+ export interface PaginationOptions {
39
+ limit?: number;
40
+ offset?: number;
41
+ }
42
+
43
+ /**
44
+ * Time series data point
45
+ */
46
+ export interface TimeSeriesPoint {
47
+ date: string;
48
+ count: number;
49
+ event?: string;
50
+ }
51
+
52
+ /**
53
+ * Aggregated metric result
54
+ */
55
+ export interface MetricResult {
56
+ label: string;
57
+ value: number;
58
+ percentage?: number;
59
+ }
60
+
61
+ /**
62
+ * Build common WHERE conditions from filters
63
+ */
64
+ function buildWhereConditions(filters: DashboardFilters) {
65
+ const conditions = [];
66
+
67
+ // Date range filtering
68
+ if (filters.dateRange?.start) {
69
+ conditions.push(gte(siteEvents.createdAt, filters.dateRange.start));
70
+ }
71
+ if (filters.dateRange?.end) {
72
+ conditions.push(lte(siteEvents.createdAt, filters.dateRange.end));
73
+ }
74
+
75
+ // Event type filtering
76
+ if (filters.eventTypes && filters.eventTypes.length > 0) {
77
+ if (filters.eventTypes.length === 1) {
78
+ conditions.push(eq(siteEvents.event, filters.eventTypes[0]));
79
+ } else {
80
+ conditions.push(sql`${siteEvents.event} IN ${filters.eventTypes}`);
81
+ }
82
+ }
83
+
84
+ // Country filtering
85
+ if (filters.countries && filters.countries.length > 0) {
86
+ if (filters.countries.length === 1) {
87
+ conditions.push(eq(siteEvents.country, filters.countries[0]));
88
+ } else {
89
+ conditions.push(sql`${siteEvents.country} IN ${filters.countries}`);
90
+ }
91
+ }
92
+
93
+ // Device type filtering
94
+ if (filters.deviceTypes && filters.deviceTypes.length > 0) {
95
+ if (filters.deviceTypes.length === 1) {
96
+ conditions.push(eq(siteEvents.device_type, filters.deviceTypes[0]));
97
+ } else {
98
+ conditions.push(sql`${siteEvents.device_type} IN ${filters.deviceTypes}`);
99
+ }
100
+ }
101
+
102
+ // Tag ID filtering
103
+ if (filters.tagIds && filters.tagIds.length > 0) {
104
+ if (filters.tagIds.length === 1) {
105
+ conditions.push(eq(siteEvents.tag_id, filters.tagIds[0]));
106
+ } else {
107
+ conditions.push(sql`${siteEvents.tag_id} IN ${filters.tagIds}`);
108
+ }
109
+ }
110
+
111
+ return conditions.length > 0 ? and(...conditions) : undefined;
112
+ }
113
+
114
+ /**
115
+ * Get total event count with filters
116
+ */
117
+ export async function getTotalEventCount(
118
+ db: DatabaseType,
119
+ filters: DashboardFilters = {}
120
+ ): Promise<number> {
121
+ const whereClause = buildWhereConditions(filters);
122
+
123
+ const result = await db
124
+ .select({ count: count() })
125
+ .from(siteEvents)
126
+ .where(whereClause);
127
+
128
+ return result[0]?.count || 0;
129
+ }
130
+
131
+ /**
132
+ * Get events by type with counts (optimized for pie charts)
133
+ */
134
+ export async function getEventTypeMetrics(
135
+ db: DatabaseType,
136
+ filters: DashboardFilters = {},
137
+ limit: number = 10
138
+ ): Promise<MetricResult[]> {
139
+ const whereClause = buildWhereConditions(filters);
140
+
141
+ const results = await db
142
+ .select({
143
+ event: siteEvents.event,
144
+ count: count()
145
+ })
146
+ .from(siteEvents)
147
+ .where(whereClause)
148
+ .groupBy(siteEvents.event)
149
+ .orderBy(desc(count()))
150
+ .limit(limit);
151
+
152
+ // Calculate total for percentages
153
+ const total = results.reduce((sum, item) => sum + item.count, 0);
154
+
155
+ return results.map(item => ({
156
+ label: item.event || 'Unknown',
157
+ value: item.count,
158
+ percentage: total > 0 ? Math.round((item.count / total) * 100) : 0
159
+ }));
160
+ }
161
+
162
+ /**
163
+ * Get country distribution metrics (optimized for geo charts)
164
+ */
165
+ export async function getCountryMetrics(
166
+ db: DatabaseType,
167
+ filters: DashboardFilters = {},
168
+ limit: number = 20
169
+ ): Promise<MetricResult[]> {
170
+ const whereClause = buildWhereConditions(filters);
171
+
172
+ const results = await db
173
+ .select({
174
+ country: siteEvents.country,
175
+ count: count()
176
+ })
177
+ .from(siteEvents)
178
+ .where(whereClause)
179
+ .groupBy(siteEvents.country)
180
+ .orderBy(desc(count()))
181
+ .limit(limit);
182
+
183
+ const total = results.reduce((sum, item) => sum + item.count, 0);
184
+
185
+ return results.map(item => ({
186
+ label: item.country || 'Unknown',
187
+ value: item.count,
188
+ percentage: total > 0 ? Math.round((item.count / total) * 100) : 0
189
+ }));
190
+ }
191
+
192
+ /**
193
+ * Get device type distribution (optimized for bar charts)
194
+ */
195
+ export async function getDeviceTypeMetrics(
196
+ db: DatabaseType,
197
+ filters: DashboardFilters = {}
198
+ ): Promise<MetricResult[]> {
199
+ const whereClause = buildWhereConditions(filters);
200
+
201
+ const results = await db
202
+ .select({
203
+ device_type: siteEvents.device_type,
204
+ count: count()
205
+ })
206
+ .from(siteEvents)
207
+ .where(whereClause)
208
+ .groupBy(siteEvents.device_type)
209
+ .orderBy(desc(count()));
210
+
211
+ const total = results.reduce((sum, item) => sum + item.count, 0);
212
+
213
+ return results.map(item => ({
214
+ label: item.device_type || 'Unknown',
215
+ value: item.count,
216
+ percentage: total > 0 ? Math.round((item.count / total) * 100) : 0
217
+ }));
218
+ }
219
+
220
+ /**
221
+ * Get top referers (optimized for traffic source analysis)
222
+ */
223
+ export async function getTopReferers(
224
+ db: DatabaseType,
225
+ filters: DashboardFilters = {},
226
+ limit: number = 10
227
+ ): Promise<MetricResult[]> {
228
+ const whereClause = buildWhereConditions(filters);
229
+
230
+ const results = await db
231
+ .select({
232
+ referer: siteEvents.referer,
233
+ count: count()
234
+ })
235
+ .from(siteEvents)
236
+ .where(whereClause)
237
+ .groupBy(siteEvents.referer)
238
+ .orderBy(desc(count()))
239
+ .limit(limit);
240
+
241
+ const total = results.reduce((sum, item) => sum + item.count, 0);
242
+
243
+ return results.map(item => ({
244
+ label: cleanReferer(item.referer || 'Direct'),
245
+ value: item.count,
246
+ percentage: total > 0 ? Math.round((item.count / total) * 100) : 0
247
+ }));
248
+ }
249
+
250
+ /**
251
+ * Get top pages by URL (optimized for content analysis)
252
+ */
253
+ export async function getTopPages(
254
+ db: DatabaseType,
255
+ filters: DashboardFilters = {},
256
+ limit: number = 10
257
+ ): Promise<MetricResult[]> {
258
+ const whereClause = buildWhereConditions(filters);
259
+
260
+ const results = await db
261
+ .select({
262
+ page_url: siteEvents.page_url,
263
+ count: count()
264
+ })
265
+ .from(siteEvents)
266
+ .where(whereClause)
267
+ .groupBy(siteEvents.page_url)
268
+ .orderBy(desc(count()))
269
+ .limit(limit);
270
+
271
+ const total = results.reduce((sum, item) => sum + item.count, 0);
272
+
273
+ return results.map(item => ({
274
+ label: cleanUrl(item.page_url || 'Unknown'),
275
+ value: item.count,
276
+ percentage: total > 0 ? Math.round((item.count / total) * 100) : 0
277
+ }));
278
+ }
279
+
280
+ /**
281
+ * Get time series data for line charts (optimized with date truncation)
282
+ */
283
+ export async function getTimeSeriesData(
284
+ db: DatabaseType,
285
+ filters: DashboardFilters = {},
286
+ granularity: 'hour' | 'day' | 'week' | 'month' = 'day'
287
+ ): Promise<TimeSeriesPoint[]> {
288
+ const whereClause = buildWhereConditions(filters);
289
+
290
+ // Use SQLite date functions for efficient grouping
291
+ let dateFormat: string;
292
+ switch (granularity) {
293
+ case 'hour':
294
+ dateFormat = '%Y-%m-%d %H:00:00';
295
+ break;
296
+ case 'day':
297
+ dateFormat = '%Y-%m-%d';
298
+ break;
299
+ case 'week':
300
+ dateFormat = '%Y-W%W';
301
+ break;
302
+ case 'month':
303
+ dateFormat = '%Y-%m';
304
+ break;
305
+ }
306
+
307
+ const timeBucketExpr = sql<string>`strftime(${dateFormat}, ${siteEvents.createdAt})`;
308
+
309
+ const results = await db
310
+ .select({
311
+ date: timeBucketExpr,
312
+ count: count()
313
+ })
314
+ .from(siteEvents)
315
+ .where(whereClause)
316
+ .groupBy(timeBucketExpr)
317
+ .orderBy(timeBucketExpr);
318
+
319
+ return results.map(item => ({
320
+ date: item.date,
321
+ count: item.count
322
+ }));
323
+ }
324
+
325
+ /**
326
+ * Get time series data by event type (for multi-line charts)
327
+ */
328
+ export async function getTimeSeriesByEvent(
329
+ db: DatabaseType,
330
+ filters: DashboardFilters = {},
331
+ granularity: 'hour' | 'day' | 'week' | 'month' = 'day',
332
+ topEventTypes: number = 5
333
+ ): Promise<{ [eventType: string]: TimeSeriesPoint[] }> {
334
+ const whereClause = buildWhereConditions(filters);
335
+
336
+ // First, get top event types
337
+ const topEvents = await db
338
+ .select({
339
+ event: siteEvents.event,
340
+ count: count()
341
+ })
342
+ .from(siteEvents)
343
+ .where(whereClause)
344
+ .groupBy(siteEvents.event)
345
+ .orderBy(desc(count()))
346
+ .limit(topEventTypes);
347
+
348
+ const eventTypes = topEvents.map(e => e.event);
349
+
350
+ // Get time series for each event type
351
+ let dateFormat: string;
352
+ switch (granularity) {
353
+ case 'hour':
354
+ dateFormat = '%Y-%m-%d %H:00:00';
355
+ break;
356
+ case 'day':
357
+ dateFormat = '%Y-%m-%d';
358
+ break;
359
+ case 'week':
360
+ dateFormat = '%Y-W%W';
361
+ break;
362
+ case 'month':
363
+ dateFormat = '%Y-%m';
364
+ break;
365
+ }
366
+
367
+ const timeBucketExpr = sql<string>`strftime(${dateFormat}, ${siteEvents.createdAt})`;
368
+
369
+ const results = await db
370
+ .select({
371
+ date: timeBucketExpr,
372
+ event: siteEvents.event,
373
+ count: count()
374
+ })
375
+ .from(siteEvents)
376
+ .where(and(
377
+ whereClause,
378
+ sql`${siteEvents.event} IN ${eventTypes}`
379
+ ))
380
+ .groupBy(
381
+ timeBucketExpr,
382
+ siteEvents.event
383
+ )
384
+ .orderBy(timeBucketExpr);
385
+
386
+ // Group by event type
387
+ const grouped: { [eventType: string]: TimeSeriesPoint[] } = {};
388
+
389
+ for (const result of results) {
390
+ if (!grouped[result.event]) {
391
+ grouped[result.event] = [];
392
+ }
393
+ grouped[result.event].push({
394
+ date: result.date,
395
+ count: result.count,
396
+ event: result.event
397
+ });
398
+ }
399
+
400
+ return grouped;
401
+ }
402
+
403
+ /**
404
+ * Get comprehensive dashboard summary (single optimized query)
405
+ */
406
+ export async function getDashboardSummary(
407
+ db: DatabaseType,
408
+ filters: DashboardFilters = {}
409
+ ): Promise<{
410
+ totalEvents: number;
411
+ uniqueVisitors: number;
412
+ topEventTypes: MetricResult[];
413
+ topCountries: MetricResult[];
414
+ topDevices: MetricResult[];
415
+ topReferers: MetricResult[];
416
+ }> {
417
+ const whereClause = buildWhereConditions(filters);
418
+
419
+ // Execute multiple queries in parallel for better performance
420
+ const [
421
+ totalEventsResult,
422
+ uniqueVisitorsResult,
423
+ eventTypesResult,
424
+ countriesResult,
425
+ devicesResult,
426
+ referersResult
427
+ ] = await Promise.all([
428
+ // Total events
429
+ db.select({ count: count() }).from(siteEvents).where(whereClause),
430
+
431
+ // Unique visitors (approximate using distinct RIDs)
432
+ db.select({ count: sql<number>`COUNT(DISTINCT ${siteEvents.rid})` }).from(siteEvents).where(whereClause),
433
+
434
+ // Top event types
435
+ db.select({
436
+ event: siteEvents.event,
437
+ count: count()
438
+ })
439
+ .from(siteEvents)
440
+ .where(whereClause)
441
+ .groupBy(siteEvents.event)
442
+ .orderBy(desc(count()))
443
+ .limit(5),
444
+
445
+ // Top countries
446
+ db.select({
447
+ country: siteEvents.country,
448
+ count: count()
449
+ })
450
+ .from(siteEvents)
451
+ .where(whereClause)
452
+ .groupBy(siteEvents.country)
453
+ .orderBy(desc(count()))
454
+ .limit(5),
455
+
456
+ // Top devices
457
+ db.select({
458
+ device_type: siteEvents.device_type,
459
+ count: count()
460
+ })
461
+ .from(siteEvents)
462
+ .where(whereClause)
463
+ .groupBy(siteEvents.device_type)
464
+ .orderBy(desc(count()))
465
+ .limit(5),
466
+
467
+ // Top referers
468
+ db.select({
469
+ referer: siteEvents.referer,
470
+ count: count()
471
+ })
472
+ .from(siteEvents)
473
+ .where(whereClause)
474
+ .groupBy(siteEvents.referer)
475
+ .orderBy(desc(count()))
476
+ .limit(5)
477
+ ]);
478
+
479
+ const totalEvents = totalEventsResult[0]?.count || 0;
480
+
481
+ return {
482
+ totalEvents,
483
+ uniqueVisitors: uniqueVisitorsResult[0]?.count || 0,
484
+ topEventTypes: eventTypesResult.map(item => ({
485
+ label: item.event || 'Unknown',
486
+ value: item.count,
487
+ percentage: totalEvents > 0 ? Math.round((item.count / totalEvents) * 100) : 0
488
+ })),
489
+ topCountries: countriesResult.map(item => ({
490
+ label: item.country || 'Unknown',
491
+ value: item.count,
492
+ percentage: totalEvents > 0 ? Math.round((item.count / totalEvents) * 100) : 0
493
+ })),
494
+ topDevices: devicesResult.map(item => ({
495
+ label: item.device_type || 'Unknown',
496
+ value: item.count,
497
+ percentage: totalEvents > 0 ? Math.round((item.count / totalEvents) * 100) : 0
498
+ })),
499
+ topReferers: referersResult.map(item => ({
500
+ label: cleanReferer(item.referer || 'Direct'),
501
+ value: item.count,
502
+ percentage: totalEvents > 0 ? Math.round((item.count / totalEvents) * 100) : 0
503
+ }))
504
+ };
505
+ }
506
+
507
+ /**
508
+ * Helper function to clean referer URLs
509
+ */
510
+ function cleanReferer(referer: string): string {
511
+ if (!referer || referer === 'null' || referer === '') {
512
+ return 'Direct';
513
+ }
514
+
515
+ try {
516
+ const url = new URL(referer);
517
+ return url.hostname;
518
+ } catch {
519
+ return referer.length > 50 ? referer.substring(0, 47) + '...' : referer;
520
+ }
521
+ }
522
+
523
+ /**
524
+ * Helper function to clean page URLs
525
+ */
526
+ function cleanUrl(url: string): string {
527
+ if (!url || url === 'null' || url === '') {
528
+ return 'Unknown';
529
+ }
530
+
531
+ try {
532
+ const urlObj = new URL(url);
533
+ return urlObj.pathname + (urlObj.search ? urlObj.search : '');
534
+ } catch {
535
+ return url.length > 50 ? url.substring(0, 47) + '...' : url;
536
+ }
537
+ }