import { TeamRatingEvent } from '../entities/TeamRatingEvent'; import { TeamRatingValue } from '../value-objects/TeamRatingValue'; import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey'; export interface TeamRatingSnapshot { teamId: string; driving: TeamRatingValue; adminTrust: TeamRatingValue; overall: number; // Calculated overall rating lastUpdated: Date; eventCount: number; } /** * Domain Service: TeamRatingSnapshotCalculator * * Calculates team rating snapshots from event ledgers. * Mirrors the user RatingSnapshotCalculator pattern. * * Pure domain logic - no persistence concerns. */ export class TeamRatingSnapshotCalculator { /** * Calculate current team rating snapshot from all events. * * @param teamId - The team ID to calculate for * @param events - All rating events for the team * @returns TeamRatingSnapshot with current ratings */ static calculate(teamId: string, events: TeamRatingEvent[]): TeamRatingSnapshot { // Start with default ratings (50 for each dimension) const defaultRating = 50; if (events.length === 0) { return { teamId, driving: TeamRatingValue.create(defaultRating), adminTrust: TeamRatingValue.create(defaultRating), overall: defaultRating, lastUpdated: new Date(), eventCount: 0, }; } // Group events by dimension const eventsByDimension = events.reduce((acc, event) => { const key = event.dimension.value; if (!acc[key]) { acc[key] = []; } acc[key].push(event); return acc; }, {} as Record); // Calculate each dimension const dimensionRatings: Record = {}; for (const [dimensionKey, dimensionEvents] of Object.entries(eventsByDimension)) { const totalWeight = dimensionEvents.reduce((sum, event) => { return sum + (event.weight || 1); }, 0); const weightedSum = dimensionEvents.reduce((sum, event) => { return sum + (event.delta.value * (event.weight || 1)); }, 0); // Normalize and add to base rating const normalizedDelta = weightedSum / totalWeight; dimensionRatings[dimensionKey] = Math.max(0, Math.min(100, defaultRating + normalizedDelta)); } const drivingRating = dimensionRatings['driving'] ?? defaultRating; const adminTrustRating = dimensionRatings['adminTrust'] ?? defaultRating; // Calculate overall as weighted average const overall = (drivingRating * 0.7 + adminTrustRating * 0.3); // Find latest event date const lastUpdated = events.reduce((latest, event) => { return event.occurredAt > latest ? event.occurredAt : latest; }, new Date(0)); return { teamId, driving: TeamRatingValue.create(drivingRating), adminTrust: TeamRatingValue.create(adminTrustRating), overall: Math.round(overall * 10) / 10, // Round to 1 decimal lastUpdated, eventCount: events.length, }; } /** * Calculate rating change for a specific dimension from events. * * @param dimension - The dimension to calculate for * @param events - Events to calculate from * @returns Net change value */ static calculateDimensionChange( dimension: TeamRatingDimensionKey, events: TeamRatingEvent[] ): number { const filtered = events.filter(e => e.dimension.equals(dimension)); if (filtered.length === 0) return 0; const totalWeight = filtered.reduce((sum, event) => { return sum + (event.weight || 1); }, 0); const weightedSum = filtered.reduce((sum, event) => { return sum + (event.delta.value * (event.weight || 1)); }, 0); return weightedSum / totalWeight; } /** * Calculate rating change over a time window. * * @param teamId - The team ID * @param events - All events * @param from - Start date * @param to - End date * @returns Snapshot of ratings at the end of the window */ static calculateOverWindow( teamId: string, events: TeamRatingEvent[], from: Date, to: Date ): TeamRatingSnapshot { const windowEvents = events.filter(e => e.occurredAt >= from && e.occurredAt <= to ); return this.calculate(teamId, windowEvents); } /** * Calculate rating change between two snapshots. * * @param before - Snapshot before changes * @param after - Snapshot after changes * @returns Object with change values */ static calculateDelta( before: TeamRatingSnapshot, after: TeamRatingSnapshot ): { driving: number; adminTrust: number; overall: number; } { return { driving: after.driving.value - before.driving.value, adminTrust: after.adminTrust.value - before.adminTrust.value, overall: after.overall - before.overall, }; } }