From 523cf1614e77c0f34eeb09aa100836d7e7202c89 Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Mon, 6 Jul 2026 15:33:30 -0400 Subject: [PATCH 1/7] feat(frontend): extractValue helper for data export --- frontend/src/lib/dataExport.js | 23 +++++++++++++++++++++ frontend/tests/dataExport.test.js | 33 +++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 frontend/src/lib/dataExport.js create mode 100644 frontend/tests/dataExport.test.js diff --git a/frontend/src/lib/dataExport.js b/frontend/src/lib/dataExport.js new file mode 100644 index 0000000..6bcbcf2 --- /dev/null +++ b/frontend/src/lib/dataExport.js @@ -0,0 +1,23 @@ +// Pure helpers for exporting daily-parameter data as a date×subject CSV matrix. +// No React, no I/O — mirrors the crossSubjectChart.js pure-helper pattern. + +// Read a single parameter's raw value from one daily status. +// Returns null when the value is absent. Note: 0 and '' are returned as-is +// (real values); only genuinely-missing lookups become null. +export function extractValue(status, param) { + if (!status || !param) return null; + switch (param.kind) { + case 'total': + return status.analysis_summary?.total ?? null; + case 'success_rate': + return status.analysis_summary?.success_rate ?? null; + case 'category': + return status.analysis_summary?.counts?.[param.category] ?? null; + case 'builtin': + return status[param.statusKey] ?? null; + case 'custom': + return status.custom_fields?.[param.fieldId] ?? null; + default: + return null; + } +} diff --git a/frontend/tests/dataExport.test.js b/frontend/tests/dataExport.test.js new file mode 100644 index 0000000..02b3084 --- /dev/null +++ b/frontend/tests/dataExport.test.js @@ -0,0 +1,33 @@ +import { extractValue } from '../src/lib/dataExport'; + +const status = { + vitals: 'ok', + custom_fields: { 'f-weight': 250 }, + analysis_summary: { total: 12, success_rate: 0.83, counts: { Success: 10, Failure: 2 } }, +}; + +describe('extractValue', () => { + it('reads a builtin field via statusKey', () => { + expect(extractValue(status, { kind: 'builtin', statusKey: 'vitals' })).toBe('ok'); + }); + it('reads a custom field via fieldId', () => { + expect(extractValue(status, { kind: 'custom', fieldId: 'f-weight' })).toBe(250); + }); + it('reads session-metric total', () => { + expect(extractValue(status, { kind: 'total' })).toBe(12); + }); + it('reads session-metric success_rate as a raw 0-1 fraction', () => { + expect(extractValue(status, { kind: 'success_rate' })).toBe(0.83); + }); + it('reads a dynamic count category', () => { + expect(extractValue(status, { kind: 'category', category: 'Failure' })).toBe(2); + }); + it('returns null when analysis_summary is absent', () => { + expect(extractValue({ custom_fields: {} }, { kind: 'total' })).toBeNull(); + expect(extractValue({}, { kind: 'category', category: 'Success' })).toBeNull(); + }); + it('returns null for a missing custom field', () => { + expect(extractValue({ custom_fields: {} }, { kind: 'custom', fieldId: 'nope' })).toBeNull(); + expect(extractValue({}, { kind: 'builtin', statusKey: 'notes' })).toBeNull(); + }); +}); From 3f8febfda313e64f254840ade5db8adb4d9eecf9 Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Mon, 6 Jul 2026 15:35:19 -0400 Subject: [PATCH 2/7] feat(frontend): listExportableParams enumerates export options --- frontend/src/lib/dataExport.js | 32 +++++++++++++++++++++++++ frontend/tests/dataExport.test.js | 39 +++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/frontend/src/lib/dataExport.js b/frontend/src/lib/dataExport.js index 6bcbcf2..3158cbb 100644 --- a/frontend/src/lib/dataExport.js +++ b/frontend/src/lib/dataExport.js @@ -21,3 +21,35 @@ export function extractValue(status, param) { return null; } } + +// Build the grouped, ordered list of exportable parameters. +// Session metrics (total, success_rate, then each dynamic count category found +// in the data) come first; then every field defined in the daily template, +// including inactive ones (historical data may exist). +export function listExportableParams(dailyTemplate, statuses) { + const params = [ + { id: '__total__', label: 'Total attempts', group: 'metrics', kind: 'total' }, + { id: '__success_rate__', label: 'Success rate', group: 'metrics', kind: 'success_rate' }, + ]; + + const cats = new Set(); + for (const s of statuses ?? []) { + const counts = s?.analysis_summary?.counts; + if (counts && typeof counts === 'object') { + for (const k of Object.keys(counts)) cats.add(k); + } + } + for (const cat of [...cats].sort()) { + params.push({ id: `__cat__${cat}`, label: cat, group: 'metrics', kind: 'category', category: cat }); + } + + for (const f of dailyTemplate ?? []) { + if (f.builtin) { + params.push({ id: `b:${f.fieldId}`, label: f.label, group: 'daily', kind: 'builtin', statusKey: f.key }); + } else { + params.push({ id: `c:${f.fieldId}`, label: f.label, group: 'daily', kind: 'custom', fieldId: f.fieldId }); + } + } + + return params; +} diff --git a/frontend/tests/dataExport.test.js b/frontend/tests/dataExport.test.js index 02b3084..e97f7f4 100644 --- a/frontend/tests/dataExport.test.js +++ b/frontend/tests/dataExport.test.js @@ -1,4 +1,5 @@ import { extractValue } from '../src/lib/dataExport'; +import { listExportableParams } from '../src/lib/dataExport'; const status = { vitals: 'ok', @@ -31,3 +32,41 @@ describe('extractValue', () => { expect(extractValue({}, { kind: 'builtin', statusKey: 'notes' })).toBeNull(); }); }); + +const dailyTemplate = [ + { fieldId: 'b-vitals', key: 'vitals', label: 'Vitals', builtin: true, active: true }, + { fieldId: 'c-weight', key: 'weight', label: 'Weight (g)', builtin: false, active: false }, +]; +const statuses = [ + { analysis_summary: { counts: { Success: 1, Failure: 0 } } }, + { analysis_summary: { counts: { Success: 2, Other: 1 } } }, + { analysis_summary: null }, +]; + +describe('listExportableParams', () => { + const params = listExportableParams(dailyTemplate, statuses); + it('starts with the two fixed session metrics', () => { + expect(params.slice(0, 2)).toEqual([ + expect.objectContaining({ id: '__total__', label: 'Total attempts', group: 'metrics', kind: 'total' }), + expect.objectContaining({ id: '__success_rate__', label: 'Success rate', group: 'metrics', kind: 'success_rate' }), + ]); + }); + it('adds one metrics entry per dynamic count category, sorted', () => { + const cats = params.filter((p) => p.kind === 'category').map((p) => p.label); + expect(cats).toEqual(['Failure', 'Other', 'Success']); + }); + it('includes every template field (incl. inactive) in the daily group', () => { + const daily = params.filter((p) => p.group === 'daily'); + expect(daily).toEqual([ + expect.objectContaining({ label: 'Vitals', kind: 'builtin', statusKey: 'vitals' }), + expect.objectContaining({ label: 'Weight (g)', kind: 'custom', fieldId: 'c-weight' }), + ]); + }); + it('gives every param a unique id', () => { + const ids = params.map((p) => p.id); + expect(new Set(ids).size).toBe(ids.length); + }); + it('tolerates empty/missing inputs', () => { + expect(listExportableParams(undefined, undefined).length).toBe(2); // just the two fixed metrics + }); +}); From b5578f6e24cfb88c0d341e8d4cf745a3acae032c Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Mon, 6 Jul 2026 15:37:33 -0400 Subject: [PATCH 3/7] =?UTF-8?q?feat(frontend):=20buildMatrix=20pivots=20st?= =?UTF-8?q?atuses=20into=20date=C3=97subject=20grid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/lib/dataExport.js | 59 +++++++++++++++++++++++++++++++ frontend/tests/dataExport.test.js | 55 ++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/frontend/src/lib/dataExport.js b/frontend/src/lib/dataExport.js index 3158cbb..ee52e49 100644 --- a/frontend/src/lib/dataExport.js +++ b/frontend/src/lib/dataExport.js @@ -53,3 +53,62 @@ export function listExportableParams(dailyTemplate, statuses) { return params; } + +// A cell has a value unless it is null, undefined, or the empty string. +// (0 and false are real values.) +function hasValue(v) { + return v !== null && v !== undefined && v !== ''; +} + +function dateKey(date) { + return String(date).slice(0, 10); // ISO datetime or date → YYYY-MM-DD +} + +// Pivot statuses into { columns, rows }. +// columns: [{ id, header }] — all animals, ordered by name, headers +// disambiguated with animal_id_string on duplicate names. +// rows: [{ date, values }] — one per date that has ≥1 value; values are +// aligned to columns, blank cells are '' (empty string). +export function buildMatrix(statuses, animals, param) { + const sortedAnimals = [...(animals ?? [])].sort((a, b) => + String(a.animal_name ?? '').localeCompare(String(b.animal_name ?? '')), + ); + + const nameCounts = new Map(); + for (const a of sortedAnimals) { + const n = a.animal_name ?? ''; + nameCounts.set(n, (nameCounts.get(n) ?? 0) + 1); + } + const columns = sortedAnimals.map((a) => { + const name = a.animal_name ?? ''; + const header = nameCounts.get(name) > 1 ? `${name} (${a.animal_id_string ?? ''})` : name; + return { id: a.id, header }; + }); + const colIndex = new Map(columns.map((c, i) => [c.id, i])); + + // cells: dateKey -> Map(animalId -> value); first status per (date, animal) wins. + const cells = new Map(); + const datesWithData = new Set(); + const ordered = [...(statuses ?? [])].sort((a, b) => dateKey(a.date).localeCompare(dateKey(b.date))); + for (const s of ordered) { + if (!colIndex.has(s.animal_id)) continue; + const dk = dateKey(s.date); + if (!cells.has(dk)) cells.set(dk, new Map()); + const byAnimal = cells.get(dk); + if (byAnimal.has(s.animal_id)) continue; // first wins + const v = extractValue(s, param); + byAnimal.set(s.animal_id, v); + if (hasValue(v)) datesWithData.add(dk); + } + + const rows = [...datesWithData].sort().map((dk) => { + const byAnimal = cells.get(dk); + const values = columns.map((c) => { + const v = byAnimal.has(c.id) ? byAnimal.get(c.id) : null; + return hasValue(v) ? v : ''; + }); + return { date: dk, values }; + }); + + return { columns, rows }; +} diff --git a/frontend/tests/dataExport.test.js b/frontend/tests/dataExport.test.js index e97f7f4..7be93a3 100644 --- a/frontend/tests/dataExport.test.js +++ b/frontend/tests/dataExport.test.js @@ -70,3 +70,58 @@ describe('listExportableParams', () => { expect(listExportableParams(undefined, undefined).length).toBe(2); // just the two fixed metrics }); }); + +import { buildMatrix } from '../src/lib/dataExport'; + +const animals = [ + { id: 'a2', animal_name: 'Beta', animal_id_string: 'R-002' }, + { id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001' }, +]; +const totalParam = { kind: 'total' }; + +describe('buildMatrix', () => { + it('orders columns by animal_name and rows by date ascending', () => { + const statuses = [ + { animal_id: 'a1', date: '2026-07-02T00:00:00.000Z', analysis_summary: { total: 5 } }, + { animal_id: 'a2', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 9 } }, + ]; + const m = buildMatrix(statuses, animals, totalParam); + expect(m.columns.map((c) => c.header)).toEqual(['Alpha', 'Beta']); + expect(m.rows.map((r) => r.date)).toEqual(['2026-07-01', '2026-07-02']); + // Row for 07-01: Alpha blank, Beta 9 + expect(m.rows[0].values).toEqual(['', 9]); + // Row for 07-02: Alpha 5, Beta blank + expect(m.rows[1].values).toEqual([5, '']); + }); + it('includes only dates that have at least one value (0 counts as a value)', () => { + const statuses = [ + { animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 0 } }, + { animal_id: 'a1', date: '2026-07-03T00:00:00.000Z', analysis_summary: null }, // no value + ]; + const m = buildMatrix(statuses, animals, totalParam); + expect(m.rows.map((r) => r.date)).toEqual(['2026-07-01']); + expect(m.rows[0].values).toEqual([0, '']); + }); + it('disambiguates duplicate animal names with the id string', () => { + const dupAnimals = [ + { id: 'a1', animal_name: 'Rat', animal_id_string: 'R-001' }, + { id: 'a2', animal_name: 'Rat', animal_id_string: 'R-002' }, + ]; + const statuses = [{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 1 } }]; + const m = buildMatrix(statuses, dupAnimals, totalParam); + expect(m.columns.map((c) => c.header)).toEqual(['Rat (R-001)', 'Rat (R-002)']); + }); + it('keeps the first status when a subject has duplicates on one date', () => { + const statuses = [ + { animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 7 } }, + { animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 99 } }, + ]; + const m = buildMatrix(statuses, animals, totalParam); + expect(m.rows[0].values).toEqual([7, '']); + }); + it('returns empty rows when no status has a value', () => { + const m = buildMatrix([{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: null }], animals, totalParam); + expect(m.rows).toEqual([]); + expect(m.columns.length).toBe(2); + }); +}); From dfe3938afc5580a2b07a63f1be5f3646fbc4fe1a Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Mon, 6 Jul 2026 15:40:24 -0400 Subject: [PATCH 4/7] 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'); + }); +}); From ea76cf9cd293ef637ab34623e17a8edbf9b2d7bc Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Mon, 6 Jul 2026 15:42:30 -0400 Subject: [PATCH 5/7] =?UTF-8?q?feat(frontend):=20ExportDataModal=20?= =?UTF-8?q?=E2=80=94=20pick=20a=20parameter=20and=20download=20CSV?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/ExportDataModal.jsx | 108 ++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 frontend/src/components/ExportDataModal.jsx diff --git a/frontend/src/components/ExportDataModal.jsx b/frontend/src/components/ExportDataModal.jsx new file mode 100644 index 0000000..3e0e2c1 --- /dev/null +++ b/frontend/src/components/ExportDataModal.jsx @@ -0,0 +1,108 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { experimentsApi } from '../api/client'; +import Button from './ui/Button'; +import Alert from './ui/Alert'; +import { listExportableParams, buildMatrix, toCSV, csvFilename } from '../lib/dataExport'; + +const GROUP_LABELS = { metrics: 'Session metrics', daily: 'Daily record fields' }; + +function triggerDownload(filename, text) { + const blob = new Blob([text], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +export default function ExportDataModal({ experimentTitle, experimentId, dailyTemplate, animals, onClose }) { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [statuses, setStatuses] = useState([]); + const [selectedId, setSelectedId] = useState(null); + + useEffect(() => { + let alive = true; + setLoading(true); + setError(null); + experimentsApi.getDailyStatuses(experimentId, {}) + .then((data) => { if (alive) setStatuses(data); }) + .catch((err) => { if (alive) setError(err.message); }) + .finally(() => { if (alive) setLoading(false); }); + return () => { alive = false; }; + }, [experimentId]); + + const params = useMemo(() => listExportableParams(dailyTemplate, statuses), [dailyTemplate, statuses]); + + // Default the selection to the first parameter once params are available. + useEffect(() => { + if (selectedId === null && params.length > 0) setSelectedId(params[0].id); + }, [params, selectedId]); + + const selectedParam = params.find((p) => p.id === selectedId) ?? null; + + const matrix = useMemo( + () => (selectedParam ? buildMatrix(statuses, animals, selectedParam) : { columns: [], rows: [] }), + [statuses, animals, selectedParam], + ); + + const hasData = matrix.rows.length > 0; + + // Group params for rendering, preserving order. + const grouped = useMemo(() => { + const out = []; + for (const p of params) { + let g = out.find((x) => x.group === p.group); + if (!g) { g = { group: p.group, items: [] }; out.push(g); } + g.items.push(p); + } + return out; + }, [params]); + + function handleExport() { + if (!hasData || !selectedParam) return; + triggerDownload(csvFilename(experimentTitle, selectedParam.label), toCSV(matrix)); + onClose(); + } + + if (loading) return

