From 3e52117c1a834ab4810782000e92c74f7c38c875 Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Sun, 19 Jul 2026 14:36:10 -0400 Subject: [PATCH] docs(plan): subject-series export implementation plan Co-Authored-By: Claude Opus 4.8 --- .../plans/2026-07-19-subject-series-export.md | 668 ++++++++++++++++++ 1 file changed, 668 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-19-subject-series-export.md diff --git a/docs/superpowers/plans/2026-07-19-subject-series-export.md b/docs/superpowers/plans/2026-07-19-subject-series-export.md new file mode 100644 index 0000000..b31e3a3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-subject-series-export.md @@ -0,0 +1,668 @@ +# Subject-Series Data Export Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the export modal's assignable-axes UI with three pickers — X field (rows), Data value (cells), and Group by — where columns are always the subjects, matching the cross-subject metrics plot. + +**Architecture:** Add X-coordinate helpers to `dataExport.js` (`numericFieldVal`, `listSampleMembers`, `buildSampleIndex`) and a focused `buildSubjectSeriesMatrix` that emits the existing `toCSV` shape. Rewrite `ExportDataModal.jsx` to the three pickers. Then delete the now-dead assignable-axes machinery. + +**Tech Stack:** React 18, plain ES modules, Jest + `@testing-library/react` (jsdom). No new dependencies. + +**Spec:** `docs/superpowers/specs/2026-07-19-subject-series-export-design.md` + +## Global Constraints + +- `dataExport.js` stays pure: no React, no I/O. +- Cell value semantics: `0` and `''` are **real** values; only genuinely-missing lookups render blank (`''`), via the existing `hasValue` + `extractValue`. +- CSV: LF (`\n`) line endings; escape every cell via the existing `escapeCsvCell`. +- **X = `Date`** → rows are distinct `YYYY-MM-DD` dates, sorted. +- **X = `# Days Reach`** (`'__days__'`) → rows are `1, 2, 3, …`; the max over subjects of that subject's count of recorded statuses (a status must have `animal_id` and `date`). A cell for `(subject, N)` reads the subject's Nth recorded day, counting **all** records sorted ascending by date (per-status ordinal, Day 1 = earliest). +- **X = daily field** (its `fieldId`) → rows are the distinct **numeric** values (`numericFieldVal`), sorted ascending; non-numeric excluded. A cell for `(subject, v)` reads that subject's first status (by date) whose field equals `v`. +- Columns are **always** the subjects (`listSubjectMembers`), clustered by group then name. Entirely-blank sample rows are dropped; subject columns are always kept. +- Group by defaults to the saved field (`groupField` prop), validated against `__none__`/`__name__`/`__id__`/active `subjectTemplate` fields; coerced to `__none__` when unset or stale. +- CSV: line 1 is `Data,`; blank line; a `Group,…` row only when grouping (missing group → `—`); header `= ,`; data rows `= ,`. Filename via existing `csvFilename(title, dataLabel)`. +- Default X = `Date`; default Data = first parameter (`Total attempts`). +- Run tests from `frontend/`: `npx jest tests/dataExport.test.js`, `npx jest tests/ExportDataModal.test.jsx`, full `npm test`. +- Two suites (`tests/ExperimentCalendar.test.jsx`, `tests/ExperimentDayView.test.jsx`, 31 tests) fail for a PRE-EXISTING, unrelated reason and must be left as-is; changes must introduce no new failures. + +--- + +### Task 1: X-coordinate helpers + +**Files:** +- Modify: `frontend/src/lib/dataExport.js` (add three exports; keep everything else for now) +- Test: `frontend/tests/dataExport.test.js` + +**Interfaces:** +- Consumes: existing private `dateKey(date)`. +- Produces: + - `numericFieldVal(status, field): number|null` — `field.builtin ? status[field.key] : status.custom_fields[field.fieldId]`, `parseFloat`, non-numeric → null. + - `listSampleMembers(statuses, xField, dailyTemplate): Array<{ id, label, sampleValue }>` — row members for the chosen X (`xField` is `'__date__'`, `'__days__'`, or a daily `fieldId`). + - `buildSampleIndex(statuses, xField, dailyTemplate): Map>` — first status per `(subject, sampleValue)` wins, per-subject date-ordered. + +- [ ] **Step 1: Write the failing tests** + +Append to `frontend/tests/dataExport.test.js`: + +```js +import { numericFieldVal, listSampleMembers, buildSampleIndex } from '../src/lib/dataExport'; + +const sDaily = [{ fieldId: 'c-w', key: 'w', label: 'Weight', builtin: false, active: true }]; +const sStatuses = [ + { animal_id: 'a1', date: '2026-07-01T00:00:00Z', custom_fields: { 'c-w': '250' } }, + { animal_id: 'a1', date: '2026-07-03T00:00:00Z', custom_fields: { 'c-w': '260' } }, + { animal_id: 'a2', date: '2026-07-01T00:00:00Z', custom_fields: { 'c-w': '250' } }, +]; + +describe('numericFieldVal', () => { + it('reads builtin via key and custom via fieldId, parsed to number', () => { + expect(numericFieldVal({ weight: '250' }, { builtin: true, key: 'weight' })).toBe(250); + expect(numericFieldVal({ custom_fields: { 'c-w': '12.5' } }, { builtin: false, fieldId: 'c-w' })).toBe(12.5); + }); + it('returns null for non-numeric, missing, or no field', () => { + expect(numericFieldVal({ custom_fields: { 'c-w': 'abc' } }, { builtin: false, fieldId: 'c-w' })).toBeNull(); + expect(numericFieldVal({}, { builtin: true, key: 'weight' })).toBeNull(); + expect(numericFieldVal({ weight: 1 }, null)).toBeNull(); + }); +}); + +describe('listSampleMembers', () => { + it('date: distinct dates sorted', () => { + expect(listSampleMembers(sStatuses, '__date__', sDaily).map((m) => m.sampleValue)).toEqual(['2026-07-01', '2026-07-03']); + }); + it('# days reach: 1..max records per subject', () => { + expect(listSampleMembers(sStatuses, '__days__', sDaily).map((m) => m.sampleValue)).toEqual([1, 2]); + }); + it('daily field: distinct numeric values sorted ascending', () => { + expect(listSampleMembers(sStatuses, 'c-w', sDaily).map((m) => m.sampleValue)).toEqual([250, 260]); + }); +}); + +describe('buildSampleIndex', () => { + it('date coord maps animalId -> dateKey -> status', () => { + const idx = buildSampleIndex(sStatuses, '__date__', sDaily); + expect(idx.get('a1').get('2026-07-03').date).toContain('2026-07-03'); + }); + it('# days reach assigns per-subject ordinals in date order', () => { + const idx = buildSampleIndex(sStatuses, '__days__', sDaily); + expect(idx.get('a1').get(1).date).toContain('2026-07-01'); + expect(idx.get('a1').get(2).date).toContain('2026-07-03'); + expect(idx.get('a2').get(1).date).toContain('2026-07-01'); + expect(idx.get('a2').has(2)).toBe(false); + }); + it('daily field maps numeric value to first status by date', () => { + const idx = buildSampleIndex(sStatuses, 'c-w', sDaily); + expect(idx.get('a1').get(250).date).toContain('2026-07-01'); + expect(idx.get('a1').get(260).date).toContain('2026-07-03'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx jest tests/dataExport.test.js -t "listSampleMembers"` +Expected: FAIL — `listSampleMembers is not defined`. + +- [ ] **Step 3: Write minimal implementation** + +Add to `frontend/src/lib/dataExport.js` (after `listExportableParams`, before the private `hasValue`/`dateKey` — note `dateKey` is defined lower in the file but hoisted as a function declaration, so referencing it here is fine): + +```js +// Numeric value of a daily field on one status (mirrors the metrics plot's helper). +export function numericFieldVal(status, field) { + if (!field) return null; + const raw = field.builtin ? status?.[field.key] : status?.custom_fields?.[field.fieldId]; + const n = parseFloat(raw); + return Number.isNaN(n) ? null : n; +} + +// Ordered row members for the chosen X coordinate. +// xField: '__date__' | '__days__' | +export function listSampleMembers(statuses, xField, dailyTemplate) { + const list = statuses ?? []; + if (xField === '__date__') { + const keys = new Set(); + for (const s of list) if (s?.date) keys.add(dateKey(s.date)); + return [...keys].sort().map((k) => ({ id: k, label: k, sampleValue: k })); + } + if (xField === '__days__') { + const counts = new Map(); + for (const s of list) { + if (s?.animal_id == null || !s?.date) continue; + counts.set(s.animal_id, (counts.get(s.animal_id) ?? 0) + 1); + } + const max = counts.size ? Math.max(...counts.values()) : 0; + return Array.from({ length: max }, (_, i) => ({ id: String(i + 1), label: String(i + 1), sampleValue: i + 1 })); + } + const field = (dailyTemplate ?? []).find((f) => f.fieldId === xField); + const vals = new Set(); + for (const s of list) { + const v = numericFieldVal(s, field); + if (v !== null) vals.add(v); + } + return [...vals].sort((a, b) => a - b).map((v) => ({ id: String(v), label: String(v), sampleValue: v })); +} + +// animalId -> Map(sampleValue -> status). First status per (subject, sampleValue) wins, +// scanning each subject's statuses in ascending date order. +export function buildSampleIndex(statuses, xField, dailyTemplate) { + const bySubject = new Map(); + for (const s of statuses ?? []) { + if (s?.animal_id == null || !s?.date) continue; + if (!bySubject.has(s.animal_id)) bySubject.set(s.animal_id, []); + bySubject.get(s.animal_id).push(s); + } + const field = xField !== '__date__' && xField !== '__days__' + ? (dailyTemplate ?? []).find((f) => f.fieldId === xField) + : null; + + const index = new Map(); + for (const [animalId, subjStatuses] of bySubject) { + const ordered = [...subjStatuses].sort((a, b) => dateKey(a.date).localeCompare(dateKey(b.date))); + const m = new Map(); + ordered.forEach((s, i) => { + let key; + if (xField === '__date__') key = dateKey(s.date); + else if (xField === '__days__') key = i + 1; + else { const v = numericFieldVal(s, field); if (v === null) return; key = v; } + if (!m.has(key)) m.set(key, s); + }); + index.set(animalId, m); + } + return index; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx jest tests/dataExport.test.js` +Expected: PASS (new suites green; all pre-existing suites still green). + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js +git commit -m "feat(export): X-coordinate helpers (date, # days reach, daily field)" +``` + +--- + +### Task 2: buildSubjectSeriesMatrix + +**Files:** +- Modify: `frontend/src/lib/dataExport.js` (add `buildSubjectSeriesMatrix` + private `xFieldLabel`) +- Test: `frontend/tests/dataExport.test.js` + +**Interfaces:** +- Consumes: `listSubjectMembers`, `listSampleMembers`, `buildSampleIndex`, `extractValue`, private `hasValue`. +- Produces: `buildSubjectSeriesMatrix({ xField, dataParam, groupField }, { statuses, animals, dailyTemplate })` → `{ corner, context: { label: 'Data', value }, groupAxis: 'col'|null, columns: [{ id, label, animalId, group }], rows: [{ member: { id, label, sampleValue }, values: [value|''] }] }` — the same shape the existing `toCSV` consumes (`groupAxis: 'col'` triggers its group-row branch). + +- [ ] **Step 1: Write the failing tests** + +Append to `frontend/tests/dataExport.test.js`: + +```js +import { buildSubjectSeriesMatrix } from '../src/lib/dataExport'; + +const ssAnimals = [ + { id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001', subject_info: { grp: 'Control' } }, + { id: 'a2', animal_name: 'Beta', animal_id_string: 'R-002', subject_info: { grp: 'Drug' } }, +]; +const ssStatuses = [ + { animal_id: 'a1', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 5 } }, + { animal_id: 'a1', date: '2026-07-02T00:00:00Z', analysis_summary: { total: 7 } }, + { animal_id: 'a2', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 9 } }, +]; +const totalParam = { id: '__total__', label: 'Total attempts', kind: 'total', group: 'metrics' }; + +describe('buildSubjectSeriesMatrix', () => { + it('date X: subjects as columns, date rows, blank where missing, context names the metric', () => { + const m = buildSubjectSeriesMatrix( + { xField: '__date__', dataParam: totalParam, groupField: '__none__' }, + { statuses: ssStatuses, animals: ssAnimals, dailyTemplate: [] }, + ); + expect(m.corner).toBe('Date'); + expect(m.context).toEqual({ label: 'Data', value: 'Total attempts' }); + expect(m.groupAxis).toBeNull(); + expect(m.columns.map((c) => c.label)).toEqual(['Alpha', 'Beta']); + expect(m.rows.map((r) => r.member.label)).toEqual(['2026-07-01', '2026-07-02']); + expect(m.rows[0].values).toEqual([5, 9]); + expect(m.rows[1].values).toEqual([7, '']); + }); + it('# days reach X: rows align each subject by day ordinal', () => { + const m = buildSubjectSeriesMatrix( + { xField: '__days__', dataParam: totalParam, groupField: '__none__' }, + { statuses: ssStatuses, animals: ssAnimals, dailyTemplate: [] }, + ); + expect(m.corner).toBe('# Days Reach'); + expect(m.rows.map((r) => r.member.label)).toEqual(['1', '2']); + expect(m.rows[0].values).toEqual([5, 9]); + expect(m.rows[1].values).toEqual([7, '']); + }); + it('grouping sets groupAxis col and clusters subjects', () => { + const m = buildSubjectSeriesMatrix( + { xField: '__date__', dataParam: totalParam, groupField: 'grp' }, + { statuses: ssStatuses, animals: ssAnimals, dailyTemplate: [] }, + ); + expect(m.groupAxis).toBe('col'); + expect(m.columns.map((c) => [c.group, c.label])).toEqual([['Control', 'Alpha'], ['Drug', 'Beta']]); + }); + it('drops entirely-blank rows', () => { + const statuses = [ + { animal_id: 'a1', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 5 } }, + { animal_id: 'a1', date: '2026-07-05T00:00:00Z', analysis_summary: null }, + ]; + const m = buildSubjectSeriesMatrix( + { xField: '__date__', dataParam: totalParam, groupField: '__none__' }, + { statuses, animals: ssAnimals, dailyTemplate: [] }, + ); + expect(m.rows.map((r) => r.member.label)).toEqual(['2026-07-01']); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx jest tests/dataExport.test.js -t "buildSubjectSeriesMatrix"` +Expected: FAIL — `buildSubjectSeriesMatrix is not defined`. + +- [ ] **Step 3: Write minimal implementation** + +Add to `frontend/src/lib/dataExport.js` (after `buildSampleIndex`): + +```js +function xFieldLabel(xField, dailyTemplate) { + if (xField === '__date__') return 'Date'; + if (xField === '__days__') return '# Days Reach'; + return (dailyTemplate ?? []).find((f) => f.fieldId === xField)?.label ?? ''; +} + +// Subjects as columns, X-field values as rows, one Data value per cell. +// Returns the { context, corner, groupAxis, columns, rows } shape toCSV consumes. +export function buildSubjectSeriesMatrix({ xField, dataParam, groupField }, { statuses, animals, dailyTemplate }) { + const columns = listSubjectMembers(animals, groupField); + const rowMembers = listSampleMembers(statuses, xField, dailyTemplate); + const index = buildSampleIndex(statuses, xField, dailyTemplate); + + const cellValue = (sampleValue, animalId) => { + const status = index.get(animalId)?.get(sampleValue); + if (!status || !dataParam) return ''; + const v = extractValue(status, dataParam); + return hasValue(v) ? v : ''; + }; + + let rows = rowMembers.map((rm) => ({ + member: rm, + values: columns.map((c) => cellValue(rm.sampleValue, c.animalId)), + })); + rows = rows.filter((r) => r.values.some((v) => v !== '')); // drop entirely-blank sample rows + + const grouped = !!groupField && groupField !== '__none__'; + return { + corner: xFieldLabel(xField, dailyTemplate), + context: { label: 'Data', value: dataParam?.label ?? '' }, + groupAxis: grouped ? 'col' : null, + columns, + rows, + }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx jest tests/dataExport.test.js` +Expected: PASS (all suites green). + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js +git commit -m "feat(export): buildSubjectSeriesMatrix — subjects as columns over an X field" +``` + +--- + +### Task 3: Rewrite ExportDataModal to the three pickers + +**Files:** +- Modify: `frontend/src/components/ExportDataModal.jsx` (full body rewrite; keep `triggerDownload`) +- Test: `frontend/tests/ExportDataModal.test.jsx` (rewrite) + +**Interfaces:** +- Consumes: `listExportableParams`, `buildSubjectSeriesMatrix`, `toCSV`, `csvFilename` from `../lib/dataExport`; `experimentsApi.getDailyStatuses`. +- Props unchanged: `experimentTitle`, `experimentId`, `dailyTemplate`, `animals`, `subjectTemplate` (default `[]`), `groupField` (default `'__none__'`), `onClose`. +- Produces: modal with three selects — X (rows), Data (cells), Group by. + +- [ ] **Step 1: Rewrite the component test** + +Replace the entire contents of `frontend/tests/ExportDataModal.test.jsx` with: + +```jsx +import React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import ExportDataModal from '../src/components/ExportDataModal'; +import * as client from '../src/api/client'; + +jest.mock('../src/api/client', () => ({ + experimentsApi: { getDailyStatuses: jest.fn() }, +})); + +const animals = [ + { id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001', subject_info: { grp: 'Control' } }, + { id: 'a2', animal_name: 'Beta', animal_id_string: 'R-002', subject_info: { grp: 'Drug' } }, +]; +const statuses = [ + { animal_id: 'a1', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 5, success_rate: 0.5 } }, + { animal_id: 'a1', date: '2026-07-02T00:00:00Z', analysis_summary: { total: 7, success_rate: 0.7 } }, + { animal_id: 'a2', date: '2026-07-01T00:00:00Z', analysis_summary: { total: 9, success_rate: 0.9 } }, +]; +const subjectTemplate = [{ fieldId: 'grp', label: 'Group', active: true }]; + +beforeEach(() => { + jest.clearAllMocks(); + client.experimentsApi.getDailyStatuses.mockResolvedValue(statuses); +}); + +// Intercept the Blob handed to URL.createObjectURL so tests can read the real CSV. +// Also stub anchor.click() — jsdom treats a click on as an +// unimplemented navigation that throws async and can flake a later test. +function mockDownload() { + let blob = null; + const origCreate = global.URL.createObjectURL; + const origRevoke = global.URL.revokeObjectURL; + const origClick = window.HTMLAnchorElement.prototype.click; + global.URL.createObjectURL = jest.fn((b) => { blob = b; return 'blob:mock'; }); + global.URL.revokeObjectURL = jest.fn(); + window.HTMLAnchorElement.prototype.click = jest.fn(); + return { + getBlob: () => blob, + restore: async () => { + await new Promise((r) => setTimeout(r, 0)); // flush handleExport's setTimeout revoke + global.URL.createObjectURL = origCreate; + global.URL.revokeObjectURL = origRevoke; + window.HTMLAnchorElement.prototype.click = origClick; + }, + }; +} + +function readBlobText(blob) { + return new Promise((resolve, reject) => { + const fr = new FileReader(); + fr.onload = () => resolve(fr.result); + fr.onerror = reject; + fr.readAsText(blob); + }); +} + +function renderModal(props = {}) { + return render( + , + ); +} + +describe('ExportDataModal', () => { + it('renders X / Data / Group by pickers; X defaults to Date', async () => { + renderModal(); + expect(await screen.findByLabelText('X axis (rows)')).toHaveValue('__date__'); + expect(screen.getByLabelText('Data (cells)')).toBeInTheDocument(); + expect(screen.getByLabelText('Group by')).toHaveValue('__none__'); + await waitFor(() => expect(screen.getByText(/2 rows × 2 subjects/i)).toBeInTheDocument()); + }); + + it('default export (X=Date, Data=Total attempts) has subject columns, date rows, blank where missing', async () => { + const dl = mockDownload(); + renderModal(); + await screen.findByLabelText('X axis (rows)'); + await waitFor(() => expect(screen.getByText(/2 rows/i)).toBeInTheDocument()); + fireEvent.click(screen.getByText('Export CSV')); + const text = await readBlobText(dl.getBlob()); + expect(text).toBe('Data,Total attempts\n\nDate,Alpha,Beta\n2026-07-01,5,9\n2026-07-02,7,'); + await dl.restore(); + }); + + it('switching X to # Days Reach makes rows the day ordinals', async () => { + const dl = mockDownload(); + renderModal(); + fireEvent.change(await screen.findByLabelText('X axis (rows)'), { target: { value: '__days__' } }); + await waitFor(() => expect(screen.getByText(/2 rows/i)).toBeInTheDocument()); + fireEvent.click(screen.getByText('Export CSV')); + const text = await readBlobText(dl.getBlob()); + expect(text).toBe('Data,Total attempts\n\n# Days Reach,Alpha,Beta\n1,5,9\n2,7,'); + await dl.restore(); + }); + + it('a valid group field adds a Group row and clusters subjects', async () => { + const dl = mockDownload(); + renderModal({ groupField: 'grp' }); + await screen.findByLabelText('X axis (rows)'); + await waitFor(() => expect(screen.getByText(/2 rows/i)).toBeInTheDocument()); + fireEvent.click(screen.getByText('Export CSV')); + const text = await readBlobText(dl.getBlob()); + expect(text).toMatch(/^Group,Control,Drug$/m); + await dl.restore(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx jest tests/ExportDataModal.test.jsx` +Expected: FAIL — old modal has no `X axis (rows)` / `Data (cells)` labels. + +- [ ] **Step 3: Rewrite the component** + +Replace the entire contents of `frontend/src/components/ExportDataModal.jsx` with: + +```jsx +import React, { useEffect, useMemo, useState } from 'react'; +import { experimentsApi } from '../api/client'; +import Button from './ui/Button'; +import Alert from './ui/Alert'; +import { listExportableParams, buildSubjectSeriesMatrix, toCSV, csvFilename } from '../lib/dataExport'; + +const PARAM_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); + setTimeout(() => URL.revokeObjectURL(url), 0); +} + +export default function ExportDataModal({ + experimentTitle, experimentId, dailyTemplate, animals, subjectTemplate = [], groupField = '__none__', onClose, +}) { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [statuses, setStatuses] = useState([]); + + const [xField, setXField] = useState('__date__'); + const [dataId, setDataId] = useState(null); + const [groupBy, setGroupBy] = useState(() => { + const valid = groupField === '__none__' || groupField === '__name__' || groupField === '__id__' + || subjectTemplate.some((f) => f.active && f.fieldId === groupField); + return valid ? groupField : '__none__'; + }); + + 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]); + const effDataId = dataId ?? params[0]?.id ?? null; + const dataParam = params.find((p) => p.id === effDataId) ?? params[0] ?? null; + + const activeDaily = useMemo(() => (dailyTemplate ?? []).filter((f) => f.active), [dailyTemplate]); + const xOptions = useMemo(() => [ + { id: '__date__', label: 'Date' }, + { id: '__days__', label: '# Days Reach' }, + ...activeDaily.map((f) => ({ id: f.fieldId, label: f.label })), + ], [activeDaily]); + + const matrix = useMemo( + () => (dataParam + ? buildSubjectSeriesMatrix({ xField, dataParam, groupField: groupBy }, { statuses, animals, dailyTemplate }) + : { columns: [], rows: [], context: { label: 'Data', value: '' }, corner: '', groupAxis: null }), + [xField, dataParam, groupBy, statuses, animals, dailyTemplate], + ); + const hasData = matrix.rows.length > 0; + + const groupedParams = 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 || !dataParam) return; + triggerDownload(csvFilename(experimentTitle, dataParam.label), toCSV(matrix)); + onClose(); + } + + if (loading) return

