import React, { useEffect, useState, useMemo } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { experimentsApi, animalsApi } from '../api/client';
import { ExperimentAnalysisCharts } from '../components/AnalysisCharts';
import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
import AnimalForm from '../components/AnimalForm';
import TemplateEditor from '../components/TemplateEditor';
import Alert from '../components/ui/Alert';
import AuditLogSection from '../components/AuditLogSection';
import ExperimentCalendar from '../components/ExperimentCalendar';
import ExportDataModal from '../components/ExportDataModal';
function AnimalCardList({ animals, subjectTemplate, onNavigate, onEdit }) {
return (
{animals.map((animal) => {
const infoFields = subjectTemplate.filter((f) => f.active && animal.subject_info?.[f.fieldId] != null);
return (
onNavigate(animal)}
>
{animal.animal_name}
ID: {animal.animal_id_string} · {animal._count?.daily_statuses ?? 0} daily status entries
{infoFields.length > 0 && (
{infoFields.map((f) => (
{f.label}: {animal.subject_info[f.fieldId]}
))}
)}
e.stopPropagation()}>
);
})}
);
}
export default function ExperimentDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [experiment, setExperiment] = useState(null);
const [animals, setAnimals] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [successMsg, setSuccessMsg] = useState(null);
const [showAddAnimal, setShowAddAnimal] = useState(false);
const [editAnimal, setEditAnimal] = useState(null);
const [showTemplate, setShowTemplate] = useState(false);
const [showSubjectTemplate, setShowSubjectTemplate] = useState(false);
const [showExport, setShowExport] = useState(false);
const [subjectTemplate, setSubjectTemplate] = useState([]);
const [dailyTemplate, setDailyTemplate] = useState([]);
const [experimentStatuses, setExperimentStatuses] = useState([]);
const [displayMode, setDisplayMode] = useState(() => localStorage.getItem(`exp-display-mode-${id}`) ?? 'list');
const [sortField, setSortField] = useState(() => localStorage.getItem(`exp-subject-sort-${id}`) ?? '__name__');
const [sortDir, setSortDir] = useState(() => localStorage.getItem(`exp-subject-sort-dir-${id}`) ?? 'asc');
const [groupField, setGroupField] = useState(() => localStorage.getItem(`exp-subject-group-${id}`) ?? '__name__');
function updateDisplayMode(mode) {
setDisplayMode(mode);
localStorage.setItem(`exp-display-mode-${id}`, mode);
}
function updateSort(field, dir) {
setSortField(field);
setSortDir(dir);
localStorage.setItem(`exp-subject-sort-${id}`, field);
localStorage.setItem(`exp-subject-sort-dir-${id}`, dir);
}
function updateGroupField(field) {
setGroupField(field);
localStorage.setItem(`exp-subject-group-${id}`, field);
}
async function load() {
setLoading(true);
setError(null);
try {
const [exp, anims, subjTmpl, dailyTmpl, expStatuses] = await Promise.all([
experimentsApi.get(id),
animalsApi.list(id),
experimentsApi.getSubjectTemplate(id),
experimentsApi.getTemplate(id),
// Only fetch statuses that have saved analysis_summary — charts are the only consumer,
// and they filter for analysis_summary.total != null anyway.
experimentsApi.getDailyStatuses(id, { analysisOnly: true }),
]);
setExperiment(exp);
setAnimals(anims);
setSubjectTemplate(subjTmpl);
setDailyTemplate(dailyTmpl);
setExperimentStatuses(expStatuses);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, [id]);
function flash(msg) {
setSuccessMsg(msg);
setTimeout(() => setSuccessMsg(null), 3000);
}
function handleAnimalSaved(animal) {
setShowAddAnimal(false);
setEditAnimal(null);
load();
flash(editAnimal ? `Animal "${animal.animal_name}" updated.` : `Animal "${animal.animal_name}" added.`);
}
function handleTemplateSaved(updatedTemplate) {
setShowTemplate(false);
if (updatedTemplate) {
// Update local experiment state so everything downstream re-renders
setExperiment((prev) => ({ ...prev, template: updatedTemplate }));
flash('Record template updated.');
}
}
const sortedAnimals = useMemo(() => {
const sorted = [...animals].sort((a, b) => {
let av, bv;
if (sortField === '__name__') {
av = a.animal_name ?? '';
bv = b.animal_name ?? '';
} else if (sortField === '__id__') {
av = a.animal_id_string ?? '';
bv = b.animal_id_string ?? '';
} else {
av = a.subject_info?.[sortField] ?? '';
bv = b.subject_info?.[sortField] ?? '';
}
// numeric-aware comparison
const an = parseFloat(av), bn = parseFloat(bv);
const cmp = (!isNaN(an) && !isNaN(bn)) ? an - bn : String(av).localeCompare(String(bv));
return sortDir === 'asc' ? cmp : -cmp;
});
return sorted;
}, [animals, sortField, sortDir, subjectTemplate]);
// Group mode: bucket sortedAnimals by the groupField value
const groupedAnimals = useMemo(() => {
if (displayMode !== 'group') return null;
const getVal = (animal) => {
if (groupField === '__name__') return animal.animal_name ?? '—';
if (groupField === '__id__') return animal.animal_id_string ?? '—';
const v = animal.subject_info?.[groupField];
return v != null && v !== '' ? String(v) : '—';
};
const buckets = new Map();
for (const animal of sortedAnimals) {
const key = getVal(animal);
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(animal);
}
// Sort bucket keys numerically-aware
return [...buckets.entries()].sort(([a], [b]) => {
const an = parseFloat(a), bn = parseFloat(b);
return (!isNaN(an) && !isNaN(bn)) ? an - bn : a.localeCompare(b);
});
}, [sortedAnimals, displayMode, groupField]);
if (loading) return Loading…
;
if (error) return (
);
return (
{/* Breadcrumb */}
{experiment.title}
{animals.length} animal{animals.length !== 1 ? 's' : ''} enrolled
{successMsg &&
}
{error &&
setError(null)} />}
{/* Template strips */}
{/* Daily record template */}
Daily record
{(experiment.template ?? []).map((f) => (
{f.active ? null : ◌}
{f.label}
))}
{/* Subject info template */}
Subject info
{subjectTemplate.map((f) => (
{f.active ? null : ◌}
{f.label}
))}
{animals.length === 0 ? (
No animals enrolled in this experiment yet.
) : (
<>
{/* Display controls */}
{/* Mode toggle */}
{[['list', 'List'], ['group', 'Group']].map(([mode, label]) => (
))}
{displayMode === 'list' && (
<>
Sort by
>
)}
{displayMode === 'group' && (
<>
Group by
· sorted within by
>
)}
{displayMode === 'list' && (
navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
onEdit={setEditAnimal}
/>
)}
{displayMode === 'group' && groupedAnimals && groupedAnimals.map(([groupValue, groupAnimals]) => {
const groupLabel = (() => {
if (groupField === '__name__' || groupField === '__id__') return groupValue;
return subjectTemplate.find((f) => f.fieldId === groupField)?.label
? `${subjectTemplate.find((f) => f.fieldId === groupField).label}: ${groupValue}`
: groupValue;
})();
return (
{groupLabel}
({groupAnimals.length})
navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
onEdit={setEditAnimal}
/>
);
})}
>
)}
experimentsApi.savePlotConfig(id, cfg)}
/>
{animals.length > 0 && (
)}
{/* Template editor modal */}
setShowTemplate(false)} title="Daily Record Template" size="full">
setShowSubjectTemplate(false)} title="Subject Info Template" size="full">
experimentsApi.getSubjectTemplate(id)}
saveFn={(fields) => experimentsApi.updateSubjectTemplate(id, fields)}
onClose={(updated) => {
setShowSubjectTemplate(false);
if (updated) { setSubjectTemplate(updated); flash('Subject info template updated.'); }
}}
/>
setShowAddAnimal(false)} title="Add Animal">
setShowAddAnimal(false)} />
setEditAnimal(null)} title="Edit Animal">
setEditAnimal(null)} />
setShowExport(false)} title="Export data" size="md">
setShowExport(false)}
/>
);
}