Merge: persist cross-subject plot config per experiment

This commit is contained in:
Experiments DB Dev
2026-06-01 10:34:54 -04:00
12 changed files with 1176 additions and 29 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "experiments" ADD COLUMN "plot_config" JSONB;
+1
View File
@@ -14,6 +14,7 @@ model Experiment {
created_at DateTime @default(now())
template Json @default("[]")
subject_info_template Json @default("[]")
plot_config Json?
animals Animal[]
@@map("experiments")
+42
View File
@@ -224,6 +224,30 @@ function validateSubjectTemplate(template) {
return null;
}
// Upper bound on the stored UI-preference blob. Sized to comfortably hold the known
// fields plus a full hiddenSubjects list (≤500 UUIDs ≈ 20 KB) with headroom, while
// still guarding against a client persisting an arbitrarily large object.
const PLOT_CONFIG_MAX_BYTES = 64 * 1024;
// The blob is intentionally schema-loose (clients may add display keys over time);
// only hiddenSubjects is structurally validated since the backend reasons about it.
function validatePlotConfig(config) {
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
return 'plot_config must be an object';
}
// req.body is already JSON-parsed, so stringify cannot throw here.
if (Buffer.byteLength(JSON.stringify(config), 'utf8') > PLOT_CONFIG_MAX_BYTES) {
return 'plot_config too large';
}
if ('hiddenSubjects' in config) {
const hidden = config.hiddenSubjects;
if (!Array.isArray(hidden) || hidden.length > 500 || !hidden.every((x) => typeof x === 'string')) {
return 'hiddenSubjects invalid';
}
}
return null;
}
// ── Template endpoints ─────────────────────────────────────────────────────────
// GET /api/experiments/:id/template
@@ -308,6 +332,24 @@ router.put('/:id/subject-template', async (req, res, next) => {
} catch (err) { next(err); }
});
// 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); }
});
// GET /api/experiments/:id/calendar?field=<fieldId|builtinKey>
// Returns { field, days: { "YYYY-MM-DD": count } } where count = subjects with non-empty value that day.
router.get('/:id/calendar', async (req, res, next) => {
+46
View File
@@ -136,3 +136,49 @@ describe('GET /health', () => {
expect(res.body.status).toBe('ok');
});
});
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(70000) };
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();
});
});
@@ -0,0 +1,678 @@
# 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.
@@ -0,0 +1,137 @@
# Persist cross-subject plot display settings per experiment
**Date:** 2026-06-01
**Backend:** `backend/src/routes/experiments.js`, Prisma schema + migration
**Frontend:** `frontend/src/components/AnalysisCharts.jsx` (`ExperimentAnalysisCharts`), `frontend/src/pages/ExperimentDetail.jsx`, `frontend/src/api/client.js`, `frontend/src/lib/crossSubjectChart.js`
## Summary
Persist a user's display settings for the "Cross-subject metrics" plot per experiment, so they are restored on the next visit instead of being re-applied every time. The settings live in a new nullable JSONB column on `experiments`, are saved via a dedicated auto-saving (debounced) endpoint, and hydrate the chart on load.
## Decisions (from brainstorming)
- **Scope:** the cross-subject plot only (the per-experiment chart in `ExperimentAnalysisCharts`). The sibling `SubjectAnalysisCharts` (per-animal page) is out of scope.
- **Save trigger:** auto-save, debounced ~1s after the last change. No explicit save button.
- **Storage:** Postgres via Prisma (the project's DB; it is not SQLite), following the existing `template` / `subject_info_template` / `csv_analysis_config` JSONB pattern.
## Persisted config shape
`experiments.plot_config` (JSONB, nullable). `null` = nothing saved → chart uses computed defaults.
```jsonc
{
"xField": "<fieldId|'__days__'>",
"groupBy": "<fieldId|''>",
"tickStep": 1,
"metric": "total|Success|Failure",
"hiddenSubjects": ["<animalId>", ...],
"sidebarOpen": false,
"counts": { "height": 200, "yMin": "", "yMax": "", "xZoomMin": "", "xZoomMax": "", "xFieldOverride": null },
"rate": { "height": 160, "yMin": "", "yMax": "", "xZoomMin": "", "xZoomMax": "", "xFieldOverride": null }
}
```
All keys are optional on read — unknown/missing keys fall back to defaults, so older blobs and future additions both stay safe. `highlightedSubject` is transient and is NOT persisted.
## Backend
### Migration (additive, safe)
`backend/prisma/migrations/<ts>_add_experiment_plot_config/migration.sql`:
```sql
ALTER TABLE "experiments" ADD COLUMN "plot_config" JSONB;
```
Schema: add `plot_config Json?` to `model Experiment`. Existing rows get `NULL`; nothing else changes.
### Route: `PUT /api/experiments/:id/plot-config`
Mirrors the existing `PUT /:id/subject-template` handler, with two differences: **no audit-log write** (debounced auto-save would spam `audit_logs`; this is a UI preference, not experiment data), and a different validator.
```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); }
});
```
`validatePlotConfig(config)`:
- Must be a non-null, non-array plain object → else `'plot_config must be an object'`.
- Serialized size cap: `JSON.stringify(config).length <= 16384` → else `'plot_config too large'`.
- If `hiddenSubjects` present, must be an array of strings with length ≤ 500 → else `'hiddenSubjects invalid'`.
- Otherwise permissive (stores the object as-is). Keeps the backend agnostic to the exact UI shape.
### Read path
`GET /api/experiments/:id` already returns all scalar columns (it uses `findUnique` without a `select`), so `plot_config` rides along automatically. No new GET endpoint.
## Frontend
### API client (`frontend/src/api/client.js`)
Add to `experimentsApi`:
```js
savePlotConfig: (id, config) => api.put(`/experiments/${id}/plot-config`, config).then((r) => r.data),
```
### Pure helpers (`frontend/src/lib/crossSubjectChart.js`)
Two pure, unit-tested functions isolate (de)serialization from React:
- `serializePlotConfig({ xField, groupBy, tickStep, metric, hiddenSubjects, sidebarOpen, counts, rate })` → plain JSON object (turns the `hiddenSubjects` Set into a sorted array; copies the two per-chart snapshots).
- `readPlotConfig(raw)` → normalized `{ xField?, groupBy?, tickStep?, metric?, hiddenSubjects: string[], sidebarOpen: bool, counts: {...}, rate: {...} }` with defensive defaults for `null`/malformed input (never throws).
### `useChartSettings` refactor (`AnalysisCharts.jsx`)
- Add optional `initial` argument; seed `height`, `yMin`, `yMax`, `xZoomMin`, `xZoomMax`, `xFieldOverride` from it when provided.
- Expose a `snapshot` object (those serializable values) for the parent to persist.
- `reset()` and `isDirty` keep working. `SubjectAnalysisCharts` calls the hook with no `initial`, so its behavior is unchanged.
### `ExperimentAnalysisCharts` wiring
New props: `plotConfig` (raw from `experiment.plot_config`) and `onPersistConfig(config)`.
- **Hydrate once:** read `readPlotConfig(plotConfig)` into a memo; pass `counts`/`rate` slices as `initial` to the two `useChartSettings` calls; in a one-time effect (guarded by `hydratedRef`) seed `xField`, `groupBy`, `tickStep`, `metric`, `hiddenSubjects` (`new Set(cfg.hiddenSubjects)`), `sidebarOpen`.
- **Serialize:** `const currentConfig = useMemo(() => serializePlotConfig({...all state, counts: countsS.snapshot, rate: rateS.snapshot}), [deps])`.
- **Debounced save:** an effect keyed on `JSON.stringify(currentConfig)` that, *after* hydration and only when the value differs from `lastSavedRef.current`, sets a 1s timeout calling `onPersistConfig(currentConfig)`, then updates `lastSavedRef`. Clears the timeout on change/unmount. This prevents both a save-on-load loop and no-op writes.
- **Status hint:** a tiny "Saving…/Saved ✓" indicator near the controls; on save failure show a quiet "couldn't save layout" note and leave the chart fully functional.
### `ExperimentDetail.jsx`
Pass the two props:
```jsx
<ExperimentAnalysisCharts
animals={animals}
experimentStatuses={experimentStatuses}
subjectTemplate={subjectTemplate}
dailyTemplate={dailyTemplate}
plotConfig={experiment.plot_config}
onPersistConfig={(cfg) => experimentsApi.savePlotConfig(id, cfg)}
/>
```
## Edge cases & non-goals
- **Stale subject ids:** a hidden `animalId` for a since-removed animal simply matches no line (already how visibility behaves). No cleanup.
- **Single-tenant, last-write-wins:** no per-user configs (there is no auth layer); config is shared per experiment.
- **Failure is non-fatal:** a failed save never breaks the chart; the user just sees the quiet note and can retry by changing something.
- **Untouched:** template/subject-template routes and their audit logging; `SubjectAnalysisCharts`; the cross-subject interaction behaviors shipped previously.
## Testing
- **Pure helpers (Jest, `frontend/tests/crossSubjectChart.test.js`):** `serializePlotConfig` round-trips (Set→sorted array, includes per-chart snapshots); `readPlotConfig` returns defaults for `null`/`{}`/malformed input and preserves valid fields. Never throws.
- **Backend route (Jest + supertest + mocked Prisma, `backend/tests/experiments.test.js`):** `PUT /:id/plot-config` returns 200 and calls `prisma.experiment.update` with the config for a valid body; 404 when the experiment is missing; 422 for a non-object body, an oversized body, and an invalid `hiddenSubjects`; and does NOT call `prisma.auditLog.create`.
- **Frontend smoke (Jest + RTL, mocked recharts, `frontend/tests/ExperimentAnalysisCharts.test.jsx`):** hydrates state from a `plotConfig` prop (e.g. a hidden subject starts unchecked); and, with fake timers, calls `onPersistConfig` ~1s after a change but not on initial render.
- **Manual:** adjust the plot on one experiment, reload → settings persist; open a second experiment → independent config; confirm no new `audit_logs` rows from plot tweaks.
+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>
+59
View File
@@ -50,3 +50,62 @@ export function groupLines(lines) {
}
return order.map((group) => ({ group, subjects: byGroup.get(group) }));
}
// ── 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).
// Symmetric with readChart so a serialize→read round-trip is lossless.
function snapshotChart(s = {}) {
const str = (v) => (typeof v === 'string' ? v : '');
return {
height: Number.isFinite(s.height) ? s.height : undefined,
yMin: str(s.yMin),
yMax: str(s.yMax),
xZoomMin: str(s.xZoomMin),
xZoomMax: str(s.xZoomMax),
xFieldOverride: typeof s.xFieldOverride === 'string' ? 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),
};
}
+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();
});
});
+68
View File
@@ -4,6 +4,8 @@ import {
setIdsHidden,
groupVisibilityState,
groupLines,
serializePlotConfig,
readPlotConfig,
} from '../src/lib/crossSubjectChart';
describe('sortPayloadByValueDesc', () => {
@@ -80,3 +82,69 @@ describe('groupLines', () => {
expect(groupLines([{ id: '1' }])[0].group).toBe('—');
});
});
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');
expect(back.rate.height).toBe(160);
expect(back.rate.xFieldOverride).toBeNull();
});
it('returns an empty hiddenSubjects array when state.hiddenSubjects is null', () => {
expect(serializePlotConfig({ ...STATE, hiddenSubjects: null }).hiddenSubjects).toEqual([]);
});
});
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();
});
});