feat(frontend): hydrate + debounced-persist cross-subject plot config

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Experiments DB Dev
2026-06-01 10:22:39 -04:00
parent 4b82df514f
commit 9a9b5dfae8
4 changed files with 143 additions and 29 deletions
+1
View File
@@ -33,6 +33,7 @@ export const experimentsApi = {
updateTemplate: (id, template) => api.put(`/experiments/${id}/template`, template).then((r) => r.data),
getSubjectTemplate: (id) => api.get(`/experiments/${id}/subject-template`).then((r) => r.data),
updateSubjectTemplate: (id, template) => api.put(`/experiments/${id}/subject-template`, template).then((r) => r.data),
savePlotConfig: (id, config) => api.put(`/experiments/${id}/plot-config`, config).then((r) => r.data),
// opts: { date?: string, analysisOnly?: boolean }
getDailyStatuses: (id, opts) => {
const params = typeof opts === 'string' ? { date: opts } : (opts ?? {});
+74 -28
View File
@@ -1,11 +1,11 @@
import React, { useState, useMemo, useEffect } from 'react';
import React, { useState, useMemo, useEffect, useRef } from 'react';
import { format } from 'date-fns';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
Legend, ResponsiveContainer,
} from 'recharts';
import { SubjectSidebar } from './SubjectSidebar';
import { sortPayloadByValueDesc, toggleInSet, setIdsHidden } from '../lib/crossSubjectChart';
import { sortPayloadByValueDesc, toggleInSet, setIdsHidden, serializePlotConfig, readPlotConfig } from '../lib/crossSubjectChart';
// ── Color palettes ─────────────────────────────────────────────────────────────
@@ -89,14 +89,14 @@ const HEIGHT_OPTIONS = [
{ label: 'L', value: 340 },
];
function useChartSettings(defaultHeight) {
function useChartSettings(defaultHeight, initial = {}) {
const [expanded, setExpanded] = useState(false);
const [height, setHeight] = useState(defaultHeight);
const [yMin, setYMin] = useState('');
const [yMax, setYMax] = useState('');
const [xZoomMin, setXZoomMin] = useState('');
const [xZoomMax, setXZoomMax] = useState('');
const [xFieldOverride, setXFieldOverride] = useState(null); // null → use parent's xField
const [height, setHeight] = useState(initial.height ?? defaultHeight);
const [yMin, setYMin] = useState(initial.yMin ?? '');
const [yMax, setYMax] = useState(initial.yMax ?? '');
const [xZoomMin, setXZoomMin] = useState(initial.xZoomMin ?? '');
const [xZoomMax, setXZoomMax] = useState(initial.xZoomMax ?? '');
const [xFieldOverride, setXFieldOverride] = useState(initial.xFieldOverride ?? null); // null → use parent's xField
const yDomain = useMemo(() => [
yMin !== '' ? parseFloat(yMin) : 'auto',
@@ -113,6 +113,11 @@ function useChartSettings(defaultHeight) {
const isDirty = yMin !== '' || yMax !== '' || xZoomMin !== '' || xZoomMax !== ''
|| xFieldOverride !== null || height !== defaultHeight;
const snapshot = useMemo(
() => ({ height, yMin, yMax, xZoomMin, xZoomMax, xFieldOverride }),
[height, yMin, yMax, xZoomMin, xZoomMax, xFieldOverride],
);
function reset() {
setHeight(defaultHeight);
setYMin(''); setYMax('');
@@ -126,7 +131,7 @@ function useChartSettings(defaultHeight) {
yMin, setYMin, yMax, setYMax,
xZoomMin, setXZoomMin, xZoomMax, setXZoomMax,
xFieldOverride, setXFieldOverride,
yDomain, xDomain, isDirty, reset,
yDomain, xDomain, isDirty, reset, snapshot,
};
}
@@ -445,27 +450,63 @@ const METRIC_OPTIONS = [
{ value: 'Failure', label: 'Failure count' },
];
export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectTemplate, dailyTemplate }) {
export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectTemplate, dailyTemplate, plotConfig, onPersistConfig }) {
const groupableFields = useMemo(() => (subjectTemplate ?? []).filter((f) => f.active), [subjectTemplate]);
const xAxisFields = useMemo(() => (dailyTemplate ?? []).filter((f) => f.active), [dailyTemplate]);
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);
// Read the saved config exactly once (component only mounts after the experiment loads).
const initialConfigRef = useRef(null);
if (initialConfigRef.current === null) initialConfigRef.current = readPlotConfig(plotConfig);
const initialConfig = initialConfigRef.current;
const [groupBy, setGroupBy] = useState(initialConfig.groupBy ?? defaultGroup);
const [metric, setMetric] = useState(initialConfig.metric ?? 'total');
const [xField, setXField] = useState(() => initialConfig.xField ?? defaultXField(xAxisFields));
const [tickStep, setTickStep] = useState(initialConfig.tickStep ?? 1);
// Cross-subject interaction state (client-only; global across both charts)
const [sidebarOpen, setSidebarOpen] = useState(false);
const [hiddenSubjects, setHiddenSubjects] = useState(() => new Set());
const [sidebarOpen, setSidebarOpen] = useState(initialConfig.sidebarOpen);
const [hiddenSubjects, setHiddenSubjects] = useState(() => new Set(initialConfig.hiddenSubjects));
const [highlightedSubject, setHighlightedSubject] = useState(null);
const toggleHidden = (id) => setHiddenSubjects((prev) => toggleInSet(prev, id));
const toggleGroup = (ids, hidden) => setHiddenSubjects((prev) => setIdsHidden(prev, ids, hidden));
const showAll = () => setHiddenSubjects(new Set());
const countsS = useChartSettings(200);
const rateS = useChartSettings(160);
const countsS = useChartSettings(200, initialConfig.counts);
const rateS = useChartSettings(160, initialConfig.rate);
// ── Persist display settings (debounced auto-save) ────────────────────────────
const [saveState, setSaveState] = useState('idle'); // 'idle' | 'saving' | 'saved' | 'error'
const currentConfig = useMemo(
() => serializePlotConfig({
xField, groupBy, tickStep, metric, hiddenSubjects, sidebarOpen,
counts: countsS.snapshot, rate: rateS.snapshot,
}),
[xField, groupBy, tickStep, metric, hiddenSubjects, sidebarOpen, countsS.snapshot, rateS.snapshot],
);
// Keep the latest persist callback in a ref so the save effect does not re-run
// when the parent passes a new inline function each render.
const persistRef = useRef(onPersistConfig);
persistRef.current = onPersistConfig;
const lastSavedRef = useRef(null);
useEffect(() => {
const serialized = JSON.stringify(currentConfig);
if (lastSavedRef.current === null) { lastSavedRef.current = serialized; return; } // skip initial (hydration)
// Reverted to the last-saved value before the debounce fired → nothing pending.
if (serialized === lastSavedRef.current) { setSaveState('idle'); return; }
if (!persistRef.current) { lastSavedRef.current = serialized; setSaveState('idle'); return; }
setSaveState('saving');
const t = setTimeout(() => {
Promise.resolve(persistRef.current(currentConfig))
.then(() => { lastSavedRef.current = serialized; setSaveState('saved'); })
.catch(() => setSaveState('error'));
}, 1000);
return () => clearTimeout(t);
}, [currentConfig]);
// Step 1: animalRows — does NOT depend on xField; shared by both charts
const { animalRows, lines } = useMemo(() => {
@@ -567,15 +608,20 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT
)}
<TickStepControl value={tickStep} onChange={setTickStep} />
{hasData && (
<button type="button" onClick={() => setSidebarOpen((o) => !o)}
className={[
'text-xs px-2 py-0.5 rounded border transition-colors ml-auto',
sidebarOpen
? 'bg-gray-100 border-gray-300 text-gray-700'
: 'bg-white border-gray-200 text-gray-500 hover:text-gray-700 hover:border-gray-300',
].join(' ')}>
{sidebarOpen ? '◂ Subjects' : '▸ Subjects'}
</button>
<div className="ml-auto flex items-center gap-2">
{saveState === 'saving' && <span className="text-xs text-gray-400">Saving</span>}
{saveState === 'saved' && <span className="text-xs text-emerald-500">Saved </span>}
{saveState === 'error' && <span className="text-xs text-amber-500">Couldn't save layout</span>}
<button type="button" onClick={() => setSidebarOpen((o) => !o)}
className={[
'text-xs px-2 py-0.5 rounded border transition-colors',
sidebarOpen
? 'bg-gray-100 border-gray-300 text-gray-700'
: 'bg-white border-gray-200 text-gray-500 hover:text-gray-700 hover:border-gray-300',
].join(' ')}>
{sidebarOpen ? ' Subjects' : ' Subjects'}
</button>
</div>
)}
</div>
+2
View File
@@ -367,6 +367,8 @@ export default function ExperimentDetail() {
experimentStatuses={experimentStatuses}
subjectTemplate={subjectTemplate}
dailyTemplate={dailyTemplate}
plotConfig={experiment.plot_config}
onPersistConfig={(cfg) => experimentsApi.savePlotConfig(id, cfg)}
/>
{animals.length > 0 && (
@@ -1,5 +1,5 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent, act } from '@testing-library/react';
// recharts uses ResizeObserver (absent in jsdom); stub all used pieces as passthroughs.
// Coverage boundary: with recharts mocked out, these tests cover only the sidebar
@@ -59,3 +59,68 @@ it('unchecking a subject in the sidebar persists across re-render', () => {
expect(screen.getByLabelText('Mouse-A1').checked).toBe(false);
expect(screen.getByText('Show all')).toBeInTheDocument();
});
it('hydrates hidden subjects and sidebar-open from plotConfig', () => {
render(
<ExperimentAnalysisCharts
animals={ANIMALS}
experimentStatuses={STATUSES}
subjectTemplate={SUBJECT_TEMPLATE}
dailyTemplate={DAILY_TEMPLATE}
plotConfig={{ hiddenSubjects: ['a1'], sidebarOpen: true }}
/>,
);
expect(screen.getByLabelText('Mouse-A1').checked).toBe(false);
expect(screen.getByLabelText('Mouse-B1').checked).toBe(true);
});
describe('debounced auto-save', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it('does not call onPersistConfig on initial render', () => {
const onPersist = jest.fn();
render(
<ExperimentAnalysisCharts
animals={ANIMALS} experimentStatuses={STATUSES}
subjectTemplate={SUBJECT_TEMPLATE} dailyTemplate={DAILY_TEMPLATE}
plotConfig={null} onPersistConfig={onPersist}
/>,
);
act(() => { jest.advanceTimersByTime(1500); });
expect(onPersist).not.toHaveBeenCalled();
});
it('calls onPersistConfig ~1s after a change, with the updated config', () => {
const onPersist = jest.fn().mockResolvedValue({});
render(
<ExperimentAnalysisCharts
animals={ANIMALS} experimentStatuses={STATUSES}
subjectTemplate={SUBJECT_TEMPLATE} dailyTemplate={DAILY_TEMPLATE}
plotConfig={{ sidebarOpen: true }} onPersistConfig={onPersist}
/>,
);
fireEvent.click(screen.getByLabelText('Mouse-A1'));
expect(onPersist).not.toHaveBeenCalled();
act(() => { jest.advanceTimersByTime(1100); });
expect(onPersist).toHaveBeenCalledTimes(1);
expect(onPersist.mock.calls[0][0].hiddenSubjects).toContain('a1');
});
it('does not save and clears the saving hint when a change is reverted before the debounce fires', () => {
const onPersist = jest.fn().mockResolvedValue({});
render(
<ExperimentAnalysisCharts
animals={ANIMALS} experimentStatuses={STATUSES}
subjectTemplate={SUBJECT_TEMPLATE} dailyTemplate={DAILY_TEMPLATE}
plotConfig={{ sidebarOpen: true }} onPersistConfig={onPersist}
/>,
);
fireEvent.click(screen.getByLabelText('Mouse-A1')); // hide a1
expect(screen.getByText('Saving…')).toBeInTheDocument();
fireEvent.click(screen.getByLabelText('Mouse-A1')); // revert before the 1s timer
act(() => { jest.advanceTimersByTime(1100); });
expect(onPersist).not.toHaveBeenCalled();
expect(screen.queryByText('Saving…')).toBeNull();
});
});