/** * @fileoverview Dashboard components for Task Master CLI * Displays project statistics and dependency information */ import chalk from 'chalk'; import boxen from 'boxen'; import type { Task, TaskPriority } from '@tm/core/types'; /** * Statistics for task collection */ export interface TaskStatistics { total: number; done: number; inProgress: number; pending: number; blocked: number; deferred: number; cancelled: number; review?: number; completionPercentage: number; } /** * Statistics for dependencies */ export interface DependencyStatistics { tasksWithNoDeps: number; tasksReadyToWork: number; tasksBlockedByDeps: number; mostDependedOnTaskId?: number; mostDependedOnCount?: number; avgDependenciesPerTask: number; } /** * Next task information */ export interface NextTaskInfo { id: string | number; title: string; priority?: TaskPriority; dependencies?: (string | number)[]; complexity?: number | string; } /** * Status breakdown for progress bars */ export interface StatusBreakdown { 'in-progress'?: number; pending?: number; blocked?: number; deferred?: number; cancelled?: number; review?: number; } /** * Create a progress bar with color-coded status segments */ function createProgressBar( completionPercentage: number, width: number = 30, statusBreakdown?: StatusBreakdown ): string { // If no breakdown provided, use simple green bar if (!statusBreakdown) { const filled = Math.round((completionPercentage / 100) * width); const empty = width - filled; return chalk.green('█').repeat(filled) + chalk.gray('░').repeat(empty); } // Build the bar with different colored sections // Order matches the status display: Done, Cancelled, Deferred, In Progress, Review, Pending, Blocked let bar = ''; let charsUsed = 0; // 1. Green filled blocks for completed tasks (done) const completedChars = Math.round((completionPercentage / 100) * width); if (completedChars > 0) { bar += chalk.green('█').repeat(completedChars); charsUsed += completedChars; } // 2. Gray filled blocks for cancelled (won't be done) if (statusBreakdown.cancelled && charsUsed < width) { const cancelledChars = Math.round( (statusBreakdown.cancelled / 100) * width ); const actualChars = Math.min(cancelledChars, width - charsUsed); if (actualChars > 0) { bar += chalk.gray('█').repeat(actualChars); charsUsed += actualChars; } } // 3. Gray filled blocks for deferred (won't be done now) if (statusBreakdown.deferred && charsUsed < width) { const deferredChars = Math.round((statusBreakdown.deferred / 100) * width); const actualChars = Math.min(deferredChars, width - charsUsed); if (actualChars > 0) { bar += chalk.gray('█').repeat(actualChars); charsUsed += actualChars; } } // 4. Blue filled blocks for in-progress (actively working) if (statusBreakdown['in-progress'] && charsUsed < width) { const inProgressChars = Math.round( (statusBreakdown['in-progress'] / 100) * width ); const actualChars = Math.min(inProgressChars, width - charsUsed); if (actualChars > 0) { bar += chalk.blue('█').repeat(actualChars); charsUsed += actualChars; } } // 5. Magenta empty blocks for review (almost done) if (statusBreakdown.review && charsUsed < width) { const reviewChars = Math.round((statusBreakdown.review / 100) * width); const actualChars = Math.min(reviewChars, width - charsUsed); if (actualChars > 0) { bar += chalk.magenta('░').repeat(actualChars); charsUsed += actualChars; } } // 6. Yellow empty blocks for pending (ready to start) if (statusBreakdown.pending && charsUsed < width) { const pendingChars = Math.round((statusBreakdown.pending / 100) * width); const actualChars = Math.min(pendingChars, width - charsUsed); if (actualChars > 0) { bar += chalk.yellow('░').repeat(actualChars); charsUsed += actualChars; } } // 7. Red empty blocks for blocked (can't start yet) if (statusBreakdown.blocked && charsUsed < width) { const blockedChars = Math.round((statusBreakdown.blocked / 100) * width); const actualChars = Math.min(blockedChars, width - charsUsed); if (actualChars > 0) { bar += chalk.red('░').repeat(actualChars); charsUsed += actualChars; } } // Fill any remaining space with gray empty yellow blocks if (charsUsed < width) { bar += chalk.yellow('░').repeat(width - charsUsed); } return bar; } /** * Calculate task statistics from a list of tasks */ export function calculateTaskStatistics(tasks: Task[]): TaskStatistics { const stats: TaskStatistics = { total: tasks.length, done: 0, inProgress: 0, pending: 0, blocked: 0, deferred: 0, cancelled: 0, review: 0, completionPercentage: 0 }; tasks.forEach((task) => { switch (task.status) { case 'done': stats.done++; break; case 'in-progress': stats.inProgress++; break; case 'pending': stats.pending++; break; case 'blocked': stats.blocked++; break; case 'deferred': stats.deferred++; break; case 'cancelled': stats.cancelled++; break; case 'review': stats.review = (stats.review || 0) + 1; break; } }); stats.completionPercentage = stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0; return stats; } /** * Calculate subtask statistics from tasks */ export function calculateSubtaskStatistics(tasks: Task[]): TaskStatistics { const stats: TaskStatistics = { total: 0, done: 0, inProgress: 0, pending: 0, blocked: 0, deferred: 0, cancelled: 0, review: 0, completionPercentage: 0 }; tasks.forEach((task) => { if (task.subtasks && task.subtasks.length > 0) { task.subtasks.forEach((subtask) => { stats.total++; switch (subtask.status) { case 'done': stats.done++; break; case 'in-progress': stats.inProgress++; break; case 'pending': stats.pending++; break; case 'blocked': stats.blocked++; break; case 'deferred': stats.deferred++; break; case 'cancelled': stats.cancelled++; break; case 'review': stats.review = (stats.review || 0) + 1; break; } }); } }); stats.completionPercentage = stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0; return stats; } /** * Calculate dependency statistics */ export function calculateDependencyStatistics( tasks: Task[] ): DependencyStatistics { const completedTaskIds = new Set( tasks.filter((t) => t.status === 'done').map((t) => t.id) ); const tasksWithNoDeps = tasks.filter( (t) => t.status !== 'done' && (!t.dependencies || t.dependencies.length === 0) ).length; const tasksWithAllDepsSatisfied = tasks.filter( (t) => t.status !== 'done' && t.dependencies && t.dependencies.length > 0 && t.dependencies.every((depId) => completedTaskIds.has(depId)) ).length; const tasksBlockedByDeps = tasks.filter( (t) => t.status !== 'done' && t.dependencies && t.dependencies.length > 0 && !t.dependencies.every((depId) => completedTaskIds.has(depId)) ).length; // Calculate most depended-on task const dependencyCount: Record = {}; tasks.forEach((task) => { if (task.dependencies && task.dependencies.length > 0) { task.dependencies.forEach((depId) => { const key = String(depId); dependencyCount[key] = (dependencyCount[key] || 0) + 1; }); } }); let mostDependedOnTaskId: number | undefined; let mostDependedOnCount = 0; for (const [taskId, count] of Object.entries(dependencyCount)) { if (count > mostDependedOnCount) { mostDependedOnCount = count; mostDependedOnTaskId = parseInt(taskId); } } // Calculate average dependencies const totalDependencies = tasks.reduce( (sum, task) => sum + (task.dependencies ? task.dependencies.length : 0), 0 ); const avgDependenciesPerTask = tasks.length > 0 ? totalDependencies / tasks.length : 0; return { tasksWithNoDeps, tasksReadyToWork: tasksWithNoDeps + tasksWithAllDepsSatisfied, tasksBlockedByDeps, mostDependedOnTaskId, mostDependedOnCount, avgDependenciesPerTask }; } /** * Get priority counts */ export function getPriorityBreakdown( tasks: Task[] ): Record { const breakdown: Record = { critical: 0, high: 0, medium: 0, low: 0 }; tasks.forEach((task) => { const priority = task.priority || 'medium'; breakdown[priority]++; }); return breakdown; } /** * Calculate status breakdown as percentages */ function calculateStatusBreakdown(stats: TaskStatistics): StatusBreakdown { if (stats.total === 0) return {}; return { 'in-progress': (stats.inProgress / stats.total) * 100, pending: (stats.pending / stats.total) * 100, blocked: (stats.blocked / stats.total) * 100, deferred: (stats.deferred / stats.total) * 100, cancelled: (stats.cancelled / stats.total) * 100, review: ((stats.review || 0) / stats.total) * 100 }; } /** * Format status counts in the correct order with colors * @param stats - The statistics object containing counts * @param isSubtask - Whether this is for subtasks (affects "Done" vs "Completed" label) */ function formatStatusLine( stats: TaskStatistics, isSubtask: boolean = false ): string { const parts: string[] = []; // Order: Done, Cancelled, Deferred, In Progress, Review, Pending, Blocked if (isSubtask) { parts.push(`Completed: ${chalk.green(`${stats.done}/${stats.total}`)}`); } else { parts.push(`Done: ${chalk.green(stats.done)}`); } parts.push(`Cancelled: ${chalk.gray(stats.cancelled)}`); parts.push(`Deferred: ${chalk.gray(stats.deferred)}`); // Add line break for second row const firstLine = parts.join(' '); parts.length = 0; parts.push(`In Progress: ${chalk.blue(stats.inProgress)}`); parts.push(`Review: ${chalk.magenta(stats.review || 0)}`); parts.push(`Pending: ${chalk.yellow(stats.pending)}`); parts.push(`Blocked: ${chalk.red(stats.blocked)}`); const secondLine = parts.join(' '); return firstLine + '\n' + secondLine; } /** * Display the project dashboard box */ export function displayProjectDashboard( taskStats: TaskStatistics, subtaskStats: TaskStatistics, priorityBreakdown: Record ): string { // Calculate status breakdowns using the helper function const taskStatusBreakdown = calculateStatusBreakdown(taskStats); const subtaskStatusBreakdown = calculateStatusBreakdown(subtaskStats); // Create progress bars with the breakdowns const taskProgressBar = createProgressBar( taskStats.completionPercentage, 30, taskStatusBreakdown ); const subtaskProgressBar = createProgressBar( subtaskStats.completionPercentage, 30, subtaskStatusBreakdown ); const taskPercentage = `${taskStats.completionPercentage}% ${taskStats.done}/${taskStats.total}`; const subtaskPercentage = `${subtaskStats.completionPercentage}% ${subtaskStats.done}/${subtaskStats.total}`; const content = chalk.white.bold('Project Dashboard') + '\n' + `Tasks Progress: ${taskProgressBar} ${chalk.yellow(taskPercentage)}\n` + formatStatusLine(taskStats, false) + '\n\n' + `Subtasks Progress: ${subtaskProgressBar} ${chalk.cyan(subtaskPercentage)}\n` + formatStatusLine(subtaskStats, true) + '\n\n' + chalk.cyan.bold('Priority Breakdown:') + '\n' + `${chalk.red('•')} ${chalk.white('High priority:')} ${priorityBreakdown.high}\n` + `${chalk.yellow('•')} ${chalk.white('Medium priority:')} ${priorityBreakdown.medium}\n` + `${chalk.green('•')} ${chalk.white('Low priority:')} ${priorityBreakdown.low}`; return content; } /** * Display the dependency dashboard box */ export function displayDependencyDashboard( depStats: DependencyStatistics, nextTask?: NextTaskInfo ): string { const content = chalk.white.bold('Dependency Status & Next Task') + '\n' + chalk.cyan.bold('Dependency Metrics:') + '\n' + `${chalk.green('•')} ${chalk.white('Tasks with no dependencies:')} ${depStats.tasksWithNoDeps}\n` + `${chalk.green('•')} ${chalk.white('Tasks ready to work on:')} ${depStats.tasksReadyToWork}\n` + `${chalk.yellow('•')} ${chalk.white('Tasks blocked by dependencies:')} ${depStats.tasksBlockedByDeps}\n` + `${chalk.magenta('•')} ${chalk.white('Most depended-on task:')} ${ depStats.mostDependedOnTaskId ? chalk.cyan( `#${depStats.mostDependedOnTaskId} (${depStats.mostDependedOnCount} dependents)` ) : chalk.gray('None') }\n` + `${chalk.blue('•')} ${chalk.white('Avg dependencies per task:')} ${depStats.avgDependenciesPerTask.toFixed(1)}\n\n` + chalk.cyan.bold('Next Task to Work On:') + '\n' + `ID: ${nextTask ? chalk.cyan(String(nextTask.id)) : chalk.gray('N/A')} - ${ nextTask ? chalk.white.bold(nextTask.title) : chalk.yellow('No task available') }\n` + `Priority: ${nextTask?.priority || chalk.gray('N/A')} Dependencies: ${ nextTask?.dependencies?.length ? chalk.cyan(nextTask.dependencies.join(', ')) : chalk.gray('None') }\n` + `Complexity: ${nextTask?.complexity || chalk.gray('N/A')}`; return content; } /** * Display dashboard boxes side by side or stacked */ export function displayDashboards( taskStats: TaskStatistics, subtaskStats: TaskStatistics, priorityBreakdown: Record, depStats: DependencyStatistics, nextTask?: NextTaskInfo ): void { const projectDashboardContent = displayProjectDashboard( taskStats, subtaskStats, priorityBreakdown ); const dependencyDashboardContent = displayDependencyDashboard( depStats, nextTask ); // Get terminal width const terminalWidth = process.stdout.columns || 80; const minDashboardWidth = 50; const minDependencyWidth = 50; const totalMinWidth = minDashboardWidth + minDependencyWidth + 4; // If terminal is wide enough, show side by side if (terminalWidth >= totalMinWidth) { const halfWidth = Math.floor(terminalWidth / 2); const boxContentWidth = halfWidth - 4; const dashboardBox = boxen(projectDashboardContent, { padding: 1, borderColor: 'blue', borderStyle: 'round', width: boxContentWidth, dimBorder: false }); const dependencyBox = boxen(dependencyDashboardContent, { padding: 1, borderColor: 'magenta', borderStyle: 'round', width: boxContentWidth, dimBorder: false }); // Create side-by-side layout const dashboardLines = dashboardBox.split('\n'); const dependencyLines = dependencyBox.split('\n'); const maxHeight = Math.max(dashboardLines.length, dependencyLines.length); const combinedLines = []; for (let i = 0; i < maxHeight; i++) { const dashLine = i < dashboardLines.length ? dashboardLines[i] : ''; const depLine = i < dependencyLines.length ? dependencyLines[i] : ''; const paddedDashLine = dashLine.padEnd(halfWidth, ' '); combinedLines.push(paddedDashLine + depLine); } console.log(combinedLines.join('\n')); } else { // Show stacked vertically const dashboardBox = boxen(projectDashboardContent, { padding: 1, borderColor: 'blue', borderStyle: 'round', margin: { top: 0, bottom: 1 } }); const dependencyBox = boxen(dependencyDashboardContent, { padding: 1, borderColor: 'magenta', borderStyle: 'round', margin: { top: 0, bottom: 1 } }); console.log(dashboardBox); console.log(dependencyBox); } }