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(); + } +} 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(); 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); diff --git a/backend/src/Taskdeck.Application/Services/BoardMetricsService.cs b/backend/src/Taskdeck.Application/Services/BoardMetricsService.cs new file mode 100644 index 000000000..321ec07f0 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/BoardMetricsService.cs @@ -0,0 +1,300 @@ +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; + +public class BoardMetricsService : IBoardMetricsService +{ + private readonly IUnitOfWork _unitOfWork; + private readonly IAuthorizationService _authorizationService; + + /// + /// 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) + { + _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 + 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(); + } + + // 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, 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); + + return Result.Success(new BoardMetricsResponse( + query.BoardId, + query.From, + query.To, + throughput, + avgCycleTime, + cycleTimeEntries, + wipSnapshots, + totalWip, + blockedCount, + 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, + Dictionary> cardMoveAudits) + { + if (doneColumn == null) + return Array.Empty(); + + // 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(); + + 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())) + .OrderBy(dp => dp.Date) + .ToList(); + + return grouped; + } + + internal static (double AverageCycleTimeDays, IReadOnlyList Entries) ComputeCycleTime( + List cards, + Column? doneColumn, + DateTimeOffset from, + DateTimeOffset to, + Dictionary> cardMoveAudits) + { + if (doneColumn == null) + return (0, Array.Empty()); + + var entries = new List(); + + // 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(); + + 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); + } + + 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 => + { + // 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, + c.Title, + c.BlockReason, + Math.Round(blockedDuration, 2)); + }) + .OrderByDescending(b => b.BlockedDurationDays) + .ToList(); + + return (blockedCards.Count, blockedCards); + } +} 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); +} 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..a08e0162f --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/BoardMetricsServiceTests.cs @@ -0,0 +1,458 @@ +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.Enums; +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 _auditLogRepoMock; + 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(); + _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); + } + + #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_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() + { + _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); + } + + [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 + + [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 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] + public void ComputeThroughput_ShouldReturnEmpty_WhenNoDoneColumn() + { + var result = BoardMetricsService.ComputeThroughput( + new List(), + null, + DateTimeOffset.UtcNow.AddDays(-7), + DateTimeOffset.UtcNow, + new Dictionary>()); + + result.Should().BeEmpty(); + } + + [Fact] + public void ComputeCycleTime_ShouldReturnZero_WhenNoDoneColumn() + { + var (avg, entries) = BoardMetricsService.ComputeCycleTime( + new List(), + null, + DateTimeOffset.UtcNow.AddDays(-7), + DateTimeOffset.UtcNow, + new Dictionary>()); + + 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"); + } + + [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 + + 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); + } + + 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 +} 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 + }, +} 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', diff --git a/frontend/taskdeck-web/src/store/metricsStore.ts b/frontend/taskdeck-web/src/store/metricsStore.ts new file mode 100644 index 000000000..6d81d6c39 --- /dev/null +++ b/frontend/taskdeck-web/src/store/metricsStore.ts @@ -0,0 +1,52 @@ +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 + error.value = 'Metrics are not available in demo mode.' + 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, + } +}) 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') + }) +}) 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..bd35ba630 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/store/metricsStore.demo.spec.ts @@ -0,0 +1,58 @@ +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) + }) + + it('fetchBoardMetrics in demo mode shows demo mode message', async () => { + await store.fetchBoardMetrics({ boardId: 'board-1' }) + + expect(store.error).toBe('Metrics are not available in demo mode.') + }) +}) 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() + }) +}) 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 +} 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 @@ + + + + +