diff --git a/frontend/src/lib/dataExport.js b/frontend/src/lib/dataExport.js index 3158cbb..ee52e49 100644 --- a/frontend/src/lib/dataExport.js +++ b/frontend/src/lib/dataExport.js @@ -53,3 +53,62 @@ export function listExportableParams(dailyTemplate, statuses) { return params; } + +// A cell has a value unless it is null, undefined, or the empty string. +// (0 and false are real values.) +function hasValue(v) { + return v !== null && v !== undefined && v !== ''; +} + +function dateKey(date) { + return String(date).slice(0, 10); // ISO datetime or date → YYYY-MM-DD +} + +// Pivot statuses into { columns, rows }. +// columns: [{ id, header }] — all animals, ordered by name, headers +// disambiguated with animal_id_string on duplicate names. +// rows: [{ date, values }] — one per date that has ≥1 value; values are +// aligned to columns, blank cells are '' (empty string). +export function buildMatrix(statuses, animals, param) { + const sortedAnimals = [...(animals ?? [])].sort((a, b) => + String(a.animal_name ?? '').localeCompare(String(b.animal_name ?? '')), + ); + + const nameCounts = new Map(); + for (const a of sortedAnimals) { + const n = a.animal_name ?? ''; + nameCounts.set(n, (nameCounts.get(n) ?? 0) + 1); + } + const columns = sortedAnimals.map((a) => { + const name = a.animal_name ?? ''; + const header = nameCounts.get(name) > 1 ? `${name} (${a.animal_id_string ?? ''})` : name; + return { id: a.id, header }; + }); + const colIndex = new Map(columns.map((c, i) => [c.id, i])); + + // cells: dateKey -> Map(animalId -> value); first status per (date, animal) wins. + const cells = new Map(); + const datesWithData = new Set(); + const ordered = [...(statuses ?? [])].sort((a, b) => dateKey(a.date).localeCompare(dateKey(b.date))); + for (const s of ordered) { + if (!colIndex.has(s.animal_id)) continue; + const dk = dateKey(s.date); + if (!cells.has(dk)) cells.set(dk, new Map()); + const byAnimal = cells.get(dk); + if (byAnimal.has(s.animal_id)) continue; // first wins + const v = extractValue(s, param); + byAnimal.set(s.animal_id, v); + if (hasValue(v)) datesWithData.add(dk); + } + + const rows = [...datesWithData].sort().map((dk) => { + const byAnimal = cells.get(dk); + const values = columns.map((c) => { + const v = byAnimal.has(c.id) ? byAnimal.get(c.id) : null; + return hasValue(v) ? v : ''; + }); + return { date: dk, values }; + }); + + return { columns, rows }; +} diff --git a/frontend/tests/dataExport.test.js b/frontend/tests/dataExport.test.js index e97f7f4..7be93a3 100644 --- a/frontend/tests/dataExport.test.js +++ b/frontend/tests/dataExport.test.js @@ -70,3 +70,58 @@ describe('listExportableParams', () => { expect(listExportableParams(undefined, undefined).length).toBe(2); // just the two fixed metrics }); }); + +import { buildMatrix } from '../src/lib/dataExport'; + +const animals = [ + { id: 'a2', animal_name: 'Beta', animal_id_string: 'R-002' }, + { id: 'a1', animal_name: 'Alpha', animal_id_string: 'R-001' }, +]; +const totalParam = { kind: 'total' }; + +describe('buildMatrix', () => { + it('orders columns by animal_name and rows by date ascending', () => { + const statuses = [ + { animal_id: 'a1', date: '2026-07-02T00:00:00.000Z', analysis_summary: { total: 5 } }, + { animal_id: 'a2', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 9 } }, + ]; + const m = buildMatrix(statuses, animals, totalParam); + expect(m.columns.map((c) => c.header)).toEqual(['Alpha', 'Beta']); + expect(m.rows.map((r) => r.date)).toEqual(['2026-07-01', '2026-07-02']); + // Row for 07-01: Alpha blank, Beta 9 + expect(m.rows[0].values).toEqual(['', 9]); + // Row for 07-02: Alpha 5, Beta blank + expect(m.rows[1].values).toEqual([5, '']); + }); + it('includes only dates that have at least one value (0 counts as a value)', () => { + const statuses = [ + { animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 0 } }, + { animal_id: 'a1', date: '2026-07-03T00:00:00.000Z', analysis_summary: null }, // no value + ]; + const m = buildMatrix(statuses, animals, totalParam); + expect(m.rows.map((r) => r.date)).toEqual(['2026-07-01']); + expect(m.rows[0].values).toEqual([0, '']); + }); + it('disambiguates duplicate animal names with the id string', () => { + const dupAnimals = [ + { id: 'a1', animal_name: 'Rat', animal_id_string: 'R-001' }, + { id: 'a2', animal_name: 'Rat', animal_id_string: 'R-002' }, + ]; + const statuses = [{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 1 } }]; + const m = buildMatrix(statuses, dupAnimals, totalParam); + expect(m.columns.map((c) => c.header)).toEqual(['Rat (R-001)', 'Rat (R-002)']); + }); + it('keeps the first status when a subject has duplicates on one date', () => { + const statuses = [ + { animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 7 } }, + { animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: { total: 99 } }, + ]; + const m = buildMatrix(statuses, animals, totalParam); + expect(m.rows[0].values).toEqual([7, '']); + }); + it('returns empty rows when no status has a value', () => { + const m = buildMatrix([{ animal_id: 'a1', date: '2026-07-01T00:00:00.000Z', analysis_summary: null }], animals, totalParam); + expect(m.rows).toEqual([]); + expect(m.columns.length).toBe(2); + }); +});