From 84967974370b7607fa012bda5853eeed5fd56c2a Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 1 Apr 2026 22:01:32 +0100 Subject: [PATCH 01/15] Add board metrics DTOs for throughput, cycle time, WIP, and blocked cards --- .../DTOs/BoardMetricsDtos.cs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 backend/src/Taskdeck.Application/DTOs/BoardMetricsDtos.cs diff --git a/backend/src/Taskdeck.Application/DTOs/BoardMetricsDtos.cs b/backend/src/Taskdeck.Application/DTOs/BoardMetricsDtos.cs new file mode 100644 index 000000000..631b71e5b --- /dev/null +++ b/backend/src/Taskdeck.Application/DTOs/BoardMetricsDtos.cs @@ -0,0 +1,59 @@ +namespace Taskdeck.Application.DTOs; + +/// +/// Query parameters for board metrics. +/// +public sealed record BoardMetricsQuery( + Guid BoardId, + DateTimeOffset From, + DateTimeOffset To, + Guid? LabelId = null); + +/// +/// Throughput data point: number of cards completed in a time bucket. +/// +public sealed record ThroughputDataPoint( + DateTimeOffset Date, + int CompletedCount); + +/// +/// Cycle time entry: how long a card took from creation to reaching the +/// final (rightmost) column. +/// +public sealed record CycleTimeEntry( + Guid CardId, + string CardTitle, + double CycleTimeDays); + +/// +/// Snapshot of work-in-progress across columns. +/// +public sealed record WipSnapshot( + Guid ColumnId, + string ColumnName, + int CardCount, + int? WipLimit); + +/// +/// Blocked card summary. +/// +public sealed record BlockedCardSummary( + Guid CardId, + string CardTitle, + string? BlockReason, + double BlockedDurationDays); + +/// +/// Aggregate response containing all board metrics. +/// +public sealed record BoardMetricsResponse( + Guid BoardId, + DateTimeOffset From, + DateTimeOffset To, + IReadOnlyList Throughput, + double AverageCycleTimeDays, + IReadOnlyList CycleTimeEntries, + IReadOnlyList WipSnapshots, + int TotalWip, + int BlockedCount, + IReadOnlyList BlockedCards); From ccf3d1e0f2b0d61ae8398cd3457bb7877d06f699 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 1 Apr 2026 22:01:40 +0100 Subject: [PATCH 02/15] Add IBoardMetricsService interface for board analytics --- .../Services/IBoardMetricsService.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 backend/src/Taskdeck.Application/Services/IBoardMetricsService.cs diff --git a/backend/src/Taskdeck.Application/Services/IBoardMetricsService.cs b/backend/src/Taskdeck.Application/Services/IBoardMetricsService.cs new file mode 100644 index 000000000..2a0ef74b1 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/IBoardMetricsService.cs @@ -0,0 +1,16 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Common; + +namespace Taskdeck.Application.Services; + +public interface IBoardMetricsService +{ + /// + /// Compute board metrics (throughput, cycle time, WIP, blocked) for the + /// given board and date range. The acting user must have read access. + /// + Task> GetBoardMetricsAsync( + BoardMetricsQuery query, + Guid actingUserId, + CancellationToken cancellationToken = default); +} From 4bdb7f547d514e029cfdf883d84d6e244186c8a3 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 1 Apr 2026 22:01:45 +0100 Subject: [PATCH 03/15] Add BoardMetricsService with throughput, cycle time, WIP, and blocked computation --- .../Services/BoardMetricsService.cs | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 backend/src/Taskdeck.Application/Services/BoardMetricsService.cs diff --git a/backend/src/Taskdeck.Application/Services/BoardMetricsService.cs b/backend/src/Taskdeck.Application/Services/BoardMetricsService.cs new file mode 100644 index 000000000..4ca5600bf --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/BoardMetricsService.cs @@ -0,0 +1,193 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Enums; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Application.Services; + +public class BoardMetricsService : IBoardMetricsService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IAuthorizationService? _authorizationService; + + public BoardMetricsService(IUnitOfWork unitOfWork) + : this(unitOfWork, authorizationService: null) + { + } + + public BoardMetricsService( + IUnitOfWork unitOfWork, + IAuthorizationService? authorizationService) + { + _unitOfWork = unitOfWork; + _authorizationService = authorizationService; + } + + public async Task> GetBoardMetricsAsync( + BoardMetricsQuery query, + Guid actingUserId, + CancellationToken cancellationToken = default) + { + if (query.BoardId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "Board ID is required"); + + if (actingUserId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "Acting user ID cannot be empty"); + + if (query.From >= query.To) + return Result.Failure(ErrorCodes.ValidationError, "From date must be before To date"); + + // Enforce read permission + if (_authorizationService != null) + { + var canRead = await _authorizationService.CanReadBoardAsync(actingUserId, query.BoardId); + if (!canRead.IsSuccess) + return Result.Failure(canRead.ErrorCode, canRead.ErrorMessage); + + if (!canRead.Value) + return Result.Failure(ErrorCodes.Forbidden, "You do not have permission to view metrics for this board"); + } + + // Verify board exists + var board = await _unitOfWork.Boards.GetByIdAsync(query.BoardId, cancellationToken); + if (board == null) + return Result.Failure(ErrorCodes.NotFound, "Board not found"); + + // Load columns and cards for the board + var columns = (await _unitOfWork.Columns.GetByBoardIdAsync(query.BoardId, cancellationToken)).ToList(); + var cards = (await _unitOfWork.Cards.GetByBoardIdAsync(query.BoardId, cancellationToken)).ToList(); + + // Filter by label if requested + if (query.LabelId.HasValue) + { + cards = cards.Where(c => c.CardLabels.Any(cl => cl.LabelId == query.LabelId.Value)).ToList(); + } + + // Load audit logs for card moves within the date range + var auditLogs = (await _unitOfWork.AuditLogs.QueryAsync( + query.From, + query.To, + boardId: query.BoardId, + limit: 10000, + cancellationToken: cancellationToken)).ToList(); + + // Determine the "done" column (rightmost by position) + var doneColumn = columns.OrderByDescending(c => c.Position).FirstOrDefault(); + + var throughput = ComputeThroughput(cards, doneColumn, query.From, query.To); + var (avgCycleTime, cycleTimeEntries) = ComputeCycleTime(cards, doneColumn, query.From, query.To); + var wipSnapshots = ComputeWip(columns, cards); + var totalWip = wipSnapshots.Sum(w => w.CardCount); + var (blockedCount, blockedCards) = ComputeBlocked(cards); + + return Result.Success(new BoardMetricsResponse( + query.BoardId, + query.From, + query.To, + throughput, + avgCycleTime, + cycleTimeEntries, + wipSnapshots, + totalWip, + blockedCount, + blockedCards)); + } + + internal static IReadOnlyList ComputeThroughput( + List cards, + Column? doneColumn, + DateTimeOffset from, + DateTimeOffset to) + { + if (doneColumn == null) + return Array.Empty(); + + // Cards in the done column that were updated (moved there) within the range + var completedCards = cards + .Where(c => c.ColumnId == doneColumn.Id + && c.UpdatedAt >= from + && c.UpdatedAt <= to) + .ToList(); + + // Group by date (day granularity) + var grouped = completedCards + .GroupBy(c => c.UpdatedAt.Date) + .Select(g => new ThroughputDataPoint( + new DateTimeOffset(g.Key, TimeSpan.Zero), + g.Count())) + .OrderBy(dp => dp.Date) + .ToList(); + + return grouped; + } + + internal static (double AverageCycleTimeDays, IReadOnlyList Entries) ComputeCycleTime( + List cards, + Column? doneColumn, + DateTimeOffset from, + DateTimeOffset to) + { + if (doneColumn == null) + return (0, Array.Empty()); + + // Cards that reached the done column within the date range + var doneCards = cards + .Where(c => c.ColumnId == doneColumn.Id + && c.UpdatedAt >= from + && c.UpdatedAt <= to) + .ToList(); + + if (doneCards.Count == 0) + return (0, Array.Empty()); + + var entries = doneCards + .Select(c => + { + var cycleTime = (c.UpdatedAt - c.CreatedAt).TotalDays; + return new CycleTimeEntry(c.Id, c.Title, Math.Round(cycleTime, 2)); + }) + .OrderBy(e => e.CycleTimeDays) + .ToList(); + + var avgCycleTime = Math.Round(entries.Average(e => e.CycleTimeDays), 2); + + return (avgCycleTime, entries); + } + + internal static IReadOnlyList ComputeWip( + List columns, + List cards) + { + return columns + .OrderBy(c => c.Position) + .Select(col => new WipSnapshot( + col.Id, + col.Name, + cards.Count(c => c.ColumnId == col.Id), + col.WipLimit)) + .ToList(); + } + + internal static (int BlockedCount, IReadOnlyList BlockedCards) ComputeBlocked( + List cards) + { + var blockedCards = cards + .Where(c => c.IsBlocked) + .Select(c => + { + // Estimate blocked duration from UpdatedAt (when blocked was set) to now + var blockedDuration = (DateTimeOffset.UtcNow - c.UpdatedAt).TotalDays; + return new BlockedCardSummary( + c.Id, + c.Title, + c.BlockReason, + Math.Round(blockedDuration, 2)); + }) + .OrderByDescending(b => b.BlockedDurationDays) + .ToList(); + + return (blockedCards.Count, blockedCards); + } +} From 93b91ce333088d84ee01e529165bb235b70f2f98 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 1 Apr 2026 22:01:52 +0100 Subject: [PATCH 04/15] Add MetricsController with GET /api/metrics/boards/{boardId} endpoint --- .../Controllers/MetricsController.cs | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 backend/src/Taskdeck.Api/Controllers/MetricsController.cs diff --git a/backend/src/Taskdeck.Api/Controllers/MetricsController.cs b/backend/src/Taskdeck.Api/Controllers/MetricsController.cs new file mode 100644 index 000000000..cb8060b04 --- /dev/null +++ b/backend/src/Taskdeck.Api/Controllers/MetricsController.cs @@ -0,0 +1,64 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Taskdeck.Api.Contracts; +using Taskdeck.Api.Extensions; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; + +namespace Taskdeck.Api.Controllers; + +/// +/// Board metrics endpoints: throughput, cycle time, WIP, blocked trends. +/// +[ApiController] +[Authorize] +[Route("api/[controller]")] +[Produces("application/json")] +public class MetricsController : AuthenticatedControllerBase +{ + private readonly IBoardMetricsService _metricsService; + + public MetricsController(IBoardMetricsService metricsService, IUserContext userContext) + : base(userContext) + { + _metricsService = metricsService; + } + + /// + /// Get board metrics (throughput, cycle time, WIP, blocked) for a date range. + /// + /// The board to compute metrics for. + /// Start of date range (ISO 8601). + /// End of date range (ISO 8601). + /// Optional label filter. + /// Aggregated board metrics. + /// Metrics computed successfully. + /// Invalid query parameters. + /// Authentication required. + /// No read access to the board. + /// Board not found. + [HttpGet("boards/{boardId}")] + [ProducesResponseType(typeof(BoardMetricsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)] + public async Task GetBoardMetrics( + Guid boardId, + [FromQuery] DateTimeOffset? from, + [FromQuery] DateTimeOffset? to, + [FromQuery] Guid? labelId) + { + if (!TryGetCurrentUserId(out var userId, out var errorResult)) + return errorResult!; + + // Default to last 30 days if not specified + var toDate = to ?? DateTimeOffset.UtcNow; + var fromDate = from ?? toDate.AddDays(-30); + + var query = new BoardMetricsQuery(boardId, fromDate, toDate, labelId); + var result = await _metricsService.GetBoardMetricsAsync(query, userId); + return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); + } +} From 8d44195c5a25d36984ed8ab8be9e8aa1babf57c7 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 1 Apr 2026 22:01:55 +0100 Subject: [PATCH 05/15] Register IBoardMetricsService in DI container --- .../Extensions/ApplicationServiceRegistration.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs index 8cf00a4d9..cb11f99b5 100644 --- a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs @@ -1,4 +1,5 @@ using Taskdeck.Api.Realtime; +using Taskdeck.Application.Interfaces; using Taskdeck.Application.Services; using Taskdeck.Domain.Agents; @@ -48,6 +49,10 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(sp => + new BoardMetricsService( + sp.GetRequiredService(), + sp.GetRequiredService())); services.AddScoped(); services.AddScoped(); services.AddScoped(); From b549d57f6193082e6888ef0e50771527569c02e1 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 1 Apr 2026 22:02:00 +0100 Subject: [PATCH 06/15] Add unit tests for BoardMetricsService covering validation, WIP, blocked, and computation --- .../Services/BoardMetricsServiceTests.cs | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 backend/tests/Taskdeck.Application.Tests/Services/BoardMetricsServiceTests.cs diff --git a/backend/tests/Taskdeck.Application.Tests/Services/BoardMetricsServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/BoardMetricsServiceTests.cs new file mode 100644 index 000000000..17c787d34 --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/BoardMetricsServiceTests.cs @@ -0,0 +1,279 @@ +using FluentAssertions; +using Moq; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class BoardMetricsServiceTests +{ + private readonly Mock _unitOfWorkMock; + private readonly Mock _boardRepoMock; + private readonly Mock _columnRepoMock; + private readonly Mock _cardRepoMock; + private readonly Mock _auditRepoMock; + private readonly Mock _authServiceMock; + private readonly BoardMetricsService _service; + + private readonly Guid _boardId = Guid.NewGuid(); + private readonly Guid _userId = Guid.NewGuid(); + + public BoardMetricsServiceTests() + { + _unitOfWorkMock = new Mock(); + _boardRepoMock = new Mock(); + _columnRepoMock = new Mock(); + _cardRepoMock = new Mock(); + _auditRepoMock = new Mock(); + _authServiceMock = new Mock(); + + _unitOfWorkMock.Setup(u => u.Boards).Returns(_boardRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Columns).Returns(_columnRepoMock.Object); + _unitOfWorkMock.Setup(u => u.Cards).Returns(_cardRepoMock.Object); + _unitOfWorkMock.Setup(u => u.AuditLogs).Returns(_auditRepoMock.Object); + + _authServiceMock + .Setup(a => a.CanReadBoardAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Result.Success(true)); + + _service = new BoardMetricsService(_unitOfWorkMock.Object, _authServiceMock.Object); + } + + #region Validation Tests + + [Fact] + public async Task GetBoardMetricsAsync_ShouldFail_WhenBoardIdIsEmpty() + { + var query = new BoardMetricsQuery(Guid.Empty, DateTimeOffset.UtcNow.AddDays(-7), DateTimeOffset.UtcNow); + + var result = await _service.GetBoardMetricsAsync(query, _userId); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task GetBoardMetricsAsync_ShouldFail_WhenUserIdIsEmpty() + { + var query = new BoardMetricsQuery(_boardId, DateTimeOffset.UtcNow.AddDays(-7), DateTimeOffset.UtcNow); + + var result = await _service.GetBoardMetricsAsync(query, Guid.Empty); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task GetBoardMetricsAsync_ShouldFail_WhenFromIsAfterTo() + { + var now = DateTimeOffset.UtcNow; + var query = new BoardMetricsQuery(_boardId, now, now.AddDays(-7)); + + var result = await _service.GetBoardMetricsAsync(query, _userId); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("before"); + } + + [Fact] + public async Task GetBoardMetricsAsync_ShouldFail_WhenBoardNotFound() + { + _boardRepoMock.Setup(r => r.GetByIdAsync(_boardId, default)).ReturnsAsync((Board?)null); + + var query = new BoardMetricsQuery(_boardId, DateTimeOffset.UtcNow.AddDays(-7), DateTimeOffset.UtcNow); + var result = await _service.GetBoardMetricsAsync(query, _userId); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + [Fact] + public async Task GetBoardMetricsAsync_ShouldFail_WhenUserLacksPermission() + { + _authServiceMock + .Setup(a => a.CanReadBoardAsync(_userId, _boardId)) + .ReturnsAsync(Result.Success(false)); + + var query = new BoardMetricsQuery(_boardId, DateTimeOffset.UtcNow.AddDays(-7), DateTimeOffset.UtcNow); + var result = await _service.GetBoardMetricsAsync(query, _userId); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Forbidden); + } + + #endregion + + #region Successful Metrics Computation + + [Fact] + public async Task GetBoardMetricsAsync_ShouldReturnMetrics_WithEmptyBoard() + { + SetupBoard(new List(), new List()); + + var query = new BoardMetricsQuery(_boardId, DateTimeOffset.UtcNow.AddDays(-7), DateTimeOffset.UtcNow); + var result = await _service.GetBoardMetricsAsync(query, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.Throughput.Should().BeEmpty(); + result.Value.AverageCycleTimeDays.Should().Be(0); + result.Value.WipSnapshots.Should().BeEmpty(); + result.Value.BlockedCount.Should().Be(0); + result.Value.TotalWip.Should().Be(0); + } + + [Fact] + public async Task GetBoardMetricsAsync_ShouldComputeWip_ForEachColumn() + { + var todoCol = CreateColumn("To Do", 0); + var doingCol = CreateColumn("Doing", 1); + var doneCol = CreateColumn("Done", 2); + + var cards = new List + { + CreateCard(todoCol.Id, "Card 1"), + CreateCard(todoCol.Id, "Card 2"), + CreateCard(doingCol.Id, "Card 3"), + CreateCard(doneCol.Id, "Card 4"), + }; + + SetupBoard(new List { todoCol, doingCol, doneCol }, cards); + + var query = new BoardMetricsQuery(_boardId, DateTimeOffset.UtcNow.AddDays(-7), DateTimeOffset.UtcNow); + var result = await _service.GetBoardMetricsAsync(query, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.WipSnapshots.Should().HaveCount(3); + result.Value.WipSnapshots[0].ColumnName.Should().Be("To Do"); + result.Value.WipSnapshots[0].CardCount.Should().Be(2); + result.Value.WipSnapshots[1].ColumnName.Should().Be("Doing"); + result.Value.WipSnapshots[1].CardCount.Should().Be(1); + result.Value.WipSnapshots[2].ColumnName.Should().Be("Done"); + result.Value.WipSnapshots[2].CardCount.Should().Be(1); + result.Value.TotalWip.Should().Be(4); + } + + [Fact] + public async Task GetBoardMetricsAsync_ShouldCountBlockedCards() + { + var todoCol = CreateColumn("To Do", 0); + var doneCol = CreateColumn("Done", 1); + + var card1 = CreateCard(todoCol.Id, "Blocked Card"); + card1.Block("Waiting on dependency"); + var card2 = CreateCard(todoCol.Id, "Active Card"); + + SetupBoard(new List { todoCol, doneCol }, new List { card1, card2 }); + + var query = new BoardMetricsQuery(_boardId, DateTimeOffset.UtcNow.AddDays(-7), DateTimeOffset.UtcNow); + var result = await _service.GetBoardMetricsAsync(query, _userId); + + result.IsSuccess.Should().BeTrue(); + result.Value.BlockedCount.Should().Be(1); + result.Value.BlockedCards.Should().HaveCount(1); + result.Value.BlockedCards[0].CardTitle.Should().Be("Blocked Card"); + result.Value.BlockedCards[0].BlockReason.Should().Be("Waiting on dependency"); + } + + #endregion + + #region Static Computation Tests + + [Fact] + public void ComputeThroughput_ShouldReturnEmpty_WhenNoDoneColumn() + { + var result = BoardMetricsService.ComputeThroughput( + new List(), + null, + DateTimeOffset.UtcNow.AddDays(-7), + DateTimeOffset.UtcNow); + + result.Should().BeEmpty(); + } + + [Fact] + public void ComputeCycleTime_ShouldReturnZero_WhenNoDoneColumn() + { + var (avg, entries) = BoardMetricsService.ComputeCycleTime( + new List(), + null, + DateTimeOffset.UtcNow.AddDays(-7), + DateTimeOffset.UtcNow); + + avg.Should().Be(0); + entries.Should().BeEmpty(); + } + + [Fact] + public void ComputeWip_ShouldCountCardsPerColumn() + { + var col1 = CreateColumn("A", 0); + var col2 = CreateColumn("B", 1); + var cards = new List + { + CreateCard(col1.Id, "Card 1"), + CreateCard(col1.Id, "Card 2"), + CreateCard(col2.Id, "Card 3"), + }; + + var result = BoardMetricsService.ComputeWip( + new List { col1, col2 }, + cards); + + result.Should().HaveCount(2); + result[0].CardCount.Should().Be(2); + result[1].CardCount.Should().Be(1); + } + + [Fact] + public void ComputeBlocked_ShouldReturnOnlyBlockedCards() + { + var colId = Guid.NewGuid(); + var blocked = CreateCard(colId, "Blocked"); + blocked.Block("Some reason"); + var active = CreateCard(colId, "Active"); + + var (count, cards) = BoardMetricsService.ComputeBlocked( + new List { blocked, active }); + + count.Should().Be(1); + cards.Should().HaveCount(1); + cards[0].CardTitle.Should().Be("Blocked"); + } + + #endregion + + #region Helpers + + private void SetupBoard(List columns, List cards) + { + var board = new Board("Test Board", ownerId: _userId); + _boardRepoMock.Setup(r => r.GetByIdAsync(_boardId, default)).ReturnsAsync(board); + _columnRepoMock.Setup(r => r.GetByBoardIdAsync(_boardId, default)).ReturnsAsync(columns); + _cardRepoMock.Setup(r => r.GetByBoardIdAsync(_boardId, default)).ReturnsAsync(cards); + _auditRepoMock + .Setup(r => r.QueryAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + } + + private Column CreateColumn(string name, int position, int? wipLimit = null) + { + return new Column(_boardId, name, position, wipLimit); + } + + private Card CreateCard(Guid columnId, string title) + { + return new Card(_boardId, columnId, title); + } + + #endregion +} From 9c7d0688d50d27a9a301f9c4cdc92cdf32b951b0 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 1 Apr 2026 22:07:15 +0100 Subject: [PATCH 07/15] Add metrics TypeScript types for board analytics responses --- frontend/taskdeck-web/src/types/metrics.ts | 44 ++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 frontend/taskdeck-web/src/types/metrics.ts diff --git a/frontend/taskdeck-web/src/types/metrics.ts b/frontend/taskdeck-web/src/types/metrics.ts new file mode 100644 index 000000000..2d26c4022 --- /dev/null +++ b/frontend/taskdeck-web/src/types/metrics.ts @@ -0,0 +1,44 @@ +export interface ThroughputDataPoint { + date: string + completedCount: number +} + +export interface CycleTimeEntry { + cardId: string + cardTitle: string + cycleTimeDays: number +} + +export interface WipSnapshot { + columnId: string + columnName: string + cardCount: number + wipLimit: number | null +} + +export interface BlockedCardSummary { + cardId: string + cardTitle: string + blockReason: string | null + blockedDurationDays: number +} + +export interface BoardMetricsResponse { + boardId: string + from: string + to: string + throughput: ThroughputDataPoint[] + averageCycleTimeDays: number + cycleTimeEntries: CycleTimeEntry[] + wipSnapshots: WipSnapshot[] + totalWip: number + blockedCount: number + blockedCards: BlockedCardSummary[] +} + +export interface MetricsQuery { + boardId: string + from?: string + to?: string + labelId?: string +} From b793adc2f627ab0d3f735714100cccfa3facff15 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 1 Apr 2026 22:07:20 +0100 Subject: [PATCH 08/15] Add metricsApi HTTP client for board metrics endpoint --- frontend/taskdeck-web/src/api/metricsApi.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 frontend/taskdeck-web/src/api/metricsApi.ts diff --git a/frontend/taskdeck-web/src/api/metricsApi.ts b/frontend/taskdeck-web/src/api/metricsApi.ts new file mode 100644 index 000000000..83055c648 --- /dev/null +++ b/frontend/taskdeck-web/src/api/metricsApi.ts @@ -0,0 +1,16 @@ +import http from './http' +import type { BoardMetricsResponse, MetricsQuery } from '../types/metrics' + +export const metricsApi = { + async getBoardMetrics(query: MetricsQuery): Promise { + const params = new URLSearchParams() + if (query.from) params.append('from', query.from) + if (query.to) params.append('to', query.to) + if (query.labelId) params.append('labelId', query.labelId) + + const qs = params.toString() + const url = `/metrics/boards/${encodeURIComponent(query.boardId)}${qs ? `?${qs}` : ''}` + const { data } = await http.get(url) + return data + }, +} From 7e1dc4b6aa885341694a4d971e4671bdb5d7d64b Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 1 Apr 2026 22:07:24 +0100 Subject: [PATCH 09/15] Add Pinia metricsStore for board analytics state management --- .../taskdeck-web/src/store/metricsStore.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 frontend/taskdeck-web/src/store/metricsStore.ts diff --git a/frontend/taskdeck-web/src/store/metricsStore.ts b/frontend/taskdeck-web/src/store/metricsStore.ts new file mode 100644 index 000000000..d1181caa6 --- /dev/null +++ b/frontend/taskdeck-web/src/store/metricsStore.ts @@ -0,0 +1,51 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { metricsApi } from '../api/metricsApi' +import { useToastStore } from './toastStore' +import { isDemoMode } from '../utils/demoMode' +import { getErrorDisplay } from '../composables/useErrorMapper' +import type { BoardMetricsResponse, MetricsQuery } from '../types/metrics' + +export const useMetricsStore = defineStore('metrics', () => { + const toast = useToastStore() + + const metrics = ref(null) + const loading = ref(false) + const error = ref(null) + + async function fetchBoardMetrics(query: MetricsQuery) { + if (isDemoMode) { + loading.value = true + error.value = null + metrics.value = null + loading.value = false + return + } + try { + loading.value = true + error.value = null + metrics.value = await metricsApi.getBoardMetrics(query) + } catch (e: unknown) { + const msg = getErrorDisplay(e, 'Failed to fetch board metrics').message + error.value = msg + toast.error(msg) + throw e + } finally { + loading.value = false + } + } + + function $reset() { + metrics.value = null + loading.value = false + error.value = null + } + + return { + metrics, + loading, + error, + fetchBoardMetrics, + $reset, + } +}) From 8c60e77f58c30afd6dd815c773da3479d1698dbf Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 1 Apr 2026 22:07:28 +0100 Subject: [PATCH 10/15] Add MetricsView dashboard with throughput, cycle time, WIP, and blocked charts --- .../taskdeck-web/src/views/MetricsView.vue | 594 ++++++++++++++++++ 1 file changed, 594 insertions(+) create mode 100644 frontend/taskdeck-web/src/views/MetricsView.vue diff --git a/frontend/taskdeck-web/src/views/MetricsView.vue b/frontend/taskdeck-web/src/views/MetricsView.vue new file mode 100644 index 000000000..789e380ad --- /dev/null +++ b/frontend/taskdeck-web/src/views/MetricsView.vue @@ -0,0 +1,594 @@ + + + + + From f592b523bbfd5168bc1bd63b7dd612dd7804c37e Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 1 Apr 2026 22:07:34 +0100 Subject: [PATCH 11/15] Add /workspace/metrics route and Metrics nav item in sidebar --- .../taskdeck-web/src/components/shell/ShellSidebar.vue | 10 ++++++++++ frontend/taskdeck-web/src/router/index.ts | 9 +++++++++ 2 files changed, 19 insertions(+) diff --git a/frontend/taskdeck-web/src/components/shell/ShellSidebar.vue b/frontend/taskdeck-web/src/components/shell/ShellSidebar.vue index 51a81692e..d577b4bc9 100644 --- a/frontend/taskdeck-web/src/components/shell/ShellSidebar.vue +++ b/frontend/taskdeck-web/src/components/shell/ShellSidebar.vue @@ -142,6 +142,16 @@ const navCatalog: NavItem[] = [ secondaryModes: ['guided', 'agent'], keywords: 'chat automation assistant board context', }, + { + id: 'metrics', + label: 'Metrics', + icon: 'M', + path: '/workspace/metrics', + flag: null, + primaryModes: ['workbench'], + secondaryModes: ['guided', 'agent'], + keywords: 'metrics analytics throughput cycle time wip blocked dashboard', + }, { id: 'activity', label: 'Activity', diff --git a/frontend/taskdeck-web/src/router/index.ts b/frontend/taskdeck-web/src/router/index.ts index 96ffc9d18..54d704b30 100644 --- a/frontend/taskdeck-web/src/router/index.ts +++ b/frontend/taskdeck-web/src/router/index.ts @@ -38,6 +38,7 @@ const TodayView = () => import('../views/TodayView.vue') const ReviewView = () => import('../views/ReviewView.vue') const DevToolsView = () => import('../views/DevToolsView.vue') const SavedViewsView = () => import('../views/SavedViewsView.vue') +const MetricsView = () => import('../views/MetricsView.vue') const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -131,6 +132,14 @@ const router = createRouter({ redirect: '/workspace/activity/user', }, + // Metrics routes + { + path: '/workspace/metrics', + name: 'workspace-metrics', + component: MetricsView, + meta: { requiresShell: true }, + }, + // Automation routes { path: '/workspace/automations', From 3e7786ad79a680217890a6b69970effe2778c7d1 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 1 Apr 2026 22:07:37 +0100 Subject: [PATCH 12/15] Add unit tests for metricsApi client --- .../src/tests/api/metricsApi.spec.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/api/metricsApi.spec.ts diff --git a/frontend/taskdeck-web/src/tests/api/metricsApi.spec.ts b/frontend/taskdeck-web/src/tests/api/metricsApi.spec.ts new file mode 100644 index 000000000..e2e3fbb63 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/api/metricsApi.spec.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import http from '../../api/http' +import { metricsApi } from '../../api/metricsApi' + +vi.mock('../../api/http', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +describe('metricsApi', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('getBoardMetrics sends correct URL with boardId', async () => { + vi.mocked(http.get).mockResolvedValue({ data: { boardId: 'b1' } }) + + await metricsApi.getBoardMetrics({ boardId: 'b1' }) + + expect(http.get).toHaveBeenCalledWith('/metrics/boards/b1') + }) + + it('getBoardMetrics includes from and to query params', async () => { + vi.mocked(http.get).mockResolvedValue({ data: { boardId: 'b1' } }) + + await metricsApi.getBoardMetrics({ + boardId: 'b1', + from: '2026-01-01T00:00:00Z', + to: '2026-01-31T23:59:59Z', + }) + + expect(http.get).toHaveBeenCalledWith( + '/metrics/boards/b1?from=2026-01-01T00%3A00%3A00Z&to=2026-01-31T23%3A59%3A59Z', + ) + }) + + it('getBoardMetrics includes labelId when provided', async () => { + vi.mocked(http.get).mockResolvedValue({ data: { boardId: 'b1' } }) + + await metricsApi.getBoardMetrics({ + boardId: 'b1', + labelId: 'label-123', + }) + + expect(http.get).toHaveBeenCalledWith( + '/metrics/boards/b1?labelId=label-123', + ) + }) + + it('getBoardMetrics encodes special characters in boardId', async () => { + vi.mocked(http.get).mockResolvedValue({ data: { boardId: 'b/1' } }) + + await metricsApi.getBoardMetrics({ boardId: 'b/1' }) + + expect(http.get).toHaveBeenCalledWith('/metrics/boards/b%2F1') + }) +}) From 29630fb61d8812300817c0486c297fbb9fa51701 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 1 Apr 2026 22:10:39 +0100 Subject: [PATCH 13/15] Remove unused audit log query and cleanup unused imports from metrics service --- .../Services/BoardMetricsService.cs | 9 --------- .../Services/BoardMetricsServiceTests.cs | 10 ---------- 2 files changed, 19 deletions(-) diff --git a/backend/src/Taskdeck.Application/Services/BoardMetricsService.cs b/backend/src/Taskdeck.Application/Services/BoardMetricsService.cs index 4ca5600bf..6500db7d8 100644 --- a/backend/src/Taskdeck.Application/Services/BoardMetricsService.cs +++ b/backend/src/Taskdeck.Application/Services/BoardMetricsService.cs @@ -2,7 +2,6 @@ using Taskdeck.Application.Interfaces; using Taskdeck.Domain.Common; using Taskdeck.Domain.Entities; -using Taskdeck.Domain.Enums; using Taskdeck.Domain.Exceptions; namespace Taskdeck.Application.Services; @@ -65,14 +64,6 @@ public async Task> GetBoardMetricsAsync( cards = cards.Where(c => c.CardLabels.Any(cl => cl.LabelId == query.LabelId.Value)).ToList(); } - // Load audit logs for card moves within the date range - var auditLogs = (await _unitOfWork.AuditLogs.QueryAsync( - query.From, - query.To, - boardId: query.BoardId, - limit: 10000, - cancellationToken: cancellationToken)).ToList(); - // Determine the "done" column (rightmost by position) var doneColumn = columns.OrderByDescending(c => c.Position).FirstOrDefault(); diff --git a/backend/tests/Taskdeck.Application.Tests/Services/BoardMetricsServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/BoardMetricsServiceTests.cs index 17c787d34..89dc7bb7c 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/BoardMetricsServiceTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/BoardMetricsServiceTests.cs @@ -16,7 +16,6 @@ public class BoardMetricsServiceTests private readonly Mock _boardRepoMock; private readonly Mock _columnRepoMock; private readonly Mock _cardRepoMock; - private readonly Mock _auditRepoMock; private readonly Mock _authServiceMock; private readonly BoardMetricsService _service; @@ -29,13 +28,11 @@ public BoardMetricsServiceTests() _boardRepoMock = new Mock(); _columnRepoMock = new Mock(); _cardRepoMock = new Mock(); - _auditRepoMock = new Mock(); _authServiceMock = new Mock(); _unitOfWorkMock.Setup(u => u.Boards).Returns(_boardRepoMock.Object); _unitOfWorkMock.Setup(u => u.Columns).Returns(_columnRepoMock.Object); _unitOfWorkMock.Setup(u => u.Cards).Returns(_cardRepoMock.Object); - _unitOfWorkMock.Setup(u => u.AuditLogs).Returns(_auditRepoMock.Object); _authServiceMock .Setup(a => a.CanReadBoardAsync(It.IsAny(), It.IsAny())) @@ -256,13 +253,6 @@ private void SetupBoard(List columns, List cards) _boardRepoMock.Setup(r => r.GetByIdAsync(_boardId, default)).ReturnsAsync(board); _columnRepoMock.Setup(r => r.GetByBoardIdAsync(_boardId, default)).ReturnsAsync(columns); _cardRepoMock.Setup(r => r.GetByBoardIdAsync(_boardId, default)).ReturnsAsync(cards); - _auditRepoMock - .Setup(r => r.QueryAsync( - It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) - .ReturnsAsync(new List()); } private Column CreateColumn(string name, int position, int? wipLimit = null) From cfca8dac3efc204e350ac6db8025ceb8e3222564 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 1 Apr 2026 23:30:08 +0100 Subject: [PATCH 14/15] Add metricsStore and MetricsView tests to fix branch coverage threshold Coverage for branches was 70.26%, below the 71% global threshold. Add unit tests for metricsStore (normal + demo mode) and MetricsView (14 tests covering all template branches: loading, error, empty, dashboard, empty charts, blocked alert, null block reason, watcher triggers). Branch coverage now at 72%. --- .../src/tests/store/metricsStore.demo.spec.ts | 62 ++++ .../src/tests/store/metricsStore.spec.ts | 139 +++++++++ .../src/tests/views/MetricsView.spec.ts | 268 ++++++++++++++++++ 3 files changed, 469 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/store/metricsStore.demo.spec.ts create mode 100644 frontend/taskdeck-web/src/tests/store/metricsStore.spec.ts create mode 100644 frontend/taskdeck-web/src/tests/views/MetricsView.spec.ts diff --git a/frontend/taskdeck-web/src/tests/store/metricsStore.demo.spec.ts b/frontend/taskdeck-web/src/tests/store/metricsStore.demo.spec.ts new file mode 100644 index 000000000..0ec8673ed --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/metricsStore.demo.spec.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' + +vi.mock('../../utils/demoMode', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + isDemoMode: true, + } +}) + +vi.mock('../../api/metricsApi', () => ({ + metricsApi: { + getBoardMetrics: vi.fn(), + }, +})) + +const toastMocks = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => toastMocks, +})) + +vi.mock('../../composables/useErrorMapper', () => ({ + getErrorDisplay: (_error: unknown, fallback: string) => ({ message: fallback }), +})) + +import { useMetricsStore } from '../../store/metricsStore' +import { metricsApi } from '../../api/metricsApi' + +describe('metricsStore demo mode', () => { + let store: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + store = useMetricsStore() + }) + + it('fetchBoardMetrics in demo mode does not call API', async () => { + await store.fetchBoardMetrics({ boardId: 'board-1' }) + + expect(metricsApi.getBoardMetrics).not.toHaveBeenCalled() + expect(store.metrics).toBeNull() + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + + it('fetchBoardMetrics in demo mode clears any prior error', async () => { + // Manually set error to simulate prior state + store.error = 'prior error' + + await store.fetchBoardMetrics({ boardId: 'board-1' }) + + expect(store.error).toBeNull() + }) +}) diff --git a/frontend/taskdeck-web/src/tests/store/metricsStore.spec.ts b/frontend/taskdeck-web/src/tests/store/metricsStore.spec.ts new file mode 100644 index 000000000..7b7f0c5ac --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/metricsStore.spec.ts @@ -0,0 +1,139 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { metricsApi } from '../../api/metricsApi' +import { useMetricsStore } from '../../store/metricsStore' +import type { BoardMetricsResponse } from '../../types/metrics' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + success: vi.fn(), + info: vi.fn(), + warning: vi.fn(), +})) + +vi.mock('../../utils/demoMode', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + isDemoMode: false, + } +}) + +vi.mock('../../api/metricsApi', () => ({ + metricsApi: { + getBoardMetrics: vi.fn(), + }, +})) + +vi.mock('../../store/toastStore', () => ({ + useToastStore: () => toastMocks, +})) + +vi.mock('../../composables/useErrorMapper', () => ({ + getErrorDisplay: (_error: unknown, fallback: string) => ({ message: fallback }), +})) + +const MOCK_METRICS: BoardMetricsResponse = { + boardId: 'board-1', + from: '2026-03-01T00:00:00Z', + to: '2026-03-31T23:59:59Z', + throughput: [ + { date: '2026-03-15T00:00:00Z', completedCount: 3 }, + { date: '2026-03-16T00:00:00Z', completedCount: 1 }, + ], + averageCycleTimeDays: 2.5, + cycleTimeEntries: [ + { cardId: 'c1', cardTitle: 'Card 1', cycleTimeDays: 2.0 }, + { cardId: 'c2', cardTitle: 'Card 2', cycleTimeDays: 3.0 }, + ], + wipSnapshots: [ + { columnId: 'col1', columnName: 'To Do', cardCount: 5, wipLimit: null }, + { columnId: 'col2', columnName: 'Doing', cardCount: 3, wipLimit: 4 }, + ], + totalWip: 8, + blockedCount: 1, + blockedCards: [ + { cardId: 'c3', cardTitle: 'Blocked Card', blockReason: 'Waiting', blockedDurationDays: 1.5 }, + ], +} + +describe('metricsStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('starts with empty default state', () => { + const store = useMetricsStore() + + expect(store.metrics).toBeNull() + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) + + it('fetchBoardMetrics populates metrics on success', async () => { + vi.mocked(metricsApi.getBoardMetrics).mockResolvedValue(MOCK_METRICS) + const store = useMetricsStore() + + await store.fetchBoardMetrics({ boardId: 'board-1' }) + + expect(store.metrics).toEqual(MOCK_METRICS) + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + expect(metricsApi.getBoardMetrics).toHaveBeenCalledWith({ boardId: 'board-1' }) + }) + + it('fetchBoardMetrics sets error and shows toast on failure', async () => { + vi.mocked(metricsApi.getBoardMetrics).mockRejectedValue(new Error('Network error')) + const store = useMetricsStore() + + await expect(store.fetchBoardMetrics({ boardId: 'board-1' })).rejects.toThrow('Network error') + + expect(store.error).toBe('Failed to fetch board metrics') + expect(store.loading).toBe(false) + expect(store.metrics).toBeNull() + expect(toastMocks.error).toHaveBeenCalledWith('Failed to fetch board metrics') + }) + + it('fetchBoardMetrics sets loading to true during fetch', async () => { + let resolvePromise: (value: BoardMetricsResponse) => void + const pendingPromise = new Promise((resolve) => { + resolvePromise = resolve + }) + vi.mocked(metricsApi.getBoardMetrics).mockReturnValue(pendingPromise) + const store = useMetricsStore() + + const fetchPromise = store.fetchBoardMetrics({ boardId: 'board-1' }) + expect(store.loading).toBe(true) + + resolvePromise!(MOCK_METRICS) + await fetchPromise + + expect(store.loading).toBe(false) + }) + + it('fetchBoardMetrics clears previous error on new fetch', async () => { + vi.mocked(metricsApi.getBoardMetrics).mockRejectedValueOnce(new Error('fail')) + const store = useMetricsStore() + + await expect(store.fetchBoardMetrics({ boardId: 'board-1' })).rejects.toThrow() + expect(store.error).toBe('Failed to fetch board metrics') + + vi.mocked(metricsApi.getBoardMetrics).mockResolvedValueOnce(MOCK_METRICS) + await store.fetchBoardMetrics({ boardId: 'board-1' }) + expect(store.error).toBeNull() + }) + + it('$reset restores initial state', async () => { + vi.mocked(metricsApi.getBoardMetrics).mockResolvedValue(MOCK_METRICS) + const store = useMetricsStore() + + await store.fetchBoardMetrics({ boardId: 'board-1' }) + expect(store.metrics).not.toBeNull() + + store.$reset() + expect(store.metrics).toBeNull() + expect(store.loading).toBe(false) + expect(store.error).toBeNull() + }) +}) diff --git a/frontend/taskdeck-web/src/tests/views/MetricsView.spec.ts b/frontend/taskdeck-web/src/tests/views/MetricsView.spec.ts new file mode 100644 index 000000000..7f4523ed3 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/views/MetricsView.spec.ts @@ -0,0 +1,268 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { reactive } from 'vue' +import MetricsView from '../../views/MetricsView.vue' +import type { BoardMetricsResponse } from '../../types/metrics' + +const MOCK_METRICS: BoardMetricsResponse = { + boardId: 'board-1', + from: '2026-03-01T00:00:00Z', + to: '2026-03-31T23:59:59Z', + throughput: [ + { date: '2026-03-15T00:00:00Z', completedCount: 3 }, + { date: '2026-03-16T00:00:00Z', completedCount: 1 }, + ], + averageCycleTimeDays: 2.5, + cycleTimeEntries: [ + { cardId: 'c1', cardTitle: 'Card Alpha', cycleTimeDays: 2.0 }, + { cardId: 'c2', cardTitle: 'Card Beta', cycleTimeDays: 3.0 }, + ], + wipSnapshots: [ + { columnId: 'col1', columnName: 'To Do', cardCount: 5, wipLimit: null }, + { columnId: 'col2', columnName: 'Doing', cardCount: 3, wipLimit: 2 }, + ], + totalWip: 8, + blockedCount: 1, + blockedCards: [ + { cardId: 'c3', cardTitle: 'Blocked Card', blockReason: 'Waiting', blockedDurationDays: 1.5 }, + ], +} + +const EMPTY_METRICS: BoardMetricsResponse = { + boardId: 'board-1', + from: '2026-03-01T00:00:00Z', + to: '2026-03-31T23:59:59Z', + throughput: [], + averageCycleTimeDays: 0, + cycleTimeEntries: [], + wipSnapshots: [], + totalWip: 0, + blockedCount: 0, + blockedCards: [], +} + +const mockMetricsStore = reactive({ + metrics: null as BoardMetricsResponse | null, + loading: false, + error: null as string | null, + fetchBoardMetrics: vi.fn().mockResolvedValue(undefined), + $reset: vi.fn(), +}) + +const mockBoardStore = reactive({ + boards: [] as Array<{ id: string; name: string; isArchived: boolean; description: null; createdAt: string; updatedAt: string }>, + fetchBoards: vi.fn<(...args: unknown[]) => Promise>(), +}) + +vi.mock('../../store/metricsStore', () => ({ + useMetricsStore: () => mockMetricsStore, +})) + +vi.mock('../../store/boardStore', () => ({ + useBoardStore: () => mockBoardStore, +})) + +function seedBoards() { + mockBoardStore.boards = [ + { id: 'board-1', name: 'Board One', isArchived: false, description: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, + { id: 'board-2', name: 'Board Two', isArchived: false, description: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, + ] +} + +async function waitForUi() { + await flushPromises() + await flushPromises() +} + +describe('MetricsView', () => { + beforeEach(() => { + vi.clearAllMocks() + + mockMetricsStore.metrics = null + mockMetricsStore.loading = false + mockMetricsStore.error = null + + mockBoardStore.boards = [] + mockBoardStore.fetchBoards.mockImplementation(async () => { + seedBoards() + }) + }) + + it('renders header and board selector', async () => { + const wrapper = mount(MetricsView) + await waitForUi() + + expect(wrapper.text()).toContain('Board Metrics') + expect(wrapper.find('#board-select').exists()).toBe(true) + expect(wrapper.find('#range-select').exists()).toBe(true) + }) + + it('loads boards on mount and auto-selects first', async () => { + const wrapper = mount(MetricsView) + await waitForUi() + + expect(mockBoardStore.fetchBoards).toHaveBeenCalled() + const boardSelect = wrapper.get('#board-select') + expect((boardSelect.element as HTMLSelectElement).value).toBe('board-1') + }) + + it('shows "Select a board" prompt when no board selected', async () => { + mockBoardStore.fetchBoards.mockImplementation(async () => { + // no boards + }) + const wrapper = mount(MetricsView) + await waitForUi() + + expect(wrapper.text()).toContain('Select a board above to view its metrics.') + }) + + it('shows loading spinner when loading is true', async () => { + mockMetricsStore.loading = true + const wrapper = mount(MetricsView) + await waitForUi() + + expect(wrapper.text()).toContain('Loading metrics...') + expect(wrapper.find('.td-metrics__spinner').exists()).toBe(true) + }) + + it('shows error state with retry button', async () => { + mockMetricsStore.error = 'Something went wrong' + const wrapper = mount(MetricsView) + await waitForUi() + + expect(wrapper.text()).toContain('Something went wrong') + const retryBtn = wrapper.find('.td-metrics__state--error button') + expect(retryBtn.exists()).toBe(true) + expect(retryBtn.text()).toBe('Retry') + }) + + it('shows empty data state when metrics is null but board is selected', async () => { + // Auto-select board + const wrapper = mount(MetricsView) + await waitForUi() + + // Board is selected but metrics is still null (no data yet returned) + expect(wrapper.text()).toContain('No metrics data available') + }) + + it('renders dashboard with metric data', async () => { + mockMetricsStore.metrics = MOCK_METRICS + const wrapper = mount(MetricsView) + await waitForUi() + + // Summary cards + expect(wrapper.text()).toContain('Total Throughput') + expect(wrapper.text()).toContain('4') // 3 + 1 + expect(wrapper.text()).toContain('Avg Cycle Time') + expect(wrapper.text()).toContain('2.5') + expect(wrapper.text()).toContain('Current WIP') + expect(wrapper.text()).toContain('8') + expect(wrapper.text()).toContain('Blocked') + + // Throughput chart + expect(wrapper.text()).toContain('Throughput Trend') + expect(wrapper.findAll('.td-metrics__bar-group')).toHaveLength(2) + + // WIP chart + expect(wrapper.text()).toContain('WIP by Column') + expect(wrapper.text()).toContain('To Do') + expect(wrapper.text()).toContain('Doing') + + // WIP limit violation highlighting + const overLimitBars = wrapper.findAll('.td-metrics__wip-bar-fill--over') + expect(overLimitBars.length).toBe(1) // Doing: 3 > wipLimit 2 + + // WIP limit display + expect(wrapper.text()).toContain('/ 2') + + // Cycle time table + expect(wrapper.text()).toContain('Cycle Time Details') + expect(wrapper.text()).toContain('Card Alpha') + expect(wrapper.text()).toContain('Card Beta') + + // Blocked cards table + expect(wrapper.text()).toContain('Blocked Cards') + expect(wrapper.text()).toContain('Blocked Card') + expect(wrapper.text()).toContain('Waiting') + }) + + it('renders empty chart placeholders when metrics lists are empty', async () => { + mockMetricsStore.metrics = EMPTY_METRICS + const wrapper = mount(MetricsView) + await waitForUi() + + expect(wrapper.text()).toContain('No completed cards in this period.') + expect(wrapper.text()).toContain('No columns found.') + expect(wrapper.text()).toContain('No completed cards to compute cycle time.') + expect(wrapper.text()).toContain('No blocked cards. Great!') + }) + + it('applies alert class when blocked count > 0', async () => { + mockMetricsStore.metrics = MOCK_METRICS + const wrapper = mount(MetricsView) + await waitForUi() + + const alertCard = wrapper.find('.td-metrics__card--alert') + expect(alertCard.exists()).toBe(true) + expect(alertCard.text()).toContain('Blocked') + }) + + it('does not apply alert class when blocked count is 0', async () => { + mockMetricsStore.metrics = EMPTY_METRICS + const wrapper = mount(MetricsView) + await waitForUi() + + expect(wrapper.find('.td-metrics__card--alert').exists()).toBe(false) + }) + + it('shows null block reason as "No reason given"', async () => { + mockMetricsStore.metrics = { + ...MOCK_METRICS, + blockedCards: [ + { cardId: 'c3', cardTitle: 'No Reason Card', blockReason: null, blockedDurationDays: 0.5 }, + ], + } + const wrapper = mount(MetricsView) + await waitForUi() + + expect(wrapper.text()).toContain('No reason given') + }) + + it('date range selector has all expected options', async () => { + const wrapper = mount(MetricsView) + await waitForUi() + + const rangeSelect = wrapper.get('#range-select') + const options = rangeSelect.findAll('option') + const values = options.map((o) => Number((o.element as HTMLOptionElement).value)) + expect(values).toEqual([7, 14, 30, 60, 90]) + }) + + it('fetches metrics when board selection changes', async () => { + const wrapper = mount(MetricsView) + await waitForUi() + + // Auto-select triggers first fetch + expect(mockMetricsStore.fetchBoardMetrics).toHaveBeenCalled() + vi.clearAllMocks() + + // Change board + await wrapper.get('#board-select').setValue('board-2') + await waitForUi() + + expect(mockMetricsStore.fetchBoardMetrics).toHaveBeenCalledWith( + expect.objectContaining({ boardId: 'board-2' }), + ) + }) + + it('fetches metrics when date range changes', async () => { + const wrapper = mount(MetricsView) + await waitForUi() + vi.clearAllMocks() + + await wrapper.get('#range-select').setValue('7') + await waitForUi() + + expect(mockMetricsStore.fetchBoardMetrics).toHaveBeenCalled() + }) +}) From 6cc8960fb4eded97b60b1e70caea86e17d962239 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Wed, 1 Apr 2026 23:38:00 +0100 Subject: [PATCH 15/15] Fix adversarial review findings: audit-based metrics, done column heuristic, auth hardening - Use audit log (AuditAction.Moved) to determine actual card completion timestamps for throughput and cycle time instead of unreliable UpdatedAt. Falls back to UpdatedAt when no audit data exists. - Resolve done column by matching well-known names (done, complete, finished, closed, shipped, released) case-insensitively before falling back to rightmost column by position. - Remove nullable IAuthorizationService constructor to prevent accidental auth bypass; authorization is now always enforced. - Change From >= To validation to From > To to allow same-day queries. - Show explicit "Metrics are not available in demo mode" message in frontend instead of silently returning null data. - Use UtcDateTime.Date for throughput date bucketing to avoid timezone issues. - Add tests: done column resolution, audit-based throughput/cycle time, ParseTargetColumnId, auth service error propagation, same-day query. --- .../Services/BoardMetricsService.cs | 208 ++++++++++++++---- .../Services/BoardMetricsServiceTests.cs | 193 +++++++++++++++- .../taskdeck-web/src/store/metricsStore.ts | 1 + .../src/tests/store/metricsStore.demo.spec.ts | 8 +- 4 files changed, 356 insertions(+), 54 deletions(-) diff --git a/backend/src/Taskdeck.Application/Services/BoardMetricsService.cs b/backend/src/Taskdeck.Application/Services/BoardMetricsService.cs index 6500db7d8..321ec07f0 100644 --- a/backend/src/Taskdeck.Application/Services/BoardMetricsService.cs +++ b/backend/src/Taskdeck.Application/Services/BoardMetricsService.cs @@ -1,7 +1,9 @@ +using System.Text.RegularExpressions; using Taskdeck.Application.DTOs; using Taskdeck.Application.Interfaces; using Taskdeck.Domain.Common; using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Enums; using Taskdeck.Domain.Exceptions; namespace Taskdeck.Application.Services; @@ -9,16 +11,20 @@ namespace Taskdeck.Application.Services; public class BoardMetricsService : IBoardMetricsService { private readonly IUnitOfWork _unitOfWork; - private readonly IAuthorizationService? _authorizationService; + private readonly IAuthorizationService _authorizationService; - public BoardMetricsService(IUnitOfWork unitOfWork) - : this(unitOfWork, authorizationService: null) + /// + /// Well-known column names that indicate "done" status, checked case-insensitively. + /// The first match (by highest position among matches) wins. + /// + private static readonly string[] DoneColumnNames = { - } + "done", "complete", "completed", "finished", "closed", "shipped", "released" + }; public BoardMetricsService( IUnitOfWork unitOfWork, - IAuthorizationService? authorizationService) + IAuthorizationService authorizationService) { _unitOfWork = unitOfWork; _authorizationService = authorizationService; @@ -35,19 +41,16 @@ public async Task> GetBoardMetricsAsync( if (actingUserId == Guid.Empty) return Result.Failure(ErrorCodes.ValidationError, "Acting user ID cannot be empty"); - if (query.From >= query.To) + if (query.From > query.To) return Result.Failure(ErrorCodes.ValidationError, "From date must be before To date"); // Enforce read permission - if (_authorizationService != null) - { - var canRead = await _authorizationService.CanReadBoardAsync(actingUserId, query.BoardId); - if (!canRead.IsSuccess) - return Result.Failure(canRead.ErrorCode, canRead.ErrorMessage); + var canRead = await _authorizationService.CanReadBoardAsync(actingUserId, query.BoardId); + if (!canRead.IsSuccess) + return Result.Failure(canRead.ErrorCode, canRead.ErrorMessage); - if (!canRead.Value) - return Result.Failure(ErrorCodes.Forbidden, "You do not have permission to view metrics for this board"); - } + if (!canRead.Value) + return Result.Failure(ErrorCodes.Forbidden, "You do not have permission to view metrics for this board"); // Verify board exists var board = await _unitOfWork.Boards.GetByIdAsync(query.BoardId, cancellationToken); @@ -64,11 +67,15 @@ public async Task> GetBoardMetricsAsync( cards = cards.Where(c => c.CardLabels.Any(cl => cl.LabelId == query.LabelId.Value)).ToList(); } - // Determine the "done" column (rightmost by position) - var doneColumn = columns.OrderByDescending(c => c.Position).FirstOrDefault(); + // Determine the "done" column: prefer a column whose name matches known done patterns, + // fall back to the rightmost column by position. + var doneColumn = ResolveDoneColumn(columns); + + // Load audit logs for card moves in this board to determine actual completion timestamps + var cardMoveAudits = await LoadCardMoveAuditsAsync(query.BoardId, query.From, query.To, cancellationToken); - var throughput = ComputeThroughput(cards, doneColumn, query.From, query.To); - var (avgCycleTime, cycleTimeEntries) = ComputeCycleTime(cards, doneColumn, query.From, query.To); + var throughput = ComputeThroughput(cards, doneColumn, query.From, query.To, cardMoveAudits); + var (avgCycleTime, cycleTimeEntries) = ComputeCycleTime(cards, doneColumn, query.From, query.To, cardMoveAudits); var wipSnapshots = ComputeWip(columns, cards); var totalWip = wipSnapshots.Sum(w => w.CardCount); var (blockedCount, blockedCards) = ComputeBlocked(cards); @@ -86,25 +93,110 @@ public async Task> GetBoardMetricsAsync( blockedCards)); } + /// + /// Resolve the "done" column. Prefer columns whose name matches a well-known done pattern + /// (case-insensitive). If multiple match, pick the one with the highest position. + /// If none match, fall back to the rightmost column by position. + /// + internal static Column? ResolveDoneColumn(List columns) + { + if (columns.Count == 0) return null; + + var doneByName = columns + .Where(c => DoneColumnNames.Any(name => + c.Name.Equals(name, StringComparison.OrdinalIgnoreCase))) + .OrderByDescending(c => c.Position) + .FirstOrDefault(); + + return doneByName ?? columns.OrderByDescending(c => c.Position).First(); + } + + /// + /// Load audit log entries for card moves within the date range for this board. + /// Returns a lookup: CardId -> list of (Timestamp, TargetColumnId). + /// + private async Task>> LoadCardMoveAuditsAsync( + Guid boardId, + DateTimeOffset from, + DateTimeOffset to, + CancellationToken cancellationToken) + { + var audits = await _unitOfWork.AuditLogs.QueryAsync( + from, to, + boardId: boardId, + limit: 10000, + cancellationToken: cancellationToken); + + var moveAudits = audits + .Where(a => a.Action == AuditAction.Moved && a.EntityType == "card" && a.Changes != null); + + var result = new Dictionary>(); + + foreach (var audit in moveAudits) + { + var targetColumnId = ParseTargetColumnId(audit.Changes!); + if (targetColumnId == null) continue; + + if (!result.ContainsKey(audit.EntityId)) + result[audit.EntityId] = new List<(DateTimeOffset, Guid)>(); + + result[audit.EntityId].Add((audit.Timestamp, targetColumnId.Value)); + } + + return result; + } + + /// + /// Parse the target column ID from audit log change text like "target_column=GUID; position=N". + /// + internal static Guid? ParseTargetColumnId(string changes) + { + var match = Regex.Match(changes, @"target_column=([0-9a-fA-F\-]{36})"); + if (match.Success && Guid.TryParse(match.Groups[1].Value, out var guid)) + return guid; + return null; + } + internal static IReadOnlyList ComputeThroughput( List cards, Column? doneColumn, DateTimeOffset from, - DateTimeOffset to) + DateTimeOffset to, + Dictionary> cardMoveAudits) { if (doneColumn == null) return Array.Empty(); - // Cards in the done column that were updated (moved there) within the range - var completedCards = cards - .Where(c => c.ColumnId == doneColumn.Id - && c.UpdatedAt >= from - && c.UpdatedAt <= to) - .ToList(); + // Use audit logs to find cards that were moved to the done column within the date range. + // Each audit entry for a card move to the done column counts as a completion. + var completionDates = new List(); - // Group by date (day granularity) - var grouped = completedCards - .GroupBy(c => c.UpdatedAt.Date) + foreach (var (cardId, moves) in cardMoveAudits) + { + foreach (var (timestamp, targetColumnId) in moves) + { + if (targetColumnId == doneColumn.Id) + { + completionDates.Add(timestamp); + } + } + } + + // If no audit data is available, fall back to cards currently in the done column + // with UpdatedAt in range (less accurate but provides backward compatibility). + if (completionDates.Count == 0) + { + var fallbackCards = cards + .Where(c => c.ColumnId == doneColumn.Id + && c.UpdatedAt >= from + && c.UpdatedAt <= to) + .ToList(); + + completionDates.AddRange(fallbackCards.Select(c => c.UpdatedAt)); + } + + var grouped = completionDates + .GroupBy(d => d.UtcDateTime.Date) .Select(g => new ThroughputDataPoint( new DateTimeOffset(g.Key, TimeSpan.Zero), g.Count())) @@ -118,30 +210,53 @@ internal static (double AverageCycleTimeDays, IReadOnlyList Entr List cards, Column? doneColumn, DateTimeOffset from, - DateTimeOffset to) + DateTimeOffset to, + Dictionary> cardMoveAudits) { if (doneColumn == null) return (0, Array.Empty()); - // Cards that reached the done column within the date range - var doneCards = cards - .Where(c => c.ColumnId == doneColumn.Id - && c.UpdatedAt >= from - && c.UpdatedAt <= to) - .ToList(); + var entries = new List(); - if (doneCards.Count == 0) - return (0, Array.Empty()); + // Use audit logs: find cards moved to done column, compute cycle time from creation. + foreach (var (cardId, moves) in cardMoveAudits) + { + var doneMove = moves + .Where(m => m.TargetColumnId == doneColumn.Id) + .OrderBy(m => m.Timestamp) + .FirstOrDefault(); - var entries = doneCards - .Select(c => - { - var cycleTime = (c.UpdatedAt - c.CreatedAt).TotalDays; - return new CycleTimeEntry(c.Id, c.Title, Math.Round(cycleTime, 2)); - }) - .OrderBy(e => e.CycleTimeDays) - .ToList(); + if (doneMove.Timestamp == default) continue; + + var card = cards.FirstOrDefault(c => c.Id == cardId); + if (card == null) continue; + + var cycleTime = (doneMove.Timestamp - card.CreatedAt).TotalDays; + entries.Add(new CycleTimeEntry(card.Id, card.Title, Math.Round(cycleTime, 2))); + } + + // Fallback: if no audit data, use UpdatedAt-based calculation for backward compat + if (entries.Count == 0) + { + var doneCards = cards + .Where(c => c.ColumnId == doneColumn.Id + && c.UpdatedAt >= from + && c.UpdatedAt <= to) + .ToList(); + + entries = doneCards + .Select(c => + { + var cycleTime = (c.UpdatedAt - c.CreatedAt).TotalDays; + return new CycleTimeEntry(c.Id, c.Title, Math.Round(cycleTime, 2)); + }) + .ToList(); + } + + if (entries.Count == 0) + return (0, Array.Empty()); + entries = entries.OrderBy(e => e.CycleTimeDays).ToList(); var avgCycleTime = Math.Round(entries.Average(e => e.CycleTimeDays), 2); return (avgCycleTime, entries); @@ -168,7 +283,8 @@ internal static (int BlockedCount, IReadOnlyList BlockedCard .Where(c => c.IsBlocked) .Select(c => { - // Estimate blocked duration from UpdatedAt (when blocked was set) to now + // Note: blocked duration is estimated from UpdatedAt. If the card was edited + // after being blocked, this will underestimate the true blocked duration. var blockedDuration = (DateTimeOffset.UtcNow - c.UpdatedAt).TotalDays; return new BlockedCardSummary( c.Id, diff --git a/backend/tests/Taskdeck.Application.Tests/Services/BoardMetricsServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/BoardMetricsServiceTests.cs index 89dc7bb7c..a08e0162f 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/BoardMetricsServiceTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/BoardMetricsServiceTests.cs @@ -5,6 +5,7 @@ using Taskdeck.Application.Services; using Taskdeck.Domain.Common; using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Enums; using Taskdeck.Domain.Exceptions; using Xunit; @@ -16,6 +17,7 @@ public class BoardMetricsServiceTests private readonly Mock _boardRepoMock; private readonly Mock _columnRepoMock; private readonly Mock _cardRepoMock; + private readonly Mock _auditLogRepoMock; private readonly Mock _authServiceMock; private readonly BoardMetricsService _service; @@ -28,16 +30,30 @@ public BoardMetricsServiceTests() _boardRepoMock = new Mock(); _columnRepoMock = new Mock(); _cardRepoMock = new Mock(); + _auditLogRepoMock = new Mock(); _authServiceMock = new Mock(); _unitOfWorkMock.Setup(u => u.Boards).Returns(_boardRepoMock.Object); _unitOfWorkMock.Setup(u => u.Columns).Returns(_columnRepoMock.Object); _unitOfWorkMock.Setup(u => u.Cards).Returns(_cardRepoMock.Object); + _unitOfWorkMock.Setup(u => u.AuditLogs).Returns(_auditLogRepoMock.Object); _authServiceMock .Setup(a => a.CanReadBoardAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(Result.Success(true)); + _auditLogRepoMock + .Setup(a => a.QueryAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new List()); + _service = new BoardMetricsService(_unitOfWorkMock.Object, _authServiceMock.Object); } @@ -78,6 +94,18 @@ public async Task GetBoardMetricsAsync_ShouldFail_WhenFromIsAfterTo() result.ErrorMessage.Should().Contain("before"); } + [Fact] + public async Task GetBoardMetricsAsync_ShouldSucceed_WhenFromEqualsTo() + { + var now = DateTimeOffset.UtcNow; + SetupBoard(new List(), new List()); + var query = new BoardMetricsQuery(_boardId, now, now); + + var result = await _service.GetBoardMetricsAsync(query, _userId); + + result.IsSuccess.Should().BeTrue(); + } + [Fact] public async Task GetBoardMetricsAsync_ShouldFail_WhenBoardNotFound() { @@ -104,6 +132,20 @@ public async Task GetBoardMetricsAsync_ShouldFail_WhenUserLacksPermission() result.ErrorCode.Should().Be(ErrorCodes.Forbidden); } + [Fact] + public async Task GetBoardMetricsAsync_ShouldFail_WhenAuthServiceReturnsError() + { + _authServiceMock + .Setup(a => a.CanReadBoardAsync(_userId, _boardId)) + .ReturnsAsync(Result.Failure(ErrorCodes.UnexpectedError, "Auth service unavailable")); + + var query = new BoardMetricsQuery(_boardId, DateTimeOffset.UtcNow.AddDays(-7), DateTimeOffset.UtcNow); + var result = await _service.GetBoardMetricsAsync(query, _userId); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.UnexpectedError); + } + #endregion #region Successful Metrics Computation @@ -179,6 +221,59 @@ public async Task GetBoardMetricsAsync_ShouldCountBlockedCards() #endregion + #region Done Column Resolution + + [Fact] + public void ResolveDoneColumn_ShouldPreferNamedDoneColumn() + { + var backlog = CreateColumn("Backlog", 0); + var inProgress = CreateColumn("In Progress", 1); + var done = CreateColumn("Done", 2); + var archive = CreateColumn("Archive", 3); + + var result = BoardMetricsService.ResolveDoneColumn( + new List { backlog, inProgress, done, archive }); + + result.Should().NotBeNull(); + result!.Name.Should().Be("Done"); + } + + [Fact] + public void ResolveDoneColumn_ShouldMatchCaseInsensitively() + { + var todo = CreateColumn("TODO", 0); + var completed = CreateColumn("COMPLETED", 1); + + var result = BoardMetricsService.ResolveDoneColumn( + new List { todo, completed }); + + result.Should().NotBeNull(); + result!.Name.Should().Be("COMPLETED"); + } + + [Fact] + public void ResolveDoneColumn_ShouldFallbackToRightmostColumn() + { + var backlog = CreateColumn("Backlog", 0); + var review = CreateColumn("Review", 1); + var deployed = CreateColumn("Deployed", 2); + + var result = BoardMetricsService.ResolveDoneColumn( + new List { backlog, review, deployed }); + + result.Should().NotBeNull(); + result!.Name.Should().Be("Deployed"); + } + + [Fact] + public void ResolveDoneColumn_ShouldReturnNull_WhenNoColumns() + { + var result = BoardMetricsService.ResolveDoneColumn(new List()); + result.Should().BeNull(); + } + + #endregion + #region Static Computation Tests [Fact] @@ -188,7 +283,8 @@ public void ComputeThroughput_ShouldReturnEmpty_WhenNoDoneColumn() new List(), null, DateTimeOffset.UtcNow.AddDays(-7), - DateTimeOffset.UtcNow); + DateTimeOffset.UtcNow, + new Dictionary>()); result.Should().BeEmpty(); } @@ -200,7 +296,8 @@ public void ComputeCycleTime_ShouldReturnZero_WhenNoDoneColumn() new List(), null, DateTimeOffset.UtcNow.AddDays(-7), - DateTimeOffset.UtcNow); + DateTimeOffset.UtcNow, + new Dictionary>()); avg.Should().Be(0); entries.Should().BeEmpty(); @@ -243,6 +340,98 @@ public void ComputeBlocked_ShouldReturnOnlyBlockedCards() cards[0].CardTitle.Should().Be("Blocked"); } + [Fact] + public void ComputeBlocked_ShouldReturnEmpty_WhenNoBlockedCards() + { + var colId = Guid.NewGuid(); + var active = CreateCard(colId, "Active"); + + var (count, cards) = BoardMetricsService.ComputeBlocked(new List { active }); + + count.Should().Be(0); + cards.Should().BeEmpty(); + } + + #endregion + + #region Audit-Based Throughput Tests + + [Fact] + public void ComputeThroughput_ShouldUseAuditData_WhenAvailable() + { + var doneCol = CreateColumn("Done", 1); + var card = CreateCard(doneCol.Id, "Completed Card"); + + var moveTimestamp = DateTimeOffset.UtcNow.AddDays(-2); + var audits = new Dictionary> + { + { card.Id, new List<(DateTimeOffset, Guid)> { (moveTimestamp, doneCol.Id) } } + }; + + var result = BoardMetricsService.ComputeThroughput( + new List { card }, + doneCol, + DateTimeOffset.UtcNow.AddDays(-7), + DateTimeOffset.UtcNow, + audits); + + result.Should().HaveCount(1); + result[0].CompletedCount.Should().Be(1); + } + + [Fact] + public void ComputeCycleTime_ShouldUseAuditTimestamp_ForAccuracy() + { + var doneCol = CreateColumn("Done", 1); + var card = CreateCard(doneCol.Id, "Card"); + + // Move timestamp must be after card creation to produce positive cycle time + var moveTimestamp = DateTimeOffset.UtcNow.AddDays(2); + var audits = new Dictionary> + { + { card.Id, new List<(DateTimeOffset, Guid)> { (moveTimestamp, doneCol.Id) } } + }; + + var (avg, entries) = BoardMetricsService.ComputeCycleTime( + new List { card }, + doneCol, + DateTimeOffset.UtcNow.AddDays(-7), + DateTimeOffset.UtcNow.AddDays(1), + audits); + + entries.Should().HaveCount(1); + avg.Should().BeGreaterThan(0); + } + + #endregion + + #region ParseTargetColumnId Tests + + [Fact] + public void ParseTargetColumnId_ShouldParseValidChanges() + { + var columnId = Guid.NewGuid(); + var changes = $"target_column={columnId}; position=0"; + + var result = BoardMetricsService.ParseTargetColumnId(changes); + + result.Should().Be(columnId); + } + + [Fact] + public void ParseTargetColumnId_ShouldReturnNull_ForInvalidFormat() + { + var result = BoardMetricsService.ParseTargetColumnId("some random text"); + result.Should().BeNull(); + } + + [Fact] + public void ParseTargetColumnId_ShouldReturnNull_ForEmptyString() + { + var result = BoardMetricsService.ParseTargetColumnId(""); + result.Should().BeNull(); + } + #endregion #region Helpers diff --git a/frontend/taskdeck-web/src/store/metricsStore.ts b/frontend/taskdeck-web/src/store/metricsStore.ts index d1181caa6..6d81d6c39 100644 --- a/frontend/taskdeck-web/src/store/metricsStore.ts +++ b/frontend/taskdeck-web/src/store/metricsStore.ts @@ -19,6 +19,7 @@ export const useMetricsStore = defineStore('metrics', () => { error.value = null metrics.value = null loading.value = false + error.value = 'Metrics are not available in demo mode.' return } try { diff --git a/frontend/taskdeck-web/src/tests/store/metricsStore.demo.spec.ts b/frontend/taskdeck-web/src/tests/store/metricsStore.demo.spec.ts index 0ec8673ed..bd35ba630 100644 --- a/frontend/taskdeck-web/src/tests/store/metricsStore.demo.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/metricsStore.demo.spec.ts @@ -48,15 +48,11 @@ describe('metricsStore demo mode', () => { expect(metricsApi.getBoardMetrics).not.toHaveBeenCalled() expect(store.metrics).toBeNull() expect(store.loading).toBe(false) - expect(store.error).toBeNull() }) - it('fetchBoardMetrics in demo mode clears any prior error', async () => { - // Manually set error to simulate prior state - store.error = 'prior error' - + it('fetchBoardMetrics in demo mode shows demo mode message', async () => { await store.fetchBoardMetrics({ boardId: 'board-1' }) - expect(store.error).toBeNull() + expect(store.error).toBe('Metrics are not available in demo mode.') }) })