/** * Domain Entity: PageView * * Represents a single page view event for analytics tracking. * Captures visitor interactions with leagues, drivers, teams, races. */ export type EntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor'; export type VisitorType = 'anonymous' | 'driver' | 'sponsor'; export interface PageViewProps { id: string; entityType: EntityType; entityId: string; visitorId?: string; visitorType: VisitorType; sessionId: string; referrer?: string; userAgent?: string; country?: string; timestamp: Date; durationMs?: number; } export class PageView { readonly id: string; readonly entityType: EntityType; readonly entityId: string; readonly visitorId?: string; readonly visitorType: VisitorType; readonly sessionId: string; readonly referrer?: string; readonly userAgent?: string; readonly country?: string; readonly timestamp: Date; readonly durationMs?: number; private constructor(props: PageViewProps) { this.id = props.id; this.entityType = props.entityType; this.entityId = props.entityId; this.visitorId = props.visitorId; this.visitorType = props.visitorType; this.sessionId = props.sessionId; this.referrer = props.referrer; this.userAgent = props.userAgent; this.country = props.country; this.timestamp = props.timestamp; this.durationMs = props.durationMs; } static create(props: Omit & { timestamp?: Date }): PageView { this.validate(props); return new PageView({ ...props, timestamp: props.timestamp ?? new Date(), }); } private static validate(props: Omit): void { if (!props.id || props.id.trim().length === 0) { throw new Error('PageView ID is required'); } if (!props.entityType) { throw new Error('PageView entityType is required'); } if (!props.entityId || props.entityId.trim().length === 0) { throw new Error('PageView entityId is required'); } if (!props.sessionId || props.sessionId.trim().length === 0) { throw new Error('PageView sessionId is required'); } } /** * Update duration when visitor leaves page */ withDuration(durationMs: number): PageView { if (durationMs < 0) { throw new Error('Duration must be non-negative'); } return new PageView({ ...this, durationMs, }); } /** * Check if this is a meaningful view (not a bounce) */ isMeaningfulView(): boolean { return this.durationMs !== undefined && this.durationMs >= 5000; // 5+ seconds } /** * Check if view came from external source */ isExternalReferral(): boolean { if (!this.referrer) return false; return !this.referrer.includes('gridpilot'); } }