Files
experiments-database/docs/superpowers/plans/2026-06-01-persist-cross-subject-plot-config.md
Experiments DB Dev 2dd4a329ee docs: implementation plan for persisting plot config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 10:10:39 -04:00

679 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Persist Cross-subject Plot Config Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Persist the "Cross-subject metrics" plot's display settings per experiment in the database, hydrating on load and auto-saving (debounced) on change.
**Architecture:** Add a nullable `plot_config` JSONB column to `experiments` (Prisma). Expose `PUT /api/experiments/:id/plot-config` (validated, no audit log). On the frontend, pure helpers (`serializePlotConfig`/`readPlotConfig`) translate between React state and the stored blob; `ExperimentAnalysisCharts` initializes its state from the saved config and debounce-saves changes.
**Tech Stack:** Postgres + Prisma 5, Express + express-validator, Jest + supertest (backend), React 18 + Recharts 2.15, Jest + React Testing Library (frontend).
**Constraints:** Follow the existing `subject_info_template` route/migration pattern. No audit-log writes for plot-config. Do not change `SubjectAnalysisCharts`, the template/subject-template routes, or any existing behavior. Migration is additive and nullable — existing experiments load with defaults.
---
### Task 1: Backend — column, migration, route, validator, tests
**Files:**
- Modify: `backend/prisma/schema.prisma` (model `Experiment`)
- Create: `backend/prisma/migrations/20260601000000_add_experiment_plot_config/migration.sql`
- Modify: `backend/src/routes/experiments.js` (add validator near other validators; add route after the `PUT /:id/subject-template` handler, ~line 309)
- Test: `backend/tests/experiments.test.js` (add a describe block)
- [ ] **Step 1: Write the failing tests**
Add this block to the end of `backend/tests/experiments.test.js` (it uses the file's existing `request`, `prisma` mock, `EXPERIMENT`, and `beforeEach(jest.clearAllMocks)`):
```js
describe('PUT /api/experiments/:id/plot-config', () => {
const CONFIG = { xField: '__days__', tickStep: 2, hiddenSubjects: ['a1', 'a2'], sidebarOpen: true };
it('saves a valid config and returns it without writing an audit log', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
prisma.experiment.update.mockResolvedValue({ ...EXPERIMENT, plot_config: CONFIG });
const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send(CONFIG);
expect(res.status).toBe(200);
expect(res.body).toEqual(CONFIG);
expect(prisma.experiment.update).toHaveBeenCalledWith({
where: { id: EXPERIMENT.id },
data: { plot_config: CONFIG },
});
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
it('returns 404 when the experiment does not exist', async () => {
prisma.experiment.findUnique.mockResolvedValue(null);
const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send(CONFIG);
expect(res.status).toBe(404);
expect(prisma.experiment.update).not.toHaveBeenCalled();
});
it('rejects a non-object body with 422', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send([1, 2, 3]);
expect(res.status).toBe(422);
expect(prisma.experiment.update).not.toHaveBeenCalled();
});
it('rejects an invalid hiddenSubjects with 422', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send({ hiddenSubjects: [1, 2] });
expect(res.status).toBe(422);
expect(prisma.experiment.update).not.toHaveBeenCalled();
});
it('rejects an oversized config with 422', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
const big = { blob: 'x'.repeat(20000) };
const res = await request(app).put(`/api/experiments/${EXPERIMENT.id}/plot-config`).send(big);
expect(res.status).toBe(422);
expect(prisma.experiment.update).not.toHaveBeenCalled();
});
});
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `cd backend && npx jest tests/experiments.test.js -t "plot-config"`
Expected: FAIL — route returns 404/route not found (no handler yet), assertions fail.
- [ ] **Step 3: Add the Prisma column**
In `backend/prisma/schema.prisma`, in `model Experiment`, add a `plot_config` field (place it after `subject_info_template`):
```prisma
model Experiment {
id String @id @default(uuid())
title String
created_at DateTime @default(now())
template Json @default("[]")
subject_info_template Json @default("[]")
plot_config Json?
animals Animal[]
@@map("experiments")
}
```
- [ ] **Step 4: Create the migration file**
Create `backend/prisma/migrations/20260601000000_add_experiment_plot_config/migration.sql`:
```sql
-- AlterTable
ALTER TABLE "experiments" ADD COLUMN "plot_config" JSONB;
```
- [ ] **Step 5: Regenerate the Prisma client**
Run: `cd backend && npx prisma generate`
Expected: "Generated Prisma Client" with no errors (no DB connection required).
- [ ] **Step 6: Add the validator and route**
In `backend/src/routes/experiments.js`, add this validator function alongside the other validators (e.g. right after `validateSubjectTemplate`, before the routes that use it):
```js
function validatePlotConfig(config) {
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
return 'plot_config must be an object';
}
let size;
try { size = JSON.stringify(config).length; } catch { return 'plot_config must be serializable'; }
if (size > 16384) return 'plot_config too large';
if ('hiddenSubjects' in config) {
const h = config.hiddenSubjects;
if (!Array.isArray(h) || h.length > 500 || !h.every((x) => typeof x === 'string')) {
return 'hiddenSubjects invalid';
}
}
return null;
}
```
Then add this route immediately after the `PUT /:id/subject-template` handler (after its closing `});`, ~line 309):
```js
// PUT /api/experiments/:id/plot-config — persist UI display settings (no audit log)
router.put('/:id/plot-config', async (req, res, next) => {
try {
const experiment = await prisma.experiment.findUnique({ where: { id: req.params.id } });
if (!experiment) return res.status(404).json({ error: 'Experiment not found' });
const config = req.body;
const validationError = validatePlotConfig(config);
if (validationError) return res.status(422).json({ error: validationError });
await prisma.experiment.update({
where: { id: req.params.id },
data: { plot_config: config },
});
res.json(config);
} catch (err) { next(err); }
});
```
- [ ] **Step 7: Run the tests to verify they pass**
Run: `cd backend && npx jest tests/experiments.test.js -t "plot-config"`
Expected: PASS — all 5 new tests green.
- [ ] **Step 8: Run the full backend suite (no regressions)**
Run: `cd backend && npm test`
Expected: PASS — all suites green (note any pre-existing failures unrelated to this change).
- [ ] **Step 9: Commit**
```bash
git add backend/prisma/schema.prisma backend/prisma/migrations/20260601000000_add_experiment_plot_config backend/src/routes/experiments.js backend/tests/experiments.test.js
git commit -m "feat(backend): persist per-experiment plot_config via PUT /:id/plot-config"
```
---
### Task 2: Frontend — serialize/read pure helpers
**Files:**
- Modify: `frontend/src/lib/crossSubjectChart.js` (append two functions + two private helpers)
- Test: `frontend/tests/crossSubjectChart.test.js` (append describe blocks)
- [ ] **Step 1: Write the failing tests**
Append to `frontend/tests/crossSubjectChart.test.js`. First add the two new names to the existing import at the top of the file — change the import to also include `serializePlotConfig, readPlotConfig`:
```js
import {
sortPayloadByValueDesc,
toggleInSet,
setIdsHidden,
groupVisibilityState,
groupLines,
serializePlotConfig,
readPlotConfig,
} from '../src/lib/crossSubjectChart';
```
Then append these describe blocks at the end of the file:
```js
describe('serializePlotConfig', () => {
const STATE = {
xField: '__days__', groupBy: 'g1', tickStep: 2, metric: 'Success',
hiddenSubjects: new Set(['b', 'a']), sidebarOpen: true,
counts: { height: 220, yMin: '0', yMax: '', xZoomMin: '', xZoomMax: '', xFieldOverride: 'f2' },
rate: { height: 160, yMin: '', yMax: '', xZoomMin: '', xZoomMax: '', xFieldOverride: null },
};
it('produces a plain JSON object with hiddenSubjects as a sorted array', () => {
const out = serializePlotConfig(STATE);
expect(out.hiddenSubjects).toEqual(['a', 'b']);
expect(out.sidebarOpen).toBe(true);
expect(out.xField).toBe('__days__');
expect(out.metric).toBe('Success');
expect(out.counts).toEqual({ height: 220, yMin: '0', yMax: '', xZoomMin: '', xZoomMax: '', xFieldOverride: 'f2' });
expect(out.rate.xFieldOverride).toBeNull();
});
it('round-trips through readPlotConfig (values preserved)', () => {
const back = readPlotConfig(serializePlotConfig(STATE));
expect(back.xField).toBe('__days__');
expect(back.groupBy).toBe('g1');
expect(back.tickStep).toBe(2);
expect(back.hiddenSubjects).toEqual(['a', 'b']);
expect(back.sidebarOpen).toBe(true);
expect(back.counts.height).toBe(220);
expect(back.counts.xFieldOverride).toBe('f2');
});
});
describe('readPlotConfig', () => {
it('returns safe defaults for null', () => {
const c = readPlotConfig(null);
expect(c.hiddenSubjects).toEqual([]);
expect(c.sidebarOpen).toBe(false);
expect(c.xField).toBeUndefined();
expect(c.counts.yMin).toBe('');
expect(c.counts.xFieldOverride).toBeNull();
});
it('returns safe defaults for a non-object', () => {
expect(readPlotConfig([1, 2]).hiddenSubjects).toEqual([]);
expect(readPlotConfig('nope').sidebarOpen).toBe(false);
});
it('filters non-string hiddenSubjects and ignores wrong-typed fields', () => {
const c = readPlotConfig({ hiddenSubjects: ['a', 5, 'b'], tickStep: 'x', sidebarOpen: 'yes' });
expect(c.hiddenSubjects).toEqual(['a', 'b']);
expect(c.tickStep).toBeUndefined();
expect(c.sidebarOpen).toBe(false);
});
it('does not throw on malformed nested chart settings', () => {
expect(() => readPlotConfig({ counts: 'bad', rate: [] })).not.toThrow();
const c = readPlotConfig({ counts: 'bad' });
expect(c.counts.yMin).toBe('');
expect(c.counts.height).toBeUndefined();
});
});
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `cd frontend && npx jest tests/crossSubjectChart.test.js`
Expected: FAIL — `serializePlotConfig`/`readPlotConfig` are not exported.
- [ ] **Step 3: Implement the helpers**
Append to `frontend/src/lib/crossSubjectChart.js`:
```js
// ── Plot config (de)serialization ───────────────────────────────────────────────
// Translate between ExperimentAnalysisCharts React state and the stored JSONB blob.
// readPlotConfig is defensive: it never throws and fills safe defaults for missing
// or malformed input (unknown keys are ignored).
function snapshotChart(s = {}) {
return {
height: s.height,
yMin: s.yMin ?? '',
yMax: s.yMax ?? '',
xZoomMin: s.xZoomMin ?? '',
xZoomMax: s.xZoomMax ?? '',
xFieldOverride: s.xFieldOverride ?? null,
};
}
export function serializePlotConfig(state) {
const { xField, groupBy, tickStep, metric, hiddenSubjects, sidebarOpen, counts, rate } = state;
return {
xField,
groupBy,
tickStep,
metric,
hiddenSubjects: [...(hiddenSubjects ?? [])].sort(),
sidebarOpen: !!sidebarOpen,
counts: snapshotChart(counts),
rate: snapshotChart(rate),
};
}
function readChart(c) {
const o = c && typeof c === 'object' && !Array.isArray(c) ? c : {};
const str = (v) => (typeof v === 'string' ? v : '');
return {
height: Number.isFinite(o.height) ? o.height : undefined,
yMin: str(o.yMin),
yMax: str(o.yMax),
xZoomMin: str(o.xZoomMin),
xZoomMax: str(o.xZoomMax),
xFieldOverride: typeof o.xFieldOverride === 'string' ? o.xFieldOverride : null,
};
}
export function readPlotConfig(raw) {
const c = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
return {
xField: typeof c.xField === 'string' ? c.xField : undefined,
groupBy: typeof c.groupBy === 'string' ? c.groupBy : undefined,
tickStep: Number.isFinite(c.tickStep) ? c.tickStep : undefined,
metric: typeof c.metric === 'string' ? c.metric : undefined,
hiddenSubjects: Array.isArray(c.hiddenSubjects) ? c.hiddenSubjects.filter((x) => typeof x === 'string') : [],
sidebarOpen: c.sidebarOpen === true,
counts: readChart(c.counts),
rate: readChart(c.rate),
};
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `cd frontend && npx jest tests/crossSubjectChart.test.js`
Expected: PASS — all helper tests (including the new ones) green.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/lib/crossSubjectChart.js frontend/tests/crossSubjectChart.test.js
git commit -m "feat(frontend): serialize/read helpers for plot config"
```
---
### Task 3: Frontend — hook refactor, API method, chart wiring, page props
**Files:**
- Modify: `frontend/src/api/client.js` (add `savePlotConfig` to `experimentsApi`, ~line 35)
- Modify: `frontend/src/components/AnalysisCharts.jsx` (`useChartSettings` ~line 92; imports ~line 1; `ExperimentAnalysisCharts` ~line 448)
- Modify: `frontend/src/pages/ExperimentDetail.jsx` (the `<ExperimentAnalysisCharts ... />` usage, ~line 365)
- Test: `frontend/tests/ExperimentAnalysisCharts.test.jsx` (add hydration + save tests)
- [ ] **Step 1: Add the API client method**
In `frontend/src/api/client.js`, inside the `experimentsApi` object, add after the `updateSubjectTemplate` line (~line 35):
```js
savePlotConfig: (id, config) => api.put(`/experiments/${id}/plot-config`, config).then((r) => r.data),
```
- [ ] **Step 2: Refactor `useChartSettings` to accept initial values and expose a snapshot**
In `frontend/src/components/AnalysisCharts.jsx`, replace the `useChartSettings` function (currently starting `function useChartSettings(defaultHeight) {` at ~line 92, through its `return {...}`/closing `}`) with this version. It adds an `initial` parameter (seeding the useState defaults) and a `snapshot` memo; everything else is unchanged:
```js
function useChartSettings(defaultHeight, initial = {}) {
const [expanded, setExpanded] = useState(false);
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',
yMax !== '' ? parseFloat(yMax) : 'auto',
], [yMin, yMax]);
const xDomain = useMemo(() => {
const hasMin = xZoomMin !== '';
const hasMax = xZoomMax !== '';
if (!hasMin && !hasMax) return null;
return [hasMin ? parseFloat(xZoomMin) : 'dataMin', hasMax ? parseFloat(xZoomMax) : 'dataMax'];
}, [xZoomMin, xZoomMax]);
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('');
setXZoomMin(''); setXZoomMax('');
setXFieldOverride(null);
}
return {
expanded, setExpanded,
height, setHeight,
yMin, setYMin, yMax, setYMax,
xZoomMin, setXZoomMin, xZoomMax, setXZoomMax,
xFieldOverride, setXFieldOverride,
yDomain, xDomain, isDirty, reset, snapshot,
};
}
```
- [ ] **Step 3: Update imports in `AnalysisCharts.jsx`**
At the top of the file, change the React import to include `useRef`, and add the new helper imports to the existing `crossSubjectChart` import. The React import becomes:
```jsx
import React, { useState, useMemo, useEffect, useRef } from 'react';
```
And the `crossSubjectChart` import becomes:
```jsx
import { sortPayloadByValueDesc, toggleInSet, setIdsHidden, serializePlotConfig, readPlotConfig } from '../lib/crossSubjectChart';
```
- [ ] **Step 4: Add the two new props and config-driven initialization to `ExperimentAnalysisCharts`**
Change the component signature (~line 448) to accept the new props:
```jsx
export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectTemplate, dailyTemplate, plotConfig, onPersistConfig }) {
```
Then, immediately after the `const defaultGroup = ...` line (~line 452) and BEFORE the existing `const [groupBy, ...]` state declarations, insert the once-only config read:
```jsx
// 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;
```
Now replace the existing five state declarations (`groupBy`, `metric`, `xField`, `tickStep`, and the `sidebarOpen`/`hiddenSubjects` declarations) so each is seeded from `initialConfig`. Replace these lines:
```jsx
const [groupBy, setGroupBy] = useState(defaultGroup);
const [metric, setMetric] = useState('total');
const [xField, setXField] = useState(() => defaultXField(xAxisFields));
const [tickStep, setTickStep] = useState(1);
// Cross-subject interaction state (client-only; global across both charts)
const [sidebarOpen, setSidebarOpen] = useState(false);
const [hiddenSubjects, setHiddenSubjects] = useState(() => new Set());
const [highlightedSubject, setHighlightedSubject] = useState(null);
```
with:
```jsx
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(initialConfig.sidebarOpen);
const [hiddenSubjects, setHiddenSubjects] = useState(() => new Set(initialConfig.hiddenSubjects));
const [highlightedSubject, setHighlightedSubject] = useState(null);
```
And seed the two per-chart hooks from `initialConfig`. Replace:
```jsx
const countsS = useChartSettings(200);
const rateS = useChartSettings(160);
```
with:
```jsx
const countsS = useChartSettings(200, initialConfig.counts);
const rateS = useChartSettings(160, initialConfig.rate);
```
- [ ] **Step 5: Add the serialized config + debounced auto-save**
Immediately after the `const countsS`/`const rateS` lines (and the `toggleHidden`/`toggleGroup`/`showAll` handlers which are just above them), add the serialization memo, save-state, and debounced-save effect. Insert this block right after the `const rateS = ...` line:
```jsx
// ── 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)
if (serialized === lastSavedRef.current) return;
if (!persistRef.current) { lastSavedRef.current = serialized; 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 6: Render the save-status hint in the global controls row**
Find the global-controls row in `ExperimentAnalysisCharts` — the `<div className="flex items-center flex-wrap gap-3">` containing the `<h2>Cross-subject metrics</h2>`, the X-axis select, Color-by select, `TickStepControl`, and the `▸ Subjects` button. The Subjects button currently has `ml-auto` in its className. Replace that standalone button block:
```jsx
{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>
)}
```
with a wrapper that holds the status hint and the button (note `ml-auto` moved to the wrapper, removed from the button):
```jsx
{hasData && (
<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>
)}
```
- [ ] **Step 7: Pass the new props from `ExperimentDetail.jsx`**
In `frontend/src/pages/ExperimentDetail.jsx`, find the `<ExperimentAnalysisCharts ... />` usage (~line 365) and add the two props. It must use the page's existing `experiment` object and `id` (from `useParams`) and the already-imported `experimentsApi`:
```jsx
<ExperimentAnalysisCharts
animals={animals}
experimentStatuses={experimentStatuses}
subjectTemplate={subjectTemplate}
dailyTemplate={dailyTemplate}
plotConfig={experiment.plot_config}
onPersistConfig={(cfg) => experimentsApi.savePlotConfig(id, cfg)}
/>
```
(If `id` is not already in scope, use `experiment.id` instead. Verify which identifier the file uses before editing.)
- [ ] **Step 8: Write the hydration + save tests**
Append these two tests to `frontend/tests/ExperimentAnalysisCharts.test.jsx`. Note the file already mocks `recharts` and defines `ANIMALS`, `STATUSES`, `SUBJECT_TEMPLATE`, `DAILY_TEMPLATE`, and `renderChart()`. Add `act` to the testing-library import at the top of the file (change `import { render, screen, fireEvent } from '@testing-library/react';` to include `act`):
```jsx
import { render, screen, fireEvent, act } from '@testing-library/react';
```
Then append:
```jsx
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 }}
/>,
);
// sidebar starts open (no click needed) and a1 starts hidden (unchecked)
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')); // hide a1 → config change
expect(onPersist).not.toHaveBeenCalled(); // not yet (debounced)
act(() => { jest.advanceTimersByTime(1100); });
expect(onPersist).toHaveBeenCalledTimes(1);
expect(onPersist.mock.calls[0][0].hiddenSubjects).toContain('a1');
});
});
```
- [ ] **Step 9: Run the chart test file to verify it passes**
Run: `cd frontend && npx jest tests/ExperimentAnalysisCharts.test.jsx`
Expected: PASS — the two original smoke tests plus the three new tests green. If a test reveals a genuine wiring problem, fix the wiring (do not weaken the test).
- [ ] **Step 10: Run the full frontend suite (no new regressions)**
Run: `cd frontend && npm test`
Expected: PASS for all suites except the pre-existing `ExperimentCalendar` / `ExperimentDayView` failures (31 failures that also fail on `main` — unrelated). All other suites, including `crossSubjectChart`, `SubjectSidebar`, and `ExperimentAnalysisCharts`, green.
- [ ] **Step 11: Build to confirm no compile errors**
Run: `cd frontend && npm run build`
Expected: Vite build succeeds with no errors.
- [ ] **Step 12: Commit**
```bash
git add frontend/src/api/client.js frontend/src/components/AnalysisCharts.jsx frontend/src/pages/ExperimentDetail.jsx frontend/tests/ExperimentAnalysisCharts.test.jsx
git commit -m "feat(frontend): hydrate + debounced-persist cross-subject plot config"
```
---
## Manual Verification (after all tasks, requires DB migration applied)
The running stack applies migrations on backend startup (`prisma migrate deploy` in the entrypoint). After `docker compose up --build`:
- [ ] Open an experiment with animals + saved metrics. Change X-axis, toggle a few subjects off, open the sidebar, adjust a per-chart size. Observe a brief "Saving… → Saved ✓".
- [ ] Reload the page → the same X-axis, hidden subjects, sidebar state, and chart size are restored.
- [ ] Open a different experiment → it has its own independent settings (or defaults if never configured).
- [ ] Confirm no new `audit_logs` rows are created by plot tweaks (only by template edits etc.).
- [ ] Existing controls and the previously-shipped interactions (legend toggle/highlight, ordered tooltip) still work.
## Self-Review Notes
- **Spec coverage:** migration + nullable column (Task 1 Steps 34); `PUT /:id/plot-config` with validator, no audit log (Task 1 Steps 67, asserted in Step 1); read via existing `GET /:id` (no code needed — Prisma returns the column); API client method (Task 3 Step 1); `serializePlotConfig`/`readPlotConfig` with defensive defaults (Task 2); `useChartSettings` `initial`+`snapshot` refactor (Task 3 Step 2); hydrate-once + serialize + debounced save + status hint (Task 3 Steps 46); `ExperimentDetail` props (Task 3 Step 7); tests at all three layers (Task 1 Step 1, Task 2 Step 1, Task 3 Step 8); manual checks. All spec sections covered.
- **No-audit-log requirement:** explicitly asserted by `expect(prisma.auditLog.create).not.toHaveBeenCalled()` (Task 1 Step 1).
- **Save-on-load loop prevention:** `lastSavedRef` initial-null skip (Task 3 Step 5) + the "does not call onPersistConfig on initial render" test (Task 3 Step 8).
- **Naming consistency:** `plot_config` (DB/route), `plotConfig`/`onPersistConfig` (props), `serializePlotConfig`/`readPlotConfig` (helpers), `snapshot` (hook) used identically across tasks.
- **No disallowed changes:** `SubjectAnalysisCharts` calls `useChartSettings(defaultHeight)` with no `initial`, which defaults to `{}` → unchanged behavior; template/subject-template routes untouched.