Loading data…

; + if (error) return ( +
+ +
+
+ ); + + const selectCls = 'w-full border border-gray-200 rounded px-2 py-1.5 text-sm bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400'; + + return ( +
+

+ One value per subject over an X axis — each column is a subject, each row an X value. Blank where a subject has no value. +

+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +

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

+ +
+ + +
+
+ ); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `npx jest tests/ExportDataModal.test.jsx` +Expected: PASS (all four cases). + +- [ ] **Step 5: Run the full suite** + +Run: `npm test` +Expected: `dataExport` + `ExportDataModal` green; only the two known pre-existing suites fail; no new failures. + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/components/ExportDataModal.jsx frontend/tests/ExportDataModal.test.jsx +git commit -m "feat(export): modal becomes X / Data / Group by pickers, subjects as columns" +``` + +--- + +### Task 4: Remove the dead assignable-axes code + +**Files:** +- Modify: `frontend/src/lib/dataExport.js` (delete unused exports) +- Test: `frontend/tests/dataExport.test.js` (delete their describe blocks + imports) + +**Interfaces:** +- Removes (now unused after Task 3): `EXPORT_DIMENSIONS`, `dimLabel`, `otherDimension`, `listDateMembers`, `buildStatusIndex`, `listParameterMembers`, `listDimensionMembers`, and `buildMatrix`. +- Keeps: `extractValue`, `listExportableParams`, `subjectGroupValue`, `listSubjectMembers`, `numericFieldVal`, `listSampleMembers`, `buildSampleIndex`, `buildSubjectSeriesMatrix`, `toCSV`, `csvFilename`, and the private `hasValue`, `dateKey`, `escapeCsvCell`, `xFieldLabel`. + +- [ ] **Step 1: Delete the dead source exports** + +In `frontend/src/lib/dataExport.js`, delete these eight exported definitions entirely (they are only used by each other and the old modal, now replaced): `EXPORT_DIMENSIONS`, `dimLabel`, `otherDimension`, `listDateMembers`, `buildStatusIndex`, `listParameterMembers`, `listDimensionMembers`, `buildMatrix`. Also update the top-of-file comment to describe the subject-series export (drop the "dimension-agnostic buildMatrix" wording). Keep `dateKey`, `hasValue`, `escapeCsvCell`, and everything listed under "Keeps" above. + +- [ ] **Step 2: Delete their tests** + +In `frontend/tests/dataExport.test.js`, delete the describe blocks and their now-unused import identifiers for the removed functions: the `describe('dimension primitives', …)`, `describe('subjectGroupValue', …)` KEEP (subjectGroupValue is kept — do NOT delete it), `describe('listDateMembers', …)`, `describe('buildStatusIndex', …)`, `describe('listParameterMembers / listDimensionMembers', …)`, and `describe('buildMatrix', …)` blocks. Remove `EXPORT_DIMENSIONS`, `dimLabel`, `otherDimension`, `listDateMembers`, `buildStatusIndex`, `listDimensionMembers`, `listParameterMembers`, and `buildMatrix` from the file's `import` statements, leaving the imports for kept functions (`extractValue`, `listExportableParams`, `subjectGroupValue`, `listSubjectMembers`, `numericFieldVal`, `listSampleMembers`, `buildSampleIndex`, `buildSubjectSeriesMatrix`, `toCSV`, `csvFilename`). + +- [ ] **Step 3: Verify nothing references the removed names** + +Run: `grep -rnE "buildMatrix|EXPORT_DIMENSIONS|listDimensionMembers|buildStatusIndex|listDateMembers|listParameterMembers|otherDimension|dimLabel" frontend/src frontend/tests` +Expected: no output (all references gone). + +- [ ] **Step 4: Run the suites** + +Run: `npx jest tests/dataExport.test.js tests/ExportDataModal.test.jsx` +Expected: PASS. Then `npm test` — only the two known pre-existing suites fail, no new failures. + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/lib/dataExport.js frontend/tests/dataExport.test.js +git commit -m "refactor(export): remove dead assignable-axes helpers and their tests" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** three pickers (Task 3) · X = Date/# Days Reach/daily field with the exact semantics (Tasks 1–2) · subjects always columns, clustered by group, blank rows dropped (Task 2) · Group by validated/defaulted (Task 3) · CSV `Data,` + group row + X-corner (Tasks 2–3, reusing `toCSV`) · assignable-axes UI/machinery removed (Tasks 3–4). All covered. +- **Type consistency:** sample member `{ id, label, sampleValue }`, subject column `{ id, label, animalId, group }`, and matrix `{ context, corner, groupAxis, columns, rows }` are used identically across Tasks 1–3 and match the existing `toCSV`. +- **Out of scope (per spec):** assignable columns/pinned, subject subsetting, analyzed-only day counting, footer/repeat line.