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