rename to core

This commit is contained in:
2025-12-15 13:46:07 +01:00
parent aedf58643d
commit 5c22f8820c
559 changed files with 415 additions and 767 deletions

View File

@@ -0,0 +1,282 @@
/**
* Query: GetEntityAnalyticsQuery
*
* Retrieves analytics data for an entity (league, driver, team, race).
* Returns metrics formatted for display to sponsors and admins.
*/
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { ILogger } from '@gridpilot/shared/application';
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
import type { IAnalyticsSnapshotRepository } from '../../domain/repositories/IAnalyticsSnapshotRepository';
import type { EntityType } from '../../domain/types/PageView';
import type { SnapshotPeriod } from '../../domain/types/AnalyticsSnapshot';
export interface GetEntityAnalyticsInput {
entityType: EntityType;
entityId: string;
period?: SnapshotPeriod;
since?: Date;
}
export interface EntityAnalyticsOutput {
entityType: EntityType;
entityId: string;
summary: {
totalPageViews: number;
uniqueVisitors: number;
sponsorClicks: number;
engagementScore: number;
trustIndicator: 'high' | 'medium' | 'low';
exposureValue: number;
};
trends: {
pageViewsChange: number;
uniqueVisitorsChange: number;
engagementChange: number;
};
period: {
start: Date;
end: Date;
label: string;
};
}
export class GetEntityAnalyticsQuery
implements AsyncUseCase<GetEntityAnalyticsInput, EntityAnalyticsOutput> {
constructor(
private readonly pageViewRepository: IPageViewRepository,
private readonly engagementRepository: IEngagementRepository,
private readonly snapshotRepository: IAnalyticsSnapshotRepository,
private readonly logger: ILogger
) {}
async execute(input: GetEntityAnalyticsInput): Promise<EntityAnalyticsOutput> {
this.logger.debug(`Executing GetEntityAnalyticsQuery with input: ${JSON.stringify(input)}`);
const period = input.period ?? 'weekly';
const now = new Date();
const since = input.since ?? this.getPeriodStartDate(now, period);
this.logger.debug(`Calculated period: ${period}, now: ${now.toISOString()}, since: ${since.toISOString()}`);
// Get current metrics
let totalPageViews = 0;
try {
totalPageViews = await this.pageViewRepository.countByEntityId(
input.entityType,
input.entityId,
since
);
this.logger.debug(`Total page views for entity ${input.entityId}: ${totalPageViews}`);
} catch (error) {
this.logger.error(`Error counting total page views for entity ${input.entityId}: ${error.message}`);
throw error;
}
let uniqueVisitors = 0;
try {
uniqueVisitors = await this.pageViewRepository.countUniqueVisitors(
input.entityType,
input.entityId,
since
);
this.logger.debug(`Unique visitors for entity ${input.entityId}: ${uniqueVisitors}`);
} catch (error) {
this.logger.error(`Error counting unique visitors for entity ${input.entityId}: ${error.message}`);
throw error;
}
let sponsorClicks = 0;
try {
sponsorClicks = await this.engagementRepository.getSponsorClicksForEntity(
input.entityId,
since
);
this.logger.debug(`Sponsor clicks for entity ${input.entityId}: ${sponsorClicks}`);
} catch (error) {
this.logger.error(`Error getting sponsor clicks for entity ${input.entityId}: ${error.message}`);
throw error;
}
// Calculate engagement score (weighted sum of actions)
let engagementScore = 0;
try {
engagementScore = await this.calculateEngagementScore(input.entityId, since);
this.logger.debug(`Engagement score for entity ${input.entityId}: ${engagementScore}`);
} catch (error) {
this.logger.error(`Error calculating engagement score for entity ${input.entityId}: ${error.message}`);
throw error;
}
// Determine trust indicator
const trustIndicator = this.determineTrustIndicator(totalPageViews, uniqueVisitors, engagementScore);
this.logger.debug(`Trust indicator for entity ${input.entityId}: ${trustIndicator}`);
// Calculate exposure value (for sponsor ROI)
const exposureValue = this.calculateExposureValue(totalPageViews, uniqueVisitors, sponsorClicks);
this.logger.debug(`Exposure value for entity ${input.entityId}: ${exposureValue}`);
// Get previous period for trends
const previousPeriodStart = this.getPreviousPeriodStart(since, period);
this.logger.debug(`Previous period start: ${previousPeriodStart.toISOString()}`);
let previousPageViews = 0;
try {
const fullPreviousPageViews = await this.pageViewRepository.countByEntityId(
input.entityType,
input.entityId,
previousPeriodStart
);
previousPageViews = fullPreviousPageViews - totalPageViews; // This calculates change, not just previous period's total
this.logger.debug(`Previous period full page views: ${fullPreviousPageViews}, change: ${previousPageViews}`);
} catch (error) {
this.logger.error(`Error counting previous period page views for entity ${input.entityId}: ${error.message}`);
throw error;
}
let previousUniqueVisitors = 0;
try {
const fullPreviousUniqueVisitors = await this.pageViewRepository.countUniqueVisitors(
input.entityType,
input.entityId,
previousPeriodStart
);
previousUniqueVisitors = fullPreviousUniqueVisitors - uniqueVisitors; // This calculates change, not just previous period's total
this.logger.debug(`Previous period full unique visitors: ${fullPreviousUniqueVisitors}, change: ${previousUniqueVisitors}`);
} catch (error) {
this.logger.error(`Error counting previous period unique visitors for entity ${input.entityId}: ${error.message}`);
throw error;
}
const result: EntityAnalyticsOutput = {
entityType: input.entityType,
entityId: input.entityId,
summary: {
totalPageViews,
uniqueVisitors,
sponsorClicks,
engagementScore,
trustIndicator,
exposureValue,
},
trends: {
pageViewsChange: this.calculatePercentageChange(previousPageViews, totalPageViews),
uniqueVisitorsChange: this.calculatePercentageChange(previousUniqueVisitors, uniqueVisitors),
engagementChange: 0, // Would need historical engagement data
},
period: {
start: since,
end: now,
label: this.formatPeriodLabel(since, now),
},
};
this.logger.info(`Successfully retrieved analytics for entity ${input.entityId}.`);
return result;
}
private getPeriodStartDate(now: Date, period: SnapshotPeriod): Date {
this.logger.debug(`Calculating period start date for "${period}" from ${now.toISOString()}`);
const start = new Date(now);
switch (period) {
case 'daily':
start.setDate(start.getDate() - 1);
break;
case 'weekly':
start.setDate(start.getDate() - 7);
break;
case 'monthly':
start.setMonth(start.getMonth() - 1);
break;
}
this.logger.debug(`Period start date calculated: ${start.toISOString()}`);
return start;
}
private getPreviousPeriodStart(currentStart: Date, period: SnapshotPeriod): Date {
this.logger.debug(`Calculating previous period start date for "${period}" from ${currentStart.toISOString()}`);
const start = new Date(currentStart);
switch (period) {
case 'daily':
start.setDate(start.getDate() - 1);
break;
case 'weekly':
start.setDate(start.getDate() - 7);
break;
case 'monthly':
start.setMonth(start.getMonth() - 1);
break;
}
this.logger.debug(`Previous period start date calculated: ${start.toISOString()}`);
return start;
}
private async calculateEngagementScore(entityId: string, since: Date): Promise<number> {
this.logger.debug(`Calculating engagement score for entity ${entityId} since ${since.toISOString()}`);
let sponsorClicks = 0;
try {
sponsorClicks = await this.engagementRepository.getSponsorClicksForEntity(entityId, since);
this.logger.debug(`Sponsor clicks for engagement score for entity ${entityId}: ${sponsorClicks}`);
} catch (error) {
this.logger.error(`Error getting sponsor clicks for engagement score for entity ${entityId}: ${error.message}`);
throw error;
}
const score = sponsorClicks * 10; // Weighted score
this.logger.debug(`Calculated engagement score for entity ${entityId}: ${score}`);
return score;
}
private determineTrustIndicator(
pageViews: number,
uniqueVisitors: number,
engagementScore: number
): 'high' | 'medium' | 'low' {
this.logger.debug(`Determining trust indicator with pageViews: ${pageViews}, uniqueVisitors: ${uniqueVisitors}, engagementScore: ${engagementScore}`);
const engagementRate = pageViews > 0 ? engagementScore / pageViews : 0;
const returningVisitorRate = pageViews > 0 ? (pageViews - uniqueVisitors) / pageViews : 0;
this.logger.debug(`Engagement rate: ${engagementRate}, Returning visitor rate: ${returningVisitorRate}`);
if (engagementRate > 0.1 && returningVisitorRate > 0.3) {
return 'high';
}
if (engagementRate > 0.05 || returningVisitorRate > 0.1) {
return 'medium';
}
return 'low';
}
private calculateExposureValue(
pageViews: number,
uniqueVisitors: number,
sponsorClicks: number
): number {
this.logger.debug(`Calculating exposure value with pageViews: ${pageViews}, uniqueVisitors: ${uniqueVisitors}, sponsorClicks: ${sponsorClicks}`);
// Simple exposure value calculation (could be monetized)
const exposure = (pageViews * 0.01) + (uniqueVisitors * 0.05) + (sponsorClicks * 0.50);
this.logger.debug(`Calculated exposure value: ${exposure}`);
return exposure;
}
private calculatePercentageChange(previous: number, current: number): number {
this.logger.debug(`Calculating percentage change from previous: ${previous} to current: ${current}`);
if (previous === 0) {
const change = current > 0 ? 100 : 0;
this.logger.debug(`Percentage change (previous was 0): ${change}%`);
return change;
}
const change = Math.round(((current - previous) / previous) * 100);
this.logger.debug(`Percentage change: ${change}%`);
return change;
}
private formatPeriodLabel(start: Date, end: Date): string {
this.logger.debug(`Formatting period label from ${start.toISOString()} to ${end.toISOString()}`);
const formatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
});
const label = `${formatter.format(start)} - ${formatter.format(end)}`;
this.logger.debug(`Formatted period label: "${label}"`);
return label;
}
}

