From a411d593052004432ac5aa5b02265866a04dd0b1 Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Sun, 19 Jul 2026 13:16:33 -0400 Subject: [PATCH] feat(export): dimension primitives, date members, status index --- frontend/src/lib/dataExport.js | 48 +++++++++++++++++++++++ frontend/tests/dataExport.test.js | 65 +++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/frontend/src/lib/dataExport.js b/frontend/src/lib/dataExport.js index d32649a..46e2ff1 100644 --- a/frontend/src/lib/dataExport.js +++ b/frontend/src/lib/dataExport.js @@ -54,6 +54,54 @@ export function listExportableParams(dailyTemplate, statuses) { return params; } +export const EXPORT_DIMENSIONS = [ + { dim: 'date', label: 'Date' }, + { dim: 'subject', label: 'Subject' }, + { dim: 'parameter', label: 'Parameter' }, +]; + +export function dimLabel(dim) { + return EXPORT_DIMENSIONS.find((d) => d.dim === dim)?.label ?? ''; +} + +export function otherDimension(rowDim, colDim) { + return EXPORT_DIMENSIONS.map((d) => d.dim).find((d) => d !== rowDim && d !== colDim) ?? null; +} + +// Resolve a subject's group value for a given group field id. +// null -> no grouping (field is falsy or '__none__') +// '—' -> grouping is on but this subject has no value +export function subjectGroupValue(animal, groupField) { + if (!groupField || groupField === '__none__') return null; + if (groupField === '__name__') return animal?.animal_name ?? '—'; + if (groupField === '__id__') return animal?.animal_id_string ?? '—'; + const v = animal?.subject_info?.[groupField]; + return v === null || v === undefined || v === '' ? '—' : v; +} + +export function listDateMembers(statuses) { + const keys = new Set(); + for (const s of statuses ?? []) { + if (s?.date) keys.add(dateKey(s.date)); + } + return [...keys].sort().map((k) => ({ id: k, label: k, dateKey: k })); +} + +// dateKey -> Map(animalId -> status); first status per (date, animal) wins, +// scanning in date order (matches the legacy buildMatrix tie-break). +export function buildStatusIndex(statuses) { + const index = new Map(); + const ordered = [...(statuses ?? [])].sort((a, b) => dateKey(a?.date).localeCompare(dateKey(b?.date))); + for (const s of ordered) { + if (!s?.date || s.animal_id === null || s.animal_id === undefined) continue; + const dk = dateKey(s.date); + if (!index.has(dk)) index.set(dk, new Map()); + const byAnimal = index.get(dk); + if (!byAnimal.has(s.animal_id)) byAnimal.set(s.animal_id, s); + } + return index; +} + // 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 4f01f2a..fba143b 100644 --- a/frontend/tests/dataExport.test.js +++ b/frontend/tests/dataExport.test.js @@ -155,3 +155,68 @@ describe('csvFilename', () => { expect(csvFilename('', '')).toBe('export.csv'); }); }); + +import { + EXPORT_DIMENSIONS, dimLabel, otherDimension, subjectGroupValue, + listDateMembers, buildStatusIndex, +} from '../src/lib/dataExport'; + +describe('dimension primitives', () => { + it('exposes the three dimensions with labels', () => { + expect(EXPORT_DIMENSIONS.map((d) => d.dim)).toEqual(['date', 'subject', 'parameter']); + expect(dimLabel('date')).toBe('Date'); + expect(dimLabel('subject')).toBe('Subject'); + expect(dimLabel('parameter')).toBe('Parameter'); + }); + it('otherDimension returns the leftover', () => { + expect(otherDimension('date', 'subject')).toBe('parameter'); + expect(otherDimension('parameter', 'date')).toBe('subject'); + }); +}); + +describe('subjectGroupValue', () => { + const animal = { animal_name: 'Alpha', animal_id_string: 'R-001', subject_info: { sex: 'M', cohort: '' } }; + it('returns null when no group field', () => { + expect(subjectGroupValue(animal, '__none__')).toBeNull(); + expect(subjectGroupValue(animal, '')).toBeNull(); + }); + it('resolves name / id / subject_info fields', () => { + expect(subjectGroupValue(animal, '__name__')).toBe('Alpha'); + expect(subjectGroupValue(animal, '__id__')).toBe('R-001'); + expect(subjectGroupValue(animal, 'sex')).toBe('M'); + }); + it('renders missing or empty values as an em dash', () => { + expect(subjectGroupValue(animal, 'cohort')).toBe('—'); + expect(subjectGroupValue({}, 'sex')).toBe('—'); + }); +}); + +describe('listDateMembers', () => { + it('returns sorted unique dateKeys with id/label', () => { + const statuses = [ + { date: '2026-07-02T00:00:00.000Z' }, + { date: '2026-07-01T12:00:00.000Z' }, + { date: '2026-07-02T09:00:00.000Z' }, + ]; + expect(listDateMembers(statuses)).toEqual([ + { id: '2026-07-01', label: '2026-07-01', dateKey: '2026-07-01' }, + { id: '2026-07-02', label: '2026-07-02', dateKey: '2026-07-02' }, + ]); + }); + it('tolerates empty input', () => { + expect(listDateMembers(undefined)).toEqual([]); + }); +}); + +describe('buildStatusIndex', () => { + it('indexes by dateKey then animalId, first status wins', () => { + const statuses = [ + { animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 7 } }, + { animal_id: 'a1', date: '2026-07-01T06:00:00.000Z', analysis_summary: { total: 99 } }, + { animal_id: 'a2', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 3 } }, + ]; + const idx = buildStatusIndex(statuses); + expect(idx.get('2026-07-01').get('a1').analysis_summary.total).toBe(7); + expect(idx.get('2026-07-01').get('a2').analysis_summary.total).toBe(3); + }); +});