From dfe3938afc5580a2b07a63f1be5f3646fbc4fe1a Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Mon, 6 Jul 2026 15:40:24 -0400 Subject: [PATCH] feat(frontend): toCSV + csvFilename for data export --- frontend/src/lib/dataExport.js | 20 ++++++++++++++++++++ frontend/tests/dataExport.test.js | 30 ++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/frontend/src/lib/dataExport.js b/frontend/src/lib/dataExport.js index ee52e49..d32649a 100644 --- a/frontend/src/lib/dataExport.js +++ b/frontend/src/lib/dataExport.js @@ -112,3 +112,23 @@ export function buildMatrix(statuses, animals, param) { return { columns, rows }; } + +function escapeCsvCell(value) { + const s = value === null || value === undefined ? '' : String(value); + return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + +// Serialize a matrix to an RFC-4180-ish CSV string (LF line endings). +// First column is the date; remaining columns follow matrix.columns order. +export function toCSV(matrix) { + const header = ['Date', ...matrix.columns.map((c) => c.header)].map(escapeCsvCell).join(','); + const lines = matrix.rows.map((r) => [r.date, ...r.values].map(escapeCsvCell).join(',')); + return [header, ...lines].join('\n'); +} + +// Build a download filename from the experiment title and parameter label. +export function csvFilename(title, label) { + const slug = (s) => String(s ?? '').replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, ''); + const base = [slug(title), slug(label)].filter(Boolean).join('-'); + return `${base || 'export'}.csv`; +} diff --git a/frontend/tests/dataExport.test.js b/frontend/tests/dataExport.test.js index 7be93a3..4f01f2a 100644 --- a/frontend/tests/dataExport.test.js +++ b/frontend/tests/dataExport.test.js @@ -125,3 +125,33 @@ describe('buildMatrix', () => { expect(m.columns.length).toBe(2); }); }); + +import { toCSV, csvFilename } from '../src/lib/dataExport'; + +describe('toCSV', () => { + it('writes a header row of Date + column headers, then data rows', () => { + const matrix = { + columns: [{ id: 'a1', header: 'Alpha' }, { id: 'a2', header: 'Beta' }], + rows: [{ date: '2026-07-01', values: [5, ''] }, { date: '2026-07-02', values: ['', 9] }], + }; + expect(toCSV(matrix)).toBe('Date,Alpha,Beta\n2026-07-01,5,\n2026-07-02,,9'); + }); + it('escapes commas, quotes, and newlines per RFC 4180', () => { + const matrix = { + columns: [{ id: 'a1', header: 'Note, field' }], + rows: [{ date: '2026-07-01', values: ['he said "hi"'] }, { date: '2026-07-02', values: ['line1\nline2'] }], + }; + expect(toCSV(matrix)).toBe( + 'Date,"Note, field"\n2026-07-01,"he said ""hi"""\n2026-07-02,"line1\nline2"', + ); + }); +}); + +describe('csvFilename', () => { + it('slugifies the experiment title and parameter label', () => { + expect(csvFilename('My Study #1', 'Success rate')).toBe('My-Study-1-Success-rate.csv'); + }); + it('falls back to "export" when both slugs are empty', () => { + expect(csvFilename('', '')).toBe('export.csv'); + }); +});