From 39a5a4ec0cab2a566541c8ff005552a687f59792 Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Sun, 19 Jul 2026 14:59:42 -0400 Subject: [PATCH] =?UTF-8?q?feat(export):=20buildSubjectSeriesMatrix=20?= =?UTF-8?q?=E2=80=94=20subjects=20as=20columns=20over=20an=20X=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/lib/dataExport.js | 36 +++++++++++++++++++ frontend/tests/dataExport.test.js | 58 +++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/frontend/src/lib/dataExport.js b/frontend/src/lib/dataExport.js index ffd822c..5695cde 100644 --- a/frontend/src/lib/dataExport.js +++ b/frontend/src/lib/dataExport.js @@ -120,6 +120,42 @@ export function buildSampleIndex(statuses, xField, dailyTemplate) { return index; } +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, + }; +} + export const EXPORT_DIMENSIONS = [ { dim: 'date', label: 'Date' }, { dim: 'subject', label: 'Subject' }, diff --git a/frontend/tests/dataExport.test.js b/frontend/tests/dataExport.test.js index 0862589..1099cb4 100644 --- a/frontend/tests/dataExport.test.js +++ b/frontend/tests/dataExport.test.js @@ -387,3 +387,61 @@ describe('buildSampleIndex', () => { expect(idx.get('a1').get(260).date).toContain('2026-07-03'); }); }); + +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']); + }); +});