/** * Analytics Engine for Time Entries * Processes time entries data and generates insights, scores, and analytics */ import { TimeEntry } from '@/lib/types/database'; import { ActivityScore, ContentScore, TimelinessScore, AnalyticsInsight, TimeEntryAnalysis, AggregateAnalysis } from '@/lib/types/analytics'; export class AnalyticsEngine { /** * Analyze a single time entry */ analyzeTimeEntry(timeEntry: TimeEntry): TimeEntryAnalysis { const activityScore = this.calculateActivityScore(timeEntry); const contentScore = this.calculateContentScore(timeEntry); const timelinessScore = this.calculateTimelinessScore(timeEntry); // Calculate overall score (weighted average) const overallScore = ( activityScore.score * 0.3 + contentScore.score * 0.4 + timelinessScore.score * 0.3 ); return { timeEntryId: timeEntry.id, activityScore, contentScore, timelinessScore, overallScore, insights: this.generateEntryInsights(timeEntry, activityScore, contentScore, timelinessScore), analyzedAt: new Date(), }; } /** * Analyze multiple time entries and generate aggregate insights */ analyzeTimeEntries(timeEntries: TimeEntry[]): AggregateAnalysis { if (timeEntries.length === 0) { return this.createEmptyAnalysis(); } // Analyze individual entries const individualAnalyses = timeEntries.map(entry => this.analyzeTimeEntry(entry)); // Calculate aggregate scores const avgActivityScore = this.calculateAverage(individualAnalyses.map(a => a.activityScore.score)); const avgContentScore = this.calculateAverage(individualAnalyses.map(a => a.contentScore.score)); const avgTimelinessScore = this.calculateAverage(individualAnalyses.map(a => a.timelinessScore.score)); const avgOverallScore = this.calculateAverage(individualAnalyses.map(a => a.overallScore)); // Generate insights const insights = this.generateAggregateInsights(timeEntries, individualAnalyses); // Calculate patterns and trends const patterns = this.analyzePatterns(timeEntries); const trends = this.analyzeTrends(timeEntries); return { totalEntries: timeEntries.length, totalHours: timeEntries.reduce((sum, entry) => sum + entry.hours_worked, 0), averageHoursPerEntry: this.calculateAverage(timeEntries.map(e => e.hours_worked)), dateRange: { earliest: new Date(Math.min(...timeEntries.map(e => new Date(e.entry_date).getTime()))), latest: new Date(Math.max(...timeEntries.map(e => new Date(e.entry_date).getTime()))), }, scores: { activity: avgActivityScore, content: avgContentScore, timeliness: avgTimelinessScore, overall: avgOverallScore, }, insights, patterns, trends, analyzedAt: new Date(), }; } /** * Calculate Activity Score based on entry quality, completeness, and work patterns */ private calculateActivityScore(timeEntry: TimeEntry): ActivityScore { let score = 0; const factors: string[] = []; // Factor 1: Entry completeness (40%) const completenessScore = this.calculateCompletenessScore(timeEntry); score += completenessScore * 0.4; if (completenessScore > 0.8) factors.push('Complete entry details'); // Factor 2: Time tracking consistency (30%) const consistencyScore = this.calculateConsistencyScore(timeEntry); score += consistencyScore * 0.3; if (consistencyScore > 0.7) factors.push('Consistent time tracking'); // Factor 3: Appropriate time duration (20%) const durationScore = this.calculateDurationScore(timeEntry); score += durationScore * 0.2; if (durationScore > 0.6) factors.push('Reasonable time duration'); // Factor 4: Proper categorization (10%) const categorizationScore = this.calculateCategorizationScore(timeEntry); score += categorizationScore * 0.1; if (categorizationScore > 0.5) factors.push('Proper categorization'); return { score: Math.round(score * 100) / 100, factors, breakdown: { completeness: completenessScore, consistency: consistencyScore, duration: durationScore, categorization: categorizationScore, }, }; } /** * Calculate Content Score based on time entry description quality and detail */ private calculateContentScore(timeEntry: TimeEntry): ContentScore { let score = 0; const factors: string[] = []; // Factor 1: Notes quality and length (40%) const notesScore = this.calculateNotesQualityScore(timeEntry.notes); score += notesScore * 0.4; if (notesScore > 0.7) factors.push('Detailed work description'); // Factor 2: Title clarity (20%) const titleScore = this.calculateTitleQualityScore(timeEntry.title); score += titleScore * 0.2; if (titleScore > 0.6) factors.push('Clear activity title'); // Factor 3: Internal notes usage (20%) const internalNotesScore = this.calculateInternalNotesScore(timeEntry.internal_notes); score += internalNotesScore * 0.2; if (internalNotesScore > 0.5) factors.push('Internal documentation'); // Factor 4: Technical detail level (20%) const technicalScore = this.calculateTechnicalDetailScore(timeEntry.notes, timeEntry.title); score += technicalScore * 0.2; if (technicalScore > 0.6) factors.push('Technical details included'); return { score: Math.round(score * 100) / 100, factors, breakdown: { notesQuality: notesScore, titleClarity: titleScore, internalNotes: internalNotesScore, technicalDetail: technicalScore, }, }; } /** * Calculate Timeliness Score based on entry timing relative to work performed */ private calculateTimelinessScore(timeEntry: TimeEntry): TimelinessScore { let score = 0; const factors: string[] = []; // Factor 1: Entry date vs work date (40%) const entryDelayScore = this.calculateEntryDelayScore(timeEntry); score += entryDelayScore * 0.4; if (entryDelayScore > 0.8) factors.push('Prompt time entry'); // Factor 2: Business hours compliance (30%) const businessHoursScore = this.calculateBusinessHoursScore(timeEntry); score += businessHoursScore * 0.3; if (businessHoursScore > 0.7) factors.push('Business hours compliance'); // Factor 3: Regularity pattern (20%) const regularityScore = this.calculateRegularityScore(timeEntry); score += regularityScore * 0.2; if (regularityScore > 0.6) factors.push('Regular entry pattern'); // Factor 4: Approval time (10%) const approvalScore = this.calculateApprovalTimelinessScore(timeEntry); score += approvalScore * 0.1; if (approvalScore > 0.5) factors.push('Timely approval'); return { score: Math.round(score * 100) / 100, factors, breakdown: { entryDelay: entryDelayScore, businessHours: businessHoursScore, regularity: regularityScore, approvalTimeliness: approvalScore, }, }; } /** * Helper methods for score calculations */ private calculateCompletenessScore(timeEntry: TimeEntry): number { let score = 0; const totalFields = 8; if (timeEntry.title && timeEntry.title.length > 5) score++; if (timeEntry.notes && timeEntry.notes.length > 10) score++; if (timeEntry.start_date_time) score++; if (timeEntry.end_date_time) score++; if (timeEntry.ticket_id || timeEntry.task_id || timeEntry.project_id) score++; if (timeEntry.type !== undefined && timeEntry.type !== null) score++; if (timeEntry.billable !== undefined) score++; if (timeEntry.hours_worked > 0 && timeEntry.hours_worked <= 24) score++; return score / totalFields; } private calculateConsistencyScore(timeEntry: TimeEntry): number { // Check if start/end times match the hours worked if (timeEntry.start_date_time && timeEntry.end_date_time) { const start = new Date(timeEntry.start_date_time); const end = new Date(timeEntry.end_date_time); const actualHours = (end.getTime() - start.getTime()) / (1000 * 60 * 60); const difference = Math.abs(actualHours - timeEntry.hours_worked); // Score based on how close the logged hours match the actual time span if (difference < 0.1) return 1.0; // Very close if (difference < 0.5) return 0.8; // Close if (difference < 1.0) return 0.6; // Moderate if (difference < 2.0) return 0.4; // Poor return 0.2; // Very poor } // If no start/end times, score based on reasonable hour values if (timeEntry.hours_worked > 0 && timeEntry.hours_worked <= 12) return 0.7; if (timeEntry.hours_worked > 12 && timeEntry.hours_worked <= 24) return 0.5; return 0.3; } private calculateDurationScore(timeEntry: TimeEntry): number { const hours = timeEntry.hours_worked; // Optimal range: 0.25 to 8 hours if (hours >= 0.25 && hours <= 8) return 1.0; if (hours > 8 && hours <= 12) return 0.8; if (hours > 12 && hours <= 16) return 0.6; if (hours > 16 && hours <= 24) return 0.4; if (hours > 0 && hours < 0.25) return 0.5; return 0.2; // Invalid (0 or >24 hours) } private calculateCategorizationScore(timeEntry: TimeEntry): number { let score = 0; if (timeEntry.ticket_id) score += 0.4; if (timeEntry.task_id) score += 0.3; if (timeEntry.project_id) score += 0.2; if (timeEntry.type !== undefined && timeEntry.type !== null) score += 0.1; return Math.min(score, 1.0); } private calculateNotesQualityScore(notes?: string | null): number { if (!notes) return 0; let score = 0; // Length factor if (notes.length > 20) score += 0.3; if (notes.length > 50) score += 0.2; if (notes.length > 100) score += 0.2; // Content factors if (/\b(did|completed|fixed|resolved|created|updated|configured|installed|debugged|tested)\b/i.test(notes)) { score += 0.2; } if (/\b(because|due to|issue|problem|error|requirement|request)\b/i.test(notes)) { score += 0.1; } return Math.min(score, 1.0); } private calculateTitleQualityScore(title?: string | null): number { if (!title) return 0; let score = 0; if (title.length > 5) score += 0.4; if (title.length > 10) score += 0.3; if (title.length <= 50) score += 0.2; // Not too long // Contains action words if (/\b(work|task|activity|meeting|call|support|development|testing|documentation)\b/i.test(title)) { score += 0.1; } return Math.min(score, 1.0); } private calculateInternalNotesScore(internalNotes?: string | null): number { if (!internalNotes) return 0.5; // Neutral score if not used let score = 0.5; // Base score for using internal notes if (internalNotes.length > 20) score += 0.3; if (internalNotes.length > 50) score += 0.2; return Math.min(score, 1.0); } private calculateTechnicalDetailScore(notes?: string | null, title?: string | null): number { const text = `${notes || ''} ${title || ''}`.toLowerCase(); let score = 0; // Technical keywords const technicalKeywords = [ 'api', 'database', 'server', 'code', 'script', 'config', ' firewall', 'network', 'security', 'backup', 'restore', 'install', 'update', 'debug', 'error', 'log', 'performance', 'optimization', 'migration' ]; const keywordCount = technicalKeywords.filter(keyword => text.includes(keyword)).length; score = Math.min(keywordCount * 0.2, 1.0); return score; } private calculateEntryDelayScore(timeEntry: TimeEntry): number { const entryDate = new Date(timeEntry.entry_date); const createdDate = new Date(timeEntry.created_at); const delayDays = (createdDate.getTime() - entryDate.getTime()) / (1000 * 60 * 60 * 24); if (delayDays <= 1) return 1.0; if (delayDays <= 3) return 0.8; if (delayDays <= 7) return 0.6; if (delayDays <= 14) return 0.4; if (delayDays <= 30) return 0.2; return 0.1; } private calculateBusinessHoursScore(timeEntry: TimeEntry): number { if (!timeEntry.start_date_time) return 0.7; // Neutral if no start time const startTime = new Date(timeEntry.start_date_time); const hour = startTime.getHours(); const dayOfWeek = startTime.getDay(); // Weekday (Mon-Fri) if (dayOfWeek >= 1 && dayOfWeek <= 5) { // Business hours (8 AM - 6 PM) if (hour >= 8 && hour <= 18) return 1.0; // Early evening (6 PM - 9 PM) if (hour > 18 && hour <= 21) return 0.8; // Early morning (6 AM - 8 AM) if (hour >= 6 && hour < 8) return 0.7; } // Weekend if (dayOfWeek === 0 || dayOfWeek === 6) { return 0.5; } return 0.3; // Late night or unusual hours } private calculateRegularityScore(timeEntry: TimeEntry): number { // This would ideally compare with user's historical pattern // For now, return a neutral score return 0.7; } private calculateApprovalTimelinessScore(timeEntry: TimeEntry): number { if (!timeEntry.approved) return 0.5; // Neutral if not approved if (!timeEntry.approved_date_time) return 0.3; const entryDate = new Date(timeEntry.created_at); const approvalDate = new Date(timeEntry.approved_date_time); const approvalDelayDays = (approvalDate.getTime() - entryDate.getTime()) / (1000 * 60 * 60 * 24); if (approvalDelayDays <= 1) return 1.0; if (approvalDelayDays <= 3) return 0.8; if (approvalDelayDays <= 7) return 0.6; if (approvalDelayDays <= 14) return 0.4; return 0.2; } /** * Generate insights for individual time entry */ private generateEntryInsights( timeEntry: TimeEntry, activityScore: ActivityScore, contentScore: ContentScore, timelinessScore: TimelinessScore ): AnalyticsInsight[] { const insights: AnalyticsInsight[] = []; // Low score insights if (activityScore.score < 0.5) { insights.push({ type: 'warning', category: 'activity', title: 'Low Activity Score', description: 'This time entry has incomplete information or irregular patterns.', recommendation: 'Add more details to improve tracking accuracy.', }); } if (contentScore.score < 0.5) { insights.push({ type: 'warning', category: 'content', title: 'Poor Documentation', description: 'Work description lacks detail or clarity.', recommendation: 'Include specific tasks, outcomes, and technical details.', }); } if (timelinessScore.score < 0.5) { insights.push({ type: 'warning', category: 'timeliness', title: 'Delayed Entry', description: 'Time entry was logged significantly after work was performed.', recommendation: 'Try to enter time within 24 hours of completion.', }); } // High score insights if (activityScore.score > 0.8 && contentScore.score > 0.8 && timelinessScore.score > 0.8) { insights.push({ type: 'success', category: 'overall', title: 'Excellent Time Entry', description: 'This is a well-documented and timely time entry.', recommendation: 'Keep up the good work!', }); } return insights; } /** * Generate aggregate insights for multiple time entries */ private generateAggregateInsights( timeEntries: TimeEntry[], analyses: TimeEntryAnalysis[] ): AnalyticsInsight[] { const insights: AnalyticsInsight[] = []; // Overall performance insights const avgOverallScore = this.calculateAverage(analyses.map(a => a.overallScore)); if (avgOverallScore > 0.8) { insights.push({ type: 'success', category: 'overall', title: 'High Quality Time Tracking', description: 'Overall time entry quality is excellent.', recommendation: 'Maintain current documentation standards.', }); } else if (avgOverallScore < 0.5) { insights.push({ type: 'warning', category: 'overall', title: 'Poor Time Entry Quality', description: 'Time entries need improvement in documentation and timeliness.', recommendation: 'Provide training on proper time entry practices.', }); } // Pattern insights const billableEntries = timeEntries.filter(e => e.billable).length; const billablePercentage = (billableEntries / timeEntries.length) * 100; if (billablePercentage < 50) { insights.push({ type: 'info', category: 'billing', title: 'Low Billable Percentage', description: `Only ${billablePercentage.toFixed(1)}% of entries are marked as billable.`, recommendation: 'Review billing categorization to ensure accurate invoicing.', }); } return insights; } /** * Analyze patterns in time entries */ private analyzePatterns(timeEntries: TimeEntry[]) { // Group by day of week const dayOfWeekPattern = new Array(7).fill(0); timeEntries.forEach(entry => { const dayOfWeek = new Date(entry.entry_date).getDay(); dayOfWeekPattern[dayOfWeek]++; }); // Group by hour const hourlyPattern = new Array(24).fill(0); timeEntries.forEach(entry => { if (entry.start_date_time) { const hour = new Date(entry.start_date_time).getHours(); hourlyPattern[hour]++; } }); return { dayOfWeek: dayOfWeekPattern, hourly: hourlyPattern, }; } /** * Analyze trends in time entries */ private analyzeTrends(timeEntries: TimeEntry[]) { // Sort by date const sortedEntries = [...timeEntries].sort((a, b) => new Date(a.entry_date).getTime() - new Date(b.entry_date).getTime() ); // Calculate weekly trends const weeklyTrends: { week: Date; hours: number; entries: number }[] = []; const weeklyMap = new Map(); sortedEntries.forEach(entry => { const date = new Date(entry.entry_date); const weekStart = new Date(date.setDate(date.getDate() - date.getDay())); const weekKey = weekStart.toISOString().split('T')[0]; if (!weeklyMap.has(weekKey)) { weeklyMap.set(weekKey, { hours: 0, entries: 0 }); } const week = weeklyMap.get(weekKey)!; week.hours += entry.hours_worked; week.entries++; }); weeklyMap.forEach((data, weekKey) => { weeklyTrends.push({ week: new Date(weekKey), hours: data.hours, entries: data.entries, }); }); return { weekly: weeklyTrends.sort((a, b) => a.week.getTime() - b.week.getTime()), }; } /** * Utility methods */ private calculateAverage(values: number[]): number { if (values.length === 0) return 0; return values.reduce((sum, value) => sum + value, 0) / values.length; } private createEmptyAnalysis(): AggregateAnalysis { return { totalEntries: 0, totalHours: 0, averageHoursPerEntry: 0, dateRange: { earliest: new Date(), latest: new Date(), }, scores: { activity: 0, content: 0, timeliness: 0, overall: 0, }, insights: [], patterns: { dayOfWeek: new Array(7).fill(0), hourly: new Array(24).fill(0), }, trends: { weekly: [], }, analyzedAt: new Date(), }; } } // Create singleton instance export const analyticsEngine = new AnalyticsEngine();