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
text/typescript
/**
* 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);
};