From 9a9b5dfae8043360fd0bc5e9a10d978eae67ad33 Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Mon, 1 Jun 2026 10:22:39 -0400 Subject: [PATCH] feat(frontend): hydrate + debounced-persist cross-subject plot config Co-Authored-By: Claude Opus 4.8 --- frontend/src/api/client.js | 1 + frontend/src/components/AnalysisCharts.jsx | 102 +++++++++++++----- frontend/src/pages/ExperimentDetail.jsx | 2 + .../tests/ExperimentAnalysisCharts.test.jsx | 67 +++++++++++- 4 files changed, 143 insertions(+), 29 deletions(-) diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index b4b7c33..6679ed2 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -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 ?? {}); diff --git a/frontend/src/components/AnalysisCharts.jsx b/frontend/src/components/AnalysisCharts.jsx index 536da13..58ed95c 100644 --- a/frontend/src/components/AnalysisCharts.jsx +++ b/frontend/src/components/AnalysisCharts.jsx @@ -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 )} {hasData && ( - +
+ {saveState === 'saving' && Saving…} + {saveState === 'saved' && Saved ✓} + {saveState === 'error' && Couldn't save layout} + +
)} diff --git a/frontend/src/pages/ExperimentDetail.jsx b/frontend/src/pages/ExperimentDetail.jsx index 13b59be..c5c5246 100644 --- a/frontend/src/pages/ExperimentDetail.jsx +++ b/frontend/src/pages/ExperimentDetail.jsx @@ -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 && ( diff --git a/frontend/tests/ExperimentAnalysisCharts.test.jsx b/frontend/tests/ExperimentAnalysisCharts.test.jsx index 17c2021..6a7c9fa 100644 --- a/frontend/tests/ExperimentAnalysisCharts.test.jsx +++ b/frontend/tests/ExperimentAnalysisCharts.test.jsx @@ -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( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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(); + }); +});