diff --git a/frontend/src/lib/dataExport.js b/frontend/src/lib/dataExport.js index 46e2ff1..aac6151 100644 --- a/frontend/src/lib/dataExport.js +++ b/frontend/src/lib/dataExport.js @@ -102,6 +102,42 @@ export function buildStatusIndex(statuses) { return index; } +export function listSubjectMembers(animals, groupField) { + const list = [...(animals ?? [])]; + const nameCounts = new Map(); + for (const a of list) { + const n = a?.animal_name ?? ''; + nameCounts.set(n, (nameCounts.get(n) ?? 0) + 1); + } + const members = list.map((a) => { + const name = a?.animal_name ?? ''; + const label = nameCounts.get(name) > 1 ? `${name} (${a?.animal_id_string ?? ''})` : name; + return { id: a.id, label, animalId: a.id, group: subjectGroupValue(a, groupField) }; + }); + const grouped = !!groupField && groupField !== '__none__'; + members.sort((x, y) => { + if (grouped) { + const g = String(x.group ?? '').localeCompare(String(y.group ?? '')); + if (g !== 0) return g; + } + return String(x.label).localeCompare(String(y.label)); + }); + return members; +} + +export function listParameterMembers(dailyTemplate, statuses) { + return listExportableParams(dailyTemplate, statuses).map((p) => ({ + id: p.id, label: p.label, param: p, group: p.group, + })); +} + +export function listDimensionMembers(dim, { statuses, animals, dailyTemplate, groupField }) { + if (dim === 'date') return listDateMembers(statuses); + if (dim === 'subject') return listSubjectMembers(animals, groupField); + if (dim === 'parameter') return listParameterMembers(dailyTemplate, statuses); + return []; +} + // A cell has a value unless it is null, undefined, or the empty string. // (0 and false are real values.) function hasValue(v) { diff --git a/frontend/tests/dataExport.test.js b/frontend/tests/dataExport.test.js index fba143b..d3dd5c6 100644 --- a/frontend/tests/dataExport.test.js +++ b/frontend/tests/dataExport.test.js @@ -220,3 +220,49 @@ describe('buildStatusIndex', () => { expect(idx.get('2026-07-01').get('a2').analysis_summary.total).toBe(3); }); }); + +import { listSubjectMembers, listParameterMembers, listDimensionMembers } from '../src/lib/dataExport'; + +describe('listSubjectMembers', () => { + const animals = [ + { id: 'a2', animal_name: 'Beta', animal_id_string: 'R-002', subject_info: { grp: 'Drug' } }, + { id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001', subject_info: { grp: 'Control' } }, + { id: 'a3', animal_name: 'Gamma', animal_id_string: 'R-003', subject_info: { grp: 'Control' } }, + ]; + it('orders by name and carries group=null when no group field', () => { + const m = listSubjectMembers(animals, '__none__'); + expect(m.map((s) => s.label)).toEqual(['Alpha', 'Beta', 'Gamma']); + expect(m.every((s) => s.group === null)).toBe(true); + expect(m[0]).toMatchObject({ id: 'a1', animalId: 'a1' }); + }); + it('clusters by group value then name when grouping is on', () => { + const m = listSubjectMembers(animals, 'grp'); + expect(m.map((s) => [s.group, s.label])).toEqual([ + ['Control', 'Alpha'], ['Control', 'Gamma'], ['Drug', 'Beta'], + ]); + }); + it('disambiguates duplicate names with the id string', () => { + const dup = [ + { id: 'a1', animal_name: 'Rat', animal_id_string: 'R-001' }, + { id: 'a2', animal_name: 'Rat', animal_id_string: 'R-002' }, + ]; + expect(listSubjectMembers(dup, '__none__').map((s) => s.label)).toEqual(['Rat (R-001)', 'Rat (R-002)']); + }); +}); + +describe('listParameterMembers / listDimensionMembers', () => { + const dailyTemplate = [{ fieldId: 'b-vitals', key: 'vitals', label: 'Vitals', builtin: true, active: true }]; + const statuses = [{ date: '2026-07-01T00:00:00.000Z', animal_id: 'a1', analysis_summary: { total: 1 } }]; + const animals = [{ id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001' }]; + it('wraps params with id/label/param/group', () => { + const m = listParameterMembers(dailyTemplate, statuses); + expect(m[0]).toMatchObject({ id: '__total__', label: 'Total attempts', group: 'metrics' }); + expect(m[0].param).toMatchObject({ kind: 'total' }); + }); + it('dispatches by dimension', () => { + const ctx = { statuses, animals, dailyTemplate, groupField: '__none__' }; + expect(listDimensionMembers('date', ctx).map((d) => d.id)).toEqual(['2026-07-01']); + expect(listDimensionMembers('subject', ctx).map((d) => d.id)).toEqual(['a1']); + expect(listDimensionMembers('parameter', ctx)[0].id).toBe('__total__'); + }); +});