wulf-pulse/lib/services/analytics-engine.ts
root 6eee14f8af Add comprehensive admin features and multi-system integration
- Add admin dashboard with sync controls and data browser
- Implement RMM, Auvik, and Addigy organization mappings
- Add chunked ticket sync with progress tracking
- Implement entity sync service with rate limiting
- Add analytics engine and performance optimizer
- Create data browser for all PSA entities
- Add navigation components and UI improvements
- Implement background processing and sync services
- Add comprehensive documentation and migration scripts
- Update configuration items with multi-system support
- Enhance contact management and purchase history
- Add issue type assignment and LLM analyzer
- Improve error handling and logging utilities
2025-11-19 14:18:16 -05:00

604 lines
19 KiB
TypeScript

/**
* 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<string, { hours: number; entries: number }>();
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();