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:
Generated
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.24.0",
|
"react-router-dom": "^6.24.0",
|
||||||
"recharts": "^2.12.7"
|
"recharts": "^2.15.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.24.7",
|
"@babel/core": "^7.24.7",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function getFieldValue(status, field) {
|
|||||||
|
|
||||||
// ── Analysis summary card ─────────────────────────────────────────────────────
|
// ── Analysis summary card ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
function AnalysisSummaryCard({ summary }) {
|
function AnalysisSummaryCard({ summary, statusId, onSummaryUpdated }) {
|
||||||
const { counts = {}, total, success_rate, computed_at } = summary;
|
const { counts = {}, total, success_rate, computed_at } = summary;
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
Success: 'bg-green-100 text-green-800',
|
Success: 'bg-green-100 text-green-800',
|
||||||
@@ -37,12 +37,38 @@ 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 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);
|
const catList = Object.entries(counts).sort(([, a], [, b]) => b - a);
|
||||||
let extraIdx = 0;
|
let extraIdx = 0;
|
||||||
|
|
||||||
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white border border-gray-200 rounded-xl px-5 py-4 mb-6">
|
<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">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Session metrics</p>
|
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Session metrics</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
{computed_at && <p className="text-[10px] text-gray-400">Updated {fmtDateTime(computed_at)}</p>}
|
{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>
|
</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">
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
{catList.map(([cat, n]) => {
|
{catList.map(([cat, n]) => {
|
||||||
const cls = COLORS[cat] ?? EXTRA[extraIdx++ % EXTRA.length];
|
const cls = COLORS[cat] ?? EXTRA[extraIdx++ % EXTRA.length];
|
||||||
@@ -64,6 +90,7 @@ function AnalysisSummaryCard({ summary }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 ──────────────────────────────────────────────────────────────────────
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function DailyStatusDetail() {
|
export default function DailyStatusDetail() {
|
||||||
@@ -498,18 +661,11 @@ export default function DailyStatusDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Analysis summary (pinned metrics) */}
|
{/* Analysis summary (pinned metrics) */}
|
||||||
{status.analysis_summary && !showManualForm && (
|
{status.analysis_summary ? (
|
||||||
<AnalysisSummaryCard summary={status.analysis_summary} />
|
<AnalysisSummaryCard
|
||||||
)}
|
summary={status.analysis_summary}
|
||||||
{showManualForm ? (
|
|
||||||
<ManualMetricsForm
|
|
||||||
statusId={status.id}
|
statusId={status.id}
|
||||||
existing={status.analysis_summary}
|
onSummaryUpdated={(summary) => setStatus((prev) => ({ ...prev, analysis_summary: summary }))}
|
||||||
onSaved={(summary) => {
|
|
||||||
setStatus((prev) => ({ ...prev, analysis_summary: { ...summary, computed_at: new Date().toISOString() } }));
|
|
||||||
setShowManualForm(false);
|
|
||||||
}}
|
|
||||||
onClose={() => setShowManualForm(false)}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
@@ -518,36 +674,35 @@ export default function DailyStatusDetail() {
|
|||||||
onClick={() => setShowManualForm(true)}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</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
|
<CsvAnalysis
|
||||||
dailyStatusId={status.id}
|
dailyStatusId={status.id}
|
||||||
onSaved={(saved) => setAnalyses((prev) => [saved, ...prev])}
|
onSaved={(saved) => setAnalyses((prev) => [saved, ...prev])}
|
||||||
onSummaryPushed={(summary) => setStatus((prev) => ({ ...prev, analysis_summary: { ...summary, computed_at: new Date().toISOString() } }))}
|
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 */}
|
{/* Session file registry */}
|
||||||
<FileDropZone
|
<FileDropZone
|
||||||
rows={[{ animal, status }]}
|
rows={[{ animal, status }]}
|
||||||
|
|||||||
Reference in New Issue
Block a user