diff --git a/package.json b/package.json index fd31320e..23e19f8e 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "postbuild": "node scripts/postbuild.js", "test": "jest", "cypress-open": "cypress open cypress run", - "cypress-run": "cypress run cypress run" + "cypress-run": "cypress run cypress run", + "seed-data": "tsx scripts/seed-data.ts" }, "lint-staged": { "**/*.{js,jsx,ts,tsx}": [ @@ -159,7 +160,7 @@ "eslint-plugin-cypress": "^2.15.1", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jest": "^27.9.0", - "eslint-plugin-prettier": "^5.5.3", + "eslint-plugin-prettier": "^5.5.4", "extract-react-intl-messages": "^4.1.1", "husky": "^9.1.7", "jest": "^29.7.0", @@ -185,6 +186,7 @@ "ts-jest": "^29.4.5", "ts-node": "^10.9.1", "tsup": "^8.5.0", + "tsx": "^4.19.0", "typescript": "^5.9.3" } } diff --git a/scripts/seed-data.ts b/scripts/seed-data.ts new file mode 100644 index 00000000..82a0564c --- /dev/null +++ b/scripts/seed-data.ts @@ -0,0 +1,121 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ + +/** + * Umami Sample Data Generator + * + * Generates realistic analytics data for local development and testing. + * Creates two demo websites: + * - Demo Blog: Low traffic (~100 sessions/month) + * - Demo SaaS: Average traffic (~500 sessions/day) + * + * Usage: + * npm run seed-data # Generate 30 days of data + * npm run seed-data -- --days 90 # Generate 90 days of data + * npm run seed-data -- --clear # Clear existing demo data first + * npm run seed-data -- --verbose # Show detailed progress + */ + +import { seed, type SeedConfig } from './seed/index.js'; + +function parseArgs(): SeedConfig { + const args = process.argv.slice(2); + + const config: SeedConfig = { + days: 30, + clear: false, + verbose: false, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === '--days' && args[i + 1]) { + config.days = parseInt(args[i + 1], 10); + if (isNaN(config.days) || config.days < 1) { + console.error('Error: --days must be a positive integer'); + process.exit(1); + } + i++; + } else if (arg === '--clear') { + config.clear = true; + } else if (arg === '--verbose' || arg === '-v') { + config.verbose = true; + } else if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } else if (arg.startsWith('--days=')) { + config.days = parseInt(arg.split('=')[1], 10); + if (isNaN(config.days) || config.days < 1) { + console.error('Error: --days must be a positive integer'); + process.exit(1); + } + } + } + + return config; +} + +function printHelp(): void { + console.log(` +Umami Sample Data Generator + +Generates realistic analytics data for local development and testing. + +Usage: + npm run seed-data [options] + +Options: + --days Number of days of data to generate (default: 30) + --clear Clear existing demo data before generating + --verbose, -v Show detailed progress + --help, -h Show this help message + +Examples: + npm run seed-data # Generate 30 days of data + npm run seed-data -- --days 90 # Generate 90 days of data + npm run seed-data -- --clear # Clear existing demo data first + npm run seed-data -- --days 7 -v # Generate 7 days with verbose output + +Generated Sites: + - Demo Blog: Low traffic (~90 sessions/month) + - Demo SaaS: Average traffic (~500 sessions/day) with revenue tracking + +Note: + This script is blocked from running in production environments + (NODE_ENV=production or cloud platforms like Vercel/Netlify/Railway). +`); +} + +function checkEnvironment(): void { + const nodeEnv = process.env.NODE_ENV; + + if (nodeEnv === 'production') { + console.error('\nError: seed-data cannot run in production environment.'); + console.error('This script is only for local development and testing.\n'); + process.exit(1); + } + + if (process.env.VERCEL || process.env.NETLIFY || process.env.RAILWAY_ENVIRONMENT) { + console.error('\nError: seed-data cannot run in cloud environments.'); + console.error('This script is only for local development and testing.\n'); + process.exit(1); + } +} + +async function main(): Promise { + console.log('\nUmami Sample Data Generator\n'); + + checkEnvironment(); + + const config = parseArgs(); + + try { + await seed(config); + } catch (error) { + console.error('\nError generating seed data:', error); + process.exit(1); + } +} + +main(); diff --git a/scripts/seed/distributions/devices.ts b/scripts/seed/distributions/devices.ts new file mode 100644 index 00000000..9d8b8c00 --- /dev/null +++ b/scripts/seed/distributions/devices.ts @@ -0,0 +1,80 @@ +import { weightedRandom, pickRandom, type WeightedOption } from '../utils.js'; + +export type DeviceType = 'desktop' | 'mobile' | 'tablet'; + +const deviceWeights: WeightedOption[] = [ + { value: 'desktop', weight: 0.55 }, + { value: 'mobile', weight: 0.4 }, + { value: 'tablet', weight: 0.05 }, +]; + +const browsersByDevice: Record[]> = { + desktop: [ + { value: 'Chrome', weight: 0.65 }, + { value: 'Safari', weight: 0.12 }, + { value: 'Firefox', weight: 0.1 }, + { value: 'Edge', weight: 0.1 }, + { value: 'Opera', weight: 0.03 }, + ], + mobile: [ + { value: 'Chrome', weight: 0.55 }, + { value: 'Safari', weight: 0.35 }, + { value: 'Samsung', weight: 0.05 }, + { value: 'Firefox', weight: 0.03 }, + { value: 'Opera', weight: 0.02 }, + ], + tablet: [ + { value: 'Safari', weight: 0.6 }, + { value: 'Chrome', weight: 0.35 }, + { value: 'Firefox', weight: 0.05 }, + ], +}; + +const osByDevice: Record[]> = { + desktop: [ + { value: 'Windows 10', weight: 0.5 }, + { value: 'Mac OS', weight: 0.3 }, + { value: 'Linux', weight: 0.12 }, + { value: 'Chrome OS', weight: 0.05 }, + { value: 'Windows 11', weight: 0.03 }, + ], + mobile: [ + { value: 'iOS', weight: 0.45 }, + { value: 'Android', weight: 0.55 }, + ], + tablet: [ + { value: 'iOS', weight: 0.75 }, + { value: 'Android', weight: 0.25 }, + ], +}; + +const screensByDevice: Record = { + desktop: [ + '1920x1080', + '2560x1440', + '1366x768', + '1440x900', + '3840x2160', + '1536x864', + '1680x1050', + '2560x1080', + ], + mobile: ['390x844', '414x896', '375x812', '360x800', '428x926', '393x873', '412x915', '360x780'], + tablet: ['1024x768', '768x1024', '834x1194', '820x1180', '810x1080', '800x1280'], +}; + +export interface DeviceInfo { + device: DeviceType; + browser: string; + os: string; + screen: string; +} + +export function getRandomDevice(): DeviceInfo { + const device = weightedRandom(deviceWeights); + const browser = weightedRandom(browsersByDevice[device]); + const os = weightedRandom(osByDevice[device]); + const screen = pickRandom(screensByDevice[device]); + + return { device, browser, os, screen }; +} diff --git a/scripts/seed/distributions/geographic.ts b/scripts/seed/distributions/geographic.ts new file mode 100644 index 00000000..ba6ebae3 --- /dev/null +++ b/scripts/seed/distributions/geographic.ts @@ -0,0 +1,144 @@ +import { weightedRandom, pickRandom, type WeightedOption } from '../utils.js'; + +interface GeoLocation { + country: string; + region: string; + city: string; +} + +const countryWeights: WeightedOption[] = [ + { value: 'US', weight: 0.35 }, + { value: 'GB', weight: 0.08 }, + { value: 'DE', weight: 0.06 }, + { value: 'FR', weight: 0.05 }, + { value: 'CA', weight: 0.04 }, + { value: 'AU', weight: 0.03 }, + { value: 'IN', weight: 0.08 }, + { value: 'BR', weight: 0.04 }, + { value: 'JP', weight: 0.03 }, + { value: 'NL', weight: 0.02 }, + { value: 'ES', weight: 0.02 }, + { value: 'IT', weight: 0.02 }, + { value: 'PL', weight: 0.02 }, + { value: 'SE', weight: 0.01 }, + { value: 'MX', weight: 0.02 }, + { value: 'KR', weight: 0.02 }, + { value: 'SG', weight: 0.01 }, + { value: 'ID', weight: 0.02 }, + { value: 'PH', weight: 0.01 }, + { value: 'TH', weight: 0.01 }, + { value: 'VN', weight: 0.01 }, + { value: 'RU', weight: 0.02 }, + { value: 'UA', weight: 0.01 }, + { value: 'ZA', weight: 0.01 }, + { value: 'NG', weight: 0.01 }, +]; + +const regionsByCountry: Record = { + US: [ + { region: 'CA', city: 'San Francisco' }, + { region: 'CA', city: 'Los Angeles' }, + { region: 'NY', city: 'New York' }, + { region: 'TX', city: 'Austin' }, + { region: 'TX', city: 'Houston' }, + { region: 'WA', city: 'Seattle' }, + { region: 'IL', city: 'Chicago' }, + { region: 'MA', city: 'Boston' }, + { region: 'CO', city: 'Denver' }, + { region: 'GA', city: 'Atlanta' }, + { region: 'FL', city: 'Miami' }, + { region: 'PA', city: 'Philadelphia' }, + ], + GB: [ + { region: 'ENG', city: 'London' }, + { region: 'ENG', city: 'Manchester' }, + { region: 'ENG', city: 'Birmingham' }, + { region: 'SCT', city: 'Edinburgh' }, + { region: 'ENG', city: 'Bristol' }, + ], + DE: [ + { region: 'BE', city: 'Berlin' }, + { region: 'BY', city: 'Munich' }, + { region: 'HH', city: 'Hamburg' }, + { region: 'HE', city: 'Frankfurt' }, + { region: 'NW', city: 'Cologne' }, + ], + FR: [ + { region: 'IDF', city: 'Paris' }, + { region: 'ARA', city: 'Lyon' }, + { region: 'PAC', city: 'Marseille' }, + { region: 'OCC', city: 'Toulouse' }, + ], + CA: [ + { region: 'ON', city: 'Toronto' }, + { region: 'BC', city: 'Vancouver' }, + { region: 'QC', city: 'Montreal' }, + { region: 'AB', city: 'Calgary' }, + ], + AU: [ + { region: 'NSW', city: 'Sydney' }, + { region: 'VIC', city: 'Melbourne' }, + { region: 'QLD', city: 'Brisbane' }, + { region: 'WA', city: 'Perth' }, + ], + IN: [ + { region: 'MH', city: 'Mumbai' }, + { region: 'KA', city: 'Bangalore' }, + { region: 'DL', city: 'New Delhi' }, + { region: 'TN', city: 'Chennai' }, + { region: 'TG', city: 'Hyderabad' }, + ], + BR: [ + { region: 'SP', city: 'Sao Paulo' }, + { region: 'RJ', city: 'Rio de Janeiro' }, + { region: 'MG', city: 'Belo Horizonte' }, + ], + JP: [ + { region: '13', city: 'Tokyo' }, + { region: '27', city: 'Osaka' }, + { region: '23', city: 'Nagoya' }, + ], + NL: [ + { region: 'NH', city: 'Amsterdam' }, + { region: 'ZH', city: 'Rotterdam' }, + { region: 'ZH', city: 'The Hague' }, + ], +}; + +const defaultRegions = [{ region: '', city: '' }]; + +export function getRandomGeo(): GeoLocation { + const country = weightedRandom(countryWeights); + const regions = regionsByCountry[country] || defaultRegions; + const { region, city } = pickRandom(regions); + + return { country, region, city }; +} + +const languages: WeightedOption[] = [ + { value: 'en-US', weight: 0.4 }, + { value: 'en-GB', weight: 0.08 }, + { value: 'de-DE', weight: 0.06 }, + { value: 'fr-FR', weight: 0.05 }, + { value: 'es-ES', weight: 0.05 }, + { value: 'pt-BR', weight: 0.04 }, + { value: 'ja-JP', weight: 0.03 }, + { value: 'zh-CN', weight: 0.05 }, + { value: 'ko-KR', weight: 0.02 }, + { value: 'ru-RU', weight: 0.02 }, + { value: 'it-IT', weight: 0.02 }, + { value: 'nl-NL', weight: 0.02 }, + { value: 'pl-PL', weight: 0.02 }, + { value: 'hi-IN', weight: 0.04 }, + { value: 'ar-SA', weight: 0.02 }, + { value: 'tr-TR', weight: 0.02 }, + { value: 'vi-VN', weight: 0.01 }, + { value: 'th-TH', weight: 0.01 }, + { value: 'id-ID', weight: 0.02 }, + { value: 'sv-SE', weight: 0.01 }, + { value: 'da-DK', weight: 0.01 }, +]; + +export function getRandomLanguage(): string { + return weightedRandom(languages); +} diff --git a/scripts/seed/distributions/referrers.ts b/scripts/seed/distributions/referrers.ts new file mode 100644 index 00000000..5b3f2c45 --- /dev/null +++ b/scripts/seed/distributions/referrers.ts @@ -0,0 +1,163 @@ +import { weightedRandom, pickRandom, randomInt, type WeightedOption } from '../utils.js'; + +export type ReferrerType = 'direct' | 'organic' | 'social' | 'paid' | 'referral'; + +export interface ReferrerInfo { + type: ReferrerType; + domain: string | null; + path: string | null; + utmSource: string | null; + utmMedium: string | null; + utmCampaign: string | null; + utmContent: string | null; + utmTerm: string | null; + gclid: string | null; + fbclid: string | null; +} + +const referrerTypeWeights: WeightedOption[] = [ + { value: 'direct', weight: 0.4 }, + { value: 'organic', weight: 0.25 }, + { value: 'social', weight: 0.15 }, + { value: 'paid', weight: 0.1 }, + { value: 'referral', weight: 0.1 }, +]; + +const searchEngines = [ + { domain: 'google.com', path: '/search' }, + { domain: 'bing.com', path: '/search' }, + { domain: 'duckduckgo.com', path: '/' }, + { domain: 'yahoo.com', path: '/search' }, + { domain: 'baidu.com', path: '/s' }, +]; + +const socialPlatforms = [ + { domain: 'twitter.com', path: null }, + { domain: 'x.com', path: null }, + { domain: 'linkedin.com', path: '/feed' }, + { domain: 'facebook.com', path: null }, + { domain: 'reddit.com', path: '/r/programming' }, + { domain: 'news.ycombinator.com', path: '/item' }, + { domain: 'threads.net', path: null }, + { domain: 'bsky.app', path: null }, +]; + +const referralSites = [ + { domain: 'medium.com', path: '/@author/article' }, + { domain: 'dev.to', path: '/post' }, + { domain: 'hashnode.com', path: '/blog' }, + { domain: 'techcrunch.com', path: '/article' }, + { domain: 'producthunt.com', path: '/posts' }, + { domain: 'indiehackers.com', path: '/post' }, +]; + +interface PaidCampaign { + source: string; + medium: string; + campaign: string; + useGclid?: boolean; + useFbclid?: boolean; +} + +const paidCampaigns: PaidCampaign[] = [ + { source: 'google', medium: 'cpc', campaign: 'brand_search', useGclid: true }, + { source: 'google', medium: 'cpc', campaign: 'product_awareness', useGclid: true }, + { source: 'facebook', medium: 'paid_social', campaign: 'retargeting', useFbclid: true }, + { source: 'facebook', medium: 'paid_social', campaign: 'lookalike', useFbclid: true }, + { source: 'linkedin', medium: 'cpc', campaign: 'b2b_targeting' }, + { source: 'twitter', medium: 'paid_social', campaign: 'launch_promo' }, +]; + +const organicCampaigns = [ + { source: 'newsletter', medium: 'email', campaign: 'weekly_digest' }, + { source: 'newsletter', medium: 'email', campaign: 'product_update' }, + { source: 'partner', medium: 'referral', campaign: 'integration_launch' }, +]; + +function generateClickId(): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < 32; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +export function getRandomReferrer(): ReferrerInfo { + const type = weightedRandom(referrerTypeWeights); + + const result: ReferrerInfo = { + type, + domain: null, + path: null, + utmSource: null, + utmMedium: null, + utmCampaign: null, + utmContent: null, + utmTerm: null, + gclid: null, + fbclid: null, + }; + + switch (type) { + case 'direct': + // No referrer data + break; + + case 'organic': { + const engine = pickRandom(searchEngines); + result.domain = engine.domain; + result.path = engine.path; + break; + } + + case 'social': { + const platform = pickRandom(socialPlatforms); + result.domain = platform.domain; + result.path = platform.path; + + // Some social traffic has UTM params + if (Math.random() < 0.3) { + result.utmSource = platform.domain.replace('.com', '').replace('.net', ''); + result.utmMedium = 'social'; + } + break; + } + + case 'paid': { + const campaign = pickRandom(paidCampaigns); + result.utmSource = campaign.source; + result.utmMedium = campaign.medium; + result.utmCampaign = campaign.campaign; + result.utmContent = `ad_${randomInt(1, 5)}`; + + if (campaign.useGclid) { + result.gclid = generateClickId(); + result.domain = 'google.com'; + result.path = '/search'; + } else if (campaign.useFbclid) { + result.fbclid = generateClickId(); + result.domain = 'facebook.com'; + result.path = null; + } + break; + } + + case 'referral': { + // Mix of pure referrals and organic campaigns + if (Math.random() < 0.6) { + const site = pickRandom(referralSites); + result.domain = site.domain; + result.path = site.path; + } else { + const campaign = pickRandom(organicCampaigns); + result.utmSource = campaign.source; + result.utmMedium = campaign.medium; + result.utmCampaign = campaign.campaign; + } + break; + } + } + + return result; +} diff --git a/scripts/seed/distributions/temporal.ts b/scripts/seed/distributions/temporal.ts new file mode 100644 index 00000000..da0409a9 --- /dev/null +++ b/scripts/seed/distributions/temporal.ts @@ -0,0 +1,69 @@ +import { weightedRandom, randomInt, type WeightedOption } from '../utils.js'; + +const hourlyWeights: WeightedOption[] = [ + { value: 0, weight: 0.02 }, + { value: 1, weight: 0.01 }, + { value: 2, weight: 0.01 }, + { value: 3, weight: 0.01 }, + { value: 4, weight: 0.01 }, + { value: 5, weight: 0.02 }, + { value: 6, weight: 0.03 }, + { value: 7, weight: 0.05 }, + { value: 8, weight: 0.07 }, + { value: 9, weight: 0.08 }, + { value: 10, weight: 0.09 }, + { value: 11, weight: 0.08 }, + { value: 12, weight: 0.07 }, + { value: 13, weight: 0.08 }, + { value: 14, weight: 0.09 }, + { value: 15, weight: 0.08 }, + { value: 16, weight: 0.07 }, + { value: 17, weight: 0.06 }, + { value: 18, weight: 0.05 }, + { value: 19, weight: 0.04 }, + { value: 20, weight: 0.03 }, + { value: 21, weight: 0.03 }, + { value: 22, weight: 0.02 }, + { value: 23, weight: 0.02 }, +]; + +const dayOfWeekWeights: WeightedOption[] = [ + { value: 0, weight: 0.08 }, // Sunday + { value: 1, weight: 0.16 }, // Monday + { value: 2, weight: 0.17 }, // Tuesday + { value: 3, weight: 0.17 }, // Wednesday + { value: 4, weight: 0.16 }, // Thursday + { value: 5, weight: 0.15 }, // Friday + { value: 6, weight: 0.11 }, // Saturday +]; + +export function getWeightedHour(): number { + return weightedRandom(hourlyWeights); +} + +export function getDayOfWeekMultiplier(dayOfWeek: number): number { + const weight = dayOfWeekWeights.find(d => d.value === dayOfWeek)?.weight ?? 0.14; + return weight / 0.14; // Normalize around 1.0 +} + +export function generateTimestampForDay(day: Date): Date { + const hour = getWeightedHour(); + const minute = randomInt(0, 59); + const second = randomInt(0, 59); + const millisecond = randomInt(0, 999); + + const timestamp = new Date(day); + timestamp.setHours(hour, minute, second, millisecond); + + return timestamp; +} + +export function getSessionCountForDay(baseCount: number, day: Date): number { + const dayOfWeek = day.getDay(); + const multiplier = getDayOfWeekMultiplier(dayOfWeek); + + // Add some random variance (±20%) + const variance = 0.8 + Math.random() * 0.4; + + return Math.round(baseCount * multiplier * variance); +} diff --git a/scripts/seed/generators/events.ts b/scripts/seed/generators/events.ts new file mode 100644 index 00000000..72429062 --- /dev/null +++ b/scripts/seed/generators/events.ts @@ -0,0 +1,191 @@ +import { uuid, addSeconds, randomInt } from '../utils.js'; +import { getRandomReferrer } from '../distributions/referrers.js'; +import type { SessionData } from './sessions.js'; + +export const EVENT_TYPE = { + pageView: 1, + customEvent: 2, +} as const; + +export interface PageConfig { + path: string; + title: string; + weight: number; + avgTimeOnPage: number; +} + +export interface CustomEventConfig { + name: string; + weight: number; + pages?: string[]; + data?: Record; +} + +export interface JourneyConfig { + pages: string[]; + weight: number; +} + +export interface EventData { + id: string; + websiteId: string; + sessionId: string; + visitId: string; + eventType: number; + urlPath: string; + urlQuery: string | null; + pageTitle: string | null; + hostname: string; + referrerDomain: string | null; + referrerPath: string | null; + utmSource: string | null; + utmMedium: string | null; + utmCampaign: string | null; + utmContent: string | null; + utmTerm: string | null; + gclid: string | null; + fbclid: string | null; + eventName: string | null; + tag: string | null; + createdAt: Date; +} + +export interface EventDataEntry { + id: string; + websiteId: string; + websiteEventId: string; + dataKey: string; + stringValue: string | null; + numberValue: number | null; + dateValue: Date | null; + dataType: number; + createdAt: Date; +} + +export interface SiteConfig { + hostname: string; + pages: PageConfig[]; + journeys: JourneyConfig[]; + customEvents: CustomEventConfig[]; +} + +function getPageTitle(pages: PageConfig[], path: string): string | null { + const page = pages.find(p => p.path === path); + return page?.title ?? null; +} + +function getPageTimeOnPage(pages: PageConfig[], path: string): number { + const page = pages.find(p => p.path === path); + return page?.avgTimeOnPage ?? 30; +} + +export function generateEventsForSession( + session: SessionData, + siteConfig: SiteConfig, + journey: string[], +): { events: EventData[]; eventDataEntries: EventDataEntry[] } { + const events: EventData[] = []; + const eventDataEntries: EventDataEntry[] = []; + const visitId = uuid(); + + let currentTime = session.createdAt; + const referrer = getRandomReferrer(); + + for (let i = 0; i < journey.length; i++) { + const pagePath = journey[i]; + const isFirstPage = i === 0; + + const eventId = uuid(); + const pageTitle = getPageTitle(siteConfig.pages, pagePath); + + events.push({ + id: eventId, + websiteId: session.websiteId, + sessionId: session.id, + visitId, + eventType: EVENT_TYPE.pageView, + urlPath: pagePath, + urlQuery: null, + pageTitle, + hostname: siteConfig.hostname, + referrerDomain: isFirstPage ? referrer.domain : null, + referrerPath: isFirstPage ? referrer.path : null, + utmSource: isFirstPage ? referrer.utmSource : null, + utmMedium: isFirstPage ? referrer.utmMedium : null, + utmCampaign: isFirstPage ? referrer.utmCampaign : null, + utmContent: isFirstPage ? referrer.utmContent : null, + utmTerm: isFirstPage ? referrer.utmTerm : null, + gclid: isFirstPage ? referrer.gclid : null, + fbclid: isFirstPage ? referrer.fbclid : null, + eventName: null, + tag: null, + createdAt: currentTime, + }); + + // Check for custom events on this page + for (const customEvent of siteConfig.customEvents) { + // Check if this event can occur on this page + if (customEvent.pages && !customEvent.pages.includes(pagePath)) { + continue; + } + + // Random chance based on weight + if (Math.random() < customEvent.weight) { + currentTime = addSeconds(currentTime, randomInt(2, 15)); + + const customEventId = uuid(); + events.push({ + id: customEventId, + websiteId: session.websiteId, + sessionId: session.id, + visitId, + eventType: EVENT_TYPE.customEvent, + urlPath: pagePath, + urlQuery: null, + pageTitle, + hostname: siteConfig.hostname, + referrerDomain: null, + referrerPath: null, + utmSource: null, + utmMedium: null, + utmCampaign: null, + utmContent: null, + utmTerm: null, + gclid: null, + fbclid: null, + eventName: customEvent.name, + tag: null, + createdAt: currentTime, + }); + + // Generate event data if configured + if (customEvent.data) { + for (const [key, values] of Object.entries(customEvent.data)) { + const value = values[Math.floor(Math.random() * values.length)]; + const isNumber = typeof value === 'number'; + + eventDataEntries.push({ + id: uuid(), + websiteId: session.websiteId, + websiteEventId: customEventId, + dataKey: key, + stringValue: isNumber ? null : String(value), + numberValue: isNumber ? value : null, + dateValue: null, + dataType: isNumber ? 2 : 1, // 1 = string, 2 = number + createdAt: currentTime, + }); + } + } + } + } + + // Time spent on page before navigating + const timeOnPage = getPageTimeOnPage(siteConfig.pages, pagePath); + const variance = Math.floor(timeOnPage * 0.5); + const actualTime = timeOnPage + randomInt(-variance, variance); + currentTime = addSeconds(currentTime, Math.max(5, actualTime)); + } + + return { events, eventDataEntries }; +} diff --git a/scripts/seed/generators/revenue.ts b/scripts/seed/generators/revenue.ts new file mode 100644 index 00000000..deea9e6b --- /dev/null +++ b/scripts/seed/generators/revenue.ts @@ -0,0 +1,65 @@ +import { uuid, randomFloat } from '../utils.js'; +import type { EventData } from './events.js'; + +export interface RevenueConfig { + eventName: string; + minAmount: number; + maxAmount: number; + currency: string; + weight: number; +} + +export interface RevenueData { + id: string; + websiteId: string; + sessionId: string; + eventId: string; + eventName: string; + currency: string; + revenue: number; + createdAt: Date; +} + +export function generateRevenue(event: EventData, config: RevenueConfig): RevenueData | null { + if (event.eventName !== config.eventName) { + return null; + } + + if (Math.random() > config.weight) { + return null; + } + + const revenue = randomFloat(config.minAmount, config.maxAmount); + + return { + id: uuid(), + websiteId: event.websiteId, + sessionId: event.sessionId, + eventId: event.id, + eventName: event.eventName!, + currency: config.currency, + revenue: Math.round(revenue * 100) / 100, // Round to 2 decimal places + createdAt: event.createdAt, + }; +} + +export function generateRevenueForEvents( + events: EventData[], + configs: RevenueConfig[], +): RevenueData[] { + const revenueEntries: RevenueData[] = []; + + for (const event of events) { + if (!event.eventName) continue; + + for (const config of configs) { + const revenue = generateRevenue(event, config); + if (revenue) { + revenueEntries.push(revenue); + break; // Only one revenue per event + } + } + } + + return revenueEntries; +} diff --git a/scripts/seed/generators/sessions.ts b/scripts/seed/generators/sessions.ts new file mode 100644 index 00000000..1370511f --- /dev/null +++ b/scripts/seed/generators/sessions.ts @@ -0,0 +1,52 @@ +import { uuid } from '../utils.js'; +import { getRandomDevice } from '../distributions/devices.js'; +import { getRandomGeo, getRandomLanguage } from '../distributions/geographic.js'; +import { generateTimestampForDay } from '../distributions/temporal.js'; + +export interface SessionData { + id: string; + websiteId: string; + browser: string; + os: string; + device: string; + screen: string; + language: string; + country: string; + region: string; + city: string; + createdAt: Date; +} + +export function createSession(websiteId: string, day: Date): SessionData { + const deviceInfo = getRandomDevice(); + const geo = getRandomGeo(); + const language = getRandomLanguage(); + const createdAt = generateTimestampForDay(day); + + return { + id: uuid(), + websiteId, + browser: deviceInfo.browser, + os: deviceInfo.os, + device: deviceInfo.device, + screen: deviceInfo.screen, + language, + country: geo.country, + region: geo.region, + city: geo.city, + createdAt, + }; +} + +export function createSessions(websiteId: string, day: Date, count: number): SessionData[] { + const sessions: SessionData[] = []; + + for (let i = 0; i < count; i++) { + sessions.push(createSession(websiteId, day)); + } + + // Sort by createdAt to maintain chronological order + sessions.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); + + return sessions; +} diff --git a/scripts/seed/index.ts b/scripts/seed/index.ts new file mode 100644 index 00000000..5b9de8de --- /dev/null +++ b/scripts/seed/index.ts @@ -0,0 +1,378 @@ +/* eslint-disable no-console */ +import 'dotenv/config'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient, Prisma } from '../../src/generated/prisma/client.js'; +import { uuid, generateDatesBetween, subDays, formatNumber, progressBar } from './utils.js'; +import { createSessions, type SessionData } from './generators/sessions.js'; +import { + generateEventsForSession, + type EventData, + type EventDataEntry, +} from './generators/events.js'; +import { + generateRevenueForEvents, + type RevenueData, + type RevenueConfig, +} from './generators/revenue.js'; +import { getSessionCountForDay } from './distributions/temporal.js'; +import { + BLOG_WEBSITE_NAME, + BLOG_WEBSITE_DOMAIN, + BLOG_SESSIONS_PER_DAY, + getBlogSiteConfig, + getBlogJourney, +} from './sites/blog.js'; +import { + SAAS_WEBSITE_NAME, + SAAS_WEBSITE_DOMAIN, + SAAS_SESSIONS_PER_DAY, + getSaasSiteConfig, + getSaasJourney, + saasRevenueConfigs, +} from './sites/saas.js'; + +const BATCH_SIZE = 1000; + +type SessionCreateInput = Prisma.SessionCreateManyInput; +type WebsiteEventCreateInput = Prisma.WebsiteEventCreateManyInput; +type EventDataCreateInput = Prisma.EventDataCreateManyInput; +type RevenueCreateInput = Prisma.RevenueCreateManyInput; + +export interface SeedConfig { + days: number; + clear: boolean; + verbose: boolean; +} + +export interface SeedResult { + websites: number; + sessions: number; + events: number; + eventData: number; + revenue: number; +} + +async function batchInsertSessions( + prisma: PrismaClient, + data: SessionCreateInput[], + verbose: boolean, +): Promise { + for (let i = 0; i < data.length; i += BATCH_SIZE) { + const batch = data.slice(i, i + BATCH_SIZE); + await prisma.session.createMany({ data: batch, skipDuplicates: true }); + if (verbose) { + console.log( + ` Inserted ${Math.min(i + BATCH_SIZE, data.length)}/${data.length} session records`, + ); + } + } +} + +async function batchInsertEvents( + prisma: PrismaClient, + data: WebsiteEventCreateInput[], + verbose: boolean, +): Promise { + for (let i = 0; i < data.length; i += BATCH_SIZE) { + const batch = data.slice(i, i + BATCH_SIZE); + await prisma.websiteEvent.createMany({ data: batch, skipDuplicates: true }); + if (verbose) { + console.log( + ` Inserted ${Math.min(i + BATCH_SIZE, data.length)}/${data.length} event records`, + ); + } + } +} + +async function batchInsertEventData( + prisma: PrismaClient, + data: EventDataCreateInput[], + verbose: boolean, +): Promise { + for (let i = 0; i < data.length; i += BATCH_SIZE) { + const batch = data.slice(i, i + BATCH_SIZE); + await prisma.eventData.createMany({ data: batch, skipDuplicates: true }); + if (verbose) { + console.log( + ` Inserted ${Math.min(i + BATCH_SIZE, data.length)}/${data.length} eventData records`, + ); + } + } +} + +async function batchInsertRevenue( + prisma: PrismaClient, + data: RevenueCreateInput[], + verbose: boolean, +): Promise { + for (let i = 0; i < data.length; i += BATCH_SIZE) { + const batch = data.slice(i, i + BATCH_SIZE); + await prisma.revenue.createMany({ data: batch, skipDuplicates: true }); + if (verbose) { + console.log( + ` Inserted ${Math.min(i + BATCH_SIZE, data.length)}/${data.length} revenue records`, + ); + } + } +} + +async function findAdminUser(prisma: PrismaClient): Promise { + const adminUser = await prisma.user.findFirst({ + where: { role: 'admin' }, + select: { id: true }, + }); + + if (!adminUser) { + throw new Error( + 'No admin user found in the database.\n' + + 'Please ensure you have run the initial setup and created an admin user.\n' + + 'The default admin user is created during first build (username: admin, password: umami).', + ); + } + + return adminUser.id; +} + +async function createWebsite( + prisma: PrismaClient, + name: string, + domain: string, + adminUserId: string, +): Promise { + const websiteId = uuid(); + + await prisma.website.create({ + data: { + id: websiteId, + name, + domain, + userId: adminUserId, + createdBy: adminUserId, + }, + }); + + return websiteId; +} + +async function clearDemoData(prisma: PrismaClient): Promise { + console.log('Clearing existing demo data...'); + + const demoWebsites = await prisma.website.findMany({ + where: { + OR: [{ name: BLOG_WEBSITE_NAME }, { name: SAAS_WEBSITE_NAME }], + }, + select: { id: true }, + }); + + const websiteIds = demoWebsites.map(w => w.id); + + if (websiteIds.length === 0) { + console.log(' No existing demo websites found'); + return; + } + + console.log(` Found ${websiteIds.length} demo website(s)`); + + // Delete in correct order due to foreign key constraints + await prisma.revenue.deleteMany({ where: { websiteId: { in: websiteIds } } }); + await prisma.eventData.deleteMany({ where: { websiteId: { in: websiteIds } } }); + await prisma.sessionData.deleteMany({ where: { websiteId: { in: websiteIds } } }); + await prisma.websiteEvent.deleteMany({ where: { websiteId: { in: websiteIds } } }); + await prisma.session.deleteMany({ where: { websiteId: { in: websiteIds } } }); + await prisma.segment.deleteMany({ where: { websiteId: { in: websiteIds } } }); + await prisma.report.deleteMany({ where: { websiteId: { in: websiteIds } } }); + await prisma.website.deleteMany({ where: { id: { in: websiteIds } } }); + + console.log(' Cleared existing demo data'); +} + +interface SiteGeneratorConfig { + name: string; + domain: string; + sessionsPerDay: number; + getSiteConfig: () => ReturnType; + getJourney: () => string[]; + revenueConfigs?: RevenueConfig[]; +} + +async function generateSiteData( + prisma: PrismaClient, + config: SiteGeneratorConfig, + days: Date[], + adminUserId: string, + verbose: boolean, +): Promise<{ sessions: number; events: number; eventData: number; revenue: number }> { + console.log(`\nGenerating data for ${config.name}...`); + + const websiteId = await createWebsite(prisma, config.name, config.domain, adminUserId); + console.log(` Created website: ${config.name} (${websiteId})`); + + const siteConfig = config.getSiteConfig(); + + const allSessions: SessionData[] = []; + const allEvents: EventData[] = []; + const allEventData: EventDataEntry[] = []; + const allRevenue: RevenueData[] = []; + + for (let dayIndex = 0; dayIndex < days.length; dayIndex++) { + const day = days[dayIndex]; + const sessionCount = getSessionCountForDay(config.sessionsPerDay, day); + const sessions = createSessions(websiteId, day, sessionCount); + + for (const session of sessions) { + const journey = config.getJourney(); + const { events, eventDataEntries } = generateEventsForSession(session, siteConfig, journey); + + allSessions.push(session); + allEvents.push(...events); + allEventData.push(...eventDataEntries); + + if (config.revenueConfigs) { + const revenueEntries = generateRevenueForEvents(events, config.revenueConfigs); + allRevenue.push(...revenueEntries); + } + } + + // Show progress (every day in verbose mode, otherwise every 2 days) + const shouldShowProgress = verbose || dayIndex % 2 === 0 || dayIndex === days.length - 1; + if (shouldShowProgress) { + process.stdout.write( + `\r ${progressBar(dayIndex + 1, days.length)} Day ${dayIndex + 1}/${days.length}`, + ); + } + } + + console.log(''); // New line after progress bar + + // Batch insert all data + console.log(` Inserting ${formatNumber(allSessions.length)} sessions...`); + await batchInsertSessions(prisma, allSessions as SessionCreateInput[], verbose); + + console.log(` Inserting ${formatNumber(allEvents.length)} events...`); + await batchInsertEvents(prisma, allEvents as WebsiteEventCreateInput[], verbose); + + if (allEventData.length > 0) { + console.log(` Inserting ${formatNumber(allEventData.length)} event data entries...`); + await batchInsertEventData(prisma, allEventData as EventDataCreateInput[], verbose); + } + + if (allRevenue.length > 0) { + console.log(` Inserting ${formatNumber(allRevenue.length)} revenue entries...`); + await batchInsertRevenue(prisma, allRevenue as RevenueCreateInput[], verbose); + } + + return { + sessions: allSessions.length, + events: allEvents.length, + eventData: allEventData.length, + revenue: allRevenue.length, + }; +} + +function createPrismaClient(): PrismaClient { + const url = process.env.DATABASE_URL; + if (!url) { + throw new Error( + 'DATABASE_URL environment variable is not set.\n' + + 'Please set DATABASE_URL in your .env file or environment.\n' + + 'Example: DATABASE_URL=postgresql://user:password@localhost:5432/umami', + ); + } + + let schema: string | undefined; + try { + const connectionUrl = new URL(url); + schema = connectionUrl.searchParams.get('schema') ?? undefined; + } catch { + throw new Error( + 'DATABASE_URL is not a valid URL.\n' + + 'Expected format: postgresql://user:password@host:port/database\n' + + `Received: ${url.substring(0, 30)}...`, + ); + } + + const adapter = new PrismaPg({ connectionString: url }, { schema }); + + return new PrismaClient({ + adapter, + errorFormat: 'pretty', + }); +} + +export async function seed(config: SeedConfig): Promise { + const prisma = createPrismaClient(); + + try { + const endDate = new Date(); + const startDate = subDays(endDate, config.days); + const days = generateDatesBetween(startDate, endDate); + + console.log(`\nSeed Configuration:`); + console.log( + ` Date range: ${startDate.toISOString().split('T')[0]} to ${endDate.toISOString().split('T')[0]}`, + ); + console.log(` Days: ${days.length}`); + console.log(` Clear existing: ${config.clear}`); + + if (config.clear) { + await clearDemoData(prisma); + } + + // Find admin user to own the demo websites + const adminUserId = await findAdminUser(prisma); + console.log(` Using admin user: ${adminUserId}`); + + // Generate Blog site (low traffic) + const blogResults = await generateSiteData( + prisma, + { + name: BLOG_WEBSITE_NAME, + domain: BLOG_WEBSITE_DOMAIN, + sessionsPerDay: BLOG_SESSIONS_PER_DAY, + getSiteConfig: getBlogSiteConfig, + getJourney: getBlogJourney, + }, + days, + adminUserId, + config.verbose, + ); + + // Generate SaaS site (high traffic) + const saasResults = await generateSiteData( + prisma, + { + name: SAAS_WEBSITE_NAME, + domain: SAAS_WEBSITE_DOMAIN, + sessionsPerDay: SAAS_SESSIONS_PER_DAY, + getSiteConfig: getSaasSiteConfig, + getJourney: getSaasJourney, + revenueConfigs: saasRevenueConfigs, + }, + days, + adminUserId, + config.verbose, + ); + + const result: SeedResult = { + websites: 2, + sessions: blogResults.sessions + saasResults.sessions, + events: blogResults.events + saasResults.events, + eventData: blogResults.eventData + saasResults.eventData, + revenue: blogResults.revenue + saasResults.revenue, + }; + + console.log(`\n${'─'.repeat(50)}`); + console.log(`Seed Complete!`); + console.log(`${'─'.repeat(50)}`); + console.log(` Websites: ${formatNumber(result.websites)}`); + console.log(` Sessions: ${formatNumber(result.sessions)}`); + console.log(` Events: ${formatNumber(result.events)}`); + console.log(` Event Data: ${formatNumber(result.eventData)}`); + console.log(` Revenue: ${formatNumber(result.revenue)}`); + console.log(`${'─'.repeat(50)}\n`); + + return result; + } finally { + await prisma.$disconnect(); + } +} diff --git a/scripts/seed/sites/blog.ts b/scripts/seed/sites/blog.ts new file mode 100644 index 00000000..e60b8b95 --- /dev/null +++ b/scripts/seed/sites/blog.ts @@ -0,0 +1,108 @@ +import { weightedRandom, type WeightedOption } from '../utils.js'; +import type { + SiteConfig, + JourneyConfig, + PageConfig, + CustomEventConfig, +} from '../generators/events.js'; + +export const BLOG_WEBSITE_NAME = 'Demo Blog'; +export const BLOG_WEBSITE_DOMAIN = 'blog.example.com'; + +const blogPosts = [ + 'getting-started-with-analytics', + 'privacy-first-tracking', + 'understanding-your-visitors', + 'improving-page-performance', + 'seo-best-practices', + 'content-marketing-guide', + 'building-audience-trust', + 'data-driven-decisions', +]; + +export const blogPages: PageConfig[] = [ + { path: '/', title: 'Demo Blog - Home', weight: 0.25, avgTimeOnPage: 30 }, + { path: '/blog', title: 'Blog Posts', weight: 0.2, avgTimeOnPage: 45 }, + { path: '/about', title: 'About Us', weight: 0.1, avgTimeOnPage: 60 }, + { path: '/contact', title: 'Contact', weight: 0.05, avgTimeOnPage: 45 }, + ...blogPosts.map(slug => ({ + path: `/blog/${slug}`, + title: slug + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '), + weight: 0.05, + avgTimeOnPage: 180, + })), +]; + +export const blogJourneys: JourneyConfig[] = [ + // Direct to blog post (organic search) + { pages: ['/blog/getting-started-with-analytics'], weight: 0.15 }, + { pages: ['/blog/privacy-first-tracking'], weight: 0.12 }, + { pages: ['/blog/understanding-your-visitors'], weight: 0.1 }, + + // Homepage bounces + { pages: ['/'], weight: 0.15 }, + + // Homepage to blog listing + { pages: ['/', '/blog'], weight: 0.1 }, + + // Homepage to blog post + { pages: ['/', '/blog', '/blog/seo-best-practices'], weight: 0.08 }, + { pages: ['/', '/blog', '/blog/content-marketing-guide'], weight: 0.08 }, + + // About page visits + { pages: ['/', '/about'], weight: 0.07 }, + { pages: ['/', '/about', '/contact'], weight: 0.05 }, + + // Blog post to another + { pages: ['/blog/improving-page-performance', '/blog/data-driven-decisions'], weight: 0.05 }, + + // Longer sessions + { pages: ['/', '/blog', '/blog/building-audience-trust', '/about'], weight: 0.05 }, +]; + +export const blogCustomEvents: CustomEventConfig[] = [ + { + name: 'newsletter_signup', + weight: 0.03, + pages: ['/', '/blog'], + }, + { + name: 'share_click', + weight: 0.05, + pages: blogPosts.map(slug => `/blog/${slug}`), + data: { + platform: ['twitter', 'linkedin', 'facebook', 'copy_link'], + }, + }, + { + name: 'scroll_depth', + weight: 0.2, + pages: blogPosts.map(slug => `/blog/${slug}`), + data: { + depth: [25, 50, 75, 100], + }, + }, +]; + +export function getBlogSiteConfig(): SiteConfig { + return { + hostname: BLOG_WEBSITE_DOMAIN, + pages: blogPages, + journeys: blogJourneys, + customEvents: blogCustomEvents, + }; +} + +export function getBlogJourney(): string[] { + const journeyWeights: WeightedOption[] = blogJourneys.map(j => ({ + value: j.pages, + weight: j.weight, + })); + + return weightedRandom(journeyWeights); +} + +export const BLOG_SESSIONS_PER_DAY = 3; // ~90 sessions per month diff --git a/scripts/seed/sites/saas.ts b/scripts/seed/sites/saas.ts new file mode 100644 index 00000000..133895af --- /dev/null +++ b/scripts/seed/sites/saas.ts @@ -0,0 +1,185 @@ +import { weightedRandom, type WeightedOption } from '../utils.js'; +import type { + SiteConfig, + JourneyConfig, + PageConfig, + CustomEventConfig, +} from '../generators/events.js'; +import type { RevenueConfig } from '../generators/revenue.js'; + +export const SAAS_WEBSITE_NAME = 'Demo SaaS'; +export const SAAS_WEBSITE_DOMAIN = 'app.example.com'; + +const docsSections = [ + 'getting-started', + 'installation', + 'configuration', + 'api-reference', + 'integrations', +]; + +const blogPosts = [ + 'announcing-v2', + 'customer-success-story', + 'product-roadmap', + 'security-best-practices', +]; + +export const saasPages: PageConfig[] = [ + { path: '/', title: 'Demo SaaS - Analytics Made Simple', weight: 0.2, avgTimeOnPage: 45 }, + { path: '/features', title: 'Features', weight: 0.15, avgTimeOnPage: 90 }, + { path: '/pricing', title: 'Pricing', weight: 0.15, avgTimeOnPage: 120 }, + { path: '/docs', title: 'Documentation', weight: 0.1, avgTimeOnPage: 60 }, + { path: '/blog', title: 'Blog', weight: 0.05, avgTimeOnPage: 45 }, + { path: '/signup', title: 'Sign Up', weight: 0.08, avgTimeOnPage: 90 }, + { path: '/login', title: 'Login', weight: 0.05, avgTimeOnPage: 30 }, + { path: '/demo', title: 'Request Demo', weight: 0.05, avgTimeOnPage: 60 }, + ...docsSections.map(slug => ({ + path: `/docs/${slug}`, + title: `Docs: ${slug + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' ')}`, + weight: 0.02, + avgTimeOnPage: 180, + })), + ...blogPosts.map(slug => ({ + path: `/blog/${slug}`, + title: slug + .split('-') + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '), + weight: 0.02, + avgTimeOnPage: 150, + })), +]; + +export const saasJourneys: JourneyConfig[] = [ + // Conversion funnel + { pages: ['/', '/features', '/pricing', '/signup'], weight: 0.12 }, + { pages: ['/', '/pricing', '/signup'], weight: 0.1 }, + { pages: ['/pricing', '/signup'], weight: 0.08 }, + + // Feature exploration + { pages: ['/', '/features'], weight: 0.1 }, + { pages: ['/', '/features', '/pricing'], weight: 0.08 }, + + // Documentation users + { pages: ['/docs', '/docs/getting-started'], weight: 0.08 }, + { pages: ['/docs/getting-started', '/docs/installation', '/docs/configuration'], weight: 0.06 }, + { pages: ['/docs/api-reference'], weight: 0.05 }, + + // Blog readers + { pages: ['/blog/announcing-v2'], weight: 0.05 }, + { pages: ['/blog/customer-success-story'], weight: 0.04 }, + + // Returning users + { pages: ['/login'], weight: 0.08 }, + + // Bounces + { pages: ['/'], weight: 0.08 }, + { pages: ['/pricing'], weight: 0.05 }, + + // Demo requests + { pages: ['/', '/demo'], weight: 0.03 }, +]; + +export const saasCustomEvents: CustomEventConfig[] = [ + { + name: 'signup_started', + weight: 0.6, + pages: ['/signup'], + data: { + plan: ['free', 'pro', 'enterprise'], + }, + }, + { + name: 'signup_completed', + weight: 0.3, + pages: ['/signup'], + data: { + plan: ['free', 'pro', 'enterprise'], + method: ['email', 'google', 'github'], + }, + }, + { + name: 'purchase', + weight: 0.15, + pages: ['/signup', '/pricing'], + data: { + plan: ['pro', 'enterprise'], + billing: ['monthly', 'annual'], + revenue: [29, 49, 99, 299], + currency: ['USD'], + }, + }, + { + name: 'demo_requested', + weight: 0.5, + pages: ['/demo'], + data: { + company_size: ['1-10', '11-50', '51-200', '200+'], + }, + }, + { + name: 'feature_viewed', + weight: 0.3, + pages: ['/features'], + data: { + feature: ['analytics', 'reports', 'api', 'integrations', 'privacy'], + }, + }, + { + name: 'cta_click', + weight: 0.15, + pages: ['/', '/features', '/pricing'], + data: { + button: ['hero_signup', 'nav_signup', 'pricing_cta', 'footer_cta'], + }, + }, + { + name: 'docs_search', + weight: 0.2, + pages: ['/docs', ...docsSections.map(s => `/docs/${s}`)], + data: { + query_type: ['api', 'setup', 'integration', 'troubleshooting'], + }, + }, +]; + +export const saasRevenueConfigs: RevenueConfig[] = [ + { + eventName: 'purchase', + minAmount: 29, + maxAmount: 29, + currency: 'USD', + weight: 0.7, // 70% Pro plan + }, + { + eventName: 'purchase', + minAmount: 299, + maxAmount: 299, + currency: 'USD', + weight: 0.3, // 30% Enterprise + }, +]; + +export function getSaasSiteConfig(): SiteConfig { + return { + hostname: SAAS_WEBSITE_DOMAIN, + pages: saasPages, + journeys: saasJourneys, + customEvents: saasCustomEvents, + }; +} + +export function getSaasJourney(): string[] { + const journeyWeights: WeightedOption[] = saasJourneys.map(j => ({ + value: j.pages, + weight: j.weight, + })); + + return weightedRandom(journeyWeights); +} + +export const SAAS_SESSIONS_PER_DAY = 500; diff --git a/scripts/seed/utils.ts b/scripts/seed/utils.ts new file mode 100644 index 00000000..7b44261e --- /dev/null +++ b/scripts/seed/utils.ts @@ -0,0 +1,85 @@ +import { v4 as uuidv4 } from 'uuid'; + +export interface WeightedOption { + value: T; + weight: number; +} + +export function weightedRandom(options: WeightedOption[]): T { + const totalWeight = options.reduce((sum, opt) => sum + opt.weight, 0); + let random = Math.random() * totalWeight; + + for (const option of options) { + random -= option.weight; + if (random <= 0) { + return option.value; + } + } + + return options[options.length - 1].value; +} + +export function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +export function randomFloat(min: number, max: number): number { + return Math.random() * (max - min) + min; +} + +export function pickRandom(array: T[]): T { + return array[Math.floor(Math.random() * array.length)]; +} + +export function shuffleArray(array: T[]): T[] { + const result = [...array]; + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} + +export function uuid(): string { + return uuidv4(); +} + +export function generateDatesBetween(startDate: Date, endDate: Date): Date[] { + const dates: Date[] = []; + const current = new Date(startDate); + current.setHours(0, 0, 0, 0); + + while (current <= endDate) { + dates.push(new Date(current)); + current.setDate(current.getDate() + 1); + } + + return dates; +} + +export function addHours(date: Date, hours: number): Date { + return new Date(date.getTime() + hours * 60 * 60 * 1000); +} + +export function addMinutes(date: Date, minutes: number): Date { + return new Date(date.getTime() + minutes * 60 * 1000); +} + +export function addSeconds(date: Date, seconds: number): Date { + return new Date(date.getTime() + seconds * 1000); +} + +export function subDays(date: Date, days: number): Date { + return new Date(date.getTime() - days * 24 * 60 * 60 * 1000); +} + +export function formatNumber(num: number): string { + return num.toLocaleString(); +} + +export function progressBar(current: number, total: number, width = 30): string { + const percent = current / total; + const filled = Math.round(width * percent); + const empty = width - filled; + return `[${'█'.repeat(filled)}${'░'.repeat(empty)}] ${Math.round(percent * 100)}%`; +}