# 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 3–4); `PUT /:id/plot-config` with validator, no audit log (Task 1 Steps 6–7, 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 4–6); `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.
