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:
@@ -0,0 +1,225 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
format, startOfMonth, endOfMonth, eachDayOfInterval,
|
||||
getDay, addMonths, subMonths,
|
||||
} from 'date-fns';
|
||||
import { experimentsApi } from '../api/client';
|
||||
import Modal from './ui/Modal';
|
||||
|
||||
// ── Day detail panel rendered inside modal ─────────────────────────────────────
|
||||
|
||||
function DayDetail({ statuses, animals, template }) {
|
||||
const animalMap = useMemo(
|
||||
() => Object.fromEntries(animals.map((a) => [a.id, a])),
|
||||
[animals],
|
||||
);
|
||||
const activeFields = template.filter((f) => f.active);
|
||||
|
||||
function getVal(status, field) {
|
||||
if (field.builtin) return status[field.key];
|
||||
return status.custom_fields?.[field.fieldId];
|
||||
}
|
||||
|
||||
if (statuses.length === 0) {
|
||||
return <p className="text-gray-500 text-sm py-2">No data recorded for this day.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
|
||||
{statuses.map((status) => {
|
||||
const animal = animalMap[status.animal_id];
|
||||
const nonEmptyFields = activeFields.filter((f) => {
|
||||
const v = getVal(status, f);
|
||||
return v !== null && v !== undefined && String(v).trim() !== '';
|
||||
});
|
||||
return (
|
||||
<div key={status.id} className="bg-gray-50 rounded-xl p-4 border border-gray-100">
|
||||
<div className="font-semibold text-gray-900 mb-2 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>
|
||||
{nonEmptyFields.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">No field values recorded.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1">
|
||||
{nonEmptyFields.map((field) => (
|
||||
<div key={field.fieldId ?? field.key} className="text-sm">
|
||||
<span className="text-gray-500 font-medium">{field.label}:</span>{' '}
|
||||
<span className="text-gray-900 whitespace-pre-wrap">{String(getVal(status, field))}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main calendar component ────────────────────────────────────────────────────
|
||||
|
||||
export default function ExperimentCalendar({ experimentId, template, allStatuses, animals }) {
|
||||
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);
|
||||
|
||||
// Build field options from active template fields
|
||||
const fieldOptions = useMemo(
|
||||
() =>
|
||||
template
|
||||
.filter((f) => f.active)
|
||||
.map((f) => ({
|
||||
value: f.builtin ? f.key : f.fieldId,
|
||||
label: f.label,
|
||||
})),
|
||||
[template],
|
||||
);
|
||||
|
||||
// Default: first field whose label contains "weight", else first active field
|
||||
useEffect(() => {
|
||||
if (fieldOptions.length === 0 || selectedField) return;
|
||||
const weightField = fieldOptions.find((f) =>
|
||||
f.label.toLowerCase().includes('weight'),
|
||||
);
|
||||
setSelectedField(weightField ? weightField.value : fieldOptions[0].value);
|
||||
}, [fieldOptions]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Fetch calendar counts whenever field changes
|
||||
useEffect(() => {
|
||||
if (!selectedField) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
experimentsApi
|
||||
.getCalendar(experimentId, selectedField)
|
||||
.then((data) => { if (!cancelled) setCalendarDays(data.days ?? {}); })
|
||||
.catch(() => { if (!cancelled) setCalendarDays({}); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [experimentId, selectedField]);
|
||||
|
||||
// Build the grid: leading empty cells + each day of the month
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||
const leadingPad = getDay(monthStart); // 0 = Sunday
|
||||
|
||||
// Statuses filtered to the selected day
|
||||
const selectedDayStatuses = useMemo(() => {
|
||||
if (!selectedDate) return [];
|
||||
const key = format(selectedDate, 'yyyy-MM-dd');
|
||||
return allStatuses.filter((s) => s.date.slice(0, 10) === key);
|
||||
}, [selectedDate, allStatuses]);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
|
||||
{/* Controls */}
|
||||
<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>
|
||||
|
||||
{/* Month navigation */}
|
||||
<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"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<h3 className="font-semibold text-gray-900 text-base">
|
||||
{format(currentMonth, 'MMMM yyyy')}
|
||||
</h3>
|
||||
<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"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Day-of-week headers */}
|
||||
<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>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<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>
|
||||
|
||||
{/* Day detail modal */}
|
||||
{selectedDate && (
|
||||
<Modal
|
||||
isOpen={!!selectedDate}
|
||||
onClose={() => setSelectedDate(null)}
|
||||
title={format(selectedDate, 'MMMM d, yyyy')}
|
||||
size="lg"
|
||||
>
|
||||
<DayDetail
|
||||
statuses={selectedDayStatuses}
|
||||
animals={animals}
|
||||
template={template}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user