Files
ColaFlow/docs/plans/sprint_2_story_3_task_4.md
Yaojia Wang ebb56cc9f8 feat(backend): Create Sprint 2 backend Stories and Tasks
Created detailed implementation plans for Sprint 2 backend work:

Story 1: Audit Log Foundation (Phase 1)
- Task 1: Design AuditLog database schema and create migration
- Task 2: Create AuditLog entity and Repository
- Task 3: Implement EF Core SaveChangesInterceptor
- Task 4: Write unit tests for audit logging
- Task 5: Integrate with ProjectManagement Module

Story 2: Audit Log Core Features (Phase 2)
- Task 1: Implement Changed Fields Detection (JSON Diff)
- Task 2: Integrate User Context Tracking
- Task 3: Add Multi-Tenant Isolation
- Task 4: Implement Audit Query API
- Task 5: Write Integration Tests

Story 3: Sprint Management Module
- Task 1: Create Sprint Aggregate Root and Domain Events
- Task 2: Implement Sprint Repository and EF Core Configuration
- Task 3: Create CQRS Commands and Queries
- Task 4: Implement Burndown Chart Calculation
- Task 5: Add SignalR Real-Time Notifications
- Task 6: Write Integration Tests

Total: 3 Stories, 16 Tasks, 24 Story Points (8+8+8)
Estimated Duration: 10-12 days

All tasks include:
- Detailed technical implementation guidance
- Code examples and file paths
- Testing requirements (>= 90% coverage)
- Performance benchmarks (< 5ms audit overhead)
- Multi-tenant security validation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 22:56:31 +01:00

9.6 KiB

task_id, story, status, estimated_hours, created_date, assignee
task_id story status estimated_hours created_date assignee
sprint_2_story_3_task_4 sprint_2_story_3 not_started 4 2025-11-05 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
public record GetSprintBurndownQuery(Guid SprintId) : IRequest<BurndownDto>;

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<BurndownDataPoint> IdealBurndown { get; set; } = new();
    public List<BurndownDataPoint> ActualBurndown { get; set; } = new();
    public double CompletionPercentage { get; set; }
}

public class BurndownDataPoint
{
    public DateTime Date { get; set; }
    public int StoryPoints { get; set; }
}
  1. Query Handler: colaflow-api/src/ColaFlow.Application/Sprints/Queries/GetSprintBurndown/GetSprintBurndownQueryHandler.cs
public class GetSprintBurndownQueryHandler : IRequestHandler<GetSprintBurndownQuery, BurndownDto>
{
    private readonly ISprintRepository _sprintRepository;
    private readonly IAuditLogRepository _auditLogRepository; // For historical data
    private readonly ILogger<GetSprintBurndownQueryHandler> _logger;

    public GetSprintBurndownQueryHandler(
        ISprintRepository sprintRepository,
        IAuditLogRepository auditLogRepository,
        ILogger<GetSprintBurndownQueryHandler> logger)
    {
        _sprintRepository = sprintRepository;
        _auditLogRepository = auditLogRepository;
        _logger = logger;
    }

    public async Task<BurndownDto> 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<BurndownDataPoint> CalculateIdealBurndown(
        DateTime startDate,
        DateTime endDate,
        int totalStoryPoints)
    {
        var idealBurndown = new List<BurndownDataPoint>();
        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<List<BurndownDataPoint>> CalculateActualBurndownAsync(
        Sprint sprint,
        int totalStoryPoints,
        CancellationToken cancellationToken)
    {
        var actualBurndown = new List<BurndownDataPoint>();

        // 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<List<TaskCompletion>> 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<TaskCompletion>();

        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; }
    }
}
  1. Add Controller Endpoint: Update SprintsController.cs
[HttpGet("{id}/burndown")]
[ProducesResponseType(typeof(BurndownDto), StatusCodes.Status200OK)]
public async Task<IActionResult> GetSprintBurndown([FromRoute] Guid id)
{
    var burndown = await _mediator.Send(new GetSprintBurndownQuery(id));
    return Ok(burndown);
}

Example Response:

{
  "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:

[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