135 lines
5.1 KiB
JavaScript
135 lines
5.1 KiB
JavaScript
// 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`;
|
||
}
|