Files
experiments-database/frontend/src/pages/AnimalDetail.jsx
T
Experiments DB Dev 9535f86970 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>
2026-04-23 09:26:35 -04:00

558 lines
24 KiB
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';
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. */
function getFieldValue(status, field) {
if (field.builtin) return status[field.key];
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();
const location = useLocation();
const [animal, setAnimal] = useState(null);
const [statuses, setStatuses] = useState([]);
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);
const [showAddStatus, setShowAddStatus] = useState(false);
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);
setError(null);
try {
const [animalData, statusData] = await Promise.all([
animalsApi.get(id),
dailyStatusesApi.list(id),
]);
setAnimal(animalData);
setStatuses(statusData);
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 {
setLoading(false);
}
}
useEffect(() => { load(); }, [id]);
function flash(msg) {
setSuccessMsg(msg);
setTimeout(() => setSuccessMsg(null), 3000);
}
function handleStatusSaved() {
setShowAddStatus(false);
setEditStatus(null);
load();
flash(editStatus ? 'Daily status updated.' : 'Daily status logged.');
}
async function handleDeleteStatus() {
setDeleteLoading(true);
try {
await dailyStatusesApi.delete(deleteStatus.id);
setDeleteStatus(null);
setEditStatus(null);
load();
flash('Daily status removed.');
} catch (err) {
setError(err.message);
setDeleteStatus(null);
} finally {
setDeleteLoading(false);
}
}
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">
<Alert type="error" message={error} />
<Button variant="secondary" className="mt-4" onClick={() => navigate(-1)}> Back</Button>
</div>
);
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">
{/* 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>
<span className="text-gray-900 font-medium">{animal.animal_name}</span>
</nav>
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">{animal.animal_name}</h1>
<p className="text-sm text-gray-500 mt-0.5">
ID: <span className="font-mono">{animal.animal_id_string}</span>
{' · '}
{statuses.length} daily status {statuses.length !== 1 ? 'entries' : 'entry'}
</p>
</div>
<Button onClick={() => setShowAddStatus(true)}>+ Log Daily Status</Button>
</div>
{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="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>
)}
{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>
)}
<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>
{(() => {
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}
onClose={() => setDeleteStatus(null)}
onConfirm={handleDeleteStatus}
loading={deleteLoading}
title="Delete Daily Status"
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>
);
}