feat(backend): Implement Burndown Chart calculation - Sprint 2 Story 3 Task 4
Implemented comprehensive burndown chart data calculation for sprint progress tracking.
Features:
- Created BurndownChartDto with ideal and actual burndown data points
- Implemented GetSprintBurndownQuery and Handler
- Added ideal burndown calculation (linear decrease)
- Implemented actual burndown based on task completion dates
- Calculated completion percentage
- Added GET /api/v1/sprints/{id}/burndown endpoint
Technical Details:
- MVP uses task count as story points (simplified)
- Actual burndown uses task UpdatedAt as completion date approximation
- Ideal burndown follows linear progression from total to zero
- Multi-tenant isolation enforced through existing query filters
Future Enhancements (Phase 2):
- Add StoryPoints property to WorkTask entity
- Use audit logs for exact completion timestamps
- Handle scope changes (tasks added/removed mid-sprint)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ using ColaFlow.Modules.ProjectManagement.Application.Commands.RemoveTaskFromSpri
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Queries.GetSprintById;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Queries.GetSprintsByProjectId;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Queries.GetActiveSprints;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Queries.GetSprintBurndown;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
|
||||
|
||||
namespace ColaFlow.API.Controllers;
|
||||
@@ -134,4 +135,16 @@ public class SprintsController : ControllerBase
|
||||
await _mediator.Send(new RemoveTaskFromSprintCommand(id, taskId));
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get burndown chart data for a sprint
|
||||
/// </summary>
|
||||
[HttpGet("{id}/burndown")]
|
||||
public async Task<ActionResult<BurndownChartDto>> GetBurndown(Guid id)
|
||||
{
|
||||
var result = await _mediator.Send(new GetSprintBurndownQuery(id));
|
||||
if (result == null)
|
||||
return NotFound();
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetSprintBurndown;
|
||||
|
||||
/// <summary>
|
||||
/// Burndown Chart Data Transfer Object
|
||||
/// </summary>
|
||||
public record BurndownChartDto(
|
||||
Guid SprintId,
|
||||
string SprintName,
|
||||
DateTime StartDate,
|
||||
DateTime EndDate,
|
||||
int TotalStoryPoints,
|
||||
int RemainingStoryPoints,
|
||||
double CompletionPercentage,
|
||||
List<BurndownDataPoint> IdealBurndown,
|
||||
List<BurndownDataPoint> ActualBurndown
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Single data point in the burndown chart
|
||||
/// </summary>
|
||||
public record BurndownDataPoint(
|
||||
DateTime Date,
|
||||
int StoryPoints
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetSprintBurndown;
|
||||
|
||||
/// <summary>
|
||||
/// Query to get burndown chart data for a sprint
|
||||
/// </summary>
|
||||
public record GetSprintBurndownQuery(Guid SprintId) : IRequest<BurndownChartDto?>;
|
||||
@@ -0,0 +1,157 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ColaFlow.Modules.ProjectManagement.Application.Common.Interfaces;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.Repositories;
|
||||
using ColaFlow.Modules.ProjectManagement.Domain.ValueObjects;
|
||||
|
||||
namespace ColaFlow.Modules.ProjectManagement.Application.Queries.GetSprintBurndown;
|
||||
|
||||
/// <summary>
|
||||
/// Handler for GetSprintBurndownQuery
|
||||
/// Calculates ideal and actual burndown data for sprint progress visualization
|
||||
/// </summary>
|
||||
public sealed class GetSprintBurndownQueryHandler : IRequestHandler<GetSprintBurndownQuery, BurndownChartDto?>
|
||||
{
|
||||
private readonly IProjectRepository _projectRepository;
|
||||
private readonly IApplicationDbContext _context;
|
||||
private readonly ILogger<GetSprintBurndownQueryHandler> _logger;
|
||||
|
||||
public GetSprintBurndownQueryHandler(
|
||||
IProjectRepository projectRepository,
|
||||
IApplicationDbContext context,
|
||||
ILogger<GetSprintBurndownQueryHandler> logger)
|
||||
{
|
||||
_projectRepository = projectRepository ?? throw new ArgumentNullException(nameof(projectRepository));
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<BurndownChartDto?> Handle(GetSprintBurndownQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. Get Sprint
|
||||
var sprintId = SprintId.From(request.SprintId);
|
||||
var sprint = await _projectRepository.GetSprintByIdReadOnlyAsync(sprintId, cancellationToken);
|
||||
|
||||
if (sprint == null)
|
||||
{
|
||||
_logger.LogWarning("Sprint not found: {SprintId}", request.SprintId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Get all tasks in this sprint
|
||||
var taskIds = sprint.TaskIds.Select(t => t.Value).ToList();
|
||||
var tasks = await _context.Tasks
|
||||
.Where(t => taskIds.Contains(t.Id.Value))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 3. Calculate total story points (simplified: use EstimatedHours as story points for MVP)
|
||||
// In Phase 2, add StoryPoints property to WorkTask
|
||||
var totalPoints = tasks.Count; // Simple count for MVP
|
||||
var completedTasks = tasks.Where(t => t.Status.Name == "Done").ToList();
|
||||
var remainingPoints = totalPoints - completedTasks.Count;
|
||||
|
||||
// 4. Calculate ideal burndown (linear)
|
||||
var idealBurndown = CalculateIdealBurndown(sprint.StartDate, sprint.EndDate, totalPoints);
|
||||
|
||||
// 5. Calculate actual burndown (based on task completion dates)
|
||||
var actualBurndown = CalculateActualBurndown(sprint.StartDate, sprint.EndDate, tasks, totalPoints);
|
||||
|
||||
// 6. Calculate completion percentage
|
||||
var completionPercentage = totalPoints > 0
|
||||
? Math.Round((double)(totalPoints - remainingPoints) / totalPoints * 100, 2)
|
||||
: 0;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Calculated burndown for Sprint {SprintId}: Total={Total}, Remaining={Remaining}, Completion={Completion}%",
|
||||
request.SprintId, totalPoints, remainingPoints, completionPercentage);
|
||||
|
||||
return new BurndownChartDto(
|
||||
sprint.Id.Value,
|
||||
sprint.Name,
|
||||
sprint.StartDate,
|
||||
sprint.EndDate,
|
||||
totalPoints,
|
||||
remainingPoints,
|
||||
completionPercentage,
|
||||
idealBurndown,
|
||||
actualBurndown
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate ideal burndown - linear decrease from total to 0
|
||||
/// </summary>
|
||||
private List<BurndownDataPoint> CalculateIdealBurndown(DateTime startDate, DateTime endDate, int totalPoints)
|
||||
{
|
||||
var dataPoints = new List<BurndownDataPoint>();
|
||||
var totalDays = (endDate.Date - startDate.Date).Days;
|
||||
|
||||
if (totalDays <= 0)
|
||||
{
|
||||
dataPoints.Add(new BurndownDataPoint(startDate.Date, totalPoints));
|
||||
return dataPoints;
|
||||
}
|
||||
|
||||
var pointsPerDay = (double)totalPoints / totalDays;
|
||||
|
||||
for (int day = 0; day <= totalDays; day++)
|
||||
{
|
||||
var date = startDate.Date.AddDays(day);
|
||||
var remaining = totalPoints - (int)Math.Round(pointsPerDay * day);
|
||||
dataPoints.Add(new BurndownDataPoint(date, Math.Max(0, remaining)));
|
||||
}
|
||||
|
||||
return dataPoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculate actual burndown based on task completion dates
|
||||
/// MVP: Uses UpdatedAt timestamp as completion date approximation
|
||||
/// Phase 2: Use audit logs for exact completion dates
|
||||
/// </summary>
|
||||
private List<BurndownDataPoint> CalculateActualBurndown(
|
||||
DateTime startDate,
|
||||
DateTime endDate,
|
||||
List<Domain.Aggregates.ProjectAggregate.WorkTask> tasks,
|
||||
int totalPoints)
|
||||
{
|
||||
var dataPoints = new List<BurndownDataPoint>();
|
||||
var currentDate = startDate.Date;
|
||||
var finalDate = DateTime.UtcNow.Date < endDate.Date ? DateTime.UtcNow.Date : endDate.Date;
|
||||
|
||||
// Get completed tasks with their completion dates (approximated by UpdatedAt)
|
||||
var completedTasks = tasks
|
||||
.Where(t => t.Status.Name == "Done" && t.UpdatedAt.HasValue)
|
||||
.Select(t => new
|
||||
{
|
||||
Task = t,
|
||||
CompletedDate = t.UpdatedAt!.Value.Date
|
||||
})
|
||||
.OrderBy(t => t.CompletedDate)
|
||||
.ToList();
|
||||
|
||||
var remainingPoints = totalPoints;
|
||||
|
||||
// Generate daily data points
|
||||
while (currentDate <= finalDate)
|
||||
{
|
||||
// Count tasks completed by this date (before end of day)
|
||||
var completedByDate = completedTasks
|
||||
.Count(tc => tc.CompletedDate <= currentDate);
|
||||
|
||||
remainingPoints = totalPoints - completedByDate;
|
||||
|
||||
dataPoints.Add(new BurndownDataPoint(currentDate, Math.Max(0, remainingPoints)));
|
||||
currentDate = currentDate.AddDays(1);
|
||||
}
|
||||
|
||||
// If no data points, add at least start point
|
||||
if (dataPoints.Count == 0)
|
||||
{
|
||||
dataPoints.Add(new BurndownDataPoint(startDate.Date, totalPoints));
|
||||
}
|
||||
|
||||
return dataPoints;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user