- {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]) => (