View File

@@ -0,0 +1,66 @@
/**
* Use Case: RecordEngagementUseCase
*
* Records an engagement event when a visitor interacts with an entity.
*/
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
import { EngagementEvent, type EngagementAction, type EngagementEntityType } from '../../domain/entities/EngagementEvent';
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
export interface RecordEngagementInput {
action: EngagementAction;
entityType: EngagementEntityType;
entityId: string;
actorId?: string;
actorType: 'anonymous' | 'driver' | 'sponsor';
sessionId: string;
metadata?: Record<string, string | number | boolean>;
}
export interface RecordEngagementOutput {
eventId: string;
engagementWeight: number;
}
export class RecordEngagementUseCase
implements AsyncUseCase<RecordEngagementInput, RecordEngagementOutput> {
constructor(
private readonly engagementRepository: IEngagementRepository,
private readonly logger: ILogger,
) {}
async execute(input: RecordEngagementInput): Promise<RecordEngagementOutput> {
this.logger.debug('Executing RecordEngagementUseCase', { input });
try {
const eventId = `eng-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const baseProps: Omit<Parameters<typeof EngagementEvent.create>[0], 'timestamp'> = {
id: eventId,
action: input.action,
entityType: input.entityType,
entityId: input.entityId,
actorType: input.actorType,
sessionId: input.sessionId,
};
const event = EngagementEvent.create({
...baseProps,
...(input.actorId !== undefined ? { actorId: input.actorId } : {}),
...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
});
await this.engagementRepository.save(event);
this.logger.info('Engagement recorded successfully', { eventId, input });
return {
eventId,
engagementWeight: event.getEngagementWeight(),
};
} catch (error) {
this.logger.error('Error recording engagement', error, { input });
throw error;
}
}
}

View File

@@ -0,0 +1,64 @@
/**
* Use Case: RecordPageViewUseCase
*
* Records a page view event when a visitor accesses an entity page.
*/
import type { AsyncUseCase } from '@gridpilot/shared/application';
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
import { PageView } from '../../domain/entities/PageView';
import type { EntityType, VisitorType } from '../../domain/types/PageView';
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
export interface RecordPageViewInput {
entityType: EntityType;
entityId: string;
visitorId?: string;
visitorType: VisitorType;
sessionId: string;
referrer?: string;
userAgent?: string;
country?: string;
}
export interface RecordPageViewOutput {
pageViewId: string;
}
export class RecordPageViewUseCase
implements AsyncUseCase<RecordPageViewInput, RecordPageViewOutput> {
constructor(
private readonly pageViewRepository: IPageViewRepository,
private readonly logger: ILogger,
) {}
async execute(input: RecordPageViewInput): Promise<RecordPageViewOutput> {
this.logger.debug('Executing RecordPageViewUseCase', { input });
try {
const pageViewId = `pv-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const baseProps: Omit<Parameters<typeof PageView.create>[0], 'timestamp'> = {
id: pageViewId,
entityType: input.entityType,
entityId: input.entityId,
visitorType: input.visitorType,
sessionId: input.sessionId,
};
const pageView = PageView.create({
...baseProps,
...(input.visitorId !== undefined ? { visitorId: input.visitorId } : {}),
...(input.referrer !== undefined ? { referrer: input.referrer } : {}),
...(input.userAgent !== undefined ? { userAgent: input.userAgent } : {}),
...(input.country !== undefined ? { country: input.country } : {}),
});
await this.pageViewRepository.save(pageView);
this.logger.info('Page view recorded successfully', { pageViewId, input });
return { pageViewId };
} catch (error) {
this.logger.error('Error recording page view', error, { input });
throw error;
}
}
}

