Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7.9 KiB
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 siblingSubjectAnalysisCharts(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_configJSONB pattern.
Persisted config shape
experiments.plot_config (JSONB, nullable). null = nothing saved → chart uses computed defaults.
{
"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:
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.
// 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
hiddenSubjectspresent, 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:
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 thehiddenSubjectsSet 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 fornull/malformed input (never throws).
useChartSettings refactor (AnalysisCharts.jsx)
- Add optional
initialargument; seedheight,yMin,yMax,xZoomMin,xZoomMax,xFieldOverridefrom it when provided. - Expose a
snapshotobject (those serializable values) for the parent to persist. reset()andisDirtykeep working.SubjectAnalysisChartscalls the hook with noinitial, 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; passcounts/rateslices asinitialto the twouseChartSettingscalls; in a one-time effect (guarded byhydratedRef) seedxField,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 fromlastSavedRef.current, sets a 1s timeout callingonPersistConfig(currentConfig), then updateslastSavedRef. 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:
<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
animalIdfor 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):serializePlotConfiground-trips (Set→sorted array, includes per-chart snapshots);readPlotConfigreturns defaults fornull/{}/malformed input and preserves valid fields. Never throws. - Backend route (Jest + supertest + mocked Prisma,
backend/tests/experiments.test.js):PUT /:id/plot-configreturns 200 and callsprisma.experiment.updatewith the config for a valid body; 404 when the experiment is missing; 422 for a non-object body, an oversized body, and an invalidhiddenSubjects; and does NOT callprisma.auditLog.create. - Frontend smoke (Jest + RTL, mocked recharts,
frontend/tests/ExperimentAnalysisCharts.test.jsx): hydrates state from aplotConfigprop (e.g. a hidden subject starts unchecked); and, with fake timers, callsonPersistConfig~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_logsrows from plot tweaks.