Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions backend/src/Taskdeck.Api/Controllers/MetricsController.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Board metrics endpoints: throughput, cycle time, WIP, blocked trends.
/// </summary>
[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;
}

/// <summary>
/// Get board metrics (throughput, cycle time, WIP, blocked) for a date range.
/// </summary>
/// <param name="boardId">The board to compute metrics for.</param>
/// <param name="from">Start of date range (ISO 8601).</param>
/// <param name="to">End of date range (ISO 8601).</param>
/// <param name="labelId">Optional label filter.</param>
/// <returns>Aggregated board metrics.</returns>
/// <response code="200">Metrics computed successfully.</response>
/// <response code="400">Invalid query parameters.</response>
/// <response code="401">Authentication required.</response>
/// <response code="403">No read access to the board.</response>
/// <response code="404">Board not found.</response>
[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<IActionResult> 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();
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Taskdeck.Api.Realtime;
using Taskdeck.Application.Interfaces;
using Taskdeck.Application.Services;
using Taskdeck.Domain.Agents;

Expand Down Expand Up @@ -48,6 +49,10 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
services.AddScoped<IStarterPackApplyService, StarterPackApplyService>();
services.AddScoped<IStarterPackCatalogService, StarterPackCatalogService>();
services.AddScoped<IOutboundWebhookService, OutboundWebhookService>();
services.AddScoped<IBoardMetricsService>(sp =>
new BoardMetricsService(
sp.GetRequiredService<IUnitOfWork>(),
sp.GetRequiredService<IAuthorizationService>()));
services.AddScoped<AgentProfileService>();
services.AddScoped<AgentRunService>();
services.AddScoped<SignalRBoardRealtimeNotifier>();
Expand Down
59 changes: 59 additions & 0 deletions backend/src/Taskdeck.Application/DTOs/BoardMetricsDtos.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
namespace Taskdeck.Application.DTOs;

/// <summary>
/// Query parameters for board metrics.
/// </summary>
public sealed record BoardMetricsQuery(
Guid BoardId,
DateTimeOffset From,
DateTimeOffset To,
Guid? LabelId = null);

/// <summary>
/// Throughput data point: number of cards completed in a time bucket.
/// </summary>
public sealed record ThroughputDataPoint(
DateTimeOffset Date,
int CompletedCount);

/// <summary>
/// Cycle time entry: how long a card took from creation to reaching the
/// final (rightmost) column.
/// </summary>
public sealed record CycleTimeEntry(
Guid CardId,
string CardTitle,
double CycleTimeDays);

/// <summary>
/// Snapshot of work-in-progress across columns.
/// </summary>
public sealed record WipSnapshot(
Guid ColumnId,
string ColumnName,
int CardCount,
int? WipLimit);

/// <summary>
/// Blocked card summary.
/// </summary>
public sealed record BlockedCardSummary(
Guid CardId,
string CardTitle,
string? BlockReason,
double BlockedDurationDays);

/// <summary>
/// Aggregate response containing all board metrics.
/// </summary>
public sealed record BoardMetricsResponse(
Guid BoardId,
DateTimeOffset From,
DateTimeOffset To,
IReadOnlyList<ThroughputDataPoint> Throughput,
double AverageCycleTimeDays,
IReadOnlyList<CycleTimeEntry> CycleTimeEntries,
IReadOnlyList<WipSnapshot> WipSnapshots,
int TotalWip,
int BlockedCount,
IReadOnlyList<BlockedCardSummary> BlockedCards);
Loading
Loading