diff --git a/backend/src/Taskdeck.Api/Controllers/MetricsController.cs b/backend/src/Taskdeck.Api/Controllers/MetricsController.cs index cb8060b04..2d01956c5 100644 --- a/backend/src/Taskdeck.Api/Controllers/MetricsController.cs +++ b/backend/src/Taskdeck.Api/Controllers/MetricsController.cs @@ -18,11 +18,16 @@ namespace Taskdeck.Api.Controllers; public class MetricsController : AuthenticatedControllerBase { private readonly IBoardMetricsService _metricsService; + private readonly IMetricsExportService _exportService; - public MetricsController(IBoardMetricsService metricsService, IUserContext userContext) + public MetricsController( + IBoardMetricsService metricsService, + IMetricsExportService exportService, + IUserContext userContext) : base(userContext) { _metricsService = metricsService; + _exportService = exportService; } /// @@ -61,4 +66,48 @@ public async Task GetBoardMetrics( var result = await _metricsService.GetBoardMetricsAsync(query, userId); return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + + /// + /// Export board metrics as a downloadable CSV file. + /// + /// The board to export metrics for. + /// Start of date range (ISO 8601). + /// End of date range (ISO 8601). + /// Optional label filter. + /// Cancellation token. + /// CSV file download. + /// CSV file returned. + /// Invalid query parameters. + /// Authentication required. + /// No read access to the board. + /// Board not found. + [HttpGet("boards/{boardId}/export")] + [ProducesResponseType(typeof(FileContentResult), 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 ExportBoardMetrics( + Guid boardId, + [FromQuery] DateTimeOffset? from, + [FromQuery] DateTimeOffset? to, + [FromQuery] Guid? labelId, + CancellationToken cancellationToken) + { + 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 _exportService.ExportCsvAsync(query, userId, cancellationToken); + + if (!result.IsSuccess) + return result.ToErrorActionResult(); + + var export = result.Value; + return File(export.Content, export.ContentType, export.FileName); + } } diff --git a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs index 41684391d..b6782632d 100644 --- a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs @@ -59,6 +59,9 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection new BoardMetricsService( sp.GetRequiredService(), sp.GetRequiredService())); + services.AddScoped(sp => + new MetricsExportService( + sp.GetRequiredService())); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/src/Taskdeck.Application/Services/IMetricsExportService.cs b/backend/src/Taskdeck.Application/Services/IMetricsExportService.cs new file mode 100644 index 000000000..74f6c365e --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/IMetricsExportService.cs @@ -0,0 +1,30 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Common; + +namespace Taskdeck.Application.Services; + +/// +/// Exports board metrics data as CSV with schema-versioned columns. +/// +public interface IMetricsExportService +{ + /// + /// Generate a CSV export of board metrics for the given query. + /// The acting user must have read access to the board. + /// + Task> ExportCsvAsync( + BoardMetricsQuery query, + Guid actingUserId, + CancellationToken cancellationToken = default); +} + +/// +/// Result of a metrics export operation. +/// +/// The raw CSV content bytes. +/// Suggested file name for the download. +/// MIME type of the export. +public sealed record MetricsExportResult( + byte[] Content, + string FileName, + string ContentType); diff --git a/backend/src/Taskdeck.Application/Services/MetricsExportService.cs b/backend/src/Taskdeck.Application/Services/MetricsExportService.cs new file mode 100644 index 000000000..6b03e4918 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/MetricsExportService.cs @@ -0,0 +1,173 @@ +using System.Globalization; +using System.Text; +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Common; + +namespace Taskdeck.Application.Services; + +/// +/// Produces CSV exports of board metrics with schema-versioned columns +/// and CSV-injection-safe cell values. +/// +public class MetricsExportService : IMetricsExportService +{ + /// + /// Schema version embedded as the first comment line in every export. + /// Bump when column layout changes. + /// + internal const string SchemaVersion = "1.0"; + + private readonly IBoardMetricsService _metricsService; + + public MetricsExportService(IBoardMetricsService metricsService) + { + _metricsService = metricsService; + } + + public async Task> ExportCsvAsync( + BoardMetricsQuery query, + Guid actingUserId, + CancellationToken cancellationToken = default) + { + var metricsResult = await _metricsService.GetBoardMetricsAsync(query, actingUserId, cancellationToken); + if (!metricsResult.IsSuccess) + return Result.Failure(metricsResult.ErrorCode, metricsResult.ErrorMessage); + + var metrics = metricsResult.Value; + var csv = BuildCsv(metrics); + + var timestamp = DateTimeOffset.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); + var fileName = $"board-metrics-{metrics.BoardId:N}-{timestamp}.csv"; + + var bom = Encoding.UTF8.GetPreamble(); + var csvBytes = Encoding.UTF8.GetBytes(csv); + var content = new byte[bom.Length + csvBytes.Length]; + bom.CopyTo(content, 0); + csvBytes.CopyTo(content, bom.Length); + + return Result.Success(new MetricsExportResult(content, fileName, "text/csv")); + } + + internal static string BuildCsv(BoardMetricsResponse metrics) + { + var sb = new StringBuilder(); + + // Schema version header (comment line) + sb.AppendLine($"# schema_version={SchemaVersion}"); + sb.AppendLine($"# board_id={metrics.BoardId}"); + sb.AppendLine($"# from={metrics.From:o}"); + sb.AppendLine($"# to={metrics.To:o}"); + sb.AppendLine($"# exported_at={DateTimeOffset.UtcNow:o}"); + sb.AppendLine(); + + // Section: Summary + sb.AppendLine("[Summary]"); + sb.AppendLine("Metric,Value"); + sb.AppendLine($"AverageCycleTimeDays,{metrics.AverageCycleTimeDays.ToString(CultureInfo.InvariantCulture)}"); + sb.AppendLine($"TotalWip,{metrics.TotalWip}"); + sb.AppendLine($"BlockedCount,{metrics.BlockedCount}"); + sb.AppendLine($"TotalThroughput,{metrics.Throughput.Sum(t => t.CompletedCount)}"); + sb.AppendLine(); + + // Section: Throughput + sb.AppendLine("[Throughput]"); + sb.AppendLine("Date,CompletedCount"); + foreach (var dp in metrics.Throughput) + { + sb.AppendLine($"{dp.Date:yyyy-MM-dd},{dp.CompletedCount}"); + } + sb.AppendLine(); + + // Section: CycleTime + sb.AppendLine("[CycleTime]"); + sb.AppendLine("CardId,CardTitle,CycleTimeDays"); + foreach (var entry in metrics.CycleTimeEntries) + { + sb.AppendLine($"{entry.CardId},{SanitizeCsvField(entry.CardTitle)},{entry.CycleTimeDays.ToString(CultureInfo.InvariantCulture)}"); + } + sb.AppendLine(); + + // Section: WIP + sb.AppendLine("[WIP]"); + sb.AppendLine("ColumnId,ColumnName,CardCount,WipLimit"); + foreach (var wip in metrics.WipSnapshots) + { + sb.AppendLine($"{wip.ColumnId},{SanitizeCsvField(wip.ColumnName)},{wip.CardCount},{wip.WipLimit?.ToString(CultureInfo.InvariantCulture) ?? ""}"); + } + sb.AppendLine(); + + // Section: Blocked + sb.AppendLine("[Blocked]"); + sb.AppendLine("CardId,CardTitle,BlockReason,BlockedDurationDays"); + foreach (var blocked in metrics.BlockedCards) + { + sb.AppendLine($"{blocked.CardId},{SanitizeCsvField(blocked.CardTitle)},{SanitizeCsvField(blocked.BlockReason ?? "")},{blocked.BlockedDurationDays.ToString(CultureInfo.InvariantCulture)}"); + } + + return sb.ToString(); + } + + /// + /// Sanitize a field for safe CSV inclusion. + /// - Strips CSV injection characters (=, +, -, @, tab, carriage return) from the start + /// of the value AND from the start of each embedded line (after \n or \r\n). + /// - Quotes the field if it contains commas, quotes, or newlines. + /// - Doubles internal quote characters. + /// + internal static string SanitizeCsvField(string value) + { + if (string.IsNullOrEmpty(value)) + return value; + + // Strip leading characters that could trigger formula injection in spreadsheet apps. + // Apply to each line within the value to prevent injection via embedded newlines + // (e.g. "hello\n=CMD|'/C calc'!A0" must sanitize the second line too). + var sanitized = StripDangerousLeadingChars(value); + sanitized = SanitizeEmbeddedLines(sanitized); + + // If the field contains special CSV characters, quote it + var needsQuoting = sanitized.Contains(',') || + sanitized.Contains('"') || + sanitized.Contains('\n') || + sanitized.Contains('\r'); + + if (needsQuoting) + { + sanitized = "\"" + sanitized.Replace("\"", "\"\"") + "\""; + } + + return sanitized; + } + + private static string StripDangerousLeadingChars(string s) + { + var i = 0; + while (i < s.Length && IsDangerousLeadingChar(s[i])) + i++; + return i == 0 ? s : s[i..]; + } + + /// + /// For each line after the first within a multi-line value, strip leading + /// dangerous characters so that embedded newlines cannot smuggle formula prefixes. + /// + private static string SanitizeEmbeddedLines(string value) + { + if (!value.Contains('\n') && !value.Contains('\r')) + return value; + + var lines = value.Split('\n'); + for (var i = 1; i < lines.Length; i++) + { + var line = lines[i]; + // Handle \r\n: the \r will be at the end of the previous line's split, + // but also strip dangerous chars that appear after a bare \n. + lines[i] = StripDangerousLeadingChars(line); + } + + return string.Join('\n', lines); + } + + private static bool IsDangerousLeadingChar(char c) + => c is '=' or '+' or '-' or '@' or '\t' or '\r'; +} diff --git a/backend/tests/Taskdeck.Api.Tests/MetricsExportApiTests.cs b/backend/tests/Taskdeck.Api.Tests/MetricsExportApiTests.cs new file mode 100644 index 000000000..eb806d705 --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/MetricsExportApiTests.cs @@ -0,0 +1,139 @@ +using System.Net; +using FluentAssertions; +using Taskdeck.Api.Tests.Support; +using Xunit; + +namespace Taskdeck.Api.Tests; + +public class MetricsExportApiTests : IClassFixture +{ + private readonly TestWebApplicationFactory _factory; + + public MetricsExportApiTests(TestWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task ExportCsv_ShouldReturnUnauthorized_WhenNoToken() + { + using var client = _factory.CreateClient(); + var boardId = Guid.NewGuid(); + + var response = await client.GetAsync($"/api/metrics/boards/{boardId}/export"); + + await ApiTestHarness.AssertUnauthorizedAsync(response); + } + + [Fact] + public async Task ExportCsv_ShouldReturnCsvFile_ForOwnBoard() + { + using var client = _factory.CreateClient(); + await ApiTestHarness.AuthenticateAsync(client, "export-owner"); + var board = await ApiTestHarness.CreateBoardAsync(client, "export-board"); + + var response = await client.GetAsync($"/api/metrics/boards/{board.Id}/export"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Content.Headers.ContentType!.MediaType.Should().Be("text/csv"); + response.Content.Headers.ContentDisposition.Should().NotBeNull(); + response.Content.Headers.ContentDisposition!.DispositionType.Should().Be("attachment"); + response.Content.Headers.ContentDisposition.FileName.Should().Contain("board-metrics-"); + response.Content.Headers.ContentDisposition.FileName.Should().EndWith(".csv"); + } + + [Fact] + public async Task ExportCsv_ShouldContainSchemaVersion() + { + using var client = _factory.CreateClient(); + await ApiTestHarness.AuthenticateAsync(client, "export-schema"); + var board = await ApiTestHarness.CreateBoardAsync(client, "export-schema-board"); + + var response = await client.GetAsync($"/api/metrics/boards/{board.Id}/export"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("# schema_version=1.0"); + content.Should().Contain($"# board_id={board.Id}"); + } + + [Fact] + public async Task ExportCsv_ShouldContainAllSections() + { + using var client = _factory.CreateClient(); + await ApiTestHarness.AuthenticateAsync(client, "export-sections"); + var board = await ApiTestHarness.CreateBoardAsync(client, "export-sections-board"); + + var response = await client.GetAsync($"/api/metrics/boards/{board.Id}/export"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("[Summary]"); + content.Should().Contain("[Throughput]"); + content.Should().Contain("[CycleTime]"); + content.Should().Contain("[WIP]"); + content.Should().Contain("[Blocked]"); + } + + [Fact] + public async Task ExportCsv_ShouldReturnForbiddenOrNotFound_ForOtherUsersBoard() + { + using var ownerClient = _factory.CreateClient(); + await ApiTestHarness.AuthenticateAsync(ownerClient, "export-board-owner"); + var board = await ApiTestHarness.CreateBoardAsync(ownerClient, "export-private"); + + using var outsiderClient = _factory.CreateClient(); + await ApiTestHarness.AuthenticateAsync(outsiderClient, "export-outsider"); + + var response = await outsiderClient.GetAsync($"/api/metrics/boards/{board.Id}/export"); + + await ApiTestHarness.AssertNotFoundOrForbiddenAsync(response); + } + + [Fact] + public async Task ExportCsv_NonExistentBoard_ShouldReturnNotFoundOrForbidden() + { + using var client = _factory.CreateClient(); + await ApiTestHarness.AuthenticateAsync(client, "export-notfound"); + + var response = await client.GetAsync($"/api/metrics/boards/{Guid.NewGuid()}/export"); + + await ApiTestHarness.AssertNotFoundOrForbiddenAsync(response); + } + + [Fact] + public async Task ExportCsv_WithDateRange_ShouldRespectFilters() + { + using var client = _factory.CreateClient(); + await ApiTestHarness.AuthenticateAsync(client, "export-range"); + var board = await ApiTestHarness.CreateBoardAsync(client, "export-range-board"); + + var from = DateTimeOffset.UtcNow.AddDays(-7).ToString("o"); + var to = DateTimeOffset.UtcNow.ToString("o"); + + var response = await client.GetAsync( + $"/api/metrics/boards/{board.Id}/export?from={Uri.EscapeDataString(from)}&to={Uri.EscapeDataString(to)}"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("# schema_version=1.0"); + content.Should().Contain("# from="); + content.Should().Contain("# to="); + } + + [Fact] + public async Task ExportCsv_EmptyBoard_ShouldReturnValidCsv() + { + using var client = _factory.CreateClient(); + await ApiTestHarness.AuthenticateAsync(client, "export-empty"); + var board = await ApiTestHarness.CreateBoardAsync(client, "export-empty-board"); + + var response = await client.GetAsync($"/api/metrics/boards/{board.Id}/export"); + + response.StatusCode.Should().Be(HttpStatusCode.OK, + "CSV export for an empty board should succeed, not error"); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("[Summary]"); + content.Should().Contain("TotalThroughput,0"); + } +} diff --git a/backend/tests/Taskdeck.Application.Tests/Services/MetricsExportServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/MetricsExportServiceTests.cs new file mode 100644 index 000000000..819e1a300 --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/MetricsExportServiceTests.cs @@ -0,0 +1,200 @@ +using FluentAssertions; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Services; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class MetricsExportServiceTests +{ + private static BoardMetricsResponse CreateSampleMetrics( + Guid? boardId = null, + IReadOnlyList? throughput = null, + IReadOnlyList? cycleTime = null, + IReadOnlyList? wip = null, + IReadOnlyList? blocked = null) + { + var bid = boardId ?? Guid.NewGuid(); + return new BoardMetricsResponse( + bid, + DateTimeOffset.UtcNow.AddDays(-30), + DateTimeOffset.UtcNow, + throughput ?? Array.Empty(), + 2.5, + cycleTime ?? Array.Empty(), + wip ?? Array.Empty(), + 10, + 0, + blocked ?? Array.Empty()); + } + + [Fact] + public void BuildCsv_ShouldIncludeSchemaVersionHeader() + { + var metrics = CreateSampleMetrics(); + var csv = MetricsExportService.BuildCsv(metrics); + + csv.Should().Contain($"# schema_version={MetricsExportService.SchemaVersion}"); + } + + [Fact] + public void BuildCsv_ShouldIncludeBoardIdHeader() + { + var boardId = Guid.NewGuid(); + var metrics = CreateSampleMetrics(boardId: boardId); + var csv = MetricsExportService.BuildCsv(metrics); + + csv.Should().Contain($"# board_id={boardId}"); + } + + [Fact] + public void BuildCsv_ShouldIncludeAllSections() + { + var metrics = CreateSampleMetrics(); + var csv = MetricsExportService.BuildCsv(metrics); + + csv.Should().Contain("[Summary]"); + csv.Should().Contain("[Throughput]"); + csv.Should().Contain("[CycleTime]"); + csv.Should().Contain("[WIP]"); + csv.Should().Contain("[Blocked]"); + } + + [Fact] + public void BuildCsv_ShouldIncludeSummaryValues() + { + var metrics = CreateSampleMetrics(); + var csv = MetricsExportService.BuildCsv(metrics); + + csv.Should().Contain("AverageCycleTimeDays,2.5"); + csv.Should().Contain("TotalWip,10"); + csv.Should().Contain("BlockedCount,0"); + csv.Should().Contain("TotalThroughput,0"); + } + + [Fact] + public void BuildCsv_ShouldIncludeThroughputData() + { + var throughput = new[] + { + new ThroughputDataPoint(new DateTimeOffset(2026, 4, 1, 0, 0, 0, TimeSpan.Zero), 3), + new ThroughputDataPoint(new DateTimeOffset(2026, 4, 2, 0, 0, 0, TimeSpan.Zero), 5), + }; + + var metrics = CreateSampleMetrics(throughput: throughput); + var csv = MetricsExportService.BuildCsv(metrics); + + csv.Should().Contain("Date,CompletedCount"); + csv.Should().Contain("2026-04-01,3"); + csv.Should().Contain("2026-04-02,5"); + csv.Should().Contain("TotalThroughput,8"); + } + + [Fact] + public void BuildCsv_ShouldIncludeCycleTimeEntries() + { + var cardId = Guid.NewGuid(); + var cycleTime = new[] { new CycleTimeEntry(cardId, "Test Card", 3.5) }; + var metrics = CreateSampleMetrics(cycleTime: cycleTime); + var csv = MetricsExportService.BuildCsv(metrics); + + csv.Should().Contain("CardId,CardTitle,CycleTimeDays"); + csv.Should().Contain($"{cardId},Test Card,3.5"); + } + + [Fact] + public void BuildCsv_ShouldIncludeWipSnapshots() + { + var colId = Guid.NewGuid(); + var wip = new[] { new WipSnapshot(colId, "In Progress", 5, 8) }; + var metrics = CreateSampleMetrics(wip: wip); + var csv = MetricsExportService.BuildCsv(metrics); + + csv.Should().Contain("ColumnId,ColumnName,CardCount,WipLimit"); + csv.Should().Contain($"{colId},In Progress,5,8"); + } + + [Fact] + public void BuildCsv_ShouldIncludeBlockedCards() + { + var cardId = Guid.NewGuid(); + var blocked = new[] { new BlockedCardSummary(cardId, "Blocked Card", "Waiting on API", 2.3) }; + var metrics = CreateSampleMetrics(blocked: blocked); + var csv = MetricsExportService.BuildCsv(metrics); + + csv.Should().Contain("CardId,CardTitle,BlockReason,BlockedDurationDays"); + csv.Should().Contain($"{cardId},Blocked Card,Waiting on API,2.3"); + } + + [Fact] + public void BuildCsv_EmptyMetrics_ShouldReturnValidCsvWithHeaders() + { + var metrics = CreateSampleMetrics(); + var csv = MetricsExportService.BuildCsv(metrics); + + csv.Should().NotBeNullOrWhiteSpace(); + csv.Should().Contain("[Summary]"); + csv.Should().Contain("TotalThroughput,0"); + } + + // --- CSV Injection Protection Tests --- + + [Theory] + [InlineData("=SUM(A1:A10)", "SUM(A1:A10)")] + [InlineData("+cmd|'/C calc'!A0", "cmd|'/C calc'!A0")] + [InlineData("-1+2", "1+2")] + [InlineData("@SUM(1+1)*cmd|'/C calc'!A0", "SUM(1+1)*cmd|'/C calc'!A0")] + [InlineData("\tcmd", "cmd")] + [InlineData("\rcmd", "cmd")] + public void SanitizeCsvField_ShouldStripDangerousLeadingCharacters(string input, string expected) + { + MetricsExportService.SanitizeCsvField(input).Should().Be(expected); + } + + [Fact] + public void SanitizeCsvField_ShouldQuoteFieldWithComma() + { + MetricsExportService.SanitizeCsvField("hello, world").Should().Be("\"hello, world\""); + } + + [Fact] + public void SanitizeCsvField_ShouldDoubleQuotesInField() + { + MetricsExportService.SanitizeCsvField("say \"hello\"").Should().Be("\"say \"\"hello\"\"\""); + } + + [Fact] + public void SanitizeCsvField_ShouldQuoteFieldWithNewline() + { + MetricsExportService.SanitizeCsvField("line1\nline2").Should().Be("\"line1\nline2\""); + } + + [Fact] + public void SanitizeCsvField_ShouldHandleEmptyString() + { + MetricsExportService.SanitizeCsvField("").Should().Be(""); + } + + [Fact] + public void SanitizeCsvField_ShouldHandleNormalText() + { + MetricsExportService.SanitizeCsvField("Normal card title").Should().Be("Normal card title"); + } + + [Fact] + public void SanitizeCsvField_ShouldStripMultipleDangerousLeadingChars() + { + MetricsExportService.SanitizeCsvField("==+cmd").Should().Be("cmd"); + } + + [Theory] + [InlineData("hello\n=CMD|'/C calc'!A0", "\"hello\nCMD|'/C calc'!A0\"")] + [InlineData("line1\n+cmd\nline3", "\"line1\ncmd\nline3\"")] + [InlineData("safe\nsafe2", "\"safe\nsafe2\"")] + [InlineData("ok\n@evil", "\"ok\nevil\"")] + [InlineData("a\n\t\r=bad", "\"a\nbad\"")] + public void SanitizeCsvField_ShouldStripDangerousCharsFromEmbeddedLines(string input, string expected) + { + MetricsExportService.SanitizeCsvField(input).Should().Be(expected); + } +} diff --git a/docs/decisions/ADR-0022-analytics-export-csv-first-pdf-deferred.md b/docs/decisions/ADR-0022-analytics-export-csv-first-pdf-deferred.md new file mode 100644 index 000000000..e67211293 --- /dev/null +++ b/docs/decisions/ADR-0022-analytics-export-csv-first-pdf-deferred.md @@ -0,0 +1,39 @@ +# ADR-0022: Analytics Export — CSV First, PDF Deferred + +- **Status**: Accepted +- **Date**: 2026-04-08 +- **Deciders**: Project maintainers + +## Context + +Issue #78 (ANL-02) requires exportable analytics reports with reproducible filters. The original scope called for both CSV and PDF formats. This ADR records the decision to ship CSV first and defer PDF export. + +## Decision + +Ship CSV export as the initial analytics export format. Defer PDF export to a future iteration. + +CSV export includes: +- Schema version header for forward compatibility +- Documented column layout per section (Summary, Throughput, CycleTime, WIP, Blocked) +- CSV-injection-safe field sanitization (leading `=`, `+`, `-`, `@`, tab, CR stripped) +- Reproducible filter parameters embedded as comment headers (board_id, from, to, exported_at) +- UTF-8 BOM for reliable Excel opening + +## Alternatives Considered + +- **Ship both CSV and PDF simultaneously**: PDF generation requires either a third-party library (e.g., QuestPDF, iTextSharp) or an HTML-to-PDF headless browser pipeline. Both add significant dependency weight to a local-first SQLite application. The marginal user value of PDF over CSV is low for developer-facing analytics, since CSV is directly importable into spreadsheets, scripts, and BI tools. Rejected for this iteration. + +- **PDF only**: CSV is more machine-friendly and more useful for the developer audience. PDF is a presentation format better suited for stakeholder reports, which is not a current Taskdeck use case. Rejected. + +- **Server-side PDF via Chromium headless**: Adds ~300 MB runtime dependency. Entirely disproportionate for a local-first tool. Rejected. + +## Consequences + +- CSV export is available immediately via `GET /api/metrics/boards/{boardId}/export`. +- PDF export can be added later with minimal disruption — the export pipeline is designed with a format-agnostic interface (`IMetricsExportService`). +- If PDF is later needed, the recommended approach is a lightweight .NET PDF library (e.g., QuestPDF) added to the Infrastructure layer, keeping Application layer pure. + +## References + +- Issue: #78 (ANL-02: Exportable analytics reports) +- Related: `BoardMetricsService`, `MetricsExportService` diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index df1c17ce8..3af5d87d4 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -23,3 +23,4 @@ | [0019](ADR-0019-mcp-server-official-sdk-embedded-hosting.md) | MCP Server — Official SDK with Embedded Hosting | Accepted | 2026-04-01 | | [0020](ADR-0020-plugin-extension-architecture.md) | Plugin/Extension Architecture RFC and Sandboxing Constraints | Proposed | 2026-04-01 | | [0021](ADR-0021-jwt-invalidation-user-active-middleware.md) | JWT Invalidation — User-Active Middleware over Token Blocklist | Accepted | 2026-04-03 | +| [0022](ADR-0022-analytics-export-csv-first-pdf-deferred.md) | Analytics Export — CSV First, PDF Deferred | Accepted | 2026-04-08 | diff --git a/frontend/taskdeck-web/src/api/metricsApi.ts b/frontend/taskdeck-web/src/api/metricsApi.ts index 83055c648..d849c5665 100644 --- a/frontend/taskdeck-web/src/api/metricsApi.ts +++ b/frontend/taskdeck-web/src/api/metricsApi.ts @@ -13,4 +13,33 @@ export const metricsApi = { const { data } = await http.get(url) return data }, + + async exportBoardMetricsCsv(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)}/export${qs ? `?${qs}` : ''}` + const response = await http.get(url, { responseType: 'blob' }) + + // Extract filename from Content-Disposition header or use default + const disposition = response.headers['content-disposition'] as string | undefined + let filename = 'board-metrics.csv' + if (disposition) { + const match = disposition.match(/filename[^;=\n]*=["']?([^"';\n]*)["']?/) + if (match?.[1]) filename = match[1] + } + + // Trigger browser download + const blob = new Blob([response.data as BlobPart], { type: 'text/csv' }) + const link = document.createElement('a') + link.href = URL.createObjectURL(blob) + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(link.href) + }, } diff --git a/frontend/taskdeck-web/src/views/MetricsView.vue b/frontend/taskdeck-web/src/views/MetricsView.vue index 2cca4ef4a..5fc4e4f24 100644 --- a/frontend/taskdeck-web/src/views/MetricsView.vue +++ b/frontend/taskdeck-web/src/views/MetricsView.vue @@ -2,16 +2,21 @@ import { computed, onMounted, ref, watch } from 'vue' import { useBoardStore } from '../store/boardStore' import { useMetricsStore } from '../store/metricsStore' +import { useToastStore } from '../store/toastStore' +import { metricsApi } from '../api/metricsApi' +import { getErrorDisplay } from '../composables/useErrorMapper' import type { MetricsQuery } from '../types/metrics' import type { Board } from '../types/board' const boardStore = useBoardStore() const metricsStore = useMetricsStore() +const toast = useToastStore() const selectedBoardId = ref('') const dateRangeDays = ref(30) const boards = ref([]) const boardsLoading = ref(false) +const exporting = ref(false) const fromDate = computed(() => { const d = new Date() @@ -65,6 +70,24 @@ onMounted(async () => { } }) +async function exportCsv() { + if (!selectedBoardId.value) return + exporting.value = true + try { + const query: MetricsQuery = { + boardId: selectedBoardId.value, + from: fromDate.value, + to: toDate.value, + } + await metricsApi.exportBoardMetricsCsv(query) + } catch (e: unknown) { + const { message } = getErrorDisplay(e, 'Failed to export CSV') + toast.error(message) + } finally { + exporting.value = false + } +} + // Computed helpers for the template const metrics = computed(() => metricsStore.metrics) const loading = computed(() => metricsStore.loading) @@ -123,6 +146,17 @@ const maxWipCount = computed(() => { + +
+ +
@@ -337,6 +371,11 @@ const maxWipCount = computed(() => { letter-spacing: 0.1em; } +.td-metrics__filter-group--action { + justify-content: flex-end; + align-self: flex-end; +} + .td-metrics__select { padding: var(--td-space-2) var(--td-space-3); border: 1px solid var(--td-border-ghost);