View File

@@ -0,0 +1,149 @@
/**
* Domain Entity: AnalyticsSnapshot
*
* Aggregated analytics data for a specific entity over a time period.
* Pre-calculated metrics for sponsor dashboard and entity analytics.
*/
import type { IEntity } from '@gridpilot/shared/domain';
import type {
AnalyticsSnapshotProps,
AnalyticsMetrics,
SnapshotEntityType,
SnapshotPeriod,
} from '../types/AnalyticsSnapshot';
export type { SnapshotEntityType, SnapshotPeriod } from '../types/AnalyticsSnapshot';
import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId';
export class AnalyticsSnapshot implements IEntity<string> {
readonly id: string;
readonly entityType: SnapshotEntityType;
readonly period: SnapshotPeriod;
readonly startDate: Date;
readonly endDate: Date;
readonly metrics: AnalyticsMetrics;
readonly createdAt: Date;
private readonly entityIdVo: AnalyticsEntityId;
private constructor(props: AnalyticsSnapshotProps) {
this.id = props.id;
this.entityType = props.entityType;
this.entityIdVo = AnalyticsEntityId.create(props.entityId);
this.period = props.period;
this.startDate = props.startDate;
this.endDate = props.endDate;
this.metrics = props.metrics;
this.createdAt = props.createdAt;
}
get entityId(): string {
return this.entityIdVo.value;
}
static create(props: Omit<AnalyticsSnapshotProps, 'createdAt'> & { createdAt?: Date }): AnalyticsSnapshot {
this.validate(props);
return new AnalyticsSnapshot({
...props,
createdAt: props.createdAt ?? new Date(),
});
}
static createEmpty(
id: string,
entityType: SnapshotEntityType,
entityId: string,
period: SnapshotPeriod,
startDate: Date,
endDate: Date
): AnalyticsSnapshot {
return new AnalyticsSnapshot({
id,
entityType,
entityId,
period,
startDate,
endDate,
metrics: {
pageViews: 0,
uniqueVisitors: 0,
avgSessionDuration: 0,
bounceRate: 0,
engagementScore: 0,
sponsorClicks: 0,
sponsorUrlClicks: 0,
socialShares: 0,
leagueJoins: 0,
raceRegistrations: 0,
exposureValue: 0,
},
createdAt: new Date(),
});
}
private static validate(props: Omit<AnalyticsSnapshotProps, 'createdAt'>): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('AnalyticsSnapshot ID is required');
}
if (!props.entityType) {
throw new Error('AnalyticsSnapshot entityType is required');
}
if (!props.entityId || props.entityId.trim().length === 0) {
throw new Error('AnalyticsSnapshot entityId is required');
}
if (!props.period) {
throw new Error('AnalyticsSnapshot period is required');
}
if (props.endDate < props.startDate) {
throw new Error('AnalyticsSnapshot endDate must be after startDate');
}
}
/**
* Calculate exposure score for sponsors (weighted combination of metrics)
*/
calculateExposureScore(): number {
const { pageViews, uniqueVisitors, sponsorClicks, sponsorUrlClicks, socialShares } = this.metrics;
return (
pageViews * 1 +
uniqueVisitors * 2 +
sponsorClicks * 10 +
sponsorUrlClicks * 25 +
socialShares * 5
);
}
/**
* Calculate trust indicator based on engagement quality
*/
getTrustIndicator(): 'high' | 'medium' | 'low' {
const { bounceRate, avgSessionDuration, engagementScore } = this.metrics;
if (bounceRate < 30 && avgSessionDuration > 120000 && engagementScore > 50) {
return 'high';
}
if (bounceRate < 60 && avgSessionDuration > 30000 && engagementScore > 20) {
return 'medium';
}
return 'low';
}
/**
* Get period comparison label
*/
getPeriodLabel(): string {
const formatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: this.period === 'monthly' ? 'numeric' : undefined
});
return `${formatter.format(this.startDate)} - ${formatter.format(this.endDate)}`;
}
}

View File

