UNPKG

create-roadkit

Version:

Beautiful Next.js roadmap website generator with full-screen kanban boards, dark/light mode, and static export

664 lines (569 loc) • 18.7 kB
/** * Progress indicators and user feedback systems for RoadKit. * * This module provides comprehensive progress tracking, user feedback, * and visual indicators for the scaffolding process. It includes spinners, * progress bars, status messages, and completion summaries to keep users * informed throughout the project generation process. */ import chalk from 'chalk'; import ora from 'ora'; import type { Logger } from '../types/config'; /** * Progress stage information with timing and status */ export interface ProgressStage { name: string; description: string; estimatedDuration: number; // in milliseconds startTime?: Date; endTime?: Date; status: 'pending' | 'in_progress' | 'completed' | 'failed' | 'skipped'; error?: string; details?: string[]; subStages?: ProgressStage[]; } /** * Progress tracking configuration */ export interface ProgressConfig { showSpinner: boolean; showProgress: boolean; showETA: boolean; showDetails: boolean; showSubStages: boolean; colorOutput: boolean; logLevel: 'minimal' | 'normal' | 'verbose'; } /** * Progress statistics for reporting */ export interface ProgressStats { totalStages: number; completedStages: number; failedStages: number; skippedStages: number; totalEstimatedTime: number; actualTime: number; remainingTime: number; progressPercentage: number; } /** * Progress event types for callbacks */ export enum ProgressEventType { STAGE_STARTED = 'stage_started', STAGE_COMPLETED = 'stage_completed', STAGE_FAILED = 'stage_failed', STAGE_SKIPPED = 'stage_skipped', PROGRESS_UPDATED = 'progress_updated', COMPLETED = 'completed', FAILED = 'failed', } /** * Progress event data */ export interface ProgressEvent { type: ProgressEventType; stage?: ProgressStage; stats: ProgressStats; message?: string; timestamp: Date; } /** * Progress callback function type */ export type ProgressEventCallback = (event: ProgressEvent) => void; /** * Comprehensive progress tracking system that provides real-time feedback * on project generation progress with visual indicators and detailed reporting. */ export class ProgressTracker { private stages: ProgressStage[] = []; private currentStageIndex = -1; private startTime?: Date; private endTime?: Date; private config: ProgressConfig; private logger: Logger; private spinner?: any; private callbacks: ProgressEventCallback[] = []; /** * Initialize progress tracker with configuration * @param config - Progress tracking configuration * @param logger - Logger instance for progress reporting */ constructor(config: ProgressConfig, logger: Logger) { this.config = config; this.logger = logger; } /** * Adds a progress event callback * @param callback - Callback function to receive progress events */ public onProgress(callback: ProgressEventCallback): void { this.callbacks.push(callback); } /** * Defines the stages for progress tracking * @param stages - Array of progress stages to track */ public defineStages(stages: ProgressStage[]): void { this.stages = stages.map(stage => ({ ...stage, status: 'pending', })); this.currentStageIndex = -1; this.startTime = undefined; this.endTime = undefined; if (this.config.logLevel !== 'minimal') { this.displayStageOverview(); } } /** * Starts progress tracking */ public start(): void { this.startTime = new Date(); if (this.config.logLevel !== 'minimal') { this.logger.info(`šŸš€ Starting project generation with ${this.stages.length} stages`); if (this.config.showETA) { const totalEstimatedTime = this.calculateTotalEstimatedTime(); const estimatedMinutes = Math.ceil(totalEstimatedTime / 60000); this.logger.info(`ā±ļø Estimated completion time: ${estimatedMinutes} minute(s)`); } } this.emitEvent(ProgressEventType.PROGRESS_UPDATED); } /** * Advances to the next stage and starts it * @param message - Optional custom message for the stage * @returns The current stage or undefined if no more stages */ public nextStage(message?: string): ProgressStage | undefined { // Complete current stage if there is one if (this.currentStageIndex >= 0 && this.currentStageIndex < this.stages.length) { const currentStage = this.stages[this.currentStageIndex]; if (currentStage.status === 'in_progress') { this.completeCurrentStage(); } } // Move to next stage this.currentStageIndex++; if (this.currentStageIndex >= this.stages.length) { this.complete(); return undefined; } const stage = this.stages[this.currentStageIndex]; stage.status = 'in_progress'; stage.startTime = new Date(); if (message) { stage.details = stage.details || []; stage.details.push(message); } this.updateVisualIndicators(stage); this.emitEvent(ProgressEventType.STAGE_STARTED, stage); return stage; } /** * Completes the current stage successfully * @param message - Optional completion message */ public completeCurrentStage(message?: string): void { if (this.currentStageIndex < 0 || this.currentStageIndex >= this.stages.length) { return; } const stage = this.stages[this.currentStageIndex]; stage.status = 'completed'; stage.endTime = new Date(); if (message) { stage.details = stage.details || []; stage.details.push(message); } if (this.spinner) { this.spinner.succeed(this.formatStageMessage(stage, 'completed')); this.spinner = undefined; } this.emitEvent(ProgressEventType.STAGE_COMPLETED, stage); } /** * Fails the current stage with an error * @param error - Error message or Error object * @param recoverable - Whether the error is recoverable */ public failCurrentStage(error: string | Error, recoverable = false): void { if (this.currentStageIndex < 0 || this.currentStageIndex >= this.stages.length) { return; } const stage = this.stages[this.currentStageIndex]; stage.status = 'failed'; stage.endTime = new Date(); stage.error = error instanceof Error ? error.message : error; if (this.spinner) { this.spinner.fail(this.formatStageMessage(stage, 'failed')); this.spinner = undefined; } this.emitEvent(ProgressEventType.STAGE_FAILED, stage); if (!recoverable) { this.fail(stage.error); } } /** * Skips the current stage * @param reason - Reason for skipping the stage */ public skipCurrentStage(reason: string): void { if (this.currentStageIndex < 0 || this.currentStageIndex >= this.stages.length) { return; } const stage = this.stages[this.currentStageIndex]; stage.status = 'skipped'; stage.endTime = new Date(); stage.details = stage.details || []; stage.details.push(`Skipped: ${reason}`); if (this.spinner) { this.spinner.warn(this.formatStageMessage(stage, 'skipped')); this.spinner = undefined; } this.emitEvent(ProgressEventType.STAGE_SKIPPED, stage); } /** * Updates the current stage with additional information * @param message - Update message to add to stage details */ public updateCurrentStage(message: string): void { if (this.currentStageIndex < 0 || this.currentStageIndex >= this.stages.length) { return; } const stage = this.stages[this.currentStageIndex]; stage.details = stage.details || []; stage.details.push(message); if (this.spinner && this.config.showDetails) { this.spinner.text = this.formatStageMessage(stage, 'in_progress', message); } if (this.config.logLevel === 'verbose') { this.logger.debug(`Stage update: ${stage.name} - ${message}`); } } /** * Completes all progress tracking successfully */ public complete(): void { this.endTime = new Date(); if (this.spinner) { this.spinner.succeed('Project generation completed successfully!'); this.spinner = undefined; } this.displayCompletionSummary(); this.emitEvent(ProgressEventType.COMPLETED); } /** * Fails the entire progress tracking * @param error - Error message that caused the failure */ public fail(error: string): void { this.endTime = new Date(); if (this.spinner) { this.spinner.fail('Project generation failed'); this.spinner = undefined; } this.displayFailureSummary(error); this.emitEvent(ProgressEventType.FAILED, undefined, error); } /** * Gets current progress statistics * @returns Current progress statistics */ public getStats(): ProgressStats { const totalStages = this.stages.length; const completedStages = this.stages.filter(s => s.status === 'completed').length; const failedStages = this.stages.filter(s => s.status === 'failed').length; const skippedStages = this.stages.filter(s => s.status === 'skipped').length; const totalEstimatedTime = this.calculateTotalEstimatedTime(); const actualTime = this.endTime ? this.endTime.getTime() - (this.startTime?.getTime() || 0) : Date.now() - (this.startTime?.getTime() || 0); const progressPercentage = totalStages > 0 ? Math.round(((completedStages + skippedStages) / totalStages) * 100) : 0; const remainingStages = totalStages - completedStages - skippedStages - failedStages; const avgStageTime = completedStages > 0 ? actualTime / completedStages : 0; const remainingTime = remainingStages * avgStageTime; return { totalStages, completedStages, failedStages, skippedStages, totalEstimatedTime, actualTime, remainingTime, progressPercentage, }; } /** * Gets the current stage * @returns Current stage or undefined if no stage is active */ public getCurrentStage(): ProgressStage | undefined { if (this.currentStageIndex < 0 || this.currentStageIndex >= this.stages.length) { return undefined; } return this.stages[this.currentStageIndex]; } /** * Gets all stages * @returns Array of all progress stages */ public getStages(): ProgressStage[] { return [...this.stages]; } // Private helper methods /** * Displays an overview of all stages at the beginning */ private displayStageOverview(): void { if (this.config.logLevel === 'minimal') return; console.log(chalk.cyan('\nšŸ“‹ Project Generation Stages:\n')); this.stages.forEach((stage, index) => { const prefix = chalk.gray(`${index + 1}.`); const name = chalk.white(stage.name); const description = chalk.gray(`- ${stage.description}`); console.log(` ${prefix} ${name} ${description}`); }); console.log(); } /** * Updates visual indicators (spinner, progress bar) for the current stage */ private updateVisualIndicators(stage: ProgressStage): void { if (!this.config.showSpinner && !this.config.showProgress) { return; } const message = this.formatStageMessage(stage, 'in_progress'); if (this.config.showSpinner) { if (this.spinner) { this.spinner.stop(); } this.spinner = ora(message).start(); } else if (this.config.logLevel !== 'minimal') { this.logger.info(`ā³ ${message}`); } } /** * Formats a stage message for display */ private formatStageMessage( stage: ProgressStage, status: 'in_progress' | 'completed' | 'failed' | 'skipped', additionalInfo?: string ): string { const stats = this.getStats(); const progress = this.config.showProgress ? `[${stats.completedStages + 1}/${stats.totalStages}] ` : ''; let statusIcon = ''; switch (status) { case 'in_progress': statusIcon = 'ā³'; break; case 'completed': statusIcon = 'āœ…'; break; case 'failed': statusIcon = 'āŒ'; break; case 'skipped': statusIcon = 'ā­ļø'; break; } let message = `${progress}${statusIcon} ${stage.name}`; if (additionalInfo && this.config.showDetails) { message += ` - ${additionalInfo}`; } if (this.config.showETA && status === 'in_progress' && stats.remainingTime > 0) { const remainingMinutes = Math.ceil(stats.remainingTime / 60000); message += ` (ETA: ${remainingMinutes}m)`; } return message; } /** * Displays completion summary */ private displayCompletionSummary(): void { if (this.config.logLevel === 'minimal') return; const stats = this.getStats(); const durationSeconds = Math.ceil(stats.actualTime / 1000); console.log(chalk.green('\nšŸŽ‰ Project Generation Complete!\n')); console.log(chalk.cyan('Summary:')); console.log(` ${chalk.white('Stages completed:')} ${chalk.green(stats.completedStages)}/${stats.totalStages}`); if (stats.skippedStages > 0) { console.log(` ${chalk.white('Stages skipped:')} ${chalk.yellow(stats.skippedStages)}`); } console.log(` ${chalk.white('Total time:')} ${durationSeconds}s`); if (this.config.showETA && stats.totalEstimatedTime > 0) { const efficiency = Math.round((stats.totalEstimatedTime / stats.actualTime) * 100); console.log(` ${chalk.white('Efficiency:')} ${efficiency}% of estimated time`); } if (this.config.logLevel === 'verbose') { console.log(chalk.gray('\nStage Details:')); this.stages.forEach((stage, index) => { const duration = stage.endTime && stage.startTime ? Math.ceil((stage.endTime.getTime() - stage.startTime.getTime()) / 1000) : 0; const status = this.getStageStatusIcon(stage.status); console.log(` ${index + 1}. ${status} ${stage.name} (${duration}s)`); if (stage.details && stage.details.length > 0 && this.config.showDetails) { stage.details.forEach(detail => { console.log(chalk.gray(` • ${detail}`)); }); } }); } console.log(); } /** * Displays failure summary */ private displayFailureSummary(error: string): void { const stats = this.getStats(); const durationSeconds = Math.ceil(stats.actualTime / 1000); console.log(chalk.red('\nāŒ Project Generation Failed\n')); console.log(chalk.cyan('Summary:')); console.log(` ${chalk.white('Stages completed:')} ${chalk.green(stats.completedStages)}/${stats.totalStages}`); console.log(` ${chalk.white('Stages failed:')} ${chalk.red(stats.failedStages)}`); if (stats.skippedStages > 0) { console.log(` ${chalk.white('Stages skipped:')} ${chalk.yellow(stats.skippedStages)}`); } console.log(` ${chalk.white('Time elapsed:')} ${durationSeconds}s`); console.log(` ${chalk.white('Error:')} ${chalk.red(error)}`); if (this.config.logLevel === 'verbose') { console.log(chalk.gray('\nStage Details:')); this.stages.forEach((stage, index) => { if (stage.status === 'pending') return; const duration = stage.endTime && stage.startTime ? Math.ceil((stage.endTime.getTime() - stage.startTime.getTime()) / 1000) : 0; const status = this.getStageStatusIcon(stage.status); console.log(` ${index + 1}. ${status} ${stage.name} (${duration}s)`); if (stage.error) { console.log(chalk.red(` Error: ${stage.error}`)); } }); } console.log(); } /** * Gets a status icon for a stage status */ private getStageStatusIcon(status: ProgressStage['status']): string { switch (status) { case 'completed': return chalk.green('āœ“'); case 'failed': return chalk.red('āœ—'); case 'skipped': return chalk.yellow('ā­'); case 'in_progress': return chalk.blue('ā³'); default: return chalk.gray('ā—‹'); } } /** * Calculates total estimated time for all stages */ private calculateTotalEstimatedTime(): number { return this.stages.reduce((total, stage) => total + stage.estimatedDuration, 0); } /** * Emits a progress event to all registered callbacks */ private emitEvent( type: ProgressEventType, stage?: ProgressStage, message?: string ): void { const event: ProgressEvent = { type, stage, stats: this.getStats(), message, timestamp: new Date(), }; this.callbacks.forEach(callback => { try { callback(event); } catch (error) { this.logger.warn('Progress callback error', error); } }); } } /** * Default progress stages for RoadKit project generation */ export const DEFAULT_PROGRESS_STAGES: ProgressStage[] = [ { name: 'Validation', description: 'Validating configuration and system requirements', estimatedDuration: 2000, status: 'pending', }, { name: 'Setup', description: 'Creating project directory structure', estimatedDuration: 3000, status: 'pending', }, { name: 'Templates', description: 'Processing and copying template files', estimatedDuration: 5000, status: 'pending', }, { name: 'Themes', description: 'Applying theme customizations', estimatedDuration: 2000, status: 'pending', }, { name: 'Configuration', description: 'Generating configuration files', estimatedDuration: 3000, status: 'pending', }, { name: 'Dependencies', description: 'Installing project dependencies', estimatedDuration: 30000, status: 'pending', }, { name: 'Git', description: 'Initializing git repository', estimatedDuration: 2000, status: 'pending', }, { name: 'Validation', description: 'Validating generated project', estimatedDuration: 1000, status: 'pending', }, ]; /** * Factory function to create a ProgressTracker instance * @param config - Progress tracking configuration * @param logger - Logger instance for progress reporting * @returns Configured ProgressTracker instance */ export const createProgressTracker = ( config: Partial<ProgressConfig> = {}, logger: Logger ): ProgressTracker => { const defaultConfig: ProgressConfig = { showSpinner: true, showProgress: true, showETA: true, showDetails: true, showSubStages: false, colorOutput: true, logLevel: 'normal', }; return new ProgressTracker({ ...defaultConfig, ...config }, logger); };