Files
experiments-database/docs/superpowers/plans/2026-07-19-subject-series-export.md
Experiments DB Dev 3e52117c1a docs(plan): subject-series export implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 14:36:10 -04:00

30 KiB
Raw Permalink Blame History

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,<metric label>; blank line; a Group,… row only when grouping (missing group → ); header = <X label>,<subject headers…>; data rows = <X value>,<values…>. 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|nullfield.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<animalId, Map<sampleValue, status>> — first status per (subject, sampleValue) wins, per-subject date-ordered.
  • Step 1: Write the failing tests

Append to frontend/tests/dataExport.test.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):

// 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__' | <daily fieldId>
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
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:

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):

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
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:

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 <a href="blob:..."> 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(
    <ExportDataModal
      experimentId="exp-1"
      experimentTitle="My Study"
      dailyTemplate={[]}
      animals={animals}
      subjectTemplate={subjectTemplate}
      groupField="__none__"
      onClose={jest.fn()}
      {...props}
    />,
  );
}

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:

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 <p className="text-sm text-gray-400" aria-live="polite">Loading data</p>;
  if (error) return (
    <div className="space-y-4">
      <Alert type="error" message={error} />
      <div className="flex justify-end"><Button variant="secondary" onClick={onClose}>Close</Button></div>
    </div>
  );

  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 (
    <div className="space-y-4">
      <p className="text-sm text-gray-500">
        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.
      </p>

      <div>
        <label htmlFor="export-x" className="block text-xs font-medium text-gray-500 mb-1">X axis (rows)</label>
        <select id="export-x" value={xField} onChange={(e) => setXField(e.target.value)} className={selectCls}>
          {xOptions.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
        </select>
      </div>

      <div>
        <label htmlFor="export-data" className="block text-xs font-medium text-gray-500 mb-1">Data (cells)</label>
        <select id="export-data" value={effDataId ?? ''} onChange={(e) => setDataId(e.target.value)} className={selectCls}>
          {groupedParams.map((g) => (
            <optgroup key={g.group} label={PARAM_GROUP_LABELS[g.group] ?? g.group}>
              {g.items.map((p) => <option key={p.id} value={p.id}>{p.label}</option>)}
            </optgroup>
          ))}
        </select>
      </div>

      <div>
        <label htmlFor="export-group" className="block text-xs font-medium text-gray-500 mb-1">Group by</label>
        <select id="export-group" value={groupBy} onChange={(e) => setGroupBy(e.target.value)} className={selectCls}>
          <option value="__none__"> None </option>
          <option value="__name__">Name</option>
          <option value="__id__">Subject ID</option>
          {subjectTemplate.filter((f) => f.active).map((f) => (
            <option key={f.fieldId} value={f.fieldId}>{f.label}</option>
          ))}
        </select>
      </div>

      <p className="text-xs text-gray-400">
        {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.'}
      </p>

      <div className="flex justify-end gap-2 pt-2">
        <Button variant="secondary" onClick={onClose}>Cancel</Button>
        <Button onClick={handleExport} disabled={!hasData}>Export CSV</Button>
      </div>
    </div>
  );
}
  • 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
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
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 12) · subjects always columns, clustered by group, blank rows dropped (Task 2) · Group by validated/defaulted (Task 3) · CSV Data,<metric> + group row + X-corner (Tasks 23, reusing toCSV) · assignable-axes UI/machinery removed (Tasks 34). 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 13 and match the existing toCSV.
  • Out of scope (per spec): assignable columns/pinned, subject subsetting, analyzed-only day counting, footer/repeat line.