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:
Experiments DB Dev
2026-04-23 12:46:35 -04:00
parent 6ecc6dc19a
commit c8f0c5f298
7 changed files with 332 additions and 380 deletions
+253
View File
@@ -0,0 +1,253 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom';
import { format } from 'date-fns';
import {
ResponsiveContainer, BarChart, Bar, XAxis, YAxis,
CartesianGrid, Tooltip, Legend,
} from 'recharts';
import { experimentsApi } from '../api/client';
import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
import DailyStatusForm from '../components/DailyStatusForm';
import Alert from '../components/ui/Alert';
const BUILTIN_KEYS_SET = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
function getFieldValue(status, field) {
if (field.builtin) return status[field.key];
return status.custom_fields?.[field.fieldId];
}
function cellText(value) {
if (value === null || value === undefined || String(value).trim() === '') return null;
return String(value);
}
export default function ExperimentDayView() {
const { id, date } = useParams(); // id = experimentId, date = YYYY-MM-DD
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [experiment, setExperiment] = useState(null);
const [statuses, setStatuses] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [editStatus, setEditStatus] = useState(null);
useEffect(() => {
setLoading(true);
Promise.all([
experimentsApi.get(id),
experimentsApi.getDayStatuses(id, date),
])
.then(([exp, s]) => {
setExperiment(exp);
setStatuses(s);
})
.catch((err) => setError(err.message ?? 'Failed to load'))
.finally(() => setLoading(false));
}, [id, date]);
const dailyTemplate = useMemo(() => {
if (!experiment) return [];
const t = experiment.template ?? [];
return t.filter((f) => f.active);
}, [experiment]);
const animals = useMemo(() => experiment?.animals ?? [], [experiment]);
// Selected field from URL (passed by calendar). Used to filter which rows show.
const selectedField = searchParams.get('field') ?? null;
// Build map: animal_id → status for this day
const statusByAnimal = useMemo(() => {
const map = {};
statuses.forEach((s) => { map[s.animal_id] = s; });
return map;
}, [statuses]);
// Rows: animals with a valid (non-empty) value for the selected field, or all if no field filter
const rows = useMemo(() => {
return animals
.map((animal) => ({ animal, status: statusByAnimal[animal.id] }))
.filter(({ status }) => {
if (!status) return false;
if (!selectedField) return true;
const isBuiltin = BUILTIN_KEYS_SET.has(selectedField);
const val = isBuiltin ? status[selectedField] : status.custom_fields?.[selectedField];
return val !== null && val !== undefined && String(val).trim() !== '';
});
}, [animals, statusByAnimal, selectedField]);
// Bar chart: analysis_summary from each row's status (same contract as calendar modal)
const barData = useMemo(() => {
return rows
.map(({ animal, status }) => {
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);
}, [rows]);
const displayDate = date
? format(new Date(date + 'T12:00:00'), 'MMMM d, yyyy')
: date;
if (loading) {
return (
<div className="max-w-5xl mx-auto px-4 py-10 text-center text-gray-400">Loading</div>
);
}
if (error) {
return (
<div className="max-w-5xl mx-auto px-4 py-10">
<Alert type="error" message={error} />
</div>
);
}
return (
<div className="max-w-6xl mx-auto px-4 py-6">
{/* Breadcrumb */}
<nav className="text-sm text-gray-400 mb-4 flex items-center gap-1.5">
<Link to="/" className="hover:text-blue-600 transition-colors">Dashboard</Link>
<span>/</span>
<Link to={`/experiments/${id}`} className="hover:text-blue-600 transition-colors">
{experiment?.title ?? id}
</Link>
<span>/</span>
<span className="text-gray-700 font-medium">{displayDate}</span>
</nav>
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-bold text-gray-900">{displayDate}</h1>
<Button variant="secondary" onClick={() => navigate(`/experiments/${id}`)}>
Back to experiment
</Button>
</div>
{/* Status table */}
{rows.length === 0 ? (
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl text-gray-400">
No data recorded for this day.
</div>
) : (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden mb-8">
<div className="overflow-x-auto">
<table className="text-sm border-collapse min-w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap sticky left-0 bg-gray-50 z-10 border-r border-gray-200 min-w-[140px]">
Subject
</th>
{dailyTemplate.map((f) => {
const header = f.abbr
? (f.unit ? `${f.abbr} (${f.unit})` : f.abbr)
: (f.unit ? `${f.label} (${f.unit})` : f.label);
return (
<th
key={f.fieldId}
title={f.unit ? `${f.label} (${f.unit})` : f.label}
className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap max-w-[220px]"
style={{ minWidth: Math.max(80, header.length * 7.5 + 24) }}
>
{header}
</th>
);
})}
<th className="sticky right-0 bg-gray-50 z-10 border-l border-gray-200 px-3 py-2 min-w-[80px]" />
</tr>
</thead>
<tbody>
{rows.map(({ animal, status }, i) => {
const rowBg = i % 2 === 0 ? 'bg-white' : 'bg-gray-50/40';
return (
<tr key={animal.id} className={`border-b border-gray-100 last:border-0 ${rowBg}`}>
{/* Subject name → daily status detail */}
<td
className={`px-3 py-2 font-medium whitespace-nowrap sticky left-0 z-10 border-r border-gray-100 ${rowBg} ${
status
? 'text-blue-600 cursor-pointer hover:text-blue-800 hover:bg-blue-50/40 transition-colors'
: 'text-gray-500'
}`}
onClick={() => status && navigate(`/daily-statuses/${status.id}`)}
>
{animal.animal_name ?? animal.animal_id_string ?? animal.id.slice(0, 8)}
{animal.animal_id_string && animal.animal_name && (
<span className="ml-1.5 text-xs text-gray-400 font-normal">
{animal.animal_id_string}
</span>
)}
</td>
{dailyTemplate.map((f) => (
<td key={f.fieldId} className="px-3 py-1.5 text-gray-700 max-w-[220px] align-top">
<span className="whitespace-pre-wrap break-words">
{status ? (cellText(getFieldValue(status, f)) ?? <span className="text-gray-300"></span>) : <span className="text-gray-300"></span>}
</span>
</td>
))}
<td className={`px-2 py-1.5 sticky right-0 z-10 border-l border-gray-100 ${rowBg}`}>
{status && (
<Button size="sm" variant="secondary" onClick={() => setEditStatus(status)}>
Edit
</Button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* Session metrics bar chart */}
{barData.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-5">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Session metrics</h3>
<ResponsiveContainer width="100%" height={220}>
<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 }} allowDecimals={false} />
<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>
)}
{/* Edit modal */}
{editStatus && (
<Modal
isOpen={!!editStatus}
onClose={() => setEditStatus(null)}
title="Edit daily status"
size="lg"
>
<DailyStatusForm
existing={editStatus}
animalId={editStatus.animal_id}
experimentId={id}
template={dailyTemplate}
onSuccess={(updated) => {
setStatuses((prev) => prev.map((s) => s.id === updated.id ? updated : s));
setEditStatus(null);
}}
onCancel={() => setEditStatus(null)}
/>
</Modal>
)}
</div>
);
}