289 lines
11 KiB
TypeScript
289 lines
11 KiB
TypeScript
/**
|
|
* 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 '@core/shared/application';
|
|
import type { Logger } from '@core/shared/application';
|
|
import type { IPageViewRepository } from '../repositories/IPageViewRepository';
|
|
import type { IEngagementRepository } from '@core/analytics/domain/repositories/IEngagementRepository';
|
|
import type { IAnalyticsSnapshotRepository } from '@core/analytics/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: Logger
|
|
) {}
|
|
|
|
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) {
|
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
this.logger.error(`Error counting total page views for entity ${input.entityId}: ${err.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) {
|
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
this.logger.error(`Error counting unique visitors for entity ${input.entityId}: ${err.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) {
|
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
this.logger.error(`Error getting sponsor clicks for entity ${input.entityId}: ${err.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) {
|
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
this.logger.error(`Error calculating engagement score for entity ${input.entityId}: ${err.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) {
|
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
this.logger.error(`Error counting previous period page views for entity ${input.entityId}: ${err.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) {
|
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
this.logger.error(`Error counting previous period unique visitors for entity ${input.entityId}: ${err.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) {
|
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
this.logger.error(`Error getting sponsor clicks for engagement score for entity ${entityId}: ${err.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;
|
|
}
|
|
} |