feat: replace calendar day modal with dedicated day-view page
Calendar day clicks now navigate to /experiments/:id/day/:date (with ?field= param). ExperimentDayView shows a sticky-column table of all subjects with data that day, inline edit via modal, and a session-metrics bar chart from analysis_summary. Subject name links to /daily-statuses/:id. Backend adds ?date= filtering to GET /experiments/:id/daily-statuses. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,152 +3,15 @@ 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';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
|
||||
|
||||
function SubjectReadOnly({ status, template }) {
|
||||
const activeFields = template.filter((f) => f.active);
|
||||
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 <p className="text-xs text-gray-400">No field values recorded.</p>;
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1">
|
||||
{nonEmpty.map((f) => (
|
||||
<div key={f.fieldId ?? f.key} className="text-sm">
|
||||
<span className="text-gray-500 font-medium">{f.label}:</span>{' '}
|
||||
<span className="text-gray-900 whitespace-pre-wrap">{String(getVal(status, f))}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DayPanel({ date, animals, allStatuses, template, experimentId, selectedField, fieldOptions, 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]);
|
||||
|
||||
// Only show animals that have a status record for this day WITH a non-empty selected field
|
||||
const animalsWithData = animals.filter((a) => {
|
||||
const s = statusByAnimal[a.id];
|
||||
if (!s) return false;
|
||||
if (!selectedField) return true;
|
||||
const isBuiltin = BUILTIN_KEYS_SET.has(selectedField);
|
||||
const val = isBuiltin ? s[selectedField] : s.custom_fields?.[selectedField];
|
||||
return val !== null && val !== undefined && String(val).trim() !== '';
|
||||
});
|
||||
|
||||
const fieldLabel = fieldOptions.find((f) => f.value === selectedField)?.label;
|
||||
|
||||
// Bar chart: animals with analysis_summary on this day → Success/Failure/Total from that summary
|
||||
const barData = useMemo(() => {
|
||||
return animalsWithData
|
||||
.map((animal) => {
|
||||
const status = statusByAnimal[animal.id];
|
||||
const summary = status?.analysis_summary;
|
||||
if (!summary || summary.total === 0) return null;
|
||||
return {
|
||||
name: animal.animal_name || animal.animal_id_string || animal.id.slice(0, 8),
|
||||
total: summary.total,
|
||||
success: summary.counts?.Success ?? 0,
|
||||
failure: summary.counts?.Failure ?? 0,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}, [animalsWithData, statusByAnimal]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3 max-h-[80vh] overflow-y-auto pr-1">
|
||||
{animalsWithData.length === 0 && (
|
||||
<p className="text-gray-400 text-sm py-2">No data recorded for this day.</p>
|
||||
)}
|
||||
{/* Per-animal data for this day */}
|
||||
{animalsWithData.map((animal) => {
|
||||
const existing = statusByAnimal[animal.id];
|
||||
const isEditing = editingAnimalId === animal.id;
|
||||
return (
|
||||
<div key={animal.id} className="bg-gray-50 rounded-xl p-4 border border-gray-100">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-semibold text-gray-900 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>
|
||||
{!isEditing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingAnimalId(animal.id)}
|
||||
className="text-xs text-blue-600 hover:text-blue-800 border border-blue-200 rounded px-2 py-0.5 hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<DailyStatusForm
|
||||
animalId={animal.id}
|
||||
experimentId={experimentId}
|
||||
template={template}
|
||||
existing={{ ...existing, date: dateStr }}
|
||||
onSuccess={(saved) => { onStatusChange(saved); setEditingAnimalId(null); }}
|
||||
onCancel={() => setEditingAnimalId(null)}
|
||||
/>
|
||||
) : (
|
||||
<SubjectReadOnly status={existing} template={template} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Bar chart: overall per-subject completeness */}
|
||||
{barData.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<h4 className="text-sm font-semibold text-gray-700 mb-3">Session metrics</h4>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={barData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Bar dataKey="total" name="Total" fill="#94a3b8" radius={[3, 3, 0, 0]} />
|
||||
<Bar dataKey="success" name="Success" fill="#22c55e" radius={[3, 3, 0, 0]} />
|
||||
<Bar dataKey="failure" name="Failure" fill="#f87171" radius={[3, 3, 0, 0]} minPointSize={2} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExperimentCalendar({ experimentId, template, allStatuses, animals, onStatusChange, onStatusCreate }) {
|
||||
export default function ExperimentCalendar({ experimentId, template, allStatuses }) {
|
||||
const navigate = useNavigate();
|
||||
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);
|
||||
|
||||
const fieldOptions = useMemo(
|
||||
() => template.filter((f) => f.active).map((f) => ({ value: f.builtin ? f.key : f.fieldId, label: f.label })),
|
||||
@@ -196,7 +59,6 @@ export default function ExperimentCalendar({ experimentId, template, allStatuses
|
||||
{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>
|
||||
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -224,7 +86,7 @@ export default function ExperimentCalendar({ experimentId, template, allStatuses
|
||||
key={dateStr}
|
||||
data-testid={`day-${dateStr}`}
|
||||
disabled={!hasData}
|
||||
onClick={() => setSelectedDate(day)}
|
||||
onClick={() => navigate(`/experiments/${experimentId}/day/${dateStr}${selectedField ? `?field=${selectedField}` : ''}`)}
|
||||
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',
|
||||
@@ -238,22 +100,6 @@ export default function ExperimentCalendar({ experimentId, template, allStatuses
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedDate && (
|
||||
<Modal isOpen={!!selectedDate} onClose={() => setSelectedDate(null)} title={format(selectedDate, 'MMMM d, yyyy')} size="lg">
|
||||
<DayPanel
|
||||
date={selectedDate}
|
||||
animals={animals}
|
||||
allStatuses={allStatuses}
|
||||
template={template}
|
||||
experimentId={experimentId}
|
||||
selectedField={selectedField}
|
||||
fieldOptions={fieldOptions}
|
||||
onStatusChange={(updated) => { onStatusChange?.(updated); setSelectedDate(null); }}
|
||||
onStatusCreate={(created) => { onStatusCreate?.(created); setSelectedDate(null); }}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user