@@ -0,0 +1,111 @@
/**
* Domain Entity: EngagementEvent
*
* Represents user interactions beyond page views.
* Tracks clicks, downloads, sign-ups, and other engagement actions.
*/
import type { IEntity } from '@gridpilot/shared/domain';
import type {
EngagementAction,
EngagementEntityType,
EngagementEventProps,
} from '../types/EngagementEvent';
export type { EngagementAction, EngagementEntityType } from '../types/EngagementEvent';
import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId';
export class EngagementEvent implements IEntity<string> {
readonly id: string;
readonly action: EngagementAction;
readonly entityType: EngagementEntityType;
readonly actorId: string | undefined;
readonly actorType: 'anonymous' | 'driver' | 'sponsor';
readonly sessionId: string;
readonly metadata: Record<string, string | number | boolean> | undefined;
readonly timestamp: Date;
private readonly entityIdVo: AnalyticsEntityId;
private constructor(props: EngagementEventProps) {
this.id = props.id;
this.action = props.action;
this.entityType = props.entityType;
this.entityIdVo = AnalyticsEntityId.create(props.entityId);
this.actorId = props.actorId;
this.actorType = props.actorType;
this.sessionId = props.sessionId;
this.metadata = props.metadata;
this.timestamp = props.timestamp;
}
get entityId(): string {
return this.entityIdVo.value;
}
static create(props: Omit<EngagementEventProps, 'timestamp'> & { timestamp?: Date }): EngagementEvent {
this.validate(props);
return new EngagementEvent({
...props,
timestamp: props.timestamp ?? new Date(),
});
}
private static validate(props: Omit<EngagementEventProps, 'timestamp'>): void {
if (!props.id || props.id.trim().length === 0) {
throw new Error('EngagementEvent ID is required');
}
if (!props.action) {
throw new Error('EngagementEvent action is required');
}
if (!props.entityType) {
throw new Error('EngagementEvent entityType is required');
}
if (!props.entityId || props.entityId.trim().length === 0) {
throw new Error('EngagementEvent entityId is required');
}
if (!props.sessionId || props.sessionId.trim().length === 0) {
throw new Error('EngagementEvent sessionId is required');
}
}
/**
* Check if this is a sponsor-related engagement
*/
isSponsorEngagement(): boolean {
return this.action.startsWith('click_sponsor') ||
this.action === 'contact_sponsor' ||
this.entityType === 'sponsor' ||
this.entityType === 'sponsorship';
}
/**
* Check if this is a conversion event (high-value action)
*/
isConversionEvent(): boolean {
return ['join_league', 'register_race', 'click_sponsor_url', 'contact_sponsor'].includes(this.action);
}
/**
* Get engagement weight for analytics calculations
*/
getEngagementWeight(): number {
const weights: Record<EngagementAction, number> = {
'click_sponsor_logo': 2,
'click_sponsor_url': 5,
'download_livery_pack': 3,
'join_league': 10,
'register_race': 8,
'view_standings': 1,
'view_schedule': 1,
'share_social': 4,
'contact_sponsor': 15,
};
return weights[this.action] || 1;
}
}

View File

