9535f86970
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>
147 lines
5.7 KiB
React
147 lines
5.7 KiB
React
import React, { useState, useEffect } from 'react';
|
|
import Button from './ui/Button';
|
|
import { Input, Textarea } from './ui/FormField';
|
|
|
|
function widthToSpan(pct) {
|
|
if (pct <= 8) return 1;
|
|
if (pct <= 17) return 2;
|
|
if (pct <= 25) return 3;
|
|
if (pct <= 33) return 4;
|
|
if (pct <= 42) return 5;
|
|
if (pct <= 50) return 6;
|
|
if (pct <= 67) return 8;
|
|
if (pct <= 75) return 9;
|
|
return 12;
|
|
}
|
|
import Alert from './ui/Alert';
|
|
import { animalsApi, experimentsApi } from '../api/client';
|
|
|
|
export default function SubjectInfoForm({ animal, subjectTemplate = [], experimentId, onTemplateUpdate, onSaved, onCancel }) {
|
|
const activeFields = subjectTemplate.filter((f) => f.active);
|
|
|
|
function buildInitialValues() {
|
|
const vals = {};
|
|
for (const f of activeFields) {
|
|
vals[f.key] = animal?.subject_info?.[f.fieldId] ?? '';
|
|
}
|
|
return vals;
|
|
}
|
|
|
|
const [values, setValues] = useState(buildInitialValues);
|
|
const [apiError, setApiError] = useState(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [savingTagFor, setSavingTagFor] = useState(null);
|
|
|
|
useEffect(() => {
|
|
setValues(buildInitialValues());
|
|
setApiError(null);
|
|
}, [animal, subjectTemplate]);
|
|
|
|
const set = (key) => (e) => setValues((v) => ({ ...v, [key]: e.target.value }));
|
|
|
|
async function saveTag(field) {
|
|
const value = (values[field.key] ?? '').trim();
|
|
if (!value || !experimentId || !onTemplateUpdate) return;
|
|
setSavingTagFor(field.fieldId);
|
|
try {
|
|
const updatedTemplate = subjectTemplate.map((f) =>
|
|
f.fieldId === field.fieldId
|
|
? { ...f, tags: [...(f.tags ?? []), value] }
|
|
: f
|
|
);
|
|
await experimentsApi.updateSubjectTemplate(experimentId, updatedTemplate);
|
|
onTemplateUpdate(updatedTemplate);
|
|
} catch (_) {
|
|
} finally {
|
|
setSavingTagFor(null);
|
|
}
|
|
}
|
|
|
|
async function handleSubmit(e) {
|
|
e.preventDefault();
|
|
setLoading(true);
|
|
setApiError(null);
|
|
try {
|
|
// Preserve any fields not in the current active template
|
|
const existing = animal?.subject_info ?? {};
|
|
const updates = {};
|
|
for (const f of activeFields) {
|
|
updates[f.fieldId] = values[f.key] || null;
|
|
}
|
|
const merged = { ...existing, ...updates };
|
|
|
|
const updated = await animalsApi.update(animal.id, { subject_info: merged });
|
|
onSaved(updated);
|
|
} catch (err) {
|
|
setApiError(err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
if (activeFields.length === 0) {
|
|
return (
|
|
<div className="text-center py-6">
|
|
<p className="text-sm text-gray-400 italic">No subject info fields configured for this experiment.</p>
|
|
<p className="text-xs text-gray-400 mt-1">Use "Subject Template" on the experiment page to add fields.</p>
|
|
<div className="flex justify-end mt-4">
|
|
<Button variant="secondary" type="button" onClick={onCancel}>Close</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} noValidate>
|
|
{apiError && (
|
|
<Alert type="error" message={apiError.message} details={apiError.details} onDismiss={() => setApiError(null)} />
|
|
)}
|
|
|
|
<div className="grid grid-cols-12 gap-x-3 gap-y-2 mb-4">
|
|
{activeFields.map((field) => {
|
|
const isTextarea = field.type === 'textarea';
|
|
const span = isTextarea ? 12 : widthToSpan(field.width ?? 100);
|
|
const fieldTags = field.tags ?? [];
|
|
const currentValue = (values[field.key] ?? '').trim();
|
|
const canSaveTag = experimentId && onTemplateUpdate && currentValue && !fieldTags.includes(currentValue) && !field.tagsDisabled;
|
|
return (
|
|
<div key={field.fieldId} className={`col-span-${span} flex items-start gap-2`}>
|
|
<label className="text-xs font-medium text-gray-500 whitespace-nowrap w-28 shrink-0 text-right pt-1.5">{field.label}</label>
|
|
<div className="flex-1 min-w-0">
|
|
{isTextarea ? (
|
|
<Textarea name={field.key} value={values[field.key] ?? ''} onChange={set(field.key)} disabled={loading} />
|
|
) : (
|
|
<Input name={field.key} value={values[field.key] ?? ''} onChange={set(field.key)} disabled={loading} />
|
|
)}
|
|
{(fieldTags.length > 0 || canSaveTag) && (
|
|
<div className="flex flex-wrap items-center gap-1.5 mt-1">
|
|
{fieldTags.map((tag) => (
|
|
<button key={tag} type="button" title={`Quick-fill: ${tag}`}
|
|
onClick={() => setValues((v) => ({ ...v, [field.key]: tag }))}
|
|
className="inline-flex items-center px-2 py-0.5 rounded-full bg-indigo-50 border border-indigo-200 text-indigo-700 text-xs font-medium hover:bg-indigo-100 transition-colors">
|
|
{tag}
|
|
</button>
|
|
))}
|
|
{canSaveTag && (
|
|
<button type="button" onClick={() => saveTag(field)} disabled={savingTagFor === field.fieldId}
|
|
title="Save current value as a quick-fill tag"
|
|
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-dashed border-indigo-300 text-indigo-500 text-xs hover:border-indigo-500 hover:text-indigo-700 transition-colors disabled:opacity-50">
|
|
{savingTagFor === field.fieldId ? '…' : '+ Save as tag'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
|
|
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>Cancel</Button>
|
|
<Button type="submit" loading={loading}>Save</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|