Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
29 KiB
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(modelExperiment) -
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 thePUT /:id/subject-templatehandler, ~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)):
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):
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:
-- 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):
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):
// 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
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:
import {
sortPayloadByValueDesc,
toggleInSet,
setIdsHidden,
groupVisibilityState,
groupLines,
serializePlotConfig,
readPlotConfig,
} from '../src/lib/crossSubjectChart';
Then append these describe blocks at the end of the file:
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:
// ── 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
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(addsavePlotConfigtoexperimentsApi, ~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):
savePlotConfig: (id, config) => api.put(`/experiments/${id}/plot-config`, config).then((r) => r.data),
- Step 2: Refactor
useChartSettingsto 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:
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:
import React, { useState, useMemo, useEffect, useRef } from 'react';
And the crossSubjectChart import becomes:
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:
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:
// 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:
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:
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:
const countsS = useChartSettings(200);
const rateS = useChartSettings(160);
with:
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:
// ── 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:
{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):
{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:
<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):
import { render, screen, fireEvent, act } from '@testing-library/react';
Then append:
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
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_logsrows 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 3–4);
PUT /:id/plot-configwith validator, no audit log (Task 1 Steps 6–7, asserted in Step 1); read via existingGET /:id(no code needed — Prisma returns the column); API client method (Task 3 Step 1);serializePlotConfig/readPlotConfigwith defensive defaults (Task 2);useChartSettingsinitial+snapshotrefactor (Task 3 Step 2); hydrate-once + serialize + debounced save + status hint (Task 3 Steps 4–6);ExperimentDetailprops (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:
lastSavedRefinitial-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:
SubjectAnalysisChartscallsuseChartSettings(defaultHeight)with noinitial, which defaults to{}→ unchanged behavior; template/subject-template routes untouched.