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
+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