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 && ( )}
{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) && ( )}
))} {/* Add custom category inline */}
+ category
setNewCat(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') addCat(); }} placeholder="Name…" className="w-24 border border-dashed border-gray-300 rounded px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-indigo-400" />
Total: {total} {successRate != null && ( Success rate: {(successRate * 100).toFixed(1)}% )}
{error &&

{error}

}
); } // ── 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}
{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

{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 ( ); })}
Category Breakdown Total
{cat} {bd} {data.total}
{/* Consecutive stats */} {Object.keys(consecutive_stats ?? {}).length > 0 && (

Consecutive Runs

{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 ( ); })}
Category Runs Max Avg Lengths
{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 ( ); })}
)} {/* 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)} )}

{/* 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 */} {/* Header */}

{fmtDate(status.date)}

{animal.animal_name} {animal.animal_id_string && <> · {animal.animal_id_string}}

{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 }))} /> ) : (
)} {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 && ( )}
All entries
{nextStatus && ( )}
{/* 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" />
); }