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)} />
); }