- 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
604 lines
19 KiB
TypeScript
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();
|