Loading data…

; + if (error) return ; + + return ( +
+

+ Export one parameter as a CSV file — each row is a date, each column is a subject. +

+ +
+ + +
+ +

+ {hasData + ? `${matrix.rows.length} date${matrix.rows.length !== 1 ? 's' : ''} × ${matrix.columns.length} subject${matrix.columns.length !== 1 ? 's' : ''}` + : 'No data for this parameter yet.'} +

+ +
+ + +
+
+ ); +} From 0bf3fc479bceb3c2b1b547eee98e159090f97e23 Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Mon, 6 Jul 2026 15:46:52 -0400 Subject: [PATCH 6/7] refactor(frontend): polish ExportDataModal (derived selection, safe URL revoke, error escape) --- frontend/src/components/ExportDataModal.jsx | 22 ++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/ExportDataModal.jsx b/frontend/src/components/ExportDataModal.jsx index 3e0e2c1..72f487b 100644 --- a/frontend/src/components/ExportDataModal.jsx +++ b/frontend/src/components/ExportDataModal.jsx @@ -15,7 +15,7 @@ function triggerDownload(filename, text) { document.body.appendChild(a); a.click(); document.body.removeChild(a); - URL.revokeObjectURL(url); + setTimeout(() => URL.revokeObjectURL(url), 0); } export default function ExportDataModal({ experimentTitle, experimentId, dailyTemplate, animals, onClose }) { @@ -37,12 +37,9 @@ export default function ExportDataModal({ experimentTitle, experimentId, dailyTe const params = useMemo(() => listExportableParams(dailyTemplate, statuses), [dailyTemplate, statuses]); - // Default the selection to the first parameter once params are available. - useEffect(() => { - if (selectedId === null && params.length > 0) setSelectedId(params[0].id); - }, [params, selectedId]); - - const selectedParam = params.find((p) => p.id === selectedId) ?? null; + // Derive the effective selection: the user's pick, else the first parameter. + const effectiveId = selectedId ?? params[0]?.id ?? null; + const selectedParam = params.find((p) => p.id === effectiveId) ?? null; const matrix = useMemo( () => (selectedParam ? buildMatrix(statuses, animals, selectedParam) : { columns: [], rows: [] }), @@ -69,7 +66,14 @@ export default function ExportDataModal({ experimentTitle, experimentId, dailyTe } if (loading) return

Loading data…

; - if (error) return ; + if (error) return ( +
+ +
+ +
+
+ ); return (
@@ -81,7 +85,7 @@ export default function ExportDataModal({ experimentTitle, experimentId, dailyTe