@@ -0,0 +1,131 @@
/**
* Domain Entity: PageView
*
* Represents a single page view event for analytics tracking.
* Captures visitor interactions with leagues, drivers, teams, races.
*/
import type { IEntity } from '@gridpilot/shared/domain';
import type { EntityType, VisitorType, PageViewProps } from '../types/PageView';
export type { EntityType, VisitorType } from '../types/PageView';
import { AnalyticsEntityId } from '../value-objects/AnalyticsEntityId';
import { AnalyticsSessionId } from '../value-objects/AnalyticsSessionId';
import { PageViewId } from '../value-objects/PageViewId';
export class PageView implements IEntity<string> {
readonly entityType: EntityType;
readonly visitorId: string | undefined;
readonly visitorType: VisitorType;
readonly referrer: string | undefined;
readonly userAgent: string | undefined;
readonly country: string | undefined;
readonly timestamp: Date;
readonly durationMs: number | undefined;
private readonly idVo: PageViewId;
private readonly entityIdVo: AnalyticsEntityId;
private readonly sessionIdVo: AnalyticsSessionId;
private constructor(props: PageViewProps) {
this.idVo = PageViewId.create(props.id);
this.entityType = props.entityType;
this.entityIdVo = AnalyticsEntityId.create(props.entityId);
this.visitorId = props.visitorId;
this.visitorType = props.visitorType;
this.sessionIdVo = AnalyticsSessionId.create(props.sessionId);
this.referrer = props.referrer;
this.userAgent = props.userAgent;
this.country = props.country;
this.timestamp = props.timestamp;
this.durationMs = props.durationMs;
}
get id(): string {
return this.idVo.value;
}
get entityId(): string {
return this.entityIdVo.value;
}
get sessionId(): string {
return this.sessionIdVo.value;
}
static create(props: Omit<PageViewProps, 'timestamp'> & { timestamp?: Date }): PageView {
this.validate(props);
const baseProps: PageViewProps = {
id: props.id,
entityType: props.entityType,
entityId: props.entityId,
visitorType: props.visitorType,
sessionId: props.sessionId,
timestamp: props.timestamp ?? new Date(),
...(props.visitorId !== undefined ? { visitorId: props.visitorId } : {}),
...(props.referrer !== undefined ? { referrer: props.referrer } : {}),
...(props.userAgent !== undefined ? { userAgent: props.userAgent } : {}),
...(props.country !== undefined ? { country: props.country } : {}),
...(props.durationMs !== undefined ? { durationMs: props.durationMs } : {}),
};
return new PageView(baseProps);
}
private static validate(props: Omit<PageViewProps, 'timestamp'>): 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 PageView.create({
id: this.id,
entityType: this.entityType,
entityId: this.entityId,
visitorType: this.visitorType,
sessionId: this.sessionId,
timestamp: this.timestamp,
...(this.visitorId !== undefined ? { visitorId: this.visitorId } : {}),
...(this.referrer !== undefined ? { referrer: this.referrer } : {}),
...(this.userAgent !== undefined ? { userAgent: this.userAgent } : {}),
...(this.country !== undefined ? { country: this.country } : {}),
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');
}
}

View File

@@ -0,0 +1,6 @@
export interface ILogger {
debug(message: string, ...args: any[]): void;
info(message: string, ...args: any[]): void;
warn(message: string, ...args: any[]): void;
error(message: string, ...args: any[]): void;
}

View File

@@ -0,0 +1,27 @@
/**
* Repository Interface: IAnalyticsSnapshotRepository
*
* Defines persistence operations for AnalyticsSnapshot entities.
*/
import type { AnalyticsSnapshot, SnapshotPeriod, SnapshotEntityType } from '../entities/AnalyticsSnapshot';
export interface IAnalyticsSnapshotRepository {
save(snapshot: AnalyticsSnapshot): Promise<void>;
findById(id: string): Promise<AnalyticsSnapshot | null>;
findByEntity(entityType: SnapshotEntityType, entityId: string): Promise<AnalyticsSnapshot[]>;
findByPeriod(
entityType: SnapshotEntityType,
entityId: string,
period: SnapshotPeriod,
startDate: Date,
endDate: Date
): Promise<AnalyticsSnapshot | null>;
findLatest(entityType: SnapshotEntityType, entityId: string, period: SnapshotPeriod): Promise<AnalyticsSnapshot | null>;
getHistoricalSnapshots(
entityType: SnapshotEntityType,
entityId: string,
period: SnapshotPeriod,
limit: number
): Promise<AnalyticsSnapshot[]>;
}

View File

@@ -0,0 +1,17 @@
/**
* Repository Interface: IEngagementRepository
*
* Defines persistence operations for EngagementEvent entities.
*/
import type { EngagementEvent, EngagementAction, EngagementEntityType } from '../entities/EngagementEvent';
export interface IEngagementRepository {
save(event: EngagementEvent): Promise<void>;
findById(id: string): Promise<EngagementEvent | null>;
findByEntityId(entityType: EngagementEntityType, entityId: string): Promise<EngagementEvent[]>;
findByAction(action: EngagementAction): Promise<EngagementEvent[]>;
findByDateRange(startDate: Date, endDate: Date): Promise<EngagementEvent[]>;
countByAction(action: EngagementAction, entityId?: string, since?: Date): Promise<number>;
getSponsorClicksForEntity(entityId: string, since?: Date): Promise<number>;
}

View File

@@ -0,0 +1,17 @@
/**
* Repository Interface: IPageViewRepository
*
* Defines persistence operations for PageView entities.
*/
import type { PageView, EntityType } from '../entities/PageView';
export interface IPageViewRepository {
save(pageView: PageView): Promise<void>;
findById(id: string): Promise<PageView | null>;
findByEntityId(entityType: EntityType, entityId: string): Promise<PageView[]>;
findByDateRange(startDate: Date, endDate: Date): Promise<PageView[]>;
findBySession(sessionId: string): Promise<PageView[]>;
countByEntityId(entityType: EntityType, entityId: string, since?: Date): Promise<number>;
countUniqueVisitors(entityType: EntityType, entityId: string, since?: Date): Promise<number>;
}

View File

@@ -0,0 +1,35 @@
/**
* Domain Types: AnalyticsSnapshot
*
* Pure type/config definitions used by the AnalyticsSnapshot entity.
* Kept in domain/types so domain/entities contains only entity classes.
*/
export type SnapshotPeriod = 'daily' | 'weekly' | 'monthly';
export type SnapshotEntityType = 'league' | 'driver' | 'team' | 'race' | 'sponsor';
export interface AnalyticsMetrics {
pageViews: number;
uniqueVisitors: number;
avgSessionDuration: number;
bounceRate: number;
engagementScore: number;
sponsorClicks: number;
sponsorUrlClicks: number;
socialShares: number;
leagueJoins: number;
raceRegistrations: number;
exposureValue: number;
}
export interface AnalyticsSnapshotProps {
id: string;
entityType: SnapshotEntityType;
entityId: string;
period: SnapshotPeriod;
startDate: Date;
endDate: Date;
metrics: AnalyticsMetrics;
createdAt: Date;
}

View File

@@ -0,0 +1,37 @@
/**
* Domain Types: EngagementEvent
*
* Pure type/config definitions used by the EngagementEvent entity.
* Kept in domain/types so domain/entities contains only entity classes.
*/
export type EngagementAction =
| 'click_sponsor_logo'
| 'click_sponsor_url'
| 'download_livery_pack'
| 'join_league'
| 'register_race'
| 'view_standings'
| 'view_schedule'
| 'share_social'
| 'contact_sponsor';
export type EngagementEntityType =
| 'league'
| 'driver'
| 'team'
| 'race'
| 'sponsor'
| 'sponsorship';
export interface EngagementEventProps {
id: string;
action: EngagementAction;
entityType: EngagementEntityType;
entityId: string;
actorId?: string;
actorType: 'anonymous' | 'driver' | 'sponsor';
sessionId: string;
metadata?: Record<string, string | number | boolean>;
timestamp: Date;
}

View File

@@ -0,0 +1,34 @@
/**
* Domain Types: PageView
*
* Pure type/config definitions used by the PageView entity.
* Kept in domain/types so domain/entities contains only entity classes.
*/
export enum EntityType {
LEAGUE = 'league',
DRIVER = 'driver',
TEAM = 'team',
RACE = 'race',
SPONSOR = 'sponsor',
}
export enum VisitorType {
ANONYMOUS = 'anonymous',
DRIVER = 'driver',
SPONSOR = '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;
}

View File

@@ -0,0 +1,37 @@
import type { IValueObject } from '@gridpilot/shared/domain';
export interface AnalyticsEntityIdProps {
value: string;
}
/**
* Value Object: AnalyticsEntityId
*
* Represents the ID of an entity (league, driver, team, race, sponsor)
* within the analytics bounded context.
*/
export class AnalyticsEntityId implements IValueObject<AnalyticsEntityIdProps> {
public readonly props: AnalyticsEntityIdProps;
private constructor(value: string) {
this.props = { value };
}
static create(raw: string): AnalyticsEntityId {
const value = raw.trim();
if (!value) {
throw new Error('AnalyticsEntityId must be a non-empty string');
}
return new AnalyticsEntityId(value);
}
get value(): string {
return this.props.value;
}
equals(other: IValueObject<AnalyticsEntityIdProps>): boolean {
return this.props.value === other.props.value;
}
}

View File

@@ -0,0 +1,36 @@
import type { IValueObject } from '@gridpilot/shared/domain';
export interface AnalyticsSessionIdProps {
value: string;
}
/**
* Value Object: AnalyticsSessionId
*
* Represents an analytics session identifier within the analytics bounded context.
*/
export class AnalyticsSessionId implements IValueObject<AnalyticsSessionIdProps> {
public readonly props: AnalyticsSessionIdProps;
private constructor(value: string) {
this.props = { value };
}
static create(raw: string): AnalyticsSessionId {
const value = raw.trim();
if (!value) {
throw new Error('AnalyticsSessionId must be a non-empty string');
}
return new AnalyticsSessionId(value);
}
get value(): string {
return this.props.value;
}
equals(other: IValueObject<AnalyticsSessionIdProps>): boolean {
return this.props.value === other.props.value;
}
}

View File

@@ -0,0 +1,36 @@
import type { IValueObject } from '@gridpilot/shared/domain';
export interface PageViewIdProps {
value: string;
}
/**
* Value Object: PageViewId
*
* Represents the identifier of a PageView within the analytics bounded context.
*/
export class PageViewId implements IValueObject<PageViewIdProps> {
public readonly props: PageViewIdProps;
private constructor(value: string) {
this.props = { value };
}
static create(raw: string): PageViewId {
const value = raw.trim();
if (!value) {
throw new Error('PageViewId must be a non-empty string');
}
return new PageViewId(value);
}
get value(): string {
return this.props.value;
}
equals(other: IValueObject<PageViewIdProps>): boolean {
return this.props.value === other.props.value;
}
}

26
core/analytics/index.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* @gridpilot/analytics
*
* Analytics bounded context - tracks page views, engagement events,
* and generates analytics snapshots for sponsor exposure metrics.
*/
// Domain entities
export * from './domain/entities/PageView';
export * from './domain/entities/EngagementEvent';
export * from './domain/entities/AnalyticsSnapshot';
// Domain repositories
export * from './domain/repositories/IPageViewRepository';
export * from './domain/repositories/IEngagementRepository';
export * from './domain/repositories/IAnalyticsSnapshotRepository';
// Application use cases
export * from './application/use-cases/RecordPageViewUseCase';
export * from './application/use-cases/RecordEngagementUseCase';
export * from './application/use-cases/GetEntityAnalyticsQuery';
// Infrastructure
export * from './infrastructure/repositories/InMemoryPageViewRepository';
export * from './infrastructure/repositories/InMemoryEngagementRepository';
export * from './infrastructure/repositories/InMemoryAnalyticsSnapshotRepository';

View File

@@ -0,0 +1,142 @@
/**
* Infrastructure: InMemoryAnalyticsSnapshotRepository
*
* In-memory implementation of IAnalyticsSnapshotRepository for development/testing.
*/
import type { IAnalyticsSnapshotRepository } from '../../domain/repositories/IAnalyticsSnapshotRepository';
import { AnalyticsSnapshot, type SnapshotPeriod, type SnapshotEntityType } from '../../domain/entities/AnalyticsSnapshot';
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
export class InMemoryAnalyticsSnapshotRepository implements IAnalyticsSnapshotRepository {
private snapshots: Map<string, AnalyticsSnapshot> = new Map();
private readonly logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
this.logger.info('InMemoryAnalyticsSnapshotRepository initialized.');
}
async save(snapshot: AnalyticsSnapshot): Promise<void> {
this.logger.debug(`Saving AnalyticsSnapshot: ${snapshot.id}`);
try {
this.snapshots.set(snapshot.id, snapshot);
this.logger.info(`AnalyticsSnapshot ${snapshot.id} saved successfully.`);
} catch (error) {
this.logger.error(`Error saving AnalyticsSnapshot ${snapshot.id}:`, error);
throw error;
}
}
async findById(id: string): Promise<AnalyticsSnapshot | null> {
this.logger.debug(`Finding AnalyticsSnapshot by ID: ${id}`);
try {
const snapshot = this.snapshots.get(id) ?? null;
if (snapshot) {
this.logger.info(`Found AnalyticsSnapshot with ID: ${id}`);
} else {
this.logger.warn(`AnalyticsSnapshot with ID ${id} not found.`);
}
return snapshot;
} catch (error) {
this.logger.error(`Error finding AnalyticsSnapshot by ID ${id}:`, error);
throw error;
}
}
async findByEntity(entityType: SnapshotEntityType, entityId: string): Promise<AnalyticsSnapshot[]> {
this.logger.debug(`Finding AnalyticsSnapshots by Entity: ${entityType}, ${entityId}`);
try {
const snapshots = Array.from(this.snapshots.values()).filter(
s => s.entityType === entityType && s.entityId === entityId
);
this.logger.info(`Found ${snapshots.length} AnalyticsSnapshots for entity ${entityId}.`);
return snapshots;
} catch (error) {
this.logger.error(`Error finding AnalyticsSnapshots for entity ${entityId}:`, error);
throw error;
}
}
async findByPeriod(
entityType: SnapshotEntityType,
entityId: string,
period: SnapshotPeriod,
startDate: Date,
endDate: Date
): Promise<AnalyticsSnapshot | null> {
this.logger.debug(`Finding AnalyticsSnapshot by Period for entity ${entityId}, period ${period}, from ${startDate.toISOString()} to ${endDate.toISOString()}`);
try {
const snapshot = Array.from(this.snapshots.values()).find(
s => s.entityType === entityType &&
s.entityId === entityId &&
s.period === period &&
s.startDate >= startDate &&
s.endDate <= endDate
) ?? null;
if (snapshot) {
this.logger.info(`Found AnalyticsSnapshot for entity ${entityId}, period ${period}.`);
} else {
this.logger.warn(`No AnalyticsSnapshot found for entity ${entityId}, period ${period}, from ${startDate.toISOString()} to ${endDate.toISOString()}.`);
}
return snapshot;
} catch (error) {
this.logger.error(`Error finding AnalyticsSnapshot by period for entity ${entityId}:`, error);
throw error;
}
}
async findLatest(
entityType: SnapshotEntityType,
entityId: string,
period: SnapshotPeriod
): Promise<AnalyticsSnapshot | null> {
this.logger.debug(`Finding latest AnalyticsSnapshot for entity ${entityId}, period ${period}`);
try {
const matching = Array.from(this.snapshots.values())
.filter(s => s.entityType === entityType && s.entityId === entityId && s.period === period)
.sort((a, b) => b.endDate.getTime() - a.endDate.getTime());
const snapshot = matching[0] ?? null;
if (snapshot) {
this.logger.info(`Found latest AnalyticsSnapshot for entity ${entityId}, period ${period}.`);
} else {
this.logger.warn(`No latest AnalyticsSnapshot found for entity ${entityId}, period ${period}.`);
}
return snapshot;
} catch (error) {
this.logger.error(`Error finding latest AnalyticsSnapshot for entity ${entityId}:`, error);
throw error;
}
}
async getHistoricalSnapshots(
entityType: SnapshotEntityType,
entityId: string,
period: SnapshotPeriod,
limit: number
): Promise<AnalyticsSnapshot[]> {
this.logger.debug(`Getting historical AnalyticsSnapshots for entity ${entityId}, period ${period}, limit ${limit}`);
try {
const snapshots = Array.from(this.snapshots.values())
.filter(s => s.entityType === entityType && s.entityId === entityId && s.period === period)
.sort((a, b) => b.endDate.getTime() - a.endDate.getTime())
.slice(0, limit);
this.logger.info(`Found ${snapshots.length} historical AnalyticsSnapshots for entity ${entityId}, period ${period}.`);
return snapshots;
} catch (error) {
this.logger.error(`Error getting historical AnalyticsSnapshots for entity ${entityId}:`, error);
throw error;
}
}
// Helper for testing
clear(): void {
this.snapshots.clear();
}
// Helper for seeding demo data
seed(snapshots: AnalyticsSnapshot[]): void {
snapshots.forEach(s => this.snapshots.set(s.id, s));
}
}

