feat: add experiment calendar view with per-day subject counts

New ExperimentCalendar component added as a third view mode (List / Group / Calendar)
on the Experiment Detail page.

Backend:
- GET /api/experiments/:id/calendar?field=<fieldIdOrBuiltinKey>
  Returns { field, days: { "YYYY-MM-DD": count } } where count = number of
  subjects with a non-null/non-empty value for the chosen field on that date.
  Supports both builtin fields (vitals, treatment, notes, experiment_description)
  and custom fields addressed by fieldId UUID.

Frontend:
- ExperimentCalendar component: navigable monthly grid, field selector
  (defaults to first field with "weight" in label, else first active field),
  blue badges showing subject count per day, click to open modal with all
  subjects' data for that day.
- Integrated into ExperimentDetail as displayMode "calendar", persisted
  in localStorage alongside the existing list/group modes.
- experimentsApi.getCalendar() added to API client.

Tests:
- backend/tests/calendar.test.js: 8 unit tests (404, empty days, builtin
  count, custom field count, whitespace ignored, multi-month, field echo,
  missing param).
- frontend/tests/ExperimentCalendar.test.jsx: 13 RTL tests covering
  rendering, default field selection, count badges, month navigation,
  field-change re-fetch, day click modal, and multi-subject display.

All 47 backend + 13 calendar frontend tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Experiments DB Dev
2026-04-23 09:26:35 -04:00
parent 09a853e28b
commit 9535f86970
34 changed files with 5460 additions and 340 deletions
+410 -67
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useParams, useNavigate, Link, useLocation } from 'react-router-dom';
import { animalsApi, dailyStatusesApi, experimentsApi } from '../api/client';
import { format } from 'date-fns';
@@ -6,8 +6,10 @@ import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import DailyStatusForm from '../components/DailyStatusForm';
import SubjectInfoForm from '../components/SubjectInfoForm';
import Alert from '../components/ui/Alert';
import AuditLogSection from '../components/AuditLogSection';
import { SubjectAnalysisCharts } from '../components/AnalysisCharts';
/** Read the value for a template field from a status record.
* Custom fields are keyed by fieldId, not by the display key. */
@@ -16,6 +18,92 @@ function getFieldValue(status, field) {
return status.custom_fields?.[field.fieldId];
}
// ── Inline cell editor ─────────────────────────────────────────────────────────
function InlineCell({ statusId, field, value, currentStatus, onSaved }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(null);
const inputRef = useRef(null);
const isTextarea = field.type === 'textarea';
function startEdit(e) {
e.preventDefault();
e.stopPropagation();
setDraft(value ?? '');
setSaveError(null);
setEditing(true);
}
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
if (inputRef.current.select) inputRef.current.select();
}
}, [editing]);
async function commit() {
setEditing(false);
const newValue = draft.trim() || null;
if (newValue === (value?.trim() || null)) return;
setSaving(true);
setSaveError(null);
try {
let body;
if (field.builtin) {
body = { [field.key]: newValue };
} else {
body = { custom_fields: { ...currentStatus.custom_fields, [field.fieldId]: newValue } };
}
const updated = await dailyStatusesApi.update(statusId, body);
onSaved(updated);
} catch (err) {
setSaveError(err.message ?? 'Save failed');
} finally {
setSaving(false);
}
}
function cancel() { setEditing(false); }
if (editing) {
const sharedProps = {
ref: inputRef,
value: draft,
onChange: (e) => setDraft(e.target.value),
onBlur: commit,
onClick: (e) => e.stopPropagation(),
onKeyDown: (e) => {
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
if (e.key === 'Enter' && !isTextarea) { e.preventDefault(); commit(); }
},
className: 'w-full border border-indigo-400 rounded px-1.5 py-0.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300 bg-white',
};
return isTextarea
? <textarea {...sharedProps} rows={3} className={sharedProps.className + ' resize-none'} />
: <input type="text" {...sharedProps} />;
}
return (
<div
onDoubleClick={startEdit}
title={editing ? '' : 'Double-click to edit'}
className={`min-h-[1.25rem] cursor-default rounded transition-colors px-0.5 -mx-0.5
${saving ? 'opacity-40' : 'hover:bg-indigo-50/60 group/ic'}`}
>
{saveError
? <span className="text-red-500 text-xs">{saveError}</span>
: value
? isTextarea
? <span className="line-clamp-2 whitespace-pre-wrap">{value}</span>
: <span className="truncate block">{value}</span>
: <span className="text-gray-200 group-hover/ic:text-gray-400 select-none text-xs">· · ·</span>
}
</div>
);
}
export default function AnimalDetail() {
const { id } = useParams();
const navigate = useNavigate();
@@ -23,7 +111,9 @@ export default function AnimalDetail() {
const [animal, setAnimal] = useState(null);
const [statuses, setStatuses] = useState([]);
const [template, setTemplate] = useState(location.state?.template ?? []);
const [template, setTemplate] = useState([]);
const [subjectTemplate, setSubjectTemplate] = useState([]);
const [showSubjectInfo, setShowSubjectInfo] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [successMsg, setSuccessMsg] = useState(null);
@@ -32,6 +122,40 @@ export default function AnimalDetail() {
const [editStatus, setEditStatus] = useState(null);
const [deleteStatus, setDeleteStatus] = useState(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [showDeleteSubject, setShowDeleteSubject] = useState(false);
const [deleteSubjectLoading, setDeleteSubjectLoading] = useState(false);
const [fillingGaps, setFillingGaps] = useState(false);
// Column visibility — persisted per experiment
const [hiddenCols, setHiddenCols] = useState(new Set());
const [showColPicker, setShowColPicker] = useState(false);
const colPickerRef = useRef(null);
useEffect(() => {
if (!animal) return;
const stored = localStorage.getItem(`col-vis-${animal.id}`);
setHiddenCols(stored ? new Set(JSON.parse(stored)) : new Set());
}, [animal?.id]);
function toggleCol(fieldId) {
setHiddenCols((prev) => {
const next = new Set(prev);
next.has(fieldId) ? next.delete(fieldId) : next.add(fieldId);
localStorage.setItem(`col-vis-${animal.id}`, JSON.stringify([...next]));
return next;
});
}
useEffect(() => {
if (!showColPicker) return;
function handleClick(e) {
if (colPickerRef.current && !colPickerRef.current.contains(e.target)) {
setShowColPicker(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [showColPicker]);
async function load() {
setLoading(true);
@@ -44,11 +168,12 @@ export default function AnimalDetail() {
setAnimal(animalData);
setStatuses(statusData);
// Fetch template if not passed via navigation state
if (!location.state?.template) {
const tmpl = await experimentsApi.getTemplate(animalData.experiment_id);
setTemplate(tmpl);
}
const [tmpl, subjTmpl] = await Promise.all([
experimentsApi.getTemplate(animalData.experiment_id),
experimentsApi.getSubjectTemplate(animalData.experiment_id),
]);
setTemplate(tmpl);
setSubjectTemplate(subjTmpl);
} catch (err) {
setError(err.message);
} finally {
@@ -75,6 +200,7 @@ export default function AnimalDetail() {
try {
await dailyStatusesApi.delete(deleteStatus.id);
setDeleteStatus(null);
setEditStatus(null);
load();
flash('Daily status removed.');
} catch (err) {
@@ -85,6 +211,32 @@ export default function AnimalDetail() {
}
}
async function fillGaps() {
setFillingGaps(true);
try {
await Promise.all(missingDates.map((date) => dailyStatusesApi.create({ date, animal_id: id })));
load();
flash(`Created ${missingDates.length} missing entr${missingDates.length === 1 ? 'y' : 'ies'}.`);
} catch (err) {
setError(err.message);
} finally {
setFillingGaps(false);
}
}
async function handleDeleteSubject() {
setDeleteSubjectLoading(true);
try {
await animalsApi.delete(animal.id);
navigate(`/experiments/${animal.experiment_id}`, { replace: true });
} catch (err) {
setError(err.message);
setShowDeleteSubject(false);
} finally {
setDeleteSubjectLoading(false);
}
}
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400" aria-live="polite" aria-busy="true">Loading</div>;
if (error) return (
<div className="max-w-5xl mx-auto px-4 py-8">
@@ -94,6 +246,23 @@ export default function AnimalDetail() {
);
const activeFields = template.filter((f) => f.active);
const visibleFields = activeFields.filter((f) => !hiddenCols.has(f.fieldId));
// Dates with entries
const existingDateSet = new Set(statuses.map((s) => String(s.date).slice(0, 10)));
const missingDates = (() => {
if (statuses.length < 2) return [];
const sorted = [...existingDateSet].sort();
const missing = [];
const cur = new Date(sorted[0] + 'T12:00:00');
const end = new Date(sorted[sorted.length - 1] + 'T12:00:00');
while (cur < end) {
cur.setDate(cur.getDate() + 1);
const d = cur.toISOString().slice(0, 10);
if (!existingDateSet.has(d)) missing.push(d);
}
return missing;
})();
return (
<div className="max-w-5xl mx-auto px-4 py-8">
@@ -121,85 +290,248 @@ export default function AnimalDetail() {
{successMsg && <Alert type="success" message={successMsg} />}
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{/* Subject information card */}
{subjectTemplate.filter((f) => f.active).length > 0 && (
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Subject Information</h2>
<button
type="button"
onClick={() => setShowSubjectInfo(true)}
className="text-xs text-gray-400 hover:text-blue-600 transition-colors inline-flex items-center gap-1"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828A2 2 0 0110 16.414V19h2.586a2 2 0 001.414-.586l6.5-6.5" />
</svg>
Edit
</button>
</div>
{(() => {
const filled = subjectTemplate.filter((f) => f.active && animal.subject_info?.[f.fieldId]);
if (filled.length === 0) {
return (
<button
type="button"
onClick={() => setShowSubjectInfo(true)}
className="text-sm text-gray-400 italic hover:text-blue-600 transition-colors"
>
No information recorded yet click to add
</button>
);
}
return (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
{filled.map((f) => (
<div key={f.fieldId} className={f.type === 'textarea' ? 'sm:col-span-2' : ''}>
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{f.label}</dt>
<dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{animal.subject_info[f.fieldId]}</dd>
</div>
))}
</dl>
);
})()}
</div>
)}
{statuses.length === 0 ? (
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
<p className="text-gray-400 mb-3">No daily statuses logged yet.</p>
<Button onClick={() => setShowAddStatus(true)}>+ Log First Status</Button>
</div>
) : (
<div className="space-y-3">
{statuses.map((s) => (
<div key={s.id} className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-center justify-between mb-3">
<span className="font-semibold text-gray-900">
{format(new Date(s.date), 'MMMM d, yyyy')}
</span>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => setEditStatus(s)}>Edit</Button>
<Button size="sm" variant="danger" onClick={() => setDeleteStatus(s)}>Delete</Button>
</div>
</div>
{activeFields.length > 0 ? (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
{activeFields
.map((f) => ({ field: f, value: getFieldValue(s, f) }))
.filter(({ value }) => value)
.map(({ field, value }) => (
<div key={field.key} className={field.type === 'textarea' ? 'sm:col-span-2' : ''}>
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{field.label}</dt>
<dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{value}</dd>
</div>
))}
</dl>
) : null}
{/* Fallback: show raw builtin fields if template is empty */}
{activeFields.length === 0 && (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
{[
['Experiment Description', s.experiment_description],
['Vitals', s.vitals],
['Treatment', s.treatment],
['Notes', s.notes],
].filter(([, v]) => v).map(([label, value]) => (
<div key={label}>
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{label}</dt>
<dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{value}</dd>
</div>
))}
</dl>
)}
{activeFields.length > 0 &&
activeFields.every((f) => !getFieldValue(s, f)) && (
<p className="text-sm text-gray-400 italic">No details recorded for this date.</p>
<div className="border border-gray-200 rounded-xl overflow-hidden">
{/* Column picker toolbar */}
{activeFields.length > 0 && (
<div className="flex justify-end px-3 py-1.5 border-b border-gray-100 bg-gray-50/60">
<div className="relative" ref={colPickerRef}>
<button
type="button"
onClick={() => setShowColPicker((v) => !v)}
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-800 px-2 py-1 rounded hover:bg-gray-100 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
Columns
{hiddenCols.size > 0 && (
<span className="ml-0.5 bg-indigo-100 text-indigo-700 rounded-full px-1.5 py-px text-[10px] font-medium leading-none">
{activeFields.length - hiddenCols.size}/{activeFields.length}
</span>
)}
</button>
{showColPicker && (
<div className="absolute right-0 top-full mt-1 z-30 bg-white border border-gray-200 rounded-lg shadow-lg p-2 min-w-[180px]">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide px-1 mb-1">Show / hide columns</p>
{activeFields.map((f) => {
const visible = !hiddenCols.has(f.fieldId);
return (
<label key={f.fieldId} className="flex items-center gap-2 px-1 py-0.5 rounded hover:bg-gray-50 cursor-pointer select-none text-xs text-gray-700">
<input
type="checkbox"
checked={visible}
onChange={() => toggleCol(f.fieldId)}
className="w-3.5 h-3.5 accent-indigo-600"
/>
<span className="truncate" title={f.label}>{f.label}</span>
</label>
);
})}
{hiddenCols.size > 0 && (
<button
type="button"
onClick={() => {
setHiddenCols(new Set());
localStorage.removeItem(`col-vis-${animal.id}`);
}}
className="mt-1 w-full text-left text-[10px] text-indigo-500 hover:text-indigo-700 px-1 py-0.5"
>
Show all
</button>
)}
</div>
)}
</div>
</div>
))}
)}
<div className="overflow-x-auto">
<table className="text-sm border-collapse min-w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap sticky left-0 bg-gray-50 z-10 border-r border-gray-200 min-w-[110px]">
Date
</th>
{visibleFields.map((f) => {
const header = f.abbr ? (f.unit ? `${f.abbr} (${f.unit})` : f.abbr) : (f.unit ? `${f.label} (${f.unit})` : f.label);
const tooltip = f.unit ? `${f.label} (${f.unit})` : f.label;
const minW = Math.max(80, header.length * 7.5 + 24);
return (
<th key={f.key} title={tooltip} style={{ minWidth: minW }} className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap max-w-[220px] cursor-default">
{header}
</th>
);
})}
{activeFields.length === 0 && (
<>
{['Experiment Description', 'Vitals', 'Treatment', 'Notes'].map((l) => (
<th key={l} className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap min-w-[120px]">{l}</th>
))}
</>
)}
<th className="sticky right-0 bg-gray-50 z-10 border-l border-gray-200 px-3 py-2 min-w-[96px]" />
</tr>
</thead>
<tbody>
{statuses.map((s, i) => {
const rowBg = i % 2 === 0 ? 'bg-white' : 'bg-gray-50/40';
const renderFields = activeFields.length > 0 ? visibleFields : [
{ fieldId: '__exp_desc__', key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true },
{ fieldId: '__vitals__', key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true },
{ fieldId: '__treatment__',key: 'treatment', label: 'Treatment', type: 'text', builtin: true, active: true },
{ fieldId: '__notes__', key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: true },
];
return (
<tr key={s.id} className={`border-b border-gray-100 last:border-0 ${rowBg}`}>
<td
className={`px-3 py-2 font-medium text-gray-700 whitespace-nowrap sticky left-0 z-10 border-r border-gray-100 cursor-pointer hover:text-blue-600 hover:bg-blue-50/40 transition-colors ${rowBg}`}
onClick={() => navigate(`/daily-statuses/${s.id}`)}
>
{format(new Date(String(s.date).slice(0, 10) + 'T12:00:00'), 'MMM d, yyyy')}
</td>
{renderFields.map((f) => (
<td key={f.fieldId} className="px-3 py-1.5 text-gray-700 max-w-[220px]">
<InlineCell
statusId={s.id}
field={f}
value={getFieldValue(s, f)}
currentStatus={s}
onSaved={(updated) => setStatuses((prev) => prev.map((x) => x.id === s.id ? { ...x, ...updated } : x))}
/>
</td>
))}
<td className={`px-2 py-1.5 sticky right-0 z-10 border-l border-gray-100 ${rowBg}`}>
<div className="flex gap-1 justify-end">
<Button size="sm" variant="secondary" onClick={() => setEditStatus(s)}>Edit</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
<AuditLogSection tableName="daily_statuses" />
{missingDates.length > 0 && (
<div className="mt-3 flex items-center justify-between px-4 py-3 bg-amber-50 border border-amber-200 rounded-xl">
<p className="text-sm text-amber-800">
<span className="font-semibold">{missingDates.length}</span> date{missingDates.length !== 1 ? 's' : ''} between the earliest and latest entries have no record.
</p>
<Button variant="secondary" size="sm" onClick={fillGaps} loading={fillingGaps}>
Create {missingDates.length} empty entr{missingDates.length === 1 ? 'y' : 'ies'}
</Button>
</div>
)}
<Modal isOpen={showAddStatus} onClose={() => setShowAddStatus(false)} title="Log Daily Status" size="lg">
<SubjectAnalysisCharts statuses={statuses} dailyTemplate={template} />
<AuditLogSection tableName="daily_statuses" limit={5} />
{/* Danger zone */}
<div className="mt-10 border border-red-200 rounded-xl p-4 bg-red-50/40">
<h2 className="text-xs font-semibold text-red-600 uppercase tracking-wide mb-2">Danger Zone</h2>
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600">Permanently delete this subject and all its daily status records.</p>
<Button variant="danger" onClick={() => setShowDeleteSubject(true)}>Delete Subject</Button>
</div>
</div>
<Modal isOpen={showSubjectInfo} onClose={() => setShowSubjectInfo(false)} title="Subject Information" size="full">
<SubjectInfoForm
animal={animal}
subjectTemplate={subjectTemplate}
experimentId={animal?.experiment_id}
onTemplateUpdate={setSubjectTemplate}
onSaved={(updated) => { setAnimal(updated); setShowSubjectInfo(false); flash('Subject information saved.'); }}
onCancel={() => setShowSubjectInfo(false)}
/>
</Modal>
<Modal isOpen={showAddStatus} onClose={() => setShowAddStatus(false)} title="Log Daily Status" size="full">
<DailyStatusForm
animalId={id}
experimentId={animal?.experiment_id}
template={template}
onTemplateUpdate={setTemplate}
onSuccess={handleStatusSaved}
onCancel={() => setShowAddStatus(false)}
/>
</Modal>
<Modal isOpen={!!editStatus} onClose={() => setEditStatus(null)} title="Edit Daily Status" size="lg">
<DailyStatusForm
existing={editStatus}
animalId={id}
template={template}
onSuccess={handleStatusSaved}
onCancel={() => setEditStatus(null)}
/>
</Modal>
{(() => {
const editIdx = editStatus ? statuses.findIndex((s) => s.id === editStatus.id) : -1;
return (
<Modal isOpen={!!editStatus} onClose={() => setEditStatus(null)} title="Edit Daily Status" size="full">
<DailyStatusForm
existing={editStatus}
animalId={id}
experimentId={animal?.experiment_id}
template={template}
onTemplateUpdate={setTemplate}
onSuccess={handleStatusSaved}
onCancel={() => setEditStatus(null)}
onRequestDelete={() => setDeleteStatus(editStatus)}
hasPrev={editIdx > 0}
hasNext={editIdx < statuses.length - 1}
onNavigate={(direction, savedResult) => {
setStatuses((prev) => prev.map((s) => s.id === savedResult.id ? savedResult : s));
setEditStatus(statuses[direction === 'prev' ? editIdx - 1 : editIdx + 1]);
}}
/>
</Modal>
);
})()}
<ConfirmDialog
isOpen={!!deleteStatus}
@@ -207,7 +539,18 @@ export default function AnimalDetail() {
onConfirm={handleDeleteStatus}
loading={deleteLoading}
title="Delete Daily Status"
message={`Delete the status entry for ${deleteStatus ? format(new Date(deleteStatus.date), 'MMMM d, yyyy') : ''}?`}
message={`Delete the status entry for ${deleteStatus ? format(new Date(String(deleteStatus.date).slice(0, 10) + 'T12:00:00'), 'MMMM d, yyyy') : ''}? This cannot be undone.`}
confirmWord="delete"
/>
<ConfirmDialog
isOpen={showDeleteSubject}
onClose={() => setShowDeleteSubject(false)}
onConfirm={handleDeleteSubject}
loading={deleteSubjectLoading}
title="Delete Subject"
message={`This will permanently delete "${animal.animal_name}" and all ${statuses.length} daily status record${statuses.length !== 1 ? 's' : ''}. This cannot be undone.`}
confirmWord="delete"
/>
</div>
);
+601
View File
@@ -0,0 +1,601 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { format } from 'date-fns';
import { dailyStatusesApi, experimentsApi, analysesApi } from '../api/client';
import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import DailyStatusForm from '../components/DailyStatusForm';
import Alert from '../components/ui/Alert';
import CsvAnalysis from '../components/CsvAnalysis';
import AuditLogSection from '../components/AuditLogSection';
import RunSequenceView from '../components/RunSequenceView';
function fmtDate(raw) {
return format(new Date(String(raw).slice(0, 10) + 'T12:00:00'), 'MMMM d, yyyy');
}
function fmtDateTime(raw) {
return format(new Date(raw), 'MMM d, yyyy · h:mm a');
}
function getFieldValue(status, field) {
if (field.builtin) return status[field.key];
return status.custom_fields?.[field.fieldId];
}
// ── Analysis summary card ─────────────────────────────────────────────────────
function AnalysisSummaryCard({ summary }) {
const { counts = {}, total, success_rate, computed_at } = summary;
const COLORS = {
Success: 'bg-green-100 text-green-800',
Failure: 'bg-red-100 text-red-800',
Other: 'bg-gray-100 text-gray-700',
};
const EXTRA = ['bg-indigo-100 text-indigo-800', 'bg-yellow-100 text-yellow-800', 'bg-purple-100 text-purple-800', 'bg-pink-100 text-pink-800'];
const catList = Object.entries(counts).sort(([, a], [, b]) => b - a);
let extraIdx = 0;
return (
<div className="bg-white border border-gray-200 rounded-xl px-5 py-4 mb-6">
<div className="flex items-center justify-between mb-3">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Session metrics</p>
{computed_at && <p className="text-[10px] text-gray-400">Updated {fmtDateTime(computed_at)}</p>}
</div>
<div className="flex flex-wrap gap-3 items-center">
{catList.map(([cat, n]) => {
const cls = COLORS[cat] ?? EXTRA[extraIdx++ % EXTRA.length];
return (
<div key={cat} className={`flex flex-col items-center px-3 py-2 rounded-lg ${cls}`}>
<span className="text-xs font-medium">{cat}</span>
<span className="text-xl font-bold leading-tight">{n}</span>
</div>
);
})}
<div className="flex flex-col items-center px-3 py-2 rounded-lg bg-blue-50 text-blue-800">
<span className="text-xs font-medium">Total</span>
<span className="text-xl font-bold leading-tight">{total}</span>
</div>
{success_rate != null && (
<div className="flex flex-col items-center px-3 py-2 rounded-lg bg-indigo-50 text-indigo-800">
<span className="text-xs font-medium">Success rate</span>
<span className="text-xl font-bold leading-tight">{(success_rate * 100).toFixed(1)}%</span>
</div>
)}
</div>
</div>
);
}
// ── Manual metrics form ───────────────────────────────────────────────────────
const DEFAULT_CATS = ['Success', 'Failure', 'Other'];
function ManualMetricsForm({ statusId, existing, onSaved, onClose }) {
const initCounts = () => {
const base = { ...existing?.counts };
for (const c of DEFAULT_CATS) if (!(c in base)) base[c] = 0;
return base;
};
const [counts, setCounts] = useState(initCounts);
const [newCat, setNewCat] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const total = Object.values(counts).reduce((a, b) => a + (Number(b) || 0), 0);
const successRate = total > 0 && counts['Success'] != null
? (Number(counts['Success']) || 0) / total
: null;
function setCount(cat, raw) {
const v = Math.max(0, parseInt(raw, 10) || 0);
setCounts((prev) => ({ ...prev, [cat]: v }));
}
function addCat() {
const name = newCat.trim();
if (!name || name in counts) return;
setCounts((prev) => ({ ...prev, [name]: 0 }));
setNewCat('');
}
function removeCat(cat) {
if (DEFAULT_CATS.includes(cat)) return;
setCounts((prev) => { const n = { ...prev }; delete n[cat]; return n; });
}
async function save() {
setSaving(true);
setError(null);
try {
const finalCounts = Object.fromEntries(
Object.entries(counts).map(([k, v]) => [k, Number(v) || 0])
);
await dailyStatusesApi.updateAnalysisSummary(statusId, {
counts: finalCounts,
total,
success_rate: successRate,
source_analysis_id: null,
});
onSaved({ counts: finalCounts, total, success_rate: successRate });
} catch (err) {
setError(err.message ?? 'Failed to save');
} finally {
setSaving(false);
}
}
return (
<div className="bg-white border border-gray-200 rounded-xl px-5 py-4 mb-6">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">Set metrics manually</p>
<div className="flex flex-wrap gap-3 items-end mb-3">
{Object.entries(counts).map(([cat, val]) => (
<div key={cat} className="flex flex-col items-center gap-1">
<span className="text-xs text-gray-500">{cat}</span>
<input
type="number" min={0} value={val}
onChange={(e) => setCount(cat, e.target.value)}
className="w-20 text-center border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
/>
{!DEFAULT_CATS.includes(cat) && (
<button type="button" onClick={() => removeCat(cat)}
className="text-[10px] text-red-400 hover:text-red-600">remove</button>
)}
</div>
))}
{/* Add custom category inline */}
<div className="flex flex-col items-center gap-1">
<span className="text-xs text-gray-400">+ category</span>
<div className="flex gap-1">
<input value={newCat} onChange={(e) => setNewCat(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addCat(); }}
placeholder="Name…"
className="w-24 border border-dashed border-gray-300 rounded px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-indigo-400" />
<button type="button" onClick={addCat}
className="text-xs text-indigo-500 hover:text-indigo-700 px-1">Add</button>
</div>
</div>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500 mb-3">
<span>Total: <strong className="text-gray-800">{total}</strong></span>
{successRate != null && (
<span>Success rate: <strong className="text-gray-800">{(successRate * 100).toFixed(1)}%</strong></span>
)}
</div>
{error && <p className="text-xs text-red-500 mb-2">{error}</p>}
<div className="flex gap-2">
<button type="button" onClick={save} disabled={saving}
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors">
{saving ? 'Saving…' : 'Save'}
</button>
<button type="button" onClick={onClose}
className="px-3 py-1.5 rounded-lg text-sm text-gray-500 hover:text-gray-700 border border-gray-200 transition-colors">
Cancel
</button>
</div>
</div>
);
}
// ── Past analysis row (expandable) ────────────────────────────────────────────
function AnalysisRow({ analysis, onDelete }) {
const [expanded, setExpanded] = useState(false);
const [full, setFull] = useState(null);
const [loadingFull, setLoadingFull] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
async function expand() {
if (expanded) { setExpanded(false); return; }
setExpanded(true);
if (full) return;
setLoadingFull(true);
try {
const data = await analysesApi.get(analysis.id);
setFull(data);
} finally {
setLoadingFull(false);
}
}
async function handleDelete() {
setDeleting(true);
try {
await analysesApi.delete(analysis.id);
onDelete(analysis.id);
} finally {
setDeleting(false);
}
}
const counts = analysis.category_counts ?? {};
const summary = Object.entries(counts)
.sort(([, a], [, b]) => b.total - a.total)
.map(([cat, d]) => `${cat}: ${d.total}`)
.join(' · ');
return (
<div className="border border-gray-200 rounded-lg overflow-hidden">
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-gray-50 transition-colors"
onClick={expand}
>
<div className="flex items-center gap-3 min-w-0">
<span className="text-gray-400 text-xs">{expanded ? '▾' : '▸'}</span>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-800 truncate">
{analysis.file_name ?? 'Unnamed file'}
</p>
<p className="text-xs text-gray-400">
{fmtDateTime(analysis.created_at)}
{' · '}
<span className="font-mono">{analysis.note_col}</span>
{' · '}
{analysis.total_note_rows} note rows
</p>
</div>
</div>
<div className="flex items-center gap-3 ml-4 shrink-0">
<span className="text-xs text-gray-500 hidden sm:block">{summary}</span>
<button type="button" onClick={(e) => { e.stopPropagation(); setConfirmDelete(true); }}
className="text-xs text-red-400 hover:text-red-600 transition-colors">Delete</button>
</div>
</div>
{expanded && (
<div className="border-t border-gray-100 px-4 py-4 bg-gray-50/40">
{loadingFull && <p className="text-sm text-gray-400">Loading</p>}
{full && <AnalysisResultsView analysis={full} />}
</div>
)}
<ConfirmDialog
isOpen={confirmDelete}
onClose={() => setConfirmDelete(false)}
onConfirm={handleDelete}
loading={deleting}
title="Delete Analysis"
message="Remove this analysis record? The raw CSV is not affected."
/>
</div>
);
}
// ── Analysis results display (reused by both live and past) ───────────────────
export function AnalysisResultsView({ analysis }) {
const { timestamp_col, note_col, total_note_rows,
category_counts, consecutive_stats, runs } = analysis;
const customCategories = [];
function colorsFor(cat) {
const COLORS = {
Success: { bg: 'bg-green-100', text: 'text-green-800', dot: 'bg-green-500', border: 'border-green-300' },
Failure: { bg: 'bg-red-100', text: 'text-red-800', dot: 'bg-red-500', border: 'border-red-300' },
Other: { bg: 'bg-gray-100', text: 'text-gray-700', dot: 'bg-gray-400', border: 'border-gray-300' },
};
const EXTRA = [
{ bg: 'bg-indigo-100', text: 'text-indigo-800', dot: 'bg-indigo-500', border: 'border-indigo-300' },
{ bg: 'bg-yellow-100', text: 'text-yellow-800', dot: 'bg-yellow-500', border: 'border-yellow-300' },
{ bg: 'bg-purple-100', text: 'text-purple-800', dot: 'bg-purple-500', border: 'border-purple-300' },
];
if (COLORS[cat]) return COLORS[cat];
const idx = customCategories.indexOf(cat) % EXTRA.length;
return EXTRA[Math.max(0, idx)];
}
return (
<div className="space-y-5 text-sm">
<p className="text-xs text-gray-400">
{total_note_rows} note rows · timestamp: <span className="font-mono">{timestamp_col}</span>
{' '}/ note: <span className="font-mono">{note_col}</span>
</p>
{/* Category counts */}
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Category Counts</p>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-white border-b border-gray-200">
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Category</th>
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Breakdown</th>
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Total</th>
</tr>
</thead>
<tbody>
{Object.entries(category_counts ?? {})
.sort(([, a], [, b]) => b.total - a.total)
.map(([cat, data]) => {
const c = colorsFor(cat);
const bd = Object.entries(data.byNote ?? {})
.sort(([, a], [, b]) => b - a)
.map(([n, cnt]) => `${n}: ${cnt}`).join(' · ');
return (
<tr key={cat} className="border-b border-gray-100">
<td className="px-3 py-1.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${c.bg} ${c.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
{cat}
</span>
</td>
<td className="px-3 py-1.5 text-gray-500 font-mono text-xs">{bd}</td>
<td className="px-3 py-1.5 text-right font-semibold text-gray-800">{data.total}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Consecutive stats */}
{Object.keys(consecutive_stats ?? {}).length > 0 && (
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Consecutive Runs</p>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-white border-b border-gray-200">
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Category</th>
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Runs</th>
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Max</th>
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Avg</th>
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Lengths</th>
</tr>
</thead>
<tbody>
{Object.entries(consecutive_stats).map(([cat, s]) => {
const c = colorsFor(cat);
const summary = Object.entries(
s.runLengths.reduce((acc, l) => { acc[l] = (acc[l] || 0) + 1; return acc; }, {})
).sort(([a], [b]) => Number(a) - Number(b))
.map(([l, cnt]) => `${l}×${cnt}`).join(', ');
return (
<tr key={cat} className="border-b border-gray-100">
<td className="px-3 py-1.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${c.bg} ${c.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
{cat}
</span>
</td>
<td className="px-3 py-1.5 text-right text-gray-700">{s.totalRuns}</td>
<td className="px-3 py-1.5 text-right text-gray-700">{s.maxRun}</td>
<td className="px-3 py-1.5 text-right text-gray-700">{s.avgRun.toFixed(2)}</td>
<td className="px-3 py-1.5 text-gray-500 font-mono text-xs">{summary}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<RunSequenceView runs={runs ?? []} sequence={analysis.sequence ?? []} sessionEndTs={analysis.session_end_ts ?? null} />
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function DailyStatusDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [status, setStatus] = useState(null);
const [template, setTemplate] = useState([]);
const [allStatuses, setAllStatuses] = useState([]);
const [analyses, setAnalyses] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showEdit, setShowEdit] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [showManualForm, setShowManualForm] = useState(false);
async function load(statusId = id) {
setLoading(true);
setError(null);
try {
const s = await dailyStatusesApi.get(statusId);
const [tmpl, siblings, pastAnalyses] = await Promise.all([
experimentsApi.getTemplate(s.animal.experiment_id),
dailyStatusesApi.list(s.animal_id),
analysesApi.listForStatus(statusId),
]);
setStatus(s);
setTemplate(tmpl);
setAllStatuses(siblings);
setAnalyses(pastAnalyses);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, [id]);
async function handleDelete() {
setDeleteLoading(true);
try {
await dailyStatusesApi.delete(status.id);
navigate(`/animals/${status.animal_id}`, { replace: true });
} catch (err) {
setError(err.message);
setShowDelete(false);
} finally {
setDeleteLoading(false);
}
}
if (loading) return <div className="max-w-3xl mx-auto px-4 py-8 text-gray-400">Loading</div>;
if (error) return (
<div className="max-w-3xl mx-auto px-4 py-8">
<Alert type="error" message={error} />
<Button variant="secondary" className="mt-4" onClick={() => navigate(-1)}> Back</Button>
</div>
);
const animal = status.animal;
const activeFields = template.filter((f) => f.active);
const currentIdx = allStatuses.findIndex((s) => s.id === status.id);
const prevStatus = currentIdx > 0 ? allStatuses[currentIdx - 1] : null;
const nextStatus = currentIdx < allStatuses.length - 1 ? allStatuses[currentIdx + 1] : null;
return (
<div className="max-w-3xl mx-auto px-4 py-8">
{/* Breadcrumb */}
<nav className="text-sm text-gray-500 mb-4">
<Link to="/" className="hover:text-blue-600">Experiments</Link>
<span className="mx-2">/</span>
<Link to={`/experiments/${animal.experiment_id}`} className="hover:text-blue-600">Experiment</Link>
<span className="mx-2">/</span>
<Link to={`/animals/${animal.id}`} className="hover:text-blue-600">{animal.animal_name}</Link>
<span className="mx-2">/</span>
<span className="text-gray-900 font-medium">{fmtDate(status.date)}</span>
</nav>
{/* Header */}
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">{fmtDate(status.date)}</h1>
<p className="text-sm text-gray-500 mt-0.5">
{animal.animal_name}
{animal.animal_id_string && <> · <span className="font-mono">{animal.animal_id_string}</span></>}
</p>
</div>
<Button variant="secondary" onClick={() => setShowEdit(true)}>Edit entry</Button>
</div>
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{/* Field values */}
<div className="bg-white border border-gray-200 rounded-xl p-6 mb-6">
{activeFields.length === 0 ? (
<p className="text-sm text-gray-400 italic">No template fields configured.</p>
) : (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4">
{activeFields.map((f) => {
const value = getFieldValue(status, f);
const label = f.unit ? `${f.label} (${f.unit})` : f.label;
return (
<div key={f.fieldId} className={f.type === 'textarea' ? 'sm:col-span-2' : ''}>
<dt className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-0.5">{label}</dt>
<dd className="text-gray-800 whitespace-pre-wrap break-words">
{value ?? <span className="text-gray-300 italic"></span>}
</dd>
</div>
);
})}
</dl>
)}
</div>
{/* Analysis summary (pinned metrics) */}
{status.analysis_summary && !showManualForm && (
<AnalysisSummaryCard summary={status.analysis_summary} />
)}
{showManualForm ? (
<ManualMetricsForm
statusId={status.id}
existing={status.analysis_summary}
onSaved={(summary) => {
setStatus((prev) => ({ ...prev, analysis_summary: { ...summary, computed_at: new Date().toISOString() } }));
setShowManualForm(false);
}}
onClose={() => setShowManualForm(false)}
/>
) : (
<div className="mb-4">
<button
type="button"
onClick={() => setShowManualForm(true)}
className="text-xs text-gray-400 hover:text-indigo-600 border border-dashed border-gray-200 hover:border-indigo-300 rounded-lg px-3 py-1.5 transition-colors"
>
{status.analysis_summary ? 'Edit metrics manually' : 'Set metrics manually'}
</button>
</div>
)}
{/* CSV Analysis — new analysis */}
<CsvAnalysis
dailyStatusId={status.id}
onSaved={(saved) => setAnalyses((prev) => [saved, ...prev])}
onSummaryPushed={(summary) => setStatus((prev) => ({ ...prev, analysis_summary: { ...summary, computed_at: new Date().toISOString() } }))}
/>
{/* Past analyses */}
{analyses.length > 0 && (
<div className="mt-8 border-t border-gray-200 pt-6">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">
Past Analyses ({analyses.length})
</h2>
<div className="space-y-2">
{analyses.map((a) => (
<AnalysisRow
key={a.id}
analysis={a}
onDelete={(deletedId) => setAnalyses((prev) => prev.filter((x) => x.id !== deletedId))}
/>
))}
</div>
</div>
)}
{/* Full modification history for this entry */}
<AuditLogSection tableName="daily_statuses" recordId={status.id} />
{/* Prev / Next navigation */}
<div className="flex justify-between items-center mt-8 mb-6">
<div>
{prevStatus && (
<button type="button" onClick={() => navigate(`/daily-statuses/${prevStatus.id}`)}
className="text-sm text-blue-600 hover:text-blue-800 hover:underline">
{fmtDate(prevStatus.date)}
</button>
)}
</div>
<Link to={`/animals/${animal.id}`} className="text-sm text-gray-500 hover:text-gray-800">
All entries
</Link>
<div>
{nextStatus && (
<button type="button" onClick={() => navigate(`/daily-statuses/${nextStatus.id}`)}
className="text-sm text-blue-600 hover:text-blue-800 hover:underline">
{fmtDate(nextStatus.date)}
</button>
)}
</div>
</div>
{/* Edit modal */}
<Modal isOpen={showEdit} onClose={() => setShowEdit(false)} title="Edit Daily Status" size="full">
<DailyStatusForm
existing={status}
animalId={animal.id}
experimentId={animal.experiment_id}
template={template}
onTemplateUpdate={setTemplate}
onSuccess={(updated) => { setStatus({ ...updated, animal }); setShowEdit(false); }}
onCancel={() => setShowEdit(false)}
onRequestDelete={() => { setShowEdit(false); setShowDelete(true); }}
/>
</Modal>
<ConfirmDialog
isOpen={showDelete}
onClose={() => setShowDelete(false)}
onConfirm={handleDelete}
loading={deleteLoading}
title="Delete Daily Status"
message={`Delete the entry for ${fmtDate(status.date)}? This cannot be undone.`}
confirmWord="delete"
/>
</div>
);
}
+275 -68
View File
@@ -1,13 +1,52 @@
import React, { useEffect, useState } from 'react';
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 ConfirmDialog from '../components/ui/ConfirmDialog';
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';
function AnimalCardList({ animals, subjectTemplate, onNavigate, onEdit }) {
return (
<div className="grid gap-3">
{animals.map((animal) => {
const infoFields = subjectTemplate.filter((f) => f.active && animal.subject_info?.[f.fieldId] != null);
return (
<div
key={animal.id}
className="bg-white border border-gray-200 rounded-xl p-4 flex items-center justify-between hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer group"
onClick={() => onNavigate(animal)}
>
<div>
<div className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
{animal.animal_name}
</div>
<div className="text-sm text-gray-500">
ID: {animal.animal_id_string} · {animal._count?.daily_statuses ?? 0} daily status entries
</div>
{infoFields.length > 0 && (
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1">
{infoFields.map((f) => (
<span key={f.fieldId} className="text-xs text-gray-400">
<span className="font-medium text-gray-500">{f.label}:</span> {animal.subject_info[f.fieldId]}
</span>
))}
</div>
)}
</div>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<Button size="sm" variant="secondary" onClick={() => onEdit(animal)}>Edit</Button>
</div>
</div>
);
})}
</div>
);
}
export default function ExperimentDetail() {
const { id } = useParams();
@@ -21,20 +60,48 @@ export default function ExperimentDetail() {
const [showAddAnimal, setShowAddAnimal] = useState(false);
const [editAnimal, setEditAnimal] = useState(null);
const [deleteAnimal, setDeleteAnimal] = useState(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [showTemplate, setShowTemplate] = useState(false);
const [showSubjectTemplate, setShowSubjectTemplate] = 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] = await Promise.all([
const [exp, anims, subjTmpl, dailyTmpl, expStatuses] = await Promise.all([
experimentsApi.get(id),
animalsApi.list(id),
experimentsApi.getSubjectTemplate(id),
experimentsApi.getTemplate(id),
experimentsApi.getDailyStatuses(id),
]);
setExperiment(exp);
setAnimals(anims);
setSubjectTemplate(subjTmpl);
setDailyTemplate(dailyTmpl);
setExperimentStatuses(expStatuses);
} catch (err) {
setError(err.message);
} finally {
@@ -56,21 +123,6 @@ export default function ExperimentDetail() {
flash(editAnimal ? `Animal "${animal.animal_name}" updated.` : `Animal "${animal.animal_name}" added.`);
}
async function handleDeleteAnimal() {
setDeleteLoading(true);
try {
await animalsApi.delete(deleteAnimal.id);
setDeleteAnimal(null);
load();
flash('Animal removed.');
} catch (err) {
setError(err.message);
setDeleteAnimal(null);
} finally {
setDeleteLoading(false);
}
}
function handleTemplateSaved(updatedTemplate) {
setShowTemplate(false);
if (updatedTemplate) {
@@ -80,6 +132,49 @@ export default function ExperimentDetail() {
}
}
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 <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400" aria-live="polite" aria-busy="true">Loading</div>;
if (error) return (
<div className="max-w-5xl mx-auto px-4 py-8">
@@ -104,25 +199,18 @@ export default function ExperimentDetail() {
{animals.length} animal{animals.length !== 1 ? 's' : ''} enrolled
</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setShowTemplate(true)}>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
Record Template
</Button>
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
</div>
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
</div>
{successMsg && <Alert type="success" message={successMsg} />}
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{/* Template summary strip */}
{experiment.template && experiment.template.length > 0 && (
<div className="mb-5 flex flex-wrap gap-1.5">
{experiment.template.map((f) => (
{/* Template strips */}
<div className="mb-5 space-y-2">
{/* Daily record template */}
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide w-24 shrink-0">Daily record</span>
{(experiment.template ?? []).map((f) => (
<span
key={f.key}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${
@@ -135,12 +223,40 @@ export default function ExperimentDetail() {
))}
<button
onClick={() => setShowTemplate(true)}
className="text-xs text-gray-400 hover:text-blue-600 underline underline-offset-2 transition-colors"
className="inline-flex items-center gap-1 text-xs text-gray-400 hover:text-blue-600 transition-colors"
>
edit
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828A2 2 0 0110 16.414V19h2.586a2 2 0 001.414-.586l6.5-6.5" />
</svg>
{(experiment.template ?? []).length === 0 ? 'Set up' : 'Edit'}
</button>
</div>
)}
{/* Subject info template */}
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide w-24 shrink-0">Subject info</span>
{subjectTemplate.map((f) => (
<span
key={f.key}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${
f.active ? 'bg-emerald-50 border-emerald-200 text-emerald-700' : 'bg-gray-100 border-gray-200 text-gray-400'
}`}
>
{f.active ? null : <span title="hidden"></span>}
{f.label}
</span>
))}
<button
onClick={() => setShowSubjectTemplate(true)}
className="inline-flex items-center gap-1 text-xs text-gray-400 hover:text-emerald-600 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828A2 2 0 0110 16.414V19h2.586a2 2 0 001.414-.586l6.5-6.5" />
</svg>
{subjectTemplate.length === 0 ? 'Set up' : 'Edit'}
</button>
</div>
</div>
{animals.length === 0 ? (
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
@@ -148,37 +264,136 @@ export default function ExperimentDetail() {
<Button onClick={() => setShowAddAnimal(true)}>+ Add First Animal</Button>
</div>
) : (
<div className="grid gap-3">
{animals.map((animal) => (
<div
key={animal.id}
className="bg-white border border-gray-200 rounded-xl p-4 flex items-center justify-between hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer group"
onClick={() => navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
>
<div>
<div className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
{animal.animal_name}
</div>
<div className="text-sm text-gray-500">
ID: {animal.animal_id_string} · {animal._count?.daily_statuses ?? 0} daily status entries
</div>
</div>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<Button size="sm" variant="secondary" onClick={() => setEditAnimal(animal)}>Edit</Button>
<Button size="sm" variant="danger" onClick={() => setDeleteAnimal(animal)}>Remove</Button>
</div>
<>
{/* Display controls */}
<div className="flex items-center gap-3 mb-3 flex-wrap">
{/* Mode toggle */}
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
{[['list', 'List'], ['group', 'Group'], ['calendar', 'Calendar']].map(([mode, label]) => (
<button key={mode} type="button" onClick={() => updateDisplayMode(mode)}
className={`px-3 py-1 border-l border-gray-200 first:border-l-0 transition-colors
${displayMode === mode ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
{label}
</button>
))}
</div>
))}
</div>
{displayMode === 'list' && (
<>
<span className="text-xs text-gray-400">Sort by</span>
<select value={sortField} onChange={(e) => updateSort(e.target.value, sortDir)}
className="border border-gray-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
<option value="__name__">Name</option>
<option value="__id__">Subject ID</option>
{subjectTemplate.filter((f) => f.active).map((f) => (
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
))}
</select>
<button type="button" onClick={() => updateSort(sortField, sortDir === 'asc' ? 'desc' : 'asc')}
className="text-xs text-gray-500 hover:text-gray-800 border border-gray-200 rounded px-2 py-1 bg-white transition-colors">
{sortDir === 'asc' ? '↑ Asc' : '↓ Desc'}
</button>
</>
)}
{displayMode === 'group' && (
<>
<span className="text-xs text-gray-400">Group by</span>
<select value={groupField} onChange={(e) => updateGroupField(e.target.value)}
className="border border-gray-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
<option value="__name__">Name</option>
<option value="__id__">Subject ID</option>
{subjectTemplate.filter((f) => f.active).map((f) => (
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
))}
</select>
<span className="text-xs text-gray-400 ml-1">· sorted within by</span>
<select value={sortField} onChange={(e) => updateSort(e.target.value, sortDir)}
className="border border-gray-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
<option value="__name__">Name</option>
<option value="__id__">Subject ID</option>
{subjectTemplate.filter((f) => f.active).map((f) => (
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
))}
</select>
<button type="button" onClick={() => updateSort(sortField, sortDir === 'asc' ? 'desc' : 'asc')}
className="text-xs text-gray-500 hover:text-gray-800 border border-gray-200 rounded px-2 py-1 bg-white transition-colors">
{sortDir === 'asc' ? '↑ Asc' : '↓ Desc'}
</button>
</>
)}
</div>
{displayMode === 'list' && (
<AnimalCardList
animals={sortedAnimals}
subjectTemplate={subjectTemplate}
onNavigate={(animal) => 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 (
<div key={groupValue} className="mb-5">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">{groupLabel}</span>
<span className="text-xs text-gray-400">({groupAnimals.length})</span>
<div className="flex-1 border-t border-gray-100" />
</div>
<AnimalCardList
animals={groupAnimals}
subjectTemplate={subjectTemplate}
onNavigate={(animal) => navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
onEdit={setEditAnimal}
/>
</div>
);
})}
{displayMode === 'calendar' && (
<ExperimentCalendar
experimentId={id}
template={dailyTemplate}
allStatuses={experimentStatuses}
animals={animals}
/>
)}
</>
)}
<AuditLogSection tableName="animals" />
<ExperimentAnalysisCharts
animals={animals}
experimentStatuses={experimentStatuses}
subjectTemplate={subjectTemplate}
dailyTemplate={dailyTemplate}
/>
<AuditLogSection tableName="animals" limit={5} />
{/* Template editor modal */}
<Modal isOpen={showTemplate} onClose={() => setShowTemplate(false)} title="Daily Record Template" size="lg">
<Modal isOpen={showTemplate} onClose={() => setShowTemplate(false)} title="Daily Record Template" size="full">
<TemplateEditor experimentId={id} onClose={handleTemplateSaved} />
</Modal>
<Modal isOpen={showSubjectTemplate} onClose={() => setShowSubjectTemplate(false)} title="Subject Info Template" size="full">
<TemplateEditor
experimentId={id}
loadFn={() => experimentsApi.getSubjectTemplate(id)}
saveFn={(fields) => experimentsApi.updateSubjectTemplate(id, fields)}
onClose={(updated) => {
setShowSubjectTemplate(false);
if (updated) { setSubjectTemplate(updated); flash('Subject info template updated.'); }
}}
/>
</Modal>
<Modal isOpen={showAddAnimal} onClose={() => setShowAddAnimal(false)} title="Add Animal">
<AnimalForm experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setShowAddAnimal(false)} />
</Modal>
@@ -187,14 +402,6 @@ export default function ExperimentDetail() {
<AnimalForm existing={editAnimal} experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setEditAnimal(null)} />
</Modal>
<ConfirmDialog
isOpen={!!deleteAnimal}
onClose={() => setDeleteAnimal(null)}
onConfirm={handleDeleteAnimal}
loading={deleteLoading}
title="Remove Animal"
message={`Remove "${deleteAnimal?.animal_name}" and all its daily statuses from this experiment?`}
/>
</div>
);
}