feat: replace calendar day modal with dedicated day-view page

Calendar day clicks now navigate to /experiments/:id/day/:date
(with ?field= param). ExperimentDayView shows a sticky-column table
of all subjects with data that day, inline edit via modal, and a
session-metrics bar chart from analysis_summary. Subject name links
to /daily-statuses/:id. Backend adds ?date= filtering to
GET /experiments/:id/daily-statuses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Experiments DB Dev
2026-04-23 12:46:35 -04:00
parent 6ecc6dc19a
commit c8f0c5f298
7 changed files with 332 additions and 380 deletions
+7 -1
View File
@@ -349,8 +349,14 @@ router.get('/:id/daily-statuses', async (req, res, next) => {
const exp = await prisma.experiment.findUnique({ where: { id: req.params.id } }); const exp = await prisma.experiment.findUnique({ where: { id: req.params.id } });
if (!exp) return res.status(404).json({ error: 'Experiment not found' }); if (!exp) return res.status(404).json({ error: 'Experiment not found' });
const where = { animal: { experiment_id: req.params.id } };
if (req.query.date) {
const d = new Date(req.query.date + 'T00:00:00.000Z');
const next = new Date(d); next.setUTCDate(next.getUTCDate() + 1);
where.date = { gte: d, lt: next };
}
const statuses = await prisma.dailyStatus.findMany({ const statuses = await prisma.dailyStatus.findMany({
where: { animal: { experiment_id: req.params.id } }, where,
select: { select: {
id: true, id: true,
animal_id: true, animal_id: true,
+2
View File
@@ -5,6 +5,7 @@ import Dashboard from './pages/Dashboard';
import ExperimentDetail from './pages/ExperimentDetail'; import ExperimentDetail from './pages/ExperimentDetail';
import AnimalDetail from './pages/AnimalDetail'; import AnimalDetail from './pages/AnimalDetail';
import DailyStatusDetail from './pages/DailyStatusDetail'; import DailyStatusDetail from './pages/DailyStatusDetail';
import ExperimentDayView from './pages/ExperimentDayView';
function Navbar() { function Navbar() {
return ( return (
@@ -57,6 +58,7 @@ export default function App() {
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/experiments/:id" element={<ExperimentDetail />} /> <Route path="/experiments/:id" element={<ExperimentDetail />} />
<Route path="/experiments/:id/day/:date" element={<ExperimentDayView />} />
<Route path="/animals/:id" element={<AnimalDetail />} /> <Route path="/animals/:id" element={<AnimalDetail />} />
<Route path="/daily-statuses/:id" element={<DailyStatusDetail />} /> <Route path="/daily-statuses/:id" element={<DailyStatusDetail />} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
+1
View File
@@ -34,6 +34,7 @@ export const experimentsApi = {
getSubjectTemplate: (id) => api.get(`/experiments/${id}/subject-template`).then((r) => r.data), getSubjectTemplate: (id) => api.get(`/experiments/${id}/subject-template`).then((r) => r.data),
updateSubjectTemplate: (id, template) => api.put(`/experiments/${id}/subject-template`, template).then((r) => r.data), updateSubjectTemplate: (id, template) => api.put(`/experiments/${id}/subject-template`, template).then((r) => r.data),
getDailyStatuses: (id) => api.get(`/experiments/${id}/daily-statuses`).then((r) => r.data), getDailyStatuses: (id) => api.get(`/experiments/${id}/daily-statuses`).then((r) => r.data),
getDayStatuses: (id, date) => api.get(`/experiments/${id}/daily-statuses`, { params: { date } }).then((r) => r.data),
getCalendar: (id, field) => api.get(`/experiments/${id}/calendar`, { params: { field } }).then((r) => r.data), getCalendar: (id, field) => api.get(`/experiments/${id}/calendar`, { params: { field } }).then((r) => r.data),
}; };
+4 -158
View File
@@ -3,152 +3,15 @@ import {
format, startOfMonth, endOfMonth, eachDayOfInterval, format, startOfMonth, endOfMonth, eachDayOfInterval,
getDay, addMonths, subMonths, getDay, addMonths, subMonths,
} from 'date-fns'; } from 'date-fns';
import { import { useNavigate } from 'react-router-dom';
ResponsiveContainer, BarChart, Bar, XAxis, YAxis,
CartesianGrid, Tooltip, Legend,
} from 'recharts';
import { experimentsApi } from '../api/client';
import Modal from './ui/Modal';
import DailyStatusForm from './DailyStatusForm';
const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']); const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
function SubjectReadOnly({ status, template }) { export default function ExperimentCalendar({ experimentId, template, allStatuses }) {
const activeFields = template.filter((f) => f.active); const navigate = useNavigate();
function getVal(s, f) {
if (f.builtin) return s[f.key];
return s.custom_fields?.[f.fieldId];
}
const nonEmpty = activeFields.filter((f) => {
const v = getVal(status, f);
return v !== null && v !== undefined && String(v).trim() !== '';
});
if (nonEmpty.length === 0) return <p className="text-xs text-gray-400">No field values recorded.</p>;
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1">
{nonEmpty.map((f) => (
<div key={f.fieldId ?? f.key} className="text-sm">
<span className="text-gray-500 font-medium">{f.label}:</span>{' '}
<span className="text-gray-900 whitespace-pre-wrap">{String(getVal(status, f))}</span>
</div>
))}
</div>
);
}
function DayPanel({ date, animals, allStatuses, template, experimentId, selectedField, fieldOptions, onStatusChange, onStatusCreate }) {
const dateStr = format(date, 'yyyy-MM-dd');
const [editingAnimalId, setEditingAnimalId] = useState(null);
const statusByAnimal = useMemo(() => {
const map = {};
allStatuses.forEach((s) => {
if (s.date.slice(0, 10) === dateStr) map[s.animal_id] = s;
});
return map;
}, [allStatuses, dateStr]);
// Only show animals that have a status record for this day WITH a non-empty selected field
const animalsWithData = animals.filter((a) => {
const s = statusByAnimal[a.id];
if (!s) return false;
if (!selectedField) return true;
const isBuiltin = BUILTIN_KEYS_SET.has(selectedField);
const val = isBuiltin ? s[selectedField] : s.custom_fields?.[selectedField];
return val !== null && val !== undefined && String(val).trim() !== '';
});
const fieldLabel = fieldOptions.find((f) => f.value === selectedField)?.label;
// Bar chart: animals with analysis_summary on this day → Success/Failure/Total from that summary
const barData = useMemo(() => {
return animalsWithData
.map((animal) => {
const status = statusByAnimal[animal.id];
const summary = status?.analysis_summary;
if (!summary || summary.total === 0) return null;
return {
name: animal.animal_name || animal.animal_id_string || animal.id.slice(0, 8),
total: summary.total,
success: summary.counts?.Success ?? 0,
failure: summary.counts?.Failure ?? 0,
};
})
.filter(Boolean);
}, [animalsWithData, statusByAnimal]);
return (
<div className="space-y-3 max-h-[80vh] overflow-y-auto pr-1">
{animalsWithData.length === 0 && (
<p className="text-gray-400 text-sm py-2">No data recorded for this day.</p>
)}
{/* Per-animal data for this day */}
{animalsWithData.map((animal) => {
const existing = statusByAnimal[animal.id];
const isEditing = editingAnimalId === animal.id;
return (
<div key={animal.id} className="bg-gray-50 rounded-xl p-4 border border-gray-100">
<div className="flex items-center justify-between mb-2">
<div className="font-semibold text-gray-900 text-sm">
{animal.animal_name ?? 'Unknown subject'}
{animal.animal_id_string && (
<span className="ml-2 text-gray-400 font-normal">· {animal.animal_id_string}</span>
)}
</div>
{!isEditing && (
<button
type="button"
onClick={() => setEditingAnimalId(animal.id)}
className="text-xs text-blue-600 hover:text-blue-800 border border-blue-200 rounded px-2 py-0.5 hover:bg-blue-50 transition-colors"
>
Edit
</button>
)}
</div>
{isEditing ? (
<DailyStatusForm
animalId={animal.id}
experimentId={experimentId}
template={template}
existing={{ ...existing, date: dateStr }}
onSuccess={(saved) => { onStatusChange(saved); setEditingAnimalId(null); }}
onCancel={() => setEditingAnimalId(null)}
/>
) : (
<SubjectReadOnly status={existing} template={template} />
)}
</div>
);
})}
{/* Bar chart: overall per-subject completeness */}
{barData.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-100">
<h4 className="text-sm font-semibold text-gray-700 mb-3">Session metrics</h4>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={barData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="total" name="Total" fill="#94a3b8" radius={[3, 3, 0, 0]} />
<Bar dataKey="success" name="Success" fill="#22c55e" radius={[3, 3, 0, 0]} />
<Bar dataKey="failure" name="Failure" fill="#f87171" radius={[3, 3, 0, 0]} minPointSize={2} />
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}
export default function ExperimentCalendar({ experimentId, template, allStatuses, animals, onStatusChange, onStatusCreate }) {
const [currentMonth, setCurrentMonth] = useState(() => new Date()); const [currentMonth, setCurrentMonth] = useState(() => new Date());
const [selectedField, setSelectedField] = useState(null); const [selectedField, setSelectedField] = useState(null);
const [calendarDays, setCalendarDays] = useState({}); const [calendarDays, setCalendarDays] = useState({});
const [loading, setLoading] = useState(false);
const [selectedDate, setSelectedDate] = useState(null);
const fieldOptions = useMemo( const fieldOptions = useMemo(
() => template.filter((f) => f.active).map((f) => ({ value: f.builtin ? f.key : f.fieldId, label: f.label })), () => template.filter((f) => f.active).map((f) => ({ value: f.builtin ? f.key : f.fieldId, label: f.label })),
@@ -196,7 +59,6 @@ export default function ExperimentCalendar({ experimentId, template, allStatuses
{fieldOptions.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)} {fieldOptions.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}
{fieldOptions.length === 0 && <option value="">No fields configured</option>} {fieldOptions.length === 0 && <option value="">No fields configured</option>}
</select> </select>
{loading && <span className="text-xs text-gray-400" aria-live="polite">Loading...</span>}
</div> </div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@@ -224,7 +86,7 @@ export default function ExperimentCalendar({ experimentId, template, allStatuses
key={dateStr} key={dateStr}
data-testid={`day-${dateStr}`} data-testid={`day-${dateStr}`}
disabled={!hasData} disabled={!hasData}
onClick={() => setSelectedDate(day)} onClick={() => navigate(`/experiments/${experimentId}/day/${dateStr}${selectedField ? `?field=${selectedField}` : ''}`)}
className={[ className={[
'relative flex flex-col items-center justify-center rounded-lg py-1.5 min-h-[2.75rem] text-sm select-none transition-all', 'relative flex flex-col items-center justify-center rounded-lg py-1.5 min-h-[2.75rem] text-sm select-none transition-all',
hasData ? 'bg-blue-50 border border-blue-200 hover:bg-blue-100 cursor-pointer' : 'text-gray-300 cursor-default', hasData ? 'bg-blue-50 border border-blue-200 hover:bg-blue-100 cursor-pointer' : 'text-gray-300 cursor-default',
@@ -238,22 +100,6 @@ export default function ExperimentCalendar({ experimentId, template, allStatuses
); );
})} })}
</div> </div>
{selectedDate && (
<Modal isOpen={!!selectedDate} onClose={() => setSelectedDate(null)} title={format(selectedDate, 'MMMM d, yyyy')} size="lg">
<DayPanel
date={selectedDate}
animals={animals}
allStatuses={allStatuses}
template={template}
experimentId={experimentId}
selectedField={selectedField}
fieldOptions={fieldOptions}
onStatusChange={(updated) => { onStatusChange?.(updated); setSelectedDate(null); }}
onStatusCreate={(created) => { onStatusCreate?.(created); setSelectedDate(null); }}
/>
</Modal>
)}
</div> </div>
); );
} }
+253
View File
@@ -0,0 +1,253 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom';
import { format } from 'date-fns';
import {
ResponsiveContainer, BarChart, Bar, XAxis, YAxis,
CartesianGrid, Tooltip, Legend,
} from 'recharts';
import { experimentsApi } from '../api/client';
import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
import DailyStatusForm from '../components/DailyStatusForm';
import Alert from '../components/ui/Alert';
const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
function getFieldValue(status, field) {
if (field.builtin) return status[field.key];
return status.custom_fields?.[field.fieldId];
}
function cellText(value) {
if (value === null || value === undefined || String(value).trim() === '') return null;
return String(value);
}
export default function ExperimentDayView() {
const { id, date } = useParams(); // id = experimentId, date = YYYY-MM-DD
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [experiment, setExperiment] = useState(null);
const [statuses, setStatuses] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [editStatus, setEditStatus] = useState(null);
useEffect(() => {
setLoading(true);
Promise.all([
experimentsApi.get(id),
experimentsApi.getDayStatuses(id, date),
])
.then(([exp, s]) => {
setExperiment(exp);
setStatuses(s);
})
.catch((err) => setError(err.message ?? 'Failed to load'))
.finally(() => setLoading(false));
}, [id, date]);
const dailyTemplate = useMemo(() => {
if (!experiment) return [];
const t = experiment.template ?? [];
return t.filter((f) => f.active);
}, [experiment]);
const animals = useMemo(() => experiment?.animals ?? [], [experiment]);
// Selected field from URL (passed by calendar). Used to filter which rows show.
const selectedField = searchParams.get('field') ?? null;
// Build map: animal_id → status for this day
const statusByAnimal = useMemo(() => {
const map = {};
statuses.forEach((s) => { map[s.animal_id] = s; });
return map;
}, [statuses]);
// Rows: animals with a valid (non-empty) value for the selected field, or all if no field filter
const rows = useMemo(() => {
return animals
.map((animal) => ({ animal, status: statusByAnimal[animal.id] }))
.filter(({ status }) => {
if (!status) return false;
if (!selectedField) return true;
const isBuiltin = BUILTIN_KEYS_SET.has(selectedField);
const val = isBuiltin ? status[selectedField] : status.custom_fields?.[selectedField];
return val !== null && val !== undefined && String(val).trim() !== '';
});
}, [animals, statusByAnimal, selectedField]);
// Bar chart: analysis_summary from each row's status (same contract as calendar modal)
const barData = useMemo(() => {
return rows
.map(({ animal, status }) => {
const summary = status?.analysis_summary;
if (!summary || summary.total === 0) return null;
return {
name: animal.animal_name || animal.animal_id_string || animal.id.slice(0, 8),
total: summary.total,
success: summary.counts?.Success ?? 0,
failure: summary.counts?.Failure ?? 0,
};
})
.filter(Boolean);
}, [rows]);
const displayDate = date
? format(new Date(date + 'T12:00:00'), 'MMMM d, yyyy')
: date;
if (loading) {
return (
<div className="max-w-5xl mx-auto px-4 py-10 text-center text-gray-400">Loading</div>
);
}
if (error) {
return (
<div className="max-w-5xl mx-auto px-4 py-10">
<Alert type="error" message={error} />
</div>
);
}
return (
<div className="max-w-6xl mx-auto px-4 py-6">
{/* Breadcrumb */}
<nav className="text-sm text-gray-400 mb-4 flex items-center gap-1.5">
<Link to="/" className="hover:text-blue-600 transition-colors">Dashboard</Link>
<span>/</span>
<Link to={`/experiments/${id}`} className="hover:text-blue-600 transition-colors">
{experiment?.title ?? id}
</Link>
<span>/</span>
<span className="text-gray-700 font-medium">{displayDate}</span>
</nav>
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-bold text-gray-900">{displayDate}</h1>
<Button variant="secondary" onClick={() => navigate(`/experiments/${id}`)}>
Back to experiment
</Button>
</div>
{/* Status table */}
{rows.length === 0 ? (
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl text-gray-400">
No data recorded for this day.
</div>
) : (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden mb-8">
<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-[140px]">
Subject
</th>
{dailyTemplate.map((f) => {
const header = f.abbr
? (f.unit ? `${f.abbr} (${f.unit})` : f.abbr)
: (f.unit ? `${f.label} (${f.unit})` : f.label);
return (
<th
key={f.fieldId}
title={f.unit ? `${f.label} (${f.unit})` : f.label}
className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap max-w-[220px]"
style={{ minWidth: Math.max(80, header.length * 7.5 + 24) }}
>
{header}
</th>
);
})}
<th className="sticky right-0 bg-gray-50 z-10 border-l border-gray-200 px-3 py-2 min-w-[80px]" />
</tr>
</thead>
<tbody>
{rows.map(({ animal, status }, i) => {
const rowBg = i % 2 === 0 ? 'bg-white' : 'bg-gray-50/40';
return (
<tr key={animal.id} className={`border-b border-gray-100 last:border-0 ${rowBg}`}>
{/* Subject name → daily status detail */}
<td
className={`px-3 py-2 font-medium whitespace-nowrap sticky left-0 z-10 border-r border-gray-100 ${rowBg} ${
status
? 'text-blue-600 cursor-pointer hover:text-blue-800 hover:bg-blue-50/40 transition-colors'
: 'text-gray-500'
}`}
onClick={() => status && navigate(`/daily-statuses/${status.id}`)}
>
{animal.animal_name ?? animal.animal_id_string ?? animal.id.slice(0, 8)}
{animal.animal_id_string && animal.animal_name && (
<span className="ml-1.5 text-xs text-gray-400 font-normal">
{animal.animal_id_string}
</span>
)}
</td>
{dailyTemplate.map((f) => (
<td key={f.fieldId} className="px-3 py-1.5 text-gray-700 max-w-[220px] align-top">
<span className="whitespace-pre-wrap break-words">
{status ? (cellText(getFieldValue(status, f)) ?? <span className="text-gray-300"></span>) : <span className="text-gray-300"></span>}
</span>
</td>
))}
<td className={`px-2 py-1.5 sticky right-0 z-10 border-l border-gray-100 ${rowBg}`}>
{status && (
<Button size="sm" variant="secondary" onClick={() => setEditStatus(status)}>
Edit
</Button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* Session metrics bar chart */}
{barData.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-5">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Session metrics</h3>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={barData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} />
<Tooltip />
<Legend wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="total" name="Total" fill="#94a3b8" radius={[3, 3, 0, 0]} />
<Bar dataKey="success" name="Success" fill="#22c55e" radius={[3, 3, 0, 0]} />
<Bar dataKey="failure" name="Failure" fill="#f87171" radius={[3, 3, 0, 0]} minPointSize={2} />
</BarChart>
</ResponsiveContainer>
</div>
)}
{/* Edit modal */}
{editStatus && (
<Modal
isOpen={!!editStatus}
onClose={() => setEditStatus(null)}
title="Edit daily status"
size="lg"
>
<DailyStatusForm
existing={editStatus}
animalId={editStatus.animal_id}
experimentId={id}
template={dailyTemplate}
onSuccess={(updated) => {
setStatuses((prev) => prev.map((s) => s.id === updated.id ? updated : s));
setEditStatus(null);
}}
onCancel={() => setEditStatus(null)}
/>
</Modal>
)}
</div>
);
}
-9
View File
@@ -372,15 +372,6 @@ export default function ExperimentDetail() {
experimentId={id} experimentId={id}
template={dailyTemplate} template={dailyTemplate}
allStatuses={experimentStatuses} allStatuses={experimentStatuses}
animals={animals}
onStatusChange={(updated) => {
setExperimentStatuses((prev) =>
prev.map((s) => (s.id === updated.id ? updated : s))
);
}}
onStatusCreate={(created) => {
setExperimentStatuses((prev) => [...prev, created]);
}}
/> />
)} )}
+65 -212
View File
@@ -1,36 +1,18 @@
import React from 'react'; import React from 'react';
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; import { render, screen, fireEvent } from '@testing-library/react';
import ExperimentCalendar from '../src/components/ExperimentCalendar'; import ExperimentCalendar from '../src/components/ExperimentCalendar';
import { experimentsApi } from '../src/api/client';
jest.mock('../src/api/client', () => ({ const mockNavigate = jest.fn();
experimentsApi: { jest.mock('react-router-dom', () => ({
getCalendar: jest.fn(), useNavigate: () => mockNavigate,
},
}));
jest.mock('recharts', () => ({
ResponsiveContainer: ({ children }) => <div>{children}</div>,
BarChart: ({ children }) => <div data-testid="bar-chart">{children}</div>,
Bar: () => null,
XAxis: () => null,
YAxis: () => null,
CartesianGrid: () => null,
Tooltip: () => null,
Legend: () => null,
})); }));
const EXP_ID = 'aaaaaaaa-0000-0000-0000-000000000001'; const EXP_ID = 'aaaaaaaa-0000-0000-0000-000000000001';
const TEMPLATE = [ const TEMPLATE = [
{ fieldId: '00000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', active: true, builtin: true }, { fieldId: '00000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', active: true, builtin: true },
{ fieldId: 'ww000000-0000-0000-0000-000000000099', key: 'weights', label: 'Weights', active: true, builtin: false }, { fieldId: 'ww000000-0000-0000-0000-000000000099', key: 'weights', label: 'Weights', active: true, builtin: false },
{ fieldId: 'zz000000-0000-0000-0000-000000000001', key: 'notes', label: 'Notes', active: false, builtin: true }, { fieldId: 'zz000000-0000-0000-0000-000000000001', key: 'notes', label: 'Notes', active: false, builtin: true },
];
const ANIMALS = [
{ id: 'a1000000-0000-0000-0000-000000000001', animal_name: 'Rat A', animal_id_string: 'R001' },
{ id: 'a2000000-0000-0000-0000-000000000002', animal_name: 'Rat B', animal_id_string: 'R002' },
]; ];
function todayStr() { function todayStr() {
@@ -38,7 +20,7 @@ function todayStr() {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
} }
function makeStatus(animalId, date, customFields = {}, vitals = null, analysisSummary = null) { function makeStatus(animalId, date, customFields = {}, vitals = null) {
return { return {
id: `s-${animalId}-${date}`, id: `s-${animalId}-${date}`,
animal_id: animalId, animal_id: animalId,
@@ -48,7 +30,7 @@ function makeStatus(animalId, date, customFields = {}, vitals = null, analysisSu
treatment: null, treatment: null,
notes: null, notes: null,
custom_fields: customFields, custom_fields: customFields,
analysis_summary: analysisSummary, analysis_summary: null,
}; };
} }
@@ -60,33 +42,25 @@ beforeEach(() => {
describe('ExperimentCalendar rendering', () => { describe('ExperimentCalendar rendering', () => {
it('renders the current month and year', () => { it('renders the current month and year', () => {
render( render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
const now = new Date(); const now = new Date();
const monthLabel = now.toLocaleString('en-US', { month: 'long', year: 'numeric' }); const monthLabel = now.toLocaleString('en-US', { month: 'long', year: 'numeric' });
expect(screen.getByText(monthLabel)).toBeInTheDocument(); expect(screen.getByText(monthLabel)).toBeInTheDocument();
}); });
it('renders day-of-week headers', () => { it('renders day-of-week headers', () => {
render( render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
for (const d of ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']) { for (const d of ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']) {
expect(screen.getByText(d)).toBeInTheDocument(); expect(screen.getByText(d)).toBeInTheDocument();
} }
}); });
it('renders the field selector with active template fields only', () => { it('renders the field selector with active template fields only', () => {
render( render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />, const options = screen.getAllByRole('option').map((o) => o.textContent);
); expect(options).toContain('Vitals');
expect(screen.getByTestId('field-select')).toBeInTheDocument(); expect(options).toContain('Weights');
const options = screen.getAllByRole('option'); expect(options).not.toContain('Notes'); // inactive
const labels = options.map((o) => o.textContent);
expect(labels).toContain('Vitals');
expect(labels).toContain('Weights');
expect(labels).not.toContain('Notes'); // inactive
}); });
}); });
@@ -94,9 +68,7 @@ describe('ExperimentCalendar rendering', () => {
describe('default field selection', () => { describe('default field selection', () => {
it('defaults to the field whose label contains "weight"', () => { it('defaults to the field whose label contains "weight"', () => {
render( render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
expect(screen.getByTestId('field-select')).toHaveValue('ww000000-0000-0000-0000-000000000099'); expect(screen.getByTestId('field-select')).toHaveValue('ww000000-0000-0000-0000-000000000099');
}); });
@@ -105,9 +77,7 @@ describe('default field selection', () => {
{ fieldId: 'ff000000-0000-0000-0000-000000000001', key: 'treatment', label: 'Treatment', active: true, builtin: true }, { fieldId: 'ff000000-0000-0000-0000-000000000001', key: 'treatment', label: 'Treatment', active: true, builtin: true },
{ fieldId: 'ff000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', active: true, builtin: true }, { fieldId: 'ff000000-0000-0000-0000-000000000002', key: 'vitals', label: 'Vitals', active: true, builtin: true },
]; ];
render( render(<ExperimentCalendar experimentId={EXP_ID} template={noWeightTemplate} allStatuses={[]} />);
<ExperimentCalendar experimentId={EXP_ID} template={noWeightTemplate} allStatuses={[]} animals={ANIMALS} />,
);
expect(screen.getByTestId('field-select')).toHaveValue('treatment'); expect(screen.getByTestId('field-select')).toHaveValue('treatment');
}); });
}); });
@@ -118,30 +88,33 @@ describe('count badges', () => {
it('shows a count badge for a day where the selected field is non-empty', () => { it('shows a count badge for a day where the selected field is non-empty', () => {
const dateStr = todayStr(); const dateStr = todayStr();
const statuses = [ const statuses = [
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }), makeStatus('a1', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }),
makeStatus('a2000000-0000-0000-0000-000000000002', dateStr, { 'ww000000-0000-0000-0000-000000000099': '310' }), makeStatus('a2', dateStr, { 'ww000000-0000-0000-0000-000000000099': '310' }),
]; ];
render( render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} />);
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />,
);
expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('2'); expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('2');
}); });
it('does not show count badge on days with no data', () => { it('does not show count badge on days with no data', () => {
const dateStr = todayStr(); render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
render( expect(screen.queryByTestId(`count-${todayStr()}`)).not.toBeInTheDocument();
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
expect(screen.queryByTestId(`count-${dateStr}`)).not.toBeInTheDocument();
}); });
it('shows a count badge for builtin field (vitals)', () => { it('shows a count badge for builtin field (vitals)', () => {
const dateStr = todayStr(); const dateStr = todayStr();
const statuses = [makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, {}, 'HR 72')]; const statuses = [makeStatus('a1', dateStr, {}, 'HR 72')];
render( render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} />);
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />, fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } });
); expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('1');
// Switch to vitals field });
it('recomputes counts after field change', () => {
const dateStr = todayStr();
const statuses = [makeStatus('a1', dateStr, {}, 'HR 72')];
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} />);
// Default is weights — no badge
expect(screen.queryByTestId(`count-${dateStr}`)).not.toBeInTheDocument();
// Switch to vitals — badge appears
fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } }); fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } });
expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('1'); expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('1');
}); });
@@ -151,9 +124,7 @@ describe('count badges', () => {
describe('month navigation', () => { describe('month navigation', () => {
it('moves to the previous month on click', () => { it('moves to the previous month on click', () => {
render( render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
const now = new Date(); const now = new Date();
const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1); const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const prevLabel = prev.toLocaleString('en-US', { month: 'long', year: 'numeric' }); const prevLabel = prev.toLocaleString('en-US', { month: 'long', year: 'numeric' });
@@ -162,172 +133,54 @@ describe('month navigation', () => {
}); });
it('moves to the next month on click', () => { it('moves to the next month on click', () => {
render( render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
const now = new Date(); const now = new Date();
const next = new Date(now.getFullYear(), now.getMonth() + 1, 1); const next = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const nextLabel = next.toLocaleString('en-US', { month: 'long', year: 'numeric' }); const nextLabel = next.toLocaleString('en-US', { month: 'long', year: 'numeric' });
fireEvent.click(screen.getByLabelText('Next month')); fireEvent.click(screen.getByLabelText('Next month'));
expect(screen.getByText(nextLabel)).toBeInTheDocument(); expect(screen.getByText(nextLabel)).toBeInTheDocument();
}); });
it('recomputes counts after field change', () => {
const dateStr = todayStr();
// Only has vitals, not weights
const statuses = [makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, {}, 'HR 72')];
render(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />,
);
// Default is weights — no badge
expect(screen.queryByTestId(`count-${dateStr}`)).not.toBeInTheDocument();
// Switch to vitals — badge appears
fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } });
expect(screen.getByTestId(`count-${dateStr}`)).toHaveTextContent('1');
});
}); });
// ── Day click → modal ───────────────────────────────────────────────────────── // ── Day click navigation ──────────────────────────────────────────────────────
describe('day click modal', () => { describe('day click navigation', () => {
it('opens a modal when a day with data is clicked', async () => { it('navigates to the day view with field param when a day with data is clicked', () => {
const dateStr = todayStr(); const dateStr = todayStr();
const statuses = [ const statuses = [
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }), makeStatus('a1', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }),
]; ];
render( render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} />);
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />,
);
fireEvent.click(screen.getByTestId(`day-${dateStr}`)); fireEvent.click(screen.getByTestId(`day-${dateStr}`));
await waitFor(() => { expect(mockNavigate).toHaveBeenCalledWith(
const dialog = screen.getByRole('dialog'); `/experiments/${EXP_ID}/day/${dateStr}?field=ww000000-0000-0000-0000-000000000099`,
expect(within(dialog).getAllByText('Rat A').length).toBeGreaterThan(0); );
});
}); });
it('shows subject value in read-only view after clicking day', async () => { it('does not navigate when clicking a day without data (button is disabled)', () => {
const dateStr = todayStr(); render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} />);
const statuses = [ const dayBtn = screen.getByTestId(`day-${todayStr()}`);
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }),
];
render(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />,
);
fireEvent.click(screen.getByTestId(`day-${dateStr}`));
await waitFor(() => expect(screen.getByText('325')).toBeInTheDocument());
});
it('does not open a modal when clicking a day without data', () => {
const dateStr = todayStr();
render(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
);
const dayBtn = screen.getByTestId(`day-${dateStr}`);
expect(dayBtn).toBeDisabled(); expect(dayBtn).toBeDisabled();
fireEvent.click(dayBtn); fireEvent.click(dayBtn);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(mockNavigate).not.toHaveBeenCalled();
}); });
it('shows only animals with data — excludes subjects with no record that day', async () => { it('includes the currently selected field in the navigation URL', () => {
const dateStr = todayStr(); const dateStr = todayStr();
// Only Rat A has a record; Rat B does not const statuses = [makeStatus('a1', dateStr, {}, 'HR 72')];
const statuses = [ render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} />);
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '320' }), // Switch to vitals field first
]; fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } });
render( fireEvent.click(screen.getByTestId(`day-${dateStr}`));
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />, expect(mockNavigate).toHaveBeenCalledWith(
`/experiments/${EXP_ID}/day/${dateStr}?field=vitals`,
); );
fireEvent.click(screen.getByTestId(`day-${dateStr}`)); });
await waitFor(() => {
const dialog = screen.getByRole('dialog'); it('navigates with no field param when selectedField is null (no active fields)', () => {
expect(within(dialog).getAllByText('Rat A').length).toBeGreaterThan(0); const emptyTemplate = [];
expect(within(dialog).queryAllByText('Rat B')).toHaveLength(0); render(<ExperimentCalendar experimentId={EXP_ID} template={emptyTemplate} allStatuses={[]} />);
}); // No days have data with empty template, so nothing to click — just verify no crash
}); expect(screen.queryByTestId(`count-${todayStr()}`)).not.toBeInTheDocument();
});
// ── Bar chart (inside day modal) ──────────────────────────────────────────────
//
// The bar chart reads from status.analysis_summary — a pre-computed field on
// each DailyStatus record containing trial-level session metrics:
// { total: number, counts: { Success: number, Failure: number }, success_rate: number }
//
// X-axis: animals that have a valid (non-empty) value for the chosen calendar
// field on THAT specific day. Each day can show a different set of animals.
// Bars: Total / Success / Failure from analysis_summary for that animal on that day.
//
// Chart is hidden when no animal on that day has analysis_summary.total > 0.
// Do NOT compute success/failure from the field value itself — always use analysis_summary.
describe('bar chart inside day modal', () => {
it('renders session metrics bar chart when at least one animal has analysis_summary', async () => {
const dateStr = todayStr();
const summary = { total: 18, counts: { Success: 5, Failure: 13 }, success_rate: 0.28 };
const statuses = [
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }, null, summary),
];
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />);
fireEvent.click(screen.getByTestId(`day-${dateStr}`));
await waitFor(() => {
expect(within(screen.getByRole('dialog')).getByTestId('bar-chart')).toBeInTheDocument();
});
});
it('hides bar chart when no animal has analysis_summary (null or total=0)', async () => {
const dateStr = todayStr();
const statuses = [
// Has field data (so day is clickable) but no analysis_summary
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }),
];
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />);
fireEvent.click(screen.getByTestId(`day-${dateStr}`));
await waitFor(() => screen.getByRole('dialog'));
expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument();
});
it('hides bar chart when analysis_summary exists but total is 0', async () => {
const dateStr = todayStr();
const emptySummary = { total: 0, counts: { Success: 0, Failure: 0 }, success_rate: null };
const statuses = [
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }, null, emptySummary),
];
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />);
fireEvent.click(screen.getByTestId(`day-${dateStr}`));
await waitFor(() => screen.getByRole('dialog'));
expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument();
});
it('shows bar chart for multiple animals each with their own analysis_summary', async () => {
const dateStr = todayStr();
const summaryA = { total: 18, counts: { Success: 5, Failure: 13 }, success_rate: 0.28 };
const summaryB = { total: 10, counts: { Success: 7, Failure: 3 }, success_rate: 0.7 };
const statuses = [
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }, null, summaryA),
makeStatus('a2000000-0000-0000-0000-000000000002', dateStr, { 'ww000000-0000-0000-0000-000000000099': '310' }, null, summaryB),
];
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />);
fireEvent.click(screen.getByTestId(`day-${dateStr}`));
await waitFor(() => {
expect(within(screen.getByRole('dialog')).getByTestId('bar-chart')).toBeInTheDocument();
});
});
it('excludes from chart animals whose field value is empty on that day, even with analysis_summary', async () => {
const dateStr = todayStr();
const summary = { total: 10, counts: { Success: 7, Failure: 3 }, success_rate: 0.7 };
const statuses = [
// Rat A: has weight AND analysis_summary → appears in chart
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }, null, summary),
// Rat B: has analysis_summary but NO weight → not in day view at all, not in chart
makeStatus('a2000000-0000-0000-0000-000000000002', dateStr, {}, null, summary),
];
render(<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />);
fireEvent.click(screen.getByTestId(`day-${dateStr}`));
await waitFor(() => screen.getByRole('dialog'));
// Chart shows because Rat A has data
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
// Rat B not shown in day view (no weight value)
const dialog = screen.getByRole('dialog');
expect(within(dialog).queryAllByText('Rat B')).toHaveLength(0);
}); });
}); });