fix: move bar chart inside day modal instead of below calendar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -36,7 +36,7 @@ function SubjectReadOnly({ status, template }) {
|
||||
);
|
||||
}
|
||||
|
||||
function DayPanel({ date, animals, allStatuses, template, experimentId, onStatusChange, onStatusCreate }) {
|
||||
function DayPanel({ date, animals, allStatuses, template, experimentId, selectedField, fieldOptions, onStatusChange, onStatusCreate }) {
|
||||
const dateStr = format(date, 'yyyy-MM-dd');
|
||||
const [editingAnimalId, setEditingAnimalId] = useState(null);
|
||||
|
||||
@@ -48,8 +48,33 @@ function DayPanel({ date, animals, allStatuses, template, experimentId, onStatus
|
||||
return map;
|
||||
}, [allStatuses, dateStr]);
|
||||
|
||||
const barData = useMemo(() => {
|
||||
if (!selectedField) return [];
|
||||
const isBuiltin = BUILTIN_KEYS_SET.has(selectedField);
|
||||
return animals
|
||||
.map((animal) => {
|
||||
const statuses = allStatuses.filter((s) => s.animal_id === animal.id);
|
||||
const total = statuses.length;
|
||||
const success = statuses.filter((s) => {
|
||||
const val = isBuiltin ? s[selectedField] : s.custom_fields?.[selectedField];
|
||||
return val !== null && val !== undefined && String(val).trim() !== '';
|
||||
}).length;
|
||||
return {
|
||||
name: animal.animal_name || animal.animal_id_string || animal.id.slice(0, 8),
|
||||
total,
|
||||
success,
|
||||
failure: total - success,
|
||||
pct: total > 0 ? Math.round((success / total) * 100) : 0,
|
||||
};
|
||||
})
|
||||
.filter((d) => d.total > 0);
|
||||
}, [animals, allStatuses, selectedField]);
|
||||
|
||||
const fieldLabel = fieldOptions.find((f) => f.value === selectedField)?.label;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 max-h-[70vh] overflow-y-auto pr-1">
|
||||
<div className="space-y-3 max-h-[80vh] overflow-y-auto pr-1">
|
||||
{/* Per-animal data for this day */}
|
||||
{animals.map((animal) => {
|
||||
const existing = statusByAnimal[animal.id];
|
||||
const isEditing = editingAnimalId === animal.id;
|
||||
@@ -92,6 +117,35 @@ function DayPanel({ date, animals, allStatuses, template, experimentId, onStatus
|
||||
</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">
|
||||
Overall completeness by subject
|
||||
{fieldLabel && <span className="ml-2 font-normal text-gray-400">— {fieldLabel}</span>}
|
||||
</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 }} 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]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-2 flex flex-wrap gap-4">
|
||||
{barData.map((d) => (
|
||||
<span key={d.name} className="text-xs text-gray-500">
|
||||
<span className="font-medium text-gray-700">{d.name}</span>: {d.pct}% complete
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -133,28 +187,6 @@ export default function ExperimentCalendar({ experimentId, template, allStatuses
|
||||
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||
const leadingPad = getDay(monthStart);
|
||||
|
||||
const barData = useMemo(() => {
|
||||
if (!selectedField) return [];
|
||||
const isBuiltin = BUILTIN_KEYS_SET.has(selectedField);
|
||||
return animals
|
||||
.map((animal) => {
|
||||
const statuses = allStatuses.filter((s) => s.animal_id === animal.id);
|
||||
const total = statuses.length;
|
||||
const success = statuses.filter((s) => {
|
||||
const val = isBuiltin ? s[selectedField] : s.custom_fields?.[selectedField];
|
||||
return val !== null && val !== undefined && String(val).trim() !== '';
|
||||
}).length;
|
||||
return {
|
||||
name: animal.animal_name || animal.animal_id_string || animal.id.slice(0, 8),
|
||||
total,
|
||||
success,
|
||||
failure: total - success,
|
||||
pct: total > 0 ? Math.round((success / total) * 100) : 0,
|
||||
};
|
||||
})
|
||||
.filter((d) => d.total > 0);
|
||||
}, [animals, allStatuses, selectedField]);
|
||||
|
||||
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>
|
||||
@@ -214,38 +246,6 @@ export default function ExperimentCalendar({ experimentId, template, allStatuses
|
||||
})}
|
||||
</div>
|
||||
|
||||
{barData.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">
|
||||
Field completeness by subject
|
||||
{fieldOptions.find((f) => f.value === selectedField) && (
|
||||
<span className="ml-2 font-normal text-gray-400">
|
||||
— {fieldOptions.find((f) => f.value === selectedField).label}
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<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]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-2 flex flex-wrap gap-4">
|
||||
{barData.map((d) => (
|
||||
<span key={d.name} className="text-xs text-gray-500">
|
||||
<span className="font-medium text-gray-700">{d.name}</span>: {d.pct}% complete
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedDate && (
|
||||
<Modal isOpen={!!selectedDate} onClose={() => setSelectedDate(null)} title={format(selectedDate, 'MMMM d, yyyy')} size="lg">
|
||||
<DayPanel
|
||||
@@ -254,6 +254,8 @@ export default function ExperimentCalendar({ experimentId, template, allStatuses
|
||||
allStatuses={allStatuses}
|
||||
template={template}
|
||||
experimentId={experimentId}
|
||||
selectedField={selectedField}
|
||||
fieldOptions={fieldOptions}
|
||||
onStatusChange={(updated) => { onStatusChange?.(updated); setSelectedDate(null); }}
|
||||
onStatusCreate={(created) => { onStatusCreate?.(created); setSelectedDate(null); }}
|
||||
/>
|
||||
|
||||
@@ -200,7 +200,7 @@ describe('day click modal', () => {
|
||||
fireEvent.click(screen.getByTestId(`day-${dateStr}`));
|
||||
await waitFor(() => {
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(within(dialog).getByText('Rat A')).toBeInTheDocument();
|
||||
expect(within(dialog).getAllByText('Rat A').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -238,16 +238,16 @@ describe('day click modal', () => {
|
||||
fireEvent.click(screen.getByTestId(`day-${dateStr}`));
|
||||
await waitFor(() => {
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(within(dialog).getByText('Rat A')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('Rat B')).toBeInTheDocument(); // shown even without data
|
||||
expect(within(dialog).getAllByText('Rat A').length).toBeGreaterThan(0);
|
||||
expect(within(dialog).getAllByText('Rat B').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Bar chart ─────────────────────────────────────────────────────────────────
|
||||
// ── Bar chart (inside day modal) ──────────────────────────────────────────────
|
||||
|
||||
describe('bar chart', () => {
|
||||
it('renders bar chart when animals have statuses', () => {
|
||||
describe('bar chart inside day modal', () => {
|
||||
it('renders bar chart inside modal when animals have statuses', async () => {
|
||||
const dateStr = todayStr();
|
||||
const statuses = [
|
||||
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }),
|
||||
@@ -255,10 +255,25 @@ describe('bar chart', () => {
|
||||
render(
|
||||
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={ANIMALS} />,
|
||||
);
|
||||
expect(screen.getByTestId('bar-chart')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId(`day-${dateStr}`));
|
||||
await waitFor(() => {
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(within(dialog).getByTestId('bar-chart')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not render bar chart when no animals have statuses', () => {
|
||||
it('does not render bar chart in modal when no animals have overall statuses', async () => {
|
||||
const dateStr = todayStr();
|
||||
// Give Rat A a status today so the day is clickable, but use a different animal not in barData
|
||||
const statuses = [
|
||||
makeStatus('a1000000-0000-0000-0000-000000000001', dateStr, { 'ww000000-0000-0000-0000-000000000099': '325' }),
|
||||
];
|
||||
// Use animals list with no matching statuses for barData by filtering to empty
|
||||
render(
|
||||
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={statuses} animals={[]} />,
|
||||
);
|
||||
// No days enabled since animals=[] means no bar chart, but day is also not enabled
|
||||
// Just verify no bar chart on empty statuses/animals
|
||||
render(
|
||||
<ExperimentCalendar experimentId={EXP_ID} template={TEMPLATE} allStatuses={[]} animals={ANIMALS} />,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user