feat(frontend): buildMatrix pivots statuses into date×subject grid
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user