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.GetSprintById;
|
||||||
using ColaFlow.Modules.ProjectManagement.Application.Queries.GetSprintsByProjectId;
|
using ColaFlow.Modules.ProjectManagement.Application.Queries.GetSprintsByProjectId;
|
||||||
using ColaFlow.Modules.ProjectManagement.Application.Queries.GetActiveSprints;
|
using ColaFlow.Modules.ProjectManagement.Application.Queries.GetActiveSprints;
|
||||||
|
using ColaFlow.Modules.ProjectManagement.Application.Queries.GetSprintBurndown;
|
||||||
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
|
using ColaFlow.Modules.ProjectManagement.Application.DTOs;
|
||||||
|
|
||||||
namespace ColaFlow.API.Controllers;
|
namespace ColaFlow.API.Controllers;
|
||||||
@@ -134,4 +135,16 @@ public class SprintsController : ControllerBase
|
|||||||
await _mediator.Send(new RemoveTaskFromSprintCommand(id, taskId));
|
await _mediator.Send(new RemoveTaskFromSprintCommand(id, taskId));
|
||||||
return NoContent();
|
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