feat: manual metrics editor + past-analyses inline viewer on daily status

- AnalysisSummaryCard gains an "Edit manually" affordance powered by
  the new ManualMetricsForm (add/remove categories, edit counts,
  recompute total + success rate).
- New PastAnalysesViewer renders a tab-style selector of saved CSV
  analyses with an inline AnalysisResultsView panel.
- Reorganizes the page so the manual-metrics path is reachable when no
  analysis_summary exists yet.
- Sync package-lock to the recharts ^2.15.4 already declared in package.json.
This commit is contained in:
Experiments DB Dev
2026-05-01 07:40:10 -04:00
parent 69ce5fbeab
commit cdff6732b0
2 changed files with 209 additions and 54 deletions
+1 -1
View File
@@ -13,7 +13,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.24.0",
"recharts": "^2.12.7"
"recharts": "^2.15.4"
},
"devDependencies": {
"@babel/core": "^7.24.7",
+208 -53
View File
@@ -27,7 +27,7 @@ function getFieldValue(status, field) {
// ── Analysis summary card ─────────────────────────────────────────────────────
function AnalysisSummaryCard({ summary }) {
function AnalysisSummaryCard({ summary, statusId, onSummaryUpdated }) {
const { counts = {}, total, success_rate, computed_at } = summary;
const COLORS = {
Success: 'bg-green-100 text-green-800',
@@ -37,33 +37,60 @@ function AnalysisSummaryCard({ summary }) {
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 (
<div className="bg-white border border-gray-200 rounded-xl px-5 py-4 mb-6">
<div className="flex items-center justify-between mb-3">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Session metrics</p>
{computed_at && <p className="text-[10px] text-gray-400">Updated {fmtDateTime(computed_at)}</p>}
</div>
<div className="flex flex-wrap gap-3 items-center">
{catList.map(([cat, n]) => {
const cls = COLORS[cat] ?? EXTRA[extraIdx++ % EXTRA.length];
return (
<div key={cat} className={`flex flex-col items-center px-3 py-2 rounded-lg ${cls}`}>
<span className="text-xs font-medium">{cat}</span>
<span className="text-xl font-bold leading-tight">{n}</span>
</div>
);
})}
<div className="flex flex-col items-center px-3 py-2 rounded-lg bg-blue-50 text-blue-800">
<span className="text-xs font-medium">Total</span>
<span className="text-xl font-bold leading-tight">{total}</span>
<div className="flex items-center gap-3">
{computed_at && <p className="text-[10px] text-gray-400">Updated {fmtDateTime(computed_at)}</p>}
{!showForm && (
<button
type="button"
onClick={() => 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
</button>
)}
</div>
{success_rate != null && (
<div className="flex flex-col items-center px-3 py-2 rounded-lg bg-indigo-50 text-indigo-800">
<span className="text-xs font-medium">Success rate</span>
<span className="text-xl font-bold leading-tight">{(success_rate * 100).toFixed(1)}%</span>
</div>
)}
</div>
{showForm ? (
<ManualMetricsForm
statusId={statusId}
existing={summary}
onSaved={(updated) => {
onSummaryUpdated({ ...updated, computed_at: new Date().toISOString() });
setShowForm(false);
}}
onClose={() => setShowForm(false)}
/>
) : (
<div className="flex flex-wrap gap-3 items-center">
{catList.map(([cat, n]) => {
const cls = COLORS[cat] ?? EXTRA[extraIdx++ % EXTRA.length];
return (
<div key={cat} className={`flex flex-col items-center px-3 py-2 rounded-lg ${cls}`}>
<span className="text-xs font-medium">{cat}</span>
<span className="text-xl font-bold leading-tight">{n}</span>
</div>
);
})}
<div className="flex flex-col items-center px-3 py-2 rounded-lg bg-blue-50 text-blue-800">
<span className="text-xs font-medium">Total</span>
<span className="text-xl font-bold leading-tight">{total}</span>
</div>
{success_rate != null && (
<div className="flex flex-col items-center px-3 py-2 rounded-lg bg-indigo-50 text-indigo-800">
<span className="text-xs font-medium">Success rate</span>
<span className="text-xl font-bold leading-tight">{(success_rate * 100).toFixed(1)}%</span>
</div>
)}
</div>
)}
</div>
);
}
@@ -379,6 +406,142 @@ export function AnalysisResultsView({ analysis }) {
);
}
// ── 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 (
<div className="mt-8 border-t border-gray-200 pt-6">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
Past Analyses ({analyses.length})
</h2>
</div>
{/* Tab selector — scrollable if many */}
{analyses.length > 1 && (
<div className="flex gap-1.5 overflow-x-auto pb-2 mb-4 scrollbar-thin">
{analyses.map((a) => {
const active = a.id === selectedId;
return (
<button
key={a.id}
type="button"
onClick={() => 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'}`}
>
<span className="text-xs font-medium truncate max-w-[160px]">
{a.file_name ?? 'Unnamed file'}
</span>
<span className={`text-[10px] mt-0.5 ${active ? 'text-indigo-500' : 'text-gray-400'}`}>
{fmtDateTime(a.created_at)}
</span>
</button>
);
})}
</div>
)}
{/* Selected analysis panel */}
{selected && (
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
{/* Panel header */}
<div className="flex items-start justify-between px-4 py-3 border-b border-gray-100 bg-gray-50/50">
<div>
<p className="text-sm font-medium text-gray-800">
{selected.file_name ?? 'Unnamed file'}
</p>
<p className="text-xs text-gray-400 mt-0.5">
{fmtDateTime(selected.created_at)}
{' · '}
<span className="font-mono">{selected.note_col}</span>
{' · '}
{selected.total_note_rows} note rows
{categorySummary(selected) && (
<span className="ml-2 text-gray-500">{categorySummary(selected)}</span>
)}
</p>
</div>
<button
type="button"
onClick={() => setConfirmDelete(selected.id)}
className="text-xs text-red-400 hover:text-red-600 transition-colors shrink-0 ml-4 mt-0.5"
>
Delete
</button>
</div>
{/* Panel body */}
<div className="px-4 py-4">
{isLoading && (
<p className="text-sm text-gray-400 py-4 text-center">Loading</p>
)}
{!isLoading && fullData && <AnalysisResultsView analysis={fullData} />}
</div>
</div>
)}
<ConfirmDialog
isOpen={!!confirmDelete}
onClose={() => setConfirmDelete(null)}
onConfirm={() => handleDelete(confirmDelete)}
loading={deleting}
title="Delete Analysis"
message="Remove this analysis record? The raw CSV is not affected."
/>
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function DailyStatusDetail() {
@@ -498,18 +661,11 @@ export default function DailyStatusDetail() {
</div>
{/* Analysis summary (pinned metrics) */}
{status.analysis_summary && !showManualForm && (
<AnalysisSummaryCard summary={status.analysis_summary} />
)}
{showManualForm ? (
<ManualMetricsForm
{status.analysis_summary ? (
<AnalysisSummaryCard
summary={status.analysis_summary}
statusId={status.id}
existing={status.analysis_summary}
onSaved={(summary) => {
setStatus((prev) => ({ ...prev, analysis_summary: { ...summary, computed_at: new Date().toISOString() } }));
setShowManualForm(false);
}}
onClose={() => setShowManualForm(false)}
onSummaryUpdated={(summary) => setStatus((prev) => ({ ...prev, analysis_summary: summary }))}
/>
) : (
<div className="mb-4">
@@ -518,36 +674,35 @@ export default function DailyStatusDetail() {
onClick={() => 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"
>
{status.analysis_summary ? 'Edit metrics manually' : 'Set metrics manually'}
Set metrics manually
</button>
</div>
)}
{showManualForm && !status.analysis_summary && (
<ManualMetricsForm
statusId={status.id}
existing={null}
onSaved={(summary) => {
setStatus((prev) => ({ ...prev, analysis_summary: { ...summary, computed_at: new Date().toISOString() } }));
setShowManualForm(false);
}}
onClose={() => setShowManualForm(false)}
/>
)}
{/* CSV Analysis — new analysis */}
{/* Past analyses — inline viewer */}
<PastAnalysesViewer
analyses={analyses}
onDelete={(deletedId) => setAnalyses((prev) => prev.filter((x) => x.id !== deletedId))}
/>
{/* CSV Analysis — after past analyses */}
<CsvAnalysis
dailyStatusId={status.id}
onSaved={(saved) => setAnalyses((prev) => [saved, ...prev])}
onSummaryPushed={(summary) => setStatus((prev) => ({ ...prev, analysis_summary: { ...summary, computed_at: new Date().toISOString() } }))}
/>
{/* Past analyses */}
{analyses.length > 0 && (
<div className="mt-8 border-t border-gray-200 pt-6">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">
Past Analyses ({analyses.length})
</h2>
<div className="space-y-2">
{analyses.map((a) => (
<AnalysisRow
key={a.id}
analysis={a}
onDelete={(deletedId) => setAnalyses((prev) => prev.filter((x) => x.id !== deletedId))}
/>
))}
</div>
</div>
)}
{/* Session file registry */}
<FileDropZone
rows={[{ animal, status }]}