Install App
diff --git a/src/BikeTracking.Frontend/src/services/export-api.ts b/src/BikeTracking.Frontend/src/services/export-api.ts
new file mode 100644
index 0000000..0f2d540
--- /dev/null
+++ b/src/BikeTracking.Frontend/src/services/export-api.ts
@@ -0,0 +1,82 @@
+import { getApiBaseUrl } from "./api-config";
+
+const SESSION_KEY = "bike_tracking_auth_session";
+
+function getAuthHeaders(): Record {
+ const headers: Record = {};
+
+ try {
+ const raw = sessionStorage.getItem(SESSION_KEY);
+ if (!raw) {
+ return headers;
+ }
+
+ const parsed = JSON.parse(raw) as { userId?: number };
+ if (typeof parsed.userId === "number" && parsed.userId > 0) {
+ headers["X-User-Id"] = parsed.userId.toString();
+ }
+ } catch {
+ // Ignore malformed session payloads and continue unauthenticated.
+ }
+
+ return headers;
+}
+
+/**
+ * Downloads all expense records for the authenticated rider as a UTF-8 CSV file.
+ * Mirrors the existing `downloadExpenseReceipt` blob-download pattern.
+ */
+export async function fetchExpensesCsv(): Promise {
+ const response = await fetch(`${getApiBaseUrl()}/api/exports/expenses`, {
+ headers: getAuthHeaders(),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Expense export failed: ${response.status} ${response.statusText}`);
+ }
+
+ const blob = await response.blob();
+ const objectUrl = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+
+ link.href = objectUrl;
+ link.download = "expenses-export.csv";
+ document.body.append(link);
+ link.click();
+ link.remove();
+
+ // Revoke the object URL after a short delay to allow the download to start.
+ setTimeout(() => {
+ URL.revokeObjectURL(objectUrl);
+ }, 100);
+}
+
+/**
+ * Downloads all ride records for the authenticated rider as a ZIP archive
+ * containing one CSV per calendar year.
+ * Mirrors the existing `downloadExpenseReceipt` blob-download pattern.
+ */
+export async function fetchRideHistoryZip(): Promise {
+ const response = await fetch(`${getApiBaseUrl()}/api/exports/rides`, {
+ headers: getAuthHeaders(),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Ride history export failed: ${response.status} ${response.statusText}`);
+ }
+
+ const blob = await response.blob();
+ const objectUrl = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+
+ link.href = objectUrl;
+ link.download = "ride-history-export.zip";
+ document.body.append(link);
+ link.click();
+ link.remove();
+
+ // Revoke the object URL after a short delay to allow the download to start.
+ setTimeout(() => {
+ URL.revokeObjectURL(objectUrl);
+ }, 100);
+}
diff --git a/src/BikeTracking.Frontend/tests/e2e/export.spec.ts b/src/BikeTracking.Frontend/tests/e2e/export.spec.ts
new file mode 100644
index 0000000..4622920
--- /dev/null
+++ b/src/BikeTracking.Frontend/tests/e2e/export.spec.ts
@@ -0,0 +1,222 @@
+import { expect, test } from "@playwright/test";
+import { createAndLoginUser, uniqueUser } from "./support/auth-helpers";
+import { recordExpense } from "./support/expense-helpers";
+import { recordRide } from "./support/ride-helpers";
+
+const TEST_PIN = "87654321";
+
+test.describe("028-csv-data-export e2e", () => {
+ // ─────────────────────────────────────────────────────────────────────────
+ // Scenario 1: Expense CSV export
+ // ─────────────────────────────────────────────────────────────────────────
+
+ test("Scenario 1: expense CSV downloads with correct filename, header row, and data row", async ({
+ page,
+ }) => {
+ const userName = uniqueUser("e2e-export-expense");
+ await createAndLoginUser(page, userName, TEST_PIN);
+
+ await recordExpense(page, {
+ expenseDate: "2026-01-15",
+ amount: "49.95",
+ note: "Chain replacement",
+ });
+
+ await page.goto("/settings");
+
+ // Intercept the download
+ const [download] = await Promise.all([
+ page.waitForEvent("download"),
+ page.getByRole("button", { name: /export expenses/i }).click(),
+ ]);
+
+ expect(download.suggestedFilename()).toBe("expenses-export.csv");
+
+ const stream = await download.createReadStream();
+ const chunks: Buffer[] = [];
+ for await (const chunk of stream) {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ }
+ const content = Buffer.concat(chunks).toString("utf-8");
+ const lines = content.split("\n").filter((l) => l.trim().length > 0);
+
+ // Header row
+ expect(lines[0]).toBe("ExpenseId,Date,Amount,Notes,CreatedAtUtc");
+
+ // At least one data row
+ expect(lines.length).toBeGreaterThanOrEqual(2);
+
+ // Data row contains the expense data
+ expect(lines.slice(1).join("\n")).toContain("Chain replacement");
+ expect(lines.slice(1).join("\n")).toContain("2026-01-15");
+ });
+
+ test("Scenario 1 (empty dataset): expense CSV with no expenses returns header-only", async ({
+ page,
+ }) => {
+ const userName = uniqueUser("e2e-export-expense-empty");
+ await createAndLoginUser(page, userName, TEST_PIN);
+
+ await page.goto("/settings");
+
+ const [download] = await Promise.all([
+ page.waitForEvent("download"),
+ page.getByRole("button", { name: /export expenses/i }).click(),
+ ]);
+
+ expect(download.suggestedFilename()).toBe("expenses-export.csv");
+
+ const stream = await download.createReadStream();
+ const chunks: Buffer[] = [];
+ for await (const chunk of stream) {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ }
+ const content = Buffer.concat(chunks).toString("utf-8");
+ const lines = content.split("\n").filter((l) => l.trim().length > 0);
+
+ expect(lines).toHaveLength(1);
+ expect(lines[0]).toBe("ExpenseId,Date,Amount,Notes,CreatedAtUtc");
+ });
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Scenario 2: Ride history ZIP export
+ // ─────────────────────────────────────────────────────────────────────────
+
+ test("Scenario 2: ride history ZIP downloads with correct filename", async ({
+ page,
+ }) => {
+ const userName = uniqueUser("e2e-export-ride");
+ await createAndLoginUser(page, userName, TEST_PIN);
+
+ await recordRide(page, {
+ rideDateTimeLocal: "2026-01-15T07:30",
+ miles: "12.5",
+ });
+
+ await page.goto("/settings");
+
+ const [download] = await Promise.all([
+ page.waitForEvent("download"),
+ page.getByRole("button", { name: /export ride history/i }).click(),
+ ]);
+
+ expect(download.suggestedFilename()).toBe("ride-history-export.zip");
+ });
+
+ test("Scenario 2 (empty dataset): ride ZIP with no rides returns ZIP file", async ({
+ page,
+ }) => {
+ const userName = uniqueUser("e2e-export-ride-empty");
+ await createAndLoginUser(page, userName, TEST_PIN);
+
+ await page.goto("/settings");
+
+ const [download] = await Promise.all([
+ page.waitForEvent("download"),
+ page.getByRole("button", { name: /export ride history/i }).click(),
+ ]);
+
+ expect(download.suggestedFilename()).toBe("ride-history-export.zip");
+ // Verify the response came back (file has non-zero size)
+ const path = await download.path();
+ expect(path).toBeTruthy();
+ });
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Scenario 3: User isolation
+ // ─────────────────────────────────────────────────────────────────────────
+
+ test("Scenario 3: expense export is scoped to the authenticated user only", async ({
+ page,
+ browser,
+ }) => {
+ const userA = uniqueUser("e2e-export-isolation-a");
+ const userB = uniqueUser("e2e-export-isolation-b");
+
+ // User A records an expense
+ await createAndLoginUser(page, userA, TEST_PIN);
+ await recordExpense(page, {
+ expenseDate: "2026-03-01",
+ amount: "25.00",
+ note: "User A only expense",
+ });
+
+ await page.goto("/settings");
+ const [downloadA] = await Promise.all([
+ page.waitForEvent("download"),
+ page.getByRole("button", { name: /export expenses/i }).click(),
+ ]);
+
+ const streamA = await downloadA.createReadStream();
+ const chunksA: Buffer[] = [];
+ for await (const chunk of streamA) {
+ chunksA.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ }
+ const contentA = Buffer.concat(chunksA).toString("utf-8");
+
+ expect(contentA).toContain("User A only expense");
+
+ // User B session (separate context)
+ const contextB = await browser.newContext();
+ const pageB = await contextB.newPage();
+ await createAndLoginUser(pageB, userB, TEST_PIN);
+
+ await pageB.goto("/settings");
+ const [downloadB] = await Promise.all([
+ pageB.waitForEvent("download"),
+ pageB.getByRole("button", { name: /export expenses/i }).click(),
+ ]);
+
+ const streamB = await downloadB.createReadStream();
+ const chunksB: Buffer[] = [];
+ for await (const chunk of streamB) {
+ chunksB.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ }
+ const contentB = Buffer.concat(chunksB).toString("utf-8");
+
+ // User B's export must NOT contain User A's data
+ expect(contentB).not.toContain("User A only expense");
+
+ await contextB.close();
+ });
+
+ // ─────────────────────────────────────────────────────────────────────────
+ // Scenario 4: Independent button operation
+ // ─────────────────────────────────────────────────────────────────────────
+
+ test("Scenario 4: Export Expenses and Export Ride History buttons operate independently", async ({
+ page,
+ }) => {
+ const userName = uniqueUser("e2e-export-independent");
+ await createAndLoginUser(page, userName, TEST_PIN);
+
+ await page.goto("/settings");
+
+ // Both buttons should be visible
+ await expect(
+ page.getByRole("button", { name: /export expenses/i }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: /export ride history/i }),
+ ).toBeVisible();
+
+ // Click Expenses only — ride history button should remain enabled
+ const [expensesDownload] = await Promise.all([
+ page.waitForEvent("download"),
+ page.getByRole("button", { name: /export expenses/i }).click(),
+ ]);
+ expect(expensesDownload.suggestedFilename()).toBe("expenses-export.csv");
+
+ // Ride history button is still clickable
+ await expect(
+ page.getByRole("button", { name: /export ride history/i }),
+ ).toBeEnabled();
+
+ // Click Ride History independently
+ const [ridesDownload] = await Promise.all([
+ page.waitForEvent("download"),
+ page.getByRole("button", { name: /export ride history/i }).click(),
+ ]);
+ expect(ridesDownload.suggestedFilename()).toBe("ride-history-export.zip");
+ });
+});
From ec25a9e2708adb82e8612cf995a720c26f49214b Mon Sep 17 00:00:00 2001
From: aligneddev
Date: Tue, 28 Jul 2026 16:18:09 +0000
Subject: [PATCH 3/4] don't include ids
---
.../contracts/export-endpoints.md | 16 ++-
.../Export/ExpenseExportEndpointTests.cs | 100 ++++++++++++------
.../Export/RideExportEndpointTests.cs | 4 +-
.../Export/ExpenseCsvExportService.cs | 23 ++--
.../Export/RideHistoryCsvExportService.cs | 40 ++++---
5 files changed, 117 insertions(+), 66 deletions(-)
diff --git a/specs/028-csv-data-export/contracts/export-endpoints.md b/specs/028-csv-data-export/contracts/export-endpoints.md
index cde30e5..c52c197 100644
--- a/specs/028-csv-data-export/contracts/export-endpoints.md
+++ b/specs/028-csv-data-export/contracts/export-endpoints.md
@@ -32,16 +32,15 @@ Content-Disposition: attachment; filename="expenses-export.csv"
**Body**: UTF-8 CSV file with BOM-free encoding.
```
-ExpenseId,Date,Amount,Notes,CreatedAtUtc
-101,2026-01-15,49.95,Chain replacement,2026-01-15T10:23:00Z
-102,2026-02-03,12.00,,2026-02-03T08:00:00Z
-103,2026-03-10,7.50,"Tyre, inner tube",2026-03-10T12:00:00Z
+Date,Amount,Notes,CreatedAtUtc
+2026-01-15,49.95,Chain replacement,2026-01-15T10:23:00Z
+2026-02-03,12.00,,2026-02-03T08:00:00Z
+2026-03-10,7.50,"Tyre, inner tube",2026-03-10T12:00:00Z
```
**Column definitions**:
| Column | Format | Nullable |
|--------------|-------------------------------|----------|
-| `ExpenseId` | Integer | No |
| `Date` | `yyyy-MM-dd` | No |
| `Amount` | Decimal (no currency symbol) | No |
| `Notes` | String, RFC 4180 quoted | Yes (blank) |
@@ -93,15 +92,14 @@ ride-history-export.zip
Each per-year CSV format:
```
-RideId,Date,Miles,RideMinutes,Temperature,GasPricePerGallon,WindSpeedMph,WindDirectionDeg,RelativeHumidityPercent,CloudCoverPercent,PrecipitationType,Note,WeatherUserOverridden,Difficulty,PrimaryTravelDirection,WindResistanceRating,ImportSource,SnapshotAverageCarMpg,SnapshotMileageRateCents,SnapshotYearlyGoalMiles,SnapshotOilChangePrice,CreatedAtUtc
-1,2025-06-15T07:30:00,12.5,45,68.0,3.459,8.2,45,55,10,,Morning commute,false,3,NE,2,,25.0,6700,2000,79.00,2025-06-15T12:35:00Z
-2,2025-06-16T07:28:00,12.5,43,71.0,,,,,,,,"Windy, tough ride",false,5,North,4,,,,,2025-06-16T12:30:00Z
+Date,Miles,RideMinutes,Temperature,GasPricePerGallon,WindSpeedMph,WindDirectionDeg,RelativeHumidityPercent,CloudCoverPercent,PrecipitationType,Note,WeatherUserOverridden,Difficulty,PrimaryTravelDirection,WindResistanceRating,ImportSource,SnapshotAverageCarMpg,SnapshotMileageRateCents,SnapshotYearlyGoalMiles,SnapshotOilChangePrice,CreatedAtUtc
+2025-06-15T07:30:00,12.5,45,68.0,3.459,8.2,45,55,10,,Morning commute,false,3,NE,2,,25.0,6700,2000,79.00,2025-06-15T12:35:00Z
+2025-06-16T07:28:00,12.5,43,71.0,,,,,,,,"Windy, tough ride",false,5,North,4,,,,,2025-06-16T12:30:00Z
```
**Column definitions**:
| Column | Format | Nullable |
|---------------------------|--------------------------------|---------------|
-| `RideId` | Integer | No |
| `Date` | `yyyy-MM-ddTHH:mm:ss` | No |
| `Miles` | Decimal | No |
| `RideMinutes` | Integer | Yes (blank) |
diff --git a/src/BikeTracking.Api.Tests/Endpoints/Export/ExpenseExportEndpointTests.cs b/src/BikeTracking.Api.Tests/Endpoints/Export/ExpenseExportEndpointTests.cs
index c272d15..47dd1f4 100644
--- a/src/BikeTracking.Api.Tests/Endpoints/Export/ExpenseExportEndpointTests.cs
+++ b/src/BikeTracking.Api.Tests/Endpoints/Export/ExpenseExportEndpointTests.cs
@@ -68,7 +68,7 @@ public async Task GetExpenseExport_FirstLineIsHeaderRow()
var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.True(lines.Length >= 1);
- Assert.Equal("ExpenseId,Date,Amount,Notes,CreatedAtUtc", lines[0]);
+ Assert.Equal("Date,Amount,Notes,CreatedAtUtc", lines[0]);
}
// ──────────────────────────────────────────────────────────────────────
@@ -86,7 +86,7 @@ public async Task GetExpenseExport_WithNoExpenses_ReturnsHeaderOnly()
var lines = body.Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Single(lines);
- Assert.Equal("ExpenseId,Date,Amount,Notes,CreatedAtUtc", lines[0]);
+ Assert.Equal("Date,Amount,Notes,CreatedAtUtc", lines[0]);
}
// ──────────────────────────────────────────────────────────────────────
@@ -99,9 +99,21 @@ public async Task GetExpenseExport_WithMultipleExpenses_ReturnsAllDataRows()
await using var host = await ExportApiHost.StartAsync();
var userId = await host.SeedUserAsync("export-multi");
- await host.SeedExpenseAsync(userId, new DateTime(2026, 1, 15), 49.95m, "Chain replacement", false);
+ await host.SeedExpenseAsync(
+ userId,
+ new DateTime(2026, 1, 15),
+ 49.95m,
+ "Chain replacement",
+ false
+ );
await host.SeedExpenseAsync(userId, new DateTime(2026, 2, 3), 12.00m, null, false);
- await host.SeedExpenseAsync(userId, new DateTime(2026, 3, 10), 7.50m, "Tyre, inner tube", false);
+ await host.SeedExpenseAsync(
+ userId,
+ new DateTime(2026, 3, 10),
+ 7.50m,
+ "Tyre, inner tube",
+ false
+ );
var response = await host.Client.GetWithExportAuthAsync("/api/exports/expenses", userId);
var body = await response.Content.ReadAsStringAsync();
@@ -117,7 +129,13 @@ public async Task GetExpenseExport_DataRowContainsExpectedFields()
await using var host = await ExportApiHost.StartAsync();
var userId = await host.SeedUserAsync("export-fields");
- await host.SeedExpenseAsync(userId, new DateTime(2026, 1, 15), 49.95m, "Chain replacement", false);
+ await host.SeedExpenseAsync(
+ userId,
+ new DateTime(2026, 1, 15),
+ 49.95m,
+ "Chain replacement",
+ false
+ );
var response = await host.Client.GetWithExportAuthAsync("/api/exports/expenses", userId);
var body = await response.Content.ReadAsStringAsync();
@@ -140,7 +158,13 @@ public async Task GetExpenseExport_NotesWithComma_IsRfc4180Quoted()
await using var host = await ExportApiHost.StartAsync();
var userId = await host.SeedUserAsync("export-quoted");
- await host.SeedExpenseAsync(userId, new DateTime(2026, 3, 10), 7.50m, "Tyre, inner tube", false);
+ await host.SeedExpenseAsync(
+ userId,
+ new DateTime(2026, 3, 10),
+ 7.50m,
+ "Tyre, inner tube",
+ false
+ );
var response = await host.Client.GetWithExportAuthAsync("/api/exports/expenses", userId);
var body = await response.Content.ReadAsStringAsync();
@@ -165,7 +189,7 @@ public async Task GetExpenseExport_NullNotes_RendersAsBlankCell()
Assert.Equal(2, lines.Length);
// Notes cell is blank — row ends with two commas before CreatedAtUtc or empty Notes cell
var fields = SplitCsvRow(lines[1]);
- Assert.Equal(string.Empty, fields[3]); // Notes is index 3
+ Assert.Equal(string.Empty, fields[2]); // Notes is index 2
}
// ──────────────────────────────────────────────────────────────────────
@@ -179,8 +203,20 @@ public async Task GetExpenseExport_ReturnsOnlyAuthenticatedUserExpenses()
var riderA = await host.SeedUserAsync("scope-rider-a");
var riderB = await host.SeedUserAsync("scope-rider-b");
- await host.SeedExpenseAsync(riderA, new DateTime(2026, 1, 1), 10m, "Rider A expense", false);
- await host.SeedExpenseAsync(riderB, new DateTime(2026, 1, 2), 99m, "Rider B expense", false);
+ await host.SeedExpenseAsync(
+ riderA,
+ new DateTime(2026, 1, 1),
+ 10m,
+ "Rider A expense",
+ false
+ );
+ await host.SeedExpenseAsync(
+ riderB,
+ new DateTime(2026, 1, 2),
+ 99m,
+ "Rider B expense",
+ false
+ );
var response = await host.Client.GetWithExportAuthAsync("/api/exports/expenses", riderA);
var body = await response.Content.ReadAsStringAsync();
@@ -349,17 +385,19 @@ bool isDeleted
using var scope = App.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService();
- dbContext.Expenses.Add(new ExpenseEntity
- {
- RiderId = riderId,
- ExpenseDate = expenseDate,
- Amount = amount,
- Notes = notes,
- IsDeleted = isDeleted,
- Version = 1,
- CreatedAtUtc = DateTime.UtcNow,
- UpdatedAtUtc = DateTime.UtcNow,
- });
+ dbContext.Expenses.Add(
+ new ExpenseEntity
+ {
+ RiderId = riderId,
+ ExpenseDate = expenseDate,
+ Amount = amount,
+ Notes = notes,
+ IsDeleted = isDeleted,
+ Version = 1,
+ CreatedAtUtc = DateTime.UtcNow,
+ UpdatedAtUtc = DateTime.UtcNow,
+ }
+ );
await dbContext.SaveChangesAsync();
}
@@ -374,16 +412,18 @@ public async Task SeedRideAsync(
using var scope = App.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService();
- dbContext.Rides.Add(new RideEntity
- {
- RiderId = riderId,
- RideDateTimeLocal = rideDateTimeLocal,
- Miles = miles,
- Notes = notes,
- WeatherUserOverridden = false,
- Version = 1,
- CreatedAtUtc = DateTime.UtcNow,
- });
+ dbContext.Rides.Add(
+ new RideEntity
+ {
+ RiderId = riderId,
+ RideDateTimeLocal = rideDateTimeLocal,
+ Miles = miles,
+ Notes = notes,
+ WeatherUserOverridden = false,
+ Version = 1,
+ CreatedAtUtc = DateTime.UtcNow,
+ }
+ );
await dbContext.SaveChangesAsync();
}
diff --git a/src/BikeTracking.Api.Tests/Endpoints/Export/RideExportEndpointTests.cs b/src/BikeTracking.Api.Tests/Endpoints/Export/RideExportEndpointTests.cs
index 08de415..5effad3 100644
--- a/src/BikeTracking.Api.Tests/Endpoints/Export/RideExportEndpointTests.cs
+++ b/src/BikeTracking.Api.Tests/Endpoints/Export/RideExportEndpointTests.cs
@@ -72,7 +72,7 @@ public async Task GetRideExport_WithNoRides_ReturnsZipWithSingleHeaderOnlyCsv()
// Only header row
Assert.Single(lines);
- Assert.StartsWith("RideId,", lines[0]);
+ Assert.StartsWith("Date,", lines[0]);
}
// ──────────────────────────────────────────────────────────────────────
@@ -123,7 +123,7 @@ public async Task GetRideExport_EachYearCsvHasCorrectHeader()
var firstLine = await reader.ReadLineAsync();
Assert.NotNull(firstLine);
- Assert.StartsWith("RideId,Date,Miles,RideMinutes", firstLine);
+ Assert.StartsWith("Date,Miles,RideMinutes", firstLine);
Assert.EndsWith(",CreatedAtUtc", firstLine);
}
diff --git a/src/BikeTracking.Api/Application/Export/ExpenseCsvExportService.cs b/src/BikeTracking.Api/Application/Export/ExpenseCsvExportService.cs
index 6ae0920..c238b01 100644
--- a/src/BikeTracking.Api/Application/Export/ExpenseCsvExportService.cs
+++ b/src/BikeTracking.Api/Application/Export/ExpenseCsvExportService.cs
@@ -8,35 +8,32 @@ namespace BikeTracking.Api.Application.Export;
/// RFC 4180-compliant CSV string with header row.
///
///
-/// Output columns: ExpenseId, Date, Amount, Notes, CreatedAtUtc
+/// Output columns: Date, Amount, Notes, CreatedAtUtc
/// Filter: WHERE RiderId = @riderId AND IsDeleted = false ORDER BY ExpenseDate DESC
///
public sealed class ExpenseCsvExportService(BikeTrackingDbContext db)
{
- private static readonly string[] Headers =
- ["ExpenseId", "Date", "Amount", "Notes", "CreatedAtUtc"];
+ private static readonly string[] Headers = ["Date", "Amount", "Notes", "CreatedAtUtc"];
///
/// Generates the full CSV content as a UTF-8 string.
/// Returns a header-only CSV when the rider has no expenses.
///
- public async Task ExportAsync(long riderId, CancellationToken cancellationToken = default)
+ public async Task ExportAsync(
+ long riderId,
+ CancellationToken cancellationToken = default
+ )
{
- var expenses = await db.Expenses
- .Where(e => e.RiderId == riderId && !e.IsDeleted)
+ var expenses = await db
+ .Expenses.Where(e => e.RiderId == riderId && !e.IsDeleted)
.OrderByDescending(e => e.ExpenseDate)
.ToListAsync(cancellationToken);
- var lines = new List(expenses.Count + 1)
- {
- CsvRowBuilder.BuildHeader(Headers)
- };
+ var lines = new List(expenses.Count + 1) { CsvRowBuilder.BuildHeader(Headers) };
foreach (var expense in expenses)
{
- var row = CsvRowBuilder.BuildRow(
- [
- expense.Id.ToString(),
+ var row = CsvRowBuilder.BuildRow([
expense.ExpenseDate.ToString("yyyy-MM-dd"),
expense.Amount.ToString("G29"),
expense.Notes,
diff --git a/src/BikeTracking.Api/Application/Export/RideHistoryCsvExportService.cs b/src/BikeTracking.Api/Application/Export/RideHistoryCsvExportService.cs
index 970687a..e1c926e 100644
--- a/src/BikeTracking.Api/Application/Export/RideHistoryCsvExportService.cs
+++ b/src/BikeTracking.Api/Application/Export/RideHistoryCsvExportService.cs
@@ -18,22 +18,40 @@ public sealed class RideHistoryCsvExportService(BikeTrackingDbContext db)
{
private static readonly string[] Headers =
[
- "RideId", "Date", "Miles", "RideMinutes", "Temperature", "GasPricePerGallon",
- "WindSpeedMph", "WindDirectionDeg", "RelativeHumidityPercent", "CloudCoverPercent",
- "PrecipitationType", "Note", "WeatherUserOverridden", "Difficulty",
- "PrimaryTravelDirection", "WindResistanceRating", "ImportSource",
- "SnapshotAverageCarMpg", "SnapshotMileageRateCents", "SnapshotYearlyGoalMiles",
- "SnapshotOilChangePrice", "CreatedAtUtc",
+ "Date",
+ "Miles",
+ "RideMinutes",
+ "Temperature",
+ "GasPricePerGallon",
+ "WindSpeedMph",
+ "WindDirectionDeg",
+ "RelativeHumidityPercent",
+ "CloudCoverPercent",
+ "PrecipitationType",
+ "Note",
+ "WeatherUserOverridden",
+ "Difficulty",
+ "PrimaryTravelDirection",
+ "WindResistanceRating",
+ "ImportSource",
+ "SnapshotAverageCarMpg",
+ "SnapshotMileageRateCents",
+ "SnapshotYearlyGoalMiles",
+ "SnapshotOilChangePrice",
+ "CreatedAtUtc",
];
///
/// Generates the ride history ZIP archive and returns a sealed .
/// The caller is responsible for disposing the returned stream.
///
- public async Task ExportAsync(long riderId, CancellationToken cancellationToken = default)
+ public async Task ExportAsync(
+ long riderId,
+ CancellationToken cancellationToken = default
+ )
{
- var rides = await db.Rides
- .Where(r => r.RiderId == riderId)
+ var rides = await db
+ .Rides.Where(r => r.RiderId == riderId)
.OrderByDescending(r => r.RideDateTimeLocal)
.ToListAsync(cancellationToken);
@@ -76,9 +94,7 @@ private static void WriteYearCsv(
foreach (var ride in rides)
{
- var row = CsvRowBuilder.BuildRow(
- [
- ride.Id.ToString(),
+ var row = CsvRowBuilder.BuildRow([
ride.RideDateTimeLocal.ToString("yyyy-MM-ddTHH:mm:ss"),
ride.Miles.ToString("G29"),
ride.RideMinutes?.ToString(),
From d350eca1a26e0800467b3313317b5066201620f6 Mon Sep 17 00:00:00 2001
From: aligneddev
Date: Tue, 28 Jul 2026 16:40:13 +0000
Subject: [PATCH 4/4] fix the tests
---
src/BikeTracking.Frontend/tests/e2e/export.spec.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/BikeTracking.Frontend/tests/e2e/export.spec.ts b/src/BikeTracking.Frontend/tests/e2e/export.spec.ts
index 4622920..f9ccedc 100644
--- a/src/BikeTracking.Frontend/tests/e2e/export.spec.ts
+++ b/src/BikeTracking.Frontend/tests/e2e/export.spec.ts
@@ -41,7 +41,7 @@ test.describe("028-csv-data-export e2e", () => {
const lines = content.split("\n").filter((l) => l.trim().length > 0);
// Header row
- expect(lines[0]).toBe("ExpenseId,Date,Amount,Notes,CreatedAtUtc");
+ expect(lines[0]).toBe("Date,Amount,Notes,CreatedAtUtc");
// At least one data row
expect(lines.length).toBeGreaterThanOrEqual(2);
@@ -75,7 +75,7 @@ test.describe("028-csv-data-export e2e", () => {
const lines = content.split("\n").filter((l) => l.trim().length > 0);
expect(lines).toHaveLength(1);
- expect(lines[0]).toBe("ExpenseId,Date,Amount,Notes,CreatedAtUtc");
+ expect(lines[0]).toBe("Date,Amount,Notes,CreatedAtUtc");
});
// ─────────────────────────────────────────────────────────────────────────