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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -372,15 +372,6 @@ export default function ExperimentDetail() {
|
||||
experimentId={id}
|
||||
template={dailyTemplate}
|
||||
allStatuses={experimentStatuses}
|
||||
animals={animals}
|
||||
onStatusChange={(updated) => {
|
||||
setExperimentStatuses((prev) =>
|
||||
prev.map((s) => (s.id === updated.id ? updated : s))
|
||||
);
|
||||
}}
|
||||
onStatusCreate={(created) => {
|
||||
setExperimentStatuses((prev) => [...prev, created]);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user