UNPKG

@aot-tech/clockify-mcp-server

Version:

MCP Server for Clockify time tracking integration with AI tools

789 lines (788 loc) 34.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.multiUserTimeEntriestool = exports.teamProductivityReportTool = exports.bulkUserTimeAnalyticsTool = void 0; const zod_1 = require("zod"); const entries_1 = require("../clockify-sdk/entries"); const users_1 = require("../clockify-sdk/users"); const projects_1 = require("../clockify-sdk/projects"); const reports_1 = require("../clockify-sdk/reports"); const isLocalDevelopment = process.env.NODE_ENV === 'development' || process.env.DEBUG_MODE === 'true'; function debugLog(message, data) { if (!isLocalDevelopment) { return; } const timestamp = new Date().toISOString(); if (data) { console.log(`[CLOCKIFY-MCP-DEBUG] [${timestamp}] ${message}`, data); } else { console.log(`[CLOCKIFY-MCP-DEBUG] [${timestamp}] ${message}`); } } const bulkCache = new Map(); const CACHE_DURATION = 5 * 60 * 1000; const invalidateUserCache = (userId) => { for (const [key, value] of bulkCache.entries()) { if (key.includes(userId)) { bulkCache.delete(key); } } }; const CACHE_VERSION = "v1"; function getCacheKey(prefix, params) { const normalizedParams = {}; normalizedParams.workspaceId = params.workspaceId; normalizedParams.userIds = Array.isArray(params.userIds) ? [...params.userIds].sort() : params.userIds; normalizedParams.startDate = params.startDate instanceof Date ? params.startDate.toISOString().split("T")[0] : params.startDate; normalizedParams.endDate = params.endDate instanceof Date ? params.endDate.toISOString().split("T")[0] : params.endDate; normalizedParams.includeProjects = Boolean(params.includeProjects); normalizedParams.includeCorrelation = Boolean(params.includeCorrelation); normalizedParams.aggregateBy = params.aggregateBy || "user"; if (params.projectIds && Array.isArray(params.projectIds)) { normalizedParams.projectIds = [...params.projectIds].sort(); } if (params.includeDetailed !== undefined) { normalizedParams.includeDetailed = Boolean(params.includeDetailed); } const sortedParams = Object.keys(normalizedParams) .sort() .reduce((result, key) => { result[key] = normalizedParams[key]; return result; }, {}); const cacheKey = `${CACHE_VERSION}_${prefix}_${JSON.stringify(sortedParams)}`; debugLog(`Generated cache key for ${prefix}:`, { originalParams: params, normalizedParams: sortedParams, cacheKey: cacheKey.substring(0, 200) + "...", }); return cacheKey; } function isValidCache(key) { const cached = bulkCache.get(key); const isValid = cached !== undefined && Date.now() - cached.timestamp < CACHE_DURATION; const age = cached ? Date.now() - cached.timestamp : 0; debugLog(`Cache validation for key: ${key.substring(0, 100)}...`, { exists: cached !== undefined, age: age, maxAge: CACHE_DURATION, isValid: isValid, }); return isValid; } function setCache(key, data) { const timestamp = Date.now(); bulkCache.set(key, { data, timestamp }); debugLog(`Cache set for key: ${key.substring(0, 100)}...`, { timestamp: timestamp, cacheSize: bulkCache.size, }); } function getCache(key) { const cached = bulkCache.get(key); debugLog(`Cache get for key: ${key.substring(0, 100)}...`, { found: cached !== undefined, age: cached ? Date.now() - cached.timestamp : 0, }); return cached?.data; } exports.bulkUserTimeAnalyticsTool = { name: "clockify_bulk_user_time_analytics", description: "OPTIMIZED: Get comprehensive time analytics for multiple users in one call. Ideal for team productivity analysis and performance ranking.", parameters: { workspaceId: zod_1.z.string().describe("Workspace ID"), userIds: zod_1.z.array(zod_1.z.string()).describe("Array of user IDs to analyze"), startDate: zod_1.z.coerce.date().describe("Start date for analysis"), endDate: zod_1.z.coerce.date().describe("End date for analysis"), includeProjects: zod_1.z .boolean() .optional() .default(true) .describe("Include project breakdowns"), includeCorrelation: zod_1.z .boolean() .optional() .default(true) .describe("Include correlation data for cross-platform analysis"), aggregateBy: zod_1.z .enum(["user", "project", "day"]) .optional() .default("user") .describe("How to aggregate the results"), }, handler: async (params) => { try { const cacheKey = getCacheKey("bulk_user_time_analytics", params); debugLog("Bulk User Time Analytics - Parameters:", { workspaceId: params.workspaceId, userIds: params.userIds, startDate: params.startDate.toISOString(), endDate: params.endDate.toISOString(), includeProjects: params.includeProjects, includeCorrelation: params.includeCorrelation, aggregateBy: params.aggregateBy, }); debugLog("Cache key generated:", cacheKey.substring(0, 200) + "..."); if (isValidCache(cacheKey)) { debugLog("Using cached results for bulk user time analytics"); return { content: [ { type: "text", text: JSON.stringify(getCache(cacheKey), null, 2), }, ], }; } debugLog("Cache miss - executing fresh API calls for bulk user time analytics"); debugLog(`Processing ${params.userIds.length} users for time analytics`); const userPromises = params.userIds.map(async (userId) => { debugLog(`Fetching time entries for user: ${userId}`); const timeEntriesResponse = await entries_1.entriesService.find({ workspaceId: params.workspaceId, userId: userId, start: params.startDate, end: params.endDate, }); const timeEntries = timeEntriesResponse.entries.data || []; debugLog(`User ${userId}: Found ${timeEntries.length} time entries`); const totalDuration = timeEntries.reduce((sum, entry) => { const duration = calculateDuration(entry.timeInterval.start, entry.timeInterval.end); return sum + duration; }, 0); const billableTime = timeEntries .filter((entry) => entry.billable) .reduce((sum, entry) => { const duration = calculateDuration(entry.timeInterval.start, entry.timeInterval.end); return sum + duration; }, 0); const projectBreakdown = params.includeProjects ? groupByProject(timeEntries) : null; const dailyBreakdown = groupByDate(timeEntries); const userInfo = await getUserInfo(userId, params.workspaceId); const metrics = { totalHours: totalDuration / 3600, billableHours: billableTime / 3600, nonBillableHours: (totalDuration - billableTime) / 3600, billablePercentage: totalDuration > 0 ? (billableTime / totalDuration) * 100 : 0, totalSessions: timeEntries.length, avgSessionDuration: timeEntries.length > 0 ? totalDuration / timeEntries.length / 3600 : 0, workingDays: Object.keys(dailyBreakdown).length, avgHoursPerDay: Object.keys(dailyBreakdown).length > 0 ? totalDuration / Object.keys(dailyBreakdown).length / 3600 : 0, }; debugLog(`User ${userId} metrics:`, { totalHours: metrics.totalHours, billableHours: metrics.billableHours, billablePercentage: metrics.billablePercentage, totalSessions: metrics.totalSessions, }); return { userId, userInfo, metrics, projectBreakdown, dailyBreakdown, correlationData: params.includeCorrelation ? { clockifyUserId: userId, clockifyUserName: userInfo.name, clockifyUserEmail: userInfo.email, jiraCorrelationKey: (userInfo.email || userInfo.name) .toLowerCase() .replace(/\s+/g, "."), projectIds: [ ...new Set(timeEntries .map((entry) => entry.projectId) .filter(Boolean)), ], timeframe: { startDate: params.startDate.toISOString(), endDate: params.endDate.toISOString(), totalDays: Math.ceil((params.endDate.getTime() - params.startDate.getTime()) / (1000 * 60 * 60 * 24)), }, } : null, }; }); const userAnalytics = await Promise.all(userPromises); debugLog(`Completed processing ${userAnalytics.length} users for time analytics`); const teamSummary = { totalUsers: userAnalytics.length, totalHours: userAnalytics.reduce((sum, user) => sum + user.metrics.totalHours, 0), totalBillableHours: userAnalytics.reduce((sum, user) => sum + user.metrics.billableHours, 0), avgHoursPerUser: userAnalytics.reduce((sum, user) => sum + user.metrics.totalHours, 0) / userAnalytics.length, avgBillablePercentage: userAnalytics.reduce((sum, user) => sum + user.metrics.billablePercentage, 0) / userAnalytics.length, totalSessions: userAnalytics.reduce((sum, user) => sum + user.metrics.totalSessions, 0), topPerformer: userAnalytics.reduce((best, current) => current.metrics.totalHours > best.metrics.totalHours ? current : best), }; const rankedUsers = userAnalytics .sort((a, b) => b.metrics.totalHours - a.metrics.totalHours) .map((user, index) => ({ ...user, rank: index + 1 })); const insights = generateTeamInsights(userAnalytics, teamSummary); const result = { summary: teamSummary, users: rankedUsers, insights, aggregation: params.aggregateBy, timeframe: { startDate: params.startDate.toISOString(), endDate: params.endDate.toISOString(), }, metadata: { workspaceId: params.workspaceId, usersAnalyzed: params.userIds.length, includeProjects: params.includeProjects, includeCorrelation: params.includeCorrelation, generatedAt: new Date().toISOString(), }, }; setCache(cacheKey, result); debugLog("Result cached successfully for bulk user time analytics"); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error in bulk user time analytics: ${error instanceof Error ? error.message : "Unknown error"}`, }, ], }; } }, }; exports.teamProductivityReportTool = { name: "clockify_team_productivity_report", description: "OPTIMIZED: Generate comprehensive team productivity report with cross-platform correlation data and performance metrics.", parameters: { workspaceId: zod_1.z.string().describe("Workspace ID"), projectIds: zod_1.z .array(zod_1.z.string()) .optional() .describe("Optional array of project IDs to filter by"), userIds: zod_1.z .array(zod_1.z.string()) .optional() .describe("Optional array of user IDs to filter by (if not provided, gets all users)"), startDate: zod_1.z.coerce.date().describe("Start date for the report"), endDate: zod_1.z.coerce.date().describe("End date for the report"), includeDetailed: zod_1.z .boolean() .default(true) .describe("Include detailed breakdowns"), includeCorrelation: zod_1.z .boolean() .default(true) .describe("Include correlation data for cross-platform analysis"), }, handler: async (params) => { try { const cacheKey = getCacheKey("team_productivity_report", params); debugLog("Team Productivity Report - Parameters:", { workspaceId: params.workspaceId, projectIds: params.projectIds, userIds: params.userIds, startDate: params.startDate.toISOString(), endDate: params.endDate.toISOString(), includeDetailed: params.includeDetailed, includeCorrelation: params.includeCorrelation, }); debugLog("Cache key generated:", cacheKey.substring(0, 200) + "..."); if (isValidCache(cacheKey)) { debugLog("Using cached results for team productivity report"); return { content: [ { type: "text", text: JSON.stringify(getCache(cacheKey), null, 2), }, ], }; } debugLog("Cache miss - executing fresh API calls for team productivity report"); debugLog("Generating summary report from Clockify API"); const summaryResponse = await reports_1.reportsService.getSummaryReport({ workspaceId: params.workspaceId, dateRangeStart: params.startDate, dateRangeEnd: params.endDate, projects: params.projectIds, groups: ["USER", "TIMEENTRY"] }); const summaryData = summaryResponse.data || summaryResponse; debugLog("Summary report data received from Clockify API"); const result = summaryData; setCache(cacheKey, result); debugLog("Result cached successfully for team productivity report"); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error in team productivity report: ${error instanceof Error ? error.message : "Unknown error"}`, }, ], }; } }, }; exports.multiUserTimeEntriestool = { name: "clockify_multi_user_time_entries", description: "OPTIMIZED: Get time entries for multiple users simultaneously with batch processing and aggregation options.", parameters: { workspaceId: zod_1.z.string().describe("Workspace ID"), userIds: zod_1.z.array(zod_1.z.string()).describe("Array of user IDs"), startDate: zod_1.z.coerce.date().describe("Start date for time entries"), endDate: zod_1.z.coerce.date().describe("End date for time entries"), aggregateBy: zod_1.z .enum(["user", "project", "day"]) .default("user") .describe("How to aggregate results"), includeProjects: zod_1.z .boolean() .default(true) .describe("Include project information"), batchSize: zod_1.z .number() .default(5) .describe("Number of users to process in parallel (default: 5)"), }, handler: async (params) => { try { const cacheKey = getCacheKey("multi_user_time_entries", params); if (isValidCache(cacheKey)) { return { content: [ { type: "text", text: JSON.stringify(getCache(cacheKey), null, 2), }, ], }; } const batchSize = params.batchSize; const userBatches = []; for (let i = 0; i < params.userIds.length; i += batchSize) { userBatches.push(params.userIds.slice(i, i + batchSize)); } const allUserData = []; for (const batch of userBatches) { const batchPromises = batch.map(async (userId) => { const timeEntriesResponse = await entries_1.entriesService.find({ workspaceId: params.workspaceId, userId: userId, start: params.startDate, end: params.endDate, }); const timeEntries = timeEntriesResponse.entries.data || []; const userInfo = await getUserInfo(userId, params.workspaceId); return { userId, userInfo, timeEntries, summary: { totalEntries: timeEntries.length, totalHours: timeEntries.reduce((sum, entry) => { const duration = calculateDuration(entry.timeInterval.start, entry.timeInterval.end); return sum + duration; }, 0) / 3600, billableHours: timeEntries .filter((entry) => entry.billable) .reduce((sum, entry) => { const duration = calculateDuration(entry.timeInterval.start, entry.timeInterval.end); return sum + duration; }, 0) / 3600, projectCount: new Set(timeEntries.map((entry) => entry.projectId).filter(Boolean)).size, dateRange: { startDate: params.startDate.toISOString(), endDate: params.endDate.toISOString(), }, }, }; }); const batchResults = await Promise.all(batchPromises); allUserData.push(...batchResults); } let aggregatedData = {}; switch (params.aggregateBy) { case "user": aggregatedData = aggregateByUser(allUserData); break; case "project": aggregatedData = await aggregateByProject(allUserData, params.workspaceId, params.includeProjects); break; case "day": aggregatedData = aggregateByDay(allUserData); break; } const overallSummary = { totalUsers: allUserData.length, totalEntries: allUserData.reduce((sum, user) => sum + user.summary.totalEntries, 0), totalHours: allUserData.reduce((sum, user) => sum + user.summary.totalHours, 0), totalBillableHours: allUserData.reduce((sum, user) => sum + user.summary.billableHours, 0), avgHoursPerUser: allUserData.reduce((sum, user) => sum + user.summary.totalHours, 0) / allUserData.length, totalProjectsInvolved: new Set(allUserData.flatMap((user) => user.timeEntries .map((entry) => entry.projectId) .filter(Boolean))).size, dateRange: { startDate: params.startDate.toISOString(), endDate: params.endDate.toISOString(), }, }; const result = { overallSummary, aggregatedData, userDetails: allUserData, aggregationType: params.aggregateBy, metadata: { workspaceId: params.workspaceId, batchSize: params.batchSize, processedBatches: userBatches.length, includeProjects: params.includeProjects, generatedAt: new Date().toISOString(), }, }; setCache(cacheKey, result); return { content: [ { type: "text", text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Error in multi-user time entries: ${error instanceof Error ? error.message : "Unknown error"}`, }, ], }; } }, }; function calculateDuration(start, end) { if (!end) return 0; return (new Date(end).getTime() - new Date(start).getTime()) / 1000; } function calculateConsistencyScore(entries) { if (entries.length === 0) return 0; const dailyTotals = groupByDate(entries); const dailyHours = Object.values(dailyTotals).map((total) => total / 3600); if (dailyHours.length <= 1) return 100; const mean = dailyHours.reduce((sum, hours) => sum + hours, 0) / dailyHours.length; const variance = dailyHours.reduce((sum, hours) => sum + Math.pow(hours - mean, 2), 0) / dailyHours.length; const standardDeviation = Math.sqrt(variance); return Math.max(0, 100 - standardDeviation * 20); } function calculateEfficiency(entries) { if (entries.length === 0) return 0; const totalDuration = entries.reduce((sum, entry) => { return (sum + calculateDuration(entry.timeInterval.start, entry.timeInterval.end)); }, 0); const billableDuration = entries .filter((entry) => entry.billable) .reduce((sum, entry) => { return (sum + calculateDuration(entry.timeInterval.start, entry.timeInterval.end)); }, 0); return totalDuration > 0 ? (billableDuration / totalDuration) * 100 : 0; } function calculateConsistency(dailyBreakdown) { const days = Object.keys(dailyBreakdown); if (days.length <= 1) return 100; const hours = Object.values(dailyBreakdown).map((seconds) => seconds / 3600); const mean = hours.reduce((sum, h) => sum + h, 0) / hours.length; const variance = hours.reduce((sum, h) => sum + Math.pow(h - mean, 2), 0) / hours.length; const standardDeviation = Math.sqrt(variance); return Math.max(0, 100 - standardDeviation * 15); } function calculateTimeDistribution(entries, totalTime) { const entryTime = entries.reduce((sum, entry) => { return (sum + calculateDuration(entry.timeInterval.start, entry.timeInterval.end)); }, 0); return totalTime > 0 ? (entryTime / totalTime) * 100 : 0; } function groupByProject(entries) { const grouped = {}; entries.forEach((entry) => { const projectId = entry.projectId || "no-project"; if (!grouped[projectId]) { grouped[projectId] = { projectId, projectName: entry.project?.name || "No Project", totalTime: 0, billableTime: 0, entries: [], }; } const duration = calculateDuration(entry.timeInterval.start, entry.timeInterval.end); grouped[projectId].totalTime += duration; if (entry.billable) { grouped[projectId].billableTime += duration; } grouped[projectId].entries.push(entry); }); Object.values(grouped).forEach((project) => { project.totalHours = project.totalTime / 3600; project.billableHours = project.billableTime / 3600; project.billablePercentage = project.totalTime > 0 ? (project.billableTime / project.totalTime) * 100 : 0; }); return grouped; } function groupByDate(entries) { const grouped = {}; entries.forEach((entry) => { const date = new Date(entry.timeInterval.start).toISOString().split("T")[0]; const duration = calculateDuration(entry.timeInterval.start, entry.timeInterval.end); grouped[date] = (grouped[date] || 0) + duration; }); return grouped; } function aggregateByUser(userData) { const result = {}; userData.forEach((user) => { result[user.userId] = { userInfo: user.userInfo, summary: user.summary, projectBreakdown: groupByProject(user.timeEntries), dailyBreakdown: groupByDate(user.timeEntries), }; }); return result; } async function aggregateByProject(userData, workspaceId, includeProjects) { const projectAggregation = {}; userData.forEach((user) => { user.timeEntries.forEach((entry) => { const projectId = entry.projectId || "no-project"; if (!projectAggregation[projectId]) { projectAggregation[projectId] = { projectId, projectName: entry.project?.name || "No Project", totalTime: 0, billableTime: 0, users: new Set(), entries: [], }; } const duration = calculateDuration(entry.timeInterval.start, entry.timeInterval.end); projectAggregation[projectId].totalTime += duration; if (entry.billable) { projectAggregation[projectId].billableTime += duration; } projectAggregation[projectId].users.add(user.userId); projectAggregation[projectId].entries.push(entry); }); }); const result = {}; for (const [projectId, data] of Object.entries(projectAggregation)) { result[projectId] = { projectId: data.projectId, projectName: data.projectName, totalHours: data.totalTime / 3600, billableHours: data.billableTime / 3600, billablePercentage: data.totalTime > 0 ? (data.billableTime / data.totalTime) * 100 : 0, userCount: data.users.size, entryCount: data.entries.length, users: Array.from(data.users), }; } return result; } function aggregateByDay(userData) { const dailyAggregation = {}; userData.forEach((user) => { user.timeEntries.forEach((entry) => { const date = new Date(entry.timeInterval.start) .toISOString() .split("T")[0]; if (!dailyAggregation[date]) { dailyAggregation[date] = { date, totalTime: 0, billableTime: 0, users: new Set(), entries: [], }; } const duration = calculateDuration(entry.timeInterval.start, entry.timeInterval.end); dailyAggregation[date].totalTime += duration; if (entry.billable) { dailyAggregation[date].billableTime += duration; } dailyAggregation[date].users.add(user.userId); dailyAggregation[date].entries.push(entry); }); }); const result = {}; for (const [date, data] of Object.entries(dailyAggregation)) { result[date] = { date: data.date, totalHours: data.totalTime / 3600, billableHours: data.billableTime / 3600, billablePercentage: data.totalTime > 0 ? (data.billableTime / data.totalTime) * 100 : 0, userCount: data.users.size, entryCount: data.entries.length, avgHoursPerUser: data.totalTime / data.users.size / 3600, }; } return result; } async function getUserInfo(userId, workspaceId) { try { const cacheKey = `user_info_${userId}`; if (isValidCache(cacheKey)) { return getCache(cacheKey); } const usersResponse = await users_1.usersService.getAllUsers({ workspaceId }); const users = usersResponse.data; debugLog(`getUserInfo: Retrieved ${users?.length || 0} users for workspace ${workspaceId}`); const user = users?.find((u) => u.id === userId); if (user) { debugLog(`getUserInfo: Found user ${userId}:`, { id: user.id, name: user.name, email: user.email }); } else { debugLog(`getUserInfo: User ${userId} not found in workspace ${workspaceId}`); } const userInfo = user ? { id: user.id, name: user.name || "Unknown User", email: user.email || null } : { id: userId, name: "Unknown User", email: null }; setCache(cacheKey, userInfo); return userInfo; } catch (error) { debugLog(`getUserInfo: Error fetching user ${userId}:`, error); return { id: userId, name: "Unknown User", email: null }; } } async function getProjectInfo(projectId, workspaceId) { try { const cacheKey = `project_info_${projectId}`; if (isValidCache(cacheKey)) { return getCache(cacheKey); } const projectsResponse = await projects_1.projectsService.fetchAll(workspaceId, ""); const projects = projectsResponse.data; debugLog(`getProjectInfo: Retrieved ${projects?.length || 0} projects for workspace ${workspaceId}`); const project = projects?.find((p) => p.id === projectId); if (project) { debugLog(`getProjectInfo: Found project ${projectId}:`, { id: project.id, name: project.name }); } else { debugLog(`getProjectInfo: Project ${projectId} not found in workspace ${workspaceId}`); } const projectInfo = project ? { id: project.id, name: project.name || "Unknown Project" } : { id: projectId, name: "Unknown Project" }; setCache(cacheKey, projectInfo); return projectInfo; } catch (error) { debugLog(`getProjectInfo: Error fetching project ${projectId}:`, error); return { id: projectId, name: "Unknown Project" }; } } function generateTeamInsights(userAnalytics, teamSummary) { const insights = []; if (teamSummary.topPerformer) { insights.push(`Top performer: ${teamSummary.topPerformer.userInfo.name} with ${teamSummary.topPerformer.metrics.totalHours.toFixed(1)} hours logged`); } if (teamSummary.avgBillablePercentage > 0) { insights.push(`Team average billable percentage: ${teamSummary.avgBillablePercentage.toFixed(1)}%`); } const highConsistency = userAnalytics.filter((user) => user.metrics.consistency > 80); if (highConsistency.length > 0) { insights.push(`${highConsistency.length} team members show high consistency (>80%)`); } const highProductivity = userAnalytics.filter((user) => user.metrics.billablePercentage > 75); if (highProductivity.length > 0) { insights.push(`${highProductivity.length} team members have high productivity scores (>75)`); } if (teamSummary.avgSessionDuration > 0) { insights.push(`Average session duration: ${teamSummary.avgSessionDuration.toFixed(1)} hours`); } return insights; } function generateTeamProductivityInsights(userAnalytics, projectAnalytics, teamSummary) { const insights = []; insights.push(`Team logged ${teamSummary.totalHours.toFixed(1)} hours across ${teamSummary.totalProjects} projects`); if (teamSummary.topPerformer) { insights.push(`Most productive: ${teamSummary.topPerformer.userInfo.name} (${teamSummary.topPerformer.metrics.totalHours.toFixed(1)} hours)`); } if (teamSummary.mostActiveProject) { insights.push(`Most active project: ${teamSummary.mostActiveProject.projectInfo.name} (${teamSummary.mostActiveProject.metrics.totalHours.toFixed(1)} hours)`); } const billableRate = teamSummary.avgBillablePercentage; if (billableRate > 80) { insights.push(`Excellent billable rate: ${billableRate.toFixed(1)}%`); } else if (billableRate > 60) { insights.push(`Good billable rate: ${billableRate.toFixed(1)}%`); } else { insights.push(`Billable rate needs improvement: ${billableRate.toFixed(1)}%`); } const highPerformers = userAnalytics.filter((user) => user.metrics.billablePercentage > 75); if (highPerformers.length > 0) { insights.push(`${highPerformers.length} high performers with billable efficiency >75%`); } const highEfficiency = userAnalytics.filter((user) => user.metrics.billablePercentage > 75); if (highEfficiency.length > 0) { insights.push(`${highEfficiency.length} team members have high billable efficiency (>75%)`); } return insights; }