From 85109037717684bd9987baec47e4c1acec84c675 Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Sun, 19 Jul 2026 14:54:25 -0400 Subject: [PATCH] feat(export): X-coordinate helpers (date, # days reach, daily field) --- frontend/src/lib/dataExport.js | 64 +++++++++++++++++++++++++++++++ frontend/tests/dataExport.test.js | 52 +++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/frontend/src/lib/dataExport.js b/frontend/src/lib/dataExport.js index a44c268..ffd822c 100644 --- a/frontend/src/lib/dataExport.js +++ b/frontend/src/lib/dataExport.js @@ -56,6 +56,70 @@ export function listExportableParams(dailyTemplate, statuses) { return params; } +// 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__' | +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; +} + 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 88d43bf..0862589 100644 --- a/frontend/tests/dataExport.test.js +++ b/frontend/tests/dataExport.test.js @@ -335,3 +335,55 @@ describe('listParameterMembers / listDimensionMembers', () => { expect(listDimensionMembers('parameter', ctx)[0].id).toBe('__total__'); }); }); + +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'); + }); +});