From 8877747a63320cce728bcf885ceb5b7d59be316f Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Mon, 1 Jun 2026 10:06:40 -0400 Subject: [PATCH 1/6] docs: design for persisting cross-subject plot config per experiment Co-Authored-By: Claude Opus 4.8 --- ...ersist-cross-subject-plot-config-design.md | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-01-persist-cross-subject-plot-config-design.md diff --git a/docs/superpowers/specs/2026-06-01-persist-cross-subject-plot-config-design.md b/docs/superpowers/specs/2026-06-01-persist-cross-subject-plot-config-design.md new file mode 100644 index 0000000..0434411 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-persist-cross-subject-plot-config-design.md @@ -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": "", + "groupBy": "", + "tickStep": 1, + "metric": "total|Success|Failure", + "hiddenSubjects": ["", ...], + "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/_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 + 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. From 2dd4a329ee67c1bac371d21d369515dd15b22883 Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Mon, 1 Jun 2026 10:10:39 -0400 Subject: [PATCH 2/6] docs: implementation plan for persisting plot config Co-Authored-By: Claude Opus 4.8 --- ...06-01-persist-cross-subject-plot-config.md | 678 ++++++++++++++++++ 1 file changed, 678 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-01-persist-cross-subject-plot-config.md diff --git a/docs/superpowers/plans/2026-06-01-persist-cross-subject-plot-config.md b/docs/superpowers/plans/2026-06-01-persist-cross-subject-plot-config.md new file mode 100644 index 0000000..6c750b2 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-persist-cross-subject-plot-config.md @@ -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 `` 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 `
` containing the `

Cross-subject metrics

`, 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 && ( + + )} +``` + +with a wrapper that holds the status hint and the button (note `ml-auto` moved to the wrapper, removed from the button): + +```jsx + {hasData && ( +
+ {saveState === 'saving' && Saving…} + {saveState === 'saved' && Saved ✓} + {saveState === 'error' && Couldn't save layout} + +
+ )} +``` + +- [ ] **Step 7: Pass the new props from `ExperimentDetail.jsx`** + +In `frontend/src/pages/ExperimentDetail.jsx`, find the `` 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 + 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( + , + ); + // 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( + , + ); + act(() => { jest.advanceTimersByTime(1500); }); + expect(onPersist).not.toHaveBeenCalled(); + }); + + it('calls onPersistConfig ~1s after a change, with the updated config', () => { + const onPersist = jest.fn().mockResolvedValue({}); + render( + , + ); + fireEvent.click(screen.getByLabelText('Mouse-A1')); // 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. From e9511bdac3713b631f60353ac0a02d741178e377 Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Mon, 1 Jun 2026 10:12:23 -0400 Subject: [PATCH 3/6] feat(backend): persist per-experiment plot_config via PUT /:id/plot-config Co-Authored-By: Claude Opus 4.8 --- .../migration.sql | 2 + backend/prisma/schema.prisma | 1 + backend/src/routes/experiments.js | 41 +++++++++++++++++ backend/tests/experiments.test.js | 46 +++++++++++++++++++ 4 files changed, 90 insertions(+) create mode 100644 backend/prisma/migrations/20260601000000_add_experiment_plot_config/migration.sql diff --git a/backend/prisma/migrations/20260601000000_add_experiment_plot_config/migration.sql b/backend/prisma/migrations/20260601000000_add_experiment_plot_config/migration.sql new file mode 100644 index 0000000..01b6bb6 --- /dev/null +++ b/backend/prisma/migrations/20260601000000_add_experiment_plot_config/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "experiments" ADD COLUMN "plot_config" JSONB; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 7c29b47..5eb5dbe 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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") diff --git a/backend/src/routes/experiments.js b/backend/src/routes/experiments.js index 2581c29..01c6154 100644 --- a/backend/src/routes/experiments.js +++ b/backend/src/routes/experiments.js @@ -224,6 +224,29 @@ function validateSubjectTemplate(template) { return null; } +// Upper bound on the stored UI-preference blob. Generous for the known fields; +// guards against a client persisting an arbitrarily large object. +const PLOT_CONFIG_MAX_BYTES = 16 * 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 +331,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= // Returns { field, days: { "YYYY-MM-DD": count } } where count = subjects with non-empty value that day. router.get('/:id/calendar', async (req, res, next) => { diff --git a/backend/tests/experiments.test.js b/backend/tests/experiments.test.js index b9927f8..be586fd 100644 --- a/backend/tests/experiments.test.js +++ b/backend/tests/experiments.test.js @@ -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(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(); + }); +}); From 4b82df514f3ffd2914504b19fae8a7b06d30a37d Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Mon, 1 Jun 2026 10:16:16 -0400 Subject: [PATCH 4/6] feat(frontend): serialize/read helpers for plot config Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/crossSubjectChart.js | 59 ++++++++++++++++++++ frontend/tests/crossSubjectChart.test.js | 68 ++++++++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/frontend/src/lib/crossSubjectChart.js b/frontend/src/lib/crossSubjectChart.js index 8443231..88d895c 100644 --- a/frontend/src/lib/crossSubjectChart.js +++ b/frontend/src/lib/crossSubjectChart.js @@ -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), + }; +} diff --git a/frontend/tests/crossSubjectChart.test.js b/frontend/tests/crossSubjectChart.test.js index 39f1e60..33d72ab 100644 --- a/frontend/tests/crossSubjectChart.test.js +++ b/frontend/tests/crossSubjectChart.test.js @@ -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(); + }); +}); From 9a9b5dfae8043360fd0bc5e9a10d978eae67ad33 Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Mon, 1 Jun 2026 10:22:39 -0400 Subject: [PATCH 5/6] feat(frontend): hydrate + debounced-persist cross-subject plot config Co-Authored-By: Claude Opus 4.8 --- frontend/src/api/client.js | 1 + frontend/src/components/AnalysisCharts.jsx | 102 +++++++++++++----- frontend/src/pages/ExperimentDetail.jsx | 2 + .../tests/ExperimentAnalysisCharts.test.jsx | 67 +++++++++++- 4 files changed, 143 insertions(+), 29 deletions(-) diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index b4b7c33..6679ed2 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -33,6 +33,7 @@ export const experimentsApi = { updateTemplate: (id, template) => api.put(`/experiments/${id}/template`, template).then((r) => r.data), getSubjectTemplate: (id) => api.get(`/experiments/${id}/subject-template`).then((r) => r.data), updateSubjectTemplate: (id, template) => api.put(`/experiments/${id}/subject-template`, template).then((r) => r.data), + savePlotConfig: (id, config) => api.put(`/experiments/${id}/plot-config`, config).then((r) => r.data), // opts: { date?: string, analysisOnly?: boolean } getDailyStatuses: (id, opts) => { const params = typeof opts === 'string' ? { date: opts } : (opts ?? {}); diff --git a/frontend/src/components/AnalysisCharts.jsx b/frontend/src/components/AnalysisCharts.jsx index 536da13..58ed95c 100644 --- a/frontend/src/components/AnalysisCharts.jsx +++ b/frontend/src/components/AnalysisCharts.jsx @@ -1,11 +1,11 @@ -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo, useEffect, useRef } from 'react'; import { format } from 'date-fns'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, } from 'recharts'; import { SubjectSidebar } from './SubjectSidebar'; -import { sortPayloadByValueDesc, toggleInSet, setIdsHidden } from '../lib/crossSubjectChart'; +import { sortPayloadByValueDesc, toggleInSet, setIdsHidden, serializePlotConfig, readPlotConfig } from '../lib/crossSubjectChart'; // ── Color palettes ───────────────────────────────────────────────────────────── @@ -89,14 +89,14 @@ const HEIGHT_OPTIONS = [ { label: 'L', value: 340 }, ]; -function useChartSettings(defaultHeight) { +function useChartSettings(defaultHeight, initial = {}) { const [expanded, setExpanded] = useState(false); - const [height, setHeight] = useState(defaultHeight); - const [yMin, setYMin] = useState(''); - const [yMax, setYMax] = useState(''); - const [xZoomMin, setXZoomMin] = useState(''); - const [xZoomMax, setXZoomMax] = useState(''); - const [xFieldOverride, setXFieldOverride] = useState(null); // null → use parent's xField + const [height, setHeight] = useState(initial.height ?? defaultHeight); + const [yMin, setYMin] = useState(initial.yMin ?? ''); + const [yMax, setYMax] = useState(initial.yMax ?? ''); + const [xZoomMin, setXZoomMin] = useState(initial.xZoomMin ?? ''); + const [xZoomMax, setXZoomMax] = useState(initial.xZoomMax ?? ''); + const [xFieldOverride, setXFieldOverride] = useState(initial.xFieldOverride ?? null); // null → use parent's xField const yDomain = useMemo(() => [ yMin !== '' ? parseFloat(yMin) : 'auto', @@ -113,6 +113,11 @@ function useChartSettings(defaultHeight) { const isDirty = yMin !== '' || yMax !== '' || xZoomMin !== '' || xZoomMax !== '' || xFieldOverride !== null || height !== defaultHeight; + const snapshot = useMemo( + () => ({ height, yMin, yMax, xZoomMin, xZoomMax, xFieldOverride }), + [height, yMin, yMax, xZoomMin, xZoomMax, xFieldOverride], + ); + function reset() { setHeight(defaultHeight); setYMin(''); setYMax(''); @@ -126,7 +131,7 @@ function useChartSettings(defaultHeight) { yMin, setYMin, yMax, setYMax, xZoomMin, setXZoomMin, xZoomMax, setXZoomMax, xFieldOverride, setXFieldOverride, - yDomain, xDomain, isDirty, reset, + yDomain, xDomain, isDirty, reset, snapshot, }; } @@ -445,27 +450,63 @@ const METRIC_OPTIONS = [ { value: 'Failure', label: 'Failure count' }, ]; -export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectTemplate, dailyTemplate }) { +export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectTemplate, dailyTemplate, plotConfig, onPersistConfig }) { const groupableFields = useMemo(() => (subjectTemplate ?? []).filter((f) => f.active), [subjectTemplate]); const xAxisFields = useMemo(() => (dailyTemplate ?? []).filter((f) => f.active), [dailyTemplate]); const defaultGroup = (groupableFields.find((f) => /^group$/i.test(f.label)) ?? groupableFields[0])?.fieldId ?? ''; - const [groupBy, setGroupBy] = useState(defaultGroup); - const [metric, setMetric] = useState('total'); - const [xField, setXField] = useState(() => defaultXField(xAxisFields)); - const [tickStep, setTickStep] = useState(1); + // Read the saved config exactly once (component only mounts after the experiment loads). + const initialConfigRef = useRef(null); + if (initialConfigRef.current === null) initialConfigRef.current = readPlotConfig(plotConfig); + const initialConfig = initialConfigRef.current; + const [groupBy, setGroupBy] = useState(initialConfig.groupBy ?? defaultGroup); + const [metric, setMetric] = useState(initialConfig.metric ?? 'total'); + const [xField, setXField] = useState(() => initialConfig.xField ?? defaultXField(xAxisFields)); + const [tickStep, setTickStep] = useState(initialConfig.tickStep ?? 1); // Cross-subject interaction state (client-only; global across both charts) - const [sidebarOpen, setSidebarOpen] = useState(false); - const [hiddenSubjects, setHiddenSubjects] = useState(() => new Set()); + const [sidebarOpen, setSidebarOpen] = useState(initialConfig.sidebarOpen); + const [hiddenSubjects, setHiddenSubjects] = useState(() => new Set(initialConfig.hiddenSubjects)); const [highlightedSubject, setHighlightedSubject] = useState(null); const toggleHidden = (id) => setHiddenSubjects((prev) => toggleInSet(prev, id)); const toggleGroup = (ids, hidden) => setHiddenSubjects((prev) => setIdsHidden(prev, ids, hidden)); const showAll = () => setHiddenSubjects(new Set()); - const countsS = useChartSettings(200); - const rateS = useChartSettings(160); + const countsS = useChartSettings(200, initialConfig.counts); + const rateS = useChartSettings(160, initialConfig.rate); + + // ── Persist display settings (debounced auto-save) ──────────────────────────── + const [saveState, setSaveState] = useState('idle'); // 'idle' | 'saving' | 'saved' | 'error' + + const currentConfig = useMemo( + () => serializePlotConfig({ + xField, groupBy, tickStep, metric, hiddenSubjects, sidebarOpen, + counts: countsS.snapshot, rate: rateS.snapshot, + }), + [xField, groupBy, tickStep, metric, hiddenSubjects, sidebarOpen, countsS.snapshot, rateS.snapshot], + ); + + // Keep the latest persist callback in a ref so the save effect does not re-run + // when the parent passes a new inline function each render. + const persistRef = useRef(onPersistConfig); + persistRef.current = onPersistConfig; + + const lastSavedRef = useRef(null); + useEffect(() => { + const serialized = JSON.stringify(currentConfig); + if (lastSavedRef.current === null) { lastSavedRef.current = serialized; return; } // skip initial (hydration) + // Reverted to the last-saved value before the debounce fired → nothing pending. + if (serialized === lastSavedRef.current) { setSaveState('idle'); return; } + if (!persistRef.current) { lastSavedRef.current = serialized; setSaveState('idle'); return; } + setSaveState('saving'); + const t = setTimeout(() => { + Promise.resolve(persistRef.current(currentConfig)) + .then(() => { lastSavedRef.current = serialized; setSaveState('saved'); }) + .catch(() => setSaveState('error')); + }, 1000); + return () => clearTimeout(t); + }, [currentConfig]); // Step 1: animalRows — does NOT depend on xField; shared by both charts const { animalRows, lines } = useMemo(() => { @@ -567,15 +608,20 @@ export function ExperimentAnalysisCharts({ animals, experimentStatuses, subjectT )} {hasData && ( - +
+ {saveState === 'saving' && Saving…} + {saveState === 'saved' && Saved ✓} + {saveState === 'error' && Couldn't save layout} + +
)}
diff --git a/frontend/src/pages/ExperimentDetail.jsx b/frontend/src/pages/ExperimentDetail.jsx index 13b59be..c5c5246 100644 --- a/frontend/src/pages/ExperimentDetail.jsx +++ b/frontend/src/pages/ExperimentDetail.jsx @@ -367,6 +367,8 @@ export default function ExperimentDetail() { experimentStatuses={experimentStatuses} subjectTemplate={subjectTemplate} dailyTemplate={dailyTemplate} + plotConfig={experiment.plot_config} + onPersistConfig={(cfg) => experimentsApi.savePlotConfig(id, cfg)} /> {animals.length > 0 && ( diff --git a/frontend/tests/ExperimentAnalysisCharts.test.jsx b/frontend/tests/ExperimentAnalysisCharts.test.jsx index 17c2021..6a7c9fa 100644 --- a/frontend/tests/ExperimentAnalysisCharts.test.jsx +++ b/frontend/tests/ExperimentAnalysisCharts.test.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; // recharts uses ResizeObserver (absent in jsdom); stub all used pieces as passthroughs. // Coverage boundary: with recharts mocked out, these tests cover only the sidebar @@ -59,3 +59,68 @@ it('unchecking a subject in the sidebar persists across re-render', () => { expect(screen.getByLabelText('Mouse-A1').checked).toBe(false); expect(screen.getByText('Show all')).toBeInTheDocument(); }); + +it('hydrates hidden subjects and sidebar-open from plotConfig', () => { + render( + , + ); + expect(screen.getByLabelText('Mouse-A1').checked).toBe(false); + expect(screen.getByLabelText('Mouse-B1').checked).toBe(true); +}); + +describe('debounced auto-save', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('does not call onPersistConfig on initial render', () => { + const onPersist = jest.fn(); + render( + , + ); + act(() => { jest.advanceTimersByTime(1500); }); + expect(onPersist).not.toHaveBeenCalled(); + }); + + it('calls onPersistConfig ~1s after a change, with the updated config', () => { + const onPersist = jest.fn().mockResolvedValue({}); + render( + , + ); + fireEvent.click(screen.getByLabelText('Mouse-A1')); + expect(onPersist).not.toHaveBeenCalled(); + act(() => { jest.advanceTimersByTime(1100); }); + expect(onPersist).toHaveBeenCalledTimes(1); + expect(onPersist.mock.calls[0][0].hiddenSubjects).toContain('a1'); + }); + + it('does not save and clears the saving hint when a change is reverted before the debounce fires', () => { + const onPersist = jest.fn().mockResolvedValue({}); + render( + , + ); + fireEvent.click(screen.getByLabelText('Mouse-A1')); // hide a1 + expect(screen.getByText('Saving…')).toBeInTheDocument(); + fireEvent.click(screen.getByLabelText('Mouse-A1')); // revert before the 1s timer + act(() => { jest.advanceTimersByTime(1100); }); + expect(onPersist).not.toHaveBeenCalled(); + expect(screen.queryByText('Saving…')).toBeNull(); + }); +}); From b0c6217cb40c67c78930161c7e0e983f5c4afb0b Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Mon, 1 Jun 2026 10:30:06 -0400 Subject: [PATCH 6/6] fix(backend): widen plot_config size cap to fit a full hiddenSubjects list Co-Authored-By: Claude Opus 4.8 --- backend/src/routes/experiments.js | 7 ++++--- backend/tests/experiments.test.js | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/src/routes/experiments.js b/backend/src/routes/experiments.js index 01c6154..27f93ca 100644 --- a/backend/src/routes/experiments.js +++ b/backend/src/routes/experiments.js @@ -224,9 +224,10 @@ function validateSubjectTemplate(template) { return null; } -// Upper bound on the stored UI-preference blob. Generous for the known fields; -// guards against a client persisting an arbitrarily large object. -const PLOT_CONFIG_MAX_BYTES = 16 * 1024; +// 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. diff --git a/backend/tests/experiments.test.js b/backend/tests/experiments.test.js index be586fd..058cbc5 100644 --- a/backend/tests/experiments.test.js +++ b/backend/tests/experiments.test.js @@ -176,7 +176,7 @@ describe('PUT /api/experiments/:id/plot-config', () => { it('rejects an oversized config with 422', async () => { prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT); - const big = { blob: 'x'.repeat(20000) }; + 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();