/** * Analytics Integration Service * Integrates Time Entries analytics with existing entities for enriched analysis */ import { TimeEntry } from '@/lib/types/database'; import { analyticsEngine } from './analytics-engine'; import { llmAnalyzer } from './llm-analyzer'; import { postgresClient } from './postgres-client'; import { TimelineEvent, AnalyticsInsight, AggregateAnalysis, LLMAnalysisRequest } from '@/lib/types/analytics'; export interface EnrichedTimeEntry extends TimeEntry { resource_name?: string; ticket_title?: string; ticket_number?: string; task_title?: string; project_name?: string; company_name?: string; analysis?: { activityScore: number; contentScore: number; timelinessScore: number; overallScore: number; }; } export interface EnrichmentOptions { includeResourceInfo?: boolean; includeTicketInfo?: boolean; includeTaskInfo?: boolean; includeProjectInfo?: boolean; includeCompanyInfo?: boolean; includeAnalysis?: boolean; includeRelatedEntities?: boolean; } export class AnalyticsIntegrationService { /** * Enrich time entries with related entity data and analysis */ async enrichTimeEntries( timeEntries: TimeEntry[], options: EnrichmentOptions = {} ): Promise { const enriched: EnrichedTimeEntry[] = []; // Extract IDs for batch queries const resourceIds = [...new Set(timeEntries.map(te => te.resource_id).filter((id): id is number => id != null))]; const ticketIds = [...new Set(timeEntries.map(te => te.ticket_id).filter((id): id is number => id != null))]; const taskIds = [...new Set(timeEntries.map(te => te.task_id).filter((id): id is number => id != null))]; const projectIds = [...new Set(timeEntries.map(te => te.project_id).filter((id): id is number => id != null))]; const companyIds = [...new Set(timeEntries.map(te => te.company_id).filter((id): id is number => id != null))]; // Batch fetch related entities const [resources, tickets, tasks, projects, companies] = await Promise.all([ options.includeResourceInfo && resourceIds.length > 0 ? this.getResources(resourceIds) : Promise.resolve([]), options.includeTicketInfo && ticketIds.length > 0 ? this.getTickets(ticketIds) : Promise.resolve([]), options.includeTaskInfo && taskIds.length > 0 ? this.getTasks(taskIds) : Promise.resolve([]), options.includeProjectInfo && projectIds.length > 0 ? this.getProjects(projectIds) : Promise.resolve([]), options.includeCompanyInfo && companyIds.length > 0 ? this.getCompanies(companyIds) : Promise.resolve([]), ]); // Create lookup maps const resourceMap = new Map(resources.map(r => [r.id, r])); const ticketMap = new Map(tickets.map(t => [t.id, t])); const taskMap = new Map(tasks.map(t => [t.id, t])); const projectMap = new Map(projects.map(p => [p.id, p])); const companyMap = new Map(companies.map(c => [c.id, c])); // Enrich each time entry for (const timeEntry of timeEntries) { const enrichedEntry: EnrichedTimeEntry = { ...timeEntry }; // Add related entity information if (options.includeResourceInfo) { const resource = resourceMap.get(timeEntry.resource_id); if (resource) { enrichedEntry.resource_name = `${resource.first_name} ${resource.last_name}`; } } if (options.includeTicketInfo) { const ticket = ticketMap.get(timeEntry.ticket_id); if (ticket) { enrichedEntry.ticket_title = ticket.title; enrichedEntry.ticket_number = ticket.ticket_number; } } if (options.includeTaskInfo) { const task = taskMap.get(timeEntry.task_id); if (task) { enrichedEntry.task_title = task.title; } } if (options.includeProjectInfo) { const project = projectMap.get(timeEntry.project_id); if (project) { enrichedEntry.project_name = project.project_name; } } if (options.includeCompanyInfo) { const company = companyMap.get(timeEntry.company_id); if (company) { enrichedEntry.company_name = company.company_name; } } // Add analysis if (options.includeAnalysis) { const analysis = analyticsEngine.analyzeTimeEntry(timeEntry); enrichedEntry.analysis = { activityScore: analysis.activityScore.score, contentScore: analysis.contentScore.score, timelinessScore: analysis.timelinessScore.score, overallScore: analysis.overallScore, }; } enriched.push(enrichedEntry); } return enriched; } /** * Generate timeline events from enriched time entries */ async generateTimelineEvents( timeEntries: TimeEntry[], options: EnrichmentOptions = { includeResourceInfo: true, includeTicketInfo: true, includeTaskInfo: true, includeProjectInfo: true, includeCompanyInfo: true, } ): Promise { const enrichedEntries = await this.enrichTimeEntries(timeEntries, options); const events: TimelineEvent[] = enrichedEntries.map(entry => ({ id: `te-${entry.id}`, type: 'time_entry', timestamp: new Date(entry.entry_date), title: entry.title || 'Time Entry', description: this.generateEventDescription(entry), duration: entry.hours_worked, metadata: { timeEntryId: entry.id, resourceId: entry.resource_id, resourceName: entry.resource_name, ticketId: entry.ticket_id, ticketTitle: entry.ticket_title, ticketNumber: entry.ticket_number, taskId: entry.task_id, taskTitle: entry.task_title, projectId: entry.project_id, projectName: entry.project_name, companyId: entry.company_id, companyName: entry.company_name, billable: entry.billable, approved: entry.approved, score: entry.analysis?.overallScore, }, score: entry.analysis?.overallScore, isHumanActivity: true, importance: this.determineImportance(entry), })); // Add key moments if requested if (options.includeRelatedEntities) { const keyMomentEvents = await this.generateKeyMomentEvents(timeEntries); events.push(...keyMomentEvents); } // Sort by timestamp return events.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); } /** * Generate comprehensive analysis with entity context */ async generateComprehensiveAnalysis( timeEntries: TimeEntry[], options: EnrichmentOptions = { includeResourceInfo: true, includeTicketInfo: true, includeProjectInfo: true, includeCompanyInfo: true, includeAnalysis: true, } ): Promise<{ analysis: AggregateAnalysis; enrichedEntries: EnrichedTimeEntry[]; entityInsights: AnalyticsInsight[]; }> { // Enrich time entries const enrichedEntries = await this.enrichTimeEntries(timeEntries, options); // Generate basic analysis const analysis = analyticsEngine.analyzeTimeEntries(timeEntries); // Generate entity-specific insights const entityInsights = await this.generateEntityInsights(enrichedEntries); // Combine insights analysis.insights = [...analysis.insights, ...entityInsights]; return { analysis, enrichedEntries, entityInsights, }; } /** * Generate LLM analysis with enriched context */ async generateLLMAnalysis( timeEntries: TimeEntry[], analysisType: 'productivity' | 'quality' | 'patterns' | 'anomalies' | 'comprehensive' = 'comprehensive', options: EnrichmentOptions = { includeResourceInfo: true, includeTicketInfo: true, includeProjectInfo: true, includeCompanyInfo: true, } ) { const enrichedEntries = await this.enrichTimeEntries(timeEntries, options); const request: LLMAnalysisRequest = { timeEntries: enrichedEntries.map(entry => ({ id: entry.id, notes: entry.notes || undefined, title: entry.title || undefined, hours_worked: entry.hours_worked, entry_date: entry.entry_date.toISOString(), resource_name: entry.resource_name, ticket_title: entry.ticket_title, })), analysisType, context: { timeRange: `${new Date().toISOString().split('T')[0]}`, resourceIds: [...new Set(enrichedEntries.map(e => e.resource_id).filter((id): id is number => id != null))], projectIds: [...new Set(enrichedEntries.map(e => e.project_id).filter((id): id is number => id != null))], }, }; return llmAnalyzer.analyzeTimeEntries(request); } /** * Generate entity-specific insights */ private async generateEntityInsights(enrichedEntries: EnrichedTimeEntry[]): Promise { const insights: AnalyticsInsight[] = []; // Resource-based insights const resourceGroups = this.groupBy(enrichedEntries, 'resource_id'); for (const [resourceId, entries] of Object.entries(resourceGroups)) { const resourceName = entries[0]?.resource_name || `Resource ${resourceId}`; const avgScore = entries.reduce((sum, e) => sum + (e.analysis?.overallScore || 0), 0) / entries.length; if (avgScore < 0.5) { insights.push({ type: 'warning', category: 'activity', title: `Low Performance: ${resourceName}`, description: `${resourceName} has an average score of ${(avgScore * 100).toFixed(1)}%`, recommendation: 'Review time entry quality and provide training if needed', severity: 'medium', actionable: true, }); } } // Project-based insights const projectGroups = this.groupBy(enrichedEntries, 'project_id'); for (const [projectId, entries] of Object.entries(projectGroups)) { const projectName = entries[0]?.project_name || `Project ${projectId}`; const totalHours = entries.reduce((sum, e) => sum + e.hours_worked, 0); if (totalHours > 100) { insights.push({ type: 'info', category: 'performance', title: `High Activity: ${projectName}`, description: `${Number(totalHours).toFixed(1)} hours logged for this project`, recommendation: 'Monitor project progress and resource allocation', severity: 'low', actionable: true, }); } } // Ticket-based insights const ticketGroups = this.groupBy(enrichedEntries, 'ticket_id'); for (const [ticketId, entries] of Object.entries(ticketGroups)) { const ticketTitle = entries[0]?.ticket_title || `Ticket ${ticketId}`; const totalHours = entries.reduce((sum, e) => sum + e.hours_worked, 0); if (totalHours > 20) { insights.push({ type: 'warning', category: 'performance', title: `Time Intensive: ${ticketTitle}`, description: `${Number(totalHours).toFixed(1)} hours logged for this ticket`, recommendation: 'Review if ticket scope is appropriate or needs to be split', severity: 'medium', actionable: true, }); } } return insights; } /** * Generate key moment events from related entities */ private async generateKeyMomentEvents(timeEntries: TimeEntry[]): Promise { const events: TimelineEvent[] = []; // Get ticket creation and resolution dates const ticketIds = [...new Set(timeEntries.map(te => te.ticket_id).filter((id): id is number => id != null))]; if (ticketIds.length > 0) { const tickets = await this.getTickets(ticketIds); for (const ticket of tickets) { // Ticket creation if (ticket.create_date) { events.push({ id: `ticket-created-${ticket.id}`, type: 'key_moment', timestamp: new Date(ticket.create_date), title: `Ticket Created: ${ticket.ticket_number}`, description: `Ticket "${ticket.title}" was created`, metadata: { ticketId: ticket.id, ticketNumber: ticket.ticket_number, ticketTitle: ticket.title, }, isHumanActivity: false, importance: 'high', }); } // Ticket resolution if (ticket.completed_date) { events.push({ id: `ticket-resolved-${ticket.id}`, type: 'milestone', timestamp: new Date(ticket.completed_date), title: `Ticket Resolved: ${ticket.ticket_number}`, description: `Ticket "${ticket.title}" was resolved`, metadata: { ticketId: ticket.id, ticketNumber: ticket.ticket_number, ticketTitle: ticket.title, }, isHumanActivity: false, importance: 'critical', }); } } } return events; } /** * Helper methods */ private async getResources(resourceIds: number[]) { const result = await postgresClient.query( 'SELECT id, first_name, last_name FROM resources WHERE id = ANY($1)', [resourceIds] ); return result.rows; } private async getTickets(ticketIds: number[]) { const result = await postgresClient.query( 'SELECT id, ticket_number, title, create_date, completed_date FROM tickets WHERE id = ANY($1)', [ticketIds] ); return result.rows; } private async getTasks(taskIds: number[]) { const result = await postgresClient.query( 'SELECT id, title FROM tasks WHERE id = ANY($1)', [taskIds] ); return result.rows; } private async getProjects(projectIds: number[]) { const result = await postgresClient.query( 'SELECT id, project_name FROM projects WHERE id = ANY($1)', [projectIds] ); return result.rows; } private async getCompanies(companyIds: number[]) { const result = await postgresClient.query( 'SELECT id, company_name FROM companies WHERE id = ANY($1)', [companyIds] ); return result.rows; } private generateEventDescription(entry: EnrichedTimeEntry): string { const parts: string[] = []; if (entry.resource_name) { parts.push(`${entry.resource_name} worked`); } parts.push(`${entry.hours_worked} hours`); if (entry.ticket_title) { parts.push(`on "${entry.ticket_title}"`); } if (entry.project_name) { parts.push(`for project "${entry.project_name}"`); } if (entry.notes) { parts.push(`- ${entry.notes}`); } return parts.join(' '); } private determineImportance(entry: EnrichedTimeEntry): 'low' | 'medium' | 'high' | 'critical' { if (entry.hours_worked > 8) return 'high'; if (entry.billable && entry.hours_worked > 4) return 'high'; if (entry.billable) return 'medium'; if (entry.hours_worked > 2) return 'medium'; return 'low'; } private groupBy(array: T[], key: keyof T): Record { return array.reduce((groups, item) => { const groupKey = String(item[key]); if (!groups[groupKey]) { groups[groupKey] = []; } groups[groupKey].push(item); return groups; }, {} as Record); } } // Create singleton instance export const analyticsIntegration = new AnalyticsIntegrationService();