CSV Export for the Task Manager
Summary
Key decisions
| Decision | Choice | Why |
|---|---|---|
| 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)andserializeRow(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.locationnavigation — 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_csvwith row count + filters used - Changelog entry + short help-doc note ("exports respect your current filters")
- Feature flag
csv_exportfor staged rollout, removed after a week of clean telemetry
Risks
| Risk | Severity | Mitigation |
|---|---|---|
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
- Should users be able to pick columns, or is the fixed v1 set enough? (Defer unless requested.)
- Export the description field in full, truncated, or omitted? Long markdown descriptions bloat rows.
- Is 50k rows the right cap for your data volumes, and do we ever need async "email me the file" delivery?
- Timezone source of truth — user profile setting vs browser tz sent as a query param?
- Should completed/archived tasks be included by default when no status filter is active?