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.
- package/.env.example +37 -0
- package/README.md +486 -0
- package/alchemy.run.ts +155 -0
- package/cli/bootstrap-admin.ts +284 -0
- package/cli/deploy-staging.ts +692 -0
- package/cli/import-events.ts +628 -0
- package/cli/import-sites.ts +518 -0
- package/cli/index.ts +609 -0
- package/cli/init-db.ts +269 -0
- package/cli/migrate-to-durable-objects.ts +564 -0
- package/cli/migration-worker.ts +300 -0
- package/cli/performance-test.ts +588 -0
- package/cli/pg/client.ts +4 -0
- package/cli/pg/new-site.ts +153 -0
- package/cli/rollback-durable-objects.ts +622 -0
- package/cli/seed-data.ts +459 -0
- package/cli/setup.js +18 -0
- package/cli/setup.ts +463 -0
- package/cli/validate-migration.ts +200 -0
- package/cli/wrangler-migration.jsonc +28 -0
- package/db/adapter.ts +166 -0
- package/db/analytics_engine/client.ts +0 -0
- package/db/analytics_engine/sites.ts +0 -0
- package/db/client.ts +16 -0
- package/db/d1/client.ts +8 -0
- package/db/d1/drizzle.config.ts +35 -0
- package/db/d1/migrations/0000_true_maelstrom.sql +165 -0
- package/db/d1/migrations/0001_wonderful_bloodaxe.sql +12 -0
- package/db/d1/migrations/0002_late_frightful_four.sql +1 -0
- package/db/d1/migrations/0003_cuddly_obadiah_stane.sql +16 -0
- package/db/d1/migrations/0004_mute_stardust.sql +1 -0
- package/db/d1/migrations/0005_awesome_silvermane.sql +3 -0
- package/db/d1/migrations/0006_volatile_shriek.sql +2 -0
- package/db/d1/migrations/0007_superb_lila_cheney.sql +1 -0
- package/db/d1/migrations/0008_bitter_longshot.sql +17 -0
- package/db/d1/migrations/0009_wonderful_madame_masque.sql +28 -0
- package/db/d1/migrations/meta/0000_snapshot.json +1112 -0
- package/db/d1/migrations/meta/0001_snapshot.json +1187 -0
- package/db/d1/migrations/meta/0002_snapshot.json +1194 -0
- package/db/d1/migrations/meta/0003_snapshot.json +1296 -0
- package/db/d1/migrations/meta/0004_snapshot.json +1303 -0
- package/db/d1/migrations/meta/0005_snapshot.json +1325 -0
- package/db/d1/migrations/meta/0006_snapshot.json +1339 -0
- package/db/d1/migrations/meta/0007_snapshot.json +1347 -0
- package/db/d1/migrations/meta/0008_snapshot.json +1464 -0
- package/db/d1/migrations/meta/0009_snapshot.json +1648 -0
- package/db/d1/migrations/meta/_journal.json +76 -0
- package/db/d1/schema.ts +407 -0
- package/db/d1/sites.ts +374 -0
- package/db/d1/teamAiUsage.ts +101 -0
- package/db/d1/teams.ts +127 -0
- package/db/durable/drizzle.config.ts +8 -0
- package/db/durable/durableObjectClient.ts +480 -0
- package/db/durable/events.ts +100 -0
- package/db/durable/migrations/0000_fair_bucky.sql +38 -0
- package/db/durable/migrations/meta/0000_snapshot.json +278 -0
- package/db/durable/migrations/meta/_journal.json +13 -0
- package/db/durable/migrations/migrations.js +10 -0
- package/db/durable/schema.ts +5 -0
- package/db/durable/siteDurableObject.ts +1352 -0
- package/db/durable/types.ts +53 -0
- package/db/postgres/client.ts +13 -0
- package/db/postgres/drizzle.config.ts +12 -0
- package/db/postgres/migrations/0000_brainy_sprite.sql +116 -0
- package/db/postgres/migrations/meta/0000_snapshot.json +681 -0
- package/db/postgres/migrations/meta/_journal.json +13 -0
- package/db/postgres/schema.ts +145 -0
- package/db/postgres/sites.ts +118 -0
- package/db/tranformReports.ts +595 -0
- package/db/types.ts +55 -0
- package/endpoints/api_worker.tsx +1854 -0
- package/endpoints/site_do_worker.ts +11 -0
- package/index.d.ts +63 -0
- package/index.ts +83 -0
- package/lib/auth.ts +279 -0
- package/lib/geojson/world_countries.json +45307 -0
- package/lib/random_name.ts +41 -0
- package/lib/sendMail.ts +252 -0
- package/package.json +142 -0
- package/public/favicon.ico +0 -0
- package/public/images/android-chrome-192x192.png +0 -0
- package/public/images/android-chrome-512x512.png +0 -0
- package/public/images/apple-touch-icon.png +0 -0
- package/public/images/favicon-16x16.png +0 -0
- package/public/images/favicon-32x32.png +0 -0
- package/public/images/lytx_dark_dashboard.png +0 -0
- package/public/images/lytx_light_dashboard.png +0 -0
- package/public/images/safari-pinned-tab.svg +4 -0
- package/public/logo.png +0 -0
- package/public/site.webmanifest +26 -0
- package/public/sw.js +107 -0
- package/src/Document.tsx +86 -0
- package/src/api/ai_api.ts +1156 -0
- package/src/api/authMiddleware.ts +45 -0
- package/src/api/auth_api.ts +465 -0
- package/src/api/event_labels_api.ts +193 -0
- package/src/api/events_api.ts +210 -0
- package/src/api/queueWorker.ts +303 -0
- package/src/api/reports_api.ts +278 -0
- package/src/api/seed_api.ts +288 -0
- package/src/api/sites_api.ts +904 -0
- package/src/api/tag_api.ts +458 -0
- package/src/api/tag_api_v2.ts +289 -0
- package/src/api/team_api.ts +456 -0
- package/src/app/Dashboard.tsx +1339 -0
- package/src/app/Events.tsx +974 -0
- package/src/app/Explore.tsx +312 -0
- package/src/app/Layout.tsx +58 -0
- package/src/app/Settings.tsx +1302 -0
- package/src/app/components/DashboardCard.tsx +118 -0
- package/src/app/components/EditableCell.tsx +123 -0
- package/src/app/components/EventForm.tsx +93 -0
- package/src/app/components/MarketingFooter.tsx +49 -0
- package/src/app/components/MarketingNav.tsx +150 -0
- package/src/app/components/Nav.tsx +755 -0
- package/src/app/components/NewSiteSetup.tsx +298 -0
- package/src/app/components/SQLEditor.tsx +740 -0
- package/src/app/components/SiteSelector.tsx +126 -0
- package/src/app/components/SiteTag.tsx +42 -0
- package/src/app/components/SiteTagInstallCard.tsx +241 -0
- package/src/app/components/WorldMapCard.tsx +337 -0
- package/src/app/components/charts/ChartComponents.tsx +1481 -0
- package/src/app/components/charts/EventFunnel.tsx +45 -0
- package/src/app/components/charts/EventSummary.tsx +194 -0
- package/src/app/components/charts/SankeyFlows.tsx +72 -0
- package/src/app/components/marketing/CheckIcon.tsx +16 -0
- package/src/app/components/marketing/MarketingLayout.tsx +23 -0
- package/src/app/components/marketing/SectionHeading.tsx +35 -0
- package/src/app/components/reports/AskAiWorkspace.tsx +371 -0
- package/src/app/components/reports/CreateReportStarter.tsx +74 -0
- package/src/app/components/reports/DashboardRouteFiltersContext.tsx +14 -0
- package/src/app/components/reports/DashboardToolbar.tsx +154 -0
- package/src/app/components/reports/DashboardWorkspaceLayout.tsx +63 -0
- package/src/app/components/reports/DashboardWorkspaceShell.tsx +118 -0
- package/src/app/components/reports/ReportBuilderWorkspace.tsx +76 -0
- package/src/app/components/reports/custom/CustomReportBuilderPage.tsx +1667 -0
- package/src/app/components/reports/custom/ReportWidgetChart.tsx +297 -0
- package/src/app/components/reports/custom/buildWidgetSql.ts +151 -0
- package/src/app/components/reports/custom/chartPalettes.ts +18 -0
- package/src/app/components/reports/custom/types.ts +50 -0
- package/src/app/components/reports/reportBuilderMenuItems.ts +17 -0
- package/src/app/components/reports/useDashboardToolbarControls.tsx +235 -0
- package/src/app/components/ui/AlertBanner.tsx +101 -0
- package/src/app/components/ui/Button.tsx +55 -0
- package/src/app/components/ui/Card.tsx +80 -0
- package/src/app/components/ui/Input.tsx +72 -0
- package/src/app/components/ui/Link.tsx +23 -0
- package/src/app/components/ui/ReportBuilderMenu.tsx +246 -0
- package/src/app/components/ui/ThemeToggle.tsx +54 -0
- package/src/app/constants.ts +6 -0
- package/src/app/headers.ts +33 -0
- package/src/app/providers/AuthProvider.tsx +189 -0
- package/src/app/providers/ClientProviders.tsx +18 -0
- package/src/app/providers/QueryProvider.tsx +23 -0
- package/src/app/providers/ThemeProvider.tsx +88 -0
- package/src/app/utils/chartThemes.ts +146 -0
- package/src/app/utils/keybinds.ts +96 -0
- package/src/app/utils/media.tsx +24 -0
- package/src/client.tsx +114 -0
- package/src/config/createLytxAppConfig.ts +252 -0
- package/src/config/resourceNames.ts +88 -0
- package/src/db/index.ts +67 -0
- package/src/index.css +285 -0
- package/src/lib/featureFlags.ts +69 -0
- package/src/pages/GetStarted.tsx +290 -0
- package/src/pages/Home.tsx +268 -0
- package/src/pages/Login.tsx +283 -0
- package/src/pages/PrivacyPolicy.tsx +120 -0
- package/src/pages/Signup.tsx +267 -0
- package/src/pages/TermsOfService.tsx +126 -0
- package/src/pages/VerifyEmail.tsx +56 -0
- package/src/session/durableObject.ts +7 -0
- package/src/session/siteSchema.ts +86 -0
- package/src/session/types.ts +36 -0
- package/src/templates/README.md +80 -0
- package/src/templates/cleanFunctions.js +44 -0
- package/src/templates/embedFunctions.js +52 -0
- package/src/templates/lytx-shared.ts +662 -0
- package/src/templates/lytxpixel-core.ts +144 -0
- package/src/templates/lytxpixel.ts +267 -0
- package/src/templates/lytxpixelBrowser.js +634 -0
- package/src/templates/lytxpixelBrowser.mjs +634 -0
- package/src/templates/parseData.js +12 -0
- package/src/templates/script.ts +31 -0
- package/src/templates/template.tsx +50 -0
- package/src/templates/test.js +3 -0
- package/src/templates/trackWebEvents.ts +177 -0
- package/src/templates/vendors/clickcease.ts +8 -0
- package/src/templates/vendors/google.ts +174 -0
- package/src/templates/vendors/linkedin.ts +23 -0
- package/src/templates/vendors/meta.ts +56 -0
- package/src/templates/vendors/quantcast.ts +22 -0
- package/src/templates/vendors/simplfi.ts +7 -0
- package/src/types/app-context.ts +16 -0
- package/src/utilities/dashboardParams.ts +188 -0
- package/src/utilities/dashboardQueries.ts +537 -0
- package/src/utilities/dashboardTransforms.ts +167 -0
- package/src/utilities/dataValidation.ts +414 -0
- package/src/utilities/detector.ts +73 -0
- package/src/utilities/encrypt.ts +103 -0
- package/src/utilities/index.ts +13 -0
- package/src/utilities/parser.ts +117 -0
- package/src/utilities/performanceMonitoring.ts +570 -0
- package/src/utilities/route_interuptors.ts +24 -0
- package/src/worker.tsx +675 -0
- package/tsconfig.json +78 -0
- package/types/env.d.ts +16 -0
- package/types/rw.d.ts +7 -0
- package/types/shims.d.ts +53 -0
- package/types/vite.d.ts +19 -0
- package/vite/vite-plugin-pixel-bundle.ts +126 -0
- package/vite.config.ts +53 -0
- 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
|
+
}
|