Files
experiments-database/frontend/src/components/ExperimentCalendar.jsx
T
Experiments DB Dev 6ecc6dc19a test: document and test analysis_summary bar chart contract
5 tests cover the bar chart rules:
- Shows when analysis_summary.total > 0
- Hidden when no summary or total=0
- Multiple animals each with their own summary
- Animals excluded from day view (empty field) also excluded from chart
- animalsWithData now filters by selected field value (not just record presence)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:31:36 -04:00

260 lines
11 KiB
React

import React, { useState, useEffect, useMemo } from 'react';
import {
format, startOfMonth, endOfMonth, eachDayOfInterval,
getDay, addMonths, subMonths,
} from 'date-fns';
import {
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']);
function SubjectReadOnly({ status, template }) {
const activeFields = template.filter((f) => f.active);
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 [selectedField, setSelectedField] = useState(null);
const [calendarDays, setCalendarDays] = useState({});
const [loading, setLoading] = useState(false);
const [selectedDate, setSelectedDate] = useState(null);
const fieldOptions = useMemo(
() => template.filter((f) => f.active).map((f) => ({ value: f.builtin ? f.key : f.fieldId, label: f.label })),
[template],
);
useEffect(() => {
if (fieldOptions.length === 0 || selectedField) return;
const weightField = fieldOptions.find((f) => f.label.toLowerCase().includes('weight'));
setSelectedField(weightField ? weightField.value : fieldOptions[0]?.value ?? null);
}, [fieldOptions]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!selectedField) return;
const isBuiltin = BUILTIN_KEYS_SET.has(selectedField);
const days = {};
allStatuses.forEach((s) => {
const val = isBuiltin ? s[selectedField] : s.custom_fields?.[selectedField];
if (val !== null && val !== undefined && String(val).trim() !== '') {
const key = s.date.slice(0, 10);
days[key] = (days[key] || 0) + 1;
}
});
setCalendarDays(days);
}, [allStatuses, selectedField]);
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
const leadingPad = getDay(monthStart);
return (
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm mt-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">Daily Record Calendar</h3>
<div className="flex flex-wrap items-center gap-3 mb-5">
<span className="text-sm text-gray-600 font-medium">Count by field:</span>
<select
data-testid="field-select"
className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
value={selectedField ?? ''}
onChange={(e) => setSelectedField(e.target.value)}
disabled={fieldOptions.length === 0}
>
{fieldOptions.map((f) => <option key={f.value} value={f.value}>{f.label}</option>)}
{fieldOptions.length === 0 && <option value="">No fields configured</option>}
</select>
{loading && <span className="text-xs text-gray-400" aria-live="polite">Loading...</span>}
</div>
<div className="flex items-center justify-between mb-4">
<button aria-label="Previous month" onClick={() => setCurrentMonth((m) => subMonths(m, 1))}
className="p-2 rounded-lg hover:bg-gray-100 text-gray-500 text-lg leading-none">&#8249;</button>
<span className="font-semibold text-gray-900 text-base">{format(currentMonth, 'MMMM yyyy')}</span>
<button aria-label="Next month" onClick={() => setCurrentMonth((m) => addMonths(m, 1))}
className="p-2 rounded-lg hover:bg-gray-100 text-gray-500 text-lg leading-none">&#8250;</button>
</div>
<div className="grid grid-cols-7 mb-1">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
<div key={d} className="text-center text-xs font-medium text-gray-400 py-1 select-none">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: leadingPad }).map((_, i) => <div key={`pad-${i}`} />)}
{days.map((day) => {
const dateStr = format(day, 'yyyy-MM-dd');
const count = calendarDays[dateStr] ?? 0;
const hasData = count > 0;
return (
<button
key={dateStr}
data-testid={`day-${dateStr}`}
disabled={!hasData}
onClick={() => setSelectedDate(day)}
className={[
'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',
].join(' ')}
>
<span className={hasData ? 'font-semibold text-blue-900 text-xs' : 'text-xs'}>{day.getDate()}</span>
{hasData && (
<span data-testid={`count-${dateStr}`} className="text-[10px] leading-none font-bold text-blue-600 mt-0.5">{count}</span>
)}
</button>
);
})}
</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>
);
}