feat: add experiment calendar view with per-day subject counts

New ExperimentCalendar component added as a third view mode (List / Group / Calendar)
on the Experiment Detail page.

Backend:
- GET /api/experiments/:id/calendar?field=<fieldIdOrBuiltinKey>
  Returns { field, days: { "YYYY-MM-DD": count } } where count = number of
  subjects with a non-null/non-empty value for the chosen field on that date.
  Supports both builtin fields (vitals, treatment, notes, experiment_description)
  and custom fields addressed by fieldId UUID.

Frontend:
- ExperimentCalendar component: navigable monthly grid, field selector
  (defaults to first field with "weight" in label, else first active field),
  blue badges showing subject count per day, click to open modal with all
  subjects' data for that day.
- Integrated into ExperimentDetail as displayMode "calendar", persisted
  in localStorage alongside the existing list/group modes.
- experimentsApi.getCalendar() added to API client.

Tests:
- backend/tests/calendar.test.js: 8 unit tests (404, empty days, builtin
  count, custom field count, whitespace ignored, multi-month, field echo,
  missing param).
- frontend/tests/ExperimentCalendar.test.jsx: 13 RTL tests covering
  rendering, default field selection, count badges, month navigation,
  field-change re-fetch, day click modal, and multi-subject display.

All 47 backend + 13 calendar frontend tests passing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Experiments DB Dev
2026-04-23 09:26:35 -04:00
parent 09a853e28b
commit 9535f86970
34 changed files with 5460 additions and 340 deletions
+21 -3
View File
@@ -1,8 +1,10 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Routes, Route, Link, useLocation } from 'react-router-dom';
import { systemApi } from './api/client';
import Dashboard from './pages/Dashboard';
import ExperimentDetail from './pages/ExperimentDetail';
import AnimalDetail from './pages/AnimalDetail';
import DailyStatusDetail from './pages/DailyStatusDetail';
function Navbar() {
return (
@@ -33,18 +35,34 @@ function NotFound() {
);
}
function Footer() {
const [dbTimezone, setDbTimezone] = useState(null);
useEffect(() => {
systemApi.info().then((d) => setDbTimezone(d.dbTimezone)).catch(() => {});
}, []);
return (
<footer className="border-t border-gray-200 mt-12 py-3">
<div className="max-w-5xl mx-auto px-4 text-xs text-gray-400 text-right">
Database timezone: <span className="font-mono">{dbTimezone ?? '…'}</span>
</div>
</footer>
);
}
export default function App() {
return (
<div className="min-h-screen bg-gray-50">
<div className="min-h-screen bg-gray-50 flex flex-col">
<Navbar />
<main>
<main className="flex-1">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/experiments/:id" element={<ExperimentDetail />} />
<Route path="/animals/:id" element={<AnimalDetail />} />
<Route path="/daily-statuses/:id" element={<DailyStatusDetail />} />
<Route path="*" element={<NotFound />} />
</Routes>
</main>
<Footer />
</div>
);
}
+18
View File
@@ -31,6 +31,10 @@ export const experimentsApi = {
delete: (id) => api.delete(`/experiments/${id}`),
getTemplate: (id) => api.get(`/experiments/${id}/template`).then((r) => r.data),
updateTemplate: (id, template) => api.put(`/experiments/${id}/template`, template).then((r) => r.data),
getSubjectTemplate: (id) => api.get(`/experiments/${id}/subject-template`).then((r) => r.data),
updateSubjectTemplate: (id, template) => api.put(`/experiments/${id}/subject-template`, template).then((r) => r.data),
getDailyStatuses: (id) => api.get(`/experiments/${id}/daily-statuses`).then((r) => r.data),
getCalendar: (id, field) => api.get(`/experiments/${id}/calendar`, { params: { field } }).then((r) => r.data),
};
// ── Animals ───────────────────────────────────────────────────────────────────
@@ -51,9 +55,23 @@ export const dailyStatusesApi = {
create: (data) => api.post('/daily-statuses', data).then((r) => r.data),
update: (id, data) => api.put(`/daily-statuses/${id}`, data).then((r) => r.data),
delete: (id) => api.delete(`/daily-statuses/${id}`),
updateAnalysisSummary: (id, data) => api.put(`/daily-statuses/${id}/analysis-summary`, data).then((r) => r.data),
};
// ── Audit Logs ────────────────────────────────────────────────────────────────
export const auditLogsApi = {
list: (params) => api.get('/audit-logs', { params }).then((r) => r.data),
};
// ── System ────────────────────────────────────────────────────────────────────
export const systemApi = {
info: () => api.get('/info').then((r) => r.data),
};
// ── Analyses ──────────────────────────────────────────────────────────────────
export const analysesApi = {
listForStatus: (statusId) => api.get(`/daily-statuses/${statusId}/analyses`).then((r) => r.data),
create: (statusId, data) => api.post(`/daily-statuses/${statusId}/analyses`, data).then((r) => r.data),
get: (id) => api.get(`/analyses/${id}`).then((r) => r.data),
delete: (id) => api.delete(`/analyses/${id}`),
};
+413
View File
@@ -0,0 +1,413 @@
import React, { useState, useMemo } from 'react';
import { format } from 'date-fns';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
Legend, ResponsiveContainer,
} from 'recharts';
// ── Color palettes ─────────────────────────────────────────────────────────────
const CAT_COLORS = { Success: '#22c55e', Failure: '#ef4444', Other: '#9ca3af' };
const EXTRA_CAT = ['#f59e0b', '#a855f7', '#ec4899', '#14b8a6', '#f97316'];
const GROUP_COLORS = ['#6366f1', '#f59e0b', '#22c55e', '#ef4444', '#a855f7', '#14b8a6', '#ec4899', '#f97316', '#84cc16', '#06b6d4'];
function catColor(cat, idx = 0) {
return CAT_COLORS[cat] ?? EXTRA_CAT[idx % EXTRA_CAT.length];
}
// ── Shared tooltip ─────────────────────────────────────────────────────────────
function ChartTooltip({ active, payload, label, labelKey = 'dateStr', unit = '' }) {
if (!active || !payload?.length) return null;
const header = payload[0]?.payload?.[labelKey] ?? `Day ${label}`;
return (
<div className="bg-white border border-gray-200 rounded shadow-sm px-3 py-2 text-xs space-y-0.5">
<p className="font-semibold text-gray-700 mb-1">{header}</p>
{payload.map((p) => (
<p key={p.dataKey} style={{ color: p.stroke ?? p.color }}>
{p.name}: {p.value != null ? `${p.value}${unit}` : '—'}
</p>
))}
</div>
);
}
// ── Shared helpers ─────────────────────────────────────────────────────────────
const DAYS_REACH_RE = /#?\s*days?\s*reach/i;
/** Pick the default xField from a list of active daily-template fields. */
function defaultXField(fields) {
return fields.find((f) => DAYS_REACH_RE.test(f.label))?.fieldId ?? '__days__';
}
/** Read a template field's value from a status row (numeric, or null). */
function numericFieldVal(status, field) {
if (!field) return null;
const raw = field.builtin ? status[field.key] : status.custom_fields?.[field.fieldId];
const n = parseFloat(raw);
return isNaN(n) ? null : n;
}
const SELECT_CLS = 'border border-gray-200 rounded px-1.5 py-0.5 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400';
const STEP_OPTIONS = [1, 2, 5, 10];
/** Build explicit integer ticks for a numeric x-axis so every step value is shown. */
function buildTicks(data, step) {
const xs = data.map((d) => d.x).filter((x) => x != null && isFinite(x));
if (!xs.length) return undefined;
const lo = Math.floor(Math.min(...xs));
const hi = Math.ceil(Math.max(...xs));
const ticks = [];
for (let t = Math.ceil(lo / step) * step; t <= hi; t += step) ticks.push(t);
if (!ticks.length || ticks[0] > lo) ticks.unshift(lo);
if (ticks[ticks.length - 1] < hi) ticks.push(hi);
return ticks;
}
function TickStepControl({ value, onChange }) {
return (
<div className="flex items-center gap-1.5 text-xs">
<span className="text-gray-400">Tick every</span>
<div className="flex rounded border border-gray-200 overflow-hidden">
{STEP_OPTIONS.map((s) => (
<button key={s} type="button" onClick={() => onChange(s)}
className={`px-2 py-0.5 transition-colors border-l border-gray-200 first:border-l-0
${value === s ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
{s}
</button>
))}
</div>
</div>
);
}
// ── Subject charts ─────────────────────────────────────────────────────────────
export function SubjectAnalysisCharts({ statuses, dailyTemplate }) {
const xAxisFields = (dailyTemplate ?? []).filter((f) => f.active);
const [xField, setXField] = useState(() => defaultXField(xAxisFields));
const [tickStep, setTickStep] = useState(1);
const { data, cats, xLabel } = useMemo(() => {
const xAxisField = xAxisFields.find((f) => f.fieldId === xField);
const xLabel = xAxisField ? xAxisField.label : '# Days Reach';
const withMetrics = statuses
.filter((s) => s.analysis_summary?.total != null)
.sort((a, b) => String(a.date).localeCompare(String(b.date)));
const rows = withMetrics.map((s, i) => {
const xVal = xField === '__days__' || !xAxisField
? i + 1
: numericFieldVal(s, xAxisField);
const dateStr = format(new Date(String(s.date).slice(0, 10) + 'T12:00:00'), 'MMM d');
const { counts = {}, total, success_rate } = s.analysis_summary;
return { _x: xVal, dateStr, total, rate: success_rate != null ? +(success_rate * 100).toFixed(1) : null, ...counts };
}).filter((r) => r._x != null).sort((a, b) => a._x - b._x);
const data = rows.map((r) => ({ ...r, x: r._x }));
const catSet = new Set();
for (const d of data) {
for (const k of Object.keys(d)) {
if (!['x', '_x', 'dateStr', 'total', 'rate'].includes(k)) catSet.add(k);
}
}
return { data, cats: [...catSet], xLabel };
}, [statuses, xField, dailyTemplate]);
const ticks = useMemo(() => buildTicks(data, tickStep), [data, tickStep]);
if (data.length === 0) return null;
const xAxisProps = {
dataKey: 'x', type: 'number', tick: { fontSize: 10 },
ticks,
domain: ticks?.length ? [ticks[0], ticks[ticks.length - 1]] : ['auto', 'auto'],
label: { value: xLabel, position: 'insideBottomRight', offset: -4, fontSize: 10 },
allowDecimals: false,
};
return (
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6 space-y-5">
<div className="flex items-center flex-wrap gap-3">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Session metrics over time</h2>
<div className="flex items-center gap-1.5 text-xs">
<span className="text-gray-400">X axis:</span>
<select value={xField} onChange={(e) => setXField(e.target.value)} className={SELECT_CLS}>
<option value="__days__"># Days Reach (ordinal)</option>
{xAxisFields.map((f) => (
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
))}
</select>
</div>
<TickStepControl value={tickStep} onChange={setTickStep} />
</div>
{/* Chart 1 — counts */}
<div>
<p className="text-xs text-gray-500 mb-2">Attempts per session</p>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
<XAxis {...xAxisProps} />
<YAxis tick={{ fontSize: 10 }} width={30} allowDecimals={false} />
<Tooltip content={<ChartTooltip labelKey="dateStr" />} />
<Legend wrapperStyle={{ fontSize: 11 }} />
{cats.map((cat, i) => (
<Line key={cat} type="monotone" dataKey={cat} stroke={catColor(cat, i)} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 4 }} connectNulls isAnimationActive={false} />
))}
<Line type="monotone" dataKey="total" name="Total" stroke="#3b82f6" strokeWidth={1.5} strokeDasharray="5 3" dot={{ r: 3 }} connectNulls isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</div>
{/* Chart 2 — success rate */}
<div>
<p className="text-xs text-gray-500 mb-2">Success rate (%)</p>
<ResponsiveContainer width="100%" height={160}>
<LineChart data={data} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
<XAxis {...xAxisProps} />
<YAxis tick={{ fontSize: 10 }} width={36} domain={[0, 100]} tickFormatter={(v) => `${v}%`} />
<Tooltip content={<ChartTooltip unit="%" labelKey="dateStr" />} />
<Line type="monotone" dataKey="rate" name="Success rate" stroke="#6366f1" strokeWidth={2} dot={{ r: 3 }} connectNulls isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
}
// ── Experiment charts ──────────────────────────────────────────────────────────
const METRIC_OPTIONS = [
{ value: 'total', label: 'Total attempts' },
{ value: 'Success', label: 'Success count' },
{ value: 'Failure', label: 'Failure count' },
];
export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectTemplate, dailyTemplate }) {
const groupableFields = (subjectTemplate ?? []).filter((f) => f.active);
const xAxisFields = (dailyTemplate ?? []).filter((f) => f.active);
const defaultGroup = (groupableFields.find((f) => /^group$/i.test(f.label)) ?? groupableFields[0])?.fieldId ?? '';
const [groupBy, setGroupBy] = useState(defaultGroup);
const [metric, setMetric] = useState('total');
const [xField, setXField] = useState(() => defaultXField(xAxisFields));
const [tickStep, setTickStep] = useState(1);
const { countData, rateData, lines, xLabel } = useMemo(() => {
// animalId → group label
const animalGroup = {};
for (const animal of animals) {
const raw = groupBy ? animal.subject_info?.[groupBy] : null;
animalGroup[animal.id] = raw != null && raw !== '' ? String(raw) : '—';
}
const groups = [...new Set(Object.values(animalGroup))].sort();
// animalId → sorted statuses that have saved metrics (full status rows)
const statusesByAnimal = {};
for (const s of experimentStatuses) {
if (!statusesByAnimal[s.animal_id]) statusesByAnimal[s.animal_id] = [];
statusesByAnimal[s.animal_id].push(s);
}
const animalRows = {}; // full status rows, sorted by date
for (const animal of animals) {
animalRows[animal.id] = (statusesByAnimal[animal.id] ?? [])
.filter((s) => s.analysis_summary?.total != null)
.sort((a, b) => String(a.date).localeCompare(String(b.date)));
}
const lines = animals
.filter((a) => animalRows[a.id].length > 0)
.map((a) => ({
id: a.id,
name: a.animal_name,
group: animalGroup[a.id],
color: GROUP_COLORS[groups.indexOf(animalGroup[a.id]) % GROUP_COLORS.length],
}));
if (lines.length === 0) return { countData: [], rateData: [], lines: [], xLabel: '' };
const xAxisField = xAxisFields.find((f) => f.fieldId === xField);
const xLabel = xAxisField ? xAxisField.label : '# Days Reach';
function getX(row, dayIndex) {
if (xField === '__days__') return dayIndex + 1;
return numericFieldVal(row, xAxisField);
}
function getCountVal(summary) {
return metric === 'total'
? summary.total
: (summary.counts?.[metric] ?? null);
}
if (xField === '__days__') {
// Ordinal alignment: position by day index
const maxDay = Math.max(...lines.map((l) => animalRows[l.id].length));
const countData = Array.from({ length: maxDay }, (_, i) => ({ x: i + 1 }));
const rateData = Array.from({ length: maxDay }, (_, i) => ({ x: i + 1 }));
for (const line of lines) {
animalRows[line.id].forEach((row, i) => {
const s = row.analysis_summary;
countData[i][line.id] = getCountVal(s);
rateData[i][line.id] = s.success_rate != null ? +(s.success_rate * 100).toFixed(1) : null;
});
}
return { countData, rateData, lines, xLabel };
}
// Field-based x axis: merge all unique x values across animals
const allPts = [];
for (const line of lines) {
animalRows[line.id].forEach((row, i) => {
const x = getX(row, i);
if (x !== null) allPts.push({ x, animalId: line.id, summary: row.analysis_summary });
});
}
const sortedX = [...new Set(allPts.map((p) => p.x))].sort((a, b) => a - b);
if (sortedX.length === 0) return { countData: [], rateData: [], lines, xLabel };
const countData = sortedX.map((x) => {
const pt = { x };
for (const p of allPts.filter((p) => p.x === x)) {
pt[p.animalId] = getCountVal(p.summary);
}
return pt;
});
const rateData = sortedX.map((x) => {
const pt = { x };
for (const p of allPts.filter((p) => p.x === x)) {
const s = p.summary;
pt[p.animalId] = s.success_rate != null ? +(s.success_rate * 100).toFixed(1) : null;
}
return pt;
});
return { countData, rateData, lines, xLabel };
}, [animals, experimentStatuses, subjectTemplate, dailyTemplate, groupBy, metric, xField]);
const hasData = lines.length > 0;
const countTicks = useMemo(() => buildTicks(countData, tickStep), [countData, tickStep]);
const rateTicks = useMemo(() => buildTicks(rateData, tickStep), [rateData, tickStep]);
function xAxisProps(ticks) {
return {
dataKey: 'x', type: 'number', tick: { fontSize: 10 }, allowDecimals: false,
ticks,
domain: ticks?.length ? [ticks[0], ticks[ticks.length - 1]] : ['auto', 'auto'],
label: { value: xLabel, position: 'insideBottomRight', offset: -4, fontSize: 10 },
};
}
function ExpTooltip({ active, payload, label, unit = '' }) {
if (!active || !payload?.length) return null;
return (
<div className="bg-white border border-gray-200 rounded shadow-sm px-3 py-2 text-xs space-y-0.5 max-h-48 overflow-y-auto">
<p className="font-semibold text-gray-700 mb-1">{xLabel}: {label}</p>
{payload.map((p) => {
const line = lines.find((l) => l.id === p.dataKey);
return p.value != null ? (
<p key={p.dataKey} style={{ color: p.stroke }}>
{line?.name ?? p.dataKey}{line?.group && line.group !== '—' ? ` (${line.group})` : ''}: {p.value}{unit}
</p>
) : null;
})}
</div>
);
}
return (
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6 space-y-5">
<div className="flex items-center flex-wrap gap-3">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Cross-subject metrics</h2>
{/* X-axis field selector */}
<div className="flex items-center gap-1.5 text-xs">
<span className="text-gray-400">X axis:</span>
<select value={xField} onChange={(e) => setXField(e.target.value)} className={SELECT_CLS}>
<option value="__days__"># Days Reach (ordinal)</option>
{xAxisFields.map((f) => (
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
))}
</select>
</div>
{/* Color-by subject info field */}
{groupableFields.length > 0 && (
<div className="flex items-center gap-1.5 text-xs">
<span className="text-gray-400">Color by:</span>
<select value={groupBy} onChange={(e) => setGroupBy(e.target.value)} className={SELECT_CLS}>
<option value=""> none </option>
{groupableFields.map((f) => (
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
))}
</select>
</div>
)}
<TickStepControl value={tickStep} onChange={setTickStep} />
</div>
{!hasData && (
<p className="text-sm text-gray-400 italic">No sessions with saved metrics yet. Upload a CSV on a daily status page and click "Save metrics to daily status".</p>
)}
{hasData && (
<>
{/* Chart 1 — counts */}
<div>
<div className="flex items-center gap-3 mb-2">
<p className="text-xs text-gray-500">Count per session each line is one subject</p>
<div className="flex rounded border border-gray-200 overflow-hidden text-[10px]">
{METRIC_OPTIONS.map((opt) => (
<button key={opt.value} type="button" onClick={() => setMetric(opt.value)}
className={`px-2 py-0.5 transition-colors border-l border-gray-200 first:border-l-0
${metric === opt.value ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
{opt.label}
</button>
))}
</div>
</div>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={countData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
<XAxis {...xAxisProps(countTicks)} />
<YAxis tick={{ fontSize: 10 }} width={30} allowDecimals={false} />
<Tooltip content={<ExpTooltip />} />
<Legend wrapperStyle={{ fontSize: 11 }}
formatter={(value) => lines.find((l) => l.id === value)?.name ?? value} />
{lines.map((l) => (
<Line key={l.id} type="monotone" dataKey={l.id} name={l.id}
stroke={l.color} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 5 }} connectNulls isAnimationActive={false} />
))}
</LineChart>
</ResponsiveContainer>
</div>
{/* Chart 2 — success rate */}
<div>
<p className="text-xs text-gray-500 mb-2">Success rate (%) each line is one subject</p>
<ResponsiveContainer width="100%" height={160}>
<LineChart data={rateData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
<XAxis {...xAxisProps(rateTicks)} />
<YAxis tick={{ fontSize: 10 }} width={36} domain={[0, 100]} tickFormatter={(v) => `${v}%`} />
<Tooltip content={<ExpTooltip unit="%" />} />
<Legend wrapperStyle={{ fontSize: 11 }}
formatter={(value) => lines.find((l) => l.id === value)?.name ?? value} />
{lines.map((l) => (
<Line key={l.id} type="monotone" dataKey={l.id} name={l.id}
stroke={l.color} strokeWidth={2} dot={{ r: 3 }} activeDot={{ r: 5 }} connectNulls isAnimationActive={false} />
))}
</LineChart>
</ResponsiveContainer>
</div>
</>
)}
</div>
);
}
+10 -4
View File
@@ -36,7 +36,7 @@ const ACTION_BADGE = {
DELETE: 'bg-red-100 text-red-800',
};
export default function AuditLogSection({ tableName, recordId }) {
export default function AuditLogSection({ tableName, recordId, limit = 50 }) {
const [logs, setLogs] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
@@ -46,14 +46,14 @@ export default function AuditLogSection({ tableName, recordId }) {
useEffect(() => {
setLoading(true);
auditLogsApi
.list({ tableName, recordId, limit: 50 })
.list({ tableName, recordId, limit })
.then(({ logs, total }) => {
setLogs(logs);
setTotal(total);
})
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
}, [tableName, recordId]);
}, [tableName, recordId, limit]);
return (
<section aria-label="Modification History" className="mt-8">
@@ -76,7 +76,7 @@ export default function AuditLogSection({ tableName, recordId }) {
{logs.length > 0 && (
<div className="border border-gray-200 rounded-lg overflow-hidden divide-y divide-gray-100">
{logs.map((log) => (
{logs.slice(0, limit).map((log) => (
<div key={log.id} className="bg-white">
<button
className="w-full text-left px-4 py-3 flex items-center gap-3 hover:bg-gray-50 transition-colors"
@@ -108,6 +108,12 @@ export default function AuditLogSection({ tableName, recordId }) {
))}
</div>
)}
{total > limit && (
<p className="mt-2 text-xs text-gray-400">
Showing {limit} of {total} entries open the daily status page to see the full history.
</p>
)}
</section>
);
}
+602
View File
@@ -0,0 +1,602 @@
import React, { useState, useRef, useCallback } from 'react';
import { parseCSVHeaders, parseCSV, getUniqueNoteValues, runAnalysis, checkFrameConsecutive } from '../lib/csvAnalysis';
import { analysesApi, dailyStatusesApi } from '../api/client';
import Button from './ui/Button';
import RunSequenceView from './RunSequenceView';
// ── Constants ──────────────────────────────────────────────────────────────────
const DEFAULT_CATEGORIES = ['Success', 'Failure', 'Other'];
const CATEGORY_COLORS = {
Success: { bg: 'bg-green-100', border: 'border-green-300', text: 'text-green-800', dot: 'bg-green-500' },
Failure: { bg: 'bg-red-100', border: 'border-red-300', text: 'text-red-800', dot: 'bg-red-500' },
Other: { bg: 'bg-gray-100', border: 'border-gray-300', text: 'text-gray-700', dot: 'bg-gray-400' },
};
const EXTRA_COLORS = [
{ bg: 'bg-indigo-100', border: 'border-indigo-300', text: 'text-indigo-800', dot: 'bg-indigo-500' },
{ bg: 'bg-yellow-100', border: 'border-yellow-300', text: 'text-yellow-800', dot: 'bg-yellow-500' },
{ bg: 'bg-purple-100', border: 'border-purple-300', text: 'text-purple-800', dot: 'bg-purple-500' },
{ bg: 'bg-pink-100', border: 'border-pink-300', text: 'text-pink-800', dot: 'bg-pink-500' },
{ bg: 'bg-teal-100', border: 'border-teal-300', text: 'text-teal-800', dot: 'bg-teal-500' },
];
function getCategoryColors(cat, customCategories) {
if (CATEGORY_COLORS[cat]) return CATEGORY_COLORS[cat];
const idx = customCategories.indexOf(cat) % EXTRA_COLORS.length;
return EXTRA_COLORS[Math.max(0, idx)];
}
// ── Sub-components ─────────────────────────────────────────────────────────────
function NoteChip({ value, category, colors, selected, onSelect, draggable, onDragStart }) {
return (
<span
draggable={draggable}
onDragStart={onDragStart}
onClick={onSelect}
title={category ? `Assigned to: ${category}` : 'Unassigned — click or drag to assign'}
className={`
inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-mono font-semibold
border cursor-pointer select-none transition-all
${colors ? `${colors.bg} ${colors.border} ${colors.text}` : 'bg-white border-gray-300 text-gray-700 hover:border-indigo-400'}
${selected ? 'ring-2 ring-indigo-500 ring-offset-1' : ''}
`}
>
{value}
{category && (
<span className={`w-1.5 h-1.5 rounded-full ${colors?.dot}`} />
)}
</span>
);
}
function CategoryBucket({ name, notes, colors, isOver, onDrop, onDragOver, onDragLeave, onRemoveNote, onClickAssign }) {
return (
<div
onDragOver={(e) => { e.preventDefault(); onDragOver(); }}
onDragLeave={onDragLeave}
onDrop={(e) => { e.preventDefault(); onDrop(); }}
onClick={onClickAssign}
className={`
rounded-lg border-2 border-dashed p-3 min-h-[80px] transition-all cursor-pointer
${isOver ? `${colors.border} ${colors.bg} scale-[1.02]` : 'border-gray-200 hover:border-gray-300'}
`}
>
<p className={`text-xs font-semibold uppercase tracking-wide mb-2 ${colors.text}`}>{name}</p>
<div className="flex flex-wrap gap-1.5 min-h-[28px]">
{notes.length === 0 && (
<span className="text-xs text-gray-400 italic">Drop here</span>
)}
{notes.map((note) => (
<span key={note}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-mono font-semibold ${colors.bg} ${colors.border} ${colors.text} border`}
>
{note}
<button type="button" onClick={(e) => { e.stopPropagation(); onRemoveNote(note); }}
className="opacity-50 hover:opacity-100 leading-none"></button>
</span>
))}
</div>
</div>
);
}
// ── Main component ─────────────────────────────────────────────────────────────
export default function CsvAnalysis({ dailyStatusId, onSaved, onSummaryPushed }) {
// Phase: 'upload' | 'columns' | 'classify' | 'results'
const [phase, setPhase] = useState('upload');
// Upload phase
const [isDragOver, setIsDragOver] = useState(false);
const [fileName, setFileName] = useState('');
const [rawText, setRawText] = useState('');
const [headers, setHeaders] = useState([]);
const fileInputRef = useRef(null);
// Columns phase
const [timestampCol, setTimestampCol] = useState('');
const [noteCol, setNoteCol] = useState('');
const [frameCol, setFrameCol] = useState('');
const [frameCheck, setFrameCheck] = useState(null);
const [parsePending, setParsePending] = useState(false);
// Classify phase
const [rows, setRows] = useState([]);
const [uniqueNotes, setUniqueNotes] = useState([]);
const [categories, setCategories] = useState(DEFAULT_CATEGORIES);
const [customCategories, setCustomCategories] = useState([]);
const [classifications, setClassifications] = useState({});
const [selectedNote, setSelectedNote] = useState(null);
const [draggedNote, setDraggedNote] = useState(null);
const [overBucket, setOverBucket] = useState(null);
const [newCatName, setNewCatName] = useState('');
const [showNewCat, setShowNewCat] = useState(false);
// Results phase
const [results, setResults] = useState(null);
const [savedAnalysisId, setSavedAnalysisId] = useState(null);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(null);
const [summaryPushing, setSummaryPushing] = useState(false);
const [summaryPushed, setSummaryPushed] = useState(false);
const [summaryError, setSummaryError] = useState(null);
// ── File reading ─────────────────────────────────────────────────────────────
function readFile(file) {
if (!file || !file.name.endsWith('.csv')) {
alert('Please upload a .csv file.');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target.result;
const hdrs = parseCSVHeaders(text);
setFileName(file.name);
setRawText(text);
setHeaders(hdrs);
// Default columns
const tsDefault = hdrs.find(h => /timestamp|time/i.test(h)) ?? hdrs[0] ?? '';
const noteDefault = hdrs.find(h => /note|label|event/i.test(h)) ?? hdrs[hdrs.length - 1] ?? '';
const frameDefault = hdrs.find(h => /frame/i.test(h)) ?? '';
setTimestampCol(tsDefault);
setNoteCol(noteDefault);
setFrameCol(frameDefault);
setFrameCheck(null);
setPhase('columns');
};
reader.readAsText(file);
}
const handleDrop = useCallback((e) => {
e.preventDefault();
setIsDragOver(false);
readFile(e.dataTransfer.files[0]);
}, []);
// ── Column selection → parse data ────────────────────────────────────────────
function loadData() {
setParsePending(true);
setTimeout(() => {
const { rows: parsed } = parseCSV(rawText);
const uniq = getUniqueNoteValues(parsed, noteCol);
setRows(parsed);
setUniqueNotes(uniq);
setClassifications({});
setSelectedNote(null);
if (frameCol) setFrameCheck(checkFrameConsecutive(parsed, frameCol));
setParsePending(false);
setPhase('classify');
}, 20);
}
// ── Classification helpers ───────────────────────────────────────────────────
function assign(note, category) {
setClassifications(prev => ({ ...prev, [note]: category }));
setSelectedNote(null);
}
function unassign(note) {
setClassifications(prev => { const n = { ...prev }; delete n[note]; return n; });
}
function addCategory() {
const name = newCatName.trim();
if (!name || categories.includes(name)) return;
setCategories(prev => [...prev, name]);
setCustomCategories(prev => [...prev, name]);
setNewCatName('');
setShowNewCat(false);
}
const unassignedNotes = uniqueNotes.filter(n => !classifications[n]);
const allAssigned = uniqueNotes.length > 0 && unassignedNotes.length === 0;
// ── Run analysis ─────────────────────────────────────────────────────────────
async function compute() {
const res = runAnalysis(rows, timestampCol, noteCol, classifications);
setResults(res);
setPhase('results');
if (!dailyStatusId) return;
setSaving(true);
setSaveError(null);
try {
const saved = await analysesApi.create(dailyStatusId, {
file_name: fileName || null,
timestamp_col: timestampCol,
note_col: noteCol,
classifications,
total_note_rows: res.totalNoteRows,
session_end_ts: res.sessionEndTs,
sequence: res.sequence,
category_counts: res.categoryCounts,
consecutive_stats: res.consecutiveStats,
runs: res.runs,
});
setSavedAnalysisId(saved.id);
onSaved?.(saved);
} catch (err) {
setSaveError(err.message ?? 'Failed to save analysis');
} finally {
setSaving(false);
}
}
// ── Push summary to daily status ─────────────────────────────────────────────
async function pushSummary() {
if (!dailyStatusId || !results) return;
setSummaryPushing(true);
setSummaryPushed(false);
setSummaryError(null);
try {
const counts = {};
for (const [cat, data] of Object.entries(results.categoryCounts)) {
counts[cat] = data.total;
}
const total = Object.values(counts).reduce((a, b) => a + b, 0);
const successRate = counts['Success'] != null ? counts['Success'] / total : null;
await dailyStatusesApi.updateAnalysisSummary(dailyStatusId, {
counts,
total,
success_rate: successRate,
source_analysis_id: savedAnalysisId ?? null,
});
setSummaryPushed(true);
onSummaryPushed?.({ counts, total, success_rate: successRate });
} catch (err) {
setSummaryError(err.message ?? 'Failed to update');
} finally {
setSummaryPushing(false);
}
}
// ── Reset ────────────────────────────────────────────────────────────────────
function reset() {
setPhase('upload');
setRawText('');
setHeaders([]);
setRows([]);
setUniqueNotes([]);
setClassifications({});
setResults(null);
setSavedAnalysisId(null);
setSummaryPushed(false);
setSummaryError(null);
setCategories(DEFAULT_CATEGORIES);
setCustomCategories([]);
setFrameCheck(null);
}
// ── Render ───────────────────────────────────────────────────────────────────
return (
<div className="mt-8 border-t border-gray-200 pt-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">CSV Analysis</h2>
{phase !== 'upload' && (
<button type="button" onClick={reset} className="text-xs text-gray-400 hover:text-gray-600">
Start over
</button>
)}
</div>
{/* ── PHASE: upload ── */}
{phase === 'upload' && (
<div
onDragOver={(e) => { e.preventDefault(); setIsDragOver(true); }}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`
border-2 border-dashed rounded-xl p-10 text-center cursor-pointer transition-all
${isDragOver ? 'border-indigo-400 bg-indigo-50' : 'border-gray-200 hover:border-gray-300 bg-gray-50'}
`}
>
<input ref={fileInputRef} type="file" accept=".csv" className="hidden"
onChange={(e) => readFile(e.target.files[0])} />
<p className="text-sm text-gray-500">
{isDragOver ? 'Drop CSV file to upload' : 'Drop a CSV file here, or click to browse'}
</p>
<p className="text-xs text-gray-400 mt-1">Columns will be read; data stays in your browser</p>
</div>
)}
{/* ── PHASE: columns ── */}
{phase === 'columns' && (
<div className="space-y-4">
<p className="text-sm text-gray-600">
<span className="font-medium">{fileName}</span> loaded {headers.length} columns detected. Select the columns to analyse.
</p>
<div className="grid grid-cols-2 gap-4 max-w-lg">
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Timestamp column</label>
<select value={timestampCol} onChange={(e) => setTimestampCol(e.target.value)}
className="w-full text-sm border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400">
{headers.map(h => <option key={h} value={h}>{h}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">Note column</label>
<select value={noteCol} onChange={(e) => setNoteCol(e.target.value)}
className="w-full text-sm border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400">
{headers.map(h => <option key={h} value={h}>{h}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Frame number column <span className="font-normal text-gray-400">(gap check)</span>
</label>
<select value={frameCol} onChange={(e) => setFrameCol(e.target.value)}
className="w-full text-sm border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-indigo-400">
<option value=""> skip </option>
{headers.map(h => <option key={h} value={h}>{h}</option>)}
</select>
</div>
</div>
<Button onClick={loadData} disabled={parsePending}>
{parsePending ? 'Parsing…' : 'Load note values →'}
</Button>
</div>
)}
{/* ── PHASE: classify ── */}
{phase === 'classify' && (
<div className="space-y-5">
{/* Frame gap check results */}
{frameCheck && (
<div className={`rounded-lg border px-3 py-2.5 text-xs ${frameCheck.consecutive ? 'bg-green-50 border-green-200 text-green-800' : 'bg-amber-50 border-amber-200 text-amber-800'}`}>
<p className="font-semibold mb-1">
Frame check ({frameCol}): {frameCheck.consecutive ? `✓ all ${frameCheck.total} frames consecutive` : `⚠ issues found in ${frameCheck.total} frames`}
</p>
{frameCheck.gaps.length > 0 && (
<div className="mb-1">
<span className="font-medium">{frameCheck.gaps.length} gap{frameCheck.gaps.length !== 1 ? 's' : ''}:</span>
<ul className="mt-0.5 space-y-0.5 max-h-32 overflow-y-auto font-mono">
{frameCheck.gaps.map((g, i) => (
<li key={i}>row {g.rowIndex + 1}: expected {g.expected}, got {g.actual} (jump +{g.jump})</li>
))}
</ul>
</div>
)}
{frameCheck.duplicates.length > 0 && (
<div>
<span className="font-medium">{frameCheck.duplicates.length} duplicate{frameCheck.duplicates.length !== 1 ? 's' : ''}:</span>
<ul className="mt-0.5 space-y-0.5 font-mono max-h-20 overflow-y-auto">
{frameCheck.duplicates.map((d, i) => (
<li key={i}>row {d.rowIndex + 1}: frame {d.value}</li>
))}
</ul>
</div>
)}
</div>
)}
<p className="text-sm text-gray-600">
Found <span className="font-semibold">{uniqueNotes.length}</span> unique note value{uniqueNotes.length !== 1 ? 's' : ''}.
{selectedNote && (
<span className="ml-2 text-indigo-600 font-medium">
Selected: <span className="font-mono">{selectedNote}</span> click a category to assign
</span>
)}
</p>
{/* Unassigned chips */}
{unassignedNotes.length > 0 && (
<div>
<p className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-1.5">Unassigned</p>
<div className="flex flex-wrap gap-2">
{unassignedNotes.map(note => (
<NoteChip key={note} value={note} category={null} colors={null}
selected={selectedNote === note}
onSelect={() => setSelectedNote(prev => prev === note ? null : note)}
draggable onDragStart={() => setDraggedNote(note)}
/>
))}
</div>
</div>
)}
{/* Category buckets */}
<div className="grid grid-cols-3 gap-3">
{categories.map(cat => {
const colors = getCategoryColors(cat, customCategories);
const notesInCat = uniqueNotes.filter(n => classifications[n] === cat);
return (
<CategoryBucket key={cat} name={cat} notes={notesInCat} colors={colors}
isOver={overBucket === cat}
onDragOver={() => setOverBucket(cat)}
onDragLeave={() => setOverBucket(null)}
onDrop={() => {
if (draggedNote) { assign(draggedNote, cat); setDraggedNote(null); setOverBucket(null); }
}}
onClickAssign={() => { if (selectedNote) assign(selectedNote, cat); }}
onRemoveNote={(note) => unassign(note)}
/>
);
})}
</div>
{/* Add new category */}
<div className="flex items-center gap-2">
{showNewCat ? (
<>
<input autoFocus value={newCatName} onChange={(e) => setNewCatName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addCategory(); if (e.key === 'Escape') setShowNewCat(false); }}
placeholder="Category name…"
className="text-sm border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-indigo-400 w-40" />
<Button size="sm" type="button" onClick={addCategory}>Add</Button>
<button type="button" onClick={() => setShowNewCat(false)} className="text-xs text-gray-400 hover:text-gray-600">Cancel</button>
</>
) : (
<button type="button" onClick={() => setShowNewCat(true)}
className="text-xs text-indigo-500 hover:text-indigo-700 border border-dashed border-indigo-300 rounded-full px-3 py-0.5 hover:border-indigo-500 transition-colors">
+ New category
</button>
)}
</div>
{/* Unclassified warning */}
{!allAssigned && uniqueNotes.length > 0 && (
<p className="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded px-3 py-1.5">
{unassignedNotes.length} unassigned note value{unassignedNotes.length !== 1 ? 's' : ''} will be excluded from the analysis.
</p>
)}
<Button onClick={compute} disabled={uniqueNotes.length === 0}>
Run analysis
</Button>
</div>
)}
{/* ── PHASE: results ── */}
{phase === 'results' && results && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-500">
Analysed <span className="font-semibold">{results.totalNoteRows}</span> note rows
· columns: <span className="font-mono text-xs">{timestampCol}</span> / <span className="font-mono text-xs">{noteCol}</span>
</p>
{dailyStatusId && (
<span className="text-xs">
{saving && <span className="text-gray-400">Saving</span>}
{!saving && !saveError && results && <span className="text-green-600"> Saved</span>}
{saveError && <span className="text-red-500" title={saveError}> Save failed</span>}
</span>
)}
</div>
{/* Category counts */}
<div>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Category Counts</h3>
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Category</th>
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Note breakdown</th>
<th className="text-right px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Total</th>
</tr>
</thead>
<tbody>
{Object.entries(results.categoryCounts)
.sort(([, a], [, b]) => b.total - a.total)
.map(([cat, data]) => {
const colors = getCategoryColors(cat, customCategories);
const breakdown = Object.entries(data.byNote)
.sort(([, a], [, b]) => b - a)
.map(([n, c]) => `${n}: ${c}`)
.join(' · ');
return (
<tr key={cat} className="border-b border-gray-100">
<td className="px-3 py-2">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-semibold ${colors.bg} ${colors.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
{cat}
</span>
</td>
<td className="px-3 py-2 text-gray-500 font-mono text-xs">{breakdown}</td>
<td className="px-3 py-2 text-right font-semibold text-gray-800">{data.total}</td>
</tr>
);
})}
{Object.keys(results.categoryCounts).length === 0 && (
<tr><td colSpan={3} className="px-3 py-4 text-center text-gray-400 italic text-sm">No classified notes</td></tr>
)}
</tbody>
</table>
</div>
{/* Consecutive stats */}
{Object.keys(results.consecutiveStats).length > 0 && (
<div>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">Consecutive Runs</h3>
<p className="text-xs text-gray-400 mb-2">
How many times each category appeared in a row (run-length encoding of the category sequence, ordered by timestamp).
</p>
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Category</th>
<th className="text-right px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Total runs</th>
<th className="text-right px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Max run</th>
<th className="text-right px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Avg run</th>
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide">Run lengths</th>
</tr>
</thead>
<tbody>
{Object.entries(results.consecutiveStats)
.sort(([, a], [, b]) => b.totalRuns - a.totalRuns)
.map(([cat, s]) => {
const colors = getCategoryColors(cat, customCategories);
const runSummary = s.runLengths
.reduce((acc, len) => {
acc[len] = (acc[len] || 0) + 1;
return acc;
}, {});
const runDisplay = Object.entries(runSummary)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([len, cnt]) => `${len}×${cnt}`)
.join(', ');
return (
<tr key={cat} className="border-b border-gray-100">
<td className="px-3 py-2">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-semibold ${colors.bg} ${colors.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
{cat}
</span>
</td>
<td className="px-3 py-2 text-right text-gray-700">{s.totalRuns}</td>
<td className="px-3 py-2 text-right text-gray-700">{s.maxRun}</td>
<td className="px-3 py-2 text-right text-gray-700">{s.avgRun.toFixed(2)}</td>
<td className="px-3 py-2 text-gray-500 font-mono text-xs">{runDisplay}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<RunSequenceView
runs={results.runs}
sequence={results.sequence}
customCategories={customCategories}
sessionEndTs={results.sessionEndTs}
/>
{/* Save metrics to daily status */}
{dailyStatusId && (
<div className="pt-3 border-t border-gray-100">
<div className="flex items-center gap-3 flex-wrap">
<button type="button" onClick={pushSummary} disabled={summaryPushing}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors">
{summaryPushing ? 'Saving…' : summaryPushed ? '✓ Metrics saved to daily status' : 'Save metrics to daily status'}
</button>
{summaryError && <span className="text-xs text-red-500">{summaryError}</span>}
</div>
{!summaryPushed && (
<p className="text-[10px] text-gray-400 mt-1">
Saves success / failure counts, total attempts, and success rate to this day's record for cross-day and cross-subject graphing.
</p>
)}
</div>
)}
<div className="flex gap-3 pt-2 border-t border-gray-100">
<Button variant="secondary" onClick={() => setPhase('classify')}> Reclassify</Button>
<Button variant="secondary" onClick={reset}>Start over</Button>
</div>
</div>
)}
</div>
);
}
+145 -57
View File
@@ -1,12 +1,25 @@
import React, { useState, useEffect } from 'react';
import { format } from 'date-fns';
import Button from './ui/Button';
import FormField, { Input, Textarea } from './ui/FormField';
import { Input, Textarea } from './ui/FormField';
import Alert from './ui/Alert';
import { dailyStatusesApi } from '../api/client';
import { dailyStatusesApi, experimentsApi } from '../api/client';
const BUILTIN_KEYS = new Set(['experiment_description', 'vitals', 'treatment', 'notes']);
// Maps field width % to a 12-column grid span
function widthToSpan(pct) {
if (pct <= 8) return 1;
if (pct <= 17) return 2;
if (pct <= 25) return 3;
if (pct <= 33) return 4;
if (pct <= 42) return 5;
if (pct <= 50) return 6;
if (pct <= 67) return 8;
if (pct <= 75) return 9;
return 12;
}
/** Extract the value for a field from a status record.
* Custom fields are keyed by fieldId (stable UUID), not by key (display slug). */
function getValue(status, field) {
@@ -15,14 +28,14 @@ function getValue(status, field) {
return status.custom_fields?.[field.fieldId] ?? '';
}
export default function DailyStatusForm({ existing, animalId, template = [], onSuccess, onCancel }) {
export default function DailyStatusForm({ existing, animalId, experimentId, template = [], onTemplateUpdate, onSuccess, onCancel, onRequestDelete, hasPrev, hasNext, onNavigate }) {
const today = format(new Date(), 'yyyy-MM-dd');
// Active fields from template (date is always first and handled separately)
const activeFields = template.filter((f) => f.active);
function buildInitialValues() {
const vals = { date: existing ? format(new Date(existing.date), 'yyyy-MM-dd') : today };
const vals = { date: existing ? String(existing.date).slice(0, 10) : today };
for (const f of activeFields) {
vals[f.key] = getValue(existing, f);
}
@@ -33,6 +46,7 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS
const [errors, setErrors] = useState({});
const [apiError, setApiError] = useState(null);
const [loading, setLoading] = useState(false);
const [savingTagFor, setSavingTagFor] = useState(null); // fieldId currently being saved
useEffect(() => {
setValues(buildInitialValues());
@@ -42,6 +56,25 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS
const set = (key) => (e) => setValues((v) => ({ ...v, [key]: e.target.value }));
async function saveTag(field) {
const value = (values[field.key] ?? '').trim();
if (!value || !experimentId || !onTemplateUpdate) return;
setSavingTagFor(field.fieldId);
try {
const updatedTemplate = template.map((f) =>
f.fieldId === field.fieldId
? { ...f, tags: [...(f.tags ?? []), value] }
: f
);
await experimentsApi.updateTemplate(experimentId, updatedTemplate);
onTemplateUpdate(updatedTemplate);
} catch (err) {
// non-critical — silently ignore tag save errors
} finally {
setSavingTagFor(null);
}
}
function validate() {
const e = {};
if (!values.date) {
@@ -53,38 +86,46 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS
return Object.keys(e).length === 0;
}
async function doSave() {
const builtin = {};
const custom = {};
for (const f of activeFields) {
const val = values[f.key] || null;
if (f.builtin) {
builtin[f.key] = val;
} else {
custom[f.fieldId] = val;
}
}
const existingCustom = existing?.custom_fields ?? {};
const mergedCustom = { ...existingCustom, ...custom };
const payload = { date: values.date, ...builtin, custom_fields: mergedCustom };
return existing
? await dailyStatusesApi.update(existing.id, payload)
: await dailyStatusesApi.create({ ...payload, animal_id: animalId });
}
async function handleSubmit(e) {
e.preventDefault();
if (!validate()) return;
setLoading(true);
setApiError(null);
try {
// Separate builtin values from custom_fields
const builtin = {};
const custom = {};
for (const f of activeFields) {
const val = values[f.key] || null;
if (f.builtin) {
builtin[f.key] = val;
} else {
// Always key by fieldId so renaming or recreating a field never
// collides with or orphans data from a different field.
custom[f.fieldId] = val;
}
}
onSuccess(await doSave());
} catch (err) {
setApiError(err);
} finally {
setLoading(false);
}
}
// Preserve custom_fields for fields not in the current active template
// (e.g. a field was hidden after this record was created).
const existingCustom = existing?.custom_fields ?? {};
const mergedCustom = { ...existingCustom, ...custom };
const payload = { date: values.date, ...builtin, custom_fields: mergedCustom };
const result = existing
? await dailyStatusesApi.update(existing.id, payload)
: await dailyStatusesApi.create({ ...payload, animal_id: animalId });
onSuccess(result);
async function handleNavigate(direction) {
if (!validate()) return;
setLoading(true);
setApiError(null);
try {
const result = await doSave();
onNavigate(direction, result);
} catch (err) {
setApiError(err);
} finally {
@@ -93,36 +134,62 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS
}
return (
<form onSubmit={handleSubmit} noValidate className="space-y-4">
<form onSubmit={handleSubmit} noValidate>
{apiError && (
<Alert type="error" message={apiError.message} details={apiError.details} onDismiss={() => setApiError(null)} />
)}
{/* Date — always present */}
<FormField label="Date" name="date" error={errors.date} required>
<Input type="date" name="date" value={values.date} onChange={set('date')} required disabled={loading} />
</FormField>
<div className="grid grid-cols-12 gap-x-3 gap-y-2 mb-4">
{/* Date — always full width */}
<div className="col-span-12 flex items-center gap-2">
<label className="text-xs font-medium text-gray-500 whitespace-nowrap w-28 shrink-0 text-right">Date <span className="text-red-400">*</span></label>
<div className="w-36">
<Input type="date" name="date" value={values.date} onChange={set('date')} required disabled={loading} />
{errors.date && <p className="text-xs text-red-500 mt-0.5">{errors.date}</p>}
</div>
</div>
{/* Dynamic fields from template */}
{activeFields.map((field) => (
<FormField key={field.key} label={field.label} name={field.key}>
{field.type === 'textarea' ? (
<Textarea
name={field.key}
value={values[field.key] ?? ''}
onChange={set(field.key)}
disabled={loading}
/>
) : (
<Input
name={field.key}
value={values[field.key] ?? ''}
onChange={set(field.key)}
disabled={loading}
/>
)}
</FormField>
))}
{/* Dynamic fields */}
{activeFields.map((field) => {
const isTextarea = field.type === 'textarea';
const span = isTextarea ? 12 : widthToSpan(field.width ?? 100);
const fieldTags = field.tags ?? [];
const currentValue = (values[field.key] ?? '').trim();
const canSaveTag = experimentId && onTemplateUpdate && currentValue && !fieldTags.includes(currentValue) && !field.tagsDisabled;
return (
<div key={field.key} className={`col-span-${span} flex items-start gap-2`}>
<label className="text-xs font-medium text-gray-500 whitespace-nowrap w-28 shrink-0 text-right pt-1.5" title={field.label}>
{field.abbr || field.label}{field.unit ? <span className="font-normal text-gray-400"> ({field.unit})</span> : null}
</label>
<div className="flex-1 min-w-0">
{isTextarea ? (
<Textarea name={field.key} value={values[field.key] ?? ''} onChange={set(field.key)} disabled={loading} />
) : (
<Input name={field.key} value={values[field.key] ?? ''} onChange={set(field.key)} disabled={loading} />
)}
{(fieldTags.length > 0 || canSaveTag) && (
<div className="flex flex-wrap items-center gap-1.5 mt-1">
{fieldTags.map((tag) => (
<button key={tag} type="button" title={`Quick-fill: ${tag}`}
onClick={() => setValues((v) => ({ ...v, [field.key]: tag }))}
className="inline-flex items-center px-2 py-0.5 rounded-full bg-indigo-50 border border-indigo-200 text-indigo-700 text-xs font-medium hover:bg-indigo-100 transition-colors">
{tag}
</button>
))}
{canSaveTag && (
<button type="button" onClick={() => saveTag(field)} disabled={savingTagFor === field.fieldId}
title="Save current value as a quick-fill tag"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-dashed border-indigo-300 text-indigo-500 text-xs hover:border-indigo-500 hover:text-indigo-700 transition-colors disabled:opacity-50">
{savingTagFor === field.fieldId ? '…' : '+ Save as tag'}
</button>
)}
</div>
)}
</div>
</div>
);
})}
</div>
{activeFields.length === 0 && (
<p className="text-sm text-gray-400 italic text-center py-2">
@@ -130,9 +197,30 @@ export default function DailyStatusForm({ existing, animalId, template = [], onS
</p>
)}
<div className="flex justify-end gap-3 pt-2">
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>Cancel</Button>
<Button type="submit" loading={loading}>{existing ? 'Save Changes' : 'Log Status'}</Button>
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
<div>
{existing && onRequestDelete && (
<Button variant="danger" type="button" onClick={onRequestDelete} disabled={loading}>Delete entry</Button>
)}
</div>
<div className="flex items-center gap-3">
{existing && onNavigate && (
<div className="flex gap-1">
<button type="button" onClick={() => handleNavigate('prev')} disabled={loading || !hasPrev}
title="Save & go to previous entry"
className="px-2 py-1 rounded border border-gray-300 text-gray-500 text-sm hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
</button>
<button type="button" onClick={() => handleNavigate('next')} disabled={loading || !hasNext}
title="Save & go to next entry"
className="px-2 py-1 rounded border border-gray-300 text-gray-500 text-sm hover:bg-gray-50 disabled:opacity-30 disabled:cursor-not-allowed transition-colors">
</button>
</div>
)}
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>Cancel</Button>
<Button type="submit" loading={loading}>{existing ? 'Save Changes' : 'Log Status'}</Button>
</div>
</div>
</form>
);
@@ -0,0 +1,225 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
format, startOfMonth, endOfMonth, eachDayOfInterval,
getDay, addMonths, subMonths,
} from 'date-fns';
import { experimentsApi } from '../api/client';
import Modal from './ui/Modal';
// ── Day detail panel rendered inside modal ─────────────────────────────────────
function DayDetail({ statuses, animals, template }) {
const animalMap = useMemo(
() => Object.fromEntries(animals.map((a) => [a.id, a])),
[animals],
);
const activeFields = template.filter((f) => f.active);
function getVal(status, field) {
if (field.builtin) return status[field.key];
return status.custom_fields?.[field.fieldId];
}
if (statuses.length === 0) {
return <p className="text-gray-500 text-sm py-2">No data recorded for this day.</p>;
}
return (
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
{statuses.map((status) => {
const animal = animalMap[status.animal_id];
const nonEmptyFields = activeFields.filter((f) => {
const v = getVal(status, f);
return v !== null && v !== undefined && String(v).trim() !== '';
});
return (
<div key={status.id} className="bg-gray-50 rounded-xl p-4 border border-gray-100">
<div className="font-semibold text-gray-900 mb-2 text-sm">
{animal?.animal_name ?? 'Unknown subject'}
{animal?.animal_id_string && (
<span className="ml-2 text-gray-400 font-normal">· {animal.animal_id_string}</span>
)}
</div>
{nonEmptyFields.length === 0 ? (
<p className="text-xs text-gray-400">No field values recorded.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1">
{nonEmptyFields.map((field) => (
<div key={field.fieldId ?? field.key} className="text-sm">
<span className="text-gray-500 font-medium">{field.label}:</span>{' '}
<span className="text-gray-900 whitespace-pre-wrap">{String(getVal(status, field))}</span>
</div>
))}
</div>
)}
</div>
);
})}
</div>
);
}
// ── Main calendar component ────────────────────────────────────────────────────
export default function ExperimentCalendar({ experimentId, template, allStatuses, animals }) {
const [currentMonth, setCurrentMonth] = useState(() => new Date());
const [selectedField, setSelectedField] = useState(null);
const [calendarDays, setCalendarDays] = useState({});
const [loading, setLoading] = useState(false);
const [selectedDate, setSelectedDate] = useState(null);
// Build field options from active template fields
const fieldOptions = useMemo(
() =>
template
.filter((f) => f.active)
.map((f) => ({
value: f.builtin ? f.key : f.fieldId,
label: f.label,
})),
[template],
);
// Default: first field whose label contains "weight", else first active field
useEffect(() => {
if (fieldOptions.length === 0 || selectedField) return;
const weightField = fieldOptions.find((f) =>
f.label.toLowerCase().includes('weight'),
);
setSelectedField(weightField ? weightField.value : fieldOptions[0].value);
}, [fieldOptions]); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch calendar counts whenever field changes
useEffect(() => {
if (!selectedField) return;
let cancelled = false;
setLoading(true);
experimentsApi
.getCalendar(experimentId, selectedField)
.then((data) => { if (!cancelled) setCalendarDays(data.days ?? {}); })
.catch(() => { if (!cancelled) setCalendarDays({}); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [experimentId, selectedField]);
// Build the grid: leading empty cells + each day of the month
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
const leadingPad = getDay(monthStart); // 0 = Sunday
// Statuses filtered to the selected day
const selectedDayStatuses = useMemo(() => {
if (!selectedDate) return [];
const key = format(selectedDate, 'yyyy-MM-dd');
return allStatuses.filter((s) => s.date.slice(0, 10) === key);
}, [selectedDate, allStatuses]);
return (
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
{/* Controls */}
<div className="flex flex-wrap items-center gap-3 mb-5">
<span className="text-sm text-gray-600 font-medium">Count by field:</span>
<select
data-testid="field-select"
className="text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
value={selectedField ?? ''}
onChange={(e) => setSelectedField(e.target.value)}
disabled={fieldOptions.length === 0}
>
{fieldOptions.map((f) => (
<option key={f.value} value={f.value}>{f.label}</option>
))}
{fieldOptions.length === 0 && <option value="">No fields configured</option>}
</select>
{loading && (
<span className="text-xs text-gray-400" aria-live="polite">Loading</span>
)}
</div>
{/* Month navigation */}
<div className="flex items-center justify-between mb-4">
<button
aria-label="Previous month"
onClick={() => setCurrentMonth((m) => subMonths(m, 1))}
className="p-2 rounded-lg hover:bg-gray-100 text-gray-500 text-lg leading-none"
>
</button>
<h3 className="font-semibold text-gray-900 text-base">
{format(currentMonth, 'MMMM yyyy')}
</h3>
<button
aria-label="Next month"
onClick={() => setCurrentMonth((m) => addMonths(m, 1))}
className="p-2 rounded-lg hover:bg-gray-100 text-gray-500 text-lg leading-none"
>
</button>
</div>
{/* Day-of-week headers */}
<div className="grid grid-cols-7 mb-1">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
<div key={d} className="text-center text-xs font-medium text-gray-400 py-1 select-none">
{d}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: leadingPad }).map((_, i) => (
<div key={`pad-${i}`} />
))}
{days.map((day) => {
const dateStr = format(day, 'yyyy-MM-dd');
const count = calendarDays[dateStr] ?? 0;
const hasData = count > 0;
return (
<button
key={dateStr}
data-testid={`day-${dateStr}`}
disabled={!hasData}
onClick={() => setSelectedDate(day)}
className={[
'relative flex flex-col items-center justify-center rounded-lg py-1.5 min-h-[2.75rem] text-sm select-none transition-all',
hasData
? 'bg-blue-50 border border-blue-200 hover:bg-blue-100 cursor-pointer'
: 'text-gray-300 cursor-default',
].join(' ')}
>
<span className={hasData ? 'font-semibold text-blue-900 text-xs' : 'text-xs'}>
{day.getDate()}
</span>
{hasData && (
<span
data-testid={`count-${dateStr}`}
className="text-[10px] leading-none font-bold text-blue-600 mt-0.5"
>
{count}
</span>
)}
</button>
);
})}
</div>
{/* Day detail modal */}
{selectedDate && (
<Modal
isOpen={!!selectedDate}
onClose={() => setSelectedDate(null)}
title={format(selectedDate, 'MMMM d, yyyy')}
size="lg"
>
<DayDetail
statuses={selectedDayStatuses}
animals={animals}
template={template}
/>
</Modal>
)}
</div>
);
}
+343
View File
@@ -0,0 +1,343 @@
import React, { useState, useEffect, useMemo } from 'react';
// ── Colors ─────────────────────────────────────────────────────────────────────
const BASE_COLORS = {
Success: { bg: 'bg-green-100', border: 'border-green-300', text: 'text-green-800', dot: 'bg-green-500', badge: 'bg-green-500' },
Failure: { bg: 'bg-red-100', border: 'border-red-300', text: 'text-red-800', dot: 'bg-red-500', badge: 'bg-red-500' },
Other: { bg: 'bg-gray-100', border: 'border-gray-300', text: 'text-gray-700', dot: 'bg-gray-400', badge: 'bg-gray-400' },
};
const EXTRA_COLORS = [
{ bg: 'bg-indigo-100', border: 'border-indigo-300', text: 'text-indigo-800', dot: 'bg-indigo-500', badge: 'bg-indigo-500' },
{ bg: 'bg-yellow-100', border: 'border-yellow-300', text: 'text-yellow-800', dot: 'bg-yellow-500', badge: 'bg-yellow-500' },
{ bg: 'bg-purple-100', border: 'border-purple-300', text: 'text-purple-800', dot: 'bg-purple-500', badge: 'bg-purple-500' },
{ bg: 'bg-pink-100', border: 'border-pink-300', text: 'text-pink-800', dot: 'bg-pink-500', badge: 'bg-pink-500' },
];
function getColors(cat, customCats = []) {
if (BASE_COLORS[cat]) return BASE_COLORS[cat];
return EXTRA_COLORS[Math.max(0, customCats.indexOf(cat)) % EXTRA_COLORS.length];
}
const ORDINALS = ['1st', '2nd', '3rd', '4th', '5th'];
// ── Helpers ────────────────────────────────────────────────────────────────────
/** Build display items from a sequence slice, respecting mode + collapsibility. */
function buildItems(seqSlice, mode, collapsible) {
const categorised = seqSlice.filter(s => s.category);
if (mode === 'discrete') {
return categorised.map((s, i) => ({ id: i, category: s.category, count: 1 }));
}
// Rebuild runs within this slice, then apply collapsibility
const runs = [];
for (const s of categorised) {
if (runs.length === 0 || runs[runs.length - 1].category !== s.category) {
runs.push({ category: s.category, length: 1 });
} else {
runs[runs.length - 1].length++;
}
}
const items = [];
for (const run of runs) {
if (collapsible[run.category] === false) {
for (let i = 0; i < run.length; i++) items.push({ id: items.length, category: run.category, count: 1 });
} else {
items.push({ id: items.length, category: run.category, count: run.length });
}
}
return items;
}
/** Compute per-category counts and total for a sequence slice. */
function segStats(seqSlice) {
const byCat = {};
for (const s of seqSlice) {
if (!s.category) continue;
byCat[s.category] = (byCat[s.category] || 0) + 1;
}
const total = Object.values(byCat).reduce((a, b) => a + b, 0);
return { byCat, total };
}
// ── Component ──────────────────────────────────────────────────────────────────
/**
* Props:
* runs {category, length}[] — global run-length-encoded sequence
* sequence {note, category, timestamp}[]
* customCategories string[]
*/
export default function RunSequenceView({ runs = [], sequence = [], customCategories = [], sessionEndTs = null }) {
const [mode, setMode] = useState('grouped');
const [collapsible, setCollapsible] = useState({});
const [splitN, setSplitN] = useState(1);
const [durationMins, setDurationMins] = useState(20);
const [selectedSegment, setSelectedSegment] = useState(null);
// Initialise collapsibility for any new categories
const allCats = useMemo(() => [...new Set(runs.map(r => r.category))], [runs]);
useEffect(() => {
setCollapsible(prev => {
const next = { ...prev };
for (const cat of allCats) {
if (!(cat in next)) next[cat] = cat !== 'Success';
}
return next;
});
}, [allCats]);
// Reset selected segment when split changes
useEffect(() => { setSelectedSegment(null); }, [splitN]);
// ── Timestamp bounds ─────────────────────────────────────────────────────────
const { startTs, segSize, windowUnderrun } = useMemo(() => {
let endT = sessionEndTs != null ? sessionEndTs : null;
if (endT === null) {
const ts = sequence.map(s => parseFloat(s.timestamp)).filter(t => !isNaN(t));
endT = ts.length ? Math.max(...ts) : durationMins * 60;
}
const rawStart = endT - durationMins * 60;
return {
startTs: rawStart,
segSize: (durationMins * 60) / Math.max(splitN, 1),
windowUnderrun: rawStart < 0 ? Math.abs(rawStart) : 0,
};
}, [sequence, sessionEndTs, durationMins, splitN]);
function segIndexOf(timestamp) {
const t = parseFloat(timestamp);
if (isNaN(t)) return 0;
return Math.min(Math.max(Math.floor((t - startTs) / segSize), 0), splitN - 1);
}
// ── Segment data (only when split > 1) ───────────────────────────────────────
const segmentData = useMemo(() => {
if (splitN === 1) return null;
const groups = Array.from({ length: splitN }, () => []);
for (const s of sequence) {
groups[segIndexOf(s.timestamp)].push(s);
}
return groups.map((grp, i) => ({
index: i,
items: buildItems(grp, mode, collapsible),
stats: segStats(grp),
}));
}, [splitN, sequence, mode, collapsible, startTs, segSize]);
// ── Single-segment display items (split = 1) ─────────────────────────────────
const singleItems = useMemo(() => {
if (splitN !== 1) return [];
if (mode === 'discrete') {
const src = sequence.length > 0
? sequence.filter(s => s.category)
: runs.flatMap(r => Array.from({ length: r.length }, () => ({ category: r.category })));
return src.map((s, i) => ({ id: i, category: s.category, count: 1 }));
}
const items = [];
for (const run of runs) {
if (collapsible[run.category] === false) {
for (let i = 0; i < run.length; i++) items.push({ id: items.length, category: run.category, count: 1 });
} else {
items.push({ id: items.length, category: run.category, count: run.length });
}
}
return items;
}, [splitN, mode, runs, sequence, collapsible]);
// ── Global totals ─────────────────────────────────────────────────────────────
const globalStats = useMemo(() => segStats(sequence), [sequence]);
if (runs.length === 0) return null;
const hasTimestamps = sequence.some(s => !isNaN(parseFloat(s.timestamp)));
const activeSegStats = selectedSegment !== null && segmentData ? segmentData[selectedSegment]?.stats ?? null : null;
// ── Render ────────────────────────────────────────────────────────────────────
return (
<div>
{/* ── Controls row ── */}
<div className="flex items-center flex-wrap gap-x-4 gap-y-1.5 mb-2">
{/* Title */}
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
Run sequence ({runs.length} runs)
</p>
{/* Mode toggle */}
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
<button type="button" onClick={() => setMode('discrete')}
className={`px-2 py-0.5 transition-colors ${mode === 'discrete' ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
Discrete
</button>
<button type="button" onClick={() => setMode('grouped')}
className={`px-2 py-0.5 border-l border-gray-200 transition-colors ${mode === 'grouped' ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
Grouped
</button>
</div>
{/* Time split */}
{hasTimestamps && (
<div className="flex items-center gap-1.5 text-xs">
<span className="text-gray-400">Split:</span>
<select value={splitN} onChange={e => setSplitN(Number(e.target.value))}
className="border border-gray-200 rounded px-1.5 py-0.5 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
<option value={1}>None</option>
<option value={2}>½</option>
<option value={3}></option>
<option value={4}>¼</option>
<option value={5}></option>
</select>
<input
type="number" min={1} max={600} value={durationMins}
onChange={e => setDurationMins(Math.max(1, Number(e.target.value)))}
className="border border-gray-200 rounded px-1.5 py-0.5 w-14 text-xs text-right focus:outline-none focus:ring-1 focus:ring-indigo-400"
title="Experiment duration in minutes"
/>
<span className="text-gray-400">min</span>
</div>
)}
</div>
{/* ── Per-category collapse toggles (grouped mode) ── */}
{mode === 'grouped' && allCats.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-2">
{allCats.map(cat => {
const c = getColors(cat, customCategories);
const isGrouped = collapsible[cat] !== false;
return (
<button key={cat} type="button"
onClick={() => setCollapsible(prev => ({ ...prev, [cat]: !prev[cat] }))}
title={isGrouped ? 'Grouped — click for discrete' : 'Discrete — click to group'}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs border transition-colors
${isGrouped ? `${c.bg} ${c.border} ${c.text}` : `bg-white ${c.border} ${c.text} opacity-70`}`}>
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
{cat}: {isGrouped ? 'grouped' : 'discrete'}
</button>
);
})}
</div>
)}
{/* ── Underrun warning ── */}
{splitN > 1 && windowUnderrun > 0 && (() => {
const u = Math.round(windowUnderrun);
const m = Math.floor(u / 60), s = u % 60;
return (
<p className="text-[10px] text-amber-600 bg-amber-50 border border-amber-200 rounded px-2 py-1 mb-2">
The {durationMins}-min window starts {m}:{String(s).padStart(2,'0')} before the recording the 1st segment includes unrecorded time.
</p>
);
})()}
{/* ── Segment buttons (when split > 1) ── */}
{splitN > 1 && segmentData && (
<div className="flex flex-wrap gap-1.5 mb-2">
{segmentData.map((seg, si) => {
const isActive = selectedSegment === si;
const segStart = startTs + si * segSize;
const segEnd = startTs + (si + 1) * segSize;
const fmt = s => {
const t = Math.round(s);
const clamped = Math.max(0, t);
const m = Math.floor(clamped / 60);
const sec = clamped % 60;
const prefix = t < 0 ? '0:00*' : `${m}:${String(sec).padStart(2, '0')}`;
return prefix;
};
return (
<button key={si} type="button"
onClick={() => setSelectedSegment(prev => prev === si ? null : si)}
title={`${fmt(segStart)} ${fmt(segEnd)}`}
className={`inline-flex flex-col items-center px-2.5 py-1 rounded-lg text-xs font-medium border transition-all
${isActive ? 'bg-gray-800 text-white border-gray-800' : 'bg-white text-gray-600 border-gray-300 hover:border-gray-400'}`}>
<span className="flex items-center gap-1.5">
{ORDINALS[si]}
<span className={`text-[10px] px-1 py-px rounded-full font-semibold
${isActive ? 'bg-white/20 text-white' : 'bg-gray-100 text-gray-500'}`}>
{seg.stats.total}
</span>
</span>
<span className={`text-[9px] font-normal tabular-nums ${isActive ? 'text-gray-300' : 'text-gray-400'}`}>
{fmt(segStart)}{fmt(segEnd)}
</span>
</button>
);
})}
{selectedSegment !== null && (
<button type="button" onClick={() => setSelectedSegment(null)}
className="text-xs text-gray-400 hover:text-gray-600 px-1"></button>
)}
</div>
)}
{/* ── Selected segment stats ── */}
{activeSegStats && (
<div className="flex flex-wrap items-center gap-3 mb-2 px-3 py-2 bg-gray-50 rounded-lg border border-gray-200 text-xs">
<span className="font-semibold text-gray-700">{ORDINALS[selectedSegment]} segment</span>
<span className="text-gray-500">{activeSegStats.total} attempt{activeSegStats.total !== 1 ? 's' : ''}</span>
{Object.entries(activeSegStats.byCat).sort(([, a], [, b]) => b - a).map(([cat, n]) => {
const c = getColors(cat, customCategories);
return (
<span key={cat} className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full ${c.bg} ${c.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
{cat}: {n}
</span>
);
})}
</div>
)}
{/* ── Chip area ── */}
<div className="flex flex-wrap gap-1 items-center">
{splitN === 1
? singleItems.map(item => <Chip key={item.id} item={item} customCategories={customCategories} dimmed={false} />)
: segmentData && segmentData.map((seg, si) => (
<React.Fragment key={si}>
{si > 0 && (
<div className="flex flex-col items-center mx-0.5 self-stretch" aria-hidden>
<div className="w-px flex-1 bg-gray-300" style={{ minHeight: 20 }} />
</div>
)}
{seg.items.length === 0
? <span className="text-[10px] text-gray-300 italic px-1">empty</span>
: seg.items.map(item => (
<Chip key={`${si}-${item.id}`} item={item} customCategories={customCategories}
dimmed={selectedSegment !== null && selectedSegment !== si} />
))}
</React.Fragment>
))
}
</div>
{/* ── Global totals ── */}
<div className="flex flex-wrap items-center gap-3 mt-2 pt-2 border-t border-gray-100 text-xs text-gray-500">
<span className="font-medium text-gray-700">Total: {globalStats.total} attempt{globalStats.total !== 1 ? 's' : ''}</span>
{Object.entries(globalStats.byCat).sort(([, a], [, b]) => b - a).map(([cat, n]) => {
const c = getColors(cat, customCategories);
return (
<span key={cat} className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full ${c.bg} ${c.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
{cat}: {n}
</span>
);
})}
</div>
</div>
);
}
function Chip({ item, customCategories, dimmed }) {
const c = getColors(item.category, customCategories);
return (
<span
title={item.count > 1 ? `${item.category} × ${item.count}` : item.category}
className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-mono border transition-opacity
${c.bg} ${c.text} ${c.border} ${dimmed ? 'opacity-25' : 'opacity-100'}`}>
{item.count > 1 ? `${item.count}×` : ''}{item.category.slice(0, 3)}
</span>
);
}
+146
View File
@@ -0,0 +1,146 @@
import React, { useState, useEffect } from 'react';
import Button from './ui/Button';
import { Input, Textarea } from './ui/FormField';
function widthToSpan(pct) {
if (pct <= 8) return 1;
if (pct <= 17) return 2;
if (pct <= 25) return 3;
if (pct <= 33) return 4;
if (pct <= 42) return 5;
if (pct <= 50) return 6;
if (pct <= 67) return 8;
if (pct <= 75) return 9;
return 12;
}
import Alert from './ui/Alert';
import { animalsApi, experimentsApi } from '../api/client';
export default function SubjectInfoForm({ animal, subjectTemplate = [], experimentId, onTemplateUpdate, onSaved, onCancel }) {
const activeFields = subjectTemplate.filter((f) => f.active);
function buildInitialValues() {
const vals = {};
for (const f of activeFields) {
vals[f.key] = animal?.subject_info?.[f.fieldId] ?? '';
}
return vals;
}
const [values, setValues] = useState(buildInitialValues);
const [apiError, setApiError] = useState(null);
const [loading, setLoading] = useState(false);
const [savingTagFor, setSavingTagFor] = useState(null);
useEffect(() => {
setValues(buildInitialValues());
setApiError(null);
}, [animal, subjectTemplate]);
const set = (key) => (e) => setValues((v) => ({ ...v, [key]: e.target.value }));
async function saveTag(field) {
const value = (values[field.key] ?? '').trim();
if (!value || !experimentId || !onTemplateUpdate) return;
setSavingTagFor(field.fieldId);
try {
const updatedTemplate = subjectTemplate.map((f) =>
f.fieldId === field.fieldId
? { ...f, tags: [...(f.tags ?? []), value] }
: f
);
await experimentsApi.updateSubjectTemplate(experimentId, updatedTemplate);
onTemplateUpdate(updatedTemplate);
} catch (_) {
} finally {
setSavingTagFor(null);
}
}
async function handleSubmit(e) {
e.preventDefault();
setLoading(true);
setApiError(null);
try {
// Preserve any fields not in the current active template
const existing = animal?.subject_info ?? {};
const updates = {};
for (const f of activeFields) {
updates[f.fieldId] = values[f.key] || null;
}
const merged = { ...existing, ...updates };
const updated = await animalsApi.update(animal.id, { subject_info: merged });
onSaved(updated);
} catch (err) {
setApiError(err);
} finally {
setLoading(false);
}
}
if (activeFields.length === 0) {
return (
<div className="text-center py-6">
<p className="text-sm text-gray-400 italic">No subject info fields configured for this experiment.</p>
<p className="text-xs text-gray-400 mt-1">Use "Subject Template" on the experiment page to add fields.</p>
<div className="flex justify-end mt-4">
<Button variant="secondary" type="button" onClick={onCancel}>Close</Button>
</div>
</div>
);
}
return (
<form onSubmit={handleSubmit} noValidate>
{apiError && (
<Alert type="error" message={apiError.message} details={apiError.details} onDismiss={() => setApiError(null)} />
)}
<div className="grid grid-cols-12 gap-x-3 gap-y-2 mb-4">
{activeFields.map((field) => {
const isTextarea = field.type === 'textarea';
const span = isTextarea ? 12 : widthToSpan(field.width ?? 100);
const fieldTags = field.tags ?? [];
const currentValue = (values[field.key] ?? '').trim();
const canSaveTag = experimentId && onTemplateUpdate && currentValue && !fieldTags.includes(currentValue) && !field.tagsDisabled;
return (
<div key={field.fieldId} className={`col-span-${span} flex items-start gap-2`}>
<label className="text-xs font-medium text-gray-500 whitespace-nowrap w-28 shrink-0 text-right pt-1.5">{field.label}</label>
<div className="flex-1 min-w-0">
{isTextarea ? (
<Textarea name={field.key} value={values[field.key] ?? ''} onChange={set(field.key)} disabled={loading} />
) : (
<Input name={field.key} value={values[field.key] ?? ''} onChange={set(field.key)} disabled={loading} />
)}
{(fieldTags.length > 0 || canSaveTag) && (
<div className="flex flex-wrap items-center gap-1.5 mt-1">
{fieldTags.map((tag) => (
<button key={tag} type="button" title={`Quick-fill: ${tag}`}
onClick={() => setValues((v) => ({ ...v, [field.key]: tag }))}
className="inline-flex items-center px-2 py-0.5 rounded-full bg-indigo-50 border border-indigo-200 text-indigo-700 text-xs font-medium hover:bg-indigo-100 transition-colors">
{tag}
</button>
))}
{canSaveTag && (
<button type="button" onClick={() => saveTag(field)} disabled={savingTagFor === field.fieldId}
title="Save current value as a quick-fill tag"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-dashed border-indigo-300 text-indigo-500 text-xs hover:border-indigo-500 hover:text-indigo-700 transition-colors disabled:opacity-50">
{savingTagFor === field.fieldId ? '…' : '+ Save as tag'}
</button>
)}
</div>
)}
</div>
</div>
);
})}
</div>
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
<Button variant="secondary" type="button" onClick={onCancel} disabled={loading}>Cancel</Button>
<Button type="submit" loading={loading}>Save</Button>
</div>
</form>
);
}
+322 -90
View File
@@ -1,21 +1,111 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { experimentsApi } from '../api/client';
import Button from './ui/Button';
import Alert from './ui/Alert';
// Slugify a label into a valid field key
// ── helpers ────────────────────────────────────────────────────────────────────
function toKey(label) {
return label
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64);
return label.toLowerCase().trim()
.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 64);
}
function FieldRow({ field, onChange, onToggle, onRemove, allKeys }) {
// ── Tag chip ───────────────────────────────────────────────────────────────────
function TagChip({ value, onRemove }) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-indigo-50 border border-indigo-200 text-indigo-700 text-xs font-medium">
{value}
<button
type="button"
aria-label={`Remove tag ${value}`}
onClick={onRemove}
className="text-indigo-400 hover:text-indigo-700 leading-none"
></button>
</span>
);
}
// ── Inline tag editor (expanded panel below a field row) ──────────────────────
function TagPanel({ field, onChange, onAddTag, onRemoveTag }) {
const [draft, setDraft] = useState('');
const [err, setErr] = useState('');
const disabled = !!field.tagsDisabled;
function commit() {
const v = draft.trim();
if (!v) { setErr('Tag cannot be empty'); return; }
if (v.length > 512) { setErr('Tag must be ≤512 characters'); return; }
if ((field.tags ?? []).includes(v)) { setErr('Tag already exists'); return; }
setErr('');
onAddTag(field.fieldId, v);
setDraft('');
}
return (
<div className="px-4 pb-3 pt-2 bg-indigo-50/40 border-t border-indigo-100 rounded-b-lg">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold text-indigo-600 uppercase tracking-wide">Quick-fill tags</p>
<label className="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer select-none">
<input
type="checkbox"
checked={disabled}
onChange={(e) => onChange({ ...field, tagsDisabled: e.target.checked })}
className="w-3.5 h-3.5 accent-indigo-600"
/>
Disable tagging
</label>
</div>
{!disabled && (
<>
<div className="flex flex-wrap gap-1.5 mb-2 min-h-[24px]">
{(field.tags ?? []).length === 0 && (
<span className="text-xs text-gray-400 italic">No tags yet.</span>
)}
{(field.tags ?? []).map((t) => (
<TagChip key={t} value={t} onRemove={() => onRemoveTag(field.fieldId, t)} />
))}
</div>
<div className="flex gap-2 items-start">
<div className="flex-1">
<input
aria-label={`Add tag for ${field.label}`}
className={`w-full text-sm rounded border px-2 py-1 focus:outline-none focus:ring-2 focus:ring-indigo-400 ${err ? 'border-red-400' : 'border-gray-300'}`}
placeholder="Type a tag value…"
value={draft}
onChange={(e) => { setDraft(e.target.value); setErr(''); }}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } }}
/>
{err && <p className="text-xs text-red-500 mt-0.5">{err}</p>}
</div>
<Button size="sm" type="button" onClick={commit}>+ Add</Button>
</div>
</>
)}
{disabled && (
<p className="text-xs text-gray-400 italic">Tagging is disabled for this field. Existing tags are preserved.</p>
)}
</div>
);
}
// ── Single field row (draggable) ───────────────────────────────────────────────
function FieldRow({
field, index, total,
onChange, onToggle, onRemove, onAddTag, onRemoveTag,
allKeys,
// drag props
onDragStart, onDragEnter, onDragEnd,
isDragOver,
}) {
const [labelDraft, setLabelDraft] = useState(field.label);
const [labelError, setLabelError] = useState('');
const [showTags, setShowTags] = useState(false);
useEffect(() => { setLabelDraft(field.label); }, [field.label]);
@@ -23,115 +113,248 @@ function FieldRow({ field, onChange, onToggle, onRemove, allKeys }) {
const trimmed = labelDraft.trim();
if (!trimmed) { setLabelError('Label cannot be empty'); return; }
if (trimmed.length > 128) { setLabelError('Label must be ≤128 characters'); return; }
// For custom fields, also ensure the derived key is unique
if (!field.builtin) {
const newKey = toKey(trimmed);
if (!newKey) { setLabelError('Label must contain at least one alphanumeric character'); return; }
const duplicate = allKeys.find((k) => k !== field.key && k === newKey);
if (duplicate) { setLabelError('A field with this name already exists'); return; }
if (allKeys.find((k) => k !== field.key && k === newKey)) {
setLabelError('A field with this name already exists'); return;
}
}
setLabelError('');
onChange({ ...field, label: trimmed, key: field.builtin ? field.key : toKey(trimmed) });
}
return (
<div className={`flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors ${field.active ? 'bg-white border-gray-200' : 'bg-gray-50 border-gray-100 opacity-60'}`}>
{/* Drag handle placeholder (visual only) */}
<span className="text-gray-300 cursor-default select-none text-lg leading-none"></span>
const tagCount = (field.tags ?? []).length;
{/* Label input */}
<div className="flex-1 min-w-0">
<input
aria-label={`Field label for ${field.key}`}
className={`w-full text-sm rounded border px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 ${labelError ? 'border-red-400' : 'border-gray-300'} ${!field.active ? 'bg-gray-100' : 'bg-white'}`}
value={labelDraft}
return (
<div
className={`rounded-lg border transition-all ${isDragOver ? 'border-blue-400 shadow-md scale-[1.01]' : 'border-gray-200'} ${field.active ? 'bg-white' : 'bg-gray-50 opacity-60'}`}
draggable
onDragStart={() => onDragStart(index)}
onDragEnter={() => onDragEnter(index)}
onDragEnd={onDragEnd}
onDragOver={(e) => e.preventDefault()}
>
{/* Main row */}
<div className="flex items-center gap-3 px-3 py-2.5">
{/* Drag handle */}
<span
className="text-gray-300 hover:text-gray-500 cursor-grab active:cursor-grabbing select-none text-lg leading-none"
title="Drag to reorder"
aria-label="Drag handle"
></span>
{/* Label input */}
<div className="flex-1 min-w-0">
<input
aria-label={`Field label for ${field.key}`}
className={`w-full text-sm rounded border px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 ${labelError ? 'border-red-400' : 'border-gray-300'} ${!field.active ? 'bg-gray-100' : 'bg-white'}`}
value={labelDraft}
disabled={!field.active}
onChange={(e) => { setLabelDraft(e.target.value); setLabelError(''); }}
onBlur={commitLabel}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitLabel(); } }}
/>
{labelError && <p className="text-xs text-red-500 mt-0.5">{labelError}</p>}
<p className="text-xs text-gray-400 mt-0.5 font-mono">
key: {field.key}
{field.fieldId && (
<span className="ml-2 text-gray-300" title={`Stable field ID: ${field.fieldId}`}>
· id: {field.fieldId.slice(0, 8)}
</span>
)}
</p>
</div>
{/* Type selector */}
<select
aria-label={`Field type for ${field.key}`}
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
value={field.type}
disabled={!field.active}
onChange={(e) => { setLabelDraft(e.target.value); setLabelError(''); }}
onBlur={commitLabel}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitLabel(); } }}
onChange={(e) => onChange({ ...field, type: e.target.value })}
>
<option value="text">Short text</option>
<option value="textarea">Long text</option>
</select>
{/* Width (text fields only) */}
{field.type === 'text' && (
<select
aria-label={`Width for ${field.key}`}
title="Input width in the entry form"
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
value={field.width ?? 100}
disabled={!field.active}
onChange={(e) => onChange({ ...field, width: Number(e.target.value) })}
>
<option value={100}>Full</option>
<option value={75}>3/4</option>
<option value={67}>2/3</option>
<option value={50}>1/2</option>
<option value={42}>5/12</option>
<option value={33}>1/3</option>
<option value={25}>1/4</option>
<option value={17}>1/6</option>
<option value={8}>1/12</option>
</select>
)}
{/* Abbreviation */}
<input
aria-label={`Abbreviation for ${field.key}`}
className="text-xs border border-gray-200 rounded px-2 py-1 w-20 focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
placeholder="Abbr"
title="Column abbreviation (≤10 chars)"
maxLength={10}
value={field.abbr ?? ''}
disabled={!field.active}
onChange={(e) => onChange({ ...field, abbr: e.target.value })}
/>
{labelError && <p className="text-xs text-red-500 mt-0.5">{labelError}</p>}
<p className="text-xs text-gray-400 mt-0.5 font-mono">
key: {field.key}
{field.fieldId && (
<span className="ml-2 text-gray-300" title={`Stable field ID: ${field.fieldId}`}>
· id: {field.fieldId.slice(0, 8)}
{/* Unit */}
<input
aria-label={`Unit for ${field.key}`}
className="text-xs border border-gray-200 rounded px-2 py-1 w-20 focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
placeholder="Unit"
title="Measurement unit (≤10 chars)"
maxLength={10}
value={field.unit ?? ''}
disabled={!field.active}
onChange={(e) => onChange({ ...field, unit: e.target.value })}
/>
{/* Tags toggle */}
<button
type="button"
title={showTags ? 'Hide tags' : 'Manage quick-fill tags'}
aria-label={`Manage tags for ${field.label}`}
onClick={() => setShowTags((v) => !v)}
className={`relative text-sm px-2 py-1 rounded border transition-colors ${
showTags
? 'border-indigo-400 bg-indigo-50 text-indigo-700'
: 'border-gray-200 text-gray-500 hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-700'
}`}
>
🏷
{tagCount > 0 && (
<span className="absolute -top-1.5 -right-1.5 bg-indigo-600 text-white text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center leading-none">
{tagCount > 9 ? '9+' : tagCount}
</span>
)}
</p>
</button>
{/* Hide / Show */}
<button
type="button"
title={field.active ? 'Hide field' : 'Show field'}
aria-label={field.active ? `Hide ${field.label}` : `Show ${field.label}`}
onClick={() => onToggle(field.fieldId)}
className={`text-sm px-2 py-1 rounded border transition-colors ${
field.active
? 'border-gray-200 text-gray-500 hover:bg-yellow-50 hover:border-yellow-300 hover:text-yellow-700'
: 'border-green-200 text-green-600 hover:bg-green-50'
}`}
>
{field.active ? 'Hide' : 'Show'}
</button>
{/* Remove (custom fields only) */}
{!field.builtin && (
<button
type="button"
title="Remove field permanently"
aria-label={`Remove ${field.label}`}
onClick={() => onRemove(field.fieldId)}
className="text-sm px-2 py-1 rounded border border-red-200 text-red-500 hover:bg-red-50 transition-colors"
></button>
)}
</div>
{/* Type badge */}
<select
aria-label={`Field type for ${field.key}`}
className="text-xs border border-gray-200 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 disabled:bg-gray-100"
value={field.type}
disabled={!field.active}
onChange={(e) => onChange({ ...field, type: e.target.value })}
>
<option value="text">Short text</option>
<option value="textarea">Long text</option>
</select>
{/* Toggle active */}
<button
title={field.active ? 'Hide field' : 'Show field'}
aria-label={field.active ? `Hide field ${field.label}` : `Show field ${field.label}`}
onClick={() => onToggle(field.key)}
className={`text-sm px-2 py-1 rounded border transition-colors ${
field.active
? 'border-gray-200 text-gray-500 hover:bg-yellow-50 hover:border-yellow-300 hover:text-yellow-700'
: 'border-green-200 text-green-600 hover:bg-green-50'
}`}
>
{field.active ? 'Hide' : 'Show'}
</button>
{/* Remove — only for custom fields */}
{!field.builtin && (
<button
title="Remove field permanently"
aria-label={`Remove field ${field.label}`}
onClick={() => onRemove(field.key)}
className="text-sm px-2 py-1 rounded border border-red-200 text-red-500 hover:bg-red-50 transition-colors"
>
</button>
{/* Tag panel (collapsible) */}
{showTags && (
<TagPanel field={field} onChange={onChange} onAddTag={onAddTag} onRemoveTag={onRemoveTag} />
)}
</div>
);
}
export default function TemplateEditor({ experimentId, onClose }) {
// ── Main TemplateEditor ────────────────────────────────────────────────────────
export default function TemplateEditor({ experimentId, onClose, loadFn, saveFn }) {
const [fields, setFields] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
// Add-field form
const [newLabel, setNewLabel] = useState('');
const [newType, setNewType] = useState('text');
const [addError, setAddError] = useState('');
// Drag state
const dragIndex = useRef(null);
const [dragOverIndex, setDragOverIndex] = useState(null);
useEffect(() => {
experimentsApi.getTemplate(experimentId)
.then(setFields)
.catch((e) => setError(e.message))
.finally(() => setLoading(false));
const fn = loadFn ?? (() => experimentsApi.getTemplate(experimentId));
fn().then(setFields).catch((e) => setError(e.message)).finally(() => setLoading(false));
}, [experimentId]);
// ── field mutation helpers ─────────────────────────────────────────────────
function updateField(updated) {
setFields((prev) => prev.map((f) => f.key === updated.key ? updated : f));
setFields((prev) => prev.map((f) => f.fieldId === updated.fieldId ? updated : f));
}
function toggleField(fieldId) {
setFields((prev) => prev.map((f) => f.fieldId === fieldId ? { ...f, active: !f.active } : f));
}
function removeField(fieldId) {
setFields((prev) => prev.filter((f) => f.fieldId !== fieldId));
}
function toggleField(key) {
setFields((prev) => prev.map((f) => f.key === key ? { ...f, active: !f.active } : f));
// ── tag helpers ────────────────────────────────────────────────────────────
function addTag(fieldId, value) {
setFields((prev) => prev.map((f) =>
f.fieldId === fieldId
? { ...f, tags: [...(f.tags ?? []), value] }
: f
));
}
function removeTag(fieldId, value) {
setFields((prev) => prev.map((f) =>
f.fieldId === fieldId
? { ...f, tags: (f.tags ?? []).filter((t) => t !== value) }
: f
));
}
function removeField(key) {
setFields((prev) => prev.filter((f) => f.key !== key));
// ── drag handlers ──────────────────────────────────────────────────────────
function handleDragStart(index) {
dragIndex.current = index;
}
function handleDragEnter(index) {
setDragOverIndex(index);
}
function handleDragEnd() {
const from = dragIndex.current;
const to = dragOverIndex;
if (from !== null && to !== null && from !== to) {
setFields((prev) => {
const next = [...prev];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
});
}
dragIndex.current = null;
setDragOverIndex(null);
}
// ── add new field ──────────────────────────────────────────────────────────
function addField() {
const trimmed = newLabel.trim();
@@ -141,18 +364,21 @@ export default function TemplateEditor({ experimentId, onClose }) {
if (fields.some((f) => f.key === key)) { setAddError(`A field with key "${key}" already exists`); return; }
setAddError('');
const fieldId = crypto.randomUUID();
setFields((prev) => [...prev, { fieldId, key, label: trimmed, type: newType, builtin: false, active: true }]);
setFields((prev) => [...prev, { fieldId, key, label: trimmed, type: newType, builtin: false, active: true, tags: [] }]);
setNewLabel('');
setNewType('text');
}
// ── save ───────────────────────────────────────────────────────────────────
async function save() {
setSaving(true);
setError(null);
try {
await experimentsApi.updateTemplate(experimentId, fields);
const fn = saveFn ?? ((f) => experimentsApi.updateTemplate(experimentId, f));
await fn(fields);
setSuccess(true);
setTimeout(() => { setSuccess(false); onClose(fields); }, 800);
setTimeout(() => { setSuccess(false); onClose(fields); }, 700);
} catch (e) {
setError(e.message);
} finally {
@@ -165,7 +391,7 @@ export default function TemplateEditor({ experimentId, onClose }) {
return (
<div className="space-y-4">
{loading && <p className="text-sm text-gray-400">Loading template</p>}
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{success && <Alert type="success" message="Template saved!" />}
{!loading && (
@@ -175,14 +401,22 @@ export default function TemplateEditor({ experimentId, onClose }) {
{fields.length === 0 && (
<p className="text-sm text-gray-400 italic text-center py-4">No fields yet. Add one below.</p>
)}
{fields.map((field) => (
{fields.map((field, index) => (
<FieldRow
key={field.key}
key={field.fieldId}
field={field}
index={index}
total={fields.length}
onChange={updateField}
onToggle={toggleField}
onRemove={removeField}
onAddTag={addTag}
onRemoveTag={removeTag}
allKeys={allKeys}
onDragStart={handleDragStart}
onDragEnter={handleDragEnter}
onDragEnd={handleDragEnd}
isDragOver={dragOverIndex === index}
/>
))}
</div>
@@ -211,14 +445,12 @@ export default function TemplateEditor({ experimentId, onClose }) {
<option value="text">Short text</option>
<option value="textarea">Long text</option>
</select>
<Button size="sm" onClick={addField} type="button">+ Add</Button>
<Button size="sm" type="button" onClick={addField}>+ Add</Button>
</div>
</div>
{/* Info note */}
<p className="text-xs text-gray-400">
<strong>Hide</strong> removes a builtin field from forms while preserving its data.
<strong className="ml-1"></strong> permanently removes a custom field (data in existing records is not deleted).
Drag <span className="font-mono"></span> to reorder · <strong>Hide</strong> removes a builtin field from forms while preserving its data · <strong>🏷</strong> manages quick-fill tags
</p>
{/* Actions */}
+25 -4
View File
@@ -1,16 +1,37 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import Modal from './Modal';
import Button from './Button';
export default function ConfirmDialog({ isOpen, onClose, onConfirm, title, message, loading }) {
export default function ConfirmDialog({ isOpen, onClose, onConfirm, title, message, loading, confirmWord }) {
const [typed, setTyped] = useState('');
useEffect(() => { if (!isOpen) setTyped(''); }, [isOpen]);
const ready = confirmWord ? typed === confirmWord : true;
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="sm">
<p className="text-sm text-gray-600 mb-6">{message}</p>
<p className="text-sm text-gray-600 mb-4">{message}</p>
{confirmWord && (
<div className="mb-5">
<label className="block text-xs text-gray-500 mb-1">
Type <span className="font-mono font-semibold text-gray-700">{confirmWord}</span> to confirm
</label>
<input
type="text"
value={typed}
onChange={(e) => setTyped(e.target.value)}
autoFocus
className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-red-300 focus:border-red-400"
placeholder={confirmWord}
/>
</div>
)}
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button variant="danger" onClick={onConfirm} loading={loading}>
<Button variant="danger" onClick={onConfirm} loading={loading} disabled={!ready}>
Delete
</Button>
</div>
+1 -1
View File
@@ -10,7 +10,7 @@ export default function Modal({ isOpen, onClose, title, children, size = 'md' })
if (!isOpen) return null;
const widths = { sm: 'max-w-md', md: 'max-w-lg', lg: 'max-w-2xl', xl: 'max-w-4xl' };
const widths = { sm: 'max-w-md', md: 'max-w-lg', lg: 'max-w-2xl', xl: 'max-w-4xl', full: 'w-[75vw] max-w-none' };
return (
<div
+220
View File
@@ -0,0 +1,220 @@
/**
* CSV Analysis Library
*
* Method summary:
*
* 1. PARSING
* - parseCSVHeaders(text): reads only the first line, splits on comma
* (handles double-quoted fields with embedded commas).
* - parseCSV(text): full parse into array-of-objects. Each row is
* { [headerName]: value, ... }. Empty trailing fields are preserved as ''.
*
* 2. UNIQUE VALUES
* - getUniqueNoteValues(rows, noteCol): scans each row's noteCol,
* trims whitespace, ignores empty strings, returns sorted unique values.
*
* 3. ANALYSIS (runAnalysis)
* Input:
* rows — full parsed rows
* timestampCol — column name to sort by (numeric sort; falls back to row order)
* noteCol — column containing note values
* classifications — plain object: { noteValue: categoryName }
* unclassified notes are included in the sequence
* but excluded from category counts and run stats.
*
* Steps:
* a) Filter to rows where noteCol is non-empty.
* b) Sort by timestampCol (parseFloat; NaN values go to the end).
* c) Map each row to { note, category, timestamp }.
* d) CATEGORY COUNTS: for each category, count total occurrences
* and per-note-value breakdown.
* e) RUN-LENGTH ENCODING (consecutive stat):
* Walk the category sequence. Each time the category changes,
* start a new run. Uncategorised notes are skipped (do not break
* or continue a run). This gives an ordered list of
* { category, length } objects representing every streak.
* f) CONSECUTIVE STATS per category:
* From the runs list, group run lengths per category and compute
* totalRuns, maxRun, avgRun, and the full runLengths array.
*
* Output: { totalNoteRows, sequence, categoryCounts, runs, consecutiveStats }
*/
// ── CSV parsing ────────────────────────────────────────────────────────────────
function splitCSVLine(line) {
const fields = [];
let field = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') {
if (inQuotes && line[i + 1] === '"') { field += '"'; i++; }
else { inQuotes = !inQuotes; }
} else if (ch === ',' && !inQuotes) {
fields.push(field);
field = '';
} else {
field += ch;
}
}
fields.push(field);
return fields;
}
/** Return column names from the first line of a CSV string. */
export function parseCSVHeaders(text) {
const firstLine = text.split(/\r?\n/)[0];
return splitCSVLine(firstLine);
}
/** Parse the full CSV into { headers, rows }. */
export function parseCSV(text) {
const lines = text.split(/\r?\n/);
const headers = splitCSVLine(lines[0]);
const rows = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line.trim()) continue;
const values = splitCSVLine(line);
const row = {};
for (let j = 0; j < headers.length; j++) {
row[headers[j]] = values[j] ?? '';
}
rows.push(row);
}
return { headers, rows };
}
// ── Unique note values ─────────────────────────────────────────────────────────
/** Return sorted unique non-empty values found in noteCol. */
export function getUniqueNoteValues(rows, noteCol) {
const seen = new Set();
for (const row of rows) {
const v = (row[noteCol] ?? '').trim();
if (v) seen.add(v);
}
return [...seen].sort();
}
// ── Frame gap detection ────────────────────────────────────────────────────────
/**
* Check whether a numeric column is strictly consecutive (no gaps, no duplicates).
* Returns { consecutive: bool, gaps: [{index, expected, actual, jump}], duplicates: [{index, value}] }
* Only examines rows where the column value parses as an integer.
*/
export function checkFrameConsecutive(rows, frameCol) {
const entries = [];
for (let i = 0; i < rows.length; i++) {
const v = rows[i][frameCol];
if (v == null || String(v).trim() === '') continue;
const n = parseInt(v, 10);
if (isNaN(n)) continue;
entries.push({ rowIndex: i, value: n });
}
const gaps = [];
const duplicates = [];
for (let i = 1; i < entries.length; i++) {
const diff = entries[i].value - entries[i - 1].value;
if (diff === 0) {
duplicates.push({ rowIndex: entries[i].rowIndex, value: entries[i].value });
} else if (diff !== 1) {
gaps.push({
rowIndex: entries[i].rowIndex,
expected: entries[i - 1].value + 1,
actual: entries[i].value,
jump: diff,
});
}
}
return { consecutive: gaps.length === 0 && duplicates.length === 0, gaps, duplicates, total: entries.length };
}
// ── Analysis ───────────────────────────────────────────────────────────────────
/**
* Run the full analysis.
*
* @param {object[]} rows - Parsed CSV rows
* @param {string} timestampCol - Column to sort by
* @param {string} noteCol - Column with note values
* @param {object} classifications - { noteValue: categoryName }
* @returns {object} Analysis result
*/
export function runAnalysis(rows, timestampCol, noteCol, classifications) {
// Compute session end from ALL rows (not just note rows)
let sessionEndTs = null;
for (const r of rows) {
const t = parseFloat(r[timestampCol]);
if (!isNaN(t) && (sessionEndTs === null || t > sessionEndTs)) sessionEndTs = t;
}
// a) Filter non-empty notes
const noteRows = rows.filter(r => (r[noteCol] ?? '').trim() !== '');
// b) Sort by timestamp (numeric); preserve original order for equal/NaN timestamps
noteRows.sort((a, b) => {
const ta = parseFloat(a[timestampCol]);
const tb = parseFloat(b[timestampCol]);
if (isNaN(ta) && isNaN(tb)) return 0;
if (isNaN(ta)) return 1;
if (isNaN(tb)) return -1;
return ta - tb;
});
// c) Build sequence
const sequence = noteRows.map(r => ({
note: r[noteCol].trim(),
category: classifications[r[noteCol].trim()] ?? null,
timestamp: r[timestampCol],
}));
// d) Category counts
const categoryCounts = {};
for (const { note, category } of sequence) {
if (!category) continue;
if (!categoryCounts[category]) categoryCounts[category] = { total: 0, byNote: {} };
categoryCounts[category].total++;
categoryCounts[category].byNote[note] = (categoryCounts[category].byNote[note] || 0) + 1;
}
// e) Run-length encoding (skip uncategorised entries — they neither break nor extend a run)
const runs = [];
for (const { category } of sequence) {
if (!category) continue;
if (runs.length === 0 || runs[runs.length - 1].category !== category) {
runs.push({ category, length: 1 });
} else {
runs[runs.length - 1].length++;
}
}
// f) Consecutive stats per category
const consecutiveStats = {};
for (const { category, length } of runs) {
if (!consecutiveStats[category]) {
consecutiveStats[category] = { runLengths: [], totalRuns: 0, maxRun: 0, avgRun: 0 };
}
const s = consecutiveStats[category];
s.runLengths.push(length);
s.totalRuns++;
if (length > s.maxRun) s.maxRun = length;
}
for (const cat of Object.keys(consecutiveStats)) {
const s = consecutiveStats[cat];
s.avgRun = s.runLengths.reduce((a, b) => a + b, 0) / s.runLengths.length;
}
return {
totalNoteRows: noteRows.length,
sessionEndTs,
sequence,
categoryCounts,
runs,
consecutiveStats,
};
}
+410 -67
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { useParams, useNavigate, Link, useLocation } from 'react-router-dom';
import { animalsApi, dailyStatusesApi, experimentsApi } from '../api/client';
import { format } from 'date-fns';
@@ -6,8 +6,10 @@ import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import DailyStatusForm from '../components/DailyStatusForm';
import SubjectInfoForm from '../components/SubjectInfoForm';
import Alert from '../components/ui/Alert';
import AuditLogSection from '../components/AuditLogSection';
import { SubjectAnalysisCharts } from '../components/AnalysisCharts';
/** Read the value for a template field from a status record.
* Custom fields are keyed by fieldId, not by the display key. */
@@ -16,6 +18,92 @@ function getFieldValue(status, field) {
return status.custom_fields?.[field.fieldId];
}
// ── Inline cell editor ─────────────────────────────────────────────────────────
function InlineCell({ statusId, field, value, currentStatus, onSaved }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(null);
const inputRef = useRef(null);
const isTextarea = field.type === 'textarea';
function startEdit(e) {
e.preventDefault();
e.stopPropagation();
setDraft(value ?? '');
setSaveError(null);
setEditing(true);
}
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
if (inputRef.current.select) inputRef.current.select();
}
}, [editing]);
async function commit() {
setEditing(false);
const newValue = draft.trim() || null;
if (newValue === (value?.trim() || null)) return;
setSaving(true);
setSaveError(null);
try {
let body;
if (field.builtin) {
body = { [field.key]: newValue };
} else {
body = { custom_fields: { ...currentStatus.custom_fields, [field.fieldId]: newValue } };
}
const updated = await dailyStatusesApi.update(statusId, body);
onSaved(updated);
} catch (err) {
setSaveError(err.message ?? 'Save failed');
} finally {
setSaving(false);
}
}
function cancel() { setEditing(false); }
if (editing) {
const sharedProps = {
ref: inputRef,
value: draft,
onChange: (e) => setDraft(e.target.value),
onBlur: commit,
onClick: (e) => e.stopPropagation(),
onKeyDown: (e) => {
if (e.key === 'Escape') { e.preventDefault(); cancel(); }
if (e.key === 'Enter' && !isTextarea) { e.preventDefault(); commit(); }
},
className: 'w-full border border-indigo-400 rounded px-1.5 py-0.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300 bg-white',
};
return isTextarea
? <textarea {...sharedProps} rows={3} className={sharedProps.className + ' resize-none'} />
: <input type="text" {...sharedProps} />;
}
return (
<div
onDoubleClick={startEdit}
title={editing ? '' : 'Double-click to edit'}
className={`min-h-[1.25rem] cursor-default rounded transition-colors px-0.5 -mx-0.5
${saving ? 'opacity-40' : 'hover:bg-indigo-50/60 group/ic'}`}
>
{saveError
? <span className="text-red-500 text-xs">{saveError}</span>
: value
? isTextarea
? <span className="line-clamp-2 whitespace-pre-wrap">{value}</span>
: <span className="truncate block">{value}</span>
: <span className="text-gray-200 group-hover/ic:text-gray-400 select-none text-xs">· · ·</span>
}
</div>
);
}
export default function AnimalDetail() {
const { id } = useParams();
const navigate = useNavigate();
@@ -23,7 +111,9 @@ export default function AnimalDetail() {
const [animal, setAnimal] = useState(null);
const [statuses, setStatuses] = useState([]);
const [template, setTemplate] = useState(location.state?.template ?? []);
const [template, setTemplate] = useState([]);
const [subjectTemplate, setSubjectTemplate] = useState([]);
const [showSubjectInfo, setShowSubjectInfo] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [successMsg, setSuccessMsg] = useState(null);
@@ -32,6 +122,40 @@ export default function AnimalDetail() {
const [editStatus, setEditStatus] = useState(null);
const [deleteStatus, setDeleteStatus] = useState(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [showDeleteSubject, setShowDeleteSubject] = useState(false);
const [deleteSubjectLoading, setDeleteSubjectLoading] = useState(false);
const [fillingGaps, setFillingGaps] = useState(false);
// Column visibility — persisted per experiment
const [hiddenCols, setHiddenCols] = useState(new Set());
const [showColPicker, setShowColPicker] = useState(false);
const colPickerRef = useRef(null);
useEffect(() => {
if (!animal) return;
const stored = localStorage.getItem(`col-vis-${animal.id}`);
setHiddenCols(stored ? new Set(JSON.parse(stored)) : new Set());
}, [animal?.id]);
function toggleCol(fieldId) {
setHiddenCols((prev) => {
const next = new Set(prev);
next.has(fieldId) ? next.delete(fieldId) : next.add(fieldId);
localStorage.setItem(`col-vis-${animal.id}`, JSON.stringify([...next]));
return next;
});
}
useEffect(() => {
if (!showColPicker) return;
function handleClick(e) {
if (colPickerRef.current && !colPickerRef.current.contains(e.target)) {
setShowColPicker(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [showColPicker]);
async function load() {
setLoading(true);
@@ -44,11 +168,12 @@ export default function AnimalDetail() {
setAnimal(animalData);
setStatuses(statusData);
// Fetch template if not passed via navigation state
if (!location.state?.template) {
const tmpl = await experimentsApi.getTemplate(animalData.experiment_id);
setTemplate(tmpl);
}
const [tmpl, subjTmpl] = await Promise.all([
experimentsApi.getTemplate(animalData.experiment_id),
experimentsApi.getSubjectTemplate(animalData.experiment_id),
]);
setTemplate(tmpl);
setSubjectTemplate(subjTmpl);
} catch (err) {
setError(err.message);
} finally {
@@ -75,6 +200,7 @@ export default function AnimalDetail() {
try {
await dailyStatusesApi.delete(deleteStatus.id);
setDeleteStatus(null);
setEditStatus(null);
load();
flash('Daily status removed.');
} catch (err) {
@@ -85,6 +211,32 @@ export default function AnimalDetail() {
}
}
async function fillGaps() {
setFillingGaps(true);
try {
await Promise.all(missingDates.map((date) => dailyStatusesApi.create({ date, animal_id: id })));
load();
flash(`Created ${missingDates.length} missing entr${missingDates.length === 1 ? 'y' : 'ies'}.`);
} catch (err) {
setError(err.message);
} finally {
setFillingGaps(false);
}
}
async function handleDeleteSubject() {
setDeleteSubjectLoading(true);
try {
await animalsApi.delete(animal.id);
navigate(`/experiments/${animal.experiment_id}`, { replace: true });
} catch (err) {
setError(err.message);
setShowDeleteSubject(false);
} finally {
setDeleteSubjectLoading(false);
}
}
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400" aria-live="polite" aria-busy="true">Loading</div>;
if (error) return (
<div className="max-w-5xl mx-auto px-4 py-8">
@@ -94,6 +246,23 @@ export default function AnimalDetail() {
);
const activeFields = template.filter((f) => f.active);
const visibleFields = activeFields.filter((f) => !hiddenCols.has(f.fieldId));
// Dates with entries
const existingDateSet = new Set(statuses.map((s) => String(s.date).slice(0, 10)));
const missingDates = (() => {
if (statuses.length < 2) return [];
const sorted = [...existingDateSet].sort();
const missing = [];
const cur = new Date(sorted[0] + 'T12:00:00');
const end = new Date(sorted[sorted.length - 1] + 'T12:00:00');
while (cur < end) {
cur.setDate(cur.getDate() + 1);
const d = cur.toISOString().slice(0, 10);
if (!existingDateSet.has(d)) missing.push(d);
}
return missing;
})();
return (
<div className="max-w-5xl mx-auto px-4 py-8">
@@ -121,85 +290,248 @@ export default function AnimalDetail() {
{successMsg && <Alert type="success" message={successMsg} />}
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{/* Subject information card */}
{subjectTemplate.filter((f) => f.active).length > 0 && (
<div className="bg-white border border-gray-200 rounded-xl p-5 mb-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">Subject Information</h2>
<button
type="button"
onClick={() => setShowSubjectInfo(true)}
className="text-xs text-gray-400 hover:text-blue-600 transition-colors inline-flex items-center gap-1"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828A2 2 0 0110 16.414V19h2.586a2 2 0 001.414-.586l6.5-6.5" />
</svg>
Edit
</button>
</div>
{(() => {
const filled = subjectTemplate.filter((f) => f.active && animal.subject_info?.[f.fieldId]);
if (filled.length === 0) {
return (
<button
type="button"
onClick={() => setShowSubjectInfo(true)}
className="text-sm text-gray-400 italic hover:text-blue-600 transition-colors"
>
No information recorded yet click to add
</button>
);
}
return (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
{filled.map((f) => (
<div key={f.fieldId} className={f.type === 'textarea' ? 'sm:col-span-2' : ''}>
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{f.label}</dt>
<dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{animal.subject_info[f.fieldId]}</dd>
</div>
))}
</dl>
);
})()}
</div>
)}
{statuses.length === 0 ? (
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
<p className="text-gray-400 mb-3">No daily statuses logged yet.</p>
<Button onClick={() => setShowAddStatus(true)}>+ Log First Status</Button>
</div>
) : (
<div className="space-y-3">
{statuses.map((s) => (
<div key={s.id} className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-center justify-between mb-3">
<span className="font-semibold text-gray-900">
{format(new Date(s.date), 'MMMM d, yyyy')}
</span>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => setEditStatus(s)}>Edit</Button>
<Button size="sm" variant="danger" onClick={() => setDeleteStatus(s)}>Delete</Button>
</div>
</div>
{activeFields.length > 0 ? (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
{activeFields
.map((f) => ({ field: f, value: getFieldValue(s, f) }))
.filter(({ value }) => value)
.map(({ field, value }) => (
<div key={field.key} className={field.type === 'textarea' ? 'sm:col-span-2' : ''}>
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{field.label}</dt>
<dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{value}</dd>
</div>
))}
</dl>
) : null}
{/* Fallback: show raw builtin fields if template is empty */}
{activeFields.length === 0 && (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
{[
['Experiment Description', s.experiment_description],
['Vitals', s.vitals],
['Treatment', s.treatment],
['Notes', s.notes],
].filter(([, v]) => v).map(([label, value]) => (
<div key={label}>
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">{label}</dt>
<dd className="text-gray-800 mt-0.5 whitespace-pre-wrap">{value}</dd>
</div>
))}
</dl>
)}
{activeFields.length > 0 &&
activeFields.every((f) => !getFieldValue(s, f)) && (
<p className="text-sm text-gray-400 italic">No details recorded for this date.</p>
<div className="border border-gray-200 rounded-xl overflow-hidden">
{/* Column picker toolbar */}
{activeFields.length > 0 && (
<div className="flex justify-end px-3 py-1.5 border-b border-gray-100 bg-gray-50/60">
<div className="relative" ref={colPickerRef}>
<button
type="button"
onClick={() => setShowColPicker((v) => !v)}
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-800 px-2 py-1 rounded hover:bg-gray-100 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
</svg>
Columns
{hiddenCols.size > 0 && (
<span className="ml-0.5 bg-indigo-100 text-indigo-700 rounded-full px-1.5 py-px text-[10px] font-medium leading-none">
{activeFields.length - hiddenCols.size}/{activeFields.length}
</span>
)}
</button>
{showColPicker && (
<div className="absolute right-0 top-full mt-1 z-30 bg-white border border-gray-200 rounded-lg shadow-lg p-2 min-w-[180px]">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide px-1 mb-1">Show / hide columns</p>
{activeFields.map((f) => {
const visible = !hiddenCols.has(f.fieldId);
return (
<label key={f.fieldId} className="flex items-center gap-2 px-1 py-0.5 rounded hover:bg-gray-50 cursor-pointer select-none text-xs text-gray-700">
<input
type="checkbox"
checked={visible}
onChange={() => toggleCol(f.fieldId)}
className="w-3.5 h-3.5 accent-indigo-600"
/>
<span className="truncate" title={f.label}>{f.label}</span>
</label>
);
})}
{hiddenCols.size > 0 && (
<button
type="button"
onClick={() => {
setHiddenCols(new Set());
localStorage.removeItem(`col-vis-${animal.id}`);
}}
className="mt-1 w-full text-left text-[10px] text-indigo-500 hover:text-indigo-700 px-1 py-0.5"
>
Show all
</button>
)}
</div>
)}
</div>
</div>
))}
)}
<div className="overflow-x-auto">
<table className="text-sm border-collapse min-w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap sticky left-0 bg-gray-50 z-10 border-r border-gray-200 min-w-[110px]">
Date
</th>
{visibleFields.map((f) => {
const header = f.abbr ? (f.unit ? `${f.abbr} (${f.unit})` : f.abbr) : (f.unit ? `${f.label} (${f.unit})` : f.label);
const tooltip = f.unit ? `${f.label} (${f.unit})` : f.label;
const minW = Math.max(80, header.length * 7.5 + 24);
return (
<th key={f.key} title={tooltip} style={{ minWidth: minW }} className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap max-w-[220px] cursor-default">
{header}
</th>
);
})}
{activeFields.length === 0 && (
<>
{['Experiment Description', 'Vitals', 'Treatment', 'Notes'].map((l) => (
<th key={l} className="text-left px-3 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wide whitespace-nowrap min-w-[120px]">{l}</th>
))}
</>
)}
<th className="sticky right-0 bg-gray-50 z-10 border-l border-gray-200 px-3 py-2 min-w-[96px]" />
</tr>
</thead>
<tbody>
{statuses.map((s, i) => {
const rowBg = i % 2 === 0 ? 'bg-white' : 'bg-gray-50/40';
const renderFields = activeFields.length > 0 ? visibleFields : [
{ fieldId: '__exp_desc__', key: 'experiment_description', label: 'Experiment Description', type: 'textarea', builtin: true, active: true },
{ fieldId: '__vitals__', key: 'vitals', label: 'Vitals', type: 'text', builtin: true, active: true },
{ fieldId: '__treatment__',key: 'treatment', label: 'Treatment', type: 'text', builtin: true, active: true },
{ fieldId: '__notes__', key: 'notes', label: 'Notes', type: 'textarea', builtin: true, active: true },
];
return (
<tr key={s.id} className={`border-b border-gray-100 last:border-0 ${rowBg}`}>
<td
className={`px-3 py-2 font-medium text-gray-700 whitespace-nowrap sticky left-0 z-10 border-r border-gray-100 cursor-pointer hover:text-blue-600 hover:bg-blue-50/40 transition-colors ${rowBg}`}
onClick={() => navigate(`/daily-statuses/${s.id}`)}
>
{format(new Date(String(s.date).slice(0, 10) + 'T12:00:00'), 'MMM d, yyyy')}
</td>
{renderFields.map((f) => (
<td key={f.fieldId} className="px-3 py-1.5 text-gray-700 max-w-[220px]">
<InlineCell
statusId={s.id}
field={f}
value={getFieldValue(s, f)}
currentStatus={s}
onSaved={(updated) => setStatuses((prev) => prev.map((x) => x.id === s.id ? { ...x, ...updated } : x))}
/>
</td>
))}
<td className={`px-2 py-1.5 sticky right-0 z-10 border-l border-gray-100 ${rowBg}`}>
<div className="flex gap-1 justify-end">
<Button size="sm" variant="secondary" onClick={() => setEditStatus(s)}>Edit</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
<AuditLogSection tableName="daily_statuses" />
{missingDates.length > 0 && (
<div className="mt-3 flex items-center justify-between px-4 py-3 bg-amber-50 border border-amber-200 rounded-xl">
<p className="text-sm text-amber-800">
<span className="font-semibold">{missingDates.length}</span> date{missingDates.length !== 1 ? 's' : ''} between the earliest and latest entries have no record.
</p>
<Button variant="secondary" size="sm" onClick={fillGaps} loading={fillingGaps}>
Create {missingDates.length} empty entr{missingDates.length === 1 ? 'y' : 'ies'}
</Button>
</div>
)}
<Modal isOpen={showAddStatus} onClose={() => setShowAddStatus(false)} title="Log Daily Status" size="lg">
<SubjectAnalysisCharts statuses={statuses} dailyTemplate={template} />
<AuditLogSection tableName="daily_statuses" limit={5} />
{/* Danger zone */}
<div className="mt-10 border border-red-200 rounded-xl p-4 bg-red-50/40">
<h2 className="text-xs font-semibold text-red-600 uppercase tracking-wide mb-2">Danger Zone</h2>
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600">Permanently delete this subject and all its daily status records.</p>
<Button variant="danger" onClick={() => setShowDeleteSubject(true)}>Delete Subject</Button>
</div>
</div>
<Modal isOpen={showSubjectInfo} onClose={() => setShowSubjectInfo(false)} title="Subject Information" size="full">
<SubjectInfoForm
animal={animal}
subjectTemplate={subjectTemplate}
experimentId={animal?.experiment_id}
onTemplateUpdate={setSubjectTemplate}
onSaved={(updated) => { setAnimal(updated); setShowSubjectInfo(false); flash('Subject information saved.'); }}
onCancel={() => setShowSubjectInfo(false)}
/>
</Modal>
<Modal isOpen={showAddStatus} onClose={() => setShowAddStatus(false)} title="Log Daily Status" size="full">
<DailyStatusForm
animalId={id}
experimentId={animal?.experiment_id}
template={template}
onTemplateUpdate={setTemplate}
onSuccess={handleStatusSaved}
onCancel={() => setShowAddStatus(false)}
/>
</Modal>
<Modal isOpen={!!editStatus} onClose={() => setEditStatus(null)} title="Edit Daily Status" size="lg">
<DailyStatusForm
existing={editStatus}
animalId={id}
template={template}
onSuccess={handleStatusSaved}
onCancel={() => setEditStatus(null)}
/>
</Modal>
{(() => {
const editIdx = editStatus ? statuses.findIndex((s) => s.id === editStatus.id) : -1;
return (
<Modal isOpen={!!editStatus} onClose={() => setEditStatus(null)} title="Edit Daily Status" size="full">
<DailyStatusForm
existing={editStatus}
animalId={id}
experimentId={animal?.experiment_id}
template={template}
onTemplateUpdate={setTemplate}
onSuccess={handleStatusSaved}
onCancel={() => setEditStatus(null)}
onRequestDelete={() => setDeleteStatus(editStatus)}
hasPrev={editIdx > 0}
hasNext={editIdx < statuses.length - 1}
onNavigate={(direction, savedResult) => {
setStatuses((prev) => prev.map((s) => s.id === savedResult.id ? savedResult : s));
setEditStatus(statuses[direction === 'prev' ? editIdx - 1 : editIdx + 1]);
}}
/>
</Modal>
);
})()}
<ConfirmDialog
isOpen={!!deleteStatus}
@@ -207,7 +539,18 @@ export default function AnimalDetail() {
onConfirm={handleDeleteStatus}
loading={deleteLoading}
title="Delete Daily Status"
message={`Delete the status entry for ${deleteStatus ? format(new Date(deleteStatus.date), 'MMMM d, yyyy') : ''}?`}
message={`Delete the status entry for ${deleteStatus ? format(new Date(String(deleteStatus.date).slice(0, 10) + 'T12:00:00'), 'MMMM d, yyyy') : ''}? This cannot be undone.`}
confirmWord="delete"
/>
<ConfirmDialog
isOpen={showDeleteSubject}
onClose={() => setShowDeleteSubject(false)}
onConfirm={handleDeleteSubject}
loading={deleteSubjectLoading}
title="Delete Subject"
message={`This will permanently delete "${animal.animal_name}" and all ${statuses.length} daily status record${statuses.length !== 1 ? 's' : ''}. This cannot be undone.`}
confirmWord="delete"
/>
</div>
);
+601
View File
@@ -0,0 +1,601 @@
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';
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 }) {
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;
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>
{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>
);
}
// ── 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 (
<div className="bg-white border border-gray-200 rounded-xl px-5 py-4 mb-6">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">Set metrics manually</p>
<div className="flex flex-wrap gap-3 items-end mb-3">
{Object.entries(counts).map(([cat, val]) => (
<div key={cat} className="flex flex-col items-center gap-1">
<span className="text-xs text-gray-500">{cat}</span>
<input
type="number" min={0} value={val}
onChange={(e) => 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) && (
<button type="button" onClick={() => removeCat(cat)}
className="text-[10px] text-red-400 hover:text-red-600">remove</button>
)}
</div>
))}
{/* Add custom category inline */}
<div className="flex flex-col items-center gap-1">
<span className="text-xs text-gray-400">+ category</span>
<div className="flex gap-1">
<input value={newCat} onChange={(e) => 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" />
<button type="button" onClick={addCat}
className="text-xs text-indigo-500 hover:text-indigo-700 px-1">Add</button>
</div>
</div>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500 mb-3">
<span>Total: <strong className="text-gray-800">{total}</strong></span>
{successRate != null && (
<span>Success rate: <strong className="text-gray-800">{(successRate * 100).toFixed(1)}%</strong></span>
)}
</div>
{error && <p className="text-xs text-red-500 mb-2">{error}</p>}
<div className="flex gap-2">
<button type="button" onClick={save} disabled={saving}
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors">
{saving ? 'Saving…' : 'Save'}
</button>
<button type="button" onClick={onClose}
className="px-3 py-1.5 rounded-lg text-sm text-gray-500 hover:text-gray-700 border border-gray-200 transition-colors">
Cancel
</button>
</div>
</div>
);
}
// ── 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 (
<div className="border border-gray-200 rounded-lg overflow-hidden">
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-gray-50 transition-colors"
onClick={expand}
>
<div className="flex items-center gap-3 min-w-0">
<span className="text-gray-400 text-xs">{expanded ? '▾' : '▸'}</span>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-800 truncate">
{analysis.file_name ?? 'Unnamed file'}
</p>
<p className="text-xs text-gray-400">
{fmtDateTime(analysis.created_at)}
{' · '}
<span className="font-mono">{analysis.note_col}</span>
{' · '}
{analysis.total_note_rows} note rows
</p>
</div>
</div>
<div className="flex items-center gap-3 ml-4 shrink-0">
<span className="text-xs text-gray-500 hidden sm:block">{summary}</span>
<button type="button" onClick={(e) => { e.stopPropagation(); setConfirmDelete(true); }}
className="text-xs text-red-400 hover:text-red-600 transition-colors">Delete</button>
</div>
</div>
{expanded && (
<div className="border-t border-gray-100 px-4 py-4 bg-gray-50/40">
{loadingFull && <p className="text-sm text-gray-400">Loading</p>}
{full && <AnalysisResultsView analysis={full} />}
</div>
)}
<ConfirmDialog
isOpen={confirmDelete}
onClose={() => setConfirmDelete(false)}
onConfirm={handleDelete}
loading={deleting}
title="Delete Analysis"
message="Remove this analysis record? The raw CSV is not affected."
/>
</div>
);
}
// ── 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 (
<div className="space-y-5 text-sm">
<p className="text-xs text-gray-400">
{total_note_rows} note rows · timestamp: <span className="font-mono">{timestamp_col}</span>
{' '}/ note: <span className="font-mono">{note_col}</span>
</p>
{/* Category counts */}
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Category Counts</p>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-white border-b border-gray-200">
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Category</th>
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Breakdown</th>
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Total</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={cat} className="border-b border-gray-100">
<td className="px-3 py-1.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${c.bg} ${c.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
{cat}
</span>
</td>
<td className="px-3 py-1.5 text-gray-500 font-mono text-xs">{bd}</td>
<td className="px-3 py-1.5 text-right font-semibold text-gray-800">{data.total}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Consecutive stats */}
{Object.keys(consecutive_stats ?? {}).length > 0 && (
<div>
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1.5">Consecutive Runs</p>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-white border-b border-gray-200">
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Category</th>
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Runs</th>
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Max</th>
<th className="text-right px-3 py-1.5 text-xs font-semibold text-gray-500">Avg</th>
<th className="text-left px-3 py-1.5 text-xs font-semibold text-gray-500">Lengths</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={cat} className="border-b border-gray-100">
<td className="px-3 py-1.5">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${c.bg} ${c.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${c.dot}`} />
{cat}
</span>
</td>
<td className="px-3 py-1.5 text-right text-gray-700">{s.totalRuns}</td>
<td className="px-3 py-1.5 text-right text-gray-700">{s.maxRun}</td>
<td className="px-3 py-1.5 text-right text-gray-700">{s.avgRun.toFixed(2)}</td>
<td className="px-3 py-1.5 text-gray-500 font-mono text-xs">{summary}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<RunSequenceView runs={runs ?? []} sequence={analysis.sequence ?? []} sessionEndTs={analysis.session_end_ts ?? null} />
</div>
);
}
// ── 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 <div className="max-w-3xl mx-auto px-4 py-8 text-gray-400">Loading</div>;
if (error) return (
<div className="max-w-3xl mx-auto px-4 py-8">
<Alert type="error" message={error} />
<Button variant="secondary" className="mt-4" onClick={() => navigate(-1)}> Back</Button>
</div>
);
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 (
<div className="max-w-3xl mx-auto px-4 py-8">
{/* Breadcrumb */}
<nav className="text-sm text-gray-500 mb-4">
<Link to="/" className="hover:text-blue-600">Experiments</Link>
<span className="mx-2">/</span>
<Link to={`/experiments/${animal.experiment_id}`} className="hover:text-blue-600">Experiment</Link>
<span className="mx-2">/</span>
<Link to={`/animals/${animal.id}`} className="hover:text-blue-600">{animal.animal_name}</Link>
<span className="mx-2">/</span>
<span className="text-gray-900 font-medium">{fmtDate(status.date)}</span>
</nav>
{/* Header */}
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">{fmtDate(status.date)}</h1>
<p className="text-sm text-gray-500 mt-0.5">
{animal.animal_name}
{animal.animal_id_string && <> · <span className="font-mono">{animal.animal_id_string}</span></>}
</p>
</div>
<Button variant="secondary" onClick={() => setShowEdit(true)}>Edit entry</Button>
</div>
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{/* Field values */}
<div className="bg-white border border-gray-200 rounded-xl p-6 mb-6">
{activeFields.length === 0 ? (
<p className="text-sm text-gray-400 italic">No template fields configured.</p>
) : (
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-4">
{activeFields.map((f) => {
const value = getFieldValue(status, f);
const label = f.unit ? `${f.label} (${f.unit})` : f.label;
return (
<div key={f.fieldId} className={f.type === 'textarea' ? 'sm:col-span-2' : ''}>
<dt className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-0.5">{label}</dt>
<dd className="text-gray-800 whitespace-pre-wrap break-words">
{value ?? <span className="text-gray-300 italic"></span>}
</dd>
</div>
);
})}
</dl>
)}
</div>
{/* Analysis summary (pinned metrics) */}
{status.analysis_summary && !showManualForm && (
<AnalysisSummaryCard summary={status.analysis_summary} />
)}
{showManualForm ? (
<ManualMetricsForm
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)}
/>
) : (
<div className="mb-4">
<button
type="button"
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'}
</button>
</div>
)}
{/* CSV Analysis — new analysis */}
<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>
)}
{/* Full modification history for this entry */}
<AuditLogSection tableName="daily_statuses" recordId={status.id} />
{/* Prev / Next navigation */}
<div className="flex justify-between items-center mt-8 mb-6">
<div>
{prevStatus && (
<button type="button" onClick={() => navigate(`/daily-statuses/${prevStatus.id}`)}
className="text-sm text-blue-600 hover:text-blue-800 hover:underline">
{fmtDate(prevStatus.date)}
</button>
)}
</div>
<Link to={`/animals/${animal.id}`} className="text-sm text-gray-500 hover:text-gray-800">
All entries
</Link>
<div>
{nextStatus && (
<button type="button" onClick={() => navigate(`/daily-statuses/${nextStatus.id}`)}
className="text-sm text-blue-600 hover:text-blue-800 hover:underline">
{fmtDate(nextStatus.date)}
</button>
)}
</div>
</div>
{/* Edit modal */}
<Modal isOpen={showEdit} onClose={() => setShowEdit(false)} title="Edit Daily Status" size="full">
<DailyStatusForm
existing={status}
animalId={animal.id}
experimentId={animal.experiment_id}
template={template}
onTemplateUpdate={setTemplate}
onSuccess={(updated) => { setStatus({ ...updated, animal }); setShowEdit(false); }}
onCancel={() => setShowEdit(false)}
onRequestDelete={() => { setShowEdit(false); setShowDelete(true); }}
/>
</Modal>
<ConfirmDialog
isOpen={showDelete}
onClose={() => setShowDelete(false)}
onConfirm={handleDelete}
loading={deleteLoading}
title="Delete Daily Status"
message={`Delete the entry for ${fmtDate(status.date)}? This cannot be undone.`}
confirmWord="delete"
/>
</div>
);
}
+275 -68
View File
@@ -1,13 +1,52 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { experimentsApi, animalsApi } from '../api/client';
import { ExperimentAnalysisCharts } from '../components/AnalysisCharts';
import Button from '../components/ui/Button';
import Modal from '../components/ui/Modal';
import ConfirmDialog from '../components/ui/ConfirmDialog';
import AnimalForm from '../components/AnimalForm';
import TemplateEditor from '../components/TemplateEditor';
import Alert from '../components/ui/Alert';
import AuditLogSection from '../components/AuditLogSection';
import ExperimentCalendar from '../components/ExperimentCalendar';
function AnimalCardList({ animals, subjectTemplate, onNavigate, onEdit }) {
return (
<div className="grid gap-3">
{animals.map((animal) => {
const infoFields = subjectTemplate.filter((f) => f.active && animal.subject_info?.[f.fieldId] != null);
return (
<div
key={animal.id}
className="bg-white border border-gray-200 rounded-xl p-4 flex items-center justify-between hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer group"
onClick={() => onNavigate(animal)}
>
<div>
<div className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
{animal.animal_name}
</div>
<div className="text-sm text-gray-500">
ID: {animal.animal_id_string} · {animal._count?.daily_statuses ?? 0} daily status entries
</div>
{infoFields.length > 0 && (
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-1">
{infoFields.map((f) => (
<span key={f.fieldId} className="text-xs text-gray-400">
<span className="font-medium text-gray-500">{f.label}:</span> {animal.subject_info[f.fieldId]}
</span>
))}
</div>
)}
</div>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<Button size="sm" variant="secondary" onClick={() => onEdit(animal)}>Edit</Button>
</div>
</div>
);
})}
</div>
);
}
export default function ExperimentDetail() {
const { id } = useParams();
@@ -21,20 +60,48 @@ export default function ExperimentDetail() {
const [showAddAnimal, setShowAddAnimal] = useState(false);
const [editAnimal, setEditAnimal] = useState(null);
const [deleteAnimal, setDeleteAnimal] = useState(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [showTemplate, setShowTemplate] = useState(false);
const [showSubjectTemplate, setShowSubjectTemplate] = useState(false);
const [subjectTemplate, setSubjectTemplate] = useState([]);
const [dailyTemplate, setDailyTemplate] = useState([]);
const [experimentStatuses, setExperimentStatuses] = useState([]);
const [displayMode, setDisplayMode] = useState(() => localStorage.getItem(`exp-display-mode-${id}`) ?? 'list');
const [sortField, setSortField] = useState(() => localStorage.getItem(`exp-subject-sort-${id}`) ?? '__name__');
const [sortDir, setSortDir] = useState(() => localStorage.getItem(`exp-subject-sort-dir-${id}`) ?? 'asc');
const [groupField, setGroupField] = useState(() => localStorage.getItem(`exp-subject-group-${id}`) ?? '__name__');
function updateDisplayMode(mode) {
setDisplayMode(mode);
localStorage.setItem(`exp-display-mode-${id}`, mode);
}
function updateSort(field, dir) {
setSortField(field);
setSortDir(dir);
localStorage.setItem(`exp-subject-sort-${id}`, field);
localStorage.setItem(`exp-subject-sort-dir-${id}`, dir);
}
function updateGroupField(field) {
setGroupField(field);
localStorage.setItem(`exp-subject-group-${id}`, field);
}
async function load() {
setLoading(true);
setError(null);
try {
const [exp, anims] = await Promise.all([
const [exp, anims, subjTmpl, dailyTmpl, expStatuses] = await Promise.all([
experimentsApi.get(id),
animalsApi.list(id),
experimentsApi.getSubjectTemplate(id),
experimentsApi.getTemplate(id),
experimentsApi.getDailyStatuses(id),
]);
setExperiment(exp);
setAnimals(anims);
setSubjectTemplate(subjTmpl);
setDailyTemplate(dailyTmpl);
setExperimentStatuses(expStatuses);
} catch (err) {
setError(err.message);
} finally {
@@ -56,21 +123,6 @@ export default function ExperimentDetail() {
flash(editAnimal ? `Animal "${animal.animal_name}" updated.` : `Animal "${animal.animal_name}" added.`);
}
async function handleDeleteAnimal() {
setDeleteLoading(true);
try {
await animalsApi.delete(deleteAnimal.id);
setDeleteAnimal(null);
load();
flash('Animal removed.');
} catch (err) {
setError(err.message);
setDeleteAnimal(null);
} finally {
setDeleteLoading(false);
}
}
function handleTemplateSaved(updatedTemplate) {
setShowTemplate(false);
if (updatedTemplate) {
@@ -80,6 +132,49 @@ export default function ExperimentDetail() {
}
}
const sortedAnimals = useMemo(() => {
const sorted = [...animals].sort((a, b) => {
let av, bv;
if (sortField === '__name__') {
av = a.animal_name ?? '';
bv = b.animal_name ?? '';
} else if (sortField === '__id__') {
av = a.animal_id_string ?? '';
bv = b.animal_id_string ?? '';
} else {
av = a.subject_info?.[sortField] ?? '';
bv = b.subject_info?.[sortField] ?? '';
}
// numeric-aware comparison
const an = parseFloat(av), bn = parseFloat(bv);
const cmp = (!isNaN(an) && !isNaN(bn)) ? an - bn : String(av).localeCompare(String(bv));
return sortDir === 'asc' ? cmp : -cmp;
});
return sorted;
}, [animals, sortField, sortDir, subjectTemplate]);
// Group mode: bucket sortedAnimals by the groupField value
const groupedAnimals = useMemo(() => {
if (displayMode !== 'group') return null;
const getVal = (animal) => {
if (groupField === '__name__') return animal.animal_name ?? '—';
if (groupField === '__id__') return animal.animal_id_string ?? '—';
const v = animal.subject_info?.[groupField];
return v != null && v !== '' ? String(v) : '—';
};
const buckets = new Map();
for (const animal of sortedAnimals) {
const key = getVal(animal);
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(animal);
}
// Sort bucket keys numerically-aware
return [...buckets.entries()].sort(([a], [b]) => {
const an = parseFloat(a), bn = parseFloat(b);
return (!isNaN(an) && !isNaN(bn)) ? an - bn : a.localeCompare(b);
});
}, [sortedAnimals, displayMode, groupField]);
if (loading) return <div className="max-w-5xl mx-auto px-4 py-8 text-gray-400" aria-live="polite" aria-busy="true">Loading</div>;
if (error) return (
<div className="max-w-5xl mx-auto px-4 py-8">
@@ -104,25 +199,18 @@ export default function ExperimentDetail() {
{animals.length} animal{animals.length !== 1 ? 's' : ''} enrolled
</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setShowTemplate(true)}>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
Record Template
</Button>
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
</div>
<Button onClick={() => setShowAddAnimal(true)}>+ Add Animal</Button>
</div>
{successMsg && <Alert type="success" message={successMsg} />}
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{/* Template summary strip */}
{experiment.template && experiment.template.length > 0 && (
<div className="mb-5 flex flex-wrap gap-1.5">
{experiment.template.map((f) => (
{/* Template strips */}
<div className="mb-5 space-y-2">
{/* Daily record template */}
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide w-24 shrink-0">Daily record</span>
{(experiment.template ?? []).map((f) => (
<span
key={f.key}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${
@@ -135,12 +223,40 @@ export default function ExperimentDetail() {
))}
<button
onClick={() => setShowTemplate(true)}
className="text-xs text-gray-400 hover:text-blue-600 underline underline-offset-2 transition-colors"
className="inline-flex items-center gap-1 text-xs text-gray-400 hover:text-blue-600 transition-colors"
>
edit
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828A2 2 0 0110 16.414V19h2.586a2 2 0 001.414-.586l6.5-6.5" />
</svg>
{(experiment.template ?? []).length === 0 ? 'Set up' : 'Edit'}
</button>
</div>
)}
{/* Subject info template */}
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide w-24 shrink-0">Subject info</span>
{subjectTemplate.map((f) => (
<span
key={f.key}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${
f.active ? 'bg-emerald-50 border-emerald-200 text-emerald-700' : 'bg-gray-100 border-gray-200 text-gray-400'
}`}
>
{f.active ? null : <span title="hidden"></span>}
{f.label}
</span>
))}
<button
onClick={() => setShowSubjectTemplate(true)}
className="inline-flex items-center gap-1 text-xs text-gray-400 hover:text-emerald-600 transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536M9 13l6.586-6.586a2 2 0 112.828 2.828L11.828 15.828A2 2 0 0110 16.414V19h2.586a2 2 0 001.414-.586l6.5-6.5" />
</svg>
{subjectTemplate.length === 0 ? 'Set up' : 'Edit'}
</button>
</div>
</div>
{animals.length === 0 ? (
<div className="text-center py-16 border-2 border-dashed border-gray-200 rounded-xl">
@@ -148,37 +264,136 @@ export default function ExperimentDetail() {
<Button onClick={() => setShowAddAnimal(true)}>+ Add First Animal</Button>
</div>
) : (
<div className="grid gap-3">
{animals.map((animal) => (
<div
key={animal.id}
className="bg-white border border-gray-200 rounded-xl p-4 flex items-center justify-between hover:border-blue-300 hover:shadow-sm transition-all cursor-pointer group"
onClick={() => navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
>
<div>
<div className="font-semibold text-gray-900 group-hover:text-blue-700 transition-colors">
{animal.animal_name}
</div>
<div className="text-sm text-gray-500">
ID: {animal.animal_id_string} · {animal._count?.daily_statuses ?? 0} daily status entries
</div>
</div>
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
<Button size="sm" variant="secondary" onClick={() => setEditAnimal(animal)}>Edit</Button>
<Button size="sm" variant="danger" onClick={() => setDeleteAnimal(animal)}>Remove</Button>
</div>
<>
{/* Display controls */}
<div className="flex items-center gap-3 mb-3 flex-wrap">
{/* Mode toggle */}
<div className="flex rounded border border-gray-200 overflow-hidden text-xs">
{[['list', 'List'], ['group', 'Group'], ['calendar', 'Calendar']].map(([mode, label]) => (
<button key={mode} type="button" onClick={() => updateDisplayMode(mode)}
className={`px-3 py-1 border-l border-gray-200 first:border-l-0 transition-colors
${displayMode === mode ? 'bg-gray-700 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'}`}>
{label}
</button>
))}
</div>
))}
</div>
{displayMode === 'list' && (
<>
<span className="text-xs text-gray-400">Sort by</span>
<select value={sortField} onChange={(e) => updateSort(e.target.value, sortDir)}
className="border border-gray-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
<option value="__name__">Name</option>
<option value="__id__">Subject ID</option>
{subjectTemplate.filter((f) => f.active).map((f) => (
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
))}
</select>
<button type="button" onClick={() => updateSort(sortField, sortDir === 'asc' ? 'desc' : 'asc')}
className="text-xs text-gray-500 hover:text-gray-800 border border-gray-200 rounded px-2 py-1 bg-white transition-colors">
{sortDir === 'asc' ? '↑ Asc' : '↓ Desc'}
</button>
</>
)}
{displayMode === 'group' && (
<>
<span className="text-xs text-gray-400">Group by</span>
<select value={groupField} onChange={(e) => updateGroupField(e.target.value)}
className="border border-gray-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
<option value="__name__">Name</option>
<option value="__id__">Subject ID</option>
{subjectTemplate.filter((f) => f.active).map((f) => (
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
))}
</select>
<span className="text-xs text-gray-400 ml-1">· sorted within by</span>
<select value={sortField} onChange={(e) => updateSort(e.target.value, sortDir)}
className="border border-gray-200 rounded px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-indigo-400">
<option value="__name__">Name</option>
<option value="__id__">Subject ID</option>
{subjectTemplate.filter((f) => f.active).map((f) => (
<option key={f.fieldId} value={f.fieldId}>{f.label}</option>
))}
</select>
<button type="button" onClick={() => updateSort(sortField, sortDir === 'asc' ? 'desc' : 'asc')}
className="text-xs text-gray-500 hover:text-gray-800 border border-gray-200 rounded px-2 py-1 bg-white transition-colors">
{sortDir === 'asc' ? '↑ Asc' : '↓ Desc'}
</button>
</>
)}
</div>
{displayMode === 'list' && (
<AnimalCardList
animals={sortedAnimals}
subjectTemplate={subjectTemplate}
onNavigate={(animal) => navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
onEdit={setEditAnimal}
/>
)}
{displayMode === 'group' && groupedAnimals && groupedAnimals.map(([groupValue, groupAnimals]) => {
const groupLabel = (() => {
if (groupField === '__name__' || groupField === '__id__') return groupValue;
return subjectTemplate.find((f) => f.fieldId === groupField)?.label
? `${subjectTemplate.find((f) => f.fieldId === groupField).label}: ${groupValue}`
: groupValue;
})();
return (
<div key={groupValue} className="mb-5">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">{groupLabel}</span>
<span className="text-xs text-gray-400">({groupAnimals.length})</span>
<div className="flex-1 border-t border-gray-100" />
</div>
<AnimalCardList
animals={groupAnimals}
subjectTemplate={subjectTemplate}
onNavigate={(animal) => navigate(`/animals/${animal.id}`, { state: { template: experiment.template } })}
onEdit={setEditAnimal}
/>
</div>
);
})}
{displayMode === 'calendar' && (
<ExperimentCalendar
experimentId={id}
template={dailyTemplate}
allStatuses={experimentStatuses}
animals={animals}
/>
)}
</>
)}
<AuditLogSection tableName="animals" />
<ExperimentAnalysisCharts
animals={animals}
experimentStatuses={experimentStatuses}
subjectTemplate={subjectTemplate}
dailyTemplate={dailyTemplate}
/>
<AuditLogSection tableName="animals" limit={5} />
{/* Template editor modal */}
<Modal isOpen={showTemplate} onClose={() => setShowTemplate(false)} title="Daily Record Template" size="lg">
<Modal isOpen={showTemplate} onClose={() => setShowTemplate(false)} title="Daily Record Template" size="full">
<TemplateEditor experimentId={id} onClose={handleTemplateSaved} />
</Modal>
<Modal isOpen={showSubjectTemplate} onClose={() => setShowSubjectTemplate(false)} title="Subject Info Template" size="full">
<TemplateEditor
experimentId={id}
loadFn={() => experimentsApi.getSubjectTemplate(id)}
saveFn={(fields) => experimentsApi.updateSubjectTemplate(id, fields)}
onClose={(updated) => {
setShowSubjectTemplate(false);
if (updated) { setSubjectTemplate(updated); flash('Subject info template updated.'); }
}}
/>
</Modal>
<Modal isOpen={showAddAnimal} onClose={() => setShowAddAnimal(false)} title="Add Animal">
<AnimalForm experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setShowAddAnimal(false)} />
</Modal>
@@ -187,14 +402,6 @@ export default function ExperimentDetail() {
<AnimalForm existing={editAnimal} experimentId={id} onSuccess={handleAnimalSaved} onCancel={() => setEditAnimal(null)} />
</Modal>
<ConfirmDialog
isOpen={!!deleteAnimal}
onClose={() => setDeleteAnimal(null)}
onConfirm={handleDeleteAnimal}
loading={deleteLoading}
title="Remove Animal"
message={`Remove "${deleteAnimal?.animal_name}" and all its daily statuses from this experiment?`}
/>
</div>
);
}