Files
experiments-database/docs/superpowers/specs/2026-06-01-persist-cross-subject-plot-config-design.md
2026-06-01 10:06:40 -04:00

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 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.

{
  "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 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:

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:

<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.