import React, { useEffect, useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { format } from 'date-fns';
import { dailyStatusesApi, experimentsApi, analysesApi } from '../api/client';
import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import DailyStatusForm from '../components/DailyStatusForm';
import Alert from '../components/ui/Alert';
import CsvAnalysis from '../components/CsvAnalysis';
import AuditLogSection from '../components/AuditLogSection';
import RunSequenceView from '../components/RunSequenceView';
import FileDropZone from '../components/FileDropZone';
function fmtDate(raw) {
return format(new Date(String(raw).slice(0, 10) + 'T12:00:00'), 'MMMM d, yyyy');
}
function fmtDateTime(raw) {
return format(new Date(raw), 'MMM d, yyyy · h:mm a');
}
function getFieldValue(status, field) {
if (field.builtin) return status[field.key];
return status.custom_fields?.[field.fieldId];
}
// ── Analysis summary card ─────────────────────────────────────────────────────
function AnalysisSummaryCard({ summary, statusId, onSummaryUpdated }) {
const { counts = {}, total, success_rate, computed_at } = summary;
const COLORS = {
Success: 'bg-green-100 text-green-800',
Failure: 'bg-red-100 text-red-800',
Other: 'bg-gray-100 text-gray-700',
};
const EXTRA = ['bg-indigo-100 text-indigo-800', 'bg-yellow-100 text-yellow-800', 'bg-purple-100 text-purple-800', 'bg-pink-100 text-pink-800'];
const catList = Object.entries(counts).sort(([, a], [, b]) => b - a);
let extraIdx = 0;
const [showForm, setShowForm] = useState(false);
return (
Session metrics
{computed_at &&
Updated {fmtDateTime(computed_at)}
}
{!showForm && (
setShowForm(true)}
className="text-[10px] text-gray-400 hover:text-indigo-600 border border-dashed border-gray-200 hover:border-indigo-300 rounded px-2 py-0.5 transition-colors"
>
Edit manually
)}
{showForm ? (
{
onSummaryUpdated({ ...updated, computed_at: new Date().toISOString() });
setShowForm(false);
}}
onClose={() => setShowForm(false)}
/>
) : (
{catList.map(([cat, n]) => {
const cls = COLORS[cat] ?? EXTRA[extraIdx++ % EXTRA.length];
return (
{cat}
{n}
);
})}
Total
{total}
{success_rate != null && (
Success rate
{(success_rate * 100).toFixed(1)}%
)}
)}
);
}
// ── Manual metrics form ───────────────────────────────────────────────────────
const DEFAULT_CATS = ['Success', 'Failure', 'Other'];
function ManualMetricsForm({ statusId, existing, onSaved, onClose }) {
const initCounts = () => {
const base = { ...existing?.counts };
for (const c of DEFAULT_CATS) if (!(c in base)) base[c] = 0;
return base;
};
const [counts, setCounts] = useState(initCounts);
const [newCat, setNewCat] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const total = Object.values(counts).reduce((a, b) => a + (Number(b) || 0), 0);
const successRate = total > 0 && counts['Success'] != null
? (Number(counts['Success']) || 0) / total
: null;
function setCount(cat, raw) {
const v = Math.max(0, parseInt(raw, 10) || 0);
setCounts((prev) => ({ ...prev, [cat]: v }));
}
function addCat() {
const name = newCat.trim();
if (!name || name in counts) return;
setCounts((prev) => ({ ...prev, [name]: 0 }));
setNewCat('');
}
function removeCat(cat) {
if (DEFAULT_CATS.includes(cat)) return;
setCounts((prev) => { const n = { ...prev }; delete n[cat]; return n; });
}
async function save() {
setSaving(true);
setError(null);
try {
const finalCounts = Object.fromEntries(
Object.entries(counts).map(([k, v]) => [k, Number(v) || 0])
);
await dailyStatusesApi.updateAnalysisSummary(statusId, {
counts: finalCounts,
total,
success_rate: successRate,
source_analysis_id: null,
});
onSaved({ counts: finalCounts, total, success_rate: successRate });
} catch (err) {
setError(err.message ?? 'Failed to save');
} finally {
setSaving(false);
}
}
return (
Set metrics manually
{Object.entries(counts).map(([cat, val]) => (
{cat}
setCount(cat, e.target.value)}
className="w-20 text-center border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
/>
{!DEFAULT_CATS.includes(cat) && (
removeCat(cat)}
className="text-[10px] text-red-400 hover:text-red-600">remove
)}
))}
{/* Add custom category inline */}
Total: {total}
{successRate != null && (
Success rate: {(successRate * 100).toFixed(1)}%
)}
{error &&
{error}
}
{saving ? 'Saving…' : 'Save'}
Cancel
);
}
// ── Past analysis row (expandable) ────────────────────────────────────────────
function AnalysisRow({ analysis, onDelete }) {
const [expanded, setExpanded] = useState(false);
const [full, setFull] = useState(null);
const [loadingFull, setLoadingFull] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
async function expand() {
if (expanded) { setExpanded(false); return; }
setExpanded(true);
if (full) return;
setLoadingFull(true);
try {
const data = await analysesApi.get(analysis.id);
setFull(data);
} finally {
setLoadingFull(false);
}
}
async function handleDelete() {
setDeleting(true);
try {
await analysesApi.delete(analysis.id);
onDelete(analysis.id);
} finally {
setDeleting(false);
}
}
const counts = analysis.category_counts ?? {};
const summary = Object.entries(counts)
.sort(([, a], [, b]) => b.total - a.total)
.map(([cat, d]) => `${cat}: ${d.total}`)
.join(' · ');
return (
{expanded ? '▾' : '▸'}
{analysis.file_name ?? 'Unnamed file'}
{fmtDateTime(analysis.created_at)}
{' · '}
{analysis.note_col}
{' · '}
{analysis.total_note_rows} note rows
{summary}
{ e.stopPropagation(); setConfirmDelete(true); }}
className="text-xs text-red-400 hover:text-red-600 transition-colors">Delete
{expanded && (
{loadingFull &&
Loading…
}
{full &&
}
)}
setConfirmDelete(false)}
onConfirm={handleDelete}
loading={deleting}
title="Delete Analysis"
message="Remove this analysis record? The raw CSV is not affected."
/>
);
}
// ── Analysis results display (reused by both live and past) ───────────────────
export function AnalysisResultsView({ analysis }) {
const { timestamp_col, note_col, total_note_rows,
category_counts, consecutive_stats, runs } = analysis;
const customCategories = [];
function colorsFor(cat) {
const COLORS = {
Success: { bg: 'bg-green-100', text: 'text-green-800', dot: 'bg-green-500', border: 'border-green-300' },
Failure: { bg: 'bg-red-100', text: 'text-red-800', dot: 'bg-red-500', border: 'border-red-300' },
Other: { bg: 'bg-gray-100', text: 'text-gray-700', dot: 'bg-gray-400', border: 'border-gray-300' },
};
const EXTRA = [
{ bg: 'bg-indigo-100', text: 'text-indigo-800', dot: 'bg-indigo-500', border: 'border-indigo-300' },
{ bg: 'bg-yellow-100', text: 'text-yellow-800', dot: 'bg-yellow-500', border: 'border-yellow-300' },
{ bg: 'bg-purple-100', text: 'text-purple-800', dot: 'bg-purple-500', border: 'border-purple-300' },
];
if (COLORS[cat]) return COLORS[cat];
const idx = customCategories.indexOf(cat) % EXTRA.length;
return EXTRA[Math.max(0, idx)];
}
return (
{total_note_rows} note rows · timestamp: {timestamp_col}
{' '}/ note: {note_col}
{/* Category counts */}
Category Counts
Category
Breakdown
Total
{Object.entries(category_counts ?? {})
.sort(([, a], [, b]) => b.total - a.total)
.map(([cat, data]) => {
const c = colorsFor(cat);
const bd = Object.entries(data.byNote ?? {})
.sort(([, a], [, b]) => b - a)
.map(([n, cnt]) => `${n}: ${cnt}`).join(' · ');
return (
{cat}
{bd}
{data.total}
);
})}
{/* Consecutive stats */}
{Object.keys(consecutive_stats ?? {}).length > 0 && (
Consecutive Runs
Category
Runs
Max
Avg
Lengths
{Object.entries(consecutive_stats).map(([cat, s]) => {
const c = colorsFor(cat);
const summary = Object.entries(
s.runLengths.reduce((acc, l) => { acc[l] = (acc[l] || 0) + 1; return acc; }, {})
).sort(([a], [b]) => Number(a) - Number(b))
.map(([l, cnt]) => `${l}×${cnt}`).join(', ');
return (
{cat}
{s.totalRuns}
{s.maxRun}
{s.avgRun.toFixed(2)}
{summary}
);
})}
)}
);
}
// ── Past analyses inline viewer ───────────────────────────────────────────────
function PastAnalysesViewer({ analyses, onDelete, onNewAnalysis }) {
const [selectedId, setSelectedId] = useState(() => analyses[0]?.id ?? null);
const [cache, setCache] = useState({});
const [loadingId, setLoadingId] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null);
const [deleting, setDeleting] = useState(false);
// Load full data whenever the selected id changes
useEffect(() => {
if (!selectedId || cache[selectedId]) return;
setLoadingId(selectedId);
analysesApi.get(selectedId)
.then((data) => setCache((prev) => ({ ...prev, [selectedId]: data })))
.catch(() => {})
.finally(() => setLoadingId(null));
}, [selectedId]);
// If the list changes (new analysis added / deleted), keep selection valid
useEffect(() => {
if (!analyses.length) { setSelectedId(null); return; }
if (!analyses.find((a) => a.id === selectedId)) setSelectedId(analyses[0].id);
}, [analyses]);
async function handleDelete(id) {
setDeleting(true);
try {
await analysesApi.delete(id);
setCache((prev) => { const n = { ...prev }; delete n[id]; return n; });
onDelete(id);
} finally {
setDeleting(false);
setConfirmDelete(null);
}
}
if (!analyses.length) return null;
const selected = analyses.find((a) => a.id === selectedId) ?? null;
const fullData = selectedId ? (cache[selectedId] ?? null) : null;
const isLoading = loadingId === selectedId;
const categorySummary = (a) =>
Object.entries(a.category_counts ?? {})
.sort(([, x], [, y]) => y.total - x.total)
.map(([cat, d]) => `${cat}: ${d.total}`)
.join(' · ');
return (
{/* Header */}
Past Analyses ({analyses.length})
{/* Tab selector — scrollable if many */}
{analyses.length > 1 && (
{analyses.map((a) => {
const active = a.id === selectedId;
return (
setSelectedId(a.id)}
className={`shrink-0 flex flex-col items-start px-3 py-2 rounded-lg border text-left transition-colors
${active
? 'bg-indigo-50 border-indigo-300 text-indigo-800'
: 'bg-white border-gray-200 text-gray-600 hover:border-gray-300 hover:bg-gray-50'}`}
>
{a.file_name ?? 'Unnamed file'}
{fmtDateTime(a.created_at)}
);
})}
)}
{/* Selected analysis panel */}
{selected && (
{/* Panel header */}
{selected.file_name ?? 'Unnamed file'}
{fmtDateTime(selected.created_at)}
{' · '}
{selected.note_col}
{' · '}
{selected.total_note_rows} note rows
{categorySummary(selected) && (
{categorySummary(selected)}
)}
setConfirmDelete(selected.id)}
className="text-xs text-red-400 hover:text-red-600 transition-colors shrink-0 ml-4 mt-0.5"
>
Delete
{/* Panel body */}
{isLoading && (
Loading…
)}
{!isLoading && fullData &&
}
)}
setConfirmDelete(null)}
onConfirm={() => handleDelete(confirmDelete)}
loading={deleting}
title="Delete Analysis"
message="Remove this analysis record? The raw CSV is not affected."
/>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function DailyStatusDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [status, setStatus] = useState(null);
const [template, setTemplate] = useState([]);
const [allStatuses, setAllStatuses] = useState([]);
const [analyses, setAnalyses] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showEdit, setShowEdit] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [showManualForm, setShowManualForm] = useState(false);
async function load(statusId = id) {
setLoading(true);
setError(null);
try {
const s = await dailyStatusesApi.get(statusId);
const [tmpl, siblings, pastAnalyses] = await Promise.all([
experimentsApi.getTemplate(s.animal.experiment_id),
dailyStatusesApi.list(s.animal_id),
analysesApi.listForStatus(statusId),
]);
setStatus(s);
setTemplate(tmpl);
setAllStatuses(siblings);
setAnalyses(pastAnalyses);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, [id]);
async function handleDelete() {
setDeleteLoading(true);
try {
await dailyStatusesApi.delete(status.id);
navigate(`/animals/${status.animal_id}`, { replace: true });
} catch (err) {
setError(err.message);
setShowDelete(false);
} finally {
setDeleteLoading(false);
}
}
if (loading) return Loading…
;
if (error) return (
);
const animal = status.animal;
const activeFields = template.filter((f) => f.active);
const currentIdx = allStatuses.findIndex((s) => s.id === status.id);
const prevStatus = currentIdx > 0 ? allStatuses[currentIdx - 1] : null;
const nextStatus = currentIdx < allStatuses.length - 1 ? allStatuses[currentIdx + 1] : null;
return (
{/* Breadcrumb */}
Experiments
/
Experiment
/
{animal.animal_name}
/
{fmtDate(status.date)}
{/* Header */}
{fmtDate(status.date)}
{animal.animal_name}
{animal.animal_id_string && <> · {animal.animal_id_string} >}
setShowEdit(true)}>Edit entry
{error &&
setError(null)} />}
{/* Field values */}
{activeFields.length === 0 ? (
No template fields configured.
) : (
{activeFields.map((f) => {
const value = getFieldValue(status, f);
const label = f.unit ? `${f.label} (${f.unit})` : f.label;
return (
{label}
{value ?? — }
);
})}
)}
{/* Analysis summary (pinned metrics) */}
{status.analysis_summary ? (
setStatus((prev) => ({ ...prev, analysis_summary: summary }))}
/>
) : (
setShowManualForm(true)}
className="text-xs text-gray-400 hover:text-indigo-600 border border-dashed border-gray-200 hover:border-indigo-300 rounded-lg px-3 py-1.5 transition-colors"
>
Set metrics manually
)}
{showManualForm && !status.analysis_summary && (
{
setStatus((prev) => ({ ...prev, analysis_summary: { ...summary, computed_at: new Date().toISOString() } }));
setShowManualForm(false);
}}
onClose={() => setShowManualForm(false)}
/>
)}
{/* Past analyses — inline viewer */}
setAnalyses((prev) => prev.filter((x) => x.id !== deletedId))}
/>
{/* CSV Analysis — after past analyses */}
setAnalyses((prev) => [saved, ...prev])}
onSummaryPushed={(summary) => setStatus((prev) => ({ ...prev, analysis_summary: { ...summary, computed_at: new Date().toISOString() } }))}
/>
{/* Session file registry */}
{/* Full modification history for this entry */}
{/* Prev / Next navigation */}
{prevStatus && (
navigate(`/daily-statuses/${prevStatus.id}`)}
className="text-sm text-blue-600 hover:text-blue-800 hover:underline">
← {fmtDate(prevStatus.date)}
)}
All entries
{nextStatus && (
navigate(`/daily-statuses/${nextStatus.id}`)}
className="text-sm text-blue-600 hover:text-blue-800 hover:underline">
{fmtDate(nextStatus.date)} →
)}
{/* Edit modal */}
setShowEdit(false)} title="Edit Daily Status" size="full">
{ setStatus({ ...updated, animal }); setShowEdit(false); }}
onCancel={() => setShowEdit(false)}
onRequestDelete={() => { setShowEdit(false); setShowDelete(true); }}
/>
setShowDelete(false)}
onConfirm={handleDelete}
loading={deleteLoading}
title="Delete Daily Status"
message={`Delete the entry for ${fmtDate(status.date)}? This cannot be undone.`}
confirmWord="delete"
/>
);
}