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:
Yaojia Wang
2025-11-05 00:32:13 +01:00
parent 58e08f9fa7
commit 80c09e398f
4 changed files with 202 additions and 0 deletions

View File

@@ -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);
}
}

View File

@@ -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
);

View File

@@ -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?>;

View File

@@ -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;
}
}