--- task_id: sprint_2_story_3_task_4 story: sprint_2_story_3 status: not_started estimated_hours: 4 created_date: 2025-11-05 assignee: Backend Team --- # Task 4: Implement Burndown Chart Calculation **Story**: Story 3 - Sprint Management Module **Estimated**: 4 hours ## Description Implement burndown chart data calculation to track sprint progress. Calculate daily remaining story points and provide data for visualization. ## Acceptance Criteria - [ ] GetSprintBurndownQuery implemented - [ ] Daily remaining story points calculated - [ ] Ideal burndown line calculated - [ ] Actual burndown line calculated - [ ] BurndownDto with chart data created - [ ] Unit tests for calculation logic ## Implementation Details **Files to Create**: 1. **Burndown Query**: `colaflow-api/src/ColaFlow.Application/Sprints/Queries/GetSprintBurndown/GetSprintBurndownQuery.cs` ```csharp public record GetSprintBurndownQuery(Guid SprintId) : IRequest; public class BurndownDto { public Guid SprintId { get; set; } public string SprintName { get; set; } = string.Empty; public DateTime StartDate { get; set; } public DateTime EndDate { get; set; } public int TotalStoryPoints { get; set; } public int RemainingStoryPoints { get; set; } public List IdealBurndown { get; set; } = new(); public List ActualBurndown { get; set; } = new(); public double CompletionPercentage { get; set; } } public class BurndownDataPoint { public DateTime Date { get; set; } public int StoryPoints { get; set; } } ``` 2. **Query Handler**: `colaflow-api/src/ColaFlow.Application/Sprints/Queries/GetSprintBurndown/GetSprintBurndownQueryHandler.cs` ```csharp public class GetSprintBurndownQueryHandler : IRequestHandler { private readonly ISprintRepository _sprintRepository; private readonly IAuditLogRepository _auditLogRepository; // For historical data private readonly ILogger _logger; public GetSprintBurndownQueryHandler( ISprintRepository sprintRepository, IAuditLogRepository auditLogRepository, ILogger logger) { _sprintRepository = sprintRepository; _auditLogRepository = auditLogRepository; _logger = logger; } public async Task Handle(GetSprintBurndownQuery request, CancellationToken cancellationToken) { var sprint = await _sprintRepository.GetByIdAsync(request.SprintId, cancellationToken); if (sprint == null) throw new NotFoundException(nameof(Sprint), request.SprintId); var totalStoryPoints = sprint.Tasks.Sum(t => t.StoryPoints ?? 0); var remainingStoryPoints = sprint.Tasks .Where(t => t.Status != WorkTaskStatus.Done) .Sum(t => t.StoryPoints ?? 0); var burndownDto = new BurndownDto { SprintId = sprint.Id, SprintName = sprint.Name, StartDate = sprint.StartDate, EndDate = sprint.EndDate, TotalStoryPoints = totalStoryPoints, RemainingStoryPoints = remainingStoryPoints, CompletionPercentage = totalStoryPoints > 0 ? ((totalStoryPoints - remainingStoryPoints) / (double)totalStoryPoints) * 100 : 0 }; // Calculate ideal burndown burndownDto.IdealBurndown = CalculateIdealBurndown( sprint.StartDate, sprint.EndDate, totalStoryPoints ); // Calculate actual burndown burndownDto.ActualBurndown = await CalculateActualBurndownAsync( sprint, totalStoryPoints, cancellationToken ); return burndownDto; } private List CalculateIdealBurndown( DateTime startDate, DateTime endDate, int totalStoryPoints) { var idealBurndown = new List(); var sprintDuration = (endDate - startDate).Days; if (sprintDuration <= 0) return idealBurndown; var dailyBurnRate = totalStoryPoints / (double)sprintDuration; for (int day = 0; day <= sprintDuration; day++) { var date = startDate.AddDays(day); var remainingPoints = (int)Math.Round(totalStoryPoints - (day * dailyBurnRate)); idealBurndown.Add(new BurndownDataPoint { Date = date, StoryPoints = Math.Max(0, remainingPoints) }); } return idealBurndown; } private async Task> CalculateActualBurndownAsync( Sprint sprint, int totalStoryPoints, CancellationToken cancellationToken) { var actualBurndown = new List(); // Start with total story points actualBurndown.Add(new BurndownDataPoint { Date = sprint.StartDate, StoryPoints = totalStoryPoints }); // Get task completion history from audit logs var taskCompletions = await GetTaskCompletionHistoryAsync(sprint.Id, cancellationToken); var currentDate = sprint.StartDate.Date; var endDate = sprint.Status == SprintStatus.Completed ? sprint.EndDate.Date : DateTime.UtcNow.Date; var remainingPoints = totalStoryPoints; while (currentDate <= endDate) { currentDate = currentDate.AddDays(1); // Get tasks completed on this day var completedOnDay = taskCompletions .Where(tc => tc.CompletedDate.Date == currentDate) .Sum(tc => tc.StoryPoints); remainingPoints -= completedOnDay; actualBurndown.Add(new BurndownDataPoint { Date = currentDate, StoryPoints = Math.Max(0, remainingPoints) }); } return actualBurndown; } private async Task> GetTaskCompletionHistoryAsync( Guid sprintId, CancellationToken cancellationToken) { // Query audit logs for task status changes to "Done" // This gives us historical completion data for accurate burndown // For MVP, use current task status // TODO: Implement audit log query for historical data in Phase 2 var sprint = await _sprintRepository.GetByIdAsync(sprintId, cancellationToken); if (sprint == null) return new List(); return sprint.Tasks .Where(t => t.Status == WorkTaskStatus.Done) .Select(t => new TaskCompletion { TaskId = t.Id, StoryPoints = t.StoryPoints ?? 0, CompletedDate = t.UpdatedAt ?? DateTime.UtcNow // Approximation for MVP }) .ToList(); } private class TaskCompletion { public Guid TaskId { get; set; } public int StoryPoints { get; set; } public DateTime CompletedDate { get; set; } } } ``` 3. **Add Controller Endpoint**: Update `SprintsController.cs` ```csharp [HttpGet("{id}/burndown")] [ProducesResponseType(typeof(BurndownDto), StatusCodes.Status200OK)] public async Task GetSprintBurndown([FromRoute] Guid id) { var burndown = await _mediator.Send(new GetSprintBurndownQuery(id)); return Ok(burndown); } ``` **Example Response**: ```json { "sprintId": "abc-123", "sprintName": "Sprint 1", "startDate": "2025-11-01", "endDate": "2025-11-14", "totalStoryPoints": 50, "remainingStoryPoints": 20, "completionPercentage": 60, "idealBurndown": [ { "date": "2025-11-01", "storyPoints": 50 }, { "date": "2025-11-02", "storyPoints": 46 }, { "date": "2025-11-03", "storyPoints": 42 }, ... { "date": "2025-11-14", "storyPoints": 0 } ], "actualBurndown": [ { "date": "2025-11-01", "storyPoints": 50 }, { "date": "2025-11-02", "storyPoints": 50 }, { "date": "2025-11-03", "storyPoints": 45 }, { "date": "2025-11-04", "storyPoints": 38 }, ... { "date": "2025-11-05", "storyPoints": 20 } ] } ``` ## Technical Notes **Ideal Burndown**: - Linear decrease from total story points to 0 - Formula: `Remaining = Total - (Day * DailyBurnRate)` - DailyBurnRate = `TotalStoryPoints / SprintDuration` **Actual Burndown**: - Based on real task completions - Uses audit logs for historical accuracy (Phase 2) - MVP uses task UpdatedAt timestamp (approximation) **Future Enhancements** (Phase 2): - Query audit logs for exact task completion dates - Handle scope changes (tasks added/removed mid-sprint) - Velocity trend analysis - Sprint capacity planning ## Testing **Unit Tests**: ```csharp [Fact] public async Task GetSprintBurndown_ShouldCalculateIdealBurndown() { // Arrange var sprint = CreateTestSprint( startDate: new DateTime(2025, 11, 1), endDate: new DateTime(2025, 11, 14), totalStoryPoints: 50 ); // Act var burndown = await _handler.Handle(new GetSprintBurndownQuery(sprint.Id), default); // Assert Assert.Equal(15, burndown.IdealBurndown.Count); // 14 days + 1 Assert.Equal(50, burndown.IdealBurndown.First().StoryPoints); Assert.Equal(0, burndown.IdealBurndown.Last().StoryPoints); } [Fact] public async Task GetSprintBurndown_ShouldCalculateCompletionPercentage() { // Arrange var sprint = CreateTestSprintWithTasks(totalPoints: 100, completedPoints: 60); // Act var burndown = await _handler.Handle(new GetSprintBurndownQuery(sprint.Id), default); // Assert Assert.Equal(60, burndown.CompletionPercentage); } ``` --- **Created**: 2025-11-05 by Backend Agent