UNPKG

@boundless-oss/atlas

Version:

Atlas - MCP Server for comprehensive startup project management

480 lines (407 loc) 15 kB
import { ProductRoadmap, RoadmapTheme, Initiative, Feature, Milestone, Release, TimelineView, TimelineItem } from './types.js'; export class TimelineGenerator { generateQuarterlyView( roadmap: ProductRoadmap, startQuarter: string, endQuarter: string ): TimelineView { const items: TimelineItem[] = []; const startDate = this.parseQuarter(startQuarter); const endDate = this.parseQuarter(endQuarter); // Add themes for (const theme of roadmap.themes) { const themeStart = this.parseQuarter(theme.timeframe.startQuarter); const themeEnd = this.parseQuarter(theme.timeframe.endQuarter); // Only include themes that overlap with the view period if (themeEnd >= startDate && themeStart <= endDate) { items.push({ id: theme.id, type: 'theme', name: theme.name, startDate: themeStart, endDate: themeEnd, status: theme.status, dependencies: [], progress: theme.metrics.progressPercentage }); } } // Add milestones for (const milestone of roadmap.milestones) { if (milestone.date >= startDate && milestone.date <= endDate) { items.push({ id: milestone.id, type: 'milestone', name: milestone.name, startDate: milestone.date, endDate: milestone.date, status: milestone.status, dependencies: milestone.dependencies }); } } // Sort by start date items.sort((a, b) => a.startDate.getTime() - b.startDate.getTime()); return { type: 'quarterly', startDate, endDate, items }; } generateMonthlyView( roadmap: ProductRoadmap, initiatives: Initiative[], features: Feature[], startMonth: Date, months: number ): TimelineView { const items: TimelineItem[] = []; const endDate = new Date(startMonth); endDate.setMonth(endDate.getMonth() + months); // Add initiatives with estimated timelines for (const initiative of initiatives) { const estimatedStart = this.estimateInitiativeStart(initiative, roadmap); const estimatedEnd = this.estimateInitiativeEnd(initiative, estimatedStart); if (estimatedEnd >= startMonth && estimatedStart <= endDate) { items.push({ id: initiative.id, type: 'initiative', name: initiative.title, startDate: estimatedStart, endDate: estimatedEnd, status: initiative.status, dependencies: initiative.dependencies.map(d => d.targetId || '').filter(Boolean), progress: this.calculateInitiativeProgress(initiative, features) }); } } // Add features for (const feature of features) { const featureStart = this.estimateFeatureStart(feature, initiatives); const featureEnd = this.estimateFeatureEnd(feature, featureStart); if (featureEnd >= startMonth && featureStart <= endDate) { items.push({ id: feature.id, type: 'feature', name: feature.name, startDate: featureStart, endDate: featureEnd, status: feature.status, dependencies: [], progress: feature.status === 'completed' ? 100 : feature.status === 'in-progress' ? 50 : 0 }); } } // Add releases for (const release of roadmap.releases) { if (release.date >= startMonth && release.date <= endDate) { items.push({ id: release.id, type: 'release', name: `v${release.version}: ${release.name}`, startDate: release.date, endDate: release.date, status: release.status, dependencies: [] }); } } // Sort by start date items.sort((a, b) => a.startDate.getTime() - b.startDate.getTime()); return { type: 'monthly', startDate: startMonth, endDate, items }; } generateReleaseView( roadmap: ProductRoadmap, features: Map<string, Feature> ): TimelineView { const items: TimelineItem[] = []; const now = new Date(); // Group features by release const releaseGroups = new Map<string, Feature[]>(); for (const [_, feature] of features) { if (feature.targetRelease) { if (!releaseGroups.has(feature.targetRelease)) { releaseGroups.set(feature.targetRelease, []); } releaseGroups.get(feature.targetRelease)!.push(feature); } } // Add releases with their features for (const release of roadmap.releases) { items.push({ id: release.id, type: 'release', name: `v${release.version}: ${release.name}`, startDate: release.date, endDate: release.date, status: release.status, dependencies: [], progress: this.calculateReleaseProgress(release, releaseGroups.get(release.id) || []) }); // Add features for this release const releaseFeatures = releaseGroups.get(release.id) || []; for (const feature of releaseFeatures) { const featureStart = new Date(release.date); featureStart.setMonth(featureStart.getMonth() - 3); // Assume 3 months development items.push({ id: feature.id, type: 'feature', name: feature.name, startDate: featureStart, endDate: release.date, status: feature.status, dependencies: [], progress: feature.status === 'completed' ? 100 : feature.status === 'in-progress' ? 50 : 0 }); } } // Sort by date items.sort((a, b) => a.startDate.getTime() - b.startDate.getTime()); const startDate = items[0]?.startDate || now; const endDate = items[items.length - 1]?.endDate || now; return { type: 'release', startDate, endDate, items }; } generateNowNextLaterView( roadmap: ProductRoadmap, themes: Map<string, RoadmapTheme>, initiatives: Map<string, Initiative>, features: Map<string, Feature> ): TimelineView { const now = new Date(); const nextQuarter = new Date(now); nextQuarter.setMonth(nextQuarter.getMonth() + 3); const laterDate = new Date(now); laterDate.setMonth(laterDate.getMonth() + 9); const items: TimelineItem[] = []; // Categorize items into Now, Next, Later const categorizeByTiming = (startDate: Date) => { if (startDate <= now) return 'now'; if (startDate <= nextQuarter) return 'next'; return 'later'; }; // Process themes for (const theme of roadmap.themes) { const themeStart = this.parseQuarter(theme.timeframe.startQuarter); const timing = categorizeByTiming(themeStart); items.push({ id: theme.id, type: 'theme', name: `[${timing.toUpperCase()}] ${theme.name}`, startDate: timing === 'now' ? now : timing === 'next' ? nextQuarter : laterDate, endDate: this.parseQuarter(theme.timeframe.endQuarter), status: theme.status, dependencies: [], progress: theme.metrics.progressPercentage }); } // Process initiatives for (const [_, initiative] of initiatives) { const initiativeStart = this.estimateInitiativeStart(initiative, roadmap); const timing = categorizeByTiming(initiativeStart); items.push({ id: initiative.id, type: 'initiative', name: `[${timing.toUpperCase()}] ${initiative.title}`, startDate: timing === 'now' ? now : timing === 'next' ? nextQuarter : laterDate, endDate: this.estimateInitiativeEnd(initiative, initiativeStart), status: initiative.status, dependencies: initiative.dependencies.map(d => d.targetId || '').filter(Boolean), progress: 0 }); } // Group by timing for better visualization items.sort((a, b) => { // First sort by timing category const aCategory = a.name.startsWith('[NOW]') ? 0 : a.name.startsWith('[NEXT]') ? 1 : 2; const bCategory = b.name.startsWith('[NOW]') ? 0 : b.name.startsWith('[NEXT]') ? 1 : 2; if (aCategory !== bCategory) return aCategory - bCategory; // Then by type (themes first, then initiatives, then features) const typeOrder = { theme: 0, initiative: 1, feature: 2, milestone: 3, release: 4 }; return typeOrder[a.type] - typeOrder[b.type]; }); return { type: 'now-next-later', startDate: now, endDate: laterDate, items }; } // Helper methods private parseQuarter(quarter: string): Date { const [q, year] = quarter.split(' '); const quarterNum = parseInt(q.substring(1)); const yearNum = parseInt(year); const month = (quarterNum - 1) * 3; return new Date(yearNum, month, 1); } private estimateInitiativeStart(initiative: Initiative, roadmap: ProductRoadmap): Date { // Find the theme this initiative belongs to const theme = roadmap.themes.find(t => t.initiatives.some(i => (typeof i === 'string' ? i : i.id) === initiative.id ) ); if (theme) { return this.parseQuarter(theme.timeframe.startQuarter); } // Default to current date return new Date(); } private estimateInitiativeEnd(initiative: Initiative, startDate: Date): Date { const endDate = new Date(startDate); // Estimate based on effort const totalWeeks = initiative.effort.developmentWeeks + initiative.effort.designWeeks + initiative.effort.qaWeeks; endDate.setDate(endDate.getDate() + (totalWeeks * 7)); return endDate; } private estimateFeatureStart(feature: Feature, initiatives: Initiative[]): Date { // Find the initiative this feature belongs to const initiative = initiatives.find(i => i.features.some(f => (typeof f === 'string' ? f : f.id) === feature.id ) ); if (initiative) { // Return current date as we don't have the full roadmap context here return new Date(); } return new Date(); } private estimateFeatureEnd(feature: Feature, startDate: Date): Date { const endDate = new Date(startDate); // Estimate based on complexity const complexityWeeks = { 'low': 2, 'medium': 4, 'high': 8, 'very-high': 12 }; const weeks = complexityWeeks[feature.technicalComplexity]; endDate.setDate(endDate.getDate() + (weeks * 7)); return endDate; } private calculateInitiativeProgress(initiative: Initiative, features: Feature[]): number { const initiativeFeatures = features.filter(f => initiative.features.some(initiativeFeature => (typeof initiativeFeature === 'string' ? initiativeFeature : initiativeFeature.id) === f.id ) ); if (initiativeFeatures.length === 0) return 0; const completedCount = initiativeFeatures.filter(f => f.status === 'completed').length; return Math.round((completedCount / initiativeFeatures.length) * 100); } private calculateReleaseProgress(release: Release, features: Feature[]): number { if (features.length === 0) return 0; const completedCount = features.filter(f => f.status === 'completed').length; return Math.round((completedCount / features.length) * 100); } // Generate Gantt chart data generateGanttData(timelineView: TimelineView): any { return { tasks: timelineView.items.map((item, index) => ({ id: item.id, name: item.name, start: item.startDate.toISOString(), end: item.endDate.toISOString(), progress: item.progress || 0, dependencies: item.dependencies, type: item.type, status: item.status, row: index })), viewStart: timelineView.startDate.toISOString(), viewEnd: timelineView.endDate.toISOString() }; } // Critical path analysis findCriticalPath(items: TimelineItem[]): string[] { // Build dependency graph const graph = new Map<string, Set<string>>(); const itemMap = new Map<string, TimelineItem>(); for (const item of items) { itemMap.set(item.id, item); graph.set(item.id, new Set(item.dependencies)); } // Find items with no dependencies (start nodes) const startNodes = items.filter(item => item.dependencies.length === 0); // Calculate earliest start/finish times const earliestTimes = new Map<string, { start: number, finish: number }>(); const calculateEarliestTimes = (itemId: string): { start: number, finish: number } => { if (earliestTimes.has(itemId)) { return earliestTimes.get(itemId)!; } const item = itemMap.get(itemId)!; const dependencies = Array.from(graph.get(itemId) || []); let earliestStart = item.startDate.getTime(); if (dependencies.length > 0) { const depFinishTimes = dependencies.map(depId => { const depTimes = calculateEarliestTimes(depId); return depTimes.finish; }); earliestStart = Math.max(...depFinishTimes); } const duration = item.endDate.getTime() - item.startDate.getTime(); const earliestFinish = earliestStart + duration; earliestTimes.set(itemId, { start: earliestStart, finish: earliestFinish }); return { start: earliestStart, finish: earliestFinish }; }; // Calculate for all items for (const item of items) { calculateEarliestTimes(item.id); } // Find the critical path (items with no slack) const criticalPath: string[] = []; // Implementation simplified - in production would need full CPM algorithm // For now, return the longest dependency chain let maxDuration = 0; let longestPath: string[] = []; const findLongestPath = (itemId: string, currentPath: string[], currentDuration: number) => { currentPath.push(itemId); const item = itemMap.get(itemId)!; currentDuration += item.endDate.getTime() - item.startDate.getTime(); const dependents = items.filter(i => i.dependencies.includes(itemId)); if (dependents.length === 0) { if (currentDuration > maxDuration) { maxDuration = currentDuration; longestPath = [...currentPath]; } } else { for (const dependent of dependents) { findLongestPath(dependent.id, [...currentPath], currentDuration); } } }; for (const startNode of startNodes) { findLongestPath(startNode.id, [], 0); } return longestPath; } }