CSV Export for the Task Manager

2026-07-06 · task-manager web app · generated by Claude

Summary

Add an Export CSV button to the task list that downloads the currently filtered tasks as a CSV file. Export is generated server-side by a new endpoint that reuses the existing list-query/filter logic, streams rows (no full in-memory materialisation), escapes fields per RFC 4180, and formats due dates as ISO 8601 in the user's timezone. Rollout in four small phases: serializer → endpoint → UI button → hardening & tests.

Key decisions

DecisionChoiceWhy
Server-side vs client-side generation Server-side endpoint (GET /api/tasks/export.csv) Client only holds the current page; export must cover all matching tasks. Also keeps escaping/formatting logic in one tested place.
What gets exported Exactly what the current filters select (status, assignee, tags, due-date range, search text) — not just the visible page Matches user intent: "export what I'm looking at". Endpoint accepts the same query params as the list endpoint.
CSV dialect RFC 4180: comma delimiter, CRLF line endings, quote fields containing , " \n, double embedded quotes; UTF-8 with BOM BOM makes Excel open UTF-8 correctly (accented names, Czech/Japanese text). Standard dialect imports cleanly into Sheets/Excel/Numbers.
Due-date format ISO 8601 date (2026-07-06); datetime fields as 2026-07-06 14:30 in the requesting user's timezone, tz noted in header row comment column or filename Sortable as text, unambiguous, spreadsheet-parseable. Local tz avoids "my task moved a day" confusion.
Delivery mechanism Streamed response with Content-Disposition: attachment; filename="tasks-<filters>-<date>.csv"; UI triggers a plain navigation/anchor download Streaming keeps memory flat for large exports; anchor download needs no blob/JS plumbing and works on mobile browsers.
Column set Fixed v1 columns: id, title, description, status, priority, assignee, tags, due_date, created_at, completed_at, url Covers reporting needs without a column-picker UI. Column picker deferred (see open questions).
Auth & authorization Same session/token auth as the list endpoint; export filtered through the same visibility scope Export must never leak tasks the user can't see in the UI.

Phases

Phase 1 — CSV serializer module S

Pure, dependency-light module that turns an iterable of task records into CSV lines. No HTTP, no DB — fully unit-testable.

  • serializeHeader(columns) and serializeRow(task, columns, tz)
  • RFC 4180 escaping (quotes, commas, newlines, leading =+-@ guarded against CSV injection by prefixing ')
  • Tags joined with ; inside one cell
  • Date formatting helper honouring user tz
export function* csvLines(tasks, columns, tz) {
  yield BOM + toLine(columns.map(c => c.header));
  for (const t of tasks) {
    yield toLine(columns.map(c => escapeField(c.value(t, tz))));
  }
}

Tests: escaping matrix, unicode/BOM, injection payloads (=HYPERLINK(...)), empty fields, tag joining, tz edge cases (midnight boundary).

Phase 2 — Export endpoint M

GET /api/tasks/export.csv accepting the identical query params as GET /api/tasks (status, assignee, tag, q, due_from, due_to, sort) — refactor the filter-parsing into a shared helper rather than duplicating it.

  • Reuse the list query builder; drop pagination, keep sort order
  • Stream from a DB cursor / batched query (batch ~500) through the Phase 1 generator
  • Headers: Content-Type: text/csv; charset=utf-8, Content-Disposition: attachment, Cache-Control: no-store
  • Hard cap (e.g. 50k rows) returning 413 with a friendly message beyond it
  • Rate-limit: max 1 concurrent export per user

Tests: filter parity with list endpoint (same fixture, same params ⇒ same task ids), auth required, scope enforcement, cap behaviour, header correctness.

Phase 3 — UI: Export button S

Button in the task-list toolbar next to the existing filter controls.

  • Builds the export URL from the current filter state (same serializer the list view uses for its fetch URL)
  • Plain <a download> / window.location navigation — no blob handling
  • Disabled with tooltip when the filtered count is 0; shows row count in the label when known (Export 132 tasks (CSV))
  • Brief spinner/toast while download starts; error toast on 4xx/5xx (fetch HEAD first or handle via hidden iframe onerror)

Tests: URL contains active filters, button state on empty result, e2e happy path downloading and parsing the file.

Phase 4 — Hardening, telemetry, docs S
  • e2e test: apply filters → export → parse CSV → assert rows match on-screen list
  • Load test one large export (cap-size) to confirm flat memory profile
  • Analytics event task_export_csv with row count + filters used
  • Changelog entry + short help-doc note ("exports respect your current filters")
  • Feature flag csv_export for staged rollout, removed after a week of clean telemetry

Risks

RiskSeverityMitigation
CSV injection — task titles like =HYPERLINK(...) executing in Excel high Prefix cells starting with = + - @ with a single quote in the serializer; explicit unit tests for payloads.
Filter drift — export logic silently diverges from list logic over time medium Share one query-builder function; parity test asserting both endpoints return identical id sets for the same params.
Large exports exhausting memory or hitting request timeouts medium Cursor-based streaming, 50k row cap, 1-concurrent-export rate limit; revisit async/email delivery only if the cap is actually hit.
Excel mangling encoding or dates (dropping accents, US date parsing) medium UTF-8 BOM + ISO 8601 dates; manually verify in Excel, Google Sheets, LibreOffice before release.
Data leakage via export bypassing visibility rules high Route export through the same authorization scope as the list endpoint; test with a restricted-user fixture.

Open questions