docs: design for persisting cross-subject plot config per experiment
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user