diff --git a/frontend/src/components/ExperimentCalendar.jsx b/frontend/src/components/ExperimentCalendar.jsx index 33dbe2e..ea058e6 100644 --- a/frontend/src/components/ExperimentCalendar.jsx +++ b/frontend/src/components/ExperimentCalendar.jsx @@ -3,54 +3,91 @@ 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'; -// ── Day detail panel rendered inside modal ───────────────────────────────────── +const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']); -function DayDetail({ statuses, animals, template }) { - const animalMap = useMemo( - () => Object.fromEntries(animals.map((a) => [a.id, a])), - [animals], - ); +function SubjectReadOnly({ status, template }) { const activeFields = template.filter((f) => f.active); - - function getVal(status, field) { - if (field.builtin) return status[field.key]; - return status.custom_fields?.[field.fieldId]; + 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

No field values recorded.

; + return ( +
+ {nonEmpty.map((f) => ( +
+ {f.label}:{' '} + {String(getVal(status, f))} +
+ ))} +
+ ); +} - if (statuses.length === 0) { - return

No data recorded for this day.

; - } +function DayPanel({ date, animals, allStatuses, template, experimentId, 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]); return ( -
- {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() !== ''; - }); +
+ {animals.map((animal) => { + const existing = statusByAnimal[animal.id]; + const isEditing = editingAnimalId === animal.id; return ( -
-
- {animal?.animal_name ?? 'Unknown subject'} - {animal?.animal_id_string && ( - · {animal.animal_id_string} +
+
+
+ {animal.animal_name ?? 'Unknown subject'} + {animal.animal_id_string && ( + · {animal.animal_id_string} + )} +
+ {!isEditing && ( + )}
- {nonEmptyFields.length === 0 ? ( -

No field values recorded.

+ {isEditing ? ( + { + if (existing) { onStatusChange(saved); } else { onStatusCreate(saved); } + setEditingAnimalId(null); + }} + onCancel={() => setEditingAnimalId(null)} + /> + ) : existing ? ( + ) : ( -
- {nonEmptyFields.map((field) => ( -
- {field.label}:{' '} - {String(getVal(status, field))} -
- ))} -
+

No data recorded for this day.

)}
); @@ -59,65 +96,69 @@ function DayDetail({ statuses, animals, template }) { ); } -// ── Main calendar component ──────────────────────────────────────────────────── - -export default function ExperimentCalendar({ experimentId, template, allStatuses, animals }) { +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); - // 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.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); + 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 - // 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]); + 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]); - // 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 + const leadingPad = getDay(monthStart); - // 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]); + 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 ( -
- {/* Controls */} +
+

Daily Record Calendar

+
Count by field: - {loading && ( - Loading… - )} + {loading && Loading...}
- {/* Month navigation */}
- -

- {format(currentMonth, 'MMMM yyyy')} -

- + + {format(currentMonth, 'MMMM yyyy')} +
- {/* Day-of-week headers */}
- {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => ( -
- {d} -
+ {['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].map((d) => ( +
{d}
))}
- {/* Calendar grid */}
- {Array.from({ length: leadingPad }).map((_, i) => ( -
- ))} + {Array.from({ length: leadingPad }).map((_, i) =>
)} {days.map((day) => { const dateStr = format(day, 'yyyy-MM-dd'); const count = calendarDays[dateStr] ?? 0; @@ -184,39 +202,60 @@ export default function ExperimentCalendar({ experimentId, template, allStatuses 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', + hasData ? 'bg-blue-50 border border-blue-200 hover:bg-blue-100 cursor-pointer' : 'text-gray-300 cursor-default', ].join(' ')} > - - {day.getDate()} - + {day.getDate()} {hasData && ( - - {count} - + {count} )} ); })}
- {/* Day detail modal */} + {barData.length > 0 && ( +
+

+ Field completeness by subject + {fieldOptions.find((f) => f.value === selectedField) && ( + + — {fieldOptions.find((f) => f.value === selectedField).label} + + )} +

+ + + + + + + + + + + + +
+ {barData.map((d) => ( + + {d.name}: {d.pct}% complete + + ))} +
+
+ )} + {selectedDate && ( - setSelectedDate(null)} - title={format(selectedDate, 'MMMM d, yyyy')} - size="lg" - > - setSelectedDate(null)} title={format(selectedDate, 'MMMM d, yyyy')} size="lg"> + { onStatusChange?.(updated); setSelectedDate(null); }} + onStatusCreate={(created) => { onStatusCreate?.(created); setSelectedDate(null); }} /> )} diff --git a/frontend/src/pages/ExperimentDetail.jsx b/frontend/src/pages/ExperimentDetail.jsx index f429ae7..ecab307 100644 --- a/frontend/src/pages/ExperimentDetail.jsx +++ b/frontend/src/pages/ExperimentDetail.jsx @@ -269,7 +269,7 @@ export default function ExperimentDetail() {
{/* Mode toggle */}
- {[['list', 'List'], ['group', 'Group'], ['calendar', 'Calendar']].map(([mode, label]) => ( + {[['list', 'List'], ['group', 'Group']].map(([mode, label]) => (