@boundless-oss/atlas
Version:
Atlas - MCP Server for comprehensive startup project management
1,445 lines (1,241 loc) • 52.5 kB
text/typescript
import { ConfigManager } from '../../config/config-manager.js';
import { StorageManager } from '../../storage/storage-manager.js';
import { MCPError } from '../../utils/error-handler.js';
import {
Sprint,
Story,
Epic,
SprintPlanningSession,
SprintRetrospective,
StandupReport,
VelocityReport,
BurndownChart,
SprintStatus,
BacklogFilter,
CreateSprintOptions,
CreateStoryOptions,
CreateEpicOptions,
SprintPlanningOptions,
StandupOptions,
RetrospectiveOptions,
StoryUpdateOptions,
VelocityOptions,
EpicUpdateOptions,
SprintUpdateOptions
} from './types.js';
import { promises as fs } from 'fs';
import path from 'path';
export class AgileManager {
private configManager: ConfigManager;
private storageManager: StorageManager;
private agileDataPath: string;
private sprints: Map<string, Sprint>;
private stories: Map<string, Story>;
private epics: Map<string, Epic>;
private standups: StandupReport[];
private retrospectives: SprintRetrospective[];
constructor(configManager: ConfigManager) {
this.configManager = configManager;
this.storageManager = new StorageManager();
this.agileDataPath = '';
this.sprints = new Map();
this.stories = new Map();
this.epics = new Map();
this.standups = [];
this.retrospectives = [];
}
async initialize(): Promise<void> {
const location = await this.storageManager.getStorageLocation();
this.agileDataPath = path.join(location.data, 'agile');
await this.loadExistingData();
// Create sample data only in development mode and if explicitly enabled
const isDev = process.env.NODE_ENV === 'development' || process.env.ATLAS_DEV === 'true';
const shouldCreateSampleData = process.env.ATLAS_CREATE_SAMPLE_DATA === 'true';
if ((isDev || shouldCreateSampleData) && this.sprints.size === 0 && this.stories.size === 0 && this.epics.size === 0) {
console.log('🔧 Development mode detected - creating sample agile data for dashboard testing');
await this.createSampleData();
}
}
private async loadExistingData(): Promise<void> {
try {
// Load sprints
const sprintsPath = path.join(this.agileDataPath, 'sprints.json');
if (await this.fileExists(sprintsPath)) {
const sprintsData = JSON.parse(await fs.readFile(sprintsPath, 'utf-8'));
sprintsData.forEach((sprint: Sprint) => {
sprint.startDate = new Date(sprint.startDate);
sprint.endDate = new Date(sprint.endDate);
this.sprints.set(sprint.id, sprint);
});
}
// Load stories
const storiesPath = path.join(this.agileDataPath, 'stories.json');
if (await this.fileExists(storiesPath)) {
const storiesData = JSON.parse(await fs.readFile(storiesPath, 'utf-8'));
storiesData.forEach((story: Story) => {
story.createdAt = new Date(story.createdAt);
story.updatedAt = new Date(story.updatedAt);
// Initialize required arrays if undefined
story.acceptanceCriteria = story.acceptanceCriteria || [];
story.tags = story.tags || [];
story.dependencies = story.dependencies || [];
story.watchers = story.watchers || [];
// Initialize required objects
story.timeTracking = story.timeTracking || {
estimated: 0,
actual: 0,
remaining: 0,
logs: []
};
story.attachments = story.attachments || [];
story.comments = story.comments || [];
story.customFields = story.customFields || [];
story.subtasks = story.subtasks || [];
// Convert optional dates
if (story.dueDate) story.dueDate = new Date(story.dueDate);
if (story.startDate) story.startDate = new Date(story.startDate);
if (story.completedAt) story.completedAt = new Date(story.completedAt);
this.stories.set(story.id, story);
});
}
// Load epics
const epicsPath = path.join(this.agileDataPath, 'epics.json');
if (await this.fileExists(epicsPath)) {
const epicsData = JSON.parse(await fs.readFile(epicsPath, 'utf-8'));
epicsData.forEach((epic: Epic) => {
epic.createdAt = new Date(epic.createdAt);
epic.updatedAt = new Date(epic.updatedAt);
this.epics.set(epic.id, epic);
});
}
// Load standups
const standupsPath = path.join(this.agileDataPath, 'standups.json');
if (await this.fileExists(standupsPath)) {
const standupsData = JSON.parse(await fs.readFile(standupsPath, 'utf-8'));
this.standups = standupsData.map((standup: any) => ({
...standup,
date: new Date(standup.date)
}));
}
// Load retrospectives
const retrospectivesPath = path.join(this.agileDataPath, 'retrospectives.json');
if (await this.fileExists(retrospectivesPath)) {
const retrospectivesData = JSON.parse(await fs.readFile(retrospectivesPath, 'utf-8'));
this.retrospectives = retrospectivesData.map((retro: any) => ({
...retro,
date: new Date(retro.date)
}));
}
} catch (error) {
console.error('Error loading agile data:', error);
}
// Validate and repair data integrity after loading
const validationResult = await this.validateAndRepairDataIntegrity();
if (!validationResult.valid && validationResult.repairs.length > 0) {
console.log('🔧 Agile data integrity repairs performed:');
validationResult.repairs.forEach(repair => console.log(` - ${repair}`));
}
}
async validateAndRepairDataIntegrity(): Promise<{valid: boolean; repairs: string[]}> {
const repairs: string[] = [];
let dataModified = false;
// Check data version to determine repair strategy
const dataVersion = await this.getDataVersion();
const needsEpicSync = this.compareVersions(dataVersion, '1.0.19') < 0;
if (needsEpicSync) {
repairs.push(`Detected data version ${dataVersion} < 1.0.19 - performing epic-story relationship migration`);
}
// Step 1: Build a map of valid epic IDs
const validEpicIds = new Set(this.epics.keys());
// Step 2: Validate and repair story-epic relationships
if (!needsEpicSync) {
// For v1.0.19+ data: Only remove truly invalid epic references
for (const [storyId, story] of this.stories.entries()) {
if (story.epic && !validEpicIds.has(story.epic)) {
repairs.push(`Story ${storyId} had invalid epic reference ${story.epic} - removed`);
story.epic = undefined;
story.updatedAt = new Date();
dataModified = true;
}
}
}
// For pre-v1.0.19 data: Keep all epic references that point to valid epics
// Step 3: Rebuild epic.storyIds arrays from story references
if (needsEpicSync) {
// For pre-v1.0.19 data: Always rebuild epic.storyIds from story references
repairs.push('Rebuilding all epic.storyIds arrays from story references');
// Clear all epic.storyIds arrays first
for (const [epicId, epic] of this.epics.entries()) {
epic.storyIds = [];
epic.updatedAt = new Date();
}
// Rebuild from story references
for (const [storyId, story] of this.stories.entries()) {
if (story.epic && this.epics.has(story.epic)) {
const epic = this.epics.get(story.epic)!;
epic.storyIds.push(storyId);
repairs.push(` - Added story ${storyId} to epic ${story.epic}`);
}
}
dataModified = true;
} else {
// For v1.0.19+ data: Only fix discrepancies
const epicStoryMap = new Map<string, string[]>();
// Initialize empty arrays for all epics
for (const epicId of validEpicIds) {
epicStoryMap.set(epicId, []);
}
// Collect all valid story-epic relationships
for (const [storyId, story] of this.stories.entries()) {
if (story.epic && validEpicIds.has(story.epic)) {
epicStoryMap.get(story.epic)!.push(storyId);
}
}
// Update epic.storyIds arrays only if they differ
for (const [epicId, epic] of this.epics.entries()) {
const correctStoryIds = epicStoryMap.get(epicId) || [];
const currentStoryIds = epic.storyIds || [];
// Check if arrays differ
if (JSON.stringify(correctStoryIds.sort()) !== JSON.stringify(currentStoryIds.sort())) {
repairs.push(`Epic ${epicId} storyIds updated: ${currentStoryIds.length} -> ${correctStoryIds.length} stories`);
epic.storyIds = correctStoryIds;
epic.updatedAt = new Date();
dataModified = true;
}
}
}
// Step 4: Validate sprint-story relationships
const validSprintIds = new Set(this.sprints.keys());
for (const [storyId, story] of this.stories.entries()) {
if (story.sprintId && !validSprintIds.has(story.sprintId)) {
repairs.push(`Story ${storyId} had invalid sprint reference ${story.sprintId} - removed`);
story.sprintId = undefined;
story.updatedAt = new Date();
dataModified = true;
}
}
// Save changes if any repairs were made
if (dataModified) {
await Promise.all([
this.saveStories(),
this.saveEpics(),
this.saveSprints()
]);
// Update version if we performed a migration
if (needsEpicSync) {
await this.updateDataVersion('1.0.19');
repairs.push('Updated data version to 1.0.19');
}
}
return {
valid: repairs.length === 0,
repairs
};
}
private async fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
async createSprint(options: CreateSprintOptions): Promise<Sprint> {
const startDate = options.startDate || new Date();
const sprint: Sprint = {
id: `sprint-${this.generateId()}`,
name: options.name,
goal: options.goal,
status: 'active',
startDate: startDate,
endDate: new Date(startDate.getTime() + (options.duration * 24 * 60 * 60 * 1000)),
duration: options.duration,
team: options.team || [],
storyIds: [],
epicIds: [],
capacity: 0,
velocity: 0,
burndownData: [],
};
this.sprints.set(sprint.id, sprint);
// Add initial stories if provided
if (options.initialStoryIds && options.initialStoryIds.length > 0) {
for (const storyId of options.initialStoryIds) {
try {
await this.addStoryToSprint(storyId, sprint.id);
} catch (error) {
console.error(`Failed to add story ${storyId} to sprint: ${error}`);
}
}
}
await this.saveSprints();
return sprint;
}
async addStory(options: CreateStoryOptions): Promise<Story> {
// Handle epicId alias
const epicId = (options as any).epicId || options.epic;
// Convert legacy string acceptanceCriteria to structured format
let acceptanceCriteria: any[] = [];
if (options.acceptanceCriteria) {
if (typeof options.acceptanceCriteria[0] === 'string') {
// Legacy format - convert strings to structured criteria
acceptanceCriteria = (options.acceptanceCriteria as string[]).map((desc, idx) => ({
id: `ac-${Date.now()}-${idx}`,
description: desc,
status: 'pending' as const,
}));
} else {
// New structured format
acceptanceCriteria = options.acceptanceCriteria as any[];
}
}
const story: Story = {
id: `story-${this.generateId()}`,
title: options.title,
description: options.description,
status: options.sprintId ? 'todo' : 'backlog' as any,
priority: options.priority || 'medium',
storyPoints: options.storyPoints || 0,
acceptanceCriteria: acceptanceCriteria,
acceptanceCriteriaLegacy: typeof options.acceptanceCriteria?.[0] === 'string'
? options.acceptanceCriteria as string[]
: undefined,
assignee: options.assignee,
epic: epicId,
sprintId: options.sprintId,
createdAt: new Date(),
updatedAt: new Date(),
hoursSpent: 0,
tags: options.tags || [],
// Initialize new enhanced properties
dependencies: options.dependencies || [],
timeTracking: options.timeEstimate ? {
estimated: options.timeEstimate,
actual: 0,
remaining: options.timeEstimate,
logs: []
} : {
estimated: 0,
actual: 0,
remaining: 0,
logs: []
},
attachments: [],
comments: [],
customFields: [],
subtasks: [],
watchers: options.watchers || [],
groomedWithUserFeedback: options.groomedWithUserFeedback ?? false,
};
this.stories.set(story.id, story);
// Add to sprint if specified
if (options.sprintId && this.sprints.has(options.sprintId)) {
const sprint = this.sprints.get(options.sprintId)!;
sprint.storyIds.push(story.id);
this.sprints.set(sprint.id, sprint);
await this.saveSprints();
}
// Add to epic if specified
if (epicId && this.epics.has(epicId)) {
const epic = this.epics.get(epicId)!;
if (!epic.storyIds.includes(story.id)) {
epic.storyIds.push(story.id);
epic.updatedAt = new Date();
this.epics.set(epic.id, epic);
await this.saveEpics();
}
}
await this.saveStories();
// Return with epicId for API compatibility
return { ...story, epicId: story.epic } as any;
}
async createEpic(options: CreateEpicOptions): Promise<Epic> {
const epic: Epic = {
id: `epic-${this.generateId()}`,
title: options.title,
description: options.description,
status: 'planned',
priority: options.priority || 'medium',
goals: options.goals || [],
owner: options.owner,
createdAt: new Date(),
updatedAt: new Date(),
storyIds: [],
stories: [],
progress: 0,
};
this.epics.set(epic.id, epic);
// Handle stories if provided
const storiesData = (options as any).stories;
if (storiesData && Array.isArray(storiesData)) {
const createdStories = [];
let totalPoints = 0;
for (const storyData of storiesData) {
const story = await this.addStory({
title: storyData.title,
description: storyData.description,
storyPoints: storyData.storyPoints || 0,
priority: storyData.priority || 'medium',
acceptanceCriteria: storyData.acceptanceCriteria || [],
epic: epic.id,
});
epic.storyIds.push(story.id);
createdStories.push(story);
totalPoints += story.storyPoints;
}
// Return epic with stories array populated
const epicWithStories = { ...epic, stories: createdStories, totalStoryPoints: totalPoints };
await this.saveEpics();
return epicWithStories as any;
}
await this.saveEpics();
return epic;
}
async saveEpic(epic: Epic): Promise<void> {
this.epics.set(epic.id, epic);
await this.saveEpics();
}
async updateEpic(epicId: string, updates: EpicUpdateOptions): Promise<Epic> {
if (!this.epics.has(epicId)) {
throw new Error(`Epic ${epicId} not found`);
}
const epic = this.epics.get(epicId)!;
// Update basic properties
if (updates.title !== undefined) epic.title = updates.title;
if (updates.description !== undefined) epic.description = updates.description;
if (updates.status !== undefined) epic.status = updates.status;
if (updates.priority !== undefined) epic.priority = updates.priority;
if (updates.goals !== undefined) epic.goals = updates.goals;
if (updates.owner !== undefined) epic.owner = updates.owner;
// Update metadata if provided
if (updates.metadata !== undefined) {
epic.metadata = {
...epic.metadata,
...updates.metadata
};
}
// Update timestamp
epic.updatedAt = new Date();
// Recalculate progress based on story statuses
if (epic.storyIds.length > 0) {
const stories = epic.storyIds
.map(id => this.stories.get(id))
.filter(story => story !== undefined) as Story[];
const totalStories = stories.length;
const completedStories = stories.filter(s => s.status === 'done').length;
epic.progress = totalStories > 0 ? Math.round((completedStories / totalStories) * 100) : 0;
}
this.epics.set(epic.id, epic);
await this.saveEpics();
return epic;
}
async updateSprint(sprintId: string, updates: SprintUpdateOptions): Promise<Sprint> {
if (!this.sprints.has(sprintId)) {
throw new Error(`Sprint ${sprintId} not found`);
}
const sprint = this.sprints.get(sprintId)!;
// Update basic properties
if (updates.name !== undefined) sprint.name = updates.name;
if (updates.goal !== undefined) sprint.goal = updates.goal;
if (updates.status !== undefined) sprint.status = updates.status;
if (updates.endDate !== undefined) sprint.endDate = updates.endDate;
if (updates.team !== undefined) sprint.team = updates.team;
if (updates.capacity !== undefined) sprint.capacity = updates.capacity;
// Recalculate duration if end date changed
if (updates.endDate !== undefined) {
sprint.duration = Math.ceil(
(sprint.endDate.getTime() - sprint.startDate.getTime()) / (1000 * 60 * 60 * 24)
);
}
// Recalculate velocity if sprint is completed
if (sprint.status === 'completed') {
const sprintStories = sprint.storyIds
.map(id => this.stories.get(id))
.filter(story => story !== undefined) as Story[];
sprint.velocity = sprintStories
.filter(story => story.status === 'done')
.reduce((sum, story) => sum + story.storyPoints, 0);
}
this.sprints.set(sprint.id, sprint);
await this.saveSprints();
return sprint;
}
async addStoryToSprint(storyId: string, sprintId: string): Promise<void> {
const story = this.stories.get(storyId);
const sprint = this.sprints.get(sprintId);
if (!story) {
throw new Error(`Story ${storyId} not found`);
}
if (!sprint) {
throw new Error(`Sprint ${sprintId} not found`);
}
// Update story
story.sprintId = sprintId;
story.updatedAt = new Date();
this.stories.set(story.id, story);
// Update sprint
if (!sprint.storyIds.includes(storyId)) {
sprint.storyIds.push(storyId);
this.sprints.set(sprint.id, sprint);
}
// Save both
await Promise.all([this.saveStories(), this.saveSprints()]);
}
async conductSprintPlanning(options: SprintPlanningOptions): Promise<SprintPlanningSession> {
if (!this.sprints.has(options.sprintId)) {
throw new Error(`Sprint ${options.sprintId} not found`);
}
const sprint = this.sprints.get(options.sprintId)!;
const planning: SprintPlanningSession = {
id: `planning-${Date.now()}`,
sprintId: options.sprintId,
date: new Date(),
attendees: options.attendees,
capacity: options.capacity || 0,
commitments: options.commitments,
notes: '',
outcome: 'ready',
};
// Update sprint with planning results
sprint.capacity = planning.capacity;
sprint.storyIds = [...new Set([...sprint.storyIds, ...planning.commitments])];
sprint.status = 'active';
this.sprints.set(sprint.id, sprint);
await this.saveSprints();
return planning;
}
async recordStandup(options: StandupOptions): Promise<StandupReport> {
const standup: StandupReport = {
id: `standup-${Date.now()}`,
sprintId: options.sprintId,
attendee: options.attendee,
date: new Date(),
yesterday: options.yesterday,
today: options.today,
blockers: options.blockers,
};
this.standups.push(standup);
await this.saveStandups();
return standup;
}
async conductRetrospective(options: RetrospectiveOptions): Promise<SprintRetrospective> {
if (!this.sprints.has(options.sprintId)) {
throw new Error(`Sprint ${options.sprintId} not found`);
}
const retrospective: SprintRetrospective = {
id: `retro-${Date.now()}`,
sprintId: options.sprintId,
date: new Date(),
attendees: options.attendees,
whatWentWell: options.whatWentWell,
whatCanImprove: options.whatCanImprove,
actionItems: options.actionItems,
sprintRating: 7, // Default rating
};
this.retrospectives.push(retrospective);
await this.saveRetrospectives();
// Mark sprint as completed
const sprint = this.sprints.get(options.sprintId)!;
sprint.status = 'completed';
this.sprints.set(sprint.id, sprint);
await this.saveSprints();
return retrospective;
}
async updateStoryStatus(storyId: string, updates: StoryUpdateOptions): Promise<Story> {
if (!this.stories.has(storyId)) {
throw new MCPError({
message: `Story ${storyId} not found`,
code: 'STORY_NOT_FOUND',
category: 'validation',
details: { storyId },
recoverable: false,
suggestedActions: [
'Check that the story ID is correct',
'Use list_agile_backlog to see available stories',
'Ensure the story was not deleted'
]
});
}
const story = this.stories.get(storyId)!;
// Validate status transition to in_progress
if (updates.status === 'in_progress' && !story.groomedWithUserFeedback) {
throw new MCPError({
message: 'Story must be groomed with user feedback before moving to in_progress',
code: 'STORY_NOT_GROOMED',
category: 'validation',
details: {
storyId,
currentStatus: story.status,
targetStatus: updates.status,
groomedWithUserFeedback: story.groomedWithUserFeedback
},
recoverable: true,
suggestedActions: [
'Mark the story as groomed with user feedback first',
'Run a grooming session with stakeholders',
'Update the story with groomedWithUserFeedback: true'
]
});
}
// Validate documentation requirements when moving from todo/backlog
if (story.status === 'todo' || story.status === 'backlog') {
if (updates.status && updates.status !== 'todo' && updates.status !== 'backlog') {
// Story is moving out of todo/backlog, check documentation
if (story.documentationStatus !== 'approved') {
throw new MCPError({
message: 'Story documentation must be approved before moving out of To Do',
code: 'DOCUMENTATION_NOT_APPROVED',
category: 'validation',
details: {
storyId,
currentStatus: story.status,
targetStatus: updates.status,
documentationStatus: story.documentationStatus || 'pending',
designDocumentUrl: story.designDocumentUrl,
implementationDocumentUrl: story.implementationDocumentUrl
},
recoverable: true,
suggestedActions: [
'Add design document URL to the story',
'Add implementation document URL to the story',
'Get documentation reviewed and approved',
'Update documentationStatus to "approved"'
]
});
}
}
}
// Update basic properties
if (updates.status !== undefined) story.status = updates.status;
if (updates.title !== undefined) story.title = updates.title;
if (updates.description !== undefined) story.description = updates.description;
if (updates.priority !== undefined) story.priority = updates.priority;
if (updates.storyPoints !== undefined) story.storyPoints = updates.storyPoints;
if (updates.assignee !== undefined) story.assignee = updates.assignee;
if (updates.tags !== undefined) story.tags = updates.tags;
if (updates.groomedWithUserFeedback !== undefined) story.groomedWithUserFeedback = updates.groomedWithUserFeedback;
if (updates.designDocumentUrl !== undefined) story.designDocumentUrl = updates.designDocumentUrl;
if (updates.implementationDocumentUrl !== undefined) story.implementationDocumentUrl = updates.implementationDocumentUrl;
if (updates.documentationStatus !== undefined) story.documentationStatus = updates.documentationStatus;
if (updates.documentationLastReviewed !== undefined) story.documentationLastReviewed = updates.documentationLastReviewed;
if (updates.documentationReviewers !== undefined) story.documentationReviewers = updates.documentationReviewers;
// Handle epic changes
if (updates.epic !== undefined) {
const oldEpicId = story.epic;
const newEpicId = updates.epic;
// Remove from old epic if it exists
if (oldEpicId && this.epics.has(oldEpicId)) {
const oldEpic = this.epics.get(oldEpicId)!;
oldEpic.storyIds = oldEpic.storyIds.filter(id => id !== storyId);
oldEpic.updatedAt = new Date();
this.epics.set(oldEpicId, oldEpic);
}
// Add to new epic if it exists
if (newEpicId && this.epics.has(newEpicId)) {
const newEpic = this.epics.get(newEpicId)!;
if (!newEpic.storyIds.includes(storyId)) {
newEpic.storyIds.push(storyId);
newEpic.updatedAt = new Date();
this.epics.set(newEpicId, newEpic);
}
story.epic = newEpicId;
} else if (newEpicId) {
throw new MCPError({
message: `Epic ${newEpicId} not found`,
code: 'EPIC_NOT_FOUND',
category: 'validation',
details: { epicId: newEpicId, storyId },
recoverable: false,
suggestedActions: [
'Check that the epic ID is correct',
'Use list_epics to see available epics',
'Create the epic first before assigning stories'
]
});
} else {
story.epic = undefined;
}
await this.saveEpics();
}
// Handle acceptance criteria updates
if (updates.acceptanceCriteria !== undefined) {
if (typeof updates.acceptanceCriteria[0] === 'string') {
// Legacy format - convert strings to structured criteria
story.acceptanceCriteria = (updates.acceptanceCriteria as string[]).map((desc, idx) => ({
id: `ac-${Date.now()}-${idx}`,
description: desc,
status: 'pending' as const,
}));
story.acceptanceCriteriaLegacy = updates.acceptanceCriteria as string[];
} else {
// New structured format
story.acceptanceCriteria = updates.acceptanceCriteria as any[];
}
}
// Update enhanced properties
if (updates.dependencies !== undefined) story.dependencies = updates.dependencies;
if (updates.timeTracking !== undefined) {
story.timeTracking = { ...story.timeTracking, ...updates.timeTracking };
}
// Handle time tracking
if (updates.hoursSpent !== undefined) {
story.hoursSpent = updates.hoursSpent;
if (!story.timeTracking) {
story.timeTracking = {
estimated: 0,
actual: updates.hoursSpent,
remaining: 0,
logs: []
};
} else {
story.timeTracking.actual = updates.hoursSpent;
}
}
story.updatedAt = new Date();
this.stories.set(storyId, story);
await this.saveStories();
return story;
}
async generateBurndownChart(sprintId: string): Promise<BurndownChart> {
if (!this.sprints.has(sprintId)) {
throw new Error(`Sprint ${sprintId} not found`);
}
const sprint = this.sprints.get(sprintId)!;
const sprintStories = sprint.storyIds
.map(id => this.stories.get(id))
.filter(story => story !== undefined) as Story[];
const totalStoryPoints = sprintStories.reduce((sum, story) => sum + story.storyPoints, 0);
const completedPoints = sprintStories
.filter(story => story.status === 'done')
.reduce((sum, story) => sum + story.storyPoints, 0);
const sprintStart = sprint.startDate;
const sprintEnd = sprint.endDate;
const totalDays = Math.ceil((sprintEnd.getTime() - sprintStart.getTime()) / (1000 * 60 * 60 * 24));
const currentDate = new Date();
const daysElapsed = Math.min(
Math.ceil((currentDate.getTime() - sprintStart.getTime()) / (1000 * 60 * 60 * 24)),
totalDays
);
const idealBurnRate = totalStoryPoints / totalDays;
const dailyData = [];
for (let day = 0; day <= Math.min(daysElapsed, totalDays); day++) {
const idealRemaining = Math.max(0, totalStoryPoints - (idealBurnRate * day));
const actualRemaining = day === daysElapsed ? totalStoryPoints - completedPoints : idealRemaining;
dailyData.push({
day,
idealPoints: idealRemaining,
actualPoints: actualRemaining,
remainingPoints: actualRemaining,
completedPoints: totalStoryPoints - actualRemaining,
});
}
const onTrack = completedPoints >= (totalStoryPoints * (daysElapsed / totalDays)) * 0.8;
return {
sprintId,
totalStoryPoints,
completedPoints,
sprintDays: totalDays,
dailyData,
onTrack,
daysRemaining: Math.max(0, totalDays - daysElapsed),
trend: completedPoints > 0 ? 'improving' : 'stable',
};
}
async generateVelocityReport(options: VelocityOptions): Promise<VelocityReport> {
const completedSprints = Array.from(this.sprints.values())
.filter(sprint => sprint.status === 'completed')
.sort((a, b) => b.endDate.getTime() - a.endDate.getTime())
.slice(0, options.lastNSprints);
if (completedSprints.length === 0) {
throw new Error('No completed sprints found for velocity calculation');
}
const sprintData = completedSprints.map(sprint => {
const sprintStories = sprint.storyIds
.map(id => this.stories.get(id))
.filter(story => story !== undefined) as Story[];
const velocity = sprintStories
.filter(story => story.status === 'done')
.reduce((sum, story) => sum + story.storyPoints, 0);
const totalPoints = sprintStories.reduce((sum, story) => sum + story.storyPoints, 0);
const completionRate = totalPoints > 0 ? Math.round((velocity / totalPoints) * 100) : 0;
return {
sprintName: sprint.name,
velocity,
completionRate,
startDate: sprint.startDate,
endDate: sprint.endDate,
};
});
const velocities = sprintData.map(sprint => sprint.velocity);
const averageVelocity = Math.round(velocities.reduce((sum, v) => sum + v, 0) / velocities.length);
const highestVelocity = Math.max(...velocities);
const lowestVelocity = Math.min(...velocities);
const trend = velocities.length > 1
? velocities[0] > velocities[velocities.length - 1] ? 'improving' : 'declining'
: 'stable';
const insights = [
`Team completed ${completedSprints.length} sprints`,
`Average velocity: ${averageVelocity} story points`,
`Velocity range: ${lowestVelocity} - ${highestVelocity} points`,
];
const recommendations = [
averageVelocity < 20 ? 'Consider breaking down stories into smaller tasks' : '',
trend === 'declining' ? 'Review team capacity and remove blockers' : '',
'Track velocity consistently for better sprint planning',
].filter(rec => rec !== '');
return {
teamName: options.teamName || 'Default Team',
sprintsAnalyzed: completedSprints.length,
periodStart: completedSprints[completedSprints.length - 1].startDate,
periodEnd: completedSprints[0].endDate,
averageVelocity,
highestVelocity,
lowestVelocity,
trend,
sprintData,
insights,
recommendations,
};
}
async getSprintStatus(sprintId?: string): Promise<SprintStatus> {
let sprint: Sprint;
if (sprintId) {
if (!this.sprints.has(sprintId)) {
throw new Error(`Sprint ${sprintId} not found`);
}
sprint = this.sprints.get(sprintId)!;
} else {
// Get current active sprint
const activeSprints = Array.from(this.sprints.values())
.filter(s => s.status === 'active')
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
if (activeSprints.length === 0) {
throw new Error('No active sprint found');
}
sprint = activeSprints[0];
}
// Get full story details
const sprintStories = sprint.storyIds
.map(id => this.stories.get(id))
.filter(story => story !== undefined) as Story[];
// Get unique epic IDs from stories
const epicIds = new Set<string>();
sprintStories.forEach(story => {
if (story.epic) {
epicIds.add(story.epic);
}
});
// Get full epic details
const epics = Array.from(epicIds)
.map(epicId => this.epics.get(epicId))
.filter(epic => epic !== undefined) as Epic[];
const totalStories = sprintStories.length;
const completedStories = sprintStories.filter(story => story.status === 'done').length;
const inProgressStories = sprintStories.filter(story => story.status === 'in_progress').length;
const remainingStories = totalStories - completedStories - inProgressStories;
const totalStoryPoints = sprintStories.reduce((sum, story) => sum + story.storyPoints, 0);
const completedStoryPoints = sprintStories
.filter(story => story.status === 'done')
.reduce((sum, story) => sum + story.storyPoints, 0);
const blockers = this.standups
.filter(standup => standup.sprintId === sprint.id)
.flatMap(standup => standup.blockers)
.filter((blocker, index, arr) => arr.indexOf(blocker) === index);
const daysElapsed = Math.ceil((new Date().getTime() - sprint.startDate.getTime()) / (1000 * 60 * 60 * 24));
const sprintDuration = Math.ceil((sprint.endDate.getTime() - sprint.startDate.getTime()) / (1000 * 60 * 60 * 24));
const daysRemaining = Math.max(0, sprintDuration - daysElapsed);
const currentVelocity = daysElapsed > 0 ? Math.round(completedStoryPoints / daysElapsed) : 0;
// Calculate burndown data
const burndownData = this.calculateBurndownData(sprint, sprintStories);
const completionPercentage = totalStories > 0 ? (completedStories / totalStories) * 100 : 0;
const sprintHealth = completionPercentage > 75 ? 'Excellent' :
completionPercentage > 50 ? 'Good' :
completionPercentage > 25 ? 'At Risk' : 'Critical';
// Team metrics
const teamMembers = sprint.team || [];
const teamCapacity = sprint.capacity || 0;
const utilizationPercentage = teamCapacity > 0 ? (totalStoryPoints / teamCapacity) * 100 : 0;
return {
sprint,
stories: sprintStories, // Full story objects
epics, // Full epic objects
totalStories,
completedStories,
inProgressStories,
remainingStories,
totalStoryPoints,
completedStoryPoints,
remainingStoryPoints: totalStoryPoints - completedStoryPoints,
sprintHealth,
blockers,
currentVelocity,
metrics: {
velocity: sprint.velocity || currentVelocity,
capacity: teamCapacity,
utilization: utilizationPercentage,
burndownData,
health: sprintHealth.toLowerCase().replace(' ', '-') as 'excellent' | 'good' | 'at-risk' | 'critical',
daysElapsed,
daysRemaining,
sprintDuration
},
team: {
members: teamMembers,
availability: teamMembers.length,
blockers
}
};
}
private calculateBurndownData(sprint: Sprint, stories: Story[]): BurndownChart {
const sprintDuration = Math.ceil((sprint.endDate.getTime() - sprint.startDate.getTime()) / (1000 * 60 * 60 * 24));
const totalStoryPoints = stories.reduce((sum, story) => sum + story.storyPoints, 0);
const dailyData = [];
const now = new Date();
for (let day = 0; day <= sprintDuration; day++) {
const dayDate = new Date(sprint.startDate.getTime() + day * 24 * 60 * 60 * 1000);
if (dayDate > now) {
// Future date - project ideal burndown
const idealRemaining = totalStoryPoints * (1 - (day / sprintDuration));
dailyData.push({
day,
date: dayDate,
remainingPoints: Math.round(idealRemaining),
completedPoints: totalStoryPoints - Math.round(idealRemaining),
idealRemaining: Math.round(idealRemaining)
});
} else {
// Past or current date - calculate actual
const completedByDay = stories.filter(story => {
return story.status === 'done' &&
story.completedAt &&
story.completedAt <= dayDate;
}).reduce((sum, story) => sum + story.storyPoints, 0);
dailyData.push({
day,
date: dayDate,
remainingPoints: totalStoryPoints - completedByDay,
completedPoints: completedByDay,
idealRemaining: Math.round(totalStoryPoints * (1 - (day / sprintDuration)))
});
}
}
const currentDay = Math.min(
Math.floor((now.getTime() - sprint.startDate.getTime()) / (1000 * 60 * 60 * 24)),
sprintDuration
);
const completedPoints = stories
.filter(story => story.status === 'done')
.reduce((sum, story) => sum + story.storyPoints, 0);
const onTrack = completedPoints >= (totalStoryPoints * (currentDay / sprintDuration) * 0.9);
return {
sprintId: sprint.id,
totalStoryPoints,
completedPoints,
sprintDays: sprintDuration,
daysRemaining: Math.max(0, sprintDuration - currentDay),
dailyData,
onTrack,
trend: onTrack ? 'on-track' : 'behind'
};
}
async getBacklog(filter: BacklogFilter = {}): Promise<Story[]> {
let backlogStories = Array.from(this.stories.values())
.filter(story => !story.sprintId || story.sprintId === '');
// Apply filters
if (filter.epic) {
backlogStories = backlogStories.filter(story => story.epic === filter.epic);
}
if (filter.priority) {
backlogStories = backlogStories.filter(story => story.priority === filter.priority);
}
if (filter.assignee) {
backlogStories = backlogStories.filter(story => story.assignee === filter.assignee);
}
if (filter.status) {
backlogStories = backlogStories.filter(story => story.status === filter.status);
}
if (filter.maxStoryPoints) {
backlogStories = backlogStories.filter(story =>
story.storyPoints !== undefined && story.storyPoints <= filter.maxStoryPoints!
);
}
// Sort by priority and creation date
const priorityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
return backlogStories.sort((a, b) => {
const priorityDiff = (priorityOrder[b.priority] || 0) - (priorityOrder[a.priority] || 0);
if (priorityDiff !== 0) return priorityDiff;
return b.createdAt.getTime() - a.createdAt.getTime();
});
}
async getSprints(filter?: { status?: string; includeCompleted?: boolean }): Promise<Sprint[]> {
let sprints = Array.from(this.sprints.values());
// Apply filters
if (filter?.status) {
sprints = sprints.filter(sprint => sprint.status === filter.status);
}
if (filter?.includeCompleted === false) {
sprints = sprints.filter(sprint => sprint.status !== 'completed');
}
// Sort by start date (most recent first)
return sprints.sort((a, b) => b.startDate.getTime() - a.startDate.getTime());
}
async getSprint(sprintId: string): Promise<Sprint | null> {
const sprint = this.sprints.get(sprintId);
return sprint || null;
}
async getActiveSprint(): Promise<Sprint | null> {
const sprints = Array.from(this.sprints.values());
const activeSprint = sprints.find(sprint => sprint.status === 'active');
return activeSprint || null;
}
async getEpics(filter?: { status?: string; owner?: string }): Promise<Epic[]> {
let epics = Array.from(this.epics.values());
// Apply filters
if (filter?.status) {
epics = epics.filter(epic => epic.status === filter.status);
}
if (filter?.owner) {
epics = epics.filter(epic => epic.owner === filter.owner);
}
// Sort by priority and creation date
const priorityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
return epics.sort((a, b) => {
const priorityDiff = (priorityOrder[b.priority] || 0) - (priorityOrder[a.priority] || 0);
if (priorityDiff !== 0) return priorityDiff;
return b.createdAt.getTime() - a.createdAt.getTime();
});
}
async getEpic(epicId: string): Promise<Epic | null> {
const epic = this.epics.get(epicId);
return epic || null;
}
async getStory(storyId: string): Promise<Story | null> {
const story = this.stories.get(storyId);
return story || null;
}
async getAllStories(): Promise<Story[]> {
return Array.from(this.stories.values());
}
async getStoriesForSprint(sprintId: string): Promise<Story[]> {
return Array.from(this.stories.values())
.filter(story => story.sprintId === sprintId);
}
async getEpicsForSprint(sprintId: string): Promise<Epic[]> {
const sprint = this.sprints.get(sprintId);
if (!sprint) {
return [];
}
const epicIds = new Set<string>();
for (const storyId of sprint.storyIds) {
const story = this.stories.get(storyId);
if (story && story.epic) {
epicIds.add(story.epic);
}
}
const epics: Epic[] = [];
for (const epicId of epicIds) {
const epic = this.epics.get(epicId);
if (epic) {
epics.push(epic);
}
}
return epics;
}
async getStoriesForEpicInSprint(epicId: string, sprintId: string): Promise<Story[]> {
const sprint = this.sprints.get(sprintId);
if (!sprint) {
return [];
}
const stories: Story[] = [];
for (const storyId of sprint.storyIds) {
const story = this.stories.get(storyId);
if (story && story.epic === epicId) {
stories.push(story);
}
}
return stories;
}
async getSprintProgress(sprintId: string): Promise<{ epicProgress: Map<string, { total: number; completed: number }> }> {
const sprint = this.sprints.get(sprintId);
if (!sprint) {
return { epicProgress: new Map() };
}
const epicProgress = new Map<string, { total: number; completed: number }>();
for (const storyId of sprint.storyIds) {
const story = this.stories.get(storyId);
if (story && story.epic) {
const current = epicProgress.get(story.epic) || { total: 0, completed: 0 };
current.total += story.storyPoints;
if (story.status === 'done') {
current.completed += story.storyPoints;
}
epicProgress.set(story.epic, current);
}
}
return { epicProgress };
}
async addEpicToSprint(epicId: string, sprintId: string): Promise<void> {
const sprint = this.sprints.get(sprintId);
const epic = this.epics.get(epicId);
if (!sprint) {
throw new Error(`Sprint ${sprintId} not found`);
}
if (!epic) {
throw new Error(`Epic ${epicId} not found`);
}
if (!sprint.epicIds) {
sprint.epicIds = [];
}
if (!sprint.epicIds.includes(epicId)) {
sprint.epicIds.push(epicId);
this.sprints.set(sprintId, sprint);
await this.saveSprints();
}
}
async removeEpicFromSprint(epicId: string, sprintId: string): Promise<void> {
const sprint = this.sprints.get(sprintId);
if (!sprint) {
throw new Error(`Sprint ${sprintId} not found`);
}
if (sprint.epicIds) {
sprint.epicIds = sprint.epicIds.filter(id => id !== epicId);
this.sprints.set(sprintId, sprint);
await this.saveSprints();
}
}
async getEpicsForSprintDirect(sprintId: string): Promise<Epic[]> {
const sprint = this.sprints.get(sprintId);
if (!sprint || !sprint.epicIds) {
return [];
}
const epics: Epic[] = [];
for (const epicId of sprint.epicIds) {
const epic = this.epics.get(epicId);
if (epic) {
epics.push(epic);
}
}
return epics;
}
private async saveSprints(): Promise<void> {
const sprintsPath = path.join(this.agileDataPath, 'sprints.json');
await fs.mkdir(path.dirname(sprintsPath), { recursive: true });
await fs.writeFile(sprintsPath, JSON.stringify(Array.from(this.sprints.values()), null, 2));
}
private async saveStories(): Promise<void> {
const storiesPath = path.join(this.agileDataPath, 'stories.json');
await fs.mkdir(path.dirname(storiesPath), { recursive: true });
await fs.writeFile(storiesPath, JSON.stringify(Array.from(this.stories.values()), null, 2));
}
private async saveEpics(): Promise<void> {
const epicsPath = path.join(this.agileDataPath, 'epics.json');
await fs.mkdir(path.dirname(epicsPath), { recursive: true });
await fs.writeFile(epicsPath, JSON.stringify(Array.from(this.epics.values()), null, 2));
}
private async saveStandups(): Promise<void> {
const standupsPath = path.join(this.agileDataPath, 'standups.json');
await fs.mkdir(path.dirname(standupsPath), { recursive: true });
await fs.writeFile(standupsPath, JSON.stringify(this.standups, null, 2));
}
private async saveRetrospectives(): Promise<void> {
const retrospectivesPath = path.join(this.agileDataPath, 'retrospectives.json');
await fs.mkdir(path.dirname(retrospectivesPath), { recursive: true });
await fs.writeFile(retrospectivesPath, JSON.stringify(this.retrospectives, null, 2));
}
private generateId(): string {
return `${Date.now().toString(16)}-${Math.random().toString(16).substring(2, 10)}`;
}
private async createSampleData(): Promise<void> {
console.log('🔧 Creating sample agile data for dashboard');
try {
// Create sample epic
const epic = await this.createEpic({
title: 'User Authentication System',
description: 'Implement comprehensive user authentication including login, registration, password reset, and session management',
goals: [
'Secure user registration and login',
'Session management with JWT tokens',
'Password reset functionality',
'Multi-factor authentication support'
],
priority: 'high',
owner: 'Product Team'
});
// Create sample sprint
const sprint = await this.createSprint({
name: 'Sprint 1 - Authentication Foundation',
goal: 'Build core authentication infrastructure and user login flow',
duration: 14,
startDate: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000), // Started 5 days ago
team: ['Alice Johnson', 'Bob Chen', 'Carol Davis']
});
// Update sprint to active status
await this.updateSprint(sprint.id, {
status: 'active',
capacity: 25
});
// Create sample stories
const stories = [
{
title: 'User Registration API',
description: 'Create REST API endpoints for user registration with email validation',
storyPoints: 8,
priority: 'high' as const,
status: 'done' as const,
acceptanceCriteria: [
'API accepts email, password, and basic profile info',
'Email validation is performed',
'Password meets security requirements',
'User account is created in database',
'Confirmation email is sent'
],
assignee: 'Alice Johnson',
epic: epic.id,
sprintId: sprint.id,
tags: ['backend', 'api', 'security']
},
{
title: 'Login Form UI',
description: 'Design and implement responsive login form with validation',
storyPoints: 5,
priority: 'high' as const,
status: 'in_progress' as const,
acceptanceCriteria: [
'Responsive design works on mobile and desktop',
'Real-time validation feedback',
'Clear error messages',
'Remember me checkbox',
'Forgot password link'
],
assignee: 'Bob Chen',
epic: epic.id,
sprintId: sprint.id,
tags: ['frontend', 'ui', 'forms']
},
{
title: 'JWT Token Management',
description: 'Implement JWT token generation, validation, and refresh logic',
storyPoints: 8,
priority: 'high' as const,
status: 'review' as const,
acceptanceCriteria: [
'Tokens are generated on successful login',
'Token expiration is handled',
'Refresh token functionality',
'Token validation middleware',
'Secure token storage'
],
assignee: 'Carol Davis',
epic: epic.id,
sprintId: sprint.id,
tags: ['backend', 'security', 'authentication']
},
{
title: 'Password Reset Flow',
description: 'Build password reset functionality with secure email tokens',
storyPoints: 5,
priority: 'medium' as const,
status: 'todo' as const,
acceptanceCriteria: [
'Password reset email generation',
'Secure reset token creation',