View File

@@ -0,0 +1,140 @@
/**
* Infrastructure: InMemoryEngagementRepository
*
* In-memory implementation of IEngagementRepository for development/testing.
*/
import type { IEngagementRepository } from '../../domain/repositories/IEngagementRepository';
import { EngagementEvent, type EngagementAction, type EngagementEntityType } from '../../domain/entities/EngagementEvent';
import { ILogger } from '@gridpilot/shared/logging/ILogger';
export class InMemoryEngagementRepository implements IEngagementRepository {
private events: Map<string, EngagementEvent> = new Map();
private logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
this.logger.info('InMemoryEngagementRepository initialized.');
}
async save(event: EngagementEvent): Promise<void> {
this.logger.debug(`Attempting to save engagement event: ${event.id}`);
try {
this.events.set(event.id, event);
this.logger.info(`Successfully saved engagement event: ${event.id}`);
} catch (error) {
this.logger.error(`Error saving engagement event ${event.id}:`, error);
throw error;
}
}
async findById(id: string): Promise<EngagementEvent | null> {
this.logger.debug(`Attempting to find engagement event by ID: ${id}`);
try {
const event = this.events.get(id) ?? null;
if (event) {
this.logger.info(`Found engagement event by ID: ${id}`);
} else {
this.logger.warn(`Engagement event not found for ID: ${id}`);
// The original was info, but if a requested ID is not found that's more of a warning than an info.
}
return event;
} catch (error) {
this.logger.error(`Error finding engagement event by ID ${id}:`, error);
throw error;
}
}
async findByEntityId(entityType: EngagementEntityType, entityId: string): Promise<EngagementEvent[]> {
this.logger.debug(`Attempting to find engagement events for entityType: ${entityType}, entityId: ${entityId}`);
try {
const events = Array.from(this.events.values()).filter(
e => e.entityType === entityType && e.entityId === entityId
);
this.logger.info(`Found ${events.length} engagement events for entityType: ${entityType}, entityId: ${entityId}`);
return events;
} catch (error) {
this.logger.error(`Error finding engagement events by entity ID ${entityId}:`, error);
throw error;
}
}
async findByAction(action: EngagementAction): Promise<EngagementEvent[]> {
this.logger.debug(`Attempting to find engagement events by action: ${action}`);
try {
const events = Array.from(this.events.values()).filter(
e => e.action === action
);
this.logger.info(`Found ${events.length} engagement events for action: ${action}`);
return events;
} catch (error) {
this.logger.error(`Error finding engagement events by action ${action}:`, error);
throw error;
}
}
async findByDateRange(startDate: Date, endDate: Date): Promise<EngagementEvent[]> {
this.logger.debug(`Attempting to find engagement events by date range: ${startDate.toISOString()} - ${endDate.toISOString()}`);
try {
const events = Array.from(this.events.values()).filter(
e => e.timestamp >= startDate && e.timestamp <= endDate
);
this.logger.info(`Found ${events.length} engagement events for date range.`);
return events;
} catch (error) {
this.logger.error(`Error finding engagement events by date range:`, error);
throw error;
}
}
async countByAction(action: EngagementAction, entityId?: string, since?: Date): Promise<number> {
this.logger.debug(`Attempting to count engagement events for action: ${action}, entityId: ${entityId}, since: ${since?.toISOString()}`);
try {
const count = Array.from(this.events.values()).filter(
e => e.action === action &&
(!entityId || e.entityId === entityId) &&
(!since || e.timestamp >= since)
).length;
this.logger.info(`Counted ${count} engagement events for action: ${action}, entityId: ${entityId}`);
return count;
} catch (error) {
this.logger.error(`Error counting engagement events by action ${action}:`, error);
throw error;
}
}
async getSponsorClicksForEntity(entityId: string, since?: Date): Promise<number> {
this.logger.debug(`Attempting to get sponsor clicks for entity ID: ${entityId}, since: ${since?.toISOString()}`);
try {
const count = Array.from(this.events.values()).filter(
e => e.entityId === entityId &&
(e.action === 'click_sponsor_logo' || e.action === 'click_sponsor_url') &&
(!since || e.timestamp >= since)
).length;
this.logger.info(`Counted ${count} sponsor clicks for entity ID: ${entityId}`);
return count;
} catch (error) {
this.logger.error(`Error getting sponsor clicks for entity ID ${entityId}:`, error);
throw error;
}
}
// Helper for testing
clear(): void {
this.logger.debug('Clearing all engagement events.');
this.events.clear();
this.logger.info('All engagement events cleared.');
}
// Helper for seeding demo data
seed(events: EngagementEvent[]): void {
this.logger.debug(`Seeding ${events.length} engagement events.`);
try {
events.forEach(e => this.events.set(e.id, e));
this.logger.info(`Successfully seeded ${events.length} engagement events.`);
} catch (error) {
this.logger.error(`Error seeding engagement events:`, error);
throw error;
}
}
}

