fix: day modal bar chart plots that session's field values per animal

X-axis: animal names, Y-axis: the selected field's numeric value recorded
that specific day. Animals with empty or non-numeric values are excluded.
Each day's chart is independent — different animals, different values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Experiments DB Dev
2026-04-23 12:24:16 -04:00
parent 7439a1a101
commit b888c1b76d
2 changed files with 32 additions and 29 deletions
+15 -23
View File
@@ -53,29 +53,25 @@ function DayPanel({ date, animals, allStatuses, template, experimentId, selected
const fieldLabel = fieldOptions.find((f) => f.value === selectedField)?.label; const fieldLabel = fieldOptions.find((f) => f.value === selectedField)?.label;
// Bar chart: session stats across ALL days, for animals with at least one valid field value // Bar chart: this day's field value per animal (numeric only, filtered to animals present that day)
const barData = useMemo(() => { const barData = useMemo(() => {
if (!selectedField) return []; if (!selectedField) return [];
const isBuiltin = BUILTIN_KEYS_SET.has(selectedField); const isBuiltin = BUILTIN_KEYS_SET.has(selectedField);
return animals return animalsWithData
.map((animal) => { .map((animal) => {
const statuses = allStatuses.filter((s) => s.animal_id === animal.id); const status = statusByAnimal[animal.id];
const total = statuses.length; if (!status) return null;
if (total === 0) return null; const raw = isBuiltin ? status[selectedField] : status.custom_fields?.[selectedField];
const success = statuses.filter((s) => { if (raw === null || raw === undefined || String(raw).trim() === '') return null;
const val = isBuiltin ? s[selectedField] : s.custom_fields?.[selectedField]; const num = parseFloat(raw);
return val !== null && val !== undefined && String(val).trim() !== ''; if (isNaN(num)) return null;
}).length;
if (success === 0) return null; // skip animals with no valid field data
return { return {
name: animal.animal_name || animal.animal_id_string || animal.id.slice(0, 8), name: animal.animal_name || animal.animal_id_string || animal.id.slice(0, 8),
total, value: num,
success,
failure: total - success,
}; };
}) })
.filter(Boolean); .filter(Boolean);
}, [animals, allStatuses, selectedField]); }, [animalsWithData, statusByAnimal, selectedField]);
return ( return (
<div className="space-y-3 max-h-[80vh] overflow-y-auto pr-1"> <div className="space-y-3 max-h-[80vh] overflow-y-auto pr-1">
@@ -124,20 +120,16 @@ function DayPanel({ date, animals, allStatuses, template, experimentId, selected
{/* Bar chart: overall per-subject completeness */} {/* Bar chart: overall per-subject completeness */}
{barData.length > 0 && ( {barData.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-100"> <div className="mt-4 pt-4 border-t border-gray-100">
<h4 className="text-sm font-semibold text-gray-700 mb-1"> <h4 className="text-sm font-semibold text-gray-700 mb-3">
Session statistics {fieldLabel ?? 'selected field'} {fieldLabel ?? 'Field'} this session
</h4> </h4>
<p className="text-xs text-gray-400 mb-3">All sessions across the experiment</p>
<ResponsiveContainer width="100%" height={200}> <ResponsiveContainer width="100%" height={200}>
<BarChart data={barData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}> <BarChart data={barData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" /> <CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} /> <XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} /> <YAxis tick={{ fontSize: 11 }} />
<Tooltip /> <Tooltip formatter={(v) => [v, fieldLabel ?? 'Value']} />
<Legend wrapperStyle={{ fontSize: 11 }} /> <Bar dataKey="value" name={fieldLabel ?? 'Value'} fill="#6366f1" radius={[3, 3, 0, 0]} />
<Bar dataKey="total" name="Total" fill="#94a3b8" />
<Bar dataKey="success" name="Success" fill="#22c55e" />
<Bar dataKey="failure" name="Failure" fill="#f87171" minPointSize={2} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
+17 -6
View File
@@ -248,7 +248,7 @@ describe('day click modal', () => {
// ── Bar chart (inside day modal) ────────────────────────────────────────────── // ── Bar chart (inside day modal) ──────────────────────────────────────────────
describe('bar chart inside day modal', () => { describe('bar chart inside day modal', () => {
it('renders session stats bar chart when animals have valid field data', async () => { it('renders bar chart with that day\'s numeric field values', async () => {
const dateStr = todayStr(); const dateStr = todayStr();
const statuses = [ const statuses = [
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }), makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }),
@@ -258,14 +258,13 @@ describe('bar chart inside day modal', () => {
); );
fireEvent.click(screen.getByTestId(`day-${dateStr}`)); fireEvent.click(screen.getByTestId(`day-${dateStr}`));
await waitFor(() => { await waitFor(() => {
const dialog = screen.getByRole('dialog'); expect(within(screen.getByRole('dialog')).getByTestId('bar-chart')).toBeInTheDocument();
expect(within(dialog).getByTestId('bar-chart')).toBeInTheDocument();
}); });
}); });
it('excludes animals with no valid field data from bar chart', async () => { it('omits animals with empty or non-numeric field value from bar chart', async () => {
const dateStr = todayStr(); const dateStr = todayStr();
// Rat A has weight data; Rat B has only empty custom_fields — should be excluded from chart // Rat A has numeric weight; Rat B has empty weight — Rat B excluded from chart but shown in day list
const statuses = [ const statuses = [
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }), makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }),
makeStatus('a2000000-0000-0000-0000-000000000002', dateStr, {}), makeStatus('a2000000-0000-0000-0000-000000000002', dateStr, {}),
@@ -275,7 +274,19 @@ describe('bar chart inside day modal', () => {
); );
fireEvent.click(screen.getByTestId(`day-${dateStr}`)); fireEvent.click(screen.getByTestId(`day-${dateStr}`));
await waitFor(() => screen.getByRole('dialog')); await waitFor(() => screen.getByRole('dialog'));
// Both animals are in the day view (both have records), but bar chart only has Rat A
expect(screen.getByTestId('bar-chart')).toBeInTheDocument(); expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
}); });
it('hides bar chart when no animals have a numeric value for the selected field that day', async () => {
const dateStr = todayStr();
// vitals is text — no bar chart
const statuses = [makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, {}, 'HR 72')];
render(
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />,
);
fireEvent.change(screen.getByTestId('field-select'), { target: { value: 'vitals' } });
fireEvent.click(screen.getByTestId(`day-${dateStr}`));
await waitFor(() => screen.getByRole('dialog'));
expect(screen.queryByTestId('bar-chart')).not.toBeInTheDocument();
});
}); });