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:
Experiments DB Dev
2026-04-23 09:26:35 -04:00
parent 09a853e28b
commit 9535f86970
34 changed files with 5460 additions and 340 deletions
@@ -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>
);
}