View File

@@ -0,0 +1,144 @@
/**
* Infrastructure: InMemoryPageViewRepository
*
* In-memory implementation of IPageViewRepository for development/testing.
*/
import type { IPageViewRepository } from '../../domain/repositories/IPageViewRepository';
import { PageView, type EntityType } from '../../domain/entities/PageView';
import { ILogger } from '@gridpilot/shared/logging/ILogger';
export class InMemoryPageViewRepository implements IPageViewRepository {
private pageViews: Map<string, PageView> = new Map();
private logger: ILogger;
constructor(logger: ILogger) {
this.logger = logger;
this.logger.info('InMemoryPageViewRepository initialized.');
}
async save(pageView: PageView): Promise<void> {
this.logger.debug(`Attempting to save page view: ${pageView.id}`);
try {
this.pageViews.set(pageView.id, pageView);
this.logger.info(`Successfully saved page view: ${pageView.id}`);
} catch (error) {
this.logger.error(`Error saving page view ${pageView.id}:`, error);
throw error;
}
}
async findById(id: string): Promise<PageView | null> {
this.logger.debug(`Attempting to find page view by ID: ${id}`);
try {
const pageView = this.pageViews.get(id) ?? null;
if (pageView) {
this.logger.info(`Found page view by ID: ${id}`);
} else {
this.logger.warn(`Page view not found for ID: ${id}`);
}
return pageView;
} catch (error) {
this.logger.error(`Error finding page view by ID ${id}:`, error);
throw error;
}
}
async findByEntityId(entityType: EntityType, entityId: string): Promise<PageView[]> {
this.logger.debug(`Attempting to find page views for entityType: ${entityType}, entityId: ${entityId}`);
try {
const pageViews = Array.from(this.pageViews.values()).filter(
pv => pv.entityType === entityType && pv.entityId === entityId
);
this.logger.info(`Found ${pageViews.length} page views for entityType: ${entityType}, entityId: ${entityId}`);
return pageViews;
} catch (error) {
this.logger.error(`Error finding page views by entity ID ${entityId}:`, error);
throw error;
}
}
async findByDateRange(startDate: Date, endDate: Date): Promise<PageView[]> {
this.logger.debug(`Attempting to find page views by date range: ${startDate.toISOString()} - ${endDate.toISOString()}`);
try {
const pageViews = Array.from(this.pageViews.values()).filter(
pv => pv.timestamp >= startDate && pv.timestamp <= endDate
);
this.logger.info(`Found ${pageViews.length} page views for date range.`);
return pageViews;
} catch (error) {
this.logger.error(`Error finding page views by date range:`, error);
throw error;
}
}
async findBySession(sessionId: string): Promise<PageView[]> {
this.logger.debug(`Attempting to find page views by session ID: ${sessionId}`);
try {
const pageViews = Array.from(this.pageViews.values()).filter(
pv => pv.sessionId === sessionId
);
this.logger.info(`Found ${pageViews.length} page views for session ID: ${sessionId}`);
return pageViews;
} catch (error) {
this.logger.error(`Error finding page views by session ID ${sessionId}:`, error);
throw error;
}
}
async countByEntityId(entityType: EntityType, entityId: string, since?: Date): Promise<number> {
this.logger.debug(`Attempting to count page views for entityType: ${entityType}, entityId: ${entityId}, since: ${since?.toISOString()}`);
try {
const count = Array.from(this.pageViews.values()).filter(
pv => pv.entityType === entityType &&
pv.entityId === entityId &&
(!since || pv.timestamp >= since)
).length;
this.logger.info(`Counted ${count} page views for entityType: ${entityType}, entityId: ${entityId}`);
return count;
} catch (error) {
this.logger.error(`Error counting page views by entity ID ${entityId}:`, error);
throw error;
}
}
async countUniqueVisitors(entityType: EntityType, entityId: string, since?: Date): Promise<number> {
this.logger.debug(`Attempting to count unique visitors for entityType: ${entityType}, entityId: ${entityId}, since: ${since?.toISOString()}`);
try {
const visitors = new Set<string>();
Array.from(this.pageViews.values())
.filter(
pv => pv.entityType === entityType &&
pv.entityId === entityId &&
(!since || pv.timestamp >= since)
)
.forEach(pv => {
visitors.add(pv.visitorId ?? pv.sessionId);
});
this.logger.info(`Counted ${visitors.size} unique visitors for entityType: ${entityType}, entityId: ${entityId}`);
return visitors.size;
} catch (error) {
this.logger.error(`Error counting unique visitors for entity ID ${entityId}:`, error);
throw error;
}
}
// Helper for testing
clear(): void {
this.logger.debug('Clearing all page views.');
this.pageViews.clear();
this.logger.info('All page views cleared.');
}
// Helper for seeding demo data
seed(pageViews: PageView[]): void {
this.logger.debug(`Seeding ${pageViews.length} page views.`);
try {
pageViews.forEach(pv => this.pageViews.set(pv.id, pv));
this.logger.info(`Successfully seeded ${pageViews.length} page views.`);
} catch (error) {
this.logger.error(`Error seeding page views:`, error);
throw error;
}
}
}

View File

@@ -0,0 +1,14 @@
{
"name": "@gridpilot/analytics",
"version": "0.1.0",
"main": "./index.ts",
"types": "./index.ts",
"type": "module",
"exports": {
".": "./index.ts",
"./domain/*": "./domain/*",
"./application/*": "./application/*",
"./infrastructure/*": "./infrastructure/*"
},
"dependencies": {}
}