// Pure helpers for exporting daily-parameter data as a date×subject CSV matrix. // No React, no I/O — mirrors the crossSubjectChart.js pure-helper pattern. // Read a single parameter's raw value from one daily status. // Returns null when the value is absent. Note: 0 and '' are returned as-is // (real values); only genuinely-missing lookups become null. export function extractValue(status, param) { if (!status || !param) return null; switch (param.kind) { case 'total': return status.analysis_summary?.total ?? null; case 'success_rate': return status.analysis_summary?.success_rate ?? null; case 'category': return status.analysis_summary?.counts?.[param.category] ?? null; case 'builtin': return status[param.statusKey] ?? null; case 'custom': return status.custom_fields?.[param.fieldId] ?? null; default: return null; } } // Build the grouped, ordered list of exportable parameters. // Session metrics (total, success_rate, then each dynamic count category found // in the data) come first; then every field defined in the daily template, // including inactive ones (historical data may exist). export function listExportableParams(dailyTemplate, statuses) { const params = [ { id: '__total__', label: 'Total attempts', group: 'metrics', kind: 'total' }, { id: '__success_rate__', label: 'Success rate', group: 'metrics', kind: 'success_rate' }, ]; const cats = new Set(); for (const s of statuses ?? []) { const counts = s?.analysis_summary?.counts; if (counts && typeof counts === 'object') { for (const k of Object.keys(counts)) cats.add(k); } } for (const cat of [...cats].sort()) { params.push({ id: `__cat__${cat}`, label: cat, group: 'metrics', kind: 'category', category: cat }); } for (const f of dailyTemplate ?? []) { if (f.builtin) { params.push({ id: `b:${f.fieldId}`, label: f.label, group: 'daily', kind: 'builtin', statusKey: f.key }); } else { params.push({ id: `c:${f.fieldId}`, label: f.label, group: 'daily', kind: 'custom', fieldId: f.fieldId }); } } 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 }; } function escapeCsvCell(value) { const s = value === null || value === undefined ? '' : String(value); return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; } // Serialize a matrix to an RFC-4180-ish CSV string (LF line endings). // First column is the date; remaining columns follow matrix.columns order. export function toCSV(matrix) { const header = ['Date', ...matrix.columns.map((c) => c.header)].map(escapeCsvCell).join(','); const lines = matrix.rows.map((r) => [r.date, ...r.values].map(escapeCsvCell).join(',')); return [header, ...lines].join('\n'); } // Build a download filename from the experiment title and parameter label. export function csvFilename(title, label) { const slug = (s) => String(s ?? '').replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, ''); const base = [slug(title), slug(label)].filter(Boolean).join('-'); return `${base || 'export'}.csv`; }