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:
@@ -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 }]}
|
||||
|
||||
Reference in New Issue
Block a user